@marcel91200 / caddy Mirror · read-only / commits / 46815412
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

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>

Francis Lavoie committed Nov 3, 2026 at 18:07 UTC 468154121df3c5e5cc56dae45edcf70037761894
8 files changed +636 -380
.github/scripts/changelog-commits.sh new
+60
@@ -0,0 +1,60 @@
1 +#!/usr/bin/env bash
2 +# Prints the commits that are new in <target> compared to the release <base>,
3 +# oldest first, one SHA per line.
4 +#
5 +# Usage: changelog-commits.sh <base> <target>
6 +#
7 +# When <base> is an ancestor of <target>, that's simply base..target. When it
8 +# isn't (e.g. base is a 2.11.x release cut from a maintenance branch and
9 +# target is on master), base..target also contains the master commits whose
10 +# fixes were already cherry-picked into the base release. Those are skipped
11 +# when any of the following holds:
12 +#
13 +# 1. The commit has the same patch ID as a commit on the base side
14 +# (a clean cherry-pick).
15 +# 2. Cherry-picking it onto the base would change nothing, but
16 +# cherry-picking it onto the merge base would. That is, the base
17 +# already has the change, and it got there after the branches split.
18 +# This catches clean picks whose lines were changed again later. The
19 +# merge-base condition keeps a revert of something the base never had
20 +# (which would also change nothing) in the list.
21 +# 3. Its subject ends with a PR number, e.g. "(#1234)", and a commit on
22 +# the base side has the identical subject. This catches backports that
23 +# had to be adapted to apply. Subjects without a PR number are never
24 +# matched this way: unrelated commits can share one (for example
25 +# GitHub's "Merge commit from fork" for security advisories).
26 +set -euo pipefail
27 +
28 +base=$(git rev-parse --verify "${1:?usage: $0 <base> <target>}^{commit}")
29 +target=$(git rev-parse --verify "${2:?usage: $0 <base> <target>}^{commit}")
30 +
31 +if git merge-base --is-ancestor "$base" "$target"; then
32 + git rev-list --reverse "$base..$target"
33 + exit 0
34 +fi
35 +
36 +merge_base=$(git merge-base "$base" "$target")
37 +base_tree=$(git rev-parse "$base^{tree}")
38 +merge_base_tree=$(git rev-parse "$merge_base^{tree}")
39 +backported_subjects=$(git log --format=%s "$merge_base..$base" | grep -E '\(#[0-9]+\)$' || true)
40 +
41 +# picking_changes_nothing <commit> <onto> <onto-tree>
42 +picking_changes_nothing() {
43 + local result
44 + if ! result=$(git merge-tree --write-tree --merge-base="$1^" "$2" "$1" 2>/dev/null); then
45 + return 1 # conflicts, so it would change something
46 + fi
47 + [ "$(head -n 1 <<< "$result")" = "$3" ]
48 +}
49 +
50 +for commit in $(git rev-list --reverse --cherry-pick --right-only "$base...$target"); do
51 + subject=$(git log -1 --format=%s "$commit")
52 + if [[ $subject =~ \(#[0-9]+\)$ ]] && grep -qxF -- "$subject" <<< "$backported_subjects"; then
53 + continue
54 + fi
55 + if picking_changes_nothing "$commit" "$base" "$base_tree" &&
56 + ! picking_changes_nothing "$commit" "$merge_base" "$merge_base_tree"; then
57 + continue
58 + fi
59 + echo "$commit"
60 +done
.github/scripts/previous-release.sh new
+41
@@ -0,0 +1,41 @@
1 +#!/usr/bin/env bash
2 +# Prints the tag of the highest published (non-draft) GitHub release whose
3 +# version is lower than the given one, or nothing if there is none.
4 +#
5 +# Usage: previous-release.sh <version>
6 +#
7 +# Release changelogs should cover everything since the last release users
8 +# actually received. "The last tag" is not good enough:
9 +#
10 +# - When a release attempt is abandoned after tagging, the Go module proxy
11 +# has usually cached the tag, so it can't be deleted and reused, and the
12 +# next attempt gets a new patch number. Those abandoned tags never get a
13 +# published release, so they are skipped here.
14 +# - Releases can be cut from maintenance branches (e.g. 2.11), so the
15 +# previous release isn't necessarily an ancestor of the new one. Picking
16 +# by version means 2.12.0 compares against the latest 2.11.x, and a 2.11.x
17 +# released after 2.12.0 compares against the previous 2.11.x.
18 +#
19 +# Requires GH_TOKEN and GITHUB_REPOSITORY in the environment.
20 +set -euo pipefail
21 +
22 +version=${1:?usage: $0 <version>}
23 +
24 +# sort -V orders "2.12.0-beta.1" after "2.12.0", but with the prerelease
25 +# separator replaced by "~" (which sorts before everything, even the end of
26 +# the string) it orders like semver.
27 +sort_key() {
28 + printf '%s' "${1#v}" | sed 's/-/~/'
29 +}
30 +
31 +tags=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" --jq '.[] | select(.draft | not) | .tag_name')
32 +
33 +{
34 + # The new version itself, marked by an empty tag column.
35 + printf '%s\t\n' "$(sort_key "$version")"
36 + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' <<< "$tags" | while IFS= read -r tag; do
37 + if [ "$tag" != "$version" ]; then
38 + printf '%s\t%s\n' "$(sort_key "$tag")" "$tag"
39 + fi
40 + done
41 +} | sort -t $'\t' -k1,1V | awk -F '\t' '$2 == "" { print prev; exit } { prev = $2 }'
.github/scripts/release-notes.sh new
+43
@@ -0,0 +1,43 @@
1 +#!/usr/bin/env bash
2 +# Prints GitHub's generated release notes for <tag> (What's Changed, New
3 +# Contributors, Full Changelog), compared against <previous-tag>, without
4 +# the PRs that already shipped in <previous-tag>.
5 +#
6 +# Usage: release-notes.sh <tag> <target-commit> [previous-tag]
7 +#
8 +# GitHub's generator lists every PR in previous...tag. When the previous
9 +# release was cut from a maintenance branch, that includes the master PRs
10 +# whose fixes were cherry-picked into it, so those are removed here, using
11 +# the same rules as changelog-commits.sh. Only lines ending in a PR link can
12 +# be removed; anything else is left alone.
13 +#
14 +# Requires GH_TOKEN and GITHUB_REPOSITORY in the environment.
15 +set -euo pipefail
16 +
17 +tag=${1:?usage: $0 <tag> <target-commit> [previous-tag]}
18 +target=${2:?usage: $0 <tag> <target-commit> [previous-tag]}
19 +previous=${3:-}
20 +
21 +args=(-f "tag_name=$tag" -f "target_commitish=$(git rev-parse --verify "$target^{commit}")")
22 +if [ -n "$previous" ]; then
23 + args+=(-f "previous_tag_name=$previous")
24 +fi
25 +notes=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" "${args[@]}" --jq .body)
26 +
27 +if [ -z "$previous" ]; then
28 + printf '%s\n' "$notes"
29 + exit 0
30 +fi
31 +
32 +scripts=$(dirname "$0")
33 +kept=$("$scripts/changelog-commits.sh" "$previous" "$target")
34 +skipped_prs=$(git rev-list "$previous..$target" | { grep -vxF -f <(printf '%s\n' "$kept") || true; } |
35 + xargs -r git log --no-walk --format=%s | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p' | sort -u)
36 +
37 +if [ -z "$skipped_prs" ]; then
38 + printf '%s\n' "$notes"
39 + exit 0
40 +fi
41 +
42 +pattern="/pull/($(paste -sd '|' <<< "$skipped_prs"))\$"
43 +printf '%s\n' "$notes" | tr -d '\r' | grep -vE -- "$pattern"
.github/scripts/release-proposal.js new
+121
@@ -0,0 +1,121 @@
1 +// Shared release proposal logic for the release workflows, loaded from
2 +// actions/github-script with require(). Keeping it in one place makes the
3 +// approval tracker and the release job agree on what an approved proposal is.
4 +//
5 +// A proposal is a draft PR labelled "release-proposal", titled exactly
6 +// "Release Proposal: <version>", whose head commit message carries a
7 +// "Target-Commit: <full sha>" trailer. The trailer is written by the
8 +// Release Proposal workflow; unlike the PR body, it can't be changed
9 +// without pushing a new commit, which makes earlier approvals stale.
10 +'use strict';
11 +
12 +const VERSION_RE = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
13 +const TARGET_COMMIT_RE = /^Target-Commit: ([0-9a-f]{40})$/m;
14 +const REQUIRED_APPROVALS = 2;
15 +
16 +function proposalTitle(version) {
17 + return `Release Proposal: ${version}`;
18 +}
19 +
20 +function validateVersion(version) {
21 + if (!VERSION_RE.test(version)) {
22 + throw new Error(`invalid release version ${JSON.stringify(version)}`);
23 + }
24 +}
25 +
26 +// parseMaintainers splits the MAINTAINER_LOGINS secret (comma or semicolon
27 +// separated) into lowercased logins. GitHub logins are case-insensitive.
28 +function parseMaintainers(raw) {
29 + return (raw || '')
30 + .split(/[,;]/)
31 + .map(login => login.trim().toLowerCase())
32 + .filter(login => login.length > 0);
33 +}
34 +
35 +// findOpenProposals returns every open release proposal for the version.
36 +async function findOpenProposals({ github, context, version }) {
37 + validateVersion(version);
38 + const prs = await github.paginate(github.rest.pulls.list, {
39 + owner: context.repo.owner,
40 + repo: context.repo.repo,
41 + state: 'open',
42 + per_page: 100,
43 + });
44 + return prs.filter(pr =>
45 + pr.title === proposalTitle(version) &&
46 + pr.labels.some(label => label.name === 'release-proposal'));
47 +}
48 +
49 +// targetCommit reads the Target-Commit trailer from the proposal's head commit.
50 +async function targetCommit({ github, context, pr }) {
51 + const { data: commit } = await github.rest.git.getCommit({
52 + owner: context.repo.owner,
53 + repo: context.repo.repo,
54 + commit_sha: pr.head.sha,
55 + });
56 + const match = commit.message.match(TARGET_COMMIT_RE);
57 + if (!match) {
58 + throw new Error(`proposal #${pr.number} head commit ${pr.head.sha} has no Target-Commit trailer`);
59 + }
60 + return match[1];
61 +}
62 +
63 +// maintainerApprovals returns the maintainers whose latest review approves
64 +// the proposal's current head commit. Plain comments don't change a
65 +// reviewer's verdict, and approvals of an earlier head commit don't count.
66 +async function maintainerApprovals({ github, context, pr, maintainers }) {
67 + const reviews = await github.paginate(github.rest.pulls.listReviews, {
68 + owner: context.repo.owner,
69 + repo: context.repo.repo,
70 + pull_number: pr.number,
71 + per_page: 100,
72 + });
73 + const latest = new Map();
74 + for (const review of reviews) {
75 + if (!review.user || !['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) {
76 + continue;
77 + }
78 + const login = review.user.login.toLowerCase();
79 + const previous = latest.get(login);
80 + if (!previous || new Date(review.submitted_at) > new Date(previous.submitted_at)) {
81 + latest.set(login, review);
82 + }
83 + }
84 + return [...latest.entries()]
85 + .filter(([login, review]) =>
86 + maintainers.includes(login) &&
87 + review.state === 'APPROVED' &&
88 + review.commit_id === pr.head.sha)
89 + .map(([, review]) => review.user.login);
90 +}
91 +
92 +// checkProposal finds the single open proposal for the version and returns
93 +// it with its target commit and maintainer approvals. It throws if there
94 +// isn't exactly one proposal or the maintainer list is empty, so callers
95 +// fail closed.
96 +async function checkProposal({ github, context, version, maintainerLogins }) {
97 + const maintainers = parseMaintainers(maintainerLogins);
98 + if (maintainers.length === 0) {
99 + throw new Error('MAINTAINER_LOGINS is empty; cannot count approvals');
100 + }
101 + const proposals = await findOpenProposals({ github, context, version });
102 + if (proposals.length !== 1) {
103 + throw new Error(`expected exactly one open PR titled "${proposalTitle(version)}" with the release-proposal label, found ${proposals.length}`);
104 + }
105 + const pr = proposals[0];
106 + const approvers = await maintainerApprovals({ github, context, pr, maintainers });
107 + return {
108 + pr,
109 + targetCommit: await targetCommit({ github, context, pr }),
110 + approvers,
111 + approved: approvers.length >= REQUIRED_APPROVALS,
112 + };
113 +}
114 +
115 +module.exports = {
116 + REQUIRED_APPROVALS,
117 + checkProposal,
118 + findOpenProposals,
119 + proposalTitle,
120 + validateVersion,
121 +};
.github/workflows/auto-release-pr.yml
+55 -64
@@ -1,9 +1,14 @@
1 name: Release Proposal Approval Tracker
2
3 +# pull_request_target (instead of pull_request) runs in the context of the
4 +# base branch, so runs for fork PRs don't sit waiting for workflow approval
5 +# and expire. That's safe here because these jobs never check out or run
6 +# code from the PR: the only checkout is of the base branch, for the shared
7 +# release proposal script.
8 on:
9 pull_request_review:
10 types: [submitted, dismissed]
6 - pull_request:
11 + pull_request_target:
12 types: [labeled, unlabeled, synchronize, closed]
13
14 permissions:
@@ -15,76 +20,59 @@ jobs:
20 check-approvals:
21 name: Track Maintainer Approvals
22 runs-on: ubuntu-latest
18 - # Only run on PRs with release-proposal label
19 - if: contains(github.event.pull_request.labels.*.name, 'release-proposal') && github.event.pull_request.state == 'open'
20 -
23 + # Only run on open release proposals, which are created by the Release
24 + # Proposal workflow from a branch in this repository
25 + if: |
26 + github.repository == 'caddyserver/caddy' &&
27 + contains(github.event.pull_request.labels.*.name, 'release-proposal') &&
28 + github.event.pull_request.state == 'open' &&
29 + github.event.pull_request.head.repo.full_name == github.repository
30 +
31 steps:
32 + - name: Checkout release scripts
33 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
34 + with:
35 + ref: ${{ github.event.repository.default_branch }}
36 + sparse-checkout: .github/scripts
37 + persist-credentials: false
38 +
39 - name: Check approvals and update PR
40 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
41 env:
42 MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
43 with:
44 script: |
28 - const pr = context.payload.pull_request;
29 -
45 + const { checkProposal, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
46 + const payloadPR = context.payload.pull_request;
47 +
48 // Extract version from PR title (e.g., "Release Proposal: v1.2.3")
31 - const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/);
32 - const commitMatch = pr.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/);
33 -
34 - if (!versionMatch || !commitMatch) {
35 - console.log('Could not extract version from title or commit from body');
49 + const versionMatch = payloadPR.title.match(/^Release Proposal: (v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)$/);
50 + if (!versionMatch) {
51 + console.log(`Not a release proposal title: ${payloadPR.title}`);
52 return;
53 }
38 -
54 const version = versionMatch[1];
40 - const targetCommit = commitMatch[1];
41 -
42 - console.log(`Version: ${version}, Target Commit: ${targetCommit}`);
43 -
44 - // Get all reviews
45 - const reviews = await github.rest.pulls.listReviews({
46 - owner: context.repo.owner,
47 - repo: context.repo.repo,
48 - pull_number: pr.number
49 - });
50 -
51 - // Get list of maintainers
52 - const maintainerLoginsRaw = process.env.MAINTAINER_LOGINS || '';
53 - const maintainerLogins = maintainerLoginsRaw
54 - .split(/[,;]/)
55 - .map(login => login.trim())
56 - .filter(login => login.length > 0);
57 -
58 - console.log(`Maintainer logins: ${maintainerLogins.join(', ')}`);
59 -
60 - // Get the latest review from each user
61 - const latestReviewsByUser = {};
62 - reviews.data.forEach(review => {
63 - const username = review.user.login;
64 - if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) {
65 - latestReviewsByUser[username] = review;
66 - }
55 +
56 + // Same checks as the release workflow runs when the tag is pushed
57 + const { pr, targetCommit, approvers, approved } = await checkProposal({
58 + github, context, version, maintainerLogins: process.env.MAINTAINER_LOGINS,
59 });
68 -
69 - // Count approvals from maintainers
70 - const maintainerApprovals = Object.entries(latestReviewsByUser)
71 - .filter(([username, review]) =>
72 - maintainerLogins.includes(username) &&
73 - review.state === 'APPROVED'
74 - )
75 - .map(([username, review]) => username);
76 -
77 - const approvalCount = maintainerApprovals.length;
78 - console.log(`Found ${approvalCount} maintainer approvals from: ${maintainerApprovals.join(', ')}`);
79 -
60 + if (pr.number !== payloadPR.number) {
61 + core.setFailed(`PR #${payloadPR.number} is not the open proposal for ${version} (#${pr.number} is)`);
62 + return;
63 + }
64 +
65 + console.log(`Version: ${version}, Target Commit: ${targetCommit}`);
66 + console.log(`Found ${approvers.length} maintainer approvals of ${pr.head.sha} from: ${approvers.join(', ')}`);
67 +
68 // Get current labels
69 const currentLabels = pr.labels.map(label => label.name);
70 const hasApprovedLabel = currentLabels.includes('approved');
71 const hasAwaitingApprovalLabel = currentLabels.includes('awaiting-approval');
84 -
85 - if (approvalCount >= 2 && !hasApprovedLabel) {
72 +
73 + if (approved && !hasApprovedLabel) {
74 console.log('✅ Quorum reached! Updating PR...');
87 -
75 +
76 // Remove awaiting-approval label if present
77 if (hasAwaitingApprovalLabel) {
78 await github.rest.issues.removeLabel({
@@ -94,7 +82,7 @@ jobs:
82 name: 'awaiting-approval'
83 }).catch(e => console.log('Label not found:', e.message));
84 }
97 -
85 +
86 // Add approved label
87 await github.rest.issues.addLabels({
88 owner: context.repo.owner,
@@ -102,13 +90,13 @@ jobs:
90 issue_number: pr.number,
91 labels: ['approved']
92 });
105 -
93 +
94 // Add comment with tagging instructions
107 - const approversList = maintainerApprovals.map(u => `@${u}`).join(', ');
95 + const approversList = approvers.map(u => `@${u}`).join(', ');
96 const commentBody = [
97 '## ✅ Approval Quorum Reached',
98 '',
111 - `This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`,
99 + `This release proposal has been approved by ${approvers.length} maintainers: ${approversList}`,
100 '',
101 '### Tagging Instructions',
102 '',
@@ -123,18 +111,18 @@ jobs:
111 '',
112 'The release workflow will automatically start when the tag is pushed.'
113 ].join('\n');
126 -
114 +
115 await github.rest.issues.createComment({
116 owner: context.repo.owner,
117 repo: context.repo.repo,
118 issue_number: pr.number,
119 body: commentBody
120 });
133 -
121 +
122 console.log('Posted tagging instructions');
135 - } else if (approvalCount < 2 && hasApprovedLabel) {
123 + } else if (!approved && hasApprovedLabel) {
124 console.log('⚠️ Approval count dropped below quorum, removing approved label');
137 -
125 +
126 // Remove approved label
127 await github.rest.issues.removeLabel({
128 owner: context.repo.owner,
@@ -142,7 +130,7 @@ jobs:
130 issue_number: pr.number,
131 name: 'approved'
132 }).catch(e => console.log('Label not found:', e.message));
145 -
133 +
134 // Add awaiting-approval label
135 if (!hasAwaitingApprovalLabel) {
136 await github.rest.issues.addLabels({
@@ -153,14 +141,17 @@ jobs:
141 });
142 }
143 } else {
156 - console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`);
144 + console.log(`⏳ Waiting for more approvals (${approvers.length}/${REQUIRED_APPROVALS} required)`);
145 }
146
147 +
148 handle-pr-closed:
149 name: Handle PR Closed Without Tag
150 runs-on: ubuntu-latest
151 if: |
152 + github.repository == 'caddyserver/caddy' &&
153 contains(github.event.pull_request.labels.*.name, 'release-proposal') &&
154 + github.event.pull_request.head.repo.full_name == github.repository &&
155 github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released')
156
157 steps:
.github/workflows/release-proposal.yml
+151 -141
@@ -36,196 +36,199 @@ jobs:
36 with:
37 fetch-depth: 0
38
39 + # Inputs, step outputs and other untrusted text (commit subjects are
40 + # contributor-written PR titles) are only ever passed through env:, never
41 + # interpolated as expressions into run: or script: bodies.
42 - name: Trim and validate inputs
43 id: inputs
44 + env:
45 + INPUT_VERSION: ${{ inputs.version }}
46 + INPUT_COMMIT_HASH: ${{ inputs.commit_hash }}
47 run: |
48 # Trim whitespace from inputs
43 - VERSION=$(echo "${{ inputs.version }}" | xargs)
44 - COMMIT_HASH=$(echo "${{ inputs.commit_hash }}" | xargs)
45 -
46 - echo "version=$VERSION" >> $GITHUB_OUTPUT
47 - echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT
48 -
49 + VERSION=$(echo "$INPUT_VERSION" | xargs)
50 + COMMIT_HASH=$(echo "$INPUT_COMMIT_HASH" | xargs)
51 +
52 # Validate version format
53 if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
54 echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)"
55 exit 1
56 fi
54 -
57 +
58 # Validate commit hash format
59 if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then
60 echo "Error: Commit hash must be a valid SHA (7-40 characters)"
61 exit 1
62 fi
60 -
61 - # Check if commit exists
62 - if ! git cat-file -e "$COMMIT_HASH"; then
63 - echo "Error: Commit $COMMIT_HASH does not exist"
63 +
64 + # Resolve to the full SHA. This fails if the commit doesn't exist or
65 + # a short SHA is ambiguous, and from here on only the full SHA is used.
66 + if ! FULL_COMMIT_HASH=$(git rev-parse --verify --quiet "${COMMIT_HASH}^{commit}"); then
67 + echo "Error: Commit $COMMIT_HASH does not exist or is ambiguous"
68 exit 1
69 fi
70
71 + echo "version=$VERSION" >> "$GITHUB_OUTPUT"
72 + echo "commit_hash=$FULL_COMMIT_HASH" >> "$GITHUB_OUTPUT"
73 +
74 - name: Check if tag already exists
75 + env:
76 + VERSION: ${{ steps.inputs.outputs.version }}
77 run: |
69 - if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then
70 - echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists"
78 + if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then
79 + echo "Error: Tag $VERSION already exists"
80 exit 1
81 fi
82
83 - name: Check for existing proposal PR
75 - id: check_existing
84 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
85 + env:
86 + VERSION: ${{ steps.inputs.outputs.version }}
87 with:
88 script: |
79 - const version = '${{ steps.inputs.outputs.version }}';
80 -
81 - // Search for existing open PRs with release-proposal label that match this version
82 - const openPRs = await github.rest.pulls.list({
83 - owner: context.repo.owner,
84 - repo: context.repo.repo,
85 - state: 'open',
86 - sort: 'updated',
87 - direction: 'desc'
88 - });
89 -
90 - const existingOpenPR = openPRs.data.find(pr =>
91 - pr.title.includes(version) &&
92 - pr.labels.some(label => label.name === 'release-proposal')
93 - );
94 -
95 - if (existingOpenPR) {
96 - const hasReleased = existingOpenPR.labels.some(label => label.name === 'released');
97 - const hasReleaseInProgress = existingOpenPR.labels.some(label => label.name === 'release-in-progress');
98 -
99 - if (hasReleased || hasReleaseInProgress) {
100 - core.setFailed(`A release for ${version} is already in progress or completed: ${existingOpenPR.html_url}`);
89 + const { findOpenProposals } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
90 + const version = process.env.VERSION;
91 +
92 + const [existing] = await findOpenProposals({ github, context, version });
93 + if (existing) {
94 + const labels = existing.labels.map(label => label.name);
95 + if (labels.includes('released') || labels.includes('release-in-progress')) {
96 + core.setFailed(`A release for ${version} is already in progress or completed: ${existing.html_url}`);
97 } else {
102 - core.setFailed(`An open release proposal already exists for ${version}: ${existingOpenPR.html_url}\n\nPlease use the existing PR or close it first.`);
98 + core.setFailed(`An open release proposal already exists for ${version}: ${existing.html_url}\n\nPlease use the existing PR or close it first.`);
99 }
100 return;
101 }
106 -
107 - // Check for closed PRs with this version that were cancelled
108 - const closedPRs = await github.rest.pulls.list({
109 - owner: context.repo.owner,
110 - repo: context.repo.repo,
111 - state: 'closed',
112 - sort: 'updated',
113 - direction: 'desc'
114 - });
115 -
116 - const cancelledPR = closedPRs.data.find(pr =>
117 - pr.title.includes(version) &&
118 - pr.labels.some(label => label.name === 'release-proposal') &&
119 - pr.labels.some(label => label.name === 'cancelled')
120 - );
121 -
122 - if (cancelledPR) {
123 - console.log(`Found previously cancelled proposal for ${version}: ${cancelledPR.html_url}`);
124 - console.log('Creating new proposal to replace cancelled one...');
125 - } else {
126 - console.log(`No existing proposal found for ${version}, proceeding...`);
127 - }
102 + console.log(`No open proposal found for ${version}, proceeding...`);
103
104 - name: Generate changelog and create branch
105 id: setup
106 + env:
107 + GH_TOKEN: ${{ github.token }}
108 + VERSION: ${{ steps.inputs.outputs.version }}
109 + COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }}
110 + REF_NAME: ${{ github.ref_name }}
111 run: |
132 - VERSION="${{ steps.inputs.outputs.version }}"
133 - COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}"
134 -
112 # Create a new branch for the release proposal
113 BRANCH_NAME="release_proposal-$VERSION"
114 git checkout -b "$BRANCH_NAME"
138 -
139 - # Calculate how many commits behind HEAD
140 - COMMITS_BEHIND=$(git rev-list --count ${COMMIT_HASH}..HEAD)
141 -
142 - if [ "$COMMITS_BEHIND" -eq 0 ]; then
143 - BEHIND_INFO="This is the latest commit (HEAD)"
115 +
116 + # Describe where the commit is relative to the branch this workflow
117 + # was run from. The commit may be on a maintenance branch (e.g. 2.11)
118 + # instead, in which case "behind HEAD" would be meaningless.
119 + if git merge-base --is-ancestor "$COMMIT_HASH" HEAD; then
120 + COMMITS_BEHIND=$(git rev-list --count "${COMMIT_HASH}..HEAD")
121 + if [ "$COMMITS_BEHIND" -eq 0 ]; then
122 + BEHIND_INFO="This is the latest commit on $REF_NAME"
123 + else
124 + BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind $REF_NAME**"
125 + fi
126 else
145 - BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**"
127 + COMMIT_BRANCHES=$(git branch -r --contains "$COMMIT_HASH" --format='%(refname:lstrip=3)' | grep -v '^HEAD$' | paste -sd, - | sed 's/,/, /g')
128 + BEHIND_INFO="This commit is not on $REF_NAME; it is on: ${COMMIT_BRANCHES:-no branch}"
129 fi
147 -
148 - echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT
149 - echo "behind_info=$BEHIND_INFO" >> $GITHUB_OUTPUT
150 -
151 - # Get the last tag
152 - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
153 -
154 - if [ -z "$LAST_TAG" ]; then
155 - echo "No previous tag found, generating full changelog"
130 + echo "behind_info=$BEHIND_INFO" >> "$GITHUB_OUTPUT"
131 +
132 + # Base the changelog on the previous *published* release by version,
133 + # not the last tag, and leave out commits that already shipped in it
134 + # as backports. See the scripts for details.
135 + LAST_RELEASE=$(.github/scripts/previous-release.sh "$VERSION")
136 +
137 + if [ -z "$LAST_RELEASE" ]; then
138 + echo "No previous release found, generating full changelog"
139 COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH")
140 else
158 - echo "Generating changelog since $LAST_TAG"
159 - COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH")
141 + echo "Generating changelog since $LAST_RELEASE"
142 + CHANGELOG_SHAS=$(.github/scripts/changelog-commits.sh "$LAST_RELEASE" "$COMMIT_HASH")
143 + COMMITS=""
144 + if [ -n "$CHANGELOG_SHAS" ]; then
145 + COMMITS=$(git log --no-walk=unsorted --pretty=format:"- %s (%h)" --stdin <<< "$CHANGELOG_SHAS")
146 + fi
147 fi
161 -
162 - # Store changelog for PR body
163 - CLEANSED_COMMITS=$(echo "$COMMITS" | sed 's/`/\\`/g')
164 - echo "changelog<<EOF" >> $GITHUB_OUTPUT
165 - echo "$CLEANSED_COMMITS" >> $GITHUB_OUTPUT
166 - echo "EOF" >> $GITHUB_OUTPUT
167 -
168 - # Create empty commit for the PR
148 + echo "last_release=${LAST_RELEASE:-the beginning}" >> "$GITHUB_OUTPUT"
149 +
150 + # The changelog goes to the next step through a file, never through
151 + # an expression, since commit subjects are untrusted.
152 + printf '%s\n' "$COMMITS" > "$RUNNER_TEMP/changelog.md"
153 +
154 + # Create an empty commit for the PR. The Target-Commit trailer is the
155 + # source of truth for what was approved: the release workflow reads it
156 + # from the proposal's head commit, and only counts approvals of that
157 + # head commit, so it can't be changed after approval.
158 git config user.name "github-actions[bot]"
159 git config user.email "github-actions[bot]@users.noreply.github.com"
171 - git commit --allow-empty -m "Release proposal for $VERSION"
172 -
160 + git commit --allow-empty -m "Release proposal for $VERSION" -m "Target-Commit: $COMMIT_HASH"
161 +
162 # Push the branch
163 git push origin "$BRANCH_NAME"
175 -
176 - echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
164 +
165 + echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
166
167 - name: Create release proposal PR
168 id: create_pr
169 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
170 + env:
171 + VERSION: ${{ steps.inputs.outputs.version }}
172 + COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }}
173 + BRANCH_NAME: ${{ steps.setup.outputs.branch_name }}
174 + BEHIND_INFO: ${{ steps.setup.outputs.behind_info }}
175 + LAST_RELEASE: ${{ steps.setup.outputs.last_release }}
176 with:
177 script: |
183 - const changelog = `${{ steps.setup.outputs.changelog }}`;
184 -
178 + const fs = require('fs');
179 + const { proposalTitle, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
180 + const { VERSION, COMMIT_HASH, BRANCH_NAME, BEHIND_INFO, LAST_RELEASE, RUNNER_TEMP } = process.env;
181 + const changelog = fs.readFileSync(`${RUNNER_TEMP}/changelog.md`, 'utf8').trimEnd();
182 +
183 + const body = [
184 + `## Release Proposal: ${VERSION}`,
185 + '',
186 + `**Target Commit:** \`${COMMIT_HASH}\``,
187 + `**Requested by:** @${context.actor}`,
188 + `**Commit Status:** ${BEHIND_INFO}`,
189 + '',
190 + `This PR proposes creating release tag \`${VERSION}\` at commit \`${COMMIT_HASH}\`.`,
191 + '',
192 + '### Approval Process',
193 + '',
194 + `This PR requires **approval from ${REQUIRED_APPROVALS}+ maintainers** before the tag can be created.`,
195 + 'Approvals only count for the PR\'s current head commit, whose `Target-Commit` trailer records the commit to tag.',
196 + '',
197 + '### What happens next?',
198 + '',
199 + '1. Maintainers review this proposal',
200 + `2. When ${REQUIRED_APPROVALS}+ maintainer approvals are received, an automated workflow will post tagging instructions`,
201 + '3. A maintainer manually creates and pushes the signed tag',
202 + '4. The release workflow is triggered automatically by the tag push, and checks the approvals again',
203 + '5. Upon release completion, this PR is closed and the branch is deleted',
204 + '',
205 + `### Changes Since ${LAST_RELEASE}`,
206 + '',
207 + changelog,
208 + '',
209 + '### Release Checklist',
210 + '',
211 + '- [ ] All tests pass',
212 + '- [ ] Security review completed',
213 + '- [ ] Documentation updated',
214 + '- [ ] Breaking changes documented',
215 + '- [ ] Release notes drafted',
216 + '',
217 + '---',
218 + '',
219 + '**Note:** Tag creation is manual and requires a signed tag from a maintainer.',
220 + ].join('\n');
221 +
222 const pr = await github.rest.pulls.create({
223 owner: context.repo.owner,
224 repo: context.repo.repo,
188 - title: `Release Proposal: ${{ steps.inputs.outputs.version }}`,
189 - head: '${{ steps.setup.outputs.branch_name }}',
225 + title: proposalTitle(VERSION),
226 + head: BRANCH_NAME,
227 base: 'master',
191 - body: `## Release Proposal: ${{ steps.inputs.outputs.version }}
192 -
193 - **Target Commit:** \`${{ steps.inputs.outputs.commit_hash }}\`
194 - **Requested by:** @${{ github.actor }}
195 - **Commit Status:** ${{ steps.setup.outputs.behind_info }}
196 -
197 - This PR proposes creating release tag \`${{ steps.inputs.outputs.version }}\` at commit \`${{ steps.inputs.outputs.commit_hash }}\`.
198 -
199 - ### Approval Process
200 -
201 - This PR requires **approval from 2+ maintainers** before the tag can be created.
202 -
203 - ### What happens next?
204 -
205 - 1. Maintainers review this proposal
206 - 2. When 2+ maintainer approvals are received, an automated workflow will post tagging instructions
207 - 3. A maintainer manually creates and pushes the signed tag
208 - 4. The release workflow is triggered automatically by the tag push
209 - 5. Upon release completion, this PR is closed and the branch is deleted
210 -
211 - ### Changes Since Last Release
212 -
213 - ${changelog}
214 -
215 - ### Release Checklist
216 -
217 - - [ ] All tests pass
218 - - [ ] Security review completed
219 - - [ ] Documentation updated
220 - - [ ] Breaking changes documented
221 - - [ ] Release notes drafted
222 -
223 - ---
224 -
225 - **Note:** Tag creation is manual and requires a signed tag from a maintainer.`,
228 + body,
229 draft: true
230 });
228 -
231 +
232 // Add labels
233 await github.rest.issues.addLabels({
234 owner: context.repo.owner,
@@ -233,18 +236,25 @@ jobs:
236 issue_number: pr.data.number,
237 labels: ['release-proposal', 'awaiting-approval']
238 });
236 -
239 +
240 console.log(`Created PR: ${pr.data.html_url}`);
238 -
241 +
242 return { number: pr.data.number, url: pr.data.html_url };
243 result-encoding: json
244
245 - name: Post summary
246 + env:
247 + VERSION: ${{ steps.inputs.outputs.version }}
248 + COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }}
249 + BEHIND_INFO: ${{ steps.setup.outputs.behind_info }}
250 + PR_URL: ${{ fromJson(steps.create_pr.outputs.result).url }}
251 run: |
244 - echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY
245 - echo "" >> $GITHUB_STEP_SUMMARY
246 - echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY
247 - echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY
248 - echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY
249 - echo "" >> $GITHUB_STEP_SUMMARY
250 - echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY
252 + {
253 + echo "## Release Proposal PR Created! 🚀"
254 + echo ""
255 + echo "Version: **$VERSION**"
256 + echo "Commit: **$COMMIT_HASH**"
257 + echo "Status: $BEHIND_INFO"
258 + echo ""
259 + echo "PR: $PR_URL"
260 + } >> "$GITHUB_STEP_SUMMARY"
.github/workflows/release.yml
+163 -175
@@ -20,13 +20,26 @@ jobs:
20 contents: write
21 pull-requests: write
22 issues: write
23 -
23 +
24 outputs:
25 verification_passed: ${{ steps.verify.outputs.passed }}
26 tag_version: ${{ steps.info.outputs.version }}
27 proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }}
28 -
28 +
29 + # Values from the event, step outputs and API results are only ever
30 + # passed through env:, never interpolated as expressions into run: or
31 + # script: bodies.
32 steps:
33 + # Validate the tag name before anything else uses it. Git allows
34 + # characters like $, ( and ; in tag names that match the v*.*.* trigger,
35 + # and the tag name ends up in shell commands (including GoReleaser hooks).
36 + - name: Validate tag name
37 + run: |
38 + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
39 + echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag"
40 + exit 1
41 + fi
42 +
43 - name: Checkout code
44 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
45 with:
@@ -45,8 +58,8 @@ jobs:
58 - name: Get tag info
59 id: info
60 run: |
48 - echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
49 - echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
61 + echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
62 + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
63
64 # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027
65 - name: Print Go version and environment
@@ -80,61 +93,62 @@ jobs:
93 id: verify
94 env:
95 signing_keys: ${{ secrets.SIGNING_KEYS }}
96 + TAG: ${{ steps.vars.outputs.version_tag }}
97 run: |
98 # Read the string into an array, splitting by IFS
99 IFS=";" read -ra keys_collection <<< "$signing_keys"
86 -
100 +
101 # ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-the-runner-context
88 - touch "${{ runner.temp }}/allowed_signers"
102 + touch "$RUNNER_TEMP/allowed_signers"
103
104 # Iterate and print the split elements
105 for item in "${keys_collection[@]}"; do
92 -
106 +
107 # trim leading whitespaces
108 item="${item##*( )}"
109
110 # trim trailing whitespaces
111 item="${item%%*( )}"
98 -
112 +
113 IFS=" " read -ra key_components <<< "$item"
114 # git wants it in format: email address, type, public key
115 # ssh has it in format: type, public key, email address
102 - echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "${{ runner.temp }}/allowed_signers"
116 + echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "$RUNNER_TEMP/allowed_signers"
117 done
118
105 - git config set --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers"
119 + git config set --global gpg.ssh.allowedSignersFile "$RUNNER_TEMP/allowed_signers"
120 +
121 + echo "Verifying the tag: $TAG"
122
107 - echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}"
108 -
123 # Verify the tag is signed
110 - if ! git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1; then
124 + if ! git verify-tag -v "$TAG" 2>&1; then
125 echo "❌ Tag verification failed!"
126 echo "passed=false" >> $GITHUB_OUTPUT
113 - git push --delete origin "${{ steps.vars.outputs.version_tag }}"
127 + git push --delete origin "$TAG"
128 exit 1
129 fi
130 # Run it again to capture the output
117 - git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1 | tee /tmp/verify-output.txt;
131 + git verify-tag -v "$TAG" 2>&1 | tee /tmp/verify-output.txt;
132
133 # SSH verification output typically includes the key fingerprint
134 # Use GNU grep with Perl regex for cleaner extraction (Linux environment)
135 KEY_SHA256=$(grep -oP "SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "")
122 -
136 +
137 if [ -z "$KEY_SHA256" ]; then
138 # Try alternative pattern with "key" prefix
139 KEY_SHA256=$(grep -oP "key SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "")
140 fi
127 -
141 +
142 if [ -z "$KEY_SHA256" ]; then
143 # Fallback: extract any base64-like string (40+ chars)
144 KEY_SHA256=$(grep -oP '[A-Za-z0-9+/]{40,}=?' /tmp/verify-output.txt | head -1 || echo "")
145 fi
132 -
146 +
147 if [ -z "$KEY_SHA256" ]; then
148 echo "Somehow could not extract SSH key fingerprint from git verify-tag output"
149 echo "Cancelling flow and deleting tag"
150 echo "passed=false" >> $GITHUB_OUTPUT
137 - git push --delete origin "${{ steps.vars.outputs.version_tag }}"
151 + git push --delete origin "$TAG"
152 exit 1
153 fi
154
@@ -142,189 +156,136 @@ jobs:
156 echo "passed=true" >> $GITHUB_OUTPUT
157 echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT
158
159 + # Fails closed: there must be exactly one open proposal for this exact
160 + # version, with enough maintainer approvals of its current head commit.
161 + # The tag is left in place on failure, so the job can be re-run once
162 + # the proposal is approved.
163 - name: Find related release proposal
164 id: find_proposal
165 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
166 + env:
167 + TAG: ${{ steps.vars.outputs.version_tag }}
168 + MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
169 with:
170 script: |
150 - const version = '${{ steps.vars.outputs.version_tag }}';
151 -
152 - // Search for PRs with release-proposal label that match this version
153 - const prs = await github.rest.pulls.list({
154 - owner: context.repo.owner,
155 - repo: context.repo.repo,
156 - state: 'open', // Changed to 'all' to find both open and closed PRs
157 - sort: 'updated',
158 - direction: 'desc'
171 + const { checkProposal, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
172 +
173 + const { pr, targetCommit, approvers, approved } = await checkProposal({
174 + github, context, version: process.env.TAG, maintainerLogins: process.env.MAINTAINER_LOGINS,
175 });
160 -
161 - // Find the most recent PR for this version
162 - const proposal = prs.data.find(pr =>
163 - pr.title.includes(version) &&
164 - pr.labels.some(label => label.name === 'release-proposal')
165 - );
166 -
167 - if (!proposal) {
168 - console.log(`⚠️ No release proposal PR found for ${version}`);
169 - console.log('This might be a hotfix or emergency release');
170 - return { number: null, approved: true, approvals: 0, proposedCommit: null };
171 - }
172 -
173 - console.log(`Found proposal PR #${proposal.number} for version ${version}`);
174 -
175 - // Extract commit hash from PR body
176 - const commitMatch = proposal.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/);
177 - const proposedCommit = commitMatch ? commitMatch[1] : null;
178 -
179 - if (proposedCommit) {
180 - console.log(`Proposal was for commit: ${proposedCommit}`);
181 - } else {
182 - console.log('⚠️ No target commit hash found in PR body');
183 - }
184 -
185 - // Get PR reviews to extract approvers
186 - let approvers = 'Validated by automation';
187 - let approvalCount = 2; // Minimum required
188 -
189 - try {
190 - const reviews = await github.rest.pulls.listReviews({
191 - owner: context.repo.owner,
192 - repo: context.repo.repo,
193 - pull_number: proposal.number
194 - });
195 -
196 - // Get latest review per user and filter for approvals
197 - const latestReviewsByUser = {};
198 - reviews.data.forEach(review => {
199 - const username = review.user.login;
200 - if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) {
201 - latestReviewsByUser[username] = review;
202 - }
203 - });
204 -
205 - const approvalReviews = Object.values(latestReviewsByUser).filter(review =>
206 - review.state === 'APPROVED'
207 - );
208 -
209 - if (approvalReviews.length > 0) {
210 - approvers = approvalReviews.map(r => '@' + r.user.login).join(', ');
211 - approvalCount = approvalReviews.length;
212 - console.log(`Found ${approvalCount} approvals from: ${approvers}`);
213 - }
214 - } catch (error) {
215 - console.log(`Could not fetch reviews: ${error.message}`);
176 + console.log(`Found proposal PR #${pr.number}, for commit ${targetCommit}`);
177 + console.log(`Maintainer approvals of ${pr.head.sha}: ${approvers.join(', ') || 'none'}`);
178 +
179 + if (!approved) {
180 + core.setFailed(`Proposal #${pr.number} has ${approvers.length} maintainer approvals of its current head commit; ${REQUIRED_APPROVALS} are required`);
181 + return;
182 }
217 -
183 +
184 return {
219 - number: proposal.number,
220 - approved: true,
221 - approvals: approvalCount,
222 - approvers: approvers,
223 - proposedCommit: proposedCommit
185 + number: pr.number,
186 + approvals: approvers.length,
187 + approvers: approvers.map(login => `@${login}`).join(', '),
188 + proposedCommit: targetCommit
189 };
190 result-encoding: json
191
192 - name: Verify proposal commit
193 + env:
194 + PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }}
195 + CURRENT_COMMIT: ${{ steps.info.outputs.sha }}
196 + TAG: ${{ steps.vars.outputs.version_tag }}
197 run: |
229 - APPROVALS='${{ steps.find_proposal.outputs.result }}'
230 -
231 - # Parse JSON
232 - PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit')
233 - CURRENT_COMMIT="${{ steps.info.outputs.sha }}"
234 -
198 echo "Proposed commit: $PROPOSED_COMMIT"
199 echo "Current commit: $CURRENT_COMMIT"
237 -
238 - # Check if commits match (if proposal had a target commit)
239 - if [ "$PROPOSED_COMMIT" != "null" ] && [ -n "$PROPOSED_COMMIT" ]; then
240 - # Normalize both commits to full SHA for comparison
241 - PROPOSED_FULL=$(git rev-parse "$PROPOSED_COMMIT" 2>/dev/null || echo "")
242 - CURRENT_FULL=$(git rev-parse "$CURRENT_COMMIT" 2>/dev/null || echo "")
243 -
244 - if [ -z "$PROPOSED_FULL" ]; then
245 - echo "⚠️ Could not resolve proposed commit: $PROPOSED_COMMIT"
246 - elif [ "$PROPOSED_FULL" != "$CURRENT_FULL" ]; then
247 - echo "❌ Commit mismatch!"
248 - echo "The tag points to commit $CURRENT_FULL but the proposal was for $PROPOSED_FULL"
249 - echo "This indicates an error in tag creation."
250 - # Delete the tag remotely
251 - git push --delete origin "${{ steps.vars.outputs.version_tag }}"
252 - echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted"
253 - exit 1
254 - else
255 - echo "✅ Commit hash matches proposal"
256 - fi
257 - else
258 - echo "⚠️ No target commit found in proposal (might be legacy release)"
200 +
201 + # Both are full SHAs, so compare them exactly
202 + if [[ ! "$PROPOSED_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
203 + echo "❌ Proposal has no valid target commit: $PROPOSED_COMMIT"
204 + exit 1
205 fi
260 -
261 - echo "✅ Tag verification completed"
206 + if [ "$PROPOSED_COMMIT" != "$CURRENT_COMMIT" ]; then
207 + echo "❌ Commit mismatch!"
208 + echo "The tag points to commit $CURRENT_COMMIT but the proposal was for $PROPOSED_COMMIT"
209 + echo "This indicates an error in tag creation."
210 + # Delete the tag remotely
211 + git push --delete origin "$TAG"
212 + echo "Tag $TAG has been deleted"
213 + exit 1
214 + fi
215 +
216 + echo "✅ Commit hash matches proposal"
217
218 - name: Update release proposal PR
264 - if: fromJson(steps.find_proposal.outputs.result).number != null
219 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
220 + env:
221 + RESULT: ${{ steps.find_proposal.outputs.result }}
222 + TAG: ${{ steps.info.outputs.version }}
223 + KEY_ID: ${{ steps.verify.outputs.key_id }}
224 + SHA: ${{ steps.info.outputs.sha }}
225 with:
226 script: |
268 - const result = ${{ steps.find_proposal.outputs.result }};
269 -
270 - if (result.number) {
271 - // Add in-progress label
272 - await github.rest.issues.addLabels({
273 - owner: context.repo.owner,
274 - repo: context.repo.repo,
275 - issue_number: result.number,
276 - labels: ['release-in-progress']
277 - });
278 -
279 - // Remove approved label if present
280 - try {
281 - await github.rest.issues.removeLabel({
282 - owner: context.repo.owner,
283 - repo: context.repo.repo,
284 - issue_number: result.number,
285 - name: 'approved'
286 - });
287 - } catch (e) {
288 - console.log('Approved label not found:', e.message);
289 - }
290 -
291 - const commentBody = [
292 - '## 🚀 Release Workflow Started',
293 - '',
294 - '- **Tag:** ${{ steps.info.outputs.version }}',
295 - '- **Signed by key:** ${{ steps.verify.outputs.key_id }}',
296 - '- **Commit:** ${{ steps.info.outputs.sha }}',
297 - '- **Approved by:** ' + result.approvers,
298 - '',
299 - 'Release workflow is now running. This PR will be updated when the release is published.'
300 - ].join('\n');
301 -
302 - await github.rest.issues.createComment({
227 + const result = JSON.parse(process.env.RESULT);
228 +
229 + // Add in-progress label
230 + await github.rest.issues.addLabels({
231 + owner: context.repo.owner,
232 + repo: context.repo.repo,
233 + issue_number: result.number,
234 + labels: ['release-in-progress']
235 + });
236 +
237 + // Remove approved label if present
238 + try {
239 + await github.rest.issues.removeLabel({
240 owner: context.repo.owner,
241 repo: context.repo.repo,
242 issue_number: result.number,
306 - body: commentBody
243 + name: 'approved'
244 });
245 + } catch (e) {
246 + console.log('Approved label not found:', e.message);
247 }
248
249 + const commentBody = [
250 + '## 🚀 Release Workflow Started',
251 + '',
252 + `- **Tag:** ${process.env.TAG}`,
253 + `- **Signed by key:** ${process.env.KEY_ID}`,
254 + `- **Commit:** ${process.env.SHA}`,
255 + `- **Approved by:** ${result.approvers}`,
256 + '',
257 + 'Release workflow is now running. This PR will be updated when the release is published.'
258 + ].join('\n');
259 +
260 + await github.rest.issues.createComment({
261 + owner: context.repo.owner,
262 + repo: context.repo.repo,
263 + issue_number: result.number,
264 + body: commentBody
265 + });
266 +
267 - name: Summary
268 + env:
269 + PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }}
270 + APPROVERS: ${{ fromJson(steps.find_proposal.outputs.result).approvers }}
271 + TAG: ${{ steps.info.outputs.version }}
272 + SHA: ${{ steps.info.outputs.sha }}
273 + KEY_ID: ${{ steps.verify.outputs.key_id }}
274 run: |
312 - APPROVALS='${{ steps.find_proposal.outputs.result }}'
313 - PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit // "N/A"')
314 - APPROVERS=$(echo "$APPROVALS" | jq -r '.approvers // "N/A"')
315 -
316 - echo "## Tag Verification Summary 🔐" >> $GITHUB_STEP_SUMMARY
317 - echo "" >> $GITHUB_STEP_SUMMARY
318 - echo "- **Tag:** ${{ steps.info.outputs.version }}" >> $GITHUB_STEP_SUMMARY
319 - echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY
320 - echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY
321 - echo "- **Signature:** ✅ Verified" >> $GITHUB_STEP_SUMMARY
322 - echo "- **Signed by:** ${{ steps.verify.outputs.key_id }}" >> $GITHUB_STEP_SUMMARY
323 - echo "- **Approvals:** ✅ Sufficient" >> $GITHUB_STEP_SUMMARY
324 - echo "- **Approved by:** $APPROVERS" >> $GITHUB_STEP_SUMMARY
325 - echo "" >> $GITHUB_STEP_SUMMARY
326 - echo "Proceeding with release build..." >> $GITHUB_STEP_SUMMARY
327 -
275 + {
276 + echo "## Tag Verification Summary 🔐"
277 + echo ""
278 + echo "- **Tag:** $TAG"
279 + echo "- **Commit:** $SHA"
280 + echo "- **Proposed Commit:** $PROPOSED_COMMIT"
281 + echo "- **Signature:** ✅ Verified"
282 + echo "- **Signed by:** $KEY_ID"
283 + echo "- **Approvals:** ✅ Sufficient"
284 + echo "- **Approved by:** $APPROVERS"
285 + echo ""
286 + echo "Proceeding with release build..."
287 + } >> "$GITHUB_STEP_SUMMARY"
288 +
289 release:
290 name: Release
291 needs: verify-tag
@@ -354,6 +315,15 @@ jobs:
315 pull-requests: write
316
317 steps:
318 + # Same check as in verify-tag: the tag name ends up in GoReleaser's
319 + # shell hooks via $TAG.
320 + - name: Validate tag name
321 + run: |
322 + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
323 + echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag"
324 + exit 1
325 + fi
326 +
327 - name: Harden the runner (Audit all outbound calls)
328 uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
329 with:
@@ -426,12 +396,28 @@ jobs:
396 run: |
397 go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
398 xcaddy version
399 + # Generate the draft's notes in the same format as GitHub's "Generate
400 + # release notes" button, compared against the previous *published*
401 + # release by version (not the previous tag, which may be an abandoned
402 + # release attempt), and without PRs that already shipped in it as
403 + # backports. GoReleaser's own changelog can do neither.
404 + - name: Generate release notes
405 + env:
406 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
407 + TAG: ${{ steps.vars.outputs.version_tag }}
408 + NOTES_FILE: ${{ runner.temp }}/release-notes.md
409 + run: |
410 + PREVIOUS_RELEASE=$(.github/scripts/previous-release.sh "$TAG")
411 + echo "Previous published release: ${PREVIOUS_RELEASE:-none}"
412 + .github/scripts/release-notes.sh "$TAG" "$TAG" "$PREVIOUS_RELEASE" > "$NOTES_FILE"
413 + cat "$NOTES_FILE"
414 +
415 # GoReleaser will take care of publishing those artifacts into the release
416 - name: Run GoReleaser
417 uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
418 with:
419 version: latest
434 - args: release --clean --timeout 60m
420 + args: release --clean --timeout 60m --release-notes ${{ runner.temp }}/release-notes.md
421 env:
422 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
423 TAG: ${{ steps.vars.outputs.version_tag }}
@@ -497,9 +483,11 @@ jobs:
483 - name: Update release proposal PR
484 if: needs.verify-tag.outputs.proposal_issue_number != ''
485 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
486 + env:
487 + PROPOSAL_NUMBER: ${{ needs.verify-tag.outputs.proposal_issue_number }}
488 with:
489 script: |
502 - const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}');
490 + const prNumber = parseInt(process.env.PROPOSAL_NUMBER, 10);
491
492 if (prNumber) {
493 // Get PR details to find the branch
.goreleaser.yml
+2
@@ -203,6 +203,8 @@ release:
203 draft: true
204 prerelease: auto
205
206 +# Only used for local runs: the release workflow passes --release-notes
207 +# generated by .github/scripts/release-notes.sh, which skips this.
208 changelog:
209 sort: asc
210 filters: