ci: verify signed packages before protected draft publication

This commit is contained in:
Emil
2026-09-09 19:55:53 +03:00
parent 1d08396b80
commit b2ac174398
18 changed files with 2349 additions and 38 deletions
+41 -37
View File
@@ -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/*
+5
View File
@@ -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
+95
View File
@@ -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"
+190
View File
@@ -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"
+2
View File
@@ -10,3 +10,5 @@ src-tauri/gen/
*.p12
*.pfx
*.sig
__pycache__/
+4 -1
View File
@@ -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 игры уже реализованы.
+280
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
/target/
+23
View File
@@ -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",
]
+9
View File
@@ -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"
+32
View File
@@ -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<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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
}
}
}
+415
View File
@@ -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)
+115
View File
@@ -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()
+237
View File
@@ -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("<I", data, 60)[0]
require(
64 <= offset <= len(data) - 26 and data[offset : offset + 4] == b"PE\0\0",
"invalid PE header",
)
return struct.unpack_from("<H", data, offset + 4)[0], struct.unpack_from(
"<H", data, offset + 24
)[0]
def pe_x64(data):
require(pe_machine(data) == (0x8664, 0x20B), "launcher executable must be AMD64 PE32+")
def appimage(path):
with path.open("rb") as stream:
data = stream.read(64)
require(
len(data) == 64 and data[:6] == b"\x7fELF\x02\x01", "AppImage must be little-endian ELF64"
)
require(data[8:11] == b"AI\x02", "AppImage must use Type 2 format")
require(struct.unpack_from("<H", data, 18)[0] == 62, "AppImage must target AMD64")
def safe_member(name):
name = name.removeprefix("./")
path = PurePosixPath(name)
require(
not path.is_absolute() and ".." not in path.parts and "\\" not in name,
"unsafe archive member",
)
return name
def mac_app(path, platform, version):
with tarfile.open(path, "r:gz") as archive:
members = {}
for index, item in enumerate(archive):
require(index < 10000, "too many app archive entries")
name = safe_member(item.name).rstrip("/")
require(name not in members, "duplicate app archive member")
members[name] = item
plists = [name for name in members if name.endswith(".app/Contents/Info.plist")]
require(len(plists) == 1, "expected exactly one app Info.plist")
info = members[plists[0]]
require(info.isfile() and 0 < info.size <= 1024 * 1024, "invalid app Info.plist")
details = plistlib.loads(archive.extractfile(info).read())
executable = details.get("CFBundleExecutable", "")
require(
isinstance(executable, str)
and executable not in {"", ".", ".."}
and "/" not in executable
and "\\" not in executable,
"invalid app executable name",
)
require(details.get("CFBundleShortVersionString") == version, "app version mismatch")
main = plists[0].removesuffix("Info.plist") + "MacOS/" + executable
require(main in members and members[main].isfile(), "app executable missing or linked")
data = archive.extractfile(members[main]).read(32)
require(
len(data) == 32 and data[:4] == b"\xcf\xfa\xed\xfe",
"expected a thin little-endian Mach-O64 executable",
)
cpu, _, kind = struct.unpack_from("<III", data, 4)
wanted = 0x0100000C if platform == "darwin-aarch64" else 0x01000007
require(cpu == wanted and kind == 2, "app Mach-O architecture/type mismatch")
def deb(path, version):
control = None
debian_binary = None
with path.open("rb") as stream:
require(stream.read(8) == b"!<arch>\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)
+127
View File
@@ -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()
+228
View File
@@ -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)
+14
View File
@@ -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
+531
View File
@@ -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("<I", data, 60, 128)
data[128:132] = b"PE\0\0"
struct.pack_into("<H", data, 132, machine)
struct.pack_into("<H", data, 152, magic)
marker = b"__TAURI_BUNDLE_TYPE_VAR_UNK"
data[200 : 200 + len(marker)] = marker
return bytes(data)
def fixture_tar(files):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
for name, data in files.items():
info = tarfile.TarInfo(name)
info.size = len(data)
archive.addfile(info, io.BytesIO(data))
return stream.getvalue()
def fixture_deb(architecture="amd64", version="0.3.0"):
control = fixture_tar(
{
"./control": f"Package: shacraft-launcher\nVersion: {version}\nArchitecture: {architecture}\n".encode()
}
)
result = bytearray(b"!<arch>\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("<IIIIIIII", 0xFEEDFACF, cpu, 0, 2, 0, 0, 0, 0)
return fixture_tar(
{
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
{
"CFBundleExecutable": "shacraft-launcher",
"CFBundleShortVersionString": version,
}
),
"ShaCraft.app/Contents/MacOS/shacraft-launcher": executable,
}
)
if name.endswith(".AppImage"):
data = bytearray(64)
data[:6] = b"\x7fELF\x02\x01"
data[8:11] = b"AI\x02"
struct.pack_into("<H", data, 18, 62)
return bytes(data)
if name.endswith(".deb"):
return fixture_deb(version=version)
if name.endswith(".dmg"):
return b"koly" + bytes(508)
if name.endswith(".msi"):
data = bytearray(512)
data[:8] = bytes.fromhex("d0cf11e0a1b11ae1")
data[28:30] = b"\xfe\xff"
return bytes(data)
return fixture_pe(0x14C, 0x10B) # x86 NSIS wrapper is valid for an x64 payload.
class ReleaseTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="shacraft-release-tests-")
cls.base = Path(cls.temporary.name)
# A new unencrypted disposable key per test process. No static private
# key or credentials are stored in the repository or test output.
cls.key = cls.base / "test-only.key"
environment = dict(os.environ)
for name in (
"TAURI_SIGNING_PRIVATE_KEY",
"TAURI_SIGNING_PRIVATE_KEY_PATH",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
):
environment.pop(name, None)
result = subprocess.run(
[
"node",
str(ROOT / "node_modules/@tauri-apps/cli/tauri.js"),
"signer",
"generate",
"--ci",
"--password",
"",
"--write-keys",
str(cls.key),
],
env=environment,
capture_output=True,
timeout=120,
check=False,
)
if result.returncode:
raise RuntimeError("disposable Tauri signer setup failed")
cls.key.chmod(0o600)
cls.key_values = {
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(cls.key) + ".pub").read_text().strip(),
"TAURI_SIGNING_PRIVATE_KEY": cls.key.read_text().strip(),
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
"SHACRAFT_UPDATER_TEST_BUILD": "0",
}
cls.originals = cls.base / "complete"
cls.originals.mkdir()
with patch.dict(os.environ, cls.key_values):
for name in release.expected_names("0.3.0"):
path = cls.originals / name
path.write_bytes(fixture_package(name))
release.signer(ROOT, path)
data = release.metadata(
ROOT, cls.originals, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
)
(cls.originals / "latest.json").write_bytes(release.canonical(data))
release.signer(ROOT, cls.originals / "latest.json")
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def setUp(self):
self.case = tempfile.TemporaryDirectory(dir=self.base)
self.directory = Path(self.case.name) / "assets"
shutil.copytree(self.originals, self.directory)
self.environment = patch.dict(os.environ, self.key_values)
self.environment.start()
def tearDown(self):
self.environment.stop()
self.case.cleanup()
def verify(self):
release.verify_release(ROOT, self.directory, "0.3.0", "v0.3.0")
def resign_metadata(self, mutate):
path = self.directory / "latest.json"
data = json.loads(path.read_bytes())
mutate(data)
path.write_bytes(release.canonical(data))
release.signer(ROOT, path)
def test_complete_release_and_metadata_generation_are_deterministic(self):
self.verify()
first = release.metadata(
ROOT, self.directory, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
)
self.assertEqual(release.canonical(first), (self.directory / "latest.json").read_bytes())
def test_package_bit_flip_fails_actual_plugin_verification(self):
path = self.directory / release.filename("0.3.0", "linux-x86_64", ".AppImage")
path.write_bytes(path.read_bytes() + b"tampered")
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_metadata_substitution_is_rejected_before_json_parsing(self):
(self.directory / "latest.json").write_bytes(b"not even JSON")
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_valid_signature_for_other_artifact_does_not_suffice(self):
linux = release.filename("0.3.0", "linux-x86_64", ".AppImage")
windows = release.filename("0.3.0", "windows-x86_64", "-setup.exe")
shutil.copyfile(self.directory / (windows + ".sig"), self.directory / (linux + ".sig"))
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_wrong_public_key_fails_real_verifier(self):
encoded = release.public_key()
import base64
lines = base64.b64decode(encoded).decode().splitlines()
raw = bytearray(base64.b64decode(lines[1]))
raw[-1] ^= 1
lines[1] = base64.b64encode(raw).decode()
wrong = base64.b64encode(("\n".join(lines) + "\n").encode()).decode()
with patch.dict(os.environ, {"SHACRAFT_UPDATER_PUBLIC_KEY": wrong}):
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_all_platforms_and_manual_packages_are_mandatory(self):
for name in sorted(release.expected_names("0.3.0")):
path = self.directory / (name + ".sig")
original = path.read_bytes()
path.unlink()
with self.subTest(name=name), self.assertRaisesRegex(ValueError, "asset set"):
self.verify()
path.write_bytes(original)
def test_extra_ci_marker_or_file_blocks_promotion(self):
(self.directory / "CI_NOT_FOR_RELEASE.txt").write_text("test key")
with self.assertRaisesRegex(ValueError, "asset set"):
self.verify()
def test_signed_metadata_must_match_tag_and_exact_source_assets(self):
alterations = [
lambda x: x.update(version="0.2.0"),
lambda x: x.update(tag="v0.4.0"),
lambda x: x["platforms"].pop("darwin-aarch64"),
lambda x: x["platforms"].update({"linux-aarch64": x["platforms"]["linux-x86_64"]}),
lambda x: x["manualPackages"].pop("windows-x86_64-msi"),
lambda x: x["platforms"]["linux-x86_64"].update(url="https://evil.invalid/package"),
lambda x: x["platforms"]["linux-x86_64"].update(
url=x["platforms"]["linux-x86_64"]["url"].replace("v0.3.0", "v0.2.0")
),
lambda x: x["platforms"]["linux-x86_64"].update(sha256="0" * 64),
lambda x: x["platforms"]["linux-x86_64"].update(size=True),
lambda x: x.update(pub_date="2026-09-09T00:00:00+03:00"),
lambda x: x.update(extra="not allowed"),
]
for index, alter in enumerate(alterations):
shutil.copyfile(self.originals / "latest.json", self.directory / "latest.json")
self.resign_metadata(alter)
with self.subTest(index=index), self.assertRaises(ValueError):
self.verify()
def test_duplicate_json_fields_are_rejected_even_if_signed(self):
path = self.directory / "latest.json"
path.write_bytes(
path.read_bytes().replace(
b'"schemaVersion": 1,', b'"schemaVersion": 1, "schemaVersion": 1,'
)
)
release.signer(ROOT, path)
with self.assertRaisesRegex(ValueError, "duplicate JSON"):
self.verify()
def test_collect_requires_generated_updater_sig_and_signs_manual_deb(self):
bundle = Path(self.case.name) / "bundle"
(bundle / "appimage").mkdir(parents=True)
(bundle / "deb").mkdir()
app = bundle / "appimage/Test.AppImage"
app.write_bytes(fixture_package("Test.AppImage"))
(bundle / "deb/Test.deb").write_bytes(fixture_package("Test.deb"))
output = Path(self.case.name) / "collected"
with self.assertRaisesRegex(ValueError, "missing generated updater signature"):
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
shutil.rmtree(output)
release.signer(ROOT, app)
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
self.assertEqual(len(list(output.iterdir())), 4)
for suffix in (".AppImage", ".deb"):
data = output / release.filename("0.3.0", "linux-x86_64", suffix)
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
def test_missing_key_fails_and_optional_password_never_prompts(self):
data = self.directory / "latest.json"
with patch.dict(os.environ, {"TAURI_SIGNING_PRIVATE_KEY": ""}):
with self.assertRaisesRegex(ValueError, "PRIVATE_KEY is missing"):
release.signer(ROOT, data)
with patch.dict(os.environ):
os.environ.pop("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", None)
release.signer(ROOT, data)
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
def test_release_preflight_rejects_placeholder_mismatch_and_test_mode(self):
source = Path(self.case.name) / "source"
(source / "src-tauri").mkdir(parents=True)
(source / "package.json").write_text('{"version":"0.3.0"}')
(source / "package-lock.json").write_text(
'{"version":"0.3.0","packages":{"":{"version":"0.3.0"}}}'
)
(source / "src-tauri/tauri.conf.json").write_text('{"version":"0.3.0"}')
(source / "src-tauri/Cargo.toml").write_text('[package]\nversion="0.3.0"\n')
(source / "src-tauri/updater-public-key.txt").write_text("UNCONFIGURED")
with self.assertRaisesRegex(ValueError, "differs from committed"):
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
with patch.dict(os.environ, {"SHACRAFT_UPDATER_TEST_BUILD": "1"}):
with self.assertRaisesRegex(ValueError, "test keys cannot release"):
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
(source / "package.json").write_text('{"version":"0.3.1"}')
with self.assertRaisesRegex(ValueError, "source versions disagree"):
release.preflight(source, "0.3.0", "v0.3.0")
class PackageFormatTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-format-tests-")
self.root = Path(self.temporary.name)
def tearDown(self):
self.temporary.cleanup()
def package(self, name, data=None):
path = self.root / name
path.write_bytes(fixture_package(name) if data is None else data)
return path
def test_mac_archive_binds_main_executable_cpu_and_version(self):
path = self.package("darwin-aarch64.app.tar.gz")
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
for platform, version in [("darwin-x86_64", "0.3.0"), ("darwin-aarch64", "0.2.0")]:
with self.subTest(platform=platform, version=version), self.assertRaises(ValueError):
release_formats.mac_app(path, platform, version)
def test_mac_archive_rejects_traversal_and_missing_binary(self):
for files in (
{"../Bad.app/Contents/Info.plist": b"bad"},
{
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
{"CFBundleExecutable": "missing", "CFBundleShortVersionString": "0.3.0"}
)
},
):
path = self.package("darwin-aarch64.app.tar.gz", fixture_tar(files))
with self.assertRaises(ValueError):
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
def test_appimage_rejects_arm64_wrong_class_and_type1(self):
path = self.package("linux-x86_64.AppImage")
release_formats.appimage(path)
for offset, data in [(18, struct.pack("<H", 183)), (4, b"\x01"), (10, b"\x01")]:
bad = bytearray(fixture_package(path.name))
bad[offset : offset + len(data)] = data
path.write_bytes(bad)
with self.assertRaises(ValueError):
release_formats.appimage(path)
def test_deb_control_binds_architecture_and_version(self):
path = self.package("linux-x86_64.deb")
release_formats.deb(path, "0.3.0")
for architecture, version in [("arm64", "0.3.0"), ("amd64", "0.2.0")]:
path.write_bytes(fixture_deb(architecture, version))
with self.assertRaises(ValueError):
release_formats.deb(path, "0.3.0")
def test_nsis_stub_may_be_x86_but_extracted_main_must_equal_x64_build(self):
wrapper = self.package("setup.exe")
main = self.package("shacraft-launcher.exe", fixture_pe())
listing = subprocess.CompletedProcess(
[], 0, b"Path = setup.exe\nPath = $INSTDIR/shacraft-launcher.exe\n", b""
)
for extracted, success in [
(release_formats.expected_nsis_payload(fixture_pe()), True),
(fixture_pe(), False),
(release_formats.expected_nsis_payload(fixture_pe(0xAA64, 0x20B)), False),
(release_formats.expected_nsis_payload(fixture_pe()) + b"different", False),
]:
with (
patch.object(release_formats.shutil, "which", return_value=str(main)),
patch.object(
release_formats.subprocess,
"run",
side_effect=[listing, subprocess.CompletedProcess([], 0, extracted, b"")],
),
):
if success:
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
else:
with self.assertRaises(ValueError):
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
def test_nsis_identity_allows_only_the_actual_first_bundle_marker_patch(self):
original = fixture_pe() + b"__TAURI_BUNDLE_TYPE_VAR_UNK"
expected = release_formats.expected_nsis_payload(original)
self.assertEqual(
expected,
original.replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_NSS", 1),
)
self.assertEqual(expected.count(b"__TAURI_BUNDLE_TYPE_VAR_UNK"), 1)
with self.assertRaisesRegex(ValueError, "bundle-type marker"):
release_formats.expected_nsis_payload(
fixture_pe().replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_MSI")
)
# No certificate table/checksum/other byte range is ignored.
mutated = bytearray(expected)
mutated[190] ^= 1
self.assertNotEqual(bytes(mutated), release_formats.expected_nsis_payload(original))
self.assertNotEqual(
expected.replace(b"_VAR_NSS", b"_VAR_MSI"),
release_formats.expected_nsis_payload(original),
)
def test_msi_readonly_metadata_requires_x64_and_version(self):
msi = self.package("setup.msi")
main = self.package("shacraft-launcher.exe", fixture_pe())
for architecture, version, success in [
("x64;1033", "0.3.0", True),
("Intel;1033", "0.3.0", False),
("x64;1033", "0.2.0", False),
]:
result = subprocess.CompletedProcess(
[], 0, json.dumps({"template": architecture, "version": version}).encode(), b""
)
with patch.object(release_formats.subprocess, "run", return_value=result) as command:
if success:
release_formats.windows(msi, ".msi", "0.3.0", main)
else:
with self.assertRaises(ValueError):
release_formats.windows(msi, ".msi", "0.3.0", main)
self.assertIn("release_msi.ps1", command.call_args.args[0][4])
class WorkflowPolicyTests(unittest.TestCase):
def protected(self):
return {
"deployment_branch_policy": {
"protected_branches": True,
"custom_branch_policies": False,
},
"protection_rules": [
{
"type": "required_reviewers",
"prevent_self_review": True,
"reviewers": [{"type": "User", "reviewer": {"id": 123}}],
}
],
}
def test_protected_environment_requires_existing_independent_review(self):
release_github.validate_environment(self.protected())
for data in (
None,
{},
{"deployment_branch_policy": {}},
dict(self.protected(), protection_rules=[]),
):
with self.subTest(data=data), self.assertRaises(ValueError):
release_github.validate_environment(data)
for mutate in (
lambda d: d["deployment_branch_policy"].update(protected_branches=False),
lambda d: d["protection_rules"][0].update(prevent_self_review=False),
lambda d: d["protection_rules"][0].update(reviewers=[]),
):
data = self.protected()
mutate(data)
with self.assertRaises(ValueError):
release_github.validate_environment(data)
def test_gate_does_not_emit_missing_environment_name(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "output"
env = {
"GITHUB_REPOSITORY": release.REPOSITORY,
"GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REF": "refs/heads/main",
"GITHUB_OUTPUT": str(output),
}
with (
patch.dict(os.environ, env),
patch.object(release_github, "api", side_effect=[{"protected": True}, None]),
):
with self.assertRaisesRegex(ValueError, "absent"):
release_github.gate("launcher-release")
self.assertFalse(output.exists())
def test_publication_never_runs_without_exact_confirmation(self):
env = {
"GITHUB_REPOSITORY": release.REPOSITORY,
"GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REF": "refs/heads/main",
}
with patch.dict(os.environ, env), patch.object(release_github, "gh") as client:
with self.assertRaisesRegex(ValueError, "confirmation"):
release_github.publish(ROOT, "0.3.0", "v0.3.0", "publish v0.2.0")
client.assert_not_called()
def test_asset_snapshot_rejects_public_or_incomplete_draft(self):
data = {
"draft": True,
"prerelease": False,
"assets": [
{
"id": 1,
"name": "latest.json",
"size": 10,
"state": "uploaded",
"digest": "sha256:abc",
}
],
}
self.assertEqual(release_github.asset_snapshot(data)["latest.json"][0], 1)
for change in (
{"draft": False},
{"prerelease": True},
{"assets": [dict(data["assets"][0], state="new")]},
{"assets": data["assets"] * 2},
):
with self.assertRaises(ValueError):
release_github.asset_snapshot(dict(data, **change))
def test_version_and_tag_cannot_inject_paths_or_exceed_msi_limits(self):
release.version_tag("0.3.0", "v0.3.0")
for version, tag in (
("0.3.0", "main"),
("0.3.0", "v0.3.0/evil"),
("01.3.0", "v01.3.0"),
("0.3.0-beta", "v0.3.0-beta"),
("256.0.0", "v256.0.0"),
("0.0.65536", "v0.0.65536"),
):
with self.subTest(version=version, tag=tag), self.assertRaises(ValueError):
release.version_tag(version, tag)
if __name__ == "__main__":
unittest.main()