Skip to content

Record a release that cannot be reconciled and reconcile the rest - #253

Merged
anth-volk merged 8 commits into
mainfrom
fix/release-reconciliation-continues
Oct 26, 2026
Merged

anth-volk merged 8 commits into
mainfrom
fix/release-reconciliation-continues

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #252

Base: main. This was opened on #247 (head 65ad119), which was stacked on #250. Both merged on 2026-10-21 (#250 at 16:51 UTC, #247 at 19:05 UTC, after #247 was restacked to 3e205e0), and GitHub retargeted this PR to main. Its two commits were rebased onto main (9ee43ff) with git rebase --onto origin/main 65ad119; main's tree is identical to 65ad119's, and git range-diff marks both commits identical (1a66fa7 → d592474, b83108a → 197762f). The counts below were rerun on the rebased commits.

#255 (reading diagnostics schema 9) is stacked on this branch. Head: 0f8321b.

Problem

Release reconciliation threw at the first release it could not publish or repair. It printed no report, never reached the releases after that one, and never checked or updated the latest-release selection. Every later run stopped at the same release. Since 2026-10-02 this has blocked every US release reconciliation (runs 37036146264 and 39488424340), first at populace-us-2024-0fb05b6-4b57d15a287c-20260930T150401Z, so populace-us-2024-buildo-acs-local-766b7c7-20261017T030317Z was never reached. #247 removes that particular error; this PR removes the stop-at-first-error behaviour.

Change

reconcileReleaseBuilds (frontend/packages/calibration-tree/src/publish-calibration-tree.ts) now works the way reconcileStagingBuilds has since #237:

  • Each release in its own try. An error for one release becomes that release's failed outcome, with the error as reason and the existing entry's build ID (or null), and the loop continues. This covers every per-release error: a tag that moved ("Released dataset … changed from … to …"), a moved folder, a corrupt stored build, a diagnostics file the reader refuses, a refused repair ("Rebuilt calibration index differs from immutable manifest entry …"), a size limit, an upload error, and an audit that finds the upload incomplete.
  • The report is always printed. runPublisher already printed the report and then threw reconciliationFailureMessage(report) for both modes; for releases that message was never reached. It now reads "N of M releases failed to reconcile: . The printed report gives each reason." The staging message is unchanged ("… staging sources failed …"). The process exits with status 1, so the workflow run fails.
  • Errors before the loop still stop reconciliation: reading the manifest, listing releases and tags, resolving tag folders, and the first Blob listing. Some of these can be attributed to one tag or folder (an invalid release_manifest.json on a tag, a non-404 error from a folder probe, a folder whose commit can't be found); they still stop every release with no report, as before this PR. See "Review" below.

A failed release leaves no partial state

  • No manifest entry. The release steps that write are now: upload the bundle (publishRelease), list the stored files, audit the build, then write the manifest entry. The audit used to come after the manifest write, so an upload the audit found incomplete was already registered. The audit (auditCalibrationTreeBuild) reads the build's own index and the Blob listing, never the manifest, so it gives the same answer before the write. Every error is now raised before the manifest names a new build. The one exception is the manifest write's own read-back check ("Calibration tree manifest verification failed."), which runs after the write was stored; that was already the case and is unchanged.
  • No latest-release selection. The selection step (now selectLatestRelease) skips a release that failed in this run and leaves latestReleaseBuildArtifactId naming the build it named before. Without this, a release whose repair failed but which already had a manifest entry would have been selected.
  • No index for a new build that fails before the upload. Almost every refusal (reader, digest, size limit, moved tag) happens before any upload. The bundle uploads its index last (calibration-tree-bundle.ts orders it last, uploadCalibrationTreeBundle uploads in order), so an upload that fails part-way leaves no index. An upload that completes but fails its audit leaves its files, index included, under the build's ID with no manifest entry pointing at them. The build ID is a digest of the country, the release ID and the source files' digests (calibrationTreeBuildArtifactId), so the next run rebuilds the same ID, and uploadCalibrationTreeFile compares each existing file byte for byte before reusing it. Nothing reads files that no manifest entry names.
  • The latest-release step reports instead of throwing. If the pointer cannot be read, names a release that is not completely published, or names a release that failed in this run, the report gets a new optional field, latestReleaseFailure, with the reason, and the publisher exits with an error after printing the report. It used to throw "Latest release … is not completely published." with no report. The field is absent when the selection was updated, so a successful run prints the same report as before.

Manifest writes and concurrency

  • Only the publisher writes calibration-trees/manifest.json (updateCalibrationTreeManifest; the dashboard's lazy comparison writes use other paths). Each write reads the stored manifest, adds one entry and writes with ifMatch on the ETag it read; after a conflict it reads again and writes once more without the condition. A failed release now makes no manifest write at all, so the reads and writes for the releases after it are exactly those of a run without it. The local manifest copy is replaced only by a successful write's return value, and each later write re-reads the stored manifest anyway.
  • The workflow's calibration-tree-manifest-publication concurrency group still allows one publisher job at a time across all countries and event kinds, with cancel-in-progress: false. The workflow is unchanged.
  • A run with a failure now continues to build the remaining releases, so it holds the concurrency group for as long as a run in which nothing failed, plus the time spent on the failed release. That is never longer than today's run for the same releases once the failure is fixed. timeout-minutes: 30 is unchanged; for reference, the largest recorded US tree took about 11 to 12 minutes to compile on a laptop (see the Group calibration tree geographies by geography ID, not label #250 review).

Dry run

--dry-run records failures the same way, uploads and writes nothing, and only reads the upstream pointer in the latest-release step (as before). It exits with an error if any release failed, after printing the report.

Byte identity of successful publications

Nothing in the bundle, the uploaded files or the manifest entry changed. Only the order of the listing and audit relative to the manifest write changed, and a successful run makes the same uploads and the same manifest writes (same entries, same makeLatest calls). Checked by:

  • publish-calibration-tree-golden.test.ts (7 pass): dry-run repairs of the recorded US, UK and Belgium releases still rebuild their recorded index digests, and the staging tests are unchanged;
  • calibration-tree-golden.test.ts (12 pass) and test-support/calibration-tree-golden-cases.test.ts (2 pass), with no golden record changed;
  • the existing test in publish-calibration-tree.test.ts that publishes tagged UK builds from their shared release folder, which checks every manifest entry and the latest-release selection, passes unchanged.

Tests (red, then green)

Commit What Result
d592474 (was 1a66fa7) Tests only. Five new tests in publish-calibration-tree.test.ts; the in-memory store there now records uploads, listings, audits and manifest writes in order. In publish-calibration-tree-golden.test.ts, the refused-repair test now expects a failed outcome instead of a rejected call. 6 fail, 21 pass across the two files. Each failure is the call rejecting with the failing release's error: "Schema 8 category provider.category references inconsistent providers: provider and other." (4 tests), "Release build microcosm-uk-2024-25-dense-… remains incomplete: missing …/tier-1.json.gz" (1), "Rebuilt calibration index differs from immutable manifest entry cf259cfc…" (1). bun run lint fails only because the tests read the new latestReleaseFailure field (TS2339, two places).
197762f (was b83108a) The fix and the documentation; tests unchanged 27 pass, 0 fail; bun run lint passes
0f8321b Comments and one test name only: the new tests now say "tagged build" and "pointer file" for the fixture's release tags and latest-national.json 20 pass in publish-calibration-tree.test.ts; bun run lint passes

The new tests:

  1. A UK repository whose releases are tagged builds of shared release folders; the dense tagged build (first in order) has a file the schema 8 reader refuses: it is failed with the reader's error and no build ID; the national tagged build is still published and becomes the latest release; nothing is uploaded or written for the dense one.
  2. The dense tagged build's upload audits as incomplete: it is failed and never written to the manifest; the recorded operation order is upload, list, audit, then manifest write for each release.
  3. The release the pointer file (latest-national.json) names fails: the selection keeps naming the June release, and latestReleaseFailure says why.
  4. runPublisher with two failures (a refused file and a tag that moved to another commit): it prints one report with all five outcomes, then rejects with "2 of 5 releases failed to reconcile: , . The printed report gives each reason."
  5. A dry run records the failure and uploads and writes nothing.

At 197762f:

  • calibration-tree package (bun test packages/calibration-tree): 378 pass, 0 fail, 34 files (373 on main, plus 5 new);
  • publish-calibration-tree.test.ts 20, publish-calibration-tree-golden.test.ts 7, calibration-tree-golden.test.ts 12, test-support/calibration-tree-golden-cases.test.ts 2, package-boundary.test.ts 9, calibration-tree-artifact.test.ts 16, calibration-tree.test.ts 41, all 0 fail;
  • app/api/hf-webhook/route.test.ts and lib/microcosm/hf-webhook-config.test.ts (the webhook that dispatches this workflow): 11 pass;
  • bun run lint in frontend/ (both tsc --noEmit runs): passes.

Documentation

  • docs/calibration-tree-artifacts.md: the release publication section says what a failed release means; "Failure meanings and recovery" now describes a failed release workflow the way it describes a staging one; "Historical reconciliation" no longer says release reconciliation stops at its first release, and describes the order of a release's writes, what files an error leaves, the manifest write's condition, and when the latest-release selection is left unchanged.
  • docs/ai/workflows/publish-calibration-tree-artifacts.md: the publish steps include the audit before the manifest entry and the rule that a failed release is never selected; the reconciliation paragraph describes the failed outcome.
  • The workflow file never described the stop-at-first-error behaviour, so it is unchanged.

Environment variables

No workflow, deployment script or configuration changes, and the changed code reads no new environment variable. The publisher's existing variables, for reference (names checked with gh secret list / gh variable list, values not read):

Variable Read by Provider Wiring
BLOB_READ_WRITE_TOKEN runPublisher GitHub Actions repository secret job env: of publish-calibration-tree.yml, inherited by bun run
HF_TOKEN (fallback HUGGINGFACE_TOKEN, not set) Hugging Face reads GitHub Actions repository secret job env:
POPULACE_UK_STAGING_HF_TOKEN UK staging reads (registration token_env) GitHub Actions repository secret job env:
CALIBRATION_TREE_BUILD_ENABLED the job's if: GitHub Actions repository variable vars. context
CALIBRATION_TREE_MAX_RAW_BYTES, CALIBRATION_TREE_MAX_GZIP_BYTES size limits not set; defaults apply —

Not changed

Review (2026-10-21)

The change is correct (review report kept in the maintainer's notes). Pushed by the review, test first:

Commit What Result
73a2c32 Test only: a dry run must report latestReleaseFailure when the release named by the pointer file fails 1 fail, 20 pass (latestReleaseFailure was undefined)
bb77ca6 The check now runs before a dry run returns 28 pass across the two changed test files
f0d1e54 Docs only: the errors that still stop the whole run also include the first Blob listing and the per-tag and per-folder Hugging Face lookups —

At f0d1e54: the calibration-tree package 379 pass, 0 fail (the counts above are from 0f8321b); golden tests 12, 2 and 7; boundary 9; webhook 11; typecheck passes.

Confirmed: the run still exits 1 when a release fails, and nothing reads the old output; the audit never reads the manifest, so skip and repair decisions and successful publications are unchanged; partial uploads from a failed release are reused safely, because build IDs and index bytes are reproducible and existing files are compared byte for byte; compare-and-swap and the concurrency group are unchanged. Every environment variable is wired: BLOB_READ_WRITE_TOKEN, HF_TOKEN and POPULACE_UK_STAGING_HF_TOKEN are repository secrets passed through the job's env:; CALIBRATION_TREE_BUILD_ENABLED is a repository variable read in the job's if:; the size limits, HUGGINGFACE_TOKEN, MICROCOSM_UK_LOCAL_CALIBRATION_DIR and the repository overrides (POPULACE_HF_REPO, POPULACE_UK_HF_REPO/_REVISION, POPULACE_BE_HF_REPO/_REVISION and the staging ones) have no provider and fall back to defaults, as intended.

Not fixed (follow-ups):

  • Medium, already on main: one bad tag or folder still stops every release with no report (see "Change").
  • Low: staging still writes its manifest entry before its audit, so an incomplete staging upload gets an entry while a failed release now doesn't.
  • Low: a failed new release reports buildArtifactId: null even when its files were uploaded; staging reports the new ID.
  • Operational: a run with a failure now builds every remaining release, so it holds the shared concurrency slot longer. The first US run after this merges may compile several trees waiting since 2026-10-02 within timeout-minutes: 30; on a timeout no report is printed, and releases already written keep their entries.

Review round 2026-10-26

Reviewed at f0d1e54, as it would merge into the current main (c84a916). A local merge has no conflicts. On the merged tree, bun test packages/calibration-tree gives 386 pass and 0 fail, and bun run lint and bun run build both pass. main was not merged into this branch, because nothing here needed it.

  • The 2026-10-21 fixes are correct and complete.
    • After bb77ca6, a dry run reports latestReleaseFailure when the pointer file names a release that failed. A dry run still does not check whether the named release is completely published.
    • After f0d1e54, the docs list every lookup that still stops the run before the per-release loop.
  • main since then. List releases published only as a Hugging Face tag in the release picker #258 adds releases that are published only as a tag to the release list. It reads the dashboard manifest and the folder listing, and it doesn't change the publisher. A failed new release has no manifest entry, so the release list never offers it as a tag-only release. Refuse a country with no configured release instead of serving the US one #248, Start production deploy runs only for main CI runs #261 and Stop trusting the compromised eMudhra CA certificates #256 don't touch the publisher or its workflow. I found no bad interaction with the per-release error handling, the latest-release selection or the dry-run report.
  • Findings. Both are low severity and are in comments and docs only, with no change in behaviour.
    • Fixed in 3034a78: the ReconciliationOutcome comment said nothing is written for a failed source. In fact, staging writes its entry before the audit, and a release's uploaded files stay in storage.
    • Fixed in 3034a78 and 64f9cf4: the docs and the loop comment said that a failed release gets no manifest entry and is never selected as the latest release. In fact, an entry the release already had stays, and so does an existing selection. A failed new release does get an entry when the manifest write is stored but its read-back check fails.
  • Still open from 2026-10-21. These are the follow-ups listed above:
    • one bad tag or folder still stops every release;
    • staging still writes its manifest entry before the audit;
    • a failed new release still reports buildArtifactId: null;
    • runs are now longer, measured against timeout-minutes: 30.
  • The "Dry run" paragraph above is out of date. Since bb77ca6, a dry run also reads the pointer file and reports latestReleaseFailure when the pointer cannot be read or when it names a release that failed in the same run.
  • Read calibration diagnostics schema 9, whose hierarchy labels are declared once in catalogs #255: this branch was merged into it in e634386, with no rebase.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
calibration-diagnostics Ready Ready Preview Oct 26, 2026 12:35pm UTC

Request Review

@anth-volk
anth-volk force-pushed the fix/tree-builder-label-variants branch from 65ad119 to 3e205e0 Compare October 21, 2026 16:58
Base automatically changed from fix/tree-builder-label-variants to main October 21, 2026 19:35
anth-volk and others added 2 commits October 21, 2026 23:47
Release reconciliation throws at the first release it cannot publish or
repair, so it prints no report, never reaches the releases after it, and
never updates the latest-release selection. These tests describe the
behaviour staging reconciliation has had since #237:

- a release the schema 8 reader refuses is reported as `failed` with the
  error, and the releases after it are still published;
- a release whose uploaded build fails its audit gets no manifest entry;
  the audit now comes before the manifest write;
- when the release the line pointer names fails, the latest-release
  selection is left as it was and the report says why;
- the publisher prints the whole report, then exits with an error that
  names every failed release;
- a dry run records the failure and writes nothing;
- the recorded-digest repair test now expects a `failed` outcome instead
  of a rejected call.

The in-memory store in publish-calibration-tree.test.ts now records the
order of uploads, listings, audits and manifest writes.

Tests only. All six fail on the current publisher, which throws at the
failing release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Release reconciliation threw at the first release it could not publish
or repair. It printed no report, left every release after it
unreconciled and never updated the latest-release selection, on every
later invocation. Since 2026-10-02 one US release has blocked every later
US release this way.

Each release's error is now recorded as that release's `failed` outcome,
with the error as its reason, and the remaining releases are still
reconciled, as staging reconciliation has done since #237. After
printing the report, the publisher exits with an error naming every
failed release, so the workflow still fails. Errors before the
per-release loop (reading the manifest, listing releases or tags) still
stop reconciliation.

A failed release leaves no manifest entry and is never selected as the
latest release:

- The uploaded build is now listed and audited before its manifest
  entry is written, not after, so an incomplete upload is never
  registered. The audit reads the build's own index and the Blob
  listing, not the manifest. Successful publications upload and write
  the same bytes as before.
- The latest-release selection skips a release that failed in the same
  run, keeping the build it named before. When the selection cannot be
  read or updated, the report's new `latestReleaseFailure` field says
  why, and the publisher exits with an error after printing the report
  (it used to throw without a report).

The failure message reads "N of M releases failed to reconcile" for
releases; the staging message is unchanged. The docs describe the new
behaviour and the order of a release's writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comments and one test name added in the previous two commits called
the UK fixture's tagged builds "cuts" and its `latest-national.json`
pointer file a "line pointer". They now say tagged build and pointer
file. Comments and a test name only; no behaviour changes, and the 20
tests in the file still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 21, 2026
…ma-9-catalogs

Brings in #253's test-only rewording commit, so this branch stays on top
of #253 without rewriting its history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 3 commits October 22, 2026 00:05
…rites

When the release the country's pointer file names fails to reconcile, a
run that writes leaves the latest-release selection unchanged and says
so in the report's `latestReleaseFailure`. A dry run records the same
`failed` outcome but returns from the selection step as soon as it has
read the pointer, so its report has no `latestReleaseFailure` and its
error message omits the selection. Both reports have every input the
check needs: the pointer and the failed releases.

The new test runs the same repository twice, once writing and once as a
dry run, and expects the same `latestReleaseFailure` and the same
failure message from both. It fails on the current publisher: the dry
run's `latestReleaseFailure` is undefined.

Tests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The latest-release step returned as soon as a dry run had read the
upstream pointer, before checking whether the release it names failed in
the same run. A dry run therefore omitted `latestReleaseFailure` and the
selection from its error message, although a run that writes reports
both.

The failed-release check now runs before the dry-run return. A dry run
still writes nothing and still does not check whether the named release
is completely published, since it writes no manifest entries. Runs that
write behave exactly as before. The field's comment and the
documentation say what a dry run reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The documentation said only reading the manifest and listing releases,
tags or staging runs stop reconciliation before any source. The first
Blob listing does too, and so do the Hugging Face lookups that build the
release list before the per-release loop: the folder probe and
`release_manifest.json` of each tag the manifest does not already pin,
and the commit of each folder release without a tag. An error in one of
those still stops every release with no report. The failure-meanings
list now names them as well.

Documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 21, 2026
…ma-9-catalogs

Brings in #253 up to f0d1e54. Both branches added a test at the same
place in publish-calibration-tree.test.ts; the merge keeps both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 2 commits October 26, 2026 16:33
The outcome type's comment said nothing was written for a `failed`
source. That is not true for staging, which writes a run's manifest
entry before it audits the upload, so a run whose audit fails has an
entry; nor for a release, whose files uploaded before the error stay
under its build ID. The release-reconciliation docs and the loop's
comment said a failed release gets no manifest entry and is never
selected as the latest release. A release that already had an entry
(a refused repair, or a tag that moved) keeps it, and if the selection
already named it, it still does: the run only never moves the
selection to a failed release.

Comments and documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The outcome type's comment and the release-reconciliation docs now say a
failed release gets no new manifest entry, but leave out the case the
loop's comment names: the manifest write is stored and only its
read-back check fails ("Calibration tree manifest verification
failed."). That release is reported as failed, yet the stored manifest
has its entry. Both now name that case.

Comments and documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 26, 2026
…ma-9-catalogs

Brings in #253's two review commits (comments and documentation only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review October 26, 2026 13:24
@anth-volk
anth-volk merged commit 4ab46d0 into main Oct 26, 2026
5 checks passed
@anth-volk
anth-volk deleted the fix/release-reconciliation-continues branch October 26, 2026 13:25

This branch was successfully deployed

1 active deployment
Preview — 64f9cf45 Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release reconciliation stops at the first release it cannot publish, leaving later releases unreconciled

1 participant