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