From 677771beb011f95e2a8e37f70edb4d4f5bc9c2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Mon, 31 Aug 2026 22:27:41 +0800 Subject: [PATCH] release: require notarized artifacts for TokenStep 0.2.13 --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 126 +++++++++++++++++--- README.md | 25 ++-- docs/RELEASE.md | 48 ++++---- docs/RELEASE_NOTES_0.2.13.md | 17 +++ script/build_swiftui_and_run.sh | 2 +- script/package_release.sh | 181 +++++++++++++++++------------ script/test_release_contract.sh | 91 +++++++++++++++ script/verify_release_artifacts.sh | 104 +++++++++++++++++ script/verify_update_installer.sh | 7 +- 10 files changed, 472 insertions(+), 131 deletions(-) create mode 100644 docs/RELEASE_NOTES_0.2.13.md create mode 100755 script/test_release_contract.sh create mode 100755 script/verify_release_artifacts.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7b5aae..8b25174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 + - name: Check release safety contract + run: ./script/test_release_contract.sh - name: Run Swift tests run: swift test --package-path TokenStepSwift - name: Run usage collector fixture checks diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 553362c..7081f58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,18 +4,60 @@ on: workflow_dispatch: inputs: version: - description: "Release version, for example 0.1.0" + description: "Release version, for example 0.2.13" required: true type: string permissions: contents: write +concurrency: + group: tokenstep-release + cancel-in-progress: false + jobs: release: runs-on: macos-15 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate release request + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid release version: $VERSION" >&2 + exit 2 + fi + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Releases must run from main, got $GITHUB_REF" >&2 + exit 2 + fi + git fetch origin main --tags + if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then + echo "Workflow checkout is not the current origin/main." >&2 + exit 1 + fi + if [[ ! -f "docs/RELEASE_NOTES_$VERSION.md" ]]; then + echo "Missing docs/RELEASE_NOTES_$VERSION.md" >&2 + exit 1 + fi + grep -F "# TokenStep $VERSION" "docs/RELEASE_NOTES_$VERSION.md" + grep -F "TokenStep-$VERSION.dmg" README.md + grep -F "VERSION=\"\${TOKENSTEP_VERSION:-$VERSION}\"" script/build_swiftui_and_run.sh + if gh release view "v$VERSION" >/dev/null 2>&1; then + echo "Release v$VERSION already exists." >&2 + exit 1 + fi + if git ls-remote --exit-code origin "refs/tags/v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists." >&2 + exit 1 + fi - name: Import Developer ID certificate env: @@ -34,7 +76,7 @@ jobs: security list-keychain -d user -s "$KEYCHAIN_PATH" security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - name: Build, sign, notarize, and package + - name: Build, sign, notarize, and verify locally env: TOKENSTEP_VERSION: ${{ inputs.version }} CODE_SIGN_IDENTITY: ${{ secrets.CODE_SIGN_IDENTITY }} @@ -43,22 +85,72 @@ jobs: APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} run: ./script/package_release.sh --notarize - - name: Create GitHub Release + - name: Create verified draft release env: GH_TOKEN: ${{ github.token }} VERSION: ${{ inputs.version }} run: | - NOTES_FILE="docs/RELEASE_NOTES_$VERSION.md" - if [[ -f "$NOTES_FILE" ]]; then - gh release create "v$VERSION" \ - "release/TokenStep-$VERSION.dmg" \ - "release/TokenStep-$VERSION.zip" \ - --title "TokenStep $VERSION" \ - --notes-file "$NOTES_FILE" - else - gh release create "v$VERSION" \ - "release/TokenStep-$VERSION.dmg" \ - "release/TokenStep-$VERSION.zip" \ - --title "TokenStep $VERSION" \ - --notes "TokenStep $VERSION" - fi + set -euo pipefail + gh release create "v$VERSION" \ + "release/TokenStep-$VERSION.dmg" \ + "release/TokenStep-$VERSION.zip" \ + "release/TokenStep-$VERSION-SHA256SUMS.txt" \ + --draft \ + --latest=false \ + --fail-on-no-commits \ + --target "$GITHUB_SHA" \ + --title "TokenStep $VERSION" \ + --notes-file "docs/RELEASE_NOTES_$VERSION.md" + + - name: Download and verify draft assets + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + DOWNLOAD_DIR="$RUNNER_TEMP/tokenstep-release-download" + mkdir -p "$DOWNLOAD_DIR" + gh release download "v$VERSION" --dir "$DOWNLOAD_DIR" + ( + cd "$DOWNLOAD_DIR" + shasum -a 256 --check --strict "TokenStep-$VERSION-SHA256SUMS.txt" + ) + TOKENSTEP_EXPECTED_TEAM_ID="$APPLE_TEAM_ID" \ + ./script/verify_release_artifacts.sh \ + "$DOWNLOAD_DIR/TokenStep-$VERSION.dmg" \ + "$DOWNLOAD_DIR/TokenStep-$VERSION.zip" \ + "$VERSION" + ./script/verify_update_installer.sh \ + "$DOWNLOAD_DIR/TokenStep-$VERSION.dmg" \ + "$VERSION" \ + "TokenStepSwift/dist/TokenStep.app/Contents/Helpers/TokenStepHelper" + + - name: Publish verified release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: gh release edit "v$VERSION" --draft=false --latest + + - name: Verify public release state and links + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + [[ "$(gh release view "v$VERSION" --json isDraft --jq .isDraft)" == "false" ]] + [[ "$(gh release view "v$VERSION" --json isPrerelease --jq .isPrerelease)" == "false" ]] + ASSET_NAMES="$(gh release view "v$VERSION" --json assets --jq '.assets[].name')" + for expected in \ + "TokenStep-$VERSION.dmg" \ + "TokenStep-$VERSION.zip" \ + "TokenStep-$VERSION-SHA256SUMS.txt"; do + grep -Fx "$expected" <<<"$ASSET_NAMES" + done + curl --fail --silent --show-error --location --head --retry 5 \ + "https://github.com/Backtthefuture/TokenStep/releases/download/v$VERSION/TokenStep-$VERSION.dmg" \ + >/dev/null + curl --fail --silent --show-error --location --head --retry 5 \ + "https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-$VERSION.dmg" \ + >/dev/null + gh release view "v$VERSION" --json tagName,publishedAt,url,assets diff --git a/README.md b/README.md index aa337d7..11defc9 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ TokenStep 是一个 macOS 菜单栏 App,用来本地统计你在 Codex、Claud 下载最新版 DMG,打开后把 `TokenStep.app` 拖进「应用程序」即可使用: -[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.12.dmg) +[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.13.dmg) 也可以从 Release 页面查看所有版本: @@ -32,6 +32,10 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时 Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases +## 0.2.13 更新:修复自动更新的 Apple 公证门禁 + +0.2.13 修复了 0.2.12 因发布包遗漏 Apple 公证票据而无法自动安装的问题。新版已经过 Developer ID 签名、Apple 公证、App/DMG 票据装订、macOS 分发策略检查和隔离更新安装验证;不需要关闭 Gatekeeper 或更改系统安全设置。发布流程也改为“先草稿上传、回下载验收,再公开”,未公证产物无法进入公开 Release。完整说明见 [0.2.13 发布说明](docs/RELEASE_NOTES_0.2.13.md)。 + ## 0.2.12 更新:找回特洛伊火海的火焰动效 0.2.12 找回了 0.2.11 中意外丢失的「特洛伊火海」火焰动效:火焰 shader 改由场景更新循环驱动的自定义时钟供能,不再依赖在常驻渲染架构下会冻结的内置时间变量。火焰在每次打开浮层时持续燃烧,多次开关后依然保持动画,0.2.11 修复的开浮层闪烁不会回归。完整说明见 [0.2.12 发布说明](docs/RELEASE_NOTES_0.2.12.md)。 @@ -146,7 +150,7 @@ TokenStep 第一次从“更换配色”升级为完整的**主题皮肤包系 - 新增奥德赛弓箭阶梯 Logo;用量采用骨金、冷金或余烬橙,绿色只保留给同步成功等状态反馈。 - 关闭排行榜后浮层会自动收短,多来源额度则使用紧凑布局完整展示。 -打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.11.dmg)。 +打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.13.dmg)。 ## TokenStep 适合谁? @@ -205,7 +209,7 @@ TokenStep 默认只做本地统计。 ## 安装方式 -1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.11.dmg)。 +1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.13.dmg)。 2. 打开 DMG。 3. 把 `TokenStep.app` 拖到「应用程序」。 4. 启动 TokenStep。 @@ -262,18 +266,10 @@ TokenStepSwift/dist/TokenStep.app ## 发布打包 -Developer ID 签名: - -```bash -TOKENSTEP_VERSION=0.2.11 \ -CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ -./script/package_release.sh -``` - -签名 + Apple 公证: +公开发布强制执行 Developer ID 签名、Apple 公证、票据装订、系统分发检查和隔离安装验证。不再生成可被误上传的“仅签名、未公证”发布包: ```bash -TOKENSTEP_VERSION=0.2.11 \ +TOKENSTEP_VERSION=0.2.13 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize @@ -284,11 +280,12 @@ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ```text release/TokenStep-.zip release/TokenStep-.dmg +release/TokenStep--SHA256SUMS.txt ``` 维护者说明见 [docs/RELEASE.md](docs/RELEASE.md)。 -0.2.11 发布说明见 [docs/RELEASE_NOTES_0.2.11.md](docs/RELEASE_NOTES_0.2.11.md);0.2.10 发布说明见 [docs/RELEASE_NOTES_0.2.10.md](docs/RELEASE_NOTES_0.2.10.md);0.2.9 发布说明见 [docs/RELEASE_NOTES_0.2.9.md](docs/RELEASE_NOTES_0.2.9.md);0.2.8 发布说明见 [docs/RELEASE_NOTES_0.2.8.md](docs/RELEASE_NOTES_0.2.8.md);0.2.7 发布说明见 [docs/RELEASE_NOTES_0.2.7.md](docs/RELEASE_NOTES_0.2.7.md);引力边界实现说明见 [docs/INTERSTELLAR_THEME_PACK_0.2.7.md](docs/INTERSTELLAR_THEME_PACK_0.2.7.md);0.2.6 更新闭环说明见 [docs/RELEASE_NOTES_0.2.6.md](docs/RELEASE_NOTES_0.2.6.md);0.2.4 奥德赛主题包说明见 [docs/ODYSSEY_THEME_PACK_0.2.4.md](docs/ODYSSEY_THEME_PACK_0.2.4.md)。 +0.2.13 发布说明见 [docs/RELEASE_NOTES_0.2.13.md](docs/RELEASE_NOTES_0.2.13.md);0.2.12 发布说明见 [docs/RELEASE_NOTES_0.2.12.md](docs/RELEASE_NOTES_0.2.12.md);0.2.11 发布说明见 [docs/RELEASE_NOTES_0.2.11.md](docs/RELEASE_NOTES_0.2.11.md);0.2.10 发布说明见 [docs/RELEASE_NOTES_0.2.10.md](docs/RELEASE_NOTES_0.2.10.md);0.2.9 发布说明见 [docs/RELEASE_NOTES_0.2.9.md](docs/RELEASE_NOTES_0.2.9.md);0.2.8 发布说明见 [docs/RELEASE_NOTES_0.2.8.md](docs/RELEASE_NOTES_0.2.8.md);0.2.7 发布说明见 [docs/RELEASE_NOTES_0.2.7.md](docs/RELEASE_NOTES_0.2.7.md);引力边界实现说明见 [docs/INTERSTELLAR_THEME_PACK_0.2.7.md](docs/INTERSTELLAR_THEME_PACK_0.2.7.md);0.2.6 更新闭环说明见 [docs/RELEASE_NOTES_0.2.6.md](docs/RELEASE_NOTES_0.2.6.md);0.2.4 奥德赛主题包说明见 [docs/ODYSSEY_THEME_PACK_0.2.4.md](docs/ODYSSEY_THEME_PACK_0.2.4.md)。 ## 开源协议 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index cf98b24..995ecab 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -15,20 +15,13 @@ Check local signing identities: security find-identity -p codesigning -v ``` -## Build and Sign +## Build Without Publishing ```bash -TOKENSTEP_VERSION=0.1.0 \ -CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ -./script/package_release.sh +TOKENSTEP_VERSION=0.2.13 ./script/build_swiftui_and_run.sh --no-launch ``` -This creates: - -```text -release/TokenStep-0.1.0.zip -release/TokenStep-0.1.0.dmg -``` +This produces a local development app only. It does not create anything under `release/` and must not be uploaded as a public build. ## Configure Notarization @@ -41,10 +34,10 @@ xcrun notarytool store-credentials tokenstep-notary \ --password "app-specific-password" ``` -Then release with notarization: +Every public package is notarized. The release script has no sign-only public mode: ```bash -TOKENSTEP_VERSION=0.1.0 \ +TOKENSTEP_VERSION=0.2.13 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize @@ -53,7 +46,7 @@ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ Alternatively, pass credentials through environment variables: ```bash -TOKENSTEP_VERSION=0.1.0 \ +TOKENSTEP_VERSION=0.2.13 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ APPLE_ID="you@example.com" \ APPLE_TEAM_ID="TEAMID" \ @@ -65,21 +58,30 @@ Do not commit Apple credentials to the repository. ## Validate -After notarization: +The packaging command already runs all of these gates and fails before producing a publishable checksum file if any gate fails: ```bash -spctl -a -vv TokenStepSwift/dist/TokenStep.app -spctl -a -vv -t install release/TokenStep-0.1.0.dmg -xcrun stapler validate TokenStepSwift/dist/TokenStep.app -xcrun stapler validate release/TokenStep-0.1.0.dmg +./script/verify_release_artifacts.sh \ + release/TokenStep-0.2.13.dmg \ + release/TokenStep-0.2.13.zip \ + 0.2.13 + +./script/verify_update_installer.sh \ + release/TokenStep-0.2.13.dmg \ + 0.2.13 \ + TokenStepSwift/dist/TokenStep.app/Contents/Helpers/TokenStepHelper ``` +`verify_release_artifacts.sh` requires valid code signatures, stapled notarization tickets, `syspolicy_check distribution`, Gatekeeper assessment, exact version, and the expected Team ID. This remains authoritative even if the maintainer Mac has Gatekeeper assessments disabled. + ## Publish to GitHub -1. Create a GitHub Release for the version tag. -2. Upload the notarized DMG. -3. Upload the ZIP as a fallback artifact. -4. Include a short changelog and supported clients. +1. Merge the release commit to `main` and wait for CI. +2. Run the repository's `Release` workflow from `main` with the exact version. +3. The workflow creates a draft and uploads the notarized DMG, ZIP, and checksum file. +4. The workflow downloads the draft assets, checks their hashes, reruns distribution and isolated-installer verification, and only then publishes the release as Latest. + +Do not manually upload artifacts that did not pass this workflow. A failed post-upload check must leave the release as a draft, never as a public release. ## GitHub Actions Release @@ -93,6 +95,6 @@ The repository includes a manual Release workflow. Configure these repository se - `APPLE_TEAM_ID`: Apple Developer Team ID - `APPLE_APP_PASSWORD`: app-specific password for notarization -Then run the `Release` workflow manually with a version number such as `0.1.0`. +Then run the `Release` workflow manually from `main` with a version number such as `0.2.13`. Apple's official overview is here: [Notarizing macOS software before distribution](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution). diff --git a/docs/RELEASE_NOTES_0.2.13.md b/docs/RELEASE_NOTES_0.2.13.md new file mode 100644 index 0000000..1f980a7 --- /dev/null +++ b/docs/RELEASE_NOTES_0.2.13.md @@ -0,0 +1,17 @@ +# TokenStep 0.2.13 + +TokenStep 0.2.13 restores automatic updates for users blocked by the missing Apple notarization ticket in the 0.2.12 release artifacts. + +## Fixed + +- Rebuild the current TokenStep app as version 0.2.13 with Developer ID signing, Apple notarization, and stapled tickets on both the app and DMG. +- Users on 0.2.11 can install this update without disabling Gatekeeper or changing macOS security settings. +- Keep the Trojan Inferno flame-animation and popover-flash fixes from 0.2.12 unchanged. + +## Release safety + +- Public packaging now requires explicit version, signing identity, notarization credentials, release notes, and an available macOS distribution-policy checker. +- Every app, ZIP, and DMG must pass code-signature, notarization-ticket, Gatekeeper, version, Team ID, and isolated update-installer verification. +- GitHub Releases are created as drafts, downloaded again, checksum-verified, and only then published as Latest. + +Token totals, costs, quotas, rankings, collectors, settings, and theme behavior are unchanged. diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index 16ef04d..dfbccf2 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -23,7 +23,7 @@ HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME" ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey" INTERSTELLAR_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar" -VERSION="${TOKENSTEP_VERSION:-0.2.12}" +VERSION="${TOKENSTEP_VERSION:-0.2.13}" LAUNCH=true VERIFY=false diff --git a/script/package_release.sh b/script/package_release.sh index 6062509..3734ecd 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -3,19 +3,25 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_NAME="TokenStep" -PRODUCT_NAME="TokenStepSwift" SWIFT_DIR="$ROOT_DIR/TokenStepSwift" BUILT_APP_BUNDLE="$SWIFT_DIR/dist/$APP_NAME.app" -APP_BUNDLE="$BUILT_APP_BUNDLE" RELEASE_DIR="$ROOT_DIR/release" -VERSION="${TOKENSTEP_VERSION:-0.2.12}" +VERSION="${TOKENSTEP_VERSION:-}" IDENTITY="${CODE_SIGN_IDENTITY:-}" -NOTARIZE=false +NOTES_FILE="$ROOT_DIR/docs/RELEASE_NOTES_${VERSION}.md" +EXPECTED_TEAM_ID="${APPLE_TEAM_ID:-}" +NOTARY_ARGS=() usage() { cat <<'USAGE' Usage: - TOKENSTEP_VERSION=0.2.12 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] + TOKENSTEP_VERSION=0.2.13 \ + CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ + TOKENSTEP_NOTARY_PROFILE="notarytool-profile" \ + ./script/package_release.sh [--notarize] + +Public release artifacts are always submitted to Apple, stapled, and verified. +The --notarize flag is retained for compatibility but is no longer optional behavior. Notarization credentials, choose one: TOKENSTEP_NOTARY_PROFILE="notarytool-profile" @@ -25,13 +31,13 @@ Notarization credentials, choose one: Outputs: release/TokenStep-.zip release/TokenStep-.dmg + release/TokenStep--SHA256SUMS.txt USAGE } for arg in "$@"; do case "$arg" in --notarize) - NOTARIZE=true ;; --help|-h) usage @@ -45,86 +51,104 @@ for arg in "$@"; do esac done +if [[ -z "$VERSION" ]]; then + echo "TOKENSTEP_VERSION is required; release versions must be explicit." >&2 + exit 2 +fi +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid TOKENSTEP_VERSION: $VERSION" >&2 + exit 2 +fi if [[ -z "$IDENTITY" ]]; then echo "CODE_SIGN_IDENTITY is required for public distribution." >&2 echo "Run: security find-identity -p codesigning -v" >&2 exit 2 fi +if [[ ! -f "$NOTES_FILE" ]]; then + echo "Release notes are required: $NOTES_FILE" >&2 + exit 2 +fi +if [[ ! -x /usr/bin/syspolicy_check ]]; then + echo "syspolicy_check is required for public distribution verification." >&2 + exit 2 +fi + +if [[ -n "${TOKENSTEP_NOTARY_PROFILE:-}" ]]; then + NOTARY_ARGS=(--keychain-profile "$TOKENSTEP_NOTARY_PROFILE") +elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_PASSWORD:-}" ]]; then + NOTARY_ARGS=(--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD") +else + echo "Apple notarization credentials are mandatory for every release artifact." >&2 + echo "Set TOKENSTEP_NOTARY_PROFILE or APPLE_ID + APPLE_TEAM_ID + APPLE_APP_PASSWORD." >&2 + exit 2 +fi -rm -rf "$RELEASE_DIR" -mkdir -p "$RELEASE_DIR" +if ! /usr/bin/security find-identity -p codesigning -v | /usr/bin/grep -Fq "\"$IDENTITY\""; then + echo "The requested Developer ID identity is not available: $IDENTITY" >&2 + exit 2 +fi + +if [[ -z "$EXPECTED_TEAM_ID" && "$IDENTITY" =~ \(([A-Z0-9]{10})\)$ ]]; then + EXPECTED_TEAM_ID="${BASH_REMATCH[1]}" +fi + +echo "Validating Apple notarization credentials..." +/usr/bin/xcrun notarytool history "${NOTARY_ARGS[@]}" >/dev/null + +/bin/rm -rf "$RELEASE_DIR" +/bin/mkdir -p "$RELEASE_DIR" echo "Building $APP_NAME $VERSION..." TOKENSTEP_VERSION="$VERSION" "$ROOT_DIR/script/build_swiftui_and_run.sh" --no-launch -PACKAGE_WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tokenstep-release.XXXXXX")" -trap 'rm -rf "$PACKAGE_WORK_DIR"' EXIT +PACKAGE_WORK_DIR="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/tokenstep-release.XXXXXX")" +trap '/bin/rm -rf "$PACKAGE_WORK_DIR"' EXIT APP_BUNDLE="$PACKAGE_WORK_DIR/$APP_NAME.app" -ditto "$BUILT_APP_BUNDLE" "$APP_BUNDLE" +/usr/bin/ditto "$BUILT_APP_BUNDLE" "$APP_BUNDLE" clean_bundle_metadata() { - find "$APP_BUNDLE" \( -name ".DS_Store" -o -name "*.nssyncsc" \) -delete + /usr/bin/find "$APP_BUNDLE" \( -name ".DS_Store" -o -name "*.nssyncsc" \) -delete } echo "Signing app with Developer ID..." clean_bundle_metadata if [[ -f "$APP_BUNDLE/Contents/Helpers/TokenStepHelper" ]]; then - codesign --force --timestamp --options runtime --sign "$IDENTITY" "$APP_BUNDLE/Contents/Helpers/TokenStepHelper" + /usr/bin/codesign --force --timestamp --options runtime --sign "$IDENTITY" "$APP_BUNDLE/Contents/Helpers/TokenStepHelper" fi clean_bundle_metadata -codesign --force --timestamp --options runtime --sign "$IDENTITY" "$APP_BUNDLE" -codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" +/usr/bin/codesign --force --timestamp --options runtime --sign "$IDENTITY" "$APP_BUNDLE" +/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" ZIP_PATH="$RELEASE_DIR/$APP_NAME-$VERSION.zip" DMG_STAGING="$PACKAGE_WORK_DIR/dmg-staging" DMG_PATH="$RELEASE_DIR/$APP_NAME-$VERSION.dmg" -echo "Creating zip..." -ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP_PATH" +echo "Creating zip for Apple notarization..." +/usr/bin/ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP_PATH" submit_for_notarization() { local artifact="$1" - - if [[ -n "${TOKENSTEP_NOTARY_PROFILE:-}" ]]; then - xcrun notarytool submit "$artifact" --keychain-profile "$TOKENSTEP_NOTARY_PROFILE" --wait - return - fi - - if [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_PASSWORD:-}" ]]; then - xcrun notarytool submit "$artifact" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_PASSWORD" \ - --wait - return - fi - - echo "Notarization requested, but no credentials were provided." >&2 - echo "Set TOKENSTEP_NOTARY_PROFILE or APPLE_ID + APPLE_TEAM_ID + APPLE_APP_PASSWORD." >&2 - exit 2 + /usr/bin/xcrun notarytool submit "$artifact" "${NOTARY_ARGS[@]}" --wait } -if [[ "$NOTARIZE" == true ]]; then - echo "Submitting zip for notarization..." - submit_for_notarization "$ZIP_PATH" - echo "Stapling app ticket..." - xcrun stapler staple "$APP_BUNDLE" - xcrun stapler validate "$APP_BUNDLE" - echo "Recreating zip with stapled app..." - rm -f "$ZIP_PATH" - ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP_PATH" -fi +echo "Submitting zip for notarization..." +submit_for_notarization "$ZIP_PATH" +echo "Stapling app ticket..." +/usr/bin/xcrun stapler staple "$APP_BUNDLE" +/usr/bin/xcrun stapler validate "$APP_BUNDLE" +echo "Recreating zip with the stapled app..." +/bin/rm -f "$ZIP_PATH" +/usr/bin/ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP_PATH" echo "Creating dmg..." -rm -rf "$DMG_STAGING" -mkdir -p "$DMG_STAGING" -ditto "$APP_BUNDLE" "$DMG_STAGING/$APP_NAME.app" -ln -s /Applications "$DMG_STAGING/Applications" +/bin/mkdir -p "$DMG_STAGING" +/usr/bin/ditto "$APP_BUNDLE" "$DMG_STAGING/$APP_NAME.app" +/bin/ln -s /Applications "$DMG_STAGING/Applications" DMG_CREATED=false for attempt in 1 2 3; do - rm -f "$DMG_PATH" - if hdiutil create \ + /bin/rm -f "$DMG_PATH" + if /usr/bin/hdiutil create \ -volname "$APP_NAME" \ -srcfolder "$DMG_STAGING" \ -ov \ @@ -134,38 +158,45 @@ for attempt in 1 2 3; do break fi echo "DMG creation attempt $attempt failed; retrying..." >&2 - sleep $((attempt * 3)) + /bin/sleep $((attempt * 3)) done if [[ "$DMG_CREATED" != true ]]; then echo "DMG creation failed after 3 attempts." >&2 exit 1 fi -echo "Signing dmg with Developer ID..." -codesign --force --timestamp --sign "$IDENTITY" "$DMG_PATH" -codesign --verify --verbose=2 "$DMG_PATH" - -if [[ "$NOTARIZE" == true ]]; then - echo "Submitting dmg for notarization..." - submit_for_notarization "$DMG_PATH" - echo "Stapling dmg ticket..." - xcrun stapler staple "$DMG_PATH" - xcrun stapler validate "$DMG_PATH" -fi -echo "Validating signature..." -spctl -a -vv "$APP_BUNDLE" -spctl -a -vv -t open --context context:primary-signature "$DMG_PATH" -if [[ "$NOTARIZE" == true ]]; then - ZIP_VALIDATE_DIR="$RELEASE_DIR/zip-validate" - rm -rf "$ZIP_VALIDATE_DIR" - mkdir -p "$ZIP_VALIDATE_DIR" - ditto -x -k "$ZIP_PATH" "$ZIP_VALIDATE_DIR" - xcrun stapler validate "$ZIP_VALIDATE_DIR/$APP_NAME.app" - rm -rf "$ZIP_VALIDATE_DIR" -fi +echo "Signing dmg with Developer ID..." +/usr/bin/codesign --force --timestamp --sign "$IDENTITY" "$DMG_PATH" +/usr/bin/codesign --verify --verbose=2 "$DMG_PATH" + +echo "Submitting dmg for notarization..." +submit_for_notarization "$DMG_PATH" +echo "Stapling dmg ticket..." +/usr/bin/xcrun stapler staple "$DMG_PATH" +/usr/bin/xcrun stapler validate "$DMG_PATH" + +echo "Running distribution and artifact verification..." +TOKENSTEP_EXPECTED_TEAM_ID="$EXPECTED_TEAM_ID" \ + "$ROOT_DIR/script/verify_release_artifacts.sh" "$DMG_PATH" "$ZIP_PATH" "$VERSION" + +echo "Running the isolated automatic-update installer verification..." +"$ROOT_DIR/script/verify_update_installer.sh" \ + "$DMG_PATH" \ + "$VERSION" \ + "$APP_BUNDLE/Contents/Helpers/TokenStepHelper" + +CHECKSUM_PATH="$RELEASE_DIR/$APP_NAME-$VERSION-SHA256SUMS.txt" +( + cd "$RELEASE_DIR" + /usr/bin/shasum -a 256 \ + "$(/usr/bin/basename "$DMG_PATH")" \ + "$(/usr/bin/basename "$ZIP_PATH")" \ + > "$(/usr/bin/basename "$CHECKSUM_PATH")" +) echo -echo "Release artifacts:" +echo "Verified public release artifacts:" echo " $ZIP_PATH" echo " $DMG_PATH" +echo " $CHECKSUM_PATH" diff --git a/script/test_release_contract.sh b/script/test_release_contract.sh new file mode 100755 index 0000000..62c74f7 --- /dev/null +++ b/script/test_release_contract.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_SCRIPT="$ROOT_DIR/script/build_swiftui_and_run.sh" +PACKAGE_SCRIPT="$ROOT_DIR/script/package_release.sh" +ARTIFACT_VERIFIER="$ROOT_DIR/script/verify_release_artifacts.sh" +INSTALLER_VERIFIER="$ROOT_DIR/script/verify_update_installer.sh" +RELEASE_WORKFLOW="$ROOT_DIR/.github/workflows/release.yml" + +CURRENT_VERSION="$(/usr/bin/sed -n 's/^VERSION="${TOKENSTEP_VERSION:-\([^}]*\)}"$/\1/p' "$BUILD_SCRIPT")" +if [[ ! "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Could not resolve the current release version from $BUILD_SCRIPT" >&2 + exit 1 +fi + +assert_contains() { + local file="$1" + local text="$2" + if ! /usr/bin/grep -Fq -- "$text" "$file"; then + echo "Release contract is missing '$text' in $file" >&2 + exit 1 + fi +} + +for script in "$PACKAGE_SCRIPT" "$ARTIFACT_VERIFIER" "$INSTALLER_VERIFIER"; do + /bin/bash -n "$script" +done + +if [[ ! -f "$ROOT_DIR/docs/RELEASE_NOTES_$CURRENT_VERSION.md" ]]; then + echo "Missing release notes for $CURRENT_VERSION" >&2 + exit 1 +fi +assert_contains "$ROOT_DIR/README.md" "TokenStep-$CURRENT_VERSION.dmg" +assert_contains "$PACKAGE_SCRIPT" "notarytool submit" +assert_contains "$PACKAGE_SCRIPT" "verify_release_artifacts.sh" +assert_contains "$PACKAGE_SCRIPT" "verify_update_installer.sh" +assert_contains "$ARTIFACT_VERIFIER" "stapler validate \"\$DMG_PATH\"" +assert_contains "$ARTIFACT_VERIFIER" "syspolicy_check distribution \"\$app_path\"" +assert_contains "$INSTALLER_VERIFIER" "stapler validate \"\$VERIFY_DESTINATION\"" +assert_contains "$INSTALLER_VERIFIER" "syspolicy_check distribution \"\$VERIFY_DESTINATION\"" +assert_contains "$RELEASE_WORKFLOW" "--draft" +assert_contains "$RELEASE_WORKFLOW" "gh release download" +assert_contains "$RELEASE_WORKFLOW" "--draft=false --latest" + +draft_line="$(/usr/bin/grep -n 'name: Create verified draft release' "$RELEASE_WORKFLOW" | /usr/bin/cut -d: -f1)" +download_line="$(/usr/bin/grep -n 'name: Download and verify draft assets' "$RELEASE_WORKFLOW" | /usr/bin/cut -d: -f1)" +publish_line="$(/usr/bin/grep -n 'name: Publish verified release' "$RELEASE_WORKFLOW" | /usr/bin/cut -d: -f1)" +if [[ -z "$draft_line" || -z "$download_line" || -z "$publish_line" || \ + "$draft_line" -ge "$download_line" || "$download_line" -ge "$publish_line" ]]; then + echo "Release workflow must create a draft, verify downloaded assets, then publish." >&2 + exit 1 +fi + +set +e +missing_version_output="$( + /usr/bin/env \ + -u TOKENSTEP_VERSION \ + -u CODE_SIGN_IDENTITY \ + -u TOKENSTEP_NOTARY_PROFILE \ + -u APPLE_ID \ + -u APPLE_TEAM_ID \ + -u APPLE_APP_PASSWORD \ + "$PACKAGE_SCRIPT" --notarize 2>&1 +)" +missing_version_rc=$? +set -e +if [[ "$missing_version_rc" -ne 2 || "$missing_version_output" != *"TOKENSTEP_VERSION is required"* ]]; then + echo "Packaging did not fail closed when the release version was missing." >&2 + exit 1 +fi + +set +e +missing_notary_output="$( + /usr/bin/env \ + -u TOKENSTEP_NOTARY_PROFILE \ + -u APPLE_ID \ + -u APPLE_TEAM_ID \ + -u APPLE_APP_PASSWORD \ + TOKENSTEP_VERSION="$CURRENT_VERSION" \ + CODE_SIGN_IDENTITY="Developer ID Application: Release Contract Test (AAAAAAAAAA)" \ + "$PACKAGE_SCRIPT" --notarize 2>&1 +)" +missing_notary_rc=$? +set -e +if [[ "$missing_notary_rc" -ne 2 || "$missing_notary_output" != *"notarization credentials are mandatory"* ]]; then + echo "Packaging did not fail closed when Apple notarization credentials were missing." >&2 + exit 1 +fi + +echo "Release safety contract passed for TokenStep $CURRENT_VERSION." diff --git a/script/verify_release_artifacts.sh b/script/verify_release_artifacts.sh new file mode 100755 index 0000000..10b08af --- /dev/null +++ b/script/verify_release_artifacts.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "Usage: $0 /path/to/TokenStep.dmg /path/to/TokenStep.zip EXPECTED_VERSION" >&2 + exit 2 +fi + +DMG_PATH="$1" +ZIP_PATH="$2" +EXPECTED_VERSION="$3" +EXPECTED_TEAM_ID="${TOKENSTEP_EXPECTED_TEAM_ID:-${APPLE_TEAM_ID:-}}" + +for artifact in "$DMG_PATH" "$ZIP_PATH"; do + if [[ ! -f "$artifact" ]]; then + echo "Release artifact not found: $artifact" >&2 + exit 2 + fi +done + +for tool in \ + /usr/bin/codesign \ + /usr/bin/ditto \ + /usr/bin/hdiutil \ + /usr/bin/shasum \ + /usr/sbin/spctl \ + /usr/bin/syspolicy_check \ + /usr/bin/xcrun \ + /usr/libexec/PlistBuddy; do + if [[ ! -x "$tool" ]]; then + echo "Required release verification tool is unavailable: $tool" >&2 + exit 2 + fi +done + +VERIFY_ROOT="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/tokenstep-release-verify.XXXXXX")" +DMG_MOUNT="$VERIFY_ROOT/dmg" +ZIP_EXTRACT="$VERIFY_ROOT/zip" +DMG_ATTACHED=false + +cleanup() { + if [[ "$DMG_ATTACHED" == true ]]; then + /usr/bin/hdiutil detach "$DMG_MOUNT" -force -quiet >/dev/null 2>&1 || true + fi + if [[ -n "${VERIFY_ROOT:-}" && "$VERIFY_ROOT" == *tokenstep-release-verify.* ]]; then + /bin/rm -rf "$VERIFY_ROOT" + fi +} +trap cleanup EXIT + +/bin/mkdir -p "$DMG_MOUNT" "$ZIP_EXTRACT" + +validate_app() { + local app_path="$1" + local label="$2" + local info_plist="$app_path/Contents/Info.plist" + local actual_version + local actual_team_id="" + + if [[ ! -d "$app_path" || ! -f "$info_plist" ]]; then + echo "$label does not contain TokenStep.app with an Info.plist." >&2 + exit 1 + fi + + actual_version="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$info_plist")" + if [[ "$actual_version" != "$EXPECTED_VERSION" ]]; then + echo "$label version mismatch: expected $EXPECTED_VERSION, got $actual_version" >&2 + exit 1 + fi + + /usr/bin/codesign --verify --deep --strict --verbose=2 "$app_path" + /usr/bin/xcrun stapler validate "$app_path" + /usr/bin/syspolicy_check distribution "$app_path" + /usr/sbin/spctl --assess --type execute --verbose=2 "$app_path" + + if [[ -n "$EXPECTED_TEAM_ID" ]]; then + actual_team_id="$(/usr/bin/codesign -dv --verbose=4 "$app_path" 2>&1 | /usr/bin/sed -n 's/^TeamIdentifier=//p' | /usr/bin/head -n 1)" + if [[ "$actual_team_id" != "$EXPECTED_TEAM_ID" ]]; then + echo "$label TeamIdentifier mismatch: expected $EXPECTED_TEAM_ID, got ${actual_team_id:-missing}" >&2 + exit 1 + fi + fi + + echo "Verified $label: version=$actual_version team=${actual_team_id:-not-enforced}" +} + +echo "Verifying DMG signature and notarization ticket..." +/usr/bin/codesign --verify --verbose=2 "$DMG_PATH" +/usr/bin/xcrun stapler validate "$DMG_PATH" + +echo "Verifying the app mounted from the DMG..." +/usr/bin/hdiutil attach -readonly -nobrowse -quiet -mountpoint "$DMG_MOUNT" "$DMG_PATH" +DMG_ATTACHED=true +validate_app "$DMG_MOUNT/TokenStep.app" "DMG app" +/usr/bin/hdiutil detach "$DMG_MOUNT" -force -quiet +DMG_ATTACHED=false + +echo "Verifying the app extracted from the ZIP..." +/usr/bin/ditto -x -k "$ZIP_PATH" "$ZIP_EXTRACT" +validate_app "$ZIP_EXTRACT/TokenStep.app" "ZIP app" + +echo "Release artifact SHA-256 values:" +/usr/bin/shasum -a 256 "$DMG_PATH" "$ZIP_PATH" +echo "Verified signed, notarized, stapled TokenStep $EXPECTED_VERSION release artifacts." diff --git a/script/verify_update_installer.sh b/script/verify_update_installer.sh index 43b88ce..e82202e 100755 --- a/script/verify_update_installer.sh +++ b/script/verify_update_installer.sh @@ -19,6 +19,9 @@ if [[ ! -x "$HELPER_SOURCE" ]]; then exit 2 fi +/usr/bin/codesign --verify --verbose=2 "$DMG_PATH" +/usr/bin/xcrun stapler validate "$DMG_PATH" + VERIFY_ROOT="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/tokenstep-update-verify.XXXXXX")" VERIFY_DESTINATION="$VERIFY_ROOT/Applications/TokenStep.app" VERIFY_HELPER="$VERIFY_ROOT/TokenStepHelper" @@ -53,6 +56,8 @@ if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then fi /usr/bin/codesign --verify --deep --strict "$VERIFY_DESTINATION" +/usr/bin/xcrun stapler validate "$VERIFY_DESTINATION" +/usr/bin/syspolicy_check distribution "$VERIFY_DESTINATION" /usr/sbin/spctl --assess --type execute "$VERIFY_DESTINATION" /bin/cat "$VERIFY_LOG" -echo "Verified TokenStep $INSTALLED_VERSION update installation in an isolated destination." +echo "Verified signed, notarized TokenStep $INSTALLED_VERSION update installation in an isolated destination."