From bcd6d0060452ba098850ce11c6aacc4d0ca0eb25 Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 24 Aug 2026 05:12:20 +0800 Subject: [PATCH 1/3] fix: stop release when source changes during build (#163) --- scripts/release-cli.sh | 10 +- scripts/tests/release-source-stability.sh | 130 ++++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 scripts/tests/release-source-stability.sh diff --git a/scripts/release-cli.sh b/scripts/release-cli.sh index f41a8964..e6e6bcd3 100755 --- a/scripts/release-cli.sh +++ b/scripts/release-cli.sh @@ -44,6 +44,7 @@ xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ || { echo "error: notary profile '$NOTARY_PROFILE' unusable — run: xcrun notarytool store-credentials $NOTARY_PROFILE (interactive, user-only)" >&2; exit 3; } [[ -z "$(git status --porcelain)" ]] \ || { echo "error: working tree not clean (including untracked files — they could leak into the build) — commit, stash, or clean first" >&2; exit 3; } +SOURCE_HEAD=$(git rev-parse HEAD) if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null 2>&1; then echo "error: local tag v$VERSION already exists" >&2; exit 3 fi @@ -59,6 +60,11 @@ swift build -c release BIN=".build/release/$BINARY_NAME" [[ -f "$BIN" ]] || { echo "error: built binary not found at $BIN" >&2; exit 4; } +# SOURCE GATE — the build must still correspond to the clean commit captured +# at step 0. Refuse before codesign so unknown bytes never receive Developer ID. +[[ "$(git rev-parse HEAD)" == "$SOURCE_HEAD" && -z "$(git status --porcelain)" ]] \ + || { echo "error: working tree changed during the build — refusing to sign bytes that may not correspond to commit $SOURCE_HEAD" >&2; exit 3; } + echo "→ [2/7] codesign (Developer ID, hardened runtime, timestamp)" codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID" "$BIN" @@ -89,9 +95,9 @@ codesign --verify --strict -R "$REQUIREMENT" "$WORKDIR/$BINARY_NAME" \ echo "→ [7/7] gh release create (creates tag v$VERSION at HEAD — no pre-pushed tag, so a create failure leaves no dead-end state)" gh release create "v$VERSION" --repo "$REPO" \ - --target "$(git rev-parse HEAD)" \ + --target "$SOURCE_HEAD" \ --title "v$VERSION" \ - --notes "Developer ID signed + Apple notarized arm64 binary (CLI depends on MLX — Apple Silicon only; Intel builds from source). Released via scripts/release-cli.sh (pre-upload signature gate, PsychQuant/macdoc#119)." \ + --notes "Developer ID signed + Apple notarized arm64 binary built from commit $SOURCE_HEAD (CLI depends on MLX — Apple Silicon only; Intel builds from source). Released via scripts/release-cli.sh (source-stability + pre-upload signature gates, PsychQuant/macdoc#119)." \ "$WORKDIR/$BINARY_NAME" "$WORKDIR/$BINARY_NAME.sha256" echo "✓ released $BINARY_NAME v$VERSION (signed, notarized, gated, sha256 attached)" diff --git a/scripts/tests/release-source-stability.sh b/scripts/tests/release-source-stability.sh new file mode 100644 index 00000000..9e4523fa --- /dev/null +++ b/scripts/tests/release-source-stability.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +SOURCE_SCRIPT="$ROOT/scripts/release-cli.sh" +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/macdoc-release-source-test.XXXXXX") +trap 'rm -rf "$TEST_ROOT"' EXIT + +REPO="$TEST_ROOT/repo" +FAKE_PATH="$TEST_ROOT/fake-path" +EVENT_LOG="$TEST_ROOT/events.log" +mkdir -p "$REPO/scripts" "$FAKE_PATH" +cp "$SOURCE_SCRIPT" "$REPO/scripts/release-cli.sh" +echo original > "$REPO/source.txt" +echo '.build/' > "$REPO/.gitignore" + +git -C "$REPO" init -q +git -C "$REPO" config user.name test +git -C "$REPO" config user.email test@example.invalid +git -C "$REPO" add .gitignore scripts/release-cli.sh source.txt +git -C "$REPO" commit -qm baseline +BASELINE_HEAD=$(git -C "$REPO" rev-parse HEAD) +git init -q --bare "$TEST_ROOT/origin.git" +git -C "$REPO" remote add origin "$TEST_ROOT/origin.git" + +cat > "$FAKE_PATH/git" <<'EOF' +#!/bin/bash +if [ "${1:-}" = "ls-remote" ]; then exit 0; fi +exec /usr/bin/git "$@" +EOF + +cat > "$FAKE_PATH/swift" <<'EOF' +#!/bin/bash +echo swift-build >> "$EVENT_LOG" +case "${MUTATION_MODE:-none}" in + file) echo changed-during-build >> source.txt ;; + head) + echo committed-during-build >> source.txt + /usr/bin/git add source.txt + /usr/bin/git commit -qm committed-during-build + ;; +esac +mkdir -p .build/release +cat > .build/release/macdoc <<'BIN' +#!/bin/bash +echo test-binary +BIN +chmod +x .build/release/macdoc +EOF + +cat > "$FAKE_PATH/codesign" <<'EOF' +#!/bin/bash +echo codesign >> "$EVENT_LOG" +exit 0 +EOF + +cat > "$FAKE_PATH/xcrun" <<'EOF' +#!/bin/bash +echo "xcrun:$*" >> "$EVENT_LOG" +if [ "${2:-}" = "submit" ]; then echo 'status: Accepted'; fi +exit 0 +EOF + +cat > "$FAKE_PATH/lipo" <<'EOF' +#!/bin/bash +echo arm64 +EOF + +cat > "$FAKE_PATH/ditto" <<'EOF' +#!/bin/bash +last="" +for last in "$@"; do :; done +: > "$last" +EOF + +cat > "$FAKE_PATH/gh" <<'EOF' +#!/bin/bash +if [ "${1:-}" = "release" ] && [ "${2:-}" = "view" ]; then exit 1; fi +if [ "${1:-}" = "release" ] && [ "${2:-}" = "create" ]; then + echo "gh-release-create:$*" >> "$EVENT_LOG" + exit 0 +fi +exit 1 +EOF + +chmod +x "$FAKE_PATH"/* + +run_release() { + : > "$EVENT_LOG" + set +e + ( + cd "$REPO" + EVENT_LOG="$EVENT_LOG" MUTATION_MODE="$1" PATH="$FAKE_PATH:$PATH" \ + bash scripts/release-cli.sh 9.9.9 + ) >"$TEST_ROOT/output-$1.log" 2>&1 + RELEASE_RC=$? + set -e +} + +run_release file +[[ "$RELEASE_RC" -eq 3 ]] || { + echo "FAIL: source mutation must stop release with exit 3; got $RELEASE_RC" >&2 + cat "$TEST_ROOT/output-file.log" >&2 + exit 1 +} + +if grep -q '^codesign$\|notarytool submit\|^gh-release-create:' "$EVENT_LOG"; then + echo "FAIL: signing/notarization/upload ran after source mutation" >&2 + cat "$EVENT_LOG" >&2 + exit 1 +fi +grep -q 'working tree changed during the build' "$TEST_ROOT/output-file.log" +/usr/bin/git -C "$REPO" checkout -q -- source.txt + +run_release none +[[ "$RELEASE_RC" -eq 0 ]] +grep -q '^codesign$' "$EVENT_LOG" +grep -q "^gh-release-create:.*--target $BASELINE_HEAD" "$EVENT_LOG" + +run_release head +[[ "$RELEASE_RC" -eq 3 ]] +if grep -q '^codesign$\|notarytool submit\|^gh-release-create:' "$EVENT_LOG"; then + echo "FAIL: signing/notarization/upload ran after HEAD changed" >&2 + cat "$EVENT_LOG" >&2 + exit 1 +fi +grep -q 'working tree changed during the build' "$TEST_ROOT/output-head.log" + +echo "PASS: release refuses source/HEAD mutation before signing and pins release target" From 50dc46c6fa8914d0dfd6fac0fddd3f719cab37cd Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 24 Aug 2026 05:24:50 +0800 Subject: [PATCH 2/3] fix: build release from isolated source snapshot (#163) --- scripts/release-cli.sh | 38 ++++++++++++++++------- scripts/tests/release-source-stability.sh | 16 ++++++++-- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/scripts/release-cli.sh b/scripts/release-cli.sh index e6e6bcd3..18ad73ac 100755 --- a/scripts/release-cli.sh +++ b/scripts/release-cli.sh @@ -42,9 +42,12 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." echo "→ [0/7] pre-flight: notary profile alive?" xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ || { echo "error: notary profile '$NOTARY_PROFILE' unusable — run: xcrun notarytool store-credentials $NOTARY_PROFILE (interactive, user-only)" >&2; exit 3; } -[[ -z "$(git status --porcelain)" ]] \ +SOURCE_HEAD=$(git rev-parse HEAD) \ + || { echo "error: cannot resolve source HEAD" >&2; exit 3; } +SOURCE_STATUS=$(git status --porcelain) \ + || { echo "error: cannot inspect working tree status" >&2; exit 3; } +[[ -z "$SOURCE_STATUS" ]] \ || { echo "error: working tree not clean (including untracked files — they could leak into the build) — commit, stash, or clean first" >&2; exit 3; } -SOURCE_HEAD=$(git rev-parse HEAD) if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null 2>&1; then echo "error: local tag v$VERSION already exists" >&2; exit 3 fi @@ -55,15 +58,29 @@ if gh release view "v$VERSION" --repo "$REPO" >/dev/null 2>&1; then echo "error: release v$VERSION already exists on $REPO" >&2; exit 3 fi -echo "→ [1/7] release build (arm64 — MLX is Apple Silicon only)" -swift build -c release -BIN=".build/release/$BINARY_NAME" +BUILD_PARENT=$(mktemp -d) || { echo "error: cannot create isolated build directory" >&2; exit 3; } +BUILD_TREE="$BUILD_PARENT/source" +WORKDIR="" +cleanup() { + if [ -d "$BUILD_TREE" ]; then git worktree remove --force "$BUILD_TREE" >/dev/null 2>&1 || true; fi + rm -rf "$BUILD_PARENT" + if [ -n "$WORKDIR" ]; then rm -rf "$WORKDIR"; fi +} +trap cleanup EXIT +git worktree add --detach "$BUILD_TREE" "$SOURCE_HEAD" >/dev/null \ + || { echo "error: cannot materialize isolated build tree for $SOURCE_HEAD" >&2; exit 3; } + +echo "→ [1/7] release build from isolated commit $SOURCE_HEAD (arm64 — MLX is Apple Silicon only)" +(cd "$BUILD_TREE" && swift build -c release) +BIN="$BUILD_TREE/.build/release/$BINARY_NAME" [[ -f "$BIN" ]] || { echo "error: built binary not found at $BIN" >&2; exit 4; } -# SOURCE GATE — the build must still correspond to the clean commit captured -# at step 0. Refuse before codesign so unknown bytes never receive Developer ID. -[[ "$(git rev-parse HEAD)" == "$SOURCE_HEAD" && -z "$(git status --porcelain)" ]] \ - || { echo "error: working tree changed during the build — refusing to sign bytes that may not correspond to commit $SOURCE_HEAD" >&2; exit 3; } +BUILD_HEAD_AFTER=$(git -C "$BUILD_TREE" rev-parse HEAD) \ + || { echo "error: cannot re-read isolated build HEAD" >&2; exit 3; } +BUILD_STATUS_AFTER=$(git -C "$BUILD_TREE" status --porcelain) \ + || { echo "error: cannot inspect isolated build tree status" >&2; exit 3; } +[[ "$BUILD_HEAD_AFTER" == "$SOURCE_HEAD" && -z "$BUILD_STATUS_AFTER" ]] \ + || { echo "error: isolated build tree changed during the build — refusing to sign bytes that may not correspond to commit $SOURCE_HEAD" >&2; exit 3; } echo "→ [2/7] codesign (Developer ID, hardened runtime, timestamp)" codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID" "$BIN" @@ -77,7 +94,6 @@ ARCHS=" $(lipo -archs "$BIN" 2>/dev/null) " echo "→ [4/7] notarize (must be Accepted)" WORKDIR=$(mktemp -d) -trap 'rm -rf "$WORKDIR"' EXIT ditto -c -k --keepParent "$BIN" "$WORKDIR/$BINARY_NAME.zip" NOTARY_OUT=$(xcrun notarytool submit "$WORKDIR/$BINARY_NAME.zip" --keychain-profile "$NOTARY_PROFILE" --wait 2>&1) echo "$NOTARY_OUT" | grep -q "status: Accepted" \ @@ -93,7 +109,7 @@ codesign --verify --strict -R "$REQUIREMENT" "$WORKDIR/$BINARY_NAME" \ [[ "$(shasum -a 256 "$WORKDIR/$BINARY_NAME" | awk '{print $1}')" == "$(cat "$WORKDIR/$BINARY_NAME.sha256")" ]] \ || { echo "error: FINAL GATE FAILED — sha256 asset does not match upload artifact" >&2; exit 5; } -echo "→ [7/7] gh release create (creates tag v$VERSION at HEAD — no pre-pushed tag, so a create failure leaves no dead-end state)" +echo "→ [7/7] gh release create (creates tag v$VERSION at source commit $SOURCE_HEAD)" gh release create "v$VERSION" --repo "$REPO" \ --target "$SOURCE_HEAD" \ --title "v$VERSION" \ diff --git a/scripts/tests/release-source-stability.sh b/scripts/tests/release-source-stability.sh index 9e4523fa..fb936d7d 100644 --- a/scripts/tests/release-source-stability.sh +++ b/scripts/tests/release-source-stability.sh @@ -35,6 +35,7 @@ cat > "$FAKE_PATH/swift" <<'EOF' echo swift-build >> "$EVENT_LOG" case "${MUTATION_MODE:-none}" in file) echo changed-during-build >> source.txt ;; + primary) echo changed-in-primary-tree >> "$PRIMARY_REPO/source.txt" ;; head) echo committed-during-build >> source.txt /usr/bin/git add source.txt @@ -91,7 +92,7 @@ run_release() { set +e ( cd "$REPO" - EVENT_LOG="$EVENT_LOG" MUTATION_MODE="$1" PATH="$FAKE_PATH:$PATH" \ + EVENT_LOG="$EVENT_LOG" MUTATION_MODE="$1" PRIMARY_REPO="$REPO" PATH="$FAKE_PATH:$PATH" \ bash scripts/release-cli.sh 9.9.9 ) >"$TEST_ROOT/output-$1.log" 2>&1 RELEASE_RC=$? @@ -110,7 +111,16 @@ if grep -q '^codesign$\|notarytool submit\|^gh-release-create:' "$EVENT_LOG"; th cat "$EVENT_LOG" >&2 exit 1 fi -grep -q 'working tree changed during the build' "$TEST_ROOT/output-file.log" +grep -q 'build tree changed during the build' "$TEST_ROOT/output-file.log" +/usr/bin/git -C "$REPO" checkout -q -- source.txt + +run_release primary +[[ "$RELEASE_RC" -eq 0 ]] || { + echo "FAIL: isolated release should ignore concurrent primary-tree edits; got $RELEASE_RC" >&2 + cat "$TEST_ROOT/output-primary.log" >&2 + exit 1 +} +grep -q "^gh-release-create:.*--target $BASELINE_HEAD" "$EVENT_LOG" /usr/bin/git -C "$REPO" checkout -q -- source.txt run_release none @@ -125,6 +135,6 @@ if grep -q '^codesign$\|notarytool submit\|^gh-release-create:' "$EVENT_LOG"; th cat "$EVENT_LOG" >&2 exit 1 fi -grep -q 'working tree changed during the build' "$TEST_ROOT/output-head.log" +grep -q 'build tree changed during the build' "$TEST_ROOT/output-head.log" echo "PASS: release refuses source/HEAD mutation before signing and pins release target" From 2d7bfd077ee6247fa52632a0c50c4d62a6817c15 Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 24 Aug 2026 05:27:13 +0800 Subject: [PATCH 3/3] docs: state isolated release trust boundary (#163) --- scripts/release-cli.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/release-cli.sh b/scripts/release-cli.sh index 18ad73ac..6683b6ab 100755 --- a/scripts/release-cli.sh +++ b/scripts/release-cli.sh @@ -42,12 +42,12 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." echo "→ [0/7] pre-flight: notary profile alive?" xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ || { echo "error: notary profile '$NOTARY_PROFILE' unusable — run: xcrun notarytool store-credentials $NOTARY_PROFILE (interactive, user-only)" >&2; exit 3; } -SOURCE_HEAD=$(git rev-parse HEAD) \ - || { echo "error: cannot resolve source HEAD" >&2; exit 3; } SOURCE_STATUS=$(git status --porcelain) \ || { echo "error: cannot inspect working tree status" >&2; exit 3; } [[ -z "$SOURCE_STATUS" ]] \ || { echo "error: working tree not clean (including untracked files — they could leak into the build) — commit, stash, or clean first" >&2; exit 3; } +SOURCE_HEAD=$(git rev-parse HEAD) \ + || { echo "error: cannot resolve source HEAD" >&2; exit 3; } if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null 2>&1; then echo "error: local tag v$VERSION already exists" >&2; exit 3 fi @@ -70,6 +70,10 @@ trap cleanup EXIT git worktree add --detach "$BUILD_TREE" "$SOURCE_HEAD" >/dev/null \ || { echo "error: cannot materialize isolated build tree for $SOURCE_HEAD" >&2; exit 3; } +# Threat boundary: this isolates the artifact from concurrent edits in the +# primary working tree and stale .build state. Compiler/build-plugin trust and +# reproducible-toolchain attestation are separate supply-chain concerns. + echo "→ [1/7] release build from isolated commit $SOURCE_HEAD (arm64 — MLX is Apple Silicon only)" (cd "$BUILD_TREE" && swift build -c release) BIN="$BUILD_TREE/.build/release/$BINARY_NAME"