From 41710de63377a68796c846d2d7a0671f97f3258f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:13:37 -0700 Subject: [PATCH 1/2] ci: verify the release owes every asset by name, not by count verify-assets asserted only `assets | length -eq 0`, so a release that built ONE target out of five passed the gate. That is how busbar v1.5.3 shipped five assets where seven were expected, with Apple Silicon Mac and x86_64 Linux both 404ing for users. A count can never see a MISSING platform; only a name can. Ported busbar core release.yml`s contractual-filename-set approach: * .github/release-targets.json is now the platform list, in exactly one place. A `targets` job reads it and emits BOTH the build matrix and the exact set of asset filenames that matrix owes the release, so the set that is built and the set that is verified are the same computation and cannot drift. It carries a floor so a truncated manifest cannot produce an empty expectation list that passes vacuously. * verify-assets now asserts every expected asset is present BY NAME and at least 1 KiB (GitHub lists a truncated upload identically to a good one), prints a per-asset table to the step summary, and names the missing platforms in the failure. * verify-assets runs on `!cancelled()`. The build matrix is fail-fast:false, so a partial matrix FAILS the job and a `needs:` on a failed job SKIPS its dependent by default: the one guard that exists to notice a broken release was switched off precisely when the release was broken. The build matrix is byte-identical to what it was, just sourced from the manifest. Verified locally against synthetic asset sets: complete passes; one-platform-missing, one-of-five, truncated, zero-asset, and right-count-wrong-name are all refused. --- .github/release-targets.json | 60 +++++++++++ .github/workflows/release.yml | 189 +++++++++++++++++++++++++--------- 2 files changed, 203 insertions(+), 46 deletions(-) create mode 100644 .github/release-targets.json diff --git a/.github/release-targets.json b/.github/release-targets.json new file mode 100644 index 0000000..53e37bd --- /dev/null +++ b/.github/release-targets.json @@ -0,0 +1,60 @@ +{ + "_comment": [ + "THE PLATFORM LIST, IN EXACTLY ONE PLACE.", + "", + "release.yml's `targets` job reads this file and emits TWO things from it: the build matrix the", + "`store-plugin` job runs, and the exact set of release-asset filenames that matrix is contractually", + "obliged to produce. `verify-assets` asserts every one of those names is present on the DRAFT", + "release before promoting it, so adding or removing a platform is ONE edit here and its", + "verification comes along automatically.", + "", + "WHY A NAME AND NOT A COUNT. busbar v1.5.3 published FIVE assets where SEVEN were expected:", + "aarch64-apple-darwin and x86_64-unknown-linux-gnu were both missing, which is Apple Silicon Mac", + "and x86_64 Linux, the two most common platforms there are. The guard of the day asserted", + "`assets != 0`, which a five-asset release passes comfortably. A COUNT CAN NEVER SEE A MISSING", + "PLATFORM; ONLY A NAME CAN. And a hardcoded expected-names list inside the verifier would just be", + "a SECOND place to forget a platform, which is the same defect one level up -- hence one file,", + "two derived outputs.", + "", + "FIELDS, all of which are inputs to the SAME build steps, never selectors for different ones:", + " target the rust target triple. The published asset is always", + " --.tar.gz -- plugin-pack writes a tarball on every", + " platform, Windows included.", + " os the GitHub-hosted runner label that builds this target natively.", + " libext the cdylib extension this platform produces (so / dylib / dll).", + " libprefix the cdylib filename prefix ('lib' everywhere except MSVC)." + ], + "asset_prefix": "busbar-store-postgres", + "targets": [ + { + "target": "x86_64-unknown-linux-gnu", + "os": "ubuntu-latest", + "libext": "so", + "libprefix": "lib" + }, + { + "target": "aarch64-unknown-linux-gnu", + "os": "ubuntu-24.04-arm", + "libext": "so", + "libprefix": "lib" + }, + { + "target": "x86_64-apple-darwin", + "os": "macos-latest", + "libext": "dylib", + "libprefix": "lib" + }, + { + "target": "aarch64-apple-darwin", + "os": "macos-latest", + "libext": "dylib", + "libprefix": "lib" + }, + { + "target": "x86_64-pc-windows-msvc", + "os": "windows-latest", + "libext": "dll", + "libprefix": "" + } + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba8a038..3ba57a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,55 @@ jobs: echo "$out" fi + # THE PLATFORM LIST, IN EXACTLY ONE PLACE. This job reads .github/release-targets.json and emits + # BOTH the build matrix `store-plugin` runs AND the exact set of asset filenames that matrix is + # contractually obliged to produce. `store-plugin` consumes the first; `verify-assets` consumes the + # second -- so "the set that was supposed to be built" and "the set that gets verified" are + # literally the same computation and cannot drift apart. + # + # WHY IT IS A JOB AND NOT A LITERAL MATRIX. busbar v1.5.3 published FIVE assets where seven were + # expected, and the guard of the day asserted `assets != 0`, which a five-asset release passes + # comfortably. A count can never see a MISSING platform; only a name can. A hardcoded + # expected-names list in the verifier would be a second place to forget a platform, which is the + # same defect one level up. + targets: + name: release target matrix (single source of truth) + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.emit.outputs.matrix }} + assets: ${{ steps.emit.outputs.assets }} + steps: + - uses: actions/checkout@v7 + - name: Emit the target matrix and the asset names it must produce + id: emit + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os + spec = json.load(open(".github/release-targets.json")) + tag = os.environ["GITHUB_REF_NAME"] + ver = tag[1:] if tag.startswith("v") else tag + # EVERY per-target difference travels in the matrix as a PARAMETER, so there is no `if:` + # and no second build path for a property to be established on one and unproven on the + # other. + fields = ("target", "os", "libext", "libprefix") + inc = [{k: t[k] for k in fields} for t in spec["targets"]] + assets = ["%s-%s-%s.tar.gz" % (spec["asset_prefix"], ver, t["target"]) + for t in spec["targets"]] + # A FLOOR, BECAUSE A LOOP OVER A DISCOVERED SET WITH NO FLOOR PASSES WHEN THE SET IS EMPTY. + # Both the build matrix and the expectation list are enumerated from this output, so a + # truncated or mis-parsed manifest would otherwise build nothing, expect nothing, and + # report green all the way to a promoted release with no assets on it. + if len(inc) < 5: + raise SystemExit( + "release-targets.json declares %d targets; this plugin ships 5. Refusing to " + "run a build matrix and an expectation list over a set this small: an empty " + "expectation list passes for a release that published nothing." % len(inc)) + print("matrix=" + json.dumps({"include": inc})) + print("assets=" + json.dumps(assets)) + PY + cat "$GITHUB_OUTPUT" + # One signed .tar.gz per target: {cdylib + manifest.json}, packed by busbar-plugin-pack and # signed with the busbar release PRIVATE key (BUSBAR_SIGN_KEY secret) so it verifies as # first-party against the PUBLIC key embedded in busbar's own release binaries. If that secret @@ -60,33 +109,12 @@ jobs: # plugins.trust.allow_unsigned) rather than blocking the release — same seam busbarAI's own # release.yml documents (TODO(release-keys)). store-plugin: - needs: create-release + needs: [create-release, targets] name: store-plugin (${{ matrix.target }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest - libext: so - libprefix: lib - - target: aarch64-unknown-linux-gnu - os: ubuntu-24.04-arm - libext: so - libprefix: lib - - target: x86_64-apple-darwin - os: macos-latest - libext: dylib - libprefix: lib - - target: aarch64-apple-darwin - os: macos-latest - libext: dylib - libprefix: lib - - target: x86_64-pc-windows-msvc - os: windows-latest - libext: dll - libprefix: "" + matrix: ${{ fromJSON(needs.targets.outputs.matrix) }} steps: - name: Checkout store-postgres uses: actions/checkout@v7 @@ -160,41 +188,110 @@ jobs: with: subject-path: "plugin-dist/*.tar.gz" - # PHANTOM-RELEASE GUARD: assert the published Release actually carries assets before we treat this - # as a real release. The per-target build/upload jobs run with fail-fast:false, and `create-release` - # always makes the (initially empty) Release up front — so a build/pack failure on EVERY target - # (e.g. a stale Cargo.lock tripping `--locked`) leaves a tag + Release with ZERO assets: a "phantom" - # that silently breaks busbar's plugin-registry-gate. This job fails the whole release run loud if - # assets == 0, so a phantom can never ship (or notify downstream) unnoticed. It depends on the build - # matrix but does NOT inherit its fail-fast:false — one green target is enough to have assets, but - # zero across the board must hard-fail here. + # PHANTOM- AND PARTIAL-RELEASE GUARD, AND THE ONLY THING THAT EVER PUBLISHES. It asserts the DRAFT + # carries every asset the matrix owes it, BY NAME, and only then promotes it to published+latest. + # Nothing above this job is user-facing: `create-release` makes a DRAFT, which does not resolve as + # `releases/latest` and is invisible to `gh release download`, so a red verdict here stops the + # release before a single user-facing name is minted instead of reporting damage already done. + # + # WHY BY NAME. The check this replaces asserted `assets != 0`. `store-plugin` runs `fail-fast: false`, so + # a release that built ONE target out of 5 passed that check comfortably -- which is exactly + # how busbar v1.5.3 shipped five assets where seven were expected and the two most common + # platforms 404'd for every user who followed the documented download link. A count cannot see a + # missing platform. The expected names come from the same `targets` job that produced the build + # matrix, so the expectation cannot drift away from the thing being built. + # + # `!cancelled()` IS LOAD-BEARING, and it is the second half of that same defect: `store-plugin` runs + # fail-fast:false, so a partial matrix FAILS the job, and a `needs:` on a failed job SKIPS its + # dependent by default -- the one guard that exists to notice a broken release would be switched + # off precisely when the release is broken. Running on `!cancelled()` turns a partial matrix into + # a RED verify-assets that NAMES the missing platforms, instead of a grey one that names nothing. verify-assets: - needs: [store-plugin] + name: the draft owes every asset the manifest names + needs: [targets, store-plugin] + if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - - name: Assert the Release has at least one asset + - name: Assert the draft carries every asset, then promote it env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EXPECTED: ${{ needs.targets.outputs.assets }} run: | set -euo pipefail - count="$(gh release view "${GITHUB_REF_NAME}" \ - --repo "${GITHUB_REPOSITORY}" \ - --json assets --jq '.assets | length')" - echo "Release ${GITHUB_REF_NAME} has ${count} asset(s)." - if [ "${count}" -eq 0 ]; then - echo "::error::PHANTOM RELEASE: ${GITHUB_REF_NAME} was published with 0 assets." \ - "Every build/pack target failed to upload a tarball. Failing the release run so this" \ - "tag is not mistaken for a real release by busbar's plugin-registry-gate. Fix the" \ - "build (check Cargo.lock freshness vs --locked and the plugin cdylib build step)," \ - "delete this tag+release, and re-cut." >&2 + # `!cancelled()` means this runs even when `targets` itself failed, and an empty + # expectation list would then "verify" every release vacuously. Refuse instead. + if [ -z "${EXPECTED:-}" ]; then + echo "::error::The targets job produced no expected-asset list, so there is nothing to" \ + "verify ${GITHUB_REF_NAME} against. Refusing to promote: it stays a draft." >&2 exit 1 fi - # Only now, with assets provably attached, does this stop being a draft and become - # the release that `releases/latest` resolves to. + gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \ + --json assets --jq '.assets[] | "\(.name)\t\(.size)"' > /tmp/got.tsv || : > /tmp/got.tsv + echo "Draft ${GITHUB_REF_NAME} carries these assets:" + cat /tmp/got.tsv + python3 - <<'PY' + import json, os, sys + expected = json.loads(os.environ["EXPECTED"]) + tag = os.environ["GITHUB_REF_NAME"] + got = {} + for line in open("/tmp/got.tsv"): + line = line.rstrip("\n") + if not line: + continue + name, _, size = line.partition("\t") + got[name] = int(size or 0) + + missing = [a for a in expected if a not in got] + # A NAME IN THE ASSET LIST IS NOT A USABLE ARTIFACT: GitHub creates the row as soon as the + # upload starts, so a 0-byte or truncated upload lists identically to a good one. 1 KiB is + # far below any real plugin tarball and far above an empty or header-only file. + empty = [a for a in expected if a in got and got[a] < 1024] + + lines = ["### Draft asset verification", "", + "| asset | bytes | verdict |", "| --- | --- | --- |"] + for a in expected: + if a not in got: + lines.append("| `%s` | - | MISSING |" % a) + elif got[a] < 1024: + lines.append("| `%s` | %d | TOO SMALL |" % (a, got[a])) + else: + lines.append("| `%s` | %d | ok |" % (a, got[a])) + extra = sorted(set(got) - set(expected)) + if extra: + lines += ["", "Also present (not required): " + ", ".join("`%s`" % e for e in extra)] + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + open(summary, "a").write("\n".join(lines) + "\n") + print("\n".join(lines)) + + if not got: + print("::error::PHANTOM RELEASE: the %s draft has 0 assets. Every build/pack target " + "failed to upload a tarball. Nothing is public and nothing was promoted, so " + "this is a clean retry: fix the build (check Cargo.lock freshness vs --locked " + "and the plugin cdylib build step) and re-run this workflow." % tag, + file=sys.stderr) + sys.exit(1) + if missing: + print("::error::INCOMPLETE RELEASE: the %s draft is missing %d of %d required " + "asset(s): %s. Each missing name is a PLATFORM whose users would get a 404 from " + "the documented download URL, and busbar's plugin-registry-gate resolves the " + "first-party plugin by exactly this name. Nothing was promoted, so fix that " + "target's leg and re-run: no tag to delete, no release to unpublish." % + (tag, len(missing), len(expected), ", ".join(missing)), file=sys.stderr) + if empty: + print("::error::TRUNCATED RELEASE: these %s draft assets are under 1 KiB, which means " + "the upload was cut short and the asset is useless to anyone who downloads it: " + "%s" % (tag, ", ".join(empty)), file=sys.stderr) + if missing or empty: + sys.exit(1) + print("All %d required assets present and plausibly sized." % len(expected)) + PY + # Only now, with EVERY promised asset provably attached and plausibly sized, does this + # stop being a draft and become the release that `releases/latest` resolves to. gh release edit "${GITHUB_REF_NAME}" \ --repo "${GITHUB_REPOSITORY}" \ --draft=false --latest - echo "::notice::Published ${GITHUB_REF_NAME} with ${count} asset(s)." + echo "::notice::Published ${GITHUB_REF_NAME} with every asset in the contract." # Instant marketing-site rebuild the moment this plugin ships a real release -- marketing's # deploy.yml listens for this exact repository_dispatch event type (plus its own daily-poll From 5b11196bb9c32cee7d152297b2f47a789d04f889 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:50:06 -0700 Subject: [PATCH 2/2] ci: bring consumer-verify onto main, pinned to an immutable shared-workflow ref The dev copy of this workflow called the shared verifier at @dev, a moving ref, and (in three repos) carried a torn cron -- the schedule's MM HH had been transplanted onto the bundle_image line, leaving 'cron: " * * *"' (invalid) and a bundle_image that named a time of day instead of an image. This is a clean copy for main: the original per-repo cron spread is restored so the fleet does not stampede the schedule at once, bundle_image is restored to the value the repo's artifacts actually call for, and the shared workflow is pinned to ceb7104a4cdb06f3bba20b68c6c1a76fac2215f7 -- the tip of plugin-consumer-verify.yml as of busbar v1.5.4. The v1.5.4 tag itself does not carry the file, so the commit SHA is the immutable pin. --- .github/workflows/consumer-verify.yml | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/consumer-verify.yml diff --git a/.github/workflows/consumer-verify.yml b/.github/workflows/consumer-verify.yml new file mode 100644 index 0000000..ab87811 --- /dev/null +++ b/.github/workflows/consumer-verify.yml @@ -0,0 +1,59 @@ +name: consumer-verify + +# Does what this repo PUBLISHED actually work when a user gets it? +# +# Every other workflow here reports on itself. ci.yml proves the code builds and its tests pass. +# release.yml's verify-assets proves the upload step believed it succeeded, asserted from inside the +# run that did the uploading. None of that is evidence about the artifact a user downloads, and the +# gap is not theoretical: webrequest-hook v1.0.4 published as a zero-asset phantom, and +# headroom-hook's published bundle could not boot the gateway because its shipped config used shapes +# busbar 1.5.3 retired. Both were green everywhere. Nothing anywhere noticed. +# +# The logic lives in ONE place for the whole fleet, exactly like plugin-ci.yml, so a fix reaches +# every plugin at once instead of being copied ten times and drifting nine. +# +# WHY BOTH TRIGGERS. release: published catches a broken publish immediately, and it fires whether or +# not the release workflow itself finished happy - which matters, because a verifier that only runs +# when everything already worked is not a verifier. The daily schedule catches ROT: an artifact that +# published fine can stop working later when nothing about it changed (a bundle that no longer boots +# against a newer engine, an asset deleted by hand, a release un-flagged as latest). A +# publish-time-only check structurally cannot see that class. +on: + release: + types: [published] + schedule: + - cron: "43 9 * * *" + workflow_dispatch: + inputs: + version: + description: "Version to verify (e.g. 1.0.4). Empty means the newest published release." + required: false + type: string + +permissions: + contents: read + issues: write + actions: read + +jobs: + consumer: + # Pinned to an IMMUTABLE ref, never @dev: the verdict must not change because the shared + # workflow moved. This SHA is the tip of plugin-consumer-verify.yml as of busbar v1.5.4 + # (the file is not carried by the v1.5.4 tag itself, so the commit SHA is the pin). + uses: GetBusbar/busbar/.github/workflows/plugin-consumer-verify.yml@ceb7104a4cdb06f3bba20b68c6c1a76fac2215f7 + with: + version: ${{ inputs.version || '' }} + # Read off the PUBLISHED artifact, not guessed from the crate name: the filename prefix and the + # manifest name genuinely differ across this fleet (the store repos drop the trailing -plugin + # that the auth repos keep, and store-valkey publishes as busbar-store-redis). + asset_prefix: busbar-store-postgres + plugin_name: busbar-store-postgres-plugin + plugin_alias: postgres + plugin_kind: store + # EMPTY, and correct: this repo publishes exactly one kind of artifact, a signed + # busbar-store-*.tar.gz per target (see release.yml's `plugin-dist/*.tar.gz` upload) -- no + # container bundle anywhere in this repo's workflows. `bundle_image: ""` makes the shared + # workflow declare the runnable-bundle boot check NOT-APPLICABLE rather than silently skip it. + bundle_image: "" + bundle_env: "" + secrets: inherit