From a83cbdbb70fb43aa5f8af92ae342d65bb769bb4f Mon Sep 17 00:00:00 2001 From: Adam Plowman Date: Wed, 21 Oct 2026 11:43:03 +0100 Subject: [PATCH 1/2] fix: correct stable release changelog shell quoting Document recovery after a release tag has already been pushed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/README.md | 35 ++++++++++++++++++++++++++++++ .github/workflows/release-impl.yml | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f316163..bccd871 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -16,3 +16,38 @@ jobs: > [!CAUTION] > These workflows **must** be placed in this folder in order to function correctly. + +## Recovering a partially completed release + +The `release-impl.yml` workflow pushes the version commit and tag before generating +the incremental release notes. A failure after that push does not roll back those +changes. Check the remote branch, tag, GitHub release, and PyPI version before +attempting recovery. + +The `bump-version` job deliberately skips reruns (`gh-run-attempt != '1'`), so +rerunning a failed release cannot regenerate missing release notes or restart its +dependent publishing jobs. Rerunning also does not necessarily load a newer +revision of the reusable workflow. + +If the release tag exists but publishing has not started: + +1. Fix the failing step in this repository before making another release. +2. Keep the existing version commit and tag; do not delete or move a public tag, + or run the version bump again to recover the same version. +3. Prepare a separate, manually dispatched recovery workflow that checks out the + existing release tag, bypasses version/tag mutation, and supplies the original + `new_tag` and `version` outputs to the downstream jobs. +4. Generate `CHANGELOG_increment.md` using the release template and the range + from the previous stable tag to the existing release tag, for example: + ```bash + ./git-chglog --template .chglog/RELEASE.tpl.md --output CHANGELOG_increment.md "v0.2.0..v0.3.0" + ``` + Upload it as the `CHANGELOG_increment` artifact expected by the publishing job. +5. Ensure all downstream checkouts use the existing release tag rather than a + branch that may have advanced. Build the required executable and benchmark + artifacts, then publish and update documentation through the remaining jobs. + +The recovery workflow described above is not currently provided by +`release-impl.yml`; it must be prepared and reviewed before dispatch. If either +GitHub or PyPI already has the version, inspect that publication before proceeding +to avoid duplicate uploads or replacing existing release assets. diff --git a/.github/workflows/release-impl.yml b/.github/workflows/release-impl.yml index bd4a1f2..1af8290 100644 --- a/.github/workflows/release-impl.yml +++ b/.github/workflows/release-impl.yml @@ -281,7 +281,7 @@ jobs: - name: Generate incremental CHANGELOG for GitHub release body (stable release) if: inputs.pr-base-ref == inputs.release_branch run: | - ./git-chglog --template .chglog/RELEASE.tpl.md --output CHANGELOG_increment.md "$cur_tag.."" + ./git-chglog --template .chglog/RELEASE.tpl.md --output CHANGELOG_increment.md "$cur_tag.." cat CHANGELOG_increment.md env: cur_tag: ${{ steps.cur_tag.outputs.tag }} From d2e626099c8f19cb08e674a849337c14ca3f9be9 Mon Sep 17 00:00:00 2001 From: Adam Plowman Date: Wed, 21 Oct 2026 12:07:54 +0100 Subject: [PATCH 2/2] feat: recover unpublished stable releases from existing tags Validate recovery tags and publication state, pin builds to the release commit, and skip version and repository mutations. Add offline regression tests and recovery documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/README.md | 50 +++--- .github/workflows/release-impl.yml | 152 +++++++++++++++--- .github/workflows/test-release-recovery.yml | 27 ++++ scripts/README.md | 6 +- scripts/test-release-recovery.sh | 165 ++++++++++++++++++++ 5 files changed, 355 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/test-release-recovery.yml create mode 100644 scripts/test-release-recovery.sh diff --git a/.github/workflows/README.md b/.github/workflows/README.md index bccd871..1e444d4 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -24,7 +24,8 @@ the incremental release notes. A failure after that push does not roll back thos changes. Check the remote branch, tag, GitHub release, and PyPI version before attempting recovery. -The `bump-version` job deliberately skips reruns (`gh-run-attempt != '1'`), so +In normal release mode, the `bump-version` job deliberately skips reruns +(`gh-run-attempt != '1'`), so rerunning a failed release cannot regenerate missing release notes or restart its dependent publishing jobs. Rerunning also does not necessarily load a newer revision of the reusable workflow. @@ -34,20 +35,33 @@ If the release tag exists but publishing has not started: 1. Fix the failing step in this repository before making another release. 2. Keep the existing version commit and tag; do not delete or move a public tag, or run the version bump again to recover the same version. -3. Prepare a separate, manually dispatched recovery workflow that checks out the - existing release tag, bypasses version/tag mutation, and supplies the original - `new_tag` and `version` outputs to the downstream jobs. -4. Generate `CHANGELOG_increment.md` using the release template and the range - from the previous stable tag to the existing release tag, for example: - ```bash - ./git-chglog --template .chglog/RELEASE.tpl.md --output CHANGELOG_increment.md "v0.2.0..v0.3.0" - ``` - Upload it as the `CHANGELOG_increment` artifact expected by the publishing job. -5. Ensure all downstream checkouts use the existing release tag rather than a - branch that may have advanced. Build the required executable and benchmark - artifacts, then publish and update documentation through the remaining jobs. - -The recovery workflow described above is not currently provided by -`release-impl.yml`; it must be prepared and reviewed before dispatch. If either -GitHub or PyPI already has the version, inspect that publication before proceeding -to avoid duplicate uploads or replacing existing release assets. +3. Call `release-impl.yml` from a `workflow_dispatch` workflow with `recovery-tag` + set to the existing stable tag (for example `v0.3.0`) and + `recovery-previous-tag` set to the previous stable tag (`v0.2.0`). + Pass the stable branch as `pr-base-ref` and the pre-release branch as + `pr-head-ref`, along with the usual application inputs and secrets. + Set `pr-merged` to `"false"`; recovery does not require a PR merge. +4. Review and explicitly confirm publication in the caller before dispatching. + Use the same concurrency group as normal releases to prevent overlap. + +Recovery validates that both tags exist, the previous tag strictly precedes the +release tag, the release commit belongs to the stable branch, and its Poetry +package version matches the tag. It supports stable `vX.Y.Z` tags and the +production PyPI or TestPyPI upload URLs only. It rejects existing GitHub releases +(including drafts visible to the token), existing PyPI versions, and API errors. +It is not a general resume mechanism for partially published versions. + +After validation, recovery checks out the resolved release **commit SHA** for app +data caching, executable builds, benchmarks, package builds, and documentation. +It generates the `CHANGELOG_increment` artifact with only the two selected tags +visible to git-chglog and requests the recovery tag alone. This excludes the +previous release and combines changes across intervening prereleases, without +deleting any tags. It then runs the existing build/publish jobs. Version bumping, +tag creation, application repository pushes, and branch rebases are skipped. +Binary links are written only to the documentation build checkout; the website +repository is still updated, including the stable documentation symlink. + +Recovery can be rerun if it fails **before publication**. If either GitHub or PyPI +already has the version, inspect that publication and finish the remaining work +separately; the preflight will refuse another recovery run. Do not delete a +publication merely to bypass this safeguard. diff --git a/.github/workflows/release-impl.yml b/.github/workflows/release-impl.yml index 1af8290..b772602 100644 --- a/.github/workflows/release-impl.yml +++ b/.github/workflows/release-impl.yml @@ -124,6 +124,16 @@ on: type: string required: false default: "2.1.3" + recovery-tag: + description: > + Existing stable release tag to publish without bumping versions or moving tags. + Only supported for workflow_dispatch, before GitHub or PyPI publication. + type: string + default: "" + recovery-previous-tag: + description: Previous stable release tag used as the exclusive release notes boundary. + type: string + default: "" secrets: general-token: description: > @@ -161,13 +171,14 @@ on: jobs: bump-version: - # skip if trying to re-run; PR is closed without merging; '[skip release]' is in the PR title; or if merging any branch other than pre_release_branch into release_branch - if: inputs.gh-run-attempt == '1' && inputs.pr-merged == 'true' && !contains(inputs.pr-title, '[skip release]') && (inputs.pr-base-ref == inputs.pre_release_branch || (inputs.pr-base-ref == inputs.release_branch && inputs.pr-head-ref == inputs.pre_release_branch)) && (!(github.event && github.event.pull_request) || github.event.pull_request.merged) + # Recovery requires manual dispatch. Normal releases skip reruns, unmerged PRs, and unrelated merges. + if: (inputs.recovery-tag != '' && github.event_name == 'workflow_dispatch') || (inputs.recovery-tag == '' && inputs.gh-run-attempt == '1' && inputs.pr-merged == 'true' && !contains(inputs.pr-title, '[skip release]') && (inputs.pr-base-ref == inputs.pre_release_branch || (inputs.pr-base-ref == inputs.release_branch && inputs.pr-head-ref == inputs.pre_release_branch)) && (!(github.event && github.event.pull_request) || github.event.pull_request.merged)) runs-on: ubuntu-latest outputs: new_tag: ${{ steps.new_tag.outputs.tag }} version: ${{ steps.new_tag.outputs.version }} changelog: ${{ steps.upload-changelog.outputs.artifact-url }} + source-ref: ${{ steps.recovery.outputs.sha }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -175,25 +186,93 @@ jobs: fetch-depth: 0 # get all history and tags ref: ${{ inputs.pr-base-ref }} token: ${{ secrets.commit-token }} - persist-credentials: true + persist-credentials: ${{ inputs.recovery-tag == '' }} allow-unsafe-pr-checkout: true - name: Configure git + if: inputs.recovery-tag == '' uses: $/configure-git - name: Set up python ${{ inputs.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ inputs.python-version }} + - name: Set up poetry for recovery validation + if: inputs.recovery-tag != '' + uses: $/setup-poetry + with: + version: ${{ inputs.poetry-version }} + + - name: Validate unpublished stable release for recovery + if: inputs.recovery-tag != '' + id: recovery + env: + TAG: ${{ inputs.recovery-tag }} + PREVIOUS_TAG: ${{ inputs.recovery-previous-tag }} + BASE_BRANCH: ${{ inputs.pr-base-ref }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + REPOSITORY: ${{ inputs.repository }} + CALLER_REPOSITORY: ${{ github.repository }} + PYPI_URL: ${{ inputs.pypi-url }} + GH_TOKEN: ${{ secrets.general-token }} + GH_API_URL: ${{ github.api_url }} + run: | + set -euo pipefail + if [[ "$BASE_BRANCH" != "$RELEASE_BRANCH" || "$REPOSITORY" != "$CALLER_REPOSITORY" ]]; then + echo "::error::Recovery must target the stable branch of the calling repository." + exit 1 + fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ || ! "$PREVIOUS_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ || "$TAG" == "$PREVIOUS_TAG" ]]; then + echo "::error::Recovery requires two different stable tags of the form vX.Y.Z." + exit 1 + fi + if [[ "$(printf '%s\n' "$PREVIOUS_TAG" "$TAG" | sort -V | head -n 1)" != "$PREVIOUS_TAG" ]]; then + echo "::error::The previous stable version must be lower than the recovery version." + exit 1 + fi + sha=$(git rev-parse --verify "refs/tags/$TAG^{commit}") + previous_sha=$(git rev-parse --verify "refs/tags/$PREVIOUS_TAG^{commit}") + if [[ "$sha" == "$previous_sha" ]] || ! git merge-base --is-ancestor "$previous_sha" "$sha"; then + echo "::error::The previous stable tag must strictly precede the recovery tag." + exit 1 + fi + if ! git merge-base --is-ancestor "$sha" "origin/$RELEASE_BRANCH"; then + echo "::error::The recovery tag must be on the stable release branch." + exit 1 + fi + git checkout --detach "$sha" + package_version=$(poetry version) + read -r package version <<< "$package_version" + if [[ "$version" != "${TAG#v}" ]]; then + echo "::error::The package version does not match the recovery tag." + exit 1 + fi + case "$PYPI_URL" in + https://upload.pypi.org/legacy/) index=https://pypi.org/pypi ;; + https://test.pypi.org/legacy/) index=https://test.pypi.org/pypi ;; + *) echo "::error::Recovery supports only PyPI and TestPyPI."; exit 1 ;; + esac + github_status=$(curl --silent --show-error --location --output /dev/null --write-out '%{http_code}' \ + --header "Authorization: Bearer $GH_TOKEN" \ + "$GH_API_URL/repos/$REPOSITORY/releases/tags/$TAG") + pypi_status=$(curl --silent --show-error --location --output /dev/null --write-out '%{http_code}' \ + "$index/$package/$version/json") + if [[ "$github_status" != 404 || "$pypi_status" != 404 ]]; then + echo "::error::Recovery requires an unpublished version. GitHub HTTP: $github_status; PyPI HTTP: $pypi_status. Inspect existing publications or API errors before proceeding." + exit 1 + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Get git-chglog executable run: | wget https://github.com/git-chglog/git-chglog/releases/download/v0.15.0/git-chglog_0.15.0_linux_amd64.tar.gz tar --extract --file git-chglog_0.15.0_linux_amd64.tar.gz git-chglog - name: Install commitizen + if: inputs.recovery-tag == '' run: pip install commitizen - name: Manipulate tags (stable release) - if: inputs.pr-base-ref == inputs.release_branch + if: inputs.recovery-tag == '' && inputs.pr-base-ref == inputs.release_branch id: pre_tag # delete all pre-release tags, set current version to the latest stable release, run: | @@ -203,6 +282,7 @@ jobs: git tag -l | awk '/^(v[0-9]+\.[0-9]+\.[0-9]+(a|b|rc).*)$/ {print $1}' | xargs git tag -d - name: Get current tag + if: inputs.recovery-tag == '' id: cur_tag run: | CUR_TAG=$(git describe --tags $(git rev-list --tags --max-count=1)) @@ -210,11 +290,11 @@ jobs: echo "tag=$CUR_TAG" >> $GITHUB_OUTPUT - name: Commitizen bump (pre-release) # Bump version strings (pre-release) and add a new tag; commit - if: inputs.pr-base-ref == inputs.pre_release_branch + if: inputs.recovery-tag == '' && inputs.pr-base-ref == inputs.pre_release_branch run: cz bump --prerelease alpha - name: Commitizen bump # First update version number to latest stable release, then bump to new stable release, add a new tag and commit - if: inputs.pr-base-ref == inputs.release_branch + if: inputs.recovery-tag == '' && inputs.pr-base-ref == inputs.release_branch run: | python3 -c ' from commitizen.bump import update_version_in_files @@ -235,23 +315,30 @@ jobs: - name: Get new tag id: new_tag run: | - NEW_TAG=$(git describe --tags $(git rev-list --tags --max-count=1)) + if [[ -n "$RECOVERY_TAG" ]]; then + NEW_TAG="$RECOVERY_TAG" + else + NEW_TAG=$(git describe --tags $(git rev-list --tags --max-count=1)) + fi echo "NEW_TAG is: $NEW_TAG" echo "tag=$NEW_TAG" >> $GITHUB_OUTPUT echo "version=${NEW_TAG:1}" >> $GITHUB_OUTPUT + env: + RECOVERY_TAG: ${{ inputs.recovery-tag }} - name: Generate CHANGELOG (stable release) - if: inputs.pr-base-ref == inputs.release_branch + if: inputs.recovery-tag == '' && inputs.pr-base-ref == inputs.release_branch run: | ./git-chglog --output CHANGELOG.md git add CHANGELOG.md - name: Generate CHANGELOG-dev (pre-release) - if: inputs.pr-base-ref == inputs.pre_release_branch + if: inputs.recovery-tag == '' && inputs.pr-base-ref == inputs.pre_release_branch run: | ./git-chglog --output CHANGELOG-dev.md git add CHANGELOG-dev.md - name: Push new CHANGELOG + if: inputs.recovery-tag == '' run: | git tag -d $new_tag git commit --amend --no-edit @@ -261,7 +348,7 @@ jobs: new_tag: ${{ steps.new_tag.outputs.tag }} - name: Rebase into develop branch if exists (stable release) - if: inputs.pr-base-ref == inputs.release_branch + if: inputs.recovery-tag == '' && inputs.pr-base-ref == inputs.release_branch run: | exists_in_remote=$(git ls-remote --heads origin "refs/heads/$PRE_BRANCH") echo "exists_in_remote: $exists_in_remote" @@ -281,11 +368,19 @@ jobs: - name: Generate incremental CHANGELOG for GitHub release body (stable release) if: inputs.pr-base-ref == inputs.release_branch run: | - ./git-chglog --template .chglog/RELEASE.tpl.md --output CHANGELOG_increment.md "$cur_tag.." + if [[ -n "$RECOVERY_TAG" ]]; then + tag_pattern="^(${PREVIOUS_TAG//./[.]}|${RECOVERY_TAG//./[.]})$" + ./git-chglog --tag-filter-pattern "$tag_pattern" --sort semver \ + --template .chglog/RELEASE.tpl.md --output CHANGELOG_increment.md "$RECOVERY_TAG" + else + ./git-chglog --template .chglog/RELEASE.tpl.md --output CHANGELOG_increment.md "$cur_tag.." + fi cat CHANGELOG_increment.md env: cur_tag: ${{ steps.cur_tag.outputs.tag }} new_tag: ${{ steps.new_tag.outputs.tag }} + RECOVERY_TAG: ${{ inputs.recovery-tag }} + PREVIOUS_TAG: ${{ inputs.recovery-previous-tag }} - name: Generate incremental CHANGELOG for GitHub release body (pre-release) if: inputs.pr-base-ref == inputs.pre_release_branch @@ -303,6 +398,7 @@ jobs: path: CHANGELOG_increment.md cache-app-data: + needs: bump-version # skip if '[skip release]' is in the PR title; or if merging any branch other than pre_release_branch into release_branch if: "!contains(inputs.pr-title, '[skip release]') && (inputs.pr-base-ref == inputs.pre_release_branch || (inputs.pr-base-ref == inputs.release_branch && inputs.pr-head-ref == inputs.pre_release_branch)) && (!(github.event && github.event.pull_request) || github.event.pull_request.merged)" permissions: @@ -313,7 +409,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.pr-base-ref }} + ref: ${{ needs.bump-version.outputs.source-ref || inputs.pr-base-ref }} allow-unsafe-pr-checkout: true persist-credentials: false @@ -375,7 +471,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.pr-base-ref }} # otherwise we get the ref when the workflow started (missing above commit) + ref: ${{ needs.bump-version.outputs.source-ref || inputs.pr-base-ref }} # use the validated commit during recovery allow-unsafe-pr-checkout: true persist-credentials: false - name: Set up python ${{ inputs.python-version }} @@ -527,7 +623,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.pr-base-ref }} # otherwise we get the ref when the workflow started (missing above commit) + ref: ${{ needs.bump-version.outputs.source-ref || inputs.pr-base-ref }} # use the validated commit during recovery allow-unsafe-pr-checkout: true persist-credentials: false - name: Cache dependencies @@ -640,7 +736,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.ref }} + ref: ${{ needs.bump-version.outputs.source-ref || github.ref }} allow-unsafe-pr-checkout: true persist-credentials: false - name: Set up python ${{ inputs.python-version }} @@ -692,6 +788,7 @@ jobs: make-workflow-benchmark-upload: needs: + - bump-version - make-workflow-benchmark - cleanup-cache runs-on: ubuntu-latest @@ -701,7 +798,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.ref }} + ref: ${{ needs.bump-version.outputs.source-ref || github.ref }} allow-unsafe-pr-checkout: true persist-credentials: false - name: Make benchmark directory @@ -739,7 +836,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.pr-base-ref }} # otherwise we get the ref when the workflow started (missing above commit) + ref: ${{ needs.bump-version.outputs.source-ref || inputs.pr-base-ref }} # use the validated commit during recovery allow-unsafe-pr-checkout: true persist-credentials: false - name: Set up python ${{ inputs.python-version }} @@ -819,16 +916,16 @@ jobs: TOKEN: ${{ secrets.pypi-token }} build-documentation: - needs: release-github-PyPI + needs: [bump-version, release-github-PyPI] runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # get all history and tags - ref: ${{ inputs.pr-base-ref }} # otherwise we get the ref when the workflow started (missing above commit) + ref: ${{ needs.bump-version.outputs.source-ref || inputs.pr-base-ref }} # use the validated commit during recovery token: ${{ secrets.commit-token }} - persist-credentials: true + persist-credentials: ${{ inputs.recovery-tag == '' }} allow-unsafe-pr-checkout: true - name: Configure git @@ -842,14 +939,17 @@ jobs: - name: Write binary links YAML file and push run: | echo -e "$BIN_LINKS" > docs/source/released_binaries.yml - git add . - git commit -m "build: update binary download links file [skip ci]" - git push + if [[ -z "$RECOVERY_TAG" ]]; then + git add . + git commit -m "build: update binary download links file [skip ci]" + git push + fi env: BIN_LINKS: ${{ needs.release-github-PyPI.outputs.binary_download_links }} + RECOVERY_TAG: ${{ inputs.recovery-tag }} - name: Rebase into develop branch if exists (stable release) - if: inputs.pr-base-ref == inputs.release_branch + if: inputs.recovery-tag == '' && inputs.pr-base-ref == inputs.release_branch run: | exists_in_remote=$(git ls-remote --heads origin "refs/heads/$PRE_BRANCH") echo "exists_in_remote: $exists_in_remote" @@ -937,10 +1037,10 @@ jobs: - name: Update doc version switcher run: | - curl "$URL" --output docs/make_vers_switcher.py + curl --fail --show-error --location "$URL" --output docs/make_vers_switcher.py python docs/make_vers_switcher.py "$DOC_PREFIX" env: - URL: https://raw.githubusercontent.com/${{ inputs.repository }}/${{ github.ref_name }}/docs/make_vers_switcher.py + URL: https://raw.githubusercontent.com/${{ inputs.repository }}/${{ needs.bump-version.outputs.source-ref || github.ref_name }}/docs/make_vers_switcher.py DOC_PREFIX: ${{ inputs.docs_url_prefix }} - name: Push changes diff --git a/.github/workflows/test-release-recovery.yml b/.github/workflows/test-release-recovery.yml new file mode 100644 index 0000000..4f64b94 --- /dev/null +++ b/.github/workflows/test-release-recovery.yml @@ -0,0 +1,27 @@ +name: Test release recovery + +on: + pull_request: + paths: + - .github/workflows/release-impl.yml + - .github/workflows/test-release-recovery.yml + - scripts/test-release-recovery.sh + push: + branches: [main] + paths: + - .github/workflows/release-impl.yml + - .github/workflows/test-release-recovery.yml + - scripts/test-release-recovery.sh + +permissions: + contents: read + +jobs: + recovery: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test recovery validation and release notes + run: bash scripts/test-release-recovery.sh diff --git a/scripts/README.md b/scripts/README.md index c9f2daf..ec26f87 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,3 +1,7 @@ # Support scripts -Currently just for the `test-impl.yml` workflow. +The Python scripts support the `test-impl.yml` workflow. + +Run `bash scripts/test-release-recovery.sh` to test stable release recovery with +temporary local Git repositories and mocked Poetry/API calls. No credentials, +network requests, or publication are needed. diff --git a/scripts/test-release-recovery.sh b/scripts/test-release-recovery.sh new file mode 100644 index 0000000..08450d6 --- /dev/null +++ b/scripts/test-release-recovery.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +workflow="$root/.github/workflows/release-impl.yml" +tmp=$(mktemp -d) +trap 'rm -r -- "$tmp"' EXIT + +extract_step() { + awk -v name="$1" ' + $0 == " - name: " name { found = 1; next } + found && /^ run: \|$/ { run = 1; next } + run && /^ / { print substr($0, 11); next } + run && /^$/ { print; next } + run { exit } + ' "$workflow" +} + +extract_step 'Validate unpublished stable release for recovery' > "$tmp/validate.sh" +extract_step 'Get new tag' > "$tmp/new-tag.sh" +extract_step 'Generate incremental CHANGELOG for GitHub release body (stable release)' > "$tmp/changelog.sh" +for script in validate new-tag changelog; do + test -s "$tmp/$script.sh" + bash -n "$tmp/$script.sh" +done + +mkdir "$tmp/repo" +cd "$tmp/repo" +git init --quiet --initial-branch=main --template= +git config core.autocrlf false +git config core.hooksPath /dev/null +git config user.name 'Recovery workflow test' +git config user.email 'recovery-test@example.invalid' +commit_version() { + printf '%s\n' "$1" > version.txt + git add version.txt + git commit --quiet -m "Version $1" +} +commit_version 0.2.0 +git tag v0.2.0 +previous_sha=$(git rev-parse HEAD) +git tag v0.8.0 +commit_version 0.3.0 +git tag -a v0.3.0 -m 'Stable release' +release_sha=$(git rev-parse HEAD) +git tag v0.2.1 +git tag v0.4.0 +commit_version 0.3.1 +git tag v0.3.1 +lightweight_sha=$(git rev-parse HEAD) +commit_version 0.5.0 +git tag v0.5.0 +git update-ref refs/remotes/origin/main HEAD +git checkout --quiet -b unrelated "$previous_sha" +commit_version 0.9.0 +git tag v0.9.0 +git tag v0.1.1 +git checkout --quiet main +refs=$(git for-each-ref) + +poetry() { + test "$*" = version + printf 'hpcflow %s\n' "$(cat version.txt)" +} +curl() { + local url="${!#}" + case "$url" in + https://api.github.com/repos/hpcflow/hpcflow/releases/tags/*) + printf '%s' "${GITHUB_STATUS:-404}" + return "${CURL_EXIT:-0}" + ;; + https://pypi.org/pypi/hpcflow/*/json|https://test.pypi.org/pypi/hpcflow/*/json) + printf '%s' "${PYPI_STATUS:-404}" + ;; + *) printf 'Unexpected API URL: %s\n' "$url" >&2; return 1 ;; + esac +} +export -f poetry curl +export TAG=v0.3.0 PREVIOUS_TAG=v0.2.0 BASE_BRANCH=main RELEASE_BRANCH=main +export REPOSITORY=hpcflow/hpcflow CALLER_REPOSITORY=hpcflow/hpcflow +export PYPI_URL=https://upload.pypi.org/legacy/ GH_TOKEN=test-token GH_API_URL=https://api.github.com +export GITHUB_OUTPUT="$tmp/output" + +check_validation() { + local expected="$1" name="$2" status=0 expected_sha="${EXPECTED_SHA:-$release_sha}" + shift 2 + git checkout --quiet main + : > "$GITHUB_OUTPUT" + env "$@" bash -e "$tmp/validate.sh" > "$tmp/log" 2>&1 || status=$? + if [[ "$expected" == pass ]]; then + if [[ "$status" != 0 ]] || ! grep -qx "sha=$expected_sha" "$GITHUB_OUTPUT"; then + cat "$tmp/log" + printf 'FAIL: %s\n' "$name" >&2 + exit 1 + fi + test "$(git rev-parse HEAD)" = "$expected_sha" + elif [[ "$status" == 0 || -s "$GITHUB_OUTPUT" ]]; then + cat "$tmp/log" + printf 'FAIL: %s was not rejected\n' "$name" >&2 + exit 1 + fi + test "$(git for-each-ref)" = "$refs" + printf 'PASS: %s\n' "$name" +} + +check_validation pass 'unpublished annotated tag, even when main has advanced' +EXPECTED_SHA="$lightweight_sha" check_validation pass 'unpublished lightweight tag' TAG=v0.3.1 +check_validation pass 'TestPyPI upload endpoint' PYPI_URL=https://test.pypi.org/legacy/ +check_validation fail 'wrong base branch' BASE_BRANCH=develop +check_validation fail 'wrong calling repository' CALLER_REPOSITORY=other/repo +check_validation fail 'missing recovery tag' TAG= +check_validation fail 'missing previous tag' PREVIOUS_TAG= +check_validation fail 'prerelease tag' TAG=v0.3.0a1 +check_validation fail 'ref injection' TAG='v0.3.0;echo unwanted' +check_validation fail 'identical tag names' PREVIOUS_TAG=v0.3.0 +check_validation fail 'different tags on the same commit' PREVIOUS_TAG=v0.2.1 +check_validation fail 'missing tag ref' TAG=v8.0.0 +check_validation fail 'previous tag is newer' PREVIOUS_TAG=v0.5.0 +check_validation fail 'previous version is higher despite earlier commit' PREVIOUS_TAG=v0.8.0 +check_validation fail 'unrelated previous tag' PREVIOUS_TAG=v0.1.1 +check_validation fail 'release is not on stable branch' TAG=v0.9.0 +check_validation fail 'package version mismatch' TAG=v0.4.0 +check_validation fail 'unsupported package index' PYPI_URL=https://example.invalid/ +check_validation fail 'existing GitHub publication' GITHUB_STATUS=200 +check_validation fail 'existing PyPI publication' PYPI_STATUS=200 +check_validation fail 'GitHub API permission failure' GITHUB_STATUS=403 +check_validation fail 'PyPI API server failure' PYPI_STATUS=503 +check_validation fail 'API transport failure' CURL_EXIT=7 + +: > "$GITHUB_OUTPUT" +RECOVERY_TAG=v0.3.0 bash -e "$tmp/new-tag.sh" +grep -qx 'tag=v0.3.0' "$GITHUB_OUTPUT" +grep -qx 'version=0.3.0' "$GITHUB_OUTPUT" +printf 'PASS: recovery tag and version outputs\n' +git checkout --quiet --detach "$release_sha" +: > "$GITHUB_OUTPUT" +normal_tag=$(git describe --tags "$(git rev-list --tags --max-count=1)") +RECOVERY_TAG= bash -e "$tmp/new-tag.sh" +grep -qx "tag=$normal_tag" "$GITHUB_OUTPUT" +printf 'PASS: normal release tag output\n' + +cat > git-chglog <<'SH' +#!/usr/bin/env bash +set -eu +if [[ -n "$RECOVERY_TAG" ]]; then + test "$#" -eq 9 + test "$1" = --tag-filter-pattern + test "$2" = '^(v0[.]2[.]0|v0[.]3[.]0)$' + test "$3" = --sort + test "$4" = semver + shift 4 +fi +test "$#" -eq 5 +test "$1" = --template +test "$2" = .chglog/RELEASE.tpl.md +test "$3" = --output +test "$4" = CHANGELOG_increment.md +test "$5" = "$EXPECTED_RANGE" +printf 'Release notes\n' > "$4" +SH +chmod +x git-chglog +RECOVERY_TAG=v0.3.0 PREVIOUS_TAG=v0.2.0 EXPECTED_RANGE=v0.3.0 bash -e "$tmp/changelog.sh" +RECOVERY_TAG= PREVIOUS_TAG= cur_tag=v0.2.0 EXPECTED_RANGE=v0.2.0.. bash -e "$tmp/changelog.sh" +printf 'PASS: recovery and normal stable release notes ranges\n' +test "$(git for-each-ref)" = "$refs"