← All needs

Partial Dual-Write in set-stage.sh Leaves project.yaml and stage.txt Inconsistent

## Summary `set-stage.sh` rewrites `project.yaml` in place before updating `production/stage.txt`. If the second write fails, the script exits without rolling back the first, without deleting its temporary file, and before the split-stage check, leaving the two stage files inconsistent. ## Environment - Repository: `Donchitos/Claude-Code-Game-Studios` - Target commit: `05ad7c8a5b8aa2aa86bbd3439ae7cf7295cac418` - File: `.claude/scripts/set-stage.sh:200-311` - Runtime: Debian 12; bash ## Steps to reproduce 1. Prepare a fixture whose `project.yaml` and `production/stage.txt` both hold `Concept`. 2. Make `production/stage.txt` read-only (`chmod 0444`). 3. Run the real `.claude/scripts/set-stage.sh Production --root `. 4. Inspect both stage files, the exit code, and any leftover temporary files. ## Expected behavior project.yaml and production/stage.txt must hold the same stage after every attempted stage write, or neither file is changed. ## Actual behavior A reachable failure path in set-stage.sh leaves project.yaml holding the new stage while production/stage.txt still holds the old stage or is absent: the dual-write transition is only partly completed and no cleanup/rollback restores the pre-call state. ``` if (full == "project.stage") { nst++; stlines = stlines (nst > 1 ? "," : "") L laststage = L; curval = v; colon[L] = n + p } } eol = crlf ? "\r" : "" action = ""; at = 0 if (projkind == "scalar") { print "unsupported\t" projline > ENVIRON["STATUS"]; exit 0 } if (laststage > 0) { at = laststage if (curval == stage) action = "unchanged" else { action = "replaced" line = raw[at]; cr = (line ~ /\r$/); sub(/\r$/, "", line) pre = substr(line, 1, colon[at]); after = substr(line, colon[at] + 1) lead = after; sub(/[^ \t].*$/, "", lead); if (lead == "") lead = " " a = after; sub(/^[ \t]+/, "", a) q = substr(a, 1, 1) if (a == "") tail = "" else if (q == "#") tail = " " a else if (q == "\"" || q == "\047") { e = index(substr(a, 2), q) tail = (e > 0) ? substr(a, e + 2) : "" stage = q stage q } else { # Keep an inline comment and the spaces before it; drop the old value. h = index(a, " #"); tail = "" if (h > 0) { tok = substr(a, 1, h - 1); sub(/[ \t]+$/, "", tok); tail = substr(a, length(tok) + 1) } } raw[at] = pre lead stage tail (cr ? "\r" : "") } } else if (projkind == "map") { action = "inserted"; at = projline ci = indent[projline] + 2 for (L = projline + 1; L indent[projline]) ci = indent[L] break } pad = ""; for (i = 0; i 0) { last = fwline for (L = fwline + 1; L 0 && kind[last] != "blank") ? eol "\n" : "" ins[last] = sep "project:" eol "\n stage: " stage eol at = last + (sep != "" ? 3 : 2) } for (L = 1; L 1 ? stlines : "") > ENVIRON["STATUS"] } ' project.yaml > "$Y_NEW" IFS="$(printf '\t')" read -r Y_ACTION Y_LINE Y_DUPS /dev/null rm -f "$Y_STATUS" if [ "${Y_ACTION:-}" = "unsupported" ]; then rm -f "$Y_NEW" echo "set-stage: project.yaml line $Y_LINE sets \`project:\` to a one-line value, so it has no \`stage:\` to edit." >&2 echo " Expected layout: a block, \`project:\` on its own line and \` stage: \` under it. Nothing was written." >&2 exit 5 fi [ -n "${Y_ACTION:-}" ] || { rm -f "$Y_NEW"; echo "set-stage: could not parse project.yaml; nothing was written" >&2; exit 1; } if [ -n "${Y_DUPS:-}" ]; then echo "set-stage: project.stage is set more than once in project.yaml (lines $Y_DUPS); line $Y_LINE is the one readers use, so that is the one edited" >&2 fi [ "$Y_ACTION" = "unchanged" ] && { rm -f "$Y_NEW"; Y_NEW=""; } fi # --- production/stage.txt -------------------------------------------------- T_ACTION="written" if [ -f production/stage.txt ] && printf '%s' "$STAGE" | cmp -s - production/stage.txt; then T_ACTION="unchanged" fi if [ "$DRY" = 1 ]; then [ -n "$Y_NEW" ] && rm -f "$Y_NEW" printf 'project.yaml\t%s\t%s\nstage.txt\t%s\t-\n' "$Y_ACTION" "$Y_LINE" "$T_ACTION" exit 0 fi [ -n "$Y_NEW" ] && replace_file project.yaml "$Y_NEW" if [ "$T_ACTION" = "written" ]; then mkdir -p production 2>/dev/null T_NEW=$(mktemp "production/.ccgs-stage-XXXXXX" 2>/dev/null) || { echo "set-stage: cannot create a temporary file in production/" >&2; exit 1; } printf '%s' "$STAGE" > "$T_NEW" || { rm -f "$T_NEW"; echo "set-stage: could not write production/stage.txt" >&2; exit 1; } replace_file production/stage.txt "$T_NEW" fi # --- read back through the one reader ------------------------------------- BACK=$(bash "$HERE/project-state.sh" --stage --root .) SY=$(printf '%s\n' "$BACK" | awk -F'\t' '$1 == "stage_yaml" { print $2 }') ST=$(printf '%s\n' "$BACK" | awk -F'\t' '$1 == "stage_txt" { print $2 }') printf 'project.yaml\t%s\t%s\nstage.txt\t%s\t-\n' "$Y_ACTION" "$Y_LINE" "$T_ACTION" printf 'stage_yaml\t%s\tproject.yaml\nstage_txt\t%s\tproduction/stage.txt\n' "$SY" "$ST" if [ "$SY" != "$STAGE" ] || [ "$ST" != "$STAGE" ]; then echo "set-stage: after writing, project.yaml reads '$SY' and production/stage.txt reads '$ST', not '$STAGE'." >&2 echo " A split stage misleads every reader; fix the file that differs before going on." >&2 exit 4 fi exit 0 ``` ## Root cause The script rewrites project.yaml in place before updating production/stage.txt and performs no rollback. When the second write fails, replace_file exits immediately, leaving the two stage sources inconsistent and its temporary file behind; the split-stage check that follows is never reached. ## Suggested fix Write production/stage.txt first, or back up project.yaml and roll it back on failure. At minimum, execute the split-stage check before exiting, remove the .ccgs-stage-* temporary file, and report the inconsistent state explicitly. ## Impact A permission or I/O failure places the repository in a state where project.yaml records the new stage while production/stage.txt records the old one. Subsequent readers resolve the stage from project.yaml and proceed with an incorrect stage, and the inconsistency is not reported.