Skip to content

Commit 8a98813

Browse files
authored
Merge ci/release-safety-main: draft-first promote + per-name asset verification, pinned consumer-verify on main
PR CI fully green (both busbar legs). Local behavioral proof: actionlint clean; embedded verifier proven against six synthetic asset sets plus the truncated-manifest floor.
2 parents ab71022 + e0514c7 commit 8a98813

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+
"`auth-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-auth-oidc-plugin",
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: "33 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-auth-oidc-plugin
50+
plugin_name: busbar-auth-oidc-plugin
51+
plugin_alias: oidc
52+
plugin_kind: auth
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
@@ -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

Comments
 (0)