Skip to content

Publish release builds side by side with releases.json as the only record of what is current - #24

Merged
anth-volk merged 24 commits into
mainfrom
feat/release-writer-releases-json
Oct 22, 2026
Merged

anth-volk merged 24 commits into
mainfrom
feat/release-writer-releases-json

Conversation

@anth-volk

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

Copy link
Copy Markdown
Contributor

Fixes #20

Last of four stacked pull requests (base: #23). Adds the release writer: each build is published side by side, and one releases.json per dataset repository is the only record of what is current. Nothing calls the writer yet; Microcosm switches to it in H2c.

What changes

microcosm_emitter_interface.releases (shared, so Microcosm, the emitter and a scheduled repository check run the same code):

  • The record: schema_version 1, default (a release or null), and per release its role, current (a build or null) and each build's published_at and withdrawn, serialized in that order with releases and builds sorted.
  • The changes: add_build (optionally making it current), make_current, withdraw_build.
  • The checks every write must pass:
    • check_record: current names a listed build that is not withdrawn; default names a listed release;
    • check_change: existing build entries change only withdrawn (none removed);
    • check_repository: every listed build has its folder releases/<release>/<build>/ and a release_manifest.json naming the same release and build;
    • check_commit: no file inside an existing build folder is added, changed or removed, listed or not; no write creates a latest*.json file; current cannot change while any latest*.json file exists (the migration window before the old pointer files are deleted).

The writer (microcosm_emitter.huggingface.service.releases), queued as a release_change job:

  • publish_release(PublishRelease(repository, release, build, release_role, make_current, files)): files with roles, including release_manifest.json. promote_build and withdraw_build change only releases.json.
  • Every request carries a request_id, a new random one unless the caller gives one, and the emitter applies each request once: sending the same request again returns its receipt, while a new request is applied even when it repeats an earlier change (promoting a build back, or retrying a withdrawal or a refused change once its cause is gone). The module queues the validated request's own message, never extra keys.
  • Every write: read releases.json and the file list at main's head, apply the change, run the checks, commit the new build folder and releases.json together with parent_commit set to that head. On a parent-commit conflict (HTTP 409 or 412), read the new head and apply the change again, up to five times; two concurrent publications both land.
  • Publishing refuses a build whose folder exists (BUILD_FOLDER_EXISTS), except when the build is listed with a byte-identical manifest: that reports published with the commit that wrote it, even if another build was made current since (a retry after a commit that landed but raised). Publishing never promotes an already published build.
  • A releases.json above 4 MiB or nested too deeply to decode is refused as INVALID_RECORD, and such a manifest as MANIFEST_MISMATCH; neither is retried.
  • No Hugging Face tags or branches, no current/ folder, no mutable root file, no latest*.json; existing tags and files are untouched. release.py's staging branch, release tag and pointer-file commit are replaced by this layout, not ported.
  • Preserved files are re-hashed before upload; Hub failures stay pending and are retried, for at most an hour (REQUEST_EXPIRED); a promotion, withdrawal or make-current publication that a newer such change overtook is refused (NEWER_CHANGE_LANDED); broken rules are refused with the rule's code and messages. Release changes run on a worker thread of their own (see Queue Hugging Face staging uploads with one commit per repository per cycle #23's DeliveryWorker). Release jobs skip the 10-second spacing between commits; bundles and run files keep it (they all still make at most one commit per repository per cycle).

Left open, with extension points (not decided here)

  • Who may promote or withdraw: HuggingFaceModule(release_change_policy=...) takes a function of the typed change and a ReleaseCaller (the user the emitter runs as and the build process that started it) that returns a refusal reason or None. The module applies it when the change is handed over, so a refusal (NOT_AUTHORIZED) is answered at once and never queued. The default allows every change; nothing configures it yet.
  • Whether the dense release has a current build: current may stay null; make_current=False publishes without promoting.
  • How the dashboard shows withdrawn builds: the record only stores withdrawn.
  • The US layout: nothing here depends on a country; release and build names are opaque identifiers.
  • Also open: setting default (the writer never changes it; a new record starts with null), P1's migration write (it sets current while the pointer files still exist, which check_commit refuses, so it needs its own one-time path or a migration mode), and withdrawing the current build (refused; promote another build first). release_role is an addition to the plan's call: a new release needs its role for releases.json.

Release

Two added fragments: merging releases the next minor version of both distributions.

Validation

All tests use the in-memory Hugging Face API; nothing touches a real repository.

Commit Red (tests first) Green
244672a Add the releases.json record and its publication checks to the interface tests/interface/test_releases_record.py: 26 of 26 fail (module missing) 26 passed; re-run: 26 passed
ca43761 Publish release builds side by side through the release writer tests/huggingface/test_release_writer.py, tests/interface/test_release_client.py: 29 of 29 fail. Fixture tests with the three new release fixtures: 5 failed, 39 passed 29 passed; fixtures 44 passed; re-run of the four files: 73 passed

Before the review fixes, across the stack, every test file it adds or changes: 261 passed. bash tools/check-artifacts.sh exit 0; ruff check and ruff format --check pass.

Review fixes (2026-10-20)

Each fix was written red, then green; only the test files this pull request adds or changes were run. All tests use the in-memory Hub.

Commit Finding Red (tests first) Green
f5c8daf Build release messages inside the client's error handling #22's fix carried up to publish_release, promote_build and withdraw_build. tests/interface/test_client_errors.py: 2 failed, 16 passed 29 passed with test_release_client.py
9ad61be Apply each requested release change; deduplicate only resent requests Content-derived hand-over IDs made A → B → A, a withdrawal retried after another promotion, and a request repeated after its refusal's cause was gone do nothing. release writer, release client, message and fixture tests: 14 failed, 73 passed 87 passed (the three unreleased release fixtures gain request_id; their digests are updated)
75ba8e9 Report a landed publication as published after another promotion _already_published refused a landed publication whose build was no longer current. test_release_writer.py: 2 failed, 23 passed 25 passed
21ba3d6 Refuse deeply nested or oversized release records instead of retrying RecursionError from json.loads became a pending REPOSITORY_UNAVAILABLE, retried forever. test_releases_record.py, test_release_writer.py: 5 failed, 51 passed 56 passed
1fc8fb9 Keep the commit spacing for every job except release changes Every job skipped MIN_COMMIT_SPACING_SECONDS, so bundles could commit in bursts again. test_release_writer.py: 1 failed tests/huggingface: 121 passed
97a57f4 Queue typed release changes and pass the caller to the change policy dict(message) queued; string statuses; RELEASE_CHANGE, MAX_PARENT_CONFLICT_ATTEMPTS and UPLOAD_FAILED defined twice or more; core.auth._huggingface_token imported; the policy had no caller identity. test_boundaries.py, test_release_writer.py: 7 failed, 36 passed 43 passed; with tests/huggingface, tests/interface, tests/telemetry/test_delivery.py (its monkeypatches follow the renamed huggingface_token): 334 passed

Re-run after the last commit, every test file this pull request adds or changes: 183 passed. ruff check and ruff format --check pass. The other commits' hashes changed when this branch was restacked.

Not built here, as asked: P1's one-time migration path, which check_commit still refuses while pointer files exist.

Review fixes, second round (2026-10-20)

Each fix is a commit that adds a test failing because of the bug, then a commit with the code. Only the test files this pull request adds or changes were run. All tests use the in-memory Hub.

Commits (red → green) Finding Red (tests first) Green
1287dd1 → 417d651 Expire queued release changes and refuse ones a newer change overtook A release change that failed for any reason other than a broken rule (CREDENTIAL_MISSING, UPLOAD_FAILED including 401/403, REPOSITORY_UNAVAILABLE, PARENT_CONFLICT) stayed pending, was retried without limit and in backoff order, and was applied to whatever record it found. So it could land after a newer change from this or another machine and put back an older current, or land days later after hf auth login. tests/huggingface/test_release_writer.py: 4 failed, 34 passed. The older build became current in both ordering cases; the change pending for two days was published; the expired change stayed pending with CREDENTIAL_MISSING. The two passing tests guard that changes handed over in order all apply and that a plain publication queued before a newer change still lands. 40 passed (two more tests added with the fix drive the recorded request time directly); 195 across this pull request's changed test files
a314547 → 0cb0a28 Name a caller without a user name by its numeric user ID On Python 3.13 and later getpass.getuser() raises when no user name variable is set and the user ID has no password-database entry (a container run with an arbitrary user ID), so every release change came back unavailable. The policy itself is unchanged (allow-all by default). test_release_writer.py: 1 failed, 40 passed (OSError: No username set in the environment) 41 passed
327be59 Say that release changes skip the commit spacing The stale comment on MIN_COMMIT_SPACING_SECONDS and the queue docstring's "and later releases". Documentation only: no red step —
7233dc6 → baab6ed Accept only the integer schema version and keep written records readable Review code-quality items: ReleasesRecord.parse accepted "schema_version": true or 1.0; the writer never checked that a record it writes stays within the 4 MiB it accepts when reading (check_change now refuses it with RECORD_TOO_LARGE). tests/interface/test_releases_record.py: 3 failed, 29 passed 73 passed with test_release_writer.py
6bd4a45 Share the writer's refusal for broken rules; test running out of races Review code-quality items: the violation messages were joined in two places; nothing tested a change that loses every parent-commit race (PARENT_CONFLICT). No behaviour change and no red step: the new test guards existing behaviour 42 passed

The mechanism for queued release changes (also in releases.py's docstring and the README):

  • Expiry. A release change still pending an hour after its hand-over (RELEASE_CHANGE_EXPIRY_SECONDS) becomes refused with REQUEST_EXPIRED. That holds without a credential too, so a later login never applies it.
  • Order. An ordered change (a promotion, a withdrawal, or a publication that makes its build current) is refused with NEWER_CHANGE_LANDED when an ordered change requested after it has already landed on the repository's record. The writer learns that from two places:
    • this machine's queue, which also knows later changes that left the record unchanged;
    • the title of the commit that last changed releases.json. Every writer commit's title now ends [newest request <time>]: the newest request time of the ordered changes applied so far.
  • Plain publications that only add a build are never refused for order and don't count as newer, because concurrent publications must both land (plan item 5). Their commits carry the recorded time forward.
  • No empty commits. A change that leaves releases.json unchanged makes no commit and reports the head, which is what the Hub would do anyway.
  • What the mechanism can't see. A newer change on another machine that left releases.json unchanged (promoting the build that is already current) makes no commit, so it records no time; only the expiry bounds how late an older change can still land after it. Request times come from each machine's clock.
  • Why this design. Strict per-repository request order can't see other machines, and an expiry alone leaves an hour's window across machines. The record's shape is fixed by the plan, so the request time lives in the commit title, which the Hub returns with each file's last commit.

Restack onto #23's fixes. Some of this pull request's commits changed while it was rebased onto the second round of #23 fixes:

Re-run after the last commit, every test file this pull request adds or changes: 200 passed. ruff check, ruff format --check and tools/check-changelog.sh pass.

Unchanged, as open decisions: who may promote or withdraw (the policy stays allow-all), the manifest's build field, role names and file list (H2c), and AGENTS.md's publishing rule.

Fix round 2 (2026-10-20)

Restack onto #23's round-2 fixes. Every commit of this pull request has a new SHA; the titles are unchanged. The sections above use the earlier SHAs: 630afd4 is now 9c9c8da, 93a08d0 is b8bc990, 1287dd1 is 568fce8, 417d651 is 42e5686, a314547 is 65f7242, 0cb0a28 is 0b8b3a7, 327be59 is 69a3f48, 7233dc6 is e5eb811, baab6ed is 7998111 and 6bd4a45 is 7788f61. Conflict resolutions:

So the commits from 9c9c8da to ebd4893 fail #23's new test that a bundle waits for the spacing after a run-file commit, as the original 630afd4 let bundles skip it; b8bc990 and the head pass.

Commits (red → green) Finding Red (tests first) Green
943913f → 35481d4 Keep release order per release; an overtaken publication keeps its build 417d651 compared request times across every release in a repository. A change to one release was refused because a later change to another release had landed first, and two publications that landed at once and both made their build current lost one build (plan §7, item 5). tests/huggingface/test_release_writer.py: 4 failed, 44 passed. Each failure is the refusal ('refused', 'NEWER_CHANGE_LANDED') where ('published', None) is expected: the review's test (a national promotion requested before a dense promotion that landed first); its trigger A through the module's queue; its trigger B (concurrent publications to two releases, both making their build current); and concurrent publications to one release when the newer one lands first. The two passing tests guard the older-first order and that a stale promotion is still refused after another release's later commit. 50 passed (two tests added with the fix: the overtaken publication's message and title, and the window that drops old times); 208 across this pull request's changed test files

Design: order per release.

  • Commit titles now end [newest requests: <release>=<time>; ...]: for each release, the newest request time among the ordered changes applied to it, carried forward by every writer commit. releases.json is unchanged.
  • This machine's queue is grouped by release the same way.
  • Refusals. A promotion or a withdrawal is refused with NEWER_CHANGE_LANDED only when a promotion, a withdrawal or a publication that makes its build current, to the same release and requested after it, has landed. Within one release the original fix holds.
  • An overtaken publication that makes its build current is no longer refused. It lists its build, leaves current as the newer change set it, and reports published with a message saying so. Two publications that land at once, to one release or to two, therefore both keep their builds, and the newer request's build is current.
  • Title size. A release's time leaves the titles once it is two hours (twice the expiry) older than the newest time listed. Every change requested before it has expired by then, unless two machines' clocks differ by more than an hour. Without that, repositories that give each build its own release would grow the titles without end.
  • Why this design. The commit title is the only place, per change, that the writer reads back cheaply (the Hub returns it with each file's last commit), and the plan fixes releases.json's shape. Finding each release's last writer commit in the history instead would read the repository's whole commit history on every change.

The remaining one-hour window, left open. A newer change on another machine that leaves releases.json unchanged (for example, promoting the build that is already current) makes no commit, so it records no request time. An older change to the same release, queued on another machine, can then still land within one hour of its own hand-over; after that it is refused with REQUEST_EXPIRED. Closing this needs a change to the releases.json schema, which is your decision: the schema and the one-hour limit are unchanged here.

Re-run after the last commit, every test file this pull request adds or changes: 208 passed. ruff check, ruff format --check and tools/check-changelog.sh pass.

Fix round 3 (2026-10-20)

Restack onto #23's round-3 fixes. Every commit has a new SHA and the same title. Only 9c9c8da (now 5088f71) differs as a patch: Git merged #23's new README sentence about shutdown into it without a conflict. The sections above use the round-2 SHAs: aec7519 is now d2bf70a, 9c9c8da is 5088f71, 6fb10eb is a534280, b1a9fff is 69e49f5, 538a378 is 701c891, ebd4893 is c32cac9, b8bc990 is e3b50b4, 865fa7a is 3e3a2a6, 568fce8 is 43dbdfb, 42e5686 is 901269b, 65f7242 is 7c5f4d6, 0b8b3a7 is c97c065, 69a3f48 is f2bffe4, e5eb811 is 95f02d2, 7998111 is 6980d95, 7788f61 is 3b0182e, 943913f is 149f0d4 and 35481d4 is 5583e82. As before, the commits from 5088f71 to c32cac9 fail #23's test that a bundle waits for the spacing after a run-file commit; e3b50b4 and every later commit pass it. #23's new round-3 test passes at every commit of this pull request.

Commits (red → green) Finding Red (tests first) Green
357c8d5 → 22fce01 Order only changes that set current; withdrawals are never overtaken A withdrawal counted as a newer change to current, though it can never change current. A make-current publication requested before an unrelated withdrawal was listed but silently not made current, and promotions and withdrawals were refused with no conflict. tests/huggingface/test_release_writer.py: 4 failed, 50 passed: a later withdrawal of another build kept the publication from becoming current; a promotion was refused for a later withdrawal of another build; a withdrawal was refused for a later promotion of another build; a queued promotion was refused for a later withdrawal of another build (on one machine, through the queue) 54 passed
57bd90d → cd73066 Name the release's current build in every release receipt A publication that asked to become current but did not got status: published and error_code: None; only the prose message said so. test_release_writer.py: 2 failed, 54 passed; tests/interface/test_release_client.py: 2 failed, 18 passed. Each: the receipt does not say which build is current 56 and 20 passed; 224 across this pull request's changed test files and #23's test_delivery_workers.py
dccca0b → d1fa450 Measure the title window from the Hub's date; ignore future times One machine more than two hours fast dropped every other release's time from the commit titles (turning off the order check across machines for them), and its own time refused later changes to its release on other machines until real time caught up. test_release_writer.py: 2 failed, 57 passed: a fast clock on another release's change turned off the order check for this release; a fast clock blocked a later promotion of its release on another machine. A guard (a clock one minute fast still orders changes) passes before and after. 59 passed; 244 across this pull request's changed test files, test_delivery_workers.py and test_hub.py

Design: only changes that set current are ordered. is_ordered becomes sets_current: true for a promotion and for a publication with make_current=True, false for a withdrawal. A withdrawal records no request time in the commit title, does not count among this machine's landed changes, and is never refused with NEWER_CHANGE_LANDED. It cannot conflict with a change to current: withdraw_build refuses the current build and make_current refuses a withdrawn one, so whichever of the two lands second for the same build is refused by those rules, and changes to different builds give the same record in either order. The title suffix keeps its form. This replaces the round-2 "Refusals" bullet above.

Design: the receipt's current field. ReleaseReceipt gains current: str | None:

  • on a published receipt, the release's current build in releases.json once the change was applied (or, when nothing needed writing, as the writer found it), and None when the release has no current build;
  • None on pending, refused and unavailable receipts.

So H2c can check receipt["status"] == "published" and receipt["current"] == request.build to know its build became current, without reading message. The writer fills it on its three published paths (a commit, a change that needed no commit, a build already published); the module's hand-over receipts and unavailable_release_receipt carry None. The publish_release docstring now states this and the one-hour expiry. The protocol fixtures record only request messages and the answer's top-level receipt key, so no fixture describes receipt fields, and none changed. The behaviour itself is unchanged and still awaits your acceptance (Decision 19): an overtaken make-current publication adds its build and leaves current with the newer request's build; its receipt now says so in current.

Design: a bound on what one fast clock can do. Every change is requested before its commit, and the Hub dates every commit with its own clock. The writer now reads that date from the same get_paths_info answer as the title (DatasetRepository.last_commit_title_and_date; no extra request).

  • A time in the title more than CLOCK_TOLERANCE_SECONDS (5 minutes) after the Hub's date for that commit can only come from a fast clock. It is ignored and not carried into the next title; that machine's change is then ordered only by its own queue and the expiry.
  • The two-hour window that drops old times now ends at the Hub's date for the last commit to releases.json, not at the newest time listed, so no machine's clock decides which other releases' times are dropped. A change requested before a dropped time is more than two hours older than a commit that has already landed, so it has expired, as before.
  • When the Hub reports no date, both fall back to the round-2 behaviour. The in-memory Hub dates commits only when a test sets commit_clock.
  • The releases.json schema and the one-hour limit are unchanged. Clocks that differ by less than the tolerance can still land changes requested closer together than that difference in the wrong order, as before. This replaces the round-2 "Title size" bullet above.

Re-run after the last commit, every test file this pull request adds or changes, plus every test file that uses the in-memory Hub (its support file changed): 333 passed. tools/check-artifacts.sh exit 0; ruff check, ruff format --check and tools/check-changelog.sh pass.

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #25 October 19, 2026 21:21
@anth-volk
anth-volk force-pushed the feat/release-writer-releases-json branch from ec71b7a to 3bc95d7 Compare October 20, 2026 13:00
@anth-volk
anth-volk force-pushed the feat/release-writer-releases-json branch from 3bc95d7 to ca43761 Compare October 20, 2026 13:15
anth-volk added a commit that referenced this pull request Oct 20, 2026
A release change that failed for any reason other than a broken rule
stayed pending, was retried without limit and in backoff order, and was
applied to whatever record it found. So an older promotion could land
after a newer change from this or another machine and put back an older
current build, and a change pending for days could land after a login.

The tests cover the #24 review's case (another machine's newer change
lands first), the same case with the newer change made on this machine
and changing nothing, a change pending for two days before and after a
login, and two guards: changes handed over in order all apply, and a
plain publication queued before a newer change still lands.

Red: 4 failed, 34 passed. The older SECOND build became current in
both ordering cases ("assert 'uk-frs-calib...323Z-6cba5d71' ==
'uk-frs-calib...443Z-d12ff9b3'"); the change pending for two days was
"published"; the expired change stayed ("pending",
"CREDENTIAL_MISSING").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the feat/release-writer-releases-json branch from 97a57f4 to 6bd4a45 Compare October 20, 2026 17:47
anth-volk added a commit that referenced this pull request Oct 20, 2026
The mechanism, also described in releases.py and the README:

- Expiry: a release change still pending an hour after its hand-over
  (RELEASE_CHANGE_EXPIRY_SECONDS) becomes refused with REQUEST_EXPIRED,
  also when no credential is present, so it never lands after a later
  login.
- Order: an ordered change (a promotion, a withdrawal, or a publication
  that makes its build current) is refused with NEWER_CHANGE_LANDED when
  an ordered change requested after it has already landed on the
  repository's record. The writer learns that from this machine's queue,
  which also holds later changes that left the record unchanged, and
  from the title of the commit that last changed releases.json. Every
  writer commit's title now ends "[newest request <time>]", the newest
  request time of the ordered changes applied so far.
- A publication that only adds a build is never refused for order and
  does not count as newer, because concurrent publications must both
  land (plan item 5). Its commit carries the recorded time forward.
- A change that leaves releases.json unchanged makes no commit and
  reports the head, as the Hub would have done anyway.

One case is left to the expiry alone: another machine's newer change
that left releases.json unchanged records no time. Request times come
from each machine's clock.

Green: 40 passed in the release-writer tests, including two added with
the fix that drive the recorded request time directly; 195 passed
across #24's changed test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
From the #24 review's code-quality notes: ReleasesRecord.parse accepted
"schema_version": true or 1.0, because True == 1 == 1.0 in Python; and
the writer never checked that a record it writes stays within the 4 MiB
it accepts when reading, so one large write would make every later read
refuse the record.

Red: 3 failed, 29 passed. Both schema versions were accepted ("DID NOT
RAISE ReleaseRecordError"); the oversized change gave no violation
("assert [] == ['RECORD_TOO_LARGE']").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- One helper builds the refusal for a list of broken rules: the first
  rule's code and every rule's message. The writer built it in two
  places before.
- A new test covers a change whose parent commit loses every one of its
  MAX_PARENT_CONFLICT_ATTEMPTS: it stays pending with PARENT_CONFLICT
  and writes nothing.

No behaviour change and no red step: the test guards existing
behaviour, which the #24 review found untested.

Green: 42 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The bundle worker and the host's thread each list due repositories and
then deliver them in turn, so a list can be minutes old when a delivery
starts. After the lease is taken, `_claim_job` now refuses to claim a
job, and `run_files_due` reports no run files due, until
`MIN_COMMIT_SPACING_SECONDS` have passed since this machine's last commit
to the repository. The commit at shutdown still ignores the spacing.

The helpers `_spacing_elapsed`, `commit_spacing_elapsed` and
`_run_files_allowed` match the ones #24 uses, which also exempts release
changes from the spacing.

Green: 7 passed; every test file this branch adds or changes: 199 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
A release change that failed for any reason other than a broken rule
stayed pending, was retried without limit and in backoff order, and was
applied to whatever record it found. So an older promotion could land
after a newer change from this or another machine and put back an older
current build, and a change pending for days could land after a login.

The tests cover the #24 review's case (another machine's newer change
lands first), the same case with the newer change made on this machine
and changing nothing, a change pending for two days before and after a
login, and two guards: changes handed over in order all apply, and a
plain publication queued before a newer change still lands.

Red: 4 failed, 34 passed. The older SECOND build became current in
both ordering cases ("assert 'uk-frs-calib...323Z-6cba5d71' ==
'uk-frs-calib...443Z-d12ff9b3'"); the change pending for two days was
"published"; the expired change stayed ("pending",
"CREDENTIAL_MISSING").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The mechanism, also described in releases.py and the README:

- Expiry: a release change still pending an hour after its hand-over
  (RELEASE_CHANGE_EXPIRY_SECONDS) becomes refused with REQUEST_EXPIRED,
  also when no credential is present, so it never lands after a later
  login.
- Order: an ordered change (a promotion, a withdrawal, or a publication
  that makes its build current) is refused with NEWER_CHANGE_LANDED when
  an ordered change requested after it has already landed on the
  repository's record. The writer learns that from this machine's queue,
  which also holds later changes that left the record unchanged, and
  from the title of the commit that last changed releases.json. Every
  writer commit's title now ends "[newest request <time>]", the newest
  request time of the ordered changes applied so far.
- A publication that only adds a build is never refused for order and
  does not count as newer, because concurrent publications must both
  land (plan item 5). Its commit carries the recorded time forward.
- A change that leaves releases.json unchanged makes no commit and
  reports the head, as the Hub would have done anyway.

One case is left to the expiry alone: another machine's newer change
that left releases.json unchanged records no time. Request times come
from each machine's clock.

Green: 40 passed in the release-writer tests, including two added with
the fix that drive the recorded request time directly; 195 passed
across #24's changed test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
From the #24 review's code-quality notes: ReleasesRecord.parse accepted
"schema_version": true or 1.0, because True == 1 == 1.0 in Python; and
the writer never checked that a record it writes stays within the 4 MiB
it accepts when reading, so one large write would make every later read
refuse the record.

Red: 3 failed, 29 passed. Both schema versions were accepted ("DID NOT
RAISE ReleaseRecordError"); the oversized change gave no violation
("assert [] == ['RECORD_TOO_LARGE']").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- One helper builds the refusal for a list of broken rules: the first
  rule's code and every rule's message. The writer built it in two
  places before.
- A new test covers a change whose parent commit loses every one of its
  MAX_PARENT_CONFLICT_ATTEMPTS: it stays pending with PARENT_CONFLICT
  and writes nothing.

No behaviour change and no red step: the test guards existing
behaviour, which the #24 review found untested.

Green: 42 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the feat/release-writer-releases-json branch from 6bd4a45 to 35481d4 Compare October 20, 2026 18:47
anth-volk added a commit that referenced this pull request Oct 20, 2026
The #24 fix review found that `417d651` compares request times across
every release in a repository. A change to one release is then refused
with `NEWER_CHANGE_LANDED` because a later change to another release
landed first, and two publications that land at once and both make
their build current lose one build, which breaks plan section 7, item 5.

New tests, with a second release (`microcosm-uk-2024-25-dense`) in the
same repository:

- the review's test: a national promotion requested before a dense
  promotion that landed first must land;
- the review's trigger A through the module's queue: a national
  promotion that failed once must land after a later dense promotion;
- the review's trigger B: two publications to the two releases that both
  make their build current must both land and both be current;
- two publications to one release that both make their build current
  must both be listed, with the newer request's build current, whichever
  lands first (the older-first case passes today);
- a stale national promotion is still refused after the dense release's
  later commit (passes today: the original fix must hold).

Red: 4 failed, 44 passed. Each failure is the refusal
`('refused', 'NEWER_CHANGE_LANDED')` where `('published', None)` is
expected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the feat/release-writer-releases-json branch from 35481d4 to d1fa450 Compare October 20, 2026 20:01
anth-volk added a commit that referenced this pull request Oct 20, 2026
A release change that failed for any reason other than a broken rule
stayed pending, was retried without limit and in backoff order, and was
applied to whatever record it found. So an older promotion could land
after a newer change from this or another machine and put back an older
current build, and a change pending for days could land after a login.

The tests cover the #24 review's case (another machine's newer change
lands first), the same case with the newer change made on this machine
and changing nothing, a change pending for two days before and after a
login, and two guards: changes handed over in order all apply, and a
plain publication queued before a newer change still lands.

Red: 4 failed, 34 passed. The older SECOND build became current in
both ordering cases ("assert 'uk-frs-calib...323Z-6cba5d71' ==
'uk-frs-calib...443Z-d12ff9b3'"); the change pending for two days was
"published"; the expired change stayed ("pending",
"CREDENTIAL_MISSING").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The mechanism, also described in releases.py and the README:

- Expiry: a release change still pending an hour after its hand-over
  (RELEASE_CHANGE_EXPIRY_SECONDS) becomes refused with REQUEST_EXPIRED,
  also when no credential is present, so it never lands after a later
  login.
- Order: an ordered change (a promotion, a withdrawal, or a publication
  that makes its build current) is refused with NEWER_CHANGE_LANDED when
  an ordered change requested after it has already landed on the
  repository's record. The writer learns that from this machine's queue,
  which also holds later changes that left the record unchanged, and
  from the title of the commit that last changed releases.json. Every
  writer commit's title now ends "[newest request <time>]", the newest
  request time of the ordered changes applied so far.
- A publication that only adds a build is never refused for order and
  does not count as newer, because concurrent publications must both
  land (plan item 5). Its commit carries the recorded time forward.
- A change that leaves releases.json unchanged makes no commit and
  reports the head, as the Hub would have done anyway.

One case is left to the expiry alone: another machine's newer change
that left releases.json unchanged records no time. Request times come
from each machine's clock.

Green: 40 passed in the release-writer tests, including two added with
the fix that drive the recorded request time directly; 195 passed
across #24's changed test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
From the #24 review's code-quality notes: ReleasesRecord.parse accepted
"schema_version": true or 1.0, because True == 1 == 1.0 in Python; and
the writer never checked that a record it writes stays within the 4 MiB
it accepts when reading, so one large write would make every later read
refuse the record.

Red: 3 failed, 29 passed. Both schema versions were accepted ("DID NOT
RAISE ReleaseRecordError"); the oversized change gave no violation
("assert [] == ['RECORD_TOO_LARGE']").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- One helper builds the refusal for a list of broken rules: the first
  rule's code and every rule's message. The writer built it in two
  places before.
- A new test covers a change whose parent commit loses every one of its
  MAX_PARENT_CONFLICT_ATTEMPTS: it stays pending with PARENT_CONFLICT
  and writes nothing.

No behaviour change and no red step: the test guards existing
behaviour, which the #24 review found untested.

Green: 42 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The #24 fix review found that `417d651` compares request times across
every release in a repository. A change to one release is then refused
with `NEWER_CHANGE_LANDED` because a later change to another release
landed first, and two publications that land at once and both make
their build current lose one build, which breaks plan section 7, item 5.

New tests, with a second release (`microcosm-uk-2024-25-dense`) in the
same repository:

- the review's test: a national promotion requested before a dense
  promotion that landed first must land;
- the review's trigger A through the module's queue: a national
  promotion that failed once must land after a later dense promotion;
- the review's trigger B: two publications to the two releases that both
  make their build current must both land and both be current;
- two publications to one release that both make their build current
  must both be listed, with the newer request's build current, whichever
  lands first (the older-first case passes today);
- a stale national promotion is still refused after the dense release's
  later commit (passes today: the original fix must hold).

Red: 4 failed, 44 passed. Each failure is the refusal
`('refused', 'NEWER_CHANGE_LANDED')` where `('published', None)` is
expected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The round-2 review of #24 found that a withdrawal counts as a newer
change when the writer decides whether a change to a release was
overtaken, although a withdrawal can never change `current`
(`withdraw_build` refuses the current build and `make_current` refuses a
withdrawn one). The four new tests are the review's triggers:

- a publication that makes its build current, requested before an
  unrelated withdrawal that landed first, must still become current;
- a promotion requested before an unrelated withdrawal must land;
- a withdrawal requested before an unrelated promotion must land;
- on one machine, a promotion that failed once must land after a
  withdrawal of another build, handed over later, landed first.

Red: 4 failed, 50 passed. Each failure is the stated reason:
`a later withdrawal of another build kept the publication from becoming
current`, `a promotion was refused for a later withdrawal of another
build`, `a withdrawal was refused for a later promotion of another
build`, and `a queued promotion was refused for a later withdrawal of
another build`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the feat/release-writer-releases-json branch from 384bc5e to ab3d28c Compare October 21, 2026 21:58
Base automatically changed from feat/huggingface-staging-queue to main October 21, 2026 22:30
@anth-volk
anth-volk force-pushed the feat/release-writer-releases-json branch from ab3d28c to 2db29df Compare October 21, 2026 22:30
anth-volk and others added 24 commits October 22, 2026 16:57
`microcosm_emitter_interface.releases` holds the one record of which build
of each release is current, and the rules every write follows:

- Shape: schema_version 1, `default`, and for each release its `role`,
  `current` (a build or null) and each build's `published_at` and
  `withdrawn`; serialized in that order with releases and builds sorted.
- Changes: `add_build` (optionally making it current), `make_current`
  and `withdraw_build`. A listed build cannot be published again, a
  release keeps its first role, a withdrawn build cannot become current
  and the current build cannot be withdrawn.
- Checks: `check_record` (current names a listed, non-withdrawn build),
  `check_change` (existing build entries change only `withdrawn`),
  `check_repository` (every listed build has its folder and a
  release_manifest.json naming the same release and build) and
  `check_commit` (no file inside an existing build folder is added,
  changed or removed; no latest*.json is written; current does not
  change while any latest*.json exists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The emitter's `huggingface` module now owns every write to a dataset
repository's releases.json (nothing calls it yet; Microcosm switches in a
later change):

- `PublishRelease` names the release, the build, whether to make it
  current, the release's role and each file with its role; it must
  include release_manifest.json. `PromoteBuild` and `WithdrawBuild`
  change only releases.json.
- Each change reads releases.json and the file list at main's head,
  applies the change, runs the shared checks from
  microcosm_emitter_interface.releases and commits the new build folder
  and releases.json with parent_commit. On a parent-commit conflict it
  reads the new head and applies the change again.
- No Hugging Face tags or branches, no current/ folder, no latest*.json;
  existing tags and files are untouched. release.py's staging branch,
  tag and pointer-file commit are replaced, not ported.
- A retried publication that already landed reports published; preserved
  files are re-hashed; Hub failures stay pending and are retried.
- `release_change_policy` is the extension point for who may promote.
- Jobs no longer wait for the 10-second spacing, which applies to the
  frequent run-file commits only.
- Protocol fixtures for the three release messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`publish_release`, `promote_build` and `withdraw_build` built their message
before the client's error handling, and the unavailable receipt rebuilt it,
so a release file whose path cannot be resolved raised into the build. The
release calls now pass a message builder like every other call, and the
unavailable receipt has an empty `handover_id` when the files cannot be
converted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`PromoteBuild`, `WithdrawBuild` and `PublishRelease` derived their
hand-over identifier from the change alone, and `HandoverQueue.enqueue_job`
returns the stored receipt for a known identifier. Because the queue
database persists across builds, these changes silently did nothing:
promoting a build back after another was promoted (A, B, then A again), and
retrying a withdrawal after another build was promoted. A refusal such as
`POINTER_FILES_PRESENT` or `NOT_AUTHORIZED` also stayed final after its
cause was gone.

Every release request now carries a `request_id`: a new random one by
default, or one the caller gives. The message and the hand-over identifier
include it, so a new request is applied even when it repeats an earlier
change, and only a resent request (the same `request_id` and change) is
deduplicated. The emitter refuses a release change without a valid
`request_id` (`INVALID_REQUEST`). The three unreleased release fixtures
gain the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`_already_published` refused a publication with `BUILD_FOLDER_EXISTS` when
`make_current` was true and its build was listed with an identical manifest
but was no longer current. That is what a retry finds after a commit that
landed but raised an error, when another build was promoted in between. A
build listed with a byte-identical `release_manifest.json` now always
reports `published` with the commit that wrote it; when it is not current
the receipt's message names the current build. Publishing never promotes an
already published build.

The fake Hub can now let a commit land and then raise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ReleasesRecord.parse` caught only `ValueError` and `UnicodeDecodeError`,
and `check_repository` likewise. A deeply nested `releases.json` or release
manifest makes `json.loads` raise `RecursionError`, which
`apply_release_change` mapped to a pending `REPOSITORY_UNAVAILABLE` and
retried forever.

- Such a record is refused as `INVALID_RECORD`, and such a manifest as
  `MANIFEST_MISMATCH`; both are final.
- A record larger than `MAX_RECORD_BYTES` (4 MiB) is refused as
  `INVALID_RECORD` before it is decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release writer's change to `HandoverQueue.due_repositories` let every
job skip `MIN_COMMIT_SPACING_SECONDS`, not only release changes, so bundles
could again commit in bursts, each starting the dataset repository's
webhook. Only jobs of `UNSPACED_JOB_KINDS` (release changes) now skip the
spacing; a repository due only for a bundle waits, and within the spacing
the worker claims only a release change, so an earlier bundle job is not
taken in its place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The module queues the validated change's own message
  (`PublishRelease.message()` and so on) instead of `dict(message)`, so
  keys the rules do not know never reach the queue or the hand-over
  identifier. The worker rebuilds the typed change from it
  (`release_change_from_message`), and `apply_release_change` takes the
  typed change. Release and bundle outcome statuses are `Literal` types.
- `MAX_PARENT_CONFLICT_ATTEMPTS`, `UPLOAD_FAILED`,
  `REPOSITORY_UNAVAILABLE`, `PRESERVED_FILES_MISSING` and
  `PRESERVED_BYTES_CHANGED` are defined once, in `huggingface.service.hub`;
  `RELEASE_CHANGE` once, in the interface; file hashing once, in the queue.
- The credential lookup is public (`core.auth.huggingface_token`), so the
  Hugging Face module no longer imports a private name.
- `ReleaseChangePolicy` now receives the change and a `ReleaseCaller` (the
  user the emitter runs as and the build process that started it). The
  module applies it when the change is handed over, so a refusal
  (`NOT_AUTHORIZED`) is answered at once and never queued. Who may promote
  is still undecided; the default allows every change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A release change that failed for any reason other than a broken rule
stayed pending, was retried without limit and in backoff order, and was
applied to whatever record it found. So an older promotion could land
after a newer change from this or another machine and put back an older
current build, and a change pending for days could land after a login.

The tests cover the #24 review's case (another machine's newer change
lands first), the same case with the newer change made on this machine
and changing nothing, a change pending for two days before and after a
login, and two guards: changes handed over in order all apply, and a
plain publication queued before a newer change still lands.

Red: 4 failed, 34 passed. The older SECOND build became current in
both ordering cases ("assert 'uk-frs-calib...323Z-6cba5d71' ==
'uk-frs-calib...443Z-d12ff9b3'"); the change pending for two days was
"published"; the expired change stayed ("pending",
"CREDENTIAL_MISSING").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mechanism, also described in releases.py and the README:

- Expiry: a release change still pending an hour after its hand-over
  (RELEASE_CHANGE_EXPIRY_SECONDS) becomes refused with REQUEST_EXPIRED,
  also when no credential is present, so it never lands after a later
  login.
- Order: an ordered change (a promotion, a withdrawal, or a publication
  that makes its build current) is refused with NEWER_CHANGE_LANDED when
  an ordered change requested after it has already landed on the
  repository's record. The writer learns that from this machine's queue,
  which also holds later changes that left the record unchanged, and
  from the title of the commit that last changed releases.json. Every
  writer commit's title now ends "[newest request <time>]", the newest
  request time of the ordered changes applied so far.
- A publication that only adds a build is never refused for order and
  does not count as newer, because concurrent publications must both
  land (plan item 5). Its commit carries the recorded time forward.
- A change that leaves releases.json unchanged makes no commit and
  reports the head, as the Hub would have done anyway.

One case is left to the expiry alone: another machine's newer change
that left releases.json unchanged records no time. Request times come
from each machine's clock.

Green: 40 passed in the release-writer tests, including two added with
the fix that drive the recorded request time directly; 195 passed
across #24's changed test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Python 3.13 and later getpass.getuser() raises OSError when no user
name variable is set and the user ID has no password-database entry, as
in a container run with an arbitrary user ID. The module looked the
caller up for every release change, so every change failed.

Red: 1 failed, 40 passed: "OSError: No username set in the environment".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release-change caller's user comes from getpass.getuser(), and from
the numeric user ID when that raises, so a container run with an
arbitrary user ID can still hand over release changes. The permission
policy itself is unchanged: by default it still allows every change.

Green: 41 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment on MIN_COMMIT_SPACING_SECONDS still said only the shutdown
commit ignores the spacing, and the queue's docstring still called
release changes a later kind of job. Both now match the code: release
changes and the shutdown commit skip the spacing; run files and dataset
bundles keep it.

Documentation only; no red step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the #24 review's code-quality notes: ReleasesRecord.parse accepted
"schema_version": true or 1.0, because True == 1 == 1.0 in Python; and
the writer never checked that a record it writes stays within the 4 MiB
it accepts when reading, so one large write would make every later read
refuse the record.

Red: 3 failed, 29 passed. Both schema versions were accepted ("DID NOT
RAISE ReleaseRecordError"); the oversized change gave no violation
("assert [] == ['RECORD_TOO_LARGE']").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ReleasesRecord.parse accepts schema_version only as the integer 1.
- check_change refuses a new record whose serialized size is above
  MAX_RECORD_BYTES (RECORD_TOO_LARGE). The writer runs check_change on
  every change, so it never writes a record readers would refuse.

Green: 73 passed (the record tests and the release-writer tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- One helper builds the refusal for a list of broken rules: the first
  rule's code and every rule's message. The writer built it in two
  places before.
- A new test covers a change whose parent commit loses every one of its
  MAX_PARENT_CONFLICT_ATTEMPTS: it stays pending with PARENT_CONFLICT
  and writes nothing.

No behaviour change and no red step: the test guards existing
behaviour, which the #24 review found untested.

Green: 42 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The #24 fix review found that `417d651` compares request times across
every release in a repository. A change to one release is then refused
with `NEWER_CHANGE_LANDED` because a later change to another release
landed first, and two publications that land at once and both make
their build current lose one build, which breaks plan section 7, item 5.

New tests, with a second release (`microcosm-uk-2024-25-dense`) in the
same repository:

- the review's test: a national promotion requested before a dense
  promotion that landed first must land;
- the review's trigger A through the module's queue: a national
  promotion that failed once must land after a later dense promotion;
- the review's trigger B: two publications to the two releases that both
  make their build current must both land and both be current;
- two publications to one release that both make their build current
  must both be listed, with the newer request's build current, whichever
  lands first (the older-first case passes today);
- a stale national promotion is still refused after the dense release's
  later commit (passes today: the original fix must hold).

Red: 4 failed, 44 passed. Each failure is the refusal
`('refused', 'NEWER_CHANGE_LANDED')` where `('published', None)` is
expected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`417d651` compared request times across every release in a repository.
Order is now kept per release:

- The writer's commit titles end `[newest requests: <release>=<time>; ...]`,
  each release's newest request time among the ordered changes applied to
  it, carried forward by every writer commit. A release's time leaves the
  titles once it is two hours (`ORDER_WINDOW_SECONDS`, twice the expiry)
  older than the newest time listed: every change requested before it has
  expired by then, unless two machines' clocks differ by over an hour.
  Without that limit, repositories that give each build a new release
  would grow the titles without end.
- This machine's own landed changes are grouped by release too.
- A promotion or withdrawal is refused with `NEWER_CHANGE_LANDED` only
  when an ordered change to the same release, requested after it, has
  landed. The original order fix is unchanged within a release.
- A publication that makes its build current and is overtaken in that way
  is no longer refused: it lists its build, leaves `current` as the newer
  change set it, and reports `published` with a message saying so. Two
  publications that land at once, to one release or to two, therefore
  both keep their builds, and the newer request's build is current (plan
  section 7, item 5).

The `releases.json` schema and the one-hour expiry are unchanged. Another
machine's newer change that leaves `releases.json` unchanged still records
no time, so an older change to that release can land within the hour.

Three earlier tests asserted the old single-time title suffix; they now
assert the per-release suffix. Two tests come with the fix: the
overtaken publication's message and title, and the window that drops old
release times from titles. The README, the `ReleaseReceipt` docstring and
the changelog fragment describe the per-release rule.

Green: 50 passed (`tests/huggingface/test_release_writer.py`); every test
file this branch adds or changes: 208 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-2 review of #24 found that a withdrawal counts as a newer
change when the writer decides whether a change to a release was
overtaken, although a withdrawal can never change `current`
(`withdraw_build` refuses the current build and `make_current` refuses a
withdrawn one). The four new tests are the review's triggers:

- a publication that makes its build current, requested before an
  unrelated withdrawal that landed first, must still become current;
- a promotion requested before an unrelated withdrawal must land;
- a withdrawal requested before an unrelated promotion must land;
- on one machine, a promotion that failed once must land after a
  withdrawal of another build, handed over later, landed first.

Red: 4 failed, 50 passed. Each failure is the stated reason:
`a later withdrawal of another build kept the publication from becoming
current`, `a promotion was refused for a later withdrawal of another
build`, `a withdrawal was refused for a later promotion of another
build`, and `a queued promotion was refused for a later withdrawal of
another build`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`is_ordered` becomes `sets_current`: true for a promotion and for a
publication that makes its build current, and false for a withdrawal.
A withdrawal therefore records no request time in the commit title, does
not count among this machine's landed changes
(`_newest_current_changes_landed`), and is never refused with
`NEWER_CHANGE_LANDED`. A promotion, or a publication that makes its
build current, is overtaken only by a later change of the same release
that also sets `current`.

A withdrawal cannot conflict with such a change: `withdraw_build`
refuses the current build and `make_current` refuses a withdrawn one, so
whichever of a withdrawal and a promotion of the same build lands second
is refused by those rules, and changes to different builds give the same
record in either order.

The commit-title suffix keeps its form. The module docstring, the
`ReleaseReceipt` docstring, the README and the changelog fragment now
say that withdrawals are not ordered.

Green: 54 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-2 review of #24 found that a publication that asked to become
current but was overtaken gets `status: published` and `error_code:
None`, and only a prose `message` says its build is not current. A
caller cannot check this from a field. The new tests expect a `current`
field on every release receipt:

- the review's trigger: another machine's later promotion lands while
  this machine's make-current publication waits after a transient error;
  the publication's receipt must name the other build;
- the published receipts of a publication made current, a plain
  publication, a promotion, a promotion that writes nothing and a
  publication of a build already published must each name the build
  that is current; pending and refused receipts carry `current: None`;
- through the interface client, a published receipt names the build,
  and an `unavailable` receipt carries `current: None`.

Red: 4 failed (2 in `test_release_writer.py`, 54 passed there; 2 in
`test_release_client.py`, 18 passed there). Each failure is the stated
reason: `the receipt does not say which build is current`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ReleaseReceipt` gains `current: str | None`. On a `published` receipt
it is the release's current build in `releases.json` once the change was
applied (or, when nothing needed writing, as the writer found it), and
`None` when the release has no current build; on `pending`, `refused`
and `unavailable` receipts it is `None`. A publication with
`make_current=True` made its build current exactly when its receipt is
`published` and `current` equals its build, so H2c can tell "published
and current" from "published, not current" without reading `message`.

The writer sets `ReleaseOutcome.current` on its three `published` paths
(a commit, a change that needed no commit, and a build already
published), and the delivery copies it into the receipt. The hand-over
receipts the module answers with (pending, and refused at hand-over) and
the interface's `unavailable_release_receipt` carry `current: None`.

The `publish_release` docstring now states the one-hour expiry and the
cases where a make-current publication is `published` but not current.
The README and the changelog fragment describe the field. The protocol
fixtures record only request messages and the answer's top-level
`receipt` key, so no fixture describes receipt fields and none changed.

The behaviour of an overtaken make-current publication (it adds its
build and leaves `current` with the newer request's build) is unchanged;
it awaits the user's acceptance.

Green: `test_release_writer.py` 56 passed, `test_release_client.py` 20
passed; every test file this branch changes, plus #23's
`test_delivery_workers.py`: 224 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-2 review of #24 (CODE QUALITY 1) found that the two-hour limit
on commit-title times is measured from the newest time in the title.
One machine whose clock is more than two hours fast therefore drops every
other release's time from the titles, turning off the order check across
machines for those releases, and its own time refuses every later change
to its release on other machines until real time catches up.

The in-memory Hub can now date its commits with a clock of its own
(`commit_clock`) and report that date from `get_paths_info`, as the Hub
does; commits stay undated unless a test sets it. With the Hub's clock
standing still, the new tests check:

- after a machine three hours fast promotes the dense build, a stale
  national promotion is still refused with `NEWER_CHANGE_LANDED`;
- after a machine three hours fast promotes a national build, a later
  national promotion from another machine lands;
- a guard: a time one minute after the Hub's date still orders changes.

Red: 2 failed, 57 passed. The failures are the stated reasons: `a fast
clock on another release's change turned off the order check for this
release` and `a fast clock blocked a later promotion of its release on
another machine`. The guard passes before and after the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Request times in writer commit titles come from each machine's clock,
but every change is requested before its commit. Two changes bound what
one fast clock can do, without changing the `releases.json` schema or
the one-hour expiry:

- The writer now reads the Hub's date for the commit that last changed
  `releases.json` from the same `get_paths_info` answer as its title
  (`DatasetRepository.last_commit_title_and_date`; no extra request). A
  time in that title more than `CLOCK_TOLERANCE_SECONDS` (5 minutes)
  after that date is ignored, and is not carried into the next title.
  A machine whose clock runs fast therefore no longer refuses later
  changes to its release on other machines until real time catches up;
  its own change is ordered only by its own queue and the expiry.
- The two-hour window that drops old times from the titles now ends at
  that Hub date instead of at the newest time listed, so no machine's
  clock decides which other releases' times are dropped. Every change
  requested before a dropped time is more than two hours older than a
  commit that has already landed, so it has expired, as before.

When the Hub reports no date, both fall back to the earlier behaviour.
Clock differences within the tolerance still allow the misordering of
changes requested closer together than the difference, as before; the
module docstring and the README say so.

Green: `test_release_writer.py` 59 passed; every test file this branch
changes, plus #23's `test_delivery_workers.py` and `test_hub.py`: 244
passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the feat/release-writer-releases-json branch from 2db29df to 7a47f9a Compare October 22, 2026 12:57
@anth-volk
anth-volk merged commit e742215 into main Oct 22, 2026
10 checks passed
@anth-volk
anth-volk deleted the feat/release-writer-releases-json branch October 22, 2026 17:45
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.

Publish release builds side by side, with releases.json as the only record of what is current

1 participant