Skip to content

PREQ-7781 Claim build numbers atomically via Git refs instead of verify-and-retry - #336

Merged
julien-carsique-sonarsource merged 3 commits into
masterfrom
fix/jcarsique/PREQ-7781-atomic-lock
Aug 26, 2026
Merged

julien-carsique-sonarsource merged 3 commits into
masterfrom
fix/jcarsique/PREQ-7781-atomic-lock

Conversation

@julien-carsique-sonarsource

@julien-carsique-sonarsource julien-carsique-sonarsource commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

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, no
Vault-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 runs
that 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/refs fails with 422 if the ref already
exists) - get_build_number.sh claims a number by creating refs/build-number/<N>, exclusively
and deterministically.

⚠️ Breaking change: contents: write now required

Every caller of get-build-number needs to change contents: read to contents: write in its
permissions: block. Claiming a number now writes Git references directly with the calling
workflow'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: read gets a 403 on every claim
except 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's
Git References section for the full table):

  • refs/build-number/<N> - the claim itself, sole source of truth for uniqueness. Deleted once
    superseded by the next claim - safe only because that deletion happens while holding the
    exclusive build-number-lock below. An earlier revision deleted superseded refs without that
    lock, reasoning the claim loop only ever scans forward from the current max; gitar-bot's review
    caught 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); the
    README's new Inspecting current refs section documents
    git ls-remote to check what currently exists.
  • refs/build-number-lock/global - exclusive, repository-wide, transient lock serializing
    every 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 ... EXIT inside 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-number refs; it assumes claim volume stays
    low 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 the
    candidate is no longer an expected race, so it's now treated as a fatal, actionable error instead
    of something to retry past. The /global segment isn't decorative: POST .../git/refs rejects a
    ref 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_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 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 the
breaking-change note above) - not a Vault-issued credential. Vault is only used to read the legacy
build_number repository property once, as a one-time migration seed for repositories with
existing 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 migration
token is available. The whole Vault dependency is temporary and will be dropped once every
repository has migrated (has ≥1 build-number ref).

acquire_run_lock.sh and get_build_number.sh started as two files (split by token model: the
lock 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: write on a Vault-issued token at all - that moved to each
calling workflow's own permissions: block (a per-repo, per-run, auto-revoked token, not a
standing 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: write permission (write implies read). Companion PR
SonarSource/re-terraform-aws-vault#9518 is closed - it's no longer needed.

Known follow-ups (not blocking this fix)

  • Every claim now serializes through build-number-lock/global (see Design above), on the assumed
    tradeoff 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.
  • Once an audit confirms no repository is still pinned to a pre-ref-only version of
    get-build-number, drop the Vault dependency (and the build-number Vault preset) entirely.
  • refs/build-runs/<run_id>/<N> markers are never automatically deleted and accumulate for the
    lifetime of the repository. An earlier revision of this commit built and real-CI-tested a
    prune-markers maintenance 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 how
    many 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 the
    README's new Inspecting current refs section) covers the
    "what's the current state" need in the meantime.
  • Idea raised, not implemented: embed a date in the build-runs marker name for readability when
    browsing, e.g. refs/build-runs/<YYYYMMDD>/<run_id>/<N>. Rejected in that exact form - leading
    with the date breaks correctness, not just style: git/matching-refs is prefix-only (no
    wildcard 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 never
    looked up.

Real-repo verification

In addition to unit tests, this branch (not @master) is exercised by a throwaway draft PR in
sonar-dummy - SonarSource/sonar-dummy#642.
Every SonarSource/ci-github-actions/*@master call across that repository's workflows is
redirected to this branch for the PR's duration - not just an isolated test workflow, but the real
build.yml pipeline (build-maven x4, report-ci-metrics, promote), integration-test.yml,
unified-dogfooding.yml, and pre-commit.yml's config-npm call - plus a dedicated workflow
calling get-build-number directly 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:

  • 3-way matrix, run 32875091444 -
    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 internal
    reuse assertion confirms all three end up with 12100.
  • Real build pipeline, run 32875091336 -
    Build claims 12102; Build Windows, Build on WarpBuild Runner, Build macOS, and Promote
    all reuse it via refs/build-runs/<run_id>/12102 without ever touching
    refs/build-number-lock/global themselves.

Test plan

  • spec/get_build_number_spec.sh rewritten: fresh claim, migration seed (with and without a
    token), 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.
  • 436 examples, 0 failures (./run_shell_tests.sh)
  • 100% line coverage on get_build_number.sh (kcov)
  • shellcheck, actionlint, yamllint, markdownlint clean
  • Real CI green on test-build-number-generation, -reuse, -reuse-from-env,
    -reuse-same-run[-windows] (against this repository's own real, accumulated build-runs
    markers - see the duration note in the README)
  • Real-repo smoke test in sonar-dummy (see above)
  • SonarCloud Quality Gate passed

@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

PREQ-7781

Comment thread get-build-number/get_build_number.sh Outdated
Comment thread get-build-number/get_build_number.sh
Comment thread get-build-number/get_build_number.sh Outdated
Comment thread get-build-number/get_build_number.sh Outdated
@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch from b043ed8 to d66d0fe Compare August 24, 2026 16:49
Comment thread get-build-number/acquire_run_lock.sh Outdated
Comment thread get-build-number/acquire_run_lock.sh Outdated
@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch 3 times, most recently from 2f76db7 to 2afe148 Compare August 25, 2026 08:02
Comment thread get-build-number/action.yml Outdated
Comment thread get-build-number/action.yml Outdated
Comment thread get-build-number/get_build_number.sh Outdated
Comment thread get-build-number/acquire_run_lock.sh Outdated
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>
@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch 2 times, most recently from 54518d3 to 9c95bc8 Compare August 25, 2026 10:16
Comment thread get-build-number/get_build_number.sh
Comment thread README.md Outdated
gitar-bot[bot]

This comment was marked as resolved.

@gitar-bot

gitar-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

Confirmed against the current get_build_number.sh (the two-script design with acquire_run_lock.sh is gone, consolidated into one script with trap release_lock EXIT at get-build-number/get_build_number.sh:66):

  • Lock release on cancellation: correct, resolved by the redesign. The trap ... EXIT at line 66 fires on any script termination, so it isn't tied to Actions' success/failure/cancelled distinction the way the old YAML if: always() && steps.claim.outcome == 'failure' step was.
  • New leader on freed lock: correct, resolved by the redesign. The loop at get_build_number.sh:71-92 re-checks try_reuse_existing_claim and retries create_ref on the lock ref every iteration, so a waiter picks up the lock as soon as it's freed rather than being stuck polling for a marker that belongs to a phase that no longer exists.

The other two, however, don't match what's in the branch right now:

  • Empty migration token: get_build_number.sh:110 only enters the legacy-seed branch if [[ -n "${LEGACY_PROPERTY_TOKEN:-}" ]]. When the token is empty/missing, that block is skipped silently — there's no ::warning:: anywhere in the file referencing a missing/empty migration token. MAX_CLAIMED just stays 0 with only the generic ::debug::Highest known build number: 0 at line 123.
  • find_run_marker swallowing errors: find_run_marker() at get_build_number.sh:42-44 is still gh api ... --jq '.[0].ref // empty' || true — the || true discards any API error without capturing or logging it. There's no ::debug:: emitting the failure detail.

Could you double check these last two — are they on a different commit/branch than what's currently pushed, or still pending?

@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch from 9c95bc8 to 962c5ae Compare August 25, 2026 12:04
@gitar-bot
gitar-bot Bot dismissed their stale review August 25, 2026 12:11

✅ All blocking issues resolved.

Configure merge blocking

@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch 2 times, most recently from 0a186d0 to 613b794 Compare August 25, 2026 15:41
Comment thread get-build-number/get_build_number.sh
Comment thread get-build-number/get_build_number.sh
@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch from 613b794 to 8f49d57 Compare August 25, 2026 15:45
@julien-carsique-sonarsource
julien-carsique-sonarsource marked this pull request as ready for review August 25, 2026 15:49
@julien-carsique-sonarsource
julien-carsique-sonarsource requested a review from a team as a code owner August 25, 2026 15:49
Copilot AI lite review requested due to automatic review settings August 25, 2026 15:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and build-poetry still 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 EXIT trap is not guaranteed to run if runner cancellation escalates to SIGTERM or kills the process. A cancellation at this point can therefore leave refs/build-number-lock/global behind 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-number namespace is never deleted, but get_build_number.sh explicitly 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.

Comment thread .github/workflows/pre-commit.yml
Comment thread .github/workflows/test-build-number.yml
Comment thread .github/workflows/test-shell-scripts.yml
Comment thread .github/workflows/test-update-release-channel.yml
Comment thread README.md
Comment thread get-build-number/get_build_number.sh Outdated
Comment thread get-build-number/get_build_number.sh
Comment thread get-build-number/get_build_number.sh Outdated
Comment thread get-build-number/get_build_number.sh Outdated
Comment thread get-build-number/get_build_number.sh Outdated
@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch 2 times, most recently from ec214ef to 3329814 Compare August 25, 2026 17:04
@julien-carsique-sonarsource

Copy link
Copy Markdown
Contributor Author

@gitar-bot Both are addressed in the current commit (3329814 as of this reply) - this comment looks like it's checking a commit from before the repository-wide-lock redesign landed, which restructured the whole file (it's ~180 lines now, so the line numbers you cite don't correspond to anything current):

  • Empty migration token: get_build_number.sh:139 - ::warning title=Legacy build number not checked::No refs/${NUMBER_NS}/* exist yet and no migration token is available; starting from 1. ... This fires whenever LEGACY_PROPERTY_TOKEN is empty.
  • find_run_marker swallowing errors: no || true anywhere in the current file. find_run_marker() (lines 57-68) retries the check once, and on exhausting both attempts emits ::warning title=Marker check inconclusive::Could not confirm whether refs/${RUN_NS}/* exists after 2 attempts (${output}); proceeding as if not yet published. with the actual API error captured in ${output}.

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.

@gitar-bot

gitar-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

@gitar-bot Both are addressed in the current commit (3329814 as of this reply) - this comment looks like it's checking a commit from before the repository-wide-lock redesign landed, which restructured the whole file (it's ~180 lines now, so the line numbers you cite don't correspond to anything current):

  • Empty migration token: get_build_number.sh:139 - ::warning title=Legacy build number not checked::No refs/${NUMBER_NS}/* exist yet and no migration token is available; starting from 1. ... This fires whenever LEGACY_PROPERTY_TOKEN is empty.
  • find_run_marker swallowing errors: no || true anywhere in the current file. find_run_marker() (lines 57-68) retries the check once, and on exhausting both attempts emits ::warning title=Marker check inconclusive::Could not confirm whether refs/${RUN_NS}/* exists after 2 attempts (${output}); proceeding as if not yet published. with the actual API error captured in ${output}.

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 get-build-number/get_build_number.sh at 3329814 directly and both are there as described:

  • Line 138-139: the ::warning title=Legacy build number not checked::... fires whenever LEGACY_PROPERTY_TOKEN is empty.
  • find_run_marker() (lines 58-68): no || true in the file; it retries once and on exhausting both attempts emits ::warning title=Marker check inconclusive::... with the real API error in ${output}.

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>
@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch from 3329814 to 279844f Compare August 26, 2026 12:00
Comment thread get-build-number/get_build_number.sh
Comment thread get-build-number/get_build_number.sh
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>
@julien-carsique-sonarsource
julien-carsique-sonarsource force-pushed the fix/jcarsique/PREQ-7781-atomic-lock branch from 279844f to cf0354f Compare August 26, 2026 13:09
@gitar-bot

gitar-bot Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Code Review ✅ Approved 20 resolved / 20 findings

Replaces 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

📄 get-build-number/get_build_number.sh:34-48 📄 get-build-number/get_build_number.sh:61-64
The claim loop scans linearly from BUILD_NUMBER+1 and gives up after MAX_ATTEMPTS (default 50) consecutive collisions (get_build_number.sh:35-57). Since refs/build-locks/* are never pruned and the property PATCH is best-effort, if the hint lags the true frontier by more than MAX_ATTEMPTS (e.g. after repeated PATCH failures or a burst of >50 concurrent claims), every candidate in the window collides and the run fails even though a free number exists just beyond the window. Consider seeding the starting candidate from the highest existing build-lock ref, or exponentially jumping the candidate on repeated collisions instead of a fixed +1 linear scan.

✅ Edge Case: Empty LEGACY_PROPERTY_TOKEN silently changes token for legacy read

📄 get-build-number/get_build_number.sh:40-41 📄 get-build-number/action.yml:70
On the migration path (no locks yet) the legacy property is read with GH_TOKEN="${LEGACY_PROPERTY_TOKEN:-}". If the Vault fetch yields an empty string, the request is made with an empty/unauthenticated token (or falls back to the ambient github.token, which may lack repository_custom_properties: read); either way the gh api call can fail and, being in a command substitution under set -e, aborts the whole build with a confusing error on a fresh repo. Guard the migration read: only attempt it when LEGACY_PROPERTY_TOKEN is non-empty, otherwise skip seeding (candidate starts at 1) and/or emit a clear message.

✅ Edge Case: Re-run after pre-marker failure deadlocks and can never claim a number

📄 get-build-number/acquire_run_lock.sh:20 📄 get-build-number/acquire_run_lock.sh:29 📄 get-build-number/acquire_run_lock.sh:34-48 📄 get-build-number/get_build_number.sh:80-84 📄 get-build-number/action.yml:48-62
GITHUB_RUN_ID is stable across workflow re-runs (only run_attempt changes) and refs/build-run-locks/<run_id> is created atomically and never deleted. If the winning job acquires the lock but dies (cancelled, or get_build_number.sh hits a fatal API error / marker-record failure at lines 66-67 or 80-84) before publishing refs/build-runs/<run_id>/<N>, then re-running the workflow re-enters acquire_run_lock.sh with the same run_id, finds the lock already held, waits LOCK_WAIT_MAX_ATTEMPTS for a marker that will never appear, and fails. Every subsequent re-run fails identically, so the run can only succeed by pushing a new commit. This is a regression from the previous read-only check_existing_claim.sh, where a re-run would simply claim a fresh number. Consider cleaning up refs/build-run-locks/<run_id> in an always() step when the claiming job fails, or letting a re-run (GITHUB_RUN_ATTEMPT > 1) fall through to claim a new number when no marker appears before timeout.

✅ Edge Case: Transient gh error during marker polling aborts loser job

📄 get-build-number/acquire_run_lock.sh:36-50
In the wait loop, MARKER=$(gh api ... matching-refs ...) at acquire_run_lock.sh:37 runs under set -euo pipefail. A single transient/network failure of this gh call makes the command substitution fail and immediately aborts the loser job with a non-descriptive error, instead of being retried on the next poll iteration like a genuine 'marker not yet present' case. Consider tolerating a failed poll (e.g. MARKER=$(gh api ... || true)) so transient errors are retried within the bounded wait rather than failing the job outright.

✅ Bug: Lock release never runs on cancellation, contradicting its comment

📄 get-build-number/action.yml:75-88 📄 get-build-number/acquire_run_lock.sh:20-32 📄 get-build-number/acquire_run_lock.sh:53-57
The step's comment says always() is used "so this still runs on cancellation", but the guard is steps.claim.outcome == 'failure' — a step interrupted by a job/workflow cancellation has outcome cancelled, not failure, so the DELETE is skipped and refs/build-run-locks/<run_id> stays held with no marker. Any rerun of that run then takes the loser path in acquire_run_lock.sh (ref already exists), polls for a marker that will never appear, and fails after LOCK_WAIT_MAX_ATTEMPTS — permanently, until the ref is deleted by hand. Note the condition must also exclude the loser case (run-lock succeeded with skip=true), where the lock belongs to another job and must not be deleted.

...and 15 more resolved from earlier reviews

Implementation Status ◻️ 0 of 1 objectives covered
◻️ PREQ-7781 - 0 of 1 objectives covered

This PR does not implement any changes related to claiming build numbers atomically via Git refs.

Other objectives on this issue, possibly covered elsewhere:

  • ◻️ Claim build numbers atomically via Git refs instead of verify-and-retry to prevent race conditions in GitHub Stacked PRs
Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqubecloud

Copy link
Copy Markdown

@julien-carsique-sonarsource
julien-carsique-sonarsource merged commit d41706a into master Aug 26, 2026
21 checks passed
@julien-carsique-sonarsource
julien-carsique-sonarsource deleted the fix/jcarsique/PREQ-7781-atomic-lock branch August 26, 2026 16:04
julien-carsique-sonarsource added a commit to SonarSource/sonar-dummy-gradle-oss that referenced this pull request Aug 27, 2026
… 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>
julien-carsique-sonarsource added a commit to SonarSource/sonar-dummy-gradle-oss that referenced this pull request Aug 27, 2026
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>
julien-carsique-sonarsource added a commit to SonarSource/sonar-dummy-gradle-oss that referenced this pull request Aug 27, 2026
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>
julien-carsique-sonarsource added a commit to SonarSource/sonar-dummy-js that referenced this pull request Aug 27, 2026
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>
julien-carsique-sonarsource added a commit to SonarSource/sonar-dummy-js that referenced this pull request Aug 27, 2026
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>
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.

3 participants