Repository navigation
188 lines (174 loc) · 9.05 KB
/
Copy pathrelease-verify.yml
File metadata and controls
188 lines (174 loc) · 9.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
name: Verify module release
# The paired check that a module release cannot live without.
#
# ns8-module.yml and docker-publish.yml are both triggered by the same tag push
# and run CONCURRENTLY. That means the module workflow cannot verify that the app
# image exists: on a fresh release the app image is frequently not published yet,
# and checking there races the app publish. It is not a timing bug that a longer
# retry can fix. An app build can lag by however long it takes, and that grows,
# so a longer retry drifts toward never failing a genuinely absent app image,
# while a short one fails releases whose app image was merely slow.
#
# So the pair check lives here instead. This workflow is triggered by the
# COMPLETION of both publish workflows, and the job runs only when both of them
# concluded successfully for the same commit. There is no sleep, no retry budget
# and no polling loop deciding whether to wait: the two completion events are the
# dependency. Whichever workflow finishes last triggers the run that does the
# work, so a slow app publish simply means the job runs later, not that it fails.
#
# Consequence worth knowing: once both have succeeded, an app tag that is still
# absent is genuinely wrong rather than late, so assert-multiarc.py --pair fails
# on it promptly instead of waiting.
on:
workflow_run:
workflows: ['NS8 module publish', 'Docker publish']
types: [completed]
permissions:
actions: read # read the sibling workflow's conclusion for this commit
contents: read # resolve which tag, if any, points at this commit
concurrency:
group: verify-release-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
jobs:
verify:
name: Verify module and app tags agree
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
# Live evidence that the sibling lookup actually works against the real API.
# This runs unconditionally and does not depend on the gate's decision,
# because the previous version of this workflow looked correct in every
# local test and had never once issued the call. A lookup bug must be
# visible in a run log, not inferred from a stubbed unit test.
- name: Probe both publish workflows
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
rc=0
for entry in 'NS8 module publish|ns8-module.yml' 'Docker publish|docker-publish.yml'; do
display="${entry%%|*}"
file="${entry##*|}"
url="repos/${{ github.repository }}/actions/workflows/$file/runs?per_page=100"
if runs=$(gh api "$url" 2>&1); then
echo "$file -> 200, $(printf '%s' "$runs" | jq '.total_count') run(s) on record"
match=$(printf '%s' "$runs" | jq -r --arg sha "$SHA" \
'[.workflow_runs[] | select(.head_sha == $sha)] | first |
"\(.status // "none") \(.conclusion // "none") \(.name // "?")"')
echo "$file run for $SHA: ${match:-none}"
else
echo "::error::lookup failed for '$display' ($file): $runs"
rc=1
fi
done
exit $rc
- name: Wait for both publish workflows to finish
id: gate
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.event.workflow_run.head_sha }}
TRIGGERING: ${{ github.event.workflow_run.name }}
# Both publish workflows must have concluded for this commit. The one
# that triggered this run has already reported; the other may still be
# running, in which case its own completion will trigger us again.
SIBLING: ${{ github.event.workflow_run.name == 'NS8 module publish' && 'Docker publish' || 'NS8 module publish' }}
run: |
set -euo pipefail
echo "triggering workflow: $TRIGGERING (conclusion ${{ github.event.workflow_run.conclusion }})"
echo "waiting for sibling: $SIBLING"
if [ "${{ github.event.workflow_run.conclusion }}" != "success" ]; then
echo "::notice::the triggering workflow did not succeed; nothing to verify."
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "outcome=not-verified" >> "$GITHUB_OUTPUT"
exit 0
fi
# The REST path wants the workflow FILE name or the numeric id. It does
# NOT accept the display name, and answers 404 for one. Measured:
# .../actions/workflows/NS8 module publish/runs -> 404
# .../actions/workflows/ns8-module.yml/runs -> 200
# Under `set -e` that 404 kills the step, so every run of this workflow
# failed at this line and the gate could never verify anything.
#
# Note the display name is still what the workflow_run TRIGGER matches on
# and what github.event.workflow_run.name returns. Only the API path needs
# the file name, so this mapping is deliberately confined to that one call.
case "$SIBLING" in
'NS8 module publish') sibling_file='ns8-module.yml' ;;
'Docker publish') sibling_file='docker-publish.yml' ;;
*)
# Loud, not silent. A renamed workflow must not look like a healthy
# no-op, which is exactly how the previous version of this bug hid.
echo "::error::no file name is mapped for sibling workflow '$SIBLING'."
echo "::error::Add it to the case statement above. The display names live in .github/workflows/ns8-module.yml and docker-publish.yml."
exit 1
;;
esac
echo "sibling display name: $SIBLING"
echo "sibling workflow file: $sibling_file"
runs=$(gh api "repos/${{ github.repository }}/actions/workflows/$sibling_file/runs?per_page=100")
total=$(printf '%s' "$runs" | jq '.total_count')
echo "sibling workflow run count: $total"
read -r status conclusion < <(
printf '%s' "$runs" \
| jq -r --arg sha "$SHA" \
'([.workflow_runs[] | select(.head_sha == $sha)] | first) // {} |
"\(.status // "absent") \(.conclusion // "absent")"'
)
echo "sibling status=$status conclusion=$conclusion"
if [ "$status" != "completed" ]; then
echo "::notice::$SIBLING has not finished for this commit (status=$status)." \
"Its completion will trigger this workflow again. Not a failure."
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "outcome=waiting-for-sibling" >> "$GITHUB_OUTPUT"
elif [ "$conclusion" != "success" ]; then
echo "::error::$SIBLING concluded '$conclusion' for this commit, so this is not a clean release."
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "outcome=blocked" >> "$GITHUB_OUTPUT"
else
echo "ready=true" >> "$GITHUB_OUTPUT"
echo "outcome=verify" >> "$GITHUB_OUTPUT"
fi
- name: Resolve the released tag
if: steps.gate.outputs.ready == 'true'
id: tag
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
# workflow_run does not carry the ref, so find the tag that points at
# this commit. A plain branch push has no such tag and skips, which is
# how this stays out of the way of ordinary commits.
tag=$(gh api "repos/${{ github.repository }}/tags" --paginate \
--jq "map(select(.commit.sha == \"$SHA\")) | map(.name) | map(select(test(\"^v[0-9]+\\\\.[0-9]+\\\\.[0-9]+$\"))) | first // empty" \
| head -1 | tr -d '\r')
if [ -z "$tag" ]; then
echo "::notice::no release tag points at $SHA; not a tag release, skipping."
echo "found=false" >> "$GITHUB_OUTPUT"
else
echo "found=true" >> "$GITHUB_OUTPUT"
echo "name=$tag" >> "$GITHUB_OUTPUT"
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
fi
- name: Assert the module and app tags form a matched multi-arch pair
if: steps.gate.outputs.ready == 'true' && steps.tag.outputs.found == 'true'
working-directory: packaging/ns8
env:
MODULE_REPO: ghcr.io/${{ github.repository_owner }}/ns8-calnode
run: |
# Same version tag plus its major.minor alias, plus :latest. The module
# publishes exactly these three, so all three must resolve to the same
# release and carry the same app reference.
#
# No app repository is passed: the app image is taken from each
# module's org.nethserver.images label, which is the only authoritative
# source for what a module actually runs.
version="${{ steps.tag.outputs.version }}"
major_minor="${version%.*}"
python3 assert-multiarc.py \
--pair \
--expect-app-tag "$version" \
"$MODULE_REPO" \
"$version" "$major_minor" latest