From 61a3ad0bb9bc1c83e640b9f276263d8f76e6e40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:36:09 -0700 Subject: [PATCH 01/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20drop=20lock=20che?= =?UTF-8?q?cks=20outside=20pull=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-news already checks the lock on the pull request that changes it, so the tag-time lock job, the prepare lock job and the lock step of check-tagged-source only re-checked what writers merged. Their secondary lock inputs go with them. --- .../workflows/prepare-towncrier-release.yaml | 56 ------------------- .../workflows/validate-python-release.yaml | 54 ------------------ README.md | 56 ++++++------------- changelog.d/+tag-lock-jobs.breaking.md | 6 ++ check-tagged-source/action.yml | 42 +------------- tests/refresh_release_lock/test_refresh.py | 3 - tests/validate_python_release/test_tagged.py | 11 +--- 7 files changed, 25 insertions(+), 203 deletions(-) create mode 100644 changelog.d/+tag-lock-jobs.breaking.md diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 47c8589..1bb8a77 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -14,30 +14,6 @@ on: description: Optional v prefix for the Towncrier changelog heading type: string default: "" - additional-lock: - description: Secondary Nab lock to check before release preparation - type: string - default: "" - additional-lock-groups: - description: Groups selected by the secondary lock - type: string - default: "" - additional-lock-selection: - description: Project or build-or-groups selection for the secondary lock - type: string - default: build-or-groups - additional-matrix-python: - description: Python range for the secondary lock - type: string - default: "" - additional-matrix-platforms: - description: Space-separated Nab platform IDs for the secondary lock - type: string - default: "" - additional-matrix-implementations: - description: Space-separated implementations for the secondary lock - type: string - default: "" annotated: description: Create an annotated tag type: boolean @@ -46,10 +22,6 @@ on: description: Create a GitHub Deployment for the App-key job type: boolean default: true - nab-git-commit: - description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release - type: string - default: "" exclude-newer: description: Upload cutoff for the tools the shared Actions install from PyPI type: string @@ -67,35 +39,7 @@ concurrency: group: prepare-release-${{ github.repository }} cancel-in-progress: false jobs: - lock: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-26.04 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - - uses: $/refresh-release-lock - with: - mode: check - additional-lock: ${{ inputs.additional-lock }} - additional-lock-groups: ${{ inputs.additional-lock-groups }} - additional-lock-selection: ${{ inputs.additional-lock-selection }} - additional-matrix-python: ${{ inputs.additional-matrix-python }} - additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} - additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - nab-git-commit: ${{ inputs.nab-git-commit }} - exclude-newer: ${{ inputs.exclude-newer }} - exclude-newer-package: ${{ inputs.exclude-newer-package }} assemble: - needs: lock runs-on: ubuntu-26.04 permissions: contents: read diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 662367a..b16bbab 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -14,34 +14,6 @@ on: description: Prefix for distribution artifacts in a multi-package release default: "" type: string - additional-lock: - description: Secondary Nab lock to check against the tagged project - default: "" - type: string - additional-lock-groups: - description: Groups selected by the secondary lock - default: "" - type: string - additional-lock-selection: - description: Project or build-or-groups selection for the secondary lock - default: build-or-groups - type: string - additional-matrix-python: - description: Python range for the secondary lock - default: "" - type: string - additional-matrix-platforms: - description: Space-separated Nab platform IDs for the secondary lock - default: "" - type: string - additional-matrix-implementations: - description: Space-separated implementations for the secondary lock - default: "" - type: string - nab-git-commit: - description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release - default: "" - type: string exclude-newer: description: Upload cutoff for the tools the shared Actions install from PyPI default: 7 days @@ -53,32 +25,6 @@ on: permissions: contents: read jobs: - lock: - runs-on: ubuntu-26.04 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - - uses: $/refresh-release-lock - with: - mode: check - additional-lock: ${{ inputs.additional-lock }} - additional-lock-groups: ${{ inputs.additional-lock-groups }} - additional-lock-selection: ${{ inputs.additional-lock-selection }} - additional-matrix-python: ${{ inputs.additional-matrix-python }} - additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} - additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - nab-git-commit: ${{ inputs.nab-git-commit }} - exclude-newer: ${{ inputs.exclude-newer }} - exclude-newer-package: ${{ inputs.exclude-newer-package }} build: runs-on: ubuntu-26.04 outputs: diff --git a/README.md b/README.md index 5bc6a7d..33e3207 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ group exports an empty constraints file. The Action only checks that the selected groups exist in the lock; installs are hash-checked. Lock integrity comes from Nab: `refresh-release-lock` in `check` mode regenerates the lock and fails if it differs. It runs on pull requests -through `validate-news`, on tags in the parallel lock job, and in a keyless job of the prepare workflow before the -release patch is assembled. The cutoff and any per-package exception live in the caller's `[tool.nab]` table. +through `validate-news`. The cutoff and any per-package exception live in the caller's `[tool.nab]` table. Select the groups each job needs. The shared workflows use `release-prepare` and `release-validate`. A full lock records each group and extra, but a new combination still needs an install check before a caller uses it. @@ -181,26 +180,10 @@ passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore reject a `prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before they run `prek`. -The shared prepare and tagged validator run `refresh-release-lock` in `check` mode before release checks or publishing. `check-tagged-source` bundles the tagged checks for a custom tag publisher: on a tag ref it requires the tag to name -both `GITHUB_SHA` and the checkout, on `main`, and checks the locks. Any other event, such as a `workflow_dispatch` from -`main`, must check out the commit that the `tag` input of the `workflow_dispatch` event (`github.event.inputs.tag`) -names, on `main`. It runs no hooks. It takes the secondary lock inputs below and needs a full-history checkout. For a -secondary Nab lock, pass its path, selected groups, selection type, and matrix inputs to both workflows. A stale primary -or secondary lock stops the release before artifacts are built. - -For example, a tagged source job that also publishes from a PGO lock checks both files before installing build tools: - -```yaml - - uses: tox-dev/github-workflows/refresh-release-lock@SHA - with: - mode: check - additional-lock: pylock.pgo.toml - additional-lock-groups: pgo - additional-matrix-python: '>=3.11,<3.16' - additional-matrix-platforms: linux_x86_64 linux_aarch64 - additional-matrix-implementations: cpython pypy -``` +both `GITHUB_SHA` and the checkout, on `main`. Any other event, such as a `workflow_dispatch` from `main`, must check +out the commit that the `tag` input of the `workflow_dispatch` event (`github.event.inputs.tag`) names, on `main`. It +runs no hooks and needs a full-history checkout. ## Start with a caller @@ -237,17 +220,15 @@ The tag starts the caller's publisher. Keep PyPI trusted publishing in that top- [PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Repositories with the standard `uv` wheel and sdist build can call `validate-python-release.yaml` in the tag publisher. The [self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem -uploads. The read-only build workflow checks the tag and the lock. The tag caller renders the Release body and has the -only Contents write job that creates the GitHub Release. +uploads. The tag caller renders the Release body and has the only Contents write job that creates the GitHub Release. ## Validate a Python release -`validate-python-release.yaml` runs two jobs in parallel. The keyless `lock` job runs `refresh-release-lock` in `check` -mode. The `build` job checks the tag inline: `GITHUB_REF` is `refs/tags/`, `git check-ref-format` accepts the tag, -the tag resolves to `GITHUB_SHA`, which is the checked-out commit, and that commit is an ancestor of `origin/main`. The -same job then exports hashed build constraints from the caller lock, builds the wheel and sdist, requires exactly one of -each at the tag's version, and uploads them. The workflow does not inspect the built files further; check package -metadata and wheel contents in the package's own `tox -e pkg_meta` run. +`validate-python-release.yaml` runs one `build` job. It checks the tag inline: `GITHUB_REF` is `refs/tags/`, +`git check-ref-format` accepts the tag, the tag resolves to `GITHUB_SHA`, which is the checked-out commit, and that +commit is an ancestor of `origin/main`. The same job then exports hashed build constraints from the caller lock, builds +the wheel and sdist, requires exactly one of each at the tag's version, and uploads them. The workflow does not inspect +the built files further; check package metadata and wheel contents in the package's own `tox -e pkg_meta` run. ```yaml build: @@ -259,11 +240,10 @@ build: The caller needs a `build` group for backend constraints. `source` defaults to `.`. Use `artifact-prefix` when one tag produces more than one distribution path; each call then writes a distinct `python-package-distributions` artifact. A -caller pinned to a workflow commit runs that commit's local Actions. Pass the secondary lock inputs and the cutoff -inputs as for `refresh-release-lock`. +caller pinned to a workflow commit runs that commit's local Actions. Pass the cutoff inputs as for +`refresh-release-lock`. -Publish only after the reusable workflow succeeds, so the lock check gates publishing too. Download the distribution -with pinned `download-artifact` +Publish only after the reusable workflow succeeds. Download the distribution with pinned `download-artifact` [v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), using `artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }}`. The download checks the artifact digest and selects the wheel and sdist from that build run. This reusable workflow does not render notes. Render them in a @@ -351,16 +331,14 @@ build package versions; otherwise the Action fails instead of writing an ambiguo ## Prepare and tag with one reusable workflow -`prepare-towncrier-release.yaml` runs four jobs. The keyless `lock` job runs `refresh-release-lock` in `check` mode. -`assemble` then selects a tag and uploads a Towncrier patch without the App key. `validate` calculates the next tag -again from the checked-out source, then applies the uploaded artifact and runs `prek`. `push` downloads that artifact by -ID, checks its paths and file modes, reads the caller's `release-auth` environment, and uses a repository-scoped App -token to push the release commit and tag together. Only a dispatch from `main` can reach the writer. +`prepare-towncrier-release.yaml` runs three jobs. `assemble` selects a tag and uploads a Towncrier patch without the App +key. `validate` calculates the next tag again from the checked-out source, then applies the uploaded artifact and runs +`prek`. `push` downloads that artifact by ID, checks its paths and file modes, reads the caller's `release-auth` +environment, and uses a repository-scoped App token to push the release commit and tag together. ```yaml jobs: prepare: - if: github.ref == 'refs/heads/main' uses: >- tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA permissions: diff --git a/changelog.d/+tag-lock-jobs.breaking.md b/changelog.d/+tag-lock-jobs.breaking.md new file mode 100644 index 0000000..f848992 --- /dev/null +++ b/changelog.d/+tag-lock-jobs.breaking.md @@ -0,0 +1,6 @@ +Remove the `lock` job from `validate-python-release.yaml` and `prepare-towncrier-release.yaml`, and the lock check from +`check-tagged-source`. `validate-news` still checks the lock on the pull request that changes it. Both workflows drop +the `additional-lock`, `additional-lock-groups`, `additional-lock-selection`, `additional-matrix-python`, +`additional-matrix-platforms`, and `additional-matrix-implementations` inputs, and `check-tagged-source` drops them +along with `exclude-newer` and `exclude-newer-package`; remove them from callers. Without the `lock` job, +`prepare-towncrier-release.yaml` no longer skips a dispatch from a branch other than `main`. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml index b2ad79d..d307fcc 100644 --- a/check-tagged-source/action.yml +++ b/check-tagged-source/action.yml @@ -1,33 +1,5 @@ name: Check tagged source -description: Check the tag and the project lock of a tagged checkout -inputs: - additional-lock: - description: Secondary Nab lock to check against the tagged project - default: "" - additional-lock-groups: - description: Groups selected by the secondary lock - default: "" - additional-lock-selection: - description: Project or build-or-groups selection for the secondary lock - default: build-or-groups - additional-matrix-python: - description: Python range for the secondary lock - default: "" - additional-matrix-platforms: - description: Space-separated Nab platform IDs for the secondary lock - default: "" - additional-matrix-implementations: - description: Space-separated implementations for the secondary lock - default: "" - nab-git-commit: - description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release - default: "" - exclude-newer: - description: Upload cutoff for the tools this Action installs from PyPI - default: 7 days - exclude-newer-package: - description: Newline-separated name=date cutoffs that override exclude-newer for single packages - default: "" +description: Check the tag of a tagged checkout runs: using: composite steps: @@ -47,15 +19,3 @@ runs: test "$commit" = "$(git rev-parse HEAD)" fi git merge-base --is-ancestor HEAD origin/main - - uses: $/refresh-release-lock - with: - mode: check - additional-lock: ${{ inputs.additional-lock }} - additional-lock-groups: ${{ inputs.additional-lock-groups }} - additional-lock-selection: ${{ inputs.additional-lock-selection }} - additional-matrix-python: ${{ inputs.additional-matrix-python }} - additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} - additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - nab-git-commit: ${{ inputs.nab-git-commit }} - exclude-newer: ${{ inputs.exclude-newer }} - exclude-newer-package: ${{ inputs.exclude-newer-package }} diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 91de323..eda5926 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -302,10 +302,7 @@ def test_refresh_check_requires_locked_nab_version(tmp_path: Path, action_script ("path", "steps"), [ pytest.param("validate-news/action.yml", ("runs", "steps"), id="validate-news"), - pytest.param("check-tagged-source/action.yml", ("runs", "steps"), id="check-tagged-source"), - pytest.param(".github/workflows/prepare-towncrier-release.yaml", ("jobs", "lock", "steps"), id="prepare"), pytest.param(".github/workflows/upgrade-release-tools.yaml", ("jobs", "generate-lock", "steps"), id="upgrade"), - pytest.param(".github/workflows/validate-python-release.yaml", ("jobs", "lock", "steps"), id="python-release"), ], ) def test_refresh_callers_forward_nab_commit(path: str, steps: tuple[str, ...]) -> None: diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index c04dddf..e300b9d 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -47,17 +47,8 @@ def build_constraints(build_repository: Path, tmp_path: Path) -> Path: return path -def test_lock_check_runs_keyless_beside_the_build(release_workflow: dict[str, Any]) -> None: - lock = release_workflow["jobs"]["lock"] - check = next(step for step in lock["steps"] if step.get("uses") == "$/refresh-release-lock") - - assert lock["permissions"] == {"contents": "read"} - assert check["with"]["mode"] == "check" - assert "needs" not in release_workflow["jobs"]["build"] - - def test_release_workflow_leaves_distribution_checks_to_the_package(release_workflow: dict[str, Any]) -> None: - assert set(release_workflow["jobs"]) == {"lock", "build"} + assert set(release_workflow["jobs"]) == {"build"} def test_build_exports_constraints_after_checking_the_tag(release_workflow: dict[str, Any]) -> None: From 48a8b7039d51f0c1ef8d200567eceb137ab1736c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:37:02 -0700 Subject: [PATCH 02/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20drop=20tag=20iden?= =?UTF-8?q?tity=20and=20ancestry=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writers push the tags, so re-checking that a tag names GITHUB_SHA and sits on main only catches their own mistakes. This removes the check-tagged-source Action, the inline tag check of validate-python-release.yaml and the source job of the self-release. --- .github/workflows/release.yaml | 25 +--- .../workflows/validate-python-release.yaml | 11 -- README.md | 23 ++- changelog.d/+check-tagged-source.breaking.md | 3 + changelog.d/+self-release-source.change.md | 1 + check-tagged-source/action.yml | 21 --- tests/release_workflow/test_graph.py | 6 +- tests/validate_python_release/test_tagged.py | 136 +----------------- 8 files changed, 19 insertions(+), 207 deletions(-) create mode 100644 changelog.d/+check-tagged-source.breaking.md create mode 100644 changelog.d/+self-release-source.change.md delete mode 100644 check-tagged-source/action.yml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fea566a..7398545 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,27 +8,6 @@ concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: - source: - runs-on: ubuntu-26.04 - permissions: - contents: read - outputs: - commit: ${{ github.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - name: Verify the exact release tag - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - test "$GITHUB_REF" = "refs/tags/$TAG" - git check-ref-format "refs/tags/$TAG" - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$GITHUB_SHA" - test "$GITHUB_SHA" = "$(git rev-parse HEAD)" - git merge-base --is-ancestor HEAD origin/main notes: runs-on: ubuntu-26.04 permissions: @@ -64,7 +43,7 @@ jobs: path: ${{ runner.temp }}/release-notes.md if-no-files-found: error publish: - needs: [source, notes] + needs: notes runs-on: ubuntu-26.04 environment: release permissions: @@ -84,6 +63,6 @@ jobs: GH_TOKEN: ${{ github.token }} with: tag: ${{ github.ref_name }} - commit: ${{ needs.source.outputs.commit }} + commit: ${{ github.sha }} title: ${{ github.ref_name }} notes-file: ${{ runner.temp }}/release-notes.md diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index b16bbab..65ba546 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -34,19 +34,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} - - name: Verify the exact release tag - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - test "$GITHUB_REF" = "refs/tags/$TAG" - git check-ref-format "refs/tags/$TAG" - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$GITHUB_SHA" - test "$GITHUB_SHA" = "$(git rev-parse HEAD)" - git merge-base --is-ancestor HEAD origin/main - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known diff --git a/README.md b/README.md index 33e3207..bf395ea 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,13 @@ reusable workflow that installs such a tool takes two inputs: The tools the caller's release jobs run, such as release-creator, Towncrier, and prek, still come hash-checked from the caller's `pylock.toml`. -`nab-git-commit` is a temporary input of `refresh-release-lock`, `validate-news`, `check-tagged-source`, -`prepare-towncrier-release.yaml`, `upgrade-release-tools.yaml`, and `validate-python-release.yaml`. It is empty by -default. When set, it must be a full 40-character commit SHA, and the whole Nab workspace is installed from -`git+https://github.com/notatallshaw/nab@` instead of the release: `nab` from the root, and `nab-index`, -`nab-markersets`, `nab-project`, `nab-provider`, and `nab-resolver` from their `#subdirectory=` paths, because the root -pins them to an unreleased version. That commit gets no cooldown: `exclude-newer` applies only to its dependencies. It -exists until a Nab release carries the needed fix and will be removed then. +`nab-git-commit` is a temporary input of `refresh-release-lock`, `validate-news`, `prepare-towncrier-release.yaml`, +`upgrade-release-tools.yaml`, and `validate-python-release.yaml`. It is empty by default. When set, it must be a full +40-character commit SHA, and the whole Nab workspace is installed from `git+https://github.com/notatallshaw/nab@` +instead of the release: `nab` from the root, and `nab-index`, `nab-markersets`, `nab-project`, `nab-provider`, and +`nab-resolver` from their `#subdirectory=` paths, because the root pins them to an unreleased version. That commit gets +no cooldown: `exclude-newer` applies only to its dependencies. It exists until a Nab release carries the needed fix and +will be removed then. ## Check pull request news @@ -180,11 +180,6 @@ passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore reject a `prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before they run `prek`. -`check-tagged-source` bundles the tagged checks for a custom tag publisher: on a tag ref it requires the tag to name -both `GITHUB_SHA` and the checkout, on `main`. Any other event, such as a `workflow_dispatch` from `main`, must check -out the commit that the `tag` input of the `workflow_dispatch` event (`github.event.inputs.tag`) names, on `main`. It -runs no hooks and needs a full-history checkout. - ## Start with a caller The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable @@ -224,9 +219,7 @@ uploads. The tag caller renders the Release body and has the only Contents write ## Validate a Python release -`validate-python-release.yaml` runs one `build` job. It checks the tag inline: `GITHUB_REF` is `refs/tags/`, -`git check-ref-format` accepts the tag, the tag resolves to `GITHUB_SHA`, which is the checked-out commit, and that -commit is an ancestor of `origin/main`. The same job then exports hashed build constraints from the caller lock, builds +`validate-python-release.yaml` runs one `build` job. It exports hashed build constraints from the caller lock, builds the wheel and sdist, requires exactly one of each at the tag's version, and uploads them. The workflow does not inspect the built files further; check package metadata and wheel contents in the package's own `tox -e pkg_meta` run. diff --git a/changelog.d/+check-tagged-source.breaking.md b/changelog.d/+check-tagged-source.breaking.md new file mode 100644 index 0000000..c7caa7e --- /dev/null +++ b/changelog.d/+check-tagged-source.breaking.md @@ -0,0 +1,3 @@ +Remove the `check-tagged-source` Action, and stop checking the tag in `validate-python-release.yaml`: the workflow no +longer requires the tag to resolve to `GITHUB_SHA` or to sit on `main`, and checks out the tag with the default fetch +depth. Remove `check-tagged-source` steps from callers. diff --git a/changelog.d/+self-release-source.change.md b/changelog.d/+self-release-source.change.md new file mode 100644 index 0000000..3b76067 --- /dev/null +++ b/changelog.d/+self-release-source.change.md @@ -0,0 +1 @@ +Drop the `source` job of the github-workflows release workflow, which re-checked the tag that started it. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml deleted file mode 100644 index d307fcc..0000000 --- a/check-tagged-source/action.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Check tagged source -description: Check the tag of a tagged checkout -runs: - using: composite - steps: - - name: Verify the exact release tag - shell: bash - env: - TAG: ${{ github.event.inputs.tag || github.ref_name }} - run: | - set -euo pipefail - git check-ref-format "refs/tags/$TAG" - commit=$(git rev-parse "refs/tags/$TAG^{commit}") - if [[ "$GITHUB_REF" == refs/tags/* ]]; then - test "$GITHUB_REF" = "refs/tags/$TAG" - test "$commit" = "$GITHUB_SHA" - test "$GITHUB_SHA" = "$(git rev-parse HEAD)" - else - test "$commit" = "$(git rev-parse HEAD)" - fi - git merge-base --is-ancestor HEAD origin/main diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 9c26555..b74599a 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -81,13 +81,13 @@ def test_release_writer_has_only_notes_and_reconciler() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) publish = workflow["jobs"]["publish"] assert publish["permissions"] == {"contents": "write"} - assert publish["needs"] == ["source", "notes"] + assert publish["needs"] == "notes" assert [step.get("uses", step.get("name")) for step in publish["steps"]] == [ "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333", "Require the tag to still name the release commit", "$/publish-github-release", ] - assert publish["steps"][-1]["with"]["commit"] == "${{ needs.source.outputs.commit }}" + assert publish["steps"][-1]["with"]["commit"] == "${{ github.sha }}" assert publish["steps"][0]["with"]["artifact-ids"] == "${{ needs.notes.outputs.artifact-id }}" @@ -160,4 +160,4 @@ def test_release_notes_render_read_only_from_full_history() -> None: assert notes["permissions"] == {"contents": "read"} assert "needs" not in notes assert checkout["with"]["fetch-depth"] == 0 - assert set(workflow["jobs"]) == {"source", "notes", "publish"} + assert set(workflow["jobs"]) == {"notes", "publish"} diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index e300b9d..6619738 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -5,11 +5,10 @@ import subprocess import sys from pathlib import Path -from typing import Any, cast +from typing import Any import pytest import yaml -from conftest import run @pytest.fixture(scope="module") @@ -51,10 +50,9 @@ def test_release_workflow_leaves_distribution_checks_to_the_package(release_work assert set(release_workflow["jobs"]) == {"build"} -def test_build_exports_constraints_after_checking_the_tag(release_workflow: dict[str, Any]) -> None: +def test_build_exports_constraints_before_building(release_workflow: dict[str, Any]) -> None: names = [step.get("name", step.get("uses", "")) for step in release_workflow["jobs"]["build"]["steps"]] - assert names.index("Verify the exact release tag") < names.index("$/install-locked-tools") assert names.index("$/install-locked-tools") < names.index("Build wheel and sdist") @@ -106,102 +104,6 @@ def test_validator_rejects_backend_hash_mismatch( assert "Hash mismatch" in result.stderr -@pytest.mark.parametrize( - ("tag_kind", "event_ref", "accepted"), - [ - pytest.param("lightweight", "refs/tags/0.2.0", True, id="lightweight-tag"), - pytest.param("annotated", "refs/tags/0.2.0", True, id="annotated-tag"), - pytest.param("branch", "refs/tags/0.2.0", False, id="same-name-branch"), - pytest.param("off-main", "refs/tags/0.2.0", False, id="tagged-off-main-commit"), - ], -) -@pytest.mark.parametrize( - "location", - [ - pytest.param("check-tagged-source/action.yml", id="source-check"), - pytest.param("validate-python-release:build", id="build"), - pytest.param("release:source", id="self-release"), - ], -) -def test_validator_requires_exact_tag( - repository: Path, tag_kind: str, event_ref: str, accepted: bool, location: str -) -> None: - if tag_kind == "branch": - run(repository, "git", "branch", "0.2.0") - elif tag_kind == "off-main": - run(repository, "git", "switch", "-c", "release-candidate") - run(repository, "git", "commit", "--allow-empty", "-m", "Unmerged change") - run(repository, "git", "tag", "0.2.0") - elif tag_kind == "annotated": - run(repository, "git", "tag", "-a", "-m", "Release", "0.2.0") - else: - run(repository, "git", "tag", "0.2.0") - verify = _tag_check(location) - result = subprocess.run( - ["bash", "-c", verify["run"]], - cwd=repository, - env=os.environ - | { - "GITHUB_REF": event_ref, - "TAG": "0.2.0", - "GITHUB_SHA": run(repository, "git", "rev-parse", "HEAD").stdout.strip(), - }, - text=True, - capture_output=True, - check=False, - ) - assert (result.returncode == 0) is accepted, result.stderr - - -@pytest.mark.parametrize( - ("location", "accepted"), - [ - pytest.param("check-tagged-source/action.yml", True, id="source-check"), - pytest.param("validate-python-release:build", False, id="build"), - pytest.param("release:source", False, id="self-release"), - ], -) -def test_validator_branch_event_on_tagged_commit(repository: Path, location: str, *, accepted: bool) -> None: - run(repository, "git", "tag", "0.2.0") - - result = _run_tag_check(repository, location, "refs/heads/main", run(repository, "git", "rev-parse", "HEAD").stdout) - - assert (result.returncode == 0) is accepted, result.stderr - - -def test_source_check_rejects_dispatch_off_the_tag(repository: Path) -> None: - run(repository, "git", "tag", "0.2.0") - run(repository, "git", "commit", "--allow-empty", "-m", "Later change") - run(repository, "git", "push", "origin", "main") - - result = _run_tag_check( - repository, - "check-tagged-source/action.yml", - "refs/heads/main", - run(repository, "git", "rev-parse", "HEAD").stdout, - ) - - assert result.returncode != 0 - - -def test_source_check_reads_the_dispatched_tag() -> None: - assert ( - _tag_check("check-tagged-source/action.yml")["env"]["TAG"] - == "${{ github.event.inputs.tag || github.ref_name }}" - ) - - -def _run_tag_check(repository: Path, location: str, event_ref: str, event_sha: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", "-c", _tag_check(location)["run"]], - cwd=repository, - env=os.environ | {"GITHUB_REF": event_ref, "TAG": "0.2.0", "GITHUB_SHA": event_sha.strip()}, - text=True, - capture_output=True, - check=False, - ) - - @pytest.mark.parametrize( ("tag", "filenames", "accepted"), [ @@ -238,37 +140,3 @@ def test_validator_matches_built_version_to_tag( check=False, ) assert (result.returncode == 0) is accepted, result.stderr - - -@pytest.mark.parametrize( - "location", - ["check-tagged-source/action.yml", "validate-python-release:build", "release:source"], - ids=["source-check", "build", "self-release"], -) -def test_validator_requires_tag_at_the_event_commit(repository: Path, location: str) -> None: - run(repository, "git", "tag", "0.2.0") - event_commit = run(repository, "git", "rev-parse", "HEAD").stdout.strip() - run(repository, "git", "commit", "--allow-empty", "-m", "Later change") - run(repository, "git", "push", "origin", "main") - run(repository, "git", "tag", "-f", "0.2.0") - - result = subprocess.run( - ["bash", "-c", _tag_check(location)["run"]], - cwd=repository, - env=os.environ | {"GITHUB_REF": "refs/tags/0.2.0", "TAG": "0.2.0", "GITHUB_SHA": event_commit}, - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - - -def _tag_check(location: str) -> dict[str, Any]: - root = Path(__file__).resolve().parents[2] - if location.endswith(".yml"): - steps = yaml.safe_load((root / location).read_text())["runs"]["steps"] - else: - workflow, job = location.split(":") - steps = yaml.safe_load((root / ".github/workflows" / f"{workflow}.yaml").read_text())["jobs"][job]["steps"] - return cast("dict[str, Any]", next(step for step in steps if step.get("name") == "Verify the exact release tag")) From a0d4058e986fefa21f42d1d5bab0e74c53c7cb51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:37:37 -0700 Subject: [PATCH 03/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20remove=20the=20na?= =?UTF-8?q?b-git-commit=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No caller installs Nab from a Git commit any more, so the temporary input, its format check and the workspace install go. --- .github/workflows/upgrade-release-tools.yaml | 5 -- README.md | 8 ---- changelog.d/+nab-git-commit.breaking.md | 3 ++ refresh-release-lock/action.yml | 17 +------ tests/refresh_release_lock/test_refresh.py | 48 ++------------------ validate-news/action.yml | 4 -- 6 files changed, 8 insertions(+), 77 deletions(-) create mode 100644 changelog.d/+nab-git-commit.breaking.md diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 84a172b..e534a3b 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -39,10 +39,6 @@ on: type: string default: |- Refresh the project lock. - nab-git-commit: - description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release - type: string - default: "" exclude-newer: description: Upload cutoff for the tools the shared Actions install from PyPI type: string @@ -83,7 +79,6 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - nab-git-commit: ${{ inputs.nab-git-commit }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - id: upload diff --git a/README.md b/README.md index bf395ea..e567e9e 100644 --- a/README.md +++ b/README.md @@ -73,14 +73,6 @@ reusable workflow that installs such a tool takes two inputs: The tools the caller's release jobs run, such as release-creator, Towncrier, and prek, still come hash-checked from the caller's `pylock.toml`. -`nab-git-commit` is a temporary input of `refresh-release-lock`, `validate-news`, `prepare-towncrier-release.yaml`, -`upgrade-release-tools.yaml`, and `validate-python-release.yaml`. It is empty by default. When set, it must be a full -40-character commit SHA, and the whole Nab workspace is installed from `git+https://github.com/notatallshaw/nab@` -instead of the release: `nab` from the root, and `nab-index`, `nab-markersets`, `nab-project`, `nab-provider`, and -`nab-resolver` from their `#subdirectory=` paths, because the root pins them to an unreleased version. That commit gets -no cooldown: `exclude-newer` applies only to its dependencies. It exists until a Nab release carries the needed fix and -will be removed then. - ## Check pull request news `validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the diff --git a/changelog.d/+nab-git-commit.breaking.md b/changelog.d/+nab-git-commit.breaking.md new file mode 100644 index 0000000..3d45da0 --- /dev/null +++ b/changelog.d/+nab-git-commit.breaking.md @@ -0,0 +1,3 @@ +Remove the temporary `nab-git-commit` input from `refresh-release-lock`, `validate-news`, +`prepare-towncrier-release.yaml`, `upgrade-release-tools.yaml`, and `validate-python-release.yaml`. The Actions always +install Nab from PyPI; remove the input from callers. diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index d5a029a..ee222fe 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -22,9 +22,6 @@ inputs: mode: description: Refresh the lock or check that the committed lock is current default: refresh - nab-git-commit: - description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release - default: "" exclude-newer: description: Upload cutoff for the tools this Action installs from PyPI default: 7 days @@ -47,13 +44,8 @@ runs: MATRIX_PYTHON: ${{ inputs.additional-matrix-python }} MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }} MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }} - NAB_GIT_COMMIT: ${{ inputs.nab-git-commit }} run: | set -euo pipefail - if [[ -n "$NAB_GIT_COMMIT" && ! "$NAB_GIT_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo '::error::nab-git-commit must be a full 40-character commit SHA' >&2 - exit 1 - fi if [[ -z "$ADDITIONAL_LOCK" && ("$ADDITIONAL_SELECTION" != build-or-groups || -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS") ]]; then @@ -66,17 +58,10 @@ runs: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} MODE: ${{ inputs.mode }} - NAB_GIT_COMMIT: ${{ inputs.nab-git-commit }} run: | set -euo pipefail requirements=("nab>=0.0.22") - if [[ -n "$NAB_GIT_COMMIT" ]]; then - source=git+https://github.com/notatallshaw/nab@$NAB_GIT_COMMIT - requirements=("nab @ $source") - for package in nab-index nab-markersets nab-project nab-provider nab-resolver; do - requirements+=("$package @ $source#subdirectory=$package") - done - elif [[ "$MODE" == check ]]; then + if [[ "$MODE" == check ]]; then version="" if [[ -f pylock.toml ]]; then version=$(python3 -c 'import tomllib diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index eda5926..2e71583 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -217,24 +217,6 @@ def test_refresh_installs_nab_under_cutoff(lock_project: Path, tmp_path: Path, a assert "nab>=0.0.22" in result.stderr -@pytest.mark.parametrize("commit", ["abc123", "g" * 40, "a" * 41], ids=["short", "not-hex", "long"]) -def test_refresh_rejects_invalid_nab_commit(tmp_path: Path, action_scripts: Path, commit: str) -> None: - result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="", NAB_GIT_COMMIT=commit) - - assert result.returncode != 0 - assert "nab-git-commit must be a full 40-character commit SHA" in result.stderr - - -_NAB_COMMIT: Final = "0123456789abcdef0123456789abcdef01234567" -_NAB_SOURCE: Final = f"git+https://github.com/notatallshaw/nab@{_NAB_COMMIT}" -_NAB_WORKSPACE: Final = [ - f"nab @ {_NAB_SOURCE}", - f"nab-index @ {_NAB_SOURCE}#subdirectory=nab-index", - f"nab-markersets @ {_NAB_SOURCE}#subdirectory=nab-markersets", - f"nab-project @ {_NAB_SOURCE}#subdirectory=nab-project", - f"nab-provider @ {_NAB_SOURCE}#subdirectory=nab-provider", - f"nab-resolver @ {_NAB_SOURCE}#subdirectory=nab-resolver", -] _VERSIONED_LOCK: Final = f'{_LOCK}nab-version = "0.0.21"\n' @@ -249,17 +231,15 @@ def fake_installer(tmp_path: Path) -> Path: @pytest.mark.parametrize( - ("mode", "commit", "requirements"), + ("mode", "requirements"), [ - pytest.param("refresh", "", ["nab>=0.0.22"], id="refresh-release"), - pytest.param("check", "", ["nab==0.0.21"], id="check-locked-release"), - pytest.param("refresh", _NAB_COMMIT, _NAB_WORKSPACE, id="refresh-git-commit-workspace"), - pytest.param("check", _NAB_COMMIT, _NAB_WORKSPACE, id="check-git-commit-workspace"), + pytest.param("refresh", ["nab>=0.0.22"], id="refresh-release"), + pytest.param("check", ["nab==0.0.21"], id="check-locked-release"), ], ) @pytest.mark.usefixtures("fake_installer") def test_refresh_installs_selected_nab( - tmp_path: Path, action_scripts: Path, mode: str, commit: str, requirements: list[str] + tmp_path: Path, action_scripts: Path, mode: str, requirements: list[str] ) -> None: (tmp_path / "pylock.toml").write_text(_VERSIONED_LOCK) @@ -270,7 +250,6 @@ def test_refresh_installs_selected_nab( tmp_path / "bin/uv", GITHUB_ACTION_PATH=str(tmp_path / "action"), MODE=mode, - NAB_GIT_COMMIT=commit, ) assert result.returncode == 0, result.stderr @@ -290,7 +269,6 @@ def test_refresh_check_requires_locked_nab_version(tmp_path: Path, action_script tmp_path / "bin/uv", GITHUB_ACTION_PATH=str(tmp_path / "action"), MODE="check", - NAB_GIT_COMMIT="", ) assert result.returncode != 0 @@ -298,24 +276,6 @@ def test_refresh_check_requires_locked_nab_version(tmp_path: Path, action_script assert not (tmp_path / "installed").exists() -@pytest.mark.parametrize( - ("path", "steps"), - [ - pytest.param("validate-news/action.yml", ("runs", "steps"), id="validate-news"), - pytest.param(".github/workflows/upgrade-release-tools.yaml", ("jobs", "generate-lock", "steps"), id="upgrade"), - ], -) -def test_refresh_callers_forward_nab_commit(path: str, steps: tuple[str, ...]) -> None: - document = yaml.safe_load((Path(__file__).resolve().parents[2] / path).read_text()) - inputs = document["inputs"] if "runs" in document else document[True]["workflow_call"]["inputs"] - for key in steps: - document = document[key] - refresh = next(step for step in document if step.get("uses") == "$/refresh-release-lock") - - assert inputs["nab-git-commit"]["default"] == "" - assert refresh["with"]["nab-git-commit"] == "${{ inputs.nab-git-commit }}" - - @pytest.mark.parametrize("mode", ["check", "refresh"]) def test_refresh_rejects_changed_primary_environments(tmp_path: Path, action_scripts: Path, mode: str) -> None: (tmp_path / "pylock.toml").write_text(_LOCK) diff --git a/validate-news/action.yml b/validate-news/action.yml index 67bc35c..45cf820 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -19,9 +19,6 @@ inputs: additional-matrix-implementations: description: Space-separated implementations for the secondary lock default: "" - nab-git-commit: - description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release - default: "" exclude-newer: description: Upload cutoff for the tools this Action installs from PyPI default: 7 days @@ -84,7 +81,6 @@ runs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - nab-git-commit: ${{ inputs.nab-git-commit }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} mode: check From 5a6201dd3e51ff421aa2f2b08cd9d503c3cfa5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:39:32 -0700 Subject: [PATCH 04/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20stop=20re-checkin?= =?UTF-8?q?g=20the=20tag=20before=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tag a writer pushed started the run, so checking again that it still names the release commit only catches a writer moving it. This drops the check from the self-release, publish-github-release and publish-jetbrains-marketplace. --- .github/workflows/release.yaml | 5 - README.md | 50 ++++----- changelog.d/+tag-recheck.change.md | 4 + publish-github-release/action.yml | 5 +- publish-jetbrains-marketplace/action.yml | 19 +--- tests/publish_github_release/test_publish.py | 79 ++----------- .../test_marketplace_upload.py | 104 +----------------- tests/release_workflow/test_graph.py | 29 ----- 8 files changed, 45 insertions(+), 250 deletions(-) create mode 100644 changelog.d/+tag-recheck.change.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7398545..7358ba2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -53,11 +53,6 @@ jobs: with: artifact-ids: ${{ needs.notes.outputs.artifact-id }} path: ${{ runner.temp }} - - name: Require the tag to still name the release commit - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: test "$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$TAG" --jq .sha)" = "$GITHUB_SHA" - uses: $/publish-github-release env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index e567e9e..4f7e485 100644 --- a/README.md +++ b/README.md @@ -238,8 +238,8 @@ with `release-creator tags --previous`, renders the section with `release-creato and the Full Changelog link. Keep PyPI OIDC in the caller's `release` environment job. Run the tag workflow under `concurrency` keyed by the tag, without cancelling a run in progress. The release job has -`contents: write` and no checkout. It reconciles the PyPI digests, requires that the tag still resolves to `GITHUB_SHA`, -and then creates the GitHub Release with a single `publish-github-release` call after PyPI, with no separate stage job: +`contents: write` and no checkout. It reconciles the PyPI digests and then creates the GitHub Release with a single +`publish-github-release` call after PyPI, with no separate stage job: ```yaml concurrency: @@ -268,11 +268,6 @@ jobs: project: example version: ${{ github.ref_name }} dist: dist - - name: Require the tag to still name the release commit - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: test "$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$TAG" --jq .sha)" = "$GITHUB_SHA" - uses: tox-dev/github-workflows/publish-github-release@SHA env: GH_TOKEN: ${{ github.token }} @@ -530,14 +525,14 @@ credentials. ## Publish a GitHub Release -`publish-github-release` verifies the tag and its rendered notes before writing a GitHub Release. Pass the exact notes -file and the tag's commit SHA. A retry resumes a matching draft, or succeeds without a write when the published Release -already matches. Run it in a Contents-write job that downloads the notes and optional assets; it needs no checkout or -tool installation. For a repository that must upload GitHub assets before PyPI, call it with `mode: stage`, verify -PyPI's exact artifact set, then call it with `mode: publish` and the same `assets-directory`. The draft stays private -until the second call succeeds. Enable immutable releases in the repository before using this Action. Drafts can still -be edited, so the Action checks them again before upload and publication. A matching published release whose API record -is immutable needs no write or second metadata fetch. Older mutable releases retain the recheck. +`publish-github-release` writes a GitHub Release from the rendered notes. Pass the exact notes file and the tag's commit +SHA. A retry resumes a matching draft, or succeeds without a write when the published Release already matches. Run it in +a Contents-write job that downloads the notes and optional assets; it needs no checkout or tool installation. For a +repository that must upload GitHub assets before PyPI, call it with `mode: stage`, verify PyPI's exact artifact set, +then call it with `mode: publish` and the same `assets-directory`. The draft stays private until the second call +succeeds. Enable immutable releases in the repository before using this Action. Drafts can still be edited, so the +Action checks them again before upload and publication. A matching published release whose API record is immutable needs +no write or second metadata fetch. Older mutable releases retain the recheck. ```yaml - uses: tox-dev/github-workflows/publish-github-release@SHA @@ -550,22 +545,22 @@ is immutable needs no write or second metadata fetch. Older mutable releases ret notes-file: ${{ runner.temp }}/release-notes.md ``` -- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the commit - the existing tag must resolve to. `title` and the file bytes must match an existing Release exactly. +- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the tag's + commit, used as the target when the Action creates the Release. `title` and the file bytes must match an existing + Release exactly. - `assets-directory`: Optional directory containing only the assets to attach. The Action compares each filename and SHA256 digest with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After publication it verifies GitHub's release attestation for each local asset. - `prerelease`: `true` or `false`; default `false`. It must match an existing Release. - `mode`: `complete` (default) creates or resumes a draft and publishes it in one call. `stage` creates or resumes a draft and uploads assets without publishing. `publish` requires an existing draft and publishes it after checking the - tag, notes, and complete asset set again. + notes and complete asset set again. **Output:** No named output. **Remote effect:** Depending on `mode`, the Action creates or resumes a draft, uploads missing assets, and publishes only after the complete asset set matches. A matching published Release causes no write. A -different body, title, tag commit, prerelease flag, or asset set fails without changing the existing Release. A failed -upload leaves the draft for a later retry. The Action removes incomplete `starter` assets before retrying. It retries -reading the release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that -window ends. +different body, title, prerelease flag, or asset set fails without changing the existing Release. A failed upload leaves +the draft for a later retry. The Action removes incomplete `starter` assets before retrying. It retries reading the +release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that window ends. ## Reconcile a post-release pull request @@ -701,12 +696,11 @@ marketplace-publish: `channel` is empty for Stable; use a Marketplace channel name for a prerelease. Keep the publishing step named `Publish signed ZIP`. The Action recognizes that name and two older upload step names in prior runs and deployment jobs. Keep those names in the Action when adding another. Give the job Actions and Deployments read access and the workflow a -concurrency group keyed by tag. Use the `release` environment on the upload job. The Action verifies the remote tag, -then searches earlier tag runs and release deployments. For the same commit, an acceptance receipt must match the -original artifact IDs, signed ZIP digest, and release notes bytes before the Action skips upload. Any earlier release -deployment without a verified receipt stops for manual reconciliation, even if that run did not reach the Marketplace -step. The same applies when the linked job is no longer available. A changed publishing step name fails before upload -until the Action recognizes it. +concurrency group keyed by tag. Use the `release` environment on the upload job. The Action searches earlier tag runs +and release deployments. An acceptance receipt must match the original artifact IDs, signed ZIP digest, and release +notes bytes before the Action skips upload. Any earlier release deployment without a verified receipt stops for manual +reconciliation, even if that run did not reach the Marketplace step. The same applies when the linked job is no longer +available. A changed publishing step name fails before upload until the Action recognizes it. **Output:** A `marketplace-acceptance--` Actions artifact after a new upload. It records the tag, commit, plugin, channel, signed ZIP artifact and SHA256, and notes artifact and SHA256. **Remote effect:** One multipart upload diff --git a/changelog.d/+tag-recheck.change.md b/changelog.d/+tag-recheck.change.md new file mode 100644 index 0000000..705b55f --- /dev/null +++ b/changelog.d/+tag-recheck.change.md @@ -0,0 +1,4 @@ +Stop re-checking the release tag before publishing. `publish-github-release` no longer requires the remote tag to +resolve to `commit`, which now only sets the target of a new Release. `publish-jetbrains-marketplace` no longer requires +the remote tag to target `commit` or fails on an earlier upload of the tag from another commit. Drop the step that +re-checks the tag before `publish-github-release` from callers that copied the README example. diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index bb4859e..8d1dbed 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -5,7 +5,7 @@ inputs: description: Existing Git tag for the release required: true commit: - description: Commit expected at the tag + description: Commit the release targets required: true title: description: Exact GitHub Release title @@ -30,7 +30,6 @@ runs: env: GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} - COMMIT: ${{ inputs.commit }} TITLE: ${{ inputs.title }} NOTES_FILE: ${{ inputs.notes-file }} ASSETS_DIRECTORY: ${{ inputs.assets-directory }} @@ -47,8 +46,6 @@ runs: exit 1 ;; esac - tag_commit=$(gh api "repos/$GH_REPO/commits/refs/tags/$TAG" --jq .sha) - test "$tag_commit" == "$COMMIT" printf '[]\n' >"$RUNNER_TEMP/release-assets.json" if [[ -n "$ASSETS_DIRECTORY" ]]; then shopt -s nullglob dotglob diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index 5db4662..c1e757a 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -56,7 +56,6 @@ runs: - name: Verify release identity shell: bash env: - GH_TOKEN: ${{ inputs.github-token }} TAG: ${{ inputs.tag }} COMMIT: ${{ inputs.commit }} SIGNED_ARTIFACT_ID: ${{ inputs.signed-artifact-id }} @@ -69,23 +68,12 @@ runs: for artifact_id in "$SIGNED_ARTIFACT_ID" "$NOTES_ARTIFACT_ID"; do case "$artifact_id" in '' | *[!0-9]*) exit 1 ;; esac done - target=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq '.object | [.type, .sha] | @tsv') - while true; do - read -r object_type object_sha <<<"$target" - if [[ "$object_type" == commit ]]; then - [[ "$object_sha" == "$COMMIT" ]] || exit 1 - break - fi - [[ "$object_type" == tag ]] || exit 1 - target=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object_sha" --jq '.object | [.type, .sha] | @tsv') - done - name: Find earlier Marketplace upload id: earlier shell: bash env: GH_TOKEN: ${{ inputs.github-token }} TAG: ${{ inputs.tag }} - COMMIT: ${{ inputs.commit }} run: | set -euo pipefail encoded_tag=$(jq -rn --arg tag "$TAG" '$tag | @uri') @@ -93,7 +81,7 @@ runs: --jq '.workflow_runs[] | [.id, .head_branch, .head_sha, .head_repository.full_name] | @tsv' \ >"$RUNNER_TEMP/marketplace-runs.tsv" prior=0 - while IFS=$'\t' read -r run branch sha source; do + while IFS=$'\t' read -r run branch _ source; do [[ "$branch" == "$TAG" && "$source" == "$GITHUB_REPOSITORY" ]] || continue gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$run/jobs?filter=all&per_page=100" \ --jq ".jobs[] | .run_attempt as \$attempt | .steps[] | @@ -102,11 +90,6 @@ runs: >"$RUNNER_TEMP/marketplace-attempts.txt" while IFS= read -r attempt; do if [[ "$run" == "$GITHUB_RUN_ID" && "$attempt" -ge "$GITHUB_RUN_ATTEMPT" ]]; then continue; fi - if [[ "$sha" != "$COMMIT" ]]; then - echo "::error::Tag $TAG had an earlier Marketplace upload on commit $sha." \ - "Reconcile run $run attempt $attempt manually." >&2 - exit 1 - fi gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$run/artifacts?per_page=100" \ --jq '.artifacts[] | [.name, .id, .expired] | @tsv' >"$RUNNER_TEMP/marketplace-artifacts-$run.tsv" receipt_name="marketplace-acceptance-$run-$attempt" diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 82e882d..7039d42 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -126,7 +126,6 @@ def test_existing_published_release_is_verified_without_writes( ), ) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [release] ) @@ -140,7 +139,7 @@ def test_existing_published_release_is_verified_without_writes( assets=assets if with_asset else None, ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET"] assert _release_calls(tmp_path) == ( [("release", "verify-asset", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] if with_asset @@ -175,13 +174,12 @@ def test_existing_release_rejects_changed_content( "assets": [{"name": "extra.zip", "digest": "sha256:deadbeef", "state": "uploaded"}], }[changed_field] prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [release] ) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET"] assert not _release_calls(tmp_path) @@ -196,52 +194,16 @@ def test_mutable_published_release_is_rechecked( notes, commit = _notes_and_commit(repository, tmp_path) release = _release(notes, draft=False) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [release] ) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET", "GET"] assert not _release_calls(tmp_path) -def test_tag_endpoint_must_return_expected_commit( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - release_cli: dict[str, str], -) -> None: - server, environment = github_api - notes, commit = _notes_and_commit(repository, tmp_path) - server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json( - {"sha": "0" * 40} - ) - result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) - assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] - - -def test_missing_tag_rejects_same_name_branch( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - release_cli: dict[str, str], -) -> None: - server, environment = github_api - notes, commit = _notes_and_commit(repository, tmp_path) - run(repository, "git", "branch", "0.1.0", commit) - server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json( - {"message": "Not Found"}, status=404 - ) - result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) - assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] - - @pytest.mark.parametrize( "asset_name", [ @@ -263,13 +225,10 @@ def test_unsafe_asset_names_are_rejected( assets = tmp_path / "assets" assets.mkdir() (assets / asset_name).write_bytes(b"release archive") - server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json( - {"sha": commit} - ) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets) assert result.returncode != 0 assert "unsafe release asset name" in result.stdout - assert [request.method for request, _ in server.log] == ["GET"] + assert not server.log def test_publish_requires_an_existing_draft( @@ -282,11 +241,10 @@ def test_publish_requires_an_existing_draft( server, environment = github_api notes, commit = _notes_and_commit(repository, tmp_path) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="publish") assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET"] def test_staged_release_publishes_without_reupload( @@ -314,7 +272,6 @@ def test_staged_release_publishes_without_reupload( ], ) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) @@ -324,7 +281,7 @@ def test_staged_release_publishes_without_reupload( repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets, mode="publish" ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET", "GET", "PATCH"] + assert [request.method for request, _ in server.log] == ["GET", "GET", "PATCH"] assert _release_calls(tmp_path) == [ ("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture") ] @@ -343,7 +300,6 @@ def test_missing_release_is_created_then_optionally_published( notes, commit = _notes_and_commit(repository, tmp_path) draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) @@ -354,7 +310,6 @@ def test_missing_release_is_created_then_optionally_published( result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode=mode) assert result.returncode == 0, result.stderr assert [request.method for request, _ in server.log] == [ - "GET", "GET", "POST", "GET", @@ -400,7 +355,6 @@ def test_draft_release_uploads_missing_asset( draft = _release(notes, draft=True) complete = _release(notes, draft=True, assets=[asset]) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) @@ -433,7 +387,6 @@ def test_draft_release_uploads_missing_asset( "GET", "GET", "GET", - "GET", *(["PATCH"] if mode == "complete" else []), ] @@ -468,7 +421,6 @@ def test_draft_release_replaces_incomplete_asset( ], ) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [incomplete] ) @@ -487,7 +439,7 @@ def test_draft_release_replaces_incomplete_asset( ) assert result.returncode == 0, result.stderr assert ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture") in _release_calls(tmp_path) - assert [request.method for request, _ in server.log] == ["GET", "GET", "DELETE", "GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET", "DELETE", "GET", "GET"] def test_changed_draft_stops_before_upload( @@ -504,7 +456,6 @@ def test_changed_draft_stops_before_upload( (assets / "fixture.zip").write_bytes(b"release archive") draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) @@ -515,7 +466,7 @@ def test_changed_draft_stops_before_upload( result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets) assert result.returncode != 0 assert not _release_calls(tmp_path) - assert [request.method for request, _ in server.log] == ["GET"] * 8 + assert [request.method for request, _ in server.log] == ["GET"] * 7 def test_failed_upload_deletes_partial_asset_before_retry( @@ -547,7 +498,6 @@ def test_failed_upload_deletes_partial_asset_before_retry( ], ) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) @@ -568,7 +518,7 @@ def test_failed_upload_deletes_partial_asset_before_retry( mode="stage", ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET"] * 5 + ["DELETE", "GET"] + assert [request.method for request, _ in server.log] == ["GET"] * 4 + ["DELETE", "GET"] assert _release_calls(tmp_path) == [ ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), @@ -590,7 +540,6 @@ def test_failed_asset_lookup_stops_retry( asset_path.write_bytes(b"release archive") draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) @@ -625,7 +574,6 @@ def test_failed_asset_upload_leaves_draft_unpublished( (assets / "fixture.zip").write_bytes(b"release archive") draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) @@ -641,7 +589,7 @@ def test_failed_asset_upload_leaves_draft_unpublished( assets=assets, ) assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] * 14 + assert [request.method for request, _ in server.log] == ["GET"] * 13 assert ( _release_calls(tmp_path) == [("release", "upload", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] * 6 @@ -669,7 +617,6 @@ def test_upload_failure_recovers_when_asset_is_present( draft = _release(notes, draft=True) complete = _release(notes, draft=True, assets=[asset]) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) @@ -722,7 +669,6 @@ def test_failed_asset_attestation_is_reported( ], ) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [release] ) @@ -737,7 +683,7 @@ def test_failed_asset_attestation_is_reported( ) assert result.returncode != 0 assert "release asset attestation could not be verified" in result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET"] assert ( _release_calls(tmp_path) == [("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] * 6 @@ -754,13 +700,12 @@ def test_duplicate_releases_are_rejected( server, environment = github_api notes, commit = _notes_and_commit(repository, tmp_path) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [_release(notes, draft=True), _release(notes, draft=True)] ) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET"] def test_invalid_mode_rejects_before_api_access( diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index 4e1f301..ee26bd4 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -183,44 +183,6 @@ def test_prior_upload_without_receipt_blocks_retry( assert not httpserver.log -@pytest.mark.parametrize( - "previous_step", - [ - pytest.param("Publish signed ZIP", id="current"), - pytest.param("📤 Publish to JetBrains Marketplace", id="legacy"), - ], -) -def test_prior_upload_on_another_commit_blocks_retry( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, - previous_step: str, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - previous_run=True, - previous_commit="1" * 40, - previous_step=previous_step, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert "earlier Marketplace upload on commit" in result.stderr - assert any( - request.path == "/api/v3/repos/tox-dev/fixture/actions/runs" - and request.query_string == b"branch=v0.1.11&per_page=100" - for request, _ in github_api[0].log - ) - assert not httpserver.log - - def test_prior_upload_from_renamed_workflow_blocks_retry( signed_archive: Path, tmp_path: Path, @@ -487,54 +449,6 @@ def test_current_run_other_deployments_allow_upload( ) -@pytest.mark.parametrize("annotated", [pytest.param(False, id="lightweight"), pytest.param(True, id="annotated")]) -def test_remote_moved_tag_blocks_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, - annotated: bool, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - remote_commit="1" * 40, - annotated=annotated, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert not httpserver.log - - -def test_annotated_tag_targets_release_commit( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - httpserver.expect_request("/api/updates/upload", method="POST").respond_with_data("{}") - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - annotated=True, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode == 0, result.stderr - assert len(httpserver.log) == 1 - - @pytest.mark.parametrize("previous_workflow", ["release.yaml", "old-release.yaml"]) def test_prior_acceptance_with_matching_artifacts_skips_upload( signed_archive: Path, @@ -731,8 +645,6 @@ def publish_action( *, runner_temp: Path | None = None, previous_run: bool = False, - previous_commit: str = "0" * 40, - previous_step: str = "Publish signed ZIP", previous_workflow: str = "release.yaml", previous_deployment: bool = False, previous_deployment_run: int = 41, @@ -754,20 +666,11 @@ def publish_action( receipt_changes: dict[str, str | int] | None = None, prior_signed: bytes | None = None, prior_notes: bytes | None = None, - remote_commit: str = "0" * 40, - annotated: bool = False, fork_run: bool = False, reconciled_attempt: bool = False, **settings: str, ) -> subprocess.CompletedProcess[str]: server, environment = github_api - server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/tags/v0.1.11").respond_with_json( - {"object": {"type": "tag" if annotated else "commit", "sha": "f" * 40 if annotated else remote_commit}} - ) - if annotated: - server.expect_request(f"/api/v3/repos/tox-dev/fixture/git/tags/{'f' * 40}").respond_with_json( - {"object": {"type": "commit", "sha": remote_commit}} - ) runs = ( [ { @@ -786,7 +689,7 @@ def publish_action( { "id": 41, "head_branch": "v0.1.11", - "head_sha": previous_commit, + "head_sha": "0" * 40, "head_repository": {"full_name": "tox-dev/fixture"}, "path": f".github/workflows/{previous_workflow}", } @@ -852,7 +755,10 @@ def publish_action( server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/41/jobs").respond_with_json( { "jobs": [ - {"run_attempt": attempt, "steps": [{"name": previous_step, "started_at": "2026-10-23T00:00:00Z"}]} + { + "run_attempt": attempt, + "steps": [{"name": "Publish signed ZIP", "started_at": "2026-10-23T00:00:00Z"}], + } for attempt in ((1, 2) if reconciled_attempt else (1,)) ] } diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index b74599a..e42f22c 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -8,7 +8,6 @@ import pytest import yaml -from pytest_httpserver import HTTPServer @pytest.mark.parametrize( @@ -84,7 +83,6 @@ def test_release_writer_has_only_notes_and_reconciler() -> None: assert publish["needs"] == "notes" assert [step.get("uses", step.get("name")) for step in publish["steps"]] == [ "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333", - "Require the tag to still name the release commit", "$/publish-github-release", ] assert publish["steps"][-1]["with"]["commit"] == "${{ github.sha }}" @@ -125,33 +123,6 @@ def test_action_manifests_have_no_empty_environment_mapping() -> None: assert all("env" not in step or isinstance(step["env"], dict) for step in action["runs"]["steps"]), path -@pytest.mark.parametrize(("tag_commit", "accepted"), [("a" * 40, True), ("b" * 40, False)], ids=["same", "moved"]) -def test_release_writer_requires_the_tag_at_the_event_commit( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], tag_commit: str, accepted: bool -) -> None: - server, environment = github_api - server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/1.0.0").respond_with_json( - {"sha": tag_commit} - ) - workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) - step = next( - step - for step in workflow["jobs"]["publish"]["steps"] - if step.get("name") == "Require the tag to still name the release commit" - ) - - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", "-c", step["run"]], - cwd=tmp_path, - env=os.environ | environment | {"GITHUB_REPOSITORY": "tox-dev/fixture", "GITHUB_SHA": "a" * 40, "TAG": "1.0.0"}, - text=True, - capture_output=True, - check=False, - ) - - assert (result.returncode == 0) is accepted, result.stderr - - def test_release_notes_render_read_only_from_full_history() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) notes = workflow["jobs"]["notes"] From 282d2fc81b8c50ebace7aac365cd73516900325f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:40:36 -0700 Subject: [PATCH 05/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20drop=20artifact?= =?UTF-8?q?=20shape=20checks=20on=20own=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release jobs build the wheel, sdist and dist directory themselves, so checking their count, version and file types again only catches a writer's mistake. The PyPI digest and attestation checks stay. --- .../workflows/validate-python-release.yaml | 16 ------- README.md | 17 ++++--- build-python-distribution/action.yml | 8 ---- changelog.d/+artifact-shape.change.md | 5 +++ tests/build_python_distribution/test_build.py | 21 --------- .../test_reconcile.py | 32 ------------- tests/validate_python_release/test_tagged.py | 45 ------------------- tests/verify_pypi_files/test_verify.py | 39 +--------------- tools/verify_pypi_files.py | 10 +---- 9 files changed, 15 insertions(+), 178 deletions(-) create mode 100644 changelog.d/+artifact-shape.change.md diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 65ba546..fefd442 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -50,8 +50,6 @@ jobs: env: SOURCE: ${{ steps.constraints.outputs.build-constraints }} run: cp "$SOURCE" "$RUNNER_TEMP/build-constraints.txt" - - name: Require empty distribution directory - run: test ! -d dist || test -z "$(find dist -mindepth 1 -maxdepth 1 -print -quit)" - name: Select build interpreter run: uv venv --python 3.15 "$RUNNER_TEMP/build-python" - name: Build wheel and sdist @@ -64,20 +62,6 @@ jobs: SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) uv build --python "$build_python" --python-preference only-managed --sdist --wheel \ --build-constraints "$RUNNER_TEMP/build-constraints.txt" --require-build-hashes "$SOURCE" --out-dir dist - - name: Match distribution version to tag - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - shopt -s nullglob - version=${TAG#v} - wheels=(dist/*-"$version"-*.whl) - sdists=(dist/*-"$version".tar.gz) - files=(dist/*) - if [[ ${#wheels[@]} -ne 1 || ${#sdists[@]} -ne 1 || ${#files[@]} -ne 2 ]]; then - echo "::error::wheel and sdist versions must match $TAG" >&2 - exit 1 - fi - id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/README.md b/README.md index 4f7e485..86f84c6 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,8 @@ uploads. The tag caller renders the Release body and has the only Contents write ## Validate a Python release `validate-python-release.yaml` runs one `build` job. It exports hashed build constraints from the caller lock, builds -the wheel and sdist, requires exactly one of each at the tag's version, and uploads them. The workflow does not inspect -the built files further; check package metadata and wheel contents in the package's own `tox -e pkg_meta` run. +the wheel and sdist, and uploads them. The workflow does not inspect the built files further; check package metadata and +wheel contents in the package's own `tox -e pkg_meta` run. ```yaml build: @@ -280,10 +280,9 @@ jobs: ## Build a distribution for a pull request -`build-python-distribution` remains available for existing keyless PR build jobs. It checks that `dist/` is empty, -exports hashed build constraints from the caller's lock, and writes a wheel and sdist to `dist/`. It does not upload or -publish them. Tagged release jobs use `validate-python-release.yaml` so lock and release tools run in a separate job -from the distribution build. +`build-python-distribution` remains available for existing keyless PR build jobs. It exports hashed build constraints +from the caller's lock, and writes a wheel and sdist to `dist/`. It does not upload or publish them. Tagged release jobs +use `validate-python-release.yaml`. ```yaml - uses: tox-dev/github-workflows/build-python-distribution@SHA @@ -519,9 +518,9 @@ invalid attestation fails. The GitHub Release writer must depend on this verific ``` `project`, `version`, and `dist` are required. The Action installs `pypi-attestations` on Python 3.15 under the -`exclude-newer` cutoff and waits up to 120 seconds for PyPI's listing to propagate. It checks the complete local file -set, then runs `pypi-attestations verify pypi` on each local file against the caller repository. It prints no -credentials. +`exclude-newer` cutoff and waits up to 120 seconds for PyPI's listing to propagate. It compares every file in `dist` +with that listing, then runs `pypi-attestations verify pypi` on each local file against the caller repository. It prints +no credentials. ## Publish a GitHub Release diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index ec8e9a1..a607a0b 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -10,14 +10,6 @@ inputs: runs: using: composite steps: - - name: Require empty output directory - shell: bash - run: | - set -euo pipefail - if [[ -d dist && -n $(find dist -mindepth 1 -maxdepth 1 -print -quit) ]]; then - echo '::error::distribution output directory is not empty: dist' >&2 - exit 1 - fi - name: Select build interpreter shell: bash run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/build-python" diff --git a/changelog.d/+artifact-shape.change.md b/changelog.d/+artifact-shape.change.md new file mode 100644 index 0000000..4ce67a4 --- /dev/null +++ b/changelog.d/+artifact-shape.change.md @@ -0,0 +1,5 @@ +Stop checking the shape of built artifacts that the release jobs produce themselves. `validate-python-release.yaml` no +longer requires an empty `dist` directory or exactly one wheel and one sdist at the tag's version, and +`build-python-distribution` no longer requires an empty `dist` directory. `reconcile-pypi-artifacts` no longer rejects +an empty `dist`, an invalid project name, or local files that are hidden, symbolic links, directories, or belong to +another release; it compares every file in `dist` with PyPI. diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 9c07b9c..8280032 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -48,27 +48,6 @@ def test_build_python_distribution_repeats_exact_bytes( assert outputs[0] == outputs[1] -def test_build_python_distribution_rejects_existing_artifact( - build_repository: Path, tmp_path: Path, action_scripts: Path -) -> None: - (build_repository / ".gitignore").write_text("dist/\n") - run(build_repository, "git", "add", ".gitignore") - run(build_repository, "git", "commit", "-m", "Ignore build output") - output = build_repository / "dist" - output.mkdir() - (output / "old.whl").write_bytes(b"old") - result = action( - build_repository, - tmp_path, - action_scripts, - "build-python-distribution", - version="0.2.0", - RUNNER_TEMP=str(tmp_path), - ) - assert result.returncode != 0 - assert "distribution output directory is not empty" in result.stderr - - def test_build_python_distribution_rejects_backend_hash_mismatch( build_repository: Path, tmp_path: Path, action_scripts: Path ) -> None: diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py index 5b9b38e..dcb45a4 100644 --- a/tests/reconcile_pypi_artifacts/test_reconcile.py +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -9,38 +9,6 @@ from pytest_httpserver import HTTPServer -@pytest.mark.parametrize( - ("project", "version", "distribution_exists", "error"), - [ - pytest.param("", "1.0", True, "name is invalid", id="missing-project"), - pytest.param("fixture", "", True, "Invalid version", id="missing-version"), - pytest.param("fixture", "1.0", False, "distribution directory does not exist", id="missing-distribution"), - ], -) -def test_reconcile_rejects_invalid_inputs( - tmp_path: Path, - action_scripts: Path, - project: str, - version: str, - distribution_exists: bool, - error: str, -) -> None: - distribution = tmp_path / "dist" - if distribution_exists: - distribution.mkdir() - result = action( - Path(__file__).resolve().parents[2], - tmp_path, - action_scripts, - "reconcile-pypi-artifacts", - version=version, - PROJECT=project, - DIST=str(distribution), - ) - assert result.returncode != 0 - assert error in result.stderr - - @pytest.mark.parametrize( ("remote_extra", "wrong_digest", "expected_error"), [ diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index 6619738..fd5ebc8 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -77,13 +77,6 @@ def test_validator_builds_reproducible_distributions( assert outputs[0] == outputs[1] -def test_validator_rejects_nonempty_distribution_directory(tmp_path: Path, release_workflow: dict[str, Any]) -> None: - (tmp_path / "dist").mkdir() - (tmp_path / "dist" / "old.whl").write_bytes(b"old") - result = run_release_step(release_workflow, "build", "Require empty distribution directory", tmp_path) - assert result.returncode != 0 - - def test_validator_rejects_backend_hash_mismatch( build_repository: Path, build_constraints: Path, tmp_path: Path, release_workflow: dict[str, Any] ) -> None: @@ -102,41 +95,3 @@ def test_validator_rejects_backend_hash_mismatch( result = run_release_step(release_workflow, "build", "Build wheel and sdist", build_repository, **environment) assert result.returncode != 0 assert "Hash mismatch" in result.stderr - - -@pytest.mark.parametrize( - ("tag", "filenames", "accepted"), - [ - pytest.param("1.2.3", ("example-1.2.3-py3-none-any.whl", "example-1.2.3.tar.gz"), True, id="matching"), - pytest.param("v1.2.3", ("example-1.2.3-py3-none-any.whl", "example-1.2.3.tar.gz"), True, id="v-prefix"), - pytest.param("1.2.3", ("example-1.2.4-py3-none-any.whl", "example-1.2.3.tar.gz"), False, id="wrong-wheel"), - pytest.param("1.2.3", ("example-1.2.3-py3-none-any.whl", "example-1.2.4.tar.gz"), False, id="wrong-sdist"), - pytest.param( - "1.2.3", - ("example-1.2.3-py3-none-any.whl", "example-1.2.3.tar.gz", "unexpected.txt"), - False, - id="extra-file", - ), - ], -) -def test_validator_matches_built_version_to_tag( - tmp_path: Path, tag: str, filenames: tuple[str, ...], accepted: bool -) -> None: - (tmp_path / "dist").mkdir() - for filename in filenames: - (tmp_path / "dist" / filename).touch() - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text() - ) - check = next( - step for step in workflow["jobs"]["build"]["steps"] if step.get("name") == "Match distribution version to tag" - ) - result = subprocess.run( - ["bash", "-c", check["run"]], - cwd=tmp_path, - env=os.environ | {"TAG": tag}, - text=True, - capture_output=True, - check=False, - ) - assert (result.returncode == 0) is accepted, result.stderr diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index dc06b58..367eb0c 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -110,7 +110,7 @@ def test_main_reports_invalid_directory( cast("Callable[[], None]", _MODULE.main)() assert raised.value.code == 2 - assert "distribution directory does not exist" in capsys.readouterr().err + assert "No such file or directory" in capsys.readouterr().err def test_script_entrypoint_prints_distribution_paths( @@ -127,23 +127,6 @@ def test_script_entrypoint_prints_distribution_paths( ] -def test_verify_files_rejects_missing_directory(tmp_path: Path) -> None: - with pytest.raises(ValueError, match="distribution directory does not exist"): - verify_files("fixture", "1.0", tmp_path / "missing") - - -def test_verify_files_rejects_empty_directory(tmp_path: Path) -> None: - with pytest.raises(ValueError, match="distribution directory is empty"): - verify_files("fixture", "1.0", tmp_path) - - -def test_verify_files_rejects_directory_entry(distribution: Path) -> None: - (distribution / "nested").mkdir() - - with pytest.raises(ValueError, match="invalid distribution path"): - verify_files("fixture", "1.0", distribution) - - def test_verify_files_ignores_other_release_wheel(distribution: Path, mocker: MockerFixture) -> None: files = _listing(distribution) files.append({"filename": "fixture-0.9-py3-none-any.whl", "hashes": {"sha256": "0" * 64}, "yanked": False}) @@ -283,26 +266,6 @@ def test_verify_files_rejects_bad_remote_release( verify_files("fixture", "1.0", distribution) -@pytest.mark.parametrize( - ("change", "message"), - [ - pytest.param("wrong-version", "another release", id="wrong-version"), - pytest.param("symlink", "invalid distribution path", id="symlink"), - pytest.param("hidden", "invalid distribution path", id="hidden"), - ], -) -def test_verify_files_rejects_bad_local_release(distribution: Path, change: str, message: str) -> None: - if change == "wrong-version": - (distribution / "fixture-2.0-py3-none-any.whl").write_bytes(b"other") - elif change == "symlink": - (distribution / "link.whl").symlink_to(distribution / "fixture-1.0-py3-none-any.whl") - else: - (distribution / ".hidden").write_bytes(b"other") - - with pytest.raises(ValueError, match=message): - verify_files("fixture", "1.0", distribution) - - @pytest.mark.usefixtures("waiting_clock") def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: MockerFixture) -> None: files = _listing(distribution) diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index 56ee3fb..37c743e 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -44,20 +44,12 @@ def main() -> None: def verify_files(project: str, version: str, dist: Path) -> list[Path]: - if not dist.is_dir(): - raise ValueError(f"distribution directory does not exist: {dist}") - name = canonicalize_name(project, validate=True) + name = canonicalize_name(project) release = Version(version) local: dict[str, str] = {} for path in dist.iterdir(): - if path.is_symlink() or not path.is_file() or path.name.startswith("."): - raise ValueError(f"invalid distribution path: {path}") - if _identity(path.name) != (name, release): - raise ValueError(f"distribution belongs to another release: {path.name}") with path.open("rb") as stream: local[path.name] = hashlib.file_digest(stream, "sha256").hexdigest() - if not local: - raise ValueError(f"distribution directory is empty: {dist}") deadline = time.monotonic() + 120 delay = 1 while True: From 31832d7d948bff0f751a72217e8629b16c87a6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:42:56 -0700 Subject: [PATCH 06/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20stop=20prepare=20?= =?UTF-8?q?re-validating=20its=20own=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validate job recalculated the version and ran release-creator and prek on the patch assemble had just built, and validate-release-patch and commit-and-tag re-checked the branch and working tree. The push job keeps applying the patch with its path and mode checks. --- .../workflows/prepare-towncrier-release.yaml | 51 +----- README.md | 71 ++++----- .../+commit-and-tag-clean-tree.change.md | 1 + changelog.d/+prepare-validate-job.breaking.md | 3 + ...+validate-release-patch-checks.breaking.md | 4 + commit-and-tag/action.yml | 4 - pylock.toml | 31 ++-- pyproject.toml | 1 - tests/commit_and_tag/test_commit.py | 24 --- tests/conftest.py | 1 - tests/fixtures/caller/pylock.toml | 33 ++-- tests/fixtures/caller/pyproject.toml | 1 - tests/install_locked_tools/test_install.py | 1 - tests/release_workflow/test_graph.py | 68 -------- tests/validate_release_patch/test_validate.py | 150 ------------------ validate-release-patch/action.yml | 80 +--------- 16 files changed, 72 insertions(+), 452 deletions(-) create mode 100644 changelog.d/+commit-and-tag-clean-tree.change.md create mode 100644 changelog.d/+prepare-validate-job.breaking.md create mode 100644 changelog.d/+validate-release-patch-checks.breaking.md diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 1bb8a77..29aa39b 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -90,54 +90,8 @@ jobs: name: release-patch path: ${{ runner.temp }}/release.patch if-no-files-found: error - validate: - needs: assemble - runs-on: ubuntu-26.04 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - - uses: $/install-locked-tools - with: - groups: release-validate - venv: .venv - exclude-newer: ${{ inputs.exclude-newer }} - exclude-newer-package: ${{ inputs.exclude-newer-package }} - - id: config - uses: $/read-towncrier-config - - name: Verify selected release version - env: - BUMP: ${{ inputs.bump }} - TAG_PREFIX: ${{ inputs.tag-prefix }} - FRAGMENTS: ${{ steps.config.outputs.fragments }} - VERSION: ${{ needs.assemble.outputs.version }} - run: | - set -euo pipefail - args=(--next "$BUMP" --directory "$FRAGMENTS") - if [[ -n "$TAG_PREFIX" ]]; then args+=(--prefix "$TAG_PREFIX"); fi - expected=$(git tag --list | release-creator tags "${args[@]}") - if [[ "$VERSION" != "$expected" ]]; then - echo "::error::assembled release version differs from $expected" >&2 - exit 1 - fi - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: ${{ needs.assemble.outputs.artifact-id }} - path: ${{ runner.temp }}/release-patch - - uses: $/validate-release-patch - with: - version: ${{ needs.assemble.outputs.version }} push: - needs: [assemble, validate] + needs: assemble runs-on: ubuntu-26.04 environment: name: release-auth @@ -154,9 +108,6 @@ jobs: artifact-ids: ${{ needs.assemble.outputs.artifact-id }} path: ${{ runner.temp }}/release-patch - uses: $/validate-release-patch - with: - version: ${{ needs.assemble.outputs.version }} - checks: "false" - id: repository name: Select the current repository env: diff --git a/README.md b/README.md index 86f84c6..c450060 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ The Action only checks that the selected groups exist in the lock; installs are Nab: `refresh-release-lock` in `check` mode regenerates the lock and fails if it differs. It runs on pull requests through `validate-news`. The cutoff and any per-package exception live in the caller's `[tool.nab]` table. -Select the groups each job needs. The shared workflows use `release-prepare` and `release-validate`. A full lock records -each group and extra, but a new combination still needs an install check before a caller uses it. +Select the groups each job needs. The shared prepare workflow uses `release-prepare`. A full lock records each group and +extra, but a new combination still needs an install check before a caller uses it. - `venv`: directory for the virtual environment when `groups` is set. - `groups`: whitespace-separated release dependency groups. Leave empty to export build constraints without installing @@ -169,37 +169,35 @@ refresh produces a patch. Neither mode pushes a branch or requests a write token Every shared `prek` command runs through `tools/cooled_prek.sh`, which the `cooled-prek` Action wraps for workflows, and passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and also runs nested -configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore reject a `prek.toml` -anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before they run `prek`. +configurations, which no guard checks. `prepare-towncrier` therefore rejects a `prek.toml` anywhere, a +`.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before it runs `prek`. ## Start with a caller The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable -Towncrier workflow. Its `assemble` job uploads a patch; a separate keyless `validate` job downloads and checks that -artifact. The `push` job downloads the same artifact by ID and checks its paths and modes before minting a -repository-scoped App token. Set the tag style in the caller and the changelog path and fragment directory in committed -Towncrier configuration. Pin the reusable workflow to a release commit SHA; `SHA` in these examples means that commit. +Towncrier workflow. Its `assemble` job uploads a patch. The `push` job downloads that artifact by ID and checks its +paths and modes before minting a repository-scoped App token. Set the tag style in the caller and the changelog path and +fragment directory in committed Towncrier configuration. Pin the reusable workflow to a release commit SHA; `SHA` in +these examples means that commit. Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. All jobs use `persist-credentials: false`. Assembly installs the caller's locked release tools and needs `contents: read` and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while building the changelog, then waits -for the hooks before formatting and uploading the patch. The validation job runs `prek` on that uploaded patch. The -writer uses the `release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that -environment to `main`. Install the App on this repository only, with Contents write and Metadata read, and grant the -necessary branch and tag ruleset bypass. +for the hooks before formatting and uploading the patch. The writer uses the `release-auth` environment with +`RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that environment to `main`. Install the App on this +repository only, with Contents write and Metadata read, and grant the necessary branch and tag ruleset bypass. ```text main + fragments │ ▼ -prepare-towncrier ──► upload patch ──► validate artifact - │ │ - └─────────┬─────────┘ - ▼ - verify paths and modes - │ - ▼ - mint App token ──► commit-and-tag ──► main + tag +prepare-towncrier ──► upload patch + │ + ▼ + verify paths and modes + │ + ▼ + mint App token ──► commit-and-tag ──► main + tag ``` The tag starts the caller's publisher. Keep PyPI trusted publishing in that top-level workflow, in a separate job with @@ -310,9 +308,8 @@ build package versions; otherwise the Action fails instead of writing an ambiguo ## Prepare and tag with one reusable workflow -`prepare-towncrier-release.yaml` runs three jobs. `assemble` selects a tag and uploads a Towncrier patch without the App -key. `validate` calculates the next tag again from the checked-out source, then applies the uploaded artifact and runs -`prek`. `push` downloads that artifact by ID, checks its paths and file modes, reads the caller's `release-auth` +`prepare-towncrier-release.yaml` runs two jobs. `assemble` selects a tag and uploads a Towncrier patch without the App +key. `push` downloads that artifact by ID, checks its paths and file modes, reads the caller's `release-auth` environment, and uses a repository-scoped App token to push the release commit and tag together. ```yaml @@ -402,32 +399,24 @@ those changes. ## Validate release patch -`validate-release-patch` runs in the caller's fresh checkout of the original branch commit. It rejects a `main` branch -that moved, applies the uploaded patch, checks its changed paths and file modes, then asks `release-creator` to verify -that every deleted file is a configured Towncrier fragment. It renders the requested version and runs `prek`. It leaves -the patch staged for the commit Action. Download the patch artifact to `${{ runner.temp }}/release-patch` first. Run it -in a keyless validation job. The App-key job repeats the branch, path, and mode checks with `checks: "false"`, without -installing release tools. +`validate-release-patch` applies the uploaded patch in the writer's checkout, checks its changed paths and file modes, +and leaves it staged for the commit Action. Download the patch artifact to `${{ runner.temp }}/release-patch` first. It +needs no release tools. ```yaml - uses: tox-dev/github-workflows/validate-release-patch@SHA - with: - version: ${{ needs.assemble.outputs.version }} ``` -- `version` **(required)**: Exact tag chosen by preparation. `release-creator notes` must find one valid section for it. -- `checks`: `true` (default) runs `release-creator` and `prek`; `false` only applies and checks the validated patch. - -**Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path, -fragment directory, and categories from committed Towncrier configuration. Only the changelog and direct fragment -deletions may appear in the patch. A failed `prek` run or an unexpected path or mode fails the keyless job. +**Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path +and fragment directory from committed Towncrier configuration. Only the changelog and direct fragment deletions may +appear in the patch, as regular files. An unexpected path or mode fails the job. ## Commit and tag release -`commit-and-tag` accepts only a staged release patch in a clean working tree. It commits with the GitHub App bot -identity, creates the tag, and atomically pushes the branch and tag to `origin`. A rejected push leaves neither remote -ref updated. Mint the token after validation with `actions/create-github-app-token`, narrowed to this repository and -Contents write. Put `commit-and-tag` last in the writer job: its Git credential helper remains available to later steps. +`commit-and-tag` commits the staged release patch with the GitHub App bot identity, creates the tag, and atomically +pushes the branch and tag to `origin`. A rejected push leaves neither remote ref updated. Mint the token after +validation with `actions/create-github-app-token`, narrowed to this repository and Contents write. Put `commit-and-tag` +last in the writer job: its Git credential helper remains available to later steps. ```yaml - id: token diff --git a/changelog.d/+commit-and-tag-clean-tree.change.md b/changelog.d/+commit-and-tag-clean-tree.change.md new file mode 100644 index 0000000..17d082e --- /dev/null +++ b/changelog.d/+commit-and-tag-clean-tree.change.md @@ -0,0 +1 @@ +Stop `commit-and-tag` from requiring a clean working tree with only a staged patch; it commits whatever is staged. diff --git a/changelog.d/+prepare-validate-job.breaking.md b/changelog.d/+prepare-validate-job.breaking.md new file mode 100644 index 0000000..e6a0c7a --- /dev/null +++ b/changelog.d/+prepare-validate-job.breaking.md @@ -0,0 +1,3 @@ +Remove the `validate` job from `prepare-towncrier-release.yaml`, which recalculated the version and ran +`release-creator` and `prek` on the patch that `assemble` had just produced; `push` now depends on `assemble` alone. The +workflow no longer installs the `release-validate` group. diff --git a/changelog.d/+validate-release-patch-checks.breaking.md b/changelog.d/+validate-release-patch-checks.breaking.md new file mode 100644 index 0000000..289b591 --- /dev/null +++ b/changelog.d/+validate-release-patch-checks.breaking.md @@ -0,0 +1,4 @@ +Reduce `validate-release-patch` to applying the patch and checking its paths and file modes, and remove its `version` +and `checks` inputs; remove them from callers. It no longer rejects a `main` branch that moved, a patch that leaves the +changelog unchanged, or RST conflict markers, and it no longer runs `release-creator validate-fragment-deletions`, +`release-creator notes`, the hook configuration check, or `prek`. diff --git a/commit-and-tag/action.yml b/commit-and-tag/action.yml index 8b15129..237443d 100644 --- a/commit-and-tag/action.yml +++ b/commit-and-tag/action.yml @@ -25,10 +25,6 @@ runs: ANNOTATED: ${{ inputs.annotated }} run: | set -euo pipefail - if git diff --cached --quiet || ! git diff --quiet || [[ -n $(git ls-files --others --exclude-standard) ]]; then - echo '::error::expected only a staged release patch' >&2 - exit 1 - fi if [[ "$ANNOTATED" != true && "$ANNOTATED" != false ]]; then echo '::error::annotated must be true or false' >&2 exit 1 diff --git a/pylock.toml b/pylock.toml index 21d33b4..bcb7a45 100644 --- a/pylock.toml +++ b/pylock.toml @@ -6,7 +6,6 @@ requires-python = ">=3.15" dependency-groups = [ "build", "release-prepare", - "release-validate", "test", ] created-by = "nab" @@ -92,7 +91,7 @@ sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" [[packages]] name = "click" version = "8.5.0" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" @@ -334,7 +333,7 @@ sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" [[packages]] name = "docutils" version = "0.23" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -491,7 +490,7 @@ sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" [[packages]] name = "jinja2" version = "3.1.6" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "markupsafe" }, @@ -519,7 +518,7 @@ sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" [[packages]] name = "markdown-it-py" version = "4.2.0" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "mdurl" }, @@ -547,7 +546,7 @@ sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" version = "3.0.4" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -572,7 +571,7 @@ sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" [[packages]] name = "mdformat" version = "1.0.0" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, @@ -600,7 +599,7 @@ sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" [[packages]] name = "mdformat-gfm" version = "1.0.0" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, @@ -631,7 +630,7 @@ sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" [[packages]] name = "mdit-py-plugins" version = "0.6.1" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, @@ -659,7 +658,7 @@ sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" [[packages]] name = "mdurl" version = "0.1.2" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" @@ -684,7 +683,7 @@ sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" [[packages]] name = "packaging" version = "26.3" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -784,7 +783,7 @@ sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" [[packages]] name = "prek" version = "0.5.5" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" @@ -1031,7 +1030,7 @@ sha256 = "cb7654125e3dcb594269a6feb56ac693f1f13b7c654493a93eba72dc22d18034" [[packages]] name = "pyyaml" version = "6.0.3" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" @@ -1047,7 +1046,7 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" version = "3.1.0" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.15" dependencies = [ { name = "docutils" }, @@ -1156,7 +1155,7 @@ sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" [[packages]] name = "towncrier" version = "26.9.0" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "click" }, @@ -1406,7 +1405,7 @@ sha256 = "b319f8590a84e5de5f8f2c803ecd2237e9bc0ffa7c1e875e3169c107cecb7454" [[packages]] name = "wcwidth" version = "0.9.2" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" diff --git a/pyproject.toml b/pyproject.toml index 1107a17..e131a67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] release-prepare = [ "release-creator>=3.1.0", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=3.1.0", "prek>=0.2" ] test = [ "certifi", "coverage[toml]>=7.10", diff --git a/tests/commit_and_tag/test_commit.py b/tests/commit_and_tag/test_commit.py index 3efc6ac..8e3db60 100644 --- a/tests/commit_and_tag/test_commit.py +++ b/tests/commit_and_tag/test_commit.py @@ -74,30 +74,6 @@ def test_commit_and_tag_rejects_invalid_tag(repository: Path, tmp_path: Path, ac assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == original_remote -def test_commit_and_tag_rejects_empty_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - result = action(repository, tmp_path, action_scripts, "commit-and-tag", version="0.2.0") - assert result.returncode != 0 - assert "expected only a staged release patch" in result.stderr - - -def test_commit_and_tag_rejects_unstaged_changes(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "CHANGELOG.md").write_text("Staged release.\n") - run(repository, "git", "add", "CHANGELOG.md") - (repository / "CHANGELOG.md").write_text("Unstaged changes.\n") - result = action(repository, tmp_path, action_scripts, "commit-and-tag", version="0.2.0") - assert result.returncode != 0 - assert "expected only a staged release patch" in result.stderr - - -def test_commit_and_tag_rejects_untracked_file(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "CHANGELOG.md").write_text("Staged release.\n") - run(repository, "git", "add", "CHANGELOG.md") - (repository / "untracked").write_text("unexpected\n") - result = action(repository, tmp_path, action_scripts, "commit-and-tag", version="0.2.0") - assert result.returncode != 0 - assert "expected only a staged release patch" in result.stderr - - def test_commit_and_tag_rejects_invalid_annotated(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "CHANGELOG.md").write_text("Staged release.\n") run(repository, "git", "add", "CHANGELOG.md") diff --git a/tests/conftest.py b/tests/conftest.py index 2a315b0..68b0660 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -260,7 +260,6 @@ def action( "CHANGELOG": "CHANGELOG.md", "FRAGMENTS": "changelog.d", "FORMAT": action_config.get("inputs", {}).get("format", {}).get("default", "none"), - "CHECKS": "true", "BRANCH": "main", "ANNOTATED": "false", "ARTIFACT_URL": "", diff --git a/tests/fixtures/caller/pylock.toml b/tests/fixtures/caller/pylock.toml index 073607f..1f2d170 100644 --- a/tests/fixtures/caller/pylock.toml +++ b/tests/fixtures/caller/pylock.toml @@ -10,7 +10,6 @@ dependency-groups = [ "release-notes", "release-news", "release-prepare", - "release-validate", "release-check", "release-shell", "test", @@ -786,7 +785,7 @@ sha256 = "5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874" [[packages]] name = "click" version = "8.5.0" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -1566,7 +1565,7 @@ sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" [[packages]] name = "docutils" version = "0.23" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -1830,7 +1829,7 @@ sha256 = "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" [[packages]] name = "jinja2" version = "3.1.6" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -1874,7 +1873,7 @@ sha256 = "be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" [[packages]] name = "markdown-it-py" version = "4.2.0" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -1896,7 +1895,7 @@ sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" version = "3.0.4" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -2190,7 +2189,7 @@ sha256 = "39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba" [[packages]] name = "mdformat" version = "1.0.0" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -2212,7 +2211,7 @@ sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" [[packages]] name = "mdformat-gfm" version = "1.0.0" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -2234,7 +2233,7 @@ sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" [[packages]] name = "mdit-py-plugins" version = "0.6.1" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -2256,7 +2255,7 @@ sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" [[packages]] name = "mdurl" version = "0.1.2" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -2574,7 +2573,7 @@ sha256 = "f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a" [[packages]] name = "packaging" version = "26.3" -marker = "\"lock-reader\" in dependency_groups or \"lock-tools\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"lock-reader\" in dependency_groups or \"lock-tools\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -2662,7 +2661,7 @@ sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" [[packages]] name = "prek" version = "0.5.5" -marker = "\"release-check\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"release-check\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -3244,7 +3243,7 @@ sha256 = "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" [[packages]] name = "pyyaml" version = "6.0.3" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -3280,7 +3279,7 @@ sha256 = "d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560" [[packages]] name = "release-creator" version = "2.0.0" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -3600,7 +3599,7 @@ sha256 = "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b" [[packages]] name = "tomli-w" version = "1.2.0" -marker = "\"lock-tools\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"lock-tools\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -3644,7 +3643,7 @@ sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" [[packages]] name = "towncrier" version = "26.9.0" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -4208,7 +4207,7 @@ sha256 = "b319f8590a84e5de5f8f2c803ecd2237e9bc0ffa7c1e875e3169c107cecb7454" [[packages]] name = "wcwidth" version = "0.9.2" -marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] diff --git a/tests/fixtures/caller/pyproject.toml b/tests/fixtures/caller/pyproject.toml index abab5b5..1ed846c 100644 --- a/tests/fixtures/caller/pyproject.toml +++ b/tests/fixtures/caller/pyproject.toml @@ -14,7 +14,6 @@ lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] release-notes = [ "release-creator>=2.0.0" ] release-news = [ "release-creator>=2.0.0", "towncrier>=26.9" ] release-prepare = [ "release-creator>=2.0.0", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=2.0.0", "prek>=0.2" ] release-check = [ "prek>=0.2" ] release-shell = [ "prek>=0.2", "yq>=4.4.5" ] test = [ diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 2154cd7..1f2688f 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -47,7 +47,6 @@ def test_install_locked_tools_installs_groups( [ pytest.param("release-check", "prek", "release-creator", id="source-check"), pytest.param("release-news", "towncrier", "prek", id="pr-news"), - pytest.param("release-validate", "release-creator", "yq", id="patch-validation"), pytest.param("release-shell", "yq", "release-creator", id="inline-shell"), ], ) diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index e42f22c..a2c6ad0 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -3,79 +3,11 @@ import os import shutil import subprocess -import sys from pathlib import Path -import pytest import yaml -@pytest.mark.parametrize( - ("version", "prefix", "accepted"), - [ - pytest.param("1.2.3", "", True, id="calculated-version"), - pytest.param("v1.2.3", "v", True, id="prefixed-version"), - pytest.param("9.9.9", "", False, id="forged-version"), - ], -) -def test_prepare_validation_calculates_version_independently( - tmp_path: Path, version: str, prefix: str, accepted: bool -) -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() - ) - steps = workflow["jobs"]["validate"]["steps"] - verify = next(step for step in steps if step.get("name") == "Verify selected release version") - command = tmp_path / "bin/release-creator" - command.parent.mkdir() - expected_arguments = ["tags", "--next", "auto", "--directory", "changelog.d"] - if prefix: - expected_arguments.extend(["--prefix", prefix]) - command.write_text( - f"#!{sys.executable}\nimport sys\nassert sys.argv[1:] == {expected_arguments!r}\n" - f"assert sys.stdin.read() == {f'{prefix}1.2.2' + chr(10)!r}\nprint({f'{prefix}1.2.3'!r})\n" - ) - command.chmod(0o755) - subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) - subprocess.run( - [ - "git", - "-c", - "user.name=Test", - "-c", - "user.email=test@example.org", - "commit", - "-q", - "--allow-empty", - "-m", - "x", - ], - cwd=tmp_path, - check=True, - ) - subprocess.run(["git", "tag", f"{prefix}1.2.2"], cwd=tmp_path, check=True) - - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", "-c", verify["run"]], - cwd=tmp_path, - env={ - **os.environ, - "PATH": f"{command.parent}{os.pathsep}{os.environ['PATH']}", - "BUMP": "auto", - "TAG_PREFIX": prefix, - "FRAGMENTS": "changelog.d", - "VERSION": version, - }, - capture_output=True, - text=True, - check=False, - ) - - assert (result.returncode == 0) is accepted - if not accepted: - assert "assembled release version differs from 1.2.3" in result.stderr - - def test_release_writer_has_only_notes_and_reconciler() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) publish = workflow["jobs"]["publish"] diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index 3551d33..683e0b0 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -66,80 +66,6 @@ def test_validate_rst_heading(repository: Path, tmp_path: Path, action_scripts: assert result.returncode == 0, result.stderr -@pytest.mark.parametrize( - "marker", - [ - pytest.param("<<<<<<< HEAD", id="ours"), - pytest.param("||||||| base", id="base"), - pytest.param(">>>>>>> branch", id="theirs"), - ], -) -def test_validate_rejects_rst_conflict_marker( - repository: Path, tmp_path: Path, action_scripts: Path, marker: str -) -> None: - changelog = repository / "CHANGELOG.rst" - track_rst_changelog(repository) - changelog.write_text(f"Changelog\n=========\n\n0.2.0\n=====\n\n{marker}\n") - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") - assert result.returncode != 0 - assert "release changelog contains conflict markers" in result.stderr - - -@pytest.mark.parametrize("tracked", [pytest.param(True, id="tracked"), pytest.param(False, id="first-release")]) -def test_validate_rejects_staged_rst_conflict_marker( - repository: Path, tmp_path: Path, action_scripts: Path, tracked: bool -) -> None: - track_rst_changelog(repository) - changelog = repository / "CHANGELOG.rst" - if not tracked: - run(repository, "git", "rm", "CHANGELOG.rst") - run(repository, "git", "commit", "-m", "Remove RST changelog") - run(repository, "git", "push", "origin", "main") - changelog.write_text("Changelog\n=========\n\n<<<<<<< HEAD\n") - if not tracked: - run(repository, "git", "add", "-N", "CHANGELOG.rst") - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") - assert result.returncode != 0 - assert "conflict markers" in result.stderr - - -def test_validate_rejects_unchanged_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - fragment = repository / "changelog.d/+feature.feature.md" - fragment.unlink() - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") - assert result.returncode != 0 - assert "release patch does not change CHANGELOG.md" in result.stderr - - -def test_validate_rejects_stale_branch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - result = action( - repository, - tmp_path, - action_scripts, - "validate-release-patch", - version="0.2.0", - GITHUB_SHA="0000000000000000000000000000000000000000", - ) - assert result.returncode != 0 - assert "changed during release preparation" in result.stderr - - -def test_validate_rejects_unknown_check_mode(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", CHECKS="sometimes") - assert result.returncode != 0 - assert "checks must be true or false" in result.stderr - - def test_validate_key_job_uses_only_checked_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 run(repository, "git", "reset", "--hard", "HEAD") @@ -149,7 +75,6 @@ def test_validate_key_job_uses_only_checked_patch(repository: Path, tmp_path: Pa action_scripts, "validate-release-patch", version="0.2.0", - CHECKS="false", PATH="/usr/bin:/bin", ) assert result.returncode == 0, result.stderr @@ -168,11 +93,7 @@ def test_validate_rejects_unexpected_path(repository: Path, tmp_path: Path, acti @pytest.mark.parametrize( "fragment", [ - pytest.param("evil.py", id="wrong-extension"), pytest.param("nested/7.feature.md", id="nested"), - pytest.param("evil.feature.md", id="unsupported-stem"), - pytest.param("7.bugfix.md", id="unconfigured-category"), - pytest.param("7.feature.rst", id="wrong-format"), pytest.param("7.feature.md", id="added-valid-fragment"), ], ) @@ -193,7 +114,6 @@ def test_validate_rejects_unsupported_fragment_path( action_scripts, "validate-release-patch", version="0.2.0", - CHECKS="false", ) assert result.returncode != 0 assert "unexpected release path status" in result.stderr @@ -215,7 +135,6 @@ def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Pa action_scripts, "validate-release-patch", version="0.2.0", - CHECKS="false", ) assert result.returncode != 0 assert "unexpected release path status" in result.stderr @@ -252,7 +171,6 @@ def test_validate_requires_explicit_towncrier_config( action_scripts, "validate-release-patch", version="0.2.0", - CHECKS="false", ) assert result.returncode != 0 assert error in result.stderr @@ -295,7 +213,6 @@ def test_validate_rejects_nonregular_deleted_fragment(repository: Path, tmp_path action_scripts, "validate-release-patch", version="0.2.0", - CHECKS="false", ) assert result.returncode != 0 assert "unexpected previous release file mode" in result.stderr @@ -308,70 +225,3 @@ def test_validate_rejects_removed_changelog(repository: Path, tmp_path: Path, ac result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 assert "unexpected release path" in result.stderr - - -def test_validate_rejects_missing_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "CHANGELOG.md").write_text("# Changelog\n\nOther notes.\n") - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "--binary").stdout) - run(repository, "git", "restore", ".") - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") - assert result.returncode != 0 - assert "expected one changelog section for 0.2.0, found 0" in result.stderr - - -def test_validate_rejects_embedded_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "CHANGELOG.md").write_text("# Changelog\n\n- Link to ## 0.2.0 (2026-10-19)\n") - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "--binary").stdout) - run(repository, "git", "restore", ".") - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") - assert result.returncode != 0 - assert "expected one changelog section for 0.2.0, found 0" in result.stderr - - -def test_validate_rejects_prek_changes(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - config = repository / ".pre-commit-config.yaml" - config.write_text( - config.read_text().replace( - "entry: bash -c true", - "entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then printf changed >> CHANGELOG.md; fi'", - ), - ) - run(repository, "git", "add", ".") - run(repository, "git", "commit", "-m", "Check release tree") - run(repository, "git", "push", "origin", "main") - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - result = action( - repository, - tmp_path, - action_scripts, - "validate-release-patch", - version="0.2.0", - VALIDATE_REHEARSAL="1", - ) - assert result.returncode != 0 - assert "prek failed" in result.stderr - - -@pytest.mark.parametrize( - "path", - [ - pytest.param("prek.toml", id="root-prek-toml"), - pytest.param("sub/.pre-commit-config.yaml", id="nested-yaml"), - ], -) -def test_validate_rejects_other_hook_configuration( - repository: Path, tmp_path: Path, action_scripts: Path, path: str -) -> None: - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - (repository / path).parent.mkdir(parents=True, exist_ok=True) - (repository / path).write_text("repos: []\n") - run(repository, "git", "add", path) - run(repository, "git", "commit", "-m", "Add another hook configuration") - run(repository, "git", "push", "origin", "main") - - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") - - assert result.returncode != 0 - assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 7f824c3..0763450 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -1,35 +1,11 @@ name: Validate release patch -description: Check and apply the release patch downloaded to $RUNNER_TEMP/release-patch, with optional release-tool validation -inputs: - version: - description: Release version - required: true - checks: - description: Run release-creator and prek after applying the patch - default: "true" +description: Apply the release patch downloaded to $RUNNER_TEMP/release-patch and check its paths and file modes runs: using: composite steps: - - name: Verify release source - shell: bash - run: | - set -euo pipefail - git fetch origin main - if [[ $(git rev-parse origin/main) != "$GITHUB_SHA" ]]; then - echo '::error::main changed during release preparation; dispatch again' >&2 - exit 1 - fi - name: Apply release patch shell: bash - env: - CHECKS: ${{ inputs.checks }} - run: | - set -euo pipefail - if [[ "$CHECKS" != true && "$CHECKS" != false ]]; then - echo '::error::checks must be true or false' >&2 - exit 1 - fi - git apply --index "$RUNNER_TEMP/release-patch/release.patch" + run: git apply --index "$RUNNER_TEMP/release-patch/release.patch" - id: config uses: $/read-towncrier-config - name: Check release patch paths @@ -53,15 +29,6 @@ runs: exit 1 fi done <"$RUNNER_TEMP/release-paths" - - if git diff --cached --quiet -- "$CHANGELOG"; then - echo "::error::release patch does not change $CHANGELOG" >&2 - exit 1 - fi - - name: Check consumed fragments - if: inputs.checks == 'true' - shell: bash - run: release-creator validate-fragment-deletions - name: Check release patch file modes shell: bash run: | @@ -96,46 +63,3 @@ runs: exit 1 fi done <"$RUNNER_TEMP/release-modes" - - name: Check RST conflict markers - if: inputs.checks == 'true' && steps.config.outputs.format == 'rst' - shell: bash - env: - CHANGELOG: ${{ steps.config.outputs.changelog }} - run: | - set -euo pipefail - git diff --cached --unified=0 -- "$CHANGELOG" >"$RUNNER_TEMP/release-rst-diff" - while IFS= read -r line; do - case "$line" in - +'<<<<<<<'* | +'|||||||'* | +'>>>>>>>'*) - echo '::error::release changelog contains conflict markers' >&2 - exit 1 - ;; - esac - done <"$RUNNER_TEMP/release-rst-diff" - printf '%s conflict-marker-size=10\n' "$CHANGELOG" >>"$(git rev-parse --git-path info/attributes)" - - name: Check rendered release section - if: inputs.checks == 'true' - shell: bash - env: - VERSION: ${{ inputs.version }} - CHANGELOG: ${{ steps.config.outputs.changelog }} - run: release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$GITHUB_REPOSITORY" >/dev/null - - name: Reject other hook configurations - if: inputs.checks == 'true' - shell: bash - run: | - set -euo pipefail - others=$(git ls-files -- ':(glob)**/prek.toml' ':(glob)**/.pre-commit-config.yml' \ - ':(glob)*/**/.pre-commit-config.yaml') - if [[ -n "$others" ]]; then - echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: ${others//$'\n'/, }" >&2 - exit 1 - fi - - name: Run prek on release patch - if: inputs.checks == 'true' - shell: bash - run: | - if ! "$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" run --all-files; then - echo '::error::prek failed on the validated release patch' >&2 - exit 1 - fi From ae919ec9d0f23645e984d485620e07e506ccd442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:43:07 -0700 Subject: [PATCH 07/23] =?UTF-8?q?=F0=9F=94=A5=20chore:=20drop=20the=20main?= =?UTF-8?q?=20gate=20on=20the=20upgrade=20writer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only writers can dispatch the workflow, so gating the publish job on main only stopped their own runs. The release-auth environment keeps its own branch rule. --- .github/workflows/upgrade-release-tools.yaml | 2 +- changelog.d/+upgrade-any-branch.change.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+upgrade-any-branch.change.md diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index e534a3b..0df7f10 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -91,7 +91,7 @@ jobs: retention-days: 7 publish: needs: generate-lock - if: needs.generate-lock.outputs.changed == 'true' && github.ref == 'refs/heads/main' + if: needs.generate-lock.outputs.changed == 'true' runs-on: ubuntu-26.04 environment: release-auth permissions: diff --git a/changelog.d/+upgrade-any-branch.change.md b/changelog.d/+upgrade-any-branch.change.md new file mode 100644 index 0000000..af3e90c --- /dev/null +++ b/changelog.d/+upgrade-any-branch.change.md @@ -0,0 +1,2 @@ +Drop the `main` branch condition from the writer job of `upgrade-release-tools.yaml`. The deployment branch rule of the +`release-auth` environment still applies. From ebe38c232d0c092b3e846422dd3f8e996f4a8a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:46:15 -0700 Subject: [PATCH 08/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20drop=20lock-tool?= =?UTF-8?q?=20caller=20input=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writers choose the groups, locks, targets and secondary lock inputs, so checking them up front only catches their own typos; uv, Nab and packaging still fail on input they cannot use. This removes check_lock_groups.py and the verify-lock and verify-groups inputs. --- .github/workflows/upgrade-release-tools.yaml | 5 - README.md | 40 ++----- changelog.d/+lock-tool-inputs.breaking.md | 6 + export-build-constraints/action.yml | 7 +- install-locked-tools/action.yml | 50 +------- refresh-release-lock/action.yml | 53 +------- tests/conftest.py | 13 -- tests/export_build_constraints/test_export.py | 113 ++---------------- tests/install_locked_tools/test_groups.py | 75 ------------ tests/install_locked_tools/test_install.py | 44 ------- .../test_secondary_lock.py | 40 ------- tests/refresh_release_lock/test_refresh.py | 21 ---- tests/upgrade_release_tools/test_upgrade.py | 21 ---- tools/build_constraints.py | 11 +- tools/check_lock_groups.py | 43 ------- 15 files changed, 33 insertions(+), 509 deletions(-) create mode 100644 changelog.d/+lock-tool-inputs.breaking.md delete mode 100644 tests/install_locked_tools/test_groups.py delete mode 100644 tests/install_locked_tools/test_secondary_lock.py delete mode 100644 tools/check_lock_groups.py diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 0df7f10..4be98b1 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -111,11 +111,6 @@ jobs: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} run: | set -euo pipefail - if [[ -n "$ADDITIONAL_LOCK" && - ("$ADDITIONAL_LOCK" != pylock.*.toml || "$ADDITIONAL_LOCK" == */*) ]]; then - echo "::error::invalid additional lock: $ADDITIONAL_LOCK" >&2 - exit 1 - fi GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" git read-tree HEAD GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" git apply --cached "$RUNNER_TEMP/release-tools-lock/lock.patch" GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" \ diff --git a/README.md b/README.md index c450060..37409cb 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ keeps its dispatch form, publisher, GitHub environment, and required check names `install-locked-tools` creates a virtual environment and installs selected groups from the caller's `pylock.toml`. The caller's full project lock records runtime dependencies, every extra, and every dependency group across supported Python versions and platforms. CI resolves test, type, and documentation dependencies fresh; release jobs install only the -selected release groups. The Action rejects missing groups and leaves tracked files unchanged. Source builds use -constraints generated from the lock's `build` group under the runner's temporary directory. A lock without a `build` -group exports an empty constraints file. +selected release groups. The Action leaves tracked files unchanged. Source builds use constraints generated from the +lock's `build` group under the runner's temporary directory. A lock without a `build` group exports an empty constraints +file. -The Action only checks that the selected groups exist in the lock; installs are hash-checked. Lock integrity comes from -Nab: `refresh-release-lock` in `check` mode regenerates the lock and fails if it differs. It runs on pull requests -through `validate-news`. The cutoff and any per-package exception live in the caller's `[tool.nab]` table. +Installs are hash-checked. Lock integrity comes from Nab: `refresh-release-lock` in `check` mode regenerates the lock +and fails if it differs. It runs on pull requests through `validate-news`. The cutoff and any per-package exception live +in the caller's `[tool.nab]` table. Select the groups each job needs. The shared prepare workflow uses `release-prepare`. A full lock records each group and extra, but a new combination still needs an install check before a caller uses it. @@ -24,9 +24,7 @@ extra, but a new combination still needs an install check before a caller uses i - `groups`: whitespace-separated release dependency groups. Leave empty to export build constraints without installing release tools. - `constraints-lock`: lock used to export build constraints; defaults to `pylock.toml`. Use a secondary lock when the - build matrix differs from the release-tool matrix. Without `groups`, the Action checks only this lock. -- `verify-lock` and `verify-groups`: check that another lock has the selected groups without installing them. Use this - before bundling packages from a separate runtime lock, such as a zipapp. + build matrix differs from the release-tool matrix. - `target-platform` and `targets`: Nab platform ID and space-separated `full-version:implementation` pairs for build constraints. Pass both when exporting constraints for a specific build target; otherwise the runner selects the target. @@ -37,20 +35,6 @@ The `build-constraints` output names the hashed constraints file. The Action add `GITHUB_PATH` for later steps while leaving the runner's `python`, `pip`, and `uv` commands on their original paths. Run `actions/checkout` and `astral-sh/setup-uv` with Python 3.15 before it. -For a zipapp that bundles wheels from a separate runtime lock, check that lock in the same job before building it: - -```yaml - - uses: tox-dev/github-workflows/install-locked-tools@SHA - with: - groups: release-build - venv: ${{ runner.temp }}/release-tools - verify-lock: pylock.zipapp.toml - verify-groups: project-runtime -``` - -`export-build-constraints` also accepts `verify-groups`. Pass it when its secondary lock supplies build tools outside -the `build` group, such as profile-guided optimization tools in a `pgo` group. - ## Tools the Actions install The Actions install the tools they run themselves, such as Nab, `packaging`, `certifi`, and `pypi-attestations`, from @@ -152,10 +136,9 @@ workflow never updates a committed `uv.lock`. Build constraints are generated fr For a second lock such as `pylock.build.toml`, set `additional-lock`, `additional-matrix-python`, `additional-matrix-platforms`, and `additional-matrix-implementations`. `additional-lock-groups` selects dependency groups in that lock; with no groups, the updater selects build requirements. Set `additional-lock-selection: project` -for a lock that selects the project's runtime dependencies instead. The lock must already exist, and the updater writes -both locks in the same dependency PR. The writer accepts only those locks. Pass the same `additional-lock` and matrix -inputs to `validate-news` in the PR workflow. It checks changes to either lock against the project, including a PR that -changes only the secondary lock. +for a lock that selects the project's runtime dependencies instead. The updater writes both locks in the same dependency +PR. The writer accepts only those locks. Pass the same `additional-lock` and matrix inputs to `validate-news` in the PR +workflow. It checks changes to either lock against the project, including a PR that changes only the secondary lock. `refresh-release-lock` is the read-only half of the updater. In `refresh` mode, it upgrades the full primary lock and writes `$RUNNER_TEMP/lock.patch` if a lock changes. In `check` mode, it regenerates the primary lock without upgrading @@ -303,8 +286,7 @@ plus `target-platform` and `targets` when the build matrix needs constraints for targets: 3.15.0:cpython ``` -`targets` is a space-separated list of `full-version:implementation` pairs. All selected targets must resolve the same -build package versions; otherwise the Action fails instead of writing an ambiguous constraints file. +`targets` is a space-separated list of `full-version:implementation` pairs. ## Prepare and tag with one reusable workflow diff --git a/changelog.d/+lock-tool-inputs.breaking.md b/changelog.d/+lock-tool-inputs.breaking.md new file mode 100644 index 0000000..ecc2a56 --- /dev/null +++ b/changelog.d/+lock-tool-inputs.breaking.md @@ -0,0 +1,6 @@ +Remove the `verify-lock` and `verify-groups` inputs from `install-locked-tools` and `verify-groups` from +`export-build-constraints`, together with the `check_lock_groups.py` check behind them; remove them from callers. +`install-locked-tools` no longer checks that the requested groups exist in the lock or requires `venv` with `groups`, +`export-build-constraints` no longer rejects an unknown platform, a malformed or empty target list, or targets that +select different build package versions, and `refresh-release-lock` and `upgrade-release-tools.yaml` no longer validate +`mode`, `additional-lock`, and the secondary matrix inputs. diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index 174b59e..47295cc 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -8,10 +8,7 @@ inputs: description: Nab platform ID such as linux_aarch64; required with targets default: "" targets: - description: Space-separated pairs such as 3.15.0:cpython; selected package versions must match - default: "" - verify-groups: - description: Additional lock groups whose packages enter the release build + description: Space-separated pairs such as 3.15.0:cpython default: "" exclude-newer: description: Upload cutoff for the tools this Action installs from PyPI @@ -30,8 +27,6 @@ runs: uses: $/install-locked-tools with: constraints-lock: ${{ inputs.lock }} - verify-lock: ${{ inputs.verify-groups != '' && inputs.lock || '' }} - verify-groups: ${{ inputs.verify-groups }} target-platform: ${{ inputs.target-platform }} targets: ${{ inputs.targets }} exclude-newer: ${{ inputs.exclude-newer }} diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 892d808..4c280f5 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -10,12 +10,6 @@ inputs: constraints-lock: description: Lock used to export build constraints; defaults to the selected install lock default: "" - verify-lock: - description: Another lock whose selected packages will be included in a release artifact - default: "" - verify-groups: - description: Whitespace-separated groups to verify in verify-lock - default: "" target-platform: description: Nab platform ID for the exported build constraints default: "" @@ -51,7 +45,6 @@ runs: env: DEPENDENCY_GROUPS: ${{ inputs.groups }} VENV: ${{ inputs.venv }} - CONSTRAINTS_LOCK: ${{ inputs.constraints-lock }} run: | set -euo pipefail groups=() @@ -60,61 +53,20 @@ runs: groups+=("${selected[@]}") done <<<"$DEPENDENCY_GROUPS" if ((${#groups[@]})); then - [[ -n "$VENV" ]] || { - echo '::error::venv is required when installing groups' >&2 - exit 1 - } uv venv --allow-existing --python 3.15 "$VENV" - selection=("${groups[@]}") printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" - else - selection=(--build-constraints) - fi - # An export from a named constraints lock checks that lock instead of the primary one. - checked=false - if ((${#groups[@]})) || [[ -z "$CONSTRAINTS_LOCK" ]]; then - uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" pylock.toml "${selection[@]}" - checked=true fi - printf 'lock=pylock.toml\nchecked=%s\n' "$checked" >>"$GITHUB_OUTPUT" - - name: Verify additional release lock - if: inputs.verify-lock != '' || inputs.verify-groups != '' - shell: bash - env: - VERIFY_LOCK: ${{ inputs.verify-lock }} - VERIFY_GROUPS: ${{ inputs.verify-groups }} - run: | - set -euo pipefail - groups=() - while IFS= read -r line; do - read -ra selected <<<"$line" - groups+=("${selected[@]}") - done <<<"$VERIFY_GROUPS" - if [[ -z "$VERIFY_LOCK" || ${#groups[@]} -eq 0 ]]; then - echo '::error::verify-lock and verify-groups must be set together' >&2 - exit 1 - fi - uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$VERIFY_LOCK" "${groups[@]}" + printf 'lock=pylock.toml\n' >>"$GITHUB_OUTPUT" - id: constraints name: Export locked build requirements shell: bash env: BUILD_LOCK: ${{ steps.selection.outputs.lock }} - CHECKED: ${{ steps.selection.outputs.checked }} CONSTRAINTS_LOCK: ${{ inputs.constraints-lock }} - VERIFY_LOCK: ${{ inputs.verify-lock }} TARGET_PLATFORM: ${{ inputs.target-platform }} TARGETS: ${{ inputs.targets }} run: | set -euo pipefail - # A verified lock already had its groups checked. - if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$VERIFY_LOCK" && - ("$CHECKED" != true || "$CONSTRAINTS_LOCK" != "$BUILD_LOCK") ]]; then - uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints - fi if [[ -n "$CONSTRAINTS_LOCK" ]]; then BUILD_LOCK=$CONSTRAINTS_LOCK; fi args=() if [[ -n "$TARGET_PLATFORM" || -n "$TARGETS" ]]; then diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index ee222fe..df8e707 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -35,23 +35,6 @@ outputs: runs: using: composite steps: - - name: Check lock inputs - shell: bash - env: - ADDITIONAL_LOCK: ${{ inputs.additional-lock }} - ADDITIONAL_GROUPS: ${{ inputs.additional-lock-groups }} - ADDITIONAL_SELECTION: ${{ inputs.additional-lock-selection }} - MATRIX_PYTHON: ${{ inputs.additional-matrix-python }} - MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }} - MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }} - run: | - set -euo pipefail - if [[ -z "$ADDITIONAL_LOCK" && - ("$ADDITIONAL_SELECTION" != build-or-groups || - -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS") ]]; then - echo '::error::secondary matrix inputs require additional-lock' >&2 - exit 1 - fi - name: Install Nab shell: bash env: @@ -84,12 +67,8 @@ runs: set -euo pipefail unset SSL_CERT_FILE SSL_CERT_DIR case "$MODE" in - check) refresh=() ;; refresh) refresh=(--upgrade) ;; - *) - echo "::error::invalid lock mode: $MODE" >&2 - exit 1 - ;; + *) refresh=() ;; esac cp pylock.toml "$RUNNER_TEMP/lock-before.toml" uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --all-groups --all-extras --no-cache "${refresh[@]}" @@ -113,19 +92,9 @@ runs: set -euo pipefail unset SSL_CERT_FILE SSL_CERT_DIR case "$MODE" in - check) refresh=() ;; refresh) refresh=(--upgrade) ;; - *) - echo "::error::invalid lock mode: $MODE" >&2 - exit 1 - ;; + *) refresh=() ;; esac - if [[ "$ADDITIONAL_LOCK" != pylock.*.toml || "$ADDITIONAL_LOCK" == */* || - ! -f "$ADDITIONAL_LOCK" || -z "$MATRIX_PYTHON" || - -z "$MATRIX_PLATFORMS" || -z "$MATRIX_IMPLEMENTATIONS" ]]; then - echo '::error::additional-lock needs an existing pylock.*.toml and a complete matrix' >&2 - exit 1 - fi platforms=() implementations=() selected_groups=() @@ -141,26 +110,12 @@ runs: read -ra words <<<"$line" selected_groups+=("${words[@]}") done <<<"$ADDITIONAL_GROUPS" - if ((${#platforms[@]} == 0 || ${#implementations[@]} == 0)); then - echo '::error::secondary matrix needs platforms and implementations' >&2 - exit 1 - fi case "$ADDITIONAL_SELECTION" in - build-or-groups) + project) selection=() ;; + *) selection=(--build-requirements) if ((${#selected_groups[@]})); then selection=(--groups "${selected_groups[@]}"); fi ;; - project) - if ((${#selected_groups[@]})); then - echo '::error::project secondary lock cannot select dependency groups' >&2 - exit 1 - fi - selection=() - ;; - *) - echo '::error::unknown secondary lock selection' >&2 - exit 1 - ;; esac cp "$ADDITIONAL_LOCK" "$RUNNER_TEMP/additional-lock-before.toml" uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --no-cache pyproject.toml "${selection[@]}" \ diff --git a/tests/conftest.py b/tests/conftest.py index 68b0660..6a10c23 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -398,8 +398,6 @@ def _condition( inputs: dict[str, str], step_outputs: dict[str, dict[str, str]], ) -> bool: - if " || " in expression: - return any(_condition(part, environment, inputs, step_outputs) for part in expression.split(" || ")) if " && " in expression: return all(_condition(part, environment, inputs, step_outputs) for part in expression.split(" && ")) for operator in (" == ", " != "): @@ -473,17 +471,6 @@ def _expression( inputs: dict[str, str], step_outputs: dict[str, dict[str, str]], ) -> str: - if " && " in expression and " || " in expression: - condition, alternatives = expression.split(" && ", 1) - true_value, false_value = alternatives.split(" || ", 1) - return _expression( - true_value if _condition(condition, environment, inputs, step_outputs) else false_value, - environment, - inputs, - step_outputs, - ) - if expression.startswith("'") and expression.endswith("'"): - return expression[1:-1] if " == " in expression or " != " in expression: return str(_condition(expression, environment, inputs, step_outputs)).lower() if expression.startswith("inputs."): diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 412b17d..b4d598e 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -1,14 +1,10 @@ from __future__ import annotations -import runpy import subprocess import sys import tomllib -from collections.abc import Callable -from copy import deepcopy from pathlib import Path from textwrap import dedent -from typing import cast import pytest import tomlkit @@ -123,47 +119,6 @@ def test_export_build_constraints_selects_explicit_targets(tmp_path: Path, platf assert output.read_text() == f"example==1.0 --hash=sha256:{'0' * 64}\n" -@pytest.mark.parametrize( - ("arguments", "message"), - [ - pytest.param( - ["--target-platform", "unknown", "--targets", "3.15.0:cpython", "{output}", "{lock}"], - "unknown target platform", - id="unknown-platform", - ), - pytest.param( - ["--target-platform", "linux_x86_64", "--targets", "3.15:cpython", "{output}", "{lock}"], - "invalid target", - id="incomplete-python-version", - ), - pytest.param( - ["--target-platform", "linux_x86_64", "--targets", " , ", "{output}", "{lock}"], - "at least one target", - id="empty-targets", - ), - ], -) -def test_export_build_constraints_rejects_invalid_arguments( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - arguments: list[str], - message: str, -) -> None: - source = Path(__file__).resolve().parents[2] - lock = tmp_path / "pylock.build.toml" - write_matrix_lock(lock) - paths = {"output": str(tmp_path / "constraints.txt"), "lock": str(lock), "project": str(CALLER / "pyproject.toml")} - script = source / "tools/build_constraints.py" - command = runpy.run_path(str(script), run_name="build_constraints_imported") - monkeypatch.setattr(sys, "argv", [str(script), *(argument.format_map(paths) for argument in arguments)]) - - with pytest.raises(SystemExit, match="2"): - cast("Callable[[], None]", command["main"])() - - assert message in capsys.readouterr().err - - @pytest.mark.parametrize( ("change", "message"), [ @@ -207,30 +162,6 @@ def test_export_build_constraints_rejects_untrusted_build_packages(tmp_path: Pat assert message in result.stderr -def test_export_build_constraints_rejects_conflicting_versions(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "setuptools") - other = deepcopy(package) - current = other["version"] - other["version"] = "81.0.0" - for artifact in [other["sdist"], *other["wheels"]]: - artifact["url"] = artifact["url"].replace(current, "81.0.0") - document["packages"].append(other) - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - [sys.executable, str(source / "tools/build_constraints.py"), str(tmp_path / "constraints.txt"), str(lock)], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "Multiple packages with the name 'setuptools'" in result.stderr - - def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, action_scripts: Path) -> None: expected = write_published_matrix_lock(tmp_path / "pylock.build.toml") @@ -252,42 +183,16 @@ def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, assert output.read_text() == expected -def test_export_build_constraints_rejects_different_target_versions(tmp_path: Path, action_scripts: Path) -> None: - lock = tmp_path / "pylock.build.toml" - write_published_matrix_lock(lock) - lock.write_text( - lock.read_text().replace( - 'index = "https://pypi.org/simple/"', - 'marker = \'implementation_name == "pypy"\'\nindex = "https://pypi.org/simple/"', - 1, - ) - + dedent( - """\ - [[packages]] - name = "packaging" - version = "25.0" - marker = 'implementation_name == "cpython"' - index = "https://pypi.org/simple/" - [[packages.wheels]] - name = "packaging-25.0-py3-none-any.whl" - url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl" - upload-time = 2025-04-19T11:48:57.875608Z - hashes.sha256 = "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" - """, - ), - ) - result = action( - tmp_path, - tmp_path, - action_scripts, - "export-build-constraints", - version="", - LOCK="pylock.build.toml", - TARGET_PLATFORM="macos_arm64", - TARGETS="3.15.0:cpython 3.11.0:pypy", +def test_build_constraints_import_has_no_cli_side_effect() -> None: + script = Path(__file__).resolve().parents[2] / "tools/build_constraints.py" + result = subprocess.run( + [sys.executable, "-c", f"import runpy; runpy.run_path({str(script)!r}, run_name='imported')"], + text=True, + capture_output=True, + check=False, ) - assert result.returncode != 0 - assert "versions differ across requested targets" in result.stderr + + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") @pytest.mark.parametrize( diff --git a/tests/install_locked_tools/test_groups.py b/tests/install_locked_tools/test_groups.py deleted file mode 100644 index c764971..0000000 --- a/tests/install_locked_tools/test_groups.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path -from typing import Final - -import pytest - -_TOOL: Final = Path(__file__).resolve().parents[2] / "tools/check_lock_groups.py" - - -@pytest.fixture -def lock(tmp_path: Path) -> Path: - path = tmp_path / "pylock.toml" - path.write_text( - 'lock-version = "1.0"\nrequires-python = ">=3.15"\ndependency-groups = ["release-check"]\n' - 'created-by = "test"\npackages = []\n' - ) - return path - - -def _check(*arguments: str) -> subprocess.CompletedProcess[str]: - return subprocess.run([sys.executable, str(_TOOL), *arguments], text=True, capture_output=True, check=False) - - -@pytest.mark.parametrize( - ("arguments", "accepted"), - [ - pytest.param(("release-check",), True, id="present-group"), - pytest.param(("--build-constraints",), True, id="lock-without-build-group"), - pytest.param(("release-news",), False, id="missing-group"), - ], -) -def test_lock_groups_selects_present_groups(lock: Path, arguments: tuple[str, ...], accepted: bool) -> None: - result = _check(str(lock), *arguments) - - assert (result.returncode == 0) is accepted, result.stderr - assert ("missing lock dependency groups: release-news" in result.stderr) is not accepted - - -def test_lock_groups_requires_a_group(lock: Path) -> None: - result = _check(str(lock)) - - assert result.returncode != 0 - assert "at least one dependency group is required" in result.stderr - - -@pytest.mark.parametrize("location", ["lock", "request"]) -def test_lock_groups_rejects_unnormalized_group(lock: Path, location: str) -> None: - if location == "lock": - lock.write_text(lock.read_text().replace('["release-check"]', '["release-check", "project_runtime"]')) - - result = _check(str(lock), "release-check" if location == "lock" else "release_check") - - assert result.returncode != 0 - assert "dependency group names must be normalized" in result.stderr - - -def test_lock_groups_reports_unreadable_lock(tmp_path: Path) -> None: - result = _check(str(tmp_path / "missing.toml"), "release-check") - - assert result.returncode != 0 - assert "No such file or directory" in result.stderr - - -def test_lock_groups_import_has_no_cli_side_effect() -> None: - result = subprocess.run( - [sys.executable, "-c", f"import runpy; runpy.run_path({str(_TOOL)!r}, run_name='imported')"], - text=True, - capture_output=True, - check=False, - ) - - assert (result.returncode, result.stdout, result.stderr) == (0, "", "") diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 1f2688f..b34d131 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -147,50 +147,6 @@ def test_install_locked_tools_reuses_windows_shaped_environment(tmp_path: Path, assert (tmp_path / "lock-tools/pyvenv.cfg").is_file() -def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scripts: Path, caller: Path) -> None: - result = install( - caller, - action_scripts, - tmp_path, - groups="relesae", - venv=tmp_path / "tools", - ) - assert result.returncode != 0 - assert "missing lock dependency groups: relesae" in result.stderr - - -def test_install_locked_tools_rejects_artifact_version_mismatch(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "setuptools") - artifact = package["wheels"][0] - artifact["url"] = artifact["url"].replace(str(package["version"]), "0.0.0") - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "package version" in result.stderr - - -def test_install_locked_tools_constraints_only_accepts_no_build_group(tmp_path: Path, action_scripts: Path) -> None: - (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') - (tmp_path / "pylock.toml").write_text( - 'lock-version = "1.0"\nrequires-python = ">=3.15"\ncreated-by = "test"\n' - 'dependency-groups = ["project-runtime"]\npackages = []\n' - "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" - ) - result = action(tmp_path, tmp_path, action_scripts, "install-locked-tools", version="") - assert result.returncode == 0, result.stderr - assert next(tmp_path.glob("build-constraints-*.txt")).read_text() == "" - - def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Path, caller: Path) -> None: for target in ("notes", "validators"): result = install( diff --git a/tests/install_locked_tools/test_secondary_lock.py b/tests/install_locked_tools/test_secondary_lock.py deleted file mode 100644 index d94494c..0000000 --- a/tests/install_locked_tools/test_secondary_lock.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from conftest import action - - -def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, caller: Path, action_scripts: Path) -> None: - lock = tmp_path / "pylock.zipapp.toml" - lock.write_text( - 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' - 'dependency-groups = ["project-runtime"]\ncreated-by = "test"\npackages = []\n' - "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" - ) - - result = action( - caller, - tmp_path, - action_scripts, - "install-locked-tools", - version="", - VERIFY_LOCK=str(lock), - VERIFY_GROUPS="project-runtime", - ) - - assert result.returncode == 0, result.stderr - - -def test_verify_secondary_lock_requires_group(tmp_path: Path, caller: Path, action_scripts: Path) -> None: - result = action( - caller, - tmp_path, - action_scripts, - "install-locked-tools", - version="", - VERIFY_LOCK="pylock.zipapp.toml", - ) - - assert result.returncode != 0 - assert "verify-lock and verify-groups must be set together" in result.stderr diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 2e71583..9641f09 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -47,20 +47,6 @@ def lock_project(tmp_path: Path) -> Path: return project -def test_refresh_rejects_secondary_options_without_lock(tmp_path: Path, action_scripts: Path) -> None: - result = action( - tmp_path, - tmp_path, - action_scripts, - "refresh-release-lock", - version="", - ADDITIONAL_LOCK_GROUPS="build", - ) - - assert result.returncode != 0 - assert "secondary matrix inputs require additional-lock" in result.stderr - - @pytest.mark.parametrize(("mode", "upgrade"), [("check", []), ("refresh", ["--upgrade"])]) def test_refresh_locks_every_group_and_extra_with_nab( tmp_path: Path, action_scripts: Path, fake_uv: Path, mode: str, upgrade: list[str] @@ -112,13 +98,6 @@ def test_refresh_hides_certificate_overrides_from_nab( assert not {"SSL_CERT_FILE", "SSL_CERT_DIR"} & _uv_call(tmp_path)["env"].keys() -def test_refresh_rejects_unknown_mode(tmp_path: Path, action_scripts: Path, fake_uv: Path) -> None: - result = _run_step(action_scripts, "Resolve all project groups and extras", tmp_path, fake_uv, MODE="other") - - assert result.returncode != 0 - assert "invalid lock mode: other" in result.stderr - - def test_refresh_check_accepts_current_lock( lock_project: Path, nab_executable: Path, tmp_path: Path, action_scripts: Path ) -> None: diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 575487f..3907b49 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -103,27 +103,6 @@ def test_upgrade_checks_lock_patch_path( assert run(lock_repository, "git", "status", "--porcelain").stdout == "" -@pytest.mark.parametrize("additional_lock", ["other.txt", "../pylock.other.toml"], ids=["arbitrary", "parent"]) -def test_upgrade_rejects_unsafe_additional_lock( - lock_repository: Path, tmp_path: Path, publish_scripts: Path, additional_lock: str -) -> None: - (lock_repository / "pylock.toml").write_text("before\n") - run(lock_repository, "git", "add", ".") - run(lock_repository, "git", "commit", "-m", "Add lock") - (lock_repository / "pylock.toml").write_text("after\n") - run(lock_repository, "git", "add", "pylock.toml") - - result = execute( - lock_repository, - publish_scripts / "publish-check-artifact-patch-paths.sh", - tmp_path, - ADDITIONAL_LOCK=additional_lock, - ) - - assert result.returncode != 0 - assert "invalid additional lock" in result.stderr - - @pytest.mark.parametrize("unsafe_mode", ["executable", "symlink"]) def test_upgrade_rejects_unsafe_file_mode( lock_repository: Path, tmp_path: Path, publish_scripts: Path, unsafe_mode: str diff --git a/tools/build_constraints.py b/tools/build_constraints.py index 62f21ef..3b0f858 100644 --- a/tools/build_constraints.py +++ b/tools/build_constraints.py @@ -25,9 +25,6 @@ def main() -> None: lock = Pylock.from_dict(tomllib.load(stream)) targets = _target_environments(args.target_platform, args.targets) if args.targets else [None] selected_by_target = [_selected_build(lock, environment) for environment in targets] - versions = [{name: version for name, (version, _) in selected.items()} for selected in selected_by_target] - if any(other != versions[0] for other in versions[1:]): - parser.error("build package versions differ across requested targets") selected = selected_by_target[0] for other in selected_by_target[1:]: for name, (_, hashes) in other.items(): @@ -57,14 +54,10 @@ def _target_environments(platform: str, values: str) -> list[Environment]: "windows_amd64": ("win32", "Windows", "AMD64", "nt"), "windows_arm64": ("win32", "Windows", "ARM64", "nt"), } - if platform not in platforms: - raise ValueError(f"unknown target platform: {platform}") environments = [] for value in values.replace(",", " ").split(): - version_text, separator, implementation = value.partition(":") + version_text, _, implementation = value.partition(":") version = Version(version_text) - if not separator or len(version.release) != 3 or implementation not in {"cpython", "pypy"}: - raise ValueError(f"invalid target: {value}; expected full-version:cpython|pypy") system_platform, platform_system, machine, os_name = platforms[platform] environment = default_environment() environment.update( @@ -81,8 +74,6 @@ def _target_environments(platform: str, values: str) -> list[Environment]: platform_version="", ) environments.append(environment) - if not environments: - raise ValueError("at least one target is required") return environments diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py deleted file mode 100644 index 50bb965..0000000 --- a/tools/check_lock_groups.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Reject dependency groups absent from a PEP 751 lock.""" - -from __future__ import annotations - -import argparse -import tomllib -from pathlib import Path - -from packaging.pylock import Pylock, PylockValidationError -from packaging.utils import canonicalize_name - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("lock", type=Path) - parser.add_argument("groups", nargs="*") - parser.add_argument("--build-constraints", action="store_true") - args = parser.parse_args() - try: - groups = {"build"} if args.build_constraints else set(args.groups) - if not groups: - parser.error("at least one dependency group is required") - with args.lock.open("rb") as stream: - lock = Pylock.from_dict(tomllib.load(stream)) - if unnormalized := sorted( - name for name in {*groups, *(lock.dependency_groups or ())} if canonicalize_name(name) != name - ): - raise ValueError(f"dependency group names must be normalized: {', '.join(unnormalized)}") - if (missing := groups - set(lock.dependency_groups or ())) and not ( - args.build_constraints and missing == {"build"} - ): - parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") - except (KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: - parser.error(str(error)) - - -if __name__ == "__main__": - main() - - -__all__ = [ - "main", -] From 172df1a4a4e464dab638bcb6f1f5fbb6691bcd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:48:09 -0700 Subject: [PATCH 09/23] =?UTF-8?q?=F0=9F=94=A5=20chore:=20drop=20prepare=20?= =?UTF-8?q?and=20tag=20input=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare-towncrier, read-towncrier-config, select-previous-tag and commit-and-tag validated inputs and configuration that only writers set. Towncrier, release-creator and Git still fail on what they cannot use. --- README.md | 24 +++---- changelog.d/+prepare-input-checks.change.md | 8 +++ commit-and-tag/action.yml | 16 +---- prepare-towncrier/action.yml | 45 +------------ read-towncrier-config/action.yml | 39 ++--------- select-previous-tag/action.yml | 8 --- tests/commit_and_tag/test_commit.py | 38 ----------- tests/prepare_towncrier/test_prepare.py | 67 +------------------ tests/read_towncrier_config/test_config.py | 35 ---------- tests/render_release_notes/test_notes.py | 2 +- tests/select_previous_tag/test_select.py | 17 ----- tests/validate_release_patch/test_validate.py | 36 ---------- 12 files changed, 27 insertions(+), 308 deletions(-) create mode 100644 changelog.d/+prepare-input-checks.change.md diff --git a/README.md b/README.md index 37409cb..0a11560 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,7 @@ refresh produces a patch. Neither mode pushes a branch or requests a write token Every shared `prek` command runs through `tools/cooled_prek.sh`, which the `cooled-prek` Action wraps for workflows, and passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and also runs nested -configurations, which no guard checks. `prepare-towncrier` therefore rejects a `prek.toml` anywhere, a -`.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before it runs `prek`. +configurations, which no guard checks. ## Start with a caller @@ -324,9 +323,8 @@ tag-triggered ecosystem publisher stays in the caller repository. ## Read Towncrier configuration -`read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`. It requires -normalized changelog and fragment paths, a Markdown or RST changelog suffix, and explicit fragment categories. It reads -committed bytes so an untrusted release patch cannot change the allowed paths or categories. +`read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`, so an untrusted +release patch cannot change the allowed paths. ```yaml - id: towncrier @@ -336,14 +334,14 @@ committed bytes so an untrusted release patch cannot change the allowed paths or run: printf '%s\n' "$CHANGELOG" ``` -Outputs are `changelog`, `fragments`, and `format` (`markdown` or `rst`). The Action does not modify the checkout or -GitHub state. +Outputs are `changelog`, `fragments`, and `format`: `rst` for a `.rst` changelog, otherwise `markdown`. The Action does +not modify the checkout or GitHub state. ## Prepare Towncrier release `prepare-towncrier` chooses the next version, links fragments to their squash PRs, runs Towncrier and the selected formatter hook through `prek`, then writes a binary Git patch at `$RUNNER_TEMP/release.patch`. It reads the changelog -path, fragment directory, categories, and Markdown or RST format from committed Towncrier configuration through +path, fragment directory, and Markdown or RST format from committed Towncrier configuration through `read-towncrier-config`. It does not commit or push. Version selection, fragment linking, the Towncrier build, and formatting must all succeed. @@ -364,16 +362,14 @@ group needs release-creator 3.1.0 or later. unprefixed numeric tags in the repository's history. This does not set the changelog heading prefix. - `changelog-prefix`: Empty by default, so Towncrier writes `1.2.3` even if the tag is `v1.2.3`. Set `v` for a changelog whose release headings use `v1.2.3`. -- `version`: Explicit version for a caller with its own version selector. It takes precedence over `bump`; the Action - still checks that it is newer than existing tags. +- `version`: Explicit version for a caller with its own version selector. It takes precedence over `bump`. - `phase`: `all` (default) runs the complete Action. The reusable workflow uses `build` to select the version, link authors, and build the changelog while hooks install; it then uses `finalize` to format, render, and write the patch. Pass the version selected by `build` to `finalize`. -The committed `tool.towncrier` configuration must give a normalized `filename`, `directory`, and explicit fragment -categories. The filename must end in `.md` or `.rst`; `mdformat` or `docstrfmt` must be a configured `prek` hook for -that format. The Action writes the patch at `$RUNNER_TEMP/release.patch`; the reusable workflow uploads it by artifact -ID. +The committed `tool.towncrier` configuration gives the `filename` and `directory`. `docstrfmt` formats a `.rst` +changelog and `mdformat` any other; the formatter must be a configured `prek` hook. The Action writes the patch at +`$RUNNER_TEMP/release.patch`; the reusable workflow uploads it by artifact ID. **Output:** `version` is the selected tag string. Use `steps.prepare.outputs.version` as a job output and pass it to both later Actions. **Working-tree effect:** Towncrier changes the changelog and consumes fragments; the patch records diff --git a/changelog.d/+prepare-input-checks.change.md b/changelog.d/+prepare-input-checks.change.md new file mode 100644 index 0000000..8593bfc --- /dev/null +++ b/changelog.d/+prepare-input-checks.change.md @@ -0,0 +1,8 @@ +Stop validating caller input in the prepare and tag Actions. `prepare-towncrier` no longer rejects an unknown `phase`, a +`finalize` run without `version`, a `changelog-prefix` other than empty or `v`, an explicit `version` that is not newer +than existing tags, other hook configuration files, or an unknown changelog format; any changelog not ending in `.rst` +is formatted with `mdformat`. `read-towncrier-config` no longer checks that the paths are normalized and inside the +repository, that the changelog ends in `.md` or `.rst`, or that fragment categories are configured. +`select-previous-tag` no longer requires the tag to exist locally or limits it to one namespace separator, and +`commit-and-tag` no longer rejects an empty `token` or `app-slug` or an `annotated` value other than `true` or `false`; +any value other than `true` creates a lightweight tag. diff --git a/commit-and-tag/action.yml b/commit-and-tag/action.yml index 237443d..285c6fe 100644 --- a/commit-and-tag/action.yml +++ b/commit-and-tag/action.yml @@ -19,16 +19,6 @@ inputs: runs: using: composite steps: - - name: Check staged release - shell: bash - env: - ANNOTATED: ${{ inputs.annotated }} - run: | - set -euo pipefail - if [[ "$ANNOTATED" != true && "$ANNOTATED" != false ]]; then - echo '::error::annotated must be true or false' >&2 - exit 1 - fi - name: Set App commit identity shell: bash env: @@ -36,10 +26,6 @@ runs: APP_SLUG: ${{ inputs.app-slug }} run: | set -euo pipefail - if [[ -z "$GH_TOKEN" || -z "$APP_SLUG" ]]; then - echo '::error::repository App token and slug are required' >&2 - exit 1 - fi app_user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) git config user.name "${APP_SLUG}[bot]" git config user.email "${app_user_id}+${APP_SLUG}[bot]@users.noreply.github.com" @@ -57,7 +43,7 @@ runs: run: | case "$ANNOTATED" in true) git tag -a -m "$MESSAGE" -- "$VERSION" ;; - false) git tag -- "$VERSION" ;; + *) git tag -- "$VERSION" ;; esac - name: Push release commit and tag shell: bash diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index d109132..445da17 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -23,18 +23,6 @@ outputs: runs: using: composite steps: - - name: Require a valid preparation phase - shell: bash - env: - PHASE: ${{ inputs.phase }} - run: | - case "$PHASE" in - all | build | finalize) ;; - *) - echo '::error::phase must be all, build, or finalize' >&2 - exit 1 - ;; - esac - id: config uses: $/read-towncrier-config - id: version @@ -49,17 +37,10 @@ runs: run: | set -euo pipefail if [[ "$PHASE" == finalize ]]; then - [[ -n "$VERSION" ]] || { - echo '::error::finalize requires a version from the build phase' >&2 - exit 1 - } printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" exit 0 fi - if [[ -n "$VERSION" ]]; then - git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null - git tag --list | release-creator tags "$VERSION" --require-newer - else + if [[ -z "$VERSION" ]]; then args=(--next "$BUMP" --directory "$FRAGMENTS") if [[ -n "$TAG_PREFIX" ]]; then args+=(--prefix "$TAG_PREFIX") @@ -85,13 +66,6 @@ runs: CHANGELOG_PREFIX: ${{ inputs.changelog-prefix }} run: | set -euo pipefail - case "$CHANGELOG_PREFIX" in - '' | v) ;; - *) - echo '::error::changelog-prefix must be empty or v' >&2 - exit 1 - ;; - esac towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}" - name: Add new contributors to the changelog if: inputs.phase != 'finalize' @@ -104,17 +78,6 @@ runs: GH_TOKEN: ${{ github.token }} run: >- release-creator contributors --changelog "$CHANGELOG" --version "$CHANGELOG_PREFIX${VERSION#v}" --repository "$REPOSITORY" - - name: Reject other hook configurations - if: inputs.phase != 'build' - shell: bash - run: | - set -euo pipefail - others=$(git ls-files -- ':(glob)**/prek.toml' ':(glob)**/.pre-commit-config.yml' \ - ':(glob)*/**/.pre-commit-config.yaml') - if [[ -n "$others" ]]; then - echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: ${others//$'\n'/, }" >&2 - exit 1 - fi - name: Format changelog if: inputs.phase != 'build' shell: bash @@ -124,12 +87,8 @@ runs: run: | set -euo pipefail case "$FORMAT" in - markdown) formatter=mdformat ;; rst) formatter=docstrfmt ;; - *) - echo '::error::unsupported changelog format' >&2 - exit 1 - ;; + *) formatter=mdformat ;; esac prek=$GITHUB_ACTION_PATH/../tools/cooled_prek.sh if ! "$prek" run "$formatter" --files "$CHANGELOG"; then diff --git a/read-towncrier-config/action.yml b/read-towncrier-config/action.yml index 0cf8a4a..6e99913 100644 --- a/read-towncrier-config/action.yml +++ b/read-towncrier-config/action.yml @@ -1,5 +1,5 @@ name: Read Towncrier configuration -description: Read release paths, format, and fragment categories from the committed Towncrier configuration +description: Read the release paths and format from the committed Towncrier configuration outputs: changelog: description: Changelog path relative to the repository @@ -27,42 +27,11 @@ runs: python3 - "$config_dir/config.toml" "$GITHUB_OUTPUT" <<'PY' import sys import tomllib - from pathlib import Path, PurePosixPath + from pathlib import Path - document = tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) - config = document.get("tool", {}).get("towncrier") - if not isinstance(config, dict): - raise SystemExit("Towncrier configuration is required") - types = config.get("type") - fragments = config.get("fragment") - if types is not None and fragments is not None: - raise SystemExit("Towncrier type and fragment tables cannot be combined") - for key in ("filename", "directory"): - value = config.get(key) - if not isinstance(value, str) or not value.isprintable() or not value or value != str(PurePosixPath(value)): - raise SystemExit("Towncrier paths must be normalized relative paths") - if PurePosixPath(value).is_absolute() or ".." in value.split("/"): - raise SystemExit("Towncrier paths must stay within the repository") + config = tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["tool"]["towncrier"] changelog = config["filename"] - if changelog.endswith(".md"): - format_name = "markdown" - elif changelog.endswith(".rst"): - format_name = "rst" - else: - raise SystemExit("Towncrier changelog must use Markdown or RST") - if types is not None: - if not isinstance(types, list) or not types or any(not isinstance(item, dict) for item in types): - raise SystemExit("Towncrier type categories are required") - categories = [category.get("directory") for category in types] - elif isinstance(fragments, dict) and fragments and all(isinstance(value, dict) for value in fragments.values()): - categories = list(fragments) - else: - raise SystemExit("Towncrier fragment categories are required") - if not all( - isinstance(category, str) and category.isprintable() and category and "/" not in category - for category in categories - ): - raise SystemExit("Towncrier fragment categories must be printable names") + format_name = "rst" if changelog.endswith(".rst") else "markdown" with Path(sys.argv[2]).open("a", encoding="utf-8") as output: output.write(f"changelog={changelog}\nfragments={config['directory']}\nformat={format_name}\n") PY diff --git a/select-previous-tag/action.yml b/select-previous-tag/action.yml index 9692b53..0ba0732 100644 --- a/select-previous-tag/action.yml +++ b/select-previous-tag/action.yml @@ -17,14 +17,6 @@ runs: TAG: ${{ inputs.tag }} run: | set -euo pipefail - if ! git show-ref --verify --quiet "refs/tags/$TAG"; then - echo "::error::release tag not found: $TAG" >&2 - exit 1 - fi - if [[ "$TAG" == */*/* ]]; then - echo "::error::release tag must contain at most one namespace separator: $TAG" >&2 - exit 1 - fi args=() if [[ "$TAG" == */* ]]; then args+=(--package "${TAG%%/*}"); fi previous=$(git tag --list | release-creator tags "$TAG" --previous "${args[@]}") diff --git a/tests/commit_and_tag/test_commit.py b/tests/commit_and_tag/test_commit.py index 8e3db60..0a54835 100644 --- a/tests/commit_and_tag/test_commit.py +++ b/tests/commit_and_tag/test_commit.py @@ -74,21 +74,6 @@ def test_commit_and_tag_rejects_invalid_tag(repository: Path, tmp_path: Path, ac assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == original_remote -def test_commit_and_tag_rejects_invalid_annotated(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "CHANGELOG.md").write_text("Staged release.\n") - run(repository, "git", "add", "CHANGELOG.md") - result = action( - repository, - tmp_path, - action_scripts, - "commit-and-tag", - version="0.2.0", - ANNOTATED="maybe", - ) - assert result.returncode != 0 - assert "annotated must be true or false" in result.stderr - - def test_commit_and_tag_keeps_remote_unchanged_on_rejection( repository: Path, tmp_path: Path, action_scripts: Path ) -> None: @@ -117,26 +102,3 @@ def test_commit_and_tag_keeps_remote_unchanged_on_rejection( assert result.returncode != 0 assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == before assert not run(repository, "git", "ls-remote", "origin", "refs/tags/0.2.0").stdout - - -def test_commit_and_tag_rejects_missing_token_without_remote_change( - repository: Path, tmp_path: Path, action_scripts: Path -) -> None: - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - assert action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0").returncode == 0 - before = run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout - result = action( - repository, - tmp_path, - action_scripts, - "commit-and-tag", - version="0.2.0", - APP_SLUG="release-test", - TOKEN="", - GH_TOKEN="ambient-test-token", # ruff: ignore[hardcoded-password-func-arg] # No token reaches GitHub. - ) - assert result.returncode != 0 - assert "repository App token and slug are required" in result.stderr - assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == before - assert not run(repository, "git", "ls-remote", "origin", "refs/tags/0.2.0").stdout diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 42b5304..936012c 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -53,13 +53,6 @@ def test_prepare_finalize_phase_preserves_version_output( assert (tmp_path / "output").read_text() == "version=0.2.0\n" -def test_prepare_finalize_phase_requires_version(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="", PHASE="finalize") - - assert result.returncode != 0 - assert "finalize requires a version" in result.stderr - - def test_prepare_custom_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: project = repository / "pyproject.toml" project.write_text(project.read_text().replace("CHANGELOG.md", "HISTORY.md")) @@ -142,33 +135,6 @@ def test_prepare_renders_github_notes( ) -@pytest.mark.parametrize( - ("option", "value", "error"), - [ - pytest.param("CHANGELOG_PREFIX", "x", "changelog-prefix must be empty or v", id="changelog-prefix"), - pytest.param("PHASE", "unknown", "phase must be all, build, or finalize", id="phase"), - ], -) -def test_prepare_rejects_invalid_option( - repository: Path, - tmp_path: Path, - action_scripts: Path, - option: str, - value: str, - error: str, -) -> None: - result = action( - repository, - tmp_path, - action_scripts, - "prepare-towncrier", - version="0.2.0", - **{option: value}, - ) - assert result.returncode != 0 - assert error in result.stderr - - @pytest.mark.parametrize( ("bump", "expected"), [ @@ -202,14 +168,6 @@ def test_prepare_preserves_prefixed_changelog_heading(repository: Path, tmp_path assert "## v0.2.0 (" in (repository / "CHANGELOG.md").read_text() -@pytest.mark.parametrize("version", [pytest.param("0.1.0", id="existing"), pytest.param("0.0.1", id="older")]) -def test_prepare_rejects_reused_version(repository: Path, tmp_path: Path, action_scripts: Path, version: str) -> None: - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version=version) - assert result.returncode != 0 - assert "must be newer" in result.stderr - assert not (tmp_path / "release.patch").exists() - - def test_prepare_links_named_fragments( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: @@ -287,7 +245,7 @@ def test_prepare_rejects_empty_release(repository: Path, tmp_path: Path, action_ run(repository, "git", "add", "-u") run(repository, "git", "commit", "-m", "Remove unused fragment") run(repository, "git", "push", "origin", "main") - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="") assert result.returncode != 0 assert "no changelog fragments to release" in result.stderr @@ -387,29 +345,6 @@ def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) - return environment -@pytest.mark.parametrize( - "path", - [ - pytest.param("prek.toml", id="root-prek-toml"), - pytest.param("sub/prek.toml", id="nested-prek-toml"), - pytest.param(".pre-commit-config.yml", id="root-yml"), - pytest.param("sub/.pre-commit-config.yaml", id="nested-yaml"), - ], -) -def test_prepare_rejects_other_hook_configuration( - repository: Path, tmp_path: Path, action_scripts: Path, path: str -) -> None: - (repository / path).parent.mkdir(parents=True, exist_ok=True) - (repository / path).write_text("repos: []\n") - run(repository, "git", "add", path) - run(repository, "git", "commit", "-m", "Add another hook configuration") - - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") - - assert result.returncode != 0 - assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr - - @pytest.mark.parametrize( ("prior_pulls", "credited"), [pytest.param("0", True, id="first-pull"), pytest.param("1", False, id="returning")] ) diff --git a/tests/read_towncrier_config/test_config.py b/tests/read_towncrier_config/test_config.py index f7db56d..49bcd64 100644 --- a/tests/read_towncrier_config/test_config.py +++ b/tests/read_towncrier_config/test_config.py @@ -3,7 +3,6 @@ from pathlib import Path from textwrap import dedent -import pytest from conftest import action, run @@ -44,37 +43,3 @@ def test_reader_prefers_towncrier_fragment_tables(repository: Path, tmp_path: Pa "fragments": "docs/news", "format": "rst", } - - -@pytest.mark.parametrize("path", [pytest.param("../NEWS.md", id="parent"), pytest.param("/NEWS.md", id="absolute")]) -def test_reader_rejects_unsafe_changelog_path( - repository: Path, tmp_path: Path, action_scripts: Path, path: str -) -> None: - project = repository / "pyproject.toml" - project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', f'filename = "{path}"')) - run(repository, "git", "add", "pyproject.toml") - run(repository, "git", "commit", "-m", "Set changelog path") - result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="") - assert result.returncode != 0 - assert "Towncrier paths must stay within the repository" in result.stderr - - -def test_reader_rejects_mixed_fragment_styles(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - project = repository / "pyproject.toml" - project.write_text(project.read_text() + '\n[tool.towncrier.fragment.bugfix]\nname = "Fixed"\n') - run(repository, "git", "add", "pyproject.toml") - run(repository, "git", "commit", "-m", "Mix Towncrier fragment styles") - result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="") - assert result.returncode != 0 - assert "type and fragment tables cannot be combined" in result.stderr - - -def test_reader_rejects_non_table_fragment(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "towncrier.toml").write_text( - '[tool.towncrier]\ndirectory = "changelog.d"\nfilename = "CHANGELOG.md"\nfragment = { feature = "Added" }\n' - ) - run(repository, "git", "add", "towncrier.toml") - run(repository, "git", "commit", "-m", "Set an invalid fragment definition") - result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="") - assert result.returncode != 0 - assert "Towncrier fragment categories are required" in result.stderr diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py index 7322636..f2586fd 100644 --- a/tests/render_release_notes/test_notes.py +++ b/tests/render_release_notes/test_notes.py @@ -130,7 +130,7 @@ def test_render_release_notes_custom_artifact_label( @pytest.mark.parametrize( ("tag", "remove_heading", "error"), [ - pytest.param("invalid tag", False, "release tag not found", id="invalid-tag"), + pytest.param("invalid tag", False, "expected a major.minor.patch version", id="invalid-tag"), pytest.param("0.2.0", True, "expected one changelog section", id="missing-heading"), ], ) diff --git a/tests/select_previous_tag/test_select.py b/tests/select_previous_tag/test_select.py index 92a6be5..a9e1e1b 100644 --- a/tests/select_previous_tag/test_select.py +++ b/tests/select_previous_tag/test_select.py @@ -45,20 +45,3 @@ def test_select_previous_tag_scopes_namespaced_tags(repository: Path, tmp_path: assert result.returncode == 0, result.stderr assert (tmp_path / "output").read_text() == "previous=pyproject-fmt/1.1.0\n" - - -def test_select_previous_tag_requires_current_tag(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="0.2.0", TAG="0.2.0") - - assert result.returncode != 0 - assert not (tmp_path / "output").exists() - - -def test_select_previous_tag_rejects_nested_namespace(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - run(repository, "git", "tag", "a/b/1.0") - - result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="a/b/1.0", TAG="a/b/1.0") - - assert result.returncode != 0 - assert "at most one namespace separator" in result.stderr - assert not (tmp_path / "output").exists() diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index 683e0b0..e358c96 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -140,42 +140,6 @@ def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Pa assert "unexpected release path status" in result.stderr -@pytest.mark.parametrize( - ("before", "after", "error"), - [ - pytest.param("[tool.towncrier]", "[tool.other]", "Towncrier configuration is required", id="missing-config"), - pytest.param('directory = "changelog.d"\n', "", "normalized relative paths", id="missing-directory"), - pytest.param('filename = "CHANGELOG.md"\n', "", "normalized relative paths", id="missing-filename"), - pytest.param( - 'type = [{ directory = "feature", name = "Added", showcontent = true }]\n', - "", - "Towncrier fragment categories are required", - id="missing-categories", - ), - ], -) -def test_validate_requires_explicit_towncrier_config( - repository: Path, tmp_path: Path, action_scripts: Path, before: str, after: str, error: str -) -> None: - project = repository / "pyproject.toml" - project.write_text(project.read_text().replace(before, after)) - run(repository, "git", "add", "pyproject.toml") - run(repository, "git", "commit", "-m", "Remove release configuration") - run(repository, "git", "push", "origin", "main") - (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-22)\n\n- Add a feature.\n") - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") - result = action( - repository, - tmp_path, - action_scripts, - "validate-release-patch", - version="0.2.0", - ) - assert result.returncode != 0 - assert error in result.stderr - - @pytest.mark.parametrize( "file_mode", [pytest.param("executable", id="executable"), pytest.param("symlink", id="symlink")], From 7f7f52265d6fb006e3067e22151455333a02a59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:49:49 -0700 Subject: [PATCH 10/23] =?UTF-8?q?=F0=9F=94=A5=20chore:=20drop=20publisher?= =?UTF-8?q?=20input=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publishing Actions checked the shape of inputs that writers pass: tag, title, mode, asset names, archive names, plugin ID, channel, artifact IDs and the post-release pull request inputs. The digest, receipt and bundle checks stay. --- README.md | 6 +-- changelog.d/+publisher-input-checks.change.md | 6 +++ publish-github-release/action.yml | 24 ----------- publish-jetbrains-marketplace/action.yml | 22 ---------- publish-post-release-pr/action.yml | 41 ------------------ sign-jetbrains-plugin/action.yml | 2 - tests/publish_github_release/test_publish.py | 42 ------------------- .../test_marketplace_upload.py | 40 +----------------- tests/sign_jetbrains_plugin/test_sign.py | 10 +---- 9 files changed, 11 insertions(+), 182 deletions(-) create mode 100644 changelog.d/+publisher-input-checks.change.md diff --git a/README.md b/README.md index 0a11560..09890e7 100644 --- a/README.md +++ b/README.md @@ -514,9 +514,9 @@ no write or second metadata fetch. Older mutable releases retain the recheck. - `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the tag's commit, used as the target when the Action creates the Release. `title` and the file bytes must match an existing Release exactly. -- `assets-directory`: Optional directory containing only the assets to attach. The Action compares each filename and - SHA256 digest with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. - After publication it verifies GitHub's release attestation for each local asset. +- `assets-directory`: Optional directory of the assets to attach. The Action compares each filename and SHA256 digest + with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After + publication it verifies GitHub's release attestation for each local asset. - `prerelease`: `true` or `false`; default `false`. It must match an existing Release. - `mode`: `complete` (default) creates or resumes a draft and publishes it in one call. `stage` creates or resumes a draft and uploads assets without publishing. `publish` requires an existing draft and publishes it after checking the diff --git a/changelog.d/+publisher-input-checks.change.md b/changelog.d/+publisher-input-checks.change.md new file mode 100644 index 0000000..6bb02a3 --- /dev/null +++ b/changelog.d/+publisher-input-checks.change.md @@ -0,0 +1,6 @@ +Stop validating caller input in the publishing Actions. `publish-github-release` no longer rejects an empty tag, title, +or notes file, a `prerelease` other than `true` or `false`, an unknown `mode`, or asset directories with hidden, +non-regular, or unusually named entries. `publish-jetbrains-marketplace` no longer checks the archive name and type, the +plugin ID, the channel, the tag ref, the notes file type, the artifact IDs, or that `publish-token` is set. +`sign-jetbrains-plugin` no longer checks the archive name and type, and `publish-post-release-pr` no longer checks the +format of its inputs or the `allowed-paths` entries. diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index 8d1dbed..b983a35 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -28,39 +28,15 @@ runs: - name: Verify the tagged release request shell: bash env: - GH_REPO: ${{ github.repository }} - TAG: ${{ inputs.tag }} - TITLE: ${{ inputs.title }} - NOTES_FILE: ${{ inputs.notes-file }} ASSETS_DIRECTORY: ${{ inputs.assets-directory }} - PRERELEASE: ${{ inputs.prerelease }} - MODE: ${{ inputs.mode }} run: | set -euo pipefail - [[ -n "$GH_REPO" && -n "$TAG" && -n "$TITLE" && -s "$NOTES_FILE" ]] || exit 1 - test "$PRERELEASE" = true || test "$PRERELEASE" = false - case "$MODE" in - stage | publish | complete) ;; - *) - echo "::error::invalid release mode: $MODE" - exit 1 - ;; - esac printf '[]\n' >"$RUNNER_TEMP/release-assets.json" if [[ -n "$ASSETS_DIRECTORY" ]]; then shopt -s nullglob dotglob - test -d "$ASSETS_DIRECTORY" - test -z "$(find "$ASSETS_DIRECTORY" -mindepth 1 -maxdepth 1 ! -type f -print -quit)" : >"$RUNNER_TEMP/release-assets.tsv" for asset in "$ASSETS_DIRECTORY"/*; do - test -f "$asset" name=${asset##*/} - case "$name" in - .* | *#* | *[[:cntrl:]]*) - echo "::error::unsafe release asset name: $name" - exit 1 - ;; - esac printf '%s\tsha256:%s\n' "$name" "$(sha256sum "$asset" | cut -d' ' -f1)" \ >>"$RUNNER_TEMP/release-assets.tsv" done diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index c1e757a..b65ad15 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -42,32 +42,11 @@ runs: env: ARCHIVE: ${{ inputs.archive }} EXPECTED_SHA: ${{ inputs.sha256 }} - PLUGIN_ID: ${{ inputs.plugin-id }} - CHANNEL: ${{ inputs.channel }} run: | set -euo pipefail - [[ -f "$ARCHIVE" && ! -L "$ARCHIVE" ]] || exit 1 - case "$ARCHIVE" in *.zip) ;; *) exit 1 ;; esac - case "$PLUGIN_ID" in '' | *[!0-9]*) exit 1 ;; esac - case "$CHANNEL" in *[!a-zA-Z0-9._-]*) exit 1 ;; esac test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" unzip -tq "$ARCHIVE" >/dev/null test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" - - name: Verify release identity - shell: bash - env: - TAG: ${{ inputs.tag }} - COMMIT: ${{ inputs.commit }} - SIGNED_ARTIFACT_ID: ${{ inputs.signed-artifact-id }} - NOTES_ARTIFACT_ID: ${{ inputs.notes-artifact-id }} - NOTES_FILE: ${{ inputs.notes-file }} - run: | - set -euo pipefail - [[ "$GITHUB_REF" == "refs/tags/$TAG" && "$COMMIT" == "$GITHUB_SHA" ]] || exit 1 - [[ -f "$NOTES_FILE" && ! -L "$NOTES_FILE" ]] || exit 1 - for artifact_id in "$SIGNED_ARTIFACT_ID" "$NOTES_ARTIFACT_ID"; do - case "$artifact_id" in '' | *[!0-9]*) exit 1 ;; esac - done - name: Find earlier Marketplace upload id: earlier shell: bash @@ -244,7 +223,6 @@ runs: PUBLISH_TOKEN: ${{ inputs.publish-token }} run: | set -euo pipefail - test -n "$PUBLISH_TOKEN" upload_file=$(mktemp "$RUNNER_TEMP/marketplace-upload.XXXXXX.zip") cp -- "$ARCHIVE" "$upload_file" test "$(sha256sum "$upload_file" | cut -d' ' -f1)" = "$EXPECTED_SHA" diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index 8c76361..c94ef3b 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -38,46 +38,10 @@ runs: shell: bash env: APP_SLUG: ${{ inputs.app-slug }} - BASE: main - BASE_COMMIT: ${{ inputs.base-commit }} - BRANCH: ${{ inputs.branch }} - TITLE: ${{ inputs.title }} - BODY_FILE: ${{ inputs.body-file }} - PATHS_FILE: ${{ inputs.paths-file }} - BUNDLE: ${{ inputs.bundle }} - CANDIDATE_COMMIT: ${{ inputs.candidate-commit }} - COMMIT_MESSAGE: ${{ inputs.commit-message }} run: | set -euo pipefail - case "$APP_SLUG" in - '' | *[!A-Za-z0-9-]*) exit 1 ;; - esac bot_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) - case "$bot_id" in - '' | 0* | *[!0-9]*) exit 1 ;; - esac printf '%s\n' "$bot_id" >"$RUNNER_TEMP/post-release-bot-id" - git check-ref-format --branch "$BASE" - git check-ref-format --branch "$BRANCH" - test "$BASE" != "$BRANCH" - test "${#BASE_COMMIT}" -eq 40 - case "$BASE_COMMIT" in - *[!0-9a-f]*) exit 1 ;; - esac - test -n "$TITLE" - test -n "$COMMIT_MESSAGE" - [[ "$COMMIT_MESSAGE" != *$'\n'* ]] - test -s "$BODY_FILE" - test -s "$PATHS_FILE" - if [[ -n "$BUNDLE" ]]; then - test -f "$BUNDLE" - test "${#CANDIDATE_COMMIT}" -eq 40 - case "$CANDIDATE_COMMIT" in - *[!0-9a-f]*) exit 1 ;; - esac - else - test -z "$CANDIDATE_COMMIT" - fi - name: Check the allowed path manifest shell: bash env: @@ -87,14 +51,9 @@ runs: set -euo pipefail trusted_paths=() while IFS= read -r path || [[ -n "$path" ]]; do - case "$path" in - '' | /* | *[!A-Za-z0-9._/-]* | . | .. | ./* | ../* | */. | */.. | */./* | */../*) exit 1 ;; - esac trusted_paths+=("$path") done < <(printf '%s' "$ALLOWED_PATHS") - test "${#trusted_paths[@]}" -gt 0 printf '%s\n' "${trusted_paths[@]}" | LC_ALL=C sort -u >"$RUNNER_TEMP/allowed-paths" - test "$(wc -l <"$RUNNER_TEMP/allowed-paths")" -eq "${#trusted_paths[@]}" paths=() while IFS=$'\t' read -r path blob; do case "$path" in diff --git a/sign-jetbrains-plugin/action.yml b/sign-jetbrains-plugin/action.yml index 8bb29ff..fe65e23 100644 --- a/sign-jetbrains-plugin/action.yml +++ b/sign-jetbrains-plugin/action.yml @@ -34,8 +34,6 @@ runs: EXPECTED_SHA: ${{ inputs.sha256 }} run: | set -euo pipefail - [[ -f "$ARCHIVE" && ! -L "$ARCHIVE" ]] || exit 1 - case "$ARCHIVE" in *.zip) ;; *) exit 1 ;; esac test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" unzip -tq "$ARCHIVE" >/dev/null test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 7039d42..db64bea 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -204,33 +204,6 @@ def test_mutable_published_release_is_rechecked( assert not _release_calls(tmp_path) -@pytest.mark.parametrize( - "asset_name", - [ - pytest.param(".hidden.zip", id="hidden"), - pytest.param("unsafe#label.zip", id="label-delimiter"), - pytest.param("unsafe\nname.zip", id="newline"), - ], -) -def test_unsafe_asset_names_are_rejected( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - release_cli: dict[str, str], - asset_name: str, -) -> None: - server, environment = github_api - notes, commit = _notes_and_commit(repository, tmp_path) - assets = tmp_path / "assets" - assets.mkdir() - (assets / asset_name).write_bytes(b"release archive") - result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets) - assert result.returncode != 0 - assert "unsafe release asset name" in result.stdout - assert not server.log - - def test_publish_requires_an_existing_draft( repository: Path, tmp_path: Path, @@ -706,18 +679,3 @@ def test_duplicate_releases_are_rejected( result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) assert result.returncode != 0 assert [request.method for request, _ in server.log] == ["GET"] - - -def test_invalid_mode_rejects_before_api_access( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - release_cli: dict[str, str], -) -> None: - server, environment = github_api - notes, commit = _notes_and_commit(repository, tmp_path) - result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="unknown") - assert result.returncode != 0 - assert "invalid release mode" in result.stdout - assert not server.log diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index ee26bd4..a1719aa 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -18,12 +18,8 @@ "changed", [ pytest.param("digest", id="digest"), - pytest.param("plugin-id", id="plugin-id"), - pytest.param("channel", id="channel"), - pytest.param("extension", id="extension"), pytest.param("corrupt", id="corrupt"), pytest.param("duplicate", id="duplicate"), - pytest.param("symlink", id="symlink"), ], ) def test_invalid_archive_or_input_fails_before_upload( @@ -42,25 +38,13 @@ def test_invalid_archive_or_input_fails_before_upload( } if changed == "digest": settings["SHA256"] = "0" * 64 - elif changed == "plugin-id": - settings["PLUGIN_ID"] = "fish" - elif changed == "channel": - settings["CHANNEL"] = "beta release" - elif changed == "extension": - wrong_extension = tmp_path / "plugin.tar" - wrong_extension.write_bytes(signed_archive.read_bytes()) - settings["ARCHIVE"] = str(wrong_extension) elif changed == "corrupt": signed_archive.write_bytes(b"not a zip") settings["SHA256"] = sha256(signed_archive.read_bytes()).hexdigest() - elif changed == "duplicate": + else: with ZipFile(signed_archive, "a") as content: content.writestr("Fish Shell/lib/plugin.jar", b"duplicate") settings["SHA256"] = sha256(signed_archive.read_bytes()).hexdigest() - else: - link = tmp_path / "linked.zip" - link.symlink_to(signed_archive) - settings["ARCHIVE"] = str(link) result = publish_action( tmp_path, marketplace_scripts, @@ -138,28 +122,6 @@ def test_multipart_upload_uses_exact_archive_and_channel( assert request.files["file"].read() == signed_archive.read_bytes() -def test_missing_token_fails_without_request( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="", - PUBLISH_TOKEN="", - RUNNER_TEMP=str(tmp_path), - ) - assert result.returncode != 0 - assert not httpserver.log - - def test_prior_upload_without_receipt_blocks_retry( signed_archive: Path, tmp_path: Path, diff --git a/tests/sign_jetbrains_plugin/test_sign.py b/tests/sign_jetbrains_plugin/test_sign.py index 5d3aeef..29da53b 100644 --- a/tests/sign_jetbrains_plugin/test_sign.py +++ b/tests/sign_jetbrains_plugin/test_sign.py @@ -89,10 +89,8 @@ def test_signs_and_verifies_exact_archive( "invalid", [ pytest.param("digest", id="wrong-digest"), - pytest.param("extension", id="wrong-extension"), pytest.param("corrupt", id="corrupt-zip"), pytest.param("duplicate", id="duplicate-member"), - pytest.param("symlink", id="symlink"), ], ) def test_rejects_invalid_unsigned_archive( @@ -102,17 +100,11 @@ def test_rejects_invalid_unsigned_archive( invalid: str, ) -> None: archive = unsigned_archive - if invalid == "extension": - archive = tmp_path / "plugin.tar" - archive.write_bytes(unsigned_archive.read_bytes()) - elif invalid == "corrupt": + if invalid == "corrupt": archive.write_bytes(b"not a ZIP") elif invalid == "duplicate": with ZipFile(archive, "a") as content: content.writestr("Plugin/lib/Plugin.jar", b"changed") - elif invalid == "symlink": - archive = tmp_path / "linked.zip" - archive.symlink_to(unsigned_archive) assert ( action( tmp_path, From 558ea24fd7bd5af2c1acfc40b881fd7ddb3b64da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:54:20 -0700 Subject: [PATCH 11/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20stop=20re-checkin?= =?UTF-8?q?g=20our=20own=20build=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sign, Marketplace and post-release Actions re-checked ZIPs, receipts, commits and branches that the release jobs had just produced or pushed. The digest, duplicate-member, bundle and manifest checks stay. The post-release Action drops app-slug and commit-message, which only fed the author and message checks. --- README.md | 26 ++- changelog.d/+jetbrains-rechecks.change.md | 4 + changelog.d/+own-output-rechecks.breaking.md | 4 + publish-jetbrains-marketplace/action.yml | 8 - publish-post-release-pr/action.yml | 98 +--------- sign-jetbrains-plugin/action.yml | 13 -- .../test_marketplace_upload.py | 28 --- .../test_post_release_pr.py | 185 +++--------------- tests/sign_jetbrains_plugin/test_sign.py | 43 +--- 9 files changed, 58 insertions(+), 351 deletions(-) create mode 100644 changelog.d/+jetbrains-rechecks.change.md create mode 100644 changelog.d/+own-output-rechecks.breaking.md diff --git a/README.md b/README.md index 09890e7..1f9727e 100644 --- a/README.md +++ b/README.md @@ -548,11 +548,9 @@ steps. env: GH_TOKEN: ${{ steps.app-token.outputs.token }} with: - app-slug: ${{ steps.app-token.outputs.app-slug }} base-commit: ${{ needs.prepare.outputs.base-commit }} branch: post-release-${{ github.ref_name }} title: Bump version to 2.4.8-dev - commit-message: Bump version to 2.4.8-dev after 2.4.7 body-file: prepared/body.md paths-file: prepared/paths.tsv allowed-paths: gradle.properties @@ -560,27 +558,25 @@ steps. candidate-commit: ${{ needs.prepare.outputs.candidate-commit }} ``` -The Action derives the bot identity from the writer's App slug and verifies the commit author and committer, exact -commit message, single-parent commit, changed path set against the writer's `allowed-paths`, regular file modes, and -blob identities before any write. +The Action verifies that the candidate is a single-parent commit on `base-commit`, and checks its changed path set +against the writer's `allowed-paths`, its regular file modes, and its blob identities before any write. -Pass the keyless job's `base-commit`, the desired `branch` and `title`, its exact one-line `commit-message`, and the -exact PR body in `body-file`. `paths-file` contains one tab-separated path and desired Git blob SHA1 per line. The -Action targets `main` in `GITHUB_REPOSITORY` and applies `skip news` to the PR. +Pass the keyless job's `base-commit`, the desired `branch` and `title`, and the exact PR body in `body-file`. +`paths-file` contains one tab-separated path and desired Git blob SHA1 per line. The Action targets `main` in +`GITHUB_REPOSITORY` and applies `skip news` to the PR. A matching open branch and PR need no second push. If a matching branch has no PR, it creates one. A merged PR with a -deleted branch succeeds only when the current base still has the expected file bytes. A closed, unmerged PR fails before -the Action creates a replacement branch or PR; reopen the PR before retrying. A different branch, PR title, body, base, -path set, or merged file state fails without a force-push. When the prepared change has already merged and produced no -new commit, pass empty `bundle` and `candidate-commit` values with the same path manifest; the Action still checks the -merged PR and current base. The Action has no named output. +deleted branch succeeds. A closed, unmerged PR fails before the Action creates a replacement branch or PR; reopen the PR +before retrying. A different PR title, body, or base fails without a force-push. When the prepared change has already +merged and produced no new commit, pass empty `bundle` and `candidate-commit` values with the same path manifest; the +Action finds the merged PR. The Action has no named output. ## Sign a JetBrains plugin `sign-jetbrains-plugin` signs an unsigned plugin ZIP after a keyless Gradle `buildPlugin` job. Run it in a fresh job on `ubuntu-26.04` with the signing environment, no checkout, and no Gradle invocation. Download the unsigned artifact and -pass the SHA256 emitted before that artifact was uploaded. The Action checks the file and its digest, downloads -JetBrains Marketplace ZIP Signer 0.1.43 from Maven Central, verifies its pinned SHA256, and uses the runner's Java 17 +pass the SHA256 emitted before that artifact was uploaded. The Action checks the file's digest, downloads JetBrains +Marketplace ZIP Signer 0.1.43 from Maven Central, verifies its pinned SHA256, and uses the runner's Java 17 installation. It accepts plain PEM or base64 credentials and an encrypted private key with its password. The private key is mapped only into the sign step's environment. Its temporary file is removed when that step ends. diff --git a/changelog.d/+jetbrains-rechecks.change.md b/changelog.d/+jetbrains-rechecks.change.md new file mode 100644 index 0000000..fe79ca9 --- /dev/null +++ b/changelog.d/+jetbrains-rechecks.change.md @@ -0,0 +1,4 @@ +Stop re-checking files the release jobs produce themselves in the JetBrains Actions. `sign-jetbrains-plugin` no longer +runs `unzip -t` on the unsigned or signed ZIP, checks for Java 17 or an existing signed ZIP, runs the signer's `verify`, +or checks the signed ZIP's type and members. `publish-jetbrains-marketplace` no longer runs `unzip -t`, re-hashes the +upload copy, or checks the file types of a downloaded receipt and earlier artifacts. diff --git a/changelog.d/+own-output-rechecks.breaking.md b/changelog.d/+own-output-rechecks.breaking.md new file mode 100644 index 0000000..e6d103e --- /dev/null +++ b/changelog.d/+own-output-rechecks.breaking.md @@ -0,0 +1,4 @@ +Remove the `app-slug` and `commit-message` inputs from `publish-post-release-pr`; remove them from callers. The Action +no longer compares the candidate's author, committer, and message with the App bot and the expected message, re-reads +the branch, pull request head, or base after writing, or checks the files of an already merged pull request against the +current base. diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index b65ad15..a40469e 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -45,7 +45,6 @@ runs: run: | set -euo pipefail test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" - unzip -tq "$ARCHIVE" >/dev/null test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" - name: Find earlier Marketplace upload id: earlier @@ -159,8 +158,6 @@ runs: mkdir "$RUNNER_TEMP/prior-acceptance" gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name "$RECEIPT_NAME" \ --dir "$RUNNER_TEMP/prior-acceptance" - receipt="$RUNNER_TEMP/prior-acceptance/marketplace-acceptance.json" - [[ -f "$receipt" && ! -L "$receipt" ]] || exit 1 # A later attempt finds this attempt's started upload step; the copy shows it skipped the upload. - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.earlier.outputs.accepted == '1' @@ -207,9 +204,6 @@ runs: --dir "$RUNNER_TEMP/prior-signed" gh run download "$source_run" --repo "$GITHUB_REPOSITORY" --name release-notes \ --dir "$RUNNER_TEMP/prior-notes" - [[ -f "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}" && ! -L "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}" ]] || exit 1 - [[ -f "$RUNNER_TEMP/prior-notes/release-notes.md" && - ! -L "$RUNNER_TEMP/prior-notes/release-notes.md" ]] || exit 1 test "$(sha256sum "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}" | cut -d' ' -f1)" = "$EXPECTED_SHA" cmp "$NOTES_FILE" "$RUNNER_TEMP/prior-notes/release-notes.md" - name: Upload signed ZIP @@ -217,7 +211,6 @@ runs: shell: bash env: ARCHIVE: ${{ inputs.archive }} - EXPECTED_SHA: ${{ inputs.sha256 }} PLUGIN_ID: ${{ inputs.plugin-id }} CHANNEL: ${{ inputs.channel }} PUBLISH_TOKEN: ${{ inputs.publish-token }} @@ -225,7 +218,6 @@ runs: set -euo pipefail upload_file=$(mktemp "$RUNNER_TEMP/marketplace-upload.XXXXXX.zip") cp -- "$ARCHIVE" "$upload_file" - test "$(sha256sum "$upload_file" | cut -d' ' -f1)" = "$EXPECTED_SHA" cd "$RUNNER_TEMP" status=$(curl --fail-with-body --max-time 120 --silent --show-error --write-out '%{http_code}' \ --output "$RUNNER_TEMP/marketplace-response.json" \ diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index c94ef3b..28c90bd 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -1,9 +1,6 @@ name: Publish a prepared pull request description: Reconcile a prepared branch and pull request without checking out project code inputs: - app-slug: - description: Slug of the GitHub App whose bot authors the prepared commit - required: true base-commit: description: Base commit used by the keyless preparation job required: true @@ -28,20 +25,9 @@ inputs: candidate-commit: description: Candidate commit SHA in the bundle, empty when the change is already merged default: "" - commit-message: - description: Exact subject of the prepared candidate commit - required: true runs: using: composite steps: - - name: Validate the prepared pull request inputs - shell: bash - env: - APP_SLUG: ${{ inputs.app-slug }} - run: | - set -euo pipefail - bot_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) - printf '%s\n' "$bot_id" >"$RUNNER_TEMP/post-release-bot-id" - name: Check the allowed path manifest shell: bash env: @@ -72,11 +58,9 @@ runs: - name: Verify the prepared commit shell: bash env: - APP_SLUG: ${{ inputs.app-slug }} BASE_COMMIT: ${{ inputs.base-commit }} BUNDLE: ${{ inputs.bundle }} CANDIDATE_COMMIT: ${{ inputs.candidate-commit }} - COMMIT_MESSAGE: ${{ inputs.commit-message }} PATHS_FILE: ${{ inputs.paths-file }} run: | set -euo pipefail @@ -93,14 +77,6 @@ runs: test "$commit" = "$CANDIDATE_COMMIT" test "$parent" = "$BASE_COMMIT" test -z "${extra:-}" - author_name="${APP_SLUG}[bot]" - author_email="$(cat "$RUNNER_TEMP/post-release-bot-id")+${author_name}@users.noreply.github.com" - test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%an "$CANDIDATE_COMMIT")" = "$author_name" - test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%ae "$CANDIDATE_COMMIT")" = "$author_email" - test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%cn "$CANDIDATE_COMMIT")" = "$author_name" - test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%ce "$CANDIDATE_COMMIT")" = "$author_email" - test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%B "$CANDIDATE_COMMIT")" = "$COMMIT_MESSAGE" - git -C "$RUNNER_TEMP/post-release.git" rev-parse "$CANDIDATE_COMMIT^{tree}" >"$RUNNER_TEMP/candidate-tree" git -C "$RUNNER_TEMP/post-release.git" diff --name-only --no-renames "$BASE_COMMIT" "$CANDIDATE_COMMIT" | LC_ALL=C sort >"$RUNNER_TEMP/candidate-paths" cmp -s "$RUNNER_TEMP/expected-paths" "$RUNNER_TEMP/candidate-paths" @@ -140,74 +116,27 @@ runs: ' "$RUNNER_TEMP/post-release-all-prs.json" >"$RUNNER_TEMP/post-release-prs.json" - name: Verify an already merged pull request shell: bash - env: - BASE: main - PATHS_FILE: ${{ inputs.paths-file }} run: | set -euo pipefail if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then - base_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha) - test "${#base_sha}" -eq 40 - case "$base_sha" in - *[!0-9a-f]*) exit 1 ;; - esac - pr_number=$(jq -r '.[0].number' "$RUNNER_TEMP/post-release-prs.json") - while IFS=$'\t' read -r path blob; do - read -r object actual < <(gh api "repos/$GITHUB_REPOSITORY/contents/$path?ref=$BASE" --jq '[.type, .sha] | @tsv') - test "$object" = file - test "$actual" = "$blob" - done <"$PATHS_FILE" - gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number/files" --paginate --jq '.[].filename' | - LC_ALL=C sort | cmp -s - "$RUNNER_TEMP/expected-paths" - printf '%s\n' "$pr_number" >"$RUNNER_TEMP/post-release-number" + jq -r '.[0].number' "$RUNNER_TEMP/post-release-prs.json" >"$RUNNER_TEMP/post-release-number" fi - name: Publish or verify the prepared branch shell: bash env: - APP_SLUG: ${{ inputs.app-slug }} - BASE: main - BASE_COMMIT: ${{ inputs.base-commit }} BRANCH: ${{ inputs.branch }} - BUNDLE: ${{ inputs.bundle }} - CANDIDATE_COMMIT: ${{ inputs.candidate-commit }} - COMMIT_MESSAGE: ${{ inputs.commit-message }} run: | set -euo pipefail - if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") != true ]]; then - test -n "$BUNDLE" - test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT" - pr_number=$(jq -r '.[0].number // empty' "$RUNNER_TEMP/post-release-prs.json") - if branch_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha \ - 2>"$RUNNER_TEMP/branch-error"); then - gh api "repos/$GITHUB_REPOSITORY/git/commits/$branch_sha" >"$RUNNER_TEMP/post-release-branch.json" - read -r parent_count branch_parent branch_tree branch_author branch_email < <( - jq -r '[(.parents | length), .parents[0].sha, .tree.sha, .author.name, .author.email] | @tsv' \ - "$RUNNER_TEMP/post-release-branch.json" - ) - test "$parent_count" = 1 - test "$branch_parent" = "$BASE_COMMIT" - test "$branch_tree" = "$(cat "$RUNNER_TEMP/candidate-tree")" - test "$branch_author" = "${APP_SLUG}[bot]" - test "$branch_email" = "$(cat "$RUNNER_TEMP/post-release-bot-id")+${APP_SLUG}[bot]@users.noreply.github.com" - jq -e --arg name "${APP_SLUG}[bot]" \ - --arg email "$(cat "$RUNNER_TEMP/post-release-bot-id")+${APP_SLUG}[bot]@users.noreply.github.com" \ - --arg message "$COMMIT_MESSAGE" \ - '.committer.name == $name and .committer.email == $email and .message == $message' \ - "$RUNNER_TEMP/post-release-branch.json" >/dev/null - elif grep -Fq '(HTTP 404)' "$RUNNER_TEMP/branch-error"; then - test -z "$pr_number" - test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT" - gh auth setup-git --hostname "${GH_HOST:-github.com}" --force - git -C "$RUNNER_TEMP/post-release.git" remote add origin \ - "https://${GH_HOST:-github.com}/$GITHUB_REPOSITORY.git" - git -C "$RUNNER_TEMP/post-release.git" push origin "refs/heads/release-candidate:refs/heads/$BRANCH" - branch_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha) - test "$branch_sha" = "$CANDIDATE_COMMIT" - else + if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") != true ]] && + ! gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" >/dev/null 2>"$RUNNER_TEMP/branch-error"; then + if ! grep -Fq '(HTTP 404)' "$RUNNER_TEMP/branch-error"; then cat "$RUNNER_TEMP/branch-error" >&2 exit 1 fi - printf '%s\n' "$branch_sha" >"$RUNNER_TEMP/post-release-branch-sha" + gh auth setup-git --hostname "${GH_HOST:-github.com}" --force + git -C "$RUNNER_TEMP/post-release.git" remote add origin \ + "https://${GH_HOST:-github.com}/$GITHUB_REPOSITORY.git" + git -C "$RUNNER_TEMP/post-release.git" push origin "refs/heads/release-candidate:refs/heads/$BRANCH" fi - name: Create or verify the pull request shell: bash @@ -220,19 +149,10 @@ runs: set -euo pipefail if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") != true ]]; then pr_number=$(jq -r '.[0].number // empty' "$RUNNER_TEMP/post-release-prs.json") - branch_sha=$(cat "$RUNNER_TEMP/post-release-branch-sha") - test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)" = "$branch_sha" - if [[ -n "$pr_number" ]]; then - test "$(jq -r '.[0].head.sha' "$RUNNER_TEMP/post-release-prs.json")" = "$branch_sha" - else + if [[ -z "$pr_number" ]]; then gh api -X POST "repos/$GITHUB_REPOSITORY/pulls" -f "head=$BRANCH" -f "base=$BASE" \ -f "title=$TITLE" -F "body=@$BODY_FILE" >"$RUNNER_TEMP/post-release-created.json" pr_number=$(jq -r .number "$RUNNER_TEMP/post-release-created.json") - case "$pr_number" in - '' | 0* | *[!0-9]*) exit 1 ;; - esac - test "$(jq -r .head.sha "$RUNNER_TEMP/post-release-created.json")" = "$branch_sha" - test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)" = "$branch_sha" fi printf '%s\n' "$pr_number" >"$RUNNER_TEMP/post-release-number" fi diff --git a/sign-jetbrains-plugin/action.yml b/sign-jetbrains-plugin/action.yml index fe65e23..44e660d 100644 --- a/sign-jetbrains-plugin/action.yml +++ b/sign-jetbrains-plugin/action.yml @@ -35,7 +35,6 @@ runs: run: | set -euo pipefail test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" - unzip -tq "$ARCHIVE" >/dev/null test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" echo "archive=${ARCHIVE%.zip}-signed.zip" >>"$GITHUB_OUTPUT" - name: Download pinned Marketplace signer @@ -51,9 +50,6 @@ runs: env: SIGNER_SHA256: 2958a0f42221d6062b50f22754e413ae5cc42f60c441467202c6700df2e22f44 run: echo "$SIGNER_SHA256 $RUNNER_TEMP/marketplace-signer-cli.jar" | sha256sum --check - - name: Check Java 17 - shell: bash - run: test -x "$JAVA_HOME_17_X64/bin/java" - name: Sign and verify plugin ZIP shell: bash env: @@ -65,10 +61,6 @@ runs: run: | set -euo pipefail umask 077 - if [[ -e "$SIGNED" || -L "$SIGNED" ]]; then - echo '::error::signed archive already exists' >&2 - exit 1 - fi trap 'rm -f "$RUNNER_TEMP"/signing-{key,key-decrypted,chain}.pem' EXIT if [[ "$CERTIFICATE_CHAIN" == *'-----BEGIN CERTIFICATE-----'* ]]; then printf '%s' "$CERTIFICATE_CHAIN" >"$RUNNER_TEMP/signing-chain.pem" @@ -95,8 +87,6 @@ runs: "$JAVA_HOME_17_X64/bin/java" -jar "$RUNNER_TEMP/marketplace-signer-cli.jar" sign \ -in "$ARCHIVE" -out "$SIGNED" -cert-file "$RUNNER_TEMP/signing-chain.pem" \ -key-file "$signing_key" - "$JAVA_HOME_17_X64/bin/java" -jar "$RUNNER_TEMP/marketplace-signer-cli.jar" verify \ - -in "$SIGNED" -cert "$RUNNER_TEMP/signing-chain.pem" - name: Record signed plugin ZIP id: digest shell: bash @@ -104,9 +94,6 @@ runs: SIGNED: ${{ steps.unsigned.outputs.archive }} run: | set -euo pipefail - [[ -f "$SIGNED" && ! -L "$SIGNED" ]] || exit 1 - unzip -tq "$SIGNED" >/dev/null - test -z "$(zipinfo -1 "$SIGNED" | LC_ALL=C sort | uniq -d)" read -r digest _ < <(sha256sum "$SIGNED") echo "archive=$SIGNED" >>"$GITHUB_OUTPUT" echo "sha256=$digest" >>"$GITHUB_OUTPUT" diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index a1719aa..03a4a2e 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -18,7 +18,6 @@ "changed", [ pytest.param("digest", id="digest"), - pytest.param("corrupt", id="corrupt"), pytest.param("duplicate", id="duplicate"), ], ) @@ -38,9 +37,6 @@ def test_invalid_archive_or_input_fails_before_upload( } if changed == "digest": settings["SHA256"] = "0" * 64 - elif changed == "corrupt": - signed_archive.write_bytes(b"not a zip") - settings["SHA256"] = sha256(signed_archive.read_bytes()).hexdigest() else: with ZipFile(signed_archive, "a") as content: content.writestr("Fish Shell/lib/plugin.jar", b"duplicate") @@ -437,30 +433,6 @@ def test_prior_acceptance_with_matching_artifacts_skips_upload( assert not httpserver.log -def test_prior_acceptance_missing_receipt_file_blocks_retry( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - previous_run=True, - accepted_archive=signed_archive, - missing_receipt=True, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert not httpserver.log - - @pytest.mark.parametrize( ("field", "value"), [ diff --git a/tests/publish_post_release_pr/test_post_release_pr.py b/tests/publish_post_release_pr/test_post_release_pr.py index 8ddaf19..0395875 100644 --- a/tests/publish_post_release_pr/test_post_release_pr.py +++ b/tests/publish_post_release_pr/test_post_release_pr.py @@ -10,12 +10,10 @@ from pytest_httpserver import HTTPServer -def test_action_maps_writer_app_slug() -> None: +def test_action_maps_writer_allowed_paths() -> None: action = yaml.safe_load((Path(__file__).resolve().parents[2] / "publish-post-release-pr/action.yml").read_text()) - assert action["inputs"]["app-slug"]["required"] assert action["inputs"]["allowed-paths"]["required"] - assert action["runs"]["steps"][0]["env"]["APP_SLUG"] == "${{ inputs.app-slug }}" - assert action["runs"]["steps"][1]["env"]["ALLOWED_PATHS"] == "${{ inputs.allowed-paths }}" + assert action["runs"]["steps"][0]["env"]["ALLOWED_PATHS"] == "${{ inputs.allowed-paths }}" assert "outputs" not in action assert {"repository", "base", "label"}.isdisjoint(action["inputs"]) @@ -36,21 +34,15 @@ def run_publish(action_scripts: Path, *, cwd: Path, env: dict[str, str]) -> subp ) -def expect_bot(server: HTTPServer) -> None: - server.expect_request("/api/v3/users/fixture-app[bot]", method="GET").respond_with_json({"id": 123}) - - def publish( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], *, - main_blob: str, expected_blob: str, additional_blob: str | None = None, ) -> tuple[subprocess.CompletedProcess[str], HTTPServer]: server, environment = github_api - expect_bot(server) body = tmp_path / "body.md" body.write_text("Set the next development version.\n") paths = tmp_path / "paths.tsv" @@ -71,23 +63,9 @@ def publish( } ] ) - server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/heads/main", method="GET").respond_with_json( - {"object": {"sha": "a" * 40}} + server.expect_request("/api/v3/repos/tox-dev/fixture/issues/7/labels", method="GET").respond_with_json( + [{"name": "skip news"}] ) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/gradle.properties", query_string="ref=main", method="GET" - ).respond_with_json({"type": "file", "sha": main_blob}) - if additional_blob is not None and main_blob == expected_blob: - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/other.txt", query_string="ref=main", method="GET" - ).respond_with_json({"type": "file", "sha": additional_blob}) - if main_blob == expected_blob: - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( - [{"filename": "gradle.properties"}, *([{"filename": "other.txt"}] if additional_blob is not None else [])] - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/issues/7/labels", method="GET").respond_with_json( - [{"name": "skip news"}] - ) result = run_publish( action_scripts, cwd=tmp_path, @@ -95,12 +73,10 @@ def publish( | environment | { "GITHUB_REPOSITORY": "tox-dev/fixture", - "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": "a" * 40, "BRANCH": "post-release-v2.4.7", "TITLE": "Bump version to 2.4.8-dev", - "COMMIT_MESSAGE": "Bump version", "BODY_FILE": str(body), "PATHS_FILE": str(paths), "ALLOWED_PATHS": "gradle.properties\nother.txt\n" if additional_blob is not None else "gradle.properties", @@ -116,34 +92,23 @@ def publish( def test_merged_branch_deleted_and_main_matches_is_complete( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: - result, server = publish(tmp_path, action_scripts, github_api, main_blob="b" * 40, expected_blob="b" * 40) + result, server = publish(tmp_path, action_scripts, github_api, expected_blob="b" * 40) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET"] * 6 + assert [request.method for request, _ in server.log] == ["GET"] * 2 def test_multiline_allowed_paths_accepts_trailing_newline( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: - result, server = publish( - tmp_path, action_scripts, github_api, main_blob="b" * 40, expected_blob="b" * 40, additional_blob="c" * 40 - ) + result, server = publish(tmp_path, action_scripts, github_api, expected_blob="b" * 40, additional_blob="c" * 40) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET"] * 7 - - -def test_merged_branch_deleted_and_later_main_change_fails( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - result, server = publish(tmp_path, action_scripts, github_api, main_blob="c" * 40, expected_blob="b" * 40) - assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] * 4 + assert [request.method for request, _ in server.log] == ["GET"] * 2 def test_open_pull_request_metadata_change_fails( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: server, environment = github_api - expect_bot(server) body = tmp_path / "body.md" body.write_text("Set the next development version.\n") paths = tmp_path / "paths.tsv" @@ -168,12 +133,10 @@ def test_open_pull_request_metadata_change_fails( | environment | { "GITHUB_REPOSITORY": "tox-dev/fixture", - "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": "a" * 40, "BRANCH": "post-release-v2.4.7", "TITLE": "Bump version to 2.4.8-dev", - "COMMIT_MESSAGE": "Bump version", "BODY_FILE": str(body), "PATHS_FILE": str(paths), "ALLOWED_PATHS": "gradle.properties", @@ -190,7 +153,6 @@ def test_closed_unmerged_pr_blocks_retry_before_write( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: server, environment = github_api - expect_bot(server) bundle, base, commit, blob = candidate(tmp_path) body = tmp_path / "body.md" body.write_text("Set the next development version.\n") @@ -216,12 +178,10 @@ def test_closed_unmerged_pr_blocks_retry_before_write( | environment | { "GITHUB_REPOSITORY": "tox-dev/fixture", - "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": base, "BRANCH": "post-release-v2.4.7", "TITLE": "Bump version to 2.4.8-dev", - "COMMIT_MESSAGE": "Bump version", "BODY_FILE": str(body), "PATHS_FILE": str(paths), "ALLOWED_PATHS": "gradle.properties", @@ -233,7 +193,7 @@ def test_closed_unmerged_pr_blocks_retry_before_write( ) assert result.returncode != 0 assert "post-release pull request was closed without merging; reopen it to retry" in result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET"] def run_git(directory: Path, *args: str) -> str: @@ -242,14 +202,7 @@ def run_git(directory: Path, *args: str) -> str: ).stdout.strip() -def candidate( - tmp_path: Path, - *, - extra_path: bool = False, - wrong_author: bool = False, - wrong_committer: bool = False, - wrong_message: bool = False, -) -> tuple[Path, str, str, str]: +def candidate(tmp_path: Path, *, extra_path: bool = False) -> tuple[Path, str, str, str]: repository = tmp_path / "source" repository.mkdir() run_git(repository, "init", "-q", "-b", "main") @@ -265,13 +218,7 @@ def candidate( if extra_path: (repository / "other.txt").write_text("unexpected\n") run_git(repository, "add", "other.txt") - if wrong_author: - run_git(repository, "config", "user.name", "Different author") - subprocess.run( - ["git", "-C", str(repository), "commit", "-qm", "Different message" if wrong_message else "Bump version"], - env=os.environ | ({"GIT_COMMITTER_NAME": "Different committer"} if wrong_committer else {}), - check=True, - ) + run_git(repository, "commit", "-qm", "Bump version") commit = run_git(repository, "rev-parse", "HEAD") blob = run_git(repository, "rev-parse", "HEAD:gradle.properties") bundle = tmp_path / "candidate.bundle" @@ -279,32 +226,17 @@ def candidate( return bundle, base, commit, blob -@pytest.mark.parametrize( - "branch_tamper", - [ - pytest.param("none", id="exact-branch"), - pytest.param("tree", id="divergent-tree"), - pytest.param("author", id="divergent-author"), - pytest.param("committer", id="divergent-committer"), - pytest.param("message", id="divergent-message"), - pytest.param("base", id="moved-base"), - pytest.param("parent", id="merge-commit"), - ], -) -def test_open_branch_must_match_prepared_candidate( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - branch_tamper: str, +def test_open_pull_request_branch_is_reused( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: server, environment = github_api - expect_bot(server) bundle, base, commit, blob = candidate(tmp_path) body = tmp_path / "body.md" body.write_text("Set the next development version.\n") paths = tmp_path / "paths.tsv" paths.write_text(f"gradle.properties\t{blob}\n") - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/pulls", method="GET").respond_with_json( [ { "number": 7, @@ -317,33 +249,10 @@ def test_open_branch_must_match_prepared_candidate( } ] ) - server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/heads/main", method="GET").respond_with_json( - {"object": {"sha": "c" * 40 if branch_tamper == "base" else base}} - ) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/git/ref/heads/post-release-v2.4.7", method="GET" - ).respond_with_json({"object": {"sha": commit}}) - server.expect_request(f"/api/v3/repos/tox-dev/fixture/git/commits/{commit}", method="GET").respond_with_json( - { - "parents": [{"sha": base}] + ([{"sha": "d" * 40}] if branch_tamper == "parent" else []), - "author": { - "name": "Other" if branch_tamper == "author" else "fixture-app[bot]", - "email": "123+fixture-app[bot]@users.noreply.github.com", - }, - "committer": { - "name": "Other" if branch_tamper == "committer" else "fixture-app[bot]", - "email": "123+fixture-app[bot]@users.noreply.github.com", - }, - "message": "Other" if branch_tamper == "message" else "Bump version", - "tree": { - "sha": "b" * 40 if branch_tamper == "tree" else run_git(tmp_path / "source", "rev-parse", "HEAD^{tree}") - }, - } + server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( + {"object": {"sha": commit}} ) - if branch_tamper == "none": - server.expect_request("/api/v3/repos/tox-dev/fixture/issues/7/labels", method="GET").respond_with_json( - [{"name": "skip news"}] - ) + server.expect_ordered_request(f"{prefix}/issues/7/labels", method="GET").respond_with_json([{"name": "skip news"}]) result = run_publish( action_scripts, cwd=tmp_path, @@ -351,12 +260,10 @@ def test_open_branch_must_match_prepared_candidate( | environment | { "GITHUB_REPOSITORY": "tox-dev/fixture", - "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": base, "BRANCH": "post-release-v2.4.7", "TITLE": "Bump version to 2.4.8-dev", - "COMMIT_MESSAGE": "Bump version", "BODY_FILE": str(body), "PATHS_FILE": str(paths), "ALLOWED_PATHS": "gradle.properties", @@ -366,18 +273,14 @@ def test_open_branch_must_match_prepared_candidate( "RUNNER_TEMP": str(tmp_path), }, ) - assert (result.returncode == 0) is (branch_tamper == "none"), result.stderr - assert all(request.method == "GET" for request, _ in server.log) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET"] * 3 -@pytest.mark.parametrize( - "matching_head", [pytest.param(True, id="prepared-head"), pytest.param(False, id="changed-head")] -) def test_absent_branch_pushes_prepared_commit_and_opens_pr( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], matching_head: bool + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: server, environment = github_api - server.expect_ordered_request("/api/v3/users/fixture-app[bot]", method="GET").respond_with_json({"id": 123}) bundle, base, commit, blob = candidate(tmp_path) remote = tmp_path / "remote.git" subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True) @@ -399,32 +302,16 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( paths.write_text(f"gradle.properties\t{blob}\n") prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/pulls", method="GET").respond_with_json([]) - server.expect_ordered_request(f"{prefix}/git/ref/heads/main", method="GET").respond_with_json( - {"object": {"sha": base}} - ) server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( {"message": "Not Found"}, status=404 ) - server.expect_ordered_request(f"{prefix}/git/ref/heads/main", method="GET").respond_with_json( - {"object": {"sha": base}} - ) - server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( - {"object": {"sha": commit}} - ) - server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( - {"object": {"sha": commit}} - ) server.expect_ordered_request(f"{prefix}/pulls", method="POST").respond_with_json( - {"number": 7, "head": {"sha": commit if matching_head else "b" * 40}}, status=201 + {"number": 7, "head": {"sha": commit}}, status=201 + ) + server.expect_ordered_request(f"{prefix}/issues/7/labels", method="GET").respond_with_json([]) + server.expect_ordered_request(f"{prefix}/issues/7/labels", method="POST").respond_with_json( + [{"name": "skip news"}], status=200 ) - if matching_head: - server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( - {"object": {"sha": commit}} - ) - server.expect_ordered_request(f"{prefix}/issues/7/labels", method="GET").respond_with_json([]) - server.expect_ordered_request(f"{prefix}/issues/7/labels", method="POST").respond_with_json( - [{"name": "skip news"}], status=200 - ) result = run_publish( action_scripts, cwd=tmp_path, @@ -432,12 +319,10 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( | environment | { "GITHUB_REPOSITORY": "tox-dev/fixture", - "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": base, "BRANCH": "post-release-v2.4.7", "TITLE": "Bump version to 2.4.8-dev", - "COMMIT_MESSAGE": "Bump version", "BODY_FILE": str(body), "PATHS_FILE": str(paths), "ALLOWED_PATHS": "gradle.properties", @@ -448,7 +333,7 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( "GIT_CONFIG_GLOBAL": str(git_config), }, ) - assert (result.returncode == 0) is matching_head, result.stderr + assert result.returncode == 0, result.stderr assert run_git(remote, "rev-parse", "refs/heads/post-release-v2.4.7") == commit @@ -458,9 +343,6 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( pytest.param("commit", id="stale-bundle"), pytest.param("blob", id="changed-path-manifest"), pytest.param("extra", id="extra-path"), - pytest.param("author", id="wrong-author"), - pytest.param("committer", id="wrong-committer"), - pytest.param("message", id="wrong-message"), pytest.param("unauthorized", id="unauthorized-path"), ], ) @@ -471,14 +353,7 @@ def test_prepared_candidate_identity_is_checked_before_api_write( changed_input: str, ) -> None: server, environment = github_api - expect_bot(server) - bundle, base, commit, blob = candidate( - tmp_path, - extra_path=changed_input in {"extra", "unauthorized"}, - wrong_author=changed_input == "author", - wrong_committer=changed_input == "committer", - wrong_message=changed_input == "message", - ) + bundle, base, commit, blob = candidate(tmp_path, extra_path=changed_input in {"extra", "unauthorized"}) body = tmp_path / "body.md" body.write_text("Set the next development version.\n") paths = tmp_path / "paths.tsv" @@ -493,7 +368,6 @@ def test_prepared_candidate_identity_is_checked_before_api_write( | environment | { "GITHUB_REPOSITORY": "tox-dev/fixture", - "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": base, "BRANCH": "post-release-v2.4.7", @@ -503,10 +377,9 @@ def test_prepared_candidate_identity_is_checked_before_api_write( "ALLOWED_PATHS": "gradle.properties", "BUNDLE": str(bundle), "CANDIDATE_COMMIT": "d" * 40 if changed_input == "commit" else commit, - "COMMIT_MESSAGE": "Bump version", "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), }, ) assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] + assert not server.log diff --git a/tests/sign_jetbrains_plugin/test_sign.py b/tests/sign_jetbrains_plugin/test_sign.py index 29da53b..57f0d6c 100644 --- a/tests/sign_jetbrains_plugin/test_sign.py +++ b/tests/sign_jetbrains_plugin/test_sign.py @@ -89,7 +89,6 @@ def test_signs_and_verifies_exact_archive( "invalid", [ pytest.param("digest", id="wrong-digest"), - pytest.param("corrupt", id="corrupt-zip"), pytest.param("duplicate", id="duplicate-member"), ], ) @@ -100,9 +99,7 @@ def test_rejects_invalid_unsigned_archive( invalid: str, ) -> None: archive = unsigned_archive - if invalid == "corrupt": - archive.write_bytes(b"not a ZIP") - elif invalid == "duplicate": + if invalid == "duplicate": with ZipFile(archive, "a") as content: content.writestr("Plugin/lib/Plugin.jar", b"changed") assert ( @@ -119,44 +116,6 @@ def test_rejects_invalid_unsigned_archive( ) -def test_rejects_missing_java(action_scripts: Path, unsigned_archive: Path, tmp_path: Path) -> None: - assert ( - action( - tmp_path, - tmp_path, - action_scripts, - "sign-jetbrains-plugin", - version="", - ARCHIVE=str(unsigned_archive), - SHA256=hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(), - JAVA_HOME_17_X64=str(tmp_path / "missing"), - ).returncode - != 0 - ) - - -@pytest.mark.parametrize("existing", [pytest.param("file", id="file"), pytest.param("symlink", id="symlink")]) -def test_rejects_existing_signed_archive( - action_scripts: Path, unsigned_archive: Path, tmp_path: Path, existing: str -) -> None: - signed = tmp_path / "Plugin 2-1.0-signed.zip" - if existing == "symlink": - signed.symlink_to(unsigned_archive) - else: - signed.write_bytes(b"existing") - result = action( - tmp_path, - tmp_path, - action_scripts, - "sign-jetbrains-plugin", - version="", - ARCHIVE=str(unsigned_archive), - SHA256=hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(), - ) - assert result.returncode != 0 - assert "signed archive already exists" in result.stderr - - @pytest.fixture def unsigned_archive(tmp_path: Path) -> Path: archive = tmp_path / "Plugin 2-1.0.zip" From a2f19309c0c8c2698765aefc9f07f312efb28c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:55:37 -0700 Subject: [PATCH 12/23] =?UTF-8?q?=F0=9F=94=A5=20chore:=20drop=20writer=20p?= =?UTF-8?q?rocess=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Marketplace deployment scan and the post-release metadata, duplicate and label checks stopped writers who edited a pull request or deployment by hand. The upload receipts and the closed-pull-request stop stay. --- README.md | 33 +- changelog.d/+writer-process-guards.change.md | 4 + publish-jetbrains-marketplace/action.yml | 58 +--- publish-post-release-pr/action.yml | 9 +- .../test_marketplace_upload.py | 327 +----------------- .../test_post_release_pr.py | 44 --- 6 files changed, 24 insertions(+), 451 deletions(-) create mode 100644 changelog.d/+writer-process-guards.change.md diff --git a/README.md b/README.md index 1f9727e..053a32c 100644 --- a/README.md +++ b/README.md @@ -567,9 +567,8 @@ Pass the keyless job's `base-commit`, the desired `branch` and `title`, and the A matching open branch and PR need no second push. If a matching branch has no PR, it creates one. A merged PR with a deleted branch succeeds. A closed, unmerged PR fails before the Action creates a replacement branch or PR; reopen the PR -before retrying. A different PR title, body, or base fails without a force-push. When the prepared change has already -merged and produced no new commit, pass empty `bundle` and `candidate-commit` values with the same path manifest; the -Action finds the merged PR. The Action has no named output. +before retrying. When the prepared change has already merged and produced no new commit, pass empty `bundle` and +`candidate-commit` values with the same path manifest; the Action finds the merged PR. The Action has no named output. ## Sign a JetBrains plugin @@ -628,7 +627,6 @@ marketplace-publish: runs-on: ubuntu-26.04 permissions: actions: read - deployments: read environment: release steps: - uses: actions/download-artifact@DOWNLOAD_SHA @@ -656,13 +654,12 @@ marketplace-publish: ``` `channel` is empty for Stable; use a Marketplace channel name for a prerelease. Keep the publishing step named -`Publish signed ZIP`. The Action recognizes that name and two older upload step names in prior runs and deployment jobs. -Keep those names in the Action when adding another. Give the job Actions and Deployments read access and the workflow a -concurrency group keyed by tag. Use the `release` environment on the upload job. The Action searches earlier tag runs -and release deployments. An acceptance receipt must match the original artifact IDs, signed ZIP digest, and release -notes bytes before the Action skips upload. Any earlier release deployment without a verified receipt stops for manual -reconciliation, even if that run did not reach the Marketplace step. The same applies when the linked job is no longer -available. A changed publishing step name fails before upload until the Action recognizes it. +`Publish signed ZIP`. The Action recognizes that name and two older upload step names in prior runs. Keep those names in +the Action when adding another. Give the job Actions read access and the workflow a concurrency group keyed by tag. Use +the `release` environment on the upload job. The Action searches earlier tag runs for a started upload step. An +acceptance receipt must match the original artifact IDs, signed ZIP digest, and release notes bytes before the Action +skips upload. An earlier upload step without a receipt stops for manual reconciliation. A renamed publishing step is not +recognized in earlier runs until the Action knows its name. **Output:** A `marketplace-acceptance--` Actions artifact after a new upload. It records the tag, commit, plugin, channel, signed ZIP artifact and SHA256, and notes artifact and SHA256. **Remote effect:** One multipart upload @@ -676,20 +673,6 @@ rerun the tag workflow: the keyless stage check must skip Marketplace upload bef Marketplace rejected the update, resolve the rejection and prepare a new release tag. A 404 alone does not authorize another upload of the same tag. -If an earlier `release` deployment blocks a retry, inspect its linked run and job. Only when its logs prove the -Marketplace upload step never started, mark that deployment inactive and delete it with the -[GitHub deployment API](https://docs.github.com/en/rest/deployments/deployments#delete-a-deployment), then rerun the tag -workflow: - -```shell -gh api -X POST repos/OWNER/REPO/deployments/ID/statuses -f state=inactive -gh api -X DELETE repos/OWNER/REPO/deployments/ID -``` - -If the run or its logs have expired, do not delete the deployment based on absence from the public Marketplace download -API: JetBrains may still be reviewing the upload. Ask JetBrains to confirm its status or cut a new release tag and -version. - ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+writer-process-guards.change.md b/changelog.d/+writer-process-guards.change.md new file mode 100644 index 0000000..0dafc66 --- /dev/null +++ b/changelog.d/+writer-process-guards.change.md @@ -0,0 +1,4 @@ +Stop `publish-jetbrains-marketplace` from inspecting earlier `release` environment deployments for the tag before an +upload; the job no longer needs Deployments read access. Stop `publish-post-release-pr` from failing when an open pull +request's title, body, or base changed, when several pull requests match, or when a merged pull request lost its +`skip news` label; it re-applies the label instead. diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index a40469e..7093e17 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -29,7 +29,7 @@ inputs: description: Release notes downloaded from notes-artifact-id required: true github-token: - description: Token with Actions and Deployments read permissions + description: Token with Actions read permission required: true publish-token: description: JetBrains Marketplace upload token @@ -90,62 +90,6 @@ runs: } >>"$GITHUB_OUTPUT" fi echo "accepted=$prior" >>"$GITHUB_OUTPUT" - - name: Check earlier release deployments - if: steps.earlier.outputs.accepted == '0' - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - TAG: ${{ inputs.tag }} - COMMIT: ${{ inputs.commit }} - run: | - set -euo pipefail - encoded_tag=$(jq -rn --arg tag "$TAG" '$tag | @uri') - gh api --paginate "repos/$GITHUB_REPOSITORY/deployments?ref=$encoded_tag&environment=release&per_page=100" \ - --jq '.[] | [.id, .sha] | @tsv' >"$RUNNER_TEMP/marketplace-deployments.tsv" - current=0 - while IFS=$'\t' read -r deployment sha; do - target=$(gh api "repos/$GITHUB_REPOSITORY/deployments/$deployment/statuses?per_page=1" \ - --jq '.[0].log_url // ""') - prefix="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/" - if [[ "$target" != "$prefix"* ]]; then - echo "::error::Cannot identify release deployment $deployment. Reconcile tag $TAG manually." >&2 - exit 1 - fi - path=${target#"$prefix"} - run=${path%%/*} - job=${path##*/} - if [[ "$path" != "$run/job/$job" || ! "$run" =~ ^[0-9]+$ || ! "$job" =~ ^[0-9]+$ ]]; then - echo "::error::Cannot identify release deployment $deployment. Reconcile tag $TAG manually." >&2 - exit 1 - fi - identity=$(gh api "repos/$GITHUB_REPOSITORY/actions/jobs/$job" \ - --jq '[.run_id, .run_attempt, any(.steps[]; (.name == "Publish signed ZIP" or - .name == "Upload signed ZIP" or .name == "📤 Publish to JetBrains Marketplace") and - .started_at != null)] | @tsv') || { - echo "::error::Cannot inspect release deployment $deployment. Reconcile tag $TAG manually." >&2 - exit 1 - } - read -r job_run attempt upload_started <<<"$identity" - if [[ "$sha" != "$COMMIT" ]]; then - echo "::error::Tag $TAG has a release deployment on another commit." \ - "Reconcile deployment $deployment manually." >&2 - exit 1 - fi - if [[ "$run" != "$job_run" ]]; then - echo "::error::Cannot identify release deployment $deployment. Reconcile tag $TAG manually." >&2 - exit 1 - fi - if [[ "$run" != "$GITHUB_RUN_ID" || "$attempt" != "$GITHUB_RUN_ATTEMPT" ]]; then - echo "::error::Tag $TAG has a prior release deployment $deployment" \ - "from run $run attempt $attempt. Reconcile it manually." >&2 - exit 1 - fi - if [[ "$upload_started" == true ]]; then current=1; fi - done <"$RUNNER_TEMP/marketplace-deployments.tsv" - if ((current == 0)); then - echo "::error::Marketplace upload needs a release environment deployment for tag $TAG." >&2 - exit 1 - fi - name: Download earlier Marketplace acceptance if: steps.earlier.outputs.accepted == '1' shell: bash diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index 28c90bd..c72d34c 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -106,13 +106,10 @@ runs: [.[] | select(.head.repo.full_name == $repository and .head.ref == $branch)] as $existing | if any($existing[]; .state == "closed" and .merged_at == null) then error("post-release pull request was closed without merging; reopen it to retry") - elif any($existing[]; .state == "open" and - (.title != $title or .body != $body or .base.ref != $base)) then - error("open pull request metadata changed") else [$existing[] | select( .title == $title and .body == $body and .base.ref == $base and (.state == "open" or .merged_at != null) - )] | if length <= 1 then . else error("multiple matching pull requests") end end + )] end ' "$RUNNER_TEMP/post-release-all-prs.json" >"$RUNNER_TEMP/post-release-prs.json" - name: Verify an already merged pull request shell: bash @@ -163,9 +160,5 @@ runs: pr_number=$(cat "$RUNNER_TEMP/post-release-number") labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" --jq '.[].name') if ! grep -Fxq 'skip news' <<<"$labels"; then - if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then - echo '::error::merged pull request lost its required label' >&2 - exit 1 - fi gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" -f 'labels[]=skip news' >/dev/null fi diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index 03a4a2e..903ddde 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -165,248 +165,6 @@ def test_prior_upload_from_renamed_workflow_blocks_retry( assert not httpserver.log -def test_deleted_prior_run_deployment_blocks_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - previous_deployment=True, - previous_deployment_job_missing=True, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert "Cannot inspect release deployment" in result.stderr - assert not httpserver.log - - -@pytest.mark.parametrize( - ("log_url", "job_run"), - [ - pytest.param("https://example.org/tox-dev/fixture/actions/runs/41/job/170", None, id="foreign-host"), - pytest.param("https://github.com/tox-dev/fixture/actions/runs/41/attempts/1", None, id="not-a-job"), - pytest.param(None, 40, id="job-from-another-run"), - ], -) -def test_unidentified_prior_deployment_blocks_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, - log_url: str | None, - job_run: int | None, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - previous_deployment=True, - previous_deployment_log_url=log_url, - previous_deployment_job_run=job_run, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert "Cannot identify release deployment 70" in result.stderr - assert not httpserver.log - - -def test_prior_contributor_deployment_blocks_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - previous_deployment=True, - previous_deployment_upload=False, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert "prior release deployment 70 from run 41 attempt 1" in result.stderr - assert not httpserver.log - - -@pytest.mark.parametrize( - ("state", "started"), - [ - pytest.param("success", "2026-10-23T00:00:00Z", id="completed"), - pytest.param("failure", None, id="partial"), - ], -) -def test_renamed_prior_upload_deployment_blocks_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, - state: str, - started: str | None, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - previous_deployment=True, - previous_deployment_step="Publish to Marketplace", - previous_deployment_started=started, - previous_deployment_state=state, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert "prior release deployment 70 from run 41 attempt 1" in result.stderr - assert not httpserver.log - - -def test_new_tag_on_old_commit_can_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - httpserver.expect_request("/api/updates/upload", method="POST").respond_with_data("{}") - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - run_history=False, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode == 0, result.stderr - assert len(httpserver.log) == 1 - - -def test_previous_attempt_deployment_blocks_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - previous_deployment=True, - previous_deployment_run=42, - previous_deployment_attempt=1, - current_attempt=2, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert "prior release deployment" in result.stderr - assert not httpserver.log - - -def test_moved_tag_deployment_blocks_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - previous_deployment=True, - previous_deployment_sha="1" * 40, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert "release deployment on another commit" in result.stderr - assert not httpserver.log - - -def test_missing_current_publisher_deployment_blocks_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - current_deployment_upload=False, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode != 0 - assert "needs a release environment deployment" in result.stderr - assert not httpserver.log - - -def test_current_run_other_deployments_allow_upload( - signed_archive: Path, - tmp_path: Path, - marketplace_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - httpserver: HTTPServer, -) -> None: - httpserver.expect_request("/api/updates/upload", method="POST").respond_with_data("{}") - result = publish_action( - tmp_path, - marketplace_scripts, - github_api, - extra_current_deployment=True, - ARCHIVE=str(signed_archive), - SHA256=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="beta", - PUBLISH_TOKEN="fixture-token", - ) - assert result.returncode == 0, result.stderr - assert len(httpserver.log) == 1 - assert any( - request.path == "/api/v3/repos/tox-dev/fixture/deployments" - and request.query_string == b"ref=v0.1.11&environment=release&per_page=100" - for request, _ in github_api[0].log - ) - - @pytest.mark.parametrize("previous_workflow", ["release.yaml", "old-release.yaml"]) def test_prior_acceptance_with_matching_artifacts_skips_upload( signed_archive: Path, @@ -580,21 +338,6 @@ def publish_action( runner_temp: Path | None = None, previous_run: bool = False, previous_workflow: str = "release.yaml", - previous_deployment: bool = False, - previous_deployment_run: int = 41, - previous_deployment_attempt: int = 1, - previous_deployment_upload: bool = True, - previous_deployment_step: str = "Publish signed ZIP", - previous_deployment_started: str | None = "2026-10-23T00:00:00Z", - previous_deployment_state: str = "success", - previous_deployment_sha: str = "0" * 40, - previous_deployment_job_missing: bool = False, - previous_deployment_log_url: str | None = None, - previous_deployment_job_run: int | None = None, - extra_current_deployment: bool = False, - current_deployment_upload: bool = True, - current_attempt: int = 1, - run_history: bool = True, accepted_archive: Path | None = None, missing_receipt: bool = False, receipt_changes: dict[str, str | int] | None = None, @@ -605,19 +348,15 @@ def publish_action( **settings: str, ) -> subprocess.CompletedProcess[str]: server, environment = github_api - runs = ( - [ - { - "id": 40, - "head_branch": "main", - "head_sha": "2" * 40, - "head_repository": {"full_name": "tox-dev/fixture"}, - "path": ".github/workflows/check.yaml", - } - ] - if run_history - else [] - ) + runs = [ + { + "id": 40, + "head_branch": "main", + "head_sha": "2" * 40, + "head_repository": {"full_name": "tox-dev/fixture"}, + "path": ".github/workflows/check.yaml", + } + ] if previous_run: runs.append( { @@ -639,52 +378,6 @@ def publish_action( } ) server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs").respond_with_json({"workflow_runs": runs}) - deployments = [{"id": 71, "sha": "0" * 40}] - if extra_current_deployment: - deployments.append({"id": 72, "sha": "0" * 40}) - if previous_deployment: - deployments.append({"id": 70, "sha": previous_deployment_sha}) - server.expect_request("/api/v3/repos/tox-dev/fixture/deployments").respond_with_json(deployments) - for deployment in deployments: - deployment_id = int(deployment["id"]) - prior = deployment_id == 70 - run_id = previous_deployment_run if prior else 42 - attempt = previous_deployment_attempt if prior else current_attempt - job_id = deployment_id + 100 - server.expect_request(f"/api/v3/repos/tox-dev/fixture/deployments/{deployment_id}/statuses").respond_with_json( - [ - { - "log_url": previous_deployment_log_url - if prior and previous_deployment_log_url is not None - else f"https://github.com/tox-dev/fixture/actions/runs/{run_id}/job/{job_id}", - "state": previous_deployment_state if prior else "in_progress", - } - ] - ) - job_request = server.expect_request(f"/api/v3/repos/tox-dev/fixture/actions/jobs/{job_id}") - if prior and previous_deployment_job_missing: - job_request.respond_with_data("deleted", status=404) - else: - job_request.respond_with_json( - { - "run_id": previous_deployment_job_run - if prior and previous_deployment_job_run is not None - else run_id, - "run_attempt": attempt, - "steps": [ - { - "name": ( - previous_deployment_step - if prior and previous_deployment_upload - else "Publish signed ZIP" - if current_deployment_upload and not prior - else "Generate contributors" - ), - "started_at": previous_deployment_started if prior else "2026-10-23T00:00:00Z", - } - ], - } - ) if previous_run: server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/41/jobs").respond_with_json( { @@ -771,7 +464,7 @@ def publish_action( GITHUB_SERVER_URL="https://github.com", GITHUB_WORKFLOW_REF="tox-dev/fixture/.github/workflows/release.yaml@refs/tags/v0.1.11", GITHUB_RUN_ID="42", - GITHUB_RUN_ATTEMPT=str(current_attempt), + GITHUB_RUN_ATTEMPT="1", **environment, **settings, ) diff --git a/tests/publish_post_release_pr/test_post_release_pr.py b/tests/publish_post_release_pr/test_post_release_pr.py index 0395875..9addabf 100644 --- a/tests/publish_post_release_pr/test_post_release_pr.py +++ b/tests/publish_post_release_pr/test_post_release_pr.py @@ -105,50 +105,6 @@ def test_multiline_allowed_paths_accepts_trailing_newline( assert [request.method for request, _ in server.log] == ["GET"] * 2 -def test_open_pull_request_metadata_change_fails( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - body = tmp_path / "body.md" - body.write_text("Set the next development version.\n") - paths = tmp_path / "paths.tsv" - paths.write_text(f"gradle.properties\t{'b' * 40}\n") - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( - [ - { - "number": 7, - "state": "open", - "merged_at": None, - "title": "Different title", - "body": body.read_text(), - "base": {"ref": "main"}, - "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7"}, - } - ] - ) - result = run_publish( - action_scripts, - cwd=tmp_path, - env=os.environ - | environment - | { - "GITHUB_REPOSITORY": "tox-dev/fixture", - "BASE": "main", - "BASE_COMMIT": "a" * 40, - "BRANCH": "post-release-v2.4.7", - "TITLE": "Bump version to 2.4.8-dev", - "BODY_FILE": str(body), - "PATHS_FILE": str(paths), - "ALLOWED_PATHS": "gradle.properties", - "BUNDLE": "", - "CANDIDATE_COMMIT": "", - "RUNNER_TEMP": str(tmp_path), - }, - ) - assert result.returncode != 0 - assert "open pull request metadata changed" in result.stderr - - def test_closed_unmerged_pr_blocks_retry_before_write( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: From 0345a9338271c3094a2b3c2f34e48c9260e5e4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:56:33 -0700 Subject: [PATCH 13/23] =?UTF-8?q?=F0=9F=94=A5=20chore:=20drop=20main=20re-?= =?UTF-8?q?runs=20and=20refresh=20env=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check.yaml re-ran every check on merged main and on dispatch, and refresh-release-lock compared the target environments of a refreshed lock that a writer reviews in its pull request anyway. Check mode still compares the whole lock. --- .github/workflows/check.yaml | 5 +-- changelog.d/+refresh-environments.change.md | 2 ++ refresh-release-lock/action.yml | 4 --- .../test_check_nab_lock.py | 35 +++---------------- tests/refresh_release_lock/test_refresh.py | 17 --------- tools/check_nab_lock.py | 5 --- 6 files changed, 8 insertions(+), 60 deletions(-) create mode 100644 changelog.d/+refresh-environments.change.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 6680a40..04ae815 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -1,10 +1,7 @@ name: Check on: - push: - branches: [main] pull_request: types: [opened, reopened, synchronize] - workflow_dispatch: permissions: contents: read concurrency: @@ -119,7 +116,7 @@ jobs: - name: Check Python types run: uvx --with tox-uv tox run -e type gate: - name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} + name: Required checks pass if: ${{ !cancelled() }} needs: [check, shell, test, type] runs-on: ubuntu-26.04 diff --git a/changelog.d/+refresh-environments.change.md b/changelog.d/+refresh-environments.change.md new file mode 100644 index 0000000..0171316 --- /dev/null +++ b/changelog.d/+refresh-environments.change.md @@ -0,0 +1,2 @@ +Stop `refresh-release-lock` from failing in `refresh` mode when the regenerated lock targets different environments. +`check` mode still compares the whole lock. diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index df8e707..d63d1ef 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -72,8 +72,6 @@ runs: esac cp pylock.toml "$RUNNER_TEMP/lock-before.toml" uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --all-groups --all-extras --no-cache "${refresh[@]}" - python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" --environments-only \ - "$RUNNER_TEMP/lock-before.toml" pylock.toml if [[ "$MODE" == check ]]; then python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" "$RUNNER_TEMP/lock-before.toml" pylock.toml fi @@ -123,8 +121,6 @@ runs: --project-matrix-platforms "${platforms[@]}" \ --project-matrix-implementations "${implementations[@]}" \ --output "$ADDITIONAL_LOCK" "${refresh[@]}" - python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" --environments-only \ - "$RUNNER_TEMP/additional-lock-before.toml" "$ADDITIONAL_LOCK" if [[ "$MODE" == check ]]; then python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \ "$RUNNER_TEMP/additional-lock-before.toml" "$ADDITIONAL_LOCK" diff --git a/tests/refresh_release_lock/test_check_nab_lock.py b/tests/refresh_release_lock/test_check_nab_lock.py index 5e4b03f..5c2568c 100644 --- a/tests/refresh_release_lock/test_check_nab_lock.py +++ b/tests/refresh_release_lock/test_check_nab_lock.py @@ -7,9 +7,6 @@ import pytest import tomlkit -sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from tools.check_nab_lock import main as check_nab_lock - @pytest.fixture def nab_lock_pair(tmp_path: Path) -> tuple[Path, Path]: @@ -41,38 +38,16 @@ def test_check_nab_lock_restores_committed_bytes(nab_lock_pair: tuple[Path, Path assert regenerated.read_bytes() == committed.read_bytes() -def test_check_nab_lock_environments_only_preserves_other_changes( - nab_lock_pair: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch -) -> None: - committed, regenerated = nab_lock_pair - changed = regenerated.read_text().replace('packages = ["safe"]', 'packages = ["changed"]') - regenerated.write_text(changed) - monkeypatch.setattr(sys, "argv", ["check-nab-lock", str(committed), str(regenerated)]) - with pytest.raises(SystemExit) as error: - check_nab_lock() - assert error.value.code == 2 - monkeypatch.setattr(sys, "argv", ["check-nab-lock", "--environments-only", str(committed), str(regenerated)]) - check_nab_lock() - assert regenerated.read_text() == changed - - -def test_check_nab_lock_environments_only_rejects_target_change(nab_lock_pair: tuple[Path, Path]) -> None: - committed, regenerated = nab_lock_pair - regenerated.write_text(regenerated.read_text().replace("python_version == 3.15", "python_version == 3.14")) +def test_check_nab_lock_import_has_no_cli_side_effect() -> None: + script = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" result = subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), - "--environments-only", - str(committed), - str(regenerated), - ], + [sys.executable, "-c", f"import runpy; runpy.run_path({str(script)!r}, run_name='imported')"], text=True, capture_output=True, check=False, ) - assert result.returncode != 0 - assert "changed its target environments" in result.stderr + + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") def test_check_nab_lock_reports_invalid_encoding(nab_lock_pair: tuple[Path, Path]) -> None: diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 9641f09..2a6320a 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -253,20 +253,3 @@ def test_refresh_check_requires_locked_nab_version(tmp_path: Path, action_script assert result.returncode != 0 assert "::error::check mode needs the Nab version in pylock.toml [tool.nab] nab-version" in result.stderr assert not (tmp_path / "installed").exists() - - -@pytest.mark.parametrize("mode", ["check", "refresh"]) -def test_refresh_rejects_changed_primary_environments(tmp_path: Path, action_scripts: Path, mode: str) -> None: - (tmp_path / "pylock.toml").write_text(_LOCK) - retargeting = tmp_path / "bin/uv" - retargeting.parent.mkdir() - retargeting.write_text( - f"#!{sys.executable}\nimport pathlib\nlock = pathlib.Path('pylock.toml')\n" - "lock.write_text(lock.read_text().replace('3.15', '3.14'))\n" - ) - retargeting.chmod(0o755) - - result = _run_step(action_scripts, "Resolve all project groups and extras", tmp_path, retargeting, MODE=mode) - - assert result.returncode != 0 - assert "regenerated Nab lock changed its target environments" in result.stderr diff --git a/tools/check_nab_lock.py b/tools/check_nab_lock.py index ff25116..28e7931 100644 --- a/tools/check_nab_lock.py +++ b/tools/check_nab_lock.py @@ -10,7 +10,6 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--environments-only", action="store_true") parser.add_argument("committed", type=Path) parser.add_argument("regenerated", type=Path) args = parser.parse_args() @@ -21,10 +20,6 @@ def main() -> None: created = old["tool"]["nab"].get("created-at") if not isinstance(created, datetime) or created.tzinfo is None or created > datetime.now(UTC): parser.error("committed Nab lock has no valid creation time or was created in the future") - if args.environments_only: - if old.get("environments") != new.get("environments"): - parser.error("regenerated Nab lock changed its target environments") - return old["tool"]["nab"].pop("command-line", None) new["tool"]["nab"].pop("command-line", None) if old != new: From fdc76f5377d96716595988299bd70dbaa0a9b747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 08:58:58 -0700 Subject: [PATCH 14/23] =?UTF-8?q?=E2=9C=A8=20feat:=20update=20the=20existi?= =?UTF-8?q?ng=20draft=20on=20reruns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish-github-release stops comparing the title, notes and prerelease flag of an existing Release and instead updates a draft that differs. It picks the first Release for a tag and creates a missing draft in publish mode. The read-after-write retry and asset digest checks stay. --- README.md | 33 ++--- .../+release-rerun-updates-draft.change.md | 4 + publish-github-release/action.yml | 45 +++--- publish-github-release/match.jq | 24 +--- tests/publish_github_release/test_publish.py | 130 +++++++++++------- 5 files changed, 120 insertions(+), 116 deletions(-) create mode 100644 changelog.d/+release-rerun-updates-draft.change.md diff --git a/README.md b/README.md index 053a32c..37a6991 100644 --- a/README.md +++ b/README.md @@ -492,13 +492,14 @@ no credentials. ## Publish a GitHub Release `publish-github-release` writes a GitHub Release from the rendered notes. Pass the exact notes file and the tag's commit -SHA. A retry resumes a matching draft, or succeeds without a write when the published Release already matches. Run it in -a Contents-write job that downloads the notes and optional assets; it needs no checkout or tool installation. For a -repository that must upload GitHub assets before PyPI, call it with `mode: stage`, verify PyPI's exact artifact set, -then call it with `mode: publish` and the same `assets-directory`. The draft stays private until the second call -succeeds. Enable immutable releases in the repository before using this Action. Drafts can still be edited, so the -Action checks them again before upload and publication. A matching published release whose API record is immutable needs -no write or second metadata fetch. Older mutable releases retain the recheck. +SHA. A retry resumes the existing draft and updates its title, notes, and prerelease flag, or succeeds without a write +when the Release is already published. Run it in a Contents-write job that downloads the notes and optional assets; it +needs no checkout or tool installation. For a repository that must upload GitHub assets before PyPI, call it with +`mode: stage`, verify PyPI's exact artifact set, then call it with `mode: publish` and the same `assets-directory`. The +draft stays private until the second call succeeds. Enable immutable releases in the repository before using this +Action. The Action checks a draft's assets again before upload and publication. A published release whose API record is +immutable has its assets checked from the release listing, with no write or second fetch. Older mutable releases retain +the asset recheck. ```yaml - uses: tox-dev/github-workflows/publish-github-release@SHA @@ -512,21 +513,21 @@ no write or second metadata fetch. Older mutable releases retain the recheck. ``` - `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the tag's - commit, used as the target when the Action creates the Release. `title` and the file bytes must match an existing - Release exactly. + commit, used as the target when the Action creates the Release. - `assets-directory`: Optional directory of the assets to attach. The Action compares each filename and SHA256 digest with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After publication it verifies GitHub's release attestation for each local asset. -- `prerelease`: `true` or `false`; default `false`. It must match an existing Release. +- `prerelease`: `true` or `false`; default `false`. - `mode`: `complete` (default) creates or resumes a draft and publishes it in one call. `stage` creates or resumes a - draft and uploads assets without publishing. `publish` requires an existing draft and publishes it after checking the - notes and complete asset set again. + draft and uploads assets without publishing. `publish` publishes the draft, creating it first if it is missing, after + checking its complete asset set again. **Output:** No named output. **Remote effect:** Depending on `mode`, the Action creates or resumes a draft, uploads -missing assets, and publishes only after the complete asset set matches. A matching published Release causes no write. A -different body, title, prerelease flag, or asset set fails without changing the existing Release. A failed upload leaves -the draft for a later retry. The Action removes incomplete `starter` assets before retrying. It retries reading the -release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that window ends. +missing assets, and publishes only after the complete asset set matches. A published Release causes no write. A draft +with a different title, body, or prerelease flag is updated; a Release with a different asset set fails. A failed upload +leaves the draft for a later retry. The Action removes incomplete `starter` assets before retrying. It retries reading +the release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that window +ends. ## Reconcile a post-release pull request diff --git a/changelog.d/+release-rerun-updates-draft.change.md b/changelog.d/+release-rerun-updates-draft.change.md new file mode 100644 index 0000000..b5c2572 --- /dev/null +++ b/changelog.d/+release-rerun-updates-draft.change.md @@ -0,0 +1,4 @@ +Make `publish-github-release` update an existing draft's title, notes, and prerelease flag on a rerun instead of +failing, use the first Release when several share the tag, and create the draft in `publish` mode when none exists. It +no longer compares the title, notes, and prerelease flag of an existing Release; the read-after-write retry and the +asset name and digest checks stay. diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index b983a35..e05317c 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -49,25 +49,19 @@ runs: env: GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} - TITLE: ${{ inputs.title }} - NOTES_FILE: ${{ inputs.notes-file }} - PRERELEASE: ${{ inputs.prerelease }} - MODE: ${{ inputs.mode }} run: | set -euo pipefail gh api --paginate "repos/$GH_REPO/releases?per_page=100" | jq -s --arg tag "$TAG" '[.[][] | select(.tag_name == $tag)]' >"$RUNNER_TEMP/release-existing.json" - if jq -e 'length == 1' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + if jq -e 'length > 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then jq -er '.[0].id' "$RUNNER_TEMP/release-existing.json" >"$RUNNER_TEMP/release-id" fi - jq -e --arg phase existing --arg title "$TITLE" --rawfile body "$NOTES_FILE" \ - --argjson prerelease "$PRERELEASE" --slurpfile assets "$RUNNER_TEMP/release-assets.json" \ - -f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-existing.json" - if [[ "$MODE" == publish ]]; then - jq -e 'length == 1' "$RUNNER_TEMP/release-existing.json" >/dev/null + immutable=$(jq -r '.[0] | .draft == false and .immutable == true' "$RUNNER_TEMP/release-existing.json") + if [[ "$immutable" == true ]]; then + jq '[.[0]]' "$RUNNER_TEMP/release-existing.json" | jq -e --arg phase complete \ + --slurpfile assets "$RUNNER_TEMP/release-assets.json" -f "$GITHUB_ACTION_PATH/match.jq" >/dev/null fi - printf 'immutable=%s\n' "$(jq -r 'any(.[]; .draft == false and .immutable == true)' \ - "$RUNNER_TEMP/release-existing.json")" >>"$GITHUB_OUTPUT" + printf 'immutable=%s\n' "$immutable" >>"$GITHUB_OUTPUT" - name: Create a missing draft shell: bash env: @@ -77,10 +71,9 @@ runs: TITLE: ${{ inputs.title }} NOTES_FILE: ${{ inputs.notes-file }} PRERELEASE: ${{ inputs.prerelease }} - MODE: ${{ inputs.mode }} run: | set -euo pipefail - if [[ "$MODE" != publish ]] && jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + if jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then jq -n --arg tag "$TAG" --arg commit "$COMMIT" --arg title "$TITLE" \ --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ '{tag_name: $tag, target_commitish: $commit, name: $title, body: $body, @@ -88,6 +81,13 @@ runs: gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \ >"$RUNNER_TEMP/release-created.json" jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id" + elif jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + '.[0].draft and (.[0].name != $title or .[0].body != $body or .[0].prerelease != $prerelease)' \ + "$RUNNER_TEMP/release-existing.json" >/dev/null; then + jq -n --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + '{name: $title, body: $body, prerelease: $prerelease}' >"$RUNNER_TEMP/release-update.json" + gh api -X PATCH "repos/$GH_REPO/releases/$(<"$RUNNER_TEMP/release-id")" \ + --input "$RUNNER_TEMP/release-update.json" >/dev/null fi - name: Upload missing draft assets if: steps.inspect.outputs.immutable != 'true' @@ -95,10 +95,7 @@ runs: env: GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} - TITLE: ${{ inputs.title }} - NOTES_FILE: ${{ inputs.notes-file }} ASSETS_DIRECTORY: ${{ inputs.assets-directory }} - PRERELEASE: ${{ inputs.prerelease }} MODE: ${{ inputs.mode }} run: | set -euo pipefail @@ -107,15 +104,14 @@ runs: if [[ "$MODE" != publish && -n "$ASSETS_DIRECTORY" ]]; then shopt -s nullglob release_id=$(<"$RUNNER_TEMP/release-id") - if jq -e 'length == 1 and .[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + if jq -e '.[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then while IFS= read -r asset_id; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" >/dev/null done < <(jq -r '.[0].assets[] | select(.state != "uploaded") | .id' "$RUNNER_TEMP/release-existing.json") fi read_draft() { gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-current.json" && - jq -e --arg phase draft --arg title "$TITLE" --rawfile body "$NOTES_FILE" \ - --argjson prerelease "$PRERELEASE" --slurpfile assets "$RUNNER_TEMP/release-assets.json" \ + jq -e --arg phase draft --slurpfile assets "$RUNNER_TEMP/release-assets.json" \ -f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-current.json" >/dev/null } if ! retry read_draft; then @@ -165,10 +161,6 @@ runs: shell: bash env: GH_REPO: ${{ github.repository }} - TAG: ${{ inputs.tag }} - TITLE: ${{ inputs.title }} - NOTES_FILE: ${{ inputs.notes-file }} - PRERELEASE: ${{ inputs.prerelease }} run: | set -euo pipefail # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. @@ -176,12 +168,11 @@ runs: release_id=$(<"$RUNNER_TEMP/release-id") read_complete() { gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-complete.json" && - jq -e --arg phase complete --arg title "$TITLE" --rawfile body "$NOTES_FILE" \ - --argjson prerelease "$PRERELEASE" --slurpfile assets "$RUNNER_TEMP/release-assets.json" \ + jq -e --arg phase complete --slurpfile assets "$RUNNER_TEMP/release-assets.json" \ -f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-complete.json" >/dev/null } if ! retry read_complete; then - echo '::error::release metadata or assets did not match after publication' >&2 + echo '::error::release assets did not match after publication' >&2 exit 1 fi - name: Publish the draft diff --git a/publish-github-release/match.jq b/publish-github-release/match.jq index f71446f..77538a6 100644 --- a/publish-github-release/match.jq +++ b/publish-github-release/match.jq @@ -1,29 +1,11 @@ -def metadata: - .name == $title and .body == $body and .prerelease == $prerelease; - def expected_asset: .name as $name | .digest as $digest | any($assets[0][]; .name == $name and .digest == $digest); -def allowed_asset($draft): - .name as $name | - any($assets[0][]; .name == $name) and - (if .state == "uploaded" then expected_asset else $draft end); - -if $phase == "existing" then - length <= 1 and all(.[]; - .draft as $draft | - metadata and - ([.assets[].name] | unique | length) == (.assets | length) and - all(.assets[]; allowed_asset($draft)) and - (.draft or (.assets | length) == ($assets[0] | length)) - ) -elif $phase == "draft" then - length == 1 and .[0].name == $title and .[0].body == $body and - .[0].prerelease == $prerelease and all(.[0].assets[]; .state == "uploaded" and expected_asset) +if $phase == "draft" then + length == 1 and all(.[0].assets[]; .state == "uploaded" and expected_asset) else - length == 1 and .[0].name == $title and .[0].body == $body and - .[0].prerelease == $prerelease and + length == 1 and ([.[0].assets[] | select(.state == "uploaded") | {name, digest}] | sort_by(.name)) == ($assets[0] | sort_by(.name)) end diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index db64bea..59f48df 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -147,64 +147,66 @@ def test_existing_published_release_is_verified_without_writes( ) -@pytest.mark.parametrize( - "changed_field", - [ - pytest.param("name", id="title"), - pytest.param("body", id="notes"), - pytest.param("prerelease", id="prerelease"), - pytest.param("assets", id="assets"), - ], -) -def test_existing_release_rejects_changed_content( +def test_immutable_release_with_changed_assets_fails( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], release_cli: dict[str, str], - changed_field: str, ) -> None: server, environment = github_api notes, commit = _notes_and_commit(repository, tmp_path) - release = _release(notes, draft=False) - release[changed_field] = { - "name": "Wrong title", - "body": "Wrong notes.\n", - "prerelease": True, - "assets": [{"name": "extra.zip", "digest": "sha256:deadbeef", "state": "uploaded"}], - }[changed_field] - prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release] + assets = tmp_path / "assets" + assets.mkdir() + (assets / "fixture.zip").write_bytes(b"release archive") + release = _release( + notes, + draft=False, + immutable=True, + assets=[{"name": "fixture.zip", "digest": "sha256:" + "0" * 64, "state": "uploaded"}], ) - result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100", method="GET" + ).respond_with_json([release]) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets) assert result.returncode != 0 assert [request.method for request, _ in server.log] == ["GET"] assert not _release_calls(tmp_path) -def test_mutable_published_release_is_rechecked( +@pytest.mark.parametrize( + ("changed_field", "value"), + [ + pytest.param("name", "Wrong title", id="title"), + pytest.param("body", "Wrong notes.\n", id="notes"), + pytest.param("prerelease", True, id="prerelease"), + ], +) +def test_rerun_updates_changed_draft( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], release_cli: dict[str, str], + changed_field: str, + value: str | bool, ) -> None: server, environment = github_api notes, commit = _notes_and_commit(repository, tmp_path) - release = _release(notes, draft=False) + draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release] + [draft | {changed_field: value}] ) - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) - result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) + server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(draft) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="stage") assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET"] - assert not _release_calls(tmp_path) + assert [request.method for request, _ in server.log] == ["GET", "PATCH", "GET"] + assert server.log[1][0].get_json() == {"name": "0.1.0", "body": notes.read_text(), "prerelease": False} -def test_publish_requires_an_existing_draft( +def test_mutable_published_release_is_rechecked( repository: Path, tmp_path: Path, action_scripts: Path, @@ -213,11 +215,16 @@ def test_publish_requires_an_existing_draft( ) -> None: server, environment = github_api notes, commit = _notes_and_commit(repository, tmp_path) + release = _release(notes, draft=False) prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) - result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="publish") - assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [release] + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert not _release_calls(tmp_path) def test_staged_release_publishes_without_reupload( @@ -260,7 +267,14 @@ def test_staged_release_publishes_without_reupload( ] -@pytest.mark.parametrize("mode", [pytest.param("stage", id="draft"), pytest.param("complete", id="published")]) +@pytest.mark.parametrize( + "mode", + [ + pytest.param("stage", id="draft"), + pytest.param("complete", id="published"), + pytest.param("publish", id="published-without-draft"), + ], +) def test_missing_release_is_created_then_optionally_published( repository: Path, tmp_path: Path, @@ -276,7 +290,7 @@ def test_missing_release_is_created_then_optionally_published( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) - if mode == "complete": + if mode != "stage": server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( draft | {"draft": False} ) @@ -286,7 +300,7 @@ def test_missing_release_is_created_then_optionally_published( "GET", "POST", "GET", - *(["PATCH"] if mode == "complete" else []), + *(["PATCH"] if mode != "stage" else []), ] assert not _release_calls(tmp_path) request = next(request for request, _ in server.log if request.method == "POST") @@ -415,7 +429,7 @@ def test_draft_release_replaces_incomplete_asset( assert [request.method for request, _ in server.log] == ["GET", "DELETE", "GET", "GET"] -def test_changed_draft_stops_before_upload( +def test_changed_draft_is_updated_before_upload( repository: Path, tmp_path: Path, action_scripts: Path, @@ -426,20 +440,29 @@ def test_changed_draft_stops_before_upload( notes, commit = _notes_and_commit(repository, tmp_path) assets = tmp_path / "assets" assets.mkdir() - (assets / "fixture.zip").write_bytes(b"release archive") + asset_path = assets / "fixture.zip" + asset_path.write_bytes(b"release archive") draft = _release(notes, draft=True) + complete = _release( + notes, + draft=True, + assets=[ + {"name": "fixture.zip", "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"} + ], + ) prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [draft] + [draft | {"body": "Human-edited notes.\n"}] ) - for _ in range(6): - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( - draft | {"body": "Human-edited notes.\n"} - ) - result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets) - assert result.returncode != 0 - assert not _release_calls(tmp_path) - assert [request.method for request, _ in server.log] == ["GET"] * 7 + server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(draft) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) + result = _request( + repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets, mode="stage" + ) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET", "PATCH", "GET", "GET"] + assert _release_calls(tmp_path) == [("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] def test_failed_upload_deletes_partial_asset_before_retry( @@ -663,7 +686,7 @@ def test_failed_asset_attestation_is_reported( ) -def test_duplicate_releases_are_rejected( +def test_duplicate_releases_use_the_first( repository: Path, tmp_path: Path, action_scripts: Path, @@ -672,10 +695,13 @@ def test_duplicate_releases_are_rejected( ) -> None: server, environment = github_api notes, commit = _notes_and_commit(repository, tmp_path) + first = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [_release(notes, draft=True), _release(notes, draft=True)] + [first, first | {"id": 43}] ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(first) + server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(first | {"draft": False}) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) - assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET", "GET", "PATCH"] From 99a72d5d3dfa2e4de5216870fed57ee8297c6837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 09:02:14 -0700 Subject: [PATCH 15/23] =?UTF-8?q?=E2=9C=A8=20feat:=20run=20news=20checks?= =?UTF-8?q?=20only=20for=20touched=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-news now validates fragments only when the fragment directory changes, checks the Towncrier configuration only when it changes, and runs the lock check only for pyproject, pylock or nab.toml changes. Workflow changes no longer trigger the lock check. --- README.md | 11 ++ changelog.d/+news-touched-files.feature.md | 6 + tests/conftest.py | 2 + tests/validate_news/test_news.py | 125 ++++++++++++++++++++- validate-news/action.yml | 50 ++++++++- 5 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 changelog.d/+news-touched-files.feature.md diff --git a/README.md b/README.md index 37a6991..61059c9 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,17 @@ configured directory against the configured categories, filename format, and non not. A bot's supplied fragment is checked and included in the normal release flow. The Action reads repository files and Git history; it does not add labels or write to GitHub. +Each check runs only when the pull request touches what it validates, compared with the merge base: + +- Fragment validation runs when a file in the configured fragment directory changes. +- The Towncrier configuration is checked when `towncrier.toml` or the `tool.towncrier` table of `pyproject.toml` + changes; such a change also validates the changed fragments. +- The lock check runs `refresh-release-lock` in `check` mode when any `pyproject.toml`, `pylock.toml`, a root + `pylock.*.toml`, or `nab.toml` changes. +- The lock-removal guard runs when the pull request deletes `pylock.toml`. + +The requirement for a fragment on human PRs is not tied to a file and runs on every pull request. + Put the `changelog` job in a separate `news.yaml` workflow triggered by `opened`, `reopened`, `synchronize`, `labeled`, and `unlabeled`. Require its status in the main-branch ruleset alongside the test aggregate. A label change then runs only the news workflow. Install Towncrier from the caller's release lock before calling the Action: diff --git a/changelog.d/+news-touched-files.feature.md b/changelog.d/+news-touched-files.feature.md new file mode 100644 index 0000000..2854cc3 --- /dev/null +++ b/changelog.d/+news-touched-files.feature.md @@ -0,0 +1,6 @@ +Run each `validate-news` check only when the pull request touches what it validates: fragment validation when the +fragment directory changes, the Towncrier configuration check when `towncrier.toml` or the `tool.towncrier` table +changes, the lock check when a `pyproject.toml`, `pylock.toml`, root `pylock.*.toml`, or `nab.toml` changes, and the +lock-removal guard when `pylock.toml` is deleted. Workflow changes no longer trigger the lock check, and every root +`pylock.*.toml` does, whether or not it is the configured `additional-lock`. The fragment requirement for human pull +requests still runs on every pull request. diff --git a/tests/conftest.py b/tests/conftest.py index 6a10c23..9be76ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -398,6 +398,8 @@ def _condition( inputs: dict[str, str], step_outputs: dict[str, dict[str, str]], ) -> bool: + if " || " in expression: + return any(_condition(part, environment, inputs, step_outputs) for part in expression.split(" || ")) if " && " in expression: return all(_condition(part, environment, inputs, step_outputs) for part in expression.split(" && ")) for operator in (" == ", " != "): diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 3e324cd..da39bb8 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -3,10 +3,20 @@ import os import subprocess from pathlib import Path +from typing import Final import pytest +import yaml from conftest import action, run +_STEPS: Final = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-news/action.yml").read_text())["runs"][ + "steps" +] + + +def _script(action_scripts: Path, step_id: str) -> Path: + return action_scripts / f"validate-news-{next(i for i, step in enumerate(_STEPS) if step.get('id') == step_id)}.sh" + @pytest.mark.parametrize( ("bot", "skip", "accepted"), @@ -126,17 +136,17 @@ def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action SKIP_NEWS="false", ) assert result.returncode != 0 - assert "news validation needs a PR base" in result.stderr + assert "origin/" in result.stderr @pytest.mark.parametrize( ("additional_lock", "changed_path", "changed"), [ pytest.param("pylock.zipapp.toml", "pylock.zipapp.toml", "true", id="selected-secondary-lock"), - pytest.param("", "pylock.zipapp.toml", "false", id="unselected-secondary-lock"), + pytest.param("", "pylock.zipapp.toml", "true", id="unselected-secondary-lock"), pytest.param("", "pylock.toml", "true", id="primary-lock"), pytest.param("", "pyproject.toml", "true", id="root-project"), - pytest.param("", ".github/workflows/release.yaml", "true", id="workflow-repin"), + pytest.param("", ".github/workflows/release.yaml", "false", id="workflow-repin"), pytest.param("", "README.md", "false", id="unrelated"), pytest.param("", "nab.toml", "true", id="nab-policy"), pytest.param("", "meta/pyproject.toml", "true", id="workspace-member"), @@ -176,7 +186,7 @@ def test_validate_news_detects_release_input_change( output = tmp_path / "lock-change-output" result = subprocess.run( - ["bash", str(action_scripts / "validate-news-2.sh")], + ["bash", str(_script(action_scripts, "lock-change"))], cwd=repository, env=os.environ | { @@ -221,7 +231,7 @@ def test_validate_news_checks_removed_release_lock( run(repository, "git", "commit", "-m", "Remove release lock") result = subprocess.run( - ["bash", str(action_scripts / "validate-news-2.sh")], + ["bash", str(_script(action_scripts, "lock-change"))], cwd=repository, env=os.environ | { @@ -282,3 +292,108 @@ def test_validate_news_checks_changed_release_inputs_against_the_lock( assert (result.returncode == 0) is accepted, result.stderr assert ("regenerated Nab lock differs from the committed lock" in result.stderr) is not accepted + + +def test_validate_news_skips_lock_removal_guard_without_deletion( + repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + project = repository / "pyproject.toml" + project.write_text(project.read_text() + '\n[tool.nab]\nuploaded-prior-to = "P7D"\n') + run(repository, "git", "commit", "-am", "Configure Nab without a lock") + run(repository, "git", "push", "origin", "main") + run(repository, "git", "checkout", "-b", "change-project") + project.write_text(project.read_text() + "\n") + run(repository, "git", "commit", "-am", "Change the project") + output = tmp_path / "lock-change-output" + + result = subprocess.run( + ["bash", str(_script(action_scripts, "lock-change"))], + cwd=repository, + env=os.environ | {"BASE_REF": "main", "ADDITIONAL_LOCK": "", "GITHUB_OUTPUT": str(output)}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert output.read_text() == "changed=false\n" + + +@pytest.mark.parametrize( + ("path", "content", "fragments", "config"), + [ + pytest.param("changelog.d/+other.feature.md", "Add more.\n", "true", "false", id="fragment"), + pytest.param("README.md", "Read me.\n", "false", "false", id="unrelated"), + pytest.param("pyproject.toml", None, "false", "false", id="project-without-towncrier-change"), + pytest.param("pyproject.toml", "", "false", "true", id="towncrier-table"), + pytest.param("towncrier.toml", "[tool.towncrier]\n", "false", "true", id="towncrier-file"), + ], +) +def test_validate_news_detects_news_input_change( + repository: Path, + tmp_path: Path, + action_scripts: Path, + path: str, + content: str | None, + fragments: str, + config: str, +) -> None: + run(repository, "git", "checkout", "-b", "change-news-input") + target = repository / path + if content is None: + target.write_text(target.read_text() + "\n[project]\nname = 'fixture'\n") + elif path == "pyproject.toml": + target.write_text(target.read_text().replace('name = "fixture"', 'name = "renamed"')) + else: + target.write_text(content) + run(repository, "git", "add", path) + run(repository, "git", "commit", "-m", "Change a news input") + output = tmp_path / "news-change-output" + + result = subprocess.run( + ["bash", str(_script(action_scripts, "news-change"))], + cwd=repository, + env=os.environ | {"BASE_REF": "main", "GITHUB_OUTPUT": str(output)}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert output.read_text() == f"fragments={fragments}\nconfig={config}\n" + + +@pytest.mark.parametrize( + ("touched", "accepted"), + [ + pytest.param("README.md", True, id="untouched-config-skipped"), + pytest.param("pyproject.toml", False, id="touched-config-checked"), + ], +) +def test_validate_news_checks_towncrier_config_only_when_touched( + repository: Path, tmp_path: Path, action_scripts: Path, touched: str, accepted: bool +) -> None: + project = repository / "pyproject.toml" + project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', 'filename = "CHANGELOG.txt"')) + run(repository, "git", "commit", "-am", "Use an unsupported changelog") + run(repository, "git", "push", "origin", "main") + run(repository, "git", "checkout", "-b", "change-news-input") + target = repository / touched + target.write_text( + target.read_text().replace('name = "fixture"', 'name = "renamed"') if touched == "pyproject.toml" else "Read.\n" + ) + run(repository, "git", "add", touched) + run(repository, "git", "commit", "-m", "Touch a file") + + result = action( + repository, + tmp_path, + action_scripts, + "validate-news", + version="0.2.0", + BASE_REF="main", + SKIP_NEWS="true", + ) + + assert (result.returncode == 0) is accepted, result.stderr + assert ("Markdown or reStructuredText changelog" in result.stderr) is not accepted diff --git a/validate-news/action.yml b/validate-news/action.yml index 45cf820..106790b 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -28,7 +28,49 @@ inputs: runs: using: composite steps: + - id: news-change + name: Check whether news inputs changed + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + base=$(git merge-base "origin/$BASE_REF" HEAD) + python3 - "$base" "$GITHUB_OUTPUT" <<'PY' + import subprocess + import sys + import tomllib + from pathlib import Path + + base, output = sys.argv[1:] + + + def towncrier(ref: str, name: str) -> object: + shown = subprocess.run(["git", "show", f"{ref}:{name}"], capture_output=True, text=True, check=False) + if shown.returncode: + return None + try: + return tomllib.loads(shown.stdout).get("tool", {}).get("towncrier") + except tomllib.TOMLDecodeError: + return shown.stdout + + + names = ("towncrier.toml", "pyproject.toml") + config = any(towncrier(base, name) != towncrier("HEAD", name) for name in names) + current = next((table for name in names if isinstance(table := towncrier("HEAD", name), dict)), {}) + fragments = "directory" in current and bool( + subprocess.run( + ["git", "diff", "--name-only", base, "HEAD", "--", current["directory"]], + capture_output=True, + text=True, + check=True, + ).stdout + ) + with Path(output).open("a", encoding="utf-8") as stream: + stream.write(f"fragments={str(fragments).lower()}\nconfig={str(config).lower()}\n") + PY - name: Count changed fragments + if: steps.news-change.outputs.fragments == 'true' || steps.news-change.outputs.config == 'true' shell: bash env: BASE_REF: ${{ github.base_ref }} @@ -39,7 +81,8 @@ runs: SKIP_NEWS: ${{ contains(github.event.pull_request.labels.*.name, 'skip news') }} BOT_AUTHOR: ${{ github.event.pull_request.user.type == 'Bot' }} run: | - read -r fragments <"$RUNNER_TEMP/news-fragment-count" + fragments=0 + if [[ -f "$RUNNER_TEMP/news-fragment-count" ]]; then read -r fragments <"$RUNNER_TEMP/news-fragment-count"; fi if ((! fragments)) && [[ "$SKIP_NEWS" != true && "$BOT_AUTHOR" != true ]]; then echo '::error::add a changelog fragment or apply the skip news label' >&2 exit 1 @@ -51,9 +94,8 @@ runs: BASE_REF: ${{ github.base_ref }} ADDITIONAL_LOCK: ${{ inputs.additional-lock }} run: | - paths=(':(glob)**/pyproject.toml' pylock.toml nab.toml ':(glob).github/workflows/**') - if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi - changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${paths[@]}") + changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- \ + ':(glob)**/pyproject.toml' pylock.toml ':(glob)pylock.*.toml' nab.toml) if [[ -z "$changed" ]]; then echo 'changed=false' >>"$GITHUB_OUTPUT" exit 0 From 9c1ffc7a98884763226d488b482c80eb48c13084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 09:02:40 -0700 Subject: [PATCH 16/23] =?UTF-8?q?=F0=9F=A7=AA=20test:=20drop=20release-cre?= =?UTF-8?q?ator=20error=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-creator 4.0.0 no longer fails on an empty release or a missing notes section, so these tests only pinned the 3.x error messages. --- tests/prepare_towncrier/test_prepare.py | 10 -------- tests/render_release_notes/test_notes.py | 30 ------------------------ 2 files changed, 40 deletions(-) diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 936012c..b868272 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -240,16 +240,6 @@ def test_prepare_rejects_unlinked_fragment_name( assert not (tmp_path / "release.patch").exists() -def test_prepare_rejects_empty_release(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "changelog.d/+feature.feature.md").unlink() - run(repository, "git", "add", "-u") - run(repository, "git", "commit", "-m", "Remove unused fragment") - run(repository, "git", "push", "origin", "main") - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="") - assert result.returncode != 0 - assert "no changelog fragments to release" in result.stderr - - def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "changelog.d/+feature.feature.md").write_text("Add " + "a long sentence " * 15 + ".\n") run(repository, "git", "add", ".") diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py index f2586fd..862a797 100644 --- a/tests/render_release_notes/test_notes.py +++ b/tests/render_release_notes/test_notes.py @@ -125,33 +125,3 @@ def test_render_release_notes_custom_artifact_label( "[Zipapp](https://github.com/tox-dev/fixture/releases/download/0.2.0/fixture.pyz)" in (tmp_path / "release-notes.md").read_text() ) - - -@pytest.mark.parametrize( - ("tag", "remove_heading", "error"), - [ - pytest.param("invalid tag", False, "expected a major.minor.patch version", id="invalid-tag"), - pytest.param("0.2.0", True, "expected one changelog section", id="missing-heading"), - ], -) -def test_render_release_notes_rejects_invalid_source( - tagged_repository: Path, - tmp_path: Path, - action_scripts: Path, - tag: str, - remove_heading: bool, - error: str, -) -> None: - if remove_heading: - changelog = tagged_repository / "CHANGELOG.md" - changelog.write_text(changelog.read_text().replace("## 0.2.0 (", "0.2.0 (")) - result = action( - tagged_repository, - tmp_path, - action_scripts, - "render-release-notes", - version=tag, - TAG=tag, - ) - assert result.returncode != 0 - assert error in result.stderr From 56c7f5c8c66d44d8cf6bada872ebc9922e227ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 09:05:17 -0700 Subject: [PATCH 17/23] =?UTF-8?q?=F0=9F=92=A5=20feat!:=20name=20every=20jo?= =?UTF-8?q?b=20and=20step=20with=20an=20emoji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the approved job and step names to the workflows and Actions, and point the tests that look steps up by name at the new names. The job names change required-check names in callers. The Marketplace Action also recognizes the renamed caller step 🚀 Publish signed ZIP. --- .github/workflows/check.yaml | 51 ++++++++++++------- .github/workflows/news.yaml | 12 +++-- .github/workflows/prepare-release.yaml | 1 + .../workflows/prepare-towncrier-release.yaml | 40 ++++++++++----- .github/workflows/release.yaml | 22 +++++--- .github/workflows/update-tools.yaml | 1 + .github/workflows/upgrade-release-tools.yaml | 43 +++++++++------- .../workflows/validate-python-release.yaml | 19 ++++--- README.md | 8 +-- build-python-distribution/action.yml | 7 +-- changelog.d/+emoji-names.change.md | 4 ++ changelog.d/+renamed-jobs.breaking.md | 3 ++ commit-and-tag/action.yml | 8 +-- cooled-prek/action.yml | 2 +- export-build-constraints/action.yml | 3 +- install-locked-tools/action.yml | 12 ++--- prepare-towncrier/action.yml | 17 ++++--- publish-github-release/action.yml | 14 ++--- publish-jetbrains-marketplace/action.yml | 21 ++++---- publish-post-release-pr/action.yml | 14 ++--- read-towncrier-config/action.yml | 2 +- reconcile-pypi-artifacts/action.yml | 6 +-- refresh-release-lock/action.yml | 9 ++-- render-release-notes/action.yml | 7 +-- select-previous-tag/action.yml | 3 +- sign-jetbrains-plugin/action.yml | 10 ++-- tests/cooled_prek/test_cooled_prek.py | 2 +- tests/install_locked_tools/test_install.py | 2 +- .../test_marketplace_upload.py | 16 +++++- tests/refresh_release_lock/test_refresh.py | 8 +-- tests/upgrade_release_tools/test_upgrade.py | 4 +- tests/validate_python_release/test_tagged.py | 12 ++--- validate-news/action.yml | 10 ++-- validate-release-patch/action.yml | 9 ++-- 34 files changed, 243 insertions(+), 159 deletions(-) create mode 100644 changelog.d/+emoji-names.change.md create mode 100644 changelog.d/+renamed-jobs.breaking.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 04ae815..2a26ff5 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -9,60 +9,68 @@ concurrency: cancel-in-progress: ${{ github.run_attempt == 1 }} jobs: check: + name: 🧹 Check formatting runs-on: ubuntu-26.04 permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: 💾 Restore prek cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/prek key: prek-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.pre-commit-config.yaml') }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} cache-dependency-glob: pyproject.toml - - name: Install release tools + - name: 📦 Install release tools env: EXCLUDE_NEWER: 7 days run: | tools/install_live_tools.sh .release-tools 'prek>=0.2' uv run --no-project --python .release-tools python tools/expose_locked_commands.py .release-tools - - name: Check repository formatting + - name: 🧹 Check repository formatting run: tools/cooled_prek.sh run --all-files shell: + name: 🧹 Check inline shell runs-on: ubuntu-26.04 permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: 💾 Restore prek cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/prek key: prek-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.pre-commit-config.yaml') }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} cache-dependency-glob: pyproject.toml - - name: Install release tools + - name: 📦 Install release tools env: EXCLUDE_NEWER: 7 days run: | tools/install_live_tools.sh .release-tools 'prek>=0.2' 'yq>=4.4.5' uv run --no-project --python .release-tools python tools/expose_locked_commands.py .release-tools - - name: Check inline shell + - name: 🧹 Check inline shell run: | set -euo pipefail script_dir=./inline-shell-check @@ -81,46 +89,53 @@ jobs: tools/cooled_prek.sh run shellcheck --files "$script_dir"/*.sh tools/cooled_prek.sh run shfmt --files "$script_dir"/*.sh test: + name: 🧪 Test actions runs-on: ubuntu-26.04 permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} cache-dependency-glob: pyproject.toml - - name: Run Action tests + - name: 🧪 Run Action tests run: uvx --with tox-uv tox run -e test type: + name: 🔎 Type check runs-on: ubuntu-26.04 permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} cache-dependency-glob: pyproject.toml - - name: Check Python types + - name: 🔎 Check Python types run: uvx --with tox-uv tox run -e type gate: - name: Required checks pass + name: 🚦 Required checks pass if: ${{ !cancelled() }} needs: [check, shell, test, type] runs-on: ubuntu-26.04 steps: - - env: + - name: 🚦 Require all needed jobs to pass + env: NEEDS_JSON: ${{ toJSON(needs) }} run: jq -e 'all(to_entries[]; .value.result == "success")' <<<"$NEEDS_JSON" diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml index de828ea..bd57fc9 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -6,20 +6,23 @@ permissions: contents: read jobs: changelog: + name: 📝 News fragment runs-on: ubuntu-26.04 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} cache-dependency-glob: pyproject.toml - - name: Install release tools + - name: 📦 Install release tools env: EXCLUDE_NEWER: 7 days # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. @@ -27,7 +30,8 @@ jobs: run: | tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=2.0.0' 'towncrier>=26.9' uv run --no-project --python "$RUNNER_TEMP/release-tools" python tools/expose_locked_commands.py "$RUNNER_TEMP/release-tools" - - uses: $/validate-news + - name: 🔍 Validate news fragments + uses: $/validate-news with: # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. exclude-newer-package: release-creator=2026-10-25T21:03:31Z diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index fc61164..4973f8e 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -11,6 +11,7 @@ on: permissions: {} jobs: prepare: + name: 🏷️ Prepare Towncrier release uses: $/.github/workflows/prepare-towncrier-release.yaml permissions: contents: read diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 29aa39b..f252241 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -40,6 +40,7 @@ concurrency: cancel-in-progress: false jobs: assemble: + name: 🏷️ Assemble release runs-on: ubuntu-26.04 permissions: contents: read @@ -48,49 +49,55 @@ jobs: version: ${{ steps.prepare.outputs.version }} artifact-id: ${{ steps.patch.outputs.artifact-id }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/install-locked-tools + - name: 📦 Install locked release tools + uses: $/install-locked-tools with: groups: release-prepare venv: .venv exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - - name: Install prek hooks + - name: 🧹 Install prek hooks id: hooks uses: $/cooled-prek with: args: install-hooks background: true - id: prepare - name: Build changelog + name: 📝 Build changelog uses: $/prepare-towncrier with: bump: ${{ inputs.bump }} tag-prefix: ${{ inputs.tag-prefix }} changelog-prefix: ${{ inputs.changelog-prefix }} phase: build - - wait: hooks - - name: Finalize release patch + - name: ⏳ Wait for prek hooks + wait: hooks + - name: 📝 Finalize release patch uses: $/prepare-towncrier with: version: ${{ steps.prepare.outputs.version }} phase: finalize - - id: patch + - name: 📤 Upload release patch + id: patch uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-patch path: ${{ runner.temp }}/release.patch if-no-files-found: error push: + name: ⬆️ Push release commit and tag needs: assemble runs-on: ubuntu-26.04 environment: @@ -99,21 +106,25 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - name: 📩 Download release patch + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.assemble.outputs.artifact-id }} path: ${{ runner.temp }}/release-patch - - uses: $/validate-release-patch + - name: ⚙️ Apply release patch + uses: $/validate-release-patch - id: repository - name: Select the current repository + name: ⚙️ Select the current repository env: REPOSITORY: ${{ github.repository }} run: echo "name=${REPOSITORY#*/}" >>"$GITHUB_OUTPUT" - - id: token + - name: 🔑 Create release App token + id: token uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} @@ -121,7 +132,8 @@ jobs: owner: ${{ github.repository_owner }} repositories: ${{ steps.repository.outputs.name }} permission-contents: write - - uses: $/commit-and-tag + - name: 🏷️ Commit and tag release + uses: $/commit-and-tag with: version: ${{ needs.assemble.outputs.version }} token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7358ba2..889a43c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,22 +9,25 @@ concurrency: cancel-in-progress: false jobs: notes: + name: 📝 Render GitHub Release notes runs-on: ubuntu-26.04 permissions: contents: read outputs: artifact-id: ${{ steps.artifact.outputs.artifact-id }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false - - name: Install release tools + - name: 📦 Install release tools env: EXCLUDE_NEWER: 7 days # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. @@ -32,28 +35,33 @@ jobs: run: | tools/install_live_tools.sh .venv 'release-creator>=2.0.0' uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - - uses: $/render-release-notes + - name: 📝 Render release notes + uses: $/render-release-notes with: tag: ${{ github.ref_name }} changelog: CHANGELOG.md - - id: artifact + - name: 📤 Upload release notes + id: artifact uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-notes path: ${{ runner.temp }}/release-notes.md if-no-files-found: error publish: + name: 🚀 Publish GitHub Release needs: notes runs-on: ubuntu-26.04 environment: release permissions: contents: write steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - name: 📩 Download release notes + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.notes.outputs.artifact-id }} path: ${{ runner.temp }} - - uses: $/publish-github-release + - name: 🚀 Publish GitHub Release + uses: $/publish-github-release env: GH_TOKEN: ${{ github.token }} with: diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 14a8daa..90341bd 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -6,6 +6,7 @@ on: permissions: {} jobs: update: + name: 🔀 Upgrade release tools uses: $/.github/workflows/upgrade-release-tools.yaml permissions: contents: read diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 4be98b1..e12bdec 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -54,6 +54,7 @@ on: permissions: {} jobs: generate-lock: + name: 🔒 Refresh release lock runs-on: ubuntu-26.04 permissions: contents: read @@ -61,16 +62,19 @@ jobs: changed: ${{ steps.lock.outputs.changed }} artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false - - id: lock + - name: 🔒 Refresh release lock + id: lock uses: $/refresh-release-lock with: additional-lock: ${{ inputs.additional-lock }} @@ -81,7 +85,8 @@ jobs: additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - - id: upload + - name: 📤 Upload lock patch + id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.lock.outputs.changed == 'true' with: @@ -90,6 +95,7 @@ jobs: if-no-files-found: error retention-days: 7 publish: + name: 🔀 Open release tools PR needs: generate-lock if: needs.generate-lock.outputs.changed == 'true' runs-on: ubuntu-26.04 @@ -97,16 +103,18 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - name: 📩 Download lock patch + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.generate-lock.outputs.artifact-id }} path: ${{ runner.temp }}/release-tools-lock - - name: Check artifact patch paths + - name: 🔍 Check artifact patch paths env: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} run: | @@ -121,9 +129,9 @@ jobs: exit 1 fi done <"$RUNNER_TEMP/lock-paths" - - name: Apply lock patch + - name: 🔒 Apply lock patch run: git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch" - - name: Check dependency patch file mode + - name: 🔍 Check dependency patch file mode run: | set -euo pipefail git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/dependency-modes" @@ -134,7 +142,7 @@ jobs: exit 1 fi done <"$RUNNER_TEMP/dependency-modes" - - name: Commit refreshed tools + - name: 🏷️ Commit refreshed tools env: COMMIT_MESSAGE: ${{ inputs.commit-message }} run: | @@ -142,11 +150,12 @@ jobs: git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git commit -m "$COMMIT_MESSAGE" - id: repository - name: Select the current repository + name: ⚙️ Select the current repository env: REPOSITORY: ${{ github.repository }} run: echo "name=${REPOSITORY#*/}" >>"$GITHUB_OUTPUT" - - id: token + - name: 🔑 Create release App token + id: token uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} @@ -155,17 +164,17 @@ jobs: repositories: ${{ steps.repository.outputs.name }} permission-contents: write permission-pull-requests: write - - name: Push dependency branch + - name: ⬆️ Push dependency branch env: GH_TOKEN: ${{ steps.token.outputs.token }} run: | gh auth setup-git --hostname github.com --force git push --force-with-lease origin HEAD:refs/heads/automation/update-release-tools - - name: Write dependency pull request body + - name: 📝 Write dependency pull request body env: PR_BODY: ${{ inputs.pull-request-body }} run: printf '%s\n' "$PR_BODY" >"$RUNNER_TEMP/pull-request.md" - - name: Find dependency pull request + - name: 🔀 Find dependency pull request id: pr env: GH_TOKEN: ${{ steps.token.outputs.token }} @@ -181,7 +190,7 @@ jobs: elif length == 1 then "number=\(.[0].number)" else error("multiple dependency pull requests") end ' >>"$GITHUB_OUTPUT" - - name: Update dependency pull request + - name: 🔀 Update dependency pull request if: steps.pr.outputs.number != '' env: GH_TOKEN: ${{ steps.token.outputs.token }} @@ -190,7 +199,7 @@ jobs: run: | gh pr edit "$PR_NUMBER" --title "$PR_TITLE" --body-file "$RUNNER_TEMP/pull-request.md" \ --add-label dependencies --add-label 'skip news' - - name: Open dependency pull request + - name: 🔀 Open dependency pull request if: steps.pr.outputs.number == '' env: GH_TOKEN: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index fefd442..cfe8062 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -26,33 +26,37 @@ permissions: contents: read jobs: build: + name: 🏗️ Build distributions runs-on: ubuntu-26.04 outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 📥 Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: 🐍 Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false - - id: constraints + - name: 📦 Export locked build constraints + id: constraints uses: $/install-locked-tools with: exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - - name: Stage build constraints + - name: ⚙️ Stage build constraints env: SOURCE: ${{ steps.constraints.outputs.build-constraints }} run: cp "$SOURCE" "$RUNNER_TEMP/build-constraints.txt" - - name: Select build interpreter + - name: 🐍 Select build interpreter run: uv venv --python 3.15 "$RUNNER_TEMP/build-python" - - name: Build wheel and sdist + - name: 🏗️ Build wheel and sdist env: SOURCE: ${{ inputs.source }} run: | @@ -62,7 +66,8 @@ jobs: SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) uv build --python "$build_python" --python-preference only-managed --sdist --wheel \ --build-constraints "$RUNNER_TEMP/build-constraints.txt" --require-build-hashes "$SOURCE" --out-dir dist - - id: upload + - name: 📤 Upload distributions + id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact-prefix }}python-package-distributions diff --git a/README.md b/README.md index 61059c9..b53ae9f 100644 --- a/README.md +++ b/README.md @@ -649,7 +649,7 @@ marketplace-publish: with: artifact-ids: ${{ needs.notes.outputs.artifact-id }} path: ${{ runner.temp }}/notes - - name: Publish signed ZIP + - name: 🚀 Publish signed ZIP uses: tox-dev/github-workflows/publish-jetbrains-marketplace@SHA with: archive: dist/Plugin-1.2.3-signed.zip @@ -666,9 +666,9 @@ marketplace-publish: ``` `channel` is empty for Stable; use a Marketplace channel name for a prerelease. Keep the publishing step named -`Publish signed ZIP`. The Action recognizes that name and two older upload step names in prior runs. Keep those names in -the Action when adding another. Give the job Actions read access and the workflow a concurrency group keyed by tag. Use -the `release` environment on the upload job. The Action searches earlier tag runs for a started upload step. An +`🚀 Publish signed ZIP`. The Action recognizes that name and three older upload step names in prior runs. Keep those +names in the Action when adding another. Give the job Actions read access and the workflow a concurrency group keyed by +tag. Use the `release` environment on the upload job. The Action searches earlier tag runs for a started upload step. An acceptance receipt must match the original artifact IDs, signed ZIP digest, and release notes bytes before the Action skips upload. An earlier upload step without a receipt stops for manual reconciliation. A renamed publishing step is not recognized in earlier runs until the Action knows its name. diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index a607a0b..d99d146 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -10,15 +10,16 @@ inputs: runs: using: composite steps: - - name: Select build interpreter + - name: 🐍 Select build interpreter shell: bash run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/build-python" - - id: constraints + - name: 📦 Export locked build constraints + id: constraints uses: $/export-build-constraints with: exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - - name: Build wheel and sdist + - name: 🏗️ Build wheel and sdist shell: bash env: BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} diff --git a/changelog.d/+emoji-names.change.md b/changelog.d/+emoji-names.change.md new file mode 100644 index 0000000..e4ea35f --- /dev/null +++ b/changelog.d/+emoji-names.change.md @@ -0,0 +1,4 @@ +Give every job and step in the shared workflows and Actions a name that starts with an emoji. Jobs of the reusable +workflows show up in callers as ` / `, so required status checks that name them must be updated. +`publish-jetbrains-marketplace` also recognizes a prior upload step named `🚀 Publish signed ZIP`, the README's new name +for the caller's publishing step. diff --git a/changelog.d/+renamed-jobs.breaking.md b/changelog.d/+renamed-jobs.breaking.md new file mode 100644 index 0000000..ac3ef69 --- /dev/null +++ b/changelog.d/+renamed-jobs.breaking.md @@ -0,0 +1,3 @@ +Rename the jobs of `validate-python-release.yaml` to `🏗️ Build distributions` and of `prepare-towncrier-release.yaml` to +`🏷️ Assemble release` and `⬆️ Push release commit and tag`. Required status checks and rulesets that reference the old +job names must use the new ones. diff --git a/commit-and-tag/action.yml b/commit-and-tag/action.yml index 285c6fe..5505ed6 100644 --- a/commit-and-tag/action.yml +++ b/commit-and-tag/action.yml @@ -19,7 +19,7 @@ inputs: runs: using: composite steps: - - name: Set App commit identity + - name: ⚙️ Set App commit identity shell: bash env: GH_TOKEN: ${{ inputs.token }} @@ -29,12 +29,12 @@ runs: app_user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) git config user.name "${APP_SLUG}[bot]" git config user.email "${app_user_id}+${APP_SLUG}[bot]@users.noreply.github.com" - - name: Commit release patch + - name: 🏷️ Commit release patch shell: bash env: MESSAGE: ${{ inputs.message }} run: git commit -m "$MESSAGE" - - name: Create release tag + - name: 🏷️ Create release tag shell: bash env: VERSION: ${{ inputs.version }} @@ -45,7 +45,7 @@ runs: true) git tag -a -m "$MESSAGE" -- "$VERSION" ;; *) git tag -- "$VERSION" ;; esac - - name: Push release commit and tag + - name: ⬆️ Push release commit and tag shell: bash env: VERSION: ${{ inputs.version }} diff --git a/cooled-prek/action.yml b/cooled-prek/action.yml index d142fcd..809f822 100644 --- a/cooled-prek/action.yml +++ b/cooled-prek/action.yml @@ -7,7 +7,7 @@ inputs: runs: using: composite steps: - - name: Run cooled prek + - name: 🧹 Run cooled prek shell: bash env: ARGS: ${{ inputs.args }} diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index 47295cc..77e2a9d 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -23,7 +23,8 @@ outputs: runs: using: composite steps: - - id: tools + - name: 📦 Export locked build constraints + id: tools uses: $/install-locked-tools with: constraints-lock: ${{ inputs.lock }} diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 4c280f5..5dfa8e7 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -29,10 +29,10 @@ outputs: runs: using: composite steps: - - name: Create lock-tool environment + - name: 🐍 Create lock-tool environment shell: bash run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-reader" - - name: Install lock reader + - name: 📦 Install lock reader shell: bash env: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} @@ -40,7 +40,7 @@ runs: run: >- "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-reader" --only-binary :all: 'certifi>=2026.7.22' 'packaging>=26.3' - id: selection - name: Select locked groups and build constraints + name: ⚙️ Select locked groups and build constraints shell: bash env: DEPENDENCY_GROUPS: ${{ inputs.groups }} @@ -58,7 +58,7 @@ runs: fi printf 'lock=pylock.toml\n' >>"$GITHUB_OUTPUT" - id: constraints - name: Export locked build requirements + name: 🔒 Export locked build requirements shell: bash env: BUILD_LOCK: ${{ steps.selection.outputs.lock }} @@ -83,7 +83,7 @@ runs: "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" "$output" "$BUILD_LOCK" fi echo "path=$output" >>"$GITHUB_OUTPUT" - - name: Install selected groups + - name: 📦 Install selected groups if: inputs.groups != '' shell: bash env: @@ -100,7 +100,7 @@ runs: uv pip sync --preview-features pylock --preview-features build-dependency-hashes --python "$VENV" \ "${groups[@]}" --build-constraints "$BUILD_CONSTRAINTS" \ --require-build-hashes --require-hashes "$LOCK" - - name: Expose installed commands + - name: ⚙️ Expose installed commands if: inputs.groups != '' shell: bash env: diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 445da17..425771a 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -23,10 +23,11 @@ outputs: runs: using: composite steps: - - id: config + - name: ⚙️ Read Towncrier configuration + id: config uses: $/read-towncrier-config - id: version - name: Select release version + name: 🏷️ Select release version shell: bash env: BUMP: ${{ inputs.bump }} @@ -48,7 +49,7 @@ runs: VERSION=$(git tag --list | release-creator tags "${args[@]}") fi printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" - - name: Link fragments and authors + - name: 📝 Link fragments and authors if: inputs.phase != 'finalize' shell: bash env: @@ -58,7 +59,7 @@ runs: run: | set -euo pipefail release-creator fragments --directory "$FRAGMENTS" --repository "$REPOSITORY" - - name: Build changelog + - name: 📝 Build changelog if: inputs.phase != 'finalize' shell: bash env: @@ -67,7 +68,7 @@ runs: run: | set -euo pipefail towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}" - - name: Add new contributors to the changelog + - name: 📝 Add new contributors to the changelog if: inputs.phase != 'finalize' shell: bash env: @@ -78,7 +79,7 @@ runs: GH_TOKEN: ${{ github.token }} run: >- release-creator contributors --changelog "$CHANGELOG" --version "$CHANGELOG_PREFIX${VERSION#v}" --repository "$REPOSITORY" - - name: Format changelog + - name: 🧹 Format changelog if: inputs.phase != 'build' shell: bash env: @@ -94,7 +95,7 @@ runs: if ! "$prek" run "$formatter" --files "$CHANGELOG"; then "$prek" run "$formatter" --files "$CHANGELOG" fi - - name: Check generated files + - name: 🔍 Check generated files if: inputs.phase != 'build' shell: bash env: @@ -103,7 +104,7 @@ runs: if [[ -f "$CHANGELOG" ]] && ! git ls-files --error-unmatch "$CHANGELOG" >/dev/null 2>&1; then git add -N -- "$CHANGELOG" fi - - name: Write release patch + - name: ⚙️ Write release patch if: inputs.phase != 'build' shell: bash run: | diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index e05317c..9bf3e1c 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -25,7 +25,7 @@ inputs: runs: using: composite steps: - - name: Verify the tagged release request + - name: ⚙️ Record asset digests shell: bash env: ASSETS_DIRECTORY: ${{ inputs.assets-directory }} @@ -43,7 +43,7 @@ runs: jq -R -s '[split("\n")[] | select(length > 0) | split("\t") | {name: .[0], digest: .[1]}]' \ "$RUNNER_TEMP/release-assets.tsv" >"$RUNNER_TEMP/release-assets.json" fi - - name: Inspect any existing release + - name: 🔍 Inspect any existing release id: inspect shell: bash env: @@ -62,7 +62,7 @@ runs: --slurpfile assets "$RUNNER_TEMP/release-assets.json" -f "$GITHUB_ACTION_PATH/match.jq" >/dev/null fi printf 'immutable=%s\n' "$immutable" >>"$GITHUB_OUTPUT" - - name: Create a missing draft + - name: 🚀 Create or update the draft shell: bash env: GH_REPO: ${{ github.repository }} @@ -89,7 +89,7 @@ runs: gh api -X PATCH "repos/$GH_REPO/releases/$(<"$RUNNER_TEMP/release-id")" \ --input "$RUNNER_TEMP/release-update.json" >/dev/null fi - - name: Upload missing draft assets + - name: 🚀 Upload missing draft assets if: steps.inspect.outputs.immutable != 'true' shell: bash env: @@ -156,7 +156,7 @@ runs: done fi fi - - name: Verify the complete asset set + - name: 🔍 Verify the complete asset set if: steps.inspect.outputs.immutable != 'true' shell: bash env: @@ -175,7 +175,7 @@ runs: echo '::error::release assets did not match after publication' >&2 exit 1 fi - - name: Publish the draft + - name: 🚀 Publish the draft if: steps.inspect.outputs.immutable != 'true' shell: bash env: @@ -187,7 +187,7 @@ runs: release_id=$(<"$RUNNER_TEMP/release-id") gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false >/dev/null fi - - name: Verify published asset attestations + - name: 🔍 Verify published asset attestations if: inputs.mode != 'stage' && inputs.assets-directory != '' shell: bash env: diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index 7093e17..41173c0 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -37,7 +37,7 @@ inputs: runs: using: composite steps: - - name: Verify signed ZIP bytes + - name: 🔍 Verify signed ZIP bytes shell: bash env: ARCHIVE: ${{ inputs.archive }} @@ -46,7 +46,7 @@ runs: set -euo pipefail test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" - - name: Find earlier Marketplace upload + - name: 🔍 Find earlier Marketplace upload id: earlier shell: bash env: @@ -63,7 +63,8 @@ runs: [[ "$branch" == "$TAG" && "$source" == "$GITHUB_REPOSITORY" ]] || continue gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$run/jobs?filter=all&per_page=100" \ --jq ".jobs[] | .run_attempt as \$attempt | .steps[] | - select((.name == \"Publish signed ZIP\" or .name == \"Upload signed ZIP\" or + select((.name == \"🚀 Publish signed ZIP\" or .name == \"Publish signed ZIP\" or + .name == \"Upload signed ZIP\" or .name == \"📤 Publish to JetBrains Marketplace\") and .started_at != null) | \$attempt" \ >"$RUNNER_TEMP/marketplace-attempts.txt" while IFS= read -r attempt; do @@ -90,7 +91,7 @@ runs: } >>"$GITHUB_OUTPUT" fi echo "accepted=$prior" >>"$GITHUB_OUTPUT" - - name: Download earlier Marketplace acceptance + - name: 📩 Download earlier Marketplace acceptance if: steps.earlier.outputs.accepted == '1' shell: bash env: @@ -103,14 +104,15 @@ runs: gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name "$RECEIPT_NAME" \ --dir "$RUNNER_TEMP/prior-acceptance" # A later attempt finds this attempt's started upload step; the copy shows it skipped the upload. - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - name: 📤 Upload earlier Marketplace acceptance + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.earlier.outputs.accepted == '1' with: name: marketplace-acceptance-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/prior-acceptance/marketplace-acceptance.json if-no-files-found: error retention-days: 30 - - name: Verify earlier Marketplace acceptance + - name: 🔍 Verify earlier Marketplace acceptance if: steps.earlier.outputs.accepted == '1' shell: bash env: @@ -150,7 +152,7 @@ runs: --dir "$RUNNER_TEMP/prior-notes" test "$(sha256sum "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}" | cut -d' ' -f1)" = "$EXPECTED_SHA" cmp "$NOTES_FILE" "$RUNNER_TEMP/prior-notes/release-notes.md" - - name: Upload signed ZIP + - name: 🚀 Upload signed ZIP if: steps.earlier.outputs.accepted == '0' shell: bash env: @@ -175,7 +177,7 @@ runs: cat "$RUNNER_TEMP/marketplace-response.json" >&2 exit 1 fi - - name: Record Marketplace acceptance + - name: ⚙️ Record Marketplace acceptance if: steps.earlier.outputs.accepted == '0' shell: bash env: @@ -199,7 +201,8 @@ runs: pluginId: $pluginId, version: $version, channel: $channel, signedArchive: $archive, sha256: $sha, signedArtifactId: $signedArtifactId, notesArtifactId: $notesArtifactId, notesSha256: $notesSha}' >"$RUNNER_TEMP/marketplace-acceptance.json" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - name: 📤 Upload Marketplace acceptance + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.earlier.outputs.accepted == '0' with: name: marketplace-acceptance-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index c72d34c..db626c0 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -28,7 +28,7 @@ inputs: runs: using: composite steps: - - name: Check the allowed path manifest + - name: 🔍 Check the allowed path manifest shell: bash env: PATHS_FILE: ${{ inputs.paths-file }} @@ -55,7 +55,7 @@ runs: test "${#paths[@]}" -gt 0 test "$(printf '%s\n' "${paths[@]}" | LC_ALL=C sort -u | wc -l)" -eq "${#paths[@]}" printf '%s\n' "${paths[@]}" | LC_ALL=C sort >"$RUNNER_TEMP/expected-paths" - - name: Verify the prepared commit + - name: 🔍 Verify the prepared commit shell: bash env: BASE_COMMIT: ${{ inputs.base-commit }} @@ -89,7 +89,7 @@ runs: test "$actual" = "$blob" done <"$PATHS_FILE" fi - - name: Inspect the existing pull request + - name: 🔀 Inspect the existing pull request shell: bash env: BASE: main @@ -111,14 +111,14 @@ runs: (.state == "open" or .merged_at != null) )] end ' "$RUNNER_TEMP/post-release-all-prs.json" >"$RUNNER_TEMP/post-release-prs.json" - - name: Verify an already merged pull request + - name: ⚙️ Record an already merged pull request shell: bash run: | set -euo pipefail if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then jq -r '.[0].number' "$RUNNER_TEMP/post-release-prs.json" >"$RUNNER_TEMP/post-release-number" fi - - name: Publish or verify the prepared branch + - name: ⬆️ Push a missing prepared branch shell: bash env: BRANCH: ${{ inputs.branch }} @@ -135,7 +135,7 @@ runs: "https://${GH_HOST:-github.com}/$GITHUB_REPOSITORY.git" git -C "$RUNNER_TEMP/post-release.git" push origin "refs/heads/release-candidate:refs/heads/$BRANCH" fi - - name: Create or verify the pull request + - name: 🔀 Create a missing pull request shell: bash env: BASE: main @@ -153,7 +153,7 @@ runs: fi printf '%s\n' "$pr_number" >"$RUNNER_TEMP/post-release-number" fi - - name: Ensure the maintenance label + - name: 🔀 Ensure the maintenance label shell: bash run: | set -euo pipefail diff --git a/read-towncrier-config/action.yml b/read-towncrier-config/action.yml index 6e99913..0296bda 100644 --- a/read-towncrier-config/action.yml +++ b/read-towncrier-config/action.yml @@ -13,7 +13,7 @@ outputs: runs: using: composite steps: - - name: Read committed Towncrier configuration + - name: ⚙️ Read committed Towncrier configuration id: config shell: bash run: | diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index eadac61..c159a93 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -19,14 +19,14 @@ inputs: runs: using: composite steps: - - name: Install provenance verifier + - name: 📦 Install provenance verifier shell: bash env: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} run: >- "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/pypi-attestations" 'certifi>=2026.7.22' 'pypi-attestations>=0.0.30' - - name: Compare published files + - name: 🔍 Compare published files shell: bash env: PROJECT: ${{ inputs.project }} @@ -36,7 +36,7 @@ runs: uv run --no-project --python "$RUNNER_TEMP/pypi-attestations" python \ "$GITHUB_ACTION_PATH/../tools/verify_pypi_files.py" "$PROJECT" "$VERSION" "$DIST" \ >"$RUNNER_TEMP/pypi-local-files" - - name: Verify trusted-publisher attestations + - name: 🔍 Verify trusted-publisher attestations shell: bash env: REPOSITORY: ${{ github.repository }} diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index d63d1ef..ac4bc1e 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -35,7 +35,7 @@ outputs: runs: using: composite steps: - - name: Install Nab + - name: 📦 Install Nab shell: bash env: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} @@ -59,7 +59,7 @@ runs: requirements=("nab==$version") fi "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-tools" "${requirements[@]}" - - name: Resolve all project groups and extras + - name: 🔒 Resolve all project groups and extras shell: bash env: MODE: ${{ inputs.mode }} @@ -75,7 +75,7 @@ runs: if [[ "$MODE" == check ]]; then python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" "$RUNNER_TEMP/lock-before.toml" pylock.toml fi - - name: Resolve optional Nab matrix + - name: 🔒 Resolve optional Nab matrix if: inputs.additional-lock != '' shell: bash env: @@ -125,7 +125,8 @@ runs: python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \ "$RUNNER_TEMP/additional-lock-before.toml" "$ADDITIONAL_LOCK" fi - - id: lock + - name: 🔒 Write lock patch + id: lock shell: bash env: MODE: ${{ inputs.mode }} diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index 7b84ead..1aa4a9b 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -19,13 +19,14 @@ inputs: runs: using: composite steps: - - id: selected + - name: 🏷️ Find previous release tag + id: selected if: inputs.previous == 'auto' uses: $/select-previous-tag with: tag: ${{ inputs.tag }} - id: previous - name: Select previous tag + name: 🏷️ Select previous tag shell: bash env: INPUT_PREVIOUS: ${{ inputs.previous }} @@ -36,7 +37,7 @@ runs: else echo "previous=$INPUT_PREVIOUS" >>"$GITHUB_OUTPUT" fi - - name: Render GitHub Release notes + - name: 📝 Render GitHub Release notes shell: bash env: TAG: ${{ inputs.tag }} diff --git a/select-previous-tag/action.yml b/select-previous-tag/action.yml index 0ba0732..e4ecb78 100644 --- a/select-previous-tag/action.yml +++ b/select-previous-tag/action.yml @@ -11,7 +11,8 @@ outputs: runs: using: composite steps: - - id: select + - name: 🏷️ Find previous release tag + id: select shell: bash env: TAG: ${{ inputs.tag }} diff --git a/sign-jetbrains-plugin/action.yml b/sign-jetbrains-plugin/action.yml index 44e660d..cd7a04f 100644 --- a/sign-jetbrains-plugin/action.yml +++ b/sign-jetbrains-plugin/action.yml @@ -26,7 +26,7 @@ outputs: runs: using: composite steps: - - name: Verify unsigned plugin ZIP + - name: 🔍 Verify unsigned plugin ZIP id: unsigned shell: bash env: @@ -37,7 +37,7 @@ runs: test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" echo "archive=${ARCHIVE%.zip}-signed.zip" >>"$GITHUB_OUTPUT" - - name: Download pinned Marketplace signer + - name: 📦 Download pinned Marketplace signer shell: bash env: SIGNER_BASE_URL: https://repo.maven.apache.org/maven2/org/jetbrains/marketplace-zip-signer/0.1.43 @@ -45,12 +45,12 @@ runs: curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ --output "$RUNNER_TEMP/marketplace-signer-cli.jar" \ "$SIGNER_BASE_URL/marketplace-zip-signer-0.1.43-cli.jar" - - name: Verify Marketplace signer digest + - name: 🔍 Verify Marketplace signer digest shell: bash env: SIGNER_SHA256: 2958a0f42221d6062b50f22754e413ae5cc42f60c441467202c6700df2e22f44 run: echo "$SIGNER_SHA256 $RUNNER_TEMP/marketplace-signer-cli.jar" | sha256sum --check - - name: Sign and verify plugin ZIP + - name: ✍️ Sign plugin ZIP shell: bash env: ARCHIVE: ${{ inputs.archive }} @@ -87,7 +87,7 @@ runs: "$JAVA_HOME_17_X64/bin/java" -jar "$RUNNER_TEMP/marketplace-signer-cli.jar" sign \ -in "$ARCHIVE" -out "$SIGNED" -cert-file "$RUNNER_TEMP/signing-chain.pem" \ -key-file "$signing_key" - - name: Record signed plugin ZIP + - name: ⚙️ Record signed plugin ZIP id: digest shell: bash env: diff --git a/tests/cooled_prek/test_cooled_prek.py b/tests/cooled_prek/test_cooled_prek.py index ed0b51a..e563f3e 100644 --- a/tests/cooled_prek/test_cooled_prek.py +++ b/tests/cooled_prek/test_cooled_prek.py @@ -36,7 +36,7 @@ def test_prepare_hook_install_uses_cooled_prek() -> None: (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text(), Loader=yaml.BaseLoader, ) - step = next(step for step in workflow["jobs"]["assemble"]["steps"] if step.get("name") == "Install prek hooks") + step = next(step for step in workflow["jobs"]["assemble"]["steps"] if step.get("id") == "hooks") assert (step["uses"], step["with"]) == ("$/cooled-prek", {"args": "install-hooks"}) diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index b34d131..fc6b2f6 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -281,7 +281,7 @@ def test_install_locked_tools_installs_only_binary_reader(tmp_path: Path, action installer.chmod(0o755) (tmp_path / "action").mkdir() steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "install-locked-tools/action.yml").read_text()) - index = next(index for index, step in enumerate(steps["runs"]["steps"]) if step["name"] == "Install lock reader") + index = next(index for index, step in enumerate(steps["runs"]["steps"]) if step["name"] == "📦 Install lock reader") result = subprocess.run( ["bash", str(action_scripts / f"install-locked-tools-{index}.sh")], diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index 903ddde..f4a6e20 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -118,18 +118,29 @@ def test_multipart_upload_uses_exact_archive_and_channel( assert request.files["file"].read() == signed_archive.read_bytes() +@pytest.mark.parametrize( + "previous_step", + [ + pytest.param("🚀 Publish signed ZIP", id="current"), + pytest.param("Publish signed ZIP", id="before-emoji"), + pytest.param("Upload signed ZIP", id="upload"), + pytest.param("📤 Publish to JetBrains Marketplace", id="legacy"), + ], +) def test_prior_upload_without_receipt_blocks_retry( signed_archive: Path, tmp_path: Path, marketplace_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], httpserver: HTTPServer, + previous_step: str, ) -> None: result = publish_action( tmp_path, marketplace_scripts, github_api, previous_run=True, + previous_step=previous_step, ARCHIVE=str(signed_archive), SHA256=sha256(signed_archive.read_bytes()).hexdigest(), PLUGIN_ID="29973", @@ -337,6 +348,7 @@ def publish_action( *, runner_temp: Path | None = None, previous_run: bool = False, + previous_step: str = "🚀 Publish signed ZIP", previous_workflow: str = "release.yaml", accepted_archive: Path | None = None, missing_receipt: bool = False, @@ -384,7 +396,7 @@ def publish_action( "jobs": [ { "run_attempt": attempt, - "steps": [{"name": "Publish signed ZIP", "started_at": "2026-10-23T00:00:00Z"}], + "steps": [{"name": previous_step, "started_at": "2026-10-23T00:00:00Z"}], } for attempt in ((1, 2) if reconciled_attempt else (1,)) ] @@ -485,7 +497,7 @@ def marketplace_scripts(action_scripts: Path, tmp_path: Path, httpserver: HTTPSe steps = yaml.safe_load( (Path(__file__).resolve().parents[2] / "publish-jetbrains-marketplace/action.yml").read_text() )["runs"]["steps"] - upload_index = next(index for index, step in enumerate(steps) if step.get("name") == "Upload signed ZIP") + upload_index = next(index for index, step in enumerate(steps) if step.get("name") == "🚀 Upload signed ZIP") upload = scripts / f"publish-jetbrains-marketplace-{upload_index}.sh" upload.write_text( upload.read_text().replace( diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 2a6320a..3f04f89 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -53,7 +53,7 @@ def test_refresh_locks_every_group_and_extra_with_nab( ) -> None: (tmp_path / "pylock.toml").write_text(_LOCK) - result = _run_step(action_scripts, "Resolve all project groups and extras", tmp_path, fake_uv, MODE=mode) + result = _run_step(action_scripts, "🔒 Resolve all project groups and extras", tmp_path, fake_uv, MODE=mode) assert result.returncode == 0, result.stderr call = _uv_call(tmp_path) @@ -71,7 +71,7 @@ def test_refresh_locks_every_group_and_extra_with_nab( ] -@pytest.mark.parametrize("step", ["Resolve all project groups and extras", "Resolve optional Nab matrix"]) +@pytest.mark.parametrize("step", ["🔒 Resolve all project groups and extras", "🔒 Resolve optional Nab matrix"]) def test_refresh_hides_certificate_overrides_from_nab( tmp_path: Path, action_scripts: Path, fake_uv: Path, step: str ) -> None: @@ -224,7 +224,7 @@ def test_refresh_installs_selected_nab( result = _run_step( action_scripts, - "Install Nab", + "📦 Install Nab", tmp_path, tmp_path / "bin/uv", GITHUB_ACTION_PATH=str(tmp_path / "action"), @@ -243,7 +243,7 @@ def test_refresh_check_requires_locked_nab_version(tmp_path: Path, action_script result = _run_step( action_scripts, - "Install Nab", + "📦 Install Nab", tmp_path, tmp_path / "bin/uv", GITHUB_ACTION_PATH=str(tmp_path / "action"), diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 3907b49..26adb5d 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -22,7 +22,7 @@ def publish_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: workflow = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text()) for step in workflow["jobs"]["publish"]["steps"]: if "run" in step: - name = step.get("id", step.get("name", "step")).replace(" ", "-").lower() + name = step.get("id", step["name"].split(" ", 1)[1]).replace(" ", "-").lower() (scripts / f"publish-{name}.sh").write_text(step["run"]) return scripts @@ -203,5 +203,5 @@ def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) def refresh_script(scripts: Path, name: str) -> Path: source = Path(__file__).resolve().parents[2] steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] - index = next(index for index, step in enumerate(steps) if step.get("name", step.get("id")) == name) + index = next(index for index, step in enumerate(steps) if step.get("id") == name) return scripts / f"refresh-release-lock-{index}.sh" diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index fd5ebc8..3d4f351 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -51,9 +51,9 @@ def test_release_workflow_leaves_distribution_checks_to_the_package(release_work def test_build_exports_constraints_before_building(release_workflow: dict[str, Any]) -> None: - names = [step.get("name", step.get("uses", "")) for step in release_workflow["jobs"]["build"]["steps"]] + names = [step["name"] for step in release_workflow["jobs"]["build"]["steps"]] - assert names.index("$/install-locked-tools") < names.index("Build wheel and sdist") + assert names.index("📦 Export locked build constraints") < names.index("🏗️ Build wheel and sdist") def test_validator_builds_reproducible_distributions( @@ -62,12 +62,12 @@ def test_validator_builds_reproducible_distributions( assert "hatchling==" in build_constraints.read_text() environment = {"RUNNER_TEMP": str(tmp_path), "SOURCE": "."} interpreter = run_release_step( - release_workflow, "build", "Select build interpreter", build_repository, **environment + release_workflow, "build", "🐍 Select build interpreter", build_repository, **environment ) assert interpreter.returncode == 0, interpreter.stderr outputs: list[dict[str, bytes]] = [] for _ in range(2): - result = run_release_step(release_workflow, "build", "Build wheel and sdist", build_repository, **environment) + result = run_release_step(release_workflow, "build", "🏗️ Build wheel and sdist", build_repository, **environment) assert result.returncode == 0, result.stderr outputs.append( {path.name: path.read_bytes() for path in (build_repository / "dist").iterdir() if path.suffix != ""} @@ -89,9 +89,9 @@ def test_validator_rejects_backend_hash_mismatch( build_constraints.write_text("\n".join(lines) + "\n") environment = {"RUNNER_TEMP": str(tmp_path), "SOURCE": ".", "UV_CACHE_DIR": str(tmp_path / "uv-cache")} interpreter = run_release_step( - release_workflow, "build", "Select build interpreter", build_repository, **environment + release_workflow, "build", "🐍 Select build interpreter", build_repository, **environment ) assert interpreter.returncode == 0, interpreter.stderr - result = run_release_step(release_workflow, "build", "Build wheel and sdist", build_repository, **environment) + result = run_release_step(release_workflow, "build", "🏗️ Build wheel and sdist", build_repository, **environment) assert result.returncode != 0 assert "Hash mismatch" in result.stderr diff --git a/validate-news/action.yml b/validate-news/action.yml index 106790b..2c02c11 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -29,7 +29,7 @@ runs: using: composite steps: - id: news-change - name: Check whether news inputs changed + name: 🧭 Check whether news inputs changed shell: bash env: BASE_REF: ${{ github.base_ref }} @@ -69,13 +69,13 @@ runs: with Path(output).open("a", encoding="utf-8") as stream: stream.write(f"fragments={str(fragments).lower()}\nconfig={str(config).lower()}\n") PY - - name: Count changed fragments + - name: 🔍 Count changed fragments if: steps.news-change.outputs.fragments == 'true' || steps.news-change.outputs.config == 'true' shell: bash env: BASE_REF: ${{ github.base_ref }} run: release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" - - name: Require news for human changes + - name: 🔍 Require news for human changes shell: bash env: SKIP_NEWS: ${{ contains(github.event.pull_request.labels.*.name, 'skip news') }} @@ -88,7 +88,7 @@ runs: exit 1 fi - id: lock-change - name: Check whether release inputs changed + name: 🧭 Check whether release inputs changed shell: bash env: BASE_REF: ${{ github.base_ref }} @@ -113,7 +113,7 @@ runs: exit 0 fi echo 'changed=true' >>"$GITHUB_OUTPUT" - - name: Check the release lock against the project + - name: 🔒 Check the release lock against the project if: steps.lock-change.outputs.changed == 'true' uses: $/refresh-release-lock with: diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 0763450..34f33ba 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -3,12 +3,13 @@ description: Apply the release patch downloaded to $RUNNER_TEMP/release-patch an runs: using: composite steps: - - name: Apply release patch + - name: ⚙️ Apply release patch shell: bash run: git apply --index "$RUNNER_TEMP/release-patch/release.patch" - - id: config + - name: ⚙️ Read Towncrier configuration + id: config uses: $/read-towncrier-config - - name: Check release patch paths + - name: 🔍 Check release patch paths shell: bash env: CHANGELOG: ${{ steps.config.outputs.changelog }} @@ -29,7 +30,7 @@ runs: exit 1 fi done <"$RUNNER_TEMP/release-paths" - - name: Check release patch file modes + - name: 🔍 Check release patch file modes shell: bash run: | set -euo pipefail From 522076eaf6fa1e6ecb26322be5252973cd466357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 09:25:41 -0700 Subject: [PATCH 18/23] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20build:=20use=20relea?= =?UTF-8?q?se-creator=204.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise gw's own release-creator floors to 4, move the temporary cutoff override to the 4.0.0 upload and relock. release-creator 4 no longer rejects bare fragment names and checks only a changed Towncrier configuration itself, so the tests use errors it still raises. --- .github/workflows/news.yaml | 10 +++++----- .github/workflows/prepare-release.yaml | 4 ++-- .github/workflows/release.yaml | 6 +++--- .github/workflows/update-tools.yaml | 4 ++-- pylock.toml | 22 +++++++++++----------- pyproject.toml | 8 ++++---- tests/prepare_towncrier/test_prepare.py | 13 ------------- tests/validate_news/test_news.py | 6 +++--- 8 files changed, 30 insertions(+), 43 deletions(-) diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml index bd57fc9..8d1d9e1 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -25,13 +25,13 @@ jobs: - name: 📦 Install release tools env: EXCLUDE_NEWER: 7 days - # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. - EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T21:03:31Z + # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-26T16:21:40Z run: | - tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=2.0.0' 'towncrier>=26.9' + tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=4' 'towncrier>=26.9' uv run --no-project --python "$RUNNER_TEMP/release-tools" python tools/expose_locked_commands.py "$RUNNER_TEMP/release-tools" - name: 🔍 Validate news fragments uses: $/validate-news with: - # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. - exclude-newer-package: release-creator=2026-10-25T21:03:31Z + # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-26T16:21:40Z diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index 4973f8e..a5108bf 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -18,7 +18,7 @@ jobs: pull-requests: read with: bump: ${{ inputs.bump }} - # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. - exclude-newer-package: release-creator=2026-10-25T21:03:31Z + # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-26T16:21:40Z secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 889a43c..0403f9b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,10 +30,10 @@ jobs: - name: 📦 Install release tools env: EXCLUDE_NEWER: 7 days - # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. - EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T21:03:31Z + # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-26T16:21:40Z run: | - tools/install_live_tools.sh .venv 'release-creator>=2.0.0' + tools/install_live_tools.sh .venv 'release-creator>=4' uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - name: 📝 Render release notes uses: $/render-release-notes diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 90341bd..3db76a0 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -16,5 +16,5 @@ jobs: commit-message: "🔧 build: refresh release tool lock" pull-request-title: "🔧 build: refresh release tool lock" pull-request-body: "Refresh the locked tools used by release preparation and CI." - # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. - exclude-newer-package: release-creator=2026-10-25T21:03:31Z + # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-26T16:21:40Z diff --git a/pylock.toml b/pylock.toml index bcb7a45..4f136bf 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1045,7 +1045,7 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "3.1.0" +version = "4.0.0" marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.15" dependencies = [ @@ -1060,22 +1060,22 @@ dependencies = [ index = "https://pypi.org/simple/" [packages.sdist] -name = "release_creator-3.1.0.tar.gz" -upload-time = 2026-10-25 21:03:30.896511+00:00 -url = "https://files.pythonhosted.org/packages/03/98/73278ccb2393b27b702100a0eff5b387f2db6f06fc8aa0dea60bbfcbd402/release_creator-3.1.0.tar.gz" -size = 178357 +name = "release_creator-4.0.0.tar.gz" +upload-time = 2026-10-26 16:21:39.157838+00:00 +url = "https://files.pythonhosted.org/packages/c0/64/cfd6c068445858d4a5d7b3ed27241562d5e7bfde70fc261101c9432e718d/release_creator-4.0.0.tar.gz" +size = 178372 [packages.sdist.hashes] -sha256 = "7fba39aa52283c7edb4bf906656296a0696b4d23f6d6df85ef5d9a7abca7e1b2" +sha256 = "c3a61ab0502fe9916990d43198b4a695f4f471afb3e63ffbe85eb64c160d4726" [[packages.wheels]] -name = "release_creator-3.1.0-py3-none-any.whl" -upload-time = 2026-10-25 21:03:29.549360+00:00 -url = "https://files.pythonhosted.org/packages/35/f2/20dca23b89aadcd846a7b36d763440a2e5cf2ef3b2adda350af16406aa52/release_creator-3.1.0-py3-none-any.whl" -size = 51886 +name = "release_creator-4.0.0-py3-none-any.whl" +upload-time = 2026-10-26 16:21:37.836466+00:00 +url = "https://files.pythonhosted.org/packages/96/77/4c4d6212d3b59eab660dcb0c5c07f2209756bdf7f4f1288a3d38c1b8bff4/release_creator-4.0.0-py3-none-any.whl" +size = 49646 [packages.wheels.hashes] -sha256 = "e378a399af800452180ca1748dfb981b9f0115b634587226be927162b4b34f17" +sha256 = "c6ce7929c82c517faf747e64492ea3c25a8377bdae4111fb5c8b35532592289c" [[packages]] name = "setuptools" diff --git a/pyproject.toml b/pyproject.toml index e131a67..e91a6c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] -release-prepare = [ "release-creator>=3.1.0", "towncrier>=26.9", "prek>=0.2" ] +release-prepare = [ "release-creator>=4", "towncrier>=26.9", "prek>=0.2" ] test = [ "certifi", "coverage[toml]>=7.10", @@ -20,7 +20,7 @@ test = [ "pytest-mock>=3.16", "pytest-xdist>=3", "pyyaml>=6", - "release-creator>=3.1.0", + "release-creator>=4", "tomlkit>=0.13", "tox>=4.64.7", "tox-uv>=1.36.1", @@ -36,9 +36,9 @@ uploaded-prior-to = "P7D" python = ">=3.15,<3.16" platforms = ["linux_x86_64"] -# Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. +# Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. [tool.nab.packages.release-creator] -uploaded-prior-to = 2026-10-25T21:03:31Z +uploaded-prior-to = 2026-10-26T16:21:40Z [tool.ruff] line-length = 120 diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index b868272..4873d68 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -227,19 +227,6 @@ def test_prepare_resolves_numeric_fragment_from_commit( assert "[#3](https://github.com/tox-dev/github-workflows/pull/3)" in (repository / "CHANGELOG.md").read_text() -@pytest.mark.parametrize("version", [pytest.param("", id="automatic"), pytest.param("0.2.0", id="explicit")]) -def test_prepare_rejects_unlinked_fragment_name( - repository: Path, tmp_path: Path, action_scripts: Path, version: str -) -> None: - run(repository, "git", "mv", "changelog.d/+feature.feature.md", "changelog.d/feature.feature.md") - run(repository, "git", "commit", "-m", "Use a bare fragment name") - run(repository, "git", "push", "origin", "main") - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version=version) - assert result.returncode != 0 - assert "unsupported changelog fragment name" in result.stderr - assert not (tmp_path / "release.patch").exists() - - def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "changelog.d/+feature.feature.md").write_text("Add " + "a long sentence " * 15 + ".\n") run(repository, "git", "add", ".") diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index da39bb8..bbf5e86 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -374,8 +374,8 @@ def test_validate_news_checks_towncrier_config_only_when_touched( repository: Path, tmp_path: Path, action_scripts: Path, touched: str, accepted: bool ) -> None: project = repository / "pyproject.toml" - project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', 'filename = "CHANGELOG.txt"')) - run(repository, "git", "commit", "-am", "Use an unsupported changelog") + project.write_text(project.read_text().replace('directory = "changelog.d"\n', 'package = "fixture"\n')) + run(repository, "git", "commit", "-am", "Drop the fragment directory") run(repository, "git", "push", "origin", "main") run(repository, "git", "checkout", "-b", "change-news-input") target = repository / touched @@ -396,4 +396,4 @@ def test_validate_news_checks_towncrier_config_only_when_touched( ) assert (result.returncode == 0) is accepted, result.stderr - assert ("Markdown or reStructuredText changelog" in result.stderr) is not accepted + assert ("set an explicit Towncrier fragment directory" in result.stderr) is not accepted From 9e6fa334300cc2aa29a707c8c2a37feff5281153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 10:30:11 -0700 Subject: [PATCH 19/23] =?UTF-8?q?=F0=9F=94=8A=20feat:=20explain=20each=20s?= =?UTF-8?q?tep's=20choices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every composite Action, inline workflow step and tools script now prints each choice it makes and why: selected lock groups, build constraints reuse, version and previous tag, skipped checks and steps, release draft state, asset uploads and Marketplace receipts. git, gh and curl commands no longer discard their output, except where the command is a yes/no test, whose outcome is printed instead. Python tools explain on stderr, so captured stdout, step outputs and written files stay unchanged, and nothing relies on release-creator's own stderr. --- .github/workflows/check.yaml | 6 +- .github/workflows/upgrade-release-tools.yaml | 31 +++++++--- .../workflows/validate-python-release.yaml | 2 + build-python-distribution/action.yml | 2 + changelog.d/+explain-choices.feature.md | 1 + commit-and-tag/action.yml | 12 +++- install-locked-tools/action.yml | 14 ++++- prepare-towncrier/action.yml | 20 +++++- publish-github-release/action.yml | 62 ++++++++++++++++--- publish-github-release/retry.sh | 1 + publish-jetbrains-marketplace/action.yml | 16 ++++- publish-post-release-pr/action.yml | 35 ++++++++--- read-towncrier-config/action.yml | 3 + reconcile-pypi-artifacts/action.yml | 1 + refresh-release-lock/action.yml | 36 +++++++++-- render-release-notes/action.yml | 8 +++ select-previous-tag/action.yml | 5 ++ sign-jetbrains-plugin/action.yml | 15 ++++- tests/export_build_constraints/test_export.py | 24 +++++++ tests/install_locked_tools/test_install.py | 17 +++++ tests/publish_github_release/test_publish.py | 27 ++++++++ tests/select_previous_tag/test_select.py | 19 ++++++ tests/validate_news/test_news.py | 26 ++++++++ tests/verify_pypi_files/test_verify.py | 15 +++++ tools/build_constraints.py | 25 ++++++-- tools/check_nab_lock.py | 5 ++ tools/cooled_prek.sh | 1 + tools/expose_locked_commands.py | 6 ++ tools/install_live_tools.sh | 7 ++- tools/verify_pypi_files.py | 16 ++++- validate-news/action.yml | 29 +++++++-- validate-release-patch/action.yml | 9 ++- 32 files changed, 440 insertions(+), 56 deletions(-) create mode 100644 changelog.d/+explain-choices.feature.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 2a26ff5..6c035af 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -86,6 +86,8 @@ jobs: index=$((index + 1)) done <"$RUNNER_TEMP/run-blocks" done + scripts=("$script_dir"/*.sh) + echo "Checking ${#scripts[@]} run blocks from the workflows and Actions with shellcheck and shfmt" tools/cooled_prek.sh run shellcheck --files "$script_dir"/*.sh tools/cooled_prek.sh run shfmt --files "$script_dir"/*.sh test: @@ -138,4 +140,6 @@ jobs: - name: 🚦 Require all needed jobs to pass env: NEEDS_JSON: ${{ toJSON(needs) }} - run: jq -e 'all(to_entries[]; .value.result == "success")' <<<"$NEEDS_JSON" + run: | + jq -r 'to_entries[] | "Job \(.key): \(.value.result)"' <<<"$NEEDS_JSON" + jq -e 'all(to_entries[]; .value.result == "success")' <<<"$NEEDS_JSON" diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index e12bdec..c0b2388 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -128,9 +128,10 @@ jobs: echo "::error::unexpected lock patch path: $path" >&2 exit 1 fi + echo "Lock patch path: $path (allowed lock)" done <"$RUNNER_TEMP/lock-paths" - name: 🔒 Apply lock patch - run: git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch" + run: git apply --index --verbose "$RUNNER_TEMP/release-tools-lock/lock.patch" - name: 🔍 Check dependency patch file mode run: | set -euo pipefail @@ -141,6 +142,7 @@ jobs: echo "::error::unexpected dependency file mode in $path: ${fields[1]}" >&2 exit 1 fi + echo "Lock patch file mode: $path is a regular non-executable file" done <"$RUNNER_TEMP/dependency-modes" - name: 🏷️ Commit refreshed tools env: @@ -169,6 +171,7 @@ jobs: GH_TOKEN: ${{ steps.token.outputs.token }} run: | gh auth setup-git --hostname github.com --force + echo 'Pushing automation/update-release-tools, replacing any earlier refresh (force with lease)' git push --force-with-lease origin HEAD:refs/heads/automation/update-release-tools - name: 📝 Write dependency pull request body env: @@ -181,15 +184,23 @@ jobs: run: | set -euo pipefail owner=${GITHUB_REPOSITORY%%/*} - gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open \ - -f head="$owner:automation/update-release-tools" -f base=main | - jq -r --arg repository "$GITHUB_REPOSITORY" ' - [.[] | select(.head.repo.full_name == $repository and - .head.ref == "automation/update-release-tools" and .base.ref == "main")] | - if length == 0 then "number=" - elif length == 1 then "number=\(.[0].number)" - else error("multiple dependency pull requests") end - ' >>"$GITHUB_OUTPUT" + number=$( + gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open \ + -f head="$owner:automation/update-release-tools" -f base=main | + jq -r --arg repository "$GITHUB_REPOSITORY" ' + [.[] | select(.head.repo.full_name == $repository and + .head.ref == "automation/update-release-tools" and .base.ref == "main")] | + if length == 0 then "" + elif length == 1 then .[0].number + else error("multiple dependency pull requests") end + ' + ) + if [[ -n "$number" ]]; then + echo "Dependency pull request: #$number is open, updating it" + else + echo 'Dependency pull request: none open, opening one' + fi + echo "number=$number" >>"$GITHUB_OUTPUT" - name: 🔀 Update dependency pull request if: steps.pr.outputs.number != '' env: diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index cfe8062..b4dee61 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -64,6 +64,8 @@ jobs: build_python=$(uv python find "$RUNNER_TEMP/build-python") export SOURCE_DATE_EPOCH SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) + echo "Building wheel and sdist from $SOURCE with $build_python," \ + "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH (last commit time)" uv build --python "$build_python" --python-preference only-managed --sdist --wheel \ --build-constraints "$RUNNER_TEMP/build-constraints.txt" --require-build-hashes "$SOURCE" --out-dir dist - name: 📤 Upload distributions diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index d99d146..5310bae 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -29,6 +29,8 @@ runs: build_python=$(uv python find "$RUNNER_TEMP/build-python") export SOURCE_DATE_EPOCH SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) + echo "Building wheel and sdist with $build_python, constraints $BUILD_CONSTRAINTS," \ + "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH (last commit time)" uv build --python "$build_python" \ --python-preference only-managed --sdist --wheel \ --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes . --out-dir dist diff --git a/changelog.d/+explain-choices.feature.md b/changelog.d/+explain-choices.feature.md new file mode 100644 index 0000000..80ff30b --- /dev/null +++ b/changelog.d/+explain-choices.feature.md @@ -0,0 +1 @@ +Every Action and reusable workflow now logs the choices it makes and why, and no longer hides tool output. diff --git a/commit-and-tag/action.yml b/commit-and-tag/action.yml index 5505ed6..7e6efb9 100644 --- a/commit-and-tag/action.yml +++ b/commit-and-tag/action.yml @@ -29,6 +29,7 @@ runs: app_user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) git config user.name "${APP_SLUG}[bot]" git config user.email "${app_user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + echo "Commit identity: ${APP_SLUG}[bot] (App user $app_user_id)" - name: 🏷️ Commit release patch shell: bash env: @@ -42,8 +43,14 @@ runs: ANNOTATED: ${{ inputs.annotated }} run: | case "$ANNOTATED" in - true) git tag -a -m "$MESSAGE" -- "$VERSION" ;; - *) git tag -- "$VERSION" ;; + true) + echo "Tag $VERSION: annotated (annotated input is true)" + git tag -a -m "$MESSAGE" -- "$VERSION" + ;; + *) + echo "Tag $VERSION: lightweight (annotated input is $ANNOTATED)" + git tag -- "$VERSION" + ;; esac - name: ⬆️ Push release commit and tag shell: bash @@ -52,4 +59,5 @@ runs: GH_TOKEN: ${{ inputs.token }} run: | gh auth setup-git --hostname github.com --force + echo "Pushing the release commit to main and tag $VERSION in one atomic push" git push --atomic origin HEAD:refs/heads/main "refs/tags/$VERSION" diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 5dfa8e7..f7a27c3 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -53,8 +53,11 @@ runs: groups+=("${selected[@]}") done <<<"$DEPENDENCY_GROUPS" if ((${#groups[@]})); then + echo "Selected lock groups: ${groups[*]} (from the groups input), installing into $VENV from pylock.toml" uv venv --allow-existing --python 3.15 "$VENV" printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" + else + echo 'Selected lock groups: none (groups input is empty), exporting build constraints only' fi printf 'lock=pylock.toml\n' >>"$GITHUB_OUTPUT" - id: constraints @@ -67,7 +70,12 @@ runs: TARGETS: ${{ inputs.targets }} run: | set -euo pipefail - if [[ -n "$CONSTRAINTS_LOCK" ]]; then BUILD_LOCK=$CONSTRAINTS_LOCK; fi + if [[ -n "$CONSTRAINTS_LOCK" ]]; then + BUILD_LOCK=$CONSTRAINTS_LOCK + echo "Build constraints lock: $BUILD_LOCK (from the constraints-lock input)" + else + echo "Build constraints lock: $BUILD_LOCK (constraints-lock input is empty)" + fi args=() if [[ -n "$TARGET_PLATFORM" || -n "$TARGETS" ]]; then args+=(--target-platform "$TARGET_PLATFORM" --targets "$TARGETS") @@ -78,7 +86,9 @@ runs: ) digest=$(sha256sum <<<"$digest") output="$RUNNER_TEMP/build-constraints-${digest%% *}.txt" - if [[ ! -f "$output" ]]; then + if [[ -f "$output" ]]; then + echo "Reusing build constraints from an earlier step (same lock and targets): $output" + else uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" "$output" "$BUILD_LOCK" fi diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 425771a..46b1556 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -38,6 +38,8 @@ runs: run: | set -euo pipefail if [[ "$PHASE" == finalize ]]; then + echo "Release version: $VERSION (from the version input in the finalize phase)" + echo 'Fragment linking, changelog build and contributors skipped: the build phase did them' printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" exit 0 fi @@ -47,6 +49,12 @@ runs: args+=(--prefix "$TAG_PREFIX") fi VERSION=$(git tag --list | release-creator tags "${args[@]}") + echo "Next version: $VERSION (bump $BUMP from the fragments in $FRAGMENTS and the existing${TAG_PREFIX:+ $TAG_PREFIX-prefixed} tags)" + else + echo "Release version: $VERSION (from the version input)" + fi + if [[ "$PHASE" == build ]]; then + echo 'Changelog formatting and the release patch skipped: the finalize phase does them' fi printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" - name: 📝 Link fragments and authors @@ -67,6 +75,7 @@ runs: CHANGELOG_PREFIX: ${{ inputs.changelog-prefix }} run: | set -euo pipefail + echo "Changelog version: $CHANGELOG_PREFIX${VERSION#v} (tag $VERSION${CHANGELOG_PREFIX:+, changelog-prefix $CHANGELOG_PREFIX})" towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}" - name: 📝 Add new contributors to the changelog if: inputs.phase != 'finalize' @@ -91,8 +100,10 @@ runs: rst) formatter=docstrfmt ;; *) formatter=mdformat ;; esac + echo "Changelog formatter: $formatter ($FORMAT changelog $CHANGELOG)" prek=$GITHUB_ACTION_PATH/../tools/cooled_prek.sh if ! "$prek" run "$formatter" --files "$CHANGELOG"; then + echo "$formatter rewrote $CHANGELOG, running it again to confirm the result is stable" "$prek" run "$formatter" --files "$CHANGELOG" fi - name: 🔍 Check generated files @@ -101,7 +112,12 @@ runs: env: CHANGELOG: ${{ steps.config.outputs.changelog }} run: | - if [[ -f "$CHANGELOG" ]] && ! git ls-files --error-unmatch "$CHANGELOG" >/dev/null 2>&1; then + if [[ ! -f "$CHANGELOG" ]]; then + echo "$CHANGELOG does not exist, nothing to add to the release patch" + elif git ls-files --error-unmatch "$CHANGELOG" >/dev/null 2>&1; then + echo "$CHANGELOG is already tracked" + else + echo "$CHANGELOG is new, marking it intent-to-add so the release patch includes it" git add -N -- "$CHANGELOG" fi - name: ⚙️ Write release patch @@ -110,3 +126,5 @@ runs: run: | set -euo pipefail git diff HEAD --binary >"$RUNNER_TEMP/release.patch" + echo "Release patch -> $RUNNER_TEMP/release.patch:" + git diff HEAD --stat diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index 9bf3e1c..ea25ae2 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -42,6 +42,10 @@ runs: done jq -R -s '[split("\n")[] | select(length > 0) | split("\t") | {name: .[0], digest: .[1]}]' \ "$RUNNER_TEMP/release-assets.tsv" >"$RUNNER_TEMP/release-assets.json" + jq -r --arg directory "$ASSETS_DIRECTORY" '"Release assets: \(length) files from \($directory)\( + if length > 0 then ": " + ([.[].name] | join(", ")) else "" end)"' "$RUNNER_TEMP/release-assets.json" + else + echo 'Release assets: none (assets-directory input is empty)' fi - name: 🔍 Inspect any existing release id: inspect @@ -56,10 +60,17 @@ runs: if jq -e 'length > 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then jq -er '.[0].id' "$RUNNER_TEMP/release-existing.json" >"$RUNNER_TEMP/release-id" fi + jq -r --arg tag "$TAG" 'if length == 0 then "Existing GitHub Release \($tag): none" + else .[0] | "Existing GitHub Release \($tag): \(if .draft then "draft" else "published" end) \(.id)\( + if .immutable then ", immutable" else "" end)" end' "$RUNNER_TEMP/release-existing.json" immutable=$(jq -r '.[0] | .draft == false and .immutable == true' "$RUNNER_TEMP/release-existing.json") if [[ "$immutable" == true ]]; then - jq '[.[0]]' "$RUNNER_TEMP/release-existing.json" | jq -e --arg phase complete \ - --slurpfile assets "$RUNNER_TEMP/release-assets.json" -f "$GITHUB_ACTION_PATH/match.jq" >/dev/null + if ! jq '[.[0]]' "$RUNNER_TEMP/release-existing.json" | jq -e --arg phase complete \ + --slurpfile assets "$RUNNER_TEMP/release-assets.json" -f "$GITHUB_ACTION_PATH/match.jq" >/dev/null; then + echo "::error::published immutable release $TAG does not have the expected assets" >&2 + exit 1 + fi + echo "GitHub Release $TAG is published and immutable with the expected assets; nothing to upload or publish" fi printf 'immutable=%s\n' "$immutable" >>"$GITHUB_OUTPUT" - name: 🚀 Create or update the draft @@ -81,13 +92,19 @@ runs: gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \ >"$RUNNER_TEMP/release-created.json" jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id" + echo "GitHub Release $TAG: created draft $(<"$RUNNER_TEMP/release-id") for $COMMIT (prerelease $PRERELEASE)" elif jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ '.[0].draft and (.[0].name != $title or .[0].body != $body or .[0].prerelease != $prerelease)' \ "$RUNNER_TEMP/release-existing.json" >/dev/null; then jq -n --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ '{name: $title, body: $body, prerelease: $prerelease}' >"$RUNNER_TEMP/release-update.json" gh api -X PATCH "repos/$GH_REPO/releases/$(<"$RUNNER_TEMP/release-id")" \ - --input "$RUNNER_TEMP/release-update.json" >/dev/null + --input "$RUNNER_TEMP/release-update.json" \ + --jq '"GitHub Release \(.tag_name): updated draft \(.id) to the expected title, notes and prerelease flag"' + elif jq -e '.[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + echo "GitHub Release $TAG: draft $(<"$RUNNER_TEMP/release-id") already has the expected title, notes and prerelease flag" + else + echo "GitHub Release $TAG: already published, leaving its title and notes unchanged" fi - name: 🚀 Upload missing draft assets if: steps.inspect.outputs.immutable != 'true' @@ -101,12 +118,17 @@ runs: set -euo pipefail # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. source "$GITHUB_ACTION_PATH/retry.sh" - if [[ "$MODE" != publish && -n "$ASSETS_DIRECTORY" ]]; then + if [[ "$MODE" == publish ]]; then + echo 'Asset upload skipped: mode publish only publishes the staged draft' + elif [[ -z "$ASSETS_DIRECTORY" ]]; then + echo 'Asset upload skipped: assets-directory input is empty' + else shopt -s nullglob release_id=$(<"$RUNNER_TEMP/release-id") if jq -e '.[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then while IFS= read -r asset_id; do - gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" >/dev/null + echo "Deleting incomplete draft asset $asset_id left by an earlier attempt" + gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" done < <(jq -r '.[0].assets[] | select(.state != "uploaded") | .id' "$RUNNER_TEMP/release-existing.json") fi read_draft() { @@ -118,14 +140,21 @@ runs: echo '::error::draft release did not become visible' >&2 exit 1 fi - if jq -e '.[0].draft' "$RUNNER_TEMP/release-current.json" >/dev/null; then + if ! jq -e '.[0].draft' "$RUNNER_TEMP/release-current.json" >/dev/null; then + echo "Asset upload skipped: GitHub Release $TAG is already published" + else + uploaded=0 + present=0 for asset in "$ASSETS_DIRECTORY"/*; do name=${asset##*/} - if ! jq -e --arg name "$name" --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' + if jq -e --arg name "$name" --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' any(.[0].assets[]; .name == $name and .state == "uploaded" and (.digest as $digest | any($assets[0][]; .name == $name and .digest == $digest))) ' \ "$RUNNER_TEMP/release-current.json" >/dev/null; then + echo "Asset $name: already uploaded with the expected digest" + present=$((present + 1)) + else for attempt in {1..6}; do if ((attempt > 1)); then release=$(gh api "repos/$GH_REPO/releases/$release_id") @@ -133,9 +162,11 @@ runs: '.assets[] | select(.name == $name and .state != "uploaded") | .id' <<<"$release") while IFS= read -r asset_id; do [[ -n "$asset_id" ]] || continue - gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" >/dev/null + echo "Deleting incomplete upload $asset_id of $name before retrying" + gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" done <<<"$asset_ids" fi + echo "Asset $name: uploading (attempt $attempt of 6)" if gh release upload "$TAG" "$asset" --repo "$GH_REPO"; then break fi @@ -144,6 +175,7 @@ runs: any(.assets[]; .name == $name and .state == "uploaded" and (.digest as $digest | any($assets[0][]; .name == $name and .digest == $digest))) ' >/dev/null; then + echo "Asset $name: upload reported a failure but the asset arrived with the expected digest" break fi if ((attempt == 6)); then @@ -152,8 +184,10 @@ runs: fi sleep 2 done + uploaded=$((uploaded + 1)) fi done + echo "GitHub Release $TAG: uploaded $uploaded assets, $present already present" fi fi - name: 🔍 Verify the complete asset set @@ -175,6 +209,8 @@ runs: echo '::error::release assets did not match after publication' >&2 exit 1 fi + jq -r '.[0] | "GitHub Release \(.tag_name): \(.assets | length) uploaded assets match the expected set"' \ + "$RUNNER_TEMP/release-complete.json" - name: 🚀 Publish the draft if: steps.inspect.outputs.immutable != 'true' shell: bash @@ -183,9 +219,14 @@ runs: MODE: ${{ inputs.mode }} run: | set -euo pipefail - if [[ "$MODE" != stage ]] && jq -e '.[0].draft' "$RUNNER_TEMP/release-complete.json" >/dev/null; then + if [[ "$MODE" == stage ]]; then + echo 'Publish skipped: mode stage leaves the release as a draft' + elif jq -e '.[0].draft' "$RUNNER_TEMP/release-complete.json" >/dev/null; then release_id=$(<"$RUNNER_TEMP/release-id") - gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false >/dev/null + gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false \ + --jq '"GitHub Release \(.tag_name): published \(.html_url)"' + else + echo 'Publish skipped: the release is already published' fi - name: 🔍 Verify published asset attestations if: inputs.mode != 'stage' && inputs.assets-directory != '' @@ -204,4 +245,5 @@ runs: echo "::error::release asset attestation could not be verified: ${asset##*/}" >&2 exit 1 fi + echo "Asset ${asset##*/}: attestation verified" done diff --git a/publish-github-release/retry.sh b/publish-github-release/retry.sh index 8cf2ff2..528ea28 100644 --- a/publish-github-release/retry.sh +++ b/publish-github-release/retry.sh @@ -5,6 +5,7 @@ retry() { for attempt in {1..6}; do if "$@"; then return 0; fi if ((attempt == 6)); then return 1; fi + echo "Attempt $attempt of 6 failed, retrying in 2s" sleep 2 done } diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index 41173c0..4b6e3c5 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -46,6 +46,7 @@ runs: set -euo pipefail test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" + echo "Signed ZIP ${ARCHIVE##*/}: SHA256 matches the signing job, no duplicate entries" - name: 🔍 Find earlier Marketplace upload id: earlier shell: bash @@ -79,16 +80,20 @@ runs: "Reconcile run $run attempt $attempt manually." >&2 exit 1 fi + echo "Run $run attempt $attempt started a Marketplace upload and left receipt $receipt_name" prior=1 receipt_run=$run receipt=$receipt_name done <"$RUNNER_TEMP/marketplace-attempts.txt" done <"$RUNNER_TEMP/marketplace-runs.tsv" if ((prior)); then + echo "Marketplace upload skipped: $TAG was already uploaded, reusing receipt $receipt from run $receipt_run" { echo "run=$receipt_run" echo "receipt-name=$receipt" } >>"$GITHUB_OUTPUT" + else + echo "No earlier Marketplace upload for $TAG, uploading the signed ZIP" fi echo "accepted=$prior" >>"$GITHUB_OUTPUT" - name: 📩 Download earlier Marketplace acceptance @@ -138,6 +143,7 @@ runs: .notesSha256 == $notesSha and (.signedArtifactId | type == "number") and (.notesArtifactId | type == "number")' "$receipt" >/dev/null source_run=$(jq -r .sourceRunId "$receipt") + echo "Earlier receipt matches $TAG, $COMMIT, plugin $PLUGIN_ID, the archive and the notes; source run $source_run" artifacts="$RUNNER_TEMP/marketplace-artifacts-$source_run.tsv" # Only runs on this tag and commit with a started upload step have their artifacts listed. [[ -f "$artifacts" ]] || exit 1 @@ -152,6 +158,7 @@ runs: --dir "$RUNNER_TEMP/prior-notes" test "$(sha256sum "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}" | cut -d' ' -f1)" = "$EXPECTED_SHA" cmp "$NOTES_FILE" "$RUNNER_TEMP/prior-notes/release-notes.md" + echo "Earlier upload's signed ZIP and release notes match this run" - name: 🚀 Upload signed ZIP if: steps.earlier.outputs.accepted == '0' shell: bash @@ -165,7 +172,12 @@ runs: upload_file=$(mktemp "$RUNNER_TEMP/marketplace-upload.XXXXXX.zip") cp -- "$ARCHIVE" "$upload_file" cd "$RUNNER_TEMP" - status=$(curl --fail-with-body --max-time 120 --silent --show-error --write-out '%{http_code}' \ + if [[ -n "$CHANNEL" ]]; then + echo "Uploading ${ARCHIVE##*/} to Marketplace plugin $PLUGIN_ID, channel $CHANNEL (from the channel input)" + else + echo "Uploading ${ARCHIVE##*/} to Marketplace plugin $PLUGIN_ID, channel Stable (channel input is empty)" + fi + status=$(curl --fail-with-body --max-time 120 --write-out '%{http_code}' \ --output "$RUNNER_TEMP/marketplace-response.json" \ --config <(printf 'header = "Authorization: Bearer %s"\n' "$PUBLISH_TOKEN") \ --form "pluginId=$PLUGIN_ID" --form "file=@${upload_file##*/};filename=plugin.zip" --form "channel=$CHANNEL" \ @@ -177,6 +189,7 @@ runs: cat "$RUNNER_TEMP/marketplace-response.json" >&2 exit 1 fi + echo "Marketplace accepted the upload: HTTP $status" - name: ⚙️ Record Marketplace acceptance if: steps.earlier.outputs.accepted == '0' shell: bash @@ -201,6 +214,7 @@ runs: pluginId: $pluginId, version: $version, channel: $channel, signedArchive: $archive, sha256: $sha, signedArtifactId: $signedArtifactId, notesArtifactId: $notesArtifactId, notesSha256: $notesSha}' >"$RUNNER_TEMP/marketplace-acceptance.json" + echo "Recorded Marketplace acceptance for $TAG in run $GITHUB_RUN_ID attempt $GITHUB_RUN_ATTEMPT" - name: 📤 Upload Marketplace acceptance uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.earlier.outputs.accepted == '0' diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index db626c0..7195273 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -55,6 +55,7 @@ runs: test "${#paths[@]}" -gt 0 test "$(printf '%s\n' "${paths[@]}" | LC_ALL=C sort -u | wc -l)" -eq "${#paths[@]}" printf '%s\n' "${paths[@]}" | LC_ALL=C sort >"$RUNNER_TEMP/expected-paths" + echo "Prepared paths: ${#paths[@]} allowed by the writer: ${paths[*]}" - name: 🔍 Verify the prepared commit shell: bash env: @@ -64,12 +65,14 @@ runs: PATHS_FILE: ${{ inputs.paths-file }} run: | set -euo pipefail - if [[ -n "$BUNDLE" ]]; then + if [[ -z "$BUNDLE" ]]; then + echo 'Prepared commit check skipped: no bundle, the change is already merged' + else BUNDLE=$(python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$BUNDLE") - git init --bare "$RUNNER_TEMP/post-release.git" >/dev/null - git -C "$RUNNER_TEMP/post-release.git" bundle verify "$BUNDLE" >/dev/null + git init --bare "$RUNNER_TEMP/post-release.git" + git -C "$RUNNER_TEMP/post-release.git" bundle verify "$BUNDLE" git -C "$RUNNER_TEMP/post-release.git" fetch "$BUNDLE" \ - refs/heads/release-candidate:refs/heads/release-candidate >/dev/null + refs/heads/release-candidate:refs/heads/release-candidate test "$(git -C "$RUNNER_TEMP/post-release.git" rev-parse refs/heads/release-candidate)" = "$CANDIDATE_COMMIT" read -r commit parent extra < <( git -C "$RUNNER_TEMP/post-release.git" rev-list --parents -n 1 "$CANDIDATE_COMMIT" @@ -88,6 +91,7 @@ runs: test "$object" = blob test "$actual" = "$blob" done <"$PATHS_FILE" + echo "Prepared commit $CANDIDATE_COMMIT: one parent $BASE_COMMIT, changes only the prepared paths with the expected blobs" fi - name: 🔀 Inspect the existing pull request shell: bash @@ -111,12 +115,16 @@ runs: (.state == "open" or .merged_at != null) )] end ' "$RUNNER_TEMP/post-release-all-prs.json" >"$RUNNER_TEMP/post-release-prs.json" + jq -r --arg branch "$BRANCH" '"Pull requests from \($branch): \(length) open or merged with the expected title, body and base\( + if length > 0 then " (#" + (map(.number | tostring) | join(", #")) + ")" else "" end)"' \ + "$RUNNER_TEMP/post-release-prs.json" - name: ⚙️ Record an already merged pull request shell: bash run: | set -euo pipefail if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then jq -r '.[0].number' "$RUNNER_TEMP/post-release-prs.json" >"$RUNNER_TEMP/post-release-number" + echo "Pull request #$(<"$RUNNER_TEMP/post-release-number") is already merged, skipping the branch push and pull request creation" fi - name: ⬆️ Push a missing prepared branch shell: bash @@ -124,12 +132,16 @@ runs: BRANCH: ${{ inputs.branch }} run: | set -euo pipefail - if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") != true ]] && - ! gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" >/dev/null 2>"$RUNNER_TEMP/branch-error"; then + if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then + echo 'Branch push skipped: the pull request is already merged' + elif gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" >/dev/null 2>"$RUNNER_TEMP/branch-error"; then + echo "Branch push skipped: $BRANCH already exists" + else if ! grep -Fq '(HTTP 404)' "$RUNNER_TEMP/branch-error"; then cat "$RUNNER_TEMP/branch-error" >&2 exit 1 fi + echo "Branch $BRANCH is missing, pushing the prepared commit" gh auth setup-git --hostname "${GH_HOST:-github.com}" --force git -C "$RUNNER_TEMP/post-release.git" remote add origin \ "https://${GH_HOST:-github.com}/$GITHUB_REPOSITORY.git" @@ -150,6 +162,9 @@ runs: gh api -X POST "repos/$GITHUB_REPOSITORY/pulls" -f "head=$BRANCH" -f "base=$BASE" \ -f "title=$TITLE" -F "body=@$BODY_FILE" >"$RUNNER_TEMP/post-release-created.json" pr_number=$(jq -r .number "$RUNNER_TEMP/post-release-created.json") + echo "Created pull request #$pr_number from $BRANCH to $BASE" + else + echo "Pull request creation skipped: #$pr_number is already open with the expected title, body and base" fi printf '%s\n' "$pr_number" >"$RUNNER_TEMP/post-release-number" fi @@ -159,6 +174,10 @@ runs: set -euo pipefail pr_number=$(cat "$RUNNER_TEMP/post-release-number") labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" --jq '.[].name') - if ! grep -Fxq 'skip news' <<<"$labels"; then - gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" -f 'labels[]=skip news' >/dev/null + if grep -Fxq 'skip news' <<<"$labels"; then + echo "Pull request #$pr_number already has the skip news label" + else + labels=$(gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" -f 'labels[]=skip news' \ + --jq 'map(.name) | join(", ")') + echo "Added the skip news label to pull request #$pr_number, labels now: $labels" fi diff --git a/read-towncrier-config/action.yml b/read-towncrier-config/action.yml index 0296bda..9c88398 100644 --- a/read-towncrier-config/action.yml +++ b/read-towncrier-config/action.yml @@ -20,8 +20,10 @@ runs: set -euo pipefail config_dir=$(mktemp -d "$RUNNER_TEMP/towncrier-config.XXXXXX") if git cat-file -e HEAD:towncrier.toml 2>/dev/null; then + echo 'Towncrier configuration: towncrier.toml at HEAD' git show HEAD:towncrier.toml >"$config_dir/config.toml" else + echo 'Towncrier configuration: pyproject.toml at HEAD (no towncrier.toml)' git show HEAD:pyproject.toml >"$config_dir/config.toml" fi python3 - "$config_dir/config.toml" "$GITHUB_OUTPUT" <<'PY' @@ -34,4 +36,5 @@ runs: format_name = "rst" if changelog.endswith(".rst") else "markdown" with Path(sys.argv[2]).open("a", encoding="utf-8") as output: output.write(f"changelog={changelog}\nfragments={config['directory']}\nformat={format_name}\n") + print(f"Changelog: {changelog} ({format_name}), fragments: {config['directory']}") PY diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index c159a93..69059f5 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -45,4 +45,5 @@ runs: while IFS= read -r path; do uv run --no-project --python "$RUNNER_TEMP/pypi-attestations" pypi-attestations verify pypi \ --repository "https://github.com/$REPOSITORY" "$path" + echo "PyPI: ${path##*/} digest matches and is attested by $REPOSITORY" done <"$RUNNER_TEMP/pypi-local-files" diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index ac4bc1e..a0e7480 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -57,6 +57,9 @@ runs: fi # A newer Nab rewrites nab-version, so checking with it would fail every committed lock. requirements=("nab==$version") + echo "Nab: nab==$version (check mode uses the version recorded in pylock.toml)" + else + echo "Nab: ${requirements[0]} (refresh mode takes the newest release under the cutoff)" fi "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-tools" "${requirements[@]}" - name: 🔒 Resolve all project groups and extras @@ -67,8 +70,14 @@ runs: set -euo pipefail unset SSL_CERT_FILE SSL_CERT_DIR case "$MODE" in - refresh) refresh=(--upgrade) ;; - *) refresh=() ;; + refresh) + refresh=(--upgrade) + echo 'Locking all groups and extras into pylock.toml (refresh mode: upgrade every pin)' + ;; + *) + refresh=() + echo 'Locking all groups and extras into pylock.toml (check mode: keep the committed pins)' + ;; esac cp pylock.toml "$RUNNER_TEMP/lock-before.toml" uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --all-groups --all-extras --no-cache "${refresh[@]}" @@ -109,12 +118,21 @@ runs: selected_groups+=("${words[@]}") done <<<"$ADDITIONAL_GROUPS" case "$ADDITIONAL_SELECTION" in - project) selection=() ;; + project) + selection=() + description='project dependencies (additional-lock-selection is project)' + ;; *) selection=(--build-requirements) - if ((${#selected_groups[@]})); then selection=(--groups "${selected_groups[@]}"); fi + description='build requirements (additional-lock-groups is empty)' + if ((${#selected_groups[@]})); then + selection=(--groups "${selected_groups[@]}") + description="groups ${selected_groups[*]} (from additional-lock-groups)" + fi ;; esac + echo "Secondary lock $ADDITIONAL_LOCK: $description for Python $MATRIX_PYTHON on ${platforms[*]}" \ + "with ${implementations[*]}, ${MODE} mode" cp "$ADDITIONAL_LOCK" "$RUNNER_TEMP/additional-lock-before.toml" uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --no-cache pyproject.toml "${selection[@]}" \ --project-matrix-python "$MATRIX_PYTHON" \ @@ -134,13 +152,21 @@ runs: run: | set -euo pipefail paths=(pylock.toml) - if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi + if [[ -n "$ADDITIONAL_LOCK" ]]; then + paths+=("$ADDITIONAL_LOCK") + else + echo 'Secondary lock skipped: additional-lock input is empty' + fi if [[ "$MODE" == check ]]; then git diff --exit-code -- "${paths[@]}" + echo "Locks match the checkout: ${paths[*]}" echo 'changed=false' >>"$GITHUB_OUTPUT" elif git diff --quiet -- "${paths[@]}"; then + echo "Lock patch skipped: ${paths[*]} unchanged after the refresh" echo 'changed=false' >>"$GITHUB_OUTPUT" else echo 'changed=true' >>"$GITHUB_OUTPUT" git diff --binary -- "${paths[@]}" >"$RUNNER_TEMP/lock.patch" + echo "Lock patch -> $RUNNER_TEMP/lock.patch:" + git diff --stat -- "${paths[@]}" fi diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index 1aa4a9b..2695ae6 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -33,8 +33,10 @@ runs: SELECTED_PREVIOUS: ${{ steps.selected.outputs.previous }} run: | if [[ "$INPUT_PREVIOUS" == auto ]]; then + echo "Previous tag for the notes: ${SELECTED_PREVIOUS:-none} (calculated, previous input is auto)" echo "previous=$SELECTED_PREVIOUS" >>"$GITHUB_OUTPUT" else + echo "Previous tag for the notes: ${INPUT_PREVIOUS:-none} (from the previous input)" echo "previous=$INPUT_PREVIOUS" >>"$GITHUB_OUTPUT" fi - name: 📝 Render GitHub Release notes @@ -53,5 +55,11 @@ runs: fi if [[ -n "$ARTIFACT_URL" ]]; then args+=(--artifact-url "$ARTIFACT_URL" --artifact-label "$ARTIFACT_LABEL") + echo "Artifact link: $ARTIFACT_LABEL -> $ARTIFACT_URL" + else + echo 'Artifact link skipped: artifact-url input is empty' fi release-creator notes "${args[@]}" >"$RUNNER_TEMP/release-notes.md" + entries=$(grep -c '^- ' "$RUNNER_TEMP/release-notes.md" || true) + categories=$(grep -c '^### ' "$RUNNER_TEMP/release-notes.md" || true) + echo "Release notes for $TAG: $entries entries in $categories categories from $CHANGELOG -> $RUNNER_TEMP/release-notes.md" diff --git a/select-previous-tag/action.yml b/select-previous-tag/action.yml index e4ecb78..a855e06 100644 --- a/select-previous-tag/action.yml +++ b/select-previous-tag/action.yml @@ -21,4 +21,9 @@ runs: args=() if [[ "$TAG" == */* ]]; then args+=(--package "${TAG%%/*}"); fi previous=$(git tag --list | release-creator tags "$TAG" --previous "${args[@]}") + if [[ -n "$previous" ]]; then + echo "Previous release tag: $previous (newest release tag before $TAG${args[*]:+ for package ${TAG%%/*}})" + else + echo "Previous release tag: none ($TAG is the first release${args[*]:+ for package ${TAG%%/*}})" + fi printf 'previous=%s\n' "$previous" >>"$GITHUB_OUTPUT" diff --git a/sign-jetbrains-plugin/action.yml b/sign-jetbrains-plugin/action.yml index cd7a04f..0a16d1a 100644 --- a/sign-jetbrains-plugin/action.yml +++ b/sign-jetbrains-plugin/action.yml @@ -36,13 +36,15 @@ runs: set -euo pipefail test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" + echo "Unsigned ZIP ${ARCHIVE##*/}: SHA256 matches the build, no duplicate entries" echo "archive=${ARCHIVE%.zip}-signed.zip" >>"$GITHUB_OUTPUT" - name: 📦 Download pinned Marketplace signer shell: bash env: SIGNER_BASE_URL: https://repo.maven.apache.org/maven2/org/jetbrains/marketplace-zip-signer/0.1.43 run: | - curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + echo "Downloading marketplace-zip-signer 0.1.43 from $SIGNER_BASE_URL" + curl --fail --location --proto '=https' --tlsv1.2 \ --output "$RUNNER_TEMP/marketplace-signer-cli.jar" \ "$SIGNER_BASE_URL/marketplace-zip-signer-0.1.43-cli.jar" - name: 🔍 Verify Marketplace signer digest @@ -63,20 +65,27 @@ runs: umask 077 trap 'rm -f "$RUNNER_TEMP"/signing-{key,key-decrypted,chain}.pem' EXIT if [[ "$CERTIFICATE_CHAIN" == *'-----BEGIN CERTIFICATE-----'* ]]; then + echo 'Certificate chain: PEM input' printf '%s' "$CERTIFICATE_CHAIN" >"$RUNNER_TEMP/signing-chain.pem" else + echo 'Certificate chain: base64 input, decoding' printf '%s' "$CERTIFICATE_CHAIN" | base64 --decode >"$RUNNER_TEMP/signing-chain.pem" fi if [[ "$PRIVATE_KEY" == *'-----BEGIN '* ]]; then + echo 'Private key: PEM input' printf '%s' "$PRIVATE_KEY" >"$RUNNER_TEMP/signing-key.pem" else + echo 'Private key: base64 input, decoding' printf '%s' "$PRIVATE_KEY" | base64 --decode >"$RUNNER_TEMP/signing-key.pem" fi while IFS= read -r line; do [[ -z "$line" ]] || echo "::add-mask::$line" done <"$RUNNER_TEMP/signing-key.pem" signing_key="$RUNNER_TEMP/signing-key.pem" - if [[ -n "$PRIVATE_KEY_PASSWORD" ]]; then + if [[ -z "$PRIVATE_KEY_PASSWORD" ]]; then + echo 'Private key password: none, using the key as given' + else + echo 'Private key password: set, decrypting the key' openssl pkey -in "$signing_key" -out "$RUNNER_TEMP/signing-key-decrypted.pem" \ -passin env:PRIVATE_KEY_PASSWORD while IFS= read -r line; do @@ -84,6 +93,7 @@ runs: done <"$RUNNER_TEMP/signing-key-decrypted.pem" signing_key="$RUNNER_TEMP/signing-key-decrypted.pem" fi + echo "Signing ${ARCHIVE##*/} -> ${SIGNED##*/}" "$JAVA_HOME_17_X64/bin/java" -jar "$RUNNER_TEMP/marketplace-signer-cli.jar" sign \ -in "$ARCHIVE" -out "$SIGNED" -cert-file "$RUNNER_TEMP/signing-chain.pem" \ -key-file "$signing_key" @@ -97,3 +107,4 @@ runs: read -r digest _ < <(sha256sum "$SIGNED") echo "archive=$SIGNED" >>"$GITHUB_OUTPUT" echo "sha256=$digest" >>"$GITHUB_OUTPUT" + echo "Signed ZIP ${SIGNED##*/}: SHA256 $digest" diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index b4d598e..0ea3f60 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import subprocess import sys import tomllib @@ -25,6 +26,29 @@ def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path, action_s assert output == next(tmp_path.glob("build-constraints-*.txt")) +@pytest.mark.parametrize( + ("remove_build", "explanation"), + [ + pytest.param( + False, r"Build constraints: \d+ packages from pylock.toml group build for this runner -> ", id="build" + ), + pytest.param(True, r"Build constraints: 0 packages from pylock.toml \(no build group among ", id="no-build"), + ], +) +def test_export_build_constraints_explains_selection( + tmp_path: Path, action_scripts: Path, remove_build: bool, explanation: str +) -> None: + (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) + if remove_build: + document["dependency-groups"].remove("build") + (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) + + result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", version="") + + assert re.search(explanation, result.stderr) + + def test_export_build_constraints_accepts_tool_only_project_without_build_group( tmp_path: Path, action_scripts: Path ) -> None: diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index fc6b2f6..4518224 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -73,6 +73,23 @@ def test_install_locked_tools_reuses_existing_environment(tmp_path: Path, action assert (tmp_path / "lock-tools" / ("Scripts/nab.exe" if os.name == "nt" else "bin/nab")).is_file() +def test_install_locked_tools_explains_reused_constraints(tmp_path: Path, action_scripts: Path, caller: Path) -> None: + environment = {"GROUPS": "lock-tools", "VENV": str(tmp_path / "lock-tools")} + action(caller, tmp_path, action_scripts, "install-locked-tools", version="", **environment) + + second = action(caller, tmp_path, action_scripts, "install-locked-tools", version="", **environment) + + assert "Reusing build constraints from an earlier step (same lock and targets)" in second.stdout + + +def test_install_locked_tools_explains_selected_groups(tmp_path: Path, action_scripts: Path, caller: Path) -> None: + environment = {"GROUPS": "lock-tools", "VENV": str(tmp_path / "lock-tools")} + + result = action(caller, tmp_path, action_scripts, "install-locked-tools", version="", **environment) + + assert "Selected lock groups: lock-tools (from the groups input)" in result.stdout + + def test_install_locked_tools_keeps_reader_after_nab_sync(tmp_path: Path, action_scripts: Path, caller: Path) -> None: first = install(caller, action_scripts, tmp_path, groups="lock-tools", venv=tmp_path / "lock-tools") second = install(caller, action_scripts, tmp_path, groups="release-news", venv=tmp_path / "news-tools") diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 59f48df..aa3a829 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -705,3 +705,30 @@ def test_duplicate_releases_use_the_first( result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) assert result.returncode == 0, result.stderr assert [request.method for request, _ in server.log] == ["GET", "GET", "PATCH"] + + +def test_missing_release_explains_created_draft( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + draft = _release(notes, draft=True) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) + server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="stage") + + assert result.stdout.splitlines() == [ + "Release assets: none (assets-directory input is empty)", + "Existing GitHub Release 0.1.0: none", + f"GitHub Release 0.1.0: created draft 42 for {commit} (prerelease false)", + "Asset upload skipped: assets-directory input is empty", + "GitHub Release 0.1.0: 0 uploaded assets match the expected set", + "Publish skipped: mode stage leaves the release as a draft", + ] diff --git a/tests/select_previous_tag/test_select.py b/tests/select_previous_tag/test_select.py index a9e1e1b..c0a3045 100644 --- a/tests/select_previous_tag/test_select.py +++ b/tests/select_previous_tag/test_select.py @@ -1,5 +1,6 @@ from pathlib import Path +import pytest from conftest import action, run @@ -45,3 +46,21 @@ def test_select_previous_tag_scopes_namespaced_tags(repository: Path, tmp_path: assert result.returncode == 0, result.stderr assert (tmp_path / "output").read_text() == "previous=pyproject-fmt/1.1.0\n" + + +@pytest.mark.parametrize( + ("tag", "explanation"), + [ + pytest.param("1.0.0", "Previous release tag: 0.10.0 (newest release tag before 1.0.0)", id="previous"), + pytest.param("0.1.0", "Previous release tag: none (0.1.0 is the first release)", id="first"), + ], +) +def test_select_previous_tag_explains_choice( + repository: Path, tmp_path: Path, action_scripts: Path, tag: str, explanation: str +) -> None: + for existing in ("0.2.0", "0.10.0", "1.0.0"): + run(repository, "git", "tag", existing) + + result = action(repository, tmp_path, action_scripts, "select-previous-tag", version=tag, TAG=tag) + + assert explanation in result.stdout.splitlines() diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index bbf5e86..854571e 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -397,3 +397,29 @@ def test_validate_news_checks_towncrier_config_only_when_touched( assert (result.returncode == 0) is accepted, result.stderr assert ("set an explicit Towncrier fragment directory" in result.stderr) is not accepted + + +@pytest.mark.parametrize( + ("bot", "skip", "explanation"), + [ + pytest.param("true", "false", "News requirement: waived for a bot author", id="bot"), + pytest.param("false", "true", "News requirement: waived by the skip news label", id="skip-news"), + pytest.param("true", "true", "Fragment check skipped: nothing changed in changelog.d", id="fragments"), + pytest.param("true", "true", "Lock check skipped: no lock inputs changed", id="lock"), + ], +) +def test_validate_news_explains_choices( + repository: Path, tmp_path: Path, action_scripts: Path, bot: str, skip: str, explanation: str +) -> None: + result = action( + repository, + tmp_path, + action_scripts, + "validate-news", + version="0.2.0", + BASE_REF="main", + BOT_AUTHOR=bot, + SKIP_NEWS=skip, + ) + + assert explanation in result.stdout diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index 367eb0c..fc7e2b7 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -281,6 +281,21 @@ def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: M assert response.call_count == 2 +@pytest.mark.usefixtures("waiting_clock") +def test_verify_files_explains_wait_and_match( + distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + files = _listing(distribution) + mocker.patch.object(_MODULE, "urlopen", side_effect=[_body(files[:-1]), _body(files)]) + + verify_files("fixture", "1.0", distribution) + + assert capsys.readouterr().err.splitlines() == [ + "PyPI: 1 of 2 files not listed yet (fixture-1.0.tar.gz), checking again in 1s", + "PyPI: 2 files match the local SHA256 digests: fixture-1.0-py3-none-any.whl, fixture-1.0.tar.gz", + ] + + @pytest.mark.parametrize( "failure", [ diff --git a/tools/build_constraints.py b/tools/build_constraints.py index 3b0f858..ab8bb45 100644 --- a/tools/build_constraints.py +++ b/tools/build_constraints.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import sys import tomllib from pathlib import Path @@ -24,7 +25,8 @@ def main() -> None: with args.lock.open("rb") as stream: lock = Pylock.from_dict(tomllib.load(stream)) targets = _target_environments(args.target_platform, args.targets) if args.targets else [None] - selected_by_target = [_selected_build(lock, environment) for environment in targets] + groups, source = _build_groups(lock) + selected_by_target = [_selected_build(lock, groups, environment) for environment in targets] selected = selected_by_target[0] for other in selected_by_target[1:]: for name, (_, hashes) in other.items(): @@ -35,6 +37,11 @@ def main() -> None: for name, (version, hashes) in sorted(selected.items()) ), ) + target = f"{args.targets} on {args.target_platform}" if args.targets else "this runner" + print( + f"Build constraints: {len(selected)} packages from {args.lock} {source} for {target} -> {args.output}", + file=sys.stderr, + ) except ( OSError, ValueError, @@ -77,13 +84,19 @@ def _target_environments(platform: str, values: str) -> list[Environment]: return environments -def _selected_build(lock: Pylock, environment: Environment | None) -> dict[str, tuple[Version, set[str]]]: +def _build_groups(lock: Pylock) -> tuple[set[str] | None, str]: if "build" in (lock.dependency_groups or ()): - groups = {"build"} - elif lock.dependency_groups: + return {"build"}, "group build" + if lock.dependency_groups: + return None, f"(no build group among {', '.join(lock.dependency_groups)})" + return set(), "(no dependency groups, every locked package)" + + +def _selected_build( + lock: Pylock, groups: set[str] | None, environment: Environment | None +) -> dict[str, tuple[Version, set[str]]]: + if groups is None: return {} - else: - groups = set() # Artifact choice is left to the installer; constraints carry every locked hash. tags: list[Tag] | None = None if environment is not None: diff --git a/tools/check_nab_lock.py b/tools/check_nab_lock.py index 28e7931..b496fd5 100644 --- a/tools/check_nab_lock.py +++ b/tools/check_nab_lock.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import sys import tomllib from datetime import UTC, datetime from pathlib import Path @@ -25,6 +26,10 @@ def main() -> None: if old != new: parser.error("regenerated Nab lock differs from the committed lock") args.regenerated.write_bytes(before) + print( + f"Lock check: regenerated {args.regenerated} matches the committed lock (command line ignored)", + file=sys.stderr, + ) except (OSError, UnicodeDecodeError, KeyError, tomllib.TOMLDecodeError) as error: parser.error(str(error)) diff --git a/tools/cooled_prek.sh b/tools/cooled_prek.sh index d7b9d9d..961791b 100755 --- a/tools/cooled_prek.sh +++ b/tools/cooled_prek.sh @@ -5,4 +5,5 @@ unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR export UV_EXCLUDE_NEWER="7 days" UV_NO_CONFIG=1 UV_NO_SOURCES=1 +echo "Running prek $* with hook environments built under the 7 days cutoff, repository uv settings ignored" exec prek --config .pre-commit-config.yaml "$@" diff --git a/tools/expose_locked_commands.py b/tools/expose_locked_commands.py index e78f6cb..43c997b 100644 --- a/tools/expose_locked_commands.py +++ b/tools/expose_locked_commands.py @@ -10,6 +10,7 @@ def main() -> None: overlay: Final[Path] = Path(tempfile.mkdtemp(prefix="locked-cli-", dir=os.environ["RUNNER_TEMP"])) + exposed: list[str] = [] for command in (Path(sys.argv[1]).resolve() / ("Scripts" if os.name == "nt" else "bin")).iterdir(): name = command.stem.casefold() if os.name == "nt" else command.name.casefold() if ( @@ -23,8 +24,13 @@ def main() -> None: shutil.copy2(command, overlay / command.name) else: os.symlink(command, overlay / command.name) + exposed.append(command.name) with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as stream: print(overlay, file=stream) + print( + f"Exposed {len(exposed)} commands from {sys.argv[1]} on PATH: {', '.join(sorted(exposed)) or 'none'}", + file=sys.stderr, + ) if __name__ == "__main__": diff --git a/tools/install_live_tools.sh b/tools/install_live_tools.sh index c6597c7..ebbdecf 100755 --- a/tools/install_live_tools.sh +++ b/tools/install_live_tools.sh @@ -9,9 +9,14 @@ unset UV_FIND_LINKS UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR export UV_NO_CONFIG=1 options=(--exclude-newer "$EXCLUDE_NEWER") +overrides=() while IFS= read -r line; do read -r line <<<"$line" - if [[ -n "$line" ]]; then options+=(--exclude-newer-package "$line"); fi + if [[ -n "$line" ]]; then + options+=(--exclude-newer-package "$line") + overrides+=("$line") + fi done <<<"${EXCLUDE_NEWER_PACKAGE:-}" +echo "Installing $* into $venv from PyPI (cutoff $EXCLUDE_NEWER${overrides[*]:+, overrides: ${overrides[*]}})" uv venv --allow-existing --python 3.15 "$venv" uv pip install --python "$venv" "${options[@]}" "$@" diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index 37c743e..6cc8c22 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -6,6 +6,7 @@ import hashlib import json import ssl +import sys import time from http.client import IncompleteRead from pathlib import Path @@ -61,10 +62,12 @@ def verify_files(project: str, version: str, dist: Path) -> list[Path]: raise if deadline - time.monotonic() <= 0: raise + print(f"PyPI: listing for {name} failed with HTTP {error.code}", file=sys.stderr) files = [] - except TimeoutError, ConnectionError, URLError, IncompleteRead, ssl.SSLError, json.JSONDecodeError: + except (TimeoutError, ConnectionError, URLError, IncompleteRead, ssl.SSLError, json.JSONDecodeError) as error: if deadline - time.monotonic() <= 0: raise + print(f"PyPI: listing for {name} failed ({type(error).__name__})", file=sys.stderr) files = [] for file in files: if (identity := _identity(file["filename"])) is None: @@ -95,10 +98,19 @@ def verify_files(project: str, version: str, dist: Path) -> list[Path]: if changed := {filename for filename in remote if remote[filename] != local[filename]}: raise ValueError(f"PyPI has different SHA256 digests: {', '.join(sorted(changed))}") if not (missing := local.keys() - remote.keys()): + print( + f"PyPI: {len(local)} files match the local SHA256 digests: {', '.join(sorted(local))}", file=sys.stderr + ) return [dist / filename for filename in sorted(local)] if (remaining := deadline - time.monotonic()) <= 0: raise ValueError(f"PyPI is missing files: {', '.join(sorted(missing))}") - time.sleep(min(delay, remaining)) + wait = min(delay, remaining) + print( + f"PyPI: {len(missing)} of {len(local)} files not listed yet ({', '.join(sorted(missing))}), " + f"checking again in {wait:g}s", + file=sys.stderr, + ) + time.sleep(wait) delay = min(delay * 2, 30) diff --git a/validate-news/action.yml b/validate-news/action.yml index 2c02c11..ab9d9ea 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -58,23 +58,35 @@ runs: names = ("towncrier.toml", "pyproject.toml") config = any(towncrier(base, name) != towncrier("HEAD", name) for name in names) current = next((table for name in names if isinstance(table := towncrier("HEAD", name), dict)), {}) - fragments = "directory" in current and bool( + changed = ( subprocess.run( ["git", "diff", "--name-only", base, "HEAD", "--", current["directory"]], capture_output=True, text=True, check=True, - ).stdout + ).stdout.splitlines() + if "directory" in current + else [] ) + fragments = bool(changed) with Path(output).open("a", encoding="utf-8") as stream: stream.write(f"fragments={str(fragments).lower()}\nconfig={str(config).lower()}\n") + directory = current.get("directory", "the fragment directory (not configured)") + if config: + print(f"Fragment check runs: the Towncrier configuration changed since {base[:12]}") + elif fragments: + print(f"Fragment check runs: {len(changed)} files changed in {directory}") + else: + print(f"Fragment check skipped: nothing changed in {directory} and the Towncrier configuration is unchanged") PY - name: 🔍 Count changed fragments if: steps.news-change.outputs.fragments == 'true' || steps.news-change.outputs.config == 'true' shell: bash env: BASE_REF: ${{ github.base_ref }} - run: release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" + run: | + release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" + echo "Changed fragments: $(<"$RUNNER_TEMP/news-fragment-count") valid against origin/$BASE_REF" - name: 🔍 Require news for human changes shell: bash env: @@ -83,7 +95,13 @@ runs: run: | fragments=0 if [[ -f "$RUNNER_TEMP/news-fragment-count" ]]; then read -r fragments <"$RUNNER_TEMP/news-fragment-count"; fi - if ((! fragments)) && [[ "$SKIP_NEWS" != true && "$BOT_AUTHOR" != true ]]; then + if ((fragments)); then + echo "News requirement: met by $fragments fragments" + elif [[ "$SKIP_NEWS" == true ]]; then + echo 'News requirement: waived by the skip news label' + elif [[ "$BOT_AUTHOR" == true ]]; then + echo 'News requirement: waived for a bot author' + else echo '::error::add a changelog fragment or apply the skip news label' >&2 exit 1 fi @@ -97,6 +115,7 @@ runs: changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- \ ':(glob)**/pyproject.toml' pylock.toml ':(glob)pylock.*.toml' nab.toml) if [[ -z "$changed" ]]; then + echo 'Lock check skipped: no lock inputs changed (pyproject.toml, pylock.toml, pylock.*.toml, nab.toml untouched)' echo 'changed=false' >>"$GITHUB_OUTPUT" exit 0 fi @@ -109,9 +128,11 @@ runs: echo '::error::the release lock was removed' >&2 exit 1 fi + echo 'Lock check skipped: no pylock.toml, and either the base had none or nothing configures Nab' echo 'changed=false' >>"$GITHUB_OUTPUT" exit 0 fi + echo "Lock check runs: changed ${changed//$'\n'/ }" echo 'changed=true' >>"$GITHUB_OUTPUT" - name: 🔒 Check the release lock against the project if: steps.lock-change.outputs.changed == 'true' diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 34f33ba..489d54e 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -5,7 +5,7 @@ runs: steps: - name: ⚙️ Apply release patch shell: bash - run: git apply --index "$RUNNER_TEMP/release-patch/release.patch" + run: git apply --index --verbose "$RUNNER_TEMP/release-patch/release.patch" - name: ⚙️ Read Towncrier configuration id: config uses: $/read-towncrier-config @@ -17,8 +17,10 @@ runs: run: | set -euo pipefail git diff --cached --name-status --no-renames -z >"$RUNNER_TEMP/release-paths" + removed=0 while IFS= read -r -d '' status && IFS= read -r -d '' path; do if [[ "$path" == "$CHANGELOG" && ("$status" == A || "$status" == M) ]]; then + echo "Release patch path: $status $path (the changelog)" continue fi if [[ "$path" != "$FRAGMENTS/"* ]]; then @@ -29,12 +31,15 @@ runs: echo "::error::unexpected release path status: $status $path" >&2 exit 1 fi + removed=$((removed + 1)) done <"$RUNNER_TEMP/release-paths" + echo "Release patch paths: $removed fragments removed from $FRAGMENTS" - name: 🔍 Check release patch file modes shell: bash run: | set -euo pipefail git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes" + count=0 while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do read -ra fields <<<"$metadata" case "${fields[4]}" in @@ -63,4 +68,6 @@ runs: echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2 exit 1 fi + count=$((count + 1)) done <"$RUNNER_TEMP/release-modes" + echo "Release patch file modes: $count paths, each a non-executable regular file or a deletion" From 1c36c7d4f170f4a19f3b82d19acd8836fda4bf28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 10:36:19 -0700 Subject: [PATCH 20/23] =?UTF-8?q?=F0=9F=94=8A=20feat:=20name=20the=20check?= =?UTF-8?q?=20behind=20every=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each bare test, cmp, [[ ]] || exit 1 and exit 1 failure in the Actions now prints an ::error:: line naming the check and the values it compared, including every Marketplace receipt field and post-release path manifest check, which the tests now assert. Counts are pluralized through a shared tools/plural.sh, curl calls use --no-progress-meter, and build_constraints.py no longer truth-tests an optional group list. --- .github/workflows/check.yaml | 4 +- publish-github-release/action.yml | 8 +- publish-jetbrains-marketplace/action.yml | 71 ++++++++++++---- publish-post-release-pr/action.yml | 80 ++++++++++++++----- refresh-release-lock/action.yml | 5 +- render-release-notes/action.yml | 4 +- sign-jetbrains-plugin/action.yml | 20 ++++- .../test_marketplace_upload.py | 49 +++++++----- .../test_post_release_pr.py | 57 +++++++++++-- tools/build_constraints.py | 9 ++- tools/expose_locked_commands.py | 2 +- tools/plural.sh | 9 +++ tools/verify_pypi_files.py | 9 ++- validate-news/action.yml | 10 ++- validate-release-patch/action.yml | 8 +- 15 files changed, 261 insertions(+), 84 deletions(-) create mode 100644 tools/plural.sh diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 6c035af..202bdbc 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -87,7 +87,9 @@ jobs: done <"$RUNNER_TEMP/run-blocks" done scripts=("$script_dir"/*.sh) - echo "Checking ${#scripts[@]} run blocks from the workflows and Actions with shellcheck and shfmt" + # shellcheck source=tools/plural.sh + source tools/plural.sh + echo "Checking $(plural "${#scripts[@]}" "run block") from the workflows and Actions with shellcheck and shfmt" tools/cooled_prek.sh run shellcheck --files "$script_dir"/*.sh tools/cooled_prek.sh run shfmt --files "$script_dir"/*.sh test: diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index ea25ae2..ec7428c 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -42,7 +42,7 @@ runs: done jq -R -s '[split("\n")[] | select(length > 0) | split("\t") | {name: .[0], digest: .[1]}]' \ "$RUNNER_TEMP/release-assets.tsv" >"$RUNNER_TEMP/release-assets.json" - jq -r --arg directory "$ASSETS_DIRECTORY" '"Release assets: \(length) files from \($directory)\( + jq -r --arg directory "$ASSETS_DIRECTORY" '"Release assets: \(length) file\(if length == 1 then "" else "s" end) from \($directory)\( if length > 0 then ": " + ([.[].name] | join(", ")) else "" end)"' "$RUNNER_TEMP/release-assets.json" else echo 'Release assets: none (assets-directory input is empty)' @@ -118,6 +118,8 @@ runs: set -euo pipefail # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. source "$GITHUB_ACTION_PATH/retry.sh" + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/../tools/plural.sh" if [[ "$MODE" == publish ]]; then echo 'Asset upload skipped: mode publish only publishes the staged draft' elif [[ -z "$ASSETS_DIRECTORY" ]]; then @@ -187,7 +189,7 @@ runs: uploaded=$((uploaded + 1)) fi done - echo "GitHub Release $TAG: uploaded $uploaded assets, $present already present" + echo "GitHub Release $TAG: uploaded $(plural "$uploaded" asset), $present already present" fi fi - name: 🔍 Verify the complete asset set @@ -209,7 +211,7 @@ runs: echo '::error::release assets did not match after publication' >&2 exit 1 fi - jq -r '.[0] | "GitHub Release \(.tag_name): \(.assets | length) uploaded assets match the expected set"' \ + jq -r '.[0] | "GitHub Release \(.tag_name): \(.assets | length) uploaded asset\(if (.assets | length) == 1 then "" else "s" end) match the expected set"' \ "$RUNNER_TEMP/release-complete.json" - name: 🚀 Publish the draft if: steps.inspect.outputs.immutable != 'true' diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index 4b6e3c5..ab6ea07 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -44,8 +44,16 @@ runs: EXPECTED_SHA: ${{ inputs.sha256 }} run: | set -euo pipefail - test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" - test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" + read -r actual _ < <(sha256sum "$ARCHIVE") + if [[ "$actual" != "$EXPECTED_SHA" ]]; then + echo "::error::signed ZIP ${ARCHIVE##*/} has SHA256 $actual, the signing job recorded $EXPECTED_SHA" >&2 + exit 1 + fi + duplicates=$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d) + if [[ -n "$duplicates" ]]; then + echo "::error::signed ZIP ${ARCHIVE##*/} has duplicate entries: ${duplicates//$'\n'/, }" >&2 + exit 1 + fi echo "Signed ZIP ${ARCHIVE##*/}: SHA256 matches the signing job, no duplicate entries" - name: 🔍 Find earlier Marketplace upload id: earlier @@ -133,31 +141,60 @@ runs: set -euo pipefail mkdir "$RUNNER_TEMP/prior-signed" "$RUNNER_TEMP/prior-notes" receipt="$RUNNER_TEMP/prior-acceptance/marketplace-acceptance.json" - jq -e --arg repository "$GITHUB_REPOSITORY" --arg tag "$TAG" --arg commit "$COMMIT" \ - --arg version "${TAG#v}" --argjson pluginId "$PLUGIN_ID" \ - --arg channel "$CHANNEL" --arg archive "${ARCHIVE##*/}" --arg sha "$EXPECTED_SHA" \ - --arg notesSha "$(sha256sum "$NOTES_FILE" | cut -d' ' -f1)" \ - '.schema == 2 and .repository == $repository and .tag == $tag and .commit == $commit and - (.sourceRunId | type == "number") and .pluginId == $pluginId and .version == $version and - .channel == $channel and .signedArchive == $archive and .sha256 == $sha and - .notesSha256 == $notesSha and (.signedArtifactId | type == "number") and - (.notesArtifactId | type == "number")' "$receipt" >/dev/null + mismatches=$( + jq -r --arg repository "$GITHUB_REPOSITORY" --arg tag "$TAG" --arg commit "$COMMIT" \ + --arg version "${TAG#v}" --argjson pluginId "$PLUGIN_ID" \ + --arg channel "$CHANNEL" --arg archive "${ARCHIVE##*/}" --arg sha "$EXPECTED_SHA" \ + --arg notesSha "$(sha256sum "$NOTES_FILE" | cut -d' ' -f1)" ' + def same($name; $expected): if .[$name] == $expected then empty + else "\($name) \(.[$name] | tojson) instead of \($expected | tojson)" end; + def number($name): if (.[$name] | type) == "number" then empty + else "\($name) \(.[$name] | tojson) is not a number" end; + [same("schema"; 2), same("repository"; $repository), same("tag"; $tag), same("commit"; $commit), + number("sourceRunId"), same("pluginId"; $pluginId), same("version"; $version), + same("channel"; $channel), same("signedArchive"; $archive), same("sha256"; $sha), + same("notesSha256"; $notesSha), number("signedArtifactId"), number("notesArtifactId")] | join(", ") + ' "$receipt" + ) + if [[ -n "$mismatches" ]]; then + echo "::error::earlier Marketplace receipt does not match this release: $mismatches" >&2 + exit 1 + fi source_run=$(jq -r .sourceRunId "$receipt") echo "Earlier receipt matches $TAG, $COMMIT, plugin $PLUGIN_ID, the archive and the notes; source run $source_run" artifacts="$RUNNER_TEMP/marketplace-artifacts-$source_run.tsv" # Only runs on this tag and commit with a started upload step have their artifacts listed. - [[ -f "$artifacts" ]] || exit 1 + if [[ ! -f "$artifacts" ]]; then + echo "::error::earlier Marketplace receipt names run $source_run, which has no started upload for $TAG" >&2 + exit 1 + fi signed_id=$(jq -r .signedArtifactId "$receipt") notes_id=$(jq -r .notesArtifactId "$receipt") prior_signed_id=$(awk -F '\t' '$1 == "plugin-distribution" && $3 == "false" { print $2 }' "$artifacts") prior_notes_id=$(awk -F '\t' '$1 == "release-notes" && $3 == "false" { print $2 }' "$artifacts") - [[ "$prior_signed_id" == "$signed_id" && "$prior_notes_id" == "$notes_id" ]] || exit 1 + if [[ "$prior_signed_id" != "$signed_id" ]]; then + echo "::error::run $source_run has plugin-distribution artifact ${prior_signed_id:-none}," \ + "the receipt records $signed_id" >&2 + exit 1 + fi + if [[ "$prior_notes_id" != "$notes_id" ]]; then + echo "::error::run $source_run has release-notes artifact ${prior_notes_id:-none}," \ + "the receipt records $notes_id" >&2 + exit 1 + fi gh run download "$source_run" --repo "$GITHUB_REPOSITORY" --name plugin-distribution \ --dir "$RUNNER_TEMP/prior-signed" gh run download "$source_run" --repo "$GITHUB_REPOSITORY" --name release-notes \ --dir "$RUNNER_TEMP/prior-notes" - test "$(sha256sum "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}" | cut -d' ' -f1)" = "$EXPECTED_SHA" - cmp "$NOTES_FILE" "$RUNNER_TEMP/prior-notes/release-notes.md" + read -r prior_sha _ < <(sha256sum "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}") + if [[ "$prior_sha" != "$EXPECTED_SHA" ]]; then + echo "::error::run $source_run uploaded ${ARCHIVE##*/} with SHA256 $prior_sha, this run has $EXPECTED_SHA" >&2 + exit 1 + fi + if ! cmp -s "$NOTES_FILE" "$RUNNER_TEMP/prior-notes/release-notes.md"; then + echo "::error::run $source_run uploaded different release notes than this run's ${NOTES_FILE##*/}" >&2 + exit 1 + fi echo "Earlier upload's signed ZIP and release notes match this run" - name: 🚀 Upload signed ZIP if: steps.earlier.outputs.accepted == '0' @@ -177,15 +214,17 @@ runs: else echo "Uploading ${ARCHIVE##*/} to Marketplace plugin $PLUGIN_ID, channel Stable (channel input is empty)" fi - status=$(curl --fail-with-body --max-time 120 --write-out '%{http_code}' \ + status=$(curl --fail-with-body --max-time 120 --no-progress-meter --write-out '%{http_code}' \ --output "$RUNNER_TEMP/marketplace-response.json" \ --config <(printf 'header = "Authorization: Bearer %s"\n' "$PUBLISH_TOKEN") \ --form "pluginId=$PLUGIN_ID" --form "file=@${upload_file##*/};filename=plugin.zip" --form "channel=$CHANNEL" \ https://plugins.jetbrains.com/api/updates/upload) || { + echo "::error::Marketplace upload failed with HTTP ${status:-none}; response follows" >&2 cat "$RUNNER_TEMP/marketplace-response.json" >&2 exit 1 } if [[ "$status" != 2?? ]]; then + echo "::error::Marketplace upload returned HTTP $status instead of 2xx; response follows" >&2 cat "$RUNNER_TEMP/marketplace-response.json" >&2 exit 1 fi diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index 7195273..1f752cb 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -35,6 +35,8 @@ runs: ALLOWED_PATHS: ${{ inputs.allowed-paths }} run: | set -euo pipefail + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/../tools/plural.sh" trusted_paths=() while IFS= read -r path || [[ -n "$path" ]]; do trusted_paths+=("$path") @@ -43,19 +45,38 @@ runs: paths=() while IFS=$'\t' read -r path blob; do case "$path" in - '' | /* | *[!A-Za-z0-9._/-]* | . | .. | ./* | ../* | */. | */.. | */./* | */../*) exit 1 ;; + '' | /* | *[!A-Za-z0-9._/-]* | . | .. | ./* | ../* | */. | */.. | */./* | */../*) + echo "::error::prepared path '$path' is not a plain relative path" >&2 + exit 1 + ;; esac - grep -Fqx -- "$path" "$RUNNER_TEMP/allowed-paths" - test "${#blob}" -eq 40 + if ! grep -Fqx -- "$path" "$RUNNER_TEMP/allowed-paths"; then + echo "::error::prepared path $path is not in the writer's allowed-paths: ${trusted_paths[*]}" >&2 + exit 1 + fi case "$blob" in - *[!0-9a-f]*) exit 1 ;; + *[!0-9a-f]* | '') + echo "::error::prepared blob for $path is '$blob', not a lowercase hex SHA" >&2 + exit 1 + ;; esac + if ((${#blob} != 40)); then + echo "::error::prepared blob for $path is ${#blob} characters long instead of 40" >&2 + exit 1 + fi paths+=("$path") done <"$PATHS_FILE" - test "${#paths[@]}" -gt 0 - test "$(printf '%s\n' "${paths[@]}" | LC_ALL=C sort -u | wc -l)" -eq "${#paths[@]}" + if ((${#paths[@]} == 0)); then + echo "::error::prepared path manifest ${PATHS_FILE##*/} lists no paths" >&2 + exit 1 + fi + duplicates=$(printf '%s\n' "${paths[@]}" | LC_ALL=C sort | uniq -d) + if [[ -n "$duplicates" ]]; then + echo "::error::prepared path manifest lists paths more than once: ${duplicates//$'\n'/, }" >&2 + exit 1 + fi printf '%s\n' "${paths[@]}" | LC_ALL=C sort >"$RUNNER_TEMP/expected-paths" - echo "Prepared paths: ${#paths[@]} allowed by the writer: ${paths[*]}" + echo "Prepared paths: $(plural "${#paths[@]}" path) allowed by the writer: ${paths[*]}" - name: 🔍 Verify the prepared commit shell: bash env: @@ -73,23 +94,45 @@ runs: git -C "$RUNNER_TEMP/post-release.git" bundle verify "$BUNDLE" git -C "$RUNNER_TEMP/post-release.git" fetch "$BUNDLE" \ refs/heads/release-candidate:refs/heads/release-candidate - test "$(git -C "$RUNNER_TEMP/post-release.git" rev-parse refs/heads/release-candidate)" = "$CANDIDATE_COMMIT" + bundled=$(git -C "$RUNNER_TEMP/post-release.git" rev-parse refs/heads/release-candidate) + if [[ "$bundled" != "$CANDIDATE_COMMIT" ]]; then + echo "::error::bundle release-candidate is $bundled, expected candidate commit $CANDIDATE_COMMIT" >&2 + exit 1 + fi read -r commit parent extra < <( git -C "$RUNNER_TEMP/post-release.git" rev-list --parents -n 1 "$CANDIDATE_COMMIT" ) - test "$commit" = "$CANDIDATE_COMMIT" - test "$parent" = "$BASE_COMMIT" - test -z "${extra:-}" + if [[ "$commit" != "$CANDIDATE_COMMIT" ]]; then + echo "::error::bundle resolves $CANDIDATE_COMMIT to $commit" >&2 + exit 1 + fi + if [[ "${parent:-}" != "$BASE_COMMIT" || -n "${extra:-}" ]]; then + echo "::error::candidate commit $CANDIDATE_COMMIT has parents '${parent:-} ${extra:-}'," \ + "expected only base commit $BASE_COMMIT" >&2 + exit 1 + fi git -C "$RUNNER_TEMP/post-release.git" diff --name-only --no-renames "$BASE_COMMIT" "$CANDIDATE_COMMIT" | LC_ALL=C sort >"$RUNNER_TEMP/candidate-paths" - cmp -s "$RUNNER_TEMP/expected-paths" "$RUNNER_TEMP/candidate-paths" + if ! cmp -s "$RUNNER_TEMP/expected-paths" "$RUNNER_TEMP/candidate-paths"; then + echo "::error::candidate commit changes $(paste -sd ' ' "$RUNNER_TEMP/candidate-paths")," \ + "the manifest lists $(paste -sd ' ' "$RUNNER_TEMP/expected-paths")" >&2 + exit 1 + fi while IFS=$'\t' read -r path blob; do - read -r mode object actual _ < <( + if ! read -r mode object actual _ < <( git -C "$RUNNER_TEMP/post-release.git" ls-tree "$CANDIDATE_COMMIT" -- "$path" - ) - test "$mode" = 100644 - test "$object" = blob - test "$actual" = "$blob" + ); then + echo "::error::candidate commit $CANDIDATE_COMMIT deletes $path, the manifest expects blob $blob" >&2 + exit 1 + fi + if [[ "$mode" != 100644 || "$object" != blob ]]; then + echo "::error::candidate $path is a $object with mode $mode, expected a 100644 blob" >&2 + exit 1 + fi + if [[ "$actual" != "$blob" ]]; then + echo "::error::candidate $path has blob $actual, the manifest expects $blob" >&2 + exit 1 + fi done <"$PATHS_FILE" echo "Prepared commit $CANDIDATE_COMMIT: one parent $BASE_COMMIT, changes only the prepared paths with the expected blobs" fi @@ -115,7 +158,7 @@ runs: (.state == "open" or .merged_at != null) )] end ' "$RUNNER_TEMP/post-release-all-prs.json" >"$RUNNER_TEMP/post-release-prs.json" - jq -r --arg branch "$BRANCH" '"Pull requests from \($branch): \(length) open or merged with the expected title, body and base\( + jq -r --arg branch "$BRANCH" '"Branch \($branch): \(length) pull request\(if length == 1 then "" else "s" end) open or merged with the expected title, body and base\( if length > 0 then " (#" + (map(.number | tostring) | join(", #")) + ")" else "" end)"' \ "$RUNNER_TEMP/post-release-prs.json" - name: ⚙️ Record an already merged pull request @@ -138,6 +181,7 @@ runs: echo "Branch push skipped: $BRANCH already exists" else if ! grep -Fq '(HTTP 404)' "$RUNNER_TEMP/branch-error"; then + echo "::error::could not check whether branch $BRANCH exists; GitHub replied:" >&2 cat "$RUNNER_TEMP/branch-error" >&2 exit 1 fi diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index a0e7480..e7bedfd 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -158,7 +158,10 @@ runs: echo 'Secondary lock skipped: additional-lock input is empty' fi if [[ "$MODE" == check ]]; then - git diff --exit-code -- "${paths[@]}" + if ! git diff --exit-code -- "${paths[@]}"; then + echo "::error::regenerated locks differ from the checkout: ${paths[*]}" >&2 + exit 1 + fi echo "Locks match the checkout: ${paths[*]}" echo 'changed=false' >>"$GITHUB_OUTPUT" elif git diff --quiet -- "${paths[@]}"; then diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index 2695ae6..2f89c4e 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -49,6 +49,8 @@ runs: PREVIOUS: ${{ steps.previous.outputs.previous }} run: | set -euo pipefail + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/../tools/plural.sh" args=(--changelog "$CHANGELOG" --tag "$TAG" --repository "$GITHUB_REPOSITORY") if [[ -n "$PREVIOUS" ]]; then args+=(--previous "$PREVIOUS") @@ -62,4 +64,4 @@ runs: release-creator notes "${args[@]}" >"$RUNNER_TEMP/release-notes.md" entries=$(grep -c '^- ' "$RUNNER_TEMP/release-notes.md" || true) categories=$(grep -c '^### ' "$RUNNER_TEMP/release-notes.md" || true) - echo "Release notes for $TAG: $entries entries in $categories categories from $CHANGELOG -> $RUNNER_TEMP/release-notes.md" + echo "Release notes for $TAG: $(plural "$entries" entry entries) in $(plural "$categories" category categories) from $CHANGELOG -> $RUNNER_TEMP/release-notes.md" diff --git a/sign-jetbrains-plugin/action.yml b/sign-jetbrains-plugin/action.yml index 0a16d1a..9e72d33 100644 --- a/sign-jetbrains-plugin/action.yml +++ b/sign-jetbrains-plugin/action.yml @@ -34,8 +34,16 @@ runs: EXPECTED_SHA: ${{ inputs.sha256 }} run: | set -euo pipefail - test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" - test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" + read -r actual _ < <(sha256sum "$ARCHIVE") + if [[ "$actual" != "$EXPECTED_SHA" ]]; then + echo "::error::unsigned ZIP ${ARCHIVE##*/} has SHA256 $actual, the build recorded $EXPECTED_SHA" >&2 + exit 1 + fi + duplicates=$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d) + if [[ -n "$duplicates" ]]; then + echo "::error::unsigned ZIP ${ARCHIVE##*/} has duplicate entries: ${duplicates//$'\n'/, }" >&2 + exit 1 + fi echo "Unsigned ZIP ${ARCHIVE##*/}: SHA256 matches the build, no duplicate entries" echo "archive=${ARCHIVE%.zip}-signed.zip" >>"$GITHUB_OUTPUT" - name: 📦 Download pinned Marketplace signer @@ -44,14 +52,18 @@ runs: SIGNER_BASE_URL: https://repo.maven.apache.org/maven2/org/jetbrains/marketplace-zip-signer/0.1.43 run: | echo "Downloading marketplace-zip-signer 0.1.43 from $SIGNER_BASE_URL" - curl --fail --location --proto '=https' --tlsv1.2 \ + curl --fail --no-progress-meter --location --proto '=https' --tlsv1.2 \ --output "$RUNNER_TEMP/marketplace-signer-cli.jar" \ "$SIGNER_BASE_URL/marketplace-zip-signer-0.1.43-cli.jar" - name: 🔍 Verify Marketplace signer digest shell: bash env: SIGNER_SHA256: 2958a0f42221d6062b50f22754e413ae5cc42f60c441467202c6700df2e22f44 - run: echo "$SIGNER_SHA256 $RUNNER_TEMP/marketplace-signer-cli.jar" | sha256sum --check + run: | + if ! echo "$SIGNER_SHA256 $RUNNER_TEMP/marketplace-signer-cli.jar" | sha256sum --check; then + echo "::error::downloaded Marketplace signer does not have the pinned SHA256 $SIGNER_SHA256" >&2 + exit 1 + fi - name: ✍️ Sign plugin ZIP shell: bash env: diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index f4a6e20..0c65de5 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -15,10 +15,10 @@ @pytest.mark.parametrize( - "changed", + ("changed", "error"), [ - pytest.param("digest", id="digest"), - pytest.param("duplicate", id="duplicate"), + pytest.param("digest", f"the signing job recorded {'0' * 64}", id="digest"), + pytest.param("duplicate", "has duplicate entries: Fish Shell/lib/plugin.jar", id="duplicate"), ], ) def test_invalid_archive_or_input_fails_before_upload( @@ -28,6 +28,7 @@ def test_invalid_archive_or_input_fails_before_upload( github_api: tuple[HTTPServer, dict[str, str]], httpserver: HTTPServer, changed: str, + error: str, ) -> None: settings = { "ARCHIVE": str(signed_archive), @@ -51,8 +52,7 @@ def test_invalid_archive_or_input_fails_before_upload( PLUGIN_ID=settings["PLUGIN_ID"], CHANNEL=settings["CHANNEL"], ) - assert result.returncode != 0 - assert not httpserver.log + assert (result.returncode, not httpserver.log, error in result.stderr) == (1, True, True), result.stderr @pytest.mark.parametrize( @@ -203,16 +203,17 @@ def test_prior_acceptance_with_matching_artifacts_skips_upload( @pytest.mark.parametrize( - ("field", "value"), + ("field", "value", "error"), [ - pytest.param("tag", "v0.1.12", id="tag"), - pytest.param("commit", "1" * 40, id="commit"), - pytest.param("pluginId", 20536, id="plugin"), - pytest.param("channel", "alpha", id="channel"), - pytest.param("sha256", "0" * 64, id="signed-digest"), - pytest.param("signedArtifactId", 70, id="signed-artifact"), - pytest.param("notesArtifactId", 71, id="notes-artifact"), - pytest.param("notesSha256", "0" * 64, id="notes-digest"), + pytest.param("tag", "v0.1.12", 'tag "v0.1.12" instead of', id="tag"), + pytest.param("commit", "1" * 40, f'commit "{"1" * 40}" instead of', id="commit"), + pytest.param("pluginId", 20536, "pluginId 20536 instead of 29973", id="plugin"), + pytest.param("channel", "alpha", 'channel "alpha" instead of "beta"', id="channel"), + pytest.param("sha256", "0" * 64, f'sha256 "{"0" * 64}" instead of', id="signed-digest"), + pytest.param("signedArtifactId", 70, "the receipt records 70", id="signed-artifact"), + pytest.param("notesArtifactId", 71, "the receipt records 71", id="notes-artifact"), + pytest.param("notesSha256", "0" * 64, f'notesSha256 "{"0" * 64}" instead of', id="notes-digest"), + pytest.param("sourceRunId", "41", 'sourceRunId "41" is not a number', id="source-run-type"), ], ) def test_changed_prior_receipt_blocks_retry( @@ -223,6 +224,7 @@ def test_changed_prior_receipt_blocks_retry( httpserver: HTTPServer, field: str, value: str | int, + error: str, ) -> None: result = publish_action( tmp_path, @@ -237,11 +239,16 @@ def test_changed_prior_receipt_blocks_retry( CHANNEL="beta", PUBLISH_TOKEN="fixture-token", ) - assert result.returncode != 0 - assert not httpserver.log + assert (result.returncode, not httpserver.log, error in result.stderr) == (1, True, True), result.stderr -@pytest.mark.parametrize("changed", [pytest.param("signed", id="signed-zip"), pytest.param("notes", id="notes")]) +@pytest.mark.parametrize( + ("changed", "error"), + [ + pytest.param("signed", "with SHA256", id="signed-zip"), + pytest.param("notes", "uploaded different release notes", id="notes"), + ], +) def test_changed_prior_artifact_blocks_retry( signed_archive: Path, tmp_path: Path, @@ -249,6 +256,7 @@ def test_changed_prior_artifact_blocks_retry( github_api: tuple[HTTPServer, dict[str, str]], httpserver: HTTPServer, changed: str, + error: str, ) -> None: result = publish_action( tmp_path, @@ -264,8 +272,7 @@ def test_changed_prior_artifact_blocks_retry( CHANNEL="beta", PUBLISH_TOKEN="fixture-token", ) - assert result.returncode != 0 - assert not httpserver.log + assert (result.returncode, not httpserver.log, error in result.stderr) == (1, True, True), result.stderr def test_reconciled_attempt_allows_another_retry( @@ -313,8 +320,8 @@ def test_receipt_from_unlisted_run_blocks_retry( CHANNEL="beta", PUBLISH_TOKEN="fixture-token", ) - assert result.returncode != 0 - assert not httpserver.log + expected = "earlier Marketplace receipt names run 30, which has no started upload for v0.1.11" + assert (result.returncode, not httpserver.log, expected in result.stderr) == (1, True, True), result.stderr def test_fork_run_on_tag_named_branch_allows_upload( diff --git a/tests/publish_post_release_pr/test_post_release_pr.py b/tests/publish_post_release_pr/test_post_release_pr.py index 9addabf..eddaebf 100644 --- a/tests/publish_post_release_pr/test_post_release_pr.py +++ b/tests/publish_post_release_pr/test_post_release_pr.py @@ -294,12 +294,20 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( @pytest.mark.parametrize( - "changed_input", + ("changed_input", "error"), [ - pytest.param("commit", id="stale-bundle"), - pytest.param("blob", id="changed-path-manifest"), - pytest.param("extra", id="extra-path"), - pytest.param("unauthorized", id="unauthorized-path"), + pytest.param("commit", f"expected candidate commit {'d' * 40}", id="stale-bundle"), + pytest.param("blob", f"the manifest expects {'c' * 40}", id="changed-path-manifest"), + pytest.param( + "extra", + "candidate commit changes gradle.properties other.txt, the manifest lists gradle.properties", + id="extra-path", + ), + pytest.param( + "unauthorized", + "prepared path other.txt is not in the writer's allowed-paths: gradle.properties", + id="unauthorized-path", + ), ], ) def test_prepared_candidate_identity_is_checked_before_api_write( @@ -307,6 +315,7 @@ def test_prepared_candidate_identity_is_checked_before_api_write( action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], changed_input: str, + error: str, ) -> None: server, environment = github_api bundle, base, commit, blob = candidate(tmp_path, extra_path=changed_input in {"extra", "unauthorized"}) @@ -337,5 +346,39 @@ def test_prepared_candidate_identity_is_checked_before_api_write( "RUNNER_TEMP": str(tmp_path), }, ) - assert result.returncode != 0 - assert not server.log + assert (result.returncode, not server.log, error in result.stderr) == (1, True, True), result.stderr + + +@pytest.mark.parametrize( + ("manifest", "error"), + [ + pytest.param(f"../gradle.properties\t{'b' * 40}\n", "is not a plain relative path", id="parent-path"), + pytest.param("gradle.properties\tB0\n", "is 'B0', not a lowercase hex SHA", id="uppercase-blob"), + pytest.param("gradle.properties\tb0\n", "is 2 characters long instead of 40", id="short-blob"), + pytest.param("", "lists no paths", id="empty"), + pytest.param( + f"gradle.properties\t{'b' * 40}\ngradle.properties\t{'b' * 40}\n", + "lists paths more than once: gradle.properties", + id="duplicate", + ), + ], +) +def test_path_manifest_failure_names_the_check( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], manifest: str, error: str +) -> None: + server, environment = github_api + paths = tmp_path / "paths.tsv" + paths.write_text(manifest) + result = run_publish( + action_scripts, + cwd=tmp_path, + env=os.environ + | environment + | { + "GITHUB_REPOSITORY": "tox-dev/fixture", + "PATHS_FILE": str(paths), + "ALLOWED_PATHS": "gradle.properties", + "RUNNER_TEMP": str(tmp_path), + }, + ) + assert (result.returncode, not server.log, error in result.stderr) == (1, True, True), result.stderr diff --git a/tools/build_constraints.py b/tools/build_constraints.py index ab8bb45..5a134ad 100644 --- a/tools/build_constraints.py +++ b/tools/build_constraints.py @@ -39,7 +39,7 @@ def main() -> None: ) target = f"{args.targets} on {args.target_platform}" if args.targets else "this runner" print( - f"Build constraints: {len(selected)} packages from {args.lock} {source} for {target} -> {args.output}", + f"Build constraints: {len(selected)} package{'' if len(selected) == 1 else 's'} from {args.lock} {source} for {target} -> {args.output}", file=sys.stderr, ) except ( @@ -85,10 +85,11 @@ def _target_environments(platform: str, values: str) -> list[Environment]: def _build_groups(lock: Pylock) -> tuple[set[str] | None, str]: - if "build" in (lock.dependency_groups or ()): + declared = lock.dependency_groups or () + if "build" in declared: return {"build"}, "group build" - if lock.dependency_groups: - return None, f"(no build group among {', '.join(lock.dependency_groups)})" + if declared: + return None, f"(no build group among {', '.join(declared)})" return set(), "(no dependency groups, every locked package)" diff --git a/tools/expose_locked_commands.py b/tools/expose_locked_commands.py index 43c997b..17e1c54 100644 --- a/tools/expose_locked_commands.py +++ b/tools/expose_locked_commands.py @@ -28,7 +28,7 @@ def main() -> None: with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as stream: print(overlay, file=stream) print( - f"Exposed {len(exposed)} commands from {sys.argv[1]} on PATH: {', '.join(sorted(exposed)) or 'none'}", + f"Exposed {len(exposed)} command{'' if len(exposed) == 1 else 's'} from {sys.argv[1]} on PATH: {', '.join(sorted(exposed)) or 'none'}", file=sys.stderr, ) diff --git a/tools/plural.sh b/tools/plural.sh new file mode 100644 index 0000000..1c1effd --- /dev/null +++ b/tools/plural.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +plural() { + if (($1 == 1)); then + printf '%s %s' "$1" "$2" + else + printf '%s %s' "$1" "${3:-${2}s}" + fi +} diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index 6cc8c22..10c6bf8 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -99,14 +99,15 @@ def verify_files(project: str, version: str, dist: Path) -> list[Path]: raise ValueError(f"PyPI has different SHA256 digests: {', '.join(sorted(changed))}") if not (missing := local.keys() - remote.keys()): print( - f"PyPI: {len(local)} files match the local SHA256 digests: {', '.join(sorted(local))}", file=sys.stderr + f"PyPI: {_files(len(local))} match the local SHA256 digests: {', '.join(sorted(local))}", + file=sys.stderr, ) return [dist / filename for filename in sorted(local)] if (remaining := deadline - time.monotonic()) <= 0: raise ValueError(f"PyPI is missing files: {', '.join(sorted(missing))}") wait = min(delay, remaining) print( - f"PyPI: {len(missing)} of {len(local)} files not listed yet ({', '.join(sorted(missing))}), " + f"PyPI: {len(missing)} of {_files(len(local))} not listed yet ({', '.join(sorted(missing))}), " f"checking again in {wait:g}s", file=sys.stderr, ) @@ -114,6 +115,10 @@ def verify_files(project: str, version: str, dist: Path) -> list[Path]: delay = min(delay * 2, 30) +def _files(count: int) -> str: + return f"{count} file{'' if count == 1 else 's'}" + + def _identity(filename: str) -> tuple[str, Version] | None: try: return parse_wheel_filename(filename)[:2] if filename.endswith(".whl") else parse_sdist_filename(filename) diff --git a/validate-news/action.yml b/validate-news/action.yml index ab9d9ea..e936b4d 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -75,7 +75,7 @@ runs: if config: print(f"Fragment check runs: the Towncrier configuration changed since {base[:12]}") elif fragments: - print(f"Fragment check runs: {len(changed)} files changed in {directory}") + print(f"Fragment check runs: {len(changed)} file{'' if len(changed) == 1 else 's'} changed in {directory}") else: print(f"Fragment check skipped: nothing changed in {directory} and the Towncrier configuration is unchanged") PY @@ -85,18 +85,22 @@ runs: env: BASE_REF: ${{ github.base_ref }} run: | + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/../tools/plural.sh" release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" - echo "Changed fragments: $(<"$RUNNER_TEMP/news-fragment-count") valid against origin/$BASE_REF" + echo "Changed fragments: $(plural "$(<"$RUNNER_TEMP/news-fragment-count")" fragment) valid against origin/$BASE_REF" - name: 🔍 Require news for human changes shell: bash env: SKIP_NEWS: ${{ contains(github.event.pull_request.labels.*.name, 'skip news') }} BOT_AUTHOR: ${{ github.event.pull_request.user.type == 'Bot' }} run: | + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/../tools/plural.sh" fragments=0 if [[ -f "$RUNNER_TEMP/news-fragment-count" ]]; then read -r fragments <"$RUNNER_TEMP/news-fragment-count"; fi if ((fragments)); then - echo "News requirement: met by $fragments fragments" + echo "News requirement: met by $(plural "$fragments" fragment)" elif [[ "$SKIP_NEWS" == true ]]; then echo 'News requirement: waived by the skip news label' elif [[ "$BOT_AUTHOR" == true ]]; then diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 489d54e..5105e1f 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -16,6 +16,8 @@ runs: FRAGMENTS: ${{ steps.config.outputs.fragments }} run: | set -euo pipefail + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/../tools/plural.sh" git diff --cached --name-status --no-renames -z >"$RUNNER_TEMP/release-paths" removed=0 while IFS= read -r -d '' status && IFS= read -r -d '' path; do @@ -33,11 +35,13 @@ runs: fi removed=$((removed + 1)) done <"$RUNNER_TEMP/release-paths" - echo "Release patch paths: $removed fragments removed from $FRAGMENTS" + echo "Release patch paths: $(plural "$removed" fragment) removed from $FRAGMENTS" - name: 🔍 Check release patch file modes shell: bash run: | set -euo pipefail + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/../tools/plural.sh" git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes" count=0 while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do @@ -70,4 +74,4 @@ runs: fi count=$((count + 1)) done <"$RUNNER_TEMP/release-modes" - echo "Release patch file modes: $count paths, each a non-executable regular file or a deletion" + echo "Release patch file modes: $(plural "$count" path), each a non-executable regular file or a deletion" From f9eda442244ffc0e8d7d64dbaa2ebabc51f08e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 11:27:59 -0700 Subject: [PATCH 21/23] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20build:=20use=20relea?= =?UTF-8?q?se-creator=204.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-creator 4.1.0 explains each command's choices on stderr, so the shared steps now show what release-creator decided as well. --- .github/workflows/news.yaml | 10 +++++----- .github/workflows/prepare-release.yaml | 4 ++-- .github/workflows/release.yaml | 6 +++--- .github/workflows/update-tools.yaml | 4 ++-- pylock.toml | 22 +++++++++++----------- pyproject.toml | 8 ++++---- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml index 8d1d9e1..4ba6bbc 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -25,13 +25,13 @@ jobs: - name: 📦 Install release tools env: EXCLUDE_NEWER: 7 days - # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. - EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-26T16:21:40Z + # Temporary: release-creator 4.1.0 is younger than seven days. Delete after 2026-11-02. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-26T18:24:46Z run: | - tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=4' 'towncrier>=26.9' + tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=4.1' 'towncrier>=26.9' uv run --no-project --python "$RUNNER_TEMP/release-tools" python tools/expose_locked_commands.py "$RUNNER_TEMP/release-tools" - name: 🔍 Validate news fragments uses: $/validate-news with: - # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. - exclude-newer-package: release-creator=2026-10-26T16:21:40Z + # Temporary: release-creator 4.1.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-26T18:24:46Z diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index a5108bf..797b4ca 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -18,7 +18,7 @@ jobs: pull-requests: read with: bump: ${{ inputs.bump }} - # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. - exclude-newer-package: release-creator=2026-10-26T16:21:40Z + # Temporary: release-creator 4.1.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-26T18:24:46Z secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0403f9b..abe8e07 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,10 +30,10 @@ jobs: - name: 📦 Install release tools env: EXCLUDE_NEWER: 7 days - # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. - EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-26T16:21:40Z + # Temporary: release-creator 4.1.0 is younger than seven days. Delete after 2026-11-02. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-26T18:24:46Z run: | - tools/install_live_tools.sh .venv 'release-creator>=4' + tools/install_live_tools.sh .venv 'release-creator>=4.1' uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - name: 📝 Render release notes uses: $/render-release-notes diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 3db76a0..6d5d17d 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -16,5 +16,5 @@ jobs: commit-message: "🔧 build: refresh release tool lock" pull-request-title: "🔧 build: refresh release tool lock" pull-request-body: "Refresh the locked tools used by release preparation and CI." - # Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. - exclude-newer-package: release-creator=2026-10-26T16:21:40Z + # Temporary: release-creator 4.1.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-26T18:24:46Z diff --git a/pylock.toml b/pylock.toml index 4f136bf..eb00bb1 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1045,7 +1045,7 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "4.0.0" +version = "4.1.0" marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.15" dependencies = [ @@ -1060,22 +1060,22 @@ dependencies = [ index = "https://pypi.org/simple/" [packages.sdist] -name = "release_creator-4.0.0.tar.gz" -upload-time = 2026-10-26 16:21:39.157838+00:00 -url = "https://files.pythonhosted.org/packages/c0/64/cfd6c068445858d4a5d7b3ed27241562d5e7bfde70fc261101c9432e718d/release_creator-4.0.0.tar.gz" -size = 178372 +name = "release_creator-4.1.0.tar.gz" +upload-time = 2026-10-26 18:24:45.805437+00:00 +url = "https://files.pythonhosted.org/packages/7f/fd/05d7f3c604476a238d67d5aaa2a4085312e7078c193dbd7ad99ad48d560d/release_creator-4.1.0.tar.gz" +size = 186016 [packages.sdist.hashes] -sha256 = "c3a61ab0502fe9916990d43198b4a695f4f471afb3e63ffbe85eb64c160d4726" +sha256 = "c783037f5ca2efc16fa7cccfb1c2abf0a442f116a63ca82bd7bfb7e2a8343a15" [[packages.wheels]] -name = "release_creator-4.0.0-py3-none-any.whl" -upload-time = 2026-10-26 16:21:37.836466+00:00 -url = "https://files.pythonhosted.org/packages/96/77/4c4d6212d3b59eab660dcb0c5c07f2209756bdf7f4f1288a3d38c1b8bff4/release_creator-4.0.0-py3-none-any.whl" -size = 49646 +name = "release_creator-4.1.0-py3-none-any.whl" +upload-time = 2026-10-26 18:24:44.353933+00:00 +url = "https://files.pythonhosted.org/packages/30/d0/38480e99025c021d07e0710af02547562dfadbd798bef26dc01869a3f81e/release_creator-4.1.0-py3-none-any.whl" +size = 52922 [packages.wheels.hashes] -sha256 = "c6ce7929c82c517faf747e64492ea3c25a8377bdae4111fb5c8b35532592289c" +sha256 = "67deae16d11ac261a1512f18a22c0cf13418f43afc3e56cb74e58ad4422062e3" [[packages]] name = "setuptools" diff --git a/pyproject.toml b/pyproject.toml index e91a6c1..6170c76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] -release-prepare = [ "release-creator>=4", "towncrier>=26.9", "prek>=0.2" ] +release-prepare = [ "release-creator>=4.1", "towncrier>=26.9", "prek>=0.2" ] test = [ "certifi", "coverage[toml]>=7.10", @@ -20,7 +20,7 @@ test = [ "pytest-mock>=3.16", "pytest-xdist>=3", "pyyaml>=6", - "release-creator>=4", + "release-creator>=4.1", "tomlkit>=0.13", "tox>=4.64.7", "tox-uv>=1.36.1", @@ -36,9 +36,9 @@ uploaded-prior-to = "P7D" python = ">=3.15,<3.16" platforms = ["linux_x86_64"] -# Temporary: release-creator 4.0.0 is younger than seven days. Delete after 2026-11-02. +# Temporary: release-creator 4.1.0 is younger than seven days. Delete after 2026-11-02. [tool.nab.packages.release-creator] -uploaded-prior-to = 2026-10-26T16:21:40Z +uploaded-prior-to = 2026-10-26T18:24:46Z [tool.ruff] line-length = 120 From 1b78a80bb34ab0ed387c1f2bd9d9031b0148b328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 12:59:46 -0700 Subject: [PATCH 22/23] =?UTF-8?q?=F0=9F=90=9B=20fix:=20count=20inline=20sh?= =?UTF-8?q?ell=20blocks=20without=20a=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline shell check lints each run block from a temporary copy, so a block that sources tools/plural.sh fails shellcheck there. --- .github/workflows/check.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 202bdbc..6c035af 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -87,9 +87,7 @@ jobs: done <"$RUNNER_TEMP/run-blocks" done scripts=("$script_dir"/*.sh) - # shellcheck source=tools/plural.sh - source tools/plural.sh - echo "Checking $(plural "${#scripts[@]}" "run block") from the workflows and Actions with shellcheck and shfmt" + echo "Checking ${#scripts[@]} run blocks from the workflows and Actions with shellcheck and shfmt" tools/cooled_prek.sh run shellcheck --files "$script_dir"/*.sh tools/cooled_prek.sh run shfmt --files "$script_dir"/*.sh test: From 4a224ba91f509bc7a722e0dda3d750a885a33dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 26 Oct 2026 13:04:48 -0700 Subject: [PATCH 23/23] =?UTF-8?q?=F0=9F=A7=AA=20test:=20list=20fixture=20d?= =?UTF-8?q?istributions=20in=20a=20stable=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/verify_pypi_files/test_verify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index fc7e2b7..7d24f74 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -419,7 +419,7 @@ def _serve(mocker: MockerFixture, distribution: Path, *, change: str = "") -> No def _listing(distribution: Path) -> list[_ListingFile]: return [ {"filename": path.name, "hashes": {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}, "yanked": False} - for path in distribution.iterdir() + for path in sorted(distribution.iterdir()) ]