diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml index 7e0dd7cb..debc1b6f 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -7,7 +7,7 @@ on: jobs: build: runs-on: macos-15 - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v6 @@ -28,27 +28,17 @@ jobs: path: | Frameworks/GhosttyKit.xcframework Frameworks/ghostty-resources - key: ghosttykit-native-${{ steps.ghostty-sha.outputs.sha }} + Frameworks/.ghosttykit-provenance + key: ghosttykit-universal-v4-${{ hashFiles('ghosttykit-lock.json') }} - - name: Install Zig 0.15.2 + - name: Install verified Zig if: steps.cache.outputs.cache-hit != 'true' run: | set -euo pipefail - ZIG_REQUIRED="0.15.2" - if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then - echo "zig ${ZIG_REQUIRED} already installed" - else - echo "Installing zig ${ZIG_REQUIRED} from tarball" - curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz - tar xf /tmp/zig.tar.xz -C /tmp - sudo mkdir -p /usr/local/bin /usr/local/lib - sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig - sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig - fi - zig version + ZIG_BIN="$(bash scripts/install-verified-zig.sh --prefix "$RUNNER_TEMP/zig")" + echo "$ZIG_BIN" >> "$GITHUB_PATH" - name: Select Xcode - if: steps.cache.outputs.cache-hit != 'true' run: | set -euo pipefail if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then @@ -67,7 +57,10 @@ jobs: - name: Build GhosttyKit if: steps.cache.outputs.cache-hit != 'true' - run: bash scripts/build-ghostty.sh + run: bash scripts/build-ghostty.sh --universal + + - name: Verify universal GhosttyKit + run: bash scripts/verify-ghosttykit.sh - name: Upload GhosttyKit artifact uses: actions/upload-artifact@v7 @@ -76,4 +69,6 @@ jobs: path: | Frameworks/GhosttyKit.xcframework Frameworks/ghostty-resources + Frameworks/.ghosttykit-provenance + include-hidden-files: true retention-days: 7 diff --git a/.github/workflows/publish-ghosttykit.yml b/.github/workflows/publish-ghosttykit.yml new file mode 100644 index 00000000..a76a9822 --- /dev/null +++ b/.github/workflows/publish-ghosttykit.yml @@ -0,0 +1,213 @@ +name: Publish GhosttyKit + +on: + workflow_dispatch: + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: ghosttykit-publish + cancel-in-progress: false + +jobs: + publish: + environment: ghosttykit-publish + runs-on: macos-15 + timeout-minutes: 75 + steps: + - name: Checkout reviewed source and submodules + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + submodules: recursive + + - name: Load candidate contract and reject an existing tag + id: contract + run: | + set -euo pipefail + source scripts/ghosttykit-contract.sh + ghosttykit_load_contract ghosttykit-lock.json + [[ "$MORI_GHOSTTYKIT_ARTIFACT_STATE" == candidate ]] || { + echo "Publishing requires a candidate lock with null artifact digests." >&2 + exit 1 + } + set +e + remote_tags="$(git ls-remote --exit-code --tags origin "refs/tags/$MORI_GHOSTTYKIT_ARTIFACT_TAG")" + remote_status=$? + set -e + if [[ "$remote_status" -eq 0 && -n "$remote_tags" ]]; then + echo "Refusing to overwrite existing GhosttyKit tag $MORI_GHOSTTYKIT_ARTIFACT_TAG." >&2 + exit 1 + fi + [[ "$remote_status" -eq 2 ]] || { + echo "Could not establish whether GhosttyKit tag $MORI_GHOSTTYKIT_ARTIFACT_TAG exists." >&2 + exit 1 + } + echo "tag=$MORI_GHOSTTYKIT_ARTIFACT_TAG" >> "$GITHUB_OUTPUT" + echo "name=$MORI_GHOSTTYKIT_ARTIFACT_NAME" >> "$GITHUB_OUTPUT" + + - name: Fetch complete Ghostty source history + run: | + set -euo pipefail + if [[ "$(git -C vendor/ghostty rev-parse --is-shallow-repository)" == true ]]; then + git -C vendor/ghostty fetch --unshallow --tags + fi + source scripts/ghosttykit-contract.sh + ghosttykit_load_contract ghosttykit-lock.json + git -C vendor/ghostty cat-file -e "$MORI_GHOSTTYKIT_BASE_COMMIT^{commit}" + + - name: Install verified Zig + run: | + set -euo pipefail + ZIG_BIN="$(bash scripts/install-verified-zig.sh --prefix "$RUNNER_TEMP/zig")" + echo "$ZIG_BIN" >> "$GITHUB_PATH" + + - name: Select Xcode + run: | + set -euo pipefail + if [[ -d "/Applications/Xcode.app/Contents/Developer" ]]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)" + [[ -n "$XCODE_APP" ]] || { echo "No Xcode.app found under /Applications" >&2; exit 1; } + XCODE_DIR="$XCODE_APP/Contents/Developer" + fi + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + xcodebuild -version + + - name: Build and verify a clean universal GhosttyKit + run: | + set -euo pipefail + bash scripts/build-ghostty.sh --clean --universal + bash scripts/verify-ghosttykit.sh + + - name: Package GhosttyKit candidate + run: | + set -euo pipefail + bash scripts/package-ghosttykit.sh --output "$RUNNER_TEMP/ghosttykit-package" + + - name: Attest GhosttyKit archive + uses: actions/attest-build-provenance@v3 + with: + subject-path: ${{ runner.temp }}/ghosttykit-package/${{ steps.contract.outputs.name }} + + - name: Atomically reserve the release tag + id: tag + env: + GH_TOKEN: ${{ github.token }} + run: | + bash scripts/github-tag-reservation.sh --reserve \ + --repo "$GITHUB_REPOSITORY" \ + --tag "${{ steps.contract.outputs.tag }}" \ + --sha "$GITHUB_SHA" + echo "reserved=true" >> "$GITHUB_OUTPUT" + + - name: Verify reserved tag ownership + env: + GH_TOKEN: ${{ github.token }} + run: | + bash scripts/github-tag-reservation.sh --verify \ + --repo "$GITHUB_REPOSITORY" \ + --tag "${{ steps.contract.outputs.tag }}" \ + --sha "$GITHUB_SHA" + + - name: Create and upload draft release + id: draft + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + archive="$RUNNER_TEMP/ghosttykit-package/${{ steps.contract.outputs.name }}" + manifest="${archive%.zip}.manifest.json" + bash scripts/github-tag-reservation.sh --verify \ + --repo "$GITHUB_REPOSITORY" \ + --tag "${{ steps.contract.outputs.tag }}" \ + --sha "$GITHUB_SHA" + output="$(bash scripts/github-release-draft.sh --create-draft \ + --repo "$GITHUB_REPOSITORY" \ + --tag "${{ steps.contract.outputs.tag }}" \ + --target "$GITHUB_SHA" \ + --title "GhosttyKit ${{ steps.contract.outputs.tag }}" \ + --prerelease \ + --make-latest false \ + --require-immutable \ + --defer-asset-verification \ + --asset "$archive" \ + --asset "$manifest")" + release_id="${output#release_id=}" + [[ "$release_id" =~ ^[0-9]+$ ]] + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" + + - name: Verify server digests and uploaded bytes + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + archive="$RUNNER_TEMP/ghosttykit-package/${{ steps.contract.outputs.name }}" + manifest="${archive%.zip}.manifest.json" + bash scripts/github-release-draft.sh --verify-draft-assets \ + --repo "$GITHUB_REPOSITORY" \ + --release-id "${{ steps.draft.outputs.release_id }}" \ + --download-assets \ + --asset "$archive" \ + --asset "$manifest" + + - name: Publish immutable GhosttyKit release once + id: publish + env: + GH_TOKEN: ${{ github.token }} + run: | + bash scripts/github-release-draft.sh --publish-draft \ + --repo "$GITHUB_REPOSITORY" \ + --release-id "${{ steps.draft.outputs.release_id }}" \ + --require-immutable + + - name: Record lock values + if: success() + run: | + set -euo pipefail + archive="$RUNNER_TEMP/ghosttykit-package/${{ steps.contract.outputs.name }}" + manifest="${archive%.zip}.manifest.json" + archive_sha256="$(shasum -a 256 "$archive" | awk '{print $1}')" + framework_tree_sha256="$(python3 - "$manifest" <<'PY' + import json + import sys + with open(sys.argv[1], encoding="utf-8") as stream: + print(json.load(stream)["artifact"]["frameworkTreeSha256"]) + PY + )" + cat >> "$GITHUB_STEP_SUMMARY" <&2 + exit 1 + } - uses: softprops/action-gh-release@v2 - with: - files: | - Mori-*-macos-arm64.zip - Mori-*-macos-arm64.dmg - generate_release_notes: true + - name: Create and verify draft release + id: release-draft + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + output="$(bash scripts/github-release-draft.sh --create-draft \ + --repo "$GITHUB_REPOSITORY" \ + --tag "$GITHUB_REF_NAME" \ + --target "$GITHUB_SHA" \ + --title "Mori $VERSION" \ + --generate-notes \ + --make-latest true \ + --require-immutable \ + --asset "Mori-${VERSION}-macos-arm64.zip" \ + --asset "Mori-${VERSION}-macos-arm64.dmg")" + release_id="${output#release_id=}" + [[ "$release_id" =~ ^[0-9]+$ ]] + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" + + - name: Publish complete immutable release + id: release-publish + env: + GH_TOKEN: ${{ github.token }} + run: | + bash scripts/github-release-draft.sh --publish-draft \ + --repo "$GITHUB_REPOSITORY" \ + --release-id "${{ steps.release-draft.outputs.release_id }}" \ + --require-immutable + + - name: Delete incomplete draft release + if: failure() && steps.release-draft.outputs.release_id != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + bash scripts/github-release-draft.sh --cleanup-draft \ + --repo "$GITHUB_REPOSITORY" \ + --release-id "${{ steps.release-draft.outputs.release_id }}" - name: Cleanup keychain if: always() diff --git a/.gitmodules b/.gitmodules index e16f44e1..9f677b73 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "vendor/ghostty"] path = vendor/ghostty - url = https://github.com/ghostty-org/ghostty.git + url = https://github.com/h3nock/remux-ghostty.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 021e328b..0bd010c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 🔧 CI/CD + +- **GhosttyKit**: Build one universal macOS + iOS framework from the pinned remux Ghostty source and verify its provenance, platform slices, build mode, and tmux ABI before reuse. + ## [0.7.0] - 2026-07-31 ### ✨ Features diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 62e3051e..80836df6 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -7,6 +7,10 @@ ## [Unreleased] +### 🔧 CI/CD + +- **GhosttyKit**:从固定的 remux Ghostty 源码构建一份通用 macOS + iOS framework,并在复用前校验其来源、平台 slice、构建模式和 tmux ABI。 + ## [0.7.0] - 2026-07-31 ### ✨ 新功能 diff --git a/docs/architecture.md b/docs/architecture.md index 1f0ba89c..3a93fbe7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Pane pane tmux pane ID, e.g. %3 ## Terminal Rendering -`TerminalHost` protocol abstracts terminal backends. Primary implementation is `GhosttyAdapter` (libghostty — GPU-accelerated Metal rendering, native mouse/scroll/paste/IME). `NativeTerminalAdapter` (PTY via `forkpty()`) is kept as an emergency fallback. The GhosttyKit XCFramework is built from Ghostty source via `mise run build:ghostty` (requires Zig 0.15.2 + Xcode). +`TerminalHost` protocol abstracts terminal backends. Primary implementation is `GhosttyAdapter` (libghostty — GPU-accelerated Metal rendering, native mouse/scroll/paste/IME). `NativeTerminalAdapter` (PTY via `forkpty()`) is kept as an emergency fallback. GhosttyKit is built from the pinned remux Ghostty source: `mise run build:ghostty` builds the native macOS slice, while `mise run build:ghostty-universal` builds the shared macOS + iOS artifact (requires Zig 0.15.2 + Xcode). ## Persistence diff --git a/ghosttykit-lock.json b/ghosttykit-lock.json new file mode 100644 index 00000000..ca0b2305 --- /dev/null +++ b/ghosttykit-lock.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "source": { + "repository": "https://github.com/h3nock/remux-ghostty.git", + "commit": "aeb8f73790946d9c9ad175b3dafaec9911ef36bb", + "upstreamBaseCommit": "b213a72c03b427607b43c89ff4223a7baa079fe8" + }, + "build": { + "revision": 1, + "ghosttyVersion": "1.3.2-dev", + "zigVersion": "0.15.2", + "zigArchiveSha256": "3cc2bab367e185cdfb27501c4b30b1b0653c28d9f73df8dc91488e66ece5fa6b", + "optimize": "ReleaseFast", + "buildMode": 2, + "target": "universal", + "minimumIOSMajor": 17 + }, + "artifact": { + "repository": "vaayne/mori", + "tag": "ghosttykit-aeb8f737-r1", + "name": "GhosttyKit-aeb8f737-r1-universal.zip", + "sha256": null, + "frameworkTreeSha256": null + } +} diff --git a/mise.toml b/mise.toml index e008d507..953dd1b8 100644 --- a/mise.toml +++ b/mise.toml @@ -66,9 +66,25 @@ run = "cd Packages/MoriTerminal && swift run MoriTerminalTests" run = "cd Packages/MoriUI && swift run MoriUITests" [tasks."build:ghostty"] -description = "Build GhosttyKit XCFramework from source" +description = "Build the macOS GhosttyKit XCFramework from the pinned remux source" run = "bash scripts/build-ghostty.sh" +[tasks."build:ghostty-universal"] +description = "Build the universal macOS + iOS GhosttyKit XCFramework from the pinned remux source" +run = "bash scripts/build-ghostty.sh --universal" + +[tasks."test:ghosttykit-contract"] +description = "Run offline adversarial GhosttyKit lock and source-contract fixtures" +run = "bash scripts/tests/test-ghosttykit-contract.sh" + +[tasks."test:release-workflows"] +description = "Verify immutable-safe GitHub release draft flows offline" +run = "bash scripts/tests/test-release-workflows.sh" + +[tasks."test:ghosttykit-verify"] +description = "Prove the universal GhosttyKit verifier rejects framework tampering" +run = "bash scripts/tests/test-ghosttykit-verify.sh" + [tasks.bundle] description = "Build release and create Mori.app bundle" run = "bash scripts/bundle.sh" diff --git a/scripts/build-ghostty.sh b/scripts/build-ghostty.sh index 401f2b1c..3e52ddf4 100755 --- a/scripts/build-ghostty.sh +++ b/scripts/build-ghostty.sh @@ -9,79 +9,96 @@ set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=ghosttykit-contract.sh +source "$PROJECT_ROOT/scripts/ghosttykit-contract.sh" +ghosttykit_load_contract "$PROJECT_ROOT/ghosttykit-lock.json" GHOSTTY_DIR="$PROJECT_ROOT/vendor/ghostty" FRAMEWORK_DIR="$PROJECT_ROOT/Frameworks" XCFRAMEWORK="$FRAMEWORK_DIR/GhosttyKit.xcframework" RESOURCES_DIR="$FRAMEWORK_DIR/ghostty-resources" -repo_slug() { - local remote_url - remote_url="$(git -C "$PROJECT_ROOT" remote get-url origin 2>/dev/null || true)" - case "$remote_url" in - https://github.com/*) - remote_url="${remote_url#https://github.com/}" - remote_url="${remote_url%.git}" - ;; - git@github.com:*) - remote_url="${remote_url#git@github.com:}" - remote_url="${remote_url%.git}" - ;; - *) - return 1 - ;; - esac - printf '%s\n' "$remote_url" +has_valid_xcframework() { + [[ -f "$XCFRAMEWORK/Info.plist" ]] || return 1 + local static_lib + static_lib="$(find "$XCFRAMEWORK" -type f -name "*.a" -print -quit 2>/dev/null || true)" + [[ -n "$static_lib" ]] } -find_matching_ci_run() { - command -v gh >/dev/null 2>&1 || return 1 - - local repo ghostty_sha runs_json run_id run_sha run_ghostty_sha - repo="$(repo_slug)" || return 1 - ghostty_sha="$(git -C "$GHOSTTY_DIR" rev-parse HEAD)" - runs_json="$(gh run list --repo "$repo" --workflow CI --branch main --status success --limit 20 --json databaseId,headSha 2>/dev/null)" || return 1 +normalize_universal_xcframework() { + local macos_slice="$XCFRAMEWORK/macos-arm64_x86_64" + local canonical_library="libghostty-internal-fat.a" + [[ -d "$macos_slice" ]] || return 0 - while IFS=$'\t' read -r run_id run_sha; do - [[ -n "$run_id" && -n "$run_sha" ]] || continue - run_ghostty_sha="$(gh api "repos/$repo/contents/vendor/ghostty?ref=$run_sha" --jq '.sha' 2>/dev/null || true)" - if [[ "$run_ghostty_sha" == "$ghostty_sha" ]]; then - printf '%s\n' "$run_id" - return 0 - fi - done < <(printf '%s' "$runs_json" | jq -r '.[] | [.databaseId, .headSha] | @tsv') + if [[ -f "$macos_slice/ghostty-internal.a" ]]; then + mv "$macos_slice/ghostty-internal.a" "$macos_slice/$canonical_library" + fi + [[ -f "$macos_slice/$canonical_library" ]] || { + echo "Universal GhosttyKit is missing its macOS static library." >&2 + return 1 + } - return 1 + /usr/bin/python3 - "$XCFRAMEWORK/Info.plist" "$canonical_library" <<'PY' +import plistlib +import sys + +path, library_name = sys.argv[1:] +with open(path, "rb") as stream: + plist = plistlib.load(stream) +for library in plist.get("AvailableLibraries", []): + if library.get("LibraryIdentifier") == "macos-arm64_x86_64": + library["BinaryPath"] = library_name + library["LibraryPath"] = library_name + break +else: + raise SystemExit("Universal GhosttyKit is missing macOS metadata") +with open(path, "wb") as stream: + plistlib.dump(plist, stream, fmt=plistlib.FMT_XML, sort_keys=False) +PY } -restore_ci_artifact() { - command -v gh >/dev/null 2>&1 || return 1 - - local repo run_id tmp_dir - repo="$(repo_slug)" || return 1 - run_id="$(find_matching_ci_run)" || return 1 - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' RETURN - - echo "Downloading GhosttyKit artifact from CI run $run_id..." - gh run download "$run_id" --repo "$repo" -n GhosttyKit -D "$tmp_dir" >/dev/null - - [[ -f "$tmp_dir/GhosttyKit.xcframework/Info.plist" ]] || return 1 - [[ -d "$tmp_dir/ghostty-resources" ]] || return 1 - - mkdir -p "$FRAMEWORK_DIR" - rm -rf "$XCFRAMEWORK" "$RESOURCES_DIR" - cp -R "$tmp_dir/GhosttyKit.xcframework" "$XCFRAMEWORK" - cp -R "$tmp_dir/ghostty-resources" "$RESOURCES_DIR" - strip_archive_debug_symbols "$XCFRAMEWORK" - - echo "Restored GhosttyKit.xcframework from CI artifact into $FRAMEWORK_DIR" +write_provenance() { + local target="$1" + cat >"$FRAMEWORK_DIR/.ghosttykit-provenance" </dev/null || true)" - [[ -n "$static_lib" ]] +has_matching_provenance() { + [[ -f "$FRAMEWORK_DIR/.ghosttykit-provenance" ]] || return 1 + local source_repository source base_commit source_version revision zig_version zig_archive_sha256 + local target optimize build_mode minimum_ios_major expected_digest actual_digest + source_repository="$(awk -F= '$1 == "source_repository" { print substr($0, 19); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + source="$(awk -F= '$1 == "source_commit" { print substr($0, 15); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + base_commit="$(awk -F= '$1 == "upstream_base_commit" { print substr($0, 22); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + source_version="$(awk -F= '$1 == "source_version" { print substr($0, 16); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + revision="$(awk -F= '$1 == "build_revision" { print substr($0, 16); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + zig_version="$(awk -F= '$1 == "zig_version" { print substr($0, 13); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + zig_archive_sha256="$(awk -F= '$1 == "zig_archive_sha256" { print substr($0, 20); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + target="$(awk -F= '$1 == "xcframework_target" { print substr($0, 20); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + optimize="$(awk -F= '$1 == "optimize" { print substr($0, 10); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + build_mode="$(awk -F= '$1 == "build_mode" { print substr($0, 12); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + minimum_ios_major="$(awk -F= '$1 == "minimum_ios_major" { print substr($0, 19); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + expected_digest="$(awk -F= '$1 == "framework_tree_sha256" { print substr($0, 23); exit }' "$FRAMEWORK_DIR/.ghosttykit-provenance")" + [[ "$source_repository" == "$MORI_GHOSTTYKIT_SOURCE_REPOSITORY" && "$source" == "$MORI_GHOSTTYKIT_SOURCE_COMMIT" && "$base_commit" == "$MORI_GHOSTTYKIT_BASE_COMMIT" && "$source_version" == "$MORI_GHOSTTYKIT_VERSION" && "$revision" == "$MORI_GHOSTTYKIT_BUILD_REVISION" && "$zig_version" == "$MORI_GHOSTTYKIT_ZIG_VERSION" && "$zig_archive_sha256" == "$MORI_GHOSTTYKIT_ZIG_ARCHIVE_SHA256" && "$optimize" == "$MORI_GHOSTTYKIT_OPTIMIZE" && "$build_mode" == "$MORI_GHOSTTYKIT_BUILD_MODE" && "$minimum_ios_major" == "$MORI_GHOSTTYKIT_MIN_IOS_MAJOR" ]] || return 1 + if [[ "$UNIVERSAL" == true ]]; then + [[ "$target" == "$MORI_GHOSTTYKIT_TARGET" ]] || return 1 + else + [[ "$target" == "native" || "$target" == "$MORI_GHOSTTYKIT_TARGET" ]] || return 1 + fi + [[ "$expected_digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + actual_digest="$(ghosttykit_framework_tree_sha256 "$XCFRAMEWORK")" + [[ "$actual_digest" == "$expected_digest" ]] } # Strip archive debug symbols to avoid dsymutil warnings caused by duplicate @@ -115,14 +132,26 @@ done if [[ "$CLEAN" == true ]]; then echo "Cleaning Ghostty build artifacts..." - rm -rf "$XCFRAMEWORK" "$RESOURCES_DIR" + rm -rf "$XCFRAMEWORK" "$RESOURCES_DIR" "$FRAMEWORK_DIR/.ghosttykit-provenance" fi -# Check submodule is initialized +# Validate the recorded URL and gitlink before Git is allowed to initialize a submodule. +ghosttykit_validate_source_metadata "$PROJECT_ROOT" if [[ ! -f "$GHOSTTY_DIR/build.zig" ]]; then echo "Initializing ghostty submodule..." git -C "$PROJECT_ROOT" submodule update --init vendor/ghostty fi +ghosttykit_validate_source "$PROJECT_ROOT" +grep -Fq ".version = \"$MORI_GHOSTTYKIT_VERSION\"" "$GHOSTTY_DIR/build.zig.zon" || { + echo "Locked Ghostty version does not match build.zig.zon." >&2 + exit 1 +} + +# Fail closed on a stale or altered local framework before deciding it can be reused. +if has_valid_xcframework && [[ -d "$RESOURCES_DIR" ]] && ! has_matching_provenance; then + echo "Cached GhosttyKit provenance does not match the pinned source; rebuilding..." >&2 + rm -rf "$XCFRAMEWORK" "$FRAMEWORK_DIR/.ghosttykit-provenance" +fi # Skip if already built and has the required slices if has_valid_xcframework && [[ -d "$RESOURCES_DIR" ]]; then @@ -137,13 +166,18 @@ if has_valid_xcframework && [[ -d "$RESOURCES_DIR" ]]; then fi done fi + if [[ "$local_needs_rebuild" == false && "$UNIVERSAL" == true ]]; then + if ! MORI_GHOSTTYKIT_XCFRAMEWORK="$XCFRAMEWORK" bash "$PROJECT_ROOT/scripts/verify-ghosttykit.sh"; then + echo "Cached universal GhosttyKit failed verification; rebuilding..." >&2 + local_needs_rebuild=true + fi + fi if [[ "$local_needs_rebuild" == false ]]; then - strip_archive_debug_symbols "$XCFRAMEWORK" echo "GhosttyKit.xcframework and resources already exist at $FRAMEWORK_DIR" echo "Run with --clean to rebuild." exit 0 fi - rm -rf "$XCFRAMEWORK" + rm -rf "$XCFRAMEWORK" "$FRAMEWORK_DIR/.ghosttykit-provenance" fi if [[ -d "$XCFRAMEWORK" ]] && ! has_valid_xcframework; then @@ -151,13 +185,12 @@ if [[ -d "$XCFRAMEWORK" ]] && ! has_valid_xcframework; then rm -rf "$XCFRAMEWORK" fi -# Verify zig is available -if ! command -v zig &>/dev/null; then - echo "Error: zig not found. Run 'mise install' first." - exit 1 -fi +# A source-pinned framework must carry matching source-pinned resources; never +# leave resources from an invalidated cache beside a newly built library. +rm -rf "$RESOURCES_DIR" -ZIG_VERSION=$(zig version) +ghosttykit_validate_zig +ZIG_VERSION="$(zig version)" echo "Using Zig $ZIG_VERSION" cd "$GHOSTTY_DIR" @@ -316,15 +349,9 @@ if ! zig build \ -Demit-macos-app=false \ -Dxcframework-target="$XCFW_TARGET" \ -Dapp-runtime=none \ - -Doptimize=ReleaseFast \ + -Dversion-string="$MORI_GHOSTTYKIT_VERSION" \ + -Doptimize="$MORI_GHOSTTYKIT_OPTIMIZE" \ 2> >(tee "$BUILD_LOG" >&2); then - if grep -q "undefined symbol: __availability_version_check" "$BUILD_LOG"; then - echo "Detected Zig 0.15.x linker incompatibility with the local macOS toolchain." - if restore_ci_artifact; then - exit 0 - fi - echo "CI artifact fallback was unavailable; keeping the original Zig build failure." >&2 - fi exit 1 fi rm -f "$BUILD_LOG" @@ -345,9 +372,17 @@ fi # Copy XCFramework to project Frameworks directory mkdir -p "$FRAMEWORK_DIR" -rm -rf "$XCFRAMEWORK" +rm -rf "$XCFRAMEWORK" "$FRAMEWORK_DIR/.ghosttykit-provenance" cp -R "$BUILD_OUTPUT" "$XCFRAMEWORK" +if [[ "$UNIVERSAL" == true ]]; then + normalize_universal_xcframework +fi strip_archive_debug_symbols "$XCFRAMEWORK" +write_provenance "$XCFW_TARGET" + +if [[ "$UNIVERSAL" == true ]]; then + MORI_GHOSTTYKIT_XCFRAMEWORK="$XCFRAMEWORK" bash "$PROJECT_ROOT/scripts/verify-ghosttykit.sh" +fi echo "GhosttyKit.xcframework built successfully at $XCFRAMEWORK" # Show module map to confirm structure diff --git a/scripts/ghosttykit-contract.sh b/scripts/ghosttykit-contract.sh new file mode 100755 index 00000000..a16e41e4 --- /dev/null +++ b/scripts/ghosttykit-contract.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Machine-readable GhosttyKit contract helpers. The root lock is the authority. + +_ghosttykit_contract_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GHOSTTYKIT_DEFAULT_LOCK="$(cd "$_ghosttykit_contract_dir/.." && pwd)/ghosttykit-lock.json" + +_ghosttykit_fail() { + echo "GhosttyKit contract failed: $*" >&2 + return 1 +} + +# Load a strictly validated lock without evaluating lock-controlled shell text. +ghosttykit_load_contract() { + local lock_path="${1:-$GHOSTTYKIT_DEFAULT_LOCK}" + [[ -f "$lock_path" ]] || { _ghosttykit_fail "missing lock file: $lock_path"; return 1; } + + local values + values="$(python3 - "$lock_path" <<'PY' +import json +import re +import sys + +class ContractError(Exception): + pass + +def reject_duplicates(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ContractError(f"duplicate key: {key}") + result[key] = value + return result + +def expect_object(value, path, keys): + if not isinstance(value, dict): + raise ContractError(f"{path} must be an object") + actual = set(value) + expected = set(keys) + if actual != expected: + missing = ", ".join(sorted(expected - actual)) + unknown = ", ".join(sorted(actual - expected)) + parts = [] + if missing: + parts.append(f"missing: {missing}") + if unknown: + parts.append(f"unknown: {unknown}") + raise ContractError(f"{path} keys do not match schema ({'; '.join(parts)})") + return value + +def expect_string(value, path, pattern=None): + if not isinstance(value, str) or not value: + raise ContractError(f"{path} must be a non-empty string") + if pattern and not re.fullmatch(pattern, value): + raise ContractError(f"{path} has an invalid value") + return value + +def expect_positive_int(value, path): + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ContractError(f"{path} must be a positive integer") + return value + +def expect_digest(value, path): + if value is None: + return None + return expect_string(value, path, r"[0-9a-f]{64}") + +try: + with open(sys.argv[1], encoding="utf-8") as stream: + lock = json.load(stream, object_pairs_hook=reject_duplicates) + + expect_object(lock, "root", {"schemaVersion", "source", "build", "artifact"}) + if isinstance(lock["schemaVersion"], bool) or lock["schemaVersion"] != 1: + raise ContractError("schemaVersion must be 1") + + source = expect_object(lock["source"], "source", { + "repository", "commit", "upstreamBaseCommit", + }) + repository = expect_string(source["repository"], "source.repository", r"https://[A-Za-z0-9.-]+/[A-Za-z0-9._/-]+\.git") + source_commit = expect_string(source["commit"], "source.commit", r"[0-9a-f]{40}") + base_commit = expect_string(source["upstreamBaseCommit"], "source.upstreamBaseCommit", r"[0-9a-f]{40}") + + build = expect_object(lock["build"], "build", { + "revision", "ghosttyVersion", "zigVersion", "zigArchiveSha256", "optimize", + "buildMode", "target", "minimumIOSMajor", + }) + revision = expect_positive_int(build["revision"], "build.revision") + version = expect_string(build["ghosttyVersion"], "build.ghosttyVersion", r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?") + zig_version = expect_string(build["zigVersion"], "build.zigVersion", r"[0-9]+\.[0-9]+\.[0-9]+") + zig_archive_sha256 = expect_string(build["zigArchiveSha256"], "build.zigArchiveSha256", r"[0-9a-f]{64}") + optimize = expect_string(build["optimize"], "build.optimize", r"[A-Za-z][A-Za-z0-9_-]*") + build_mode = expect_positive_int(build["buildMode"], "build.buildMode") + target = expect_string(build["target"], "build.target", r"[a-z][a-z0-9_-]*") + minimum_ios_major = expect_positive_int(build["minimumIOSMajor"], "build.minimumIOSMajor") + + artifact = expect_object(lock["artifact"], "artifact", { + "repository", "tag", "name", "sha256", "frameworkTreeSha256", + }) + artifact_repository = expect_string(artifact["repository"], "artifact.repository", r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") + tag = expect_string(artifact["tag"], "artifact.tag", r"[A-Za-z0-9._-]+") + name = expect_string(artifact["name"], "artifact.name", r"[A-Za-z0-9._-]+\.zip") + archive_sha256 = expect_digest(artifact["sha256"], "artifact.sha256") + framework_tree_sha256 = expect_digest(artifact["frameworkTreeSha256"], "artifact.frameworkTreeSha256") + + short_commit = source_commit[:8] + expected_tag = f"ghosttykit-{short_commit}-r{revision}" + expected_name = f"GhosttyKit-{short_commit}-r{revision}-universal.zip" + if tag != expected_tag: + raise ContractError("artifact.tag does not match source.commit and build.revision") + if name != expected_name: + raise ContractError("artifact.name does not match source.commit and build.revision") + if target != "universal": + raise ContractError("build.target must be universal for the durable artifact") + if (archive_sha256 is None) != (framework_tree_sha256 is None): + raise ContractError("artifact digests must both be null (candidate) or both be final") + + fields = [ + source_commit, base_commit, repository, version, str(revision), zig_version, + zig_archive_sha256, optimize, str(build_mode), target, str(minimum_ios_major), + artifact_repository, tag, name, archive_sha256 or "", framework_tree_sha256 or "", + ] + print("\t".join(fields)) +except (OSError, json.JSONDecodeError, ContractError) as error: + print(f"GhosttyKit lock validation failed: {error}", file=sys.stderr) + sys.exit(1) +PY +)" || return 1 + + local source_commit base_commit source_repository version revision zig_version zig_archive_sha256 + local optimize build_mode target minimum_ios_major artifact_repository tag name archive_sha256 framework_tree_sha256 + IFS=$'\t' read -r source_commit base_commit source_repository version revision zig_version zig_archive_sha256 optimize build_mode target minimum_ios_major artifact_repository tag name archive_sha256 framework_tree_sha256 <<<"$values" + + MORI_GHOSTTYKIT_SOURCE_COMMIT="$source_commit" + MORI_GHOSTTYKIT_BASE_COMMIT="$base_commit" + MORI_GHOSTTYKIT_SOURCE_REPOSITORY="$source_repository" + MORI_GHOSTTYKIT_VERSION="$version" + MORI_GHOSTTYKIT_BUILD_REVISION="$revision" + MORI_GHOSTTYKIT_ZIG_VERSION="$zig_version" + MORI_GHOSTTYKIT_ZIG_ARCHIVE_SHA256="$zig_archive_sha256" + MORI_GHOSTTYKIT_OPTIMIZE="$optimize" + MORI_GHOSTTYKIT_BUILD_MODE="$build_mode" + MORI_GHOSTTYKIT_TARGET="$target" + MORI_GHOSTTYKIT_MIN_IOS_MAJOR="$minimum_ios_major" + MORI_GHOSTTYKIT_ARTIFACT_REPOSITORY="$artifact_repository" + MORI_GHOSTTYKIT_ARTIFACT_TAG="$tag" + MORI_GHOSTTYKIT_ARTIFACT_NAME="$name" + MORI_GHOSTTYKIT_ARTIFACT_SHA256="$archive_sha256" + MORI_GHOSTTYKIT_FRAMEWORK_TREE_SHA256="$framework_tree_sha256" + + if [[ -z "$MORI_GHOSTTYKIT_ARTIFACT_SHA256" ]]; then + MORI_GHOSTTYKIT_ARTIFACT_STATE="candidate" + else + MORI_GHOSTTYKIT_ARTIFACT_STATE="published" + fi +} + +ghosttykit_validate_source_metadata() { + local project_root="$1" + local configured_url gitlink mode commit path + configured_url="$(git -C "$project_root" config -f .gitmodules --get submodule.vendor/ghostty.url 2>/dev/null || true)" + [[ "$configured_url" == "$MORI_GHOSTTYKIT_SOURCE_REPOSITORY" ]] || { + _ghosttykit_fail "vendor/ghostty URL does not match the lock" + return 1 + } + + gitlink="$(git -C "$project_root" ls-tree HEAD -- vendor/ghostty)" + read -r mode _ commit path <<<"$gitlink" + [[ "$mode" == "160000" && "$commit" == "$MORI_GHOSTTYKIT_SOURCE_COMMIT" && "$path" == "vendor/ghostty" ]] || { + _ghosttykit_fail "vendor/ghostty gitlink does not match the lock" + return 1 + } + git -C "$project_root" diff --quiet --cached -- vendor/ghostty || { + _ghosttykit_fail "vendor/ghostty gitlink has staged changes" + return 1 + } +} + +ghosttykit_validate_source() { + local project_root="$1" + local ghostty_dir="$project_root/vendor/ghostty" + ghosttykit_validate_source_metadata "$project_root" || return 1 + [[ -f "$ghostty_dir/build.zig" ]] || { + _ghosttykit_fail "vendor/ghostty is not initialized" + return 1 + } + [[ "$(git -C "$ghostty_dir" rev-parse HEAD)" == "$MORI_GHOSTTYKIT_SOURCE_COMMIT" ]] || { + _ghosttykit_fail "checked-out vendor/ghostty commit does not match the lock" + return 1 + } + [[ -z "$(git -C "$ghostty_dir" status --porcelain --untracked-files=all)" ]] || { + _ghosttykit_fail "vendor/ghostty has tracked or untracked modifications" + return 1 + } +} + +# Publisher-only history check. Ordinary shallow CI checkouts still prove the +# exact source gitlink; the publisher fetches full history before calling this. +ghosttykit_validate_source_ancestry() { + local project_root="$1" + local ghostty_dir="$project_root/vendor/ghostty" + git -C "$ghostty_dir" merge-base --is-ancestor "$MORI_GHOSTTYKIT_BASE_COMMIT" "$MORI_GHOSTTYKIT_SOURCE_COMMIT" || { + _ghosttykit_fail "locked upstream base is not an ancestor of the locked source" + return 1 + } +} + +ghosttykit_validate_zig() { + command -v zig >/dev/null 2>&1 || { + _ghosttykit_fail "zig is not installed" + return 1 + } + [[ "$(zig version)" == "$MORI_GHOSTTYKIT_ZIG_VERSION" ]] || { + _ghosttykit_fail "installed zig does not match the locked version" + return 1 + } +} + +# A tree digest is content + relative path sensitive and rejects symlinks/special files. +ghosttykit_framework_tree_sha256() { + local root="$1" + [[ -d "$root" ]] || { + _ghosttykit_fail "missing framework tree: $root" + return 1 + } + python3 - "$root" <<'PY' +import hashlib +import os +import stat +import sys + +root = os.path.abspath(sys.argv[1]) +digest = hashlib.sha256() +for current, directories, files in os.walk(root, topdown=True, followlinks=False): + directories.sort() + files.sort() + for name in directories + files: + path = os.path.join(current, name) + mode = os.lstat(path).st_mode + if stat.S_ISLNK(mode) or not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)): + raise SystemExit(f"unsafe framework tree entry: {os.path.relpath(path, root)}") + for name in files: + path = os.path.join(current, name) + relative = os.path.relpath(path, root).encode("utf-8") + content = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + content.update(chunk) + digest.update(b"file\0") + digest.update(len(relative).to_bytes(8, "big")) + digest.update(relative) + digest.update(content.digest()) +print(digest.hexdigest()) +PY +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + lock_path="$GHOSTTYKIT_DEFAULT_LOCK" + while [[ $# -gt 0 ]]; do + case "$1" in + --lock) + [[ $# -ge 2 ]] || { echo "Usage: $0 [--lock PATH]" >&2; exit 2; } + lock_path="$2" + shift + ;; + *) + echo "Usage: $0 [--lock PATH]" >&2 + exit 2 + ;; + esac + shift + done + ghosttykit_load_contract "$lock_path" || exit 1 + printf 'GhosttyKit lock is a valid %s contract.\n' "$MORI_GHOSTTYKIT_ARTIFACT_STATE" +fi diff --git a/scripts/github-release-draft.sh b/scripts/github-release-draft.sh new file mode 100755 index 00000000..ed012bc6 --- /dev/null +++ b/scripts/github-release-draft.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +# Create, verify, and publish GitHub Releases without ever uploading after publish. +set -Eeuo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: + github-release-draft.sh --create-draft --repo OWNER/REPO --tag TAG --target SHA --title TITLE + [--generate-notes] [--prerelease] [--make-latest true|false] [--require-immutable] + [--defer-asset-verification] [--download-assets] --asset PATH [--asset PATH ...] + github-release-draft.sh --verify-draft-assets --repo OWNER/REPO --release-id ID + [--download-assets] --asset PATH [--asset PATH ...] + github-release-draft.sh --publish-draft --repo OWNER/REPO --release-id ID [--require-immutable] + github-release-draft.sh --cleanup-draft --repo OWNER/REPO --release-id ID + github-release-draft.sh --preflight --repo OWNER/REPO --tag TAG --target SHA --title TITLE + --asset PATH --asset PATH [...] +EOF +} + +incomplete_draft=false + +cleanup_incomplete_draft() { + set +e + if [[ "$incomplete_draft" == true && "$release_id" =~ ^[0-9]+$ ]] && release_is_draft; then + gh api --method DELETE "repos/$repo/releases/$release_id" --silent + echo "Deleted incomplete draft release $release_id." >&2 + fi +} + +fail() { + cleanup_incomplete_draft + echo "GitHub release draft failed: $*" >&2 + exit 1 +} + +mode="" +repo="${GITHUB_REPOSITORY:-}" +tag="" +target="" +title="" +release_id="" +generate_notes=false +prerelease=false +make_latest=false +require_immutable=false +defer_asset_verification=false +download_assets=false +assets=() +trap 'status=$?; cleanup_incomplete_draft; exit "$status"' ERR + +while [[ $# -gt 0 ]]; do + case "$1" in + --create-draft|--verify-draft-assets|--publish-draft|--cleanup-draft|--preflight) + [[ -z "$mode" ]] || fail "choose exactly one mode" + mode="${1#--}" + ;; + --repo|--tag|--target|--title|--release-id|--make-latest) + [[ $# -ge 2 ]] || { usage; exit 2; } + case "$1" in + --repo) repo="$2" ;; + --tag) tag="$2" ;; + --target) target="$2" ;; + --title) title="$2" ;; + --release-id) release_id="$2" ;; + --make-latest) make_latest="$2" ;; + esac + shift + ;; + --asset) + [[ $# -ge 2 ]] || { usage; exit 2; } + assets+=("$2") + shift + ;; + --generate-notes) generate_notes=true ;; + --prerelease) prerelease=true ;; + --require-immutable) require_immutable=true ;; + --defer-asset-verification) defer_asset_verification=true ;; + --download-assets) download_assets=true ;; + *) usage; exit 2 ;; + esac + shift +done + +[[ -n "$mode" && "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { usage; exit 2; } +[[ "$make_latest" == true || "$make_latest" == false ]] || fail "--make-latest must be true or false" +command -v gh >/dev/null 2>&1 || fail "gh is required" + +require_assets() { + [[ "${#assets[@]}" -gt 0 ]] || fail "at least one --asset is required" + local asset name + for asset in "${assets[@]}"; do + [[ -f "$asset" ]] || fail "missing asset: $asset" + name="$(basename "$asset")" + [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "asset name is not API-safe: $name" + done +} + +api_404_is_absent() { + local endpoint="$1" response status + set +e + response="$(gh api --include "$endpoint" 2>&1)" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + return 1 + fi + [[ "$response" == *"404"* ]] || fail "could not establish that $endpoint is absent: $response" + return 0 +} + +immutable_releases_enabled() { + [[ "$(gh api "repos/$repo/immutable-releases" --jq '.enabled')" == true ]] || \ + fail "repository immutable releases must be enabled before publishing" +} + +latest_tag() { + local response status + set +e + response="$(gh api "repos/$repo/releases/latest" --jq '.tag_name' 2>&1)" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + printf '%s\n' "$response" + elif [[ "$response" == *"404"* ]]; then + printf '%s\n' "__none__" + else + fail "could not resolve Latest: $response" + fi +} + +release_is_draft() { + [[ "$(gh api "repos/$repo/releases/$release_id" --jq '.draft')" == true ]] +} + +cleanup_draft() { + if [[ -n "$release_id" ]] && release_is_draft; then + gh api --method DELETE "repos/$repo/releases/$release_id" --silent + echo "Deleted unpublished draft release $release_id." + fi +} + +verify_assets() { + local release_json records name asset_id digest local_path local_digest downloaded + release_json="$(gh api "repos/$repo/releases/$release_id")" + records="$(python3 - "$release_json" "${assets[@]}" <<'PY' +import json +import os +import sys + +release = json.loads(sys.argv[1]) +by_name = {asset["name"]: asset for asset in release.get("assets", [])} +for path in sys.argv[2:]: + name = os.path.basename(path) + asset = by_name.get(name) + if not asset: + raise SystemExit(f"missing uploaded asset: {name}") + digest = asset.get("digest") + if not isinstance(digest, str) or not digest.startswith("sha256:") or len(digest) != 71: + raise SystemExit(f"server digest is unavailable for {name}") + print(f"{name}\t{asset['id']}\t{digest[7:]}") +PY +)" || return 1 + + while IFS=$'\t' read -r name asset_id digest; do + local_path="" + for asset in "${assets[@]}"; do + [[ "$(basename "$asset")" == "$name" ]] && local_path="$asset" && break + done + [[ -n "$local_path" ]] || fail "lost local asset mapping for $name" + local_digest="$(shasum -a 256 "$local_path" | awk '{print $1}')" + [[ "$local_digest" == "$digest" ]] || fail "server digest differs for $name" + if [[ "$download_assets" == true ]]; then + downloaded="$(mktemp "${TMPDIR:-/tmp}/mori-release-asset.XXXXXX")" + gh api -H 'Accept: application/octet-stream' "repos/$repo/releases/assets/$asset_id" >"$downloaded" + [[ "$(shasum -a 256 "$downloaded" | awk '{print $1}')" == "$local_digest" ]] || \ + fail "downloaded asset differs for $name" + rm -f "$downloaded" + fi + done <<<"$records" +} + +wait_for_verified_assets() { + local attempt + for attempt in $(seq 1 15); do + if verify_assets; then + return 0 + fi + sleep 2 + done + fail "uploaded assets did not acquire matching server digests" +} + +create_draft() { + require_assets + [[ -n "$tag" && -n "$target" && -n "$title" ]] || fail "--tag, --target, and --title are required" + [[ "$tag" =~ ^[A-Za-z0-9._-]+$ ]] || fail "tag is not API-safe" + if [[ "$require_immutable" == true ]]; then + immutable_releases_enabled + fi + api_404_is_absent "repos/$repo/releases/tags/$tag" || fail "refusing to overwrite existing release tag: $tag" + + local body response + body="$(mktemp "${TMPDIR:-/tmp}/mori-release-create.XXXXXX")" + trap 'rm -f "$body"' RETURN + python3 - "$body" "$tag" "$target" "$title" "$generate_notes" "$prerelease" "$make_latest" <<'PY' +import json +import sys +path, tag, target, title, notes, prerelease, latest = sys.argv[1:] +with open(path, "w", encoding="utf-8") as stream: + json.dump({ + "tag_name": tag, + "target_commitish": target, + "name": title, + "draft": True, + "prerelease": prerelease == "true", + "generate_release_notes": notes == "true", + "make_latest": latest, + }, stream) +PY + response="$(gh api --method POST "repos/$repo/releases" --input "$body")" + rm -f "$body" + trap - RETURN + release_id="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$response")" || fail "draft creation returned no release id" + [[ "$release_id" =~ ^[0-9]+$ ]] || fail "draft creation returned invalid release id" + incomplete_draft=true + + local asset name + for asset in "${assets[@]}"; do + name="$(basename "$asset")" + gh api --method POST "https://uploads.github.com/repos/$repo/releases/$release_id/assets?name=$name" \ + -H 'Content-Type: application/octet-stream' --input "$asset" --silent + done + if [[ "$defer_asset_verification" != true ]]; then + wait_for_verified_assets + fi + incomplete_draft=false + printf 'release_id=%s\n' "$release_id" +} + +publish_draft() { + [[ "$release_id" =~ ^[0-9]+$ ]] || fail "--release-id is required" + if [[ "$require_immutable" == true ]]; then + immutable_releases_enabled + fi + release_is_draft || fail "refusing to publish a release that is not a draft" + local body response immutable + body="$(mktemp "${TMPDIR:-/tmp}/mori-release-publish.XXXXXX")" + printf '{"draft":false}\n' >"$body" + response="$(gh api --method PATCH "repos/$repo/releases/$release_id" --input "$body")" + rm -f "$body" + [[ "$(python3 -c 'import json,sys; print(str(json.load(sys.stdin).get("draft")).lower())' <<<"$response")" == false ]] || \ + fail "GitHub did not publish the release" + immutable="$(python3 -c 'import json,sys; print(str(json.load(sys.stdin).get("immutable")).lower())' <<<"$response")" + [[ "$immutable" == true ]] || fail "published release is not immutable" + echo "Published immutable release $release_id." +} + +case "$mode" in + create-draft) + create_draft + ;; + verify-draft-assets) + [[ "$release_id" =~ ^[0-9]+$ ]] || fail "--release-id is required" + release_is_draft || fail "refusing to verify a release that is not a draft" + require_assets + wait_for_verified_assets + ;; + publish-draft) + publish_draft + ;; + cleanup-draft) + [[ "$release_id" =~ ^[0-9]+$ ]] || fail "--release-id is required" + cleanup_draft + ;; + preflight) + latest_before="$(latest_tag)" + echo "Latest before draft: $latest_before" + create_draft + incomplete_draft=true + latest_after="$(latest_tag)" + [[ "$latest_before" == "$latest_after" ]] || fail "Latest changed while only a draft existed" + cleanup_draft + incomplete_draft=false + echo "Latest after draft cleanup: $latest_after" + echo "Draft preflight preserved Latest: $latest_before" + ;; +esac diff --git a/scripts/github-tag-reservation.sh b/scripts/github-tag-reservation.sh new file mode 100755 index 00000000..8ae86589 --- /dev/null +++ b/scripts/github-tag-reservation.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Atomically reserve and safely clean a lightweight GitHub release tag. +set -euo pipefail + +usage() { + echo "Usage: $0 (--reserve|--verify|--cleanup) --repo OWNER/REPO --tag TAG --sha COMMIT" >&2 +} + +mode="" +repo="${GITHUB_REPOSITORY:-}" +tag="" +sha="" +while [[ $# -gt 0 ]]; do + case "$1" in + --reserve|--verify|--cleanup) + [[ -z "$mode" ]] || { usage; exit 2; } + mode="${1#--}" + ;; + --repo|--tag|--sha) + [[ $# -ge 2 ]] || { usage; exit 2; } + case "$1" in + --repo) repo="$2" ;; + --tag) tag="$2" ;; + --sha) sha="$2" ;; + esac + shift + ;; + *) usage; exit 2 ;; + esac + shift +done + +[[ -n "$mode" && "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { usage; exit 2; } +[[ "$tag" =~ ^[A-Za-z0-9._-]+$ && "$sha" =~ ^[0-9a-f]{40}$ ]] || { usage; exit 2; } +command -v gh >/dev/null 2>&1 || { echo "gh is required" >&2; exit 1; } + +read_ref() { + gh api "repos/$repo/git/ref/tags/$tag" --jq '[.object.type,.object.sha] | @tsv' +} + +verify_ref() { + local record type actual + record="$(read_ref)" + IFS=$'\t' read -r type actual <<<"$record" + [[ "$type" == "commit" && "$actual" == "$sha" ]] || { + echo "Tag $tag does not resolve directly to locked commit $sha." >&2 + return 1 + } +} + +case "$mode" in + reserve) + # A successful POST is the ownership boundary. Verification is a + # separate workflow step so cleanup still knows it owns the tag when a + # subsequent GET fails transiently. + gh api --method POST "repos/$repo/git/refs" \ + -f "ref=refs/tags/$tag" -f "sha=$sha" --silent + echo "Reserved refs/tags/$tag at $sha." + ;; + verify) + verify_ref + ;; + cleanup) + # Once any release exists for the tag, immutable-release ownership wins. + # Continue only after GitHub explicitly proves absence with HTTP 404; + # transient API/auth failures must preserve the tag. + set +e + release_response="$(gh api --include "repos/$repo/releases/tags/$tag" 2>&1)" + release_status=$? + set -e + if [[ "$release_status" -eq 0 ]]; then + echo "Refusing to delete tag $tag because a release exists." >&2 + exit 1 + fi + if [[ "$release_response" != *"404"* ]]; then + echo "Refusing to delete tag $tag because release absence was not established: $release_response" >&2 + exit 1 + fi + if ! verify_ref 2>/dev/null; then + echo "Refusing to delete tag $tag because it is absent, moved, or annotated." >&2 + exit 1 + fi + gh api --method DELETE "repos/$repo/git/refs/tags/$tag" --silent + echo "Deleted unpublished reserved tag $tag." + ;; +esac diff --git a/scripts/install-verified-zig.sh b/scripts/install-verified-zig.sh new file mode 100755 index 00000000..66482d19 --- /dev/null +++ b/scripts/install-verified-zig.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Install the lock-pinned macOS Zig toolchain only after verifying its archive. +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=ghosttykit-contract.sh +source "$project_root/scripts/ghosttykit-contract.sh" +ghosttykit_load_contract "$project_root/ghosttykit-lock.json" + +prefix="${RUNNER_TEMP:-$project_root/.derived-data}/zig" +while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) + [[ $# -ge 2 ]] || { echo "Usage: $0 [--prefix DIRECTORY]" >&2; exit 2; } + prefix="$2" + shift + ;; + *) + echo "Usage: $0 [--prefix DIRECTORY]" >&2 + exit 2 + ;; + esac + shift +done + +[[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]] || { + echo "Verified Zig installer currently supports only macOS arm64 runners." >&2 + exit 1 +} + +prefix="$(mkdir -p "$prefix" && cd "$prefix" && pwd -P)" +archive="${RUNNER_TEMP:-$prefix}/zig-aarch64-macos-${MORI_GHOSTTYKIT_ZIG_VERSION}.tar.xz" +extract_root="${RUNNER_TEMP:-$prefix}/zig-aarch64-macos-${MORI_GHOSTTYKIT_ZIG_VERSION}" +url="https://ziglang.org/download/${MORI_GHOSTTYKIT_ZIG_VERSION}/zig-aarch64-macos-${MORI_GHOSTTYKIT_ZIG_VERSION}.tar.xz" + +rm -f "$archive" +rm -rf "$extract_root" "$prefix/bin" "$prefix/lib" +curl -fSL "$url" -o "$archive" +printf '%s %s\n' "$MORI_GHOSTTYKIT_ZIG_ARCHIVE_SHA256" "$archive" | shasum -a 256 -c - >&2 +tar xf "$archive" -C "$(dirname "$extract_root")" +[[ -x "$extract_root/zig" && -d "$extract_root/lib" ]] || { + echo "Verified Zig archive has an unexpected layout." >&2 + exit 1 +} +mkdir -p "$prefix/bin" +cp "$extract_root/zig" "$prefix/bin/zig" +cp -R "$extract_root/lib" "$prefix/lib" +[[ "$("$prefix/bin/zig" version)" == "$MORI_GHOSTTYKIT_ZIG_VERSION" ]] || { + echo "Verified Zig installation reports the wrong version." >&2 + exit 1 +} +printf '%s\n' "$prefix/bin" diff --git a/scripts/package-ghosttykit.sh b/scripts/package-ghosttykit.sh new file mode 100755 index 00000000..58e52687 --- /dev/null +++ b/scripts/package-ghosttykit.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Package one verified universal GhosttyKit candidate. ZIP bytes are not claimed reproducible. +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=ghosttykit-contract.sh +source "$project_root/scripts/ghosttykit-contract.sh" +ghosttykit_load_contract "$project_root/ghosttykit-lock.json" +export MORI_GHOSTTYKIT_SOURCE_COMMIT MORI_GHOSTTYKIT_BASE_COMMIT MORI_GHOSTTYKIT_SOURCE_REPOSITORY +export MORI_GHOSTTYKIT_VERSION MORI_GHOSTTYKIT_BUILD_REVISION MORI_GHOSTTYKIT_ZIG_VERSION MORI_GHOSTTYKIT_ZIG_ARCHIVE_SHA256 +export MORI_GHOSTTYKIT_OPTIMIZE MORI_GHOSTTYKIT_BUILD_MODE MORI_GHOSTTYKIT_TARGET MORI_GHOSTTYKIT_MIN_IOS_MAJOR +export MORI_GHOSTTYKIT_ARTIFACT_REPOSITORY MORI_GHOSTTYKIT_ARTIFACT_TAG MORI_GHOSTTYKIT_ARTIFACT_NAME + +output_dir="$project_root/.derived-data/ghosttykit-package" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + [[ $# -ge 2 ]] || { echo "Usage: $0 [--output DIRECTORY]" >&2; exit 2; } + output_dir="$2" + shift + ;; + *) + echo "Usage: $0 [--output DIRECTORY]" >&2 + exit 2 + ;; + esac + shift +done + +mkdir -p "$output_dir" +output_dir="$(cd "$output_dir" && pwd -P)" +xcframework="$project_root/Frameworks/GhosttyKit.xcframework" +resources="$project_root/Frameworks/ghostty-resources" +source_license="$project_root/vendor/ghostty/LICENSE" +archive_path="$output_dir/$MORI_GHOSTTYKIT_ARTIFACT_NAME" +manifest_path="$output_dir/${MORI_GHOSTTYKIT_ARTIFACT_NAME%.zip}.manifest.json" + +fail() { + echo "GhosttyKit packaging failed: $*" >&2 + exit 1 +} + +[[ "$MORI_GHOSTTYKIT_ARTIFACT_STATE" == "candidate" ]] || fail "only a candidate lock may create a new package revision" +[[ ! -e "$archive_path" && ! -e "$manifest_path" ]] || fail "refusing to overwrite an existing package output" +[[ -d "$xcframework" ]] || fail "missing GhosttyKit.xcframework" +[[ -d "$resources" ]] || fail "missing ghostty-resources" +[[ -f "$source_license" ]] || fail "missing Ghostty/remux license" +command -v zip >/dev/null 2>&1 || fail "zip is required to create the archive" + +ghosttykit_validate_source "$project_root" || fail "source checkout does not match the lock" +ghosttykit_validate_source_ancestry "$project_root" || fail "source history does not match the locked upstream base" +ghosttykit_validate_zig || fail "installed Zig does not match the lock" +MORI_GHOSTTYKIT_XCFRAMEWORK="$xcframework" bash "$project_root/scripts/verify-ghosttykit.sh" + +framework_tree_sha256="$(ghosttykit_framework_tree_sha256 "$xcframework")" + +staging="$(mktemp -d "${TMPDIR:-/tmp}/mori-ghosttykit-package.XXXXXX")" +trap 'rm -rf "$staging"' EXIT +payload="$staging/payload" +mkdir -p "$payload/LICENSES" "$output_dir" +cp -R "$xcframework" "$payload/GhosttyKit.xcframework" +cp -R "$resources" "$payload/ghostty-resources" +# remux-ghostty carries Ghostty's MIT license; retain explicit names for both redistributions. +cp "$source_license" "$payload/LICENSES/Ghostty-MIT.txt" +cp "$source_license" "$payload/LICENSES/remux-ghostty-MIT.txt" + +xcode_version="$(xcodebuild -version 2>/dev/null | tr '\n' ';' | sed 's/;*$//')" +python3 - "$payload/ghosttykit-provenance.json" "$framework_tree_sha256" "$xcode_version" <<'PY' +import json +import os +import sys + +path, framework_tree_sha256, xcode_version = sys.argv[1:] +provenance = { + "schemaVersion": 1, + "source": { + "repository": os.environ["MORI_GHOSTTYKIT_SOURCE_REPOSITORY"], + "commit": os.environ["MORI_GHOSTTYKIT_SOURCE_COMMIT"], + "upstreamBaseCommit": os.environ["MORI_GHOSTTYKIT_BASE_COMMIT"], + }, + "build": { + "revision": int(os.environ["MORI_GHOSTTYKIT_BUILD_REVISION"]), + "ghosttyVersion": os.environ["MORI_GHOSTTYKIT_VERSION"], + "zigVersion": os.environ["MORI_GHOSTTYKIT_ZIG_VERSION"], + "zigArchiveSha256": os.environ["MORI_GHOSTTYKIT_ZIG_ARCHIVE_SHA256"], + "optimize": os.environ["MORI_GHOSTTYKIT_OPTIMIZE"], + "buildMode": int(os.environ["MORI_GHOSTTYKIT_BUILD_MODE"]), + "target": os.environ["MORI_GHOSTTYKIT_TARGET"], + "minimumIOSMajor": int(os.environ["MORI_GHOSTTYKIT_MIN_IOS_MAJOR"]), + }, + "artifact": { + "repository": os.environ["MORI_GHOSTTYKIT_ARTIFACT_REPOSITORY"], + "tag": os.environ["MORI_GHOSTTYKIT_ARTIFACT_TAG"], + "name": os.environ["MORI_GHOSTTYKIT_ARTIFACT_NAME"], + "frameworkTreeSha256": framework_tree_sha256, + }, + "observedToolchain": {"xcodeVersion": xcode_version}, +} +with open(path, "w", encoding="utf-8") as stream: + json.dump(provenance, stream, indent=2, sort_keys=True) + stream.write("\n") +PY + +( + cd "$payload" + zip -qry "$archive_path" GhosttyKit.xcframework ghostty-resources ghosttykit-provenance.json LICENSES +) +archive_sha256="$(shasum -a 256 "$archive_path" | awk '{print $1}')" +python3 - "$manifest_path" "$archive_sha256" "$framework_tree_sha256" <<'PY' +import json +import os +import sys + +path, archive_sha256, framework_tree_sha256 = sys.argv[1:] +manifest = { + "schemaVersion": 1, + "artifact": { + "repository": os.environ["MORI_GHOSTTYKIT_ARTIFACT_REPOSITORY"], + "tag": os.environ["MORI_GHOSTTYKIT_ARTIFACT_TAG"], + "name": os.environ["MORI_GHOSTTYKIT_ARTIFACT_NAME"], + "sha256": archive_sha256, + "frameworkTreeSha256": framework_tree_sha256, + }, +} +with open(path, "w", encoding="utf-8") as stream: + json.dump(manifest, stream, indent=2, sort_keys=True) + stream.write("\n") +PY + +printf 'Packaged GhosttyKit candidate (ZIP bytes are not claimed reproducible):\n' +printf ' archive: %s\n sha256: %s\n framework tree: %s\n manifest: %s\n' \ + "$archive_path" "$archive_sha256" "$framework_tree_sha256" "$manifest_path" diff --git a/scripts/tests/test-ghosttykit-contract.sh b/scripts/tests/test-ghosttykit-contract.sh new file mode 100755 index 00000000..4ab71ec6 --- /dev/null +++ b/scripts/tests/test-ghosttykit-contract.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Offline adversarial tests for the root GhosttyKit contract and its source/tree checks. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=../ghosttykit-contract.sh +source "$repo_root/scripts/ghosttykit-contract.sh" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/mori-ghosttykit-contract.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +die() { + echo "GhosttyKit contract test failed: $*" >&2 + exit 1 +} + +expect_reject() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then + die "accepted $description" + fi +} + +write_lock() { + local path="$1" source_commit="$2" base_commit="$3" revision="$4" + python3 - "$path" "$source_commit" "$base_commit" "$revision" <<'PY' +import json +import sys + +path, source, base, revision = sys.argv[1:] +revision = int(revision) +short = source[:8] +lock = { + "schemaVersion": 1, + "source": { + "repository": "https://example.test/remux-ghostty.git", + "commit": source, + "upstreamBaseCommit": base, + }, + "build": { + "revision": revision, + "ghosttyVersion": "9.9.9-test", + "zigVersion": "9.9.9", + "zigArchiveSha256": "a" * 64, + "optimize": "Test", + "buildMode": 2, + "target": "universal", + "minimumIOSMajor": 17, + }, + "artifact": { + "repository": "example/mori", + "tag": f"ghosttykit-{short}-r{revision}", + "name": f"GhosttyKit-{short}-r{revision}-universal.zip", + "sha256": None, + "frameworkTreeSha256": None, + }, +} +with open(path, "w", encoding="utf-8") as stream: + json.dump(lock, stream) +PY +} + +# The checked-in lock deliberately has no digest until the first trusted publish. +ghosttykit_load_contract "$repo_root/ghosttykit-lock.json" +[[ "$MORI_GHOSTTYKIT_ARTIFACT_STATE" == "candidate" ]] || die "current lock is not a valid candidate" + +invalid_json="$tmp/invalid.json" +printf '{not json}\n' >"$invalid_json" +expect_reject "malformed JSON" bash "$repo_root/scripts/ghosttykit-contract.sh" --lock "$invalid_json" + +missing_field="$tmp/missing-field.json" +printf '{"schemaVersion":1}\n' >"$missing_field" +expect_reject "missing schema field" bash "$repo_root/scripts/ghosttykit-contract.sh" --lock "$missing_field" + +unknown_field="$tmp/unknown-field.json" +python3 - "$repo_root/ghosttykit-lock.json" "$unknown_field" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as stream: + value = json.load(stream) +value["unexpected"] = True +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(value, stream) +PY +expect_reject "unknown schema field" bash "$repo_root/scripts/ghosttykit-contract.sh" --lock "$unknown_field" + +duplicate_key="$tmp/duplicate-key.json" +printf '{"schemaVersion":1,"schemaVersion":1,"source":{},"build":{},"artifact":{}}\n' >"$duplicate_key" +expect_reject "duplicate JSON key" bash "$repo_root/scripts/ghosttykit-contract.sh" --lock "$duplicate_key" + +published_lock="$tmp/published.json" +python3 - "$repo_root/ghosttykit-lock.json" "$published_lock" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as stream: + value = json.load(stream) +value["artifact"]["sha256"] = "a" * 64 +value["artifact"]["frameworkTreeSha256"] = "b" * 64 +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(value, stream) +PY +ghosttykit_load_contract "$published_lock" +[[ "$MORI_GHOSTTYKIT_ARTIFACT_STATE" == "published" ]] || die "valid final digests were not accepted" + +partial_digest="$tmp/partial-digest.json" +python3 - "$published_lock" "$partial_digest" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as stream: + value = json.load(stream) +value["artifact"]["frameworkTreeSha256"] = None +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(value, stream) +PY +expect_reject "partial artifact digest state" bash "$repo_root/scripts/ghosttykit-contract.sh" --lock "$partial_digest" + +child="$tmp/child" +super="$tmp/super" +git init -q "$child" +git -C "$child" config user.email test@example.test +git -C "$child" config user.name contract-test +printf 'base\n' >"$child/build.zig" +git -C "$child" add build.zig +git -C "$child" commit -qm base +base_commit="$(git -C "$child" rev-parse HEAD)" +printf 'source\n' >>"$child/build.zig" +git -C "$child" commit -qam source +source_commit="$(git -C "$child" rev-parse HEAD)" +git -C "$child" remote add origin https://example.test/remux-ghostty.git + +git init -q "$super" +git -C "$super" config user.email test@example.test +git -C "$super" config user.name contract-test +mkdir -p "$super/vendor" +git clone -q "$child" "$super/vendor/ghostty" +cat >"$super/.gitmodules" <<'EOF' +[submodule "vendor/ghostty"] + path = vendor/ghostty + url = https://example.test/remux-ghostty.git +EOF +git -C "$super" add .gitmodules +git -C "$super" update-index --add --cacheinfo "160000,$source_commit,vendor/ghostty" +git -C "$super" commit -qm contract + +valid_lock="$tmp/valid.json" +write_lock "$valid_lock" "$source_commit" "$base_commit" 7 +ghosttykit_load_contract "$valid_lock" +ghosttykit_validate_source "$super" + +sed -i '' 's#https://example.test/remux-ghostty.git#https://example.test/wrong.git#' "$super/.gitmodules" +expect_reject "submodule URL mismatch" ghosttykit_validate_source "$super" +sed -i '' 's#https://example.test/wrong.git#https://example.test/remux-ghostty.git#' "$super/.gitmodules" +git -C "$super/vendor/ghostty" checkout -q "$base_commit" +expect_reject "checked-out submodule mismatch" ghosttykit_validate_source "$super" +git -C "$super/vendor/ghostty" checkout -q "$source_commit" + +ghosttykit_load_contract "$valid_lock" +ghosttykit_validate_source "$super" + +git -C "$super" update-index --cacheinfo "160000,$base_commit,vendor/ghostty" +expect_reject "staged submodule gitlink mismatch" ghosttykit_validate_source "$super" +git -C "$super" reset -q -- vendor/ghostty + +ghosttykit_load_contract "$valid_lock" +ghosttykit_validate_source "$super" + +source_mismatch_lock="$tmp/source-mismatch.json" +write_lock "$source_mismatch_lock" "$base_commit" "$base_commit" 7 +ghosttykit_load_contract "$source_mismatch_lock" +expect_reject "submodule gitlink mismatch" ghosttykit_validate_source "$super" + +revision_mismatch_lock="$tmp/revision-mismatch.json" +write_lock "$revision_mismatch_lock" "$source_commit" "$base_commit" 8 +python3 - "$revision_mismatch_lock" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as stream: + value = json.load(stream) +value["artifact"]["tag"] = value["artifact"]["tag"].replace("-r8", "-r7") +with open(sys.argv[1], "w", encoding="utf-8") as stream: + json.dump(value, stream) +PY +expect_reject "revision-derived tag mismatch" bash "$repo_root/scripts/ghosttykit-contract.sh" --lock "$revision_mismatch_lock" + +ghosttykit_load_contract "$valid_lock" +mkdir -p "$tmp/bin" +printf '#!/usr/bin/env bash\nprintf "0.0.0\\n"\n' >"$tmp/bin/zig" +chmod +x "$tmp/bin/zig" +expect_reject "toolchain version mismatch" env PATH="$tmp/bin:$PATH" bash -c 'source "$1"; ghosttykit_load_contract "$2"; ghosttykit_validate_zig' _ "$repo_root/scripts/ghosttykit-contract.sh" "$valid_lock" + +framework="$tmp/GhosttyKit.xcframework" +mkdir -p "$framework/Headers" +printf 'original\n' >"$framework/Info.plist" +printf 'header\n' >"$framework/Headers/ghostty.h" +tree_sha256="$(ghosttykit_framework_tree_sha256 "$framework")" +printf 'mutated\n' >>"$framework/Info.plist" +[[ "$(ghosttykit_framework_tree_sha256 "$framework")" != "$tree_sha256" ]] || die "tree mutation did not change digest" +ln -s Info.plist "$framework/unsafe-link" +expect_reject "symlinked framework entry" ghosttykit_framework_tree_sha256 "$framework" + +workflow="$repo_root/.github/workflows/build-ghosttykit.yml" +installer="$repo_root/scripts/install-verified-zig.sh" +grep -Fq "hashFiles('ghosttykit-lock.json')" "$workflow" || die "workflow cache is not keyed by the complete lock" +grep -Fq 'scripts/install-verified-zig.sh' "$workflow" || die "workflow does not use the shared verified Zig installer" +grep -Fq 'MORI_GHOSTTYKIT_ZIG_ARCHIVE_SHA256' "$installer" || die "shared Zig installer does not verify the locked archive digest" + +echo "✅ GhosttyKit contract accepts the candidate and rejects malformed, mismatched, and mutated fixtures" diff --git a/scripts/tests/test-ghosttykit-verify.sh b/scripts/tests/test-ghosttykit-verify.sh new file mode 100755 index 00000000..37f32531 --- /dev/null +++ b/scripts/tests/test-ghosttykit-verify.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Exercise the verifier against a disposable copy; never alter the ignored real framework. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source_framework="$repo_root/Frameworks/GhosttyKit.xcframework" +source_provenance="$repo_root/Frameworks/.ghosttykit-provenance" +[[ -d "$source_framework" && -f "$source_provenance" ]] || { + echo "Build the universal framework first: mise run build:ghostty-universal" >&2 + exit 1 +} + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/mori-ghosttykit-verify.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT +cp -R "$source_framework" "$tmp/GhosttyKit.xcframework" +cp "$source_provenance" "$tmp/.ghosttykit-provenance" + +MORI_GHOSTTYKIT_XCFRAMEWORK="$tmp/GhosttyKit.xcframework" "$repo_root/scripts/verify-ghosttykit.sh" +printf '\nmutation\n' >> "$tmp/GhosttyKit.xcframework/Info.plist" +if MORI_GHOSTTYKIT_XCFRAMEWORK="$tmp/GhosttyKit.xcframework" "$repo_root/scripts/verify-ghosttykit.sh"; then + echo "Verifier accepted a modified framework." >&2 + exit 1 +fi + +echo "✅ GhosttyKit verifier rejects a modified framework" diff --git a/scripts/tests/test-release-workflows.sh b/scripts/tests/test-release-workflows.sh new file mode 100755 index 00000000..0912d846 --- /dev/null +++ b/scripts/tests/test-release-workflows.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# Offline release-draft protocol and workflow structure tests. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/mori-release-workflows.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "Release workflow test failed: $*" >&2 + exit 1 +} + +expect_reject() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then + fail "accepted $description" + fi +} + +assert_contains() { + local path="$1" needle="$2" + grep -Fq -- "$needle" "$path" || fail "$path is missing $needle" +} + +assert_not_contains() { + local path="$1" needle="$2" + if grep -Fq -- "$needle" "$path"; then + fail "$path unexpectedly contains $needle" + fi +} + +assert_order() { + local path="$1" first="$2" second="$3" first_line second_line + first_line="$(grep -nF -- "$first" "$path" | head -1 | cut -d: -f1)" + second_line="$(grep -nF -- "$second" "$path" | head -1 | cut -d: -f1)" + [[ -n "$first_line" && -n "$second_line" && "$first_line" -lt "$second_line" ]] || \ + fail "$path does not place $first before $second" +} + +assets="$tmp/assets" +mkdir -p "$assets" "$tmp/bin" "$tmp/state" +printf 'archive bytes\n' >"$assets/Mori-test.zip" +printf '{"schemaVersion":1}\n' >"$assets/Mori-test.manifest.json" + +cat >"$tmp/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$GH_LOG" +[[ "$1" == api ]] || exit 64 +shift +endpoint="" +input="" +jq="" +method="GET" +field_ref="" +field_sha="" +while [[ $# -gt 0 ]]; do + case "$1" in + --method) method="$2"; shift ;; + --input) input="$2"; shift ;; + --jq) jq="$2"; shift ;; + -f|--raw-field) + case "$2" in + ref=*) field_ref="${2#ref=}" ;; + sha=*) field_sha="${2#sha=}" ;; + esac + shift + ;; + repos/*|https://uploads.github.com/repos/*) endpoint="$1" ;; + esac + shift +done + +if [[ "$endpoint" == */releases/tags/* ]]; then + if [[ "${GH_SCENARIO:-}" == tag-exists ]]; then + printf '{"id":9}\n' + exit 0 + fi + if [[ "${GH_SCENARIO:-}" == release-query-error ]]; then + echo 'gh: Service Unavailable (HTTP 503)' >&2 + exit 1 + fi + echo 'gh: Not Found (HTTP 404)' >&2 + exit 1 +fi +if [[ "$endpoint" == */git/refs && "$method" == POST ]]; then + [[ "${GH_SCENARIO:-}" != tag-race ]] || { echo 'gh: Reference already exists (HTTP 422)' >&2; exit 1; } + printf '%s\t%s\n' "$field_ref" "$field_sha" >"$GH_STATE/ref" + printf '{"ref":"%s","object":{"type":"commit","sha":"%s"}}\n' "$field_ref" "$field_sha" + exit 0 +fi +if [[ "$endpoint" == */git/refs/tags/* && "$method" == DELETE ]]; then + rm -f "$GH_STATE/ref" + exit 0 +fi +if [[ "$endpoint" == */git/ref/tags/* ]]; then + [[ "${GH_SCENARIO:-}" != ref-query-error ]] || { echo 'gh: Service Unavailable (HTTP 503)' >&2; exit 1; } + [[ -f "$GH_STATE/ref" ]] || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } + ref_sha="$(cut -f2 "$GH_STATE/ref")" + if [[ -n "$jq" ]]; then printf 'commit\t%s\n' "$ref_sha"; else printf '{"object":{"type":"commit","sha":"%s"}}\n' "$ref_sha"; fi + exit 0 +fi +if [[ "$endpoint" == */immutable-releases ]]; then + if [[ -n "$jq" ]]; then printf 'true\n'; else printf '{"enabled":true}\n'; fi + exit 0 +fi +if [[ "$endpoint" == */releases/latest ]]; then + printf 'v0.0.1\n' + exit 0 +fi +if [[ "$method" == POST && "$endpoint" == */releases ]]; then + : >"$GH_STATE/draft" + printf '{"id":42,"draft":true}\n' + exit 0 +fi +if [[ "$method" == POST && "$endpoint" == https://uploads.github.com/*/releases/42/assets?name=* ]]; then + name="${endpoint##*name=}" + digest="$(shasum -a 256 "$input" | awk '{print $1}')" + printf '%s\t%s\t%s\n' "$name" "$digest" "$input" >> "$GH_STATE/assets" + exit 0 +fi +if [[ "$endpoint" == */releases/assets/* ]]; then + asset_id="${endpoint##*/}" + line="$(sed -n "${asset_id}p" "$GH_STATE/assets")" + path="$(printf '%s' "$line" | cut -f3-)" + cat "$path" + exit 0 +fi +if [[ "$endpoint" == */releases/42 && "$method" == PATCH ]]; then + grep -Fq '"draft":false' "$input" + : >"$GH_STATE/published" + printf '{"id":42,"draft":false,"immutable":true}\n' + exit 0 +fi +if [[ "$endpoint" == */releases/42 && "$method" == DELETE ]]; then + : >"$GH_STATE/deleted" + exit 0 +fi +if [[ "$endpoint" == */releases/42 ]]; then + if [[ -n "$jq" ]]; then + printf 'true\n' + exit 0 + fi + python3 - "$GH_STATE/assets" "${GH_SCENARIO:-}" <<'PY' +import json +import sys +assets = [] +with open(sys.argv[1], encoding="utf-8") as stream: + for number, line in enumerate(stream, 1): + name, digest, _ = line.rstrip("\n").split("\t", 2) + if sys.argv[2] == "digest-mismatch": + digest = ("a" if digest[0] != "a" else "b") + digest[1:] + assets.append({"id": number, "name": name, "digest": "sha256:" + digest}) +print(json.dumps({"id": 42, "draft": True, "immutable": False, "assets": assets})) +PY + exit 0 +fi +echo "unexpected gh api request: $method $endpoint" >&2 +exit 65 +GH +chmod +x "$tmp/bin/gh" + +run_draft() { + env PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" "$repo_root/scripts/github-release-draft.sh" "$@" +} + +# A normal release verifies two server/downloaded asset digests before the sole publish request. +: >"$tmp/gh.log" +created="$(run_draft --create-draft --repo example/mori --tag v0.0.2 --target abc123 --title Test \ + --generate-notes --make-latest true --require-immutable \ + --asset "$assets/Mori-test.zip" --asset "$assets/Mori-test.manifest.json")" +[[ "$created" == 'release_id=42' ]] || fail "draft did not return its release id" +run_draft --publish-draft --repo example/mori --release-id 42 --require-immutable +[[ -f "$tmp/state/published" ]] || fail "draft was not published" +upload_line="$(grep -n '/assets?name=' "$tmp/gh.log" | tail -1 | cut -d: -f1)" +publish_line="$(grep -n 'PATCH repos/example/mori/releases/42' "$tmp/gh.log" | cut -d: -f1)" +[[ -n "$upload_line" && -n "$publish_line" && "$upload_line" -lt "$publish_line" ]] || fail "publish occurred before all uploads" +[[ "$(grep -c 'PATCH repos/example/mori/releases/42' "$tmp/gh.log")" -eq 1 ]] || fail "release was published more than once" + +# Existing tags are a hard stop and cannot create or overwrite a release. +rm -rf "$tmp/state" && mkdir -p "$tmp/state" +: >"$tmp/gh.log" +expect_reject "an existing tag" env GH_SCENARIO=tag-exists PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$repo_root/scripts/github-release-draft.sh" --create-draft --repo example/mori --tag v0.0.2 --target abc123 --title Test \ + --asset "$assets/Mori-test.zip" +assert_not_contains "$tmp/gh.log" 'POST repos/example/mori/releases' + +# A mismatched server digest cleans the still-mutable draft and never publishes it. +rm -rf "$tmp/state" && mkdir -p "$tmp/state" +: >"$tmp/gh.log" +expect_reject "a mismatched server digest" env GH_SCENARIO=digest-mismatch PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$repo_root/scripts/github-release-draft.sh" --create-draft --repo example/mori --tag v0.0.3 --target abc123 --title Test \ + --asset "$assets/Mori-test.zip" +assert_contains "$tmp/gh.log" 'DELETE repos/example/mori/releases/42' +assert_not_contains "$tmp/gh.log" 'PATCH repos/example/mori/releases/42' + +# A non-publishing preflight downloads both assets, proves Latest is stable, and deletes its draft. +rm -rf "$tmp/state" && mkdir -p "$tmp/state" +: >"$tmp/gh.log" +run_draft --preflight --repo example/mori --tag release-draft-preflight-1 --target abc123 --title Preflight \ + --make-latest false --download-assets --asset "$assets/Mori-test.zip" --asset "$assets/Mori-test.manifest.json" >/dev/null +assert_contains "$tmp/gh.log" 'repos/example/mori/releases/latest' +[[ "$(grep -c 'repos/example/mori/releases/latest' "$tmp/gh.log")" -eq 2 ]] || fail "preflight did not compare Latest before and after" +[[ "$(grep -c 'repos/example/mori/releases/assets/' "$tmp/gh.log")" -eq 2 ]] || fail "preflight did not download both assets" +assert_contains "$tmp/gh.log" 'DELETE repos/example/mori/releases/42' +assert_not_contains "$tmp/gh.log" 'PATCH repos/example/mori/releases/42' + +# Publisher atomically reserves a lightweight tag, verifies ownership, and only +# removes its own unpublished reservation. +tag_script="$repo_root/scripts/github-tag-reservation.sh" +reserved_sha="0123456789abcdef0123456789abcdef01234567" +rm -rf "$tmp/state" && mkdir -p "$tmp/state" +: >"$tmp/gh.log" +env PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --reserve --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" >/dev/null +env PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --verify --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" +env PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --cleanup --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" >/dev/null +[[ ! -f "$tmp/state/ref" ]] || fail "cleanup left the unpublished reserved tag" +rm -rf "$tmp/state" && mkdir -p "$tmp/state" +expect_reject "a tag reservation race" env GH_SCENARIO=tag-race PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --reserve --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" + +# A transient release lookup failure must preserve the reserved tag because the +# publish request may have succeeded server-side. +rm -rf "$tmp/state" && mkdir -p "$tmp/state" +: >"$tmp/gh.log" +env PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --reserve --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" >/dev/null +expect_reject "tag cleanup after an inconclusive release query" env GH_SCENARIO=release-query-error PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --cleanup --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" +[[ -f "$tmp/state/ref" ]] || fail "inconclusive release query deleted the reserved tag" +assert_not_contains "$tmp/gh.log" 'DELETE repos/example/mori/git/refs/tags/ghosttykit-deadbeef-r1' + +# Reservation ownership is recorded immediately after POST. If the following +# verification GET fails transiently, a recovered cleanup can still remove the +# unpublished tag. +rm -rf "$tmp/state" && mkdir -p "$tmp/state" +env PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --reserve --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" >/dev/null +expect_reject "a transient post-reservation ownership lookup" env GH_SCENARIO=ref-query-error PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --verify --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" +env PATH="$tmp/bin:$PATH" GH_LOG="$tmp/gh.log" GH_STATE="$tmp/state" \ + "$tag_script" --cleanup --repo example/mori --tag ghosttykit-deadbeef-r1 --sha "$reserved_sha" >/dev/null +[[ ! -f "$tmp/state/ref" ]] || fail "recovered cleanup left the unpublished reserved tag" + +release="$repo_root/.github/workflows/release.yml" +preflight="$repo_root/.github/workflows/release-draft-preflight.yml" +publisher="$repo_root/.github/workflows/publish-ghosttykit.yml" +assert_not_contains "$release" 'softprops/action-gh-release' +assert_contains "$release" '--require-immutable' +assert_order "$release" 'Create and verify draft release' 'Publish complete immutable release' +assert_order "$release" 'Publish complete immutable release' 'Resolve release metadata' +assert_not_contains "$preflight" '--publish-draft' +assert_contains "$preflight" '--download-assets' +assert_contains "$preflight" 'workflow_dispatch:' +source "$repo_root/scripts/ghosttykit-contract.sh" +ghosttykit_load_contract "$repo_root/ghosttykit-lock.json" +[[ "$MORI_GHOSTTYKIT_ARTIFACT_TAG" == "ghosttykit-${MORI_GHOSTTYKIT_SOURCE_COMMIT:0:8}-r${MORI_GHOSTTYKIT_BUILD_REVISION}" ]] || \ + fail "GhosttyKit tag is not derived from the lock source and revision" +[[ "$MORI_GHOSTTYKIT_ARTIFACT_NAME" == "GhosttyKit-${MORI_GHOSTTYKIT_SOURCE_COMMIT:0:8}-r${MORI_GHOSTTYKIT_BUILD_REVISION}-universal.zip" ]] || \ + fail "GhosttyKit asset name is not derived from the lock source and revision" +assert_contains "$publisher" 'environment: ghosttykit-publish' +assert_contains "$publisher" 'contents: write' +assert_contains "$publisher" 'id-token: write' +assert_contains "$publisher" 'attestations: write' +python3 - "$publisher" <<'PY' +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +match = re.search(r"^permissions:\n((?: [^\n]+\n)+)\nconcurrency:", text, re.M) +if not match: + raise SystemExit("publisher permissions block is missing or not top-level") +actual = {line.strip() for line in match.group(1).splitlines()} +expected = {"contents: write", "id-token: write", "attestations: write"} +if actual != expected: + raise SystemExit(f"publisher permissions must be exactly {expected}, got {actual}") +if "actions/cache" in text: + raise SystemExit("publisher must not restore a finished-framework cache") +PY +assert_contains "$publisher" 'submodules: recursive' +assert_contains "$publisher" 'fetch-depth: 0' +assert_contains "$publisher" 'git ls-remote --exit-code --tags' +assert_contains "$publisher" 'scripts/install-verified-zig.sh' +assert_contains "$publisher" 'scripts/build-ghostty.sh --clean --universal' +assert_contains "$publisher" 'actions/attest-build-provenance@v3' +assert_contains "$repo_root/scripts/github-release-draft.sh" 'https://uploads.github.com/repos/' +assert_order "$publisher" 'Attest GhosttyKit archive' 'Atomically reserve the release tag' +assert_order "$publisher" 'Atomically reserve the release tag' 'Verify reserved tag ownership' +assert_order "$publisher" 'Verify reserved tag ownership' 'Create and upload draft release' +assert_order "$publisher" 'Create and upload draft release' 'Verify server digests and uploaded bytes' +assert_order "$publisher" 'Verify server digests and uploaded bytes' 'Publish immutable GhosttyKit release once' +assert_contains "$publisher" 'Delete incomplete draft and reserved tag' +assert_contains "$release" 'Verify release event tag ownership' + +printf '✅ Release draft flow rejects overwrite, verifies multi-asset drafts, and publishes only once\n' diff --git a/scripts/verify-ghosttykit.sh b/scripts/verify-ghosttykit.sh new file mode 100755 index 00000000..9d87644c --- /dev/null +++ b/scripts/verify-ghosttykit.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Verify the locked universal GhosttyKit layout, provenance, and remux tmux C ABI. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=ghosttykit-contract.sh +source "$repo_root/scripts/ghosttykit-contract.sh" +ghosttykit_load_contract "$repo_root/ghosttykit-lock.json" + +xcframework="${MORI_GHOSTTYKIT_XCFRAMEWORK:-$repo_root/Frameworks/GhosttyKit.xcframework}" +framework_dir="$(dirname "$xcframework")" +provenance_path="${MORI_GHOSTTYKIT_PROVENANCE:-$framework_dir/.ghosttykit-provenance}" +if [[ -z "${MORI_GHOSTTYKIT_PROVENANCE:-}" && ! -f "$provenance_path" && -f "$framework_dir/ghosttykit-provenance.json" ]]; then + provenance_path="$framework_dir/ghosttykit-provenance.json" +fi + +fail() { + echo "GhosttyKit verification failed: $*" >&2 + exit 1 +} + +[[ -f "$xcframework/Info.plist" ]] || fail "missing Info.plist at $xcframework" +[[ -f "$provenance_path" ]] || fail "missing provenance record at $provenance_path" + +read_provenance() { + local key="$1" + if [[ "$provenance_path" == *.json ]]; then + python3 - "$provenance_path" "$key" <<'PY' +import json +import sys + +class DuplicateKey(ValueError): + pass + +def no_duplicates(pairs): + value = {} + for key, item in pairs: + if key in value: + raise DuplicateKey(key) + value[key] = item + return value + +path, key = sys.argv[1:] +with open(path, encoding="utf-8") as stream: + provenance = json.load(stream, object_pairs_hook=no_duplicates) +paths = { + "source_repository": ("source", "repository"), + "source_commit": ("source", "commit"), + "upstream_base_commit": ("source", "upstreamBaseCommit"), + "source_version": ("build", "ghosttyVersion"), + "build_revision": ("build", "revision"), + "zig_version": ("build", "zigVersion"), + "zig_archive_sha256": ("build", "zigArchiveSha256"), + "xcframework_target": ("build", "target"), + "optimize": ("build", "optimize"), + "build_mode": ("build", "buildMode"), + "minimum_ios_major": ("build", "minimumIOSMajor"), + "framework_tree_sha256": ("artifact", "frameworkTreeSha256"), +} +try: + parent, child = paths[key] + value = provenance[parent][child] +except (KeyError, TypeError, DuplicateKey) as error: + raise SystemExit(f"invalid provenance: {error}") +if isinstance(value, bool) or not isinstance(value, (str, int)): + raise SystemExit("invalid provenance value") +print(value) +PY + return + fi + + local value count + count="$(awk -F= -v key="$key" '$1 == key { count += 1; value = substr($0, length(key) + 2) } END { print count }' "$provenance_path")" + [[ "$count" == "1" ]] || fail "provenance is missing or duplicates $key" + value="$(awk -F= -v key="$key" '$1 == key { print substr($0, length(key) + 2); exit }' "$provenance_path")" + printf '%s\n' "$value" +} + +[[ "$(read_provenance source_repository)" == "$MORI_GHOSTTYKIT_SOURCE_REPOSITORY" ]] || fail "unexpected source repository" +[[ "$(read_provenance source_commit)" == "$MORI_GHOSTTYKIT_SOURCE_COMMIT" ]] || fail "unexpected source commit" +[[ "$(read_provenance upstream_base_commit)" == "$MORI_GHOSTTYKIT_BASE_COMMIT" ]] || fail "unexpected upstream base commit" +[[ "$(read_provenance source_version)" == "$MORI_GHOSTTYKIT_VERSION" ]] || fail "unexpected explicit source version" +[[ "$(read_provenance build_revision)" == "$MORI_GHOSTTYKIT_BUILD_REVISION" ]] || fail "unexpected build revision" +[[ "$(read_provenance zig_version)" == "$MORI_GHOSTTYKIT_ZIG_VERSION" ]] || fail "unexpected Zig version" +[[ "$(read_provenance zig_archive_sha256)" == "$MORI_GHOSTTYKIT_ZIG_ARCHIVE_SHA256" ]] || fail "unexpected Zig archive digest" +[[ "$(read_provenance xcframework_target)" == "$MORI_GHOSTTYKIT_TARGET" ]] || fail "artifact was not built with the locked target" +[[ "$(read_provenance optimize)" == "$MORI_GHOSTTYKIT_OPTIMIZE" ]] || fail "unexpected optimization mode" +[[ "$(read_provenance build_mode)" == "$MORI_GHOSTTYKIT_BUILD_MODE" ]] || fail "unexpected build mode provenance" +[[ "$(read_provenance minimum_ios_major)" == "$MORI_GHOSTTYKIT_MIN_IOS_MAJOR" ]] || fail "unexpected minimum iOS provenance" + +provenance_tree_sha256="$(read_provenance framework_tree_sha256)" +[[ "$provenance_tree_sha256" =~ ^[0-9a-f]{64}$ ]] || fail "missing framework content digest" +actual_tree_sha256="$(ghosttykit_framework_tree_sha256 "$xcframework")" || fail "unsafe framework tree" +[[ "$actual_tree_sha256" == "$provenance_tree_sha256" ]] || fail "framework files do not match the provenance content digest" +if [[ "$MORI_GHOSTTYKIT_ARTIFACT_STATE" == "published" ]]; then + [[ "$actual_tree_sha256" == "$MORI_GHOSTTYKIT_FRAMEWORK_TREE_SHA256" ]] || \ + fail "framework files do not match the final lock digest" +fi + +# Source builders validate the checkout before producing this artifact. Consumers +# need only the reviewed lock, provenance, and bytes; they may not clone the source. +if [[ "${MORI_GHOSTTYKIT_VERIFY_SOURCE:-0}" == "1" ]]; then + ghosttykit_validate_source "$repo_root" || fail "source checkout does not match the lock" +fi + +# This release must support macOS plus the locked iOS device and simulator SDKs. +for slice in macos-arm64_x86_64 ios-arm64 ios-arm64-simulator; do + headers="$xcframework/$slice/Headers" + [[ -f "$headers/ghostty.h" ]] || fail "missing $slice header" + [[ -f "$headers/module.modulemap" ]] || fail "missing $slice module map" +done + +macos_library="$xcframework/macos-arm64_x86_64/libghostty-internal-fat.a" +[[ -f "$macos_library" ]] || fail "missing macOS static library" +[[ "$(lipo -archs "$macos_library")" == *"arm64"* && "$(lipo -archs "$macos_library")" == *"x86_64"* ]] || fail "macOS slice is not universal" + +for slice in ios-arm64 ios-arm64-simulator; do + library="$xcframework/$slice/libghostty-internal-fat.a" + [[ -f "$library" ]] || fail "missing $slice static library" + [[ "$(lipo -archs "$library")" == *"arm64"* ]] || fail "$slice lacks arm64" +done + +plist_xml="$(plutil -extract AvailableLibraries xml1 -o - "$xcframework/Info.plist")" +printf '%s' "$plist_xml" | grep -q 'macos-arm64_x86_64' || fail "missing macOS metadata" +printf '%s' "$plist_xml" | grep -q 'ios-arm64' || fail "missing ios-arm64 metadata" +printf '%s' "$plist_xml" | grep -q 'ios-arm64-simulator' || fail "missing ios-arm64-simulator metadata" +printf '%s' "$plist_xml" | grep -q 'simulator' || fail "missing simulator platform variant" + +probe_source="$(mktemp "${TMPDIR:-/tmp}/mori-ghosttykit-abi.XXXXXX.c")" +trap 'rm -f "$probe_source" "${probe_source%.c}".*' EXIT +cat >"$probe_source" <<'SOURCE' +#include +int remux_tmux_abi_probe(void) { + ghostty_tmux_client_config_s config = ghostty_tmux_client_config_new(); + return (int)config.initial_columns + (int)ghostty_info().build_mode; +} +SOURCE +for sdk in iphoneos iphonesimulator; do + sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path)" + slice="ios-arm64" + target="arm64-apple-ios${MORI_GHOSTTYKIT_MIN_IOS_MAJOR}.0" + if [[ "$sdk" == "iphonesimulator" ]]; then + slice="ios-arm64-simulator" + target="arm64-apple-ios${MORI_GHOSTTYKIT_MIN_IOS_MAJOR}.0-simulator" + fi + xcrun --sdk "$sdk" clang -target "$target" \ + -isysroot "$sdk_path" -fmodules -I "$xcframework/$slice/Headers" \ + -c "$probe_source" -o "${probe_source%.c}.$sdk.o" +done + +# The iOS binaries cannot execute on the build host. The macOS slice supplies +# the runnable optimization-mode gate for the same universal artifact. +macos_probe="${probe_source%.c}.macos" +xcrun --sdk macosx clang -target "$(uname -m)-apple-macos15.0" \ + -I "$xcframework/macos-arm64_x86_64/Headers" -x c - -x none "$macos_library" \ + -lc++ -framework Foundation -framework AppKit -framework Metal \ + -framework QuartzCore -framework IOSurface -framework Carbon -o "$macos_probe" <<'SOURCE' +#include +#include +int main(void) { printf("%d\n", (int)ghostty_info().build_mode); } +SOURCE +[[ "$("$macos_probe")" == "$MORI_GHOSTTYKIT_BUILD_MODE" ]] || fail "artifact has an unexpected build mode" + +for library in "$macos_library" "$xcframework/ios-arm64/libghostty-internal-fat.a" "$xcframework/ios-arm64-simulator/libghostty-internal-fat.a"; do + symbols="$(nm -gU "$library")" + for symbol in _ghostty_info _ghostty_tmux_client_config_new _ghostty_tmux_client_new _ghostty_tmux_client_feed _ghostty_tmux_client_outbound _ghostty_tmux_client_consume; do + grep -q "${symbol}$" <<<"$symbols" || fail "$(basename "$(dirname "$library")") lacks $symbol" + done +done + +for slice in ios-arm64 ios-arm64-simulator; do + minos_values="$(otool -l "$xcframework/$slice/libghostty-internal-fat.a" | awk '/minos / { print $2 }' | sort -u)" + [[ -n "$minos_values" ]] || fail "$slice has no LC_BUILD_VERSION minimum OS metadata" + while IFS= read -r minos; do + major="${minos%%.*}" + [[ "$major" =~ ^[0-9]+$ && "$major" -le "$MORI_GHOSTTYKIT_MIN_IOS_MAJOR" ]] || \ + fail "$slice requires iOS $minos, above the lock contract" + done <<<"$minos_values" +done + +printf 'Verified locked GhosttyKit %s: universal macOS, iOS %s device/simulator arm64 slices, and remux tmux ABI.\n' \ + "$MORI_GHOSTTYKIT_SOURCE_COMMIT" "$MORI_GHOSTTYKIT_MIN_IOS_MAJOR" diff --git a/vendor/ghostty b/vendor/ghostty index b213a72c..aeb8f737 160000 --- a/vendor/ghostty +++ b/vendor/ghostty @@ -1 +1 @@ -Subproject commit b213a72c03b427607b43c89ff4223a7baa079fe8 +Subproject commit aeb8f73790946d9c9ad175b3dafaec9911ef36bb