Skip to content

Commit 9977d24

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 30d8eeb + 3503035 commit 9977d24

2 files changed

Lines changed: 161 additions & 27 deletions

File tree

.github/release-targets.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
"`build` 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. This workflow already refused a release whose asset COUNT was not",
12+
"3, which is stricter than the fleet's `!= 0` but still blind in the way that matters: a count",
13+
"cannot tell WHICH platform is missing, and three assets of which one is misnamed or duplicated",
14+
"counts to three. busbar v1.5.3 published FIVE assets where SEVEN were expected -- Apple Silicon",
15+
"Mac and x86_64 Linux, the two most common platforms there are -- and every count-based gate in",
16+
"the fleet passed it. A COUNT CAN NEVER SEE A MISSING PLATFORM; ONLY A NAME CAN.",
17+
"",
18+
"NOTE THE VERSION COMPONENT IS THE RAW TAG, `v` AND ALL: this repo packages",
19+
"busbar-admin-${GITHUB_REF_NAME}-<target>.tar.gz, unlike the plugin repos which strip the v.",
20+
"The `targets` job derives the expected names the same way the package step builds them.",
21+
"",
22+
"FIELDS:",
23+
" target the rust target triple; the asset is always <asset_prefix>-<tag>-<target>.tar.gz.",
24+
" os the GitHub-hosted runner label that builds this target."
25+
],
26+
"asset_prefix": "busbar-admin",
27+
"targets": [
28+
{ "target": "x86_64-unknown-linux-gnu", "os": "ubuntu-latest" },
29+
{ "target": "aarch64-apple-darwin", "os": "macos-latest" },
30+
{ "target": "x86_64-apple-darwin", "os": "macos-latest" }
31+
]
32+
}

.github/workflows/release.yml

Lines changed: 129 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,6 @@ on:
2121
permissions:
2222
contents: write
2323

24-
env:
25-
# Every target in the build matrix must land an asset before the release is promoted. Keep this
26-
# in step with the matrix below; the gate compares against it rather than against "more than
27-
# zero", so a silent drop from three platforms to one fails too.
28-
EXPECTED_ASSETS: "3"
29-
3024
jobs:
3125
# Created first so the parallel matrix legs have something to attach to (uploading from a matrix
3226
# with no pre-existing release races and fails with "release not found"). Draft from the start:
@@ -46,21 +40,61 @@ jobs:
4640
--verify-tag --generate-notes \
4741
|| gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}"
4842
43+
# THE PLATFORM LIST, IN EXACTLY ONE PLACE. This job reads .github/release-targets.json and emits
44+
# BOTH the build matrix `build` runs AND the exact set of asset filenames that matrix is
45+
# contractually obliged to produce. `build` 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+
# IT REPLACES THE `EXPECTED_ASSETS: "3"` COUNT THIS FILE USED TO CARRY. That count was already
50+
# stricter than the rest of the fleet's `assets != 0`, and it was still blind in the way that
51+
# matters: it could say THREE ARE MISSING but never WHICH PLATFORM, and it passes for three
52+
# assets of which one is misnamed. It was also a second literal to keep in step with the matrix
53+
# by hand -- the 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@v4
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+
# THE RAW TAG, `v` AND ALL: the package step below names the archive
70+
# busbar-admin-${GITHUB_REF_NAME}-<target>.tar.gz, so the expectation is derived the same
71+
# way the artifact is built.
72+
tag = os.environ["GITHUB_REF_NAME"]
73+
inc = [{k: t[k] for k in ("target", "os")} for t in spec["targets"]]
74+
assets = ["%s-%s-%s.tar.gz" % (spec["asset_prefix"], tag, t["target"])
75+
for t in spec["targets"]]
76+
# A FLOOR, BECAUSE A LOOP OVER A DISCOVERED SET WITH NO FLOOR PASSES WHEN THE SET IS EMPTY.
77+
# Both the build matrix and the expectation list are enumerated from this output, so a
78+
# truncated or mis-parsed manifest would otherwise build nothing, expect nothing, and
79+
# report green all the way to a promoted release with no assets on it.
80+
if len(inc) < 3:
81+
raise SystemExit(
82+
"release-targets.json declares %d targets; busbar-admin ships 3. Refusing to run "
83+
"a build matrix and an expectation list over a set this small: an empty "
84+
"expectation list passes for a release that published nothing." % len(inc))
85+
print("matrix=" + json.dumps({"include": inc}))
86+
print("assets=" + json.dumps(assets))
87+
PY
88+
cat "$GITHUB_OUTPUT"
89+
4990
build:
50-
needs: create-release
91+
needs: [create-release, targets]
5192
strategy:
5293
# Let every leg run and report. With fail-fast the first failure cancels its siblings, which
5394
# hides how many platforms are actually broken; the draft gate below is what keeps a partial
5495
# result from reaching users, so there is nothing to protect by stopping early.
5596
fail-fast: false
56-
matrix:
57-
include:
58-
- target: x86_64-unknown-linux-gnu
59-
os: ubuntu-latest
60-
- target: aarch64-apple-darwin
61-
os: macos-latest
62-
- target: x86_64-apple-darwin
63-
os: macos-latest
97+
matrix: ${{ fromJSON(needs.targets.outputs.matrix) }}
6498
runs-on: ${{ matrix.os }}
6599
steps:
66100
- uses: actions/checkout@v4
@@ -81,25 +115,93 @@ jobs:
81115
"busbar-admin-${GITHUB_REF_NAME}-${{ matrix.target }}.tar.gz" \
82116
--repo "${GITHUB_REPOSITORY}" --clobber
83117
84-
# The only step that ever publishes. `needs: build` means a total build failure never reaches it
85-
# at all and the release stays an invisible draft; a partial failure reaches it and is refused
86-
# here on the count.
118+
# THE ONLY STEP THAT EVER PUBLISHES, and it now asserts the draft carries every asset BY NAME.
119+
# A total build failure never reaches it at all and the release stays an invisible draft; a
120+
# PARTIAL failure reaches it and is refused here, naming the platforms that are missing.
121+
#
122+
# `!cancelled()` IS LOAD-BEARING. `build` runs fail-fast:false, so a partial matrix FAILS the job,
123+
# and a `needs:` on a failed job SKIPS its dependent by default -- the one guard that exists to
124+
# notice a broken release would be switched off precisely when the release is broken. Running on
125+
# `!cancelled()` turns a partial matrix into a RED verify-assets that NAMES the missing platforms,
126+
# instead of a grey one that names nothing.
87127
verify-assets:
88-
needs: build
128+
name: the draft owes every asset the manifest names
129+
needs: [targets, build]
130+
if: ${{ !cancelled() }}
89131
runs-on: ubuntu-latest
90132
steps:
91-
- name: Require the full asset set, then promote
133+
- name: Assert the draft carries every asset, then promote it
92134
env:
93135
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
136+
EXPECTED: ${{ needs.targets.outputs.assets }}
94137
run: |
95138
set -euo pipefail
96-
count="$(gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \
97-
--json assets --jq '.assets | length')"
98-
echo "attached assets: ${count} (expected ${EXPECTED_ASSETS})"
99-
gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \
100-
--json assets --jq '.assets[].name'
101-
if [ "${count}" -ne "${EXPECTED_ASSETS}" ]; then
102-
echo "::error::PARTIAL RELEASE PREVENTED: ${GITHUB_REF_NAME} has ${count} assets, expected ${EXPECTED_ASSETS}. Leaving it as a DRAFT so no user can download an incomplete release." >&2
139+
# `!cancelled()` means this runs even when `targets` itself failed, and an empty
140+
# expectation list would then "verify" every release vacuously. Refuse instead.
141+
if [ -z "${EXPECTED:-}" ]; then
142+
echo "::error::The targets job produced no expected-asset list, so there is nothing to" \
143+
"verify ${GITHUB_REF_NAME} against. Refusing to promote: it stays a draft." >&2
103144
exit 1
104145
fi
146+
gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \
147+
--json assets --jq '.assets[] | "\(.name)\t\(.size)"' > /tmp/got.tsv || : > /tmp/got.tsv
148+
echo "Draft ${GITHUB_REF_NAME} carries these assets:"
149+
cat /tmp/got.tsv
150+
python3 - <<'PY'
151+
import json, os, sys
152+
expected = json.loads(os.environ["EXPECTED"])
153+
tag = os.environ["GITHUB_REF_NAME"]
154+
got = {}
155+
for line in open("/tmp/got.tsv"):
156+
line = line.rstrip("\n")
157+
if not line:
158+
continue
159+
name, _, size = line.partition("\t")
160+
got[name] = int(size or 0)
161+
162+
missing = [a for a in expected if a not in got]
163+
# A NAME IN THE ASSET LIST IS NOT A USABLE ARTIFACT: GitHub creates the row as soon as the
164+
# upload starts, so a 0-byte or truncated upload lists identically to a good one. 1 KiB is
165+
# far below any real busbar-admin archive and far above an empty or header-only file.
166+
empty = [a for a in expected if a in got and got[a] < 1024]
167+
168+
lines = ["### Draft asset verification", "",
169+
"| asset | bytes | verdict |", "| --- | --- | --- |"]
170+
for a in expected:
171+
if a not in got:
172+
lines.append("| `%s` | - | MISSING |" % a)
173+
elif got[a] < 1024:
174+
lines.append("| `%s` | %d | TOO SMALL |" % (a, got[a]))
175+
else:
176+
lines.append("| `%s` | %d | ok |" % (a, got[a]))
177+
extra = sorted(set(got) - set(expected))
178+
if extra:
179+
lines += ["", "Also present (not required): " + ", ".join("`%s`" % e for e in extra)]
180+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
181+
if summary:
182+
open(summary, "a").write("\n".join(lines) + "\n")
183+
print("\n".join(lines))
184+
185+
if not got:
186+
print("::error::PHANTOM RELEASE: the %s draft has 0 assets. Every build target failed "
187+
"to upload. Nothing is public and nothing was promoted, so this is a clean "
188+
"retry: fix the build and re-run this workflow." % tag, file=sys.stderr)
189+
sys.exit(1)
190+
if missing:
191+
print("::error::PARTIAL RELEASE PREVENTED: the %s draft is missing %d of %d required "
192+
"asset(s): %s. Each missing name is a PLATFORM whose users would get a 404 from "
193+
"the download link. It stays a DRAFT, so nothing user-facing exists: fix that "
194+
"target's leg and re-run." %
195+
(tag, len(missing), len(expected), ", ".join(missing)), file=sys.stderr)
196+
if empty:
197+
print("::error::TRUNCATED RELEASE: these %s draft assets are under 1 KiB, which means "
198+
"the upload was cut short and the archive is useless to anyone who downloads "
199+
"it: %s" % (tag, ", ".join(empty)), file=sys.stderr)
200+
if missing or empty:
201+
sys.exit(1)
202+
print("All %d required assets present and plausibly sized." % len(expected))
203+
PY
204+
# Only now, with EVERY promised asset provably attached and plausibly sized, does this
205+
# stop being a draft and become the release that `releases/latest` resolves to.
105206
gh release edit "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --draft=false --latest
207+
echo "::notice::Published ${GITHUB_REF_NAME} with every asset in the contract."

0 commit comments

Comments
 (0)