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 new file mode 100644 index 000000000..afde7965a --- /dev/null +++ b/.github/workflows/release-proposal.yml @@ -0,0 +1,248 @@ +name: Release Proposal + +# 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: + inputs: + version: + description: 'Version to release (e.g., v2.8.0)' + required: true + type: string + commit_hash: + description: 'Commit hash to release from' + required: true + type: string + +permissions: + contents: read + +jobs: + create-proposal: + name: Create Release Proposal + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + + 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 + + - name: Trim and validate inputs + id: inputs + run: | + # 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 "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then + echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists" + exit 1 + fi + + - 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: | + 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 "$COMMIT_HASH") + else + echo "Generating changelog since $LAST_TAG" + COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH") + fi + + # Store changelog for PR body + echo "changelog<> $GITHUB_OUTPUT + echo "$COMMITS" >> $GITHUB_OUTPUT + echo "EOF" >> $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 PR + id: create_pr + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const changelog = `${{ steps.setup.outputs.changelog }}`; + + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + 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: true + }); + + // Add labels + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + 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 PR Created! 🚀" >> $GITHUB_STEP_SUMMARY + echo "" >> $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 "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 397df5ea2..e4880a64c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,8 +13,322 @@ permissions: contents: read jobs: + verify-tag: + name: Verify Tag Signature and Approvals + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + + outputs: + verification_passed: ${{ steps.verify.outputs.passed }} + tag_version: ${{ steps.info.outputs.version }} + proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }} + + steps: + - 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@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 + # which doesn't overwrite that tag because that would be destructive. + # Credit to @francislavoie for the investigation. + # https://github.com/actions/checkout/issues/290#issuecomment-680260080 + - name: Force fetch upstream tags + run: git fetch --tags --force + + - name: Get tag info + id: info + run: | + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 + - name: Print Go version and environment + id: vars + run: | + printf "Using go at: $(which go)\n" + printf "Go version: $(go version)\n" + printf "\n\nGo environment:\n\n" + go env + printf "\n\nSystem environment:\n\n" + env + 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 + + # Parse semver + TAG=${GITHUB_REF/refs\/tags\//} + SEMVER_RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z\.-]*\)' + TAG_MAJOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\1#"` + 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 "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 + env: + signing_keys: ${{ secrets.SIGNING_KEYS }} + run: | + # Read the string into an array, splitting by IFS + IFS=";" read -ra keys_collection <<< "$signing_keys" + + # ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-the-runner-context + touch "${{ runner.temp }}/allowed_signers" + + # Iterate and print the split elements + for item in "${keys_collection[@]}"; do + + # trim leading whitespaces + item="${item##*( )}" + + # trim trailing whitespaces + item="${item%%*( )}" + + IFS=" " read -ra key_components <<< "$item" + # 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 set --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers" + + echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}" + + # Verify the tag is signed + if ! git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1; then + echo "❌ Tag verification failed!" + echo "passed=false" >> $GITHUB_OUTPUT + 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; + + # 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 "") + + if [ -z "$KEY_SHA256" ]; then + # Try alternative pattern with "key" prefix + KEY_SHA256=$(grep -oP "key SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "") + fi + + if [ -z "$KEY_SHA256" ]; then + # Fallback: extract any base64-like string (40+ chars) + KEY_SHA256=$(grep -oP '[A-Za-z0-9+/]{40,}=?' /tmp/verify-output.txt | head -1 || echo "") + fi + + if [ -z "$KEY_SHA256" ]; then + echo "Somehow could not extract SSH key fingerprint from git verify-tag output" + echo "Cancelling flow and deleting tag" + echo "passed=false" >> $GITHUB_OUTPUT + git push --delete origin "${{ steps.vars.outputs.version_tag }}" + exit 1 + fi + + echo "✅ Tag verification succeeded!" + 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 + with: + script: | + const version = '${{ steps.vars.outputs.version_tag }}'; + + // 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, + state: 'open', // Changed to 'all' to find both open and closed PRs + sort: 'updated', + direction: 'desc' + }); + + // 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 PR found for ${version}`); + console.log('This might be a hotfix or emergency release'); + return { number: null, approved: true, approvals: 0, proposedCommit: null }; + } + + console.log(`Found proposal PR #${proposal.number} for version ${version}`); + + // 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 was for commit: ${proposedCommit}`); + } else { + console.log('⚠️ No target commit hash found in PR body'); + } + + // Get PR reviews to extract approvers + let approvers = 'Validated by automation'; + let approvalCount = 2; // Minimum required + + 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: true, + approvals: approvalCount, + approvers: approvers, + proposedCommit: proposedCommit + }; + result-encoding: json + + - name: Verify proposal commit + run: | + APPROVALS='${{ steps.find_proposal.outputs.result }}' + + # Parse JSON + PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit') + CURRENT_COMMIT="${{ steps.info.outputs.sha }}" + + echo "Proposed commit: $PROPOSED_COMMIT" + echo "Current commit: $CURRENT_COMMIT" + + # 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 "") + + 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 "✅ Tag verification completed" + + - 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 Workflow Started', + '', + '- **Tag:** ${{ steps.info.outputs.version }}', + '- **Signed by key:** ${{ steps.verify.outputs.key_id }}', + '- **Commit:** ${{ steps.info.outputs.sha }}', + '- **Approved by:** ' + result.approvers, + '', + 'Release workflow is now running. This PR will be updated when the release is published.' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: result.number, + body: commentBody + }); + } + + - 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 + echo "- **Tag:** ${{ steps.info.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY + echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY + echo "- **Signature:** ✅ Verified" >> $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 + release: name: Release + needs: verify-tag + if: ${{ needs.verify-tag.outputs.verification_passed == 'true' }} strategy: matrix: os: @@ -36,6 +350,8 @@ jobs: # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps#permission-on-contents # "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) @@ -98,16 +414,6 @@ jobs: - name: Install Cloudsmith CLI run: pip install --upgrade cloudsmith-cli - - name: Validate commits and tag signatures - run: | - - # Import Matt Holt's key - curl 'https://github.com/mholt.gpg' | gpg --import - - echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}" - # tags are only accepted if signed by Matt's key - git verify-tag "${{ steps.vars.outputs.version_tag }}" || exit 1 - - name: Install Cosign uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # main - name: Cosign version @@ -188,3 +494,72 @@ jobs: echo "Pushing $filename to 'testing'" cloudsmith push deb caddy/testing/any-distro/any-version $filename done + + - name: Update release proposal PR + if: needs.verify-tag.outputs.proposal_issue_number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}'); + + 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: prNumber, + body: '## ✅ Release Published\n\nThe release has been successfully published and is now available.' + }); + + // 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, + pull_number: prNumber, + state: 'closed' + }); + console.log(`Closed PR #${prNumber}`); + } + + // 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}`); + } + }