Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/orchestrate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
14 changes: 11 additions & 3 deletions internal/generate/state_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions internal/generate/state_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down