11name : Release
22
33on :
4- push :
5- tags :
6- - ' v*'
4+ workflow_run :
5+ workflows : ["CI"]
6+ types :
7+ - completed
78
89jobs :
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
104+ - name : Checkout repository (attempt 1)
105+ id : checkout_attempt_1
106+ continue-on-error : true
107+ uses : actions/checkout@v4
20108 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-
81- - name : Checkout
109+ fetch-depth : 0
110+ ref : ${{ github.event.workflow_run.head_sha }}
111+
112+ - name : Checkout repository (attempt 2)
113+ id : checkout_attempt_2
114+ if : ${{ steps.checkout_attempt_1.outcome == 'failure' }}
115+ continue-on-error : true
82116 uses : actions/checkout@v4
83117 with :
84118 fetch-depth : 0
119+ ref : ${{ github.event.workflow_run.head_sha }}
120+
121+ - name : Checkout repository (attempt 3)
122+ if : ${{ steps.checkout_attempt_1.outcome == 'failure' && steps.checkout_attempt_2.outcome == 'failure' }}
123+ uses : actions/checkout@v4
124+ with :
125+ 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 }}"
85131
86132 - name : Setup Python
87133 uses : actions/setup-python@v5
@@ -91,15 +137,17 @@ jobs:
91137 - name : Generate release changelog
92138 run : |
93139 python3 scripts/generate_release_changelog.py \
94- --target-ref "${GITHUB_REF_NAME }" \
95- --tag-name "${GITHUB_REF_NAME }" \
140+ --target-ref "${{ needs.evaluate-trigger.outputs.target_tag } }" \
141+ --tag-name "${{ needs.evaluate-trigger.outputs.target_tag } }" \
96142 --output "release-changelog.md"
97143
98144 - name : Create GitHub Release
99145 uses : softprops/action-gh-release@v2
100146 with :
147+ tag_name : ${{ needs.evaluate-trigger.outputs.target_tag }}
148+ target_commitish : ${{ github.event.workflow_run.head_sha }}
101149 draft : false
102- prerelease : false
150+ prerelease : ${{ contains(needs.evaluate-trigger.outputs.target_tag, '-') }}
103151 generate_release_notes : false
104152 body_path : release-changelog.md
105153 env :
0 commit comments