Skip to content

fix(generate): bind the state-write CAS token to the rendered base blob - #550

Merged
joshua-temple merged 1 commit into
mainfrom
fix/state-write-cas-base-blob
Jul 10, 2026
Merged

joshua-temple merged 1 commit into
mainfrom
fix/state-write-cas-base-blob

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

The emitted workflow state-write loop (the Contents REST API path in the orchestrate and Release finalize jobs) had a compare-and-swap that was not atomic with the content it wrote. Each attempt did git reset --hard origin/$BRANCH (base blob B0), rendered the new manifest by patching B0, then in a separate later call read CURRENT_SHA via gh api .../contents?ref=$BRANCH, and PUT the B0-derived content with sha=$CURRENT_SHA.

When a concurrent component finalize landed between the render and the CURRENT_SHA read, CURRENT_SHA refreshed to the sibling's blob while the content stayed stale. The PUT then satisfied the compare-and-swap with a token decoupled from the content's base and landed stale bytes as a clean child of the sibling's commit, reverting the sibling's state leaf. Observed live on a shared-path wave: one component's dev version was reverted while the other advanced. Promote, hotfix, and rollback are unaffected because they finalize through the cascade <verb> finalize CLI, whose Go CommitWithRetry reads content and sha together (a true CAS).

Fix

internal/generate/state_write.go: capture the base blob sha the content is rendered from, right after the reset, with BASE_SHA=$(git rev-parse "origin/$BRANCH:$MANIFEST_FILE"), and PUT with sha=$BASE_SHA. Remove the decoupled CURRENT_SHA read. If a sibling lands after the reset, the current blob no longer matches BASE_SHA, GitHub returns 409, and the existing retry re-derives onto fresh trunk and converges. Exactly one writer wins per round; the loser retries. A brand-new file (no existing blob) yields an empty BASE_SHA and omits the sha, creating the file as before. The GitHub Contents API contract is that sha must equal the current blob sha or the write is rejected 409, and that sha is the git blob sha git rev-parse yields.

Verification

Regression test TestStateWriteBindsCASTokenToBaseBlob (red before, green after) asserts the emitted script captures BASE_SHA after the reset, PUTs with sha=$BASE_SHA, and no longer does the decoupled CURRENT_SHA read, for both the orchestrate and Release outputs. Goldens regenerated (orchestrate byte-identical baseline and cascade's own drift-locked orchestrate workflow); diffs limited to the CAS-token binding. go build ./..., go test ./... (2707 pass), go test ./... -race, golangci-lint run ./... all clean; e2e module builds and vets.

Caught by the v1-readiness fleet's shared-path concurrency wave.

The Contents-API state-write loop rendered manifest content from the
freshly reset trunk tip, then separately read the current remote blob
sha to satisfy the PUT compare-and-swap. When a sibling component's
finalize landed between the render and that read, the sha refreshed to
the sibling's blob while the content stayed stale, so the PUT satisfied
the CAS with a token decoupled from the content and landed stale bytes
as a clean child of the sibling commit, reverting the sibling leaf.

Capture the base blob sha with git rev-parse right after the reset,
before the content is re-rendered, and use it as the PUT sha. The base
blob sha is exactly the value the Contents API sha parameter expects, so
a sibling write after the reset makes the current blob differ from the
token and GitHub returns 409; the existing retry loop re-fetches,
re-renders on the sibling leaf, and converges. One writer wins per
round, the loser retries.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple merged commit 26ce471 into main Jul 10, 2026
20 checks passed
@joshua-temple
joshua-temple deleted the fix/state-write-cas-base-blob branch July 10, 2026 11:28
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.

1 participant