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
39 changes: 30 additions & 9 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ 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 @@ -50,6 +52,21 @@ 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

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; }

# Isolates concurrent primary-tree edits and stale .build state. Trust in the
# compiler/build plugins and reproducible-toolchain attestation are separate.

# Script-pipeline parity gate (PsychQuant/macdoc#167). The ungated
# ScriptPipelineParityTests always run here; the CLI cross-check + JPA
# coverage parity tests additionally run when their env gates are present.
Expand All @@ -61,14 +78,19 @@ echo "→ [0.5/7] pre-flight: script-pipeline parity tests"
if [[ -z "${MACDOC_TEMPLATE_DIR:-}" || -z "${MACDOC_CLI_PATH:-}" ]]; then
echo " ⚠ gated cross-check will SKIP — set MACDOC_TEMPLATE_DIR + MACDOC_CLI_PATH to run the full MCP↔CLI byte-equal parity check (ungated parity tests still run below)." >&2
fi
swift test --filter ScriptPipelineParityTests \
swift test --package-path "$BUILD_TREE" --filter ScriptPipelineParityTests \
|| { echo "error: script-pipeline parity tests failed — refusing to release a binary whose MCP tools drifted from the CLI" >&2; exit 3; }

echo "→ [1/7] universal release build"
swift build -c release --arch arm64 --arch x86_64
BIN=".build/apple/Products/Release/$BINARY_NAME"
echo "→ [1/7] universal release build from isolated commit $SOURCE_HEAD"
(cd "$BUILD_TREE" && swift build -c release --arch arm64 --arch x86_64)
BIN="$BUILD_TREE/.build/apple/Products/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 @@ -81,7 +103,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 @@ -97,11 +118,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 universal binary (arm64 + x86_64). Released via scripts/release.sh (pre-upload signature gate, PsychQuant/macdoc#119)." \
--notes "Developer ID signed + Apple notarized universal binary (arm64 + x86_64) built from commit $SOURCE_HEAD. Released via scripts/release.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)"
158 changes: 158 additions & 0 deletions scripts/tests/release-source-stability.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/bin/bash

set -euo pipefail

ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
SOURCE_SCRIPT="$ROOT/scripts/release.sh"
TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/release-source-test.XXXXXX")
trap 'rm -rf "$TEST_ROOT"' EXIT

BINARY_NAME=$(sed -n 's/^BINARY_NAME="\([^"]*\)"/\1/p' "$SOURCE_SCRIPT" | head -1)
TEST_VERSION=$(grep -h 'static let serverVersion' "$ROOT"/Sources/*/Server.swift 2>/dev/null \
| sed -n 's/.*"\([0-9][^"]*\)".*/\1/p' | head -1 || true)
[ -n "$TEST_VERSION" ] || TEST_VERSION=9.9.9

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.sh"
echo original > "$REPO/source.txt"
echo '.build/' > "$REPO/.gitignore"
SERVER_FILE=$(grep -l 'static let serverVersion' "$ROOT"/Sources/*/Server.swift 2>/dev/null | head -1 || true)
if [ -n "$SERVER_FILE" ]; then
SERVER_RELATIVE=${SERVER_FILE#"$ROOT"/}
mkdir -p "$REPO/$(dirname "$SERVER_RELATIVE")"
cp "$SERVER_FILE" "$REPO/$SERVER_RELATIVE"
fi

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 .
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
if [ "${1:-}" = "test" ]; then exit 0; fi
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/apple/Products/Release
cat > ".build/apple/Products/Release/$BINARY_NAME" <<'BIN'
#!/bin/bash
echo test-binary
BIN
chmod +x ".build/apple/Products/Release/$BINARY_NAME"
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 x86_64'
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" BINARY_NAME="$BINARY_NAME" MUTATION_MODE="$1" PRIMARY_REPO="$REPO" PATH="$FAKE_PATH:$PATH" \
bash scripts/release.sh "$TEST_VERSION"
) >"$TEST_ROOT/output-$1.log" 2>&1
RELEASE_RC=$?
set -e
}

assert_no_release_side_effects() {
if grep -q '^codesign$\|notarytool submit\|^gh-release-create:' "$EVENT_LOG"; then
echo "FAIL: signing/notarization/upload ran after source drift" >&2
cat "$EVENT_LOG" >&2
exit 1
fi
}

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
}
assert_no_release_side_effects
grep -q '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 ]] || {
echo "FAIL: clean release should complete in harness; got $RELEASE_RC" >&2
cat "$TEST_ROOT/output-none.log" >&2
exit 1
}
grep -q '^codesign$' "$EVENT_LOG"
grep -q "^gh-release-create:.*--target $BASELINE_HEAD" "$EVENT_LOG"

run_release head
[[ "$RELEASE_RC" -eq 3 ]] || {
echo "FAIL: HEAD change must stop release with exit 3; got $RELEASE_RC" >&2
cat "$TEST_ROOT/output-head.log" >&2
exit 1
}
assert_no_release_side_effects
grep -q 'tree changed during the build' "$TEST_ROOT/output-head.log"

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