switch to PR-based flow

Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>
This commit is contained in:
Mohammed Al Sahaf 2025-11-08 16:32:12 +03:00
parent 6ee8bf93f6
commit 7ddb4e1da2
No known key found for this signature in database
3 changed files with 563 additions and 235 deletions

221
.github/workflows/auto-release-pr.yml vendored Normal file
View File

@ -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');

View File

@ -1,6 +1,6 @@
name: Release Proposal 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 # Triggered manually by maintainers when ready to prepare a release
on: on:
workflow_dispatch: workflow_dispatch:
@ -9,16 +9,15 @@ on:
description: 'Version to release (e.g., v2.8.0)' description: 'Version to release (e.g., v2.8.0)'
required: true required: true
type: string type: string
prerelease: commit_hash:
description: 'Is this a pre-release?' description: 'Commit hash to release from'
required: false required: true
type: boolean type: string
default: false
permissions: permissions:
contents: read contents: write
issues: write
pull-requests: write pull-requests: write
issues: write
jobs: jobs:
create-proposal: create-proposal:
@ -35,123 +34,213 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Validate version format - name: Trim and validate inputs
id: inputs
run: | 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)" echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)"
exit 1 exit 1
fi 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 - name: Check if tag already exists
run: | run: |
if git rev-parse "${{ inputs.version }}" >/dev/null 2>&1; then if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then
echo "Error: Tag ${{ inputs.version }} already exists" echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists"
exit 1 exit 1
fi fi
- name: Generate changelog - name: Check for existing proposal PR
id: changelog 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: | run: |
# Get the HEAD commit hash VERSION="${{ steps.inputs.outputs.version }}"
HEAD_COMMIT=$(git rev-parse HEAD) COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}"
echo "head_commit=$HEAD_COMMIT" >> $GITHUB_OUTPUT
# 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 # Get the last tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LAST_TAG" ]; then if [ -z "$LAST_TAG" ]; then
echo "No previous tag found, generating full changelog" 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 else
echo "Generating changelog since $LAST_TAG" 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 fi
# Save commits to file # Store changelog for PR body
echo "$COMMITS" > /tmp/commits.txt echo "changelog<<EOF" >> $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 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const fs = require('fs'); const changelog = `${{ steps.setup.outputs.changelog }}`;
const commits = fs.readFileSync('/tmp/commits.txt', 'utf8');
const isPrerelease = '${{ inputs.prerelease }}' === 'true';
const releaseType = isPrerelease ? 'Pre-release' : 'Stable Release';
const body = [ const pr = await github.rest.pulls.create({
'## 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({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
title: `Release Proposal: ${{ inputs.version }}`, title: `Release Proposal: ${{ steps.inputs.outputs.version }}`,
body: body, head: '${{ steps.setup.outputs.branch_name }}',
labels: ['release-proposal', 'needs-approval'] 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}`); // Add labels
await github.rest.issues.addLabels({
// Pin the issue
await github.rest.issues.update({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: issue.data.number, issue_number: pr.data.number,
state: 'open' 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 - name: Post summary
run: | run: |
echo "## Release Proposal Created! 🚀" >> $GITHUB_STEP_SUMMARY echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "Version: **${{ inputs.version }}**" >> $GITHUB_STEP_SUMMARY echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY
echo "Type: **${{ inputs.prerelease == 'true' && 'Pre-release' || 'Stable Release' }}**" >> $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 "" >> $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

View File

@ -16,7 +16,6 @@ jobs:
verify-tag: verify-tag:
name: Verify Tag Signature and Approvals name: Verify Tag Signature and Approvals
runs-on: ubuntu-latest runs-on: ubuntu-latest
# environment: 'default'
outputs: outputs:
verification_passed: ${{ steps.verify.outputs.passed }} 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 || '' }} proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }}
steps: 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 - name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
# Force fetch upstream tags -- because 65 minutes # 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/ # 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: # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran:
# git fetch --prune --unshallow # git fetch --prune --unshallow
@ -59,8 +54,8 @@ jobs:
go env go env
printf "\n\nSystem environment:\n\n" printf "\n\nSystem environment:\n\n"
env env
echo "::set-output name=version_tag::${GITHUB_REF/refs\/tags\//}" echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
echo "::set-output name=short_sha::$(git rev-parse --short HEAD)" echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
# Add "pip install" CLI tools to PATH # Add "pip install" CLI tools to PATH
echo ~/.local/bin >> $GITHUB_PATH echo ~/.local/bin >> $GITHUB_PATH
@ -72,10 +67,10 @@ jobs:
TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"` TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"`
TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"` TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"`
TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"` TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"`
echo "::set-output name=tag_major::${TAG_MAJOR}" echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT
echo "::set-output name=tag_minor::${TAG_MINOR}" echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT
echo "::set-output name=tag_patch::${TAG_PATCH}" echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT
echo "::set-output name=tag_special::${TAG_SPECIAL}" echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT
- name: Validate commits and tag signatures - name: Validate commits and tag signatures
id: verify id: verify
@ -98,11 +93,12 @@ jobs:
item="${item%%*( )}" item="${item%%*( )}"
IFS=" " read -ra key_components <<< "$item" IFS=" " read -ra key_components <<< "$item"
# email address, type, public key # git wants it in format: email address, type, public key
echo "${key_components[0]} namespaces=\"git\" ${key_components[1]} ${key_components[2]}" >> "${{ runner.temp }}/allowed_signers" # 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 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 }}" echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}"
@ -113,12 +109,9 @@ jobs:
git push --delete origin "${{ steps.vars.outputs.version_tag }}" git push --delete origin "${{ steps.vars.outputs.version_tag }}"
exit 1 exit 1
fi fi
# Run it again to capture the output # Run it again to capture the output
git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1 | tee /tmp/verify-output.txt; 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 # SSH verification output typically includes the key fingerprint
# Use GNU grep with Perl regex for cleaner extraction (Linux environment) # 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 "") 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 }}" git push --delete origin "${{ steps.vars.outputs.version_tag }}"
exit 1 exit 1
fi fi
echo "✅ Tag verification succeeded!" echo "✅ Tag verification succeeded!"
echo "SSH Key SHA256: $KEY_SHA256"
echo "passed=true" >> $GITHUB_OUTPUT echo "passed=true" >> $GITHUB_OUTPUT
echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT
- name: Find related release proposal - name: Find related release proposal
id: find_proposal id: find_proposal
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
with: with:
script: | script: |
const version = '${{ steps.vars.outputs.version_tag }}'; const version = '${{ steps.vars.outputs.version_tag }}';
// Search for the release proposal issue // Search for PRs with release-proposal label that match this version
const issues = await github.rest.issues.listForRepo({ const prs = await github.rest.pulls.list({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
labels: 'release-proposal', state: 'open', // Changed to 'all' to find both open and closed PRs
state: 'open', sort: 'updated',
labels: 'release-proposal,needs-approval' direction: 'desc'
}); });
const proposal = issues.data.find(issue => // Find the most recent PR for this version
issue.title.includes(version) const proposal = prs.data.find(pr =>
pr.title.includes(version) &&
pr.labels.some(label => label.name === 'release-proposal')
); );
if (!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'); 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) console.log(`Found proposal PR #${proposal.number} for version ${version}`);
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`);
// Extract commit hash from proposal body // Extract commit hash from PR body
const commitMatch = proposal.body.match(/\*\*Commit:\*\*\s*`([a-f0-9]+)`/); const commitMatch = proposal.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/);
const proposedCommit = commitMatch ? commitMatch[1] : null; const proposedCommit = commitMatch ? commitMatch[1] : null;
if (proposedCommit) { if (proposedCommit) {
console.log(`Proposal is for commit: ${proposedCommit}`); console.log(`Proposal was for commit: ${proposedCommit}`);
} else { } 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) // Get PR reviews to extract approvers
const maintainerLoginsStr = process.env.MAINTAINER_LOGINS || ''; let approvers = 'Validated by automation';
const maintainerLogins = new Set( let approvalCount = 2; // Minimum required
maintainerLoginsStr
.split(/[,;]/)
.map(login => login.trim().toLowerCase())
.filter(login => login.length > 0)
);
console.log(`Found ${maintainerLogins.size} maintainer logins in secret`); try {
const reviews = await github.rest.pulls.listReviews({
// Count approvals from maintainers owner: context.repo.owner,
const maintainerApprovals = reactions.data.filter(reaction => repo: context.repo.repo,
maintainerLogins.has(reaction.user.login.toLowerCase()) pull_number: proposal.number
); });
const approvalCount = maintainerApprovals.length; // Get latest review per user and filter for approvals
const approved = approvalCount >= 2; const latestReviewsByUser = {};
reviews.data.forEach(review => {
console.log(`Found ${approvalCount} maintainer approvals`); const username = review.user.login;
console.log(`Approved: ${approved}`); 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 { return {
number: proposal.number, number: proposal.number,
approved: approved, approved: true,
approvals: approvalCount, approvals: approvalCount,
approvers: maintainerApprovals.map(r => '@' + r.user.login).join(', '), approvers: approvers,
proposedCommit: proposedCommit proposedCommit: proposedCommit
}; };
result-encoding: json result-encoding: json
- name: Check approval requirements - name: Verify proposal commit
run: | run: |
APPROVALS='${{ steps.find_proposal.outputs.result }}' APPROVALS='${{ steps.find_proposal.outputs.result }}'
# Parse JSON # 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') PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit')
CURRENT_COMMIT="${{ steps.info.outputs.sha }}" CURRENT_COMMIT="${{ steps.info.outputs.sha }}"
echo "Approval status: $APPROVED"
echo "Approval count: $COUNT"
echo "Approvers: $APPROVERS"
echo "Proposed commit: $PROPOSED_COMMIT" echo "Proposed commit: $PROPOSED_COMMIT"
echo "Current commit: $CURRENT_COMMIT" echo "Current commit: $CURRENT_COMMIT"
# Check if commits match # Check if commits match (if proposal had a target commit)
if [ "$PROPOSED_COMMIT" != "null" ] && [ "$PROPOSED_COMMIT" != "$CURRENT_COMMIT" ]; then if [ "$PROPOSED_COMMIT" != "null" ] && [ -n "$PROPOSED_COMMIT" ]; then
echo "❌ Commit mismatch!" # Normalize both commits to full SHA for comparison
echo "The tag points to commit $CURRENT_COMMIT but the proposal was for $PROPOSED_COMMIT" PROPOSED_FULL=$(git rev-parse "$PROPOSED_COMMIT" 2>/dev/null || echo "")
echo "This indicates the code has changed since the proposal was created." CURRENT_FULL=$(git rev-parse "$CURRENT_COMMIT" 2>/dev/null || echo "")
# 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 }}"
echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted" if [ -z "$PROPOSED_FULL" ]; then
exit 1 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 fi
echo "✅ Release has sufficient approvals" echo "✅ Tag verification completed"
- name: Update release proposal - name: Update release proposal PR
if: steps.find_proposal.outputs.result != 'null' if: fromJson(steps.find_proposal.outputs.result).number != null
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const result = ${{ steps.find_proposal.outputs.result }}; const result = ${{ steps.find_proposal.outputs.result }};
if (result.number) { 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 = [ const commentBody = [
'## ✅ Release Tag Created and Verified', '## 🚀 Release Workflow Started',
'', '',
'- **Tag:** ${{ steps.info.outputs.version }}', '- **Tag:** ${{ steps.info.outputs.version }}',
'- **SSH Key SHA256:** ${{ steps.verify.outputs.key_id }}', '- **Signed by key:** ${{ steps.verify.outputs.key_id }}',
`- **Approvals:** ${result.approvals} maintainers (${result.approvers})`,
'- **Commit:** ${{ steps.info.outputs.sha }}', '- **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'); ].join('\n');
await github.rest.issues.createComment({ await github.rest.issues.createComment({
@ -294,34 +301,13 @@ jobs:
issue_number: result.number, issue_number: result.number,
body: commentBody 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 - name: Summary
run: | run: |
APPROVALS='${{ steps.find_proposal.outputs.result }}' APPROVALS='${{ steps.find_proposal.outputs.result }}'
PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit // "N/A"') 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 "## Tag Verification Summary 🔐" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
@ -329,8 +315,9 @@ jobs:
echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY
echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY
echo "- **Signature:** ✅ Verified" >> $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 "- **Approvals:** ✅ Sufficient" >> $GITHUB_STEP_SUMMARY
echo "- **Approved by:** $APPROVERS" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "Proceeding with release build..." >> $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` # "Releases" is part of `contents`, so it needs the `write`
contents: write contents: write
issues: write issues: write
pull-requests: write
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
@ -503,41 +491,71 @@ jobs:
cloudsmith push deb caddy/testing/any-distro/any-version $filename cloudsmith push deb caddy/testing/any-distro/any-version $filename
done done
- name: Close release proposal issue - name: Update release proposal PR
if: needs.verify-tag.outputs.proposal_issue_number != '' if: needs.verify-tag.outputs.proposal_issue_number != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | 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 // Add final comment
await github.rest.issues.createComment({ await github.rest.issues.createComment({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: issueNumber, issue_number: prNumber,
body: '## ✅ Release Published\n\nThe release has been successfully published. Closing this proposal.' body: '## ✅ Release Published\n\nThe release has been successfully published and is now available.'
}); });
// Remove the release-in-progress label // Close the PR if it's still open
try { if (pr.data.state === 'open') {
await github.rest.issues.removeAllLabels({ await github.rest.pulls.update({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: issueNumber pull_number: prNumber,
state: 'closed'
}); });
} catch (error) { console.log(`Closed PR #${prNumber}`);
console.log('Label might not exist on issue');
} }
// Close the issue // Delete the branch
await github.rest.issues.update({ try {
owner: context.repo.owner, await github.rest.git.deleteRef({
repo: context.repo.repo, owner: context.repo.owner,
issue_number: issueNumber, repo: context.repo.repo,
state: 'closed', ref: `heads/${branchName}`
state_reason: 'completed' });
}); console.log(`Deleted branch: ${branchName}`);
} catch (e) {
console.log(`Closed issue #${issueNumber}`); console.log(`Could not delete branch ${branchName}: ${e.message}`);
}
} }