@marcel91200 / caddy Mirror · read-only / commits / 56282c57
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, 18:30 UTC

ci: implement new release flow (#7341)

* ci: implement new release flow Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> * remove redundant validation Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> * extract key sha Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> * pin github-scripts Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> * switch to PR-based flow Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> * don't use top-level permissions Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> * restricted global perms + specific local perms Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> * make PR draft Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com> --------- Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>

Mohammed Al Sahaf committed Nov 15, 2025 at 00:55 UTC 56282c5737ce3e96267d00ce6832bf3ee51525d6
3 files changed +854 -10
.github/workflows/auto-release-pr.yml new
+221
@@ -0,0 +1,221 @@
1 +name: Release Proposal Approval Tracker
2 +
3 +on:
4 + pull_request_review:
5 + types: [submitted, dismissed]
6 + pull_request:
7 + types: [labeled, unlabeled, synchronize, closed]
8 +
9 +permissions:
10 + contents: read
11 + pull-requests: write
12 + issues: write
13 +
14 +jobs:
15 + check-approvals:
16 + name: Track Maintainer Approvals
17 + 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 +
21 + steps:
22 + - name: Check approvals and update PR
23 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
24 + env:
25 + MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
26 + with:
27 + script: |
28 + const pr = context.payload.pull_request;
29 +
30 + // 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');
36 + return;
37 + }
38 +
39 + 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 + }
67 + });
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 +
80 + // Get current labels
81 + const currentLabels = pr.labels.map(label => label.name);
82 + const hasApprovedLabel = currentLabels.includes('approved');
83 + const hasAwaitingApprovalLabel = currentLabels.includes('awaiting-approval');
84 +
85 + if (approvalCount >= 2 && !hasApprovedLabel) {
86 + console.log('✅ Quorum reached! Updating PR...');
87 +
88 + // Remove awaiting-approval label if present
89 + if (hasAwaitingApprovalLabel) {
90 + await github.rest.issues.removeLabel({
91 + owner: context.repo.owner,
92 + repo: context.repo.repo,
93 + issue_number: pr.number,
94 + name: 'awaiting-approval'
95 + }).catch(e => console.log('Label not found:', e.message));
96 + }
97 +
98 + // Add approved label
99 + await github.rest.issues.addLabels({
100 + owner: context.repo.owner,
101 + repo: context.repo.repo,
102 + issue_number: pr.number,
103 + labels: ['approved']
104 + });
105 +
106 + // Add comment with tagging instructions
107 + const approversList = maintainerApprovals.map(u => `@${u}`).join(', ');
108 + const commentBody = [
109 + '## ✅ Approval Quorum Reached',
110 + '',
111 + `This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`,
112 + '',
113 + '### Tagging Instructions',
114 + '',
115 + 'A maintainer should now create and push the signed tag:',
116 + '',
117 + '```bash',
118 + `git checkout ${targetCommit}`,
119 + `git tag -s ${version} -m "Release ${version}"`,
120 + `git push origin ${version}`,
121 + `git checkout -`,
122 + '```',
123 + '',
124 + 'The release workflow will automatically start when the tag is pushed.'
125 + ].join('\n');
126 +
127 + await github.rest.issues.createComment({
128 + owner: context.repo.owner,
129 + repo: context.repo.repo,
130 + issue_number: pr.number,
131 + body: commentBody
132 + });
133 +
134 + console.log('Posted tagging instructions');
135 + } else if (approvalCount < 2 && hasApprovedLabel) {
136 + console.log('⚠️ Approval count dropped below quorum, removing approved label');
137 +
138 + // Remove approved label
139 + await github.rest.issues.removeLabel({
140 + owner: context.repo.owner,
141 + repo: context.repo.repo,
142 + issue_number: pr.number,
143 + name: 'approved'
144 + }).catch(e => console.log('Label not found:', e.message));
145 +
146 + // Add awaiting-approval label
147 + if (!hasAwaitingApprovalLabel) {
148 + await github.rest.issues.addLabels({
149 + owner: context.repo.owner,
150 + repo: context.repo.repo,
151 + issue_number: pr.number,
152 + labels: ['awaiting-approval']
153 + });
154 + }
155 + } else {
156 + console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`);
157 + }
158 +
159 + handle-pr-closed:
160 + name: Handle PR Closed Without Tag
161 + runs-on: ubuntu-latest
162 + if: |
163 + contains(github.event.pull_request.labels.*.name, 'release-proposal') &&
164 + github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released')
165 +
166 + steps:
167 + - name: Add cancelled label and comment
168 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
169 + with:
170 + script: |
171 + const pr = context.payload.pull_request;
172 +
173 + // Check if the release-in-progress label is present
174 + const hasReleaseInProgress = pr.labels.some(label => label.name === 'release-in-progress');
175 +
176 + if (hasReleaseInProgress) {
177 + // PR was closed while release was in progress - this is unusual
178 + await github.rest.issues.createComment({
179 + owner: context.repo.owner,
180 + repo: context.repo.repo,
181 + issue_number: pr.number,
182 + body: '⚠️ **Warning:** This PR was closed while a release was in progress. This may indicate an error. Please verify the release status.'
183 + });
184 + } else {
185 + // PR was closed before tag was created - this is normal cancellation
186 + const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/);
187 + const version = versionMatch ? versionMatch[1] : 'unknown';
188 +
189 + await github.rest.issues.createComment({
190 + owner: context.repo.owner,
191 + repo: context.repo.repo,
192 + issue_number: pr.number,
193 + body: `## 🚫 Release Proposal Cancelled\n\nThis release proposal for ${version} was closed without creating the tag.\n\nIf you want to proceed with this release later, you can create a new release proposal.`
194 + });
195 + }
196 +
197 + // Add cancelled label
198 + await github.rest.issues.addLabels({
199 + owner: context.repo.owner,
200 + repo: context.repo.repo,
201 + issue_number: pr.number,
202 + labels: ['cancelled']
203 + });
204 +
205 + // Remove other workflow labels if present
206 + const labelsToRemove = ['awaiting-approval', 'approved', 'release-in-progress'];
207 + for (const label of labelsToRemove) {
208 + try {
209 + await github.rest.issues.removeLabel({
210 + owner: context.repo.owner,
211 + repo: context.repo.repo,
212 + issue_number: pr.number,
213 + name: label
214 + });
215 + } catch (e) {
216 + console.log(`Label ${label} not found or already removed`);
217 + }
218 + }
219 +
220 + console.log('Added cancelled label and cleaned up workflow labels');
221 +
.github/workflows/release-proposal.yml new
+248
@@ -0,0 +1,248 @@
1 +name: Release Proposal
2 +
3 +# This workflow creates a release proposal as a PR that requires approval from maintainers
4 +# Triggered manually by maintainers when ready to prepare a release
5 +on:
6 + workflow_dispatch:
7 + inputs:
8 + version:
9 + description: 'Version to release (e.g., v2.8.0)'
10 + required: true
11 + type: string
12 + commit_hash:
13 + description: 'Commit hash to release from'
14 + required: true
15 + type: string
16 +
17 +permissions:
18 + contents: read
19 +
20 +jobs:
21 + create-proposal:
22 + name: Create Release Proposal
23 + runs-on: ubuntu-latest
24 + permissions:
25 + contents: write
26 + pull-requests: write
27 + issues: write
28 +
29 + steps:
30 + - name: Harden the runner (Audit all outbound calls)
31 + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
32 + with:
33 + egress-policy: audit
34 + - name: Checkout code
35 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
36 + with:
37 + fetch-depth: 0
38 +
39 + - name: Trim and validate inputs
40 + id: inputs
41 + run: |
42 + # 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 + # Validate version format
50 + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
51 + echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)"
52 + exit 1
53 + fi
54 +
55 + # Validate commit hash format
56 + if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then
57 + echo "Error: Commit hash must be a valid SHA (7-40 characters)"
58 + exit 1
59 + 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"
64 + exit 1
65 + fi
66 +
67 + - name: Check if tag already exists
68 + 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"
71 + exit 1
72 + fi
73 +
74 + - name: Check for existing proposal PR
75 + id: check_existing
76 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
77 + with:
78 + 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}`);
101 + } 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.`);
103 + }
104 + return;
105 + }
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 + }
128 +
129 + - name: Generate changelog and create branch
130 + id: setup
131 + run: |
132 + VERSION="${{ steps.inputs.outputs.version }}"
133 + COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}"
134 +
135 + # Create a new branch for the release proposal
136 + BRANCH_NAME="release_proposal-$VERSION"
137 + 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)"
144 + else
145 + BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**"
146 + 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"
156 + COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH")
157 + else
158 + echo "Generating changelog since $LAST_TAG"
159 + COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH")
160 + fi
161 +
162 + # Store changelog for PR body
163 + echo "changelog<<EOF" >> $GITHUB_OUTPUT
164 + echo "$COMMITS" >> $GITHUB_OUTPUT
165 + echo "EOF" >> $GITHUB_OUTPUT
166 +
167 + # Create empty commit for the PR
168 + git config user.name "github-actions[bot]"
169 + git config user.email "github-actions[bot]@users.noreply.github.com"
170 + git commit --allow-empty -m "Release proposal for $VERSION"
171 +
172 + # Push the branch
173 + git push origin "$BRANCH_NAME"
174 +
175 + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
176 +
177 + - name: Create release proposal PR
178 + id: create_pr
179 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
180 + with:
181 + script: |
182 + const changelog = `${{ steps.setup.outputs.changelog }}`;
183 +
184 + const pr = await github.rest.pulls.create({
185 + owner: context.repo.owner,
186 + repo: context.repo.repo,
187 + title: `Release Proposal: ${{ steps.inputs.outputs.version }}`,
188 + head: '${{ steps.setup.outputs.branch_name }}',
189 + base: 'master',
190 + body: `## Release Proposal: ${{ steps.inputs.outputs.version }}
191 +
192 + **Target Commit:** \`${{ steps.inputs.outputs.commit_hash }}\`
193 + **Requested by:** @${{ github.actor }}
194 + **Commit Status:** ${{ steps.setup.outputs.behind_info }}
195 +
196 + This PR proposes creating release tag \`${{ steps.inputs.outputs.version }}\` at commit \`${{ steps.inputs.outputs.commit_hash }}\`.
197 +
198 + ### Approval Process
199 +
200 + This PR requires **approval from 2+ maintainers** before the tag can be created.
201 +
202 + ### What happens next?
203 +
204 + 1. Maintainers review this proposal
205 + 2. When 2+ maintainer approvals are received, an automated workflow will post tagging instructions
206 + 3. A maintainer manually creates and pushes the signed tag
207 + 4. The release workflow is triggered automatically by the tag push
208 + 5. Upon release completion, this PR is closed and the branch is deleted
209 +
210 + ### Changes Since Last Release
211 +
212 + ${changelog}
213 +
214 + ### Release Checklist
215 +
216 + - [ ] All tests pass
217 + - [ ] Security review completed
218 + - [ ] Documentation updated
219 + - [ ] Breaking changes documented
220 +
221 + ---
222 +
223 + **Note:** Tag creation is manual and requires a signed tag from a maintainer.`,
224 + draft: true
225 + });
226 +
227 + // Add labels
228 + await github.rest.issues.addLabels({
229 + owner: context.repo.owner,
230 + repo: context.repo.repo,
231 + issue_number: pr.data.number,
232 + labels: ['release-proposal', 'awaiting-approval']
233 + });
234 +
235 + console.log(`Created PR: ${pr.data.html_url}`);
236 +
237 + return { number: pr.data.number, url: pr.data.html_url };
238 + result-encoding: json
239 +
240 + - name: Post summary
241 + run: |
242 + echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY
243 + echo "" >> $GITHUB_STEP_SUMMARY
244 + echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY
245 + echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY
246 + echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY
247 + echo "" >> $GITHUB_STEP_SUMMARY
248 + echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY
.github/workflows/release.yml
+385 -10
@@ -13,8 +13,322 @@ permissions:
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:
@@ -36,6 +350,8 @@ jobs:
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)
@@ -98,16 +414,6 @@ jobs:
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
@@ -188,3 +494,72 @@ jobs:
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 + }