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: