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
Francis Lavoie 2026-11-03 18:07:13 -05:00 committed by GitHub
parent bbea66f2ec
commit 468154121d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 636 additions and 380 deletions

View File

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

View File

@ -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 }'

View File

@ -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"

View File

@ -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,
};

View File

@ -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:

View File

@ -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"

View File

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

View File

@ -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: