Repository navigation
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 intoOct 2, 2026
Conversation
…'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>
This was referenced Oct 2, 2026
This was referenced Oct 2, 2026
objectstack-fleet
Bot
deleted the
claude/issue-21313-release-writer-idempotent
branch
October 2, 2026 08:08
This was referenced Oct 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #21313
Clause-②: no
What this changes
One file:
scripts/release-github-releases.mjs. No workflow edit, and no published package moves (skip-changeset).422 already_existsnow converges instead of failing.publishReleasesstill decides create-or-update from a by-tag read. Sometimes the POST then answers422with{"code":"already_exists","field":"tag_name"}inerrors[]. That means the other writer created the tag's Release between this run's read and its POST.updated, and is also listed in a newconvergedlist. Two concurrent writers converge on one Release per tag.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.already_existscomes back but no re-read can see the Release. That package still fails, and the error names both answers.::warning::). Its absence is never read as "no duplicate".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:
::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 iserror.release.yml: on the publish job, the next step ("Attach spec-changes.json",!cancelled()) and thedockerjob still run.::warning::. The run that created it was the place to fail.release-integrityred. It would also skip that lane's "Backfill spec-changes.json" step (implicitsuccess()), on a state only the maintainer can clear.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 viewandgh release uploadresolve 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:
release-integrityholdscontents: writeonly (release.yml:789on97239c3c8a). Its token cannot read Actions runs or jobs.actions: readon this job, plus:release.yml, excluding this run;PUBLISH_JOB_NAME(release-pending-publish.mjs) for this version with statusin_progress;scripts/release-pending-publish.mjs, besidesweep, with its own self-test battery. The audit step would call it before writingreleases-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_findingsfor 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.spec-changes.json@objectstack/service-knowledge@17.6.0401511494401511496@objectstack/service-realtime@17.6.0401511528401511529@objectstack/service-storage@17.6.0401511561401511562@objectstack/spec@17.6.0401511580401511585401511580only@objectstack/types@17.6.0401511638401511640Source of the table. The new
auditDuplicateReleases+describeDuplicatesran 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
110678824190has 3 annotations, the backfill job110686494582has 6. Each readsPOST release TAG failed: 422 Unprocessable Entity {"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}],"documentation_url":...}. The stub answers that sameerrors[]entry.GET /releases?per_page=100&page=1):created_at2026-10-02T02:49:42Z. That is the tagger date of the annotated tag@objectstack/spec@17.6.0(tag objecteb2207f32a, tagger timestamp 1790909382).published_atvalues spread from 03:03:49Z to 03:05:41Z.97239c3c8a:publishReleasesat:553,stubFetchat:692, the backfill step atrelease.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 (:1016onward) before touching anything. It is unchanged.When it takes effect
Both writers run the version commit's own copy of this script:
needs.release-integrity.outputs.version-commit(release.yml:1309);cds intosteps.audit.outputs.version-treebeforeGITHUB_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.already_existsreally occurred, and nothing is deleted.[1000, 3000]). A racer that lags one read converges.perPage: 2it 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.node scripts/ablation-replace.mjs, with the anchorif (!(err instanceof ReleaseAlreadyExistsError)) throw err;replaced bythrow err;.6874d1dc85→4d67725db9.got 1: POST release @objectstack/cli@17.0.0-rc.2 failed: 422 ... already_exists), battery 12 ×3 (got 0 + 3), battery 13 ×3.6874d1dc85,git diff HEADempty, marker count 0. A re-run on the committed state is green again (85).0 + 3).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths: 30 commands, all run, each exit captured before any pipe.--ranreconciliation:30 derived, 30 run, 0 NOT-MEASURED, 0 UNRUN.pnpm check:pm-dispatch-gatesran detached, as its header prescribes for agent containers:1976 cases pass, 869 s, exit 0.node scripts/release-verify-npm.mjs --self-test(it imports this module): 93 cases pass.check-step-collectors,check:required-contexts,check:workflow-step-name-quoting) are not derived, becauserelease.ymlis not in the diff.eslint scripts/release-github-releases.mjs --no-inline-config --format json): 1 file, 0 errors, 0 warnings.--print-configplaces the file in the**/*.{…,mjs,…}block.parserOptions.project, so type-aware linting is off and this diff cannot move another file's verdict.pnpm lintis CI's.skip-changeset. The root package isprivate: true, and 0 of 69 published packages'files[]name this file. Positive control: 69 of 69 namedist.Acceptance notes
docs/audits/2026-09-self-test-shape-census.mdnames this script in a dated census. It is a reading of an earlier commit and is left as is.Generated by Claude Code