13
contents: read
14
15
jobs:
16
+ verify-tag:
17
+ name: Verify Tag Signature and Approvals
18
+ runs-on: ubuntu-latest
19
+ permissions:
20
+ contents: write
21
+ pull-requests: write
22
+ issues: write
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
+
29
+ steps:
30
+ - name: Checkout code
31
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
32
+ with:
33
+ fetch-depth: 0
34
+ # Force fetch upstream tags -- because 65 minutes
35
+ # tl;dr: actions/checkout@v3 runs this line:
36
+ # git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/
37
+ # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran:
38
+ # git fetch --prune --unshallow
39
+ # which doesn't overwrite that tag because that would be destructive.
40
+ # Credit to @francislavoie for the investigation.
41
+ # https://github.com/actions/checkout/issues/290#issuecomment-680260080
42
+ - name: Force fetch upstream tags
43
+ run: git fetch --tags --force
44
+
45
+ - name: Get tag info
46
+ id: info
47
+ run: |
48
+ echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
49
+ echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
50
+
51
+ # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027
52
+ - name: Print Go version and environment
53
+ id: vars
54
+ run: |
55
+ printf "Using go at: $(which go)\n"
56
+ printf "Go version: $(go version)\n"
57
+ printf "\n\nGo environment:\n\n"
58
+ go env
59
+ printf "\n\nSystem environment:\n\n"
60
+ env
61
+ echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
62
+ echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
63
+
64
+ # Add "pip install" CLI tools to PATH
65
+ echo ~/.local/bin >> $GITHUB_PATH
66
+
67
+ # Parse semver
68
+ TAG=${GITHUB_REF/refs\/tags\//}
69
+ SEMVER_RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z\.-]*\)'
70
+ TAG_MAJOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\1#"`
71
+ TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"`
72
+ TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"`
73
+ TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"`
74
+ echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT
75
+ echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT
76
+ echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT
77
+ echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT
78
+
79
+ - name: Validate commits and tag signatures
80
+ id: verify
81
+ env:
82
+ signing_keys: ${{ secrets.SIGNING_KEYS }}
83
+ run: |
84
+ # Read the string into an array, splitting by IFS
85
+ IFS=";" read -ra keys_collection <<< "$signing_keys"
86
+
87
+ # ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-the-runner-context
88
+ touch "${{ runner.temp }}/allowed_signers"
89
+
90
+ # Iterate and print the split elements
91
+ for item in "${keys_collection[@]}"; do
92
+
93
+ # trim leading whitespaces
94
+ item="${item##*( )}"
95
+
96
+ # trim trailing whitespaces
97
+ item="${item%%*( )}"
98
+
99
+ IFS=" " read -ra key_components <<< "$item"
100
+ # git wants it in format: email address, type, public key
101
+ # 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"
103
+ done
104
+
105
+ git config set --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers"
106
+
107
+ echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}"
108
+
109
+ # Verify the tag is signed
110
+ if ! git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1; then
111
+ echo "❌ Tag verification failed!"
112
+ echo "passed=false" >> $GITHUB_OUTPUT
113
+ git push --delete origin "${{ steps.vars.outputs.version_tag }}"
114
+ exit 1
115
+ fi
116
+ # Run it again to capture the output
117
+ git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1 | tee /tmp/verify-output.txt;
118
+
119
+ # SSH verification output typically includes the key fingerprint
120
+ # Use GNU grep with Perl regex for cleaner extraction (Linux environment)
121
+ KEY_SHA256=$(grep -oP "SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "")
122
+
123
+ if [ -z "$KEY_SHA256" ]; then
124
+ # Try alternative pattern with "key" prefix
125
+ KEY_SHA256=$(grep -oP "key SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "")
126
+ fi
127
+
128
+ if [ -z "$KEY_SHA256" ]; then
129
+ # Fallback: extract any base64-like string (40+ chars)
130
+ KEY_SHA256=$(grep -oP '[A-Za-z0-9+/]{40,}=?' /tmp/verify-output.txt | head -1 || echo "")
131
+ fi
132
+
133
+ if [ -z "$KEY_SHA256" ]; then
134
+ echo "Somehow could not extract SSH key fingerprint from git verify-tag output"
135
+ echo "Cancelling flow and deleting tag"
136
+ echo "passed=false" >> $GITHUB_OUTPUT
137
+ git push --delete origin "${{ steps.vars.outputs.version_tag }}"
138
+ exit 1
139
+ fi
140
+
141
+ echo "✅ Tag verification succeeded!"
142
+ echo "passed=true" >> $GITHUB_OUTPUT
143
+ echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT
144
+
145
+ - name: Find related release proposal
146
+ id: find_proposal
147
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
148
+ with:
149
+ 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'
159
+ });
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}`);
216
+ }
217
+
218
+ return {
219
+ number: proposal.number,
220
+ approved: true,
221
+ approvals: approvalCount,
222
+ approvers: approvers,
223
+ proposedCommit: proposedCommit
224
+ };
225
+ result-encoding: json
226
+
227
+ - name: Verify proposal commit
228
+ 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
+
235
+ echo "Proposed commit: $PROPOSED_COMMIT"
236
+ 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)"
259
+ fi
260
+
261
+ echo "✅ Tag verification completed"
262
+
263
+ - name: Update release proposal PR
264
+ if: fromJson(steps.find_proposal.outputs.result).number != null
265
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
266
+ with:
267
+ 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({
303
+ owner: context.repo.owner,
304
+ repo: context.repo.repo,
305
+ issue_number: result.number,
306
+ body: commentBody
307
+ });
308
+ }
309
+
310
+ - name: Summary
311
+ 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
+
328
release:
329
name: Release
330
+ needs: verify-tag
331
+ if: ${{ needs.verify-tag.outputs.verification_passed == 'true' }}
332
strategy:
333
matrix:
334
os:
350
# https://docs.github.com/en/rest/overview/permissions-required-for-github-apps#permission-on-contents
351
# "Releases" is part of `contents`, so it needs the `write`
352
contents: write
353
+ issues: write
354
+ pull-requests: write
355
356
steps:
357
- name: Harden the runner (Audit all outbound calls)
414
- name: Install Cloudsmith CLI
415
run: pip install --upgrade cloudsmith-cli
416
101
- - name: Validate commits and tag signatures
102
- run: |
103
-
104
- # Import Matt Holt's key
105
- curl 'https://github.com/mholt.gpg' | gpg --import
106
-
107
- echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}"
108
- # tags are only accepted if signed by Matt's key
109
- git verify-tag "${{ steps.vars.outputs.version_tag }}" || exit 1
110
-
417
- name: Install Cosign
418
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # main
419
- name: Cosign version
494
echo "Pushing $filename to 'testing'"
495
cloudsmith push deb caddy/testing/any-distro/any-version $filename
496
done
497
+
498
+ - name: Update release proposal PR
499
+ if: needs.verify-tag.outputs.proposal_issue_number != ''
500
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
501
+ with:
502
+ script: |
503
+ const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}');
504
+
505
+ if (prNumber) {
506
+ // Get PR details to find the branch
507
+ const pr = await github.rest.pulls.get({
508
+ owner: context.repo.owner,
509
+ repo: context.repo.repo,
510
+ pull_number: prNumber
511
+ });
512
+
513
+ const branchName = pr.data.head.ref;
514
+
515
+ // Remove in-progress label
516
+ try {
517
+ await github.rest.issues.removeLabel({
518
+ owner: context.repo.owner,
519
+ repo: context.repo.repo,
520
+ issue_number: prNumber,
521
+ name: 'release-in-progress'
522
+ });
523
+ } catch (e) {
524
+ console.log('Label not found:', e.message);
525
+ }
526
+
527
+ // Add released label
528
+ await github.rest.issues.addLabels({
529
+ owner: context.repo.owner,
530
+ repo: context.repo.repo,
531
+ issue_number: prNumber,
532
+ labels: ['released']
533
+ });
534
+
535
+ // Add final comment
536
+ await github.rest.issues.createComment({
537
+ owner: context.repo.owner,
538
+ repo: context.repo.repo,
539
+ issue_number: prNumber,
540
+ body: '## ✅ Release Published\n\nThe release has been successfully published and is now available.'
541
+ });
542
+
543
+ // Close the PR if it's still open
544
+ if (pr.data.state === 'open') {
545
+ await github.rest.pulls.update({
546
+ owner: context.repo.owner,
547
+ repo: context.repo.repo,
548
+ pull_number: prNumber,
549
+ state: 'closed'
550
+ });
551
+ console.log(`Closed PR #${prNumber}`);
552
+ }
553
+
554
+ // Delete the branch
555
+ try {
556
+ await github.rest.git.deleteRef({
557
+ owner: context.repo.owner,
558
+ repo: context.repo.repo,
559
+ ref: `heads/${branchName}`
560
+ });
561
+ console.log(`Deleted branch: ${branchName}`);
562
+ } catch (e) {
563
+ console.log(`Could not delete branch ${branchName}: ${e.message}`);
564
+ }
565
+ }