Add channel-aware release tooling and updater recovery

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

1
scripts/__init__.py Normal file
View File

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

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

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

64
scripts/build_release.py Executable file
View File

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

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

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

261
scripts/generate_history.py Executable file
View File

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

View File

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

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

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

View File

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

355
scripts/release.sh Executable file
View File

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

1230
scripts/release_lib.py Normal file

File diff suppressed because it is too large Load Diff

83
scripts/set_version.py Executable file
View File

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

46
scripts/sign_metadata.py Executable file
View File

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

View File

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

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

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

View File

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

48
scripts/verify_release.py Executable file
View File

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