-
Notifications
You must be signed in to change notification settings - Fork 1
547 lines (522 loc) · 25.2 KB
/
Copy pathci.yml
File metadata and controls
547 lines (522 loc) · 25.2 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
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
name: CI
# One workflow, matching devup-ui and the other org projects: verification,
# changepacks version management, binary builds and release publication all
# live here rather than in a second file that can drift out of step.
#
# Release flow (driven by changepacks/action, not by hand):
# 1. A pull request touching crates/ must carry a changepack. `changepacks`
# comments the detected packs; `changepack-required` makes it a gate.
# 2. On push to main with pending changepacks, the action opens an
# "Update Versions" pull request that runs `changepacks update`.
# 3. Merging that PR leaves no changepacks, so the action cuts tags and
# *draft* releases and reports them in `pending_releases`.
# 4. `build` compiles every MCP binary for all three platforms and uploads
# them onto those drafts.
# 5. `bundle` collects those same three binaries back into one `.mcpb`
# MCP Bundle, so a user on any operating system installs the one file
# rather than picking a binary and wiring up stdio by hand.
# 6. `finalize` publishes the drafts, but only once the uploads succeeded —
# so a release is never visible without its binaries and its bundle.
on:
push:
branches: [main]
pull_request:
# The vendored agent skills go stale when the repositories they were copied
# from move, which is a thing that happens on someone else's schedule and
# that no commit here will ever announce. `skill-drift` below is the only
# job these two triggers exist for.
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
# Pinned so a release cannot start producing a differently shaped bundle on
# its own. `pack` writes the archive's Unix permission bits from the mode it
# finds on disk in this version, which is why `bundle` chmods first.
MCPB_CLI_VERSION: "2.1.2"
jobs:
# The action comments the changepack status on a pull request but does not
# fail it. A crate change that ships without a changepack never moves the
# version, so it never releases — this turns that silent outcome into a
# red check with the command to fix it.
changepack-required:
name: changepack required
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Require a changepack for crate changes
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
base="$(git merge-base "$BASE_SHA" "$HEAD_SHA")"
changed="$(git diff --name-only "$base" "$HEAD_SHA")"
crate_changes="$(printf '%s\n' "$changed" | grep -E '^crates/' || true)"
if [ -z "$crate_changes" ]; then
echo "No crate sources touched; a changepack is not required."
exit 0
fi
log_changes="$(printf '%s\n' "$changed" \
| grep -E '^\.changepacks/changepack_log_.*\.json$' || true)"
if [ -n "$log_changes" ]; then
echo "Changepack present:"
printf ' %s\n' $log_changes
exit 0
fi
{
echo "This pull request changes crate sources but adds no changepack log."
echo
echo "Without one the workspace version never moves, so the change"
echo "ships to main and is never released."
echo
echo " cargo install changepacks"
echo " changepacks"
echo
echo "Pick the affected crates, choose Major/Minor/Patch, and write"
echo "the release note, then commit the generated"
echo ".changepacks/changepack_log_*.json alongside your change."
echo
echo "--- crate files changed without a changepack ---"
printf ' %s\n' $crate_changes
} >&2
exit 1
# `cargo test` already holds the manifest to the documents in this tree: a
# hand-edited vendored copy fails `every_origin_is_well_formed` on its digest.
# What nothing checked is the other direction — whether the copy still matches
# the repository it was taken from. That drift is caused by another project
# publishing, so it arrives without a commit here and no pull request notices.
#
# Deliberately NOT a pull request gate. The thing it reports is someone else's
# change, and failing unrelated pull requests over it is a known-bad trade:
# devup-ui pinned a dependency to `latest` next to a committed generated file
# and every pull request against main went red for a staleness none of them
# caused. A weekly run mails the owner and blocks nobody.
skill-drift:
name: refresh vendored skills
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Re-vendor each skill from its source repository
# Authenticated so the GitHub API rate limit is per-repository rather
# than per-runner-IP, which unauthenticated scheduled runs share.
env:
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/refresh-skills.mjs
# A red scheduled run is a notification nobody acts on. A pull request is
# the same information in a form that can be reviewed and merged, and it
# carries the refreshed bytes so nobody has to reproduce them by hand.
- name: Open a pull request when a document moved
env:
GH_TOKEN: ${{ github.token }}
BRANCH: skills/refresh-vendored-documents
shell: bash
run: |
set -euo pipefail
if git diff --quiet; then
echo "Every vendored document already matches its source."
exit 0
fi
moved="$(git diff --name-only -- crates/devup-mcp/src/server/skills \
| grep -E '/SKILL\.md$' | sed 's#.*/skills/##; s#/SKILL\.md##' | paste -sd', ' -)"
echo "Moved upstream: ${moved:-none}"
# A re-vendored document changes what `devup_skills install` writes,
# so it is a shipped behaviour change and needs a version. Without
# this the `changepack required` gate fails this pull request, and
# the refresh would sit unreleased.
jq -n --arg moved "$moved" --arg date "$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" '{
changes: { "crates/devup-mcp/Cargo.toml": "Patch" },
note: ("Re-vendored the embedded agent skill documents that moved in their source repositories: " + $moved + ". The copies in this binary are what `devup_skills install` writes on a machine with no network, so a copy that has fallen behind installs rules the upstream project no longer states. Opened automatically by the scheduled skill-drift job; the bytes here are exactly what scripts/refresh-skills.mjs produced."),
date: $date
}' > .changepacks/changepack_log_skill_refresh.json
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
git add crates/devup-mcp/src/server/skills .changepacks
git commit -m "chore(skills): re-vendor the documents that moved upstream" \
-m "Automated by the scheduled skill-drift job. Moved: ${moved:-none}."
# Force is safe and intended here: this branch is owned by this job
# and is rewritten from main on every run, so a stale refresh never
# accumulates on top of a newer one.
git push --force origin "$BRANCH"
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" ]; then
gh pr create --base main --head "$BRANCH" \
--title "chore(skills): re-vendor the documents that moved upstream" \
--body "The embedded copies of these skills fell behind their source repositories: **${moved:-none}**.
Those copies are what \`devup_skills install\` writes on a machine with no network, so a stale one installs rules the upstream project no longer states. \`cargo test\` cannot see this: it checks the manifest against the documents in this tree, which agree with each other while both are behind.
Produced by \`node scripts/refresh-skills.mjs\` in the scheduled \`skill-drift\` job. Review the diff as you would any skill change - it is upstream prose, not generated code."
else
echo "An open pull request already tracks this branch; it now carries the newer bytes."
fi
verify:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: dtolnay/rust-toolchain@1.98.0
with:
components: rustfmt, clippy
# Run before rust-cache (which may itself invoke cargo metadata) and
# all builds: otherwise Cargo repairs a stale lock and hides the defect.
- name: Require an up-to-date committed Cargo.lock
shell: bash
run: cargo metadata --locked --format-version 1 > /dev/null
- uses: Swatinem/rust-cache@v2
- run: cargo install cargo-insta --version 1.48.0 --locked
- run: cargo fmt --all -- --check
- run: node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs plugin/tests/withdraw.test.mjs
# `plugin/dist/` is committed so a user can import the plugin into Figma
# without running a build. That only stays true if the committed bundle
# is the one this source produces, so rebuild it and refuse a difference.
# The build is reproducible: same input, byte-identical output.
- name: The committed plugin bundle matches its source
if: matrix.os == 'ubuntu-latest'
shell: bash
working-directory: plugin
run: |
npm ci
npm run build
git diff --exit-code -- dist || {
echo "::error::plugin/dist is stale. Run 'npm run build' in plugin/ and commit the result."
exit 1
}
- run: cargo test --locked -p devup-mcp --test stdio_smoke
- run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings
- run: cargo insta test --workspace --all-features --check
- run: cargo build --locked --workspace --release
- name: Test automatic version and lockfile synchronization
if: matrix.os == 'ubuntu-latest'
run: python3 .github/scripts/test_release_lock.py
# The bundle is only assembled on a release run, so without this the
# first time anyone learns the manifest is malformed is after the tag
# and the draft release already exist. Schema-check it on every pull
# request instead, against the same rendering step `bundle` performs.
- name: Validate the MCPB manifest
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
version="$(bash packaging/mcpb/workspace-version.sh)"
mkdir -p build
jq --arg version "$version" '.version = $version' \
packaging/mcpb/manifest.json > build/manifest.json
npx --yes "@anthropic-ai/mcpb@$MCPB_CLI_VERSION" validate build/manifest.json
changepacks:
name: changepacks
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# changepacks diffs HEAD against the previous release commit; the
# default shallow fetch grafts away every parent, so that lookup
# fails and the release never publishes.
fetch-depth: 0
fetch-tags: true
# This job runs after `verify`, which takes about ten minutes, so by the
# time it evaluates, main may already be several commits further on. It
# then judges a state that no longer exists: on 2026-09-08 three merges
# landed inside one such window, each stale run opened a fresh Update
# Versions pull request instead of releasing, all three were merged, and
# the workspace went 0.2.1 -> 0.3.0 -> 0.3.1 while the newest release
# stayed 0.2.1. Nothing was tagged, and re-running could not recover it,
# because the action only cuts a release in the run that actually
# consumes the changepacks - and by then there were none left.
#
# Only the run for the current tip may act. A superseded run stops here,
# and the run for the commit that replaced it does the work.
- name: Stop if a newer commit already replaced this one
id: freshness
shell: bash
run: |
set -euo pipefail
head="$(git ls-remote origin refs/heads/main | cut -f1)"
echo "this run: $GITHUB_SHA"
echo "main tip: $head"
if [ -n "$head" ] && [ "$head" != "$GITHUB_SHA" ]; then
echo "superseded=true" >> "$GITHUB_OUTPUT"
echo "Superseded by $head; leaving the release decision to its run."
else
echo "superseded=false" >> "$GITHUB_OUTPUT"
fi
- uses: dtolnay/rust-toolchain@1.98.0
if: steps.freshness.outputs.superseded == 'false' && github.event_name == 'push' && github.ref == 'refs/heads/main'
# The upstream action stages and commits immediately after its update,
# with --no-verify and no post-update extension point. Scope a Git adapter
# to this action only, running cargo update --workspace BEFORE the real
# version commit. A post-action commit could race PR auto-merge; a
# prepare-commit-msg hook runs after Git has already cached its tree.
# The adapter survives the action's reset/clean in RUNNER_TEMP, forwards
# every other command unchanged, and refuses a stale version-branch push.
- name: Prepare same-commit release lockfile synchronization
id: release_git
if: steps.freshness.outputs.superseded == 'false'
shell: bash
env:
ENABLE_SYNC: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
echo "real_git=$(command -v git)" >> "$GITHUB_OUTPUT"
if [ "$ENABLE_SYNC" = "true" ]; then
adapter="$RUNNER_TEMP/devup-release-git"
mkdir -p "$adapter"
cp .github/scripts/release_git.py "$adapter/git"
chmod +x "$adapter/git"
echo "path=$adapter:$PATH" >> "$GITHUB_OUTPUT"
else
echo "path=$PATH" >> "$GITHUB_OUTPUT"
fi
- uses: changepacks/action@main
id: changepacks
if: steps.freshness.outputs.superseded == 'false'
env:
PATH: ${{ steps.release_git.outputs.path }}
DEVUP_RELEASE_REAL_GIT: ${{ steps.release_git.outputs.real_git }}
DEVUP_RELEASE_LOCK_SYNC: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
with:
token: ${{ secrets.GITHUB_TOKEN }}
create_release: true
outputs:
changepacks: ${{ steps.changepacks.outputs.changepacks }}
release_assets_urls: ${{ steps.changepacks.outputs.release_assets_urls }}
pending_releases: ${{ steps.changepacks.outputs.pending_releases }}
build:
name: build (${{ matrix.os }})
needs: changepacks
# Only when a draft release is actually waiting for assets. On a pull
# request, or on a push that merely opened the Update Versions PR, there
# is nothing to attach to.
if: >-
needs.changepacks.outputs.pending_releases != ''
&& needs.changepacks.outputs.pending_releases != '{}'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
targets: x86_64-unknown-linux-gnu
suffix: linux-x86_64
ext: ""
- os: windows-latest
targets: x86_64-pc-windows-msvc
suffix: windows-x86_64
ext: ".exe"
- os: macos-latest
# Fused into one universal binary so a single macOS asset runs on
# both Apple Silicon and Intel.
targets: aarch64-apple-darwin x86_64-apple-darwin
suffix: macos-universal
ext: ""
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@v2
- name: Build release binaries
shell: bash
env:
TARGETS: ${{ matrix.targets }}
SUFFIX: ${{ matrix.suffix }}
EXT: ${{ matrix.ext }}
OS: ${{ matrix.os }}
# Name the commit outright instead of letting build.rs infer it.
#
# Explicit release identity remains deterministic. R5 synchronizes
# Cargo.lock in the version commit, preventing the old automatic
# Cargo rewrite that made released binaries report <sha>-dirty.
DEVUP_MCP_BUILD_ID: ${{ github.sha }}
run: |
set -euo pipefail
for target in $TARGETS; do
rustup target add "$target"
cargo build --locked --release --target "$target" -p devup-mcp
done
# Only the MCP server ships. devup-mcp-visual is a PNG comparator
# the render harness builds from source with cargo; nothing
# downloads it, so three of the six release assets were binaries no
# consumer had a use for while still costing a build on every
# platform of every release.
mkdir -p dist
out="dist/devup-mcp-${SUFFIX}${EXT}"
if [ "$OS" = "macos-latest" ]; then
lipo -create -output "$out" \
"target/aarch64-apple-darwin/release/devup-mcp" \
"target/x86_64-apple-darwin/release/devup-mcp"
file "$out"
else
set -- $TARGETS
cp "target/$1/release/devup-mcp${EXT}" "$out"
fi
ls -l dist
# `bundle` needs all three platforms' server binaries in one place, and
# each is produced on a different runner.
- name: Hand the server binary to the bundle job
uses: actions/upload-artifact@v7
with:
name: mcpb-binary-${{ matrix.suffix }}
path: dist/devup-mcp-${{ matrix.suffix }}${{ matrix.ext }}
if-no-files-found: error
retention-days: 1
- name: Upload binaries onto the draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }}
run: |
set -euo pipefail
# release_assets_urls maps project path -> asset upload URL. The
# binaries belong to the devup-mcp crate; the library crates get
# their own releases with no assets.
upload="$(printf '%s' "$ASSET_URLS" \
| jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')"
if [ -z "$upload" ]; then
echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2
printf '%s\n' "$ASSET_URLS" >&2
exit 1
fi
# Drop the RFC 6570 template suffix, e.g. "{?name,label}".
upload="${upload%%\{*}"
for file in dist/*; do
name="$(basename "$file")"
echo "uploading $name"
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file" \
"${upload}?name=${name}" >/dev/null
done
# Six loose binaries make a user pick the right file for their machine and
# then wire stdio up by hand. One `.mcpb` — a zip carrying manifest.json and
# all three platform binaries — installs in a click on any of them, because
# the manifest's platform_overrides selects the matching command at launch.
bundle:
name: bundle mcpb
needs: [changepacks, build]
if: >-
needs.changepacks.outputs.pending_releases != ''
&& needs.changepacks.outputs.pending_releases != '{}'
# Must not be windows-latest. mcpb writes the archive's Unix permission
# bits only when packing from a Unix host; from Windows it writes none at
# all, and macOS and Linux then fail to launch the binary with EACCES.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: actions/download-artifact@v8
with:
pattern: mcpb-binary-*
path: artifacts
merge-multiple: true
- name: Assemble and pack the bundle
shell: bash
run: |
set -euo pipefail
version="$(bash packaging/mcpb/workspace-version.sh)"
echo "bundling devup-mcp $version"
mkdir -p build/server/linux build/server/macos build/server/win
cp artifacts/devup-mcp-linux-x86_64 build/server/linux/devup-mcp
cp artifacts/devup-mcp-macos-universal build/server/macos/devup-mcp
cp artifacts/devup-mcp-windows-x86_64.exe build/server/win/devup-mcp.exe
# The artifact round trip does not carry file modes, and the pinned
# mcpb copies the mode it finds rather than forcing +x on the entry
# point, so the executable bit has to be put back here.
chmod +x build/server/linux/devup-mcp build/server/macos/devup-mcp
jq --arg version "$version" '.version = $version' \
packaging/mcpb/manifest.json > build/manifest.json
# `mcpb validate` only ever looks at server.entry_point, so a typo in
# a platform_overrides path would ship a bundle that is broken on
# exactly one operating system and healthy on the two we can see from
# here. Check every command the manifest is able to select.
missing=0
while read -r referenced; do
[ -n "$referenced" ] || continue
if [ ! -f "build/$referenced" ]; then
echo "manifest.json names a file the bundle does not carry: $referenced" >&2
missing=1
fi
done < <(jq -r '
[ .server.entry_point,
.server.mcp_config.command,
(.server.mcp_config.platform_overrides // {} | .[] | .command // empty) ]
| .[] | ltrimstr("${__dirname}/")' build/manifest.json | sort -u)
[ "$missing" -eq 0 ]
npx --yes "@anthropic-ai/mcpb@$MCPB_CLI_VERSION" pack build "devup-mcp-$version.mcpb"
echo "MCPB_FILE=devup-mcp-$version.mcpb" >> "$GITHUB_ENV"
# A bundle whose binaries lost their executable bit installs cleanly and
# then fails at first launch, which is exactly the kind of defect a
# release should not be able to carry. Read it back out of the archive.
- name: Verify the archive kept the executable bit
shell: bash
run: |
set -euo pipefail
for entry in server/linux/devup-mcp server/macos/devup-mcp; do
mode="$(unzip -Z "$MCPB_FILE" "$entry" \
| awk -v entry="$entry" '$NF == entry { print $1 }')"
echo "$entry: ${mode:-<absent>}"
# mcpb stores the permission bits alone, without the regular-file
# type bits, so zipinfo prints the type column as "?" rather than
# "-" even for a correctly packed archive. Only the owner execute
# position is meaningful, and an absent entry yields an empty
# string, which fails this the same way a non-executable one does.
if [ "${mode:3:1}" != "x" ]; then
echo "$entry is not executable inside $MCPB_FILE" >&2
exit 1
fi
done
- name: Upload the bundle onto the draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }}
run: |
set -euo pipefail
upload="$(printf '%s' "$ASSET_URLS" \
| jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')"
if [ -z "$upload" ]; then
echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2
printf '%s\n' "$ASSET_URLS" >&2
exit 1
fi
# Drop the RFC 6570 template suffix, e.g. "{?name,label}".
upload="${upload%%\{*}"
echo "uploading $MCPB_FILE"
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$MCPB_FILE" \
"${upload}?name=${MCPB_FILE}" >/dev/null
finalize:
name: finalize release
needs: [changepacks, build, bundle]
if: >-
needs.changepacks.outputs.pending_releases != ''
&& needs.changepacks.outputs.pending_releases != '{}'
runs-on: ubuntu-latest
steps:
# Finalize-only: the action neither installs changepacks nor touches the
# repository here, so no checkout is needed. Running it after `build`
# is what guarantees a published release always has its binaries.
- uses: changepacks/action@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
finalize_releases: ${{ needs.changepacks.outputs.pending_releases }}