Add channel-aware release tooling and updater recovery
This commit is contained in:
75
.gitea/workflows/release-status.yml
Normal file
75
.gitea/workflows/release-status.yml
Normal 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
|
||||
164
.gitea/workflows/release.yml
Normal file
164
.gitea/workflows/release.yml
Normal 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
|
||||
444
RELEASE_PIPELINE_WORKPLAN.md
Normal file
444
RELEASE_PIPELINE_WORKPLAN.md
Normal 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.
|
||||
@@ -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; "
|
||||
|
||||
83
ptlinux/system_identity.py
Normal file
83
ptlinux/system_identity.py
Normal 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
375
ptlinux/update_resolver.py
Normal 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())
|
||||
223
ptlinux/window_diagnostics.py
Normal file
223
ptlinux/window_diagnostics.py
Normal 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
216
release/README.md
Normal 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.
|
||||
35
release/SIGNING_MIGRATION.md
Normal file
35
release/SIGNING_MIGRATION.md
Normal 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
140
release/config.json
Normal 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
4
release/statuses.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"releases": {},
|
||||
"schema": 1
|
||||
}
|
||||
1
release_tests/__init__.py
Normal file
1
release_tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tracked preflight tests for the automated release system."""
|
||||
96
release_tests/test_channel_discovery.py
Normal file
96
release_tests/test_channel_discovery.py
Normal 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()
|
||||
102
release_tests/test_channel_metadata.py
Normal file
102
release_tests/test_channel_metadata.py
Normal 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()
|
||||
42
release_tests/test_channel_settings.py
Normal file
42
release_tests/test_channel_settings.py
Normal 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()
|
||||
643
release_tests/test_gitea_publisher.py
Normal file
643
release_tests/test_gitea_publisher.py
Normal 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()
|
||||
38
release_tests/test_launcher_update_cli.py
Normal file
38
release_tests/test_launcher_update_cli.py
Normal 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()
|
||||
270
release_tests/test_release_artifacts.py
Normal file
270
release_tests/test_release_artifacts.py
Normal 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()
|
||||
318
release_tests/test_release_cli.py
Normal file
318
release_tests/test_release_cli.py
Normal 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()
|
||||
156
release_tests/test_release_history.py
Normal file
156
release_tests/test_release_history.py
Normal 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()
|
||||
53
release_tests/test_release_remote.py
Normal file
53
release_tests/test_release_remote.py
Normal 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()
|
||||
603
release_tests/test_remote_rollback.py
Normal file
603
release_tests/test_remote_rollback.py
Normal 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()
|
||||
65
release_tests/test_signing.py
Normal file
65
release_tests/test_signing.py
Normal 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()
|
||||
71
release_tests/test_update_dialog.py
Normal file
71
release_tests/test_update_dialog.py
Normal 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()
|
||||
172
release_tests/test_update_resolver.py
Normal file
172
release_tests/test_update_resolver.py
Normal 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()
|
||||
259
release_tests/test_update_telemetry.py
Normal file
259
release_tests/test_update_telemetry.py
Normal 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
1
scripts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Release automation modules."""
|
||||
60
scripts/build-release.sh
Executable file
60
scripts/build-release.sh
Executable 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
64
scripts/build_release.py
Executable 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
75
scripts/generate-manifest.py
Executable 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
261
scripts/generate_history.py
Executable 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())
|
||||
163
scripts/prepare_channel_metadata.py
Normal file
163
scripts/prepare_channel_metadata.py
Normal 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
351
scripts/publish-gitea-release.sh
Executable 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}"
|
||||
280
scripts/publish-release-history.sh
Executable file
280
scripts/publish-release-history.sh
Executable 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
355
scripts/release.sh
Executable 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
1230
scripts/release_lib.py
Normal file
File diff suppressed because it is too large
Load Diff
83
scripts/set_version.py
Executable file
83
scripts/set_version.py
Executable 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
46
scripts/sign_metadata.py
Executable 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())
|
||||
70
scripts/validate_release_remote.py
Normal file
70
scripts/validate_release_remote.py
Normal 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
31
scripts/verify-release.sh
Executable 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[@]}"
|
||||
36
scripts/verify_metadata.py
Normal file
36
scripts/verify_metadata.py
Normal 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
48
scripts/verify_release.py
Executable 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())
|
||||
Reference in New Issue
Block a user