@@ -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