From b2ac174398ac2bbce24ff9e5b6a51ea3608472d7 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 9 Sep 2026 19:50:34 +0300 Subject: [PATCH] ci: verify signed packages before protected draft publication --- .github/workflows/build.yml | 78 ++-- .github/workflows/check.yml | 5 + .github/workflows/release-publish.yml | 95 +++++ .github/workflows/release.yml | 190 +++++++++ .gitignore | 2 + PLAN.md | 5 +- docs/updater-release.md | 280 ++++++++++++++ scripts/release-verifier/.gitignore | 1 + scripts/release-verifier/Cargo.lock | 23 ++ scripts/release-verifier/Cargo.toml | 9 + scripts/release-verifier/src/main.rs | 32 ++ scripts/release.py | 415 ++++++++++++++++++++ scripts/release_assets_test.py | 115 ++++++ scripts/release_formats.py | 237 ++++++++++++ scripts/release_gate_test.py | 127 ++++++ scripts/release_github.py | 228 +++++++++++ scripts/release_msi.ps1 | 14 + scripts/release_test.py | 531 ++++++++++++++++++++++++++ 18 files changed, 2349 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/release-publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/updater-release.md create mode 100644 scripts/release-verifier/.gitignore create mode 100644 scripts/release-verifier/Cargo.lock create mode 100644 scripts/release-verifier/Cargo.toml create mode 100644 scripts/release-verifier/src/main.rs create mode 100644 scripts/release.py create mode 100644 scripts/release_assets_test.py create mode 100644 scripts/release_formats.py create mode 100644 scripts/release_gate_test.py create mode 100644 scripts/release_github.py create mode 100644 scripts/release_msi.ps1 create mode 100644 scripts/release_test.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10949e2..96d9c94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Cross-platform build +name: Cross-platform build (disposable signatures) on: workflow_dispatch: @@ -8,36 +8,50 @@ on: permissions: contents: read +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + jobs: build: - name: ${{ matrix.name }} + name: CI test bundle / ${{ matrix.platform }} strategy: fail-fast: false matrix: include: - - name: Linux x64 + - platform: linux-x86_64 os: ubuntu-22.04 - args: --bundles appimage,deb - - name: Windows x64 + target: x86_64-unknown-linux-gnu + bundles: appimage,deb + - platform: windows-x86_64 os: windows-2022 - args: --bundles nsis,msi - - name: macOS Apple Silicon + target: x86_64-pc-windows-msvc + bundles: nsis,msi + - platform: darwin-aarch64 os: macos-15 - args: --target aarch64-apple-darwin --bundles dmg - - name: macOS Intel + target: aarch64-apple-darwin + bundles: dmg,app + - platform: darwin-x86_64 os: macos-15-intel - args: --target x86_64-apple-darwin --bundles dmg - + target: x86_64-apple-darwin + bundles: dmg,app runs-on: ${{ matrix.os }} - + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm + - uses: actions/setup-python@v5 + with: + python-version: '3.12' - uses: dtolnay/rust-toolchain@stable - - name: Install Linux system dependencies + with: + targets: ${{ matrix.target }} + - name: Install Linux desktop dependencies if: runner.os == 'Linux' run: | sudo apt-get update @@ -45,29 +59,19 @@ jobs: - run: npm ci - run: npm test - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - - run: npm run tauri:build -- ${{ matrix.args }} - - name: Upload Windows installers - if: runner.os == 'Windows' - uses: actions/upload-artifact@v4 + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - run: python -m unittest discover -s scripts -p 'release_test.py' -v + - name: Generate disposable CI key (never a production secret) + run: | + python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater" + python -c "import json,os; open(os.environ['GITHUB_ENV'],'a').write('RELEASE_VERSION='+json.load(open('package.json'))['version']+'\n')" + - name: Build packages and updater bundles + run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/ci-updater/updater-build.json" + - name: Collect and verify every test bundle + run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory ci-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION" + - uses: actions/upload-artifact@v4 with: - name: shacraft-launcher-windows-x64 + name: CI-NOT-FOR-RELEASE-${{ matrix.platform }} if-no-files-found: error - path: | - src-tauri/target/release/bundle/nsis/*.exe - src-tauri/target/release/bundle/msi/*.msi - - name: Upload Linux packages - if: runner.os == 'Linux' - uses: actions/upload-artifact@v4 - with: - name: shacraft-launcher-linux-x64 - if-no-files-found: error - path: | - src-tauri/target/release/bundle/appimage/*.AppImage - src-tauri/target/release/bundle/deb/*.deb - - name: Upload macOS package - if: runner.os == 'macOS' - uses: actions/upload-artifact@v4 - with: - name: shacraft-launcher-${{ matrix.name == 'macOS Apple Silicon' && 'macos-arm64' || 'macos-x64' }} - if-no-files-found: error - path: src-tauri/target/*/release/bundle/dmg/*.dmg + retention-days: 7 + path: ci-packages/* diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ccc3db0..5feb871 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,6 +21,9 @@ jobs: with: node-version: 22 cache: npm + - uses: actions/setup-python@v5 + with: + python-version: '3.12' - uses: dtolnay/rust-toolchain@stable - name: Install Linux desktop dependencies run: | @@ -30,3 +33,5 @@ jobs: - run: npm test - run: npm run build - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - run: python -m unittest discover -s scripts -p 'release_test.py' -v diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..1637f66 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,95 @@ +name: Publish verified release (operator only) + +on: + workflow_dispatch: + inputs: + version: + description: Version of the complete signed draft + required: true + type: string + tag: + description: Existing draft tag (vVERSION) + required: true + type: string + confirmation: + description: Type publish vVERSION to confirm public publication + required: true + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: launcher-release + cancel-in-progress: false + +jobs: + preflight: + if: github.repository == 'emil28092005/shacraft-launcher' && github.ref == 'refs/heads/main' + runs-on: ubuntu-22.04 + outputs: + environment: ${{ steps.gate.outputs.environment }} + commit: ${{ steps.source.outputs.commit }} + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + CONFIRMATION: ${{ inputs.confirmation }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate publication inputs + run: python -c "import os,sys; sys.path.insert(0,'scripts'); import release; release.version_tag(os.environ['RELEASE_VERSION'],os.environ['RELEASE_TAG']); release.require(os.environ['CONFIRMATION']=='publish '+os.environ['RELEASE_TAG'],'publication confirmation mismatch')" + - name: Require existing independent approval and protected-branch policy + id: gate + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/release_github.py gate launcher-release-publish + - name: Resolve tag using trusted workflow Git commands + id: source + shell: bash + run: | + set -euo pipefail + RELEASE_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}") + git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main + printf 'commit=%s\n' "$RELEASE_COMMIT" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: ${{ steps.source.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - name: Check source versions after immutable source validation + run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git + + publish: + needs: preflight + environment: ${{ needs.preflight.outputs.environment }} + runs-on: ubuntu-22.04 + permissions: + contents: write + actions: read + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + CONFIRMATION: ${{ inputs.confirmation }} + SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - name: Re-download, verify signatures/metadata/assets and explicitly publish + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/release_github.py publish --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --confirmation "$CONFIRMATION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9eafe11 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,190 @@ +name: Prepare signed release draft + +on: + workflow_dispatch: + inputs: + version: + description: Stable version matching all source versions (e.g. 0.2.0) + required: true + type: string + tag: + description: Existing tag on protected main (e.g. v0.2.0) + required: true + type: string + notes: + description: Release notes (maximum 4096 UTF-8 bytes) + default: '' + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: launcher-release + cancel-in-progress: false + +jobs: + preflight: + if: github.repository == 'emil28092005/shacraft-launcher' && github.ref == 'refs/heads/main' + runs-on: ubuntu-22.04 + outputs: + environment: ${{ steps.gate.outputs.environment }} + commit: ${{ steps.source.outputs.commit }} + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate inputs before resolving tag + run: python -c "import os,sys; sys.path.insert(0,'scripts'); import release; release.version_tag(os.environ['RELEASE_VERSION'],os.environ['RELEASE_TAG'])" + - name: Require an existing protected environment before any job references it + id: gate + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/release_github.py gate launcher-release + - name: Resolve tag using trusted workflow Git commands + id: source + shell: bash + run: | + set -euo pipefail + RELEASE_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}") + git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main + printf 'commit=%s\n' "$RELEASE_COMMIT" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: ${{ steps.source.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - name: Check source versions, existing tag and main ancestry + run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git + + build: + needs: preflight + environment: ${{ needs.preflight.outputs.environment }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x86_64 + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + bundles: appimage,deb + - platform: windows-x86_64 + os: windows-2022 + target: x86_64-pc-windows-msvc + bundles: nsis,msi + - platform: darwin-aarch64 + os: macos-15 + target: aarch64-apple-darwin + bundles: dmg,app + - platform: darwin-x86_64 + os: macos-15-intel + target: x86_64-apple-darwin + bundles: dmg,app + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + - run: npm ci + - run: npm test + - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - run: python -m unittest discover -s scripts -p 'release_test.py' -v + - name: Fail closed on missing, wrong or unpinned signing credentials + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git --signing --config "$RUNNER_TEMP/updater-build.json" + - name: Build all platform packages with updater signatures + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/updater-build.json" + - name: Collect packages, sign manual packages, verify all signatures + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory release-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION" + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform }} + if-no-files-found: error + retention-days: 7 + path: release-packages/* + + draft: + needs: [preflight, build] + environment: ${{ needs.preflight.outputs.environment }} + runs-on: ubuntu-22.04 + permissions: + contents: write + actions: read + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_NOTES: ${{ inputs.notes }} + SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - run: npm ci + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + path: release-packages + - name: Generate signed metadata, validate complete set, upload and re-verify DRAFT + env: + GH_TOKEN: ${{ github.token }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git --signing + RELEASE_DATE=$(git show -s --format=%cI HEAD) + RELEASE_DATE=$(python -c "from datetime import datetime,timezone; import sys; print(datetime.fromisoformat(sys.argv[1]).astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" "$RELEASE_DATE") + python scripts/release.py metadata --directory release-packages --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --date "$RELEASE_DATE" --notes "$RELEASE_NOTES" + python scripts/release_github.py draft --directory release-packages --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" diff --git a/.gitignore b/.gitignore index a295e3e..ca0713d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ src-tauri/gen/ *.p12 *.pfx *.sig + +__pycache__/ diff --git a/PLAN.md b/PLAN.md index dd23a02..0cc52a6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -17,7 +17,10 @@ approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity. - [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS. Unit tests и web preview не заменяют эти прогоны. -- [ ] Подписанные installer-релизы и подписанное автообновление лаунчера. +- [x] Native самообновление, русский UX, pinned updater key и защищённый release pipeline. +- [ ] Настроить защищённое GitHub environment/production secrets, опубликовать первый + updater-релиз и проверить реальную замену приложения на каждой ОС. Подпись ОС и + Apple notarization — отдельные настройки; CI test keys не предназначены игрокам. - [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX. - [ ] Выбор каталога профиля и безопасный reset только managed-файлов. - [ ] Keychain-хранилище сессии ShaCraft; OS-lock и lease игры уже реализованы. diff --git a/docs/updater-release.md b/docs/updater-release.md new file mode 100644 index 0000000..06c22e4 --- /dev/null +++ b/docs/updater-release.md @@ -0,0 +1,280 @@ +# Launcher updater releases + +The updater has its own signing key and fixed repository: +`emil28092005/shacraft-launcher`. It is independent of the ShaCraft modpack +manifest key. The committed public key is `src-tauri/updater-public-key.txt`; +the native client and release validation pin that key. An unset/placeholder key +cannot produce a release. + +## Packages and signed metadata + +The four build targets each produce two distributable files, with canonical names: + +- Windows x64: `shacraft-launcher_VERSION_windows-x86_64-setup.exe` (NSIS) + and `shacraft-launcher_VERSION_windows-x86_64.msi`. +- Linux x64: `shacraft-launcher_VERSION_linux-x86_64.AppImage` and + `shacraft-launcher_VERSION_linux-x86_64.deb`. +- macOS arm64: `shacraft-launcher_VERSION_darwin-aarch64.dmg` and + `shacraft-launcher_VERSION_darwin-aarch64.app.tar.gz`. +- macOS x64: `shacraft-launcher_VERSION_darwin-x86_64.dmg` and + `shacraft-launcher_VERSION_darwin-x86_64.app.tar.gz`. + +Every package has a detached `.sig` from the Tauri signer. AppImage, NSIS, MSI +and app tar signatures must already exist after `tauri build`; absence is an +error. The collection step signs deb and DMG explicitly. Renaming a package does +not alter its bytes or signature. The macOS app archive contains the `.app` +bundle; the DMG remains the manual installation package. These artifact formats +follow [Tauri's updater documentation](https://v2.tauri.app/plugin/updater/). + +`latest.json` contains exactly `schemaVersion` (integer 1), `version`, `tag`, +`notes`, `pub_date`, `platforms` and `manualPackages`. `version` is stable +`MAJOR.MINOR.PATCH`; `tag` is exactly `vVERSION`. Windows MSI limits apply: +major/minor at most 255 and patch at most 65535. `pub_date` uses UTC +`YYYY-MM-DDTHH:MM:SSZ`, derived from the tagged source commit. + +`platforms` has exactly `windows-x86_64`, `linux-x86_64`, `darwin-aarch64` and +`darwin-x86_64`. Their primary packages are NSIS, AppImage and the two app archives. +`manualPackages` has exactly `windows-x86_64-msi`, `linux-x86_64-deb`, +`darwin-aarch64-dmg` and `darwin-x86_64-dmg`. Despite the section name, the native +client also selects the MSI descriptor when updating an MSI installation. + +Each descriptor has exactly `url`, `signature`, `sha256` and `size`. URLs are +bound to the exact version/tag and canonical filename under +`https://github.com/emil28092005/shacraft-launcher/releases/download/vVERSION/`. +SHA-256 is lowercase hexadecimal; size is a positive integer at most 1 GiB. +Metadata is at most 32 KiB, notes at most 4096 UTF-8 bytes and signatures at most +2048 characters. + +The release script writes deterministic UTF-8 JSON with sorted keys, two-space +indentation and a trailing newline. It signs those exact bytes into +`latest.json.sig` using the same updater key. The client verifies this signature +before parsing: an artifact signature alone cannot bind a version or download +URL. The signed metadata binds the complete platform set, versions, filenames, +hashes, sizes and package signatures together. + +`scripts/release-verifier` uses `minisign-verify` 0.2.5 and the same verification +sequence as the [Tauri updater 2.11 implementation](https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/updater/src/updater.rs): +base64-decode the Tauri public key/signature containers, decode the minisign +objects, then verify the exact file bytes including the trusted comment. Missing, +nonempty-but-invalid and mismatched-key signatures all fail. The utility does +not execute installers or replace applications. + +## CI and protected draft creation + +`check.yml` runs the usual UI/Rust checks plus release tests. `build.yml` builds +all four targets with fresh disposable test keys. It has read-only repository +permissions and receives no production signing secret. Transient build config +and `SHACRAFT_UPDATER_TEST_BUILD=1` select the test key; no committed pin changes. +Artifacts are labelled `CI-NOT-FOR-RELEASE-*`, include `CI_NOT_FOR_RELEASE.txt`, +and cannot pass release validation. Test keys are never uploaded. + +Before a release, an operator must configure two existing GitHub environments: +`launcher-release` and `launcher-release-publish`. Both require a reviewer other +than the dispatcher (`prevent_self_review=true`) and permit protected branches +only. `main` must be protected. This is a two-person operation: the dispatcher +cannot approve their own deployment. The workflow gate checks these settings +through GitHub's [environment API](https://docs.github.com/en/rest/deployments/environments) +before exposing an environment name to later jobs. Missing environments or +insufficient API permissions stop the workflow; it never creates an unprotected +replacement or bypasses the check. + +Set `SHACRAFT_UPDATER_PUBLIC_KEY` as an environment/repository variable, equal to +the complete committed base64 public key. `launcher-release` alone needs the +`TAURI_SIGNING_PRIVATE_KEY` secret (official Tauri encoded key contents) and, +for an encrypted key, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. The password may be +omitted/empty for an unencrypted key; signing is noninteractive. The publish +environment needs only the public variable, never the private key. + +The private key must live outside Git, build artifacts and web roots. Keep the +key directory owner-only (0700), its key file owner-only (0600), and retain a +separate secure backup outside Git. Do not print the key, upload it as an +artifact or keep a purported encryption password beside it. Losing the key +breaks updates for installed clients; changing the public key is a coordinated +migration, not routine key regeneration. + +To prepare a release: + +1. Merge the reviewed source/version change. Package JSON, npm lock, Cargo + package and Tauri config versions must match. Create the exact `vVERSION` + tag on a commit belonging to protected `main`; the workflow never creates or + moves a tag. +2. Manually run **Prepare signed release draft** from `main` with that version, + tag and release notes. Pass the independent environment approval. Every + matrix runner checks the tag/versions and signs a challenge to prove the + supplied private key matches the committed public key before packaging. +3. All four jobs must finish. Only then does the final job assemble all eight + packages, all eight signatures and signed metadata. It verifies this complete + 18-file set before creating a GitHub draft, uploads it, downloads it again + and re-verifies the draft. A partial upload remains a draft and cannot pass + publication validation. An existing draft is never silently overwritten. + +Before any candidate source runs, trusted workflow Git commands prove that the +tag commit belongs to protected main. Later jobs check out the validated immutable +SHA, not the movable tag. Checkout credentials are removed, and the publication +token exists only in the final explicit publishing step. + +The tag/release workflow is manual; pushing a tag does not publish an update. +The scripts do not configure GitHub environments, keys or secrets. Repository +access alone is not authorization to run the publication workflow. + +## Explicit final publication + +After reviewing the draft and completing platform acceptance, an operator runs +**Publish verified release (operator only)** from `main`, supplies version/tag +and the exact confirmation `publish vVERSION`, then obtains the separate +publish-environment approval. This job has no signing key. It checks the source +versions/tag, current committed public pin, environment policy and monotonic +stable release version; downloads every draft asset and checks all signatures, +hashes, sizes, names and signed metadata again. It also checks asset IDs/sizes/ +digests did not change during validation. Only this step changes `draft` to false +and marks the release latest. Failures leave the draft unpublished. + +GitHub `latest/download/latest.json` and its detached signature can temporarily +refer to different releases during CDN propagation. The client must reject that +mismatch and retry; it must never accept unsigned metadata as a fallback. + +## Acceptance and bootstrap + +Updater signatures authenticate update bytes. They are **not** Windows +Authenticode signatures, Apple Developer ID signatures or Apple notarization. +This pipeline does not provision those certificates or claim that SmartScreen +or Gatekeeper will trust a manually downloaded package. Any OS signing/notarization +step must finish before updater signing and metadata hashing. Never modify a +package after its updater signature is created. + +Version 0.1.1 has no updater: users must install the first updater-enabled release +manually. Keeping application identifiers and installer families stable is +necessary, but unit tests do not establish upgrade compatibility. Before final +publication, validate actual old-to-new NSIS and MSI installations separately, +including per-user/elevated installation and preservation of account/settings/ +Minecraft data. The native updater preserves the MSI/NSIS family. + +Validate both macOS architectures on real supported systems, including writable +and protected application locations and process restart. Linux automatic +replacement is for an AppImage running from a writable AppImage location; a deb +installation shows availability and opens the fixed official releases page; +installation then uses the normal OS package installer. Non-AppImage or unwritable installations must not be treated +as successfully self-updated. Verify interrupted download, wrong signature, +current/no-update, relaunch and concurrent game/install behavior on each platform. + +Local release tests use temporary synthetic payloads and newly generated +throwaway keys. They cover real signature verification, bit flips, wrong keys, +metadata substitution, exact artifact sets, duplicate fields, version/tag +bindings, CI promotion rejection and missing environment protection. They do +not prove any Windows/macOS installer ran, a live GitHub release was published, +or an end user's application updated successfully. + +## Installed package migration and recovery + +The settings drawer reports the installed native package family. Automatic +updates preserve it: + +- Windows NSIS x64 → NSIS x64, retaining the existing per-user scope and saved + installation location. The signed package must contain an EXE, not MSI bytes. +- Windows MSI x64 → MSI x64, retaining the per-machine installer family. The + pinned UpgradeCode `2058b1df-56a1-51ef-bd48-d296479cd59a` is the exact value + Tauri CLI 2.11.4 derived for the existing 0.1.1 product name; ProductCode can + change for a major upgrade. Administrator permission may be required. +- MSI ↔ NSIS, simultaneous installations of both, renamed products, changed + scopes and manually moved Windows installations have no automatic migration + promise. Close the old application and use an explicit manual installer path; + verify installed-app registrations and account/settings preservation in beta. +- macOS Intel → Intel app archive, Apple Silicon → Apple Silicon app archive. + DMG is the bootstrap/manual distribution. Move the app out of a mounted DMG + into an appropriate Applications directory before use. A protected destination + may require OS permission or a manual replacement; an installation error never + counts as a completed update. +- Linux x64 AppImage → x64 AppImage at its current writable location. Debian + packages, RPM, bare development binaries and unsupported architectures never + enter the self-replacement path. Install a new deb through the system package + manager; the launcher does not run privileged package-manager commands. + +These paths follow the locked [Tauri MSI implementation](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/msi/mod.rs), +[NSIS installer template](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/templates/installer.nsi) +and [updater implementation](https://github.com/tauri-apps/plugins-workspace/blob/updater-v2.11.0/plugins/updater/src/updater.rs). +Source inspection and CI packaging do not replace real installation acceptance. + +The updater writes `launcher-state/pending-update.json` immediately before the +platform installer. Once handed off, cancellation, power loss and installer +errors can be ambiguous. Windows does not expose an installer PID/completion +result through the plugin, so the launcher never guesses that installation has +finished after a timeout. The exact target version acknowledges the marker on +startup. Until then, recovery mode blocks native game, account, settings and +update mutations. Corrupt marker bytes also open this diagnostic mode, never +normal operation. Its restriction stays latched even if the file is removed +while that process is open. Unix marker creation/acknowledgment syncs the parent +directory as well as file contents. + +For an interrupted update: + +1. Close Minecraft, all launcher instances and any installer; reboot if their + termination cannot be established. Do not delete active OS lock files. +2. Install the marker's expected target version manually from the fixed official + releases page, preserving the package family above. Replace only the app or + package, keeping the application data directory. Starting that target version + acknowledges a valid marker automatically. +3. If the marker is corrupt, or the target cannot be used, first manually restore + a complete official package of the intended supported version. With every + launcher/game/installer closed, rename only `launcher-state/pending-update.json` + to a backup outside `launcher-state`, then launch again. This is an operator + recovery after a complete package repair, not a shortcut around an active + installer. Keep the backup for diagnosis. Never remove settings, sessions, + profiles, game files, `instance.lock`, `writer.lock` or a live game lease. + +Default application data roots (or the configured XDG data home on Linux): + +- Windows: `%APPDATA%\ru.shacraft.launcher`. +- macOS: `~/Library/Application Support/ru.shacraft.launcher`. +- Linux: `~/.local/share/ru.shacraft.launcher`. + +Network/hash/signature failures before installer entry leave the app intact and +permit an explicit fresh check/retry. They do not create an install handoff or +require marker recovery. A separate “game still running” error must be resolved +by closing the game, not removing its lease. + + +## Package architecture checks + +Before collection/signing, scripts inspect package bytes without running an +installer. AppImage must have an ELF64 little-endian AMD64 header and the Type 2 +`AI\x02` marker. The deb ar/control archive must declare `Architecture: amd64` +and the release version. Each macOS app archive must have exactly one +`Info.plist`, the same release version, and a regular main executable with a +thin Mach-O64 CPU type matching its matrix target. DMG checks validate the UDIF +container trailer; they do not mount or inspect the DMG filesystem. + +On the Windows build runner, the built launcher must be AMD64 PE32+. NSIS uses +an x86 installer stub even for an x64 application: the checker accepts that +wrapper, uses the runner's 7-Zip to list/extract only the named launcher to +stdout, then requires an x64 payload identical to the built main executable after the +exact Tauri bundle-type stamp described below. +It never executes NSIS. Missing/unsupported 7-Zip inspection fails the build; +there is no silent architecture-check fallback. MSI is checked for a compound +file header and read using WindowsInstaller COM with `MSIDBOPEN_READONLY`: +Template Summary must say `x64` and ProductVersion must match. The MSI check also +checks the built main executable, but does not extract MSI's embedded cabinet or +prove that cabinet's payload matches the build. Real installer acceptance is +still required. These distinctions follow the [PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) +and [64-bit MSI package requirements](https://learn.microsoft.com/en-us/windows/win32/msi/using-64-bit-windows-installer-packages). + +The locked [Tauri CLI 2.11.4 bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle.rs) +replaces the first complete `__TAURI_BUNDLE_TYPE_VAR_UNK` token with +`__TAURI_BUNDLE_TYPE_VAR_NSS` for NSIS (`MSI` for MSI), packages that binary, then +restores the original unpatched/unsigned executable on disk after each bundle. +The NSIS comparator constructs precisely that one replacement in a copy of the +built bytes and compares the entire extracted payload. It does not mask any PE +section, checksum, certificate table, padding or other bytes. A missing marker, +wrong family stamp or any unrelated byte change fails. Both original and +extracted executables must still be AMD64 PE32+. + +Authenticode is currently unconfigured. Adding it can also change the PE checksum +and append a certificate table; this comparison intentionally fails until a +separate verified signed-baseline procedure is implemented. Do not broaden the +comparison to ignore all certificate/checksum differences merely to pass CI. + +Portable format checks repeat when validating signed release assets. Full NSIS +payload/MSI COM checks run only during collection on the Windows runner; the +Linux draft/publish verifier rechecks their container headers and signatures. +Header/metadata inspection is not a runtime, architecture-emulation or installer +migration test. Synthetic header fixtures exercise these checks; they are never +installed or executed. diff --git a/scripts/release-verifier/.gitignore b/scripts/release-verifier/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/scripts/release-verifier/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/scripts/release-verifier/Cargo.lock b/scripts/release-verifier/Cargo.lock new file mode 100644 index 0000000..39cb1a2 --- /dev/null +++ b/scripts/release-verifier/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "shacraft-release-verifier" +version = "0.1.0" +dependencies = [ + "base64", + "minisign-verify", +] diff --git a/scripts/release-verifier/Cargo.toml b/scripts/release-verifier/Cargo.toml new file mode 100644 index 0000000..0c85158 --- /dev/null +++ b/scripts/release-verifier/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "shacraft-release-verifier" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +base64 = "=0.22.1" +minisign-verify = "=0.2.5" diff --git a/scripts/release-verifier/src/main.rs b/scripts/release-verifier/src/main.rs new file mode 100644 index 0000000..cef8b5b --- /dev/null +++ b/scripts/release-verifier/src/main.rs @@ -0,0 +1,32 @@ +//! Uses precisely the Tauri updater 2.11 signature verification primitive. +//! Upstream: plugins/updater/src/updater.rs::verify_signature (MIT/Apache-2.0). +use base64::Engine; +use minisign_verify::{PublicKey, Signature}; +use std::{env, fs, process::ExitCode}; + +fn verify() -> Result<(), Box> { + let args: Vec<_> = env::args_os().skip(1).collect(); + if args.len() != 3 { + return Err("expected PUBLIC_KEY_FILE DATA_FILE SIGNATURE_FILE".into()); + } + let decode = |path: &std::ffi::OsStr| -> Result> { + let encoded = fs::read_to_string(path)?; + let bytes = base64::engine::general_purpose::STANDARD.decode(encoded.trim())?; + Ok(String::from_utf8(bytes)?) + }; + let public_key = PublicKey::decode(&decode(&args[0])?)?; + let signature = Signature::decode(&decode(&args[2])?)?; + public_key.verify(&fs::read(&args[1])?, &signature, true)?; + Ok(()) +} + +fn main() -> ExitCode { + match verify() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => { + // Never echo key material or signer diagnostics into release logs. + eprintln!("Tauri updater signature verification failed"); + ExitCode::FAILURE + } + } +} diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..f789f67 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""Assemble/verify release assets. No network or publication in this module.""" + +import argparse +import base64 +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +from datetime import datetime +from pathlib import Path + +import release_formats + +REPOSITORY = "emil28092005/shacraft-launcher" +ORIGIN = f"https://github.com/{REPOSITORY}/releases/download" +PLATFORMS = { + "windows-x86_64": ("nsis/*.exe", "-setup.exe"), + "linux-x86_64": ("appimage/*.AppImage", ".AppImage"), + "darwin-aarch64": ("macos/*.app.tar.gz", ".app.tar.gz"), + "darwin-x86_64": ("macos/*.app.tar.gz", ".app.tar.gz"), +} +MANUAL = { + "windows-x86_64-msi": ("windows-x86_64", "msi/*.msi", ".msi"), + "linux-x86_64-deb": ("linux-x86_64", "deb/*.deb", ".deb"), + "darwin-aarch64-dmg": ("darwin-aarch64", "dmg/*.dmg", ".dmg"), + "darwin-x86_64-dmg": ("darwin-x86_64", "dmg/*.dmg", ".dmg"), +} +FIELDS = {"url", "signature", "sha256", "size"} +MAX_SIZE = 1024**3 + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def version_tag(version, tag): + require( + re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version), + "release version must be stable MAJOR.MINOR.PATCH", + ) + major, minor, patch = map(int, version.split(".")) + require(major <= 255 and minor <= 255 and patch <= 65535, "version exceeds MSI limits") + require(tag == f"v{version}", "tag/version mismatch") + + +def filename(version, platform, suffix): + return f"shacraft-launcher_{version}_{platform}{suffix}" + + +def expected_names(version): + return {filename(version, key, value[1]) for key, value in PLATFORMS.items()} | { + filename(version, platform, suffix) for platform, _, suffix in MANUAL.values() + } + + +def canonical(data): + return (json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, "duplicate JSON field") + result[key] = value + return result + + +def read_json(path): + return json.loads(path.read_bytes(), object_pairs_hook=unique_object) + + +def public_key(): + value = os.environ.get("SHACRAFT_UPDATER_PUBLIC_KEY", "").strip() + require(bool(value), "SHACRAFT_UPDATER_PUBLIC_KEY is missing") + try: + decoded = base64.b64decode(value, validate=True).decode("utf-8") + lines = decoded.splitlines() + raw = base64.b64decode(lines[1], validate=True) + require( + lines[0].startswith("untrusted comment:") and len(raw) == 42 and raw[:2] == b"Ed", + "invalid Tauri public key", + ) + except (ValueError, IndexError, UnicodeError) as error: + raise ValueError("invalid Tauri public key") from error + return value + + +def verifier_path(root): + return os.environ.get( + "RELEASE_VERIFIER", + str( + root + / "scripts/release-verifier/target/release" + / ("shacraft-release-verifier.exe" if os.name == "nt" else "shacraft-release-verifier") + ), + ) + + +def verify_signature(root, data, signature): + with tempfile.TemporaryDirectory(prefix="shacraft-verify-") as temporary: + key = Path(temporary) / "public-key" + key.write_text(public_key(), encoding="utf-8") + result = subprocess.run( + [verifier_path(root), str(key), str(data), str(signature)], + capture_output=True, + timeout=120, + check=False, + ) + require(result.returncode == 0, f"invalid updater signature: {data.name}") + + +def signer(root, data): + private = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "") + require(bool(private.strip()), "TAURI_SIGNING_PRIVATE_KEY is missing") + environment = dict(os.environ) + # An omitted password means an unencrypted key. Never allow a CI prompt; + # encrypted keys without the correct password fail in the signer. + environment.setdefault("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "") + # Builds accept a key path; signer sign accepts the encoded key contents. + try: + is_key_path = len(private) < 4096 and "\n" not in private and Path(private).is_file() + except OSError: + is_key_path = False + if is_key_path: + environment["TAURI_SIGNING_PRIVATE_KEY"] = Path(private).read_text().strip() + environment.pop("TAURI_SIGNING_PRIVATE_KEY_PATH", None) + result = subprocess.run( + ["node", str(root / "node_modules/@tauri-apps/cli/tauri.js"), "signer", "sign", str(data)], + env=environment, + capture_output=True, + timeout=120, + check=False, + ) + require(result.returncode == 0, "Tauri signing failed (check protected signing credentials)") + + +def preflight(root, version, tag, check_git=False, signing=False): + version_tag(version, tag) + versions = [ + read_json(root / "package.json")["version"], + read_json(root / "package-lock.json")["version"], + read_json(root / "package-lock.json")["packages"][""]["version"], + read_json(root / "src-tauri/tauri.conf.json")["version"], + tomllib.loads((root / "src-tauri/Cargo.toml").read_text())["package"]["version"], + ] + require(all(value == version for value in versions), "source versions disagree with release") + if check_git: + + def git(*args): + return subprocess.check_output(["git", *args], cwd=root, text=True).strip() + + require( + git("rev-parse", "HEAD") == git("rev-parse", f"refs/tags/{tag}^{{commit}}"), + "checkout does not match existing release tag", + ) + subprocess.run( + ["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"], cwd=root, check=True + ) + if signing: + require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI test keys cannot release") + pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip() + require(public_key() == pinned, "release public key differs from committed updater key") + with tempfile.TemporaryDirectory(prefix="shacraft-key-check-") as temporary: + challenge = Path(temporary) / "key-check" + challenge.write_bytes(f"ShaCraft release key check {tag}\n".encode()) + signer(root, challenge) + verify_signature(root, challenge, Path(str(challenge) + ".sig")) + + +def write_build_config(destination): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes( + canonical( + { + "bundle": {"createUpdaterArtifacts": True}, + "plugins": {"updater": {"pubkey": public_key()}}, + } + ) + ) + + +def ci_key(root, directory): + require(not os.environ.get("TAURI_SIGNING_PRIVATE_KEY"), "CI must not receive production key") + directory.mkdir(parents=True, exist_ok=True) + key = directory / "DISPOSABLE-CI-ONLY.key" + result = subprocess.run( + [ + "node", + str(root / "node_modules/@tauri-apps/cli/tauri.js"), + "signer", + "generate", + "--ci", + "--password", + "", + "--write-keys", + str(key), + ], + capture_output=True, + timeout=120, + check=False, + ) + require(result.returncode == 0, "disposable CI key generation failed") + key.chmod(0o600) + values = { + "TAURI_SIGNING_PRIVATE_KEY": str(key), + "TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "", + "SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(key) + ".pub").read_text().strip(), + "SHACRAFT_UPDATER_TEST_BUILD": "1", + } + os.environ.update(values) + write_build_config(directory / "updater-build.json") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as stream: + for name, value in values.items(): + require("\n" not in value and "\r" not in value, "invalid CI environment value") + stream.write(f"{name}={value}\n") + + +def collect(root, bundle, destination, platform, version): + version_tag(version, f"v{version}") + destination.mkdir(parents=True, exist_ok=True) + require(not list(destination.iterdir()), "collection destination must be empty") + entries = [(PLATFORMS[platform][0], PLATFORMS[platform][1], True)] + entries += [ + (glob, suffix, suffix == ".msi") for key, glob, suffix in MANUAL.values() if key == platform + ] + for glob, suffix, required_signature in entries: + matches = list(bundle.glob(glob)) + require( + len(matches) == 1 and matches[0].is_file() and not matches[0].is_symlink(), + f"expected exactly one {platform} {glob}", + ) + source = matches[0] + main = bundle.parent / "shacraft-launcher.exe" if platform == "windows-x86_64" else None + release_formats.validate(source, platform, suffix, version, main) + target = destination / filename(version, platform, suffix) + shutil.copyfile(source, target) + source_signature = Path(str(source) + ".sig") + signature = Path(str(target) + ".sig") + if required_signature: + require( + source_signature.is_file() and not source_signature.is_symlink(), + f"missing generated updater signature: {source.name}", + ) + shutil.copyfile(source_signature, signature) + else: + signer(root, target) # Tauri does not produce deb/DMG updater signatures. + verify_signature(root, target, signature) + if os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") == "1": + (destination / "CI_NOT_FOR_RELEASE.txt").write_text( + "DISPOSABLE TEST KEY. These CI artifacts are not deployable releases.\n", + encoding="utf-8", + ) + + +def descriptor(root, directory, version, tag, platform, suffix): + name = filename(version, platform, suffix) + path, sig = directory / name, directory / (name + ".sig") + require( + path.is_file() and not path.is_symlink() and sig.is_file() and not sig.is_symlink(), + f"missing or unsafe release asset: {name}", + ) + size = path.stat().st_size + require(0 < size <= MAX_SIZE, "invalid package size") + verify_signature(root, path, sig) + release_formats.validate(path, platform, suffix, version) + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + signature = sig.read_text(encoding="utf-8").strip() + require(0 < len(signature) <= 2048, "invalid signature length") + return {"url": f"{ORIGIN}/{tag}/{name}", "signature": signature, "sha256": digest, "size": size} + + +def metadata(root, directory, version, tag, notes, date): + version_tag(version, tag) + require(len(notes.encode()) <= 4096, "release notes too long") + require( + re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", date), "expected UTC publication date" + ) + datetime.fromisoformat(date.replace("Z", "+00:00")) + return { + "schemaVersion": 1, + "version": version, + "tag": tag, + "notes": notes, + "pub_date": date, + "platforms": { + key: descriptor(root, directory, version, tag, key, suffix) + for key, (_, suffix) in PLATFORMS.items() + }, + "manualPackages": { + key: descriptor(root, directory, version, tag, platform, suffix) + for key, (platform, _, suffix) in MANUAL.items() + }, + } + + +def verify_release(root, directory, version, tag): + version_tag(version, tag) + names = expected_names(version) + expected = names | {name + ".sig" for name in names} | {"latest.json", "latest.json.sig"} + require( + {path.name for path in directory.iterdir()} == expected, + "release asset set is incomplete or unexpected", + ) + path = directory / "latest.json" + require(path.stat().st_size <= 32768, "metadata exceeds limit") + require( + not path.is_symlink() and not (directory / "latest.json.sig").is_symlink(), + "unsafe metadata", + ) + verify_signature( + root, path, directory / "latest.json.sig" + ) # Verify exact bytes BEFORE parsing. + actual = read_json(path) + require( + isinstance(actual, dict) + and set(actual) + == {"schemaVersion", "version", "tag", "notes", "pub_date", "platforms", "manualPackages"}, + "unexpected metadata fields", + ) + require( + type(actual["schemaVersion"]) is int and actual["schemaVersion"] == 1, + "unsupported metadata schema", + ) + require(actual["version"] == version and actual["tag"] == tag, "signed version/tag mismatch") + require( + isinstance(actual["notes"], str) and isinstance(actual["pub_date"], str), + "invalid metadata text", + ) + for section, keys in (("platforms", PLATFORMS), ("manualPackages", MANUAL)): + require( + isinstance(actual[section], dict) and set(actual[section]) == set(keys), + "unexpected platform/package set", + ) + for item in actual[section].values(): + require(isinstance(item, dict) and set(item) == FIELDS, "unexpected descriptor fields") + require( + type(item["size"]) is int and 0 < item["size"] <= MAX_SIZE, + "invalid descriptor size", + ) + require( + all(isinstance(item[name], str) for name in ("url", "signature", "sha256")), + "invalid descriptor text", + ) + expected_metadata = metadata(root, directory, version, tag, actual["notes"], actual["pub_date"]) + require( + actual == expected_metadata, + "metadata does not match exact platforms, URLs, hashes, sizes or signatures", + ) + require(path.read_bytes() == canonical(expected_metadata), "metadata is not canonical") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + sub = parser.add_subparsers(dest="command", required=True) + check = sub.add_parser("preflight") + check.add_argument("--version", required=True) + check.add_argument("--tag", required=True) + check.add_argument("--check-git", action="store_true") + check.add_argument("--signing", action="store_true") + check.add_argument("--config", type=Path) + ci = sub.add_parser("ci-key") + ci.add_argument("--directory", required=True, type=Path) + gather = sub.add_parser("collect") + gather.add_argument("--bundle", required=True, type=Path) + gather.add_argument("--directory", required=True, type=Path) + gather.add_argument("--platform", required=True, choices=PLATFORMS) + gather.add_argument("--version", required=True) + for name in ("metadata", "verify"): + item = sub.add_parser(name) + item.add_argument("--directory", required=True, type=Path) + item.add_argument("--version", required=True) + item.add_argument("--tag", required=True) + if name == "metadata": + item.add_argument("--date", required=True) + item.add_argument("--notes", default="") + args = parser.parse_args() + root = args.root.resolve() + if args.command == "preflight": + preflight(root, args.version, args.tag, args.check_git, args.signing) + if args.config: + write_build_config(args.config) + elif args.command == "ci-key": + ci_key(root, args.directory) + elif args.command == "collect": + collect(root, args.bundle, args.directory, args.platform, args.version) + elif args.command == "metadata": + require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI artifacts cannot release") + data = metadata(root, args.directory, args.version, args.tag, args.notes, args.date) + path = args.directory / "latest.json" + require( + not path.exists() and not (args.directory / "latest.json.sig").exists(), + "metadata already exists", + ) + path.write_bytes(canonical(data)) + signer(root, path) + verify_release(root, args.directory, args.version, args.tag) + else: + verify_release(root, args.directory, args.version, args.tag) + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error: + print(f"Release validation failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/release_assets_test.py b/scripts/release_assets_test.py new file mode 100644 index 0000000..df54e6c --- /dev/null +++ b/scripts/release_assets_test.py @@ -0,0 +1,115 @@ +"""No network: reject unsafe draft listings before invoking GitHub download.""" + +import copy +import unittest +from pathlib import Path +from unittest.mock import patch + +import release +import release_github + + +class DraftAssetGateTests(unittest.TestCase): + def draft(self): + packages = release.expected_names("0.3.0") + names = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"} + self.assertEqual(len(names), 18) + return { + "id": 123, "tag_name": "v0.3.0", "draft": True, "prerelease": False, + "assets": [{"name": name, "id": index, "state": "uploaded", "size": 1, "digest": None} + for index, name in enumerate(sorted(names), 1)], + } + + def assert_no_download(self, data): + with (patch.object(release_github, "api", return_value=data), + patch.object(release_github, "gh") as github, + patch.object(release, "verify_release") as verify): + with self.assertRaises(ValueError): + release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0") + github.assert_not_called() + verify.assert_not_called() + + def fake_download(self, listing): + def download(*args): + self.assertEqual(args[:3], ("release", "download", "v0.3.0")) + destination = Path(args[args.index("--dir") + 1]) + for asset in listing["assets"]: + (destination / asset["name"]).write_bytes(b"x" * asset["size"]) + return download + + def test_exact_complete_listing_still_downloads_and_verifies_actual_contents(self): + listing = self.draft() + with (patch.object(release_github, "api", side_effect=[listing, listing]) as api, + patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github, + patch.object(release, "verify_release") as verify): + self.assertEqual(release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0"), listing) + github.assert_called_once() + verify.assert_called_once() + self.assertEqual(api.call_count, 2) + + def test_unexpected_traversal_absolute_and_excessive_names_cannot_download(self): + for name in ["extra.exe", "../latest.json", r"..\latest.json", "/tmp/latest.json", + "folder/latest.json", "latest.JSON", "x" * 4096]: + with self.subTest(name=name[:50]): + listing = self.draft() + listing["assets"][0]["name"] = name + self.assert_no_download(listing) + listing = self.draft() + listing["assets"].append({"name": "extra.txt", "id": 100, "state": "uploaded", "size": 1}) + self.assert_no_download(listing) + + def test_missing_duplicate_and_unfinished_files_cannot_download(self): + for index in range(18): + with self.subTest(missing=index): + listing = self.draft() + listing["assets"].pop(index) + self.assert_no_download(listing) + listing = self.draft() + listing["assets"].append(copy.deepcopy(listing["assets"][0])) + self.assert_no_download(listing) + listing = self.draft() + listing["assets"][0]["state"] = "new" + self.assert_no_download(listing) + + def test_every_asset_size_must_be_a_positive_integer_within_its_limit(self): + listing = self.draft() + for index, asset in enumerate(listing["assets"]): + limit = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3 + for size in [0, -1, True, False, 1.0, "1", None, limit + 1]: + with self.subTest(name=asset["name"], size=size): + altered = copy.deepcopy(listing) + altered["assets"][index]["size"] = size + self.assert_no_download(altered) + + def test_declared_size_boundaries_are_accepted_without_allocating_large_files(self): + listing = self.draft() + for asset in listing["assets"]: + asset["size"] = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3 + with (patch.object(release_github, "api", return_value=listing), + patch.object(release_github, "gh", side_effect=RuntimeError("download boundary reached")) as github): + with self.assertRaisesRegex(RuntimeError, "download boundary reached"): + release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0") + github.assert_called_once() + + def test_valid_listing_does_not_bypass_signature_failure_or_remote_race_checks(self): + listing = self.draft() + with (patch.object(release_github, "api", return_value=listing) as api, + patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github, + patch.object(release, "verify_release", side_effect=ValueError("invalid updater signature")) as verify): + with self.assertRaisesRegex(ValueError, "invalid updater signature"): + release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0") + github.assert_called_once() + verify.assert_called_once() + self.assertEqual(api.call_count, 1) + changed = copy.deepcopy(listing) + changed["assets"][0]["id"] += 100 + with (patch.object(release_github, "api", side_effect=[listing, changed]), + patch.object(release_github, "gh", side_effect=self.fake_download(listing)), + patch.object(release, "verify_release") as verify): + with self.assertRaisesRegex(ValueError, "changed during verification"): + release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0") + verify.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_formats.py b/scripts/release_formats.py new file mode 100644 index 0000000..b7ab10d --- /dev/null +++ b/scripts/release_formats.py @@ -0,0 +1,237 @@ +"""Read-only package checks. Never execute or install a package under inspection.""" + +import io +import json +import plistlib +import shutil +import struct +import subprocess +import tarfile +from pathlib import Path, PurePosixPath + + +def require(condition, message): + if not condition: + raise ValueError(f"Package validation failed: {message}") + + +def pe_machine(data): + require(len(data) >= 64 and data[:2] == b"MZ", "invalid PE DOS header") + offset = struct.unpack_from("\n", "invalid deb ar header") + seen = set() + while header := stream.read(60): + require(len(header) == 60 and header[58:] == b"`\n", "invalid deb ar member") + name = header[:16].decode("ascii").strip().removesuffix("/") + require(name not in seen and len(seen) < 20, "duplicate/excessive deb members") + seen.add(name) + size = int(header[48:58].decode("ascii").strip()) + require( + 0 <= size <= 1024**3 and stream.tell() + size <= path.stat().st_size, + "invalid/truncated deb member size", + ) + if name == "debian-binary": + require(size <= 16, "invalid debian-binary size") + debian_binary = stream.read(size) + elif name in {"control.tar.gz", "control.tar.xz", "control.tar"}: + require(control is None and size <= 8 * 1024**2, "invalid deb control archive") + control = stream.read(size) + require(len(control) == size, "truncated deb control archive") + else: + stream.seek(size, 1) + if size % 2: + require(stream.read(1) == b"\n", "invalid ar padding") + require( + debian_binary == b"2.0\n" + and control is not None + and any(name.startswith("data.tar") for name in seen), + "missing deb version/control/data", + ) + with tarfile.open(fileobj=io.BytesIO(control), mode="r:*") as archive: + matches = [item for item in archive if safe_member(item.name) == "control"] + require( + len(matches) == 1 and matches[0].isfile() and matches[0].size <= 1024**2, + "invalid deb control file", + ) + text = archive.extractfile(matches[0]).read().decode("utf-8") + fields = {} + for line in text.splitlines(): + if line and not line[0].isspace(): + key, separator, value = line.partition(":") + require(separator and key not in fields, "invalid/duplicate deb control field") + fields[key] = value.strip() + require(fields.get("Architecture") == "amd64", "deb Architecture must be amd64") + require(fields.get("Version") == version, "deb version mismatch") + + +def expected_nsis_payload(original): + # tauri-cli-v2.11.4 / tauri-bundler::patch_binary changes only the first + # complete token, then restores the original on-disk main after each bundle. + # Reproduce that exact operation; never mask PE checksums, sections, + # Authenticode certificate tables or arbitrary matching regions. + token = b"__TAURI_BUNDLE_TYPE_VAR_UNK" + patched = b"__TAURI_BUNDLE_TYPE_VAR_NSS" + offset = original.find(token) + require(offset >= 0, "built launcher lacks the expected Tauri bundle-type marker") + return original[:offset] + patched + original[offset + len(token) :] + + +def nsis_payload(path, built_main): + tool = shutil.which("7z") or r"C:\Program Files\7-Zip\7z.exe" + require(Path(tool).is_file(), "7-Zip is required to inspect NSIS payload") + listing = subprocess.run( + [tool, "l", "-slt", "-sccUTF-8", "--", str(path)], + capture_output=True, + check=False, + timeout=60, + ) + require(listing.returncode == 0, "7-Zip cannot inspect NSIS payload") + matches = [] + for line in listing.stdout.decode("utf-8", errors="strict").splitlines(): + if line.startswith("Path = "): + entry = line.removeprefix("Path = ") + if PurePosixPath(entry.replace("\\", "/")).name == built_main.name: + matches.append(entry) + require(len(matches) == 1, "expected exactly one bundled launcher EXE in NSIS") + extracted = subprocess.run( + [tool, "x", "-so", "-bd", "--", str(path), matches[0]], + capture_output=True, + check=False, + timeout=60, + ) + require(extracted.returncode == 0, "7-Zip cannot extract NSIS launcher for inspection") + pe_x64(extracted.stdout) + require( + extracted.stdout == expected_nsis_payload(built_main.read_bytes()), + "NSIS payload differs from the x64 build plus the exact Tauri NSIS marker patch", + ) + + +def windows(path, suffix, version, built_main): + with path.open("rb") as stream: + header = stream.read(4096) + if suffix == ".msi": + require( + len(header) >= 512 + and header[:8] == bytes.fromhex("d0cf11e0a1b11ae1") + and header[28:30] == b"\xfe\xff", + "invalid MSI compound-file header", + ) + else: + # Tauri's NSIS x64 package legitimately uses an x86-unicode installer stub. + require( + pe_machine(header) in {(0x14C, 0x10B), (0x8664, 0x20B)}, "unsupported NSIS PE wrapper" + ) + if built_main is None: + return # Full payload/COM checks run on the Windows collection runner. + require(built_main.is_file() and not built_main.is_symlink(), "built launcher EXE is missing") + pe_x64(built_main.read_bytes()) + if suffix != ".msi": + nsis_payload(path, built_main) + else: + script = Path(__file__).with_name("release_msi.ps1") + result = subprocess.run( + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-File", + str(script), + "-PackagePath", + str(path.resolve()), + ], + capture_output=True, + check=False, + timeout=60, + ) + require(result.returncode == 0, "cannot read MSI summary/properties") + details = json.loads(result.stdout.decode("utf-8-sig")) + require(details.get("template", "").split(";")[0] == "x64", "MSI template must be x64") + require(details.get("version") == version, "MSI ProductVersion mismatch") + + +def validate(path, platform, suffix, version, built_main=None): + if suffix == ".AppImage": + appimage(path) + elif suffix == ".deb": + deb(path, version) + elif suffix == ".app.tar.gz": + mac_app(path, platform, version) + elif suffix == ".dmg": + with path.open("rb") as stream: + require(path.stat().st_size >= 512, "truncated DMG") + stream.seek(-512, 2) + require(stream.read(4) == b"koly", "invalid DMG UDIF trailer") + else: + windows(path, suffix, version, built_main) diff --git a/scripts/release_gate_test.py b/scripts/release_gate_test.py new file mode 100644 index 0000000..c3291c4 --- /dev/null +++ b/scripts/release_gate_test.py @@ -0,0 +1,127 @@ +"""Execute the actual trusted workflow gate against disposable local Git history.""" + +import os +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1])) +WORKFLOWS = [ROOT / ".github/workflows" / name for name in ("release.yml", "release-publish.yml")] +GATE = " - name: Resolve tag using trusted workflow Git commands\n" + + +def workflow_gate(workflow): + after = workflow.read_text().split(GATE, 1)[1] + lines = after.split(" run: |\n", 1)[1].splitlines() + body = [] + for line in lines: + if not line.startswith(" "): + break + body.append(line[10:]) + return "\n".join(body) + "\n" + + +class ReleaseGateTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-source-gate-") + self.directory = Path(self.temporary.name) + self.environment = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + self.git("init", "--initial-branch=main") + self.git("config", "user.email", "test@example.invalid") + self.git("config", "user.name", "Release Gate Test") + (self.directory / "reviewed.txt").write_text("Reviewed source\n") + self.git("add", ".") + self.git("commit", "-m", "Reviewed main commit") + self.good = self.git("rev-parse", "HEAD") + self.git("update-ref", "refs/remotes/origin/main", self.good) + self.git("tag", "v0.2.0") + self.git("checkout", "-b", "unreviewed") + (self.directory / "scripts").mkdir() + # This validator would falsely accept its own tag if a workflow ran it. + (self.directory / "scripts/release.py").write_text( + "from pathlib import Path\nPath('untrusted-code-ran').write_text('bypassed')\n" + ) + self.git("add", ".") + self.git("commit", "-m", "Unreviewed tag with false validator") + self.bad = self.git("rev-parse", "HEAD") + self.git("tag", "v9.9.9") + self.git("checkout", "main") + + def tearDown(self): + self.temporary.cleanup() + + def git(self, *args): + return subprocess.check_output(["git", *args], cwd=self.directory, + env=self.environment, text=True, stderr=subprocess.DEVNULL).strip() + + def run_gate(self, workflow, tag): + output = self.directory / (workflow.stem + "-output") + output.unlink(missing_ok=True) + result = subprocess.run(["bash", "-c", workflow_gate(workflow)], cwd=self.directory, + env=dict(self.environment, RELEASE_TAG=tag, GITHUB_OUTPUT=str(output)), + capture_output=True, text=True, check=False) + return result, output.read_text() if output.exists() else "" + + def test_main_tag_emits_immutable_commit(self): + for workflow in WORKFLOWS: + with self.subTest(workflow=workflow.name): + result, output = self.run_gate(workflow, "v0.2.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, f"commit={self.good}\n") + + def test_unreviewed_tag_cannot_replace_its_own_ancestry_validator(self): + for workflow in WORKFLOWS: + with self.subTest(workflow=workflow.name): + result, output = self.run_gate(workflow, "v9.9.9") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + self.assertFalse((self.directory / "untrusted-code-ran").exists()) + + def test_missing_or_retargeted_tag_cannot_change_validated_source(self): + for workflow in WORKFLOWS: + with self.subTest(workflow=workflow.name): + result, output = self.run_gate(workflow, "v8.8.8") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + self.git("tag", "-f", "v0.2.0", self.good) + result, output = self.run_gate(workflow, "v0.2.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.git("tag", "-f", "v0.2.0", self.bad) + self.assertEqual(output, f"commit={self.good}\n") + result, fresh_output = self.run_gate(workflow, "v0.2.0") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(fresh_output, "") + + def test_workflows_execute_candidate_code_only_after_gate_and_checkout_sha(self): + for workflow in WORKFLOWS: + with self.subTest(workflow=workflow.name): + text = workflow.read_text() + preflight = text.split(" preflight:\n", 1)[1].split("\n build:\n", 1)[0].split("\n publish:\n", 1)[0] + before, after = preflight.split(GATE, 1) + self.assertEqual(before.count("uses: actions/checkout@v4"), 1) + self.assertIn("fetch-depth: 0", before) + self.assertNotIn("ref:", before) # Initial checkout is trusted workflow main. + self.assertIn("release.version_tag", before) + self.assertLess(after.index("git merge-base --is-ancestor"), after.index("uses: actions/checkout@v4")) + self.assertIn("ref: ${{ steps.source.outputs.commit }}", after) + self.assertNotIn("ref: ${{ inputs.tag }}", text) + downstream = text[len(text.split(" preflight:\n", 1)[0]) + len(" preflight:\n") + len(preflight):] + self.assertGreater(downstream.count("ref: ${{ needs.preflight.outputs.commit }}"), 0) + self.assertEqual(downstream.count("uses: actions/checkout@v4"), downstream.count("ref: ${{ needs.preflight.outputs.commit }}")) + self.assertEqual(text.count("uses: actions/checkout@v4"), text.count("persist-credentials: false")) + + def test_write_token_is_only_exposed_to_explicit_final_publish_step(self): + text = WORKFLOWS[1].read_text().split("\n publish:\n", 1)[1] + job_environment = re.search(r"(?m)^ env:\n((?: .*\n)+)", text) + self.assertIsNotNone(job_environment) + self.assertNotIn("GH_TOKEN", job_environment.group(1)) + before, final = text.split(" - name: Re-download, verify signatures/metadata/assets and explicitly publish\n", 1) + self.assertNotIn("GH_TOKEN", before) + self.assertIn("GH_TOKEN: ${{ github.token }}", final) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_github.py b/scripts/release_github.py new file mode 100644 index 0000000..9f11e44 --- /dev/null +++ b/scripts/release_github.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Explicit workflow-only GitHub release operations, with fail-closed gates.""" + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import release + +ENVIRONMENTS = {"launcher-release", "launcher-release-publish"} + + +def gh(*args, missing=False): + result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=900, check=False) + if result.returncode: + try: + body = json.loads(result.stdout) + except ValueError: + body = {} + if missing and str(body.get("status")) == "404": + return None + raise ValueError("GitHub operation failed; no permission or validation bypass is allowed") + return result.stdout + + +def api(path, missing=False): + value = gh("api", f"repos/{release.REPOSITORY}/{path}", missing=missing) + return None if value is None else json.loads(value) + + +def validate_environment(data): + release.require(isinstance(data, dict), "protected environment is absent") + policy = data.get("deployment_branch_policy") or {} + release.require( + policy.get("protected_branches") is True and policy.get("custom_branch_policies") is False, + "release environment must allow protected branches only", + ) + rules = data.get("protection_rules") or [] + reviewers = next((rule for rule in rules if rule.get("type") == "required_reviewers"), {}) + release.require( + reviewers.get("prevent_self_review") is True, "environment must prevent self review" + ) + allowed = reviewers.get("reviewers") or [] + release.require( + any( + item.get("type") in {"User", "Team"} + and type((item.get("reviewer") or {}).get("id")) is int + and item["reviewer"]["id"] > 0 + for item in allowed + ), + "release environment requires an independent reviewer", + ) + + +def workflow_guard(): + release.require( + os.environ.get("GITHUB_REPOSITORY") == release.REPOSITORY, "wrong release repository" + ) + release.require( + os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch", + "release must be dispatched manually", + ) + release.require( + os.environ.get("GITHUB_REF") == "refs/heads/main", "release workflow must run from main" + ) + + +def gate(environment): + workflow_guard() + release.require(environment in ENVIRONMENTS, "unexpected release environment") + release.require( + api("branches/main").get("protected") is True, "main must be a protected branch" + ) + validate_environment(api(f"environments/{environment}", missing=True)) + # Jobs consume this output only after validation. Never reference a missing + # environment directly: GitHub would create it without protection rules. + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream: + stream.write(f"environment={environment}\n") + + +def asset_snapshot(data): + release.require( + data.get("draft") is True and data.get("prerelease") is False, + "expected a stable DRAFT release", + ) + assets = data.get("assets") or [] + result = {} + for asset in assets: + name = asset["name"] + release.require( + name not in result and asset.get("state") == "uploaded", + "duplicate or incomplete release asset", + ) + result[name] = (asset["id"], asset["size"], asset.get("digest")) + return result + + +def verify_draft(root, version, tag): + release.version_tag(version, tag) + before = api(f"releases/tags/{tag}") + release.require(before.get("tag_name") == tag, "draft tag mismatch") + snapshot = asset_snapshot(before) + packages = release.expected_names(version) + expected = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"} + release.require(set(snapshot) == expected, "draft asset set is incomplete or unexpected") + # Reject remote names and declared sizes before gh writes any asset locally. + # This is only a pre-download bound; signatures and actual bytes are still + # checked below, followed by the remote identity/race check. + for name, (_, size, _) in snapshot.items(): + limit = ( + 32 * 1024 if name == "latest.json" + else 8 * 1024 if name.endswith(".sig") + else release.MAX_SIZE + ) + release.require(type(size) is int and 0 < size <= limit, "invalid remote draft asset size") + with tempfile.TemporaryDirectory(prefix="shacraft-draft-check-") as temporary: + directory = Path(temporary) + gh("release", "download", tag, "--repo", release.REPOSITORY, "--dir", str(directory)) + release.verify_release(root, directory, version, tag) + release.require( + set(snapshot) == {p.name for p in directory.iterdir()}, + "draft assets changed during download", + ) + for path in directory.iterdir(): + release.require(snapshot[path.name][1] == path.stat().st_size, "draft size mismatch") + after = api(f"releases/tags/{tag}") + release.require( + after["id"] == before["id"] and asset_snapshot(after) == snapshot, + "draft changed during verification", + ) + return after + + +def draft(root, directory, version, tag): + workflow_guard() + release.preflight(root, version, tag, check_git=True, signing=True) + release.verify_release(root, directory, version, tag) + with tempfile.TemporaryDirectory(prefix="shacraft-release-notes-") as temporary: + notes = Path(temporary) / "notes.md" + release_notes = release.read_json(directory / "latest.json")["notes"] + instructions = ( + f"https://github.com/{release.REPOSITORY}/blob/{tag}/docs/updater-release.md" + "#installed-package-migration-and-recovery" + ) + notes.write_text( + release_notes + "\n\n" + + "[Установка, переход с 0.1.1 и восстановление](" + instructions + ").\n" + + "Используйте прежний тип пакета. Обновление .deb выполняется через менеджер пакетов.\n", + encoding="utf-8", + ) + gh( + "release", + "create", + tag, + *[str(path) for path in sorted(directory.iterdir())], + "--repo", + release.REPOSITORY, + "--draft", + "--verify-tag", + "--title", + f"ShaCraft Launcher {version}", + "--notes-file", + str(notes), + ) + verify_draft(root, version, tag) + print( + "Complete draft uploaded and re-verified. Publication requires the separate protected workflow." + ) + + +def publish(root, version, tag, confirmation): + workflow_guard() + release.require( + confirmation == f"publish {tag}", "explicit publication confirmation does not match tag" + ) + release.preflight(root, version, tag, check_git=True) + pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip() + release.require( + release.public_key() == pinned, "publication key differs from committed updater key" + ) + gate("launcher-release-publish") # Re-check protection immediately before publication. + current = api("releases/latest", missing=True) + if current is not None: + old_tag = current.get("tag_name", "") + release.version_tag(old_tag.removeprefix("v"), old_tag) + release.require( + tuple(map(int, version.split("."))) > tuple(map(int, old_tag[1:].split("."))), + "publication must advance the stable release version", + ) + verify_draft(root, version, tag) + gh("release", "edit", tag, "--repo", release.REPOSITORY, "--draft=false", "--latest") + print("Verified release published by explicit protected operator workflow.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + sub = parser.add_subparsers(dest="command", required=True) + item = sub.add_parser("gate") + item.add_argument("environment", choices=sorted(ENVIRONMENTS)) + for name in ("draft", "publish"): + item = sub.add_parser(name) + item.add_argument("--version", required=True) + item.add_argument("--tag", required=True) + if name == "draft": + item.add_argument("--directory", required=True, type=Path) + else: + item.add_argument("--confirmation", required=True) + args = parser.parse_args() + if args.command == "gate": + gate(args.environment) + elif args.command == "draft": + draft(args.root.resolve(), args.directory, args.version, args.tag) + else: + publish(args.root.resolve(), args.version, args.tag, args.confirmation) + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error: + print(f"Release stopped: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/release_msi.ps1 b/scripts/release_msi.ps1 new file mode 100644 index 0000000..3bbc36d --- /dev/null +++ b/scripts/release_msi.ps1 @@ -0,0 +1,14 @@ +param([Parameter(Mandatory=$true)][string]$PackagePath) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +# MSIDBOPEN_READONLY = 0. Reading COM properties never installs the package. +$installer = New-Object -ComObject WindowsInstaller.Installer +$summary = $installer.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $installer, @($PackagePath, 0)) +$template = $summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) +$database = $installer.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $installer, @($PackagePath, 0)) +$view = $database.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $database, @('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductVersion''')) +$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null +$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null) +if ($null -eq $record) { throw 'Missing MSI ProductVersion' } +$version = $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @(1)) +@{ template = [string]$template; version = [string]$version } | ConvertTo-Json -Compress diff --git a/scripts/release_test.py b/scripts/release_test.py new file mode 100644 index 0000000..eb7e7f3 --- /dev/null +++ b/scripts/release_test.py @@ -0,0 +1,531 @@ +"""Real Tauri signature checks using disposable keys, never release credentials.""" + +import io +import json +import os +import plistlib +import shutil +import struct +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import release +from release_assets_test import DraftAssetGateTests # noqa: F401; unittest discovery +from release_gate_test import ReleaseGateTests # noqa: F401; unittest discovery +import release_formats +import release_github + +ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1])) + + +def fixture_pe(machine=0x8664, magic=0x20B): + data = bytearray(256) + data[:2] = b"MZ" + struct.pack_into("\n") + for name, data in [ + ("debian-binary", b"2.0\n"), + ("control.tar.gz", control), + ("data.tar.gz", fixture_tar({})), + ]: + header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{100644:<8}{len(data):<10}`\n".encode() + assert len(header) == 60 + result.extend(header + data + (b"\n" if len(data) % 2 else b"")) + return bytes(result) + + +def fixture_package(name, version="0.3.0"): + if name.endswith(".app.tar.gz"): + cpu = 0x0100000C if "aarch64" in name else 0x01000007 + executable = struct.pack("