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
# 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<<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
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

View File

@ -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}`);
}
}