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