Repository navigation
335 lines (311 loc) · 19.4 KB
/
Copy pathrelease.yml
File metadata and controls
335 lines (311 loc) · 19.4 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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Create Release
on:
push:
branches:
- main
permissions:
contents: read
jobs:
# Tag the docs GIFs this release ships as v<X.Y.Z> on the aicentreflip/docs-gifs dataset
# (FLIP#1547) before `release` pushes the git tag, so ReadTheDocs' first build of the new
# version finds them. A job of its own because it alone needs HF_TOKEN; a failure here does
# not hold the release back (that build then shows the newest recording instead) — re-run it.
docs-gifs:
name: Tag the docs GIFs this release ships
if: github.repository == 'londonaicentre/FLIP'
runs-on: ubuntu-latest
environment: flip
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0 # the script checks which recordings this commit contains
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ./docs/.python-version
- name: Install uv
run: pip install uv
- name: Tag the dataset
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: uv run docs/scripts/freeze_docs_gifs.py --release-commit "$GITHUB_SHA"
release:
name: Tag and Create GitHub Release
needs: docs-gifs
# Skip on forks: they cannot push tags or create releases in the upstream
# repo, so this job would otherwise show a spurious CI failure on a fork's
# main push. Upstream main release behaviour is unchanged.
if: ${{ !cancelled() && github.repository == 'londonaicentre/FLIP' }}
runs-on: ubuntu-latest
permissions: # override top-level read-only default to allow tag/release creation
contents: write
actions: write # dispatch the image builds at the release tag (see the step below)
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Read version from pyproject.toml
id: version
run: |
VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if tag already exists
id: tag_check
run: |
if git rev-parse "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Create tag
if: steps.tag_check.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "${{ steps.version.outputs.tag }}"
git push origin "${{ steps.version.outputs.tag }}"
# Everything after the tag keys on the RELEASE not existing, not the tag: a run that
# pushed the tag and then failed (a dispatch error, a notes API blip) leaves the tag
# behind, and gating on it would make every re-run skip the builds and the release and
# still go green. Re-running now dispatches the fourteen builds again (each re-publishes
# the same :v<X.Y.Z> from the same commit) and creates the release.
- name: Check if the release already exists
id: release_check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
# The image workflows listen for a push of refs/tags/v*.*.* (FLIP#1204) — but
# GitHub starts no workflow for an event created with the repository's own
# GITHUB_TOKEN, which is what the tag push above is (the anti-recursion rule; only
# workflow_dispatch and repository_dispatch are exempt). So the fourteen builds are
# dispatched here explicitly, at the tag, one run each: inside them github.ref is
# refs/tags/v<X.Y.Z> and github.sha the tagged commit, exactly as a hand-pushed
# tag would present. The roster is every publishing image workflow;
# .github/tests/workflows/test_release.py holds it to the set that carries the
# tag trigger, so a workflow added without being listed here fails CI.
- name: Build every image at the release tag
id: dispatch
if: steps.release_check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
# The instant before the first dispatch, so the wait step can tell our runs
# from a previous release attempt's runs on the same commit (a re-run after a
# failed notes step dispatches all fourteen again). Backdated 30s: this is the
# runner's clock and createdAt is GitHub's, and a runner running fast would
# otherwise filter out its own first dispatch and wait for it forever. 30s is
# far below the gap to a previous attempt, so it costs the filter nothing.
echo "started_at=$(date -u -d '30 seconds ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| date -u -v-30S +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
dispatched=()
failed=()
for wf in \
docker_build_flip_api.yml \
docker_build_monailabel.yml \
docker_build_omop_db.yml \
docker_build_orthanc.yml \
docker_build_trust_data_access_api.yml \
docker_build_trust_imaging_api.yml \
docker_build_trust_trust_api.yml \
docker_build_xnat_db.yml \
docker_build_xnat_dcm2niix.yml \
docker_build_xnat_export_mask.yml \
docker_build_xnat_nginx.yml \
docker_build_xnat_web.yml \
fl-docker-build-flower.yml \
fl-docker-build-nvflare.yml; do
# One failed dispatch must not stop the rest: try all fourteen, then fail
# naming the ones to re-run (a re-run of this workflow dispatches them all).
if gh workflow run "$wf" --ref "$TAG"; then
echo "dispatched $wf at $TAG"
dispatched+=("$wf")
else
failed+=("$wf")
fi
done
echo "workflows=${dispatched[*]}" >> "$GITHUB_OUTPUT"
if [[ ${#failed[@]} -gt 0 ]]; then
echo "::error::could not dispatch ${failed[*]} at $TAG — re-run this workflow"
exit 1
fi
# The release apply below pins `:v<X.Y.Z>` and fails closed if it is absent
# (resolve-image-tags.sh, FLIP#1283), so the dispatch must not be fired until those
# images exist. Waiting here rather than inside the apply also keeps the ~30 min of
# build time off the `tf-apply-main` concurrency group, which an apply would hold
# for the whole wait.
#
# The runs are found by (workflow, headSha, event, headBranch, createdAt >= this
# run's dispatch), not by id: `gh workflow run` returns nothing to key on, and the
# runs appear asynchronously — a workflow with no run yet is still pending, not
# missing.
#
# `--commit` ALONE IS NOT ENOUGH. The tagged commit is also main's head, so the
# branch builds of the same commit report the same headSha — and five of the image
# workflows additionally run from `workflow_run` after main's test suites, minutes
# after STARTED_AT, so they clear the createdAt filter too. Taking the newest match
# would then clear the wait on a branch run while the `:v<X.Y.Z>` build is still
# going (the apply then dies in the resolver), or report a good release red because
# a branch run was skipped. `event == "workflow_dispatch"` plus `headBranch == TAG`
# identifies the runs this step dispatched: only a dispatch at the tag presents both.
#
# Only a *concluded* non-success is a failure; anything still queued or running keeps
# the poll going until the budget expires, and an expiry is a hard failure, never a
# silent skip. A failed poll is "still pending", not fatal — a single 502 used to
# abort the wait under `set -e` and cost a re-run of all fourteen builds — but a run of
# them in a row is a real outage and stops the release.
- name: Wait for the release builds to go green
if: steps.release_check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
HEAD_SHA: ${{ github.sha }}
WORKFLOWS: ${{ steps.dispatch.outputs.workflows }}
STARTED_AT: ${{ steps.dispatch.outputs.started_at }}
# xnat-web is the long pole at ~30 min; the budget is generous because the
# cost of expiring early is a red release whose images are in fact fine.
BUILD_WAIT_SECONDS: ${{ vars.RELEASE_BUILD_WAIT_SECONDS || 5400 }}
BUILD_POLL_SECONDS: ${{ vars.RELEASE_BUILD_POLL_SECONDS || 30 }}
BUILD_POLL_MAX_FAILURES: ${{ vars.RELEASE_BUILD_POLL_MAX_FAILURES || 10 }}
run: bash "$GITHUB_WORKSPACE/.github/scripts/wait-for-release-builds.sh"
# generate_release_notes lets GitHub choose the previous release itself, which is
# whatever was published most recently — including a flip-utils release cut seconds
# after ours on the same push. Pin the range to the previous *platform* tag instead,
# the way release-pypi.yml already does for its own artefact.
- name: Prepare release notes
if: steps.release_check.outputs.exists == 'false'
id: notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.version.outputs.tag }}"
# `|| true` is load-bearing under `set -o pipefail` — the tag for THIS release
# was created by the step above, so when it is the only match grep exits 1.
# Empty is a valid answer and is handled below.
# Stable releases only: a release candidate (v<X.Y.Z>-rc.N) sorts above its own
# release under sort -V, and would start the notes at the candidate.
PREV_TAG=$(git tag --list 'v*.*.*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | grep -v "^${TAG}$" | tail -n1 || true)
API_ARGS=(-X POST
-f "tag_name=${TAG}"
-f "target_commitish=${{ github.sha }}")
if [[ -n "$PREV_TAG" ]]; then
API_ARGS+=(-f "previous_tag_name=${PREV_TAG}")
echo "Generating notes for ${PREV_TAG}...${TAG}"
else
echo "No previous platform tag found — generating notes from the start."
fi
GENERATED=$(gh api "repos/${{ github.repository }}/releases/generate-notes" \
"${API_ARGS[@]}" --jq '.body')
# The curated header (Highlights, Breaking Changes, Site upgrade, …) from the
# release-notes template goes ABOVE the generated changelog, the same way
# release-pypi.yml publishes it — the "Site upgrade" section is what trust
# operators act on, so it belongs on the platform release.
# The licence block is stripped by its whole-line delimiters — see
# release-pypi.yml for why an unanchored range would eat every section.
VERSION="${{ steps.version.outputs.version }}"
HEADER=$(sed '/^<!--$/,/^-->$/d' "$GITHUB_WORKSPACE/.github/RELEASE_NOTES_TEMPLATE.md" \
| sed "s/{{PROJECT}}/FLIP/g" \
| sed "s/{{VERSION}}/${VERSION}/g" \
| sed "s/{{TAG}}/${TAG}/g" \
| sed "s/{{PREV_TAG}}/${PREV_TAG:-initial release}/g")
{
printf '%s\n\n' "$HEADER"
printf '%s\n' "$GENERATED"
} > /tmp/release_notes.md
echo "notes_file=/tmp/release_notes.md" >> "$GITHUB_OUTPUT"
# A release can now trigger a production apply. The ref is refs/heads/main and must
# stay there: the apply role's trust policy pins job_workflow_ref to
# terraform_apply.yml@refs/heads/{develop,main}, so dispatching at refs/tags/v<X.Y.Z>
# — the intuitive choice, since the images are built there — fails OIDC with an
# unhelpful "not authorized to perform sts:AssumeRoleWithWebIdentity".
#
# The push apply of the release commit pins its `sha-<short7>` images; only this
# dispatch pins the release, so without it the hub keeps reporting `sha-<short7>`
# (FLIP#1283). Its `ui` job then publishes the release's flip-ui (FLIP#1186).
#
# BEFORE "Create GitHub Release", DELIBERATELY. Both steps are gated on the release
# not existing, which is the re-run contract: a run that fails partway must be
# re-runnable into the same end state. With the dispatch last, a `gh workflow run`
# that failed (an API blip, a token scope change) left the release already created,
# so the re-run skipped the wait AND the dispatch and went green with production
# still on the previous release — exactly the failure the gate exists to prevent.
# Dispatching first makes the Release the last thing to happen, so its existence
# genuinely means "everything before it succeeded". The builds, the wait and the
# apply are all idempotent, so a re-run repeating them is safe.
#
# Ordering against the push-triggered apply of this same commit: both land in the
# concurrency group `tf-apply-main` with cancel-in-progress: false, so this dispatch
# queues behind the in-flight push apply rather than cancelling it, and applies
# second. Second is the correct order — the push apply pins `sha-<short7>`, this one
# pins `v<X.Y.Z>@sha256:…` on top. In practice the push apply is long finished: the
# wait above has just spent the builds' lifetime. Note that the group protects a
# RUNNING apply only: a queued one is displaced by the next run queued on main, which
# is what the confirm step below exists to catch.
#
# The main-HEAD check is the cheap half of the rollback guard. The apply re-checks it
# authoritatively (terraform_apply.yml refuses unless the tag IS the commit it has
# checked out), but failing here means the operator is told before a production apply
# is queued rather than after.
- name: Dispatch the production Terraform apply at the release tag
id: apply_dispatch
if: steps.release_check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
MAIN_SHA="$(gh api "repos/${{ github.repository }}/commits/main" --jq '.sha')"
if [[ "$MAIN_SHA" != "$HEAD_SHA" ]]; then
echo "::error::main is now $MAIN_SHA but $TAG is $HEAD_SHA — something merged during the build wait. A release apply would run main's current code with $TAG's images, rolling production back. Cut a release from $MAIN_SHA instead."
exit 1
fi
echo "apply_started_at=$(date -u -d '30 seconds ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| date -u -v-30S +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
if ! gh workflow run terraform_apply.yml --ref refs/heads/main -f release_tag="$TAG"; then
echo "::error::could not dispatch terraform_apply.yml for $TAG — production was NOT re-pinned. Re-run this workflow, or dispatch it by hand: gh workflow run terraform_apply.yml --repo ${{ github.repository }} --ref refs/heads/main -f release_tag=$TAG"
exit 1
fi
echo "dispatched terraform_apply.yml on refs/heads/main with release_tag=$TAG"
# A successful dispatch is not a run. `tf-apply-main` keeps one pending run, so the
# next apply queued on main — an operator's `fl_quiesced: true` re-dispatch of a held
# push apply, say — replaces the queued release apply, and without this step the
# release goes green with production still on the previous one (FLIP#1283).
- name: Confirm the release apply actually ran
if: steps.release_check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ steps.version.outputs.tag }}
STARTED_AT: ${{ steps.apply_dispatch.outputs.apply_started_at }}
APPLY_CONFIRM_SECONDS: ${{ vars.RELEASE_APPLY_CONFIRM_SECONDS || 900 }}
APPLY_CONFIRM_POLL_SECONDS: ${{ vars.RELEASE_APPLY_CONFIRM_POLL_SECONDS || 15 }}
run: bash "$GITHUB_WORKSPACE/.github/scripts/confirm-release-apply.sh"
- name: Create GitHub Release
if: steps.release_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.tag }}
name: "Release ${{ steps.version.outputs.tag }}"
body_path: ${{ steps.notes.outputs.notes_file }}