diff --git a/.github/github-merge-queue.md b/.github/github-merge-queue.md new file mode 100644 index 00000000..5ae23d0e --- /dev/null +++ b/.github/github-merge-queue.md @@ -0,0 +1,340 @@ +# GitHub Merge Queue Setup + +This document describes the GitHub-based approval and merge system that replaces Prow Tide. + +## Overview + +The repository now uses GitHub workflows and merge queue instead of Prow Tide for managing PR approvals and merges. This provides: + +- Label-based approval system via comment commands +- Required status checks for merge readiness +- Automatic merging via GitHub merge queue + +## Workflow Diagram + +```mermaid +flowchart TD + Start([PR Created/Updated]) --> AutoChecks{Automatic Checks} + + AutoChecks --> WIP[Check WIP Status] + AutoChecks --> ReleaseNote[Check Release Note Label] + AutoChecks --> Rebase[Check Merge Conflicts] + + WIP -->|WIP detected| WIPLabel[Add: do-not-merge/work-in-progress] + WIP -->|Ready| WIPRemove[Remove: do-not-merge/work-in-progress] + + ReleaseNote -->|Missing label| RNLabel[Add: do-not-merge/release-note-label-needed] + ReleaseNote -->|Has label| RNRemove[Remove: do-not-merge/release-note-label-needed] + + Rebase -->|Conflicts| RebaseLabel[Add: needs-rebase] + Rebase -->|Clean| RebaseRemove[Remove: needs-rebase] + + WIPLabel --> ManualReview + WIPRemove --> ManualReview + RNLabel --> ManualReview + RNRemove --> ManualReview + RebaseLabel --> ManualReview + RebaseRemove --> ManualReview + + ManualReview{Manual Review} -->|Reviewer comments /lgtm| LGTMLabel[Add: lgtm] + ManualReview -->|Maintainer comments /approve| ApprovedLabel[Add: approved] + ManualReview -->|Anyone comments /hold| HoldLabel[Add: do-not-merge/hold] + + LGTMLabel --> MergeCheck + ApprovedLabel --> MergeCheck + HoldLabel --> MergeCheck + + MergeCheck{Merge Readiness Check} + + MergeCheck -->|Has lgtm + approved
No blocking labels| Ready[✅ Status: PASS] + MergeCheck -->|Missing lgtm or approved| NotReady[❌ Status: FAIL
Missing required labels] + MergeCheck -->|Has blocking labels| Blocked[❌ Status: FAIL
Has blocking labels] + + Ready --> MergeQueue[Add to Merge Queue] + NotReady --> Wait[Wait for approvals] + Blocked --> Wait + + Wait --> ManualReview + + MergeQueue --> FinalChecks{All CI Checks Pass?} + FinalChecks -->|Yes| Merge([🎉 PR Merged]) + FinalChecks -->|No| Failed([❌ Merge Failed]) + + style Start fill:#e1f5ff + style Merge fill:#d4edda + style Failed fill:#f8d7da + style Ready fill:#d4edda + style NotReady fill:#fff3cd + style Blocked fill:#f8d7da + style MergeQueue fill:#cfe2ff +``` + +### Key Points +- **Green boxes** = Success states +- **Yellow boxes** = Waiting/pending states +- **Red boxes** = Blocked/failed states +- **Blue boxes** = Active processing + +## Approval System + +### Commands + +Maintainers with **write** access can use the following commands in PR comments: + +#### `/lgtm` - Looks Good To Me +Adds the `lgtm` label to indicate code review approval. + +``` +/lgtm +``` + +To remove the label: +``` +/lgtm cancel +``` + +**Restrictions**: +- Requires write access to the repository +- ⚠️ PR authors and co-authors cannot `/lgtm` or `/approve` their own PRs + +#### `/approve` - Final Approval +Adds the `approved` label to indicate final approval for merge. + +``` +/approve +``` + +To remove the label: +``` +/approve cancel +``` + +**Restrictions**: +- Requires write access to the repository +- ⚠️ PR authors and co-authors cannot `/lgtm` or `/approve` their own PRs + +#### `/hold` - Hold PR from Merging +Adds the `do-not-merge/hold` label to block a PR from merging. Both maintainers and PR authors can use this command. + +``` +/hold +``` + +To remove the hold: +``` +/unhold +``` +or +``` +/hold cancel +``` + +### Permissions + +- Only users with **write** access to the repository can use approval commands +- Commands must be placed at the start of a comment +- The bot will react with emoji to indicate status: + - 👀 (eyes) - Processing command + - 👍 (+1) - Label added successfully + - 👎 (-1) - Label removed successfully + - 😕 (confused) - Insufficient permissions + +## Automatic Label Management + +Several workflows automatically manage labels based on PR state: + +### Work in Progress (WIP) +- **Trigger**: PR title contains `[WIP]`, `WIP:`, `[Draft]`, `Draft:`, or 🚧 emoji, or PR is marked as draft +- **Action**: Adds `do-not-merge/work-in-progress` label +- **Resolution**: Remove WIP indicators from title or convert from draft to ready + +### Release Notes +- **Trigger**: PR is missing a release note label +- **Action**: Adds `do-not-merge/release-note-label-needed` label +- **Resolution**: Add one of these labels manually, or use commands: + - `release-note` - For user-facing changes + - `release-note-action-required` - For breaking changes requiring user action + - `release-note-none` - For internal changes with no user impact + - **Commands**: `/release-note`, `/release-note-action-required`, `/release-note-none` + - **Permissions**: ⚠️ **PR author ONLY** (following Prow's behavior - this ensures the author takes responsibility for documenting their changes) + +### Merge Conflicts +- **Trigger**: PR has merge conflicts with base branch +- **Action**: Adds `needs-rebase` label +- **Resolution**: Rebase or merge the base branch to resolve conflicts + +### Hold +- **Trigger**: Someone comments `/hold` +- **Action**: Adds `do-not-merge/hold` label +- **Resolution**: Comment `/unhold` or `/hold cancel` +- **Permissions**: PR author OR maintainers with write access + +## Blocking Labels + +The following labels will block a PR from merging (checked by the Merge Readiness workflow): + +- `do-not-merge/hold` - Manual hold requested +- `do-not-merge/invalid-owners-file` - OWNERS file is invalid +- `do-not-merge/release-note-label-needed` - Missing release note label +- `do-not-merge/requires-unreleased-pipelines` - Depends on unreleased Tekton pipelines +- `do-not-merge/work-in-progress` - PR is still in progress +- `needs-ok-to-test` - PR needs approval to run tests +- `needs-rebase` - PR has merge conflicts + + + +## Enabling Auto-Merge with Merge Queue + +Once both required labels are present: + +1. The "Merge Readiness Check" status will turn green +2. The PR can be added to the merge queue +3. Enable auto-merge on the PR to have it automatically merge when ready + +### Repository Settings + +To enable merge queue for the repository: + +1. Go to **Settings** → **General** → **Pull Requests** +2. Enable **Allow auto-merge** +3. Go to **Settings** → **Branches** → **Branch protection rules** for your main branch +4. Enable **Require merge queue** +5. Add **Merge Readiness Check** as a required status check +6. Configure merge queue settings: + - Minimum PRs to merge: 1 (or as desired) + - Maximum PRs to merge: 5 (or as desired) + - Merge method: Squash, merge commit, or rebase (as per project preference) + +## Workflow Files + +All merge automation workflows are located in `.github/workflows/` with the `pr_` prefix. + +### [`pr_approval-labels.yaml`](workflows/pr_approval-labels.yaml) +Handles `/lgtm` and `/approve` commands in PR comments. Manages label addition and removal based on user permissions. +- **Permissions**: Requires write access to the repository +- **Commands**: `/lgtm`, `/lgtm cancel`, `/approve`, `/approve cancel` +- **Restrictions**: PR authors and co-authors cannot approve their own PRs (prevents self-approval) + +### [`pr_hold-label.yaml`](workflows/pr_hold-label.yaml) +Handles `/hold` and `/unhold` commands. Allows maintainers and PR authors to block/unblock PRs from merging by adding/removing the `do-not-merge/hold` label. +- **Permissions**: PR author or users with write access +- **Commands**: `/hold`, `/unhold`, `/hold cancel` + +### [`pr_wip-label.yaml`](workflows/pr_wip-label.yaml) +Automatically detects work-in-progress PRs by checking for: +- PR titles starting with `[WIP]`, `WIP:`, `[Draft]`, or `Draft:` +- Draft PR status +- 🚧 emoji in title + +Adds/removes `do-not-merge/work-in-progress` label accordingly. + +### [`pr_release-notes-label.yaml`](workflows/pr_release-notes-label.yaml) +Checks for release note labels on PRs. Adds `do-not-merge/release-note-label-needed` if missing one of: +- `release-note` - User-facing changes +- `release-note-action-required` - Requires action from users +- `release-note-none` - No user-facing changes + +**NEW**: Also supports comment commands for easier label management: +- **Commands**: `/release-note`, `/release-note-action-required`, `/release-note-none` +- **Permissions**: ⚠️ **PR author ONLY** (matches Prow's release-note plugin behavior) +- **Rationale**: Only the PR author can set release note labels to ensure they take responsibility for documenting their changes +- **Features**: Automatically removes other release-note labels when applying a new one + +### [`pr_needs-rebase-label.yaml`](workflows/pr_needs-rebase-label.yaml) +Automatically detects merge conflicts and adds/removes the `needs-rebase` label when conflicts are present. + +### [`pr_merge-readiness.yaml`](workflows/pr_merge-readiness.yaml) +Provides a required status check that verifies both `lgtm` and `approved` labels are present and no blocking labels exist. + +## Migration from Prow + +This system replaces the Prow Tide configuration with native GitHub functionality: + +| Prow Tide | GitHub Workflows | +|-----------|------------------| +| `/lgtm` command | `/lgtm` command (via `pr_approval-labels.yaml`) | +| `/approve` command | `/approve` command (via `pr_approval-labels.yaml`) | +| `/hold` command | `/hold` command (via `pr_hold-label.yaml`) | +| Release note plugin | Release note workflow (via `pr_release-notes-label.yaml`) with `/release-note-*` commands | +| Tide merge pool | GitHub Merge Queue | +| Tide status contexts | Merge Readiness Check | +| Automatic merging | Auto-merge + Merge Queue | + +### Key Differences from Prow Tide + +#### Architecture +- **Prow Tide**: Centralized service running on Kubernetes cluster, polls GitHub API for PRs matching criteria +- **GitHub Workflows**: Distributed event-driven workflows, triggered by GitHub webhooks on PR events +- **Result**: Lower latency (immediate response vs polling), no infrastructure to maintain + +#### Label Management +- **Prow Tide**: Plugins (lgtm, approve, hold, wip) managed by central Prow plugins +- **GitHub Workflows**: Individual workflows handle each command independently +- **Result**: More modular, easier to customize individual behaviors + +#### Merge Process +- **Prow Tide**: + - Batches multiple PRs together for testing + - Maintains merge pool with sync loop (default: 2m) + - Tests PRs together, merges if batch passes + - Bisects on failure to find culprit +- **GitHub Merge Queue**: + - Tests each PR individually or in configurable groups + - Queue-based approach with configurable merge strategies + - Native GitHub UI for queue visibility + - No separate infrastructure needed +- **Result**: Similar reliability, simpler setup, better UI + +#### Permission Model +- **Prow Tide**: Uses GitHub team membership and OWNERS files +- **GitHub Workflows**: Uses GitHub's native repository permissions (read/write/admin) +- **Result**: More straightforward, one permission system to manage + +#### Status Checks +- **Prow Tide**: Multiple required contexts, complex configuration +- **GitHub Workflows**: Single "Merge Readiness Check" workflow +- **Result**: Simpler to understand, single source of truth + +#### Commands +- **Prow Tide**: Fixed set of commands defined in plugins config +- **GitHub Workflows**: Flexible, can add new commands easily by creating new workflows +- **Result**: More extensible and customizable + +#### What's the Same +✅ `/lgtm` and `/approve` commands work identically +✅ `/hold` blocks merging +✅ `do-not-merge/*` labels prevent merging +✅ Automatic WIP detection +✅ Release note label enforcement +✅ Merge conflict detection +✅ Permission-based access control + +#### What's Different +⚠️ **No batch merging** - GitHub merge queue tests PRs individually or in smaller groups +⚠️ **No OWNERS file** - Uses GitHub repository permissions instead +⚠️ **Different UI** - GitHub PR UI instead of Prow dashboard +⚠️ **Faster response** - Event-driven instead of polling (2m sync period) +⚠️ **Simpler setup** - No Prow infrastructure needed + +#### Migration Considerations +- **OWNERS files**: If you rely on OWNERS files for approval, you'll need to migrate to GitHub CODEOWNERS +- **Batch testing**: If you need to test multiple PRs together, configure merge queue groups +- **Custom plugins**: Any custom Prow plugins need to be reimplemented as GitHub workflows +- **Tide configuration**: Context requirements → Branch protection rules +- **Bot accounts**: Replace Prow bot token with GitHub Actions bot or app + +## Troubleshooting + +### Commands not working +- Verify you have write access to the repository +- Ensure the command is at the start of the comment +- Check workflow run logs in the Actions tab + +### Merge Readiness Check failing +- Verify both `lgtm` and `approved` labels are present +- Check the workflow logs for detailed status + +### Auto-merge not triggering +- Ensure merge queue is enabled in repository settings +- Verify all required status checks are passing +- Check that auto-merge is enabled on the PR diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b8a72a15..543abbd8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,6 @@ jobs: unit-tests: name: Unit Tests - needs: [build] runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -44,7 +43,6 @@ jobs: linting: name: Linting - needs: [build] runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -73,7 +71,6 @@ jobs: check-licenses: name: License Check - needs: [build] runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -87,7 +84,6 @@ jobs: ko-resolve: name: Ko Resolve (Multi-arch) - needs: [build] runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 diff --git a/.github/workflows/kind-e2e.yaml b/.github/workflows/kind-e2e.yaml index 51b37ee0..29222e25 100644 --- a/.github/workflows/kind-e2e.yaml +++ b/.github/workflows/kind-e2e.yaml @@ -20,7 +20,80 @@ defaults: working-directory: ./ jobs: + # Wait for CI checks to pass before running E2E tests + wait-for-ci: + name: Wait for CI checks + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Wait for CI workflow + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const checkNames = [ + 'Build', + 'Unit Tests', + 'Linting', + 'License Check', + 'Ko Resolve (Multi-arch)' + ]; + + const maxAttempts = 120; // 5 minutes max wait + const delayMs = 5000; // 5 seconds between checks + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const { data: checkRuns } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.payload.pull_request.head.sha + }); + + const relevantChecks = checkRuns.check_runs.filter(check => + checkNames.includes(check.name) + ); + + console.log(`Attempt ${attempt + 1}/${maxAttempts}`); + console.log('Check statuses:', relevantChecks.map(c => `${c.name}: ${c.status} (${c.conclusion})`).join(', ')); + + // Check if all required checks exist and are completed + const allChecksPresent = checkNames.every(name => + relevantChecks.some(check => check.name === name) + ); + + if (allChecksPresent) { + const allCompleted = relevantChecks.every(check => + check.status === 'completed' + ); + + if (allCompleted) { + const allPassed = relevantChecks.every(check => + check.conclusion === 'success' + ); + + if (allPassed) { + console.log('✅ All CI checks passed!'); + return; + } else { + const failedChecks = relevantChecks.filter(c => c.conclusion !== 'success'); + console.log('❌ Some CI checks failed:', failedChecks.map(c => c.name).join(', ')); + core.setFailed('CI checks failed. E2E tests will not run.'); + return; + } + } + } + + // Wait before next attempt + console.log(`Waiting ${delayMs/1000} seconds before next check...`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + + console.log('⏱️ Timeout waiting for CI checks'); + core.setFailed('Timeout waiting for CI checks to complete'); + k8s: + needs: [wait-for-ci] + # Skip the wait-for-ci dependency on push/merge_group events + if: ${{ !failure() && (github.event_name != 'pull_request' || needs.wait-for-ci.result == 'success') }} strategy: fail-fast: false # Keep running if one leg fails. matrix: @@ -34,6 +107,9 @@ jobs: pipelines-release: v0.65.0 # This job is for testing the latest LTS version of Tekton Pipelines pipelines-lts: + needs: [wait-for-ci] + # Skip the wait-for-ci dependency on push/merge_group events + if: ${{ !failure() && (github.event_name != 'pull_request' || needs.wait-for-ci.result == 'success') }} strategy: fail-fast: false # Keep running if one leg fails. matrix: diff --git a/.github/workflows/pr_approval-labels.yaml b/.github/workflows/pr_approval-labels.yaml new file mode 100644 index 00000000..45892d8f --- /dev/null +++ b/.github/workflows/pr_approval-labels.yaml @@ -0,0 +1,239 @@ +--- +name: Approval Label Management +# This workflow handles /lgtm and /approve commands in PR comments +# Replaces Prow Tide functionality for label-based approvals + +'on': + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + handle_approval_commands: + name: Process approval commands + # Only run on pull request comments + if: github.event.issue.pull_request + runs-on: ubuntu-latest + + steps: + - name: Process approval commands + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const comment_body = context.payload.comment.body.trim(); + const commenter = context.payload.comment.user.login; + + // Check if comment is an approval command + const isLgtmCommand = comment_body.startsWith('/lgtm'); + const isApproveCommand = comment_body.startsWith('/approve'); + + if (!isLgtmCommand && !isApproveCommand) { + console.log('Not an approval command, skipping'); + return; + } + + console.log(`Processing command from ${commenter}: ${comment_body}`); + + // React with eyes to show we're processing + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes' + }); + + // Check user permissions + let hasPermission = false; + try { + const { data: collaborator } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: commenter + }); + + console.log(`User ${commenter} has permission: ${collaborator.permission}`); + hasPermission = ['admin', 'write', 'maintain'].includes(collaborator.permission); + } catch (error) { + console.log(`Error checking permissions for ${commenter}:`, error.message); + hasPermission = false; + } + + // Handle insufficient permissions + if (!hasPermission) { + console.log(`User ${commenter} does not have sufficient permissions`); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'confused' + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `@${commenter} you don't have permission to use approval commands. Only users with write access or higher can approve PRs.` + }); + + return; + } + + // Get PR details to check author and co-authors + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + const prAuthor = pr.user.login; + + // Extract co-authors from commit messages and PR body + const coAuthors = new Set(); + + // Check PR body for co-authored-by + const prBody = pr.body || ''; + const coAuthorMatches = prBody.matchAll(/(?:co-authored-by|Co-authored-by|CO-AUTHORED-BY):\s*([^<]+)\s*<([^>]+)>/gi); + for (const match of coAuthorMatches) { + // Extract username from email or name + const email = match[2].toLowerCase(); + const username = email.split('@')[0].replace(/[^a-z0-9-]/g, ''); + coAuthors.add(username); + } + + // Check commits for co-authors + const { data: commits } = await github.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + for (const commit of commits) { + const message = commit.commit.message; + const commitCoAuthorMatches = message.matchAll(/(?:co-authored-by|Co-authored-by|CO-AUTHORED-BY):\s*([^<]+)\s*<([^>]+)>/gi); + for (const match of commitCoAuthorMatches) { + const email = match[2].toLowerCase(); + const username = email.split('@')[0].replace(/[^a-z0-9-]/g, ''); + coAuthors.add(username); + } + + // Add commit author as co-author if different from PR author + if (commit.author && commit.author.login && commit.author.login !== prAuthor) { + coAuthors.add(commit.author.login.toLowerCase()); + } + } + + console.log(`PR author: ${prAuthor}`); + console.log(`Co-authors: ${Array.from(coAuthors).join(', ')}`); + console.log(`Commenter: ${commenter}`); + + // Check if commenter is the PR author or co-author + const isAuthor = commenter.toLowerCase() === prAuthor.toLowerCase(); + const isCoAuthor = coAuthors.has(commenter.toLowerCase()); + + if (isAuthor || isCoAuthor) { + console.log(`User ${commenter} is the PR author or co-author and cannot approve their own PR`); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'confused' + }); + + const role = isAuthor ? 'author' : 'co-author'; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `@${commenter} as the PR ${role}, you cannot approve your own pull request. Please ask another maintainer to review and approve.` + }); + + return; + } + + // Process /lgtm command + if (isLgtmCommand) { + const isCancel = comment_body === '/lgtm cancel'; + + if (isCancel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'lgtm' + }); + console.log('Removed lgtm label'); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1' + }); + } catch (error) { + console.log('Label lgtm not found or already removed:', error.message); + } + } else if (comment_body === '/lgtm') { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['lgtm'] + }); + console.log('Added lgtm label'); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '+1' + }); + } + } + + // Process /approve command + if (isApproveCommand) { + const isCancel = comment_body === '/approve cancel'; + + if (isCancel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'approved' + }); + console.log('Removed approved label'); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1' + }); + } catch (error) { + console.log('Label approved not found or already removed:', error.message); + } + } else if (comment_body === '/approve') { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['approved'] + }); + console.log('Added approved label'); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '+1' + }); + } + } diff --git a/.github/workflows/pr_hold-label.yaml b/.github/workflows/pr_hold-label.yaml new file mode 100644 index 00000000..150263e6 --- /dev/null +++ b/.github/workflows/pr_hold-label.yaml @@ -0,0 +1,131 @@ +--- +name: Hold Label Management +# Handles /hold and /unhold commands to block/unblock PRs from merging +# Replaces Prow hold plugin + +'on': + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + handle_hold_command: + name: Process hold commands + if: github.event.issue.pull_request + runs-on: ubuntu-latest + + steps: + - name: Process hold commands + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const comment_body = context.payload.comment.body.trim(); + const commenter = context.payload.comment.user.login; + + // Check if comment is a hold command + const isHoldCommand = comment_body === '/hold'; + const isUnholdCommand = comment_body === '/unhold' || comment_body === '/hold cancel'; + + if (!isHoldCommand && !isUnholdCommand) { + console.log('Not a hold command, skipping'); + return; + } + + console.log(`Processing hold command from ${commenter}: ${comment_body}`); + + // React with eyes to show we're processing + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes' + }); + + // Check user permissions + let hasPermission = false; + try { + const { data: collaborator } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: commenter + }); + + console.log(`User ${commenter} has permission: ${collaborator.permission}`); + hasPermission = ['admin', 'write', 'maintain'].includes(collaborator.permission); + } catch (error) { + console.log(`Error checking permissions for ${commenter}:`, error.message); + hasPermission = false; + } + + // Allow PR author to hold their own PR + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + const isPrAuthor = pr.data.user.login === commenter; + + if (!hasPermission && !isPrAuthor) { + console.log(`User ${commenter} does not have sufficient permissions and is not the PR author`); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'confused' + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `@${commenter} you don't have permission to use hold commands. Only the PR author or users with write access can hold/unhold PRs.` + }); + + return; + } + + // Process /hold command + if (isHoldCommand) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['do-not-merge/hold'] + }); + console.log('Added do-not-merge/hold label'); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '+1' + }); + } + + // Process /unhold command + if (isUnholdCommand) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'do-not-merge/hold' + }); + console.log('Removed do-not-merge/hold label'); + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '+1' + }); + } catch (error) { + console.log('Label do-not-merge/hold not found or already removed:', error.message); + } + } diff --git a/.github/workflows/pr_merge-readiness.yaml b/.github/workflows/pr_merge-readiness.yaml new file mode 100644 index 00000000..20e8fbcf --- /dev/null +++ b/.github/workflows/pr_merge-readiness.yaml @@ -0,0 +1,86 @@ +--- +name: Merge Readiness Check +# This workflow provides a required status check that only passes +# when both 'lgtm' and 'approved' labels are present on the PR +# This enables auto-merge with GitHub merge queue + +'on': + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + +permissions: + contents: read + pull-requests: read + statuses: write + checks: write + +jobs: + check_merge_readiness: + name: Check approval labels + runs-on: ubuntu-latest + + steps: + - name: Check for required labels and blocking labels + id: check_labels + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const pr = context.payload.pull_request; + const labels = pr.labels.map(label => label.name); + + console.log('PR Labels:', labels); + + // Required labels + const hasLgtm = labels.includes('lgtm'); + const hasApproved = labels.includes('approved'); + + console.log('Has lgtm:', hasLgtm); + console.log('Has approved:', hasApproved); + + // Blocking labels that prevent merge + const blockingLabels = [ + 'do-not-merge/hold', + 'do-not-merge/invalid-owners-file', + 'do-not-merge/release-note-label-needed', + 'do-not-merge/requires-unreleased-pipelines', + 'do-not-merge/work-in-progress', + 'needs-ok-to-test', + 'needs-rebase' + ]; + + const presentBlockingLabels = labels.filter(label => blockingLabels.includes(label)); + + if (presentBlockingLabels.length > 0) { + console.log('❌ PR has blocking labels:', presentBlockingLabels.join(', ')); + core.setOutput('ready', 'false'); + core.setOutput('message', `PR is blocked by labels: ${presentBlockingLabels.join(', ')}`); + } else if (hasLgtm && hasApproved) { + console.log('✅ PR has both lgtm and approved labels and no blocking labels'); + core.setOutput('ready', 'true'); + core.setOutput('message', 'PR is ready to merge - both lgtm and approved labels are present, no blocking labels'); + } else { + const missing = []; + if (!hasLgtm) missing.push('lgtm'); + if (!hasApproved) missing.push('approved'); + + console.log('❌ PR is missing required labels:', missing.join(', ')); + core.setOutput('ready', 'false'); + core.setOutput('message', `PR is not ready to merge - missing labels: ${missing.join(', ')}`); + } + + - name: Set success status + if: steps.check_labels.outputs.ready == 'true' + run: | + echo "✅ ${{ steps.check_labels.outputs.message }}" + exit 0 + + - name: Set failure status + if: steps.check_labels.outputs.ready == 'false' + run: | + echo "❌ ${{ steps.check_labels.outputs.message }}" + exit 1 diff --git a/.github/workflows/pr_needs-rebase-label.yaml b/.github/workflows/pr_needs-rebase-label.yaml new file mode 100644 index 00000000..bbd96355 --- /dev/null +++ b/.github/workflows/pr_needs-rebase-label.yaml @@ -0,0 +1,80 @@ +--- +name: Needs Rebase Label +# Automatically adds/removes needs-rebase label when PR has merge conflicts +# Replaces Prow needs-rebase functionality + +'on': + pull_request_target: + types: + - opened + - reopened + - synchronize + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + check_rebase_needed: + name: Check if rebase is needed + runs-on: ubuntu-latest + + steps: + - name: Check PR mergeable state + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + // Get fresh PR data to check mergeable state + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + + console.log('PR mergeable state:', pr.mergeable_state); + console.log('PR mergeable:', pr.mergeable); + + const labels = pr.labels.map(label => label.name); + const hasNeedsRebaseLabel = labels.includes('needs-rebase'); + + // PR needs rebase if it has conflicts (mergeable === false) + // mergeable_state can be: clean, dirty, unstable, blocked, unknown + const needsRebase = pr.mergeable === false || pr.mergeable_state === 'dirty'; + + console.log('Needs rebase:', needsRebase); + console.log('Has needs-rebase label:', hasNeedsRebaseLabel); + + if (needsRebase && !hasNeedsRebaseLabel) { + // Add needs-rebase label + console.log('Adding needs-rebase label'); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['needs-rebase'] + }); + + // Add a comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: `This PR has merge conflicts and needs to be rebased. The \`needs-rebase\` label will be automatically removed once conflicts are resolved.` + }); + } else if (!needsRebase && hasNeedsRebaseLabel) { + // Remove needs-rebase label + console.log('Removing needs-rebase label'); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: 'needs-rebase' + }); + } catch (error) { + console.log('Label not found or already removed:', error.message); + } + } else { + console.log('No label change needed'); + } diff --git a/.github/workflows/pr_release-notes-label.yaml b/.github/workflows/pr_release-notes-label.yaml new file mode 100644 index 00000000..e6fb0be7 --- /dev/null +++ b/.github/workflows/pr_release-notes-label.yaml @@ -0,0 +1,227 @@ +--- +name: Release Notes Label Check +# Checks for release-note label and blocks merge if missing +# Replaces Prow release-note plugin + +'on': + pull_request_target: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + issue_comment: + types: + - created + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + handle_comment_command: + name: Handle release note comment command + runs-on: ubuntu-latest + if: github.event_name == 'issue_comment' && github.event.issue.pull_request + + steps: + - name: Check if comment contains release note command + id: check_command + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const comment = context.payload.comment.body.trim(); + const commands = { + '/release-note-none': 'release-note-none', + '/release-note': 'release-note', + '/release-note-action-required': 'release-note-action-required' + }; + + for (const [cmd, label] of Object.entries(commands)) { + if (comment === cmd) { + core.setOutput('has_command', 'true'); + core.setOutput('label', label); + return; + } + } + core.setOutput('has_command', 'false'); + + - name: Check permissions + id: check_permissions + if: steps.check_command.outputs.has_command == 'true' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const commenter = context.payload.comment.user.login; + const prNumber = context.payload.issue.number; + + // Get PR details to check if commenter is the PR author + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const isPrAuthor = pr.data.user.login === commenter; + + console.log(`Commenter: ${commenter}, PR Author: ${pr.data.user.login}, Is PR Author: ${isPrAuthor}`); + + // Following Prow's release-note plugin behavior: only PR authors can set release note labels + // This ensures the PR author takes responsibility for documenting their changes + if (!isPrAuthor) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `@${commenter} only the PR author (@${pr.data.user.login}) can set release note labels. This ensures the author takes responsibility for documenting their changes.` + }); + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'confused' + }); + core.setFailed('Only PR author can use release note commands'); + } + + core.setOutput('has_permission', isPrAuthor); + + - name: Apply release note label + if: steps.check_command.outputs.has_command == 'true' && steps.check_permissions.outputs.has_permission == 'true' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const prNumber = context.payload.issue.number; + const labelToAdd = '${{ steps.check_command.outputs.label }}'; + + // All possible release note labels + const releaseNoteLabels = [ + 'release-note', + 'release-note-action-required', + 'release-note-none' + ]; + + // Get current labels + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const currentLabelNames = currentLabels.map(label => label.name); + + // Remove other release note labels + for (const label of releaseNoteLabels) { + if (label !== labelToAdd && currentLabelNames.includes(label)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: label + }); + } + } + + // Add the new label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [labelToAdd] + }); + + // Remove blocking label if present + if (currentLabelNames.includes('do-not-merge/release-note-label-needed')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'do-not-merge/release-note-label-needed' + }); + } + + // React to the comment + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '+1' + }); + + check_release_note: + name: Check release note label + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Check for release note labels + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const pr = context.payload.pull_request; + const labels = pr.labels.map(label => label.name); + + console.log('PR labels:', labels); + + // Valid release note labels + const releaseNoteLabels = [ + 'release-note', + 'release-note-action-required', + 'release-note-none' + ]; + + const hasReleaseNoteLabel = labels.some(label => releaseNoteLabels.includes(label)); + const hasBlockingLabel = labels.includes('do-not-merge/release-note-label-needed'); + + console.log('Has release note label:', hasReleaseNoteLabel); + console.log('Has blocking label:', hasBlockingLabel); + + if (!hasReleaseNoteLabel && !hasBlockingLabel) { + // Add blocking label + console.log('Adding do-not-merge/release-note-label-needed label'); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['do-not-merge/release-note-label-needed'] + }); + + // Add a comment explaining what's needed + const commentBody = [ + 'This PR is missing a release note label. Please add one of the following labels:', + '- `release-note` - This PR has user-facing changes that should be in release notes', + '- `release-note-action-required` - This PR requires action from users', + '- `release-note-none` - This PR has no user-facing changes', + '', + 'Alternatively, you can comment with one of these commands:', + '- `/release-note`', + '- `/release-note-action-required`', + '- `/release-note-none`', + '', + 'The `do-not-merge/release-note-label-needed` label will be automatically removed once a release note label is added.' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + } else if (hasReleaseNoteLabel && hasBlockingLabel) { + // Remove blocking label + console.log('Removing do-not-merge/release-note-label-needed label'); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: 'do-not-merge/release-note-label-needed' + }); + } catch (error) { + console.log('Label not found or already removed:', error.message); + } + } else { + console.log('No label change needed'); + } diff --git a/.github/workflows/pr_wip-label.yaml b/.github/workflows/pr_wip-label.yaml new file mode 100644 index 00000000..83a662e9 --- /dev/null +++ b/.github/workflows/pr_wip-label.yaml @@ -0,0 +1,76 @@ +--- +name: Work In Progress Label +# Automatically adds/removes do-not-merge/work-in-progress label based on PR title +# Replaces Prow WIP plugin + +'on': + pull_request_target: + types: + - opened + - reopened + - synchronize + - edited + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + manage_wip_label: + name: Manage WIP label + runs-on: ubuntu-latest + + steps: + - name: Check PR title for WIP indicators + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const pr = context.payload.pull_request; + const title = pr.title.toLowerCase(); + + // WIP indicators in title + const wipPatterns = [ + /^\[wip\]/i, + /^wip:/i, + /^wip\s/i, + /^\[draft\]/i, + /^draft:/i, + /^draft\s/i, + /🚧/, + ]; + + const isWip = wipPatterns.some(pattern => pattern.test(pr.title)) || pr.draft; + + console.log('PR title:', pr.title); + console.log('Is draft:', pr.draft); + console.log('Is WIP:', isWip); + + const labels = pr.labels.map(label => label.name); + const hasWipLabel = labels.includes('do-not-merge/work-in-progress'); + + if (isWip && !hasWipLabel) { + // Add WIP label + console.log('Adding do-not-merge/work-in-progress label'); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['do-not-merge/work-in-progress'] + }); + } else if (!isWip && hasWipLabel) { + // Remove WIP label + console.log('Removing do-not-merge/work-in-progress label'); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: 'do-not-merge/work-in-progress' + }); + } catch (error) { + console.log('Label not found or already removed:', error.message); + } + } else { + console.log('No label change needed'); + } diff --git a/.github/workflows/reusable-e2e.yaml b/.github/workflows/reusable-e2e.yaml index d3c63dcb..3d9873c9 100644 --- a/.github/workflows/reusable-e2e.yaml +++ b/.github/workflows/reusable-e2e.yaml @@ -14,7 +14,6 @@ defaults: run: shell: bash working-directory: ./ - jobs: e2e-test: name: e2e test