Skip to content

Commit 1a4877b

Browse files
committed
release-gate hardening
1 parent e2ac100 commit 1a4877b

1 file changed

Lines changed: 103 additions & 71 deletions

File tree

.github/workflows/release.yml

Lines changed: 103 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,113 @@
11
name: Release
22

33
on:
4-
push:
5-
tags:
6-
- 'v*'
4+
workflow_run:
5+
workflows: ["CI"]
6+
types:
7+
- completed
78

89
jobs:
10+
evaluate-trigger:
11+
runs-on: ubuntu-latest
12+
outputs:
13+
should_release: ${{ steps.resolve-tag.outputs.should_release }}
14+
target_tag: ${{ steps.resolve-tag.outputs.target_tag }}
15+
permissions:
16+
contents: read
17+
steps:
18+
- name: Log triggering CI run
19+
shell: bash
20+
run: |
21+
set -euo pipefail
22+
head_branch="${{ github.event.workflow_run.head_branch }}"
23+
if [ -z "$head_branch" ]; then
24+
head_branch="<none>"
25+
fi
26+
27+
echo "Triggered by CI run #${{ github.event.workflow_run.run_number }}"
28+
echo "event=${{ github.event.workflow_run.event }}"
29+
echo "conclusion=${{ github.event.workflow_run.conclusion }}"
30+
echo "head_branch=${head_branch}"
31+
echo "head_sha=${{ github.event.workflow_run.head_sha }}"
32+
33+
- name: Checkout repository for tag resolution
34+
if: ${{ github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' }}
35+
uses: actions/checkout@v4
36+
with:
37+
fetch-depth: 0
38+
ref: ${{ github.event.workflow_run.head_sha }}
39+
40+
- name: Resolve release target
41+
id: resolve-tag
42+
shell: bash
43+
run: |
44+
set -euo pipefail
45+
echo "should_release=false" >> "$GITHUB_OUTPUT"
46+
echo "target_tag=" >> "$GITHUB_OUTPUT"
47+
48+
event="${{ github.event.workflow_run.event }}"
49+
conclusion="${{ github.event.workflow_run.conclusion }}"
50+
head_branch="${{ github.event.workflow_run.head_branch }}"
51+
head_sha="${{ github.event.workflow_run.head_sha }}"
52+
53+
if [ "$event" != "push" ]; then
54+
echo "Skipping release: CI completed for event '$event', not a push."
55+
exit 0
56+
fi
57+
58+
if [ -n "$head_branch" ]; then
59+
echo "Skipping release: CI completed for branch '$head_branch', not a tag push."
60+
exit 0
61+
fi
62+
63+
case "$conclusion" in
64+
success)
65+
;;
66+
failure|cancelled|timed_out|action_required)
67+
echo "Release blocked: CI for tag candidate at $head_sha concluded with '$conclusion'."
68+
exit 1
69+
;;
70+
*)
71+
echo "Release blocked: CI completed with unexpected conclusion '$conclusion' for tag candidate at $head_sha."
72+
exit 1
73+
;;
74+
esac
75+
76+
git fetch --force --tags origin
77+
mapfile -t matching_tags < <(git tag --points-at "$head_sha" 'v*' | sort)
78+
79+
if [ "${#matching_tags[@]}" -eq 0 ]; then
80+
echo "Release blocked: CI looks like a tag run, but no v* tag points at $head_sha."
81+
exit 1
82+
fi
83+
84+
if [ "${#matching_tags[@]}" -gt 1 ]; then
85+
printf 'Release blocked: multiple v* tags point at %s: %s\n' "$head_sha" "${matching_tags[*]}"
86+
exit 1
87+
fi
88+
89+
target_tag="${matching_tags[0]}"
90+
echo "Resolved release tag: ${target_tag}"
91+
echo "should_release=true" >> "$GITHUB_OUTPUT"
92+
echo "target_tag=${target_tag}" >> "$GITHUB_OUTPUT"
93+
994
create-release:
95+
needs: evaluate-trigger
96+
if: ${{ needs.evaluate-trigger.outputs.should_release == 'true' }}
1097
runs-on: ubuntu-latest
1198
concurrency:
12-
group: release-${{ github.ref }}
99+
group: release-${{ needs.evaluate-trigger.outputs.target_tag }}
13100
cancel-in-progress: false
14101
permissions:
15-
actions: read
16102
contents: write
17103
steps:
18-
- name: Wait for CI workflow success
19-
uses: actions/github-script@v7
20-
with:
21-
script: |
22-
const owner = context.repo.owner;
23-
const repo = context.repo.repo;
24-
const targetSha = context.sha;
25-
const targetRef = context.ref;
26-
const targetTag = targetRef.replace('refs/tags/', '');
27-
const workflowId = 'ci.yml';
28-
const pollMs = 20000;
29-
const timeoutMs = 45 * 60 * 1000;
30-
const startedAt = Date.now();
31-
const failingConclusions = new Set(['failure', 'cancelled', 'timed_out', 'action_required']);
32-
33-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
34-
35-
core.info(`Waiting for CI workflow (${workflowId}) success on ${targetRef} (${targetSha})`);
36-
37-
while (true) {
38-
const { data } = await github.rest.actions.listWorkflowRuns({
39-
owner,
40-
repo,
41-
workflow_id: workflowId,
42-
event: 'push',
43-
head_sha: targetSha,
44-
per_page: 100,
45-
});
46-
47-
const matchingRuns = data.workflow_runs
48-
.filter((run) => run.ref === targetRef)
49-
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
50-
51-
if (matchingRuns.length > 0) {
52-
const run = matchingRuns[0];
53-
core.info(`Found CI run #${run.run_number}: status=${run.status}, conclusion=${run.conclusion ?? 'n/a'}`);
54-
55-
if (run.status === 'completed') {
56-
if (run.conclusion === 'success') {
57-
core.info(`CI succeeded for ${targetTag}. Continuing release.`);
58-
return;
59-
}
60-
61-
if (failingConclusions.has(run.conclusion)) {
62-
core.setFailed(`CI did not succeed for ${targetTag}. Conclusion: ${run.conclusion}.`);
63-
return;
64-
}
65-
66-
core.setFailed(`CI completed without success for ${targetTag}. Conclusion: ${run.conclusion ?? 'unknown'}.`);
67-
return;
68-
}
69-
} else {
70-
core.info('CI run for this tag is not visible yet. Waiting...');
71-
}
72-
73-
if (Date.now() - startedAt >= timeoutMs) {
74-
core.setFailed(`Timed out after ${timeoutMs / 60000} minutes waiting for successful CI on ${targetTag}.`);
75-
return;
76-
}
77-
78-
await sleep(pollMs);
79-
}
80-
81104
- name: Checkout repository (attempt 1)
82105
id: checkout_attempt_1
83106
continue-on-error: true
84107
uses: actions/checkout@v4
85108
with:
86109
fetch-depth: 0
110+
ref: ${{ github.event.workflow_run.head_sha }}
87111

88112
- name: Checkout repository (attempt 2)
89113
id: checkout_attempt_2
@@ -92,12 +116,18 @@ jobs:
92116
uses: actions/checkout@v4
93117
with:
94118
fetch-depth: 0
119+
ref: ${{ github.event.workflow_run.head_sha }}
95120

96121
- name: Checkout repository (attempt 3)
97122
if: ${{ steps.checkout_attempt_1.outcome == 'failure' && steps.checkout_attempt_2.outcome == 'failure' }}
98123
uses: actions/checkout@v4
99124
with:
100125
fetch-depth: 0
126+
ref: ${{ github.event.workflow_run.head_sha }}
127+
128+
- name: Log release target
129+
run: |
130+
echo "Creating release for ${{ needs.evaluate-trigger.outputs.target_tag }} at ${{ github.event.workflow_run.head_sha }}"
101131
102132
- name: Setup Python
103133
uses: actions/setup-python@v5
@@ -107,15 +137,17 @@ jobs:
107137
- name: Generate release changelog
108138
run: |
109139
python3 scripts/generate_release_changelog.py \
110-
--target-ref "${GITHUB_REF_NAME}" \
111-
--tag-name "${GITHUB_REF_NAME}" \
140+
--target-ref "${{ needs.evaluate-trigger.outputs.target_tag }}" \
141+
--tag-name "${{ needs.evaluate-trigger.outputs.target_tag }}" \
112142
--output "release-changelog.md"
113143
114144
- name: Create GitHub Release
115145
uses: softprops/action-gh-release@v2
116146
with:
147+
tag_name: ${{ needs.evaluate-trigger.outputs.target_tag }}
148+
target_commitish: ${{ github.event.workflow_run.head_sha }}
117149
draft: false
118-
prerelease: ${{ contains(github.ref_name, '-') }}
150+
prerelease: ${{ contains(needs.evaluate-trigger.outputs.target_tag, '-') }}
119151
generate_release_notes: false
120152
body_path: release-changelog.md
121153
env:

0 commit comments

Comments
 (0)