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
2 changes: 2 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ ship 승인은 배포하겠다는 결정이지 배포 자체가 아닙니다. `s

ship 승인이 묶는 것은 리뷰가 본 프로젝트 소스 전체 스냅샷입니다. 추적 중인 모든 파일과 git이 무시하지 않는 모든 미추적 파일에서 `.sdlc/`를 뺀 집합을, 경로·내용·실행 권한 비트까지 함께 묶습니다. 그 바이트 그대로 스테이징하거나 커밋하는 것은 묶음을 깨지 않고, 리뷰 시점에 이미 커밋되어 있던 작업도 함께 묶입니다. 반면 리뷰 후의 수정, 새 파일 추가, 삭제, chmod, 심볼릭 링크 교체는 묶음을 깹니다. 리뷰가 이름을 대지 않은 파일이라도 마찬가지입니다. `check-gate.sh`, `status.sh`, `close.sh`가 같은 표현으로 알리고 바뀐 파일을 지목합니다. 예전 킷이 남긴 ship 승인은 커밋되지 않은 diff만 묶었으므로, 그 사실을 밝히며 닫힌 상태로 실패합니다. 서브모듈 내용은 묶이지 않습니다. git이 C-quote로 감싸 출력하는 경로명 — 탭, 개행, 큰따옴표, 백슬래시가 든 이름 — 은 묶을 수 없습니다. `approve.sh ship`은 그 이름을 지목하며 승인을 거부하고, 리뷰 뒤에 그런 파일이 생기면 이름을 바꾸거나 무시 목록에 넣을 때까지 게이트를 invalid source로 닫습니다. 유니코드와 공백이 든 이름은 정상 동작합니다.

실행 권한은 Git의 `core.filemode` 설정에 따라 판단합니다. Windows Git Bash처럼 값이 `false`이면 추적 중인 파일은 Git 인덱스의 실행 권한을 사용하고 새 파일은 실행 권한이 없는 것으로 처리합니다. 실행 파일로 지정하려면 리뷰 전에 `git add --chmod=+x` 또는 `git update-index --chmod=+x`를 사용하세요. 리뷰 후 인덱스의 실행 권한을 바꾸면 승인이 무효화됩니다. `core.filemode=true`인 환경에서는 파일 시스템의 chmod 변경을 직접 검사합니다.

그 전에 검증은 실제 동작을 돌립니다. 바뀐 동작을 사용자나 호출자가 실제로 만나는 인터페이스로 끝까지 실행하되, 변경 범위에 맞춰 프로젝트 자신의 명령(`.sdlc/config.md`의 `e2e:`, `qa:`, `run:`)을 씁니다. 실행할 환경이 없으면 NOT VERIFIED이며 evidence.md에 그렇게 적습니다. 통과한 단위 테스트가 조용한 대체물이 되는 일은 없습니다.

### 실패한 실행도 지식을 남긴다
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ The ship approval decides to release; it is not a release. Closing as `shipped`

The ship approval binds the project's whole source snapshot as the review saw it — every tracked file plus every untracked file git does not ignore, minus `.sdlc/`, by path, content, and executable bit. Staging or committing those exact bytes keeps the binding valid, and work that was already committed when the review ran is bound too. An edit, a new file, a deletion, a chmod, or a symlink swap afterwards breaks it, including in a file the review did not name — `check-gate.sh`, `status.sh`, and `close.sh` report it in the same words and name the files that changed. Ship approvals written by an older kit bound only the uncommitted diff and fail closed, saying so. Submodule contents are not bound. A path name git C-quotes — one containing a tab, a newline, a double quote, or a backslash — cannot be bound: `approve.sh ship` refuses it by name, and one that appears after the review closes the gate as an invalid source until it is renamed or ignored. Unicode and spaces in names are fine.

Executable bits follow Git's `core.filemode` setting. When it is `false`, as on Git Bash for Windows, the snapshot uses the index mode for tracked files and treats new files as non-executable. Use `git add --chmod=+x` or `git update-index --chmod=+x` before review to mark an executable; changing that index mode after review invalidates approval. With `core.filemode=true`, filesystem chmod changes are checked directly.

Before any of that, verification runs the real thing: the changed behavior exercised end to end through the interface a user or caller actually meets, scoped to the change, with the project's own commands (`e2e:`, `qa:`, `run:` in `.sdlc/config.md`). No environment to run it in means NOT VERIFIED, stated as such in evidence.md — a green unit suite is never a silent substitute.

### Failed runs leave knowledge
Expand Down
22 changes: 19 additions & 3 deletions gates/_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,18 @@ sdlc_source_entries() { # paths on stdin → entry lines (sorted); non-zero on a
return $rc
}
sdlc__entries_unsorted() { # helper of sdlc_source_entries
local f h t rc=0
local f h t mode filemode indexed_exec="" raw rc=0
filemode=$(git config --bool core.filemode 2>/dev/null) || filemode=true
if [ "$filemode" = false ]; then
# Git Bash's -x result is not the mode Git will commit. Follow the index
# on filesystems where Git does not trust executable bits; new files are
# non-executable until explicitly staged with --chmod=+x.
raw=$(git -c core.quotepath=off ls-files --stage 2>/dev/null) || {
echo "FAIL: git ls-files could not read source modes" >&2; return 1; }
indexed_exec="
$(printf '%s\n' "$raw" | awk '$1 == "100755" { sub(/^[^\t]*\t/, ""); print }')
"
fi
while IFS= read -r f; do
[ -n "$f" ] || continue
case "$f" in (\"*) printf 'unsupported - - %s\n' "$f"; continue;; esac # git C-quoted it
Expand All @@ -146,8 +157,13 @@ sdlc__entries_unsorted() { # helper of sdlc_source_entries
printf 'submodule - - %s\n' "$f"
elif [ -f "./$f" ]; then
if h=$(sdlc_sha256_file "./$f") && [ -n "$h" ]; then
if [ -x "./$f" ]; then printf 'f x %s %s\n' "$h" "$f"
else printf 'f - %s %s\n' "$h" "$f"; fi
mode=-
if [ "$filemode" = false ]; then
case "$indexed_exec" in (*"
$f
"*) mode=x;; esac
elif [ -x "./$f" ]; then mode=x; fi
printf 'f %s %s %s\n' "$mode" "$h" "$f"
else echo "FAIL: cannot hash file: $f" >&2; rc=1; fi
else
printf 'missing - - %s\n' "$f"
Expand Down
81 changes: 75 additions & 6 deletions gates/e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -548,10 +548,19 @@ else case "$out" in *"+ extra.sh"*) pass "C4d a file added after the review bloc
*) fail "C4d added file not reported" "$out";; esac; fi
rm -f extra.sh
# C4e an executable-bit flip is drift (same bytes, different program)
chmod +x gone.sh
if [ "$(git config --bool core.filemode)" = false ]; then
git add -- gone.sh
git update-index --chmod=+x -- gone.sh
else
chmod +x gone.sh
fi
assert_fail_msg "C4e a chmod after the review blocks 'shipped'" "the source changed after the ship review" \
sdlc close.sh neg-add shipped "done"
chmod -x gone.sh
if [ "$(git config --bool core.filemode)" = false ]; then
git update-index --chmod=-x -- gone.sh
else
chmod -x gone.sh
fi
# C4f replacing a file with a symlink is drift (same content through the link)
mv gone.sh gone.real
mklink gone.real gone.sh
Expand Down Expand Up @@ -804,8 +813,12 @@ QT=$(printf 'tab\tname.txt'); QN=$(printf 'new\nline.txt'); QQ='we"ird.txt'; QB=
mkdir -p .sdlc/work/neg-quoted
echo goal > .sdlc/work/neg-quoted/intent.md
echo ev > .sdlc/work/neg-quoted/evidence.md
# Win32 cannot represent these names literally; MSYS rewrites them. Keep the
# real filesystem cases on POSIX, and test an actual quoted Git tree everywhere.
quoted_worktree=true
case "$(uname -s)" in MINGW*|MSYS*) quoted_worktree=false;; esac
if $quoted_worktree; then
printf 'v1\n' > "$QT"; printf 'v1\n' > "$QN"; printf 'v1\n' > "$QQ"; printf 'v1\n' > "$QB"
printf 'plain\n' > '한글 and space.txt'
out=$(sdlc approve.sh ship .sdlc/work/neg-quoted/evidence.md --delegated 2>&1); rc=$?
if [ $rc -eq 0 ]; then fail "C16 ship approval bound a snapshot with git-quoted path names" "$out"
else
Expand All @@ -819,6 +832,10 @@ fi
assert_nofile .sdlc/approvals/neg-quoted.ship.approval "C16c no ship record is written when the snapshot is refused"
assert_nofile .sdlc/approvals/neg-quoted.ship.source "C16d no partial source snapshot is left behind"
rm -f "$QT" "$QN" "$QQ" "$QB"
else
echo "SKIP C16-C16d literal quoted filenames are not representable on Win32"
fi
printf 'plain\n' > '한글 and space.txt'
assert_ok_msg "C16e with those names gone the same source binds (Unicode + space path kept)" "APPROVED" \
sdlc approve.sh ship .sdlc/work/neg-quoted/evidence.md --delegated
assert_grep .sdlc/approvals/neg-quoted.ship.source '^f - [0-9a-f]* 한글 and space.txt$' "C16f the Unicode/space path is bound by content, not quoted"
Expand All @@ -831,6 +848,7 @@ cat > .sdlc/work/neg-quoted/delivery.md <<EOF
- Evidence: ok
- Confirmed: yes
EOF
if $quoted_worktree; then
printf 'v2 EVIL\n' > "$QQ" # an unsupported name ADDED after the review
assert_fail_msg "C16g a git-quoted file added after the review closes the ship gate as invalid source" \
"cannot bind" sdlc check-gate.sh ship .sdlc/work/neg-quoted/evidence.md
Expand All @@ -844,11 +862,17 @@ if [ $rc -eq 0 ]; then fail "C16j close accepted 'shipped' with an unbindable pa
else case "$out" in *"cannot bind"*'"we\"ird.txt"'*) pass "C16j close blocks and names the unsupported path";;
*) fail "C16j close blocked, but did not name the unsupported path" "$out";; esac; fi
rm -f "$QQ"
else
echo "SKIP C16g-C16j literal quoted filenames are not representable on Win32"
fi
# C16k a pr Source commit whose TREE has a git-quoted path is refused with its
# own reason (not compared against a shorter list, not called 'does not CONTAIN')
printf 'v1\n' > "$QQ"; git add -A .; git commit -qm "feat: a quoted name lands in a commit"
QCOMMIT=$(git rev-parse HEAD)
git rm -q --cached "$QQ"; rm -f "$QQ"; git commit -qm "chore: and is removed again"
# Build the tree directly, without asking the host filesystem to store the name.
git add -A .; git commit -qm "feat: Unicode source fixture"
QBASE=$(git rev-parse HEAD)
QBLOB=$(printf 'v1\n' | git hash-object -w --stdin)
QTREE=$({ git ls-tree -z HEAD; printf '100644 blob %s\t%s\0' "$QBLOB" "$QQ"; } | git mktree -z)
QCOMMIT=$(printf 'quoted path fixture\n' | git commit-tree "$QTREE" -p "$QBASE")
sed "s|^- Target: .*|- Target: pr|; s|^- Source: .*|- Source: $QCOMMIT|" .sdlc/work/neg-quoted/delivery.md > d.tmp
mv d.tmp .sdlc/work/neg-quoted/delivery.md
assert_fail_msg "C16k a delivered commit containing a git-quoted path is refused explicitly" \
Expand Down Expand Up @@ -899,6 +923,51 @@ else
fail "C14 fixture could not return to $BASE_BRANCH"
fi

# C18 reproduce Windows mode semantics on every runner. The filesystem's -x
# result must not override Git's index when core.filemode=false.
MODE_PROJECT="$FIX/proj-filemode"; mkdir -p "$MODE_PROJECT"; cd "$MODE_PROJECT"
gitinit
git config core.filemode false
printf '#!/bin/sh\necho ok\n' > 'new [script].sh'
chmod +x 'new [script].sh'
printf 'tracked\n' > 'tracked script.sh'
git add -- 'tracked script.sh'
git update-index --chmod=+x -- 'tracked script.sh'
git commit -qm "init: explicit executable index mode"
# On POSIX, force the opposite filesystem mode to prove the index is used.
chmod -x 'tracked script.sh'
mkdir -p .sdlc/work/modes
printf 'goal\n' > .sdlc/work/modes/intent.md
printf 'evidence\n' > .sdlc/work/modes/evidence.md
assert_ok "C18 non-POSIX mode source binds" sdlc approve.sh ship .sdlc/work/modes/evidence.md --delegated
assert_grep .sdlc/approvals/modes.ship.source '^f - .* new \[script\].sh$' "C18a untracked executable defaults to Git mode 100644"
assert_grep .sdlc/approvals/modes.ship.source '^f x .* tracked script.sh$' "C18b tracked executable uses index mode 100755"
git add -- 'new [script].sh'
assert_ok_msg "C18c staging the same source keeps the gate open" "GATE OPEN" \
sdlc check-gate.sh ship .sdlc/work/modes/evidence.md
git update-index --chmod=+x -- 'new [script].sh'
assert_fail_msg "C18d changing the index executable bit is drift" "the source changed after the ship review" \
sdlc check-gate.sh ship .sdlc/work/modes/evidence.md
git update-index --chmod=-x -- 'new [script].sh'
printf 'edited\n' >> 'tracked script.sh'
assert_fail_msg "C18e content drift is still rejected with core.filemode=false" "the source changed after the ship review" \
sdlc check-gate.sh ship .sdlc/work/modes/evidence.md
printf 'tracked\n' > 'tracked script.sh'
git commit -qm "feat: reviewed script"
MODE_SHA=$(git rev-parse HEAD)
assert_ok_msg "C18f committing the same source keeps the gate open" "GATE OPEN" \
sdlc check-gate.sh ship .sdlc/work/modes/evidence.md
cat > .sdlc/work/modes/delivery.md <<EOF
# Delivery: modes
- Target: pr
- Source: $MODE_SHA
- Verified-by: sh 'new [script].sh'
- Evidence: ok
- Confirmed: yes
EOF
assert_ok_msg "C18g the commit contains the reviewed non-POSIX source" "delivery: pr" \
sdlc close.sh modes shipped "reviewed source delivered"

echo
echo "================================================================"
echo "PASSED: $PASSED FAILED: $FAILED"
Expand Down
Loading