Skip to content

Commit d7fcd7c

Browse files
committed
ci: verify the release owes every asset by name, not by count
verify-assets asserted only `assets | length -eq 0`, so a release that built ONE target out of five passed the gate. That is how busbar v1.5.3 shipped five assets where seven were expected, with Apple Silicon Mac and x86_64 Linux both 404ing for users. A count can never see a MISSING platform; only a name can. Ported busbar core release.yml`s contractual-filename-set approach: * .github/release-targets.json is now the platform list, in exactly one place. A `targets` job reads it and emits BOTH the build matrix and the exact set of asset filenames that matrix owes the release, so the set that is built and the set that is verified are the same computation and cannot drift. It carries a floor so a truncated manifest cannot produce an empty expectation list that passes vacuously. * verify-assets now asserts every expected asset is present BY NAME and at least 1 KiB (GitHub lists a truncated upload identically to a good one), prints a per-asset table to the step summary, and names the missing platforms in the failure. * verify-assets runs on `!cancelled()`. The build matrix is fail-fast:false, so a partial matrix FAILS the job and a `needs:` on a failed job SKIPS its dependent by default: the one guard that exists to notice a broken release was switched off precisely when the release was broken. The build matrix is byte-identical to what it was, just sourced from the manifest. Verified locally against synthetic asset sets: complete passes; one-platform-missing, one-of-five, truncated, zero-asset, and right-count-wrong-name are all refused.
1 parent 32f07cf commit d7fcd7c

2 files changed

Lines changed: 213 additions & 70 deletions

File tree

.github/release-targets.json

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"_comment": [
3+
"THE PLATFORM LIST, IN EXACTLY ONE PLACE.",
4+
"",
5+
"release.yml's `targets` job reads this file and emits TWO things from it: the build matrix the",
6+
"`store-plugin` job runs, and the exact set of release-asset filenames that matrix is contractually",
7+
"obliged to produce. `verify-assets` asserts every one of those names is present on the DRAFT",
8+
"release before promoting it, so adding or removing a platform is ONE edit here and its",
9+
"verification comes along automatically.",
10+
"",
11+
"WHY A NAME AND NOT A COUNT. busbar v1.5.3 published FIVE assets where SEVEN were expected:",
12+
"aarch64-apple-darwin and x86_64-unknown-linux-gnu were both missing, which is Apple Silicon Mac",
13+
"and x86_64 Linux, the two most common platforms there are. The guard of the day asserted",
14+
"`assets != 0`, which a five-asset release passes comfortably. A COUNT CAN NEVER SEE A MISSING",
15+
"PLATFORM; ONLY A NAME CAN. And a hardcoded expected-names list inside the verifier would just be",
16+
"a SECOND place to forget a platform, which is the same defect one level up -- hence one file,",
17+
"two derived outputs.",
18+
"",
19+
"FIELDS, all of which are inputs to the SAME build steps, never selectors for different ones:",
20+
" target the rust target triple. The published asset is always",
21+
" <asset_prefix>-<version>-<target>.tar.gz -- plugin-pack writes a tarball on every",
22+
" platform, Windows included.",
23+
" os the GitHub-hosted runner label that builds this target natively.",
24+
" libext the cdylib extension this platform produces (so / dylib / dll).",
25+
" libprefix the cdylib filename prefix ('lib' everywhere except MSVC)."
26+
],
27+
"asset_prefix": "busbar-store-valkey",
28+
"targets": [
29+
{
30+
"target": "x86_64-unknown-linux-gnu",
31+
"os": "ubuntu-latest",
32+
"libext": "so",
33+
"libprefix": "lib"
34+
},
35+
{
36+
"target": "aarch64-unknown-linux-gnu",
37+
"os": "ubuntu-24.04-arm",
38+
"libext": "so",
39+
"libprefix": "lib"
40+
},
41+
{
42+
"target": "x86_64-apple-darwin",
43+
"os": "macos-latest",
44+
"libext": "dylib",
45+
"libprefix": "lib"
46+
},
47+
{
48+
"target": "aarch64-apple-darwin",
49+
"os": "macos-latest",
50+
"libext": "dylib",
51+
"libprefix": "lib"
52+
},
53+
{
54+
"target": "x86_64-pc-windows-msvc",
55+
"os": "windows-latest",
56+
"libext": "dll",
57+
"libprefix": ""
58+
}
59+
]
60+
}

.github/workflows/release.yml

Lines changed: 153 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -61,40 +61,68 @@ jobs:
6161
exit "$status"
6262
fi
6363
64+
# THE PLATFORM LIST, IN EXACTLY ONE PLACE. This job reads .github/release-targets.json and emits
65+
# BOTH the build matrix `store-plugin` runs AND the exact set of asset filenames that matrix is
66+
# contractually obliged to produce. `store-plugin` consumes the first; `verify-assets` consumes the
67+
# second -- so "the set that was supposed to be built" and "the set that gets verified" are
68+
# literally the same computation and cannot drift apart.
69+
#
70+
# WHY IT IS A JOB AND NOT A LITERAL MATRIX. busbar v1.5.3 published FIVE assets where seven were
71+
# expected, and the guard of the day asserted `assets != 0`, which a five-asset release passes
72+
# comfortably. A count can never see a MISSING platform; only a name can. A hardcoded
73+
# expected-names list in the verifier would be a second place to forget a platform, which is the
74+
# same defect one level up.
75+
targets:
76+
name: release target matrix (single source of truth)
77+
runs-on: ubuntu-latest
78+
outputs:
79+
matrix: ${{ steps.emit.outputs.matrix }}
80+
assets: ${{ steps.emit.outputs.assets }}
81+
steps:
82+
- uses: actions/checkout@v7
83+
- name: Emit the target matrix and the asset names it must produce
84+
id: emit
85+
run: |
86+
set -euo pipefail
87+
python3 - <<'PY' >> "$GITHUB_OUTPUT"
88+
import json, os
89+
spec = json.load(open(".github/release-targets.json"))
90+
tag = os.environ["GITHUB_REF_NAME"]
91+
ver = tag[1:] if tag.startswith("v") else tag
92+
# EVERY per-target difference travels in the matrix as a PARAMETER, so there is no `if:`
93+
# and no second build path for a property to be established on one and unproven on the
94+
# other.
95+
fields = ("target", "os", "libext", "libprefix")
96+
inc = [{k: t[k] for k in fields} for t in spec["targets"]]
97+
assets = ["%s-%s-%s.tar.gz" % (spec["asset_prefix"], ver, t["target"])
98+
for t in spec["targets"]]
99+
# A FLOOR, BECAUSE A LOOP OVER A DISCOVERED SET WITH NO FLOOR PASSES WHEN THE SET IS EMPTY.
100+
# Both the build matrix and the expectation list are enumerated from this output, so a
101+
# truncated or mis-parsed manifest would otherwise build nothing, expect nothing, and
102+
# report green all the way to a promoted release with no assets on it.
103+
if len(inc) < 5:
104+
raise SystemExit(
105+
"release-targets.json declares %d targets; this plugin ships 5. Refusing to "
106+
"run a build matrix and an expectation list over a set this small: an empty "
107+
"expectation list passes for a release that published nothing." % len(inc))
108+
print("matrix=" + json.dumps({"include": inc}))
109+
print("assets=" + json.dumps(assets))
110+
PY
111+
cat "$GITHUB_OUTPUT"
112+
64113
# One signed .tar.gz per target: {cdylib + manifest.json}, packed by busbar-plugin-pack and
65114
# signed with the busbar release PRIVATE key (BUSBAR_SIGN_KEY secret) so it verifies as
66115
# first-party against the PUBLIC key embedded in busbar's own release binaries. If that secret
67116
# isn't provisioned on this repo, falls back to an UNSIGNED tarball (loadable only under
68117
# plugins.trust.allow_unsigned) rather than blocking the release — same seam busbarAI's own
69118
# release.yml documents (TODO(release-keys)).
70119
store-plugin:
71-
needs: create-release
120+
needs: [create-release, targets]
72121
name: store-plugin (${{ matrix.target }})
73122
runs-on: ${{ matrix.os }}
74123
strategy:
75124
fail-fast: false
76-
matrix:
77-
include:
78-
- target: x86_64-unknown-linux-gnu
79-
os: ubuntu-latest
80-
libext: so
81-
libprefix: lib
82-
- target: aarch64-unknown-linux-gnu
83-
os: ubuntu-24.04-arm
84-
libext: so
85-
libprefix: lib
86-
- target: x86_64-apple-darwin
87-
os: macos-latest
88-
libext: dylib
89-
libprefix: lib
90-
- target: aarch64-apple-darwin
91-
os: macos-latest
92-
libext: dylib
93-
libprefix: lib
94-
- target: x86_64-pc-windows-msvc
95-
os: windows-latest
96-
libext: dll
97-
libprefix: ""
125+
matrix: ${{ fromJSON(needs.targets.outputs.matrix) }}
98126
steps:
99127
- name: Checkout store-valkey
100128
uses: actions/checkout@v7
@@ -168,65 +196,120 @@ jobs:
168196
with:
169197
subject-path: "plugin-dist/*.tar.gz"
170198

171-
# PHANTOM-RELEASE GUARD: assert the published Release actually carries assets before we treat this
172-
# as a real release. The per-target build/upload jobs run with fail-fast:false, and `create-release`
173-
# always makes the (initially empty) Release up front — so a build/pack failure on EVERY target
174-
# (e.g. a stale Cargo.lock tripping `--locked`) leaves a tag + Release with ZERO assets: a "phantom"
175-
# that silently breaks busbar's plugin-registry-gate. This job fails the whole release run loud if
176-
# assets == 0, so a phantom can never ship (or notify downstream) unnoticed. It depends on the build
177-
# matrix but does NOT inherit its fail-fast:false — one green target is enough to have assets, but
178-
# zero across the board must hard-fail here.
199+
# PHANTOM- AND PARTIAL-RELEASE GUARD, AND THE ONLY THING THAT EVER PUBLISHES. It asserts the DRAFT
200+
# carries every asset the matrix owes it, BY NAME, and only then promotes it to published+latest.
201+
# Nothing above this job is user-facing: `create-release` makes a DRAFT, which does not resolve as
202+
# `releases/latest` and is invisible to `gh release download`, so a red verdict here stops the
203+
# release before a single user-facing name is minted instead of reporting damage already done.
204+
#
205+
# WHY BY NAME. The check this replaces asserted `assets != 0`. `store-plugin` runs `fail-fast: false`, so
206+
# a release that built ONE target out of 5 passed that check comfortably -- which is exactly
207+
# how busbar v1.5.3 shipped five assets where seven were expected and the two most common
208+
# platforms 404'd for every user who followed the documented download link. A count cannot see a
209+
# missing platform. The expected names come from the same `targets` job that produced the build
210+
# matrix, so the expectation cannot drift away from the thing being built.
211+
#
212+
#
213+
# THIS REPO ALREADY ASSERTED NAMES, FROM A HARDCODED LIST OF FIVE TRIPLES INSIDE THIS STEP, and
214+
# the reason survives the move: v1.0.4 shipped a COMPLETE 5-asset set that was correctly built,
215+
# correctly signed and completely unloadable, because every asset was packed under the retired
216+
# `redis` identity, so `store.module: valkey` resolved against nothing. The names now come from
217+
# .github/release-targets.json -- the same file the build matrix comes from -- so the published
218+
# stem is still asserted to be EXACTLY the one busbar names
219+
# (crates/busbar/src/config/mod.rs STORE_MODULE_VALKEY_ASSET_STEM), for every target built, and
220+
# there is no longer a second list to keep in step with the first.
221+
#
222+
# `!cancelled()` IS LOAD-BEARING, and it is the second half of that same defect: `store-plugin` runs
223+
# fail-fast:false, so a partial matrix FAILS the job, and a `needs:` on a failed job SKIPS its
224+
# dependent by default -- the one guard that exists to notice a broken release would be switched
225+
# off precisely when the release is broken. Running on `!cancelled()` turns a partial matrix into
226+
# a RED verify-assets that NAMES the missing platforms, instead of a grey one that names nothing.
179227
verify-assets:
180-
needs: [store-plugin]
228+
name: the draft owes every asset the manifest names
229+
needs: [targets, store-plugin]
230+
if: ${{ !cancelled() }}
181231
runs-on: ubuntu-latest
182232
steps:
183-
- name: Assert the Release has at least one asset
233+
- name: Assert the draft carries every asset, then promote it
184234
env:
185235
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
236+
EXPECTED: ${{ needs.targets.outputs.assets }}
186237
run: |
187238
set -euo pipefail
188-
count="$(gh release view "${GITHUB_REF_NAME}" \
189-
--repo "${GITHUB_REPOSITORY}" \
190-
--json assets --jq '.assets | length')"
191-
echo "Release ${GITHUB_REF_NAME} has ${count} asset(s)."
192-
if [ "${count}" -eq 0 ]; then
193-
echo "::error::PHANTOM RELEASE: ${GITHUB_REF_NAME} was published with 0 assets." \
194-
"Every build/pack target failed to upload a tarball. Failing the release run so this" \
195-
"tag is not mistaken for a real release by busbar's plugin-registry-gate. Fix the" \
196-
"build (check Cargo.lock freshness vs --locked and the plugin cdylib build step)," \
197-
"delete this tag+release, and re-cut." >&2
239+
# `!cancelled()` means this runs even when `targets` itself failed, and an empty
240+
# expectation list would then "verify" every release vacuously. Refuse instead.
241+
if [ -z "${EXPECTED:-}" ]; then
242+
echo "::error::The targets job produced no expected-asset list, so there is nothing to" \
243+
"verify ${GITHUB_REF_NAME} against. Refusing to promote: it stays a draft." >&2
198244
exit 1
199245
fi
200-
# NAME GATE. v1.0.4 shipped a COMPLETE 5-asset set that was correctly built, correctly
201-
# signed, and completely unloadable: every asset was packed under the retired `redis`
202-
# identity, so `store.module: valkey` resolved against nothing and `store.module: redis`
203-
# was refused at config load. A count check cannot tell a loadable asset from a present
204-
# one, so assert the published stem is EXACTLY the one busbar names
205-
# (crates/busbar/src/config/mod.rs STORE_MODULE_VALKEY_ASSET_STEM), for every target the
206-
# matrix above builds.
207-
ver="${GITHUB_REF_NAME#v}"
208-
names="$(gh release view "${GITHUB_REF_NAME}" \
209-
--repo "${GITHUB_REPOSITORY}" \
210-
--json assets --jq '.assets[].name')"
211-
echo "$names"
212-
for target in aarch64-apple-darwin aarch64-unknown-linux-gnu x86_64-apple-darwin \
213-
x86_64-pc-windows-msvc x86_64-unknown-linux-gnu; do
214-
want="busbar-store-valkey-${ver}-${target}.tar.gz"
215-
if ! printf '%s\n' "$names" | grep -qx "$want"; then
216-
echo "::error::MISNAMED OR MISSING ASSET: expected '${want}' on ${GITHUB_REF_NAME}." \
217-
"busbar resolves the first-party store plugin by the manifest name" \
218-
"'busbar-store-valkey-plugin' and the config alias 'valkey', published as" \
219-
"'busbar-store-valkey-<ver>-<target>.tar.gz'. Anything else is unloadable no" \
220-
"matter how cleanly it built or signed." >&2
221-
exit 1
222-
fi
223-
done
224-
# Only now, with assets provably attached, does this stop being a draft and become
225-
# the release that `releases/latest` resolves to.
246+
gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \
247+
--json assets --jq '.assets[] | "\(.name)\t\(.size)"' > /tmp/got.tsv || : > /tmp/got.tsv
248+
echo "Draft ${GITHUB_REF_NAME} carries these assets:"
249+
cat /tmp/got.tsv
250+
python3 - <<'PY'
251+
import json, os, sys
252+
expected = json.loads(os.environ["EXPECTED"])
253+
tag = os.environ["GITHUB_REF_NAME"]
254+
got = {}
255+
for line in open("/tmp/got.tsv"):
256+
line = line.rstrip("\n")
257+
if not line:
258+
continue
259+
name, _, size = line.partition("\t")
260+
got[name] = int(size or 0)
261+
262+
missing = [a for a in expected if a not in got]
263+
# A NAME IN THE ASSET LIST IS NOT A USABLE ARTIFACT: GitHub creates the row as soon as the
264+
# upload starts, so a 0-byte or truncated upload lists identically to a good one. 1 KiB is
265+
# far below any real plugin tarball and far above an empty or header-only file.
266+
empty = [a for a in expected if a in got and got[a] < 1024]
267+
268+
lines = ["### Draft asset verification", "",
269+
"| asset | bytes | verdict |", "| --- | --- | --- |"]
270+
for a in expected:
271+
if a not in got:
272+
lines.append("| `%s` | - | MISSING |" % a)
273+
elif got[a] < 1024:
274+
lines.append("| `%s` | %d | TOO SMALL |" % (a, got[a]))
275+
else:
276+
lines.append("| `%s` | %d | ok |" % (a, got[a]))
277+
extra = sorted(set(got) - set(expected))
278+
if extra:
279+
lines += ["", "Also present (not required): " + ", ".join("`%s`" % e for e in extra)]
280+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
281+
if summary:
282+
open(summary, "a").write("\n".join(lines) + "\n")
283+
print("\n".join(lines))
284+
285+
if not got:
286+
print("::error::PHANTOM RELEASE: the %s draft has 0 assets. Every build/pack target "
287+
"failed to upload a tarball. Nothing is public and nothing was promoted, so "
288+
"this is a clean retry: fix the build (check Cargo.lock freshness vs --locked "
289+
"and the plugin cdylib build step) and re-run this workflow." % tag,
290+
file=sys.stderr)
291+
sys.exit(1)
292+
if missing:
293+
print("::error::INCOMPLETE RELEASE: the %s draft is missing %d of %d required "
294+
"asset(s): %s. Each missing name is a PLATFORM whose users would get a 404 from "
295+
"the documented download URL, and busbar's plugin-registry-gate resolves the "
296+
"first-party plugin by exactly this name. Nothing was promoted, so fix that "
297+
"target's leg and re-run: no tag to delete, no release to unpublish." %
298+
(tag, len(missing), len(expected), ", ".join(missing)), file=sys.stderr)
299+
if empty:
300+
print("::error::TRUNCATED RELEASE: these %s draft assets are under 1 KiB, which means "
301+
"the upload was cut short and the asset is useless to anyone who downloads it: "
302+
"%s" % (tag, ", ".join(empty)), file=sys.stderr)
303+
if missing or empty:
304+
sys.exit(1)
305+
print("All %d required assets present and plausibly sized." % len(expected))
306+
PY
307+
# Only now, with EVERY promised asset provably attached and plausibly sized, does this
308+
# stop being a draft and become the release that `releases/latest` resolves to.
226309
gh release edit "${GITHUB_REF_NAME}" \
227310
--repo "${GITHUB_REPOSITORY}" \
228311
--draft=false --latest
229-
echo "::notice::Published ${GITHUB_REF_NAME} with ${count} asset(s)."
312+
echo "::notice::Published ${GITHUB_REF_NAME} with every asset in the contract."
230313
231314
# Instant marketing-site rebuild the moment this plugin ships a real release -- marketing's
232315
# deploy.yml listens for this exact repository_dispatch event type (plus its own daily-poll

0 commit comments

Comments
 (0)