From b958fc4d81cca99a6c9dbd66aa2dba2129c13d0e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 10 Jul 2026 07:11:23 -0400 Subject: [PATCH] fix(generate): bind state-write CAS token to the rendered base blob 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 --- .github/workflows/orchestrate.yaml | 6 +-- internal/generate/state_write.go | 14 ++++-- internal/generate/state_write_test.go | 47 +++++++++++++++++++ ...github__workflows__orchestrate.yaml.golden | 6 +-- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index aae8fca7..3f71eca6 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -227,13 +227,13 @@ jobs: echo "cascade-state-write: attempt=$attempt/10" git fetch origin "$BRANCH" git reset --hard "origin/$BRANCH" + BASE_SHA=$(git rev-parse "origin/$BRANCH:$MANIFEST_FILE" 2>/dev/null || true) apply_state_edits if git diff --quiet "$MANIFEST_FILE"; then echo "No state changes" exit 0 fi CONTENT_B64=$(base64 -w0 "$MANIFEST_FILE" 2>/dev/null || base64 "$MANIFEST_FILE" | tr -d '\n') - CURRENT_SHA=$(gh api "repos/${{ github.repository }}/contents/$MANIFEST_FILE?ref=$BRANCH" --jq '.sha' 2>/dev/null || true) API_ARGS=("repos/${{ github.repository }}/contents/$MANIFEST_FILE" -X PUT -f "message=chore: update state [skip ci]" -f "content=$CONTENT_B64" @@ -242,8 +242,8 @@ jobs: -f "author[email]=github-actions[bot]@users.noreply.github.com" -f "committer[name]=github-actions[bot]" -f "committer[email]=github-actions[bot]@users.noreply.github.com") - if [[ -n "$CURRENT_SHA" ]]; then - API_ARGS+=(-f "sha=$CURRENT_SHA") + if [[ -n "$BASE_SHA" ]]; then + API_ARGS+=(-f "sha=$BASE_SHA") fi if gh api "${API_ARGS[@]}" >/dev/null; then echo "Pushed state via API on attempt $attempt" diff --git a/internal/generate/state_write.go b/internal/generate/state_write.go index 9a11a3b7..3ff93015 100644 --- a/internal/generate/state_write.go +++ b/internal/generate/state_write.go @@ -120,13 +120,21 @@ func writeStateCommitPush(sb *strings.Builder, indent string, params stateWriteP w(" echo \"cascade-state-write: attempt=$attempt/10\"") w(" git fetch origin \"$BRANCH\"") w(" git reset --hard \"origin/$BRANCH\"") + // Capture the base blob sha of the manifest at the freshly fetched tip, + // BEFORE applyFn re-renders it. This git blob sha is exactly what the + // Contents API `sha` parameter expects, and it binds the compare-and-swap + // token to the precise base the rendered content is derived from. A later, + // decoupled read of the current remote blob would refresh to a sibling + // component's leaf that landed after the render, letting the PUT satisfy the + // CAS with a token unrelated to the content and revert the sibling. Binding + // to the base blob turns a raced sibling write into a 409 the loop converges. + w(" BASE_SHA=$(git rev-parse \"origin/$BRANCH:$MANIFEST_FILE\" 2>/dev/null || true)") w(" %s", params.applyFn) w(" if git diff --quiet \"$MANIFEST_FILE\"; then") w(" echo \"%s\"", params.noChangeLabel) w(" exit 0") w(" fi") w(" CONTENT_B64=$(base64 -w0 \"$MANIFEST_FILE\" 2>/dev/null || base64 \"$MANIFEST_FILE\" | tr -d '\\n')") - w(" CURRENT_SHA=$(gh api \"repos/${{ github.repository }}/contents/$MANIFEST_FILE?ref=$BRANCH\" --jq '.sha' 2>/dev/null || true)") // Build the API arguments. -f message handles a multi-line message safely. w(" API_ARGS=(\"repos/${{ github.repository }}/contents/$MANIFEST_FILE\" -X PUT") w(" -f \"message=%s\"", shellSingleLineMessage(params.commitMessage)) @@ -138,8 +146,8 @@ func writeStateCommitPush(sb *strings.Builder, indent string, params stateWriteP w(" -f \"author[email]=%s\"", authorEmail) w(" -f \"committer[name]=%s\"", authorName) w(" -f \"committer[email]=%s\")", authorEmail) - w(" if [[ -n \"$CURRENT_SHA\" ]]; then") - w(" API_ARGS+=(-f \"sha=$CURRENT_SHA\")") + w(" if [[ -n \"$BASE_SHA\" ]]; then") + w(" API_ARGS+=(-f \"sha=$BASE_SHA\")") w(" fi") w(" if gh api \"${API_ARGS[@]}\" >/dev/null; then") w(" echo \"%s via API on attempt $attempt\"", params.successLabel) diff --git a/internal/generate/state_write_test.go b/internal/generate/state_write_test.go index 277fae6f..6c1a8dc8 100644 --- a/internal/generate/state_write_test.go +++ b/internal/generate/state_write_test.go @@ -299,6 +299,53 @@ func TestHotfixCherryPickCommitOmitsSkipCIMarker(t *testing.T) { } } +// TestStateWriteBindsCASTokenToBaseBlob asserts the Contents-API state write +// binds its compare-and-swap token to the exact base blob the content was +// rendered from. The emitter captures BASE_SHA with git rev-parse right after +// the fetch/reset (before applyFn re-renders), then passes sha=$BASE_SHA to the +// PUT. It must NOT read a separate, later current-blob sha through the Contents +// API: a decoupled read refreshes to a sibling component's blob that landed +// after the render, so the PUT would satisfy the CAS with a token unrelated to +// the content's base and land stale bytes as a clean child of the sibling +// commit, silently reverting the sibling leaf. Binding the token to the base +// blob makes a sibling write turn the PUT into a 409 the retry loop converges. +func TestStateWriteBindsCASTokenToBaseBlob(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + + orch, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + rel, err := NewReleaseGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + for name, content := range map[string]string{"orchestrate": orch, "release": rel} { + // (a) The base blob sha is captured from the fetched tip, which is exactly + // the git blob sha the Contents API `sha` parameter expects. + assert.Contains(t, content, `BASE_SHA=$(git rev-parse "origin/$BRANCH:$MANIFEST_FILE" 2>/dev/null || true)`, + "%s must capture the base blob sha right after the reset", name) + // (b) The PUT's CAS token is the base blob sha, guarded so a brand-new file + // (no existing blob) omits sha and creates. + assert.Contains(t, content, `if [[ -n "$BASE_SHA" ]]; then`, + "%s must guard the sha arg on an existing base blob", name) + assert.Contains(t, content, `API_ARGS+=(-f "sha=$BASE_SHA")`, + "%s PUT must bind the CAS token to the base blob sha", name) + // (c) No decoupled, later current-blob read: that is the clobber. + assert.NotContains(t, content, "CURRENT_SHA=$(gh api", + "%s must not read a separate current-blob sha decoupled from the rendered content", name) + assert.NotContains(t, content, `API_ARGS+=(-f "sha=$CURRENT_SHA")`, + "%s PUT must not use the decoupled current-blob sha", name) + } +} + // TestStateWriteNoEmDash guards the hard project rule that generated output // contains no em dashes. func TestStateWriteNoEmDash(t *testing.T) { diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden index f275092d..59e2a47c 100644 --- a/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden @@ -297,13 +297,13 @@ jobs: echo "cascade-state-write: attempt=$attempt/10" git fetch origin "$BRANCH" git reset --hard "origin/$BRANCH" + BASE_SHA=$(git rev-parse "origin/$BRANCH:$MANIFEST_FILE" 2>/dev/null || true) apply_state_edits if git diff --quiet "$MANIFEST_FILE"; then echo "No state changes" exit 0 fi CONTENT_B64=$(base64 -w0 "$MANIFEST_FILE" 2>/dev/null || base64 "$MANIFEST_FILE" | tr -d '\n') - CURRENT_SHA=$(gh api "repos/${{ github.repository }}/contents/$MANIFEST_FILE?ref=$BRANCH" --jq '.sha' 2>/dev/null || true) API_ARGS=("repos/${{ github.repository }}/contents/$MANIFEST_FILE" -X PUT -f "message=chore: update state for $ENVIRONMENT [skip ci]" -f "content=$CONTENT_B64" @@ -312,8 +312,8 @@ jobs: -f "author[email]=github-actions[bot]@users.noreply.github.com" -f "committer[name]=github-actions[bot]" -f "committer[email]=github-actions[bot]@users.noreply.github.com") - if [[ -n "$CURRENT_SHA" ]]; then - API_ARGS+=(-f "sha=$CURRENT_SHA") + if [[ -n "$BASE_SHA" ]]; then + API_ARGS+=(-f "sha=$BASE_SHA") fi if gh api "${API_ARGS[@]}" >/dev/null; then echo "Pushed state via API on attempt $attempt"