From 337f2658d6e7191ca52debc361e511f0dde8f4d1 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Wed, 28 Oct 2026 11:16:24 -0500 Subject: [PATCH] Unify package release options and support forced replacement --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 - .github/workflows/ci.yml | 2 +- README.md | 61 +++++---- git-extensions.sh | 132 ++++++------------ git-release-api.sh | 139 ------------------- git-release-langpack.sh | 170 ++++++++--------------- git-release-style.sh | 151 +++++++------------- lib/github-release.sh | 47 ++++--- lib/release-source.sh | 98 +++++++++++++ tests/test-release-api.sh | 190 ++++++++++++++++++++++++-- tests/test-release-scripts.sh | 21 ++- 11 files changed, 508 insertions(+), 504 deletions(-) delete mode 100755 git-release-api.sh create mode 100644 lib/release-source.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index fb78f1b..d9806b2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,7 +8,6 @@ body: label: Script options: - git-extensions.sh - - git-release-api.sh - git-release-style.sh - git-release-langpack.sh - phpbb-test-board diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 708c878..c330757 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: ShellCheck - run: shellcheck -x git-extensions.sh git-release-api.sh git-release-style.sh git-release-langpack.sh lib/github-release.sh tests/test-release-scripts.sh tests/test-release-api.sh phpbb-test-board/setup-board.sh phpbb-test-board/run-unit-tests.sh phpbb-test-board/php-lint.sh tests/test-phpbb-test-board.sh tests/test-pr-wait.sh + run: shellcheck -x git-extensions.sh git-release-style.sh git-release-langpack.sh lib/github-release.sh lib/release-source.sh tests/test-release-scripts.sh tests/test-release-api.sh phpbb-test-board/setup-board.sh phpbb-test-board/run-unit-tests.sh phpbb-test-board/php-lint.sh tests/test-phpbb-test-board.sh tests/test-pr-wait.sh - name: Tests run: tests/test-release-scripts.sh diff --git a/README.md b/README.md index af3d260..d065e57 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@ Tools for phpBB packages: Bash scripts that build release ZIPs and publish them | [`git-extensions.sh`](git-extensions.sh) | A phpBB extension | `--.zip`, from `composer.json` | | [`git-release-style.sh`](git-release-style.sh) | A phpBB style | `_.zip`, from `style.cfg` | | [`git-release-langpack.sh`](git-release-langpack.sh) | A phpBB language pack for the phpBB.com Customisation Database | `_.zip` | -| [`git-release-api.sh`](git-release-api.sh) | A phpBB extension and a GitHub Release from the remote default branch | `--.zip`, from committed `composer.json` | -`git-extensions.sh`, `git-release-style.sh` and `git-release-langpack.sh` build from the current git commit (`HEAD`); `git-release-api.sh` builds from the repository's default branch on GitHub (`main`, `master`, or whatever it is set to). Uncommitted changes are not included. Files marked `export-ignore` in `.gitattributes` are left out. They were written and tested on Linux. +All three scripts build from the current Git commit (`HEAD`) by default. Add `--remote` to use the repository's default branch on GitHub (`main`, `master`, or its configured name). Uncommitted changes are not included. Files marked `export-ignore` in `.gitattributes` are left out. They were written and tested on Linux. -The scripts share their GitHub release code in [`lib/github-release.sh`](lib/github-release.sh). To put a script on your `PATH`, symlink it rather than copying it, so it can still find `lib/`. +The scripts share source selection in [`lib/release-source.sh`](lib/release-source.sh) and GitHub publishing in [`lib/github-release.sh`](lib/github-release.sh). To put a script on your `PATH`, symlink it rather than copying it, so it can still find `lib/`. + +See the [Wiki release workflows](https://github.com/phpbbmodders/phpbb-release-tools/wiki/Release-workflows) for per-package examples and recovery guidance. ## Forum update posts @@ -26,29 +27,36 @@ for the required layout, image links, sizing, and preparation checks. - `git-extensions.sh`: `git`, `jq`, `zip` - `git-release-style.sh`: `git`, `zip` - `git-release-langpack.sh`: `git`, `curl`, `jq`, `unzip`, `zip`. For `--check` also `php`, `composer` and `sha256sum`. -- `git-release-api.sh`: `git`, `jq`, `zip`, and GNU coreutils (`timeout`, `mktemp`, `stat`). -- `--gh-release` and `git-release-api.sh` also need an authenticated GitHub CLI (`gh`), `jq` and `timeout`, with permission to create releases and upload assets. +- `--remote` and `--release` also need an authenticated GitHub CLI (`gh`), `jq` and `timeout`. Publishing also uses GNU `stat` and needs permission to create releases and upload assets. - `phpbb-test-board/`: see [its README](phpbb-test-board/README.md#requirements). - `pr-wait.py`: Python 3.7 or later and an authenticated GitHub CLI (`gh`). ## Usage -`git-extensions.sh`, `git-release-style.sh` and `git-release-langpack.sh` share the same modes. Run them inside the package's git repository. With no arguments they print help. +The three scripts share the same command structure. Run them inside the package's Git repository, or use `--remote --repo OWNER/REPO` from elsewhere. With no arguments they print help. ```bash -git-extensions.sh --create # build the ZIP in ../git-exported -git-extensions.sh --gh-release --dry-run # build it and preview the GitHub release -git-extensions.sh --gh-release # build it and publish a GitHub release +git-extensions.sh --create # ZIP from local HEAD +git-extensions.sh --create --remote # ZIP from GitHub's default branch +git-extensions.sh --release --remote --dry-run # ZIP and release preview +git-extensions.sh --release --remote # publish to GitHub Releases +git-extensions.sh --release --remote --force # replace an existing tag/release ZIP ``` +Use `git-release-style.sh` or `git-release-langpack.sh` for those package types. + | Option | Meaning | |---|---| -| `-C`, `--create` | Build the ZIP in `../git-exported`, next to the repository. | -| `-g`, `--gh-release` | Build the ZIP and publish it as a GitHub Release from `HEAD`. Tracked files must be committed and `HEAD` pushed. | -| `-n`, `--dry-run` | With `--gh-release`: preview the release and its generated notes without changing anything on GitHub. | +| `-C`, `--create` | Build a ZIP only. | +| `-r`, `--release` | Build a ZIP and publish it to GitHub Releases. Local publication requires clean tracked files and an already-pushed `HEAD`. | +| `--remote` | Pin and package GitHub's default-branch commit instead of local `HEAD`. | +| `-n`, `--dry-run` | With `--release`, build the ZIP and preview generated notes without changing a GitHub tag, release, or asset. A same-named local ZIP can be replaced. | +| `-f`, `--force` | With `--release`, permit replacement of an existing version's tag, notes, and ZIP. GitHub-enforced immutability and tag rules still apply. | +| `-R`, `--repo OWNER/REPO` | Select the repository for `--remote`; otherwise infer it from the current checkout. Rejected without `--remote`. | +| `-o`, `--output DIR` | ZIP destination, defaulting to `../git-exported` relative to the repository root (or current directory when outside a checkout). Relative custom paths use that same root. | | `-h`, `--help` | Show help. | -Invalid usage exits with status 2. +Existing `-g`/`--gh-release` remains an alias for `--release`, and `--output-dir` remains an alias for `--output`. Language packs now use `--release` for GitHub publication too; use `--create` for a ZIP only. Invalid usage exits with status 2. ### Extensions: `git-extensions.sh` @@ -64,35 +72,35 @@ Invalid usage exits with status 2. git-release-langpack.sh --create --check ``` -- `--check` (`-c`) runs the [phpBB Translation Validator](https://github.com/phpbb/phpbb-translation-validator) (branch `1.6.x`, phpBB 3.3 only) on the exact zip contents. It uses the English files of the official phpBB release zip as the reference, checked against phpBB's published SHA-256. It can run on its own, or with `--create` or `--gh-release`; then nothing is written or published unless validation passes. +- `--check` (`-c`) runs the [phpBB Translation Validator](https://github.com/phpbb/phpbb-translation-validator) (branch `1.6.x`, phpBB 3.3 only) on the exact zip contents. It uses the English files of the official phpBB release zip as the reference, checked against phpBB's published SHA-256. It can run on its own, or with `--create` or `--release`; then nothing is written or published unless validation passes. - The zip is `_.zip` with a folder of the same name inside. Only `ext/`, `language/` and `styles/` are included, because the [Language Pack Validation Policy](https://area51.phpbb.com/docs/dev/3.3.x/language/validation.html) does not allow other files. -- The version defaults to the current stable phpBB 3.3 release from `https://version.phpbb.com/phpbb/versions.json`. Use `--version` to set it, for example `--version 3.3.16`. With `--gh-release` that phpBB version is the tag and release name. -- `--release` was renamed to `--create`. +- The version defaults to the current stable phpBB 3.3 release from `https://version.phpbb.com/phpbb/versions.json`. Use `--version` to set it, for example `--version 3.3.16`. With `--release` that phpBB version is the tag and release name. Run `git-release-langpack.sh --help` for all options. -### GitHub releases from the default branch: `git-release-api.sh` +### Remote sources -Uses the GitHub API to resolve the latest commit on the repository's default branch, generate release notes, create a draft release, upload the package, and publish the release. An isolated Git fetch supplies that exact commit for `git archive`, preserving the extension folder layout and exclusions. The local checkout is not changed, so nothing needs to be checked out or pushed locally. - -Run from the extension repository, or supply `--repo OWNER/REPO`: +`--remote` resolves GitHub's configured default branch and fetches its exact commit into an isolated temporary Git repository. Metadata, validation, and packaging all use that pinned commit. Your local branch and working files are preserved, including dirty or unpushed local changes. ZIP-only `--create --remote` does not inspect existing releases or generate release notes. ```bash -git-release-api.sh --dry-run --repo phpbbmodders/phpbb-ext-wiki -git-release-api.sh --release --repo phpbbmodders/phpbb-ext-wiki +git-extensions.sh --release --remote --dry-run --repo phpbbmodders/phpbb-ext-wiki +git-release-style.sh --create --remote --repo OWNER/STYLE --output /tmp/style-packages +git-release-langpack.sh --create --remote --repo OWNER/LANGUAGE --check --version 3.3.17 ``` -`--dry-run` builds the local ZIP and previews GitHub-generated release notes without creating a tag, release, or asset. `--release` publishes it. With no arguments the script prints help. Use `--output DIR` to change the ZIP destination from `../git-exported`. Packaging replaces a same-named local ZIP only after the archive is complete. +The old `git-release-api.sh` entry point has been removed. Replace `git-release-api.sh --dry-run` with `git-extensions.sh --release --remote --dry-run`, and replace its `--release` operation with `git-extensions.sh --release --remote`. The `--repo` and `--output` options carry over. Update any personal aliases or automation still calling the old entry point. ### How GitHub releases behave -These rules apply to `--gh-release` and to `git-release-api.sh`. +These rules apply to `--release` (including its `--gh-release` alias) in all three scripts. -The package version is the tag and release name, and release notes are generated by GitHub. Alpha, beta, RC, and dev versions are marked as prereleases. Archived repositories are rejected, since GitHub does not allow releases on them. For stable, alpha, beta, and RC versions an existing tag or release stops the operation; those releases and assets are never replaced. +The package version is the tag and release name, and release notes are generated by GitHub. Alpha, beta, RC, and dev versions are marked as prereleases. Archived repositories are rejected, since GitHub does not allow releases on them. For stable, alpha, beta, and RC versions an existing tag or release stops the operation unless `--force` is explicitly supplied. A version ending in `-dev` is a rolling prerelease, including a build on the way to an alpha, beta or RC such as `1.1.0-a2-dev`. The first release creates it like any other release. Each later release moves the tag to the new commit, regenerates the release notes, and replaces the ZIP in the same GitHub release, so its URL stays the same. The new ZIP is uploaded under a temporary name and only replaces the old one after the upload is confirmed. The scripts print `Updated rolling release:` for this path and `Published release:` for a new release. A dry run shows what would change but never touches the tag, release, or asset. If the tag has to move, the dry-run notes still reflect where the tag points now. -The tag is reserved at the pinned commit before the release is created. A failure after tag creation may leave that tag without a release. The release stays a draft until its asset upload is confirmed. If uploading or publishing fails, the script reports the release URL and exits with an error. Inspect the remote state before retrying. For stable, alpha, beta, and RC versions a retry stops when it finds the existing tag or release. For a `-dev` version a retry reuses the leftover tag or draft release and finishes the update. A publication timeout may mean publication succeeded but could not be confirmed. Requests have finite timeouts and remote write requests are not retried automatically. +`--force` updates an existing release in place, preserving its URL, and moves its tag to the selected commit. Stable versions remain stable; alpha/beta/RC versions remain prereleases. It also handles a tag without a release or a release whose tag is missing. ZIP replacement uses the same staging/upload verification as rolling releases, and unrelated assets are preserved. A forced dry run only previews those changes. The flag does not bypass GitHub's tag protections or immutable-release policy. + +The tag is reserved at the pinned commit before the release is created. A failure after tag creation may leave that tag without a release. The release stays a draft until its asset upload is confirmed. If uploading or publishing fails, the script reports the release URL and exits with an error. Inspect the remote state before retrying. For stable, alpha, beta, and RC versions a retry stops when it finds the existing tag or release unless `--force` is supplied. For a `-dev` version a retry reuses the leftover tag or draft release and finishes the update. A publication timeout may mean publication succeeded but could not be confirmed. Requests have finite timeouts and remote write requests are not retried automatically. ### Waiting for a pull request's checks: `pr-wait.py` @@ -149,6 +157,7 @@ Contributions are welcome! ## Acknowledgments - Code review, bug fixes, and documentation assisted by [Claude](https://www.anthropic.com/claude). +- Release-script consolidation, regression tests, and documentation assisted by [Codex](https://openai.com/codex/). ## License diff --git a/git-extensions.sh b/git-extensions.sh index 838bd6c..f2ad484 100755 --- a/git-extensions.sh +++ b/git-extensions.sh @@ -1,46 +1,20 @@ #!/usr/bin/bash -# git-extensions.sh -# -# Build a phpBB extension release archive from the current Git commit and, -# optionally, publish it as a GitHub Release. -# -# Usage: -# git-extensions.sh --create -# git-extensions.sh --gh-release -# git-extensions.sh --gh-release --dry-run -# git-extensions.sh --help -# -# The archive is created from HEAD, so uncommitted changes are not included. -# Files marked export-ignore in .gitattributes are excluded. It can be run from -# anywhere inside the repository. -# -# The "name" (vendor/name) and "version" in the committed composer.json name -# the ZIP, and the version is the tag and release name. Alpha, beta, RC, and dev -# versions are published as prereleases. A version ending in -dev is a rolling -# prerelease: each --gh-release moves its tag to HEAD, regenerates its notes, -# and replaces its ZIP in the same release. Other versions are immutable, so an -# existing tag or release is an error. HEAD must already be pushed to GitHub. -# -# Output: -# ../git-exported/--.zip, with a // folder -# -# Requirements: -# --create: git, jq, zip -# --gh-release: also gh (authenticated) and timeout -# lib/github-release.sh next to this script (symlinking the script works). -# -# Exit status: 0 on success, 1 on failure, 2 on invalid usage. No ZIP is left -# behind on failure. +# Build a phpBB extension ZIP from local HEAD or the remote default branch. +# --create builds locally; --release publishes to GitHub; --dry-run previews it. +# --force permits replacing an existing tag/release, subject to GitHub policy. +# Requires the adjacent lib/ helpers; symlinking this entry point is supported. +# Use --help for package-specific options and requirements in README.md. +# Exit status: 0 success, 1 operational failure, 2 invalid usage. set -Eeuo pipefail -readonly GIT_EXPORT_DIR="../git-exported" - # Shared release logic lives next to the real script, even when it is run through a symlink. SCRIPT_DIR="$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" readonly SCRIPT_DIR # shellcheck source=lib/github-release.sh source "$SCRIPT_DIR/lib/github-release.sh" +# shellcheck source=lib/release-source.sh +source "$SCRIPT_DIR/lib/release-source.sh" MODE="" DRY_RUN=false @@ -72,33 +46,12 @@ cleanup() { } usage() { - cat <<'EOF' -Usage: - git-extensions.sh --create - git-extensions.sh --gh-release - git-extensions.sh --gh-release --dry-run - git-extensions.sh --help - -Options: - -C, --create Create the release ZIP locally. - -g, --gh-release Create the ZIP and publish it as a GitHub Release. - -n, --dry-run With --gh-release, validate and show what would be - published without making changes on GitHub. - -h, --help Show this help. - -The version in composer.json is the tag and release name. Alpha, beta, RC, -and dev versions are prereleases. A -dev version is a rolling prerelease: each ---gh-release moves its tag to HEAD and replaces its notes and ZIP in the same -release. Any other existing tag or release is an error. - -To publish the remote default branch instead of local HEAD, use -git-release-api.sh. - -Examples: - git-extensions.sh --create - git-extensions.sh --gh-release - git-extensions.sh --gh-release --dry-run -EOF + release_usage 'git-extensions.sh' + cat <<'HELP' + +The name (vendor/name) and version in composer.json name the ZIP and tag. +The ZIP contains vendor/name/. Files marked export-ignore are excluded. +HELP } require_commands() { @@ -117,13 +70,26 @@ parse_args() { while (($#)); do case "$1" in -C|--create) - [[ -z "$MODE" ]] || usage_error "only one of --create or --gh-release may be used" + [[ -z "$MODE" ]] || usage_error "only one of --create or --release may be used" MODE="create" ;; - -g|--gh-release) - [[ -z "$MODE" ]] || usage_error "only one of --create or --gh-release may be used" + -r|--release|-g|--gh-release) + [[ -z "$MODE" ]] || usage_error "only one of --create or --release may be used" MODE="gh-release" ;; + --remote) RELEASE_REMOTE=true ;; + -R|--repo) + release_option_value "$@" + RELEASE_REPO="$2" + shift + ;; + -o|--output|--output-dir) + release_option_value "$@" + RELEASE_OUTPUT="$2" + shift + ;; + -f|--force) GHR_FORCE=true ;; + -n|--dry-run) DRY_RUN=true ;; @@ -138,35 +104,22 @@ parse_args() { shift done - [[ -n "$MODE" ]] || usage_error "one of --create or --gh-release is required" + [[ -n "$MODE" ]] || usage_error "one of --create or --release is required" if [[ "$DRY_RUN" == true && "$MODE" != "gh-release" ]]; then - usage_error "--dry-run requires --gh-release" + usage_error "--dry-run requires --release" fi } # Work from the repository root, so the script also works from a subdirectory. prepare_repository() { - local repo_root - repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" \ - || die "the current directory is not inside a git repository" - cd "$repo_root" - - # Archive the commit itself; this also works on a detached HEAD and on branch names containing "/". - git rev-parse --verify -q HEAD >/dev/null || die "the repository has no commits" - COMMIT_HASH="$(git rev-parse HEAD)" - - if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then - if [[ "$MODE" == "gh-release" ]]; then - die "tracked files contain uncommitted changes" - fi - echo "Warning: uncommitted tracked changes are not included; only HEAD is packaged." >&2 - fi + release_prepare_source "$MODE" + COMMIT_HASH="$RELEASE_COMMIT" } # Read vendor, name, and version from the committed composer.json (the same commit that is archived). read_composer_metadata() { local composer_json package - composer_json="$(git show HEAD:composer.json 2>/dev/null)" \ + composer_json="$(git show "$COMMIT_HASH":composer.json 2>/dev/null)" \ || die "composer.json is not committed in the current commit" package="$(jq -r '.name // empty' <<<"$composer_json")" VERSION="$(jq -r '.version // empty' <<<"$composer_json")" @@ -185,13 +138,13 @@ create_archive() { local filename output_dir filename="$VENDOR-$RELEASE_NAME-$VERSION.zip" - mkdir -p "$GIT_EXPORT_DIR" || die "failed to create $GIT_EXPORT_DIR" - output_dir="$(cd "$GIT_EXPORT_DIR" && pwd)" || die "failed to resolve $GIT_EXPORT_DIR" + mkdir -p "$RELEASE_OUTPUT" || die "failed to create $RELEASE_OUTPUT" + output_dir="$(cd "$RELEASE_OUTPUT" && pwd)" || die "failed to resolve $RELEASE_OUTPUT" ARCHIVE="$output_dir/$filename" PARTIAL="$output_dir/.$filename.partial" - git archive --format=zip -9 --prefix="$VENDOR/$RELEASE_NAME/" --output="$PARTIAL" HEAD \ + git archive --format=zip -9 --prefix="$VENDOR/$RELEASE_NAME/" --output="$PARTIAL" "$COMMIT_HASH" \ || die "git archive failed" # git archive stores the commit hash as the ZIP archive comment. @@ -210,9 +163,9 @@ prepare_github() { require_commands gh timeout gh auth status >/dev/null 2>&1 || die "GitHub CLI is not authenticated; run: gh auth login" - GHR_SCRATCH="$(mktemp -d)" - ghr_resolve_repository "" - ghr_require_remote_commit "$COMMIT_HASH" + if [[ -z "$GHR_SCRATCH" ]]; then GHR_SCRATCH="$(mktemp -d)"; fi + if [[ -z "$GHR_REPOSITORY" ]]; then ghr_resolve_repository ""; fi + if [[ "$RELEASE_REMOTE" == false ]]; then ghr_require_remote_commit "$COMMIT_HASH"; fi ghr_check_existing "$VERSION" } @@ -240,9 +193,12 @@ create_github_release() { main() { parse_args "$@" + release_validate_options "$MODE" require_commands git jq zip trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM prepare_repository read_composer_metadata diff --git a/git-release-api.sh b/git-release-api.sh deleted file mode 100755 index e2e3f65..0000000 --- a/git-release-api.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/bash -# Package the remote default branch and publish a GitHub Release with generated release notes. -# Uses committed composer.json metadata and git archive's export-ignore rules. -# A -dev version is a rolling prerelease: each --release moves its tag to the -# remote default branch and updates the same release's notes and ZIP. Other -# versions are immutable. -# Requires git, gh (authenticated to github.com), jq, zip, and timeout. -# Usage: git-release-api.sh --dry-run|--release [--repo OWNER/REPO] [--output DIR] -# Exit status: 0 on success, 1 on operational failure, 2 on invalid arguments. - -set -Eeuo pipefail - -usage() { - cat <<'HELP' -Usage: git-release-api.sh --dry-run|--release [options] - - -n, --dry-run Build the ZIP and preview generated notes; do not publish. - -r, --release Create a release, upload the ZIP, then publish it - (or update the rolling -dev release). - -R, --repo OWNER/REPO GitHub repository (defaults to the current repository). - -o, --output DIR ZIP destination (defaults to ../git-exported). - -h, --help Show help. - -Uses the repository's default branch on GitHub (for example main or master), -not the local checkout. The composer.json version becomes -the tag and release name. Alpha, beta, RC, and dev versions are prereleases. - -Stable, alpha, beta, and RC releases are immutable: an existing release or tag -is rejected, and an upload/publication failure leaves the draft release for -inspection. - -A version ending in -dev, such as 1.1.0-dev or 1.1.0-a2-dev, is a rolling -prerelease. The first --release creates -it; each later --release moves its tag to the default branch, regenerates its notes, -and replaces its ZIP in the same release, keeping its URL. --dry-run never -changes the tag, release, or asset. -HELP -} - -fail() { echo "Error: $*" >&2; exit 1; } -argument_error() { echo "Error: $*" >&2; usage >&2; exit 2; } - -# Shared release logic lives next to the real script, even when it is run through a symlink. -script_dir=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")") -# shellcheck source=lib/github-release.sh -source "$script_dir/lib/github-release.sh" - -operation='' -repository='' -output_dir='' -if (($# == 0)); then usage; exit 0; fi -while (($#)); do - case "$1" in - -n|--dry-run|-r|--release) - [[ -z "$operation" ]] || argument_error 'Choose exactly one operation.' - operation="$1" - shift - ;; - -R|--repo|-o|--output) - if (($# < 2)); then argument_error "Missing value for $1."; fi - if [[ -z "$2" || "$2" == -* ]]; then argument_error "Missing value for $1."; fi - if [[ "$1" == -R || "$1" == --repo ]]; then repository="$2"; else output_dir="$2"; fi - shift 2 - ;; - -h|--help) usage; exit 0 ;; - *) argument_error "Unknown option: $1" ;; - esac -done -[[ -n "$operation" ]] || argument_error 'Choose --dry-run or --release.' -for dependency in git gh jq zip timeout; do - command -v "$dependency" >/dev/null || fail "Required command not found: $dependency" -done -if [[ -z "$repository" ]]; then - repository=$(timeout 60 gh repo view --json nameWithOwner --jq .nameWithOwner) || fail 'Cannot determine the GitHub repository; use --repo OWNER/REPO.' -fi -[[ "$repository" =~ ^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || argument_error 'Repository must be OWNER/REPO on github.com.' -if [[ -z "$output_dir" ]]; then - repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || repo_root="$PWD" - output_dir="$repo_root/../git-exported" -fi - -GHR_SCRATCH=$(mktemp -d) -scratch="$GHR_SCRATCH" -partial='' -cleanup() { - local status=$? - [[ -z "$partial" ]] || rm -f -- "$partial" - rm -rf -- "$scratch" - ghr_report_incomplete - return "$status" -} -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - -ghr_resolve_repository "$repository" -repository="$GHR_REPOSITORY" -clone_url=$(jq -er '.clone_url' "$scratch/repository.json") -[[ "$clone_url" == "https://github.com/$repository.git" ]] || fail 'Unexpected GitHub clone URL.' -branch=$(jq -er '.default_branch | select(type == "string")' "$scratch/repository.json") -# Reject anything git would not accept as a branch name before using it in a URL. -git check-ref-format --branch "$branch" >/dev/null 2>&1 || fail 'Unexpected default branch name.' -[[ "$branch" =~ ^[A-Za-z0-9._/-]+$ ]] || fail 'Unexpected default branch name.' -ghr_api "repos/$repository/commits/$branch" >"$scratch/commit.json" -commit=$(jq -er '.sha | select(test("^[0-9a-f]{40}$"))' "$scratch/commit.json") - -# Isolate the remote snapshot so packaging cannot alter the user's checkout. -git init -q "$scratch/source" -timeout 120 git -C "$scratch/source" -c credential.helper= \ - -c 'credential.helper=!gh auth git-credential' fetch -q --depth=1 "$clone_url" "$commit" -[[ "$(git -C "$scratch/source" rev-parse FETCH_HEAD)" == "$commit" ]] || fail "Fetched commit does not match remote $branch." -git -C "$scratch/source" show "$commit:composer.json" >"$scratch/composer.json" -package=$(jq -er '.name | select(type == "string")' "$scratch/composer.json") -version=$(jq -er '.version | select(type == "string")' "$scratch/composer.json") -[[ "$package" =~ ^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$ ]] || fail 'composer.json needs a safe vendor/name package name.' -# An alpha, beta or RC may end in -dev (1.1.0-a2-dev): a rolling build on the way to it. -[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(dev|(a(lpha)?|b(eta)?|[Rr][Cc])[0-9]*(-dev)?|p(atch)?[0-9]*))?$ ]] || fail 'composer.json has an unsupported version.' -vendor=${package%%/*} -releasename=${package#*/} -filename="$vendor-$releasename-$version.zip" -ghr_check_existing "$version" - -mkdir -p -- "$output_dir" -output_dir=$(cd "$output_dir" && pwd) -partial=$(mktemp "$output_dir/.$filename.XXXXXX.partial") -git -C "$scratch/source" archive --format zip -9 --prefix "$vendor/$releasename/" --output "$partial" "$commit" -zip -q -z "$partial" _.zip containing a _/ folder, -# and holds only ext/, language/ and styles/ (so repository files such as the -# root README.md and SECURITY.md are left out). Files marked export-ignore in -# .gitattributes are also left out. -# -# Inputs (run from anywhere inside the language pack's git repository): -# The committed tree of HEAD. Uncommitted changes are not included. -# language//iso.txt supplies the English language name (line 1). -# The phpBB version defaults to the current stable release of the chosen -# line, read from phpBB's update-check feed; -v overrides it. -# -# Outputs: -# /_.zip (with --create or --gh-release) -# A validator report on stdout (with --check) -# A GitHub Release named after the phpBB version, with the zip attached -# (with --gh-release). HEAD must already be pushed to GitHub. A version -# ending in -dev is a rolling prerelease updated in place on each run; any -# other existing tag or release is an error. -# -# Exit status: 0 on success, 1 on failure (including failed validation), -# 2 on invalid usage. -# -# Requirements: git, curl, jq, unzip, zip. For --check also: php, composer, -# sha256sum. For --gh-release also: gh (authenticated), timeout. -# lib/github-release.sh must sit next to this script (symlinking the script works). +# Build a phpBB language pack ZIP from local HEAD or the remote default branch. +# --create builds locally; --release publishes to GitHub; --dry-run previews it. +# --force permits replacing an existing tag/release, subject to GitHub policy. +# Requires the adjacent lib/ helpers; symlinking this entry point is supported. +# Use --help for package-specific options and requirements in README.md. +# Exit status: 0 success, 1 operational failure, 2 invalid usage. set -Eeuo pipefail @@ -55,6 +23,8 @@ SCRIPT_DIR="$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" readonly SCRIPT_DIR # shellcheck source=lib/github-release.sh source "$SCRIPT_DIR/lib/github-release.sh" +# shellcheck source=lib/release-source.sh +source "$SCRIPT_DIR/lib/release-source.sh" mode="" dry_run=0 @@ -62,57 +32,30 @@ do_check=0 GHR_SCRATCH="" opt_version="" opt_line="" -opt_output_dir="" work_dir="" usage() { - cat <<'EOF' -Usage: - git-release-langpack.sh --create [--check] - git-release-langpack.sh --gh-release [--check] [--dry-run] - git-release-langpack.sh --check - git-release-langpack.sh --help - -Build a phpBB language pack release zip from the current git commit. -Choose --create, --gh-release, or --check (which can be combined with either). - -Operations: - -C, --create Build _.zip in the output - directory. - -g, --gh-release Build the zip and publish it as a GitHub Release named - after the phpBB version. A -dev version is a rolling - prerelease updated in place; any other existing tag - or release is an error. - -c, --check Validate the pack with the phpBB Translation Validator - (3.3 line only). Combined with --create or - --gh-release, nothing is written or published unless - validation passes. - -n, --dry-run With --gh-release, show what would be published - without making changes on GitHub. - -Options: - -v, --version VERSION phpBB version to name and validate against, for - example 3.3.17. Default: current stable release of - the line, from phpBB's version feed. - -l, --line LINE phpBB release line used to look up the stable - version, for example 3.3. Default: derived from - --version if given, otherwise 3.3. - -o, --output-dir DIR Directory for the zip. Default: ../git-exported - relative to the repository root. - -h, --help Show this help and exit. - -Validation reference files (the official phpBB release zip, verified against -its published SHA-256) and the validator are cached in -$XDG_CACHE_HOME/git-release-langpack (default ~/.cache/git-release-langpack). -Delete that directory to force a fresh download. + release_usage 'git-release-langpack.sh' + cat <<'HELP' + +Language-pack options: + -c, --check Validate the exact ZIP contents (phpBB 3.3 only). + Can run alone, or with --create/--release. + -v, --version VERSION phpBB version to name and validate against. + -l, --line LINE Release line for version lookup (default 3.3). + +Version defaults to the current stable release from phpBB's version feed. +Only ext/, language/, and styles/ are packaged, honoring export-ignore. +Validation must pass before a ZIP is saved or anything is published. +Official reference ZIPs are SHA-256 checked. References and the validator are +cached under $XDG_CACHE_HOME/git-release-langpack (default ~/.cache/git-release-langpack). Examples: git-release-langpack.sh --check - git-release-langpack.sh --create --check - git-release-langpack.sh --create --version 3.3.16 - git-release-langpack.sh --gh-release --check --dry-run -EOF + git-release-langpack.sh --create --check --remote + git-release-langpack.sh --release --remote --check --dry-run +HELP } die() { @@ -150,26 +93,32 @@ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - -C|--create|-g|--gh-release) - [[ -z "$mode" ]] || usage_error "only one of --create or --gh-release may be used" + -C|--create|-r|--release|-g|--gh-release) + [[ -z "$mode" ]] || usage_error "only one of --create or --release may be used" case "$1" in -C|--create) mode="create" ;; *) mode="gh-release" ;; esac ;; - -r|--release) usage_error "--release was renamed to --create" ;; + --remote) RELEASE_REMOTE=true ;; + -R|--repo) + release_option_value "$@" + RELEASE_REPO="$2" + shift + ;; + -f|--force) GHR_FORCE=true ;; -c|--check) do_check=1 ;; -n|--dry-run) dry_run=1 ;; -v|--version) - [[ $# -ge 2 ]] || usage_error "$1 needs a value" + release_option_value "$@" opt_version="$2" shift ;; -l|--line) - [[ $# -ge 2 ]] || usage_error "$1 needs a value" + release_option_value "$@" opt_line="$2" shift ;; - -o|--output-dir) - [[ $# -ge 2 ]] || usage_error "$1 needs a value" - opt_output_dir="$2" + -o|--output|--output-dir) + release_option_value "$@" + RELEASE_OUTPUT="$2" shift ;; -h|--help) @@ -182,10 +131,10 @@ parse_args() { done if [[ -z "$mode" && $do_check -eq 0 ]]; then - usage_error "choose an operation: --create, --gh-release, or --check" + usage_error "choose an operation: --create, --release, or --check" fi if [[ $dry_run -eq 1 && "$mode" != "gh-release" ]]; then - usage_error "--dry-run requires --gh-release" + usage_error "--dry-run requires --release" fi if [[ -n "$opt_version" && ! "$opt_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.-]*$ ]]; then usage_error "--version must look like 3.3.17, got: $opt_version" @@ -226,7 +175,7 @@ fetch_stable_version() { # Requires exactly one committed language//iso.txt. detect_iso() { local -a iso_files - mapfile -t iso_files < <(git ls-tree -r --name-only HEAD -- language \ + mapfile -t iso_files < <(git ls-tree -r --name-only "$RELEASE_COMMIT" -- language \ | grep -E '^language/[^/]+/iso\.txt$' || true) [[ ${#iso_files[@]} -eq 1 ]] \ || die "expected exactly one committed language//iso.txt, found ${#iso_files[@]}" @@ -238,7 +187,7 @@ detect_iso() { # (English language name), lowercased, with spaces and hyphens as underscores. package_name() { local iso="$1" english_name slug - english_name="$(git show "HEAD:language/$iso/iso.txt" | head -n 1 | tr -d '\r')" + english_name="$(git show "$RELEASE_COMMIT:language/$iso/iso.txt" | head -n 1 | tr -d '\r')" slug="$(tr '[:upper:]' '[:lower:]' <<<"$english_name" \ | tr ' -' '__' | tr -cd 'a-z0-9_')" [[ -n "$slug" ]] || die "language/$iso/iso.txt line 1 gives an empty package name" @@ -251,7 +200,7 @@ build_zip() { local zip_file="$1" folder="$2" dir local -a present=() for dir in "${PACK_DIRS[@]}"; do - if git ls-tree --name-only HEAD -- "$dir" | grep -qx "$dir"; then + if git ls-tree --name-only "$RELEASE_COMMIT" -- "$dir" | grep -qx "$dir"; then present+=("$dir") else echo "Warning: $dir/ is not in the repository; the pack will not contain it." >&2 @@ -259,7 +208,7 @@ build_zip() { done [[ ${#present[@]} -gt 0 ]] || die "none of ${PACK_DIRS[*]} found in the repository" - git archive --format=zip -9 --prefix="$folder/" --output="$zip_file" HEAD -- "${present[@]}" + git archive --format=zip -9 --prefix="$folder/" --output="$zip_file" "$RELEASE_COMMIT" -- "${present[@]}" # git archive stores the commit hash as the zip comment; clear it. zip -q -z "$zip_file" /dev/null 2>&1 || die "GitHub CLI is not authenticated; run: gh auth login" fi - local repo_root - repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" \ - || die "the current directory is not inside a git repository" - cd "$repo_root" - git rev-parse --verify -q HEAD >/dev/null || die "the repository has no commits" - if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then - [[ "$mode" != "gh-release" ]] || die "tracked files contain uncommitted changes" - echo "Warning: uncommitted changes are not included; only HEAD is packaged." >&2 - fi - local commit - commit="$(git rev-parse HEAD)" + work_dir="$(mktemp -d)" + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + release_prepare_source "$mode" + local commit="$RELEASE_COMMIT" local line version iso name folder line="$(resolve_line)" @@ -406,15 +351,12 @@ main() { name="$(package_name "$iso")" folder="${name}_${version}" - work_dir="$(mktemp -d)" - trap cleanup EXIT - # Check GitHub before building: the repository, the pushed commit, and any # existing release or tag for this version. if [[ "$mode" == "gh-release" ]]; then - GHR_SCRATCH="$(mktemp -d)" - ghr_resolve_repository "" - ghr_require_remote_commit "$commit" + if [[ -z "$GHR_SCRATCH" ]]; then GHR_SCRATCH="$(mktemp -d)"; fi + if [[ -z "$GHR_REPOSITORY" ]]; then ghr_resolve_repository ""; fi + if [[ "$RELEASE_REMOTE" == false ]]; then ghr_require_remote_commit "$commit"; fi ghr_check_existing "$version" fi @@ -430,7 +372,7 @@ main() { fi if [[ -n "$mode" ]]; then - local out_dir="${opt_output_dir:-$repo_root/../git-exported}" + local out_dir="$RELEASE_OUTPUT" mkdir -p "$out_dir" out_dir="$(cd "$out_dir" && pwd)" mv "$zip_file" "$out_dir/$folder.zip.partial" diff --git a/git-release-style.sh b/git-release-style.sh index 0fa8bf9..410ab10 100755 --- a/git-release-style.sh +++ b/git-release-style.sh @@ -1,58 +1,20 @@ #!/usr/bin/bash -# git-release-style.sh -# -# Build a phpBB style release archive from the current Git commit and, -# optionally, publish it as a GitHub Release. -# -# Usage: -# git-release-style.sh --create -# git-release-style.sh --gh-release -# git-release-style.sh --gh-release --dry-run -# git-release-style.sh --help -# -# Modes: -# --create -# Create the release ZIP locally. -# -# --gh-release -# Create the release ZIP and publish it as a GitHub Release. -# -# --gh-release --dry-run -# Create the release ZIP and validate the GitHub release operation, -# but make no changes on GitHub. -# -# The style_version from style.cfg is the tag and release name. Alpha, beta, -# RC, and dev versions are published as prereleases. A version ending in -dev -# is a rolling prerelease: each --gh-release moves its tag to HEAD, regenerates -# its notes, and replaces its ZIP in the same release. Other versions are -# immutable, so an existing tag or release is an error. HEAD must already be -# pushed to GitHub. -# -# The archive is created from HEAD, so uncommitted changes are not included. -# Files marked export-ignore in .gitattributes are excluded. -# -# Output: -# ../git-exported/_.zip -# -# Requirements: -# --create: git, zip -# --gh-release: git, zip, gh (authenticated), jq, timeout -# lib/github-release.sh next to this script (symlinking the script works). -# -# Exit status: -# 0 on success -# 1 on failure -# 2 on invalid usage +# Build a phpBB style ZIP from local HEAD or the remote default branch. +# --create builds locally; --release publishes to GitHub; --dry-run previews it. +# --force permits replacing an existing tag/release, subject to GitHub policy. +# Requires the adjacent lib/ helpers; symlinking this entry point is supported. +# Use --help for package-specific options and requirements in README.md. +# Exit status: 0 success, 1 operational failure, 2 invalid usage. set -Eeuo pipefail -readonly GIT_EXPORT_DIR="../git-exported" - # Shared release logic lives next to the real script, even when it is run through a symlink. SCRIPT_DIR="$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" readonly SCRIPT_DIR # shellcheck source=lib/github-release.sh source "$SCRIPT_DIR/lib/github-release.sh" +# shellcheck source=lib/release-source.sh +source "$SCRIPT_DIR/lib/release-source.sh" MODE="" DRY_RUN=false @@ -83,30 +45,12 @@ usage_error() { } usage() { - cat <<'EOF' -Usage: - git-release-style.sh --create - git-release-style.sh --gh-release - git-release-style.sh --gh-release --dry-run - git-release-style.sh --help - -Options: - -C, --create Create the release ZIP locally. - -g, --gh-release Create the ZIP and publish it as a GitHub Release. - -n, --dry-run With --gh-release, validate and show what would be - published without making changes on GitHub. - -h, --help Show this help. - -The style_version in style.cfg is the tag and release name. Alpha, beta, RC, -and dev versions are prereleases. A -dev version is a rolling prerelease: each ---gh-release moves its tag to HEAD and replaces its notes and ZIP in the same -release. Any other existing tag or release is an error. - -Examples: - git-release-style.sh --create - git-release-style.sh --gh-release - git-release-style.sh --gh-release --dry-run -EOF + release_usage 'git-release-style.sh' + cat <<'HELP' + +The name and style_version in root style.cfg name the ZIP and tag. +The ZIP contains the style name folder. export-ignore rules are honored. +HELP } require_commands() { @@ -128,16 +72,29 @@ parse_args() { case "$1" in -C|--create) [[ -z "$MODE" ]] \ - || usage_error "only one of --create or --gh-release may be used" + || usage_error "only one of --create or --release may be used" MODE="create" ;; - -g|--gh-release) + -r|--release|-g|--gh-release) [[ -z "$MODE" ]] \ - || usage_error "only one of --create or --gh-release may be used" + || usage_error "only one of --create or --release may be used" MODE="gh-release" ;; + --remote) RELEASE_REMOTE=true ;; + -R|--repo) + release_option_value "$@" + RELEASE_REPO="$2" + shift + ;; + -o|--output|--output-dir) + release_option_value "$@" + RELEASE_OUTPUT="$2" + shift + ;; + -f|--force) GHR_FORCE=true ;; + -n|--dry-run) DRY_RUN=true ;; @@ -156,10 +113,10 @@ parse_args() { done [[ -n "$MODE" ]] \ - || usage_error "one of --create or --gh-release is required" + || usage_error "one of --create or --release is required" if [[ "$DRY_RUN" == true && "$MODE" != "gh-release" ]]; then - usage_error "--dry-run requires --gh-release" + usage_error "--dry-run requires --release" fi } @@ -195,33 +152,14 @@ read_style_cfg_value() { } prepare_repository() { - local repo_root - - repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" \ - || die "the current directory is not inside a git repository" - - cd "$repo_root" - - git rev-parse --verify -q HEAD >/dev/null \ - || die "the repository has no commits" - - COMMIT_HASH="$(git rev-parse HEAD)" - - if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then - if [[ "$MODE" == "gh-release" ]]; then - die "tracked files contain uncommitted changes" - fi - - echo \ - "Warning: uncommitted tracked changes are not included; only HEAD is packaged." \ - >&2 - fi + release_prepare_source "$MODE" + COMMIT_HASH="$RELEASE_COMMIT" } read_style_metadata() { local style_cfg - style_cfg="$(git show HEAD:style.cfg 2>/dev/null)" \ + style_cfg="$(git show "$COMMIT_HASH":style.cfg 2>/dev/null)" \ || die "style.cfg is not committed at the repository root" STYLE_NAME="$(read_style_cfg_value "$style_cfg" "name")" \ @@ -248,11 +186,11 @@ create_archive() { filename="${STYLE_NAME}_${STYLE_VERSION}.zip" - mkdir -p "$GIT_EXPORT_DIR" \ - || die "failed to create $GIT_EXPORT_DIR" + mkdir -p "$RELEASE_OUTPUT" \ + || die "failed to create $RELEASE_OUTPUT" - output_dir="$(cd "$GIT_EXPORT_DIR" && pwd)" \ - || die "failed to resolve $GIT_EXPORT_DIR" + output_dir="$(cd "$RELEASE_OUTPUT" && pwd)" \ + || die "failed to resolve $RELEASE_OUTPUT" ARCHIVE="$output_dir/$filename" PARTIAL="$output_dir/.$filename.partial" @@ -262,7 +200,7 @@ create_archive() { -9 \ --prefix="$STYLE_NAME/" \ --output="$PARTIAL" \ - HEAD; then + "$COMMIT_HASH"; then die "git archive failed" fi @@ -286,10 +224,10 @@ prepare_github() { gh auth status >/dev/null 2>&1 \ || die "GitHub CLI is not authenticated; run: gh auth login" - GHR_SCRATCH="$(mktemp -d)" + if [[ -z "$GHR_SCRATCH" ]]; then GHR_SCRATCH="$(mktemp -d)"; fi - ghr_resolve_repository "" - ghr_require_remote_commit "$COMMIT_HASH" + if [[ -z "$GHR_REPOSITORY" ]]; then ghr_resolve_repository ""; fi + if [[ "$RELEASE_REMOTE" == false ]]; then ghr_require_remote_commit "$COMMIT_HASH"; fi ghr_check_existing "$STYLE_VERSION" } @@ -323,9 +261,12 @@ create_github_release() { main() { parse_args "$@" + release_validate_options "$MODE" require_commands git zip trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM prepare_repository read_style_metadata diff --git a/lib/github-release.sh b/lib/github-release.sh index dae6ca2..4b60682 100644 --- a/lib/github-release.sh +++ b/lib/github-release.sh @@ -4,8 +4,9 @@ # # A version ending in -dev is a rolling prerelease: publishing it again moves its # tag to the new commit, regenerates the notes, and replaces the ZIP in the same -# release, keeping its URL. Other versions are immutable: an existing tag or -# release is an error. Alpha, beta, RC, and dev versions are marked as prereleases. +# release, keeping its URL. Other versions require explicit --force to replace +# an existing tag/release. GitHub-enforced immutability still cannot be overridden. +# Alpha, beta, RC, and dev versions are marked as prereleases. # # The caller sets GHR_SCRATCH to a private temporary directory and removes it. # Typical use: @@ -21,6 +22,7 @@ GHR_REPOSITORY='' GHR_PRERELEASE=false GHR_ROLLING=false +GHR_FORCE=false GHR_EXISTING_RELEASE_ID='' GHR_EXISTING_TAG_SHA='' GHR_RELEASE_URL='' @@ -55,7 +57,7 @@ ghr_require_remote_commit() { } # Classify the version and look up an existing release and tag. Only a rolling -# -dev version may already have them. +# -dev version or an explicitly forced release may already have them. ghr_check_existing() { local version="$1" GHR_PRERELEASE=false @@ -68,17 +70,22 @@ ghr_check_existing() { jq -e -s 'all(.[]; type == "array")' "$GHR_SCRATCH/releases.json" >/dev/null || ghr_fail 'Invalid release-list response.' GHR_EXISTING_RELEASE_ID='' if jq -e -s --arg tag "$version" 'any(.[][]; .tag_name == $tag)' "$GHR_SCRATCH/releases.json" >/dev/null; then - [[ "$GHR_ROLLING" == true ]] || ghr_fail "Release $version already exists; inspect it before retrying." + [[ "$GHR_ROLLING" == true || "$GHR_FORCE" == true ]] \ + || ghr_fail "Release $version already exists; use --force to replace it." # A rolling release is updated in place, so it must be unambiguous. GHR_EXISTING_RELEASE_ID=$(jq -er -s --arg tag "$version" \ '[.[][] | select(.tag_name == $tag)] | select(length == 1) | .[0].id | select(type == "number" and . > 0) | tostring' \ "$GHR_SCRATCH/releases.json") || ghr_fail "Expected exactly one release for $version; inspect the releases before retrying." + if jq -e -s --arg tag "$version" 'any(.[][]; .tag_name == $tag and .immutable == true)' "$GHR_SCRATCH/releases.json" >/dev/null; then + ghr_fail "GitHub marks release $version immutable; --force cannot override that protection." + fi fi ghr_api "repos/$GHR_REPOSITORY/git/matching-refs/tags/$version" >"$GHR_SCRATCH/tags.json" jq -e 'type == "array"' "$GHR_SCRATCH/tags.json" >/dev/null || ghr_fail 'Invalid tag-list response.' GHR_EXISTING_TAG_SHA='' if jq -e --arg ref "refs/tags/$version" 'any(.[]; .ref == $ref)' "$GHR_SCRATCH/tags.json" >/dev/null; then - [[ "$GHR_ROLLING" == true ]] || ghr_fail "Tag $version already exists; change the version before publishing another release." + [[ "$GHR_ROLLING" == true || "$GHR_FORCE" == true ]] \ + || ghr_fail "Tag $version already exists; use --force to replace it." # An annotated tag reports its tag object, which never equals the commit and so counts as moved. GHR_EXISTING_TAG_SHA=$(jq -er --arg ref "refs/tags/$version" \ '.[] | select(.ref == $ref) | .object.sha | select(test("^[0-9a-f]{40}$"))' "$GHR_SCRATCH/tags.json") || ghr_fail 'Invalid tag response.' @@ -98,7 +105,11 @@ ghr_preview() { local version="$1" commit="$2" echo "Release: $version (prerelease: $GHR_PRERELEASE)" if [[ -n "$GHR_EXISTING_RELEASE_ID" ]]; then - echo "Would update rolling release $version in place." + if [[ "$GHR_ROLLING" == true ]]; then + echo "Would update rolling release $version in place." + else + echo "Would replace existing release $version in place (--force)." + fi elif [[ "$GHR_ROLLING" == true ]]; then echo "Would create rolling release $version." fi @@ -120,14 +131,14 @@ ghr_upload_asset() { '.name == $name and .state == "uploaded" and .size == $size' "$GHR_SCRATCH/asset.json" >/dev/null || ghr_fail 'Upload response does not match the package.' } -# Publish the ZIP: create a new release, or update the rolling -dev release. +# Publish the ZIP: create a new release, or update a rolling/forced release. ghr_publish() { local version="$1" commit="$2" zip="$3" local filename filename=$(basename -- "$zip") # Rolling -dev: point the tag at the pinned commit, creating it if missing. - if [[ "$GHR_ROLLING" == true && ( -n "$GHR_EXISTING_TAG_SHA" || -n "$GHR_EXISTING_RELEASE_ID" ) ]]; then + if [[ ( "$GHR_ROLLING" == true || "$GHR_FORCE" == true ) && ( -n "$GHR_EXISTING_TAG_SHA" || -n "$GHR_EXISTING_RELEASE_ID" ) ]]; then if [[ -n "$GHR_EXISTING_RELEASE_ID" ]]; then GHR_RELEASE_URL=$(jq -er -s --arg tag "$version" '[.[][] | select(.tag_name == $tag)][0].html_url' "$GHR_SCRATCH/releases.json") fi @@ -143,7 +154,7 @@ ghr_publish() { fi if [[ -n "$GHR_EXISTING_RELEASE_ID" ]]; then - ghr_update_rolling "$version" "$commit" "$zip" "$filename" + ghr_update_release "$version" "$commit" "$zip" "$filename" return fi @@ -172,16 +183,20 @@ ghr_publish() { echo "Published release: $GHR_RELEASE_URL" } -# Update the existing rolling release in place: notes, then the ZIP, then publish. -ghr_update_rolling() { +# Update an existing release in place: notes, then the ZIP, then publish. +ghr_update_release() { local version="$1" commit="$2" zip="$3" filename="$4" local release_id="$GHR_EXISTING_RELEASE_ID" staging new_asset_id stale_id old_id - echo "Updating rolling release: $GHR_RELEASE_URL" + local kind='existing' + if [[ "$GHR_ROLLING" == true ]]; then kind='rolling'; fi + echo "Updating $kind release: $GHR_RELEASE_URL" # Draft state is left alone here; publication happens only after the new ZIP is in place. jq -n --arg tag "$version" --arg commit "$commit" --rawfile body "$GHR_SCRATCH/notes.md" \ - '{tag_name:$tag, target_commitish:$commit, name:$tag, body:$body, prerelease:true}' >"$GHR_SCRATCH/update.json" + --argjson prerelease "$GHR_PRERELEASE" \ + '{tag_name:$tag, target_commitish:$commit, name:$tag, body:$body, prerelease:$prerelease}' >"$GHR_SCRATCH/update.json" ghr_api "repos/$GHR_REPOSITORY/releases/$release_id" --method PATCH --input "$GHR_SCRATCH/update.json" >"$GHR_SCRATCH/release.json" - jq -e --arg tag "$version" '.tag_name == $tag and .prerelease == true' "$GHR_SCRATCH/release.json" >/dev/null || ghr_fail 'Release update was not confirmed.' + jq -e --arg tag "$version" --argjson prerelease "$GHR_PRERELEASE" \ + '.tag_name == $tag and .prerelease == $prerelease' "$GHR_SCRATCH/release.json" >/dev/null || ghr_fail 'Release update was not confirmed.' # Upload under a staging name first so the old ZIP stays available until the new one is confirmed. staging="$filename.new" @@ -200,9 +215,9 @@ ghr_update_rolling() { jq -e --arg name "$filename" '.name == $name' "$GHR_SCRATCH/renamed.json" >/dev/null || ghr_fail 'Asset rename was not confirmed.' ghr_api "repos/$GHR_REPOSITORY/releases/$release_id" --method PATCH -F draft=false >"$GHR_SCRATCH/published.json" - jq -e '.draft == false and .prerelease == true' "$GHR_SCRATCH/published.json" >/dev/null || ghr_fail 'Publication was not confirmed.' + jq -e --argjson prerelease "$GHR_PRERELEASE" '.draft == false and .prerelease == $prerelease' "$GHR_SCRATCH/published.json" >/dev/null || ghr_fail 'Publication was not confirmed.' GHR_COMPLETED=true - echo "Updated rolling release: $GHR_RELEASE_URL" + echo "Updated $kind release: $GHR_RELEASE_URL" } # For an EXIT trap: name a release that a failed run left in an unknown state. diff --git a/lib/release-source.sh b/lib/release-source.sh new file mode 100644 index 0000000..4ddcb0c --- /dev/null +++ b/lib/release-source.sh @@ -0,0 +1,98 @@ +# shellcheck shell=bash +# Shared source selection for the three package release scripts. +# Call release_prepare_source with create/gh-release (or empty for validation). +# It pins RELEASE_COMMIT, sets RELEASE_OUTPUT, and changes into the source repo. +# Remote snapshots live under GHR_SCRATCH, removed by the caller's EXIT trap. + +RELEASE_REMOTE=false +RELEASE_REPO='' +RELEASE_OUTPUT='' +RELEASE_COMMIT='' + +release_usage() { + cat <&2 + exit 2 + fi +} + +release_validate_options() { + local mode="$1" + if [[ -n "$RELEASE_REPO" && "$RELEASE_REMOTE" != true ]]; then + echo 'Error: --repo requires --remote' >&2 + exit 2 + fi + if [[ "$GHR_FORCE" == true && "$mode" != gh-release ]]; then + echo 'Error: --force requires --release' >&2 + exit 2 + fi +} + +release_prepare_source() { + local mode="$1" base clone_url branch command + base=$(git rev-parse --show-toplevel 2>/dev/null) || base="$PWD" + if [[ -z "$RELEASE_OUTPUT" ]]; then + RELEASE_OUTPUT="$base/../git-exported" + elif [[ "$RELEASE_OUTPUT" != /* ]]; then + RELEASE_OUTPUT="$base/$RELEASE_OUTPUT" + fi + + if [[ "$RELEASE_REMOTE" == false ]]; then + git rev-parse --show-toplevel >/dev/null 2>&1 \ + || ghr_fail 'the current directory is not inside a git repository' + cd "$base" || ghr_fail 'cannot enter the repository root' + RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}') || ghr_fail 'the repository has no commits' + if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + [[ "$mode" != gh-release ]] || ghr_fail 'tracked files contain uncommitted changes' + echo 'Warning: uncommitted tracked changes are not included; only HEAD is packaged.' >&2 + fi + return + fi + + for command in gh jq timeout; do + command -v "$command" >/dev/null || ghr_fail "required command not found: $command" + done + gh auth status >/dev/null 2>&1 || ghr_fail 'GitHub CLI is not authenticated; run: gh auth login' + GHR_SCRATCH=$(mktemp -d) + ghr_resolve_repository "$RELEASE_REPO" + clone_url=$(jq -er '.clone_url' "$GHR_SCRATCH/repository.json") + [[ "$clone_url" == "https://github.com/$GHR_REPOSITORY.git" ]] || ghr_fail 'Unexpected GitHub clone URL.' + branch=$(jq -er '.default_branch | select(type == "string")' "$GHR_SCRATCH/repository.json") + git check-ref-format --branch "$branch" >/dev/null 2>&1 || ghr_fail 'Unexpected default branch name.' + [[ "$branch" =~ ^[A-Za-z0-9._/-]+$ ]] || ghr_fail 'Unexpected default branch name.' + ghr_api "repos/$GHR_REPOSITORY/commits/$branch" >"$GHR_SCRATCH/commit.json" + RELEASE_COMMIT=$(jq -er '.sha | select(test("^[0-9a-f]{40}$"))' "$GHR_SCRATCH/commit.json") + + git init -q "$GHR_SCRATCH/source" + timeout 120 git -C "$GHR_SCRATCH/source" -c credential.helper= \ + -c 'credential.helper=!gh auth git-credential' fetch -q --depth=1 "$clone_url" "$RELEASE_COMMIT" + [[ "$(git -C "$GHR_SCRATCH/source" rev-parse FETCH_HEAD)" == "$RELEASE_COMMIT" ]] \ + || ghr_fail 'Fetched commit does not match the pinned remote commit.' + # Only Git's object database is needed for metadata and git archive. + git -C "$GHR_SCRATCH/source" update-ref HEAD "$RELEASE_COMMIT" + cd "$GHR_SCRATCH/source" || ghr_fail 'cannot enter the remote snapshot' + echo "Source: $GHR_REPOSITORY $branch at $RELEASE_COMMIT" +} diff --git a/tests/test-release-api.sh b/tests/test-release-api.sh index ca374ec..87a57c4 100755 --- a/tests/test-release-api.sh +++ b/tests/test-release-api.sh @@ -1,6 +1,6 @@ #!/usr/bin/bash # Offline release workflow checks using a real Git fixture and a mocked GitHub API. -# Covers git-release-api.sh and the --gh-release mode of the local release scripts. +# Covers local/remote sources, publication, rolling releases, and forced replacement. # Usage: tests/test-release-api.sh set -Eeuo pipefail @@ -32,6 +32,11 @@ printf '%s\n' '{"name":"acme/ext","version":"1.2.3-a2-dev"}' >"$FIXTURE_ROOT/sou git -C "$FIXTURE_ROOT/source" -c user.name='William Jacoby' -c user.email=bonelifer@gmail.com commit -qam alpha-dev ALPHA_DEV_COMMIT=$(git -C "$FIXTURE_ROOT/source" rev-parse HEAD) export ALPHA_DEV_COMMIT +git -C "$FIXTURE_ROOT/source" checkout -qb stable main +printf '%s\n' '{"name":"acme/ext","version":"1.2.3"}' >"$FIXTURE_ROOT/source/composer.json" +git -C "$FIXTURE_ROOT/source" -c user.name=test -c user.email=test@example.com commit -qam stable +STABLE_COMMIT=$(git -C "$FIXTURE_ROOT/source" rev-parse HEAD) +export STABLE_COMMIT git -C "$FIXTURE_ROOT/source" checkout -q main # A dirty local version must never change the remotely committed package version. printf '%s\n' '{"name":"acme/ext","version":"9.9.9"}' >"$FIXTURE_ROOT/source/composer.json" @@ -65,6 +70,49 @@ for value in "$@"; do done echo "$endpoint ${input:+with-input}" >>"$FIXTURE_ROOT/api.log" echo "$method $endpoint" >>"$FIXTURE_ROOT/methods.log" +# Forced-release fixtures use a stable version unless a package case overrides it. +if [[ "${SCENARIO:-}" == force_* ]]; then + version="${FORCE_VERSION:-1.2.3}" + asset="${FORCE_ASSET:-acme-ext-$version.zip}" + prerelease=false + if [[ "$version" == *-* ]]; then prerelease=true; fi + case "$endpoint" in + 'repos/acme/ext/releases?per_page=100') + if [[ "$SCENARIO" == force_tag_only ]]; then echo '[]'; else + immutable=false + if [[ "$SCENARIO" == force_immutable ]]; then immutable=true; fi + jq -n --arg version "$version" --argjson immutable "$immutable" \ + '[{tag_name:$version,id:789,html_url:("https://github.com/acme/ext/releases/tag/"+$version),immutable:$immutable}]' + fi + exit 0 ;; + repos/acme/ext/git/matching-refs/tags/*) + if [[ "$SCENARIO" == force_release_only ]]; then echo '[]'; else + jq -n --arg version "$version" --arg sha "$FIXTURE_COMMIT" \ + '[{ref:("refs/tags/"+$version),object:{type:"tag",sha:$sha}}]' + fi + exit 0 ;; + repos/acme/ext/git/refs/tags/*) + [[ "$SCENARIO" != force_tag_failure ]] || exit 1 + [[ " $* " == *' force=true '* ]] || exit 1 + jq -n --arg sha "${REMOTE_COMMIT:-$STABLE_COMMIT}" '{object:{sha:$sha}}' + exit 0 ;; + repos/acme/ext/releases/789) + if [[ -n "$input" ]]; then + cp "$input" "$FIXTURE_ROOT/update.json" + jq -n --arg version "$version" --argjson pre "$prerelease" '{tag_name:$version,prerelease:$pre}' + else + [[ "$SCENARIO" != force_publish_failure ]] || exit 1 + jq -n --argjson pre "$prerelease" '{draft:false,prerelease:$pre}' + fi + exit 0 ;; + 'repos/acme/ext/releases/789/assets?per_page=100') + jq -n --arg name "$asset" '[{id:7,name:$name},{id:8,name:($name+".new")},{id:10,name:"other-asset.zip"}]' + exit 0 ;; + repos/acme/ext/releases/assets/9) + jq -n --arg name "$asset" '{name:$name}' + exit 0 ;; + esac +fi case "$endpoint" in repos/acme/ext) if [[ "${SCENARIO:-}" == master_branch ]]; then branch=master; else branch=main; fi @@ -78,7 +126,9 @@ case "$endpoint" in repos/acme/ext/commits/main|repos/acme/ext/commits/master) [[ "$endpoint" == */main || "${SCENARIO:-}" == master_branch ]] || exit 1 [[ "${SCENARIO:-}" != main_failure ]] || exit 1 - if [[ "${SCENARIO:-}" == dev_* ]]; then sha="$DEV_COMMIT" + if [[ -n "${REMOTE_COMMIT:-}" ]]; then sha="$REMOTE_COMMIT" + elif [[ "${SCENARIO:-}" == force_* ]]; then sha="$STABLE_COMMIT" + elif [[ "${SCENARIO:-}" == dev_* ]]; then sha="$DEV_COMMIT" elif [[ "${SCENARIO:-}" == alpha_dev ]]; then sha="$ALPHA_DEV_COMMIT" else sha="$FIXTURE_COMMIT"; fi jq -n --arg sha "$sha" '{sha:$sha}' ;; @@ -120,7 +170,7 @@ case "$endpoint" in cp "$input" "$FIXTURE_ROOT/create.json" echo '{"id":123,"html_url":"https://github.com/acme/ext/releases/tag/untagged-0123"}' ;; https://uploads.github.com/repos/acme/ext/releases/*/assets?name=*) - [[ "${SCENARIO:-}" != upload_failure ]] || exit 1 + [[ "${SCENARIO:-}" != upload_failure && "${SCENARIO:-}" != force_upload_failure ]] || exit 1 cp "$input" "$FIXTURE_ROOT/uploaded.zip" jq -n --arg name "${endpoint##*name=}" --argjson size "$(stat -c %s "$input")" '{id:9,name:$name,state:"uploaded",size:$size}' ;; repos/acme/ext/releases/123) @@ -131,13 +181,17 @@ esac MOCK chmod +x "$FIXTURE_ROOT/bin/git" "$FIXTURE_ROOT/bin/gh" export PATH="$FIXTURE_ROOT/bin:$PATH" -script="$root/git-release-api.sh" +script="$root/git-extensions.sh" zip_file="$FIXTURE_ROOT/output/acme-ext-1.2.3-a1.zip" run() { : >"$FIXTURE_ROOT/api.log" : >"$FIXTURE_ROOT/methods.log" - bash "$script" "$@" >"$FIXTURE_ROOT/stdout" 2>"$FIXTURE_ROOT/stderr" + if (($#)); then + bash "$script" --remote "$@" >"$FIXTURE_ROOT/stdout" 2>"$FIXTURE_ROOT/stderr" + else + bash "$script" >"$FIXTURE_ROOT/stdout" 2>"$FIXTURE_ROOT/stderr" + fi } run @@ -146,10 +200,10 @@ grep -q '^Usage:' "$FIXTURE_ROOT/stdout" echo 'ok - no arguments show help without API calls' if run --bogus; then exit 1; else [[ $? -eq 2 ]]; fi -if run --release --dry-run; then exit 1; else [[ $? -eq 2 ]]; fi +if run --release --create; then exit 1; else [[ $? -eq 2 ]]; fi echo 'ok - invalid and conflicting options fail' -run --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" +run --release --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" unzip -tq "$zip_file" >/dev/null [[ "$(unzip -p "$zip_file" acme/ext/composer.json | jq -r .version)" == 1.2.3-a1 ]] unzip -Z1 "$zip_file" | grep -q '^acme/ext/included.txt$' @@ -157,7 +211,9 @@ if unzip -Z1 "$zip_file" | grep -q excluded.txt; then exit 1; fi [[ -z "$(unzip -z "$zip_file" | tail -n +2)" ]] grep -q 'Example merged change' "$FIXTURE_ROOT/stdout" if grep -qE 'releases with-input|uploads.github.com|releases/123' "$FIXTURE_ROOT/api.log"; then exit 1; fi -echo 'ok - dry run packages the pinned commit, honors exclusions, and previews notes without publishing' +[[ "$(git -C "$FIXTURE_ROOT/source" branch --show-current)" == main ]] +[[ "$(jq -r .version "$FIXTURE_ROOT/source/composer.json")" == 9.9.9 ]] +echo 'ok - dry run packages the pinned commit, honors exclusions, previews notes, and preserves the dirty local tree' run --release --repo acme/ext --output "$FIXTURE_ROOT/output" jq -e --arg sha "$FIXTURE_COMMIT" '.draft == true and .prerelease == true and .tag_name == "1.2.3-a1" and .target_commitish == $sha and (.body | contains("Example merged change"))' "$FIXTURE_ROOT/create.json" >/dev/null @@ -190,20 +246,20 @@ for scenario in upload_failure publish_failure; do done export SCENARIO=master_branch -run --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" +run --release --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" grep -q '^GET repos/acme/ext/commits/master$' "$FIXTURE_ROOT/methods.log" grep -q "Source: acme/ext master at $FIXTURE_COMMIT" "$FIXTURE_ROOT/stdout" echo 'ok - a repository whose default branch is master is packaged from master' export SCENARIO=archived -if run --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output"; then exit 1; fi +if run --release --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output"; then exit 1; fi grep -q 'acme/ext is archived' "$FIXTURE_ROOT/stderr" [[ "$(cat "$FIXTURE_ROOT/methods.log")" == 'GET repos/acme/ext' ]] echo 'ok - an archived repository is rejected before any other request' dev_zip="$FIXTURE_ROOT/output/acme-ext-1.2.3-dev.zip" export SCENARIO=dev_existing -run --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" +run --release --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" grep -q 'Would update rolling release 1.2.3-dev in place' "$FIXTURE_ROOT/stdout" grep -q "Would move tag 1.2.3-dev from $FIXTURE_COMMIT to $DEV_COMMIT" "$FIXTURE_ROOT/stdout" if grep -vE '^(GET |POST repos/acme/ext/releases/generate-notes$)' "$FIXTURE_ROOT/methods.log"; then exit 1; fi @@ -231,7 +287,7 @@ if grep -q 'Published release' "$FIXTURE_ROOT/stdout"; then exit 1; fi echo 'ok - dev release moves the tag, regenerates notes, and replaces the ZIP in the same release' export SCENARIO=alpha_dev -run --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" +run --release --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" grep -q 'Release: 1.2.3-a2-dev (prerelease: true)' "$FIXTURE_ROOT/stdout" [[ "$(unzip -p "$FIXTURE_ROOT/output/acme-ext-1.2.3-a2-dev.zip" acme/ext/composer.json | jq -r .version)" == 1.2.3-a2-dev ]] echo 'ok - an alpha with -dev (1.2.3-a2-dev) is accepted as a rolling prerelease' @@ -242,6 +298,46 @@ jq -e --arg sha "$DEV_COMMIT" '.draft == true and .prerelease == true and .tag_n grep -q '^POST repos/acme/ext/git/refs$' "$FIXTURE_ROOT/methods.log" grep -q 'Published release:' "$FIXTURE_ROOT/stdout" echo 'ok - first dev release is created as a normal prerelease' +# ZIP-only remote creation must not inspect or mutate releases. +export SCENARIO=existing_release +run --create --repo acme/ext --output "$FIXTURE_ROOT/zip-only" +[[ -f "$FIXTURE_ROOT/zip-only/acme-ext-1.2.3-a1.zip" ]] +if grep -q '/releases' "$FIXTURE_ROOT/methods.log"; then exit 1; fi +echo 'ok - remote ZIP creation does not require a new release version' + +export SCENARIO=force_existing +if run --release --repo acme/ext --output "$FIXTURE_ROOT/output"; then exit 1; fi +run --release --force --dry-run --repo acme/ext --output "$FIXTURE_ROOT/output" +grep -q 'Would replace existing release 1.2.3 in place (--force)' "$FIXTURE_ROOT/stdout" +if grep -vE '^(GET |POST repos/acme/ext/releases/generate-notes$)' "$FIXTURE_ROOT/methods.log"; then exit 1; fi +run --release --force --repo acme/ext --output "$FIXTURE_ROOT/output" +jq -e --arg sha "$STABLE_COMMIT" '.prerelease == false and .target_commitish == $sha' "$FIXTURE_ROOT/update.json" >/dev/null +grep -q '^PATCH repos/acme/ext/git/refs/tags/1.2.3$' "$FIXTURE_ROOT/methods.log" +grep -q 'Updated existing release:' "$FIXTURE_ROOT/stdout" +if grep -q '/assets/10' "$FIXTURE_ROOT/methods.log"; then exit 1; fi +cmp "$FIXTURE_ROOT/output/acme-ext-1.2.3.zip" "$FIXTURE_ROOT/uploaded.zip" +echo 'ok - force replaces a stable annotated tag and ZIP, preserving stable status and unrelated assets' + +for scenario in force_tag_only force_release_only; do + export SCENARIO="$scenario" + run --release --force --repo acme/ext --output "$FIXTURE_ROOT/output" + grep -q 'release:' "$FIXTURE_ROOT/stdout" + echo "ok - $scenario completes a forced replacement" +done + +for scenario in force_tag_failure force_upload_failure force_publish_failure force_immutable; do + export SCENARIO="$scenario" + if run --release --force --repo acme/ext --output "$FIXTURE_ROOT/output"; then exit 1; fi + if grep -qE '^(Updated|Published) .*release:' "$FIXTURE_ROOT/stdout"; then exit 1; fi + if [[ "$scenario" == force_upload_failure ]]; then + if grep -q '^DELETE repos/acme/ext/releases/assets/7$' "$FIXTURE_ROOT/methods.log"; then exit 1; fi + fi + if [[ "$scenario" == force_immutable ]]; then + if grep -qE '^(PATCH|DELETE|POST) ' "$FIXTURE_ROOT/methods.log"; then exit 1; fi + fi + echo "ok - $scenario fails without claiming success or overriding server protections" +done + # --gh-release from a local checkout, through the same shared release logic. unset SCENARIO git clone -q "$FIXTURE_ROOT/source" "$FIXTURE_ROOT/local" @@ -307,6 +403,17 @@ jq -e '.prerelease == true and .tag_name == "1.2.3-a1"' "$FIXTURE_ROOT/create.js unzip -Z1 "$FIXTURE_ROOT/uploaded.zip" | grep -q "^AcmeStyle/style.cfg$" grep -q 'Published release:' "$FIXTURE_ROOT/stdout" echo 'ok - git-release-style.sh --gh-release publishes a prerelease' +REMOTE_COMMIT=$(git -C "$FIXTURE_ROOT/source" rev-parse style) +export REMOTE_COMMIT +run_local git-release-style.sh --create --remote --repo acme/ext --output "$FIXTURE_ROOT/remote-style" +unzip -Z1 "$FIXTURE_ROOT/remote-style/AcmeStyle_1.2.3-a1.zip" | grep -q '^AcmeStyle/style.cfg$' +export SCENARIO=force_existing FORCE_VERSION=1.2.3-a1 FORCE_ASSET=AcmeStyle_1.2.3-a1.zip +run_local git-release-style.sh --release --remote --force +jq -e '.prerelease == true' "$FIXTURE_ROOT/update.json" >/dev/null +grep -q 'Updated existing release:' "$FIXTURE_ROOT/stdout" +echo 'ok - styles support remote ZIP creation and forced prerelease replacement' +unset SCENARIO REMOTE_COMMIT FORCE_VERSION FORCE_ASSET + git -C "$FIXTURE_ROOT/source" checkout -q --orphan langpack git -C "$FIXTURE_ROOT/source" rm -rqf --cached . @@ -321,5 +428,64 @@ jq -e '.prerelease == true and .tag_name == "1.2.3-a1"' "$FIXTURE_ROOT/create.js unzip -Z1 "$FIXTURE_ROOT/uploaded.zip" | grep -q '^example_language_1.2.3-a1/language/xx/iso.txt$' grep -q 'Published release:' "$FIXTURE_ROOT/stdout" echo 'ok - git-release-langpack.sh --gh-release publishes the pack' +REMOTE_COMMIT=$(git -C "$FIXTURE_ROOT/source" rev-parse langpack) +export REMOTE_COMMIT +run_local git-release-langpack.sh --create --remote --repo acme/ext --version 1.2.3-a1 --output "$FIXTURE_ROOT/remote-language" +unzip -Z1 "$FIXTURE_ROOT/remote-language/example_language_1.2.3-a1.zip" | grep -q '^example_language_1.2.3-a1/language/xx/iso.txt$' +export SCENARIO=force_existing FORCE_VERSION=1.2.3-a1 FORCE_ASSET=example_language_1.2.3-a1.zip +run_local git-release-langpack.sh --release --remote --force --version 1.2.3-a1 +jq -e '.prerelease == true' "$FIXTURE_ROOT/update.json" >/dev/null +grep -q 'Updated existing release:' "$FIXTURE_ROOT/stdout" +echo 'ok - language packs support remote ZIP creation and forced replacement' +unset SCENARIO REMOTE_COMMIT FORCE_VERSION FORCE_ASSET + + +# Reuse cached reference/validator fixtures to test the remote validation gate +# without downloads, dependencies, or execution of language-pack PHP files. +export XDG_CACHE_HOME="$FIXTURE_ROOT/cache" +reference="$XDG_CACHE_HOME/git-release-langpack/phpbb-3.3.17/en/language/en" +validator="$XDG_CACHE_HOME/git-release-langpack/translation-validator-1.6.x" +mkdir -p "$reference" "$validator/vendor" +: >"$validator/vendor/autoload.php" +echo reference >"$reference/common.php" +cat >"$FIXTURE_ROOT/bin/php" <<'MOCKPHP' +#!/usr/bin/bash +set -eu +stage='' +for argument in "$@"; do + if [[ "$argument" == --package-dir=* ]]; then stage="${argument#*=}"; fi +done +[[ -f "$stage/xx/language/xx/iso.txt" && -f "$stage/en/language/en/common.php" ]] +grep -q '^Example Language$' "$stage/xx/language/xx/iso.txt" +if [[ "${VALIDATOR_FAIL:-0}" == 1 ]]; then + echo 'Fatal: 0, Error: 1' + exit 1 +fi +echo 'Fatal: 0, Error: 0' +MOCKPHP +cat >"$FIXTURE_ROOT/bin/composer" <<'MOCKCOMPOSER' +#!/usr/bin/bash +# The cached validator must avoid invoking Composer. +exit 1 +MOCKCOMPOSER +chmod +x "$FIXTURE_ROOT/bin/php" "$FIXTURE_ROOT/bin/composer" +REMOTE_COMMIT=$(git -C "$FIXTURE_ROOT/source" rev-parse langpack) +export REMOTE_COMMIT +run_local git-release-langpack.sh --create --remote --check --version 3.3.17 --output "$FIXTURE_ROOT/validated" +grep -q 'Validation passed.' "$FIXTURE_ROOT/stdout" +validated_zip="$FIXTURE_ROOT/validated/example_language_3.3.17.zip" +unzip -Z1 "$validated_zip" | grep -q '^example_language_3.3.17/language/xx/iso.txt$' +# A validation-only run does not save a ZIP or inspect releases. +run_local git-release-langpack.sh --remote --check --version 3.3.17 --output "$FIXTURE_ROOT/check-only" +[[ ! -e "$FIXTURE_ROOT/check-only" ]] +if grep -q '/releases' "$FIXTURE_ROOT/methods.log"; then exit 1; fi +export SCENARIO=force_existing FORCE_VERSION=3.3.17 FORCE_ASSET=example_language_3.3.17.zip VALIDATOR_FAIL=1 +before=$(sha256sum "$validated_zip") +if run_local git-release-langpack.sh --release --remote --force --check --version 3.3.17 --output "$FIXTURE_ROOT/validated"; then exit 1; fi +grep -q 'not releasing: the pack does not pass validation' "$FIXTURE_ROOT/stderr" +[[ "$before" == "$(sha256sum "$validated_zip")" ]] +if grep -qE '^(POST|PATCH|DELETE) ' "$FIXTURE_ROOT/methods.log"; then exit 1; fi +echo 'ok - remote language validation uses the packaged snapshot; failure preserves the ZIP and blocks even forced publishing' +unset SCENARIO REMOTE_COMMIT FORCE_VERSION FORCE_ASSET VALIDATOR_FAIL echo 'All API release tests passed.' diff --git a/tests/test-release-scripts.sh b/tests/test-release-scripts.sh index b13e0b0..a242494 100755 --- a/tests/test-release-scripts.sh +++ b/tests/test-release-scripts.sh @@ -125,6 +125,15 @@ test_extensions_short_create() { "$root/git-extensions.sh" -C >/dev/null && zip_is_valid } + +test_extensions_symlink_and_output() { + new_repo + mkdir -p "$tmp/bin" + ln -sf "$root/git-extensions.sh" "$tmp/bin/git-extensions.sh" + "$tmp/bin/git-extensions.sh" --create --output custom >/dev/null \ + && unzip -tq "$tmp/work/custom/acme-myext-1.2.3.zip" >/dev/null +} + # Create a fresh style repository in $tmp/style with one commit. new_style_repo() { rm -rf "$tmp/style" "$tmp/git-exported" @@ -154,7 +163,7 @@ test_style_usage_errors_exit_2() { } test_langpack_usage_errors_exit_2() { - [[ "$(exit_status "$root/git-release-langpack.sh" --release)" == 2 ]] \ + [[ "$(exit_status "$root/git-release-langpack.sh" --release --create)" == 2 ]] \ && [[ "$(exit_status "$root/git-release-langpack.sh" --create --dry-run)" == 2 ]] \ && [[ "$(exit_status "$root/git-release-langpack.sh" --create --gh-release)" == 2 ]] } @@ -185,13 +194,21 @@ check "git-extensions.sh: fails outside a git repository" test_outside_repositor check "git-extensions.sh: no arguments prints help and builds nothing" test_extensions_no_arguments_prints_help check "git-extensions.sh: usage errors exit 2 and build nothing" test_extensions_usage_errors_exit_2 check "git-extensions.sh: -C is short for --create" test_extensions_short_create +check "git-extensions.sh: symlink finds shared helpers and respects relative output" test_extensions_symlink_and_output check "git-release-style.sh: --create builds a valid zip with the style folder" test_style_create check "git-release-style.sh: no arguments prints help; usage errors exit 2" test_style_usage_errors_exit_2 -check "git-release-langpack.sh: --release (renamed), --dry-run without --gh-release, and two modes exit 2" test_langpack_usage_errors_exit_2 +check "git-release-langpack.sh: conflicting modes and --dry-run without --release exit 2" test_langpack_usage_errors_exit_2 check "git-release-langpack.sh: no arguments prints help" test_langpack_no_arguments_prints_help check "git-release-langpack.sh: unknown option exits 2" test_langpack_unknown_option_exits_2 check "git-release-langpack.sh: an operation is required" test_langpack_needs_an_operation +for script in git-extensions.sh git-release-style.sh git-release-langpack.sh; do + check "$script: --force requires publication" test -n "$("$root/$script" --create --force 2>&1 | grep 'Error: --force requires --release')" + check "$script: --repo requires remote source" test "$(exit_status "$root/$script" --create --repo acme/ext)" = 2 + check "$script: missing output argument is rejected" test "$(exit_status "$root/$script" --create --output)" = 2 + check "$script: missing repo argument is rejected" test "$(exit_status "$root/$script" --create --remote --repo)" = 2 +done + if [[ $failures -gt 0 ]]; then echo "$failures test(s) failed." exit 1