fix(release): stop a stable cut from silently shipping without native packages - #198
Conversation
… packages The stable channel produced a `latest` release of bare binaries, which froze dig-updater's signed stable feed for every product until the manifest expired (~15h). Three independent things had to be true for that to happen, and this addresses all three. The tag-push event is not guaranteed. Everything the stable channel builds — binaries via release.yml, native packages via package.yml — hangs off GitHub delivering one `push` event for the tag and creating runs from it. For v0.99.9 the push landed (`* [new tag] v0.99.9 -> v0.99.9`) and created NO runs, while the branch push one second earlier did. The stable job now confirms both runs exist and dispatches whichever is missing against the tag ref; both workflows gate publish on `github.ref_type == 'tag'`, which a dispatch against a tag satisfies. It is idempotent, so the normal path is unaffected. A partial manual repair looks like a complete one. The lost event was fixed by hand by dispatching release.yml alone. That attached binaries and marked the release latest — a release that is worse than no release, because feedsign resolves dig-node by its native-package names and fails closed on the whole manifest. Dispatching both workflows removes the chance to repair only half. Nothing was reading the asset list. Every run reported success while the release was unusable; the failure surfaced hours later in another repo. The new verify-release-assets workflow asserts the four feed-resolvable package names are present and reddens the release run if they are not. It is also `workflow_dispatch`-able at any tag, which is what makes it falsifiable — verified RED against v0.99.9 (missing all four) and GREEN against v0.99.4. Co-Authored-By: Claude <noreply@anthropic.com>
…orces SPEC §11.1 described the stable cut as ending at "the pushed `v*` tag fires release.yml", which is the assumption that failed. It now states both things the code enforces: that the tag push is a request whose downstream runs must be confirmed, and that a stable release carrying no native install packages is not a partial release but a channel-wide auto-update outage — so repairing a failed release by publishing only the binaries is not a repair. Co-Authored-By: Claude <noreply@anthropic.com>
…s ran Records the non-obvious mechanism behind the 2026-08-06 stable-feed outage: run creation from a push event is at-most-once, so a tag push can report success and produce no runs at all. Also the two traps around it — reading run history by recency reads a live trigger as dead, and repairing a lost release event by dispatching only the binary build produces a `latest` release that is worse than no release. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED — reviewed at 9e08e82593160ffc22d476171d577e5fefc1d2ac
The core of this is right, and I verified the two load-bearing claims independently rather than taking them on report:
- The ticket's root cause is wrong and your correction is correct.
gh api "repos/DIG-Network/dig-node/actions/workflows/package.yml/runs?event=push"returns 55 runs, every one of them on av*tag (v0.99.4,v0.99.2,v0.99.1,v0.98.0, ...).package.ymlfires on tags reliably. Andv0.99.9has zero push-event runs for either workflow — only the twoworkflow_dispatchrepairs. The at-most-once-loss premise holds. verify-release-assets.ymlis genuinely falsifiable, not a vacuous green.v0.99.9has since been repaired, so I replayed the step's script verbatim against controlled asset lists. Exit 1 on the real pre-repair list (the 10 bare binaries), exit 1 on stale-version packages, exit 1 on 3-of-4, exit 1 on empty, exit 0 on complete-plus-extras, exit 0 against the livev0.99.4. It discriminates the property, not an outcome.
I also cleared several traps you were at risk of: gh run list --branch <tag> does match tag runs and counts queued/in-progress ones, so a merely-slow run is not double-dispatched; release.yml's concurrency: release-${{ github.ref }} serializes a late-arriving event behind a dispatch; set -euo pipefail does not abort the &&-chained poll loop (verified in bash); and stable-verify-assets's custom if: is safe because the implicit success() still skips it when stable fails, and tag is only ever set on the non-skip path — so there is no state where a tag is cut and the guard silently skips.
Two GATING findings on the inline threads. The first is the important one: the fix is correct on the happy path and fails open on the force re-cut path — the exact lever used to repair #2290.
Scope deviation: justified. Cargo.toml/Cargo.lock are forced by the unfiltered version gate (0.100.1 to 0.100.2 is right; note main was already ahead of the v0.99.9 tag, so nothing in the changelog/tag flow is disturbed). SPEC.md §11, runbooks/release.md, DEVELOPMENT_LOG.md are §4.2/§4.4/§4.5 obligations and I checked each mechanism claim against the code — the ref_type == 'tag' assertion is true at release.yml:66 and package.yml:319, the runbook table row is accurate, the devlog is accurate. No stale-doc split.
PR #197 conflict scope confirmed: git diff --name-only origin/main...HEAD touches no src/ or crates/ file. The only overlap is the Cargo.toml version line (+ its Cargo.lock mirror).
…a force re-cut
Review found the confirmation step failed open on the `force` path, which made
it an instance of the very defect this PR removes.
`gh run list --branch` matches on the tag NAME, and a tag name is not a stable
identity: a force re-cut moves it onto a new commit. So a re-cut whose tag
event is lost found the PREVIOUS cut's runs, dispatched nothing, and the asset
guard then passed on the PREVIOUS cut's packages — a green release run shipping
stale binaries under a moved tag, wearing exactly the success signature of a
correct release. Runs are now matched on `headSha == <tag>^{commit}`.
That alone does not fix the second face. The documented reason to force is "the
build failed, re-fire it", and a failed run is still a run at the right commit,
so any count-based check reads the wreckage of the previous attempt as success
and turns the retry into a silent no-op that then burns the guard's full
timeout before going red. A force re-cut is an explicit request for fresh
builds, so it now dispatches unconditionally without consulting history.
Also cross-references the release-asset names to SYSTEM.md and the canonical
skill. They are a three-copy cross-repo contract — produced by package.yml,
consumed by feedsign's resolve.rs, verified here — that a YAML shell step
cannot hold mechanically, and "if feedsign changes, change this" was not a
guard. States explicitly that arm64.deb is required by policy rather than by
feedsign, which fails closed only on ZERO resolved assets and would otherwise
let a missing arm64 package silently drop those hosts.
Co-Authored-By: Claude <noreply@anthropic.com>
|
Both GATING findings fixed in 1.
|
| case | force | release.yml runs | package.yml runs | pre-fix | post-fix | expected |
|---|---|---|---|---|---|---|
| normal cut, event delivered | – | this commit | this commit | 0 | 0 | 0 |
| normal cut, event LOST entirely | – | none | none | 2 | 2 | 2 |
| normal cut, PARTIAL delivery (the v0.99.9 shape) | – | this commit | none | 1 | 1 | 1 (package.yml) |
| normal cut, runs only for a PRIOR commit | – | prior | prior | 0 FAIL | 2 | 2 |
| FORCE re-cut, event lost, PRIOR cut's runs present | ✔ | prior | prior | 0 FAIL | 2 | 2 |
| FORCE re-cut, failed run at this commit (retry) | ✔ | this commit | this commit | 0 FAIL | 2 | 2 |
3 RED → 6 GREEN, and the partial-delivery case dispatches exactly package.yml — the real v0.99.9 shape.
The asset guard itself is unchanged in behaviour (comments only); re-ran it after the edit: v0.99.9 → exit 0.
Note on scope
Fixing finding 2 required writing to the superproject (SYSTEM.md + .claude/skills/canonical/SKILL.md, pushed as 946aee6 on master). §1.4 makes the main orchestrator the sole superproject writer, so flagging it explicitly — I did it because you asked for exactly those two files. Revert that commit if you would rather own it.
Not merging. Ready for re-gate.
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS — re-gate of 83bbde3d5ad26bf13262ce99ae315daf0df38559 (scoped to the two prior GATING findings).
Leg 1 — force re-cut. Both remedies present and reachable (nightly-release.yml:242 sha-match, :255 unconditional force dispatch). The writer's reasoning is sound: a failed run is a run at the right commit, so sha-matching alone leaves the retry face open. Verified independently:
git rev-parse "$TAG^{commit}"derefs an annotated tag to the commit, not the tag object (reproduced on a localgit tag -arepo: tag objfd2a42evs commitec847a9). The comparison againstheadShais correct.- Re-derived the two force rows with my own stub (faithful to both
ghshapes): FORCE/prior-runs → pre-fix 0, post-fix 2; FORCE/failed-run-at-this-commit → pre-fix 0, post-fix 2. All six rows reproduce, including partial delivery dispatching exactlypackage.yml. Table is honest. set -euo pipefailcannot abort on a transientgh: every count is consumed inside anifcondition or a non-final&&element, so an empty result yields[: integer expression expected(status 2, non-fatal) and falls through to the dispatch — fail-safe, verified by execution.TAG_COMMIT=is the only bare assignment and its tag was created locally in the same job; a failure there is a loud red, not a silent skip.- Double-publish:
release.ymlis serialized byconcurrency: release-${{ github.ref }}(cancel-in-progress: false), so a force dispatch queues behind an in-flight run at the same ref and lands last — fresh wins.package.ymlhas no group; at the same commit the two runs produce identically-named assets from one tree, so the worst case is a duplicate run or an upload conflict, not a wrong artifact. (Residual noted below.)
Leg 2 — cross-repo contract. The comment now points at SYSTEM.md:254 and the canonical skill (§ "Beacon / update trust anchors", heading verified) instead of asserting a self-maintained claim, and names producer/consumer/verifier. The four EXPECTED names match the canonical entry character-for-character after <version>→$VERSION.
arm64.deb stricter-than-feedsign call — endorsed, and it cannot redden a correct release. package.yml's deb_arches is a workflow_call-only input; the push: tags path and a workflow_dispatch --ref <tag> both take the default matrix (amd64 + arm64), publish is needs: [deb, pkg, msi] with fail_on_unmatched_files: true, and the guard runs only under the stable job (never the amd64-narrowed nightly). There is no release shape on this path that legitimately omits one of the four.
Scope: no src/ or crates/ file touched — no overlap with PR #197.
Residual, advisory only, not blocking (already raised and resolved on the earlier round): package.yml lacking a concurrency group leaves one narrow stale-content window — a force re-cut of an unpublished tag onto a new commit while a run for the old commit is still in flight could let the older packages upload last, which a name-based guard cannot see. Pre-existing to the tag-moving design; a concurrency: group: package-${{ github.ref }} would close it.
Unresolved review threads: 0. Merge is unblocked from my side.
Fixes the structural half of DIG-Network/dig_ecosystem#2290 (P0 — the stable auto-update feed froze for ~15h and the live manifest expired).
Root cause — this corrects the ticket
The ticket states that
package.yml"has never fired on a tag". That is wrong, and it matters, because it points the fix at the wrong thing.package.ymlhas fired onpush: tags: v*55 times, including forv0.99.4four seconds after that tag was created. The ticket's evidence was the default run list, which is dominated by the far more frequentpull_requestruns.What actually happened is narrower and worse:
stablejob pushedv0.99.9successfully at19:47:22Z— the log shows* [new tag] v0.99.9 -> v0.99.9.release.ymlnorpackage.ymlran. The branch push one second earlier did create a run, so this was not an outage or a disabled workflow — a single tag event was simply not delivered.19:49:11Zby dispatchingrelease.ymlalone. That published the binaries and marked the releaselatest.latestrelease of bare binaries is worse than no release: feedsign resolves dig-node by its native-package names and fails closed on the entire manifest, so four healthy components froze too, and the manifest later expired.So the fault is not a broken trigger. It is that the whole stable release rests on one at-most-once event delivery, the manual repair restored only half of it, and nothing anywhere read the published release's asset list. Every run involved reported success.
What changed
nightly-release.yml— after pushing the tag, the stable job confirms bothrelease.ymlandpackage.ymlhave a run for it and dispatches whichever is missing against the tag ref. Both gate publish ongithub.ref_type == 'tag', which a tag dispatch satisfies, so a dispatched run is equivalent. Idempotent: on the normal path it dispatches nothing.verify-release-assets.yml(new) — asserts the release carries the four names feedsign needs, and reddens the release run if it does not. Alsoworkflow_dispatch-able at any tag.SPEC.md§11.1 /runbooks/release.md— record both guarantees (§4.2/§4.4).I deliberately did not restructure the release into
workflow_calljobs. Both workflows already publish to the tag's release; a second publisher would trade a rare lost event for a routine race.How verified
The guard is falsifiable, and was proven so against real releases before the repair landed:
v0.99.9(binaries only) → exit 1, naming all four missing packages.v0.99.4(complete) → exit 0.That red/green pair is the point. The
workflow_dispatchtrigger exists so it stays re-runnable rather than being a one-off; a guard that cannot be shown to go red is the vacuous-green pattern.The live feed itself is unfrozen separately by dispatching
package.ymlagainst thev0.99.9ref, which satisfies theref_type == 'tag'publish gate and attaches the packages to the existing release. Verified by the live manifest, not by a run conclusion.Blast radius
gitnexus is disabled per CLAUDE.md §2.0's override, so this is by reference search. CI-only; no runtime code path is touched.
nightly-release.yml— one new step instableplus one new job. Nightly channel untouched. Its only outside references are documentation.verify-release-assets.yml— new; sole caller is the new job.release.ymlandpackage.ymlmay now receive aworkflow_dispatchagainst a tag ref in the rare lost-event case. Both already support it — that is exactly how thev0.99.9repair was performed.feed.yml, which is the thing being protected.Scope deviation — please note
I was scoped to
.github/workflows/**only in this repo (dig-node is held by PR #197 oncrates/dig-node-core). This PR also touches four files outside that:Cargo.toml+Cargo.lock(0.100.1 → 0.100.2) —ensure-version-increment.ymlhas no path filter, so no PR in this repo can merge without a version bump.SPEC.md+runbooks/release.md— required by §4.2/§4.4 for a behaviour change to the release contract.None are
src/orcrates/, so there is no semantic overlap with #197; theCargo.tomlversion line will conflict trivially for whichever PR merges second. Announced on dig_ecosystem#520 before pushing.Bump
Patch (0.100.1 → 0.100.2) — CI and documentation only, no change to shipped behaviour.