@@ -52,40 +52,68 @@ jobs:
5252 echo "$out"
5353 fi
5454
55+ # THE PLATFORM LIST, IN EXACTLY ONE PLACE. This job reads .github/release-targets.json and emits
56+ # BOTH the build matrix `store-plugin` runs AND the exact set of asset filenames that matrix is
57+ # contractually obliged to produce. `store-plugin` consumes the first; `verify-assets` consumes the
58+ # second -- so "the set that was supposed to be built" and "the set that gets verified" are
59+ # literally the same computation and cannot drift apart.
60+ #
61+ # WHY IT IS A JOB AND NOT A LITERAL MATRIX. busbar v1.5.3 published FIVE assets where seven were
62+ # expected, and the guard of the day asserted `assets != 0`, which a five-asset release passes
63+ # comfortably. A count can never see a MISSING platform; only a name can. A hardcoded
64+ # expected-names list in the verifier would be a second place to forget a platform, which is the
65+ # same defect one level up.
66+ targets :
67+ name : release target matrix (single source of truth)
68+ runs-on : ubuntu-latest
69+ outputs :
70+ matrix : ${{ steps.emit.outputs.matrix }}
71+ assets : ${{ steps.emit.outputs.assets }}
72+ steps :
73+ - uses : actions/checkout@v7
74+ - name : Emit the target matrix and the asset names it must produce
75+ id : emit
76+ run : |
77+ set -euo pipefail
78+ python3 - <<'PY' >> "$GITHUB_OUTPUT"
79+ import json, os
80+ spec = json.load(open(".github/release-targets.json"))
81+ tag = os.environ["GITHUB_REF_NAME"]
82+ ver = tag[1:] if tag.startswith("v") else tag
83+ # EVERY per-target difference travels in the matrix as a PARAMETER, so there is no `if:`
84+ # and no second build path for a property to be established on one and unproven on the
85+ # other.
86+ fields = ("target", "os", "libext", "libprefix")
87+ inc = [{k: t[k] for k in fields} for t in spec["targets"]]
88+ assets = ["%s-%s-%s.tar.gz" % (spec["asset_prefix"], ver, t["target"])
89+ for t in spec["targets"]]
90+ # A FLOOR, BECAUSE A LOOP OVER A DISCOVERED SET WITH NO FLOOR PASSES WHEN THE SET IS EMPTY.
91+ # Both the build matrix and the expectation list are enumerated from this output, so a
92+ # truncated or mis-parsed manifest would otherwise build nothing, expect nothing, and
93+ # report green all the way to a promoted release with no assets on it.
94+ if len(inc) < 5:
95+ raise SystemExit(
96+ "release-targets.json declares %d targets; this plugin ships 5. Refusing to "
97+ "run a build matrix and an expectation list over a set this small: an empty "
98+ "expectation list passes for a release that published nothing." % len(inc))
99+ print("matrix=" + json.dumps({"include": inc}))
100+ print("assets=" + json.dumps(assets))
101+ PY
102+ cat "$GITHUB_OUTPUT"
103+
55104 # One signed .tar.gz per target: {cdylib + manifest.json}, packed by busbar-plugin-pack and
56105 # signed with the busbar release PRIVATE key (BUSBAR_SIGN_KEY secret) so it verifies as
57106 # first-party against the PUBLIC key embedded in busbar's own release binaries. If that secret
58107 # isn't provisioned on this repo, falls back to an UNSIGNED tarball (loadable only under
59108 # plugins.trust.allow_unsigned) rather than blocking the release — same seam busbarAI's own
60109 # release.yml documents (TODO(release-keys)).
61110 store-plugin :
62- needs : create-release
111+ needs : [ create-release, targets]
63112 name : store-plugin (${{ matrix.target }})
64113 runs-on : ${{ matrix.os }}
65114 strategy :
66115 fail-fast : false
67- matrix :
68- include :
69- - target : x86_64-unknown-linux-gnu
70- os : ubuntu-latest
71- libext : so
72- libprefix : lib
73- - target : aarch64-unknown-linux-gnu
74- os : ubuntu-24.04-arm
75- libext : so
76- libprefix : lib
77- - target : x86_64-apple-darwin
78- os : macos-latest
79- libext : dylib
80- libprefix : lib
81- - target : aarch64-apple-darwin
82- os : macos-latest
83- libext : dylib
84- libprefix : lib
85- - target : x86_64-pc-windows-msvc
86- os : windows-latest
87- libext : dll
88- libprefix : " "
116+ matrix : ${{ fromJSON(needs.targets.outputs.matrix) }}
89117 steps :
90118 - name : Checkout store-mysql
91119 uses : actions/checkout@v7
@@ -159,41 +187,110 @@ jobs:
159187 with :
160188 subject-path : " plugin-dist/*.tar.gz"
161189
162- # PHANTOM-RELEASE GUARD: assert the published Release actually carries assets before we treat this
163- # as a real release. The per-target build/upload jobs run with fail-fast:false, and `create-release`
164- # always makes the (initially empty) Release up front — so a build/pack failure on EVERY target
165- # (e.g. a stale Cargo.lock tripping `--locked`) leaves a tag + Release with ZERO assets: a "phantom"
166- # that silently breaks busbar's plugin-registry-gate. This job fails the whole release run loud if
167- # assets == 0, so a phantom can never ship (or notify downstream) unnoticed. It depends on the build
168- # matrix but does NOT inherit its fail-fast:false — one green target is enough to have assets, but
169- # zero across the board must hard-fail here.
190+ # PHANTOM- AND PARTIAL-RELEASE GUARD, AND THE ONLY THING THAT EVER PUBLISHES. It asserts the DRAFT
191+ # carries every asset the matrix owes it, BY NAME, and only then promotes it to published+latest.
192+ # Nothing above this job is user-facing: `create-release` makes a DRAFT, which does not resolve as
193+ # `releases/latest` and is invisible to `gh release download`, so a red verdict here stops the
194+ # release before a single user-facing name is minted instead of reporting damage already done.
195+ #
196+ # WHY BY NAME. The check this replaces asserted `assets != 0`. `store-plugin` runs `fail-fast: false`, so
197+ # a release that built ONE target out of 5 passed that check comfortably -- which is exactly
198+ # how busbar v1.5.3 shipped five assets where seven were expected and the two most common
199+ # platforms 404'd for every user who followed the documented download link. A count cannot see a
200+ # missing platform. The expected names come from the same `targets` job that produced the build
201+ # matrix, so the expectation cannot drift away from the thing being built.
202+ #
203+ # `!cancelled()` IS LOAD-BEARING, and it is the second half of that same defect: `store-plugin` runs
204+ # fail-fast:false, so a partial matrix FAILS the job, and a `needs:` on a failed job SKIPS its
205+ # dependent by default -- the one guard that exists to notice a broken release would be switched
206+ # off precisely when the release is broken. Running on `!cancelled()` turns a partial matrix into
207+ # a RED verify-assets that NAMES the missing platforms, instead of a grey one that names nothing.
170208 verify-assets :
171- needs : [store-plugin]
209+ name : the draft owes every asset the manifest names
210+ needs : [targets, store-plugin]
211+ if : ${{ !cancelled() }}
172212 runs-on : ubuntu-latest
173213 steps :
174- - name : Assert the Release has at least one asset
214+ - name : Assert the draft carries every asset, then promote it
175215 env :
176216 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
217+ EXPECTED : ${{ needs.targets.outputs.assets }}
177218 run : |
178219 set -euo pipefail
179- count="$(gh release view "${GITHUB_REF_NAME}" \
180- --repo "${GITHUB_REPOSITORY}" \
181- --json assets --jq '.assets | length')"
182- echo "Release ${GITHUB_REF_NAME} has ${count} asset(s)."
183- if [ "${count}" -eq 0 ]; then
184- echo "::error::PHANTOM RELEASE: ${GITHUB_REF_NAME} was published with 0 assets." \
185- "Every build/pack target failed to upload a tarball. Failing the release run so this" \
186- "tag is not mistaken for a real release by busbar's plugin-registry-gate. Fix the" \
187- "build (check Cargo.lock freshness vs --locked and the plugin cdylib build step)," \
188- "delete this tag+release, and re-cut." >&2
220+ # `!cancelled()` means this runs even when `targets` itself failed, and an empty
221+ # expectation list would then "verify" every release vacuously. Refuse instead.
222+ if [ -z "${EXPECTED:-}" ]; then
223+ echo "::error::The targets job produced no expected-asset list, so there is nothing to" \
224+ "verify ${GITHUB_REF_NAME} against. Refusing to promote: it stays a draft." >&2
189225 exit 1
190226 fi
191- # Only now, with assets provably attached, does this stop being a draft and become
192- # the release that `releases/latest` resolves to.
227+ gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \
228+ --json assets --jq '.assets[] | "\(.name)\t\(.size)"' > /tmp/got.tsv || : > /tmp/got.tsv
229+ echo "Draft ${GITHUB_REF_NAME} carries these assets:"
230+ cat /tmp/got.tsv
231+ python3 - <<'PY'
232+ import json, os, sys
233+ expected = json.loads(os.environ["EXPECTED"])
234+ tag = os.environ["GITHUB_REF_NAME"]
235+ got = {}
236+ for line in open("/tmp/got.tsv"):
237+ line = line.rstrip("\n")
238+ if not line:
239+ continue
240+ name, _, size = line.partition("\t")
241+ got[name] = int(size or 0)
242+
243+ missing = [a for a in expected if a not in got]
244+ # A NAME IN THE ASSET LIST IS NOT A USABLE ARTIFACT: GitHub creates the row as soon as the
245+ # upload starts, so a 0-byte or truncated upload lists identically to a good one. 1 KiB is
246+ # far below any real plugin tarball and far above an empty or header-only file.
247+ empty = [a for a in expected if a in got and got[a] < 1024]
248+
249+ lines = ["### Draft asset verification", "",
250+ "| asset | bytes | verdict |", "| --- | --- | --- |"]
251+ for a in expected:
252+ if a not in got:
253+ lines.append("| `%s` | - | MISSING |" % a)
254+ elif got[a] < 1024:
255+ lines.append("| `%s` | %d | TOO SMALL |" % (a, got[a]))
256+ else:
257+ lines.append("| `%s` | %d | ok |" % (a, got[a]))
258+ extra = sorted(set(got) - set(expected))
259+ if extra:
260+ lines += ["", "Also present (not required): " + ", ".join("`%s`" % e for e in extra)]
261+ summary = os.environ.get("GITHUB_STEP_SUMMARY")
262+ if summary:
263+ open(summary, "a").write("\n".join(lines) + "\n")
264+ print("\n".join(lines))
265+
266+ if not got:
267+ print("::error::PHANTOM RELEASE: the %s draft has 0 assets. Every build/pack target "
268+ "failed to upload a tarball. Nothing is public and nothing was promoted, so "
269+ "this is a clean retry: fix the build (check Cargo.lock freshness vs --locked "
270+ "and the plugin cdylib build step) and re-run this workflow." % tag,
271+ file=sys.stderr)
272+ sys.exit(1)
273+ if missing:
274+ print("::error::INCOMPLETE RELEASE: the %s draft is missing %d of %d required "
275+ "asset(s): %s. Each missing name is a PLATFORM whose users would get a 404 from "
276+ "the documented download URL, and busbar's plugin-registry-gate resolves the "
277+ "first-party plugin by exactly this name. Nothing was promoted, so fix that "
278+ "target's leg and re-run: no tag to delete, no release to unpublish." %
279+ (tag, len(missing), len(expected), ", ".join(missing)), file=sys.stderr)
280+ if empty:
281+ print("::error::TRUNCATED RELEASE: these %s draft assets are under 1 KiB, which means "
282+ "the upload was cut short and the asset is useless to anyone who downloads it: "
283+ "%s" % (tag, ", ".join(empty)), file=sys.stderr)
284+ if missing or empty:
285+ sys.exit(1)
286+ print("All %d required assets present and plausibly sized." % len(expected))
287+ PY
288+ # Only now, with EVERY promised asset provably attached and plausibly sized, does this
289+ # stop being a draft and become the release that `releases/latest` resolves to.
193290 gh release edit "${GITHUB_REF_NAME}" \
194291 --repo "${GITHUB_REPOSITORY}" \
195292 --draft=false --latest
196- echo "::notice::Published ${GITHUB_REF_NAME} with ${count} asset(s) ."
293+ echo "::notice::Published ${GITHUB_REF_NAME} with every asset in the contract ."
197294
198295 # Instant marketing-site rebuild the moment this plugin ships a real release -- marketing's
199296 # deploy.yml listens for this exact repository_dispatch event type (plus its own daily-poll
0 commit comments