Skip to content

Commit b0ba9f6

Browse files
committed
release-gate hardening
1 parent 768b5b5 commit b0ba9f6

2 files changed

Lines changed: 145 additions & 71 deletions

File tree

.github/workflows/lint.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Lint (Advisory)
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
- feature/**
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
metadata_lint:
15+
name: Metadata Lint (advisory)
16+
runs-on: ubuntu-latest
17+
continue-on-error: true
18+
steps:
19+
- name: Checkout repository
20+
uses: actions/checkout@v4
21+
22+
- name: Arduino Lint
23+
uses: arduino/arduino-lint-action@v1
24+
with:
25+
library-manager: false
26+
project-type: library

.github/workflows/release.yml

Lines changed: 119 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,133 @@
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
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

Comments
 (0)