Skip to content
Open
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
42 changes: 34 additions & 8 deletions scripts/release-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +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_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
Expand All @@ -54,11 +58,34 @@ 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; }

# 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"
[[ -f "$BIN" ]] || { echo "error: built binary not found at $BIN" >&2; exit 4; }

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"

Expand All @@ -71,7 +98,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" \
Expand All @@ -87,11 +113,11 @@ 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 "$(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)"
140 changes: 140 additions & 0 deletions scripts/tests/release-source-stability.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/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 ;;
primary) echo changed-in-primary-tree >> "$PRIMARY_REPO/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" PRIMARY_REPO="$REPO" 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 '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
[[ "$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 'build tree changed during the build' "$TEST_ROOT/output-head.log"

echo "PASS: release refuses source/HEAD mutation before signing and pins release target"
Loading