From c80c8cf142c2f92798b811f5ce6a8f361d92006c Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Tue, 15 Sep 2026 16:55:45 +0100 Subject: [PATCH 1/6] fix: add fail-closed release finalizer --- .../workflows/finalize-prebuilt-release.yml | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 .github/workflows/finalize-prebuilt-release.yml diff --git a/.github/workflows/finalize-prebuilt-release.yml b/.github/workflows/finalize-prebuilt-release.yml new file mode 100644 index 0000000..9fb0e4a --- /dev/null +++ b/.github/workflows/finalize-prebuilt-release.yml @@ -0,0 +1,262 @@ +name: Finalize Prebuilt Client Release + +on: + workflow_dispatch: + inputs: + version: + description: Release version without the v prefix + required: true + type: string + release_tag: + description: Existing draft release tag + required: true + type: string + expected_source_sha: + description: Expected peeled source commit SHA + required: true + type: string + staging_asset: + description: Tar archive containing the prebuilt raw binaries + required: true + type: string + staging_sha256: + description: Expected SHA-256 of the staging archive + required: true + type: string + +permissions: + contents: write + +jobs: + finalize: + name: Verify, Apple-sign, archive-sign, and publish + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate immutable release contract + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.release_tag }} + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + STAGING_ASSET: ${{ inputs.staging_asset }} + STAGING_SHA256: ${{ inputs.staging_sha256 }} + run: | + set -euo pipefail + python3 - <<'PY' + import os, re, sys + + version = os.environ["VERSION"] + release_tag = os.environ["RELEASE_TAG"] + expected_sha = os.environ["EXPECTED_SOURCE_SHA"] + staging_asset = os.environ["STAGING_ASSET"] + staging_sha = os.environ["STAGING_SHA256"] + + if not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version): + sys.exit(f"invalid stable version: {version!r}") + if release_tag != f"v{version}": + sys.exit("release_tag must be v plus version") + if not re.fullmatch(r"[0-9a-f]{40}", expected_sha): + sys.exit("expected_source_sha must be lowercase 40-character hex") + if staging_asset != f"terraphim-clients-{version}-release-inputs.tar.gz": + sys.exit("staging_asset does not match the release version") + if not re.fullmatch(r"[0-9a-f]{64}", staging_sha): + sys.exit("staging_sha256 must be lowercase 64-character hex") + PY + + ref_json="$(gh api "repos/${{ github.repository }}/git/ref/tags/$RELEASE_TAG")" + object_sha="$(jq -r '.object.sha' <<<"$ref_json")" + object_type="$(jq -r '.object.type' <<<"$ref_json")" + while [ "$object_type" != commit ]; do + [ "$object_type" = tag ] || { + echo "ERROR: unsupported tag object type: $object_type" >&2 + exit 1 + } + tag_json="$(gh api "repos/${{ github.repository }}/git/tags/$object_sha")" + object_type="$(jq -r '.object.type' <<<"$tag_json")" + object_sha="$(jq -r '.object.sha' <<<"$tag_json")" + done + [ "$object_sha" = "$EXPECTED_SOURCE_SHA" ] || { + echo "ERROR: tag resolves to $object_sha, expected $EXPECTED_SOURCE_SHA" >&2 + exit 1 + } + + - name: Download and verify staging inputs + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.release_tag }} + STAGING_ASSET: ${{ inputs.staging_asset }} + STAGING_SHA256: ${{ inputs.staging_sha256 }} + run: | + set -euo pipefail + mkdir -p staging inputs + gh release download "$RELEASE_TAG" --pattern "$STAGING_ASSET" --dir staging + printf '%s %s\n' "$STAGING_SHA256" "staging/$STAGING_ASSET" | shasum -a 256 -c - + tar -xzf "staging/$STAGING_ASSET" -C inputs + + - name: Validate complete platform inventory + shell: bash + run: | + set -euo pipefail + targets=( + aarch64-apple-darwin + x86_64-apple-darwin + x86_64-unknown-linux-gnu + x86_64-unknown-linux-musl + aarch64-unknown-linux-musl + ) + bins=(terraphim-agent terraphim-cli terraphim-grep) + for target in "${targets[@]}"; do + for bin in "${bins[@]}"; do + path="inputs/$bin-$target" + [ -f "$path" ] || { echo "ERROR: missing $path" >&2; exit 1; } + [ -x "$path" ] || { echo "ERROR: not executable: $path" >&2; exit 1; } + done + done + for bin in "${bins[@]}"; do + path="inputs/$bin-x86_64-pc-windows-msvc.exe" + [ -f "$path" ] || { echo "ERROR: missing $path" >&2; exit 1; } + done + [ "$(lipo -archs inputs/terraphim-agent-aarch64-apple-darwin)" = arm64 ] + [ "$(lipo -archs inputs/terraphim-agent-x86_64-apple-darwin)" = x86_64 ] + file inputs/* | tee staging/file-inventory.txt + files=(inputs/*) + [ "${#files[@]}" = 18 ] || { + echo "ERROR: staging archive must contain exactly 18 binaries" >&2 + exit 1 + } + + - name: Create macOS universal binaries + shell: bash + run: | + set -euo pipefail + lipo -create \ + inputs/terraphim-agent-x86_64-apple-darwin \ + inputs/terraphim-agent-aarch64-apple-darwin \ + -output inputs/terraphim-agent-universal-apple-darwin + lipo -create \ + inputs/terraphim-grep-x86_64-apple-darwin \ + inputs/terraphim-grep-aarch64-apple-darwin \ + -output inputs/terraphim-grep-universal-apple-darwin + chmod 0755 inputs/terraphim-*-universal-apple-darwin + + - uses: 1password/install-cli-action@v2 + + - name: Load Apple credentials without exposing them + shell: bash + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + run: | + set -euo pipefail + [ -n "${OP_SERVICE_ACCOUNT_TOKEN:-}" ] || { + echo "ERROR: OP_SERVICE_ACCOUNT_TOKEN is unavailable" >&2 + exit 1 + } + { + echo "APPLE_ID=$(op read 'op://TerraphimPlatform/apple.developer.credentials/username' --no-newline)" + echo "APPLE_TEAM_ID=$(op read 'op://TerraphimPlatform/apple.developer.credentials/APPLE_TEAM_ID' --no-newline)" + echo "APPLE_APP_PASSWORD=$(op read 'op://TerraphimPlatform/apple.developer.credentials/APPLE_APP_SPECIFIC_PASSWORD' --no-newline)" + echo "CERT_BASE64=$(op read 'op://TerraphimPlatform/apple.developer.certificate/base64' --no-newline)" + echo "CERT_PASSWORD=$(op read 'op://TerraphimPlatform/apple.developer.certificate/password' --no-newline)" + } >> "$GITHUB_ENV" + + - name: Apple-sign and notarize every shipped macOS binary + shell: bash + env: + RUNNER_TEMP: ${{ runner.temp }} + run: | + set -euo pipefail + mac_binaries=( + inputs/terraphim-agent-aarch64-apple-darwin + inputs/terraphim-agent-x86_64-apple-darwin + inputs/terraphim-agent-universal-apple-darwin + inputs/terraphim-cli-aarch64-apple-darwin + inputs/terraphim-cli-x86_64-apple-darwin + inputs/terraphim-grep-aarch64-apple-darwin + inputs/terraphim-grep-x86_64-apple-darwin + inputs/terraphim-grep-universal-apple-darwin + ) + for binary in "${mac_binaries[@]}"; do + scripts/sign-macos-binary.sh \ + "$binary" "$APPLE_ID" "$APPLE_TEAM_ID" "$APPLE_APP_PASSWORD" \ + "$CERT_BASE64" "$CERT_PASSWORD" + done + + - name: Package and archive-sign release assets + shell: bash + env: + VERSION: ${{ inputs.version }} + ZIPSIGN_PRIVATE_KEY: ${{ secrets.ZIPSIGN_PRIVATE_KEY }} + run: | + set -euo pipefail + [ -n "${ZIPSIGN_PRIVATE_KEY:-}" ] || { + echo "ERROR: ZIPSIGN_PRIVATE_KEY is unavailable" >&2 + exit 1 + } + mkdir -p release-assets + unix_targets=( + aarch64-apple-darwin + x86_64-apple-darwin + x86_64-unknown-linux-gnu + x86_64-unknown-linux-musl + aarch64-unknown-linux-musl + ) + bins=(terraphim-agent terraphim-cli terraphim-grep) + for target in "${unix_targets[@]}"; do + for bin in "${bins[@]}"; do + cp "inputs/$bin-$target" "release-assets/$bin-$target" + tar -czf "release-assets/$bin-$VERSION-$target.tar.gz" \ + -C inputs "$bin-$target" + done + done + for bin in "${bins[@]}"; do + cp "inputs/$bin-x86_64-pc-windows-msvc.exe" \ + "release-assets/$bin-x86_64-pc-windows-msvc.exe" + ditto -c -k --keepParent \ + "inputs/$bin-x86_64-pc-windows-msvc.exe" \ + "release-assets/$bin-$VERSION-x86_64-pc-windows-msvc.zip" + done + cp inputs/terraphim-agent-universal-apple-darwin release-assets/ + cp inputs/terraphim-grep-universal-apple-darwin release-assets/ + cargo install zipsign --locked + scripts/sign-release-archives.sh release-assets + ( + cd release-assets + shasum -a 256 ./* > SHA256SUMS + ) + + - name: Generate stable manifests + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + for bin in terraphim-agent terraphim-cli terraphim-grep; do + scripts/build-manifest.sh "$VERSION" "$bin" release-assets \ + > "release-assets/$bin-stable.json" + done + + - name: Publish final assets and remove staging input + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.release_tag }} + STAGING_ASSET: ${{ inputs.staging_asset }} + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" release-assets/* --clobber + gh release delete-asset "$RELEASE_TAG" "$STAGING_ASSET" --yes + gh release edit "$RELEASE_TAG" --draft=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: client-release-finalization-evidence-${{ inputs.version }} + path: | + staging/file-inventory.txt + release-assets/SHA256SUMS + release-assets/*-stable.json + if-no-files-found: warn From f72b7d6999af54458a9e988de6c06f9da5b7a964 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Tue, 15 Sep 2026 17:03:59 +0100 Subject: [PATCH 2/6] fix: harden staged release validation --- .../workflows/finalize-prebuilt-release.yml | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/finalize-prebuilt-release.yml b/.github/workflows/finalize-prebuilt-release.yml index 9fb0e4a..ac43ed3 100644 --- a/.github/workflows/finalize-prebuilt-release.yml +++ b/.github/workflows/finalize-prebuilt-release.yml @@ -82,6 +82,11 @@ jobs: echo "ERROR: tag resolves to $object_sha, expected $EXPECTED_SOURCE_SHA" >&2 exit 1 } + release_json="$(gh api "repos/${{ github.repository }}/releases/tags/$RELEASE_TAG")" + [ "$(jq -r '.draft' <<<"$release_json")" = true ] || { + echo "ERROR: release $RELEASE_TAG must remain a draft until finalization succeeds" >&2 + exit 1 + } - name: Download and verify staging inputs shell: bash @@ -95,6 +100,31 @@ jobs: mkdir -p staging inputs gh release download "$RELEASE_TAG" --pattern "$STAGING_ASSET" --dir staging printf '%s %s\n' "$STAGING_SHA256" "staging/$STAGING_ASSET" | shasum -a 256 -c - + expected="$(mktemp)" + actual="$(mktemp)" + trap 'rm -f "$expected" "$actual"' EXIT + targets=( + aarch64-apple-darwin + x86_64-apple-darwin + x86_64-unknown-linux-gnu + x86_64-unknown-linux-musl + aarch64-unknown-linux-musl + ) + bins=(terraphim-agent terraphim-cli terraphim-grep) + for target in "${targets[@]}"; do + for bin in "${bins[@]}"; do + printf '%s\n' "$bin-$target" >> "$expected" + done + done + for bin in "${bins[@]}"; do + printf '%s\n' "$bin-x86_64-pc-windows-msvc.exe" >> "$expected" + done + LC_ALL=C sort -o "$expected" "$expected" + tar -tzf "staging/$STAGING_ASSET" | LC_ALL=C sort > "$actual" + diff -u "$expected" "$actual" || { + echo "ERROR: staging archive contains an unexpected path or inventory" >&2 + exit 1 + } tar -xzf "staging/$STAGING_ASSET" -C inputs - name: Validate complete platform inventory @@ -221,7 +251,7 @@ jobs: done cp inputs/terraphim-agent-universal-apple-darwin release-assets/ cp inputs/terraphim-grep-universal-apple-darwin release-assets/ - cargo install zipsign --locked + cargo install zipsign --version 0.2.1 --locked scripts/sign-release-archives.sh release-assets ( cd release-assets From 091233bb9cfa0696e2134c85272815bef513b75e Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Tue, 15 Sep 2026 17:06:31 +0100 Subject: [PATCH 3/6] fix: preserve canonical names inside release archives --- .github/workflows/finalize-prebuilt-release.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/finalize-prebuilt-release.yml b/.github/workflows/finalize-prebuilt-release.yml index ac43ed3..ce41b94 100644 --- a/.github/workflows/finalize-prebuilt-release.yml +++ b/.github/workflows/finalize-prebuilt-release.yml @@ -226,7 +226,7 @@ jobs: echo "ERROR: ZIPSIGN_PRIVATE_KEY is unavailable" >&2 exit 1 } - mkdir -p release-assets + mkdir -p release-assets package-root unix_targets=( aarch64-apple-darwin x86_64-apple-darwin @@ -238,16 +238,20 @@ jobs: for target in "${unix_targets[@]}"; do for bin in "${bins[@]}"; do cp "inputs/$bin-$target" "release-assets/$bin-$target" + cp "inputs/$bin-$target" "package-root/$bin" tar -czf "release-assets/$bin-$VERSION-$target.tar.gz" \ - -C inputs "$bin-$target" + -C package-root "$bin" + rm -f "package-root/$bin" done done for bin in "${bins[@]}"; do cp "inputs/$bin-x86_64-pc-windows-msvc.exe" \ "release-assets/$bin-x86_64-pc-windows-msvc.exe" + cp "inputs/$bin-x86_64-pc-windows-msvc.exe" "package-root/$bin.exe" ditto -c -k --keepParent \ - "inputs/$bin-x86_64-pc-windows-msvc.exe" \ + "package-root/$bin.exe" \ "release-assets/$bin-$VERSION-x86_64-pc-windows-msvc.zip" + rm -f "package-root/$bin.exe" done cp inputs/terraphim-agent-universal-apple-darwin release-assets/ cp inputs/terraphim-grep-universal-apple-darwin release-assets/ From fe981874abe0b3c7c5842631e7eaebb971699673 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Tue, 15 Sep 2026 17:56:17 +0100 Subject: [PATCH 4/6] fix(release): bind and verify v1.21.14 artifacts --- .github/release-inputs/v1.21.14.json | 42 +++ .../zipsign-primary-public-key.base64 | 1 + .../workflows/finalize-prebuilt-release.yml | 287 ++++++++++-------- scripts/build-manifest.sh | 30 +- scripts/sign-macos-binary.sh | 73 +++-- scripts/sign-release-archives.sh | 26 +- scripts/validate-release-inputs.py | 123 ++++++++ tests/test_release_finalizer_contract.py | 160 ++++++++++ 8 files changed, 570 insertions(+), 172 deletions(-) create mode 100644 .github/release-inputs/v1.21.14.json create mode 100644 .github/release-signing/zipsign-primary-public-key.base64 create mode 100755 scripts/validate-release-inputs.py create mode 100644 tests/test_release_finalizer_contract.py diff --git a/.github/release-inputs/v1.21.14.json b/.github/release-inputs/v1.21.14.json new file mode 100644 index 0000000..c9596e8 --- /dev/null +++ b/.github/release-inputs/v1.21.14.json @@ -0,0 +1,42 @@ +{ + "schema_version": 1, + "version": "1.21.14", + "release_tag": "v1.21.14", + "source_sha": "6161df6e550ead762da186b6beda36f0299eb4d7", + "staging_asset": "terraphim-clients-1.21.14-release-inputs.tar.gz", + "staging_sha256": "69457dde3e588f192a12b989db76705bb5c48a49a636d5506305a566565a7e41", + "builder": { + "build_checkout_sha": "f4afcdae653e476a1f6336b804323a080e2f313e", + "product_source_delta_from_tag": "none; post-tag commits changed release workflow and tests only", + "cargo_lock_sha256": "d3d0965ac068e1cc7a645f72edbd46c7a42bc932aa84942842223f85268324d1", + "apple_rust_toolchain": "rustc 1.98.1", + "windows_rust_toolchain": "rustc 1.98.1", + "cargo_xwin": "0.23.1", + "windows_sdk": "17", + "cross": "0.2.5 (65fe72b 2026-04-23)", + "cross_rust_toolchain": "rustc 1.95.0 (59807616e 2026-04-14)", + "x86_64_unknown_linux_gnu_image": "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:main@sha256:e3f7d4ee29f4198c22f84a8d05ab52ec209e7900bd394888b40ea81ca364ec6c", + "x86_64_unknown_linux_musl_image": "ghcr.io/cross-rs/x86_64-unknown-linux-musl:main@sha256:d54fdde7f1b680901a0bb21a2952e4921172b94c17e48603ccbbaeca8b5ef7e8", + "aarch64_unknown_linux_musl_image": "ghcr.io/cross-rs/aarch64-unknown-linux-musl:main@sha256:10304ec1a8b013544193a403a98b4547e959af1fbc22d1dad88e9ce2b3a9dde0" + }, + "binaries": [ + {"name": "terraphim-agent-aarch64-apple-darwin", "sha256": "441e3d0794d03b5fb14de714148e62d194b6da6aefe033acad2ca3434a8e3693"}, + {"name": "terraphim-agent-aarch64-unknown-linux-musl", "sha256": "4894123f0e2c969ab5275806ab02fbc4078cc6231669c1cb810f2ce45f74dd96"}, + {"name": "terraphim-agent-x86_64-apple-darwin", "sha256": "e9a958d0a8e342106575a90e239afcbf85466debe9691e958e4b8b8e87999f5c"}, + {"name": "terraphim-agent-x86_64-pc-windows-msvc.exe", "sha256": "e121dacd978d781fb690b6b7c8acaf6b290f51ceaca4b77e0934b476098167f2"}, + {"name": "terraphim-agent-x86_64-unknown-linux-gnu", "sha256": "4fa4a989fc8ce5be30ee5d83c73a8faa8752408a5e5875498772bad7ce402c17"}, + {"name": "terraphim-agent-x86_64-unknown-linux-musl", "sha256": "142135e7be634c774b32f9197ed746868447531352a080b1f9ee7992869a2aab"}, + {"name": "terraphim-cli-aarch64-apple-darwin", "sha256": "6089cd4ebb626ef00a62a4d49371134a99723de0fb784804c55e586d6dd8fde7"}, + {"name": "terraphim-cli-aarch64-unknown-linux-musl", "sha256": "402aeaf1217008715ab7042221e29e65d4b069373bd56fe879622c9f3eab0565"}, + {"name": "terraphim-cli-x86_64-apple-darwin", "sha256": "e16626f43cf6620c2d5acdb341c3c0d10b959952a832128535582940afa02c5c"}, + {"name": "terraphim-cli-x86_64-pc-windows-msvc.exe", "sha256": "b284c3281df3ac41db2bbc9d9d96625ea0ba6e382d29e5208b708080fa8c5354"}, + {"name": "terraphim-cli-x86_64-unknown-linux-gnu", "sha256": "090bc9798c449ddee38a219e9be3ced2b77dbff1944e135a4c12d4293771b218"}, + {"name": "terraphim-cli-x86_64-unknown-linux-musl", "sha256": "4eec28f4d0c84af8270307df9ab24a48ff5689acfd21a5b6cc5bfd372fe0c4d0"}, + {"name": "terraphim-grep-aarch64-apple-darwin", "sha256": "be5fb9eea90c2a5dc7d70875e891e2fe318d841fd42a2a196a14508c31841107"}, + {"name": "terraphim-grep-aarch64-unknown-linux-musl", "sha256": "1c8cb493052f4b483c52163e81fb12412e23b8a961e5eb0763d490b86696a9c3"}, + {"name": "terraphim-grep-x86_64-apple-darwin", "sha256": "33bbf7d0c632f069b130810e41238880bb4e1ac8e1b20b57b5ad2bb29aa9cf74"}, + {"name": "terraphim-grep-x86_64-pc-windows-msvc.exe", "sha256": "66dd5350bbc6ac9bcac69d45f3f6b31c284eba5815ceb82951d7649de4df8f45"}, + {"name": "terraphim-grep-x86_64-unknown-linux-gnu", "sha256": "f9896c54a95add5b915b6c79b99bb425b0ed7ae4d6677d624b4d6164679de96c"}, + {"name": "terraphim-grep-x86_64-unknown-linux-musl", "sha256": "532696f1805a18243a811b57cc4f509623720f02cbf9de482c8e1d8c9ffe1934"} + ] +} diff --git a/.github/release-signing/zipsign-primary-public-key.base64 b/.github/release-signing/zipsign-primary-public-key.base64 new file mode 100644 index 0000000..52f2860 --- /dev/null +++ b/.github/release-signing/zipsign-primary-public-key.base64 @@ -0,0 +1 @@ +iW2sM72/09yfiQ3jMB2GBALCRN+1FLLgD5qBbISFfS0= diff --git a/.github/workflows/finalize-prebuilt-release.yml b/.github/workflows/finalize-prebuilt-release.yml index ce41b94..555825e 100644 --- a/.github/workflows/finalize-prebuilt-release.yml +++ b/.github/workflows/finalize-prebuilt-release.yml @@ -4,69 +4,87 @@ on: workflow_dispatch: inputs: version: - description: Release version without the v prefix - required: true - type: string - release_tag: - description: Existing draft release tag - required: true - type: string - expected_source_sha: - description: Expected peeled source commit SHA - required: true - type: string - staging_asset: - description: Tar archive containing the prebuilt raw binaries - required: true - type: string - staging_sha256: - description: Expected SHA-256 of the staging archive + description: Reviewed release version without the v prefix required: true type: string permissions: contents: write +concurrency: + group: terraphim-client-release-${{ inputs.version }} + cancel-in-progress: false + jobs: finalize: name: Verify, Apple-sign, archive-sign, and publish + if: >- + github.repository == 'terraphim/terraphim-clients' && + github.ref == 'refs/heads/main' + environment: tsm-production-release runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Validate immutable release contract + - name: Load review-bound release contract shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.version }} - RELEASE_TAG: ${{ inputs.release_tag }} - EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} - STAGING_ASSET: ${{ inputs.staging_asset }} - STAGING_SHA256: ${{ inputs.staging_sha256 }} run: | set -euo pipefail + [ "$GITHUB_REF" = refs/heads/main ] || { + echo "ERROR: releases may run only from refs/heads/main" >&2 + exit 1 + } + [ "$(git rev-parse HEAD)" = "$GITHUB_SHA" ] || { + echo "ERROR: checkout does not match the reviewed workflow commit" >&2 + exit 1 + } python3 - <<'PY' import os, re, sys version = os.environ["VERSION"] - release_tag = os.environ["RELEASE_TAG"] - expected_sha = os.environ["EXPECTED_SOURCE_SHA"] - staging_asset = os.environ["STAGING_ASSET"] - staging_sha = os.environ["STAGING_SHA256"] - if not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version): sys.exit(f"invalid stable version: {version!r}") - if release_tag != f"v{version}": - sys.exit("release_tag must be v plus version") - if not re.fullmatch(r"[0-9a-f]{40}", expected_sha): - sys.exit("expected_source_sha must be lowercase 40-character hex") - if staging_asset != f"terraphim-clients-{version}-release-inputs.tar.gz": - sys.exit("staging_asset does not match the release version") - if not re.fullmatch(r"[0-9a-f]{64}", staging_sha): - sys.exit("staging_sha256 must be lowercase 64-character hex") PY - ref_json="$(gh api "repos/${{ github.repository }}/git/ref/tags/$RELEASE_TAG")" + contract=".github/release-inputs/v$VERSION.json" + [ -f "$contract" ] || { + echo "ERROR: no reviewed release contract for v$VERSION" >&2 + exit 1 + } + jq -e --arg version "$VERSION" ' + .schema_version == 1 and + .version == $version and + .release_tag == ("v" + $version) and + (.source_sha | test("^[0-9a-f]{40}$")) and + (.staging_asset == ("terraphim-clients-" + $version + "-release-inputs.tar.gz")) and + (.staging_sha256 | test("^[0-9a-f]{64}$")) and + (.builder.cargo_lock_sha256 | test("^[0-9a-f]{64}$")) and + (.binaries | length == 18) and + ([.binaries[].name] | unique | length == 18) and + all(.binaries[]; + (.name | test("^terraphim-(agent|cli|grep)-(aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu|x86_64-unknown-linux-musl|aarch64-unknown-linux-musl)$|^terraphim-(agent|cli|grep)-x86_64-pc-windows-msvc\\.exe$")) and + (.sha256 | test("^[0-9a-f]{64}$")) + ) + ' "$contract" >/dev/null + + { + echo "RELEASE_CONTRACT=$contract" + echo "RELEASE_TAG=$(jq -r '.release_tag' "$contract")" + echo "EXPECTED_SOURCE_SHA=$(jq -r '.source_sha' "$contract")" + echo "STAGING_ASSET=$(jq -r '.staging_asset' "$contract")" + echo "STAGING_SHA256=$(jq -r '.staging_sha256' "$contract")" + } >> "$GITHUB_ENV" + + - name: Validate immutable source and draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" object_sha="$(jq -r '.object.sha' <<<"$ref_json")" object_type="$(jq -r '.object.type' <<<"$ref_json")" while [ "$object_type" != commit ]; do @@ -74,7 +92,7 @@ jobs: echo "ERROR: unsupported tag object type: $object_type" >&2 exit 1 } - tag_json="$(gh api "repos/${{ github.repository }}/git/tags/$object_sha")" + tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object_sha")" object_type="$(jq -r '.object.type' <<<"$tag_json")" object_sha="$(jq -r '.object.sha' <<<"$tag_json")" done @@ -82,98 +100,73 @@ jobs: echo "ERROR: tag resolves to $object_sha, expected $EXPECTED_SOURCE_SHA" >&2 exit 1 } - release_json="$(gh api "repos/${{ github.repository }}/releases/tags/$RELEASE_TAG")" + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" [ "$(jq -r '.draft' <<<"$release_json")" = true ] || { echo "ERROR: release $RELEASE_TAG must remain a draft until finalization succeeds" >&2 exit 1 } - - name: Download and verify staging inputs + - name: Download, securely extract, and hash-check build inputs shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ inputs.release_tag }} - STAGING_ASSET: ${{ inputs.staging_asset }} - STAGING_SHA256: ${{ inputs.staging_sha256 }} run: | set -euo pipefail - mkdir -p staging inputs + mkdir -p staging gh release download "$RELEASE_TAG" --pattern "$STAGING_ASSET" --dir staging printf '%s %s\n' "$STAGING_SHA256" "staging/$STAGING_ASSET" | shasum -a 256 -c - - expected="$(mktemp)" - actual="$(mktemp)" - trap 'rm -f "$expected" "$actual"' EXIT - targets=( - aarch64-apple-darwin - x86_64-apple-darwin - x86_64-unknown-linux-gnu - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl - ) - bins=(terraphim-agent terraphim-cli terraphim-grep) - for target in "${targets[@]}"; do - for bin in "${bins[@]}"; do - printf '%s\n' "$bin-$target" >> "$expected" - done - done - for bin in "${bins[@]}"; do - printf '%s\n' "$bin-x86_64-pc-windows-msvc.exe" >> "$expected" - done - LC_ALL=C sort -o "$expected" "$expected" - tar -tzf "staging/$STAGING_ASSET" | LC_ALL=C sort > "$actual" - diff -u "$expected" "$actual" || { - echo "ERROR: staging archive contains an unexpected path or inventory" >&2 - exit 1 - } - tar -xzf "staging/$STAGING_ASSET" -C inputs + scripts/validate-release-inputs.py \ + --contract "$RELEASE_CONTRACT" \ + --archive "staging/$STAGING_ASSET" \ + --destination inputs - - name: Validate complete platform inventory + - name: Validate every binary format and native command surface shell: bash + env: + VERSION: ${{ inputs.version }} run: | set -euo pipefail - targets=( - aarch64-apple-darwin - x86_64-apple-darwin - x86_64-unknown-linux-gnu - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl - ) bins=(terraphim-agent terraphim-cli terraphim-grep) - for target in "${targets[@]}"; do - for bin in "${bins[@]}"; do - path="inputs/$bin-$target" - [ -f "$path" ] || { echo "ERROR: missing $path" >&2; exit 1; } - [ -x "$path" ] || { echo "ERROR: not executable: $path" >&2; exit 1; } - done - done for bin in "${bins[@]}"; do - path="inputs/$bin-x86_64-pc-windows-msvc.exe" - [ -f "$path" ] || { echo "ERROR: missing $path" >&2; exit 1; } + [ "$(lipo -archs "inputs/$bin-aarch64-apple-darwin")" = arm64 ] + [ "$(lipo -archs "inputs/$bin-x86_64-apple-darwin")" = x86_64 ] + for target in x86_64-unknown-linux-gnu x86_64-unknown-linux-musl; do + description="$(file -b "inputs/$bin-$target")" + [[ "$description" == *"ELF 64-bit"* && "$description" == *"x86-64"* ]] || { + echo "ERROR: unexpected $target format for $bin: $description" >&2 + exit 1 + } + done + description="$(file -b "inputs/$bin-aarch64-unknown-linux-musl")" + [[ "$description" == *"ELF 64-bit"* && "$description" == *"ARM aarch64"* ]] || { + echo "ERROR: unexpected aarch64 Linux format for $bin: $description" >&2 + exit 1 + } + description="$(file -b "inputs/$bin-x86_64-pc-windows-msvc.exe")" + [[ "$description" == *"PE32+ executable"* && "$description" == *"x86-64"* ]] || { + echo "ERROR: unexpected Windows format for $bin: $description" >&2 + exit 1 + } done - [ "$(lipo -archs inputs/terraphim-agent-aarch64-apple-darwin)" = arm64 ] - [ "$(lipo -archs inputs/terraphim-agent-x86_64-apple-darwin)" = x86_64 ] file inputs/* | tee staging/file-inventory.txt - files=(inputs/*) - [ "${#files[@]}" = 18 ] || { - echo "ERROR: staging archive must contain exactly 18 binaries" >&2 - exit 1 - } - - name: Create macOS universal binaries - shell: bash - run: | - set -euo pipefail - lipo -create \ - inputs/terraphim-agent-x86_64-apple-darwin \ - inputs/terraphim-agent-aarch64-apple-darwin \ - -output inputs/terraphim-agent-universal-apple-darwin - lipo -create \ - inputs/terraphim-grep-x86_64-apple-darwin \ - inputs/terraphim-grep-aarch64-apple-darwin \ - -output inputs/terraphim-grep-universal-apple-darwin - chmod 0755 inputs/terraphim-*-universal-apple-darwin + for bin in "${bins[@]}"; do + lipo -create \ + "inputs/$bin-x86_64-apple-darwin" \ + "inputs/$bin-aarch64-apple-darwin" \ + -output "inputs/$bin-universal-apple-darwin" + chmod 0755 "inputs/$bin-universal-apple-darwin" + reported="$("inputs/$bin-universal-apple-darwin" --version | tail -n 1 | awk '{print $NF}')" + [ "$reported" = "$VERSION" ] || { + echo "ERROR: $bin reports $reported, expected $VERSION" >&2 + exit 1 + } + done + inputs/terraphim-agent-universal-apple-darwin learn --help >/dev/null + inputs/terraphim-agent-universal-apple-darwin memory --help >/dev/null + inputs/terraphim-agent-universal-apple-darwin sessions expand --help >/dev/null - - uses: 1password/install-cli-action@v2 + - uses: 1password/install-cli-action@c1b138d5779f64eda6936d5caa8e754b9f3996c0 # v2 - name: Load Apple credentials without exposing them shell: bash @@ -199,20 +192,12 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} run: | set -euo pipefail - mac_binaries=( - inputs/terraphim-agent-aarch64-apple-darwin - inputs/terraphim-agent-x86_64-apple-darwin - inputs/terraphim-agent-universal-apple-darwin - inputs/terraphim-cli-aarch64-apple-darwin - inputs/terraphim-cli-x86_64-apple-darwin - inputs/terraphim-grep-aarch64-apple-darwin - inputs/terraphim-grep-x86_64-apple-darwin - inputs/terraphim-grep-universal-apple-darwin - ) - for binary in "${mac_binaries[@]}"; do - scripts/sign-macos-binary.sh \ - "$binary" "$APPLE_ID" "$APPLE_TEAM_ID" "$APPLE_APP_PASSWORD" \ - "$CERT_BASE64" "$CERT_PASSWORD" + for target in aarch64-apple-darwin x86_64-apple-darwin universal-apple-darwin; do + for bin in terraphim-agent terraphim-cli terraphim-grep; do + scripts/sign-macos-binary.sh \ + "inputs/$bin-$target" "$APPLE_ID" "$APPLE_TEAM_ID" \ + "$APPLE_APP_PASSWORD" "$CERT_BASE64" "$CERT_PASSWORD" + done done - name: Package and archive-sign release assets @@ -230,6 +215,7 @@ jobs: unix_targets=( aarch64-apple-darwin x86_64-apple-darwin + universal-apple-darwin x86_64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-musl @@ -244,6 +230,9 @@ jobs: rm -f "package-root/$bin" done done + # Windows v1.21.14 artifacts are manual-download packages. The tagged + # updater cannot verify ZIP signatures, so Windows is deliberately + # omitted from stable manifests until that source defect is fixed. for bin in "${bins[@]}"; do cp "inputs/$bin-x86_64-pc-windows-msvc.exe" \ "release-assets/$bin-x86_64-pc-windows-msvc.exe" @@ -253,16 +242,10 @@ jobs: "release-assets/$bin-$VERSION-x86_64-pc-windows-msvc.zip" rm -f "package-root/$bin.exe" done - cp inputs/terraphim-agent-universal-apple-darwin release-assets/ - cp inputs/terraphim-grep-universal-apple-darwin release-assets/ cargo install zipsign --version 0.2.1 --locked scripts/sign-release-archives.sh release-assets - ( - cd release-assets - shasum -a 256 ./* > SHA256SUMS - ) - - name: Generate stable manifests + - name: Generate manifests and final checksums shell: bash env: VERSION: ${{ inputs.version }} @@ -272,20 +255,56 @@ jobs: scripts/build-manifest.sh "$VERSION" "$bin" release-assets \ > "release-assets/$bin-stable.json" done + ( + cd release-assets + shasum -a 256 ./* > SHA256SUMS + ) - - name: Publish final assets and remove staging input + - name: Upload and byte-verify draft assets shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ inputs.release_tag }} - STAGING_ASSET: ${{ inputs.staging_asset }} run: | set -euo pipefail + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + [ "$(jq -r '.draft' <<<"$release_json")" = true ] || { + echo "ERROR: release ceased to be a draft before mutation" >&2 + exit 1 + } gh release upload "$RELEASE_TAG" release-assets/* --clobber + mkdir -p remote-assets + gh release download "$RELEASE_TAG" --dir remote-assets + for local_asset in release-assets/*; do + remote_asset="remote-assets/$(basename "$local_asset")" + [ -f "$remote_asset" ] || { + echo "ERROR: remote asset missing: $(basename "$local_asset")" >&2 + exit 1 + } + cmp "$local_asset" "$remote_asset" + done + + - name: Publish atomically and verify final inventory + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail gh release delete-asset "$RELEASE_TAG" "$STAGING_ASSET" --yes - gh release edit "$RELEASE_TAG" --draft=false + if ! gh release edit "$RELEASE_TAG" --draft=false; then + gh release upload "$RELEASE_TAG" "staging/$STAGING_ASSET" --clobber + echo "ERROR: publication failed; staging asset restored for a safe retry" >&2 + exit 1 + fi + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + [ "$(jq -r '.draft' <<<"$release_json")" = false ] + expected="$(mktemp)" + actual="$(mktemp)" + trap 'rm -f "$expected" "$actual"' EXIT + for asset in release-assets/*; do basename "$asset"; done | LC_ALL=C sort > "$expected" + jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort > "$actual" + diff -u "$expected" "$actual" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: client-release-finalization-evidence-${{ inputs.version }} diff --git a/scripts/build-manifest.sh b/scripts/build-manifest.sh index d2047a6..f683ada 100755 --- a/scripts/build-manifest.sh +++ b/scripts/build-manifest.sh @@ -17,14 +17,28 @@ artifacts_dir="$3" release_url="https://github.com/terraphim/terraphim-clients/releases/tag/v${version}" -# Build the assets JSON object: { "": "/", ... } -assets=$(cd "$artifacts_dir" && ls -1 "${bin}-${version}-"*.tar.gz 2>/dev/null | while read -r f; do - # strip prefix "--" and suffix ".tar.gz" to get the target - tgt="${f#"${bin}-${version}-"}" - tgt="${tgt%.tar.gz}" - # escape for JSON - printf ' "%s": "%s/%s"' "$tgt" "$bin" "$f" -done | paste -sd, -) +unix_targets=( + aarch64-apple-darwin + x86_64-apple-darwin + universal-apple-darwin + x86_64-unknown-linux-gnu + x86_64-unknown-linux-musl + aarch64-unknown-linux-musl +) + +assets="" +for target in "${unix_targets[@]}"; do + filename="${bin}-${version}-${target}.tar.gz" + [ -f "$artifacts_dir/$filename" ] || { + echo "ERROR: missing manifest asset: $artifacts_dir/$filename" >&2 + exit 1 + } + entry=$(printf ' "%s": "%s/%s"' "$target" "$bin" "$filename") + if [ -n "$assets" ]; then + assets="$assets,"$'\n' + fi + assets="$assets$entry" +done cat < Signing and notarizing: $(basename "$BINARY_PATH")" # Create temporary keychain KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db" KEYCHAIN_PASS=$(openssl rand -base64 32) +CERT_PATH="$RUNNER_TEMP/certificate.p12" +ZIP_PATH="${BINARY_PATH}.zip" + +cleanup() { + rm -f "$CERT_PATH" "$ZIP_PATH" + security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true +} +trap cleanup EXIT echo "==> Creating temporary keychain" security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" @@ -25,7 +33,6 @@ security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" # Import certificate echo "==> Importing certificate" -CERT_PATH="$RUNNER_TEMP/certificate.p12" # Remove newlines from base64 before decoding (macOS base64 is strict) echo "$CERT_BASE64" | tr -d '\n' | base64 --decode > "$CERT_PATH" @@ -62,38 +69,56 @@ echo "==> Verifying signature" codesign --verify --deep --strict --verbose=2 "$BINARY_PATH" # Create ZIP for notarization -ZIP_PATH="${BINARY_PATH}.zip" echo "==> Creating ZIP for notarization" ditto -c -k --keepParent "$BINARY_PATH" "$ZIP_PATH" -# Submit for notarization +# Submit for notarization and bind all later evidence to this exact submission. echo "==> Submitting for notarization" -xcrun notarytool submit "$ZIP_PATH" \ +SUBMISSION_JSON=$(xcrun notarytool submit "$ZIP_PATH" \ --apple-id "$APPLE_ID" \ --team-id "$TEAM_ID" \ --password "$APP_PASS" \ - --wait - -# Check notarization status -echo "==> Checking notarization status" -SUBMISSION_ID=$(xcrun notarytool history \ - --apple-id "$APPLE_ID" \ - --team-id "$TEAM_ID" \ - --password "$APP_PASS" \ - | grep -m1 "id:" | awk '{print $2}') - -xcrun notarytool log "$SUBMISSION_ID" \ - --apple-id "$APPLE_ID" \ - --team-id "$TEAM_ID" \ - --password "$APP_PASS" - -# Verify with spctl -echo "==> Verifying Gatekeeper acceptance" -spctl --assess --type execute --verbose "$BINARY_PATH" || true + --wait \ + --output-format json) +printf '%s\n' "$SUBMISSION_JSON" + +read -r SUBMISSION_ID SUBMISSION_STATUS < <( + python3 -c 'import json,sys; data=json.load(sys.stdin); print(data["id"], data["status"])' \ + <<<"$SUBMISSION_JSON" +) +if [ "$SUBMISSION_STATUS" != "Accepted" ]; then + echo "ERROR: notarization submission $SUBMISSION_ID returned $SUBMISSION_STATUS" >&2 + exit 1 +fi + +# Apple's accepted submission log may lag briefly. Retrieve it with a bounded +# retry; never fall back to global history, which can select another binary's ID. +echo "==> Retrieving notarization log for $SUBMISSION_ID" +log_ok=false +for attempt in 1 2 3 4 5; do + if xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$APPLE_ID" \ + --team-id "$TEAM_ID" \ + --password "$APP_PASS"; then + log_ok=true + break + fi + echo "Notarization log not ready (attempt $attempt/5)" >&2 + sleep 5 +done +if [ "$log_ok" != true ]; then + echo "ERROR: notarization log unavailable for accepted submission $SUBMISSION_ID" >&2 + exit 1 +fi + +# Gatekeeper's application-policy assessment returns "does not seem to be an +# app" for standalone CLI binaries. For this artifact type, strict codesign +# verification above plus the exact Accepted notarization submission are the +# fail-closed proof. # Cleanup echo "==> Cleaning up" -rm -f "$CERT_PATH" "$ZIP_PATH" -security delete-keychain "$KEYCHAIN_PATH" || true +cleanup +trap - EXIT echo "✅ Successfully signed and notarized: $(basename "$BINARY_PATH")" diff --git a/scripts/sign-release-archives.sh b/scripts/sign-release-archives.sh index b821010..68ed0b8 100755 --- a/scripts/sign-release-archives.sh +++ b/scripts/sign-release-archives.sh @@ -12,8 +12,9 @@ # ZIPSIGN_PRIVATE_KEY= scripts/sign-release-archives.sh # # Signs every *.tar.gz in in place (zipsign appends the -# signature trailer to the archive) and verifies each with the public half of -# the same key. Exits non-zero on any failure so CI fails closed. +# signature trailer to the archive) and verifies each with the public key +# pinned in this repository and embedded by released clients. Exits non-zero +# on any failure so CI fails closed. # set -euo pipefail @@ -23,6 +24,8 @@ if [ "$#" -lt 1 ]; then fi ARTIFACTS_DIR="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PINNED_PUBLIC_KEY_FILE="$SCRIPT_DIR/../.github/release-signing/zipsign-primary-public-key.base64" if [ -z "${ZIPSIGN_PRIVATE_KEY:-}" ]; then echo "ERROR: ZIPSIGN_PRIVATE_KEY env var is not set" >&2 exit 2 @@ -37,12 +40,13 @@ fi # to the raw 64-byte zipsign format). Cleaned up on exit. KEY_FILE="$(mktemp)" PUB_FILE="$(mktemp)" -trap 'rm -f "$KEY_FILE" "$PUB_FILE"' EXIT +TRUSTED_PUB_FILE="$(mktemp)" +trap 'rm -f "$KEY_FILE" "$PUB_FILE" "$TRUSTED_PUB_FILE"' EXIT chmod 600 "$KEY_FILE" base64 -d <<< "$ZIPSIGN_PRIVATE_KEY" > "$KEY_FILE" -# Derive the matching public key (last 32 bytes of the 64-byte private key) so -# verification always uses the exact counterpart of the signing key. +# Derive the public key (last 32 bytes of the 64-byte private key) only to prove +# that the supplied signing secret matches the separately pinned trust root. tail -c 32 "$KEY_FILE" > "$PUB_FILE" if [ "$(stat -c %s "$KEY_FILE" 2>/dev/null || stat -f %z "$KEY_FILE")" -ne 64 ]; then @@ -50,6 +54,16 @@ if [ "$(stat -c %s "$KEY_FILE" 2>/dev/null || stat -f %z "$KEY_FILE")" -ne 64 ]; exit 2 fi +[ -f "$PINNED_PUBLIC_KEY_FILE" ] || { + echo "ERROR: pinned zipsign public key is missing" >&2 + exit 2 +} +base64 -d < "$PINNED_PUBLIC_KEY_FILE" > "$TRUSTED_PUB_FILE" +if ! cmp -s "$PUB_FILE" "$TRUSTED_PUB_FILE"; then + echo "ERROR: ZIPSIGN_PRIVATE_KEY does not match the client-trusted primary key" >&2 + exit 2 +fi + shopt -s nullglob archives=( "$ARTIFACTS_DIR"/*.tar.gz ) if [ "${#archives[@]}" -eq 0 ]; then @@ -66,7 +80,7 @@ for archive in "${archives[@]}"; do exit 1 fi # Fail-closed: verify the just-signed archive before accepting it. - if ! zipsign verify tar "$archive" "$PUB_FILE"; then + if ! zipsign verify tar "$archive" "$TRUSTED_PUB_FILE"; then echo "ERROR: post-sign verification failed for $name" >&2 exit 1 fi diff --git a/scripts/validate-release-inputs.py b/scripts/validate-release-inputs.py new file mode 100755 index 0000000..890dc8b --- /dev/null +++ b/scripts/validate-release-inputs.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Validate and extract review-bound, prebuilt release inputs.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import sys +import tarfile + + +MAX_BINARY_BYTES = 512 * 1024 * 1024 +BINARIES = ("terraphim-agent", "terraphim-cli", "terraphim-grep") +UNIX_TARGETS = ( + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", +) + + +class ValidationError(ValueError): + """Raised when a release input bundle violates its reviewed contract.""" + + +def expected_binary_names() -> set[str]: + """Return the exact supported release-input inventory.""" + names = { + f"{binary}-{target}" + for binary in BINARIES + for target in UNIX_TARGETS + } + names.update( + f"{binary}-x86_64-pc-windows-msvc.exe" for binary in BINARIES + ) + return names + + +def load_expected_hashes(contract_path: pathlib.Path) -> dict[str, str]: + """Load and structurally validate binary hashes from a release contract.""" + contract = json.loads(contract_path.read_text()) + binaries = contract.get("binaries") + if not isinstance(binaries, list): + raise ValidationError("contract binaries must be a list") + expected: dict[str, str] = {} + for item in binaries: + if not isinstance(item, dict): + raise ValidationError("contract binary entries must be objects") + name = item.get("name") + digest = item.get("sha256") + if not isinstance(name, str) or not isinstance(digest, str): + raise ValidationError("contract binary name and sha256 must be strings") + if name in expected: + raise ValidationError(f"duplicate contract binary: {name}") + if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest): + raise ValidationError(f"invalid SHA-256 for contract binary: {name}") + expected[name] = digest + if set(expected) != expected_binary_names(): + missing = sorted(expected_binary_names() - set(expected)) + extra = sorted(set(expected) - expected_binary_names()) + raise ValidationError(f"contract inventory mismatch: missing={missing}, extra={extra}") + return expected + + +def validate_and_extract( + contract_path: pathlib.Path, + archive_path: pathlib.Path, + destination: pathlib.Path, +) -> None: + """Validate an archive against its contract and extract regular files only.""" + expected = load_expected_hashes(contract_path) + if destination.exists(): + raise ValidationError(f"destination already exists: {destination}") + destination.mkdir(parents=True) + + with tarfile.open(archive_path, "r:gz") as bundle: + members = bundle.getmembers() + names = [member.name for member in members] + if len(names) != len(set(names)): + raise ValidationError("duplicate staging archive member") + if set(names) != set(expected): + missing = sorted(set(expected) - set(names)) + extra = sorted(set(names) - set(expected)) + raise ValidationError(f"staging inventory mismatch: missing={missing}, extra={extra}") + for member in members: + if not member.isfile(): + raise ValidationError(f"non-regular staging member rejected: {member.name}") + if member.size <= 0 or member.size > MAX_BINARY_BYTES: + raise ValidationError(f"invalid staging member size: {member.name}") + source = bundle.extractfile(member) + if source is None: + raise ValidationError(f"cannot read staging member: {member.name}") + target = destination / member.name + digest = hashlib.sha256() + with source, target.open("xb") as output: + while chunk := source.read(1024 * 1024): + digest.update(chunk) + output.write(chunk) + target.chmod(0o755) + if digest.hexdigest() != expected[member.name]: + raise ValidationError(f"digest mismatch: {member.name}") + + +def main() -> int: + """Run the command-line validator.""" + parser = argparse.ArgumentParser() + parser.add_argument("--contract", type=pathlib.Path, required=True) + parser.add_argument("--archive", type=pathlib.Path, required=True) + parser.add_argument("--destination", type=pathlib.Path, required=True) + args = parser.parse_args() + try: + validate_and_extract(args.contract, args.archive, args.destination) + except (OSError, json.JSONDecodeError, tarfile.TarError, ValidationError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_finalizer_contract.py b/tests/test_release_finalizer_contract.py new file mode 100644 index 0000000..4169638 --- /dev/null +++ b/tests/test_release_finalizer_contract.py @@ -0,0 +1,160 @@ +import base64 +import hashlib +import importlib.util +import json +import pathlib +import re +import subprocess +import tarfile +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/finalize-prebuilt-release.yml" +CONTRACT = ROOT / ".github/release-inputs/v1.21.14.json" +PINNED_KEY = ROOT / ".github/release-signing/zipsign-primary-public-key.base64" +UPDATER_SIGNATURES = ROOT / "crates/terraphim_update/src/signature.rs" +VALIDATOR_PATH = ROOT / "scripts/validate-release-inputs.py" +VALIDATOR_SOURCE = VALIDATOR_PATH.read_text() + +SPEC = importlib.util.spec_from_file_location("release_input_validator", VALIDATOR_PATH) +assert SPEC is not None and SPEC.loader is not None +VALIDATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VALIDATOR) + + +class ReleaseFinalizerContractTests(unittest.TestCase): + def test_contract_has_exact_binary_matrix_and_unique_hashes(self): + contract = json.loads(CONTRACT.read_text()) + binaries = contract["binaries"] + names = {item["name"] for item in binaries} + expected = set() + for binary in ("terraphim-agent", "terraphim-cli", "terraphim-grep"): + for target in ( + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + ): + expected.add(f"{binary}-{target}") + expected.add(f"{binary}-x86_64-pc-windows-msvc.exe") + + self.assertEqual(names, expected) + self.assertEqual(len(binaries), 18) + self.assertTrue(all(re.fullmatch(r"[0-9a-f]{64}", item["sha256"]) for item in binaries)) + self.assertTrue(re.fullmatch(r"[0-9a-f]{40}", contract["source_sha"])) + self.assertTrue(re.fullmatch(r"[0-9a-f]{64}", contract["staging_sha256"])) + + def test_workflow_is_main_only_review_bound_and_fail_closed(self): + workflow = WORKFLOW.read_text() + required_fragments = ( + "github.ref == 'refs/heads/main'", + "environment: tsm-production-release", + "Load review-bound release contract", + "Validate immutable source and draft release", + "scripts/validate-release-inputs.py", + "Apple-sign and notarize every shipped macOS binary", + "Upload and byte-verify draft assets", + "Publish atomically and verify final inventory", + ) + for fragment in required_fragments: + self.assertIn(fragment, workflow) + self.assertIn("non-regular staging member rejected", VALIDATOR_SOURCE) + self.assertIn("digest mismatch", VALIDATOR_SOURCE) + + action_refs = re.findall(r"^\s*- uses: [^@\s]+@([^\s]+)", workflow, re.MULTILINE) + self.assertGreaterEqual(len(action_refs), 3) + self.assertTrue(all(re.fullmatch(r"[0-9a-f]{40}", ref) for ref in action_refs)) + + def test_archive_signer_uses_the_client_trusted_primary_key(self): + pinned = PINNED_KEY.read_text().strip() + self.assertEqual(len(base64.b64decode(pinned, validate=True)), 32) + updater = UPDATER_SIGNATURES.read_text() + embedded = re.search( + r"EMBEDDED_PUBLIC_KEYS.*?=\s*&\[(.*?)\];", updater, re.DOTALL + ) + self.assertIsNotNone(embedded) + keys = re.findall(r'"([A-Za-z0-9+/]{43}=)"', embedded.group(1)) + self.assertGreaterEqual(len(keys), 1) + self.assertEqual(pinned, keys[0]) + + def test_real_staging_bundle_matches_reviewed_contract(self): + archive = pathlib.Path( + "/private/tmp/terraphim-clients-1.21.14-release-inputs.tar.gz" + ) + if not archive.exists(): + self.skipTest("local release staging bundle is unavailable") + with tempfile.TemporaryDirectory() as temporary: + destination = pathlib.Path(temporary) / "inputs" + VALIDATOR.validate_and_extract(CONTRACT, archive, destination) + self.assertEqual( + {path.name for path in destination.iterdir()}, + VALIDATOR.expected_binary_names(), + ) + + def test_validator_rejects_a_symlink_even_with_an_expected_name(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + source = root / "source" + source.mkdir() + payload = b"binary" + digest = hashlib.sha256(payload).hexdigest() + names = sorted(VALIDATOR.expected_binary_names()) + for name in names: + (source / name).write_bytes(payload) + symlink_name = names[0] + (source / symlink_name).unlink() + (source / symlink_name).symlink_to(source / names[1]) + contract = root / "contract.json" + contract.write_text(json.dumps({ + "binaries": [{"name": name, "sha256": digest} for name in names] + })) + archive = root / "malicious.tar.gz" + with tarfile.open(archive, "w:gz") as bundle: + for name in names: + bundle.add(source / name, arcname=name, recursive=False) + destination = root / "output" + with self.assertRaisesRegex(VALIDATOR.ValidationError, "non-regular"): + VALIDATOR.validate_and_extract(contract, archive, destination) + + def test_manifest_builder_emits_valid_complete_json(self): + with tempfile.TemporaryDirectory() as temporary: + artifacts = pathlib.Path(temporary) + manifest_targets = VALIDATOR.UNIX_TARGETS + ("universal-apple-darwin",) + for target in manifest_targets: + (artifacts / f"terraphim-agent-1.21.14-{target}.tar.gz").write_bytes(b"x") + result = subprocess.run( + [ + str(ROOT / "scripts/build-manifest.sh"), + "1.21.14", + "terraphim-agent", + str(artifacts), + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + manifest = json.loads(result.stdout) + self.assertEqual(manifest["version"], "1.21.14") + self.assertEqual(set(manifest["assets"]), set(manifest_targets)) + + def test_manifest_builder_fails_when_a_target_is_missing(self): + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(ROOT / "scripts/build-manifest.sh"), + "1.21.14", + "terraphim-agent", + temporary, + ], + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("missing manifest asset", result.stderr) + + +if __name__ == "__main__": + unittest.main() From 6385da80ecbf263f9d30330b276b17f5f3c14e40 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Tue, 15 Sep 2026 18:06:22 +0100 Subject: [PATCH 5/6] fix(release): verify inventory before publication --- .../workflows/finalize-prebuilt-release.yml | 30 ++++++++++++++++--- crates/terraphim_update/README.md | 8 ++++- scripts/sign-macos-binary.sh | 14 +++++++-- tests/test_release_finalizer_contract.py | 5 ++++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/.github/workflows/finalize-prebuilt-release.yml b/.github/workflows/finalize-prebuilt-release.yml index 555825e..1e47703 100644 --- a/.github/workflows/finalize-prebuilt-release.yml +++ b/.github/workflows/finalize-prebuilt-release.yml @@ -282,6 +282,20 @@ jobs: } cmp "$local_asset" "$remote_asset" done + expected_draft="$(mktemp)" + actual_draft="$(mktemp)" + trap 'rm -f "$expected_draft" "$actual_draft"' EXIT + { + for asset in release-assets/*; do basename "$asset"; done + printf '%s\n' "$STAGING_ASSET" + } | LC_ALL=C sort > "$expected_draft" + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + [ "$(jq -r '.draft' <<<"$release_json")" = true ] + jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort > "$actual_draft" + diff -u "$expected_draft" "$actual_draft" || { + echo "ERROR: unexpected draft release inventory before publication" >&2 + exit 1 + } - name: Publish atomically and verify final inventory shell: bash @@ -290,6 +304,18 @@ jobs: run: | set -euo pipefail gh release delete-asset "$RELEASE_TAG" "$STAGING_ASSET" --yes + expected="$(mktemp)" + actual="$(mktemp)" + trap 'rm -f "$expected" "$actual"' EXIT + for asset in release-assets/*; do basename "$asset"; done | LC_ALL=C sort > "$expected" + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + [ "$(jq -r '.draft' <<<"$release_json")" = true ] + jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort > "$actual" + if ! diff -u "$expected" "$actual"; then + gh release upload "$RELEASE_TAG" "staging/$STAGING_ASSET" --clobber + echo "ERROR: final inventory changed; staging restored and release kept draft" >&2 + exit 1 + fi if ! gh release edit "$RELEASE_TAG" --draft=false; then gh release upload "$RELEASE_TAG" "staging/$STAGING_ASSET" --clobber echo "ERROR: publication failed; staging asset restored for a safe retry" >&2 @@ -297,10 +323,6 @@ jobs: fi release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" [ "$(jq -r '.draft' <<<"$release_json")" = false ] - expected="$(mktemp)" - actual="$(mktemp)" - trap 'rm -f "$expected" "$actual"' EXIT - for asset in release-assets/*; do basename "$asset"; done | LC_ALL=C sort > "$expected" jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort > "$actual" diff -u "$expected" "$actual" diff --git a/crates/terraphim_update/README.md b/crates/terraphim_update/README.md index 67f60f3..e9a76dc 100644 --- a/crates/terraphim_update/README.md +++ b/crates/terraphim_update/README.md @@ -15,12 +15,18 @@ This crate provides a unified interface for self-updating Terraphim AI CLI tools ## Features - Automatic update detection from GitHub Releases -- Safe self-update with signature verification (PGP) +- Safe self-update with embedded Ed25519 signature verification - Backup and rollback support - Configurable update intervals - Tokio-based async scheduler for background checks - Cross-platform support (Linux, macOS, Windows) +> **v1.21.14 Windows note:** The signed automatic-update manifests cover Linux +> and macOS. Windows v1.21.14 binaries are available as manual downloads, but +> this version of the Windows updater cannot verify ZIP signatures and therefore +> will not install them automatically. A later client release will restore +> signed Windows automatic updates. + ## Usage ### Basic Update Check diff --git a/scripts/sign-macos-binary.sh b/scripts/sign-macos-binary.sh index bf1d996..afeef99 100755 --- a/scripts/sign-macos-binary.sh +++ b/scripts/sign-macos-binary.sh @@ -49,10 +49,20 @@ security set-key-partition-list \ "$KEYCHAIN_PATH" # Add keychain to search list -security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed s/\"//g) +EXISTING_KEYCHAINS=() +while IFS= read -r keychain; do + keychain="${keychain//\"/}" + EXISTING_KEYCHAINS+=("$keychain") +done < <(security list-keychains -d user) +security list-keychains -d user -s "$KEYCHAIN_PATH" "${EXISTING_KEYCHAINS[@]}" # Find signing identity -SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') +SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \ + | awk -F'"' '/Developer ID Application/{print $2; exit}') +[ -n "$SIGNING_IDENTITY" ] || { + echo "ERROR: Developer ID Application identity was not imported" >&2 + exit 1 +} echo "==> Found signing identity: $SIGNING_IDENTITY" # Sign the binary diff --git a/tests/test_release_finalizer_contract.py b/tests/test_release_finalizer_contract.py index 4169638..2b6552a 100644 --- a/tests/test_release_finalizer_contract.py +++ b/tests/test_release_finalizer_contract.py @@ -57,6 +57,7 @@ def test_workflow_is_main_only_review_bound_and_fail_closed(self): "scripts/validate-release-inputs.py", "Apple-sign and notarize every shipped macOS binary", "Upload and byte-verify draft assets", + "unexpected draft release inventory before publication", "Publish atomically and verify final inventory", ) for fragment in required_fragments: @@ -67,6 +68,10 @@ def test_workflow_is_main_only_review_bound_and_fail_closed(self): action_refs = re.findall(r"^\s*- uses: [^@\s]+@([^\s]+)", workflow, re.MULTILINE) self.assertGreaterEqual(len(action_refs), 3) self.assertTrue(all(re.fullmatch(r"[0-9a-f]{40}", ref) for ref in action_refs)) + self.assertLess( + workflow.index("unexpected draft release inventory before publication"), + workflow.index('gh release edit "$RELEASE_TAG" --draft=false'), + ) def test_archive_signer_uses_the_client_trusted_primary_key(self): pinned = PINNED_KEY.read_text().strip() From 38b4b30fd0f25b2707b0b0daead17027eacde0c4 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Tue, 15 Sep 2026 18:11:37 +0100 Subject: [PATCH 6/6] fix(release): reconcile ambiguous publication results --- .../workflows/finalize-prebuilt-release.yml | 59 +++++++++++-------- tests/test_release_finalizer_contract.py | 22 ++++++- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/.github/workflows/finalize-prebuilt-release.yml b/.github/workflows/finalize-prebuilt-release.yml index 1e47703..dc2dbd7 100644 --- a/.github/workflows/finalize-prebuilt-release.yml +++ b/.github/workflows/finalize-prebuilt-release.yml @@ -166,32 +166,23 @@ jobs: inputs/terraphim-agent-universal-apple-darwin memory --help >/dev/null inputs/terraphim-agent-universal-apple-darwin sessions expand --help >/dev/null - - uses: 1password/install-cli-action@c1b138d5779f64eda6936d5caa8e754b9f3996c0 # v2 - - - name: Load Apple credentials without exposing them - shell: bash - env: - OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} - run: | - set -euo pipefail - [ -n "${OP_SERVICE_ACCOUNT_TOKEN:-}" ] || { - echo "ERROR: OP_SERVICE_ACCOUNT_TOKEN is unavailable" >&2 - exit 1 - } - { - echo "APPLE_ID=$(op read 'op://TerraphimPlatform/apple.developer.credentials/username' --no-newline)" - echo "APPLE_TEAM_ID=$(op read 'op://TerraphimPlatform/apple.developer.credentials/APPLE_TEAM_ID' --no-newline)" - echo "APPLE_APP_PASSWORD=$(op read 'op://TerraphimPlatform/apple.developer.credentials/APPLE_APP_SPECIFIC_PASSWORD' --no-newline)" - echo "CERT_BASE64=$(op read 'op://TerraphimPlatform/apple.developer.certificate/base64' --no-newline)" - echo "CERT_PASSWORD=$(op read 'op://TerraphimPlatform/apple.developer.certificate/password' --no-newline)" - } >> "$GITHUB_ENV" - - name: Apple-sign and notarize every shipped macOS binary shell: bash env: RUNNER_TEMP: ${{ runner.temp }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + CERT_BASE64: ${{ secrets.CERT_BASE64 }} + CERT_PASSWORD: ${{ secrets.CERT_PASSWORD }} run: | set -euo pipefail + for required in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD CERT_BASE64 CERT_PASSWORD; do + [ -n "${!required:-}" ] || { + echo "ERROR: environment-scoped secret $required is unavailable" >&2 + exit 1 + } + done for target in aarch64-apple-darwin x86_64-apple-darwin universal-apple-darwin; do for bin in terraphim-agent terraphim-cli terraphim-grep; do scripts/sign-macos-binary.sh \ @@ -316,12 +307,32 @@ jobs: echo "ERROR: final inventory changed; staging restored and release kept draft" >&2 exit 1 fi + publication_state="" if ! gh release edit "$RELEASE_TAG" --draft=false; then - gh release upload "$RELEASE_TAG" "staging/$STAGING_ASSET" --clobber - echo "ERROR: publication failed; staging asset restored for a safe retry" >&2 - exit 1 + if ! publication_state="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")"; then + echo "ERROR: publication result is ambiguous; no recovery mutation attempted" >&2 + exit 1 + fi + case "$(jq -r '.draft' <<<"$publication_state")" in + true) + gh release upload "$RELEASE_TAG" "staging/$STAGING_ASSET" --clobber + echo "ERROR: publication definitively failed; staging restored" >&2 + exit 1 + ;; + false) + echo "WARN: publish command failed after GitHub committed publication; verifying state" >&2 + ;; + *) + echo "ERROR: publication state is unknown; no recovery mutation attempted" >&2 + exit 1 + ;; + esac + fi + if [ -n "$publication_state" ]; then + release_json="$publication_state" + else + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" fi - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" [ "$(jq -r '.draft' <<<"$release_json")" = false ] jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort > "$actual" diff -u "$expected" "$actual" diff --git a/tests/test_release_finalizer_contract.py b/tests/test_release_finalizer_contract.py index 2b6552a..959d19d 100644 --- a/tests/test_release_finalizer_contract.py +++ b/tests/test_release_finalizer_contract.py @@ -66,12 +66,32 @@ def test_workflow_is_main_only_review_bound_and_fail_closed(self): self.assertIn("digest mismatch", VALIDATOR_SOURCE) action_refs = re.findall(r"^\s*- uses: [^@\s]+@([^\s]+)", workflow, re.MULTILINE) - self.assertGreaterEqual(len(action_refs), 3) + self.assertGreaterEqual(len(action_refs), 2) self.assertTrue(all(re.fullmatch(r"[0-9a-f]{40}", ref) for ref in action_refs)) + self.assertNotIn("OP_SERVICE_ACCOUNT_TOKEN", workflow) + for secret in ( + "APPLE_ID", + "APPLE_TEAM_ID", + "APPLE_APP_PASSWORD", + "CERT_BASE64", + "CERT_PASSWORD", + "ZIPSIGN_PRIVATE_KEY", + ): + self.assertIn(f"secrets.{secret}", workflow) self.assertLess( workflow.index("unexpected draft release inventory before publication"), workflow.index('gh release edit "$RELEASE_TAG" --draft=false'), ) + failed_publish = workflow.index('if ! gh release edit "$RELEASE_TAG" --draft=false') + state_query = workflow.index('if ! publication_state="$(gh api', failed_publish) + restore_staging = workflow.index( + 'gh release upload "$RELEASE_TAG" "staging/$STAGING_ASSET" --clobber', + failed_publish, + ) + self.assertLess(state_query, restore_staging) + self.assertIn("publication result is ambiguous; no recovery mutation attempted", workflow) + self.assertIn("publication state is unknown; no recovery mutation attempted", workflow) + self.assertIn("publish command failed after GitHub committed publication", workflow) def test_archive_signer_uses_the_client_trusted_primary_key(self): pinned = PINNED_KEY.read_text().strip()