Add channel-aware release tooling and updater recovery
This commit is contained in:
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