Skip to content

fix(release): the GitHub Releases writer converges on a racing writer's 422 already_exists and reports duplicate Releases - #21358

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21313-release-writer-idempotent
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21313-release-writer-idempotent

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21313
Clause-②: no

What this changes

One file: scripts/release-github-releases.mjs. No workflow edit, and no published package moves (skip-changeset).

  1. A racing writer's 422 already_exists now converges instead of failing. publishReleases still decides create-or-update from a by-tag read. Sometimes the POST then answers 422 with {"code":"already_exists","field":"tag_name"} in errors[]. That means the other writer created the tag's Release between this run's read and its POST.
    • The script now re-reads the Release by tag and PATCHes it. The re-read is bounded: one attempt at once, then one after 1 s and one after 3 s.
    • That package counts as updated, and is also listed in a new converged list. Two concurrent writers converge on one Release per tag.
    • The classifier reads only that structured errors[] entry. Any other 422 still fails the package exactly as before. That includes the body-limit 422, another field, another status, or bare prose.
    • Sometimes a 422 already_exists comes back but no re-read can see the Release. That package still fails, and the error names both answers.
  2. Duplicate Releases are detected and reported. They are never deleted. After its writes, the script reads the Releases list, newest first, and names every tag it released that carries more than one Release object. For each one it gives every id, each id's assets, and the id the by-tag endpoint resolves to (the one to keep).
    • The walk stops at the first page past this version's Releases, or at the end of the list. It reads at most 10 pages.
    • Any tag the walk never saw is reported as UNVERIFIED (a ::warning::). Its absence is never read as "no duplicate".
    • The script never sends a DELETE. Every request it sends is a GET, POST or PATCH, and the self-test pins that.
  3. The summary line now reads N created, M updated (C after a concurrent writer's create), F failed, D duplicated by this run.

Decision on the duplicate question (Zone 2 item 3): fail the run that created one, warn otherwise

The Releases API accepted two creates for one tag inside one second on five 17.6.0 tags. Idempotent create-or-update does not prevent that. It removes the 422s, but the both-POSTs-succeed case is the API's, not this script's.

Without detection, this PR alone would make things worse in one way. A race that used to end in a red run would end in a green run with a duplicate in it. So the script detects duplicates, and how loud it is depends on who made the duplicate:

  • This run created one of the pair ⇒ ::error:: and exit 1. The race was this run's, and this is the moment the maintainer needs to see it. That matches the AGENTS.md degradation rule: the run looks normal from outside while the persisted state is wrong, so the answer is error.
    • Downstream effect, read from release.yml: on the publish job, the next step ("Attach spec-changes.json", !cancelled()) and the docker job still run.
  • Every Release of the pair predates this run ⇒ ::warning::. The run that created it was the place to fail.
    • Failing every later backfill over it would leave release-integrity red. It would also skip that lane's "Backfill spec-changes.json" step (implicit success()), on a state only the maintainer can clear.
    • The warning carries the same ids, assets and keep/surplus split.

The by-tag id is named rather than assumed. On all five live pairs it is the lower id, and the stub models that. It is the Release gh release view and gh release upload resolve to, so it is where the ADR-0087 D4 asset lands.

The second guard (Zone 1): not built, reported instead

The guard would make the push-lane "Backfill GitHub Releases" step skip a version whose publish job is still in flight. It is not a small, local change to that step or its job's gate:

  • No Actions read today. release-integrity holds contents: write only (release.yml:789 on 97239c3c8a). Its token cannot read Actions runs or jobs.
  • What the guard would read. The publish job runs in a different workflow run (the version-commit push's run). So the guard needs actions: read on this job, plus:
    • a list of in-progress runs of release.yml, excluding this run;
    • each run's jobs;
    • a match on PUBLISH_JOB_NAME (release-pending-publish.mjs) for this version with status in_progress;
    • fail-closed handling of an unreadable answer.
  • Where it would live. The natural home is a new read-only subcommand of scripts/release-pending-publish.mjs, beside sweep, with its own self-test battery. The audit step would call it before writing releases-missing=true.

That is a permission widening, a new API reader and a new battery in the release-integrity job. The claim scoped this PR to a script-only fix while the maintainer prepares a release. After this PR the race costs only duplicated work, plus a same-second duplicate that now fails the run that made it, with the cleanup data. The guard goes into the dev report's out_of_scope_findings for the seat to file.

Action for the maintainer: the five 17.6.0 duplicate pairs

⛔ This PR deletes nothing, and no seat will. Each row is one Release deletion for you to approve and perform. "Keep" is the Release the by-tag endpoint resolves the tag to. For spec, that is also the one carrying the ADR-0087 D4 asset.

Tag Keep (by-tag answer) Surplus (delete) spec-changes.json
@objectstack/service-knowledge@17.6.0 401511494 401511496 neither
@objectstack/service-realtime@17.6.0 401511528 401511529 neither
@objectstack/service-storage@17.6.0 401511561 401511562 neither
@objectstack/spec@17.6.0 401511580 401511585 on 401511580 only
@objectstack/types@17.6.0 401511638 401511640 neither

Source of the table. The new auditDuplicateReleases + describeDuplicates ran read-only against the live API. The fetch wrapper refused every method except GET. It read 69 tags over 2 list pages with 7 GETs (2 list pages + 5 by-tag reads), the walk was complete, and no tag was unseen. It found exactly these five pairs.

Measured inputs

  • The 422 shape. It comes from the check-run annotations of both 17.6.0 jobs: the publish job 110678824190 has 3 annotations, the backfill job 110686494582 has 6. Each reads POST release TAG failed: 422 Unprocessable Entity {"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}],"documentation_url":...}. The stub answers that same errors[] entry.
  • The list order the audit's stopping rule relies on (GET /releases?per_page=100&page=1):
    • Every 17.6.0 Release has created_at 2026-10-02T02:49:42Z. That is the tagger date of the annotated tag @objectstack/spec@17.6.0 (tag object eb2207f32a, tagger timestamp 1790909382).
    • Their published_at values spread from 03:03:49Z to 03:05:41Z.
    • The 74 Release objects fill positions 0 to 73, and each duplicate pair is adjacent. 17.5.0 starts at position 74.
  • The Zone 2 anchors, read on 97239c3c8a: publishReleases at :553, stubFetch at :692, the backfill step at release.yml:1096, release-integrity-${{ github.ref }} at :784, the publish Releases step at :1749, release-publish-${{ github.ref }} at :1288. I also read the [finding] release.yml: release-integrity reads "cli on npm" as "the publish is complete", so a landing during a publish starts the docker backfill before the fixed group is on npm, and it goes red #20627 group gate (:1016 onward) before touching anything. It is unchanged.

When it takes effect

Both writers run the version commit's own copy of this script:

  • the publish job checks out needs.release-integrity.outputs.version-commit (release.yml:1309);
  • the backfill cds into steps.audit.outputs.version-tree before GITHUB_SHA="$VERSION_COMMIT" node scripts/release-github-releases.mjs (:1121).

So this protects the first release whose version commit contains it. A re-run of either 17.6.0 job would still run the old copy.

Tests (all at ba5030a24e)

  • pnpm check:release-body (node scripts/release-github-releases.mjs --self-test): ✓ 85 assertions, up from 49. Four new batteries; the roster floor went from 10 to 14.
    • 11, racing writer converges (8 cases). The stub's POST meets a Release the racing writer created after this run's read and before its POST. The run fails nothing, PATCHes the racer's own id, and leaves one Release per tag.
    • 12, two concurrent invocations over one store, interleaved per request (6 cases). Both runs fail zero, there is exactly one Release per tag, at least one 422 already_exists really occurred, and nothing is deleted.
    • 13, any other 422 still fails (8 cases). The body-limit 422 fails with no re-read and no PATCH. The classifier rejects another field, another status, and prose. An invisible racer fails after exactly 3 bounded re-reads (sleeps [1000, 3000]). A racer that lags one read converges.
    • 14, duplicates reported, never deleted (14 cases). The API accepts a second create. The audit names exactly that tag, both ids, the by-tag id and which one this run created. With perPage: 2 it stops at page 3 of 8. A fresh duplicate gives an error with assets and the surplus. A pre-existing pair gives a warning, and only the by-tag Release is PATCHed. A page cap or a tag the list never shows gives an UNVERIFIED warning.
  • Ablation of the new branch. Run through node scripts/ablation-replace.mjs, with the anchor if (!(err instanceof ReleaseAlreadyExistsError)) throw err; replaced by throw err;.
    • The tool confirmed the mutation: anchor count 1 → 0, blob 6874d1dc85 → 4d67725db9.
    • The self-test went red, 12 of 85: battery 11 ×6 (e.g. got 1: POST release @objectstack/cli@17.0.0-rc.2 failed: 422 ... already_exists), battery 12 ×3 (got 0 + 3), battery 13 ×3.
    • Restore proved: blob equals HEAD 6874d1dc85, git diff HEAD empty, marker count 0. A re-run on the committed state is green again (85).
    • Direction observed: as expected, the pins went red. One nuance: battery 12's "exactly one per tag" stays green under the ablation, because the store enforces uniqueness. What reds is the second writer failing all three tags (0 + 3).
  • Gates re-derived from the merge base with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths: 30 commands, all run, each exit captured before any pipe.
    • --ran reconciliation: 30 derived, 30 run, 0 NOT-MEASURED, 0 UNRUN.
    • pnpm check:pm-dispatch-gates ran detached, as its header prescribes for agent containers: 1976 cases pass, 869 s, exit 0.
    • Also run: node scripts/release-verify-npm.mjs --self-test (it imports this module): 93 cases pass.
    • The workflow-shape families the dispatch named (check-step-collectors, check:required-contexts, check:workflow-step-name-quoting) are not derived, because release.yml is not in the diff.
  • Lint, narrowed to the touched file (eslint scripts/release-github-releases.mjs --no-inline-config --format json): 1 file, 0 errors, 0 warnings.
    • Population: --print-config places the file in the **/*.{…,mjs,…} block.
    • Invariance: that config has no parserOptions.project, so type-aware linting is off and this diff cannot move another file's verdict.
    • The full pnpm lint is CI's.
  • skip-changeset. The root package is private: true, and 0 of 69 published packages' files[] name this file. Positive control: 69 of 69 name dist.

Acceptance notes

  • docs/audits/2026-09-self-test-shape-census.md names this script in a dated census. It is a reading of an earlier commit and is left as is.
  • The list read carries full Release bodies (spec's is about 124k characters). Two pages for a fresh version is a few MB in one CI step. That is acceptable, and recorded here so nobody is surprised by it.

Generated by Claude Code

…'s 422 already_exists

release.yml has two writers of one version's GitHub Releases that are not
mutually excluded: the publish job's own "Create GitHub Releases" step and the
push lane's "Backfill GitHub Releases". On 17.6.0 they overlapped and nine
POSTs answered 422 already_exists on tag_name, because the other writer had
created the release between this script's by-tag read and its POST.

publishReleases now treats that 422 (and only that one, judged on the
structured errors[] entry) as "this release exists": it re-reads the tag,
bounded at three attempts, and PATCHes it, so two writers converge on one
release per tag. Any other 422, the body-limit one included, fails as before.

The same race showed the API will accept two creates for one tag in one
second (five 17.6.0 tags carry two releases). After its writes the script
now reads the Releases list and names every released tag with more than one
release, its ids, assets and the id the by-tag endpoint resolves to. A
duplicate this run created one of fails the run; one that predates the run is
a warning. Nothing is ever deleted.

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 2, 2026
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 2, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 07:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 07:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 11d9b50 Oct 2, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21313-release-writer-idempotent branch October 2, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants