PREQ-7781 Claim build numbers atomically via Git refs instead of verify-and-retry - #336
Conversation
5b81688 to
b043ed8
Compare
b043ed8 to
d66d0fe
Compare
2f76db7 to
2afe148
Compare
Replaces the verify-after-write + retry approach (#335, closed) with a genuine atomic claim: creating refs/build-locks/<N> fails if the ref already exists, so it acts as a compare-and-swap instead of a probabilistic race-window narrowing. Drops the build_number custom property and actions/cache entirely. The starting candidate now comes from scanning refs/build-locks/* instead of the property hint, and rerun/same-run reuse is tracked via a best-effort refs/build-runs/<run_id>/<N> marker instead of a cache entry. The claim itself uses the calling workflow's own ambient token (contents: write); Vault is only used to read the legacy build_number property once, as a migration seed for repos with existing history, and can be dropped entirely once every repo has migrated. Updates this repo's own CI workflows (pre-commit, test-shell-scripts, test-update-release-channel, test-build-number) to grant contents: write instead of contents: read, since get-build-number (and anything that calls it, e.g. config-npm) now needs it for the ambient-token claim. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
54518d3 to
9c95bc8
Compare
|
Confirmed against the current
The other two, however, don't match what's in the branch right now:
Could you double check these last two — are they on a different commit/branch than what's currently pushed, or still pending? |
9c95bc8 to
962c5ae
Compare
0a186d0 to
613b794
Compare
613b794 to
8f49d57
Compare
There was a problem hiding this comment.
Pull request overview
Replaces property-based build numbering with atomic Git-ref claims, run markers, global locking, and bounded marker pruning.
Changes:
- Adds lock-protected claims, migration seeding, marker reuse, and cleanup.
- Adds pruning for markers belonging to deleted workflow runs.
- Updates action metadata, documentation, tests, and CI permissions.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Final review findings |
|---|---|
spec/prune_build_run_markers_spec.sh |
No final comments. |
spec/get_build_number_spec.sh |
No final comments. |
README.md |
Nit, 3 votes: Update config-maven, config-pip, and config-uv examples from contents: read to contents: write. |
get-build-number/prune_build_run_markers.sh |
No final comments. |
get-build-number/get_build_number.sh |
Critical, 2 votes: Failed lock deletion can permanently block claims; add safe stale-lock recovery. Critical, 2 votes: Marker lookup errors can cause duplicate claims; retry or fail on inconclusive reads. Critical, 1 vote: Missing migration credentials can incorrectly restart numbering at 1; fail closed or require opt-in. Critical, 1 vote: Leading-zero refs can be parsed as invalid octal; use explicit base-10 parsing. Critical, 1 vote: Leading-zero legacy values have the same octal issue. Critical, 2 votes: Marker-write failure can allow another same-run job to claim a second number; make publication recoverable or fail subsequent callers. |
get-build-number/action.yml |
Moderate, 2 votes: Vault lookup failure prevents the documented no-token path; make the lookup optional. Nit, 3 votes: Align the maintenance-mode description with README support for scheduled workflows. |
.github/workflows/test-update-release-channel.yml |
Critical, 1 vote: pull_request code receives contents: write; keep write-capable claiming in a trusted workflow or separate it from validation. |
.github/workflows/test-shell-scripts.yml |
Critical, 1 vote: pull_request code receives contents: write; keep write-capable claiming in a trusted workflow or separate it from validation. |
.github/workflows/test-build-number.yml |
Critical, 1 vote: pull_request code receives contents: write; keep write-capable claiming in a trusted workflow or separate it from validation. |
.github/workflows/pre-commit.yml |
Critical, 1 vote: pull_request code receives contents: write; keep write-capable claiming in a trusted workflow or separate it from validation. |
Suppressed comments (3)
get-build-number/action.yml:6
- The base action output description now correctly describes ref-based reuse, but the published output descriptions of
build-maven,build-gradle,build-yarn, andbuild-poetrystill say the number is reused from cache. Those wrappers expose this output, so update their metadata to remove the behavior that this change deleted.
description: The build number, newly claimed or reused if this workflow run already claimed one
get-build-number/get_build_number.sh:85
- An
EXITtrap is not guaranteed to run if runner cancellation escalates to SIGTERM or kills the process. A cancellation at this point can therefore leaverefs/build-number-lock/globalbehind and make later claims wait until timeout, despite the design claiming cleanup on cancellation. Add INT/TERM handlers that exit through the existing EXIT cleanup before the runner's hard kill.
trap release_lock EXIT
get-build-number/prune_build_run_markers.sh:12
- This comment says the
build-numbernamespace is never deleted, butget_build_number.shexplicitly deletes superseded refs at lines 161-164 while holding the global lock. Only this pruning script excludes that namespace; please correct the comment so maintainers do not infer that stale claim refs persist indefinitely.
# refs/build-number/<number> is intentionally not handled here - see get_build_number.sh for why that namespace is never deleted.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
ec214ef to
3329814
Compare
|
@gitar-bot Both are addressed in the current commit (
Let me know if you're seeing something different on your end - happy to dig further, but I've re-checked the pushed file directly and both are there. |
Confirmed — re-checked
My earlier replies were checking a stale commit before the repository-wide-lock redesign restructured this file. Thanks for the correction — closing these out. |
check_existing_claim.sh was a plain read: if two jobs in the same workflow run both checked before either had claimed, both would independently claim different numbers under the same run_id - each individually valid, but not the intended "one run, one number" outcome. That's a lock-free CAS allocator (every caller races for a different key), not a mutex (one shared key, losers wait) - and the previous behavior on divergence was silent, not a failure. Renamed to acquire_run_lock.sh and rebuilt around a real, exclusive, one-shot lock: refs/build-run-locks/<run_id>. The winner proceeds straight into get_build_number.sh (scan, claim, write the refs/build-runs/<run_id>/<N> marker) with nothing else in between. A loser - whether a true concurrent race or a rerun replaying an already-acquired lock - polls for that marker and reuses its number. Reruns resolve near-instantly (the marker is already there); only genuine concurrent overlaps actually wait. A wait that times out fails loudly instead of falling back to an independent claim. Consequently, get_build_number.sh's marker write is now a hard failure rather than a best-effort warning, since other jobs may be blocked on it. This also resolves the "prefer using the output instead of calling it from distinct jobs" caveat - that limitation of the previous implementation is gone, so it's removed from test-build-number.yml and the README. Never delete refs/build-number/<N>: an earlier revision of this commit kept only the latest ref, deleting superseded ones on the reasoning that the claim loop only ever scans forward from the current max. gitar-bot's review caught the actual race: a claim stalled between scanning and posting can resume after two other, faster claims have superseded and deleted the exact number it's about to post to, letting it succeed against that freed slot and duplicate an already-published number. There is no external authority that can rule this out (unlike the run-existence check below), so the ref is kept forever instead of bounded by a guess about how long an API call could stay in flight. Document contents: write as a breaking change (claiming now writes Git references with the calling workflow's own ambient token instead of a Vault-issued one) and have the claim failure message name the missing permission directly when the API denies access, instead of only dumping the raw response. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
3329814 to
279844f
Compare
refs/build-number/<N> was never deleted: without exclusive access, a
claim stalled in flight for an older candidate could resume after
two other, faster claims had superseded and deleted the exact
number it was about to post to, letting it succeed against that
freed slot and duplicate an already-published number - the ABA bug
fixed two commits ago by simply never deleting anything. That traded
correctness for an unbounded matching-refs scan that grows with the
repository's total historical claim count.
Add refs/build-number-lock/global: an exclusive, repository-wide,
transient lock (same create/trap-release pattern as the marker
write) serializing every new claim, not just those within one run.
Under it, no other claim can be scanning, posting, or deleting at
the same time, so deleting a superseded build-number ref is safe
again - at most one ref exists at a time, keeping the scan
effectively O(1) regardless of history. This assumes claim volume
stays low enough, relative to how fast one claim completes, that
the resulting wait queue stays short. (The trailing /global segment
isn't decorative: the Git References API rejects a ref name with
fewer than three slash-separated components, caught by real CI
against this branch, not by the mocked unit tests.)
The single global lock also replaces the per-run
build-run-locks/<run_id> lock entirely: serializing every claim
naturally serializes same-run job races too, so a separate
run-scoped lock added nothing. And under exclusive access, a
collision on the candidate ref is no longer an expected race - it's
now a fatal, actionable error instead of something MAX_ATTEMPTS
retried past.
As a side effect, serializing the migration-seed branch too closes
a second bug: two different concurrent runs could previously each
independently seed from the legacy build_number property and claim
distinct, non-adjacent numbers, since that seeding was only
serialized within a single run. That gap was harmless to
uniqueness (still covered by the "ignoring gaps" test) but ruled
out ever finding the max more cheaply than a full scan; it can no
longer form.
refs/build-runs/<run_id>/<N> markers are never automatically
deleted (no pruning mechanism, by design - see below); README now
documents git ls-remote as the way to inspect the current build
number and the run-to-number history directly, instead.
Review fixes (real CI + gitar-bot/copilot on this same commit):
- A leaked repository-wide lock (runner killed before its cleanup
trap runs) previously had no recovery path; the timeout error now
names the exact `gh api --method DELETE` command to clear it.
Auto-steal via holder run_id + Actions API status is a real,
structurally sound follow-up, deliberately not folded in here.
- Marker check and marker write each retry once/twice
(RETRY_INTERVAL_SECONDS apart) before giving up, narrowing (not
closing) the window where a transient API blip could make a
same-run job miss an existing marker and claim its own, second
number instead of waiting/reusing.
- Ref-name arithmetic now forces base-10 (`10#$n`) so a leading-zero
ref name can't be misread as an invalid octal literal under `set -e`.
- action.yml: the Vault fetch step is now continue-on-error, so a
missing/misconfigured secret falls through to the existing
no-migration-token warning instead of hard-failing the whole
action before that path is ever reached.
- README: config-maven/config-pip/config-uv corrected from a stale
contents: read to contents: write (each calls get-build-number
internally, same breaking change as get-build-number itself).
- The stale-refs deletion loop guards against an empty STALE_REFS
array before expanding it: bash before 4.4 treats
"${STALE_REFS[@]}" on a zero-element array as an unbound variable
under `set -u`, and macOS ships bash 3.2 by default (this action
also runs there via config-*/build-* wrappers). Only reachable on
a repository's very first claim (no refs/build-number/* yet), but
real - every repository migrating to this design hits it once.
A prune-markers maintenance mode (deleting refs/build-runs/<run_id>/<N>
markers for runs no longer on GitHub) was built and tested against this
repository's own real history in an earlier revision of this commit, but
removed before merge: the naive implementation doesn't scale (listing
the entire refs/build-runs/* namespace is unbounded per invocation regardless
of how many markers are actually stale), and a correctly bounded version
needs a different ref-naming scheme entirely. Left as a real, undocumented
follow-up rather than shipped half-solved; git ls-remote covers the
"what's the current state" need in the meantime.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
279844f to
cf0354f
Compare
Code Review ✅ Approved 20 resolved / 20 findingsReplaces the verify-after-write build number claim with an atomic Git ref create-and-lock mechanism, addressing 17 findings including race conditions, lock leaks, and permission errors. ✅ 20 resolved✅ Edge Case: Stale build_number hint can spuriously exhaust MAX_ATTEMPTS
✅ Edge Case: Empty LEGACY_PROPERTY_TOKEN silently changes token for legacy read
✅ Edge Case: Re-run after pre-marker failure deadlocks and can never claim a number
✅ Edge Case: Transient gh error during marker polling aborts loser job
✅ Bug: Lock release never runs on cancellation, contradicting its comment
...and 15 more resolved from earlier reviews Implementation Status ◻️ 0 of 1 objectives covered◻️ PREQ-7781 - 0 of 1 objectives coveredThis PR does not implement any changes related to claiming build numbers atomically via Git refs. Other objectives on this issue, possibly covered elsewhere:
OptionsAuto-apply is off → Gitar will not commit updates to this branch. Comment with these commands to change the behavior for this request:
Was this helpful? React with 👍 / 👎 | Gitar |
|
… is called get-build-number now requires contents: write to claim a number (SonarSource/ci-github-actions#336, merged) - both build.yml's dedicated get-build-number job and unified-dogfooding.yml's build job (via build-gradle -> config-gradle) call it internally, and have been hard-failing every run with a 403 since the merge. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
build.yml's dedicated get-build-number job only ever existed to sequence one claim and pass it to every other job via outputs/env. That's no longer needed: build, build-windows, test-working-directory, and promote each call an action that calls get-build-number internally (build-gradle -> config-gradle, or directly for test-working-directory/promote), and cross-job reuse within a run is now automatic (refs/build-runs/<run_id>/<N>, SonarSource/ci-github-actions#336) regardless of which job claims first. Also bumps contents: read to contents: write on unified-dogfooding.yml's build job for the same reason - both were hard-failing every run with a 403 since #336 merged. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
build.yml's dedicated get-build-number job only ever existed to sequence one claim and pass it to every other job via outputs/env. That's no longer needed: build, build-windows, test-working-directory, and promote each call an action that calls get-build-number internally (build-gradle -> config-gradle, or directly for test-working-directory/promote), and cross-job reuse within a run is now automatic (refs/build-runs/<run_id>/<N>, SonarSource/ci-github-actions#336) regardless of which job claims first. Also bumps contents: read to contents: write on unified-dogfooding.yml's build job for the same reason - both were hard-failing every run with a 403 since #336 merged. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
get-build-number now requires contents: write to claim a number (SonarSource/ci-github-actions#336, merged) - this job calls an action that calls it internally, and has been hard-failing every run with a 403 since the merge. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
build's own BUILD_NUMBER output and promote's env passthrough of it were leftover from the pre-#336 world, where cross-job reuse needed explicit wiring. Cross-job reuse is now automatic (refs/build-runs/<run_id>/<N>, SonarSource/ci-github-actions#336) - promote calls an action that calls get-build-number internally and reuses build's claim on its own, regardless of this passthrough. Missed when contents: write was bumped earlier (that PR only touched unified-dogfooding.yml). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>



Summary
Replaces the verify-after-write + retry approach from #335 (closed) with a real atomic claim,
coordinated purely through Git references - no repository custom property, no
actions/cache, noVault-issued write-capable credential for the claim itself.
gitar-bot's review on #335 correctly found verify-after-write doesn't close the race: two runsthat both read N and both PATCH N+1 both pass verification and claim the same number. Git ref
creation is a genuine compare-and-swap (
POST .../git/refsfails with 422 if the ref alreadyexists) -
get_build_number.shclaims a number by creatingrefs/build-number/<N>, exclusivelyand deterministically.
contents: writenow requiredEvery caller of
get-build-numberneeds to changecontents: readtocontents: writein itspermissions:block. Claiming a number now writes Git references directly with the callingworkflow's own ambient token (previously the write happened via a Vault-issued credential, and the
ambient token only needed read access).
This is not a soft/optional upgrade: a caller still on
contents: readgets a 403 on every claimexcept the narrow case where the current workflow run already has a marker to reuse (that path is
read-only, so it happens to work by accident, not by design). There is no working
"read-only/rerun-only mode" - any run needing a genuinely new number fails until this is updated.
The error message now calls out the missing permission by name instead of only dumping the raw API
response, to make this discoverable without reading this PR.
Please flag this in the next release notes.
Design
Three ref namespaces, all created via the same atomic
POST .../git/refs(see README'sGit References section for the full table):
refs/build-number/<N>- the claim itself, sole source of truth for uniqueness. Deleted oncesuperseded by the next claim - safe only because that deletion happens while holding the
exclusive
build-number-lockbelow. An earlier revision deleted superseded refs without thatlock, reasoning the claim loop only ever scans forward from the current max;
gitar-bot's reviewcaught the actual race: a claim stalled in flight for an older candidate could resume after two
other, faster claims had superseded and deleted the exact number it was about to post to,
letting it succeed against that freed slot and duplicate an already-published number - a real,
confirmed bug, not a hypothetical. The lock closes that: nothing else can be scanning, claiming,
or deleting while one claim holds it, so there's no external process left to race.
refs/build-runs/<run_id>/<N>- marker recording which number a workflow run claimed.Checked first, and lock-free, so a rerun or another job in the same run reuses it without ever
touching
build-number-lock. Never automatically deleted (see "Known follow-ups" below); theREADME's new Inspecting current refs section documents
git ls-remoteto check what currently exists.refs/build-number-lock/global- exclusive, repository-wide, transient lock serializingevery new claim (not scoped to a run - a single global lock also serializes same-run job races,
so a separate per-run lock is no longer needed). Created, used, and deleted again within a single
claim cycle (via a
trap ... EXITinside the script, so release runs on every exit path,including cancellation) - not held for a run's lifetime. This trades away claiming in parallel
for the ability to safely delete superseded
build-numberrefs; it assumes claim volume stayslow enough, relative to how fast one claim completes, that the resulting wait queue stays short.
MAX_ATTEMPTS/collision-retry is gone along with it - under exclusive access a collision on thecandidate is no longer an expected race, so it's now treated as a fatal, actionable error instead
of something to retry past. The
/globalsegment isn't decorative:POST .../git/refsrejects aref with fewer than three slash-separated components - caught by real CI against this branch
(both here and via a throwaway PR in
sonar-dummy), not by the mocked unit tests.As a side effect, serializing every claim (including the migration-seed branch below) through one
lock also closes a second, unrelated bug this same review surfaced: two different concurrent
workflow runs could previously each independently seed from the legacy
build_numberproperty andclaim distinct, non-adjacent numbers, since that seeding was only serialized within a single run.
That gap was harmless to uniqueness (still tested and expected via
spec/get_build_number_spec.sh's"ignoring gaps" case) but ruled out ever finding the max more cheaply than a full scan. With every
claim now serialized, that gap can no longer form, and since superseded refs are deleted, the scan
stays effectively O(1) regardless of the repository's total history - no separate optimization
needed.
All ref reads/writes use the calling workflow's own ambient token (
contents: write, see thebreaking-change note above) - not a Vault-issued credential. Vault is only used to read the legacy
build_numberrepository property once, as a one-time migration seed for repositories withexisting history (seeded with a safety buffer above the legacy value, to allow for it advancing
elsewhere during the transition); this is skipped entirely, with a
::warning::, if no migrationtoken is available. The whole Vault dependency is temporary and will be dropped once every
repository has migrated (has ≥1
build-numberref).acquire_run_lock.shandget_build_number.shstarted as two files (split by token model: thelock check needed only the ambient token, the claim needed Vault too) and were later consolidated
into one script once Vault became an unconditional fetch - there was no longer a structural reason
to keep them separate.
Vault dependency - no permission change needed
The claim no longer needs
contents: writeon a Vault-issued token at all - that moved to eachcalling workflow's own
permissions:block (a per-repo, per-run, auto-revoked token, not astanding shared credential; see the breaking-change note above). The only remaining Vault use
(reading the legacy property) is already covered by the existing, unchanged
repository_custom_properties: writepermission (write implies read). Companion PRSonarSource/re-terraform-aws-vault#9518 is closed - it's no longer needed.
Known follow-ups (not blocking this fix)
build-number-lock/global(see Design above), on the assumedtradeoff that claim volume stays low enough, relative to how fast one claim completes, that the
resulting wait queue stays short. Revisit only if real usage data after rollout shows meaningful
contention.
get-build-number, drop the Vault dependency (and thebuild-numberVault preset) entirely.refs/build-runs/<run_id>/<N>markers are never automatically deleted and accumulate for thelifetime of the repository. An earlier revision of this commit built and real-CI-tested a
prune-markersmaintenance mode for this, removed before merge: it doesn't scale as written -listing the entire
refs/build-runs/*namespace is unbounded per invocation regardless of howmany markers are actually stale, and a correctly bounded version needs a different ref-naming
scheme entirely (e.g. a coarse time-bucket prefix), not a small fix. Left as a real,
undocumented-until-now follow-up instead of shipped half-solved.
git ls-remote(see theREADME's new Inspecting current refs section) covers the
"what's the current state" need in the meantime.
build-runsmarker name for readability whenbrowsing, e.g.
refs/build-runs/<YYYYMMDD>/<run_id>/<N>. Rejected in that exact form - leadingwith the date breaks correctness, not just style:
git/matching-refsis prefix-only (nowildcard for a middle/leading segment, so a lookup can't ask "any date, this run_id" in one
call), reruns can happen on a different day than the original claim, and a claim/reuse-check
straddling midnight within the same run would compute two different "today" values and break the
same-run reuse guarantee. If revisited, the date would need to be a suffix after
run_id(e.g.
refs/build-runs/<run_id>/<N>-<YYYYMMDD>), where it's purely decorative and neverlooked up.
Real-repo verification
In addition to unit tests, this branch (not
@master) is exercised by a throwaway draft PR insonar-dummy- SonarSource/sonar-dummy#642.Every
SonarSource/ci-github-actions/*@mastercall across that repository's workflows isredirected to this branch for the PR's duration - not just an isolated test workflow, but the real
build.ymlpipeline (build-mavenx4,report-ci-metrics,promote),integration-test.yml,unified-dogfooding.yml, andpre-commit.yml'sconfig-npmcall - plus a dedicated workflowcalling
get-build-numberdirectly with a 3-way matrix (real lock contention). The first attempt(before redirecting everything) is what caught the "at least three slash-separated components" API
constraint fixed above; a later attempt is what gitar-bot's review of the sonar-dummy PR itself
caught the smoke test not actually exercising contention or asserting reuse, both since fixed
there.
Two runs' worth of real evidence, not a mock, of the exact cross-job reuse property this design
exists to guarantee:
one job wins the race (
Claimed build number 12100), the other two reuse it(
Reusing build number 12100, already claimed by this workflow run), each job's own internalreuse assertion confirms all three end up with
12100.Buildclaims12102;Build Windows,Build on WarpBuild Runner,Build macOS, andPromoteall reuse it via
refs/build-runs/<run_id>/12102without ever touchingrefs/build-number-lock/globalthemselves.Test plan
spec/get_build_number_spec.shrewritten: fresh claim, migration seed (with and without atoken), gap-scan and delete-the-superseded-refs, delete-failure warning (non-fatal), fatal
"already exists while holding the lock" anomaly, fatal API errors (including the new
contents:write-specific message), marker-write failure, lock-wait poll/timeout/race-after-
acquire, transient poll-error tolerance, malformed marker format.
./run_shell_tests.sh)get_build_number.sh(kcov)shellcheck,actionlint,yamllint,markdownlintcleantest-build-number-generation,-reuse,-reuse-from-env,-reuse-same-run[-windows](against this repository's own real, accumulatedbuild-runsmarkers - see the duration note in the README)
sonar-dummy(see above)