2121permissions :
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-
3024jobs :
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