Skip to content

Commit a3fc006

Browse files
authored
Merge ci/release-safety-main: draft-first promote + per-name asset verification
Admin merge: the only red PR checks are PRE-EXISTING on pristine origin/main (public-hygiene-lint hits in untouched files for the store repos; busbar-admin's spec-drift = main's openapi.json at 1.5.3 vs today's busbar 1.5.4 release) and carry no information about this workflow-only payload. Verification is the local behavioral proof: actionlint clean; the embedded verifier code proven against six synthetic asset sets (complete passes; one-missing, one-of-five, truncated, zero-asset, wrong-name refused) plus the truncated-manifest floor.
2 parents a42384c + 1d8e1af commit a3fc006

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+
"`store-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-store-mysql",
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 9 * * *"
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-store-mysql
50+
plugin_name: busbar-store-mysql-plugin
51+
plugin_alias: mysql
52+
plugin_kind: store
53+
# EMPTY, and correct: this repo publishes exactly one kind of artifact, a signed
54+
# busbar-store-*.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
@@ -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

Comments
 (0)