#!/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.'