Add channel-aware release tooling and updater recovery

This commit is contained in:
2026-09-10 00:39:37 +02:00
parent cc48bf45d2
commit 0a89323533
42 changed files with 7840 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
name: Publish launcher release status
on:
# Manual-release mode is active. Restore the status-file push trigger when
# automatic channel/history publication is wanted again.
workflow_dispatch:
concurrency:
group: launcher-release-publication
cancel-in-progress: false
jobs:
history:
name: Sign and publish release history
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Check out trusted release metadata
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Validate release status configuration
shell: bash
run: |
set -euo pipefail
for command in git bash curl jq python3 openssl base64 realpath stat
do
command -v "$command" >/dev/null || {
printf 'Required metadata command is unavailable: %s\n' "$command" >&2
exit 1
}
done
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
python3 -m compileall -q ptlinux scripts
python3 scripts/generate_history.py --help >/dev/null
bash -n scripts/publish-release-history.sh
- name: Materialize the protected manifest signing key
shell: bash
env:
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
if [[ -z "${MANIFEST_SIGNING_KEY:-}" ]]
then
printf '%s\n' 'MANIFEST_SIGNING_KEY is required for metadata publication.' >&2
exit 1
fi
umask 077
SIGNING_KEY_FILE="${RUNNER_TEMP}/launcher-manifest-signing-key.pem"
printf '%s\n' "$MANIFEST_SIGNING_KEY" > "$SIGNING_KEY_FILE"
chmod 0600 "$SIGNING_KEY_FILE"
printf 'PTLINUX_SIGNING_KEY=%s\n' "$SIGNING_KEY_FILE" >> "$GITHUB_ENV"
- name: Publish signed release status and history
shell: bash
env:
RELEASE_BRANCH: main
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
./scripts/publish-release-history.sh ./dist/status
- name: Remove signing key
if: always()
shell: bash
run: |
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
rm -f -- "$PTLINUX_SIGNING_KEY"
fi

View File

@@ -0,0 +1,164 @@
name: Publish launcher release
# Manual-release mode is active. Restore the v* tag-push trigger when automatic
# Gitea publication is wanted again.
on:
workflow_dispatch:
concurrency:
group: launcher-release-publication
cancel-in-progress: false
jobs:
release:
name: Build, verify, and publish tagged release
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Check out the exact tag
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Validate and derive release metadata
id: release
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
if [[ "$(git cat-file -t "$TAG")" != "tag" ]]
then
printf 'Release tag must be annotated: %s\n' "$TAG" >&2
exit 1
fi
python3 -m ptlinux.update_resolver inspect-tag "$TAG" > release-metadata.json
VERSION="$(jq -er '.version' release-metadata.json)"
CHANNEL="$(jq -er '.channel' release-metadata.json)"
PRERELEASE="$(jq -er '.prerelease' release-metadata.json)"
COMMIT="$(git rev-parse "${TAG}^{commit}")"
if [[ "$COMMIT" != "$(git rev-parse HEAD)" ]]
then
printf 'Checkout does not match tagged commit %s.\n' "$COMMIT" >&2
exit 1
fi
mapfile -t OTHER_RELEASE_TAGS < <(
git tag --list | while IFS= read -r candidate
do
[[ "$candidate" == "$TAG" ]] || printf '%s\n' "$candidate"
done
)
python3 -m ptlinux.update_resolver validate-next \
"$CHANNEL" "$VERSION" "${OTHER_RELEASE_TAGS[@]}"
{
printf 'tag=%s\n' "$TAG"
printf 'version=%s\n' "$VERSION"
printf 'channel=%s\n' "$CHANNEL"
printf 'prerelease=%s\n' "$PRERELEASE"
printf 'commit=%s\n' "$COMMIT"
} >> "$GITHUB_OUTPUT"
- name: Recover the previewed release notes
shell: bash
run: |
set -euo pipefail
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
git for-each-ref --format='%(contents)' \
"refs/tags/${{ steps.release.outputs.tag }}" > "$NOTES_FILE"
if [[ ! -s "$NOTES_FILE" ]]
then
printf '%s\n' 'Annotated release tag has no release notes.' >&2
exit 1
fi
printf 'RELEASE_NOTES_FILE=%s\n' "$NOTES_FILE" >> "$GITHUB_ENV"
- name: Run release preflight checks
shell: bash
run: |
set -euo pipefail
for command in git bash curl jq python3 tar gzip sha256sum rsync openssl base64 realpath stat
do
command -v "$command" >/dev/null || {
printf 'Required release command is unavailable: %s\n' "$command" >&2
exit 1
}
done
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
python3 -m compileall -q ptlinux ptdiag scripts
python3 -m unittest discover -s release_tests -p 'test_*.py'
python3 -m unittest discover -s tests -p 'test_*.py'
bash -n scripts/release.sh scripts/build-release.sh \
scripts/verify-release.sh scripts/publish-gitea-release.sh \
scripts/publish-release-history.sh
- name: Materialize the protected manifest signing key
shell: bash
env:
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
if [[ -z "${MANIFEST_SIGNING_KEY:-}" ]]
then
printf '%s\n' 'MANIFEST_SIGNING_KEY is required for release publication.' >&2
exit 1
fi
umask 077
SIGNING_KEY_FILE="${RUNNER_TEMP}/launcher-manifest-signing-key.pem"
printf '%s\n' "$MANIFEST_SIGNING_KEY" > "$SIGNING_KEY_FILE"
chmod 0600 "$SIGNING_KEY_FILE"
printf 'PTLINUX_SIGNING_KEY=%s\n' "$SIGNING_KEY_FILE" >> "$GITHUB_ENV"
- name: Build deterministic release artifacts
shell: bash
env:
RELEASE_COMMIT: ${{ steps.release.outputs.commit }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}
REQUIRE_RELEASE_SIGNATURE: "1"
run: |
set -euo pipefail
export SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH="$(git show -s --format=%ct '${{ steps.release.outputs.commit }}')"
./scripts/build-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist
- name: Verify complete release artifacts
shell: bash
env:
REQUIRE_RELEASE_SIGNATURE: "1"
run: |
set -euo pipefail
./scripts/verify-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist
- name: Create Gitea release, upload assets, and update channel metadata
shell: bash
env:
RELEASE_BRANCH: main
RELEASE_COMMIT: ${{ steps.release.outputs.commit }}
RELEASE_PRERELEASE: ${{ steps.release.outputs.prerelease }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
./scripts/publish-gitea-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist \
"$RELEASE_NOTES_FILE"
- name: Remove signing key
if: always()
shell: bash
run: |
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
rm -f -- "$PTLINUX_SIGNING_KEY"
fi

View File

@@ -0,0 +1,444 @@
# PristonTale EU Linux Launcher Release Pipeline Workplan
This is the tracked implementation plan for manual Gitea releases today and the
deferred automatic publication path, stable and prerelease channels, signed
manifests, release history, and channel-aware rollback. A checked item means the
repository contains the implementation or the audit evidence named beside it.
Publishing tests that require a configured Gitea server remain unchecked until
they have run against that server.
Legend: `[x]` complete, `[ ]` pending.
## Current milestone
- [x] Save the release workplan in the repository.
- [x] Inspect the existing release, update, rollback, settings, UI, and telemetry paths.
- [x] Implement and validate the automatic publication pipeline for future use.
- [x] Switch to temporary manual-publication mode: `release.sh` now creates a
signed, verified `dist/v<VERSION>/` package without commits, tags, pushes, API
calls, or uploads.
- [x] Disable automatic workflow push triggers so manual Gitea releases cannot
race an Actions publication job.
- [x] Store normal update payloads directly at the tar root while retaining
runtime extraction support for historical archives and the `0.9.10`–`0.9.11`
compatibility window.
- [ ] Re-enable automatic Gitea publication when requested.
- [x] P1: add stable/prerelease discovery and launcher channel support.
- [x] P2: harden remote rollback and managed-file reconciliation.
- [ ] P3: complete signing migration and release quality improvements.
## Phase 0 — Existing-system audit
- [x] Repository structure inspected. Runtime sources are in `ptlinux/` and
`ptdiag/`; installer assets are in `packaging/`; the checked-in channel is in
`updates/`; local, ignored development tools and tests are in `tools/` and
`tests/`.
- [x] Authoritative launcher version identified as `ptlinux/__init__.py`.
`pyproject.toml` is a required synchronized metadata mirror.
- [x] Current update archive identified as
`pristontale-eu-linux-update-v<VERSION>.tar.gz`.
- [x] Current archive layout identified. Its root is
`pristontale-eu-linux-update-v<VERSION>/`, containing `ptlinux/`, a compact
`ptdiag/` subset, and `release.json`.
- [x] Current update manifest identified as signed JSON schema 1 in
`updates/stable.json`, with version, channel, minimum updater version, archive
URL/size/SHA-256, publication time, and notes URL.
- [x] Current update URL identified as the raw `main` branch
`updates/stable.json`; `PTLINUX_UPDATE_MANIFEST_URL` can override it.
- [x] Latest-release discovery identified as the mutable signed stable manifest.
The launcher does not use Gitea `/releases/latest`.
- [x] Current rollback identified as local retained release directories selected
by atomic `current` and `previous` symlinks.
- [x] Cached archives identified in the launcher download cache and reused only
when their size and SHA-256 match the authenticated manifest.
- [x] Current staging identified: download to a `.part` file, verify while
downloading, safely extract to a staging directory, validate/self-import,
move into the immutable release store, then atomically replace symlinks.
- [x] Atomic activation confirmed through same-directory temporary symlink plus
`os.replace()` and directory `fsync`.
- [x] Removed launcher files are handled because each release is a complete,
separate directory. No in-place overlay is used.
- [x] Fixed release asset names are not required by the current launcher; the
authenticated manifest supplies the archive URL.
- [x] Existing manually generated files identified: installer tar/zip archives,
launcher update tar archive, SHA-256 files, stable manifest/signature, and
release uploads.
- [x] Existing manual exclusions identified. The launcher update builder selects
Python/runtime files explicitly and excludes installer-only modules; the AIO
builder selects runtime, packaging, installer, and README files explicitly.
- [x] SHA-256 generation and verification confirmed for archives.
- [x] Existing asymmetric signature support confirmed. The manifest is signed
and verified with the bundled public key through OpenSSL (currently RSA with
SHA-256); the private key is ignored under `release-keys/`.
- [x] Existing telemetry inspected and extended with channel, target release,
manifest identity, and complete update/rollback lifecycle metadata.
- [x] Decision: extend the existing signed-manifest and atomic release-directory
updater instead of introducing a second updater.
## Release model and invariants (phases 1–2, 19, 24–25, 32–38, 45–51, 61–63, 77, 84)
- [x] Support exactly `stable` and `prerelease` channels in the initial model.
- [x] Accept stable tags `vMAJOR.MINOR.PATCH` and prerelease tags ending in
`-beta.N` or `-rc.N`; reject channel/tag mismatches.
- [x] Treat the manually selected Gitea release tag as the authoritative,
immutable release identity.
- [ ] Restore annotated-tag and channel-pointer monotonicity enforcement when
automatic publication is re-enabled. Manual publishers must currently check
the tag in Gitea and the version ordering before copying a channel pointer.
- [x] Use one semantic-version parser and one channel/update resolver; never use
lexical version ordering.
- [x] Preserve ordering from beta to RC to final, including multi-digit fields.
- [x] Keep the internal launcher version without the leading `v`.
- [x] Build immutable, versioned assets named
`PristonTale-EU-<VERSION>.tar.gz`; normal releases place `ptlinux/`, `ptdiag/`,
and `release.json` directly at the archive root.
- [x] Accept both new flat archives and legacy version-folder archives in the
updated launcher so retained releases and rollback remain compatible.
- [x] Configure stable `0.9.10` and its `0.9.11` UI repair as legacy-wrapped
bridge releases so deployed `0.9.8` launchers can install either one
automatically.
- [x] Mark flat manifests as requiring updater `0.9.9` so legacy clients fail
closed before download/extraction rather than entering a doomed transaction.
- [x] Keep final manifests/signatures/checksums outside the archive they describe.
- [x] Keep signed metadata mandatory for trusted automatic update operations.
- [x] Stable clients can install stable releases only.
- [x] Prerelease clients resolve the greatest eligible stable or prerelease
semantic version.
- [x] Changing prerelease to stable never causes an automatic downgrade.
- [x] Model release status as `active`, `withdrawn`, or `blocked`; do not offer
withdrawn/blocked releases as new automatic updates.
- [x] Use each historical release's own signed manifest and immutable versioned
archive; verify the embedded archive checksum and every target file hash.
- [x] Persist the signed per-file inventory at install time and re-verify it
before reactivating a locally retained release; preserve compatibility for
releases installed before integrity records existed.
- [x] Never reuse or overwrite a published tag or its assets.
- [x] Fail rather than duplicating an existing Gitea release.
- [x] Preserve compatibility for existing stable installations during migration.
## Release tooling and configuration (phases 3–7, 12–14, 18, 20–23, 26–27, 65–66)
- [x] Add `.gitea/workflows/release.yml` for the deferred automatic publisher;
keep it manual-dispatch only while manual upload mode is active.
- [x] Add tracked `scripts/release.sh`, `scripts/build-release.sh`,
`scripts/generate-manifest.py`, and `scripts/verify-release.sh`.
- [x] Add `release/config.json` with application, release branch, archive naming,
manifest schema, channels, selected payload, required files, and exclusions.
- [x] Keep generated `dist/` output ignored.
- [x] Explicitly exclude Git/IDE metadata, logs, caches, credentials, developer
environment files, downloads, test output, and other project-specific files.
- [x] Implement the primary CLI as `./scripts/release.sh CHANNEL VERSION` with
aliases normalized to `prerelease`.
- [x] Validate the repository root and channel/version pair, then run configured
preflight checks before creating local artifacts.
- [x] Support `--dry-run`, `--skip-tests`, and an explicit local signing key.
- [x] Isolate preflight stdin so nested CLI tests cannot open an interactive
release-note prompt, and prefer the repository `.venv` interpreter when present.
- [x] Set the requested version only in the staged package so a manual build does
not edit the authoritative source version or synchronized project metadata.
- [x] Leave commit, tag, push, Gitea release creation, and asset upload to the
developer while manual publication mode is active.
- [ ] Restore remote, branch, clean-worktree, tag-absence, version-commit,
annotated-tag, and ordered-push handling when automatic publication resumes.
- [x] Make dry-run perform validation, notes preview, and optional tests without
commit, tag, push, or release creation.
- [x] Make `build-release.sh VERSION CHANNEL OUTPUT_DIR` independently build an
explicitly selected staging tree and clean generated output.
- [x] Normalize archive ordering, timestamps, ownership, group, and permissions
where practical.
- [x] Generate archive SHA-256 and a sorted per-file manifest without absolute paths.
- [x] Include schema, application, version, channel, tag, commit, archive
metadata, release metadata, timestamp, status, compatibility, and file hashes.
- [x] Verify archive extraction, required entrypoints, forbidden content,
checksum, manifest consistency, every managed file hash, and signature before
publication.
- [x] Give each archive download a signed SHA-256 query key so a CDN cannot keep
serving a pre-upload empty response from the immutable Gitea asset path.
## Manual Gitea publication milestone
- [x] Produce the versioned archive, archive checksum, signed manifest, and
release notes together under `dist/v<VERSION>/`.
- [x] Preserve other locally built versions and clean only the selected version
directory when rebuilding it.
- [x] Verify both the normal flat archive and configured legacy-wrapped bridge;
the updater installs either payload successfully.
- [x] Print the exact five paths to upload and the matching `v<VERSION>` tag.
- [x] Print whether Gitea's Pre-release checkbox must be selected for the chosen
channel.
- [x] Generate a signed `dist/v<VERSION>/channel/<channel>.json{,.sig}` pair for the
developer to copy to `updates/` after the Gitea assets exist.
- [x] Require all five Gitea attachments to be uploaded with nonzero sizes before
publishing the generated channel pointer.
- [x] Repair the live `v0.9.11` stable pointer after Cloudflare cached a
pre-upload zero-byte archive response. The pointer now uses the archive's
signed SHA-256 cache key, and the deployed `0.9.10` updater completed its full
remote verification against the repaired channel.
- [x] Safely discard generated-only `__pycache__` content before validating a
signed managed release, while rejecting unexpected cache contents; remove the
local diagnostic cache that blocked activation and successfully complete the
`0.9.10` to `0.9.11` transaction.
- [x] Keep the other channel pointer unchanged.
- [x] Manually regenerate signed `updates/history.json{,.sig}` from the live
manifest-backed Gitea releases so remote history and rollback discovery no
longer return HTTP 404; automatic regeneration remains deferred.
## Release notes (phases 8–11)
- [x] Support `--description` and Markdown-preserving `--description-file`.
- [x] Generate commit notes from the previous stable tag for stable releases.
- [x] For prereleases, prefer the previous prerelease of the same target version;
use the previous stable tag for the first prerelease.
- [x] Put developer text before generated commit notes by default.
- [x] Support `--no-auto-changelog`, `--changelog-only`, and `--editor` using
`${EDITOR:-nano}`.
- [x] Allow interactive Ctrl+D release-note entry only on an interactive stdin;
use generated notes in noninteractive environments.
- [x] Preview the final release notes before building and copy them unchanged to
`dist/v<VERSION>/release-notes.md` for manual use as the Gitea release body.
## Deferred Gitea Actions publication (phases 15–17, 28–31, 58–60, 64)
- [x] Implement the `v*` tag-push publisher and CI tag validation for future use.
- [x] Remove its tag-push trigger during manual publication mode; only an
intentional manual dispatch can start the retained workflow.
- [x] Derive tag, version, channel, prerelease flag, and dereferenced commit SHA
from the exact checked-out annotated tag.
- [x] Prefer `GITHUB_REF_NAME`, `GITHUB_REPOSITORY`,
`GITHUB_API_URL`, `GITHUB_SERVER_URL`, `GITHUB_OUTPUT`, `GITHUB_ENV`, and
`GITHUB_STEP_SUMMARY` context values.
- [x] Use `actions/checkout@v4` and build the exact tagged commit.
- [x] Require a minimally scoped `RELEASE_TOKEN` without logging it.
- [x] Keep the manifest private signing key only in an Actions secret, write it
with mode 0600 when needed, and remove it immediately after signing.
- [x] Run validation/tests, build, manifest generation, signing, and complete
verification before creating a Gitea release.
- [x] Create `draft=false` releases through the Gitea API with the correct
prerelease flag, target commit, title, and release notes; require a successful
response and release ID.
- [x] Upload archive, checksum, manifest, manifest signature, and release notes
separately; fail on every required upload error.
- [x] Re-query the release and verify attachment names/sizes and the prerelease flag.
- [x] Print a release summary containing title, channel, tag, commit, archive,
SHA-256, manifest, and publication status.
- [x] Document Gitea Actions, repository Actions, isolated `act_runner`, required
commands, trusted tag-only secret exposure, and minimum permissions in
`release/README.md`.
- [ ] Verify Actions enablement and the registered release runner on the live Gitea.
## Channel indexes and history (phases 32–33)
- [x] Publish independently signed `stable.json` and `prerelease.json` channel
metadata at stable URLs without cross-channel mutation.
- [x] Publish signed release history with version, channel, tag, manifest URL,
signature URL, date, notes URL, status, and compatibility metadata.
- [x] Follow the Gitea release-list pagination contract so old rollback releases
remain in history after the first 100 API results.
- [x] List only real Gitea releases that retain their own manifest and signature.
- [x] Rebuild status-aware channel manifests after a withdrawal/block so legacy
channel consumers no longer receive an inactive channel head.
## Launcher channels, resolver, and UI (phases 34–44)
- [x] Add validated `update_channel` setting with `stable` default so existing
users remain stable.
- [x] Add Stable/Prerelease settings UI with clear descriptions.
- [x] Immediately check for updates after changing the selected channel.
- [x] Do not downgrade automatically after switching prerelease to stable.
- [x] Refactor discovery, update eligibility, semantic ordering, channel
transitions, and inactive-release filtering through one `UpdateResolver`.
- [x] Connect signed remote history and remote rollback choices to `UpdateResolver`.
- [x] Show current/target versions, target channel, release date/title/summary,
notes, and a clear prerelease label in the update UI.
- [x] Populate version history from actual signed release history and show
version, channel, date, state, notes, and install/rollback action.
- [x] Add All/Stable/Prerelease history filters with channel-appropriate defaults.
- [x] Stable rollback lists stable releases by default; prerelease rollback lists
earlier prereleases and stable releases with labels.
- [x] Require explicit action and a configuration-compatibility warning for
prerelease-to-stable rollback.
- [x] Label the channel of each locally retained release and warn on an explicit
cross-channel local rollback.
## Rollback transaction hardening (phases 46–50, 52–53)
- [x] Reconcile managed files by activating a complete, target-manifest-verified
immutable release directory; files absent from the target disappear without
deleting any unmanaged path.
- [x] Keep config, logs, saves, downloads, and user-created/unmanaged files outside
managed-file deletion.
- [x] Add configuration schema compatibility metadata and reject unsafe rollback.
- [x] Preserve each installed release's signed configuration compatibility in
its local integrity record and allow signed release-history overrides for
withdrawn or compatibility-reclassified historical releases.
- [x] Precheck release/manifest/signature/archive, free space, processes,
writability, and supported schemas.
- [x] Snapshot current managed state through immutable release links, then
download/verify/stage/reconcile/apply/health
check, and restore the snapshot after local transaction failure.
- [x] Permit automatic local restore after failed update application, while
prohibiting telemetry-driven remote downgrade.
- [ ] Optionally add advanced latest/pinned update policy.
- [x] Retain 30 bounded local update/rollback history records.
## Telemetry and hotfix integration (phases 54–57)
- [x] Include current/target version and channel, selected channel, tag, manifest
schema/hash, and rollback flag in update telemetry.
- [x] Add discover/download/apply success and failure event families.
- [x] Add requested/download/apply/restore rollback event families with from/to
versions/channels and an enumerated reason.
- [x] Keep full Markdown descriptions out of telemetry; use version/tag/channel
and optional release ID only.
- [ ] Make hotfix targeting channel-aware and exact-version aware while reusing
the release hash/signature verification path.
## Failure and security behavior (phases 27–31, 59–63)
- [x] Reject `.env`, private keys, SSH keys, Git/IDE metadata, logs, caches, and
bytecode in published archives.
- [x] Never expose release credentials to pull-request workflows or untrusted code.
- [x] Stop publication after test, build, manifest, signing, release-create, or
required-asset failure; never report a partial release as successful.
- [x] Keep failed tags immutable and publish a new version after correcting a failure.
## Verification matrix (phases 67–76)
- [ ] End-to-end stable release: stable flag, artifacts, signed manifest, stable
index update, unchanged prerelease index, and stable discovery.
- [ ] End-to-end beta/RC release: prerelease flag, artifacts, prerelease index
update, unchanged stable index, prerelease discovery, and stable exclusion.
- [x] Exercise inline/file/multiline Markdown, generated notes, no-auto,
changelog-only, and editor modes; compare preview with Gitea body.
- [x] Exercise both channel preferences and resolver transitions, including no
implicit downgrade after returning to stable.
- [x] Verify semantic ordering, including `1.10.0 > 1.9.9`, stable filtering,
and prerelease-to-final transition.
- [x] Exercise stable rollback, prerelease rollback, cross-channel rollback,
managed-file removal, config/log survival, and failed-apply restoration.
- [x] Exercise withdrawn-release filtering/warnings and valid successor selection.
- [x] Simulate Gitea/API 401, 403, 404, 409, 422, 500, lookup/create/upload
timeouts, and attachment
failure without false success.
- [x] Seed forbidden path cases and verify exclusion/failure and absence from the
deterministic archive.
- [x] Verify valid signatures and reject modified manifest metadata; require
signed metadata in CI.
## Acceptance criteria (phases 78–83)
- [x] `./scripts/release.sh stable 1.8.0 --description-file ...` validates,
previews, builds, signs, and verifies the five files needed for a manual
stable Gitea release without changing Git or contacting Gitea.
- [x] `./scripts/release.sh prerelease 1.9.0-beta.1 --description ...` produces
the equivalent prerelease upload set and clearly instructs the developer to
select Gitea's Pre-release checkbox.
- [x] Stable-to-prerelease checks immediately; prerelease-to-stable retains the
installed beta until a greater eligible stable exists.
- [x] Cross-channel rollback fetches and verifies the target release, warns,
snapshots, reconciles managed files, applies and health-checks, records
telemetry, and preserves user state.
- [x] Normal development remains ordinary commits/pushes. A manual release begins
with `./scripts/release.sh stable VERSION` or
`./scripts/release.sh prerelease VERSION`; archive, checksum, manifest,
signature, and release notes generation require no manual work.
- [ ] Re-enable Gitea release creation, asset upload, and signed channel/history
publication after the manual release process has been proven in production.
## Implementation priority
### P0 — Build first
- [x] Inspect existing updater/release system.
- [x] Semantic-version handling.
- [x] Manual release CLI and all description/changelog/preview modes.
- [x] Stable/prerelease tag validation.
- [x] Retained Gitea publisher implementation with archive, SHA-256, manifest,
signature, release creation, prerelease flag, upload, and verification.
- [x] Disable its automatic trigger for the current manual publication milestone.
### P1 — Channel support
- [x] `update_channel` setting and settings UI.
- [x] Signed channel metadata and channel-aware discovery.
- [x] Central `UpdateResolver` and semantic ordering.
- [x] Signed release history, release notes in update UI, and channel-aware
rollback candidates.
- [x] Keep the inline release-notes preview in a fixed-height scrollable area so
the version table, channel filter, and update controls remain visible.
### P2 — Rollback hardening
- [x] Managed-file reconciliation and snapshot/restore validation.
- [x] Cross-channel rollback and rollback telemetry.
- [x] Withdrawn state and bounded local update history.
### P3 — Security and quality
- [x] Plan a compatible Ed25519 key migration from the deployed RSA verifier.
- [x] Improve and measure reproducibility for archive and manifest output.
- [ ] Integrate channel-aware targeted hotfix verification.
- [ ] Optional version pinning and release notifications.
## Deployment prerequisites
- [x] Manual publication needs no Actions runner, `RELEASE_TOKEN`, or Gitea
signing secret; the developer needs permission to create a repository release
and upload attachments in the Gitea UI.
- [ ] Add `RELEASE_TOKEN` with repository release/content permissions to Gitea Actions.
- [ ] Add the manifest signing private key as a protected Gitea Actions secret.
- [ ] Confirm Actions, the repository workflow, and an isolated release runner are enabled.
- [ ] Confirm the runner image provides Git, Bash, curl, jq, Python 3, tar, gzip,
sha256sum, rsync, and OpenSSL/signing support.
The configured SSH remote is reachable and `origin/main` resolves successfully.
The current development environment does not expose either Actions secret, and
the public Gitea API currently returns empty HTTP 200 responses, so runner,
repository-Actions, and secret state cannot be authenticated from this checkout.
## Validation evidence
- [x] 68 release-pipeline, resolver, channel, history, signing, rollback,
telemetry, CLI, publisher, status, and API-failure tests pass locally.
- [x] All 575 current project tests pass during the complete manual release preflight.
- [x] The published flat `stable 0.9.9` package demonstrated the expected
compatibility boundary: deployed `0.9.8` clients reject its declared minimum
updater instead of attempting an archive layout they cannot extract.
- [x] A real wrapped `stable 0.9.10` bridge under `dist/v0.9.10/` built, signed,
verified, passed updater staging, and is offered automatically to `0.9.8`.
Its only tar root is `pristontale-eu-linux-update-v0.9.10`; archive SHA-256:
`76df8942d2f0b3b533eb967f8a2379b2a7bdd44fd58305d0f8bf061778fbd9ea`.
- [x] A real wrapped `stable 0.9.11` UI repair under `dist/v0.9.11/` built,
signed, and verified with minimum updater `0.5.0`. Its only tar root is
`pristontale-eu-linux-update-v0.9.11`; archive SHA-256:
`60b13d6fbc8fe27714c7fe1a65fa73785a13962d96d03decfac1aa62c4e3f6db`.
- [ ] Publish the prepared `v0.9.11` release and its five assets in Gitea. The
remote currently has no `v0.9.11` tag, so its stable channel still resolves
to `0.9.10` even when the PyCharm checkout is running `0.9.11`.
- [ ] After the Gitea assets exist, commit and push the prepared
`updates/stable.json` and `updates/stable.json.sig` pair that points stable
discovery to `0.9.11`.
- [x] Keep the `0.9.11` embedded notes to one concise change line so clients
still running the overflowing dialog can reach the install action.
- [x] Hidden-window Tk geometry validation at the default scale confirms the
notes viewport, channel filter, 124-pixel history table, and footer all fit
inside the 820×680 update dialog.
- [x] Reproduce the reported `0.9.10` layout from its published archive: the old
dialog requests 820×828 pixels while forcing a 760×620 window. Confirm the
`0.9.11` archive uses the repaired dialog and keeps notes, history, and footer
in separate non-overlapping bounds; its deliberately short channel notes also
leave the old `0.9.10` install button reachable during the repair upgrade.
- [x] Synchronize the workspace version source and project metadata to `0.9.11`,
and make the shared PyCharm launcher configuration put `$PROJECT_DIR$` first
on `PYTHONPATH`, so debug runs load the current checkout rather than an older
installed release.
- [x] Keep update-dialog version labels tied to the running launcher. When a
PyCharm checkout is newer than the active installed symlink, show the checkout
as `Current` and describe the older on-disk release as `Installed`.
- [x] Prior deterministic-build validation produced byte-identical stable
`0.9.8` archives with SHA-256
`72346f216856235df3c6071409e2bbce17853b9f9589bf3f012743ddf7823f4b`.
- [x] Shell syntax, JSON parsing, Git whitespace checks, PHP telemetry endpoint
syntax, Python compilation, and IDE error inspections pass.
- [ ] Run stable and prerelease publication acceptance tests against the live
Gitea after the protected secrets and isolated runner are configured.

View File

@@ -2253,6 +2253,7 @@ def _extract_release_archive(archive: Path, destination: Path, version: str) ->
def _validate_release_directory(release: Path) -> str:
if not release.is_dir() or release.is_symlink():
raise LauncherUpdateError(f"launcher release directory is invalid: {release}")
_remove_generated_bytecode_caches(release)
for item in (release, *release.rglob("*")):
if item.is_symlink():
raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}")
@@ -2302,6 +2303,43 @@ def _validate_release_directory(release: Path) -> str:
return version
def _remove_generated_bytecode_caches(release: Path) -> None:
"""Remove interpreter caches that are never part of a signed release."""
try:
cache_directories = sorted(
release.rglob("__pycache__"),
key=lambda path: len(path.parts),
reverse=True,
)
except OSError as exc:
raise LauncherUpdateError(
f"cannot inspect launcher bytecode caches in {release}: {exc}"
) from exc
for cache in cache_directories:
if cache.is_symlink() or not cache.is_dir():
raise LauncherUpdateError(f"launcher bytecode cache is unsafe: {cache}")
try:
entries = tuple(cache.iterdir())
except OSError as exc:
raise LauncherUpdateError(
f"cannot inspect launcher bytecode cache {cache}: {exc}"
) from exc
for entry in entries:
if entry.is_symlink() or not entry.is_file() or entry.suffix not in {".pyc", ".pyo"}:
raise LauncherUpdateError(
f"launcher bytecode cache contains an unexpected path: {entry}"
)
try:
for entry in entries:
entry.unlink()
cache.rmdir()
except OSError as exc:
raise LauncherUpdateError(
f"cannot remove launcher bytecode cache {cache}: {exc}"
) from exc
def _self_check_release(release: Path, expected_version: str) -> None:
check = (
"import sys; "

View File

@@ -0,0 +1,83 @@
"""Identify the host OS without losing the launcher's execution runtime."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import re
EXECUTION_OS_RELEASE = Path("/etc/os-release")
CONTAINER_HOST_OS_RELEASE = Path("/run/host/os-release")
STEAM_RUNTIME_OS_ID = "steamrt"
@dataclass(frozen=True)
class SystemIdentity:
"""OS releases visible to the launcher process and its container host."""
execution_release: dict[str, str]
host_release: dict[str, str] | None
@property
def operating_system_release(self) -> dict[str, str]:
return self.host_release or self.execution_release
@property
def running_inside_steam_runtime(self) -> bool:
return (
self.execution_release.get("ID", "").strip().casefold()
== STEAM_RUNTIME_OS_ID
)
def detect_system_identity(
*,
execution_os_release: Path = EXECUTION_OS_RELEASE,
host_os_release: Path = CONTAINER_HOST_OS_RELEASE,
) -> SystemIdentity:
"""Read the runtime OS and the standard container-host identity, if present."""
execution = read_os_release(execution_os_release)
host: dict[str, str] | None = None
if execution.get("ID", "").strip().casefold() == STEAM_RUNTIME_OS_ID:
candidate = read_os_release(host_os_release)
candidate_id = candidate.get("ID", "").strip().casefold()
if candidate_id and candidate_id != STEAM_RUNTIME_OS_ID:
host = candidate
return SystemIdentity(execution_release=execution, host_release=host)
def detect_distribution_id() -> str:
"""Return the host distribution ID when a container exposes it."""
release = detect_system_identity().operating_system_release
return release.get("ID", "").strip().casefold()
def is_running_inside_steam_runtime() -> bool:
"""Return whether the launcher process itself is inside Steam Runtime."""
return detect_system_identity().running_inside_steam_runtime
def read_os_release(path: Path) -> dict[str, str]:
"""Read bounded freedesktop os-release fields without invoking host tools."""
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return {}
result: dict[str, str] = {}
for line in lines:
if not line or line.startswith("#") or "=" not in line:
continue
key, raw = line.split("=", 1)
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key):
continue
value = raw.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
value = value.replace(r"\n", " ").replace(r"\"", '"').replace(r"\\", "\\")
result[key] = value[:255]
return result

375
ptlinux/update_resolver.py Normal file
View File

@@ -0,0 +1,375 @@
"""Semantic release versions and channel-aware update selection."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import json
import re
from typing import Iterable, Sequence
STABLE_CHANNEL = "stable"
PRERELEASE_CHANNEL = "prerelease"
SUPPORTED_CHANNELS = frozenset({STABLE_CHANNEL, PRERELEASE_CHANNEL})
ACTIVE_STATUS = "active"
WITHDRAWN_STATUS = "withdrawn"
BLOCKED_STATUS = "blocked"
SUPPORTED_RELEASE_STATUSES = frozenset(
{ACTIVE_STATUS, WITHDRAWN_STATUS, BLOCKED_STATUS}
)
_VERSION_PATTERN = re.compile(
r"(?P<major>0|[1-9][0-9]*)\."
r"(?P<minor>0|[1-9][0-9]*)\."
r"(?P<patch>0|[1-9][0-9]*)"
r"(?:-(?P<label>beta|rc)\.(?P<number>0|[1-9][0-9]*))?\Z"
)
class ReleaseModelError(ValueError):
"""Raised when release metadata violates the supported release model."""
@dataclass(frozen=True)
class SemanticVersion:
"""The stable, beta, and release-candidate version subset used by releases."""
major: int
minor: int
patch: int
prerelease_label: str | None = None
prerelease_number: int | None = None
@classmethod
def parse(cls, value: str) -> SemanticVersion:
if not isinstance(value, str):
raise ReleaseModelError("version must be a string")
match = _VERSION_PATTERN.fullmatch(value)
if match is None:
raise ReleaseModelError(
"version must use MAJOR.MINOR.PATCH, "
"MAJOR.MINOR.PATCH-beta.N, or MAJOR.MINOR.PATCH-rc.N"
)
label = match.group("label")
number_text = match.group("number")
return cls(
major=int(match.group("major")),
minor=int(match.group("minor")),
patch=int(match.group("patch")),
prerelease_label=label,
prerelease_number=int(number_text) if number_text is not None else None,
)
@classmethod
def from_tag(cls, tag: str) -> SemanticVersion:
if not isinstance(tag, str) or not tag.startswith("v"):
raise ReleaseModelError("release tag must start with 'v'")
return cls.parse(tag[1:])
@property
def is_prerelease(self) -> bool:
return self.prerelease_label is not None
@property
def channel(self) -> str:
return PRERELEASE_CHANNEL if self.is_prerelease else STABLE_CHANNEL
@property
def tag(self) -> str:
return f"v{self}"
@property
def target(self) -> tuple[int, int, int]:
return self.major, self.minor, self.patch
def _ordering_key(self) -> tuple[int, int, int, int, int, int]:
if self.prerelease_label is None:
return self.major, self.minor, self.patch, 1, 0, 0
label_rank = 0 if self.prerelease_label == "beta" else 1
assert self.prerelease_number is not None
return (
self.major,
self.minor,
self.patch,
0,
label_rank,
self.prerelease_number,
)
def __lt__(self, other: object) -> bool:
if not isinstance(other, SemanticVersion):
return NotImplemented
return self._ordering_key() < other._ordering_key()
def __le__(self, other: object) -> bool:
if not isinstance(other, SemanticVersion):
return NotImplemented
return self._ordering_key() <= other._ordering_key()
def __gt__(self, other: object) -> bool:
if not isinstance(other, SemanticVersion):
return NotImplemented
return self._ordering_key() > other._ordering_key()
def __ge__(self, other: object) -> bool:
if not isinstance(other, SemanticVersion):
return NotImplemented
return self._ordering_key() >= other._ordering_key()
def __str__(self) -> str:
value = f"{self.major}.{self.minor}.{self.patch}"
if self.prerelease_label is not None:
value += f"-{self.prerelease_label}.{self.prerelease_number}"
return value
@dataclass(frozen=True)
class ReleaseRecord:
"""The resolver-facing fields shared by channel indexes and release history."""
version: str
channel: str
status: str = ACTIVE_STATUS
tag: str | None = None
manifest_url: str | None = None
manifest_signature_url: str | None = None
release_date: str | None = None
release_notes_url: str | None = None
compatibility: object | None = None
@property
def semantic_version(self) -> SemanticVersion:
return SemanticVersion.parse(self.version)
def validate(self) -> ReleaseRecord:
parsed = self.semantic_version
validate_channel(self.channel)
if parsed.channel != self.channel:
raise ReleaseModelError(
f"version {self.version} belongs to {parsed.channel}, not {self.channel}"
)
if self.status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseModelError(f"unsupported release status: {self.status}")
if self.tag is not None and self.tag != parsed.tag:
raise ReleaseModelError(
f"release tag {self.tag!r} does not match version {self.version}"
)
return self
class UpdateResolver:
"""Centralize release eligibility for updates, switching, and rollback."""
def __init__(self, releases: Iterable[ReleaseRecord]) -> None:
validated: dict[SemanticVersion, ReleaseRecord] = {}
for release in releases:
release.validate()
parsed = release.semantic_version
if parsed in validated:
raise ReleaseModelError(f"duplicate release version: {release.version}")
validated[parsed] = release
self._releases = validated
def eligible_releases(
self,
selected_channel: str,
*,
include_inactive: bool = False,
) -> tuple[ReleaseRecord, ...]:
validate_channel(selected_channel)
eligible = []
for parsed, release in self._releases.items():
if not include_inactive and release.status != ACTIVE_STATUS:
continue
if selected_channel == STABLE_CHANNEL and parsed.is_prerelease:
continue
eligible.append(release)
eligible.sort(key=lambda item: item.semantic_version, reverse=True)
return tuple(eligible)
def latest_eligible(self, selected_channel: str) -> ReleaseRecord | None:
eligible = self.eligible_releases(selected_channel)
return eligible[0] if eligible else None
def update_candidate(
self,
installed_version: str,
selected_channel: str,
) -> ReleaseRecord | None:
installed = SemanticVersion.parse(installed_version)
latest = self.latest_eligible(selected_channel)
if latest is None or latest.semantic_version <= installed:
return None
return latest
def rollback_candidates(
self,
installed_version: str,
selected_channel: str,
*,
show_prereleases: bool | None = None,
include_inactive: bool = False,
) -> tuple[ReleaseRecord, ...]:
installed = SemanticVersion.parse(installed_version)
validate_channel(selected_channel)
include_prereleases = (
selected_channel == PRERELEASE_CHANNEL
if show_prereleases is None
else show_prereleases
)
candidates = []
for release in self._releases.values():
parsed = release.semantic_version
if not include_inactive and release.status != ACTIVE_STATUS:
continue
if parsed >= installed:
continue
if parsed.is_prerelease and not include_prereleases:
continue
candidates.append(release)
candidates.sort(key=lambda item: item.semantic_version, reverse=True)
return tuple(candidates)
def validate_channel(channel: str) -> str:
if channel not in SUPPORTED_CHANNELS:
raise ReleaseModelError(
f"channel must be one of: {', '.join(sorted(SUPPORTED_CHANNELS))}"
)
return channel
def validate_channel_version(channel: str, version: str) -> SemanticVersion:
validate_channel(channel)
parsed = SemanticVersion.parse(version)
if parsed.channel != channel:
raise ReleaseModelError(
f"{channel} channel cannot publish version {version}; "
f"the version belongs to {parsed.channel}"
)
return parsed
def validate_release_progression(
channel: str,
version: str,
tags: Iterable[str],
) -> SemanticVersion:
"""Reject a release that would move its channel index backwards."""
target = validate_channel_version(channel, version)
relevant: list[SemanticVersion] = []
for tag in tags:
try:
candidate = SemanticVersion.from_tag(tag)
except ReleaseModelError:
continue
if channel == STABLE_CHANNEL and candidate.is_prerelease:
continue
relevant.append(candidate)
if relevant:
latest = max(relevant)
if target <= latest:
raise ReleaseModelError(
f"{channel} release {version} must be newer than existing "
f"release {latest}"
)
return target
def previous_release_tag(
channel: str,
version: str,
tags: Iterable[str],
) -> str | None:
"""Select the changelog base required by the two release channels."""
target = validate_channel_version(channel, version)
parsed_tags: list[SemanticVersion] = []
for tag in tags:
try:
candidate = SemanticVersion.from_tag(tag)
except ReleaseModelError:
continue
if candidate < target:
parsed_tags.append(candidate)
if channel == STABLE_CHANNEL:
stable = [candidate for candidate in parsed_tags if not candidate.is_prerelease]
return max(stable).tag if stable else None
matching_prereleases = [
candidate
for candidate in parsed_tags
if candidate.is_prerelease and candidate.target == target.target
]
if matching_prereleases:
return max(matching_prereleases).tag
stable = [candidate for candidate in parsed_tags if not candidate.is_prerelease]
return max(stable).tag if stable else None
def release_metadata_from_tag(tag: str) -> dict[str, object]:
version = SemanticVersion.from_tag(tag)
return {
"tag": tag,
"version": str(version),
"channel": version.channel,
"prerelease": version.is_prerelease,
}
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
validate = subparsers.add_parser("validate", help="validate a channel/version pair")
validate.add_argument("channel", choices=sorted(SUPPORTED_CHANNELS))
validate.add_argument("version")
inspect_tag = subparsers.add_parser("inspect-tag", help="derive release metadata")
inspect_tag.add_argument("tag")
previous = subparsers.add_parser("previous-tag", help="select a changelog base")
previous.add_argument("channel", choices=sorted(SUPPORTED_CHANNELS))
previous.add_argument("version")
previous.add_argument("tags", nargs="*")
progression = subparsers.add_parser(
"validate-next",
help="validate that a release advances its channel",
)
progression.add_argument("channel", choices=sorted(SUPPORTED_CHANNELS))
progression.add_argument("version")
progression.add_argument("tags", nargs="*")
return parser
def main(argv: Sequence[str] | None = None) -> int:
arguments = _build_parser().parse_args(argv)
try:
if arguments.command == "validate":
validate_channel_version(arguments.channel, arguments.version)
return 0
if arguments.command == "inspect-tag":
print(json.dumps(release_metadata_from_tag(arguments.tag), sort_keys=True))
return 0
if arguments.command == "previous-tag":
selected = previous_release_tag(
arguments.channel,
arguments.version,
arguments.tags,
)
if selected is not None:
print(selected)
return 0
if arguments.command == "validate-next":
validate_release_progression(
arguments.channel,
arguments.version,
arguments.tags,
)
return 0
except ReleaseModelError as exc:
parser_error = _build_parser()
parser_error.error(str(exc))
raise AssertionError(f"unhandled command: {arguments.command}")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,223 @@
"""Bounded structured diagnostics for the two game-window supervisors."""
from __future__ import annotations
from collections import Counter
from collections.abc import Callable, Mapping, Sequence
import json
import math
import os
import re
import sys
import time
WINDOW_DIAGNOSTICS_ENV = "PTLINUX_WINDOW_DIAGNOSTICS"
WINDOW_DIAGNOSTICS_SESSION_ENV = "PTLINUX_WINDOW_DIAGNOSTICS_SESSION"
WINDOW_DIAGNOSTIC_PREFIX = "PTLINUX_WINDOW_DIAGNOSTIC "
WINDOW_DIAGNOSTIC_EVENT_LIMIT = 12
WINDOW_DIAGNOSTIC_LINE_LIMIT = 4096
WINDOW_DIAGNOSTIC_COMPONENTS = frozenset({"game_window", "gamescope_host"})
_DIAGNOSTIC_NAME_PATTERN = re.compile(r"[a-z][a-z0-9_]{0,63}\Z")
_SESSION_ID_PATTERN = re.compile(r"[0-9a-f]{32}\Z")
_TRUE_VALUES = frozenset({"1", "true", "yes"})
_MAX_DETAILS_DEPTH = 4
_MAX_DETAILS_ITEMS = 48
_MAX_STRING_CHARACTERS = 256
def window_diagnostics_enabled(
environment: Mapping[str, str] | None = None,
) -> bool:
env = os.environ if environment is None else environment
return env.get(WINDOW_DIAGNOSTICS_ENV, "").strip().casefold() in _TRUE_VALUES
class WindowDiagnosticRecorder:
"""Emit a few samples while retaining aggregate counts for session shutdown."""
def __init__(
self,
component: str,
*,
environment: Mapping[str, str] | None = None,
monotonic: Callable[[], float] | None = None,
) -> None:
if component not in WINDOW_DIAGNOSTIC_COMPONENTS:
raise ValueError(f"unsupported window diagnostic component: {component}")
env = os.environ if environment is None else environment
session_id = env.get(WINDOW_DIAGNOSTICS_SESSION_ENV, "").strip().casefold()
self.component = component
self.enabled = window_diagnostics_enabled(env)
self.session_id = (
session_id if _SESSION_ID_PATTERN.fullmatch(session_id) is not None else None
)
self._monotonic = monotonic or time.monotonic
self._started_at = self._monotonic()
self._counts: Counter[str] = Counter()
self._transitions: dict[str, object] = {}
self._sequence = 0
self._emitted = 0
self._closed = False
@property
def accepts_samples(self) -> bool:
return (
self.enabled
and not self._closed
and self._emitted < WINDOW_DIAGNOSTIC_EVENT_LIMIT
)
def record(self, diagnostic: str, **details: object) -> None:
if self._closed or _DIAGNOSTIC_NAME_PATTERN.fullmatch(diagnostic) is None:
return
self._sequence += 1
self._counts[diagnostic] += 1
if not self.enabled or self._emitted >= WINDOW_DIAGNOSTIC_EVENT_LIMIT:
return
self._emitted += 1
self._emit(diagnostic, self._sequence, details)
def transition(
self,
key: str,
value: object,
diagnostic: str,
**details: object,
) -> None:
"""Record one diagnostic only when the observed state changes."""
if self._closed or self._transitions.get(key, object()) == value:
return
self._transitions[key] = value
self.record(diagnostic, **details)
def close(self, **details: object) -> None:
if self._closed:
return
self._closed = True
if not self.enabled:
return
summary = {
**details,
"counts": dict(sorted(self._counts.items())),
"sampled_events": self._emitted,
"suppressed_events": max(0, self._sequence - self._emitted),
}
self._emit("session_summary", self._sequence + 1, summary)
def _emit(
self,
diagnostic: str,
sequence: int,
details: Mapping[str, object],
) -> None:
payload: dict[str, object] = {
"schema": 1,
"component": self.component,
"diagnostic": diagnostic,
"sequence": sequence,
"elapsed_ms": max(0, round((self._monotonic() - self._started_at) * 1000)),
"details": _bounded_value(details, depth=0),
}
if self.session_id is not None:
payload["session_id"] = self.session_id
try:
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
if len(encoded.encode("utf-8")) > WINDOW_DIAGNOSTIC_LINE_LIMIT:
payload["details"] = {"payload_truncated": True}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
print(
f"{WINDOW_DIAGNOSTIC_PREFIX}{encoded}",
file=sys.stderr,
flush=True,
)
except (OSError, TypeError, ValueError):
return
def parse_window_diagnostic(line: str) -> dict[str, object] | None:
"""Parse only launcher-generated, bounded window diagnostic records."""
marker_index = line.find(WINDOW_DIAGNOSTIC_PREFIX)
if marker_index < 0:
return None
encoded = line[marker_index + len(WINDOW_DIAGNOSTIC_PREFIX):].strip()
if not encoded or len(encoded.encode("utf-8")) > WINDOW_DIAGNOSTIC_LINE_LIMIT:
return None
try:
payload = json.loads(encoded)
except (json.JSONDecodeError, UnicodeError):
return None
if not isinstance(payload, dict) or payload.get("schema") != 1:
return None
component = payload.get("component")
diagnostic = payload.get("diagnostic")
sequence = payload.get("sequence")
elapsed_ms = payload.get("elapsed_ms")
details = payload.get("details")
session_id = payload.get("session_id")
if (
not isinstance(component, str)
or component not in WINDOW_DIAGNOSTIC_COMPONENTS
or not isinstance(diagnostic, str)
or _DIAGNOSTIC_NAME_PATTERN.fullmatch(diagnostic) is None
or not isinstance(sequence, int)
or isinstance(sequence, bool)
or sequence < 1
or not isinstance(elapsed_ms, int)
or isinstance(elapsed_ms, bool)
or elapsed_ms < 0
or not isinstance(details, dict)
or (
session_id is not None
and (
not isinstance(session_id, str)
or _SESSION_ID_PATTERN.fullmatch(session_id) is None
)
)
):
return None
return {
"schema": 1,
"component": component,
"diagnostic": diagnostic,
"sequence": sequence,
"elapsed_ms": elapsed_ms,
"details": _bounded_value(details, depth=0),
**({"session_id": session_id} if isinstance(session_id, str) else {}),
}
def _bounded_value(value: object, *, depth: int) -> object:
if depth >= _MAX_DETAILS_DEPTH:
return "<max-depth>"
if value is None or isinstance(value, (bool, int)):
return value
if isinstance(value, float):
return round(value, 3) if math.isfinite(value) else None
if isinstance(value, str):
return value[:_MAX_STRING_CHARACTERS]
if isinstance(value, Mapping):
result: dict[str, object] = {}
for index, (raw_key, item) in enumerate(value.items()):
if index >= _MAX_DETAILS_ITEMS:
result["_truncated"] = True
break
key = str(raw_key)[:64]
result[key] = _bounded_value(item, depth=depth + 1)
return result
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
items = list(value[:_MAX_DETAILS_ITEMS])
return [_bounded_value(item, depth=depth + 1) for item in items]
return f"<{type(value).__name__}>"

216
release/README.md Normal file
View File

@@ -0,0 +1,216 @@
# Manual Gitea launcher releases
Manual publication mode is currently active. Build a complete local package with:
```text
./scripts/release.sh stable 1.8.0
./scripts/release.sh prerelease 1.9.0-beta.1
```
The command creates `dist/v<VERSION>/`, validates the channel/version pair,
builds and previews release notes, runs the configured preflight, creates a
deterministic archive, signs the manifest, and verifies the complete result. It
changes the requested version only inside the staged package, so the source
checkout is unchanged. It does not commit, tag, push, create a Gitea release,
or upload anything.
The default archive layout is flat: extracting the tar directly produces
`ptlinux/`, `ptdiag/`, and `release.json`. Versions `0.9.10` and `0.9.11` form a
short compatibility window and use the legacy enclosing directory. That lets
deployed `0.9.8` launchers install either release automatically, even if they
missed `0.9.10`. The updater inside both releases accepts both layouts, so
releases after `0.9.11` return to the flat layout.
Use `--description` for short Markdown or `--description-file` for a prepared
Markdown document. Generated commit notes are appended by default. Run
`./scripts/release.sh --help` for dry-run, editor, changelog-only, signing-key,
and test options.
After a successful build, create a new release in Gitea with tag `v<VERSION>`.
Leave **Pre-release** clear for `stable`; select it for `prerelease`. Use
`dist/v<VERSION>/release-notes.md` as the description and upload exactly these
five files from that version directory:
- `PristonTale-EU-<VERSION>.tar.gz`
- `PristonTale-EU-<VERSION>.tar.gz.sha256`
- `manifest.json`
- `manifest.json.sig`
- `release-notes.md`
Uploading a release does not by itself move the launcher's channel pointer.
Upload and save all five assets before publishing the channel pointer. The
signed archive URL includes its SHA-256 as a query parameter so Cloudflare and
other CDNs cannot reuse an empty response cached before the attachment existed.
After the Gitea release exists and each attachment has a nonzero size, publish
the matching files generated under
`dist/v<VERSION>/channel/` to the repository:
```text
dist/v<VERSION>/channel/stable.json -> updates/stable.json
dist/v<VERSION>/channel/stable.json.sig -> updates/stable.json.sig
dist/v<VERSION>/channel/prerelease.json -> updates/prerelease.json
dist/v<VERSION>/channel/prerelease.json.sig -> updates/prerelease.json.sig
```
Commit and push only the selected channel pair. Publishing a prerelease must not
change `updates/stable.json`.
Manual mode does not rebuild `updates/history.json`. The uploaded version can be
discovered through its channel pointer, but it will not appear as a remote
rollback candidate until signed history publication is run manually or the
automatic publisher is re-enabled.
The published `0.9.9` release is flat and therefore correctly asks deployed
`0.9.8` launchers for a manual update. Releases `0.9.10` and `0.9.11` use the
wrapped bridge layout and minimum updater `0.5.0`, so those launchers can install
either one automatically. Flat manifests after this compatibility window declare
`0.9.9` as their minimum updater version.
For direct artifact tooling without release notes or manual-upload guidance:
```text
PTLINUX_PYTHON=/path/to/python \
./scripts/build-release.sh 0.9.11 stable ./dist/v0.9.11
./scripts/verify-release.sh 0.9.11 stable ./dist/v0.9.11
```
The builder selects the runtime payload declared in `config.json`; it does not
archive the repository. It emits a deterministic versioned archive, checksum,
and external per-file manifest. When `PTLINUX_SIGNING_KEY` is set, it also
emits and immediately verifies `manifest.json.sig`.
`repository_url` is the public HTTPS base used in signed download links.
`allowed_git_remotes` is retained for the deferred automatic release validation;
it is not consulted by the current local packaging command.
## Deferred automatic publication
The automatic publisher and status publisher remain in the repository for a
later migration, but their push triggers are disabled. Both workflows are
manual-dispatch only, so creating a tag or Gitea release cannot race the manual
upload process. The remainder of this section applies only when automatic
publication is re-enabled.
## Repository and runner setup
Enable Gitea Actions for the instance and repository, then register an isolated
Docker-backed `act_runner` for trusted repository workflows. The runner image
must provide Git, Bash, curl, jq, Python 3.11 or newer, tar, gzip, coreutils
(`base64`, `realpath`, `sha256sum`, and `stat`), and OpenSSL. The release job
uses `actions/checkout@v4` and checks that the checked-out commit is the exact
commit referenced by the annotated tag.
Configure these protected Actions secrets:
- `RELEASE_TOKEN`: a dedicated token with only the repository release and
repository-content write permissions needed to create releases, upload
attachments, and update signed channel metadata.
- `MANIFEST_SIGNING_KEY`: the private key corresponding to
`ptlinux/launcher-update-public.pem`.
Secrets are available only to trusted tag and release-status workflows. Do not
add the release secrets to pull-request workflows. The signing key is written under
`RUNNER_TEMP` with mode `0600`, used for signing, and removed by an `always()`
cleanup step. Shell tracing is intentionally disabled around all secret-bearing
commands.
The current installed population verifies the deployed RSA/SHA-256 public key.
Release tooling can use OpenSSL Ed25519 signing, but changing the production
key or algorithm requires a staged key-rotation release first. Until that
migration is deployed, configure the matching existing RSA private key.
## Automatic publication behavior
The workflow validates the tag in CI, runs tests, builds and verifies every
artifact, creates a non-draft Gitea release with the derived prerelease flag,
and uploads these immutable assets separately:
- `PristonTale-EU-<version>.tar.gz`
- `PristonTale-EU-<version>.tar.gz.sha256`
- `manifest.json`
- `manifest.json.sig`
- `release-notes.md`
It re-queries the release and checks every attachment name and size before
updating metadata. A stable tag updates only `updates/stable.json` and its
signature. A beta or RC tag updates only `updates/prerelease.json` and its
signature. Both rebuild `updates/history.json` and its signature from real
Gitea releases, following API pagination so older rollback entries are retained.
The four metadata files are committed atomically through the Gitea contents API.
Existing release detection is fail-closed. Never delete, move, or reuse a tag
after publication. Fix a failed release with a new patch, beta, or RC number.
## Withdrawing or blocking a release
Edit `release/statuses.json` on `main` and set the immutable tag to
`withdrawn` or `blocked`, for example:
```json
{
"releases": {
"v1.9.0-beta.2": "withdrawn"
},
"schema": 1
}
```
The trusted `release-status.yml` workflow rebuilds and signs `updates/history.json`
from real Gitea releases. It also rebuilds both channel manifests: each channel
points to its greatest active release, or carries an authenticated inactive
status when that channel has no active release. The workflow verifies every
selected release manifest before signing and atomically publishing the channel
and history files. This prevents a withdrawn channel head from continuing to be
offered by launchers that read `updates/stable.json` or
`updates/prerelease.json` directly.
The release assets remain available for support, while new automatic update and
rollback selection excludes the inactive release. Deleting the tag's override
returns it to `active`. A status entry can also declare compatibility when a
future release changes the persistent settings schema:
```json
{
"releases": {
"v2.0.0": {
"status": "active",
"config_schema": 2,
"minimum_config_schema": 1
}
},
"schema": 1
}
```
The workflow can be run manually after a runner or API outage.
## Launcher history and rollback
The launcher fetches and verifies `updates/history.json` and its detached
signature for the version-history window. An explicit historical rollback uses
that release's immutable `manifest.json`, `manifest.json.sig`, and versioned
archive:
```text
pristontale-eu launcher-update --rollback 1.8.0
```
Running `--rollback` without a version keeps the original fast path to the
locally retained previous release. Every remote target is signature-, size-,
archive-hash-, and per-file-hash checked before it enters the immutable release
store. The launcher also persists that authenticated file inventory and verifies
it again before reactivating a locally retained release. Releases installed by an
older launcher remain usable through the compatibility path when no persisted
inventory exists. Complete release directories make reconciliation exact: files
managed by the current release but absent from the target disappear when
`current` changes, while settings, logs, saves, and downloads remain outside the
release tree.
Activation records the original `current` and `previous` links in a durable
transaction journal. Link or post-activation validation failure restores those
links, and the next launcher run recovers an interrupted transaction before any
new mutation. The local operation history retains the latest 30 update and
rollback results.
See `SIGNING_MIGRATION.md` before rotating the deployed manifest key.

View File

@@ -0,0 +1,35 @@
# Manifest signing migration
The installed launcher population currently pins the 3072-bit RSA public key in
`ptlinux/launcher-update-public.pem`. Release scripts also support OpenSSL
Ed25519 keys, but changing the Gitea secret before clients receive the new
public key would make all metadata unverifiable.
Use this staged migration when Ed25519 is adopted:
1. Generate the Ed25519 key pair offline. Keep the private key out of the
repository and record the public-key fingerprint through the normal release
review process.
2. Publish a normal RSA-signed transition release. That immutable archive adds
the Ed25519 public key and updater support, while its channel manifest still
verifies with the RSA key already installed on clients.
3. Verify the transition release from a clean pre-transition installation on
both stable and prerelease channel paths. Confirm rollback still verifies old
RSA-signed historical manifests.
4. Keep `manifest.json.sig` RSA-signed while enough installations move to the
transition release. If dual signatures are introduced, upload the Ed25519
signature under a separate filename; never change the meaning of the legacy
`.sig` asset for old clients.
5. Publish a second RSA-signed release that makes the updater choose signatures
by an authenticated algorithm and key identifier, and bundles a keyring with
both public keys. Its minimum updater compatibility must prevent an older
verifier from trusting metadata it cannot authenticate.
6. Replace the protected Actions signing secret only after the keyring release
is deployed and measured. Exercise stable discovery, prerelease discovery,
remote rollback, withdrawal publication, and tamper rejection with Ed25519.
7. Retire RSA verification in a later release. Retain the old public key for
immutable historical rollback metadata until those releases are outside the
supported rollback window.
At every stage, a failed or incorrectly signed tag remains immutable. Correct
the pipeline and publish the next semantic version rather than replacing assets.

140
release/config.json Normal file
View File

@@ -0,0 +1,140 @@
{
"application": "PristonTale-EU",
"archive_layout": "flat",
"archive_prefix": "PristonTale-EU",
"legacy_archive_root_prefix": "pristontale-eu-linux-update-v",
"legacy_minimum_updater_version": "0.5.0",
"wrapped_bridge_versions": [
"0.9.10",
"0.9.11"
],
"channels": [
"stable",
"prerelease"
],
"manifest_version": 1,
"config_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.9.9",
"release_branch": "main",
"release_status_file": "release/statuses.json",
"remote": "origin",
"repository_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux",
"allowed_git_remotes": [
"https://git.m4ko.net/m4ko/pristontale-eu-linux",
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git"
],
"version_mirrors": [
"pyproject.toml"
],
"version_source": "ptlinux/__init__.py",
"payload": {
"python_trees": [
{
"exclude": [
"desktop_entry.py",
"launcher_wrapper.py"
],
"source": "ptlinux"
}
],
"files": [
{
"mode": "0644",
"path": "ptlinux/launcher-update-public.pem"
},
{
"mode": "0644",
"path": "ptdiag/__init__.py"
},
{
"mode": "0644",
"path": "ptdiag/assbin.py"
},
{
"mode": "0644",
"path": "ptdiag/logs.py"
},
{
"mode": "0644",
"path": "ptlinux/native/pt-identity-probe.exe"
},
{
"mode": "0755",
"path": "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-compat.README"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-compat.copyright"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-lazy-output-images.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-live-resize.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-letterbox-backing.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-libdecor-input-scope.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-keep-nested-windows-visible.patch"
}
]
},
"required_files": [
"release.json",
"ptlinux/__init__.py",
"ptlinux/__main__.py",
"ptlinux/cli.py",
"ptlinux/self_update.py",
"ptlinux/update_resolver.py",
"ptlinux/launcher-update-public.pem"
],
"exclusions": [
".git",
".gitea",
".idea",
".run",
".venv",
".vscode",
"dist",
"logs",
"results",
"test-output",
"tests",
"testcases",
"tools",
"__pycache__",
"*.pyc",
"*.pyo",
".env",
".env.*",
"*.pem",
"*.key",
"id_rsa",
"id_ed25519",
"*.part",
"*.tmp",
"downloads"
],
"allowed_pem_files": [
"ptlinux/launcher-update-public.pem"
],
"preflight": [
"\"$PTLINUX_PYTHON\" -m compileall -q ptlinux ptdiag scripts release_tests",
"\"$PTLINUX_PYTHON\" -m unittest discover -s release_tests -p 'test_*.py'",
"\"$PTLINUX_PYTHON\" -m unittest discover -s tests -p 'test_*.py'"
]
}

4
release/statuses.json Normal file
View File

@@ -0,0 +1,4 @@
{
"releases": {},
"schema": 1
}

View File

@@ -0,0 +1 @@
"""Tracked preflight tests for the automated release system."""

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from unittest.mock import patch
import unittest
from ptlinux.self_update import (
DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL,
DEFAULT_STABLE_UPDATE_MANIFEST_URL,
UpdateManifest,
check_for_launcher_update,
configured_manifest_urls,
)
def manifest(version: str, channel: str, *, status: str = "active") -> UpdateManifest:
return UpdateManifest(
schema=1,
channel=channel,
version=version,
minimum_updater_version="0.5.0",
archive_url=f"https://example.test/PristonTale-EU-{version}.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T00:00:00Z",
notes_url=f"https://example.test/releases/v{version}",
tag=f"v{version}",
status=status,
)
class ChannelDiscoveryTests(unittest.TestCase):
def test_stable_fetches_only_stable_metadata(self) -> None:
self.assertEqual(
configured_manifest_urls(channel="stable", environment={}),
(("stable", DEFAULT_STABLE_UPDATE_MANIFEST_URL, False),),
)
def test_prerelease_fetches_stable_and_optional_prerelease_metadata(self) -> None:
self.assertEqual(
configured_manifest_urls(channel="prerelease", environment={}),
(
("stable", DEFAULT_STABLE_UPDATE_MANIFEST_URL, False),
("prerelease", DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL, True),
),
)
def test_stable_client_ignores_a_newer_prerelease(self) -> None:
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
return_value=manifest("1.8.0", "stable"),
) as fetch:
check = check_for_launcher_update(
current_version="1.7.4",
channel="stable",
)
self.assertEqual(fetch.call_count, 1)
self.assertEqual(check.latest_version, "1.8.0")
self.assertEqual(check.manifest.channel, "stable")
self.assertTrue(check.available)
def test_prerelease_client_chooses_the_highest_eligible_release(self) -> None:
def fetch(url: str, *_args: object, **_kwargs: object) -> UpdateManifest:
if url == DEFAULT_STABLE_UPDATE_MANIFEST_URL:
return manifest("1.8.0", "stable")
return manifest("1.9.0-beta.2", "prerelease")
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
side_effect=fetch,
):
check = check_for_launcher_update(
current_version="1.9.0-beta.1",
channel="prerelease",
)
self.assertEqual(check.latest_version, "1.9.0-beta.2")
self.assertEqual(check.manifest.channel, "prerelease")
self.assertTrue(check.available)
def test_returning_to_stable_never_offers_an_older_stable_release(self) -> None:
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
return_value=manifest("1.8.0", "stable"),
):
check = check_for_launcher_update(
current_version="1.9.0-beta.2",
channel="stable",
)
self.assertFalse(check.available)
self.assertIn("will not downgrade", check.reason)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import json
import unittest
from scripts.prepare_channel_metadata import prepare_channel_metadata
from scripts.release_lib import ReleaseToolError
def manifest(version: str, channel: str) -> bytes:
value = {
"application": "PristonTale-EU",
"archive": {
"name": f"PristonTale-EU-{version}.tar.gz",
"sha256": "a" * 64,
"size": 123,
"url": f"https://gitea.example.test/releases/download/v{version}/PristonTale-EU-{version}.tar.gz",
},
"archive_sha256": "a" * 64,
"archive_size": 123,
"archive_url": f"https://gitea.example.test/releases/download/v{version}/PristonTale-EU-{version}.tar.gz",
"channel": channel,
"compatibility": {
"config_schema": 1,
"manifest_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
},
"minimum_updater_version": "0.5.0",
"notes_url": f"https://gitea.example.test/releases/tag/v{version}",
"published_at": "2026-09-08T12:00:00Z",
"schema": 1,
"status": "active",
"tag": f"v{version}",
"version": version,
}
return (json.dumps(value, sort_keys=True) + "\n").encode()
def record(version: str, channel: str, status: str) -> dict[str, object]:
return {
"channel": channel,
"compatibility": {
"config_schema": 1,
"minimum_config_schema": 1,
},
"manifest_url": f"https://gitea.example.test/releases/download/v{version}/manifest.json",
"status": status,
"tag": f"v{version}",
"version": version,
}
class ChannelMetadataTests(unittest.TestCase):
def test_selects_latest_active_release_instead_of_withdrawn_head(self) -> None:
history = {
"schema": 1,
"releases": [
record("1.9.0", "stable", "withdrawn"),
record("1.8.0", "stable", "active"),
],
}
selected, prepared = prepare_channel_metadata(
history,
"stable",
manifest("1.8.0", "stable"),
)
self.assertEqual(selected["version"], "1.8.0")
self.assertEqual(prepared["status"], "active")
def test_marks_channel_inactive_when_no_active_release_exists(self) -> None:
history = {
"schema": 1,
"releases": [record("1.9.0-beta.1", "prerelease", "blocked")],
}
selected, prepared = prepare_channel_metadata(
history,
"prerelease",
manifest("1.9.0-beta.1", "prerelease"),
)
self.assertEqual(selected["version"], "1.9.0-beta.1")
self.assertEqual(prepared["status"], "blocked")
def test_rejects_a_manifest_that_does_not_match_selected_history(self) -> None:
history = {
"schema": 1,
"releases": [record("1.8.0", "stable", "active")],
}
with self.assertRaisesRegex(ReleaseToolError, "does not match"):
prepare_channel_metadata(
history,
"stable",
manifest("1.7.4", "stable"),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from ptlinux.paths import AppPaths
from ptlinux.settings import Settings, load_settings, save_settings
class ChannelSettingsTests(unittest.TestCase):
def test_existing_users_default_to_stable(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"realm": "Babel"}\n', encoding="utf-8")
self.assertEqual(load_settings(paths).update_channel, "stable")
def test_prerelease_preference_round_trips(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
save_settings(paths, Settings(update_channel="prerelease"))
self.assertEqual(load_settings(paths).update_channel, "prerelease")
def test_unknown_channel_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"update_channel": "nightly"}\n', encoding="utf-8")
with self.assertRaisesRegex(ValueError, "update channel"):
load_settings(paths)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,643 @@
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from scripts.release_lib import verify_signature
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MOCK_CURL = r'''#!/usr/bin/env bash
set -euo pipefail
METHOD="GET"
OUTPUT=""
FORM=""
DATA_FILE=""
URL=""
while (( $# > 0 ))
do
case "$1" in
--request)
METHOD="$2"
shift 2
;;
--output)
OUTPUT="$2"
shift 2
;;
--form)
FORM="$2"
shift 2
;;
--data-binary)
DATA_FILE="${2#@}"
shift 2
;;
--connect-timeout|--max-time|--header|--write-out)
shift 2
;;
--silent|--show-error)
shift
;;
*)
URL="$1"
shift
;;
esac
done
release_json()
{
local assets
assets="$(jq -s '.' "${MOCK_STATE_DIR}"/asset-*.json)"
jq -n \
--arg html_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" \
--arg published_at "2026-09-08T12:00:00Z" \
--arg tag "$RELEASE_TAG" \
--arg name "PristonTale EU Launcher ${MOCK_VERSION}" \
--arg target_commitish "$RELEASE_COMMIT" \
--argjson assets "$assets" \
--argjson prerelease "$RELEASE_PRERELEASE" \
'{id:71,tag_name:$tag,name:$name,draft:false,prerelease:$prerelease,target_commitish:$target_commitish,published_at:$published_at,created_at:$published_at,html_url:$html_url,assets:$assets}'
}
case "${METHOD} ${URL}" in
"GET "*"/releases/tags/"*)
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "existing" ]]
then
printf 'curl: (28) simulated release lookup timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_EXISTING_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated release lookup failure"}' > "$OUTPUT"
printf '%s' "$MOCK_EXISTING_STATUS"
exit 0
fi
if [[ -f "${MOCK_STATE_DIR}/created" ]]
then
release_json > "$OUTPUT"
printf '200'
else
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
fi
;;
"POST "*"/releases")
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "create" ]]
then
printf 'curl: (28) simulated release creation timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_CREATE_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated release creation failure"}' > "$OUTPUT"
printf '%s' "$MOCK_CREATE_STATUS"
exit 0
fi
cp -- "$DATA_FILE" "${MOCK_STATE_DIR}/release-payload.json"
: > "${MOCK_STATE_DIR}/created"
printf '%s\n' '{"id":71}' > "$OUTPUT"
printf '201'
;;
"POST "*"/assets?name="*)
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "asset" ]]
then
printf 'curl: (28) simulated attachment timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_ASSET_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated attachment failure"}' > "$OUTPUT"
printf '%s' "$MOCK_ASSET_STATUS"
exit 0
fi
name="${URL##*?name=}"
file="${FORM#attachment=@}"
file="${file%%;*}"
size="$(stat -c '%s' -- "$file")"
jq -n \
--arg name "$name" \
--arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/${name}" \
--argjson size "$size" \
'{name:$name,size:$size,browser_download_url:$url}' > "$OUTPUT"
cp -- "$OUTPUT" "${MOCK_STATE_DIR}/asset-${name}.json"
printf '201'
;;
"GET "*"/releases?limit=100&page=1")
if [[ "${MOCK_RELEASE_PAGINATION:-}" == "1" ]]
then
jq -n '[range(0;100) as $index | {draft:true,tag_name:("ignored-" + ($index|tostring))}]' > "$OUTPUT"
else
release_json | jq -s '.' > "$OUTPUT"
fi
printf '200'
;;
"GET "*"/releases?limit=100&page=2")
release_json | jq -s '.' > "$OUTPUT"
printf '200'
;;
"GET "*"/contents/"*)
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
;;
"POST "*"/contents")
cp -- "$DATA_FILE" "${MOCK_STATE_DIR}/channel-payload.json"
printf '%s\n' '{"commit":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' > "$OUTPUT"
printf '201'
;;
*)
printf 'Unexpected mock curl request: %s %s\n' "$METHOD" "$URL" >&2
exit 90
;;
esac
'''
MOCK_HISTORY_CURL = r'''#!/usr/bin/env bash
set -euo pipefail
METHOD="GET"
OUTPUT=""
DATA_FILE=""
URL=""
while (( $# > 0 ))
do
case "$1" in
--request)
METHOD="$2"
shift 2
;;
--output)
OUTPUT="$2"
shift 2
;;
--data-binary)
DATA_FILE="${2#@}"
shift 2
;;
--connect-timeout|--max-time|--header|--write-out)
shift 2
;;
--silent|--show-error)
shift
;;
*)
URL="$1"
shift
;;
esac
done
case "${METHOD} ${URL}" in
"GET "*"/releases?limit=100&page=1")
cp -- "$MOCK_RELEASES" "$OUTPUT"
printf '200'
;;
"GET "*"/manifest.json.sig")
cp -- "$MOCK_MANIFEST_SIGNATURE" "$OUTPUT"
printf '200'
;;
"GET "*"/manifest.json")
cp -- "$MOCK_MANIFEST" "$OUTPUT"
printf '200'
;;
"GET "*"/contents/"*)
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
;;
"POST "*"/contents")
cp -- "$DATA_FILE" "$MOCK_HISTORY_PAYLOAD"
printf '%s\n' '{"commit":{"sha":"cccccccccccccccccccccccccccccccccccccccc"}}' > "$OUTPUT"
printf '201'
;;
*)
printf 'Unexpected history mock request: %s %s\n' "$METHOD" "$URL" >&2
exit 90
;;
esac
'''
def _publisher_manifest(version: str = "1.8.0") -> bytes:
archive_name = f"PristonTale-EU-{version}.tar.gz"
archive_url = (
f"https://gitea.example.test/owner/project/releases/download/"
f"v{version}/{archive_name}"
)
return (
json.dumps(
{
"application": "PristonTale-EU",
"archive": {
"name": archive_name,
"sha256": "a" * 64,
"size": 123,
"url": archive_url,
},
"archive_sha256": "a" * 64,
"archive_size": 123,
"archive_url": archive_url,
"channel": "stable",
"compatibility": {
"config_schema": 1,
"manifest_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
},
"minimum_updater_version": "0.5.0",
"notes_url": (
f"https://gitea.example.test/owner/project/releases/tag/v{version}"
),
"published_at": "2026-09-08T12:00:00Z",
"schema": 1,
"status": "active",
"tag": f"v{version}",
"version": version,
},
indent=2,
sort_keys=True,
)
+ "\n"
).encode()
class GiteaPublisherTests(unittest.TestCase):
def test_publishes_assets_and_updates_only_the_selected_channel(self) -> None:
openssl = shutil.which("openssl")
jq = shutil.which("jq")
if openssl is None or jq is None:
self.skipTest("OpenSSL and jq are required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
output = root / "dist"
state = root / "state"
fake_bin = root / "bin"
for directory in (output, state, fake_bin):
directory.mkdir()
archive = output / "PristonTale-EU-0.9.8.tar.gz"
archive.write_bytes(b"archive fixture")
(output / f"{archive.name}.sha256").write_text(
f"{'a' * 64} {archive.name}\n",
encoding="ascii",
)
(output / "manifest.json").write_text('{"schema":1}\n', encoding="utf-8")
(output / "manifest.json.sig").write_bytes(b"manifest signature")
(output / "release-notes.md").write_text("Release notes.\n", encoding="utf-8")
private_key = root / "private.pem"
public_key = root / "public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
fake_curl = fake_bin / "curl"
fake_curl.write_text(MOCK_CURL, encoding="utf-8")
fake_curl.chmod(0o755)
summary = root / "summary.md"
environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"GITHUB_STEP_SUMMARY": str(summary),
"MOCK_STATE_DIR": str(state),
"MOCK_RELEASE_PAGINATION": "1",
"MOCK_VERSION": "0.9.8",
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(private_key),
"PTLINUX_UPDATE_PUBLIC_KEY": str(public_key),
"RELEASE_BRANCH": "main",
"RELEASE_COMMIT": "a" * 40,
"RELEASE_PRERELEASE": "false",
"RELEASE_TAG": "v0.9.8",
"RELEASE_TOKEN": "mock-release-token",
}
result = subprocess.run(
[
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
release_payload = json.loads(
(state / "release-payload.json").read_text(encoding="utf-8")
)
self.assertFalse(release_payload["draft"])
self.assertFalse(release_payload["prerelease"])
self.assertEqual(release_payload["tag_name"], "v0.9.8")
self.assertEqual(release_payload["body"], "Release notes.\n")
self.assertEqual(len(list(state.glob("asset-*.json"))), 5)
channel_payload = json.loads(
(state / "channel-payload.json").read_text(encoding="utf-8")
)
paths = {operation["path"] for operation in channel_payload["files"]}
self.assertIn("updates/stable.json", paths)
self.assertIn("updates/stable.json.sig", paths)
self.assertNotIn("updates/prerelease.json", paths)
self.assertIn("updates/history.json", paths)
self.assertIn("Status | Published", summary.read_text(encoding="utf-8"))
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
second = subprocess.run(
[
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(second.returncode, 0)
self.assertIn("already exists", second.stderr)
def test_status_publication_rebuilds_signed_channel_metadata(self) -> None:
openssl = shutil.which("openssl")
jq = shutil.which("jq")
if openssl is None or jq is None:
self.skipTest("OpenSSL and jq are required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake_bin = root / "bin"
fake_bin.mkdir()
private_key = root / "private.pem"
public_key = root / "public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
manifest = root / "manifest.json"
manifest.write_bytes(_publisher_manifest())
manifest_signature = root / "manifest.json.sig"
subprocess.run(
[
openssl,
"dgst",
"-sha256",
"-sign",
str(private_key),
"-out",
str(manifest_signature),
str(manifest),
],
check=True,
capture_output=True,
)
releases = root / "releases.json"
releases.write_text(
json.dumps(
[
{
"assets": [
{
"browser_download_url": (
"https://gitea.example.test/owner/project/"
"releases/download/v1.8.0/manifest.json"
),
"name": "manifest.json",
},
{
"browser_download_url": (
"https://gitea.example.test/owner/project/"
"releases/download/v1.8.0/manifest.json.sig"
),
"name": "manifest.json.sig",
},
],
"draft": False,
"html_url": (
"https://gitea.example.test/owner/project/releases/tag/v1.8.0"
),
"id": 71,
"name": "PristonTale EU Launcher 1.8.0",
"prerelease": False,
"published_at": "2026-09-08T12:00:00Z",
"tag_name": "v1.8.0",
}
]
),
encoding="utf-8",
)
statuses = root / "statuses.json"
statuses.write_text(
(
'{"releases":{"v1.8.0":{"config_schema":2,'
'"minimum_config_schema":1,"status":"withdrawn"}},"schema":1}\n'
),
encoding="utf-8",
)
mock_curl = fake_bin / "curl"
mock_curl.write_text(MOCK_HISTORY_CURL, encoding="utf-8")
mock_curl.chmod(0o755)
payload = root / "history-payload.json"
summary = root / "summary.md"
environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"GITHUB_STEP_SUMMARY": str(summary),
"MOCK_HISTORY_PAYLOAD": str(payload),
"MOCK_MANIFEST": str(manifest),
"MOCK_MANIFEST_SIGNATURE": str(manifest_signature),
"MOCK_RELEASES": str(releases),
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(private_key),
"PTLINUX_UPDATE_PUBLIC_KEY": str(public_key),
"RELEASE_BRANCH": "main",
"RELEASE_STATUS_FILE": str(statuses),
"RELEASE_TOKEN": "mock-release-token",
}
result = subprocess.run(
[str(PROJECT_ROOT / "scripts/publish-release-history.sh"), str(root / "dist")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
published = json.loads(payload.read_text(encoding="utf-8"))
files = {item["path"]: item for item in published["files"]}
self.assertEqual(
set(files),
{
"updates/history.json",
"updates/history.json.sig",
"updates/stable.json",
"updates/stable.json.sig",
},
)
stable = json.loads(base64.b64decode(files["updates/stable.json"]["content"]))
history = json.loads(base64.b64decode(files["updates/history.json"]["content"]))
self.assertEqual(stable["status"], "withdrawn")
self.assertEqual(history["releases"][0]["status"], "withdrawn")
self.assertEqual(
history["releases"][0]["compatibility"],
{"config_schema": 2, "minimum_config_schema": 1},
)
self.assertEqual(
stable["compatibility"]["config_schema"],
2,
)
published_stable = root / "published-stable.json"
published_stable_signature = root / "published-stable.json.sig"
published_stable.write_bytes(
base64.b64decode(files["updates/stable.json"]["content"])
)
published_stable_signature.write_bytes(
base64.b64decode(files["updates/stable.json.sig"]["content"])
)
verify_signature(published_stable, published_stable_signature, public_key)
self.assertIn("stable channel: `1.8.0`", summary.read_text(encoding="utf-8"))
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
def test_api_failures_never_report_publication_success(self) -> None:
if shutil.which("jq") is None:
self.skipTest("jq is required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
output = root / "dist"
state = root / "state"
fake_bin = root / "bin"
for directory in (output, state, fake_bin):
directory.mkdir()
archive = output / "PristonTale-EU-0.9.8.tar.gz"
archive.write_bytes(b"archive fixture")
(output / f"{archive.name}.sha256").write_text(
f"{'a' * 64} {archive.name}\n",
encoding="ascii",
)
for name in ("manifest.json", "manifest.json.sig", "release-notes.md"):
(output / name).write_text("fixture\n", encoding="utf-8")
fake_curl = fake_bin / "curl"
fake_curl.write_text(MOCK_CURL, encoding="utf-8")
fake_curl.chmod(0o755)
base_environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"MOCK_STATE_DIR": str(state),
"MOCK_VERSION": "0.9.8",
"RELEASE_BRANCH": "main",
"RELEASE_COMMIT": "a" * 40,
"RELEASE_PRERELEASE": "false",
"RELEASE_TAG": "v0.9.8",
"RELEASE_TOKEN": "mock-release-token",
}
command = [
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
]
cases = [
("MOCK_EXISTING_STATUS", "401"),
("MOCK_EXISTING_STATUS", "403"),
("MOCK_EXISTING_STATUS", "500"),
("MOCK_CREATE_STATUS", "404"),
("MOCK_CREATE_STATUS", "409"),
("MOCK_CREATE_STATUS", "422"),
("MOCK_CREATE_STATUS", "500"),
("MOCK_ASSET_STATUS", "500"),
("MOCK_TIMEOUT_STAGE", "existing"),
("MOCK_TIMEOUT_STAGE", "create"),
("MOCK_TIMEOUT_STAGE", "asset"),
]
for variable, value in cases:
with self.subTest(variable=variable, value=value):
(state / "created").unlink(missing_ok=True)
environment = {**base_environment, variable: value}
result = subprocess.run(
command,
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("Published stable release", result.stdout)
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from contextlib import redirect_stderr
import io
import unittest
from ptlinux.cli import build_parser
class LauncherUpdateCliTests(unittest.TestCase):
def test_bare_rollback_uses_retained_previous_release(self) -> None:
arguments = build_parser().parse_args(["launcher-update", "--rollback"])
self.assertEqual(arguments.rollback, "")
def test_explicit_rollback_accepts_version_and_history_override(self) -> None:
arguments = build_parser().parse_args(
[
"launcher-update",
"--rollback",
"1.8.0",
"--history-url",
"https://updates.example.test/history.json",
]
)
self.assertEqual(arguments.rollback, "1.8.0")
self.assertEqual(
arguments.history_url,
"https://updates.example.test/history.json",
)
def test_rollback_remains_exclusive_with_check_and_verify(self) -> None:
parser = build_parser()
with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
parser.parse_args(["launcher-update", "--rollback", "--check"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
import json
from pathlib import Path
from pathlib import PurePosixPath
import shutil
import subprocess
import tarfile
import tempfile
import unittest
from ptlinux import __version__
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
_evaluate_launcher_update,
_stage_and_install_release,
parse_update_manifest,
)
from scripts.release_lib import (
ReleaseToolError,
_archive_layout_for_version,
_forbid_release_path,
_safe_output_directory,
build_release,
load_config,
sha256_file,
sign_file,
verify_release,
verify_signature,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class ReleaseArtifactTests(unittest.TestCase):
def test_build_is_reproducible_and_matches_the_runtime_parser(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
notes = root / "notes.md"
notes.write_text("## Highlights\n\nChannel-aware updates.\n", encoding="utf-8")
commit = "a" * 40
first = root / "first"
second = root / "second"
for output in (first, second):
build_release(
__version__,
"stable",
output,
repository_url="https://gitea.example.test/owner/project",
commit=commit,
source_date_epoch=1_700_000_000,
release_notes_file=notes,
)
verify_release(__version__, "stable", output)
archive_name = f"PristonTale-EU-{__version__}.tar.gz"
self.assertEqual(
sha256_file(first / archive_name),
sha256_file(second / archive_name),
)
self.assertEqual(
(first / "manifest.json").read_bytes(),
(second / "manifest.json").read_bytes(),
)
manifest = parse_update_manifest((first / "manifest.json").read_bytes())
self.assertEqual(manifest.version, __version__)
self.assertEqual(manifest.channel, "stable")
self.assertEqual(manifest.tag, f"v{__version__}")
self.assertGreater(len(manifest.files), 10)
self.assertEqual(manifest.archive_name, archive_name)
raw_manifest = json.loads((first / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(
raw_manifest["archive_url"],
f"https://gitea.example.test/owner/project/releases/download/"
f"v{__version__}/{archive_name}?sha256={manifest.archive_sha256}",
)
expected_layout = _archive_layout_for_version(load_config(), __version__)
self.assertEqual(raw_manifest["archive"]["layout"], expected_layout)
self.assertIn("Channel-aware updates.", manifest.notes_markdown or "")
paths = AppPaths.from_environment({"HOME": str(root / "home")})
installed = _stage_and_install_release(
paths,
first / archive_name,
manifest,
)
self.assertEqual(installed, paths.launcher_releases / __version__)
self.assertTrue((installed / "ptlinux/update_resolver.py").is_file())
with tarfile.open(first / archive_name, "r:gz") as bundle:
names = bundle.getnames()
archive_root = (
f"pristontale-eu-linux-update-v{__version__}/"
if expected_layout == "wrapped"
else ""
)
self.assertIn(f"{archive_root}release.json", names)
self.assertIn(f"{archive_root}ptlinux/__init__.py", names)
self.assertEqual(
any(name.startswith("pristontale-eu-linux-update-v") for name in names),
expected_layout == "wrapped",
)
self.assertFalse(any("__pycache__" in name for name in names))
self.assertFalse(any(name.endswith(".pyc") for name in names))
self.assertFalse(any("/.git/" in name for name in names))
def test_manifest_file_list_rejects_reordering(self) -> None:
manifest = {
"schema": 1,
"channel": "stable",
"version": "1.8.0",
"minimum_updater_version": "0.5.0",
"archive_url": "https://example.test/PristonTale-EU-1.8.0.tar.gz",
"archive_sha256": "a" * 64,
"archive_size": 100,
"published_at": "2026-09-08T00:00:00Z",
"notes_url": "https://example.test/releases/v1.8.0",
"files": [
{"path": "ptlinux/z.py", "size": 1, "sha256": "b" * 64},
{"path": "ptlinux/a.py", "size": 1, "sha256": "c" * 64},
],
}
with self.assertRaisesRegex(RuntimeError, "uniquely sorted"):
parse_update_manifest(
(json.dumps(manifest, sort_keys=True) + "\n").encode("utf-8")
)
def test_manual_build_can_set_only_the_packaged_launcher_version(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
target_version = "9.8.7-beta.2"
source_path = PROJECT_ROOT / "ptlinux/__init__.py"
source_before = source_path.read_bytes()
build_release(
target_version,
"prerelease",
root / "dist",
repository_url="https://gitea.example.test/owner/project",
commit="b" * 40,
source_date_epoch=1_700_000_000,
allow_source_version_override=True,
)
verify_release(target_version, "prerelease", root / "dist")
archive = root / "dist" / f"PristonTale-EU-{target_version}.tar.gz"
with tarfile.open(archive, "r:gz") as bundle:
member = bundle.extractfile("ptlinux/__init__.py")
assert member is not None
self.assertIn(
f'__version__ = "{target_version}"'.encode(),
member.read(),
)
self.assertEqual(source_path.read_bytes(), source_before)
def test_configured_bridge_release_uses_legacy_wrapper(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
version = "0.9.10"
output = root / "dist"
build_release(
version,
"stable",
output,
repository_url="https://gitea.example.test/owner/project",
commit="c" * 40,
source_date_epoch=1_700_000_000,
allow_source_version_override=True,
)
verify_release(version, "stable", output)
manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["archive"]["layout"], "wrapped")
self.assertEqual(manifest["minimum_updater_version"], "0.5.0")
archive = output / f"PristonTale-EU-{version}.tar.gz"
archive_root = f"pristontale-eu-linux-update-v{version}"
with tarfile.open(archive, "r:gz") as bundle:
names = bundle.getnames()
self.assertIn(f"{archive_root}/release.json", names)
self.assertIn(f"{archive_root}/ptlinux/self_update.py", names)
self.assertFalse(any(name == "release.json" for name in names))
parsed = parse_update_manifest((output / "manifest.json").read_bytes())
check = _evaluate_launcher_update(
parsed,
"0.9.8",
"https://gitea.example.test/owner/project/raw/branch/main/updates/stable.json",
)
self.assertTrue(check.available)
self.assertTrue(check.supported)
paths = AppPaths.from_environment({"HOME": str(root / "home")})
installed = _stage_and_install_release(paths, archive, parsed)
self.assertEqual(installed.name, version)
def test_output_cleaning_cannot_target_source_directories(self) -> None:
self.assertEqual(
_safe_output_directory(PROJECT_ROOT / "dist/v9.8.7"),
(PROJECT_ROOT / "dist/v9.8.7").resolve(),
)
with self.assertRaisesRegex(ReleaseToolError, "repository dist directory"):
_safe_output_directory(PROJECT_ROOT / "docs")
with self.assertRaisesRegex(ReleaseToolError, "unsafe output"):
_safe_output_directory(Path(tempfile.gettempdir()))
def test_forbidden_release_paths_are_rejected(self) -> None:
config = load_config()
for path in (
PurePosixPath(".env"),
PurePosixPath("private.key"),
PurePosixPath("ptlinux/__pycache__/module.pyc"),
PurePosixPath("logs/launcher.log"),
):
with self.subTest(path=path):
with self.assertRaisesRegex(ReleaseToolError, "forbidden"):
_forbid_release_path(path, config)
_forbid_release_path(
PurePosixPath("ptlinux/launcher-update-public.pem"),
config,
)
def test_signature_verification_rejects_modified_metadata(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is unavailable")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
private_key = root / "private.pem"
public_key = root / "public.pem"
metadata = root / "manifest.json"
signature = root / "manifest.json.sig"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
metadata.write_text('{"schema": 1}\n', encoding="utf-8")
sign_file(metadata, signature, private_key)
verify_signature(metadata, signature, public_key)
metadata.write_text('{"schema": 2}\n', encoding="utf-8")
with self.assertRaisesRegex(ReleaseToolError, "verification failed"):
verify_signature(metadata, signature, public_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,318 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class ReleaseCliTests(unittest.TestCase):
def setUp(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is required")
self.temporary = tempfile.TemporaryDirectory()
base = Path(self.temporary.name)
self.repository = base / "working"
self.remote = base / "remote.git"
for relative in ("scripts", "ptlinux", "release"):
(self.repository / relative).mkdir(parents=True, exist_ok=True)
for relative in (
"scripts/__init__.py",
"scripts/build-release.sh",
"scripts/build_release.py",
"scripts/release.sh",
"scripts/release_lib.py",
"scripts/verify-release.sh",
"scripts/verify_release.py",
"ptlinux/update_resolver.py",
):
shutil.copy2(PROJECT_ROOT / relative, self.repository / relative)
(self.repository / "ptlinux/__init__.py").write_text(
'__version__ = "1.7.4"\n',
encoding="utf-8",
)
(self.repository / "pyproject.toml").write_text(
'[project]\nname = "release-fixture"\nversion = "1.7.4"\n',
encoding="utf-8",
)
(self.repository / ".gitignore").write_text(
"dist/\n__pycache__/\n*.pyc\n",
encoding="utf-8",
)
self.private_key = base / "private.pem"
public_key = self.repository / "ptlinux/launcher-update-public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(self.private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(self.private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
(self.repository / "release/config.json").write_text(
json.dumps(
{
"application": "PristonTale-EU",
"archive_layout": "flat",
"archive_prefix": "PristonTale-EU",
"channels": ["stable", "prerelease"],
"config_schema": 1,
"exclusions": [],
"legacy_archive_root_prefix": "pristontale-eu-linux-update-v",
"legacy_minimum_updater_version": "0.5.0",
"manifest_version": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
"payload": {
"files": [
{
"mode": "0644",
"path": "ptlinux/launcher-update-public.pem",
}
],
"python_trees": [{"exclude": [], "source": "ptlinux"}],
},
"preflight": [],
"release_branch": "main",
"release_status_file": "release/statuses.json",
"remote": "origin",
"repository_url": "https://gitea.example.test/owner/project",
"required_files": [
"release.json",
"ptlinux/__init__.py",
"ptlinux/launcher-update-public.pem",
],
"allowed_pem_files": ["ptlinux/launcher-update-public.pem"],
"version_mirrors": ["pyproject.toml"],
"version_source": "ptlinux/__init__.py",
"wrapped_bridge_versions": [],
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
self._run(["git", "init", "--bare", str(self.remote)], cwd=base)
self._git("init", "-b", "main")
self._git("config", "user.name", "Release Test")
self._git("config", "user.email", "release-test@example.test")
self._git("add", ".")
self._git("commit", "-m", "Initial launcher release")
self._git("tag", "-a", "v1.7.4", "-m", "Launcher 1.7.4")
self._git("remote", "add", "origin", str(self.remote))
self._git("push", "-u", "origin", "main", "v1.7.4")
(self.repository / "change.txt").write_text("update\n", encoding="utf-8")
self._git("add", "change.txt")
self._git("commit", "-m", "Improve updater reliability")
self._git("push", "origin", "main")
def tearDown(self) -> None:
self.temporary.cleanup()
def _run(
self,
command: list[str],
*,
cwd: Path | None = None,
environment: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd or self.repository,
env=environment,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
check=False,
)
def _git(self, *arguments: str) -> str:
result = self._run(["git", *arguments])
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout.strip()
def _release(self, *arguments: str, **environment: str) -> subprocess.CompletedProcess[str]:
return self._run(
[str(self.repository / "scripts/release.sh"), *arguments],
environment={
**os.environ,
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(self.private_key),
**environment,
},
)
def test_changelog_modes_preserve_markdown_and_do_not_mutate_git(self) -> None:
head_before = self._git("rev-parse", "HEAD")
tags_before = self._git("tag")
inline = self._release(
"stable",
"1.8.0",
"--description",
"## Highlights\n\n- Signed channels",
"--changelog-only",
)
self.assertEqual(inline.returncode, 0, inline.stderr)
notes = (self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
)
self.assertLess(notes.index("## Highlights"), notes.index("## Changes"))
self.assertIn("Improve updater reliability", notes)
description = Path(self.temporary.name) / "description.md"
description.write_text("## Fixes\n\n- Preserved **Markdown**", encoding="utf-8")
from_file = self._release(
"stable",
"1.8.0",
"--description-file",
str(description),
"--no-auto-changelog",
"--changelog-only",
)
self.assertEqual(from_file.returncode, 0, from_file.stderr)
notes = (self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
)
self.assertIn("- Preserved **Markdown**", notes)
self.assertNotIn("## Changes", notes)
editor = Path(self.temporary.name) / "editor"
editor.write_text(
'#!/usr/bin/env bash\nprintf "\\nEdited before publication.\\n" >> "$1"\n',
encoding="utf-8",
)
editor.chmod(0o755)
edited = self._release(
"stable",
"1.8.0",
"--editor",
"--changelog-only",
EDITOR=str(editor),
)
self.assertEqual(edited.returncode, 0, edited.stderr)
self.assertIn(
"Edited before publication.",
(self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
),
)
self.assertEqual(self._git("rev-parse", "HEAD"), head_before)
self.assertEqual(self._git("tag"), tags_before)
def test_dry_run_then_builds_manual_stable_and_prerelease_packages(self) -> None:
head_before = self._git("rev-parse", "HEAD")
tags_before = self._git("tag")
remote_before = self._git("ls-remote", "origin")
dry_run = self._release(
"stable",
"1.8.0",
"--description",
"Stable release",
"--skip-tests",
"--dry-run",
)
self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
self.assertIn("Dry run complete", dry_run.stdout)
self.assertFalse((self.repository / "dist/v1.8.0/manifest.json").exists())
stable = self._release(
"stable",
"1.8.0",
"--description",
"Stable release",
"--skip-tests",
)
self.assertEqual(stable.returncode, 0, stable.stderr)
stable_output = self.repository / "dist/v1.8.0"
manifest_path = stable_output / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], "1.8.0")
self.assertEqual(manifest["channel"], "stable")
self.assertTrue((stable_output / "manifest.json.sig").is_file())
self.assertEqual(
manifest_path.read_bytes(),
(stable_output / "channel/stable.json").read_bytes(),
)
archive = stable_output / "PristonTale-EU-1.8.0.tar.gz"
with tarfile.open(archive, "r:gz") as bundle:
version_source = bundle.extractfile("ptlinux/__init__.py")
assert version_source is not None
self.assertIn(b'__version__ = "1.8.0"', version_source.read())
self.assertIn("leave the Gitea", stable.stdout)
prerelease = self._release(
"prerelease",
"1.9.0-beta.1",
"--description",
"First beta",
"--skip-tests",
)
self.assertEqual(prerelease.returncode, 0, prerelease.stderr)
prerelease_output = self.repository / "dist/v1.9.0-beta.1"
manifest = json.loads(
(prerelease_output / "manifest.json").read_text(encoding="utf-8")
)
self.assertEqual(manifest["version"], "1.9.0-beta.1")
self.assertEqual(manifest["channel"], "prerelease")
self.assertTrue(manifest_path.is_file())
self.assertIn('select the Gitea "Pre-release" checkbox', prerelease.stdout)
self.assertEqual(self._git("rev-parse", "HEAD"), head_before)
self.assertEqual(self._git("tag"), tags_before)
self.assertEqual(self._git("ls-remote", "origin"), remote_before)
self.assertIn('1.7.4', (self.repository / "ptlinux/__init__.py").read_text())
self.assertIn('1.7.4', (self.repository / "pyproject.toml").read_text())
def test_rejects_a_channel_version_mismatch_before_mutation(self) -> None:
result = self._release(
"stable",
"1.9.0-beta.1",
"--changelog-only",
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self._git("tag", "-l", "v1.9.0-beta.1"), "")
def test_build_does_not_depend_on_release_branch_or_clean_worktree(self) -> None:
self._git("switch", "-c", "release-candidate")
(self.repository / "uncommitted.txt").write_text("local source\n", encoding="utf-8")
result = self._release(
"stable",
"1.8.0",
"--skip-tests",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self._git("tag", "-l", "v1.8.0"), "")
self.assertTrue((self.repository / "dist/v1.8.0/manifest.json").is_file())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
import unittest
from scripts.generate_history import load_release_statuses, release_history
from scripts.release_lib import ReleaseToolError
import json
from pathlib import Path
import tempfile
def gitea_release(tag: str, *, prerelease: bool) -> dict[str, object]:
return {
"id": 42,
"tag_name": tag,
"name": f"Release {tag}",
"draft": False,
"prerelease": prerelease,
"published_at": "2026-09-08T12:00:00Z",
"html_url": f"https://gitea.example.test/owner/project/releases/tag/{tag}",
"assets": [
{
"name": "manifest.json",
"browser_download_url": (
f"https://gitea.example.test/owner/project/releases/download/"
f"{tag}/manifest.json"
),
},
{
"name": "manifest.json.sig",
"browser_download_url": (
f"https://gitea.example.test/owner/project/releases/download/"
f"{tag}/manifest.json.sig"
),
},
],
}
class ReleaseHistoryTests(unittest.TestCase):
def test_history_uses_only_installable_immutable_releases(self) -> None:
stable = gitea_release("v1.8.0", prerelease=False)
beta = gitea_release("v1.9.0-beta.2", prerelease=True)
wrong_flag = gitea_release("v2.0.0-beta.1", prerelease=False)
draft = gitea_release("v2.0.0", prerelease=False)
draft["draft"] = True
history = release_history(
[stable, wrong_flag, draft, beta],
"2026-09-08T12:01:00Z",
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(
[release["version"] for release in releases],
["1.9.0-beta.2", "1.8.0"],
)
self.assertEqual(releases[0]["channel"], "prerelease")
self.assertEqual(releases[1]["channel"], "stable")
self.assertTrue(releases[0]["manifest_url"].endswith("/manifest.json"))
def test_history_rejects_an_invalid_generation_timestamp(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "timestamp"):
release_history([], "today")
def test_history_applies_signed_release_status_overrides(self) -> None:
history = release_history(
[gitea_release("v1.9.0-beta.2", prerelease=True)],
"2026-09-08T12:01:00Z",
{"v1.9.0-beta.2": "withdrawn"},
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(releases[0]["status"], "withdrawn")
self.assertEqual(
releases[0]["compatibility"],
{"config_schema": 1, "minimum_config_schema": 1},
)
def test_history_supports_explicit_config_compatibility(self) -> None:
history = release_history(
[gitea_release("v2.0.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{
"v2.0.0": {
"status": "active",
"config_schema": 3,
"minimum_config_schema": 2,
}
},
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(
releases[0]["compatibility"],
{"config_schema": 3, "minimum_config_schema": 2},
)
def test_status_file_rejects_unknown_fields_and_unsafe_schema_ranges(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "statuses.json"
path.write_text(
json.dumps(
{
"schema": 1,
"releases": {
"v2.0.0": {
"status": "active",
"config_schema": 1,
"minimum_config_schema": 2,
}
},
}
),
encoding="utf-8",
)
with self.assertRaisesRegex(ReleaseToolError, "exceeds"):
load_release_statuses(path)
path.write_text(
'{"schema":1,"releases":{"v2.0.0":{"status":"active","note":"x"}}}',
encoding="utf-8",
)
with self.assertRaisesRegex(ReleaseToolError, "unknown"):
load_release_statuses(path)
def test_history_rejects_an_unknown_status(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "invalid status"):
release_history(
[gitea_release("v1.8.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{"v1.8.0": "broken"},
)
def test_history_rejects_status_override_for_a_missing_release(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "do not match"):
release_history(
[gitea_release("v1.8.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{"v1.9.0-beta.1": "withdrawn"},
)
def test_history_rejects_duplicate_release_tags(self) -> None:
release = gitea_release("v1.8.0", prerelease=False)
with self.assertRaisesRegex(ReleaseToolError, "duplicate"):
release_history(
[release, dict(release)],
"2026-09-08T12:01:00Z",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from scripts.validate_release_remote import remote_matches, repository_identity
class ReleaseRemoteTests(unittest.TestCase):
def test_https_and_ssh_forms_identify_the_same_repository(self) -> None:
expected = repository_identity(
"https://git.m4ko.net/m4ko/pristontale-eu-linux"
)
self.assertEqual(
repository_identity("git@git.m4ko.net:m4ko/pristontale-eu-linux.git"),
expected,
)
self.assertEqual(
repository_identity(
"ssh://git@git.m4ko.net/m4ko/pristontale-eu-linux.git"
),
expected,
)
def test_file_url_and_local_path_identify_the_same_repository(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "remote.git"
self.assertEqual(
repository_identity(path.as_uri()),
repository_identity(str(path)),
)
def test_remote_must_match_one_explicitly_allowed_identity(self) -> None:
self.assertTrue(
remote_matches(
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git",
[
"https://git.m4ko.net/m4ko/pristontale-eu-linux",
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git",
],
)
)
self.assertFalse(
remote_matches(
"ssh://git@untrusted.test/m4ko/pristontale-eu-linux.git",
["https://git.m4ko.net/m4ko/pristontale-eu-linux"],
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,603 @@
from __future__ import annotations
import hashlib
import io
import json
from pathlib import Path
from types import SimpleNamespace
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherRemoteHistory,
LauncherRemoteRelease,
LauncherUpdateError,
ManifestFile,
UpdateManifest,
_activate_release,
_fetch_historical_manifest,
_recover_interrupted_activation,
_validate_release_directory,
launcher_rollback_candidates,
parse_launcher_release_history,
rollback_launcher_update,
)
def history_release(
version: str,
channel: str,
*,
status: str = "active",
) -> dict[str, object]:
tag = f"v{version}"
base = f"https://updates.example.test/releases/download/{tag}"
return {
"channel": channel,
"compatibility": {"config_schema": 1, "minimum_config_schema": 1},
"manifest_signature_url": f"{base}/manifest.json.sig",
"manifest_url": f"{base}/manifest.json",
"release_date": "2026-09-08T12:00:00Z",
"release_id": 42,
"release_notes_url": f"https://updates.example.test/releases/tag/{tag}",
"status": status,
"tag": tag,
"title": f"Launcher {version}",
"version": version,
}
def history_bytes(releases: list[dict[str, object]]) -> bytes:
return (
json.dumps(
{
"application": "PristonTale-EU",
"generated_at": "2026-09-08T12:01:00Z",
"releases": releases,
"schema": 1,
},
sort_keys=True,
)
+ "\n"
).encode("utf-8")
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str, *, extra_file: bool = False) -> None:
files = {
"release.json": (
json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
},
sort_keys=True,
)
+ "\n"
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
if extra_file:
files["ptlinux/new_feature.py"] = b"only in the newer release\n"
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
def archived_release(root: Path, version: str) -> tuple[Path, tuple[ManifestFile, ...]]:
release = root / "payload"
make_release(release, version)
archive = root / f"PristonTale-EU-{version}.tar.gz"
with tarfile.open(archive, "w:gz") as bundle:
for path in sorted(release.rglob("*")):
relative = path.relative_to(release).as_posix()
info = tarfile.TarInfo(relative)
info.mode = 0o755 if path.is_dir() else 0o644
if path.is_dir():
info.type = tarfile.DIRTYPE
bundle.addfile(info)
else:
data = path.read_bytes()
info.size = len(data)
bundle.addfile(info, io.BytesIO(data))
files = tuple(
ManifestFile(
path=path.relative_to(release).as_posix(),
size=path.stat().st_size,
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
)
for path in sorted(release.rglob("*"))
if path.is_file()
)
return archive, files
class RemoteHistoryTests(unittest.TestCase):
def test_release_validation_removes_generated_bytecode_caches(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir(mode=0o775)
(cache / "cli.cpython-314.pyc").write_bytes(b"generated bytecode")
cache.chmod(0o775)
self.assertEqual(_validate_release_directory(release), "1.8.0")
self.assertFalse(cache.exists())
def test_release_validation_rejects_unexpected_cache_content(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir()
unexpected = cache / "payload.txt"
unexpected.write_text("not bytecode\n", encoding="utf-8")
with self.assertRaisesRegex(LauncherUpdateError, "unexpected path"):
_validate_release_directory(release)
self.assertTrue(unexpected.is_file())
def test_history_is_strict_and_drives_channel_aware_rollback(self) -> None:
data = history_bytes(
[
history_release("1.9.0-beta.2", "prerelease"),
history_release("1.9.0-beta.1", "prerelease", status="withdrawn"),
history_release("1.8.0", "stable"),
history_release("1.7.4", "stable"),
]
)
history = parse_launcher_release_history(
data,
history_url="https://updates.example.test/updates/history.json",
)
stable = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="stable",
)
prerelease = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="prerelease",
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.8.0", "1.7.4"],
)
def test_history_rejects_unsorted_and_detached_signature_urls(self) -> None:
with self.assertRaisesRegex(LauncherUpdateError, "descending"):
parse_launcher_release_history(
history_bytes(
[
history_release("1.8.0", "stable"),
history_release("1.9.0", "stable"),
]
),
history_url="https://updates.example.test/updates/history.json",
)
detached = history_release("1.8.0", "stable")
detached["manifest_signature_url"] = "https://attacker.test/manifest.sig"
with self.assertRaisesRegex(LauncherUpdateError, "signature URL"):
parse_launcher_release_history(
history_bytes([detached]),
history_url="https://updates.example.test/updates/history.json",
)
def test_signed_history_compatibility_overrides_immutable_manifest_defaults(self) -> None:
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
config_schema=3,
minimum_config_schema=2,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=1,
minimum_config_schema=1,
)
with (
patch(
"ptlinux.self_update._fetch_small_file",
side_effect=[b"manifest", b"signature"],
),
patch("ptlinux.self_update._verify_manifest_signature"),
patch("ptlinux.self_update.parse_update_manifest", return_value=manifest),
):
result = _fetch_historical_manifest(
record,
Path("public.pem"),
timeout_seconds=1,
)
self.assertEqual(result.config_schema, 3)
self.assertEqual(result.minimum_config_schema, 2)
class RemoteRollbackTests(unittest.TestCase):
def test_remote_rollback_verifies_and_activates_a_complete_target_release(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0-beta.3"
make_release(current, current.name, extra_file=True)
paths.launcher_current.symlink_to(Path("releases") / current.name)
archive, files = archived_release(root, "1.8.0")
archive_bytes = archive.read_bytes()
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/PristonTale-EU-1.8.0.tar.gz",
archive_sha256=hashlib.sha256(archive_bytes).hexdigest(),
archive_size=len(archive_bytes),
published_at="2026-09-08T12:00:00Z",
notes_url="https://updates.example.test/releases/tag/v1.8.0",
tag="v1.8.0",
files=files,
manifest_sha256="a" * 64,
)
record_value = history_release("1.8.0", "stable")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url=str(record_value["manifest_url"]),
manifest_signature_url=str(record_value["manifest_signature_url"]),
release_date=str(record_value["release_date"]),
release_notes_url=str(record_value["release_notes_url"]),
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/updates/history.json",
releases=(record,),
history_sha256="b" * 64,
)
paths.config.mkdir(parents=True)
paths.settings_file.write_text(
json.dumps({"schema": 1, "user_choice": "preserve me"}),
encoding="utf-8",
)
def copy_archive(_manifest: object, destination: Path, **_kwargs: object) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive_bytes)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive", side_effect=copy_archive),
patch("ptlinux.self_update._self_check_release"),
):
result = rollback_launcher_update(
paths,
target_version="1.8.0",
selected_channel="prerelease",
)
self.assertEqual(result.from_version, "1.9.0-beta.3")
self.assertEqual(result.to_version, "1.8.0")
self.assertEqual(paths.launcher_current.resolve().name, "1.8.0")
self.assertEqual(paths.launcher_previous.resolve().name, "1.9.0-beta.3")
self.assertFalse((paths.launcher_current / "ptlinux/new_feature.py").exists())
self.assertEqual(
json.loads(paths.settings_file.read_text(encoding="utf-8"))["user_choice"],
"preserve me",
)
operations = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)["operations"]
self.assertEqual(operations[-1]["reason"], "manual")
self.assertTrue(operations[-1]["rollback"])
integrity_record = paths.launcher_release_integrity / "1.8.0.json"
self.assertTrue(integrity_record.is_file())
(paths.launcher_current / "ptlinux/__init__.py").write_text(
"locally modified after verification\n",
encoding="utf-8",
)
_activate_release(paths, current)
with self.assertRaisesRegex(LauncherUpdateError, "signed manifest"):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_remote_rollback_rejects_incompatible_configuration_before_download(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"schema":1}\n', encoding="utf-8")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=3,
minimum_config_schema=2,
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "config schemas 2 through 3"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_rollback_refuses_to_mutate_while_a_managed_game_is_active(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with (
patch("ptlinux.self_update.managed_game_process_is_active", return_value=True),
self.assertRaisesRegex(RuntimeError, "active managed game client"),
):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_precheck_rejects_insufficient_disk_space(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch(
"ptlinux.self_update.shutil.disk_usage",
return_value=SimpleNamespace(free=0),
),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "insufficient free disk space"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_failed_link_activation_restores_the_original_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
from ptlinux import self_update
real_replace = self_update._replace_release_link
calls = 0
def fail_current_once(link: Path, release: Path, releases: Path) -> None:
nonlocal calls
calls += 1
if calls == 2:
raise OSError("simulated current-link failure")
real_replace(link, release, releases)
with (
patch(
"ptlinux.self_update._replace_release_link",
side_effect=fail_current_once,
),
self.assertRaisesRegex(OSError, "simulated current-link failure"),
):
_activate_release(paths, target)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
def test_crash_journal_restores_original_links_even_if_target_is_missing(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases/1.9.0"))
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "applying",
"current": current.name,
"previous": previous.name,
"target": "1.9.0",
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
self.assertEqual(
[call.args[0] for call in send.call_args_list],
["rollback_restore_started", "rollback_restore_success"],
)
def test_completed_activation_journal_is_cleaned_without_rolling_back(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / target.name)
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "activated",
"current": current.name,
"previous": previous.name,
"target": target.name,
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), target.resolve())
self.assertEqual(paths.launcher_previous.resolve(), current.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
send.assert_not_called()
def test_diagnostic_write_failure_does_not_reverse_completed_rollback(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with patch(
"ptlinux.self_update._write_json",
side_effect=OSError("read-only diagnostics directory"),
):
result = rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), previous.resolve())
self.assertFalse(result.state_recorded)
self.assertIn("could not be saved", result.warning or "")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from scripts.release_lib import ReleaseToolError, sign_file, verify_signature
class SigningTests(unittest.TestCase):
def test_ed25519_metadata_signatures_and_tamper_rejection(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
private_key = root / "manifest-ed25519-private.pem"
public_key = root / "manifest-ed25519-public.pem"
generated = subprocess.run(
[
openssl,
"genpkey",
"-algorithm",
"ED25519",
"-out",
str(private_key),
],
capture_output=True,
text=True,
check=False,
)
if generated.returncode != 0:
self.skipTest("installed OpenSSL does not support Ed25519")
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
capture_output=True,
text=True,
check=True,
)
private_key.chmod(0o600)
source = root / "manifest.json"
signature = root / "manifest.json.sig"
source.write_text('{"schema":1}\n', encoding="utf-8")
sign_file(source, signature, private_key)
verify_signature(source, signature, public_key)
self.assertEqual(signature.stat().st_mode & 0o777, 0o644)
source.write_text('{"schema":2}\n', encoding="utf-8")
with self.assertRaisesRegex(ReleaseToolError, "verification failed"):
verify_signature(source, signature, public_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import unittest
from ptlinux.ui.components.update_dialog import (
release_history_state,
release_notes_preview,
update_version_heading,
)
class UpdateDialogTests(unittest.TestCase):
def test_release_notes_preview_shows_changes_without_build_footer(self) -> None:
preview = release_notes_preview(
"# Launcher 1.8.0\n\n"
"## Highlights\n\n"
"- Added channel-aware updates\n"
"- Improved rollback\n\n"
"---\n\nVersion: `1.8.0`\n"
)
self.assertIn("## Highlights", preview)
self.assertIn("- Improved rollback", preview)
self.assertNotIn("Version:", preview)
self.assertNotIn("Launcher 1.8.0", preview)
def test_release_notes_preview_is_bounded(self) -> None:
preview = release_notes_preview(
"\n".join(f"- Change {index}" for index in range(30))
)
self.assertIn("- Change 0", preview)
self.assertNotIn("- Change 20", preview)
self.assertTrue(preview.endswith("…"))
def test_current_version_heading_uses_running_launcher(self) -> None:
self.assertEqual(
update_version_heading(
"0.9.11",
"0.9.10",
"stable",
update_available=False,
),
"Current version: 0.9.11\nLatest Stable release: 0.9.10",
)
def test_history_distinguishes_running_and_installed_versions(self) -> None:
self.assertEqual(
release_history_state(
"active",
is_running=True,
local_active=False,
local_retained=False,
is_older=False,
),
"Current",
)
self.assertEqual(
release_history_state(
"active",
is_running=False,
local_active=True,
local_retained=True,
is_older=False,
),
"Installed",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
import unittest
from ptlinux.update_resolver import (
BLOCKED_STATUS,
PRERELEASE_CHANNEL,
STABLE_CHANNEL,
WITHDRAWN_STATUS,
ReleaseModelError,
ReleaseRecord,
SemanticVersion,
UpdateResolver,
previous_release_tag,
validate_channel_version,
validate_release_progression,
)
class SemanticVersionTests(unittest.TestCase):
def test_orders_beta_rc_final_and_multidigit_versions(self) -> None:
versions = [
SemanticVersion.parse(value)
for value in (
"1.9.0",
"1.9.0-rc.1",
"1.10.0",
"1.9.0-beta.2",
"1.9.0-beta.1",
"1.9.9",
)
]
self.assertEqual(
[str(value) for value in sorted(versions)],
[
"1.9.0-beta.1",
"1.9.0-beta.2",
"1.9.0-rc.1",
"1.9.0",
"1.9.9",
"1.10.0",
],
)
def test_rejects_unsupported_or_noncanonical_versions(self) -> None:
for value in (
"v1.2.3",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-alpha.1",
"1.2.3-beta",
"1.2.3+build",
):
with self.subTest(value=value):
with self.assertRaises(ReleaseModelError):
SemanticVersion.parse(value)
def test_rejects_channel_version_mismatches(self) -> None:
with self.assertRaisesRegex(ReleaseModelError, "belongs to prerelease"):
validate_channel_version(STABLE_CHANNEL, "1.9.0-beta.1")
with self.assertRaisesRegex(ReleaseModelError, "belongs to stable"):
validate_channel_version(PRERELEASE_CHANNEL, "1.9.0")
def test_selects_channel_specific_changelog_bases(self) -> None:
tags = ["not-a-release", "v1.7.4", "v1.9.0-beta.1", "v1.9.0-beta.2"]
self.assertEqual(
previous_release_tag(STABLE_CHANNEL, "1.8.0", tags),
"v1.7.4",
)
self.assertEqual(
previous_release_tag(PRERELEASE_CHANNEL, "1.9.0-rc.1", tags),
"v1.9.0-beta.2",
)
self.assertEqual(
previous_release_tag(PRERELEASE_CHANNEL, "2.0.0-beta.1", tags),
"v1.7.4",
)
def test_release_progression_cannot_move_a_channel_backwards(self) -> None:
validate_release_progression(
STABLE_CHANNEL,
"1.9.0",
["v1.8.0", "v2.0.0-beta.1"],
)
validate_release_progression(
PRERELEASE_CHANNEL,
"2.0.0-beta.2",
["v1.9.0", "v2.0.0-beta.1"],
)
with self.assertRaisesRegex(ReleaseModelError, "newer"):
validate_release_progression(
STABLE_CHANNEL,
"1.8.0",
["v1.9.0"],
)
with self.assertRaisesRegex(ReleaseModelError, "newer"):
validate_release_progression(
PRERELEASE_CHANNEL,
"1.9.0-rc.1",
["v2.0.0-beta.1"],
)
class UpdateResolverTests(unittest.TestCase):
def setUp(self) -> None:
self.resolver = UpdateResolver(
[
ReleaseRecord("1.7.4", STABLE_CHANNEL, tag="v1.7.4"),
ReleaseRecord("1.8.0", STABLE_CHANNEL, tag="v1.8.0"),
ReleaseRecord("1.9.0-beta.1", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0-beta.2", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0-rc.1", PRERELEASE_CHANNEL),
ReleaseRecord("2.0.0-beta.1", PRERELEASE_CHANNEL, WITHDRAWN_STATUS),
ReleaseRecord("2.0.0-beta.2", PRERELEASE_CHANNEL, BLOCKED_STATUS),
]
)
def test_stable_clients_never_receive_prereleases(self) -> None:
candidate = self.resolver.update_candidate("1.7.4", STABLE_CHANNEL)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.8.0")
def test_prerelease_clients_receive_highest_active_semantic_version(self) -> None:
candidate = self.resolver.update_candidate(
"1.9.0-beta.1",
PRERELEASE_CHANNEL,
)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.9.0-rc.1")
def test_prerelease_client_transitions_to_the_final_stable_release(self) -> None:
resolver = UpdateResolver(
[
ReleaseRecord("1.9.0-rc.1", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0", STABLE_CHANNEL),
]
)
candidate = resolver.update_candidate("1.9.0-rc.1", PRERELEASE_CHANNEL)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.9.0")
def test_switching_to_stable_does_not_downgrade(self) -> None:
self.assertIsNone(
self.resolver.update_candidate("1.9.0-beta.2", STABLE_CHANNEL)
)
def test_channel_aware_rollback_candidates_are_real_releases(self) -> None:
stable = self.resolver.rollback_candidates("1.9.0", STABLE_CHANNEL)
prerelease = self.resolver.rollback_candidates(
"1.9.0-rc.1",
PRERELEASE_CHANNEL,
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.9.0-beta.1", "1.8.0", "1.7.4"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,259 @@
from __future__ import annotations
from dataclasses import replace
import hashlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherUpdateCheck,
UpdateManifest,
_download_archive,
_record_launcher_operation,
check_for_launcher_update,
install_launcher_update,
rollback_launcher_update,
)
def release_manifest(version: str, channel: str = "stable") -> UpdateManifest:
return UpdateManifest(
schema=1,
channel=channel,
version=version,
minimum_updater_version="0.5.0",
archive_url=f"https://updates.example.test/PristonTale-EU-{version}.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=f"https://updates.example.test/releases/tag/v{version}",
tag=f"v{version}",
manifest_sha256="b" * 64,
)
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str) -> None:
files = {
"release.json": json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
}
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
class UpdateTelemetryTests(unittest.TestCase):
def test_local_operation_history_is_bounded_to_thirty_records(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
for index in range(35):
self.assertTrue(
_record_launcher_operation(
paths,
from_version=f"1.0.{index}",
to_version=f"1.0.{index + 1}",
from_channel="stable",
to_channel="stable",
selected_channel="stable",
result="success",
rollback=False,
reason="automatic_update",
)
)
value = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)
self.assertEqual(len(value["operations"]), 30)
self.assertEqual(value["operations"][0]["from"], "1.0.5")
self.assertEqual(value["operations"][-1]["to"], "1.0.35")
def test_rollback_download_emits_success_and_failure_families(self) -> None:
content = b"authenticated rollback archive"
manifest = replace(
release_manifest("1.7.4"),
archive_size=len(content),
archive_sha256=hashlib.sha256(content).hexdigest(),
)
class Response(io.BytesIO):
def __init__(self, data: bytes) -> None:
super().__init__(data)
self.headers = {"Content-Length": str(len(data))}
def geturl(self) -> str:
return manifest.archive_url
def __enter__(self) -> Response:
return self
def __exit__(self, *_args: object) -> None:
self.close()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
metadata = {
"from_version": "1.8.0",
"to_version": "1.7.4",
"rollback": True,
}
telemetry = MagicMock()
with (
patch("ptlinux.self_update.urlopen", return_value=Response(content)),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
_download_archive(
manifest,
root / "success.tar.gz",
timeout_seconds=1,
event_family="rollback",
event_metadata=metadata,
)
self.assertEqual(
[call.args[0] for call in telemetry.send_event.call_args_list],
["rollback_download_started", "rollback_download_success"],
)
telemetry.reset_mock()
with (
patch("ptlinux.self_update.urlopen", return_value=Response(b"x" * len(content))),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
self.assertRaisesRegex(RuntimeError, "SHA-256"),
):
_download_archive(
manifest,
root / "failure.tar.gz",
timeout_seconds=1,
event_family="rollback",
event_metadata=metadata,
)
self.assertEqual(
[call.args[0] for call in telemetry.send_event.call_args_list],
["rollback_download_started", "rollback_download_failed"],
)
def test_discovery_emits_release_identity_without_release_notes(self) -> None:
telemetry = MagicMock()
manifest = release_manifest("1.8.0")
with (
patch("ptlinux.self_update._fetch_authenticated_manifest", return_value=manifest),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
check_for_launcher_update(
current_version="1.7.4",
manifest_url="https://updates.example.test/stable.json",
channel="stable",
)
call = telemetry.send_event.call_args
self.assertEqual(call.args[0], "update_release_discovered")
metadata = call.kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.7.4")
self.assertEqual(metadata["target_version"], "1.8.0")
self.assertEqual(metadata["current_channel"], "stable")
self.assertEqual(metadata["target_channel"], "stable")
self.assertEqual(metadata["selected_update_channel"], "stable")
self.assertEqual(metadata["release_tag"], "v1.8.0")
self.assertEqual(metadata["manifest_version"], 1)
self.assertEqual(metadata["manifest_hash"], "b" * 64)
self.assertFalse(metadata["rollback"])
self.assertNotIn("notes", metadata)
def test_update_apply_emits_started_and_success_with_shared_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
manifest = release_manifest("1.8.0")
check = LauncherUpdateCheck(
current_version="1.7.4",
latest_version="1.8.0",
available=True,
supported=True,
reason="Launcher 1.8.0 is available.",
manifest_url="https://updates.example.test/stable.json",
manifest=manifest,
selected_channel="stable",
)
release = paths.launcher_releases / "1.8.0"
telemetry = MagicMock()
with (
patch("ptlinux.self_update.check_for_launcher_update", return_value=check),
patch("ptlinux.self_update._precheck_release_install"),
patch("ptlinux.self_update._download_archive"),
patch("ptlinux.self_update._stage_and_install_release", return_value=release),
patch("ptlinux.self_update._activate_release", return_value=None),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
result = install_launcher_update(paths, current_version="1.7.4")
self.assertTrue(result.changed)
events = [call.args[0] for call in telemetry.send_event.call_args_list]
self.assertEqual(events, ["update_apply_started", "update_apply_success"])
for call in telemetry.send_event.call_args_list:
metadata = call.kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.7.4")
self.assertEqual(metadata["target_version"], "1.8.0")
self.assertFalse(metadata["rollback"])
def test_local_rollback_emits_requested_and_apply_lifecycle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
telemetry = MagicMock()
with patch("ptlinux.self_update.current_telemetry", return_value=telemetry):
rollback_launcher_update(paths, reason="support_recommendation")
events = [call.args[0] for call in telemetry.send_event.call_args_list]
self.assertEqual(
events,
[
"rollback_requested",
"rollback_apply_started",
"rollback_apply_success",
],
)
metadata = telemetry.send_event.call_args_list[0].kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.8.0")
self.assertEqual(metadata["target_version"], "1.7.4")
self.assertEqual(metadata["from_version"], "1.8.0")
self.assertEqual(metadata["to_version"], "1.7.4")
self.assertEqual(metadata["from_channel"], "stable")
self.assertEqual(metadata["to_channel"], "stable")
self.assertEqual(metadata["reason"], "support_recommendation")
self.assertTrue(metadata["rollback"])
if __name__ == "__main__":
unittest.main()

1
scripts/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Release automation modules."""

60
scripts/build-release.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR\n' "$0" >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
VERSION="$1"
CHANNEL="$2"
OUTPUT_DIRECTORY="$3"
cd -- "$PROJECT_ROOT"
ARGUMENTS=(
"${SCRIPT_ROOT}/build_release.py"
"$VERSION"
"$CHANNEL"
"$OUTPUT_DIRECTORY"
)
if [[ -n "${REPOSITORY_URL:-}" ]]
then
ARGUMENTS+=(--repository-url "$REPOSITORY_URL")
fi
if [[ -n "${RELEASE_TAG:-}" ]]
then
ARGUMENTS+=(--tag "$RELEASE_TAG")
fi
if [[ -n "${RELEASE_COMMIT:-}" ]]
then
ARGUMENTS+=(--commit "$RELEASE_COMMIT")
fi
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]
then
ARGUMENTS+=(--source-date-epoch "$SOURCE_DATE_EPOCH")
fi
if [[ -n "${RELEASE_NOTES_FILE:-}" ]]
then
ARGUMENTS+=(--release-notes "$RELEASE_NOTES_FILE")
fi
if [[ "${REQUIRE_RELEASE_SIGNATURE:-0}" == "1" ]]
then
ARGUMENTS+=(--require-signature)
fi
if [[ "${ALLOW_SOURCE_VERSION_OVERRIDE:-0}" == "1" ]]
then
ARGUMENTS+=(--allow-source-version-override)
fi
if [[ -n "${RELEASE_ARCHIVE_LAYOUT:-}" ]]
then
ARGUMENTS+=(--archive-layout "$RELEASE_ARCHIVE_LAYOUT")
fi
exec "$PYTHON_BIN" "${ARGUMENTS[@]}"

64
scripts/build_release.py Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Build one deterministic launcher release and its external metadata."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ReleaseToolError, build_release # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
parser.add_argument("channel")
parser.add_argument("output_directory", type=Path)
parser.add_argument("--repository-url")
parser.add_argument("--tag")
parser.add_argument("--commit")
parser.add_argument("--source-date-epoch", type=int)
parser.add_argument("--release-notes", type=Path)
parser.add_argument("--signing-key", type=Path)
parser.add_argument("--require-signature", action="store_true")
parser.add_argument("--allow-source-version-override", action="store_true")
parser.add_argument("--archive-layout", choices=("flat", "wrapped"))
return parser
def main() -> int:
arguments = build_parser().parse_args()
signing_key = arguments.signing_key
if signing_key is None and os.environ.get("PTLINUX_SIGNING_KEY"):
signing_key = Path(os.environ["PTLINUX_SIGNING_KEY"])
try:
artifacts = build_release(
arguments.version,
arguments.channel,
arguments.output_directory,
repository_url=arguments.repository_url,
tag=arguments.tag,
commit=arguments.commit,
source_date_epoch=arguments.source_date_epoch,
release_notes_file=arguments.release_notes,
signing_key=signing_key,
require_signature=arguments.require_signature,
allow_source_version_override=arguments.allow_source_version_override,
archive_layout=arguments.archive_layout,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Release build failed: {exc}", file=sys.stderr)
return 1
for artifact in artifacts:
print(artifact)
return 0
if __name__ == "__main__":
raise SystemExit(main())

75
scripts/generate-manifest.py Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Generate the external schema-1 release manifest for an existing archive."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
generate_manifest,
load_config,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--channel", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--commit", required=True)
parser.add_argument("--file-root", required=True, type=Path)
parser.add_argument("--archive", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--repository-url", required=True)
parser.add_argument("--published-at", required=True)
parser.add_argument("--release-title")
parser.add_argument("--release-summary-file", type=Path)
parser.add_argument("--release-notes-url")
parser.add_argument("--archive-layout", choices=("flat", "wrapped"))
parser.add_argument("--minimum-updater-version")
return parser
def main() -> int:
arguments = build_parser().parse_args()
release_notes = ""
if arguments.release_summary_file is not None:
try:
release_notes = arguments.release_summary_file.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
print(f"Manifest generation failed: {exc}", file=sys.stderr)
return 1
try:
generate_manifest(
arguments.output,
config=load_config(),
file_root=arguments.file_root,
archive=arguments.archive,
version=arguments.version,
channel=arguments.channel,
tag=arguments.tag,
commit=arguments.commit,
repository_url=arguments.repository_url,
release_timestamp=arguments.published_at,
release_notes=release_notes,
release_title=arguments.release_title,
release_notes_url=arguments.release_notes_url,
archive_layout=arguments.archive_layout,
minimum_updater_version=arguments.minimum_updater_version,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Manifest generation failed: {exc}", file=sys.stderr)
return 1
print(arguments.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())

261
scripts/generate_history.py Executable file
View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Generate signed-history source data from Gitea release API results."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
from typing import Mapping
from urllib.parse import urlsplit
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
_parse_timestamp,
load_config,
)
from ptlinux.update_resolver import (
SUPPORTED_RELEASE_STATUSES,
ReleaseModelError,
SemanticVersion,
)
def release_history(
releases: object,
generated_at: str,
statuses: Mapping[str, object] | None = None,
) -> dict[str, object]:
_parse_timestamp(generated_at)
if not isinstance(releases, list):
raise ReleaseToolError("Gitea releases response must be an array")
records: list[tuple[SemanticVersion, dict[str, object]]] = []
seen_versions: set[SemanticVersion] = set()
included_tags: set[str] = set()
for item in releases:
if not isinstance(item, dict) or item.get("draft") is True:
continue
tag = item.get("tag_name")
if not isinstance(tag, str):
continue
try:
version = SemanticVersion.from_tag(tag)
except ReleaseModelError:
continue
if version in seen_versions:
raise ReleaseToolError(f"Gitea returned duplicate release tag {tag}")
seen_versions.add(version)
if bool(item.get("prerelease")) != version.is_prerelease:
continue
assets = item.get("assets")
if not isinstance(assets, list):
continue
asset_urls: dict[str, str] = {}
for asset in assets:
if not isinstance(asset, dict):
continue
name = asset.get("name")
url = asset.get("browser_download_url")
if isinstance(name, str) and isinstance(url, str):
asset_urls[name] = url
manifest_url = asset_urls.get("manifest.json")
signature_url = asset_urls.get("manifest.json.sig")
release_date = item.get("published_at") or item.get("created_at")
release_notes_url = item.get("html_url")
if (
manifest_url is None
or signature_url is None
or not isinstance(release_date, str)
or not isinstance(release_notes_url, str)
or not all(
_https_url(url)
for url in (manifest_url, signature_url, release_notes_url)
)
):
continue
try:
_parse_timestamp(release_date)
except ReleaseToolError:
continue
release_id = item.get("id")
override = (statuses or {}).get(tag, "active")
if isinstance(override, dict):
status = override.get("status", "active")
config_schema = _positive_integer(
override.get("config_schema", 1),
f"{tag} config_schema",
)
minimum_config_schema = _positive_integer(
override.get("minimum_config_schema", 1),
f"{tag} minimum_config_schema",
)
else:
status = override
config_schema = 1
minimum_config_schema = 1
if status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseToolError(f"invalid status for {tag}: {status!r}")
if minimum_config_schema > config_schema:
raise ReleaseToolError(
f"minimum_config_schema exceeds config_schema for {tag}"
)
title = item.get("name")
if not isinstance(title, str) or not title:
title = f"PristonTale EU Launcher {version}"
records.append(
(
version,
{
"channel": version.channel,
"compatibility": {
"config_schema": config_schema,
"minimum_config_schema": minimum_config_schema,
},
"manifest_signature_url": signature_url,
"manifest_url": manifest_url,
"release_date": release_date,
"release_id": (
release_id
if isinstance(release_id, int)
and not isinstance(release_id, bool)
and release_id > 0
else None
),
"release_notes_url": release_notes_url,
"status": status,
"tag": tag,
"title": title,
"version": str(version),
},
)
)
included_tags.add(tag)
unused_statuses = set(statuses or {}).difference(included_tags)
if unused_statuses:
raise ReleaseToolError(
"release status overrides do not match published, manifest-backed tags: "
+ ", ".join(sorted(unused_statuses))
)
records.sort(key=lambda entry: entry[0], reverse=True)
config = load_config()
return {
"application": config["application"],
"generated_at": generated_at,
"releases": [record for _version, record in records],
"schema": 1,
}
def load_release_statuses(path: Path) -> dict[str, object]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ReleaseToolError(f"invalid release status file {path}: {exc}") from exc
if not isinstance(value, dict) or value.get("schema") != 1:
raise ReleaseToolError("release status file must use schema 1")
releases = value.get("releases")
if not isinstance(releases, dict):
raise ReleaseToolError("release status file releases must be an object")
statuses: dict[str, object] = {}
for tag, status_value in releases.items():
if not isinstance(tag, str):
raise ReleaseToolError("release status tags must be strings")
try:
SemanticVersion.from_tag(tag)
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release status tag {tag!r}") from exc
status = status_value.get("status", "active") if isinstance(status_value, dict) else status_value
if not isinstance(status, str) or status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseToolError(f"invalid status for {tag}: {status!r}")
if isinstance(status_value, dict):
unknown = set(status_value).difference(
{"status", "config_schema", "minimum_config_schema"}
)
if unknown:
raise ReleaseToolError(
f"unknown release override fields for {tag}: {', '.join(sorted(unknown))}"
)
config_schema = _positive_integer(
status_value.get("config_schema", 1),
f"{tag} config_schema",
)
minimum_config_schema = _positive_integer(
status_value.get("minimum_config_schema", 1),
f"{tag} minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise ReleaseToolError(
f"minimum_config_schema exceeds config_schema for {tag}"
)
statuses[tag] = {
"status": status,
"config_schema": config_schema,
"minimum_config_schema": minimum_config_schema,
}
else:
statuses[tag] = status
return statuses
def _positive_integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ReleaseToolError(f"{label} must be a positive integer")
return value
def _https_url(value: str) -> bool:
parts = urlsplit(value)
return (
parts.scheme.casefold() == "https"
and bool(parts.netloc)
and parts.username is None
and parts.password is None
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--generated-at", required=True)
parser.add_argument("--status-file", type=Path)
arguments = parser.parse_args()
try:
releases = json.loads(arguments.input.read_text(encoding="utf-8"))
config = load_config()
status_file = arguments.status_file or (
PROJECT_ROOT / str(config["release_status_file"])
)
history = release_history(
releases,
arguments.generated_at,
load_release_statuses(status_file),
)
arguments.output.parent.mkdir(parents=True, exist_ok=True)
temporary = arguments.output.with_name(
f".{arguments.output.name}.{os.getpid()}.tmp"
)
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(history, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(0o644)
os.replace(temporary, arguments.output)
finally:
temporary.unlink(missing_ok=True)
except (OSError, UnicodeError, json.JSONDecodeError, ReleaseToolError) as exc:
print(f"Release history generation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Build one signed channel view from authenticated release history metadata."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from ptlinux.self_update import LauncherUpdateError, parse_update_manifest # noqa: E402
from ptlinux.update_resolver import ( # noqa: E402
ReleaseModelError,
ReleaseRecord,
UpdateResolver,
validate_channel,
)
from scripts.release_lib import ReleaseToolError # noqa: E402
def prepare_channel_metadata(
history: object,
channel: str,
manifest_bytes: bytes,
) -> tuple[dict[str, object], dict[str, object]]:
"""Return the selected history record and a status-aware manifest copy."""
try:
validate_channel(channel)
except ReleaseModelError as exc:
raise ReleaseToolError(str(exc)) from exc
if not isinstance(history, dict) or history.get("schema") != 1:
raise ReleaseToolError("release history must use schema 1")
releases = history.get("releases")
if not isinstance(releases, list):
raise ReleaseToolError("release history releases must be an array")
records: list[tuple[dict[str, object], ReleaseRecord]] = []
for value in releases:
if not isinstance(value, dict) or value.get("channel") != channel:
continue
try:
record = ReleaseRecord(
version=_string(value, "version"),
channel=_string(value, "channel"),
status=_string(value, "status"),
tag=_string(value, "tag"),
manifest_url=_string(value, "manifest_url"),
).validate()
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release history record: {exc}") from exc
records.append((value, record))
if not records:
raise ReleaseToolError(f"release history has no {channel} releases")
try:
resolver = UpdateResolver(record for _value, record in records)
selected_record = resolver.latest_eligible(channel)
if selected_record is None:
selected_record = resolver.eligible_releases(channel, include_inactive=True)[0]
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release history: {exc}") from exc
selected = next(
value for value, record in records if record.version == selected_record.version
)
try:
manifest = parse_update_manifest(manifest_bytes)
except LauncherUpdateError as exc:
raise ReleaseToolError(f"selected channel manifest is invalid: {exc}") from exc
for field, actual, expected in (
("version", manifest.version, selected_record.version),
("channel", manifest.channel, selected_record.channel),
("tag", manifest.tag, selected_record.tag),
):
if actual != expected:
raise ReleaseToolError(
f"selected manifest {field} {actual!r} does not match history {expected!r}"
)
try:
manifest_value = json.loads(manifest_bytes.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise ReleaseToolError(f"selected channel manifest is invalid JSON: {exc}") from exc
if not isinstance(manifest_value, dict):
raise ReleaseToolError("selected channel manifest must be an object")
manifest_value["status"] = selected_record.status
history_compatibility = selected.get("compatibility")
if not isinstance(history_compatibility, dict):
raise ReleaseToolError("selected release compatibility must be an object")
config_schema = _positive_integer(history_compatibility, "config_schema")
minimum_config_schema = _positive_integer(
history_compatibility,
"minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise ReleaseToolError(
"selected release minimum_config_schema exceeds config_schema"
)
manifest_compatibility = manifest_value.get("compatibility")
if not isinstance(manifest_compatibility, dict):
raise ReleaseToolError("selected channel manifest compatibility is invalid")
manifest_compatibility["config_schema"] = config_schema
manifest_compatibility["minimum_config_schema"] = minimum_config_schema
return selected, manifest_value
def _string(value: dict[str, object], key: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
raise ReleaseToolError(f"release history field {key!r} must be a string")
return item
def _positive_integer(value: dict[str, object], key: str) -> int:
item = value.get(key)
if isinstance(item, bool) or not isinstance(item, int) or item <= 0:
raise ReleaseToolError(
f"selected release compatibility {key!r} must be positive"
)
return item
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(0o644)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("history", type=Path)
parser.add_argument("channel", choices=("stable", "prerelease"))
parser.add_argument("manifest", type=Path)
parser.add_argument("output", type=Path)
arguments = parser.parse_args()
try:
history = json.loads(arguments.history.read_text(encoding="utf-8"))
selected, manifest = prepare_channel_metadata(
history,
arguments.channel,
arguments.manifest.read_bytes(),
)
_write_json(arguments.output, manifest)
print(_string(selected, "version"))
except (OSError, UnicodeError, json.JSONDecodeError, ReleaseToolError) as exc:
print(f"Channel metadata preparation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

351
scripts/publish-gitea-release.sh Executable file
View File

@@ -0,0 +1,351 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 4 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR RELEASE_NOTES\n' "$0" >&2
exit 2
fi
VERSION="$1"
CHANNEL="$2"
OUTPUT_DIRECTORY="$(realpath -- "$3")"
RELEASE_NOTES="$(realpath -- "$4")"
TAG="${RELEASE_TAG:-v${VERSION}}"
COMMIT="${RELEASE_COMMIT:?RELEASE_COMMIT is required}"
PRERELEASE="${RELEASE_PRERELEASE:?RELEASE_PRERELEASE is required}"
API_URL="${GITHUB_API_URL:?GITHUB_API_URL is required}"
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
SERVER_URL="${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
TOKEN="${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
RELEASE_BRANCH="${RELEASE_BRANCH:-main}"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ARCHIVE_PREFIX="$(jq -er '.archive_prefix' "${SCRIPT_ROOT}/../release/config.json")"
ARCHIVE="${OUTPUT_DIRECTORY}/${ARCHIVE_PREFIX}-${VERSION}.tar.gz"
CHECKSUM="${ARCHIVE}.sha256"
MANIFEST="${OUTPUT_DIRECTORY}/manifest.json"
MANIFEST_SIGNATURE="${OUTPUT_DIRECTORY}/manifest.json.sig"
RELEASE_NOTES_ASSET="${OUTPUT_DIRECTORY}/release-notes.md"
API_ROOT="${API_URL%/}/repos/${REPOSITORY}"
RESPONSE_DIRECTORY="$(mktemp -d)"
cleanup()
{
rm -rf -- "$RESPONSE_DIRECTORY"
}
trap cleanup EXIT
RELEASE_MODEL="$(
"$PYTHON_BIN" "${SCRIPT_ROOT}/../ptlinux/update_resolver.py" inspect-tag "$TAG"
)"
if [[ "$(jq -r '.version' <<< "$RELEASE_MODEL")" != "$VERSION" \
|| "$(jq -r '.channel' <<< "$RELEASE_MODEL")" != "$CHANNEL" \
|| "$(jq -r '.prerelease' <<< "$RELEASE_MODEL")" != "$PRERELEASE" ]]
then
printf 'Release tag, version, channel, and prerelease flag are inconsistent.\n' >&2
exit 1
fi
if [[ ! "$COMMIT" =~ ^[0-9a-f]{40,64}$ ]]
then
printf 'Release commit is not a lowercase Git object ID: %s\n' "$COMMIT" >&2
exit 1
fi
for REQUIRED_ARTIFACT in \
"$ARCHIVE" \
"$CHECKSUM" \
"$MANIFEST" \
"$MANIFEST_SIGNATURE" \
"$RELEASE_NOTES_ASSET"
do
if [[ ! -s "$REQUIRED_ARTIFACT" ]]
then
printf 'Required release artifact is missing or empty: %s\n' "$REQUIRED_ARTIFACT" >&2
exit 1
fi
done
api_request()
{
local METHOD="$1"
local URL="$2"
local OUTPUT="$3"
shift 3
curl \
--silent \
--show-error \
--connect-timeout 20 \
--max-time 180 \
--request "$METHOD" \
--header "Authorization: token ${TOKEN}" \
--header 'Accept: application/json' \
--output "$OUTPUT" \
--write-out '%{http_code}' \
"$@" \
"$URL"
}
fetch_all_releases()
{
local OUTPUT="$1"
local PAGE=1
local PAGE_COUNT
local PAGE_FILE
local STATUS
local COMBINED
printf '%s\n' '[]' > "$OUTPUT"
while (( PAGE <= 100 ))
do
PAGE_FILE="${RESPONSE_DIRECTORY}/releases-page-${PAGE}.json"
STATUS="$(
api_request GET "${API_ROOT}/releases?limit=100&page=${PAGE}" "$PAGE_FILE"
)"
if [[ "$STATUS" != "200" ]]
then
printf 'Could not fetch release history page %s: HTTP %s\n' \
"$PAGE" "$STATUS" >&2
exit 1
fi
if ! PAGE_COUNT="$(jq -er 'if type == "array" then length else error("not an array") end' "$PAGE_FILE")"
then
printf 'Gitea release history page %s is not an array.\n' "$PAGE" >&2
exit 1
fi
COMBINED="${RESPONSE_DIRECTORY}/releases-combined-${PAGE}.json"
jq -s '.[0] + .[1]' "$OUTPUT" "$PAGE_FILE" > "$COMBINED"
mv -- "$COMBINED" "$OUTPUT"
if (( PAGE_COUNT < 100 ))
then
return
fi
((PAGE += 1))
done
printf '%s\n' 'Gitea release history exceeded the 10,000-release safety limit.' >&2
exit 1
}
EXISTING_RESPONSE="${RESPONSE_DIRECTORY}/existing.json"
EXISTING_STATUS="$(api_request GET "${API_ROOT}/releases/tags/${TAG}" "$EXISTING_RESPONSE")"
case "$EXISTING_STATUS" in
404)
;;
200)
printf 'Release %s already exists. Published releases are immutable.\n' "$TAG" >&2
exit 1
;;
*)
printf 'Could not check existing release %s: HTTP %s\n' "$TAG" "$EXISTING_STATUS" >&2
jq -r '.message // empty' "$EXISTING_RESPONSE" >&2 || true
exit 1
;;
esac
RELEASE_PAYLOAD="${RESPONSE_DIRECTORY}/release-payload.json"
jq -n \
--rawfile body "$RELEASE_NOTES" \
--arg name "PristonTale EU Launcher ${VERSION}" \
--arg tag_name "$TAG" \
--arg target_commitish "$COMMIT" \
--argjson prerelease "$PRERELEASE" \
'{body:$body,draft:false,name:$name,prerelease:$prerelease,tag_name:$tag_name,target_commitish:$target_commitish}' \
> "$RELEASE_PAYLOAD"
CREATE_RESPONSE="${RESPONSE_DIRECTORY}/created.json"
CREATE_STATUS="$(
api_request POST "${API_ROOT}/releases" "$CREATE_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${RELEASE_PAYLOAD}"
)"
if [[ "$CREATE_STATUS" != "201" ]]
then
printf 'Gitea release creation failed: HTTP %s\n' "$CREATE_STATUS" >&2
jq -r '.message // empty' "$CREATE_RESPONSE" >&2 || true
exit 1
fi
RELEASE_ID="$(jq -er '.id | select(type == "number" and . > 0)' "$CREATE_RESPONSE")"
upload_asset()
{
local FILE="$1"
local NAME
local RESPONSE
local STATUS
NAME="$(basename -- "$FILE")"
RESPONSE="${RESPONSE_DIRECTORY}/asset-${NAME}.json"
STATUS="$(
api_request POST "${API_ROOT}/releases/${RELEASE_ID}/assets?name=${NAME}" "$RESPONSE" \
--form "attachment=@${FILE};type=application/octet-stream"
)"
if [[ "$STATUS" != "201" ]]
then
printf 'Gitea asset upload failed for %s: HTTP %s\n' "$NAME" "$STATUS" >&2
jq -r '.message // empty' "$RESPONSE" >&2 || true
exit 1
fi
if [[ "$(jq -r '.name // empty' "$RESPONSE")" != "$NAME" ]]
then
printf 'Gitea returned the wrong attachment name for %s.\n' "$NAME" >&2
exit 1
fi
}
upload_asset "$ARCHIVE"
upload_asset "$CHECKSUM"
upload_asset "$MANIFEST"
upload_asset "$MANIFEST_SIGNATURE"
upload_asset "$RELEASE_NOTES_ASSET"
VERIFY_RESPONSE="${RESPONSE_DIRECTORY}/verified.json"
VERIFY_STATUS="$(api_request GET "${API_ROOT}/releases/tags/${TAG}" "$VERIFY_RESPONSE")"
if [[ "$VERIFY_STATUS" != "200" ]]
then
printf 'Could not re-query published release %s: HTTP %s\n' "$TAG" "$VERIFY_STATUS" >&2
exit 1
fi
if [[ "$(jq -r '.prerelease' "$VERIFY_RESPONSE")" != "$PRERELEASE" ]]
then
printf 'Published release prerelease flag does not match channel %s.\n' "$CHANNEL" >&2
exit 1
fi
if [[ "$(jq -r '.tag_name // empty' "$VERIFY_RESPONSE")" != "$TAG" \
|| "$(jq -r '.draft' "$VERIFY_RESPONSE")" != "false" \
|| "$(jq -r '.target_commitish // empty' "$VERIFY_RESPONSE")" != "$COMMIT" ]]
then
printf 'Published release identity or target commit does not match %s.\n' "$TAG" >&2
exit 1
fi
for REQUIRED_ARTIFACT in \
"$ARCHIVE" \
"$CHECKSUM" \
"$MANIFEST" \
"$MANIFEST_SIGNATURE" \
"$RELEASE_NOTES_ASSET"
do
ASSET_NAME="$(basename -- "$REQUIRED_ARTIFACT")"
EXPECTED_SIZE="$(stat -c '%s' -- "$REQUIRED_ARTIFACT")"
ACTUAL_SIZE="$(
jq -er --arg name "$ASSET_NAME" \
'.assets[] | select(.name == $name) | .size' "$VERIFY_RESPONSE"
)"
if [[ "$ACTUAL_SIZE" != "$EXPECTED_SIZE" ]]
then
printf 'Published asset size mismatch for %s: expected %s, got %s.\n' \
"$ASSET_NAME" "$EXPECTED_SIZE" "$ACTUAL_SIZE" >&2
exit 1
fi
done
RELEASES_RESPONSE="${RESPONSE_DIRECTORY}/releases.json"
fetch_all_releases "$RELEASES_RESPONSE"
PUBLISHED_AT="$(jq -er '.published_at' "$VERIFY_RESPONSE")"
HISTORY="${OUTPUT_DIRECTORY}/history.json"
HISTORY_SIGNATURE="${OUTPUT_DIRECTORY}/history.json.sig"
"$PYTHON_BIN" "${SCRIPT_ROOT}/generate_history.py" \
"$RELEASES_RESPONSE" "$HISTORY" --generated-at "$PUBLISHED_AT"
if ! jq -e \
--arg tag "$TAG" \
--arg channel "$CHANNEL" \
'.releases[] | select(.tag == $tag and .channel == $channel and .status == "active")' \
"$HISTORY" >/dev/null
then
printf 'Published release %s is absent or inactive in generated history.\n' "$TAG" >&2
exit 1
fi
SIGNING_ARGUMENTS=(
"$HISTORY"
"$HISTORY_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" "${SIGNING_ARGUMENTS[@]}"
CHANNEL_CONTENT="$(base64 -w 0 -- "$MANIFEST")"
CHANNEL_SIGNATURE_CONTENT="$(base64 -w 0 -- "$MANIFEST_SIGNATURE")"
HISTORY_CONTENT="$(base64 -w 0 -- "$HISTORY")"
HISTORY_SIGNATURE_CONTENT="$(base64 -w 0 -- "$HISTORY_SIGNATURE")"
CHANNEL_PATH="updates/${CHANNEL}.json"
CHANNEL_SIGNATURE_PATH="updates/${CHANNEL}.json.sig"
file_operation()
{
local PATH_VALUE="$1"
local CONTENT_VALUE="$2"
local RESPONSE="${RESPONSE_DIRECTORY}/content-$(basename -- "$PATH_VALUE").json"
local STATUS
STATUS="$(api_request GET "${API_ROOT}/contents/${PATH_VALUE}?ref=${RELEASE_BRANCH}" "$RESPONSE")"
case "$STATUS" in
200)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
--arg sha "$(jq -er '.sha' "$RESPONSE")" \
'{content:$content,operation:"update",path:$path,sha:$sha}'
;;
404)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
'{content:$content,operation:"create",path:$path}'
;;
*)
printf 'Could not inspect repository metadata path %s: HTTP %s\n' \
"$PATH_VALUE" "$STATUS" >&2
exit 1
;;
esac
}
CHANNEL_OPERATION="$(file_operation "$CHANNEL_PATH" "$CHANNEL_CONTENT")"
CHANNEL_SIGNATURE_OPERATION="$(
file_operation "$CHANNEL_SIGNATURE_PATH" "$CHANNEL_SIGNATURE_CONTENT"
)"
HISTORY_OPERATION="$(file_operation updates/history.json "$HISTORY_CONTENT")"
HISTORY_SIGNATURE_OPERATION="$(
file_operation updates/history.json.sig "$HISTORY_SIGNATURE_CONTENT"
)"
CHANNEL_PAYLOAD="${RESPONSE_DIRECTORY}/channel-payload.json"
jq -n \
--arg branch "$RELEASE_BRANCH" \
--arg message "Update ${CHANNEL} channel to ${VERSION}" \
--argjson channel "$CHANNEL_OPERATION" \
--argjson channel_signature "$CHANNEL_SIGNATURE_OPERATION" \
--argjson history "$HISTORY_OPERATION" \
--argjson history_signature "$HISTORY_SIGNATURE_OPERATION" \
'{branch:$branch,message:$message,files:[$channel,$channel_signature,$history,$history_signature]}' \
> "$CHANNEL_PAYLOAD"
CHANNEL_RESPONSE="${RESPONSE_DIRECTORY}/channel-response.json"
CHANNEL_STATUS="$(
api_request POST "${API_ROOT}/contents" "$CHANNEL_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${CHANNEL_PAYLOAD}"
)"
if [[ "$CHANNEL_STATUS" != "201" ]]
then
printf 'Atomic channel/history metadata update failed: HTTP %s\n' "$CHANNEL_STATUS" >&2
jq -r '.message // empty' "$CHANNEL_RESPONSE" >&2 || true
exit 1
fi
ARCHIVE_SHA256="$(cut -d ' ' -f 1 -- "$CHECKSUM")"
SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
printf '## Published release\n\n'
printf '| Field | Value |\n|---|---|\n'
printf '| Release | PristonTale EU Launcher %s |\n' "$VERSION"
printf '| Channel | `%s` |\n' "$CHANNEL"
printf '| Tag | `%s` |\n' "$TAG"
printf '| Commit | `%s` |\n' "$COMMIT"
printf '| Archive | `%s` |\n' "$(basename -- "$ARCHIVE")"
printf '| SHA-256 | `%s` |\n' "$ARCHIVE_SHA256"
printf '| Manifest | `manifest.json` |\n'
printf '| Status | Published |\n'
} >> "$SUMMARY_FILE"
printf 'Published %s release %s (%s) with verified assets and channel metadata.\n' \
"$CHANNEL" "$TAG" "${SERVER_URL%/}/${REPOSITORY}/releases/tag/${TAG}"

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]
then
printf 'Usage: %s OUTPUT_DIR\n' "$0" >&2
exit 2
fi
OUTPUT_DIRECTORY="$(realpath -m -- "$1")"
API_URL="${GITHUB_API_URL:?GITHUB_API_URL is required}"
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
SERVER_URL="${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
TOKEN="${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
RELEASE_BRANCH="${RELEASE_BRANCH:-main}"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
STATUS_FILE="${RELEASE_STATUS_FILE:-${SCRIPT_ROOT}/../release/statuses.json}"
API_ROOT="${API_URL%/}/repos/${REPOSITORY}"
RESPONSE_DIRECTORY="$(mktemp -d)"
cleanup()
{
rm -rf -- "$RESPONSE_DIRECTORY"
}
trap cleanup EXIT
mkdir -p -- "$OUTPUT_DIRECTORY"
api_request()
{
local METHOD="$1"
local URL="$2"
local OUTPUT="$3"
shift 3
curl \
--silent \
--show-error \
--connect-timeout 20 \
--max-time 180 \
--request "$METHOD" \
--header "Authorization: token ${TOKEN}" \
--header 'Accept: application/json' \
--output "$OUTPUT" \
--write-out '%{http_code}' \
"$@" \
"$URL"
}
fetch_all_releases()
{
local OUTPUT="$1"
local PAGE=1
local PAGE_COUNT
local PAGE_FILE
local STATUS
local COMBINED
printf '%s\n' '[]' > "$OUTPUT"
while (( PAGE <= 100 ))
do
PAGE_FILE="${RESPONSE_DIRECTORY}/releases-page-${PAGE}.json"
STATUS="$(
api_request GET "${API_ROOT}/releases?limit=100&page=${PAGE}" "$PAGE_FILE"
)"
if [[ "$STATUS" != "200" ]]
then
printf 'Could not fetch release history page %s: HTTP %s\n' \
"$PAGE" "$STATUS" >&2
exit 1
fi
if ! PAGE_COUNT="$(jq -er 'if type == "array" then length else error("not an array") end' "$PAGE_FILE")"
then
printf 'Gitea release history page %s is not an array.\n' "$PAGE" >&2
exit 1
fi
COMBINED="${RESPONSE_DIRECTORY}/releases-combined-${PAGE}.json"
jq -s '.[0] + .[1]' "$OUTPUT" "$PAGE_FILE" > "$COMBINED"
mv -- "$COMBINED" "$OUTPUT"
if (( PAGE_COUNT < 100 ))
then
return
fi
((PAGE += 1))
done
printf '%s\n' 'Gitea release history exceeded the 10,000-release safety limit.' >&2
exit 1
}
RELEASES_RESPONSE="${RESPONSE_DIRECTORY}/releases.json"
fetch_all_releases "$RELEASES_RESPONSE"
GENERATED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
HISTORY="${OUTPUT_DIRECTORY}/history.json"
HISTORY_SIGNATURE="${OUTPUT_DIRECTORY}/history.json.sig"
"$PYTHON_BIN" "${SCRIPT_ROOT}/generate_history.py" \
"$RELEASES_RESPONSE" "$HISTORY" --generated-at "$GENERATED_AT" \
--status-file "$STATUS_FILE"
SIGNING_ARGUMENTS=(
"$HISTORY"
"$HISTORY_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" "${SIGNING_ARGUMENTS[@]}"
CHANNEL_DIRECTORY="${RESPONSE_DIRECTORY}/channels"
mkdir -p -- "$CHANNEL_DIRECTORY"
CHANNELS_TO_PUBLISH=()
for CHANNEL in stable prerelease
do
RECORD="$(
jq -cer --arg channel "$CHANNEL" \
'([.releases[] | select(.channel == $channel)]
| (map(select(.status == "active"))[0] // .[0]))' \
"$HISTORY" 2>/dev/null || true
)"
if [[ -z "$RECORD" || "$RECORD" == "null" ]]
then
continue
fi
MANIFEST_URL="$(jq -er '.manifest_url' <<< "$RECORD")"
MANIFEST_SIGNATURE_URL="$(jq -er '.manifest_signature_url' <<< "$RECORD")"
for ASSET_URL in "$MANIFEST_URL" "$MANIFEST_SIGNATURE_URL"
do
case "$ASSET_URL" in
"${SERVER_URL%/}/"*)
;;
*)
printf 'Refusing to send release credentials to untrusted URL: %s\n' \
"$ASSET_URL" >&2
exit 1
;;
esac
done
SOURCE_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}-source.json"
SOURCE_SIGNATURE="${CHANNEL_DIRECTORY}/${CHANNEL}-source.json.sig"
MANIFEST_STATUS="$(api_request GET "$MANIFEST_URL" "$SOURCE_MANIFEST")"
if [[ "$MANIFEST_STATUS" != "200" ]]
then
printf 'Could not fetch selected %s manifest: HTTP %s\n' \
"$CHANNEL" "$MANIFEST_STATUS" >&2
exit 1
fi
SIGNATURE_STATUS="$(
api_request GET "$MANIFEST_SIGNATURE_URL" "$SOURCE_SIGNATURE"
)"
if [[ "$SIGNATURE_STATUS" != "200" ]]
then
printf 'Could not fetch selected %s manifest signature: HTTP %s\n' \
"$CHANNEL" "$SIGNATURE_STATUS" >&2
exit 1
fi
VERIFICATION_ARGUMENTS=("$SOURCE_MANIFEST" "$SOURCE_SIGNATURE")
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
VERIFICATION_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/verify_metadata.py" \
"${VERIFICATION_ARGUMENTS[@]}"
CHANNEL_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}.json"
CHANNEL_VERSION="$(
"$PYTHON_BIN" "${SCRIPT_ROOT}/prepare_channel_metadata.py" \
"$HISTORY" "$CHANNEL" "$SOURCE_MANIFEST" "$CHANNEL_MANIFEST"
)"
CHANNEL_SIGNATURE="${CHANNEL_MANIFEST}.sig"
CHANNEL_SIGNING_ARGUMENTS=(
"$CHANNEL_MANIFEST"
"$CHANNEL_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
CHANNEL_SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" \
"${CHANNEL_SIGNING_ARGUMENTS[@]}"
printf '%s\n' "$CHANNEL_VERSION" > "${CHANNEL_DIRECTORY}/${CHANNEL}.version"
CHANNELS_TO_PUBLISH+=("$CHANNEL")
done
file_operation()
{
local PATH_VALUE="$1"
local CONTENT_VALUE="$2"
local RESPONSE="${RESPONSE_DIRECTORY}/content-$(basename -- "$PATH_VALUE").json"
local STATUS
STATUS="$(api_request GET "${API_ROOT}/contents/${PATH_VALUE}?ref=${RELEASE_BRANCH}" "$RESPONSE")"
case "$STATUS" in
200)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
--arg sha "$(jq -er '.sha' "$RESPONSE")" \
'{content:$content,operation:"update",path:$path,sha:$sha}'
;;
404)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
'{content:$content,operation:"create",path:$path}'
;;
*)
printf 'Could not inspect repository metadata path %s: HTTP %s\n' \
"$PATH_VALUE" "$STATUS" >&2
exit 1
;;
esac
}
OPERATIONS='[]'
for CHANNEL in "${CHANNELS_TO_PUBLISH[@]}"
do
CHANNEL_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}.json"
CHANNEL_SIGNATURE="${CHANNEL_MANIFEST}.sig"
CHANNEL_OPERATION="$(
file_operation "updates/${CHANNEL}.json" "$(base64 -w 0 -- "$CHANNEL_MANIFEST")"
)"
CHANNEL_SIGNATURE_OPERATION="$(
file_operation "updates/${CHANNEL}.json.sig" \
"$(base64 -w 0 -- "$CHANNEL_SIGNATURE")"
)"
OPERATIONS="$(
jq -cn \
--argjson operations "$OPERATIONS" \
--argjson channel "$CHANNEL_OPERATION" \
--argjson signature "$CHANNEL_SIGNATURE_OPERATION" \
'$operations + [$channel, $signature]'
)"
done
HISTORY_OPERATION="$(
file_operation updates/history.json "$(base64 -w 0 -- "$HISTORY")"
)"
HISTORY_SIGNATURE_OPERATION="$(
file_operation updates/history.json.sig "$(base64 -w 0 -- "$HISTORY_SIGNATURE")"
)"
OPERATIONS="$(
jq -cn \
--argjson operations "$OPERATIONS" \
--argjson history "$HISTORY_OPERATION" \
--argjson history_signature "$HISTORY_SIGNATURE_OPERATION" \
'$operations + [$history, $history_signature]'
)"
PAYLOAD="${RESPONSE_DIRECTORY}/history-payload.json"
jq -n \
--arg branch "$RELEASE_BRANCH" \
--arg message 'Publish signed launcher release status' \
--argjson files "$OPERATIONS" \
'{branch:$branch,message:$message,files:$files}' \
> "$PAYLOAD"
PUBLISH_RESPONSE="${RESPONSE_DIRECTORY}/history-publish.json"
PUBLISH_STATUS="$(
api_request POST "${API_ROOT}/contents" "$PUBLISH_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${PAYLOAD}"
)"
if [[ "$PUBLISH_STATUS" != "201" ]]
then
printf 'Signed release-history publication failed: HTTP %s\n' "$PUBLISH_STATUS" >&2
jq -r '.message // empty' "$PUBLISH_RESPONSE" >&2 || true
exit 1
fi
SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
printf '## Published launcher release status\n\n'
printf -- '- Generated: `%s`\n' "$GENERATED_AT"
printf -- '- History: `updates/history.json`\n'
printf -- '- Signature: `updates/history.json.sig`\n'
for CHANNEL in "${CHANNELS_TO_PUBLISH[@]}"
do
printf -- '- %s channel: `%s`\n' \
"$CHANNEL" "$(cat -- "${CHANNEL_DIRECTORY}/${CHANNEL}.version")"
done
} >> "$SUMMARY_FILE"
printf 'Published signed launcher history and status-aware channel metadata.\n'

355
scripts/release.sh Executable file
View File

@@ -0,0 +1,355 @@
#!/usr/bin/env bash
set -euo pipefail
usage()
{
cat <<'EOF'
Usage: ./scripts/release.sh CHANNEL VERSION [OPTIONS]
Build a verified local release package in dist/vVERSION/. This command does not
commit, tag, push, create a Gitea release, or upload files.
Channels:
stable
prerelease (aliases: pre, beta)
Options:
--description TEXT Developer-written Markdown release description
--description-file PATH Read developer-written Markdown from PATH
--no-auto-changelog Omit commit-derived change notes
--changelog-only Write and display release notes without building
--editor Edit the final notes with ${EDITOR:-nano}
--dry-run Build and verify in a temporary directory only
--skip-tests Skip configured local preflight checks
--signing-key PATH Use PATH instead of the local release signing key
-h, --help Show this help
EOF
}
if [[ $# -lt 1 ]]
then
usage >&2
exit 2
fi
if [[ "$1" == "-h" || "$1" == "--help" ]]
then
usage
exit 0
fi
if [[ $# -lt 2 ]]
then
usage >&2
exit 2
fi
CHANNEL_INPUT="$1"
VERSION="$2"
shift 2
case "$CHANNEL_INPUT" in
stable)
CHANNEL="stable"
;;
prerelease|pre|beta)
CHANNEL="prerelease"
;;
*)
printf 'Unsupported release channel: %s\n' "$CHANNEL_INPUT" >&2
exit 2
;;
esac
DESCRIPTION=""
DESCRIPTION_FILE=""
AUTO_CHANGELOG=1
CHANGELOG_ONLY=0
OPEN_EDITOR=0
DRY_RUN=0
SKIP_TESTS=0
SIGNING_KEY="${PTLINUX_SIGNING_KEY:-}"
while [[ $# -gt 0 ]]
do
case "$1" in
--description)
[[ $# -ge 2 ]] || { printf '%s\n' '--description requires text.' >&2; exit 2; }
DESCRIPTION="$2"
shift 2
;;
--description-file)
[[ $# -ge 2 ]] || { printf '%s\n' '--description-file requires a path.' >&2; exit 2; }
DESCRIPTION_FILE="$2"
shift 2
;;
--no-auto-changelog)
AUTO_CHANGELOG=0
shift
;;
--changelog-only)
CHANGELOG_ONLY=1
shift
;;
--editor)
OPEN_EDITOR=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--skip-tests)
SKIP_TESTS=1
shift
;;
--signing-key)
[[ $# -ge 2 ]] || { printf '%s\n' '--signing-key requires a path.' >&2; exit 2; }
SIGNING_KEY="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown release option: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -n "$DESCRIPTION" && -n "$DESCRIPTION_FILE" ]]
then
printf '%s\n' 'Use either --description or --description-file, not both.' >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
CONFIG_PATH="${PROJECT_ROOT}/release/config.json"
if [[ -n "${PTLINUX_PYTHON:-}" ]]
then
PYTHON_BIN="$PTLINUX_PYTHON"
elif [[ -x "${PROJECT_ROOT}/.venv/bin/python" ]]
then
PYTHON_BIN="${PROJECT_ROOT}/.venv/bin/python"
else
PYTHON_BIN="python3"
fi
if [[ "$(git -C "$PROJECT_ROOT" rev-parse --show-toplevel 2>/dev/null || true)" != "$PROJECT_ROOT" ]]
then
printf 'Release command is not inside the expected Git repository: %s\n' "$PROJECT_ROOT" >&2
exit 1
fi
cd -- "$PROJECT_ROOT"
"$PYTHON_BIN" -m ptlinux.update_resolver validate "$CHANNEL" "$VERSION"
TAG="v${VERSION}"
COMMIT="$(git rev-parse HEAD)"
SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]
then
printf '%s\n' \
"Warning: packaging the current working tree on top of source commit ${COMMIT}." \
'The signed per-file manifest records the exact packaged content.' >&2
fi
if [[ -n "$DESCRIPTION_FILE" && ! -f "$DESCRIPTION_FILE" ]]
then
printf 'Release description file does not exist: %s\n' "$DESCRIPTION_FILE" >&2
exit 1
fi
TEMPORARY_ROOT="$(mktemp -d)"
cleanup()
{
rm -rf -- "$TEMPORARY_ROOT"
}
trap cleanup EXIT
NOTES_PATH="${TEMPORARY_ROOT}/release-notes.md"
if [[ -z "$DESCRIPTION" && -z "$DESCRIPTION_FILE" && -t 0 && "$CHANGELOG_ONLY" -eq 0 ]]
then
printf '%s\n' 'Release description' '-------------------' '' \
'Enter release notes. Finish with Ctrl+D.' ''
DESCRIPTION_FILE="${TEMPORARY_ROOT}/description.md"
cat < /dev/tty > "$DESCRIPTION_FILE"
fi
{
printf '# PristonTale EU Launcher %s\n\n' "$VERSION"
if [[ -n "$DESCRIPTION" ]]
then
printf '%s\n\n' "$DESCRIPTION"
elif [[ -n "$DESCRIPTION_FILE" ]]
then
cat -- "$DESCRIPTION_FILE"
if [[ -s "$DESCRIPTION_FILE" && "$(tail -c 1 -- "$DESCRIPTION_FILE" | wc -l)" -eq 0 ]]
then
printf '\n'
fi
printf '\n'
fi
if [[ "$AUTO_CHANGELOG" -eq 1 ]]
then
mapfile -t RELEASE_TAGS < <(git tag --merged HEAD)
PREVIOUS_TAG="$(
"$PYTHON_BIN" -m ptlinux.update_resolver previous-tag \
"$CHANNEL" "$VERSION" "${RELEASE_TAGS[@]}"
)"
printf '## Changes\n\n'
if [[ -n "$PREVIOUS_TAG" ]]
then
CHANGELOG_RANGE="${PREVIOUS_TAG}..HEAD"
else
CHANGELOG_RANGE="HEAD"
fi
CHANGELOG="$(git log "$CHANGELOG_RANGE" --no-merges --pretty=format:'- %s (`%h`)')"
if [[ -n "$CHANGELOG" ]]
then
printf '%s\n' "$CHANGELOG"
else
printf '%s\n' '- No commits since the previous release.'
fi
printf '\n'
fi
printf '%s\n' '---' '' \
"Version: \`${VERSION}\` " \
"Channel: \`${CHANNEL}\` " \
"Tag: \`${TAG}\` " \
"Source commit: \`${COMMIT}\`"
} > "$NOTES_PATH"
if [[ "$OPEN_EDITOR" -eq 1 ]]
then
EDITOR_COMMAND="${EDITOR:-nano}"
"$EDITOR_COMMAND" "$NOTES_PATH"
fi
printf '%s\n' '--------------------------------------------------' 'Release Notes' \
'--------------------------------------------------'
cat -- "$NOTES_PATH"
printf '%s\n' '--------------------------------------------------'
if [[ "$CHANGELOG_ONLY" -eq 1 ]]
then
NOTES_OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}"
mkdir -p -- "$NOTES_OUTPUT_DIRECTORY"
cp -- "$NOTES_PATH" "${NOTES_OUTPUT_DIRECTORY}/release-notes.md"
printf 'Release notes written to %s\n' \
"${NOTES_OUTPUT_DIRECTORY}/release-notes.md"
exit 0
fi
if [[ "$SKIP_TESTS" -ne 1 ]]
then
mapfile -t PREFLIGHT_COMMANDS < <(
"$PYTHON_BIN" -c \
'import json,sys; value=json.load(open(sys.argv[1],encoding="utf-8")); print("\n".join(value["preflight"]))' \
"$CONFIG_PATH"
)
for PREFLIGHT_COMMAND in "${PREFLIGHT_COMMANDS[@]}"
do
printf 'Preflight: %s\n' "$PREFLIGHT_COMMAND"
PTLINUX_PYTHON="$PYTHON_BIN" bash -o pipefail -c "$PREFLIGHT_COMMAND" \
< /dev/null
done
else
printf '%s\n' 'Skipping local preflight checks by request.'
fi
if [[ -z "$SIGNING_KEY" ]]
then
SIGNING_KEY="${PROJECT_ROOT}/release-keys/launcher-update-private.pem"
fi
if [[ ! -f "$SIGNING_KEY" || -L "$SIGNING_KEY" ]]
then
printf 'Release signing key is missing: %s\n' "$SIGNING_KEY" >&2
printf '%s\n' 'Use --signing-key PATH or set PTLINUX_SIGNING_KEY.' >&2
exit 1
fi
if [[ "$DRY_RUN" -eq 1 ]]
then
OUTPUT_DIRECTORY="${TEMPORARY_ROOT}/dist/${TAG}"
else
OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}"
fi
SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
RELEASE_COMMIT="$COMMIT" \
RELEASE_TAG="$TAG" \
RELEASE_NOTES_FILE="$NOTES_PATH" \
PTLINUX_PYTHON="$PYTHON_BIN" \
PTLINUX_SIGNING_KEY="$SIGNING_KEY" \
REQUIRE_RELEASE_SIGNATURE=1 \
ALLOW_SOURCE_VERSION_OVERRIDE=1 \
"${SCRIPT_ROOT}/build-release.sh" "$VERSION" "$CHANNEL" "$OUTPUT_DIRECTORY"
PTLINUX_PYTHON="$PYTHON_BIN" \
REQUIRE_RELEASE_SIGNATURE=1 \
"${SCRIPT_ROOT}/verify-release.sh" "$VERSION" "$CHANNEL" "$OUTPUT_DIRECTORY"
if [[ "$DRY_RUN" -eq 1 ]]
then
printf '%s\n' \
'Dry run complete. The release built and verified in a temporary directory; dist/ was not changed.'
exit 0
fi
mkdir -p -- "${OUTPUT_DIRECTORY}/channel"
cp -- "${OUTPUT_DIRECTORY}/manifest.json" \
"${OUTPUT_DIRECTORY}/channel/${CHANNEL}.json"
cp -- "${OUTPUT_DIRECTORY}/manifest.json.sig" \
"${OUTPUT_DIRECTORY}/channel/${CHANNEL}.json.sig"
mapfile -t PACKAGE_METADATA < <(
"$PYTHON_BIN" -c \
'import json,sys; value=json.load(open(sys.argv[1], encoding="utf-8")); archive=value["archive"]; print(archive["layout"]); print(value["minimum_updater_version"]); print(archive["size"]); print(archive["sha256"]); print(archive["url"])' \
"${OUTPUT_DIRECTORY}/manifest.json"
)
ARCHIVE_LAYOUT="${PACKAGE_METADATA[0]}"
MINIMUM_UPDATER_VERSION="${PACKAGE_METADATA[1]}"
ARCHIVE_SIZE="${PACKAGE_METADATA[2]}"
ARCHIVE_SHA256="${PACKAGE_METADATA[3]}"
ARCHIVE_URL="${PACKAGE_METADATA[4]}"
if [[ "$CHANNEL" == "prerelease" ]]
then
PRERELEASE_INSTRUCTION='select the Gitea "Pre-release" checkbox'
else
PRERELEASE_INSTRUCTION='leave the Gitea "Pre-release" checkbox clear'
fi
printf '\nManual Gitea release package is ready in %s\n' "$OUTPUT_DIRECTORY"
printf 'Create tag/release: %s\n' "$TAG"
printf 'Release type: %s; %s.\n' "$CHANNEL" "$PRERELEASE_INSTRUCTION"
printf 'Archive layout: %s.\n' "$ARCHIVE_LAYOUT"
printf 'Minimum updater: %s.\n' "$MINIMUM_UPDATER_VERSION"
printf 'Archive size: %s bytes.\n' "$ARCHIVE_SIZE"
printf 'Archive SHA256: %s\n' "$ARCHIVE_SHA256"
printf 'Signed archive URL: %s\n' "$ARCHIVE_URL"
if [[ "$ARCHIVE_LAYOUT" == "wrapped" ]]
then
printf '%s\n' \
'Compatibility: legacy bridge archive; older launchers can install it automatically.'
else
printf '%s\n' \
'Compatibility: flat archive; launchers older than the minimum require a bridge release or installer update.'
fi
printf '%s\n' 'Upload these five files:' \
" ${OUTPUT_DIRECTORY}/PristonTale-EU-${VERSION}.tar.gz" \
" ${OUTPUT_DIRECTORY}/PristonTale-EU-${VERSION}.tar.gz.sha256" \
" ${OUTPUT_DIRECTORY}/manifest.json" \
" ${OUTPUT_DIRECTORY}/manifest.json.sig" \
" ${OUTPUT_DIRECTORY}/release-notes.md"
printf '%s\n' \
'Wait for every upload to finish and confirm the archive has the exact nonzero size above.' \
'Publish the channel files only after all five Gitea attachments are present.'
printf 'Channel files ready for a later repository update: %s/channel/%s.json{,.sig}\n' \
"$OUTPUT_DIRECTORY" "$CHANNEL"
printf '%s\n' 'No commit, tag, push, Gitea release, or upload was performed.'

1230
scripts/release_lib.py Normal file

File diff suppressed because it is too large Load Diff

83
scripts/set_version.py Executable file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Update the authoritative launcher version and its project metadata mirror."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import re
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ReleaseToolError, load_config # noqa: E402
from ptlinux.update_resolver import SemanticVersion
SOURCE_PATTERN = re.compile(
r'(?m)^__version__ = "[0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?"$'
)
PROJECT_PATTERN = re.compile(
r'(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?"$'
)
def replace_once(path: Path, pattern: re.Pattern[str], replacement: str) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ReleaseToolError(f"cannot read version file {path}: {exc}") from exc
updated, count = pattern.subn(replacement, original)
if count != 1:
raise ReleaseToolError(
f"expected exactly one version declaration in {path}, found {count}"
)
if updated == original:
return
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
handle.write(updated)
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(path.stat().st_mode & 0o777)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
arguments = parser.parse_args()
try:
SemanticVersion.parse(arguments.version)
config = load_config()
version_source = config.get("version_source")
if not isinstance(version_source, str):
raise ReleaseToolError("version_source must be configured")
replace_once(
PROJECT_ROOT / version_source,
SOURCE_PATTERN,
f'__version__ = "{arguments.version}"',
)
mirrors = config.get("version_mirrors")
if not isinstance(mirrors, list) or not all(isinstance(item, str) for item in mirrors):
raise ReleaseToolError("version_mirrors must be configured")
for mirror in mirrors:
replace_once(
PROJECT_ROOT / mirror,
PROJECT_PATTERN,
f'version = "{arguments.version}"',
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Version update failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

46
scripts/sign_metadata.py Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Sign release metadata and verify it against the launcher's public key."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
sign_file,
verify_signature,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("signature", type=Path)
parser.add_argument("private_key", type=Path)
parser.add_argument(
"--public-key",
type=Path,
default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
)
arguments = parser.parse_args()
try:
sign_file(arguments.source, arguments.signature, arguments.private_key)
verify_signature(
arguments.source,
arguments.signature,
arguments.public_key,
)
except (OSError, ReleaseToolError) as exc:
print(f"Metadata signing failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Confirm that a Git remote points at the configured release repository."""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
from urllib.parse import unquote, urlsplit
class RemoteValidationError(ValueError):
pass
def repository_identity(value: str) -> tuple[str, str]:
if not value or any(character in value for character in "\r\n\0"):
raise RemoteValidationError("repository URL is empty or malformed")
scp = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", value)
if scp is not None and "://" not in value:
return _remote_identity(scp.group(1), scp.group(2))
parts = urlsplit(value)
if parts.scheme in {"https", "http", "ssh", "git"}:
hostname = parts.hostname
if hostname is None:
raise RemoteValidationError("repository URL has no host")
host = hostname.casefold()
if parts.port is not None:
host += f":{parts.port}"
return _remote_identity(host, unquote(parts.path))
if parts.scheme == "file":
return "file", str(Path(unquote(parts.path)).expanduser().resolve())
if parts.scheme:
raise RemoteValidationError(f"unsupported repository URL scheme: {parts.scheme}")
return "file", str(Path(value).expanduser().resolve())
def remote_matches(actual: str, expected: list[str]) -> bool:
actual_identity = repository_identity(actual)
return any(repository_identity(candidate) == actual_identity for candidate in expected)
def _remote_identity(host: str, path: str) -> tuple[str, str]:
normalized = path.strip("/")
if normalized.endswith(".git"):
normalized = normalized[:-4]
if not host or not normalized or normalized.startswith("../"):
raise RemoteValidationError("repository URL has an invalid host or path")
return host.casefold(), normalized
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("actual")
parser.add_argument("expected", nargs="+")
arguments = parser.parse_args()
try:
if not remote_matches(arguments.actual, arguments.expected):
raise RemoteValidationError(
"configured Git remote does not point at the expected release repository"
)
except RemoteValidationError as exc:
print(f"Release remote validation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

31
scripts/verify-release.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR\n' "$0" >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
ARGUMENTS=(
"${SCRIPT_ROOT}/verify_release.py"
"$1"
"$2"
"$3"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
if [[ "${REQUIRE_RELEASE_SIGNATURE:-0}" == "1" ]]
then
ARGUMENTS+=(--require-signature)
fi
cd -- "$PROJECT_ROOT"
exec "$PYTHON_BIN" "${ARGUMENTS[@]}"

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Verify a detached release-metadata signature with the launcher public key."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ReleaseToolError, verify_signature # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("signature", type=Path)
parser.add_argument(
"--public-key",
type=Path,
default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
)
arguments = parser.parse_args()
try:
verify_signature(arguments.source, arguments.signature, arguments.public_key)
except (OSError, ReleaseToolError) as exc:
print(f"Metadata verification failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

48
scripts/verify_release.py Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Verify one complete release artifact set before publication."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ReleaseToolError, verify_release # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
parser.add_argument("channel")
parser.add_argument("output_directory", type=Path)
parser.add_argument("--public-key", type=Path)
parser.add_argument("--require-signature", action="store_true")
return parser
def main() -> int:
arguments = build_parser().parse_args()
try:
verify_release(
arguments.version,
arguments.channel,
arguments.output_directory,
public_key=arguments.public_key,
require_signature=arguments.require_signature,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Release verification failed: {exc}", file=sys.stderr)
return 1
print(
f"Verified {arguments.channel} release {arguments.version} in "
f"{arguments.output_directory}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())