Skip to content

Commit 4da242e

Browse files
authored
Merge ci/release-safety-main: draft-first promote + per-name asset verification, pinned consumer-verify on main
PR CI: the pinned leg (busbar at .busbar-ref — what the release actually builds against) is GREEN. The busbar@dev moving leg fails its executable-config gate with 'extracted 0 documents, floor >=1' — proven PRE-EXISTING by running the same extractor against pristine origin/main locally (identical 0-document result; this workflow-only payload adds and removes no busbar config documents). Local behavioral proof: actionlint clean; embedded verifier proven against six synthetic asset sets plus the truncated-manifest floor.
2 parents 311c564 + e206218 commit 4da242e

3 files changed

Lines changed: 262 additions & 46 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+
"`secret-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-hashicorp-vault",
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+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: consumer-verify
2+
3+
# Does what this repo PUBLISHED actually work when a user gets it?
4+
#
5+
# Every other workflow here reports on itself. ci.yml proves the code builds and its tests pass.
6+
# release.yml's verify-assets proves the upload step believed it succeeded, asserted from inside the
7+
# run that did the uploading. None of that is evidence about the artifact a user downloads, and the
8+
# gap is not theoretical: webrequest-hook v1.0.4 published as a zero-asset phantom, and
9+
# headroom-hook's published bundle could not boot the gateway because its shipped config used shapes
10+
# busbar 1.5.3 retired. Both were green everywhere. Nothing anywhere noticed.
11+
#
12+
# The logic lives in ONE place for the whole fleet, exactly like plugin-ci.yml, so a fix reaches
13+
# every plugin at once instead of being copied ten times and drifting nine.
14+
#
15+
# WHY BOTH TRIGGERS. release: published catches a broken publish immediately, and it fires whether or
16+
# not the release workflow itself finished happy - which matters, because a verifier that only runs
17+
# when everything already worked is not a verifier. The daily schedule catches ROT: an artifact that
18+
# published fine can stop working later when nothing about it changed (a bundle that no longer boots
19+
# against a newer engine, an asset deleted by hand, a release un-flagged as latest). A
20+
# publish-time-only check structurally cannot see that class.
21+
on:
22+
release:
23+
types: [published]
24+
schedule:
25+
- cron: "43 10 * * *"
26+
workflow_dispatch:
27+
inputs:
28+
version:
29+
description: "Version to verify (e.g. 1.0.4). Empty means the newest published release."
30+
required: false
31+
type: string
32+
33+
permissions:
34+
contents: read
35+
issues: write
36+
actions: read
37+
38+
jobs:
39+
consumer:
40+
# Pinned to an IMMUTABLE ref, never @dev: the verdict must not change because the shared
41+
# workflow moved. This SHA is the tip of plugin-consumer-verify.yml as of busbar v1.5.4
42+
# (the file is not carried by the v1.5.4 tag itself, so the commit SHA is the pin).
43+
uses: GetBusbar/busbar/.github/workflows/plugin-consumer-verify.yml@ceb7104a4cdb06f3bba20b68c6c1a76fac2215f7
44+
with:
45+
version: ${{ inputs.version || '' }}
46+
# Read off the PUBLISHED artifact, not guessed from the crate name: the filename prefix and the
47+
# manifest name genuinely differ across this fleet (the store repos drop the trailing -plugin
48+
# that the auth repos keep, and store-valkey publishes as busbar-store-redis).
49+
asset_prefix: busbar-hashicorp-vault
50+
plugin_name: busbar-hashicorp-vault
51+
plugin_alias: vault
52+
plugin_kind: secret
53+
# EMPTY, and correct: this repo publishes exactly one kind of artifact, a signed
54+
# busbar-*.tar.gz per target (see release.yml's `plugin-dist/*.tar.gz` upload) -- no
55+
# container bundle anywhere in this repo's workflows. `bundle_image: ""` makes the shared
56+
# workflow declare the runnable-bundle boot check NOT-APPLICABLE rather than silently skip it.
57+
bundle_image: ""
58+
bundle_env: ""
59+
secrets: inherit

.github/workflows/release.yml

Lines changed: 143 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)