ci: release changelogs since the previous published release, and workflow hardening (#8341)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Mohammed Al Sahaf <msaa1990@gmail.com>pull/8340/head
parent
bbea66f2ec
commit
468154121d
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env bash
|
||||
# Prints the commits that are new in <target> compared to the release <base>,
|
||||
# oldest first, one SHA per line.
|
||||
#
|
||||
# Usage: changelog-commits.sh <base> <target>
|
||||
#
|
||||
# When <base> is an ancestor of <target>, that's simply base..target. When it
|
||||
# isn't (e.g. base is a 2.11.x release cut from a maintenance branch and
|
||||
# target is on master), base..target also contains the master commits whose
|
||||
# fixes were already cherry-picked into the base release. Those are skipped
|
||||
# when any of the following holds:
|
||||
#
|
||||
# 1. The commit has the same patch ID as a commit on the base side
|
||||
# (a clean cherry-pick).
|
||||
# 2. Cherry-picking it onto the base would change nothing, but
|
||||
# cherry-picking it onto the merge base would. That is, the base
|
||||
# already has the change, and it got there after the branches split.
|
||||
# This catches clean picks whose lines were changed again later. The
|
||||
# merge-base condition keeps a revert of something the base never had
|
||||
# (which would also change nothing) in the list.
|
||||
# 3. Its subject ends with a PR number, e.g. "(#1234)", and a commit on
|
||||
# the base side has the identical subject. This catches backports that
|
||||
# had to be adapted to apply. Subjects without a PR number are never
|
||||
# matched this way: unrelated commits can share one (for example
|
||||
# GitHub's "Merge commit from fork" for security advisories).
|
||||
set -euo pipefail
|
||||
|
||||
base=$(git rev-parse --verify "${1:?usage: $0 <base> <target>}^{commit}")
|
||||
target=$(git rev-parse --verify "${2:?usage: $0 <base> <target>}^{commit}")
|
||||
|
||||
if git merge-base --is-ancestor "$base" "$target"; then
|
||||
git rev-list --reverse "$base..$target"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
merge_base=$(git merge-base "$base" "$target")
|
||||
base_tree=$(git rev-parse "$base^{tree}")
|
||||
merge_base_tree=$(git rev-parse "$merge_base^{tree}")
|
||||
backported_subjects=$(git log --format=%s "$merge_base..$base" | grep -E '\(#[0-9]+\)$' || true)
|
||||
|
||||
# picking_changes_nothing <commit> <onto> <onto-tree>
|
||||
picking_changes_nothing() {
|
||||
local result
|
||||
if ! result=$(git merge-tree --write-tree --merge-base="$1^" "$2" "$1" 2>/dev/null); then
|
||||
return 1 # conflicts, so it would change something
|
||||
fi
|
||||
[ "$(head -n 1 <<< "$result")" = "$3" ]
|
||||
}
|
||||
|
||||
for commit in $(git rev-list --reverse --cherry-pick --right-only "$base...$target"); do
|
||||
subject=$(git log -1 --format=%s "$commit")
|
||||
if [[ $subject =~ \(#[0-9]+\)$ ]] && grep -qxF -- "$subject" <<< "$backported_subjects"; then
|
||||
continue
|
||||
fi
|
||||
if picking_changes_nothing "$commit" "$base" "$base_tree" &&
|
||||
! picking_changes_nothing "$commit" "$merge_base" "$merge_base_tree"; then
|
||||
continue
|
||||
fi
|
||||
echo "$commit"
|
||||
done
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# Prints the tag of the highest published (non-draft) GitHub release whose
|
||||
# version is lower than the given one, or nothing if there is none.
|
||||
#
|
||||
# Usage: previous-release.sh <version>
|
||||
#
|
||||
# Release changelogs should cover everything since the last release users
|
||||
# actually received. "The last tag" is not good enough:
|
||||
#
|
||||
# - When a release attempt is abandoned after tagging, the Go module proxy
|
||||
# has usually cached the tag, so it can't be deleted and reused, and the
|
||||
# next attempt gets a new patch number. Those abandoned tags never get a
|
||||
# published release, so they are skipped here.
|
||||
# - Releases can be cut from maintenance branches (e.g. 2.11), so the
|
||||
# previous release isn't necessarily an ancestor of the new one. Picking
|
||||
# by version means 2.12.0 compares against the latest 2.11.x, and a 2.11.x
|
||||
# released after 2.12.0 compares against the previous 2.11.x.
|
||||
#
|
||||
# Requires GH_TOKEN and GITHUB_REPOSITORY in the environment.
|
||||
set -euo pipefail
|
||||
|
||||
version=${1:?usage: $0 <version>}
|
||||
|
||||
# sort -V orders "2.12.0-beta.1" after "2.12.0", but with the prerelease
|
||||
# separator replaced by "~" (which sorts before everything, even the end of
|
||||
# the string) it orders like semver.
|
||||
sort_key() {
|
||||
printf '%s' "${1#v}" | sed 's/-/~/'
|
||||
}
|
||||
|
||||
tags=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" --jq '.[] | select(.draft | not) | .tag_name')
|
||||
|
||||
{
|
||||
# The new version itself, marked by an empty tag column.
|
||||
printf '%s\t\n' "$(sort_key "$version")"
|
||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' <<< "$tags" | while IFS= read -r tag; do
|
||||
if [ "$tag" != "$version" ]; then
|
||||
printf '%s\t%s\n' "$(sort_key "$tag")" "$tag"
|
||||
fi
|
||||
done
|
||||
} | sort -t $'\t' -k1,1V | awk -F '\t' '$2 == "" { print prev; exit } { prev = $2 }'
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bash
|
||||
# Prints GitHub's generated release notes for <tag> (What's Changed, New
|
||||
# Contributors, Full Changelog), compared against <previous-tag>, without
|
||||
# the PRs that already shipped in <previous-tag>.
|
||||
#
|
||||
# Usage: release-notes.sh <tag> <target-commit> [previous-tag]
|
||||
#
|
||||
# GitHub's generator lists every PR in previous...tag. When the previous
|
||||
# release was cut from a maintenance branch, that includes the master PRs
|
||||
# whose fixes were cherry-picked into it, so those are removed here, using
|
||||
# the same rules as changelog-commits.sh. Only lines ending in a PR link can
|
||||
# be removed; anything else is left alone.
|
||||
#
|
||||
# Requires GH_TOKEN and GITHUB_REPOSITORY in the environment.
|
||||
set -euo pipefail
|
||||
|
||||
tag=${1:?usage: $0 <tag> <target-commit> [previous-tag]}
|
||||
target=${2:?usage: $0 <tag> <target-commit> [previous-tag]}
|
||||
previous=${3:-}
|
||||
|
||||
args=(-f "tag_name=$tag" -f "target_commitish=$(git rev-parse --verify "$target^{commit}")")
|
||||
if [ -n "$previous" ]; then
|
||||
args+=(-f "previous_tag_name=$previous")
|
||||
fi
|
||||
notes=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" "${args[@]}" --jq .body)
|
||||
|
||||
if [ -z "$previous" ]; then
|
||||
printf '%s\n' "$notes"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
scripts=$(dirname "$0")
|
||||
kept=$("$scripts/changelog-commits.sh" "$previous" "$target")
|
||||
skipped_prs=$(git rev-list "$previous..$target" | { grep -vxF -f <(printf '%s\n' "$kept") || true; } |
|
||||
xargs -r git log --no-walk --format=%s | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p' | sort -u)
|
||||
|
||||
if [ -z "$skipped_prs" ]; then
|
||||
printf '%s\n' "$notes"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pattern="/pull/($(paste -sd '|' <<< "$skipped_prs"))\$"
|
||||
printf '%s\n' "$notes" | tr -d '\r' | grep -vE -- "$pattern"
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
// Shared release proposal logic for the release workflows, loaded from
|
||||
// actions/github-script with require(). Keeping it in one place makes the
|
||||
// approval tracker and the release job agree on what an approved proposal is.
|
||||
//
|
||||
// A proposal is a draft PR labelled "release-proposal", titled exactly
|
||||
// "Release Proposal: <version>", whose head commit message carries a
|
||||
// "Target-Commit: <full sha>" trailer. The trailer is written by the
|
||||
// Release Proposal workflow; unlike the PR body, it can't be changed
|
||||
// without pushing a new commit, which makes earlier approvals stale.
|
||||
'use strict';
|
||||
|
||||
const VERSION_RE = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
|
||||
const TARGET_COMMIT_RE = /^Target-Commit: ([0-9a-f]{40})$/m;
|
||||
const REQUIRED_APPROVALS = 2;
|
||||
|
||||
function proposalTitle(version) {
|
||||
return `Release Proposal: ${version}`;
|
||||
}
|
||||
|
||||
function validateVersion(version) {
|
||||
if (!VERSION_RE.test(version)) {
|
||||
throw new Error(`invalid release version ${JSON.stringify(version)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// parseMaintainers splits the MAINTAINER_LOGINS secret (comma or semicolon
|
||||
// separated) into lowercased logins. GitHub logins are case-insensitive.
|
||||
function parseMaintainers(raw) {
|
||||
return (raw || '')
|
||||
.split(/[,;]/)
|
||||
.map(login => login.trim().toLowerCase())
|
||||
.filter(login => login.length > 0);
|
||||
}
|
||||
|
||||
// findOpenProposals returns every open release proposal for the version.
|
||||
async function findOpenProposals({ github, context, version }) {
|
||||
validateVersion(version);
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
return prs.filter(pr =>
|
||||
pr.title === proposalTitle(version) &&
|
||||
pr.labels.some(label => label.name === 'release-proposal'));
|
||||
}
|
||||
|
||||
// targetCommit reads the Target-Commit trailer from the proposal's head commit.
|
||||
async function targetCommit({ github, context, pr }) {
|
||||
const { data: commit } = await github.rest.git.getCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: pr.head.sha,
|
||||
});
|
||||
const match = commit.message.match(TARGET_COMMIT_RE);
|
||||
if (!match) {
|
||||
throw new Error(`proposal #${pr.number} head commit ${pr.head.sha} has no Target-Commit trailer`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// maintainerApprovals returns the maintainers whose latest review approves
|
||||
// the proposal's current head commit. Plain comments don't change a
|
||||
// reviewer's verdict, and approvals of an earlier head commit don't count.
|
||||
async function maintainerApprovals({ github, context, pr, maintainers }) {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const latest = new Map();
|
||||
for (const review of reviews) {
|
||||
if (!review.user || !['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) {
|
||||
continue;
|
||||
}
|
||||
const login = review.user.login.toLowerCase();
|
||||
const previous = latest.get(login);
|
||||
if (!previous || new Date(review.submitted_at) > new Date(previous.submitted_at)) {
|
||||
latest.set(login, review);
|
||||
}
|
||||
}
|
||||
return [...latest.entries()]
|
||||
.filter(([login, review]) =>
|
||||
maintainers.includes(login) &&
|
||||
review.state === 'APPROVED' &&
|
||||
review.commit_id === pr.head.sha)
|
||||
.map(([, review]) => review.user.login);
|
||||
}
|
||||
|
||||
// checkProposal finds the single open proposal for the version and returns
|
||||
// it with its target commit and maintainer approvals. It throws if there
|
||||
// isn't exactly one proposal or the maintainer list is empty, so callers
|
||||
// fail closed.
|
||||
async function checkProposal({ github, context, version, maintainerLogins }) {
|
||||
const maintainers = parseMaintainers(maintainerLogins);
|
||||
if (maintainers.length === 0) {
|
||||
throw new Error('MAINTAINER_LOGINS is empty; cannot count approvals');
|
||||
}
|
||||
const proposals = await findOpenProposals({ github, context, version });
|
||||
if (proposals.length !== 1) {
|
||||
throw new Error(`expected exactly one open PR titled "${proposalTitle(version)}" with the release-proposal label, found ${proposals.length}`);
|
||||
}
|
||||
const pr = proposals[0];
|
||||
const approvers = await maintainerApprovals({ github, context, pr, maintainers });
|
||||
return {
|
||||
pr,
|
||||
targetCommit: await targetCommit({ github, context, pr }),
|
||||
approvers,
|
||||
approved: approvers.length >= REQUIRED_APPROVALS,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REQUIRED_APPROVALS,
|
||||
checkProposal,
|
||||
findOpenProposals,
|
||||
proposalTitle,
|
||||
validateVersion,
|
||||
};
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
name: Release Proposal Approval Tracker
|
||||
|
||||
# pull_request_target (instead of pull_request) runs in the context of the
|
||||
# base branch, so runs for fork PRs don't sit waiting for workflow approval
|
||||
# and expire. That's safe here because these jobs never check out or run
|
||||
# code from the PR: the only checkout is of the base branch, for the shared
|
||||
# release proposal script.
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed]
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [labeled, unlabeled, synchronize, closed]
|
||||
|
||||
permissions:
|
||||
|
|
@ -15,76 +20,59 @@ 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'
|
||||
|
||||
# Only run on open release proposals, which are created by the Release
|
||||
# Proposal workflow from a branch in this repository
|
||||
if: |
|
||||
github.repository == 'caddyserver/caddy' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'release-proposal') &&
|
||||
github.event.pull_request.state == 'open' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- name: Checkout release scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/scripts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check approvals and update PR
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
const { checkProposal, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
|
||||
const payloadPR = 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');
|
||||
const versionMatch = payloadPR.title.match(/^Release Proposal: (v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)$/);
|
||||
if (!versionMatch) {
|
||||
console.log(`Not a release proposal title: ${payloadPR.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const version = versionMatch[1];
|
||||
const targetCommit = commitMatch[1];
|
||||
|
||||
|
||||
// Same checks as the release workflow runs when the tag is pushed
|
||||
const { pr, targetCommit, approvers, approved } = await checkProposal({
|
||||
github, context, version, maintainerLogins: process.env.MAINTAINER_LOGINS,
|
||||
});
|
||||
if (pr.number !== payloadPR.number) {
|
||||
core.setFailed(`PR #${payloadPR.number} is not the open proposal for ${version} (#${pr.number} is)`);
|
||||
return;
|
||||
}
|
||||
|
||||
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(', ')}`);
|
||||
|
||||
console.log(`Found ${approvers.length} maintainer approvals of ${pr.head.sha} from: ${approvers.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) {
|
||||
|
||||
if (approved && !hasApprovedLabel) {
|
||||
console.log('✅ Quorum reached! Updating PR...');
|
||||
|
||||
|
||||
// Remove awaiting-approval label if present
|
||||
if (hasAwaitingApprovalLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
|
|
@ -94,7 +82,7 @@ jobs:
|
|||
name: 'awaiting-approval'
|
||||
}).catch(e => console.log('Label not found:', e.message));
|
||||
}
|
||||
|
||||
|
||||
// Add approved label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
|
@ -102,13 +90,13 @@ jobs:
|
|||
issue_number: pr.number,
|
||||
labels: ['approved']
|
||||
});
|
||||
|
||||
|
||||
// Add comment with tagging instructions
|
||||
const approversList = maintainerApprovals.map(u => `@${u}`).join(', ');
|
||||
const approversList = approvers.map(u => `@${u}`).join(', ');
|
||||
const commentBody = [
|
||||
'## ✅ Approval Quorum Reached',
|
||||
'',
|
||||
`This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`,
|
||||
`This release proposal has been approved by ${approvers.length} maintainers: ${approversList}`,
|
||||
'',
|
||||
'### Tagging Instructions',
|
||||
'',
|
||||
|
|
@ -123,18 +111,18 @@ jobs:
|
|||
'',
|
||||
'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) {
|
||||
} else if (!approved && hasApprovedLabel) {
|
||||
console.log('⚠️ Approval count dropped below quorum, removing approved label');
|
||||
|
||||
|
||||
// Remove approved label
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
|
|
@ -142,7 +130,7 @@ jobs:
|
|||
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({
|
||||
|
|
@ -153,14 +141,17 @@ jobs:
|
|||
});
|
||||
}
|
||||
} else {
|
||||
console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`);
|
||||
console.log(`⏳ Waiting for more approvals (${approvers.length}/${REQUIRED_APPROVALS} required)`);
|
||||
}
|
||||
|
||||
|
||||
handle-pr-closed:
|
||||
name: Handle PR Closed Without Tag
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'caddyserver/caddy' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'release-proposal') &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released')
|
||||
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -36,196 +36,199 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Inputs, step outputs and other untrusted text (commit subjects are
|
||||
# contributor-written PR titles) are only ever passed through env:, never
|
||||
# interpolated as expressions into run: or script: bodies.
|
||||
- name: Trim and validate inputs
|
||||
id: inputs
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_COMMIT_HASH: ${{ inputs.commit_hash }}
|
||||
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
|
||||
|
||||
VERSION=$(echo "$INPUT_VERSION" | xargs)
|
||||
COMMIT_HASH=$(echo "$INPUT_COMMIT_HASH" | xargs)
|
||||
|
||||
# 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"
|
||||
|
||||
# Resolve to the full SHA. This fails if the commit doesn't exist or
|
||||
# a short SHA is ambiguous, and from here on only the full SHA is used.
|
||||
if ! FULL_COMMIT_HASH=$(git rev-parse --verify --quiet "${COMMIT_HASH}^{commit}"); then
|
||||
echo "Error: Commit $COMMIT_HASH does not exist or is ambiguous"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "commit_hash=$FULL_COMMIT_HASH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if tag already exists
|
||||
env:
|
||||
VERSION: ${{ steps.inputs.outputs.version }}
|
||||
run: |
|
||||
if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then
|
||||
echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists"
|
||||
if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then
|
||||
echo "Error: Tag $VERSION already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for existing proposal PR
|
||||
id: check_existing
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
VERSION: ${{ steps.inputs.outputs.version }}
|
||||
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}`);
|
||||
const { findOpenProposals } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
|
||||
const version = process.env.VERSION;
|
||||
|
||||
const [existing] = await findOpenProposals({ github, context, version });
|
||||
if (existing) {
|
||||
const labels = existing.labels.map(label => label.name);
|
||||
if (labels.includes('released') || labels.includes('release-in-progress')) {
|
||||
core.setFailed(`A release for ${version} is already in progress or completed: ${existing.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.`);
|
||||
core.setFailed(`An open release proposal already exists for ${version}: ${existing.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...`);
|
||||
}
|
||||
console.log(`No open proposal found for ${version}, proceeding...`);
|
||||
|
||||
- name: Generate changelog and create branch
|
||||
id: setup
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.inputs.outputs.version }}
|
||||
COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
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)"
|
||||
|
||||
# Describe where the commit is relative to the branch this workflow
|
||||
# was run from. The commit may be on a maintenance branch (e.g. 2.11)
|
||||
# instead, in which case "behind HEAD" would be meaningless.
|
||||
if git merge-base --is-ancestor "$COMMIT_HASH" HEAD; then
|
||||
COMMITS_BEHIND=$(git rev-list --count "${COMMIT_HASH}..HEAD")
|
||||
if [ "$COMMITS_BEHIND" -eq 0 ]; then
|
||||
BEHIND_INFO="This is the latest commit on $REF_NAME"
|
||||
else
|
||||
BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind $REF_NAME**"
|
||||
fi
|
||||
else
|
||||
BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**"
|
||||
COMMIT_BRANCHES=$(git branch -r --contains "$COMMIT_HASH" --format='%(refname:lstrip=3)' | grep -v '^HEAD$' | paste -sd, - | sed 's/,/, /g')
|
||||
BEHIND_INFO="This commit is not on $REF_NAME; it is on: ${COMMIT_BRANCHES:-no branch}"
|
||||
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"
|
||||
echo "behind_info=$BEHIND_INFO" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Base the changelog on the previous *published* release by version,
|
||||
# not the last tag, and leave out commits that already shipped in it
|
||||
# as backports. See the scripts for details.
|
||||
LAST_RELEASE=$(.github/scripts/previous-release.sh "$VERSION")
|
||||
|
||||
if [ -z "$LAST_RELEASE" ]; then
|
||||
echo "No previous release 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")
|
||||
echo "Generating changelog since $LAST_RELEASE"
|
||||
CHANGELOG_SHAS=$(.github/scripts/changelog-commits.sh "$LAST_RELEASE" "$COMMIT_HASH")
|
||||
COMMITS=""
|
||||
if [ -n "$CHANGELOG_SHAS" ]; then
|
||||
COMMITS=$(git log --no-walk=unsorted --pretty=format:"- %s (%h)" --stdin <<< "$CHANGELOG_SHAS")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Store changelog for PR body
|
||||
CLEANSED_COMMITS=$(echo "$COMMITS" | sed 's/`/\\`/g')
|
||||
echo "changelog<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CLEANSED_COMMITS" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Create empty commit for the PR
|
||||
echo "last_release=${LAST_RELEASE:-the beginning}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The changelog goes to the next step through a file, never through
|
||||
# an expression, since commit subjects are untrusted.
|
||||
printf '%s\n' "$COMMITS" > "$RUNNER_TEMP/changelog.md"
|
||||
|
||||
# Create an empty commit for the PR. The Target-Commit trailer is the
|
||||
# source of truth for what was approved: the release workflow reads it
|
||||
# from the proposal's head commit, and only counts approvals of that
|
||||
# head commit, so it can't be changed after approval.
|
||||
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"
|
||||
|
||||
git commit --allow-empty -m "Release proposal for $VERSION" -m "Target-Commit: $COMMIT_HASH"
|
||||
|
||||
# Push the branch
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release proposal PR
|
||||
id: create_pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
VERSION: ${{ steps.inputs.outputs.version }}
|
||||
COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }}
|
||||
BRANCH_NAME: ${{ steps.setup.outputs.branch_name }}
|
||||
BEHIND_INFO: ${{ steps.setup.outputs.behind_info }}
|
||||
LAST_RELEASE: ${{ steps.setup.outputs.last_release }}
|
||||
with:
|
||||
script: |
|
||||
const changelog = `${{ steps.setup.outputs.changelog }}`;
|
||||
|
||||
const fs = require('fs');
|
||||
const { proposalTitle, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
|
||||
const { VERSION, COMMIT_HASH, BRANCH_NAME, BEHIND_INFO, LAST_RELEASE, RUNNER_TEMP } = process.env;
|
||||
const changelog = fs.readFileSync(`${RUNNER_TEMP}/changelog.md`, 'utf8').trimEnd();
|
||||
|
||||
const body = [
|
||||
`## Release Proposal: ${VERSION}`,
|
||||
'',
|
||||
`**Target Commit:** \`${COMMIT_HASH}\``,
|
||||
`**Requested by:** @${context.actor}`,
|
||||
`**Commit Status:** ${BEHIND_INFO}`,
|
||||
'',
|
||||
`This PR proposes creating release tag \`${VERSION}\` at commit \`${COMMIT_HASH}\`.`,
|
||||
'',
|
||||
'### Approval Process',
|
||||
'',
|
||||
`This PR requires **approval from ${REQUIRED_APPROVALS}+ maintainers** before the tag can be created.`,
|
||||
'Approvals only count for the PR\'s current head commit, whose `Target-Commit` trailer records the commit to tag.',
|
||||
'',
|
||||
'### What happens next?',
|
||||
'',
|
||||
'1. Maintainers review this proposal',
|
||||
`2. When ${REQUIRED_APPROVALS}+ 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, and checks the approvals again',
|
||||
'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',
|
||||
'- [ ] Release notes drafted',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'**Note:** Tag creation is manual and requires a signed tag from a maintainer.',
|
||||
].join('\n');
|
||||
|
||||
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 }}',
|
||||
title: proposalTitle(VERSION),
|
||||
head: 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
|
||||
- [ ] Release notes drafted
|
||||
|
||||
---
|
||||
|
||||
**Note:** Tag creation is manual and requires a signed tag from a maintainer.`,
|
||||
body,
|
||||
draft: true
|
||||
});
|
||||
|
||||
|
||||
// Add labels
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
|
@ -233,18 +236,25 @@ jobs:
|
|||
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
|
||||
env:
|
||||
VERSION: ${{ steps.inputs.outputs.version }}
|
||||
COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }}
|
||||
BEHIND_INFO: ${{ steps.setup.outputs.behind_info }}
|
||||
PR_URL: ${{ fromJson(steps.create_pr.outputs.result).url }}
|
||||
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
|
||||
{
|
||||
echo "## Release Proposal PR Created! 🚀"
|
||||
echo ""
|
||||
echo "Version: **$VERSION**"
|
||||
echo "Commit: **$COMMIT_HASH**"
|
||||
echo "Status: $BEHIND_INFO"
|
||||
echo ""
|
||||
echo "PR: $PR_URL"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
|
|
|||
|
|
@ -20,13 +20,26 @@ jobs:
|
|||
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 || '' }}
|
||||
|
||||
|
||||
# Values from the event, step outputs and API results are only ever
|
||||
# passed through env:, never interpolated as expressions into run: or
|
||||
# script: bodies.
|
||||
steps:
|
||||
# Validate the tag name before anything else uses it. Git allows
|
||||
# characters like $, ( and ; in tag names that match the v*.*.* trigger,
|
||||
# and the tag name ends up in shell commands (including GoReleaser hooks).
|
||||
- name: Validate tag name
|
||||
run: |
|
||||
if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
|
|
@ -45,8 +58,8 @@ jobs:
|
|||
- name: Get tag info
|
||||
id: info
|
||||
run: |
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
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
|
||||
|
|
@ -80,61 +93,62 @@ jobs:
|
|||
id: verify
|
||||
env:
|
||||
signing_keys: ${{ secrets.SIGNING_KEYS }}
|
||||
TAG: ${{ steps.vars.outputs.version_tag }}
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
git config set --global gpg.ssh.allowedSignersFile "$RUNNER_TEMP/allowed_signers"
|
||||
|
||||
echo "Verifying the tag: $TAG"
|
||||
|
||||
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
|
||||
if ! git verify-tag -v "$TAG" 2>&1; then
|
||||
echo "❌ Tag verification failed!"
|
||||
echo "passed=false" >> $GITHUB_OUTPUT
|
||||
git push --delete origin "${{ steps.vars.outputs.version_tag }}"
|
||||
git push --delete origin "$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;
|
||||
git verify-tag -v "$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 }}"
|
||||
git push --delete origin "$TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -142,189 +156,136 @@ jobs:
|
|||
echo "passed=true" >> $GITHUB_OUTPUT
|
||||
echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT
|
||||
|
||||
# Fails closed: there must be exactly one open proposal for this exact
|
||||
# version, with enough maintainer approvals of its current head commit.
|
||||
# The tag is left in place on failure, so the job can be re-run once
|
||||
# the proposal is approved.
|
||||
- name: Find related release proposal
|
||||
id: find_proposal
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
TAG: ${{ steps.vars.outputs.version_tag }}
|
||||
MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
|
||||
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'
|
||||
const { checkProposal, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
|
||||
|
||||
const { pr, targetCommit, approvers, approved } = await checkProposal({
|
||||
github, context, version: process.env.TAG, maintainerLogins: process.env.MAINTAINER_LOGINS,
|
||||
});
|
||||
|
||||
// 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 #${pr.number}, for commit ${targetCommit}`);
|
||||
console.log(`Maintainer approvals of ${pr.head.sha}: ${approvers.join(', ') || 'none'}`);
|
||||
|
||||
if (!approved) {
|
||||
core.setFailed(`Proposal #${pr.number} has ${approvers.length} maintainer approvals of its current head commit; ${REQUIRED_APPROVALS} are required`);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
number: pr.number,
|
||||
approvals: approvers.length,
|
||||
approvers: approvers.map(login => `@${login}`).join(', '),
|
||||
proposedCommit: targetCommit
|
||||
};
|
||||
result-encoding: json
|
||||
|
||||
- name: Verify proposal commit
|
||||
env:
|
||||
PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }}
|
||||
CURRENT_COMMIT: ${{ steps.info.outputs.sha }}
|
||||
TAG: ${{ steps.vars.outputs.version_tag }}
|
||||
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)"
|
||||
|
||||
# Both are full SHAs, so compare them exactly
|
||||
if [[ ! "$PROPOSED_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "❌ Proposal has no valid target commit: $PROPOSED_COMMIT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Tag verification completed"
|
||||
if [ "$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 an error in tag creation."
|
||||
# Delete the tag remotely
|
||||
git push --delete origin "$TAG"
|
||||
echo "Tag $TAG has been deleted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Commit hash matches proposal"
|
||||
|
||||
- name: Update release proposal PR
|
||||
if: fromJson(steps.find_proposal.outputs.result).number != null
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
RESULT: ${{ steps.find_proposal.outputs.result }}
|
||||
TAG: ${{ steps.info.outputs.version }}
|
||||
KEY_ID: ${{ steps.verify.outputs.key_id }}
|
||||
SHA: ${{ steps.info.outputs.sha }}
|
||||
with:
|
||||
script: |
|
||||
const result = ${{ steps.find_proposal.outputs.result }};
|
||||
|
||||
if (result.number) {
|
||||
// Add in-progress label
|
||||
await github.rest.issues.addLabels({
|
||||
const result = JSON.parse(process.env.RESULT);
|
||||
|
||||
// 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,
|
||||
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: 'approved'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Approved label not found:', e.message);
|
||||
}
|
||||
|
||||
const commentBody = [
|
||||
'## 🚀 Release Workflow Started',
|
||||
'',
|
||||
`- **Tag:** ${process.env.TAG}`,
|
||||
`- **Signed by key:** ${process.env.KEY_ID}`,
|
||||
`- **Commit:** ${process.env.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
|
||||
env:
|
||||
PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }}
|
||||
APPROVERS: ${{ fromJson(steps.find_proposal.outputs.result).approvers }}
|
||||
TAG: ${{ steps.info.outputs.version }}
|
||||
SHA: ${{ steps.info.outputs.sha }}
|
||||
KEY_ID: ${{ steps.verify.outputs.key_id }}
|
||||
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
|
||||
|
||||
{
|
||||
echo "## Tag Verification Summary 🔐"
|
||||
echo ""
|
||||
echo "- **Tag:** $TAG"
|
||||
echo "- **Commit:** $SHA"
|
||||
echo "- **Proposed Commit:** $PROPOSED_COMMIT"
|
||||
echo "- **Signature:** ✅ Verified"
|
||||
echo "- **Signed by:** $KEY_ID"
|
||||
echo "- **Approvals:** ✅ Sufficient"
|
||||
echo "- **Approved by:** $APPROVERS"
|
||||
echo ""
|
||||
echo "Proceeding with release build..."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: verify-tag
|
||||
|
|
@ -354,6 +315,15 @@ jobs:
|
|||
pull-requests: write
|
||||
|
||||
steps:
|
||||
# Same check as in verify-tag: the tag name ends up in GoReleaser's
|
||||
# shell hooks via $TAG.
|
||||
- name: Validate tag name
|
||||
run: |
|
||||
if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
|
||||
with:
|
||||
|
|
@ -426,12 +396,28 @@ jobs:
|
|||
run: |
|
||||
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
|
||||
xcaddy version
|
||||
# Generate the draft's notes in the same format as GitHub's "Generate
|
||||
# release notes" button, compared against the previous *published*
|
||||
# release by version (not the previous tag, which may be an abandoned
|
||||
# release attempt), and without PRs that already shipped in it as
|
||||
# backports. GoReleaser's own changelog can do neither.
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.vars.outputs.version_tag }}
|
||||
NOTES_FILE: ${{ runner.temp }}/release-notes.md
|
||||
run: |
|
||||
PREVIOUS_RELEASE=$(.github/scripts/previous-release.sh "$TAG")
|
||||
echo "Previous published release: ${PREVIOUS_RELEASE:-none}"
|
||||
.github/scripts/release-notes.sh "$TAG" "$TAG" "$PREVIOUS_RELEASE" > "$NOTES_FILE"
|
||||
cat "$NOTES_FILE"
|
||||
|
||||
# GoReleaser will take care of publishing those artifacts into the release
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean --timeout 60m
|
||||
args: release --clean --timeout 60m --release-notes ${{ runner.temp }}/release-notes.md
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.vars.outputs.version_tag }}
|
||||
|
|
@ -497,9 +483,11 @@ jobs:
|
|||
- name: Update release proposal PR
|
||||
if: needs.verify-tag.outputs.proposal_issue_number != ''
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PROPOSAL_NUMBER: ${{ needs.verify-tag.outputs.proposal_issue_number }}
|
||||
with:
|
||||
script: |
|
||||
const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}');
|
||||
const prNumber = parseInt(process.env.PROPOSAL_NUMBER, 10);
|
||||
|
||||
if (prNumber) {
|
||||
// Get PR details to find the branch
|
||||
|
|
|
|||
|
|
@ -203,6 +203,8 @@ release:
|
|||
draft: true
|
||||
prerelease: auto
|
||||
|
||||
# Only used for local runs: the release workflow passes --release-notes
|
||||
# generated by .github/scripts/release-notes.sh, which skips this.
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
|
|
|
|||
Loading…
Reference in New Issue