ci: verify signed packages before protected draft publication
This commit is contained in:
+41
-37
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -10,3 +10,5 @@ src-tauri/gen/
|
||||
*.p12
|
||||
*.pfx
|
||||
*.sig
|
||||
|
||||
__pycache__/
|
||||
|
||||
@@ -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 игры уже реализованы.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
/target/
|
||||
Generated
+23
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user