From 0a8932353322ecd084bc244168c3d36a669d5a71 Mon Sep 17 00:00:00 2001 From: m4ko Date: Thu, 10 Sep 2026 00:39:37 +0200 Subject: [PATCH] Add channel-aware release tooling and updater recovery --- .gitea/workflows/release-status.yml | 75 ++ .gitea/workflows/release.yml | 164 +++ RELEASE_PIPELINE_WORKPLAN.md | 444 ++++++++ ptlinux/self_update.py | 38 + ptlinux/system_identity.py | 83 ++ ptlinux/update_resolver.py | 375 +++++++ ptlinux/window_diagnostics.py | 223 ++++ release/README.md | 216 ++++ release/SIGNING_MIGRATION.md | 35 + release/config.json | 140 +++ release/statuses.json | 4 + release_tests/__init__.py | 1 + release_tests/test_channel_discovery.py | 96 ++ release_tests/test_channel_metadata.py | 102 ++ release_tests/test_channel_settings.py | 42 + release_tests/test_gitea_publisher.py | 643 +++++++++++ release_tests/test_launcher_update_cli.py | 38 + release_tests/test_release_artifacts.py | 270 +++++ release_tests/test_release_cli.py | 318 ++++++ release_tests/test_release_history.py | 156 +++ release_tests/test_release_remote.py | 53 + release_tests/test_remote_rollback.py | 603 ++++++++++ release_tests/test_signing.py | 65 ++ release_tests/test_update_dialog.py | 71 ++ release_tests/test_update_resolver.py | 172 +++ release_tests/test_update_telemetry.py | 259 +++++ scripts/__init__.py | 1 + scripts/build-release.sh | 60 + scripts/build_release.py | 64 ++ scripts/generate-manifest.py | 75 ++ scripts/generate_history.py | 261 +++++ scripts/prepare_channel_metadata.py | 163 +++ scripts/publish-gitea-release.sh | 351 ++++++ scripts/publish-release-history.sh | 280 +++++ scripts/release.sh | 355 ++++++ scripts/release_lib.py | 1230 +++++++++++++++++++++ scripts/set_version.py | 83 ++ scripts/sign_metadata.py | 46 + scripts/validate_release_remote.py | 70 ++ scripts/verify-release.sh | 31 + scripts/verify_metadata.py | 36 + scripts/verify_release.py | 48 + 42 files changed, 7840 insertions(+) create mode 100644 .gitea/workflows/release-status.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 RELEASE_PIPELINE_WORKPLAN.md create mode 100644 ptlinux/system_identity.py create mode 100644 ptlinux/update_resolver.py create mode 100644 ptlinux/window_diagnostics.py create mode 100644 release/README.md create mode 100644 release/SIGNING_MIGRATION.md create mode 100644 release/config.json create mode 100644 release/statuses.json create mode 100644 release_tests/__init__.py create mode 100644 release_tests/test_channel_discovery.py create mode 100644 release_tests/test_channel_metadata.py create mode 100644 release_tests/test_channel_settings.py create mode 100644 release_tests/test_gitea_publisher.py create mode 100644 release_tests/test_launcher_update_cli.py create mode 100644 release_tests/test_release_artifacts.py create mode 100644 release_tests/test_release_cli.py create mode 100644 release_tests/test_release_history.py create mode 100644 release_tests/test_release_remote.py create mode 100644 release_tests/test_remote_rollback.py create mode 100644 release_tests/test_signing.py create mode 100644 release_tests/test_update_dialog.py create mode 100644 release_tests/test_update_resolver.py create mode 100644 release_tests/test_update_telemetry.py create mode 100644 scripts/__init__.py create mode 100755 scripts/build-release.sh create mode 100755 scripts/build_release.py create mode 100755 scripts/generate-manifest.py create mode 100755 scripts/generate_history.py create mode 100644 scripts/prepare_channel_metadata.py create mode 100755 scripts/publish-gitea-release.sh create mode 100755 scripts/publish-release-history.sh create mode 100755 scripts/release.sh create mode 100644 scripts/release_lib.py create mode 100755 scripts/set_version.py create mode 100755 scripts/sign_metadata.py create mode 100644 scripts/validate_release_remote.py create mode 100755 scripts/verify-release.sh create mode 100644 scripts/verify_metadata.py create mode 100755 scripts/verify_release.py diff --git a/.gitea/workflows/release-status.yml b/.gitea/workflows/release-status.yml new file mode 100644 index 0000000..0165178 --- /dev/null +++ b/.gitea/workflows/release-status.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..0de1ab3 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/RELEASE_PIPELINE_WORKPLAN.md b/RELEASE_PIPELINE_WORKPLAN.md new file mode 100644 index 0000000..fc96228 --- /dev/null +++ b/RELEASE_PIPELINE_WORKPLAN.md @@ -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/` 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.tar.gz`. +- [x] Current archive layout identified. Its root is + `pristontale-eu-linux-update-v/`, 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-.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/`. +- [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` tag. +- [x] Print whether Gitea's Pre-release checkbox must be selected for the chosen + channel. +- [x] Generate a signed `dist/v/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/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. diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py index 3f93017..075ed45 100644 --- a/ptlinux/self_update.py +++ b/ptlinux/self_update.py @@ -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; " diff --git a/ptlinux/system_identity.py b/ptlinux/system_identity.py new file mode 100644 index 0000000..2ebf555 --- /dev/null +++ b/ptlinux/system_identity.py @@ -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 diff --git a/ptlinux/update_resolver.py b/ptlinux/update_resolver.py new file mode 100644 index 0000000..2875e6b --- /dev/null +++ b/ptlinux/update_resolver.py @@ -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"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r"(?:-(?P