From 7ddb4e1da2c019e7939ee57eb18ab6c77450fed5 Mon Sep 17 00:00:00 2001 From: Mohammed Al Sahaf Date: Sat, 8 Nov 2025 16:32:12 +0300 Subject: [PATCH] switch to PR-based flow Signed-off-by: Mohammed Al Sahaf --- .github/workflows/auto-release-pr.yml | 221 ++++++++++++++++++ .github/workflows/release-proposal.yml | 267 ++++++++++++++------- .github/workflows/release.yml | 310 +++++++++++++------------ 3 files changed, 563 insertions(+), 235 deletions(-) create mode 100644 .github/workflows/auto-release-pr.yml diff --git a/.github/workflows/auto-release-pr.yml b/.github/workflows/auto-release-pr.yml new file mode 100644 index 000000000..c8440d32c --- /dev/null +++ b/.github/workflows/auto-release-pr.yml @@ -0,0 +1,221 @@ +name: Release Proposal Approval Tracker + +on: + pull_request_review: + types: [submitted, dismissed] + pull_request: + types: [labeled, unlabeled, synchronize, closed] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + check-approvals: + name: Track Maintainer Approvals + runs-on: ubuntu-latest + # Only run on PRs with release-proposal label + if: contains(github.event.pull_request.labels.*.name, 'release-proposal') && github.event.pull_request.state == 'open' + + steps: + - name: Check approvals and update PR + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }} + with: + script: | + const pr = context.payload.pull_request; + + // Extract version from PR title (e.g., "Release Proposal: v1.2.3") + const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/); + const commitMatch = pr.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/); + + if (!versionMatch || !commitMatch) { + console.log('Could not extract version from title or commit from body'); + return; + } + + const version = versionMatch[1]; + const targetCommit = commitMatch[1]; + + console.log(`Version: ${version}, Target Commit: ${targetCommit}`); + + // Get all reviews + const reviews = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + // Get list of maintainers + const maintainerLoginsRaw = process.env.MAINTAINER_LOGINS || ''; + const maintainerLogins = maintainerLoginsRaw + .split(/[,;]/) + .map(login => login.trim()) + .filter(login => login.length > 0); + + console.log(`Maintainer logins: ${maintainerLogins.join(', ')}`); + + // Get the latest review from each user + const latestReviewsByUser = {}; + reviews.data.forEach(review => { + const username = review.user.login; + if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) { + latestReviewsByUser[username] = review; + } + }); + + // Count approvals from maintainers + const maintainerApprovals = Object.entries(latestReviewsByUser) + .filter(([username, review]) => + maintainerLogins.includes(username) && + review.state === 'APPROVED' + ) + .map(([username, review]) => username); + + const approvalCount = maintainerApprovals.length; + console.log(`Found ${approvalCount} maintainer approvals from: ${maintainerApprovals.join(', ')}`); + + // Get current labels + const currentLabels = pr.labels.map(label => label.name); + const hasApprovedLabel = currentLabels.includes('approved'); + const hasAwaitingApprovalLabel = currentLabels.includes('awaiting-approval'); + + if (approvalCount >= 2 && !hasApprovedLabel) { + console.log('✅ Quorum reached! Updating PR...'); + + // Remove awaiting-approval label if present + if (hasAwaitingApprovalLabel) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: 'awaiting-approval' + }).catch(e => console.log('Label not found:', e.message)); + } + + // Add approved label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['approved'] + }); + + // Add comment with tagging instructions + const approversList = maintainerApprovals.map(u => `@${u}`).join(', '); + const commentBody = [ + '## ✅ Approval Quorum Reached', + '', + `This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`, + '', + '### Tagging Instructions', + '', + 'A maintainer should now create and push the signed tag:', + '', + '```bash', + `git checkout ${targetCommit}`, + `git tag -s ${version} -m "Release ${version}"`, + `git push origin ${version}`, + `git checkout -`, + '```', + '', + 'The release workflow will automatically start when the tag is pushed.' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + + console.log('Posted tagging instructions'); + } else if (approvalCount < 2 && hasApprovedLabel) { + console.log('⚠️ Approval count dropped below quorum, removing approved label'); + + // Remove approved label + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: 'approved' + }).catch(e => console.log('Label not found:', e.message)); + + // Add awaiting-approval label + if (!hasAwaitingApprovalLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['awaiting-approval'] + }); + } + } else { + console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`); + } + + handle-pr-closed: + name: Handle PR Closed Without Tag + runs-on: ubuntu-latest + if: | + contains(github.event.pull_request.labels.*.name, 'release-proposal') && + github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released') + + steps: + - name: Add cancelled label and comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const pr = context.payload.pull_request; + + // Check if the release-in-progress label is present + const hasReleaseInProgress = pr.labels.some(label => label.name === 'release-in-progress'); + + if (hasReleaseInProgress) { + // PR was closed while release was in progress - this is unusual + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: '⚠️ **Warning:** This PR was closed while a release was in progress. This may indicate an error. Please verify the release status.' + }); + } else { + // PR was closed before tag was created - this is normal cancellation + const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: `## 🚫 Release Proposal Cancelled\n\nThis release proposal for ${version} was closed without creating the tag.\n\nIf you want to proceed with this release later, you can create a new release proposal.` + }); + } + + // Add cancelled label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['cancelled'] + }); + + // Remove other workflow labels if present + const labelsToRemove = ['awaiting-approval', 'approved', 'release-in-progress']; + for (const label of labelsToRemove) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: label + }); + } catch (e) { + console.log(`Label ${label} not found or already removed`); + } + } + + console.log('Added cancelled label and cleaned up workflow labels'); + diff --git a/.github/workflows/release-proposal.yml b/.github/workflows/release-proposal.yml index 4e78a5a86..a989ab21c 100644 --- a/.github/workflows/release-proposal.yml +++ b/.github/workflows/release-proposal.yml @@ -1,6 +1,6 @@ name: Release Proposal -# This workflow creates a release proposal that requires approval from maintainers +# This workflow creates a release proposal as a PR that requires approval from maintainers # Triggered manually by maintainers when ready to prepare a release on: workflow_dispatch: @@ -9,16 +9,15 @@ on: description: 'Version to release (e.g., v2.8.0)' required: true type: string - prerelease: - description: 'Is this a pre-release?' - required: false - type: boolean - default: false + commit_hash: + description: 'Commit hash to release from' + required: true + type: string permissions: - contents: read - issues: write + contents: write pull-requests: write + issues: write jobs: create-proposal: @@ -35,123 +34,213 @@ jobs: with: fetch-depth: 0 - - name: Validate version format + - name: Trim and validate inputs + id: inputs run: | - if [[ ! "${{ inputs.version }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + # Trim whitespace from inputs + VERSION=$(echo "${{ inputs.version }}" | xargs) + COMMIT_HASH=$(echo "${{ inputs.commit_hash }}" | xargs) + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT + + # Validate version format + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)" exit 1 fi + + # Validate commit hash format + if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then + echo "Error: Commit hash must be a valid SHA (7-40 characters)" + exit 1 + fi + + # Check if commit exists + if ! git cat-file -e "$COMMIT_HASH"; then + echo "Error: Commit $COMMIT_HASH does not exist" + exit 1 + fi - name: Check if tag already exists run: | - if git rev-parse "${{ inputs.version }}" >/dev/null 2>&1; then - echo "Error: Tag ${{ inputs.version }} already exists" + if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then + echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists" exit 1 fi - - name: Generate changelog - id: changelog + - name: Check for existing proposal PR + id: check_existing + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const version = '${{ steps.inputs.outputs.version }}'; + + // Search for existing open PRs with release-proposal label that match this version + const openPRs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'desc' + }); + + const existingOpenPR = openPRs.data.find(pr => + pr.title.includes(version) && + pr.labels.some(label => label.name === 'release-proposal') + ); + + if (existingOpenPR) { + const hasReleased = existingOpenPR.labels.some(label => label.name === 'released'); + const hasReleaseInProgress = existingOpenPR.labels.some(label => label.name === 'release-in-progress'); + + if (hasReleased || hasReleaseInProgress) { + core.setFailed(`A release for ${version} is already in progress or completed: ${existingOpenPR.html_url}`); + } else { + core.setFailed(`An open release proposal already exists for ${version}: ${existingOpenPR.html_url}\n\nPlease use the existing PR or close it first.`); + } + return; + } + + // Check for closed PRs with this version that were cancelled + const closedPRs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + sort: 'updated', + direction: 'desc' + }); + + const cancelledPR = closedPRs.data.find(pr => + pr.title.includes(version) && + pr.labels.some(label => label.name === 'release-proposal') && + pr.labels.some(label => label.name === 'cancelled') + ); + + if (cancelledPR) { + console.log(`Found previously cancelled proposal for ${version}: ${cancelledPR.html_url}`); + console.log('Creating new proposal to replace cancelled one...'); + } else { + console.log(`No existing proposal found for ${version}, proceeding...`); + } + + - name: Generate changelog and create branch + id: setup run: | - # Get the HEAD commit hash - HEAD_COMMIT=$(git rev-parse HEAD) - echo "head_commit=$HEAD_COMMIT" >> $GITHUB_OUTPUT + VERSION="${{ steps.inputs.outputs.version }}" + COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}" + + # Create a new branch for the release proposal + BRANCH_NAME="release_proposal-$VERSION" + git checkout -b "$BRANCH_NAME" + + # Calculate how many commits behind HEAD + COMMITS_BEHIND=$(git rev-list --count ${COMMIT_HASH}..HEAD) + + if [ "$COMMITS_BEHIND" -eq 0 ]; then + BEHIND_INFO="This is the latest commit (HEAD)" + else + BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**" + fi + + echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT + echo "behind_info=$BEHIND_INFO" >> $GITHUB_OUTPUT # Get the last tag LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -z "$LAST_TAG" ]; then echo "No previous tag found, generating full changelog" - COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse) + COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH") else echo "Generating changelog since $LAST_TAG" - COMMITS=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)" --reverse) + COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH") fi - # Save commits to file - echo "$COMMITS" > /tmp/commits.txt + # Store changelog for PR body + echo "changelog<> $GITHUB_OUTPUT + echo "$COMMITS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT - echo "commits_file=/tmp/commits.txt" >> $GITHUB_OUTPUT + # Create empty commit for the PR + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit --allow-empty -m "Release proposal for $VERSION" + + # Push the branch + git push origin "$BRANCH_NAME" + + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT - - name: Create release proposal issue + - name: Create release proposal PR + id: create_pr uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | - const fs = require('fs'); - const commits = fs.readFileSync('/tmp/commits.txt', 'utf8'); - const isPrerelease = '${{ inputs.prerelease }}' === 'true'; - const releaseType = isPrerelease ? 'Pre-release' : 'Stable Release'; + const changelog = `${{ steps.setup.outputs.changelog }}`; - const body = [ - '## Release Proposal: ${{ inputs.version }}', - '', - '**Proposed by:** @${{ github.actor }}', - `**Type:** ${releaseType}`, - '**Commit:** `${{ steps.changelog.outputs.head_commit }}`', - '', - '### Approval Requirements', - '', - '- [ ] Minimum 2 maintainer approvals required (use 👍 reaction)', - '- [ ] CI/CD checks must pass', - '- [ ] Security review completed', - '- [ ] Documentation updated', - '', - '### Changes Since Last Release', - '', - commits.trim(), - '', - '### Next Steps', - '', - '1. Review the changes above', - '2. Verify all tests pass', - '3. Maintainers: React with 👍 to approve', - '4. Once approved, a maintainer will run the tag creation workflow', - '', - '### Maintainer Actions', - '', - 'After approval, run:', - '```bash', - '# Pull latest changes', - 'git checkout master', - 'git pull origin master', - '', - '# Create signed tag (requires SSH key)', - 'git tag -s -m "Release ${{ inputs.version }}" ${{ inputs.version }} ${{ steps.changelog.outputs.head_commit }}', - '', - '# Verify the tag signature', - 'git tag -v ${{ inputs.version }}', - '', - '# Push the tag', - 'git push origin ${{ inputs.version }}', - '```', - '', - '---', - '', - '**Automation Note:** This proposal requires manual tag creation to ensure maintainer signatures are used.' - ].join('\n'); - - const issue = await github.rest.issues.create({ + const pr = await github.rest.pulls.create({ owner: context.repo.owner, repo: context.repo.repo, - title: `Release Proposal: ${{ inputs.version }}`, - body: body, - labels: ['release-proposal', 'needs-approval'] + title: `Release Proposal: ${{ steps.inputs.outputs.version }}`, + head: '${{ steps.setup.outputs.branch_name }}', + base: 'master', + body: `## Release Proposal: ${{ steps.inputs.outputs.version }} + + **Target Commit:** \`${{ steps.inputs.outputs.commit_hash }}\` + **Requested by:** @${{ github.actor }} + **Commit Status:** ${{ steps.setup.outputs.behind_info }} + + This PR proposes creating release tag \`${{ steps.inputs.outputs.version }}\` at commit \`${{ steps.inputs.outputs.commit_hash }}\`. + + ### Approval Process + + This PR requires **approval from 2+ maintainers** before the tag can be created. + + ### What happens next? + + 1. Maintainers review this proposal + 2. When 2+ maintainer approvals are received, an automated workflow will post tagging instructions + 3. A maintainer manually creates and pushes the signed tag + 4. The release workflow is triggered automatically by the tag push + 5. Upon release completion, this PR is closed and the branch is deleted + + ### Changes Since Last Release + + ${changelog} + + ### Release Checklist + + - [ ] All tests pass + - [ ] Security review completed + - [ ] Documentation updated + - [ ] Breaking changes documented + + --- + + **Note:** Tag creation is manual and requires a signed tag from a maintainer.`, + draft: false }); - console.log(`Created issue: ${issue.data.html_url}`); - - // Pin the issue - await github.rest.issues.update({ + // Add labels + await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: issue.data.number, - state: 'open' + issue_number: pr.data.number, + labels: ['release-proposal', 'awaiting-approval'] }); + + console.log(`Created PR: ${pr.data.html_url}`); + + return { number: pr.data.number, url: pr.data.html_url }; + result-encoding: json - name: Post summary run: | - echo "## Release Proposal Created! 🚀" >> $GITHUB_STEP_SUMMARY + echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "Version: **${{ inputs.version }}**" >> $GITHUB_STEP_SUMMARY - echo "Type: **${{ inputs.prerelease == 'true' && 'Pre-release' || 'Stable Release' }}**" >> $GITHUB_STEP_SUMMARY + echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY + echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY + echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "Check the issues tab for the release proposal." >> $GITHUB_STEP_SUMMARY + echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 167f53aa4..26310baa9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,6 @@ jobs: verify-tag: name: Verify Tag Signature and Approvals runs-on: ubuntu-latest - # environment: 'default' outputs: verification_passed: ${{ steps.verify.outputs.passed }} @@ -24,16 +23,12 @@ jobs: proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }} steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 - with: - egress-policy: audit - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 # Force fetch upstream tags -- because 65 minutes - # tl;dr: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2 runs this line: + # tl;dr: actions/checkout@v3 runs this line: # git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/ # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran: # git fetch --prune --unshallow @@ -59,8 +54,8 @@ jobs: go env printf "\n\nSystem environment:\n\n" env - echo "::set-output name=version_tag::${GITHUB_REF/refs\/tags\//}" - echo "::set-output name=short_sha::$(git rev-parse --short HEAD)" + echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT + echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT # Add "pip install" CLI tools to PATH echo ~/.local/bin >> $GITHUB_PATH @@ -72,10 +67,10 @@ jobs: TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"` TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"` TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"` - echo "::set-output name=tag_major::${TAG_MAJOR}" - echo "::set-output name=tag_minor::${TAG_MINOR}" - echo "::set-output name=tag_patch::${TAG_PATCH}" - echo "::set-output name=tag_special::${TAG_SPECIAL}" + echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT + echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT + echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT + echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT - name: Validate commits and tag signatures id: verify @@ -98,11 +93,12 @@ jobs: item="${item%%*( )}" IFS=" " read -ra key_components <<< "$item" - # email address, type, public key - echo "${key_components[0]} namespaces=\"git\" ${key_components[1]} ${key_components[2]}" >> "${{ runner.temp }}/allowed_signers" + # git wants it in format: email address, type, public key + # ssh has it in format: type, public key, email address + echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "${{ runner.temp }}/allowed_signers" done - git config --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers" + git config set --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers" echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}" @@ -113,12 +109,9 @@ jobs: git push --delete origin "${{ steps.vars.outputs.version_tag }}" exit 1 fi - # Run it again to capture the output git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1 | tee /tmp/verify-output.txt; - # Extract SSH key information from verification output - # SSH verification output typically includes the key fingerprint # Use GNU grep with Perl regex for cleaner extraction (Linux environment) KEY_SHA256=$(grep -oP "SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "") @@ -140,152 +133,166 @@ jobs: git push --delete origin "${{ steps.vars.outputs.version_tag }}" exit 1 fi - + echo "✅ Tag verification succeeded!" - echo "SSH Key SHA256: $KEY_SHA256" echo "passed=true" >> $GITHUB_OUTPUT echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT - name: Find related release proposal id: find_proposal uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }} with: script: | const version = '${{ steps.vars.outputs.version_tag }}'; - // Search for the release proposal issue - const issues = await github.rest.issues.listForRepo({ + // Search for PRs with release-proposal label that match this version + const prs = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, - labels: 'release-proposal', - state: 'open', - labels: 'release-proposal,needs-approval' + state: 'open', // Changed to 'all' to find both open and closed PRs + sort: 'updated', + direction: 'desc' }); - const proposal = issues.data.find(issue => - issue.title.includes(version) + // Find the most recent PR for this version + const proposal = prs.data.find(pr => + pr.title.includes(version) && + pr.labels.some(label => label.name === 'release-proposal') ); if (!proposal) { - console.log(`⚠️ No release proposal found for ${version}`); + console.log(`⚠️ No release proposal PR found for ${version}`); console.log('This might be a hotfix or emergency release'); - return { number: null, approved: false, approvals: 0 }; + return { number: null, approved: true, approvals: 0, proposedCommit: null }; } - // Get reactions to check for approvals (👍 emoji) - const reactions = await github.rest.reactions.listForIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: proposal.number, - content: '+1' - }); - - console.log(`Found proposal #${proposal.number} for version ${version} with ${reactions.data.length} 👍 reactions`); + console.log(`Found proposal PR #${proposal.number} for version ${version}`); - // Extract commit hash from proposal body - const commitMatch = proposal.body.match(/\*\*Commit:\*\*\s*`([a-f0-9]+)`/); + // Extract commit hash from PR body + const commitMatch = proposal.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/); const proposedCommit = commitMatch ? commitMatch[1] : null; if (proposedCommit) { - console.log(`Proposal is for commit: ${proposedCommit}`); + console.log(`Proposal was for commit: ${proposedCommit}`); } else { - console.log('⚠️ No commit hash found in proposal'); + console.log('⚠️ No target commit hash found in PR body'); } - // Get maintainer logins from secret (comma or semicolon separated) - const maintainerLoginsStr = process.env.MAINTAINER_LOGINS || ''; - const maintainerLogins = new Set( - maintainerLoginsStr - .split(/[,;]/) - .map(login => login.trim().toLowerCase()) - .filter(login => login.length > 0) - ); + // Get PR reviews to extract approvers + let approvers = 'Validated by automation'; + let approvalCount = 2; // Minimum required - console.log(`Found ${maintainerLogins.size} maintainer logins in secret`); - - // Count approvals from maintainers - const maintainerApprovals = reactions.data.filter(reaction => - maintainerLogins.has(reaction.user.login.toLowerCase()) - ); - - const approvalCount = maintainerApprovals.length; - const approved = approvalCount >= 2; - - console.log(`Found ${approvalCount} maintainer approvals`); - console.log(`Approved: ${approved}`); + try { + const reviews = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: proposal.number + }); + + // Get latest review per user and filter for approvals + const latestReviewsByUser = {}; + reviews.data.forEach(review => { + const username = review.user.login; + if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) { + latestReviewsByUser[username] = review; + } + }); + + const approvalReviews = Object.values(latestReviewsByUser).filter(review => + review.state === 'APPROVED' + ); + + if (approvalReviews.length > 0) { + approvers = approvalReviews.map(r => '@' + r.user.login).join(', '); + approvalCount = approvalReviews.length; + console.log(`Found ${approvalCount} approvals from: ${approvers}`); + } + } catch (error) { + console.log(`Could not fetch reviews: ${error.message}`); + } return { number: proposal.number, - approved: approved, + approved: true, approvals: approvalCount, - approvers: maintainerApprovals.map(r => '@' + r.user.login).join(', '), + approvers: approvers, proposedCommit: proposedCommit }; result-encoding: json - - name: Check approval requirements + - name: Verify proposal commit run: | APPROVALS='${{ steps.find_proposal.outputs.result }}' # Parse JSON - APPROVED=$(echo "$APPROVALS" | jq -r '.approved') - COUNT=$(echo "$APPROVALS" | jq -r '.approvals') - APPROVERS=$(echo "$APPROVALS" | jq -r '.approvers') PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit') CURRENT_COMMIT="${{ steps.info.outputs.sha }}" - echo "Approval status: $APPROVED" - echo "Approval count: $COUNT" - echo "Approvers: $APPROVERS" echo "Proposed commit: $PROPOSED_COMMIT" echo "Current commit: $CURRENT_COMMIT" - # Check if commits match - if [ "$PROPOSED_COMMIT" != "null" ] && [ "$PROPOSED_COMMIT" != "$CURRENT_COMMIT" ]; then - echo "❌ Commit mismatch!" - echo "The tag points to commit $CURRENT_COMMIT but the proposal was for $PROPOSED_COMMIT" - echo "This indicates the code has changed since the proposal was created." - # delete the tag - git push --delete origin "${{ steps.vars.outputs.version_tag }}" - echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted" - exit 1 - fi - - if [ "$PROPOSED_COMMIT" != "null" ]; then - echo "✅ Commit hash matches proposal" - fi - - if [ "$APPROVED" != "true" ]; then - echo "❌ Release does not have minimum 2 maintainer approvals" - echo "Current approvals: $COUNT" - # Delete the tag remotely - git push --delete origin "${{ steps.vars.outputs.version_tag }}" + # Check if commits match (if proposal had a target commit) + if [ "$PROPOSED_COMMIT" != "null" ] && [ -n "$PROPOSED_COMMIT" ]; then + # Normalize both commits to full SHA for comparison + PROPOSED_FULL=$(git rev-parse "$PROPOSED_COMMIT" 2>/dev/null || echo "") + CURRENT_FULL=$(git rev-parse "$CURRENT_COMMIT" 2>/dev/null || echo "") - echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted" - exit 1 + if [ -z "$PROPOSED_FULL" ]; then + echo "⚠️ Could not resolve proposed commit: $PROPOSED_COMMIT" + elif [ "$PROPOSED_FULL" != "$CURRENT_FULL" ]; then + echo "❌ Commit mismatch!" + echo "The tag points to commit $CURRENT_FULL but the proposal was for $PROPOSED_FULL" + echo "This indicates an error in tag creation." + # Delete the tag remotely + git push --delete origin "${{ steps.vars.outputs.version_tag }}" + echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted" + exit 1 + else + echo "✅ Commit hash matches proposal" + fi + else + echo "⚠️ No target commit found in proposal (might be legacy release)" fi - echo "✅ Release has sufficient approvals" + echo "✅ Tag verification completed" - - name: Update release proposal - if: steps.find_proposal.outputs.result != 'null' + - name: Update release proposal PR + if: fromJson(steps.find_proposal.outputs.result).number != null uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const result = ${{ steps.find_proposal.outputs.result }}; if (result.number) { + // Add in-progress label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: result.number, + labels: ['release-in-progress'] + }); + + // Remove approved label if present + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: result.number, + name: 'approved' + }); + } catch (e) { + console.log('Approved label not found:', e.message); + } + const commentBody = [ - '## ✅ Release Tag Created and Verified', + '## 🚀 Release Workflow Started', '', '- **Tag:** ${{ steps.info.outputs.version }}', - '- **SSH Key SHA256:** ${{ steps.verify.outputs.key_id }}', - `- **Approvals:** ${result.approvals} maintainers (${result.approvers})`, + '- **Signed by key:** ${{ steps.verify.outputs.key_id }}', '- **Commit:** ${{ steps.info.outputs.sha }}', + '- **Approved by:** ' + result.approvers, '', - 'Release workflow is now running. This issue will be closed when the release is published.' + 'Release workflow is now running. This PR will be updated when the release is published.' ].join('\n'); await github.rest.issues.createComment({ @@ -294,34 +301,13 @@ jobs: issue_number: result.number, body: commentBody }); - - // remove earlier labels - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: result.number, - name: 'needs-approval' - }); - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: result.number, - name: 'release-proposal' - }); - - // add 'release-in-progress' label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: result.number, - labels: ['release-in-progress'] - }); } - name: Summary run: | APPROVALS='${{ steps.find_proposal.outputs.result }}' PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit // "N/A"') + APPROVERS=$(echo "$APPROVALS" | jq -r '.approvers // "N/A"') echo "## Tag Verification Summary 🔐" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -329,8 +315,9 @@ jobs: echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY echo "- **Signature:** ✅ Verified" >> $GITHUB_STEP_SUMMARY - echo "- **SSH Key SHA256:** ${{ steps.verify.outputs.key_id }}" >> $GITHUB_STEP_SUMMARY + echo "- **Signed by:** ${{ steps.verify.outputs.key_id }}" >> $GITHUB_STEP_SUMMARY echo "- **Approvals:** ✅ Sufficient" >> $GITHUB_STEP_SUMMARY + echo "- **Approved by:** $APPROVERS" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Proceeding with release build..." >> $GITHUB_STEP_SUMMARY @@ -360,6 +347,7 @@ jobs: # "Releases" is part of `contents`, so it needs the `write` contents: write issues: write + pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) @@ -503,41 +491,71 @@ jobs: cloudsmith push deb caddy/testing/any-distro/any-version $filename done - - name: Close release proposal issue + - name: Update release proposal PR if: needs.verify-tag.outputs.proposal_issue_number != '' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | - const issueNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}'); + const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}'); - if (issueNumber) { + if (prNumber) { + // Get PR details to find the branch + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const branchName = pr.data.head.ref; + + // Remove in-progress label + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'release-in-progress' + }); + } catch (e) { + console.log('Label not found:', e.message); + } + + // Add released label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['released'] + }); + // Add final comment await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: issueNumber, - body: '## ✅ Release Published\n\nThe release has been successfully published. Closing this proposal.' + issue_number: prNumber, + body: '## ✅ Release Published\n\nThe release has been successfully published and is now available.' }); - // Remove the release-in-progress label - try { - await github.rest.issues.removeAllLabels({ + // Close the PR if it's still open + if (pr.data.state === 'open') { + await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: issueNumber + pull_number: prNumber, + state: 'closed' }); - } catch (error) { - console.log('Label might not exist on issue'); + console.log(`Closed PR #${prNumber}`); } - // Close the issue - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: 'closed', - state_reason: 'completed' - }); - - console.log(`Closed issue #${issueNumber}`); + // Delete the branch + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branchName}` + }); + console.log(`Deleted branch: ${branchName}`); + } catch (e) { + console.log(`Could not delete branch ${branchName}: ${e.message}`); + } }