diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
index 2e149ed..2fe0b20 100644
--- a/.github/workflows/check.yaml
+++ b/.github/workflows/check.yaml
@@ -38,15 +38,7 @@ jobs:
groups: release-check
venv: .release-tools
- name: Check repository formatting
- env:
- UV_EXCLUDE_NEWER: 7 days
- UV_NO_CONFIG: "1"
- UV_NO_SOURCES: "1"
- run: |
- 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
- prek run --all-files
+ run: tools/cooled_prek.sh run --all-files
shell:
runs-on: ubuntu-26.04
permissions:
@@ -74,14 +66,7 @@ jobs:
groups: release-shell
venv: .release-tools
- name: Check inline shell
- env:
- UV_EXCLUDE_NEWER: 7 days
- UV_NO_CONFIG: "1"
- UV_NO_SOURCES: "1"
run: |
- 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
set -euo pipefail
script_dir=./inline-shell-check
mkdir "$script_dir"
@@ -96,8 +81,8 @@ jobs:
index=$((index + 1))
done <"$RUNNER_TEMP/run-blocks"
done
- prek run shellcheck --files "$script_dir"/*.sh
- prek run shfmt --files "$script_dir"/*.sh
+ tools/cooled_prek.sh run shellcheck --files "$script_dir"/*.sh
+ tools/cooled_prek.sh run shfmt --files "$script_dir"/*.sh
test:
runs-on: ubuntu-26.04
permissions:
diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml
index 54c9f9d..3cd3030 100644
--- a/.github/workflows/prepare-towncrier-release.yaml
+++ b/.github/workflows/prepare-towncrier-release.yaml
@@ -110,24 +110,12 @@ jobs:
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
- prek install-hooks
+ prek install-hooks --config .pre-commit-config.yaml
background: true
- - name: Build local release tool wheel
+ - uses: $/install-local-release-creator
if: inputs.local-tool-source
- env:
- BUILD_CONSTRAINTS: ${{ steps.tools.outputs.build-constraints }}
- run: |
- uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \
- --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" .
- - name: Install local release tool wheel
- if: inputs.local-tool-source
- run: uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl
- - name: Expose local release command
- if: inputs.local-tool-source
- run: |
- mkdir -p "$RUNNER_TEMP/local-release-cli"
- ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator"
- echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH"
+ with:
+ build-constraints: ${{ steps.tools.outputs.build-constraints }}
- id: prepare
name: Build changelog
uses: $/prepare-towncrier
@@ -170,22 +158,10 @@ jobs:
groups: release-validate${{ inputs.local-tool-source && ' project-runtime' || '' }}
venv: .venv
local-tool-source: ${{ inputs.local-tool-source }}
- - name: Build local release tool wheel
+ - uses: $/install-local-release-creator
if: inputs.local-tool-source
- env:
- BUILD_CONSTRAINTS: ${{ steps.tools.outputs.build-constraints }}
- run: |
- uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \
- --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" .
- - name: Install local release tool wheel
- if: inputs.local-tool-source
- run: uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl
- - name: Expose local release command
- if: inputs.local-tool-source
- run: |
- mkdir -p "$RUNNER_TEMP/local-release-cli"
- ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator"
- echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH"
+ with:
+ build-constraints: ${{ steps.tools.outputs.build-constraints }}
- id: config
uses: $/read-towncrier-config
- name: Verify selected release version
@@ -210,7 +186,6 @@ jobs:
- uses: $/validate-release-patch
with:
version: ${{ needs.assemble.outputs.version }}
- patch: ${{ runner.temp }}/release-patch/release.patch
push:
needs: [assemble, validate]
runs-on: ubuntu-26.04
@@ -231,7 +206,6 @@ jobs:
- uses: $/validate-release-patch
with:
version: ${{ needs.assemble.outputs.version }}
- patch: ${{ runner.temp }}/release-patch/release.patch
checks: "false"
- id: repository
name: Select the current repository
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 43de07c..8ad693f 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -24,24 +24,7 @@ jobs:
version: latest-known
python-version: "3.15"
enable-cache: false
- - uses: $/refresh-release-lock
- with:
- mode: check
- - uses: $/install-locked-tools
- with:
- groups: release-check
- venv: .venv
- - uses: $/verify-frozen-hooks
- - name: Check tagged source
- env:
- UV_EXCLUDE_NEWER: 7 days
- UV_NO_CONFIG: "1"
- UV_NO_SOURCES: "1"
- run: |
- 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
- .venv/bin/prek run --all-files
+ - uses: $/check-tagged-source
source:
runs-on: ubuntu-26.04
permissions:
@@ -130,7 +113,6 @@ jobs:
previous: ${{ needs.source.outputs.previous }}
changelog: CHANGELOG.md
generated-notes: ${{ runner.temp }}/generated-notes.md
- output: ${{ runner.temp }}/release-notes.md
- id: artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml
index b09934e..313dce5 100644
--- a/.github/workflows/update-tools.yaml
+++ b/.github/workflows/update-tools.yaml
@@ -12,9 +12,6 @@ jobs:
secrets:
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
with:
- upgrade-resolver: true
- lock-groups: >-
- build lock-tools lock-reader release-notes release-resolver release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check
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."
diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml
index 78aaaf2..114db8c 100644
--- a/.github/workflows/upgrade-release-tools.yaml
+++ b/.github/workflows/upgrade-release-tools.yaml
@@ -2,10 +2,6 @@ name: Upgrade release tools
on:
workflow_call:
inputs:
- lock-groups:
- description: Legacy Nab dependency groups; ignored for full project locks
- default: ""
- type: string
additional-lock:
description: Optional secondary pylock basename for a separate build matrix
type: string
@@ -30,10 +26,6 @@ on:
description: Space-separated implementations for the secondary lock
type: string
default: ""
- upgrade-resolver:
- description: Upgrade this repository's exact shared uv resolver pin with its lock
- type: boolean
- default: false
keep-uv-lock:
description: Refresh and check uv.lock alongside pylock.toml
type: boolean
@@ -77,14 +69,12 @@ jobs:
- id: lock
uses: $/refresh-release-lock
with:
- lock-groups: ${{ inputs.lock-groups }}
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 }}
- upgrade-resolver: ${{ inputs.upgrade-resolver }}
keep-uv-lock: ${{ inputs.keep-uv-lock }}
- id: upload
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -219,7 +209,7 @@ jobs:
exit 1
fi
done <"$RUNNER_TEMP/dependency-modes"
- - uses: $/verify-nab-cutoff-patch
+ - uses: $/verify-cutoff-patch
- name: Commit refreshed tools
env:
COMMIT_MESSAGE: ${{ inputs.commit-message }}
diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml
index a8f5b6e..de1d9f2 100644
--- a/.github/workflows/validate-python-release.yaml
+++ b/.github/workflows/validate-python-release.yaml
@@ -62,9 +62,8 @@ jobs:
version: latest-known
python-version: "3.15"
enable-cache: false
- - uses: $/refresh-release-lock
+ - uses: $/check-tagged-source
with:
- mode: check
keep-uv-lock: ${{ inputs.keep-uv-lock }}
additional-lock: ${{ inputs.additional-lock }}
additional-lock-groups: ${{ inputs.additional-lock-groups }}
@@ -72,21 +71,6 @@ jobs:
additional-matrix-python: ${{ inputs.additional-matrix-python }}
additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }}
additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }}
- - uses: $/install-locked-tools
- with:
- groups: release-check
- venv: ${{ runner.temp }}/release-tools
- - uses: $/verify-frozen-hooks
- - name: Run tagged source checks
- env:
- UV_EXCLUDE_NEWER: 7 days
- UV_NO_CONFIG: "1"
- UV_NO_SOURCES: "1"
- run: |
- 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
- "$RUNNER_TEMP/release-tools/bin/prek" run --all-files
constraints:
runs-on: ubuntu-26.04
outputs:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40c2f6c..ceb8a0d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,14 @@
match the fork repository, branch, commit, and workflow, and occur after the PR opened
([#15](https://github.com/tox-dev/github-workflows/pull/15)) by [@gaborbernat](https://github.com/gaborbernat).
+
+
+### Breaking changes
+
+- The tagged `source-check` job of `validate-python-release` requests `actions: read` and `pull-requests: read` and
+ checks out full history for the hook age check. A caller whose `build` job grants only `contents: read` fails at
+ startup; grant all three. This entry was added after the release, which shipped without it.
+
### Security
diff --git a/README.md b/README.md
index 16f0693..a817ebd 100644
--- a/README.md
+++ b/README.md
@@ -96,12 +96,12 @@ Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-
`pylock.toml` and the frozen pre-commit hooks in parallel. `release-creator locks` exports runtime dependencies, all
extras, and all dependency groups with a seven-day package cutoff. Reviewed exceptions live in
`[tool.release-creator.locks.exclude-newer-package]`. Both lock checks and weekly refresh install uv from the shared
-`release-resolver` group, so a setup-uv default change cannot alter the generated lock. The shared weekly updater
-refreshes this direct pin and the lock together; consumer locks use their pinned shared resolver. The hook updater
-applies the same cooldown to hook revisions and their `additional_dependencies`. Each job uploads a patch. The lock
-patch may change `pylock.toml`, an explicitly configured secondary Nab lock, and `pyproject.toml` for expired reviewed
-cutoffs. The hook patch may change only `.pre-commit-config.yaml`. The writer checks each patch before applying it and
-verifies cutoff removals before minting the App token. It then opens or updates one dependency PR from
+`release-resolver` group, so a setup-uv default change cannot alter the generated lock. In github-workflows, the weekly
+updater also refreshes this direct pin and the lock together; consumer locks use their pinned shared resolver. The hook
+updater applies the same cooldown to hook revisions and their `additional_dependencies`. Each job uploads a patch. The
+lock patch may change `pylock.toml`, an explicitly configured secondary Nab lock, and `pyproject.toml` for expired
+reviewed cutoffs. The hook patch may change only `.pre-commit-config.yaml`. The writer checks each patch before applying
+it and verifies cutoff removals before minting the App token. It then opens or updates one dependency PR from
`automation/update-release-tools` to `main`. A lock or hook update runs the normal PR checks before merge.
The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this
@@ -127,10 +127,10 @@ jobs:
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
```
-`lock-groups` remains an input for callers with an older Nab primary lock; the full project exporter ignores it and
-records every dependency group and extra. Tests install test, type, and documentation tools afresh to detect upstream
-dependency changes. The updater also refreshes a caller's secondary Nab build-matrix lock when `additional-lock` and its
-matrix inputs are set.
+`pylock.toml` must come from `release-creator locks`, which records every dependency group and extra; a Nab primary lock
+fails with a request to relock. Tests install test, type, and documentation tools afresh to detect upstream dependency
+changes. The updater also refreshes a caller's secondary Nab build-matrix lock when `additional-lock` and its matrix
+inputs are set.
The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The
workflow updates a committed `uv.lock` only when `keep-uv-lock: true`; pass that input to `validate-news` too. Both
@@ -199,20 +199,25 @@ workflow run at least that many days earlier. The default is seven days. A force
revision's age or give a new revision the old one's age. GitHub can omit pull request links from workflow runs. For
those runs, the Action observes the exact head repository, branch, commit, and workflow after the PR opened; two PRs
from the same head can share that code observation. Human PRs may add hooks or change their options under the same
-revision and dependency checks. A human PR may add the first managed pins when the base configuration has none; the
-complete closure check still applies. Once the base has managed pins, only the release App may change them. Other bot
-PRs may change revisions only. On `automation/update-release-tools`, the Action verifies the bot against
-`RELEASE_APP_CLIENT_ID` and permits exact, cooled PyPI dependency pins generated by the release App. Every external hook
-revision must be a full lowercase commit SHA. A revision with new Python dependencies stays blocked until the App
-updates its managed pins. Tagged source jobs check frozen revisions and the complete Python dependency closure before
-running `prek`.
+revision and dependency checks. A human PR may add or change managed pins when each new pin is an exact PyPI release at
+least `minimum-days` old; the complete closure check still applies. Other bot PRs may change revisions only. On
+`automation/update-release-tools`, the Action verifies the bot against `RELEASE_APP_CLIENT_ID` and permits exact, cooled
+PyPI dependency pins generated by the release App. Every external hook revision must be a full lowercase commit SHA.
+Only Python hooks may declare `additional_dependencies`, because only those get exact, cooled pins. A revision with new
+Python dependencies stays blocked until its managed pins are updated. Tagged source jobs check frozen revisions and the
+complete Python dependency closure before running `prek`.
+
+Every shared `prek` command passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and
+also runs nested configurations, which no guard checks. The cooldown check and `verify-frozen-hooks` therefore reject a
+`prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root.
On a tag, `verify-frozen-hooks` also checks when each new external `(repository, commit)` pair was first present in
GitHub. It compares the tag with the previous published release in the same tag stream and on the first-parent release
line. A new pair must come from a merged PR, and either its merge or a PR run containing the exact pair must be at least
seven days old. The first release has no baseline: every external pair needs that proof. Directly pushed hook changes
-cannot acquire it. This read-only Action has no outputs; it fails before `prek` runs. The tagged source job needs
-`actions: read`, `contents: read`, `pull-requests: read`, and a checkout with `fetch-depth: 0`.
+cannot acquire it. The proof runs before `release-creator hooks --check` builds any hook repository. This read-only
+Action has no outputs; it fails before `prek` runs. The tagged source job needs `actions: read`, `contents: read`,
+`pull-requests: read`, and a checkout with `fetch-depth: 0`.
For a release dispatched from `main` with a tag input, pass that tag to the Action. It checks that the checkout is the
tag's commit; a dispatch on `main` with the wrong checkout fails.
@@ -224,9 +229,12 @@ tag's commit; a dispatch on `main` with the wrong checkout fails.
```
The shared prepare and tagged validator run `refresh-release-lock` in `check` mode before release checks or publishing.
-Pass `keep-uv-lock: true` when the repository commits a native `uv.lock`. For a secondary Nab lock, pass its path,
-selected groups, selection type, and matrix inputs to both workflows. A stale primary, native, or secondary lock stops
-the release before artifacts are built.
+`check-tagged-source` bundles the tagged checks for a custom tag publisher: it requires `GITHUB_REF` to be the tag of
+the checkout on `main`, checks the locks, installs `release-check`, runs `verify-frozen-hooks`, and then runs
+`prek run --all-files` with the seven-day cutoff. It takes `keep-uv-lock` and the secondary lock inputs below, and needs
+the same permissions and full-history checkout as `verify-frozen-hooks`. Pass `keep-uv-lock: true` when the repository
+commits a native `uv.lock`. For a secondary Nab lock, pass its path, selected groups, selection type, and matrix inputs
+to both workflows. A stale primary, native, 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:
@@ -328,7 +336,9 @@ build:
pull-requests: read
```
-The caller needs `release-check` for prek and a `build` group for backend constraints. `source` defaults to `.`. Use
+The `build` job must grant `actions: read` and `pull-requests: read` as well as `contents: read`. The tagged hook age
+check has needed them since 3.0.1, and GitHub refuses to start the workflow when the caller grants less. The caller
+needs `release-check` for prek and 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.
@@ -467,18 +477,17 @@ those changes.
`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. 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.
+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.
```yaml
- uses: tox-dev/github-workflows/validate-release-patch@SHA
with:
version: ${{ needs.assemble.outputs.version }}
- patch: ${{ runner.temp }}/release-patch/release.patch
```
- `version` **(required)**: Exact tag chosen by preparation. `release-creator notes` must find one valid section for it.
-- `patch` **(required)**: Local path of the downloaded patch artifact, not an artifact name or URL.
- `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,
@@ -532,7 +541,6 @@ creates the Release; this Action only writes a local Markdown file.
changelog: CHANGELOG.md
artifact-url: https://pypi.org/project/example/${{ github.ref_name }}/
generated-notes: ${{ runner.temp }}/generated-notes.md
- output: ${{ runner.temp }}/release-notes.md
```
- `tag` **(required)**: Release version, such as `1.2.3`. The checkout must contain its changelog section.
@@ -544,16 +552,15 @@ creates the Release; this Action only writes a local Markdown file.
- `generated-notes`: Path to GitHub's generated-notes body. The tag publisher fetches it after PyPI publication. It
preserves New Contributors and fills any missing `by @handle` credit. Omit it when the release has no GitHub-generated
contributor metadata.
-- `output` **(required)**: Writable path for the GitHub Release body. Pass it to `gh release create --notes-file` only
- after the ecosystem publisher succeeds.
The Action reads `GITHUB_REPOSITORY` for PR and comparison links. A repository with separate release namespaces, such as
toml-fmt, calls `release-creator notes --package` directly for its package changelog.
-**Output:** No named output. **Working-tree effect:** The Action writes only `output`. It rejects an invalid tag, a
-missing heading, an empty body, or a missing comparison link when an earlier tag exists. It neither fetches generated
-notes nor writes a GitHub Release, so a PR job needs only read permissions. Fetching generated notes uses GitHub's
-release endpoint and needs Contents write in the tag publisher.
+**Output:** No named output. **Working-tree effect:** The Action writes only `$RUNNER_TEMP/release-notes.md`. Pass it to
+`gh release create --notes-file` only after the ecosystem publisher succeeds. It rejects an invalid tag, a missing
+heading, an empty body, or a missing comparison link when an earlier tag exists. It neither fetches generated notes nor
+writes a GitHub Release, so a PR job needs only read permissions. Fetching generated notes uses GitHub's release
+endpoint and needs Contents write in the tag publisher.
## Select the previous release tag
diff --git a/changelog.d/+check-tagged-source.feature.md b/changelog.d/+check-tagged-source.feature.md
new file mode 100644
index 0000000..30061b2
--- /dev/null
+++ b/changelog.d/+check-tagged-source.feature.md
@@ -0,0 +1,2 @@
+Add `check-tagged-source`, which runs the tag, lock, frozen hook, and cooled `prek` checks of a tagged checkout. The
+Python release workflow uses it, and custom tag release jobs can call it before publishing.
diff --git a/changelog.d/+fixed-notes-path.breaking.md b/changelog.d/+fixed-notes-path.breaking.md
new file mode 100644
index 0000000..417a0c2
--- /dev/null
+++ b/changelog.d/+fixed-notes-path.breaking.md
@@ -0,0 +1,2 @@
+Remove the `render-release-notes` `output` input. Every caller passed `${{ runner.temp }}/release-notes.md`, which is
+now where the Action writes the body.
diff --git a/changelog.d/+fixed-patch-path.breaking.md b/changelog.d/+fixed-patch-path.breaking.md
new file mode 100644
index 0000000..7b39a4f
--- /dev/null
+++ b/changelog.d/+fixed-patch-path.breaking.md
@@ -0,0 +1,2 @@
+Remove the `validate-release-patch` `patch` input. The Action reads the patch from
+`${{ runner.temp }}/release-patch/release.patch`, where every caller downloaded it.
diff --git a/changelog.d/+human-hook-pins.bugfix.md b/changelog.d/+human-hook-pins.bugfix.md
new file mode 100644
index 0000000..1a14f9f
--- /dev/null
+++ b/changelog.d/+human-hook-pins.bugfix.md
@@ -0,0 +1,2 @@
+Let a human pull request add or change managed hook dependency pins when each new pin is an exact, cooled PyPI release
+and the complete closure check passes. Adding a Python hook with dependencies no longer needs the release App.
diff --git a/changelog.d/+lock-producer-table.bugfix.md b/changelog.d/+lock-producer-table.bugfix.md
new file mode 100644
index 0000000..a4e0008
--- /dev/null
+++ b/changelog.d/+lock-producer-table.bugfix.md
@@ -0,0 +1 @@
+Report a lock whose `tool.release-creator` or `tool.nab` value is not a table as an error instead of a traceback.
diff --git a/changelog.d/+lock-tools-on-demand.performance.md b/changelog.d/+lock-tools-on-demand.performance.md
new file mode 100644
index 0000000..1910097
--- /dev/null
+++ b/changelog.d/+lock-tools-on-demand.performance.md
@@ -0,0 +1,2 @@
+Install the Nab lock tools in `refresh-release-lock` only for a refresh or a secondary lock. Lock checks in pull
+requests, release preparation, and tagged releases skip that install.
diff --git a/changelog.d/+marketplace-retry-receipts.bugfix.md b/changelog.d/+marketplace-retry-receipts.bugfix.md
new file mode 100644
index 0000000..fa28dff
--- /dev/null
+++ b/changelog.d/+marketplace-retry-receipts.bugfix.md
@@ -0,0 +1,3 @@
+Let a JetBrains Marketplace retry follow an attempt that reconciled an earlier upload. That attempt now keeps a copy of
+the receipt it verified, and the check reads the artifacts of the run named in the receipt. Fork pull request runs on a
+branch named like the tag no longer block the upload.
diff --git a/changelog.d/+mise-digest-first.security.md b/changelog.d/+mise-digest-first.security.md
new file mode 100644
index 0000000..f987181
--- /dev/null
+++ b/changelog.d/+mise-digest-first.security.md
@@ -0,0 +1,2 @@
+Check the downloaded mise executable against the seven-day-old release digest before mise-action runs it, and run its
+version listing outside the caller's checkout.
diff --git a/changelog.d/+mise-line-endings.bugfix.md b/changelog.d/+mise-line-endings.bugfix.md
new file mode 100644
index 0000000..7188feb
--- /dev/null
+++ b/changelog.d/+mise-line-endings.bugfix.md
@@ -0,0 +1,2 @@
+Keep CRLF line endings when refreshing a mise pin, and report a release asset without a digest as an error instead of a
+traceback.
diff --git a/changelog.d/+news-workflow-lock.bugfix.md b/changelog.d/+news-workflow-lock.bugfix.md
new file mode 100644
index 0000000..ef0f3af
--- /dev/null
+++ b/changelog.d/+news-workflow-lock.bugfix.md
@@ -0,0 +1,2 @@
+Check the release lock in pull requests that change a workflow, since re-pinning github-workflows can change the pinned
+resolver and the rendered lock.
diff --git a/changelog.d/+normalized-lock-groups.security.md b/changelog.d/+normalized-lock-groups.security.md
new file mode 100644
index 0000000..1fce646
--- /dev/null
+++ b/changelog.d/+normalized-lock-groups.security.md
@@ -0,0 +1,2 @@
+Reject dependency group names that are not normalized, in the lock and in the selected groups. A name such as
+`project_runtime` matched no lock marker, so its packages skipped the cooling and PyPI checks.
diff --git a/changelog.d/+python-hook-dependencies.security.md b/changelog.d/+python-hook-dependencies.security.md
new file mode 100644
index 0000000..35b7e59
--- /dev/null
+++ b/changelog.d/+python-hook-dependencies.security.md
@@ -0,0 +1,2 @@
+Reject `additional_dependencies` on hooks whose configured `language` is not Python. Only Python hook dependencies get
+exact, cooled pins, so a node or other hook would install them unpinned.
diff --git a/changelog.d/+recheck-earlier-heads.bugfix.md b/changelog.d/+recheck-earlier-heads.bugfix.md
new file mode 100644
index 0000000..e036a73
--- /dev/null
+++ b/changelog.d/+recheck-earlier-heads.bugfix.md
@@ -0,0 +1,2 @@
+Rerun a waiting hook cooldown check once any head of the pull request branch was observed seven days ago. The check
+accepts revisions seen on earlier heads, so a later push no longer delays the rerun.
diff --git a/changelog.d/+remove-nab-primary-lock.breaking.md b/changelog.d/+remove-nab-primary-lock.breaking.md
new file mode 100644
index 0000000..1d5e987
--- /dev/null
+++ b/changelog.d/+remove-nab-primary-lock.breaking.md
@@ -0,0 +1,3 @@
+Remove the Nab primary lock path and the `lock-groups` input of `refresh-release-lock` and `upgrade-release-tools`.
+`pylock.toml` must now come from `release-creator locks`; relock a Nab primary lock before re-pinning. Nab remains for
+secondary `additional-lock` matrices.
diff --git a/changelog.d/+remove-upgrade-resolver.breaking.md b/changelog.d/+remove-upgrade-resolver.breaking.md
new file mode 100644
index 0000000..fbaaa2c
--- /dev/null
+++ b/changelog.d/+remove-upgrade-resolver.breaking.md
@@ -0,0 +1,2 @@
+Remove the `upgrade-resolver` input of `refresh-release-lock` and `upgrade-release-tools`. The shared uv resolver pin is
+upgraded only in github-workflows, which the Action now detects from the repository name.
diff --git a/changelog.d/+resolver-group.bugfix.md b/changelog.d/+resolver-group.bugfix.md
new file mode 100644
index 0000000..42cd91a
--- /dev/null
+++ b/changelog.d/+resolver-group.bugfix.md
@@ -0,0 +1,2 @@
+Select the new uv resolver only from the `release-resolver` group, so a different uv in the test group no longer stops
+the upgrade, and widen the pin from its current version.
diff --git a/changelog.d/+resolver-upgrade-patch.bugfix.md b/changelog.d/+resolver-upgrade-patch.bugfix.md
new file mode 100644
index 0000000..c6f4ccb
--- /dev/null
+++ b/changelog.d/+resolver-upgrade-patch.bugfix.md
@@ -0,0 +1,2 @@
+Let the weekly github-workflows update publish a new uv resolver. The writer now accepts a change of the exact
+`release-resolver` uv pin when the refreshed lock selects that version.
diff --git a/changelog.d/+root-hook-config.security.md b/changelog.d/+root-hook-config.security.md
new file mode 100644
index 0000000..af36894
--- /dev/null
+++ b/changelog.d/+root-hook-config.security.md
@@ -0,0 +1,3 @@
+Run every prek command with `--config .pre-commit-config.yaml`, and reject a `prek.toml`, a `.pre-commit-config.yml`, or
+a nested `.pre-commit-config.yaml` in the hook cooldown and frozen hook checks. prek used to run those files without any
+revision, cooldown, or dependency check.
diff --git a/changelog.d/+skip-shared-lock-recheck.performance.md b/changelog.d/+skip-shared-lock-recheck.performance.md
new file mode 100644
index 0000000..7fadb54
--- /dev/null
+++ b/changelog.d/+skip-shared-lock-recheck.performance.md
@@ -0,0 +1,2 @@
+Skip the cooling check of github-workflows' own lock when installing shared tools, and the second PyPI check of a
+secondary lock's build selection when `export-build-constraints` already verified that lock.
diff --git a/changelog.d/+tagged-hook-order.security.md b/changelog.d/+tagged-hook-order.security.md
new file mode 100644
index 0000000..44bbcf7
--- /dev/null
+++ b/changelog.d/+tagged-hook-order.security.md
@@ -0,0 +1,2 @@
+Prove the age of tagged hook revisions before `release-creator hooks --check` builds hook repositories, so build
+backends from an unproven revision do not run first.
diff --git a/changelog.d/+tagged-source-main.security.md b/changelog.d/+tagged-source-main.security.md
new file mode 100644
index 0000000..130ef52
--- /dev/null
+++ b/changelog.d/+tagged-source-main.security.md
@@ -0,0 +1 @@
+Check that the release tag points at the checkout and is on `main` before the tagged source check runs hooks.
diff --git a/changelog.d/+verify-cutoff-patch.breaking.md b/changelog.d/+verify-cutoff-patch.breaking.md
new file mode 100644
index 0000000..aa5448e
--- /dev/null
+++ b/changelog.d/+verify-cutoff-patch.breaking.md
@@ -0,0 +1,2 @@
+Rename the `verify-nab-cutoff-patch` Action to `verify-cutoff-patch`, since it checks release-creator cutoffs and the
+shared resolver pin as well as Nab cutoffs.
diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml
index b4bdbc9..1601a71 100644
--- a/check-hook-update-cooldown/action.yml
+++ b/check-hook-update-cooldown/action.yml
@@ -23,10 +23,10 @@ runs:
PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH")
HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH")
case "$MINIMUM_DAYS" in
- '' | 0* | *[!0-9]*) exit 1 ;;
+ '' | 0* | *[!0-9]*) MINIMUM_DAYS=0 ;;
esac
if ((MINIMUM_DAYS < 7)); then
- echo '::error::hook cooldown must be at least seven days' >&2
+ echo '::error::minimum-days must be a whole number of at least seven' >&2
exit 1
fi
gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" >"$RUNNER_TEMP/hook-update-pr.json"
@@ -35,9 +35,17 @@ runs:
.head.sha == $head and
(.user.login | type == "string")
' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null
- gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' \
+ gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[] | [.status, .filename] | @tsv' \
>"$RUNNER_TEMP/hook-update-files"
- if grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-update-files"; then
+ while IFS=$'\t' read -r status file; do
+ name=${file##*/}
+ if [[ "$status" != removed && ("$name" == prek.toml || "$name" == .pre-commit-config.yml ||
+ ("$name" == .pre-commit-config.yaml && "$file" != .pre-commit-config.yaml)) ]]; then
+ echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: $file" >&2
+ exit 1
+ fi
+ done <"$RUNNER_TEMP/hook-update-files"
+ if cut -f2 "$RUNNER_TEMP/hook-update-files" | grep -Fxq .pre-commit-config.yaml; then
if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then
jq -e --arg repository "$REPOSITORY" '.head.repo.full_name == $repository' \
"$RUNNER_TEMP/hook-update-pr.json" >/dev/null || {
diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml
new file mode 100644
index 0000000..60a40a5
--- /dev/null
+++ b/check-tagged-source/action.yml
@@ -0,0 +1,55 @@
+name: Check tagged source
+description: Check the tag, the project lock, and the frozen hooks of a tagged checkout, then run its cooled hooks
+inputs:
+ keep-uv-lock:
+ description: Check a committed native uv.lock alongside pylock.toml
+ default: "false"
+ 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: ""
+runs:
+ using: composite
+ steps:
+ - name: Verify the exact release tag
+ shell: bash
+ 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}")" = "$(git rev-parse HEAD)"
+ git merge-base --is-ancestor HEAD origin/main
+ - uses: $/refresh-release-lock
+ with:
+ mode: check
+ keep-uv-lock: ${{ inputs.keep-uv-lock }}
+ 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 }}
+ - uses: $/install-locked-tools
+ with:
+ groups: release-check
+ venv: ${{ runner.temp }}/source-check-tools
+ - uses: $/verify-frozen-hooks
+ - name: Run tagged source hooks
+ shell: bash
+ run: '"$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" run --all-files'
diff --git a/install-local-release-creator/action.yml b/install-local-release-creator/action.yml
new file mode 100644
index 0000000..08a557a
--- /dev/null
+++ b/install-local-release-creator/action.yml
@@ -0,0 +1,20 @@
+name: Install local release-creator
+description: Build release-creator from the checkout into .venv and put its command first on PATH
+inputs:
+ build-constraints:
+ description: Hashed build constraints from install-locked-tools
+ required: true
+runs:
+ using: composite
+ steps:
+ - name: Build and install the local release-creator wheel
+ shell: bash
+ env:
+ BUILD_CONSTRAINTS: ${{ inputs.build-constraints }}
+ run: |
+ set -euo pipefail
+ uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \
+ --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" .
+ uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl
+ uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \
+ "$GITHUB_ACTION_PATH/../tools/expose_locked_commands.py" .venv
diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml
index f626c0a..abef02d 100644
--- a/install-locked-tools/action.yml
+++ b/install-locked-tools/action.yml
@@ -62,14 +62,8 @@ runs:
run: |
set -euo pipefail
case "$LOCK_SOURCE" in
- caller)
- lock=pylock.toml
- lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml)
- ;;
- shared)
- lock="$GITHUB_ACTION_PATH/../pylock.toml"
- lock_check=()
- ;;
+ caller) lock=pylock.toml ;;
+ shared) lock="$GITHUB_ACTION_PATH/../pylock.toml" ;;
*)
echo "::error::unknown lock source: $LOCK_SOURCE" >&2
exit 1
@@ -86,16 +80,19 @@ runs:
exit 1
}
uv venv --allow-existing --python 3.15 "$VENV"
- source_check=()
- if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi
- uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \
- "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" \
- "${lock_check[@]}" "${source_check[@]}"
+ selection=("${groups[@]}")
printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups"
else
+ selection=(--build-constraints)
+ fi
+ # The shared lock is checked in github-workflows CI; only a caller lock needs the cooling and PyPI checks.
+ if [[ "$LOCK_SOURCE" == caller ]]; then
+ source_check=()
+ if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi
uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \
- "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" --build-constraints \
- "${lock_check[@]}"
+ "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${selection[@]}" \
+ --shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml \
+ "${source_check[@]}"
fi
printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT"
- name: Verify additional release lock
@@ -124,11 +121,13 @@ runs:
env:
BUILD_LOCK: ${{ steps.selection.outputs.lock }}
CONSTRAINTS_LOCK: ${{ inputs.constraints-lock }}
+ VERIFY_LOCK: ${{ inputs.verify-lock }}
TARGET_PLATFORM: ${{ inputs.target-platform }}
TARGETS: ${{ inputs.targets }}
run: |
set -euo pipefail
- if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$BUILD_LOCK" ]]; then
+ # A verified lock already had its build selection checked against PyPI with its groups.
+ if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$BUILD_LOCK" && "$CONSTRAINTS_LOCK" != "$VERIFY_LOCK" ]]; then
lock_check=()
if [[ "$CONSTRAINTS_LOCK" != "$GITHUB_ACTION_PATH/../pylock.toml" ]]; then
lock_check=(--verify-pypi --project pyproject.toml)
diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml
index ad5beb9..82edc19 100644
--- a/prepare-towncrier/action.yml
+++ b/prepare-towncrier/action.yml
@@ -99,13 +99,7 @@ runs:
env:
CHANGELOG: ${{ steps.config.outputs.changelog }}
FORMAT: ${{ steps.config.outputs.format }}
- UV_EXCLUDE_NEWER: 7 days
- UV_NO_CONFIG: "1"
- UV_NO_SOURCES: "1"
run: |
- 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
set -euo pipefail
case "$FORMAT" in
markdown) formatter=mdformat ;;
@@ -115,8 +109,9 @@ runs:
exit 1
;;
esac
- if ! prek run "$formatter" --files "$CHANGELOG"; then
- prek run "$formatter" --files "$CHANGELOG"
+ prek=$GITHUB_ACTION_PATH/../tools/cooled_prek.sh
+ if ! "$prek" run "$formatter" --files "$CHANGELOG"; then
+ "$prek" run "$formatter" --files "$CHANGELOG"
fi
- name: Check generated files
if: inputs.phase != 'build'
diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml
index da545ea..5db4662 100644
--- a/publish-jetbrains-marketplace/action.yml
+++ b/publish-jetbrains-marketplace/action.yml
@@ -90,10 +90,11 @@ runs:
set -euo pipefail
encoded_tag=$(jq -rn --arg tag "$TAG" '$tag | @uri')
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs?branch=$encoded_tag&per_page=100" \
- --jq '.workflow_runs[] | [.id, .head_branch, .head_sha] | @tsv' >"$RUNNER_TEMP/marketplace-runs.tsv"
+ --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; do
- [[ "$branch" == "$TAG" ]] || continue
+ while IFS=$'\t' read -r run branch sha 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[] |
select((.name == \"Publish signed ZIP\" or .name == \"Upload signed ZIP\" or
@@ -111,19 +112,22 @@ runs:
receipt_name="marketplace-acceptance-$run-$attempt"
receipt_id=$(awk -F '\t' -v name="$receipt_name" '$1 == name && $3 == "false" { print $2 }' \
"$RUNNER_TEMP/marketplace-artifacts-$run.tsv")
- if [[ -z "$receipt_id" || "$receipt_id" == *$'\n'* || "$prior" == 1 ]]; then
+ if [[ -z "$receipt_id" || "$receipt_id" == *$'\n'* ]]; then
echo '::error::Marketplace upload may already be accepted.' \
"Reconcile run $run attempt $attempt manually." >&2
exit 1
fi
prior=1
- {
- echo "run=$run"
- echo "receipt-name=$receipt_name"
- echo "artifacts=$RUNNER_TEMP/marketplace-artifacts-$run.tsv"
- } >>"$GITHUB_OUTPUT"
+ receipt_run=$run
+ receipt=$receipt_name
done <"$RUNNER_TEMP/marketplace-attempts.txt"
done <"$RUNNER_TEMP/marketplace-runs.tsv"
+ if ((prior)); then
+ {
+ echo "run=$receipt_run"
+ echo "receipt-name=$receipt"
+ } >>"$GITHUB_OUTPUT"
+ fi
echo "accepted=$prior" >>"$GITHUB_OUTPUT"
- name: Check earlier release deployments
if: steps.earlier.outputs.accepted == '0'
@@ -181,6 +185,28 @@ runs:
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
+ env:
+ GH_TOKEN: ${{ inputs.github-token }}
+ RUN: ${{ steps.earlier.outputs.run }}
+ RECEIPT_NAME: ${{ steps.earlier.outputs.receipt-name }}
+ run: |
+ set -euo pipefail
+ 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'
+ 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
if: steps.earlier.outputs.accepted == '1'
shell: bash
@@ -193,33 +219,31 @@ runs:
ARCHIVE: ${{ inputs.archive }}
EXPECTED_SHA: ${{ inputs.sha256 }}
NOTES_FILE: ${{ inputs.notes-file }}
- RUN: ${{ steps.earlier.outputs.run }}
- RECEIPT_NAME: ${{ steps.earlier.outputs.receipt-name }}
- ARTIFACTS: ${{ steps.earlier.outputs.artifacts }}
run: |
set -euo pipefail
- mkdir "$RUNNER_TEMP/prior-acceptance" "$RUNNER_TEMP/prior-signed" "$RUNNER_TEMP/prior-notes"
- gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name "$RECEIPT_NAME" \
- --dir "$RUNNER_TEMP/prior-acceptance"
+ mkdir "$RUNNER_TEMP/prior-signed" "$RUNNER_TEMP/prior-notes"
receipt="$RUNNER_TEMP/prior-acceptance/marketplace-acceptance.json"
- [[ -f "$receipt" && ! -L "$receipt" ]] || exit 1
jq -e --arg repository "$GITHUB_REPOSITORY" --arg tag "$TAG" --arg commit "$COMMIT" \
- --arg version "${TAG#v}" --argjson runId "$RUN" --argjson pluginId "$PLUGIN_ID" \
+ --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 == $runId and .pluginId == $pluginId and .version == $version 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
+ source_run=$(jq -r .sourceRunId "$receipt")
+ 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
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=$(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
- gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name plugin-distribution \
+ gh run download "$source_run" --repo "$GITHUB_REPOSITORY" --name plugin-distribution \
--dir "$RUNNER_TEMP/prior-signed"
- gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name release-notes \
+ 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" &&
diff --git a/pyproject.toml b/pyproject.toml
index b2100fe..a25099e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,41 +40,6 @@ test = [
"ty",
]
-[tool.nab]
-mode = "universal"
-uploaded-prior-to = "P7D"
-decision-order = "stable"
-base-group = "project-runtime"
-default-groups = []
-
-[tool.nab.matrix]
-python = ">=3.15,<3.16"
-platforms = [ "linux_x86_64", "linux_aarch64", "macos_x86_64", "macos_arm64", "windows_amd64", "windows_arm64" ]
-
-[tool.nab.packages.nab]
-uploaded-prior-to = "2026-10-18T05:45:09Z"
-
-[tool.nab.packages.nab-index]
-uploaded-prior-to = "2026-10-18T05:45:09Z"
-
-[tool.nab.packages.nab-markersets]
-uploaded-prior-to = "2026-10-18T05:45:09Z"
-
-[tool.nab.packages.nab-project]
-uploaded-prior-to = "2026-10-18T05:45:09Z"
-
-[tool.nab.packages.nab-provider]
-uploaded-prior-to = "2026-10-18T05:45:09Z"
-
-[tool.nab.packages.nab-resolver]
-uploaded-prior-to = "2026-10-18T05:45:09Z"
-
-[tool.nab.packages.release-creator]
-uploaded-prior-to = "2026-10-24T09:08:40Z"
-
-[tool.nab.packages.uv]
-uploaded-prior-to = "2026-10-22T01:32:34Z"
-
[tool.release-creator.locks.exclude-newer-package]
nab = "2026-10-18T05:45:09Z"
nab-index = "2026-10-18T05:45:09Z"
@@ -91,7 +56,9 @@ line-length = 120
[tool.coverage.run]
branch = true
patch = [ "subprocess" ]
-source_dirs = [ "tools" ]
+source_dirs = [ "tests", "tools" ]
+# The bootstrap starts coverage in subprocesses, so it runs before measurement begins.
+omit = [ "*/coverage_bootstrap/*" ]
[tool.coverage.report]
fail_under = 100
diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml
index d33c8e8..5bd8870 100644
--- a/recheck-hook-update-cooldown/action.yml
+++ b/recheck-hook-update-cooldown/action.yml
@@ -41,13 +41,14 @@ runs:
branch=$(jq -r .branch <<<"$pull")
head_repository=$(jq -r .headRepository <<<"$pull")
opened=$(jq -r '.opened | fromdateiso8601' <<<"$pull")
+ # The check also accepts revisions observed on earlier heads, so maturity counts every head of the branch.
runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW/runs" \
- -f event=pull_request -f head_sha="$head" -f per_page=100 |
- jq -sc --arg repository "$head_repository" --arg branch "$branch" --arg head "$head" \
+ -f event=pull_request -f branch="$branch" -f per_page=100 |
+ jq -sc --arg repository "$head_repository" --arg branch "$branch" \
--argjson number "$number" --argjson opened "$opened" '
[.[].workflow_runs[] | select(
.event == "pull_request" and .head_repository.full_name == $repository and
- .head_branch == $branch and .head_sha == $head and
+ .head_branch == $branch and
(any(.pull_requests[]; .number == $number) or
(.pull_requests == [] and (.created_at | fromdateiso8601) >= $opened))
)]')
@@ -56,7 +57,9 @@ runs:
<<<"$runs" >/dev/null; then
continue
fi
- run=$(jq -c '[.[] | select(.status == "completed")] | sort_by(.created_at) | last // empty' <<<"$runs")
+ run=$(jq -c --arg head "$head" '
+ [.[] | select(.head_sha == $head and .status == "completed")] | sort_by(.created_at) | last // empty
+ ' <<<"$runs")
if [[ -z "$run" ]]; then
continue
fi
diff --git a/refresh-mise-pin/action.yml b/refresh-mise-pin/action.yml
index 9a9e352..f71b4dc 100644
--- a/refresh-mise-pin/action.yml
+++ b/refresh-mise-pin/action.yml
@@ -47,11 +47,27 @@ runs:
uv run --no-project --python "$RUNNER_TEMP/mise-editor" python \
"$GITHUB_ACTION_PATH/../tools/refresh_mise_pin.py" select \
"$RUNNER_TEMP/mise-releases.jsonl" "$GITHUB_OUTPUT"
+ - name: Download the selected mise executable
+ shell: bash
+ env:
+ GH_TOKEN: ${{ inputs.github-token }}
+ VERSION: ${{ steps.select.outputs.version }}
+ EXPECTED_SHA256: ${{ steps.select.outputs.sha256 }}
+ run: |
+ set -euo pipefail
+ asset="mise-v$VERSION-linux-x64"
+ mkdir -p "$RUNNER_TEMP/mise-download" "$RUNNER_TEMP/refresh-mise-pin/bin" "$RUNNER_TEMP/refresh-mise-pin-cwd"
+ gh release download "v$VERSION" --repo jdx/mise --pattern "$asset" --dir "$RUNNER_TEMP/mise-download"
+ printf '%s %s\n' "$EXPECTED_SHA256" "$RUNNER_TEMP/mise-download/$asset" | sha256sum --check --strict
+ install -m 0755 "$RUNNER_TEMP/mise-download/$asset" "$RUNNER_TEMP/refresh-mise-pin/bin/mise"
+ printf '%s' "$VERSION" >"$RUNNER_TEMP/refresh-mise-pin/bin/mise-version"
+ # mise-action checks an existing binary against the signed checksums before it runs it.
- name: Install signature-verified mise
uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2
with:
version: ${{ steps.select.outputs.version }}
mise_dir: ${{ runner.temp }}/refresh-mise-pin
+ working_directory: ${{ runner.temp }}/refresh-mise-pin-cwd
cache: "false"
install: "false"
env: "false"
diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml
index f959a2b..be30d3c 100644
--- a/refresh-release-lock/action.yml
+++ b/refresh-release-lock/action.yml
@@ -1,9 +1,6 @@
name: Refresh a release-tool lock
-description: Refresh or check a full project lock and optional Nab matrix lock
+description: Refresh or check a release-creator project lock and optional Nab matrix lock
inputs:
- lock-groups:
- description: Space-separated dependency groups for legacy Nab locks
- default: ""
additional-lock:
description: Optional secondary pylock basename for a separate build matrix
default: ""
@@ -28,9 +25,6 @@ inputs:
keep-uv-lock:
description: Refresh and check uv.lock alongside pylock.toml for projects that use both
default: "false"
- upgrade-resolver:
- description: Upgrade the shared uv pin before refreshing this repository's lock
- default: "false"
outputs:
changed:
description: Whether the lock or expired package cutoffs differ from the checkout
@@ -38,8 +32,7 @@ outputs:
runs:
using: composite
steps:
- - id: producer
- name: Identify lock producer
+ - name: Check lock inputs
shell: bash
env:
ADDITIONAL_LOCK: ${{ inputs.additional-lock }}
@@ -57,30 +50,23 @@ runs:
echo '::error::secondary matrix inputs require additional-lock' >&2
exit 1
fi
- provider=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml --provider)
- if [[ "$KEEP_UV_LOCK" == true && "$provider" != release-creator ]]; then
- echo '::error::keep-uv-lock requires a release-creator lock' >&2
- exit 1
- fi
+ python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml
if [[ "$KEEP_UV_LOCK" == true ]] && ! git ls-files --error-unmatch uv.lock >/dev/null 2>&1; then
echo '::error::keep-uv-lock requires a committed uv.lock' >&2
exit 1
fi
- echo "name=$provider" >>"$GITHUB_OUTPUT"
- echo "groups=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml)" >>"$GITHUB_OUTPUT"
- uses: $/install-locked-tools
+ if: inputs.mode == 'refresh' || inputs.additional-lock != ''
with:
groups: lock-tools
lock-source: shared
venv: ${{ runner.temp }}/lock-tools
- uses: $/install-locked-tools
- if: steps.producer.outputs.name == 'release-creator'
with:
groups: release-notes
lock-source: shared
venv: ${{ runner.temp }}/project-lock-tools
- uses: $/install-locked-tools
- if: steps.producer.outputs.name == 'release-creator'
with:
groups: release-resolver
lock-source: shared
@@ -94,52 +80,11 @@ runs:
run: >-
uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/expire_cutoffs.py" pyproject.toml
- name: Widen the shared uv pin
- if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true'
- shell: bash
- run: |
- set -euo pipefail
- [[ "$GITHUB_REPOSITORY" == tox-dev/github-workflows ]] || {
- echo '::error::only github-workflows can upgrade the shared resolver' >&2
- exit 1
- }
- uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \
- "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" widen pyproject.toml pylock.toml
- - name: Resolve selected Nab groups
- if: steps.producer.outputs.name == 'nab'
+ if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows'
shell: bash
- env:
- LOCK_GROUPS: ${{ inputs.lock-groups || steps.producer.outputs.groups }}
- MODE: ${{ inputs.mode }}
- run: |
- set -euo pipefail
- groups=()
- while IFS= read -r line; do
- read -ra selected <<<"$line"
- groups+=("${selected[@]}")
- done <<<"$LOCK_GROUPS"
- if ((${#groups[@]} == 0)); then
- echo '::error::at least one lock group is required' >&2
- exit 1
- fi
- case "$MODE" in
- check)
- refresh=()
- cp pylock.toml "$RUNNER_TEMP/pylock-before-check.toml"
- ;;
- refresh) refresh=(--upgrade) ;;
- *)
- echo "::error::invalid lock mode: $MODE" >&2
- exit 1
- ;;
- esac
- uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --no-cache --groups "${groups[@]}" \
- --output pylock.toml "${refresh[@]}"
- if [[ "$MODE" == check ]]; then
- python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \
- "$RUNNER_TEMP/pylock-before-check.toml" pylock.toml
- fi
+ run: >-
+ uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" widen pyproject.toml pylock.toml
- name: Resolve all project groups and extras
- if: steps.producer.outputs.name == 'release-creator'
shell: bash
env:
MODE: ${{ inputs.mode }}
@@ -158,17 +103,17 @@ runs:
;;
esac
- name: Pin the updated uv resolver
- if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true'
+ if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows'
shell: bash
run: >-
uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" pin pyproject.toml pylock.toml
- uses: $/install-locked-tools
- if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true'
+ if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows'
with:
groups: release-resolver
venv: ${{ runner.temp }}/updated-release-resolver
- name: Resolve with the updated uv resolver
- if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true'
+ if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows'
shell: bash
env:
KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }}
diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml
index 050f998..85fce42 100644
--- a/render-release-notes/action.yml
+++ b/render-release-notes/action.yml
@@ -1,5 +1,5 @@
name: Render release notes
-description: Validate a tagged changelog and render its GitHub Release body
+description: Validate a tagged changelog and render its GitHub Release body to $RUNNER_TEMP/release-notes.md
inputs:
tag:
description: Existing release tag
@@ -13,9 +13,6 @@ inputs:
artifact-label:
description: Link text for the published artifact
default: PyPI release
- output:
- description: Path for the rendered Markdown body
- required: true
generated-notes:
description: Existing GitHub generated-notes file with contributor metadata
default: ""
@@ -49,7 +46,6 @@ runs:
CHANGELOG: ${{ inputs.changelog }}
ARTIFACT_URL: ${{ inputs.artifact-url }}
ARTIFACT_LABEL: ${{ inputs.artifact-label }}
- OUTPUT: ${{ inputs.output }}
GENERATED_NOTES: ${{ inputs.generated-notes }}
PREVIOUS: ${{ steps.previous.outputs.previous }}
run: |
@@ -64,4 +60,4 @@ runs:
if [[ -n "$GENERATED_NOTES" ]]; then
args+=(--generated-notes "$GENERATED_NOTES")
fi
- release-creator notes "${args[@]}" >"$OUTPUT"
+ release-creator notes "${args[@]}" >"$RUNNER_TEMP/release-notes.md"
diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py
index feeceba..3534fe6 100644
--- a/tests/check_hook_update_cooldown/test_cooldown.py
+++ b/tests/check_hook_update_cooldown/test_cooldown.py
@@ -9,40 +9,10 @@
from typing import Literal
import pytest
-import yaml
from conftest import action
from pytest_httpserver import HTTPServer
-def test_action_uses_scoped_job_token() -> None:
- action = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text())
- assert [step["env"]["GH_TOKEN"] for step in action["runs"]["steps"] if "GH_TOKEN" in step.get("env", {})] == [
- "${{ github.token }}",
- "${{ github.token }}",
- "${{ github.token }}",
- "${{ github.token }}",
- ]
- assert action["runs"]["steps"][0]["env"]["RELEASE_APP_CLIENT_ID"] == "${{ inputs.release-app-client-id }}"
- workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text())
- caller = workflow["jobs"]["hook-cooldown"]["steps"][0]
- assert caller["with"]["release-app-client-id"] == "${{ vars.RELEASE_APP_CLIENT_ID }}"
-
-
-def test_action_checks_app_hook_closure_from_pull_request_head() -> None:
- steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text())[
- "runs"
- ]["steps"]
- source = next(step for step in steps if step.get("name") == "Read the base and proposed hook configurations")
- closure = next(step for step in steps if step.get("name") == "Verify managed hook dependency closure")
-
- assert '"repos/$head_repository/contents/.pre-commit-config.yaml?ref=$HEAD_SHA"' in source["run"]
- assert closure["if"] == "steps.identity.outputs.changed == 'true'"
- assert 'cp "$RUNNER_TEMP/proposed-hooks.yaml" "$RUNNER_TEMP/hook-closure/.pre-commit-config.yaml"' in closure["run"]
- assert "release-creator hooks" in closure["run"]
- assert '--check --minimum-days "$MINIMUM_DAYS"' in closure["run"]
- assert closure["env"]["MINIMUM_DAYS"] == "${{ inputs.minimum-days }}"
-
-
@pytest.mark.parametrize(
("author", "revision", "frozen_base", "accepted"),
[
@@ -332,6 +302,39 @@ def test_cooldown_checks_human_local_hook_dependencies(
assert (tmp_path / "hook-closure/.pre-commit-config.yaml").read_text() == proposed
+@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"),
+ pytest.param("sub/.pre-commit-config.yml", id="nested-yml"),
+ ],
+)
+def test_cooldown_rejects_other_hook_configuration(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], path: str
+) -> None:
+ server, environment = github_api
+ expect_pull(server, author="gaborbernat", hook_update=False, other_files=(("added", path),))
+
+ result = run_cooldown(action_scripts, tmp_path, environment)
+
+ assert result.returncode != 0
+ assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr
+
+
+def test_cooldown_allows_removing_other_hook_configuration(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ expect_pull(server, author="gaborbernat", hook_update=False, other_files=(("removed", "prek.toml"),))
+
+ result = run_cooldown(action_scripts, tmp_path, environment)
+
+ assert result.returncode == 0, result.stderr
+
+
def test_cooldown_skips_bot_without_hook_changes(
tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
) -> None:
@@ -454,6 +457,26 @@ def test_cooldown_accepts_managed_pin_with_python_marker(
assert (tmp_path / "hook-closure/.pre-commit-config.yaml").read_text() == source
+@pytest.mark.parametrize(
+ ("author", "client_id"),
+ [
+ pytest.param("gaborbernat", "Iv1.release", id="human-author"),
+ pytest.param("release-app[bot]", "", id="unconfigured-app"),
+ ],
+)
+def test_cooldown_requires_configured_app_on_updater_branch(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], author: str, client_id: str
+) -> None:
+ server, environment = github_api
+ expect_pull(server, author=author, branch="automation/update-release-tools")
+
+ result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": client_id})
+
+ assert result.returncode != 0
+ assert "release tool branch requires the configured release App" in result.stderr
+ assert "/api/v3/apps/release-app" not in [request.path for request, _ in server.log]
+
+
@pytest.mark.parametrize(
("client_id", "head_repository"),
[
@@ -527,6 +550,7 @@ def test_cooldown_rejects_invalid_minimum(
server, environment = github_api
result = run_cooldown(action_scripts, tmp_path, environment, minimum_days=minimum_days)
assert result.returncode != 0
+ assert "minimum-days must be a whole number of at least seven" in result.stderr
assert not server.log
@@ -742,6 +766,7 @@ def expect_pull(
associated_pull_request: int | None = 7,
observed_repository: str | None = None,
workflow_path: str = ".github/workflows/check.yaml",
+ other_files: tuple[tuple[str, str], ...] = (),
) -> None:
server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json(
{
@@ -754,7 +779,10 @@ def expect_pull(
}
)
server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json(
- [{"filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}]
+ [
+ {"status": "modified", "filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"},
+ *({"status": status, "filename": filename} for status, filename in other_files),
+ ]
)
if hook_update:
if author.endswith("[bot]") and branch == "automation/update-release-tools":
diff --git a/tests/check_hook_update_cooldown/test_frozen_hooks.py b/tests/check_hook_update_cooldown/test_frozen_hooks.py
index dbb5321..25cfaa6 100644
--- a/tests/check_hook_update_cooldown/test_frozen_hooks.py
+++ b/tests/check_hook_update_cooldown/test_frozen_hooks.py
@@ -32,9 +32,19 @@ def test_frozen_hook_revision(tmp_path: Path, revision: str, accepted: bool) ->
assert "full lowercase commit SHA" in result.stderr
-def test_frozen_hook_revision_allows_local_hook(tmp_path: Path) -> None:
+@pytest.mark.parametrize(
+ "hooks",
+ [
+ pytest.param("[]", id="no-hooks"),
+ pytest.param(
+ "[{id: lint, language: python, additional_dependencies: [ruff==0.1.0]}]", id="python-dependencies"
+ ),
+ pytest.param("[{id: lint, language: node}]", id="node-without-dependencies"),
+ ],
+)
+def test_frozen_hook_revision_allows_local_hook(tmp_path: Path, hooks: str) -> None:
config = tmp_path / ".pre-commit-config.yaml"
- config.write_text("repos:\n - repo: local\n hooks: []\n")
+ config.write_text(f"repos:\n - repo: local\n hooks: {hooks}\n")
result = subprocess.run(
[sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_frozen_hooks.py"), str(config)],
text=True,
@@ -50,6 +60,13 @@ def test_frozen_hook_revision_allows_local_hook(tmp_path: Path) -> None:
[
pytest.param("repos: null\n", "repository list", id="missing-list"),
pytest.param("repos:\n - local\n", "mapping with a URL", id="invalid-repository"),
+ pytest.param("repos:\n - repo: local\n hooks: [x]\n", "list of mappings", id="invalid-hooks"),
+ pytest.param(
+ "repos:\n - repo: local\n hooks:\n - id: lint\n language: node\n"
+ " additional_dependencies: [prettier@latest]\n",
+ "only Python hooks may declare additional_dependencies: lint",
+ id="node-dependencies",
+ ),
],
)
def test_frozen_hook_revision_rejects_malformed_config(tmp_path: Path, config_text: str, message: str) -> None:
diff --git a/tests/check_hook_update_cooldown/test_hook_config.py b/tests/check_hook_update_cooldown/test_hook_config.py
index d4310e5..ba82092 100644
--- a/tests/check_hook_update_cooldown/test_hook_config.py
+++ b/tests/check_hook_update_cooldown/test_hook_config.py
@@ -2,15 +2,13 @@
import json
import runpy
-import subprocess
-import sys
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from io import BytesIO
from pathlib import Path
from typing import cast
from unittest.mock import MagicMock
-from urllib.request import urlopen
+from urllib.request import Request, urlopen
import pytest
from pytest_mock import MockerFixture
@@ -191,7 +189,18 @@ def test_hook_config_rejects_human_change_without_repositories(
check_config(base, proposed, False, 7, human=True)
-def test_human_cannot_change_managed_pin(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None:
+@pytest.mark.parametrize(
+ ("uploaded_days", "accepted"),
+ [pytest.param(10, True, id="cooled"), pytest.param(1, False, id="recent")],
+)
+def test_human_managed_pin_change_needs_cooled_pypi_pin(
+ configs: tuple[Path, Path],
+ check_config: Callable[..., None],
+ pypi_file: dict[str, object],
+ pypi_request: MagicMock,
+ uploaded_days: int,
+ accepted: bool,
+) -> None:
base, proposed = configs
base.write_text(
base.read_text().replace(
@@ -199,33 +208,41 @@ def test_human_cannot_change_managed_pin(configs: tuple[Path, Path], check_confi
)
)
proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0"))
+ pypi_file["upload_time_iso_8601"] = (datetime.now(UTC) - timedelta(days=uploaded_days)).isoformat()
+ pypi_request.return_value = _response("example", pypi_file)
- with pytest.raises(ValueError, match="managed hook dependencies require the release App"):
+ if accepted:
check_config(base, proposed, False, 7, human=True)
+ else:
+ with pytest.raises(ValueError, match="uncooled or untrusted PyPI artifact"):
+ check_config(base, proposed, False, 7, human=True)
+ assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/example/1.2.0/json"
-@pytest.mark.parametrize(
- ("proposed_hooks", "accepted"),
- [
- pytest.param(
- " - id: second\n - id: first\n additional_dependencies:\n"
- ' - "other==1.2.0" # release-creator managed\n',
- True,
- id="reorder-same-owner",
- ),
- pytest.param(
- " - id: first\n - id: second\n additional_dependencies:\n"
- ' - "other==1.2.0" # release-creator managed\n',
- False,
- id="relocate-pin",
- ),
- ],
-)
-def test_human_managed_pin_stays_with_its_hook(
- configs: tuple[Path, Path],
- check_config: Callable[..., None],
- proposed_hooks: str,
- accepted: bool,
+def test_human_can_add_python_hook_next_to_managed_pins(
+ configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock
+) -> None:
+ base, proposed = configs
+ prefix = "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n"
+ first = (
+ ' - id: first\n additional_dependencies:\n - "other==1.2.0" # release-creator managed\n'
+ )
+ base.write_text(prefix + first)
+ proposed.write_text(
+ prefix
+ + first
+ + ' - id: second\n additional_dependencies:\n - "example==1.2.0" # release-creator managed\n'
+ )
+
+ check_config(base, proposed, False, 7, human=True)
+
+ assert [call.args[0].full_url for call in pypi_request.call_args_list] == [
+ "https://pypi.org/pypi/example/1.2.0/json"
+ ]
+
+
+def test_human_keeps_existing_managed_pins_without_pypi(
+ configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock
) -> None:
base, proposed = configs
prefix = "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n"
@@ -235,13 +252,15 @@ def test_human_managed_pin_stays_with_its_hook(
+ ' - "other==1.2.0" # release-creator managed\n'
+ " - id: second\n"
)
- proposed.write_text(prefix + proposed_hooks)
+ proposed.write_text(
+ prefix
+ + " - id: second\n - id: first\n additional_dependencies:\n"
+ + ' - "other==1.2.0" # release-creator managed\n'
+ )
- if accepted:
- check_config(base, proposed, False, 7, human=True)
- else:
- with pytest.raises(ValueError, match="managed hook dependencies require the release App"):
- check_config(base, proposed, False, 7, human=True)
+ check_config(base, proposed, False, 7, human=True)
+
+ pypi_request.assert_not_called()
@pytest.mark.parametrize(
@@ -293,27 +312,21 @@ def test_human_bootstrap_rejects_ambiguous_hook_identity(
check_config(base, proposed, False, 7, human=True)
-def test_human_can_bootstrap_managed_pins_from_release_migration() -> None:
+def test_human_can_bootstrap_managed_pins_from_release_migration(
+ check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock
+) -> None:
+ def listing(request: Request, **_: object) -> BytesIO:
+ name, version = request.full_url.removeprefix("https://pypi.org/pypi/").removesuffix("/json").split("/")
+ return BytesIO(json.dumps({"info": {"name": name, "version": version}, "urls": [pypi_file]}).encode())
+
+ pypi_request.side_effect = listing
root = Path(__file__).resolve().parents[2]
- result = subprocess.run(
- [
- sys.executable,
- str(root / "tools/check_hook_config.py"),
- str(Path(__file__).parent / "fixtures/pre-bootstrap.yaml"),
- str(root / ".pre-commit-config.yaml"),
- "--managed",
- "",
- "--human",
- "true",
- "--minimum-days",
- "7",
- ],
- capture_output=True,
- text=True,
- check=False,
+
+ check_config(
+ Path(__file__).parent / "fixtures/pre-bootstrap.yaml", root / ".pre-commit-config.yaml", False, 7, human=True
)
- assert result.returncode == 0, result.stderr
+ assert pypi_request.call_count > 0
def test_app_update_keeps_direct_range(
diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py
index 0427385..a3ded9f 100644
--- a/tests/check_hook_update_cooldown/test_workflow.py
+++ b/tests/check_hook_update_cooldown/test_workflow.py
@@ -2,6 +2,7 @@
import json
import os
+import re
import subprocess
from pathlib import Path
@@ -54,59 +55,75 @@ def test_required_gate_does_not_repeat_changelog_lock_check() -> None:
assert news["jobs"]["changelog"]["steps"][-1]["uses"] == "$/validate-news"
-@pytest.mark.parametrize(
- ("workflow_name", "job_name"),
- [
- pytest.param("prepare-towncrier-release", "assemble", id="prepare"),
- pytest.param("validate-python-release", "source-check", id="python-release"),
- pytest.param("release", "check", id="self-release"),
- ],
-)
-def test_tagged_source_checks_frozen_hooks_before_prek(workflow_name: str, job_name: str) -> None:
+def test_tagged_source_checks_frozen_hooks_before_prek() -> None:
+ root = Path(__file__).resolve().parents[2]
+ prepare = yaml.load((root / ".github/workflows/prepare-towncrier-release.yaml").read_text(), Loader=yaml.BaseLoader)
+ tagged = yaml.load((root / "check-tagged-source/action.yml").read_text(), Loader=yaml.BaseLoader)
+
+ for steps in (prepare["jobs"]["assemble"]["steps"], tagged["runs"]["steps"]):
+ frozen = next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-frozen-hooks")
+ assert frozen < next(index for index, step in enumerate(steps) if "prek" in step.get("run", ""))
+
+
+def test_every_hook_run_uses_the_root_config() -> None:
root = Path(__file__).resolve().parents[2]
- workflow = yaml.load((root / ".github/workflows" / f"{workflow_name}.yaml").read_text(), Loader=yaml.BaseLoader)
- steps = workflow["jobs"][job_name]["steps"]
+ runs = [
+ step.get("run", "")
+ for path in (*root.glob(".github/workflows/*.yaml"), *root.glob("*/action.yml"))
+ for document in [yaml.load(path.read_text(), Loader=yaml.BaseLoader)]
+ for steps in (
+ [document["runs"]["steps"]]
+ if "runs" in document
+ else [job.get("steps", []) for job in document["jobs"].values()]
+ )
+ for step in steps
+ ]
+ commands = [
+ command
+ for run in runs
+ for command in re.findall(r"\bprek (?:run|install-hooks|update)\b[^\n]*", run.replace("\\\n", " "))
+ ]
+
+ assert commands
+ assert all("--config" in command for command in commands), commands
+
- assert next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-frozen-hooks") < next(
- index for index, step in enumerate(steps) if "prek" in step.get("run", "")
+def test_prepare_hook_install_uses_install_time_cutoff() -> None:
+ workflow = yaml.load(
+ (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")
+ script = (Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh").read_text()
+ assert {name: step["env"][name] for name in ("UV_EXCLUDE_NEWER", "UV_NO_CONFIG", "UV_NO_SOURCES")} == {
+ "UV_EXCLUDE_NEWER": "7 days",
+ "UV_NO_CONFIG": "1",
+ "UV_NO_SOURCES": "1",
+ }
+ assert [line for line in step["run"].splitlines() if line.startswith("unset ")] == [
+ line for line in script.splitlines() if line.startswith("unset ")
+ ]
-@pytest.mark.parametrize(
- ("path", "step_name"),
- [
- pytest.param(".github/workflows/check.yaml", "Check repository formatting", id="pr-format"),
- pytest.param(".github/workflows/check.yaml", "Check inline shell", id="pr-inline-shell"),
- pytest.param(".github/workflows/prepare-towncrier-release.yaml", "Install prek hooks", id="prepare-install"),
- pytest.param(".github/workflows/release.yaml", "Check tagged source", id="self-release"),
- pytest.param(".github/workflows/validate-python-release.yaml", "Run tagged source checks", id="python-release"),
- pytest.param("prepare-towncrier/action.yml", "Format changelog", id="prepare-format"),
- pytest.param("validate-release-patch/action.yml", "Run prek on release patch", id="patch-validation"),
- ],
-)
-def test_hook_execution_uses_install_time_cutoff(path: str, step_name: str) -> None:
- document = yaml.load((Path(__file__).resolve().parents[2] / path).read_text(), Loader=yaml.BaseLoader)
- steps = (
- document["runs"]["steps"]
- if path.endswith("action.yml")
- else [step for job in document["jobs"].values() for step in job["steps"]]
+
+def test_cooled_prek_isolates_hook_installs(tmp_path: Path) -> None:
+ fake = tmp_path / "prek"
+ fake.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >args\nenv >env\n')
+ fake.chmod(0o755)
+ leaked = dict.fromkeys(("UV_INDEX_URL", "UV_EXCLUDE_NEWER_PACKAGE", "UV_SYSTEM_CERTS", "SSL_CERT_FILE"), "leak")
+
+ subprocess.run(
+ [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), "run", "--all-files"],
+ cwd=tmp_path,
+ env=os.environ | leaked | {"PATH": f"{tmp_path}:{os.environ['PATH']}", "UV_EXCLUDE_NEWER": "1 day"},
+ check=True,
)
- step = next(step for step in steps if step.get("name") == step_name)
- assert step["env"]["UV_EXCLUDE_NEWER"] == "7 days"
- assert step["env"]["UV_NO_CONFIG"] == "1"
- assert step["env"]["UV_NO_SOURCES"] == "1"
- unset_names = {name for line in step["run"].splitlines() if line.startswith("unset ") for name in line.split()[1:]}
- assert {
- "UV_CONFIG_FILE",
- "UV_INDEX",
- "UV_INDEX_URL",
- "UV_DEFAULT_INDEX",
- "UV_EXTRA_INDEX_URL",
- "UV_FIND_LINKS",
- "UV_EXCLUDE_NEWER_PACKAGE",
- "UV_INSECURE_HOST",
- "UV_SYSTEM_CERTS",
- "SSL_CERT_FILE",
- "SSL_CERT_DIR",
- } <= unset_names
+ assert (tmp_path / "args").read_text().splitlines() == ["--config", ".pre-commit-config.yaml", "run", "--all-files"]
+ environment = dict(line.split("=", 1) for line in (tmp_path / "env").read_text().splitlines() if "=" in line)
+ assert {name: environment.get(name) for name in ("UV_EXCLUDE_NEWER", "UV_NO_CONFIG", "UV_NO_SOURCES")} == {
+ "UV_EXCLUDE_NEWER": "7 days",
+ "UV_NO_CONFIG": "1",
+ "UV_NO_SOURCES": "1",
+ }
+ assert not leaked.keys() & environment.keys()
diff --git a/tests/conftest.py b/tests/conftest.py
index c4555e1..a9c82c5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,20 +2,19 @@
import json
import os
+import shutil
import ssl
import subprocess
import sys
import tomllib
from collections.abc import Iterator, Mapping
-from hashlib import sha256
-from html import escape
from pathlib import Path
from textwrap import dedent
from typing import Final, TypedDict, cast
from urllib.parse import urlsplit
-from urllib.request import urlopen
import certifi
+import coverage
import pytest
import trustme
import yaml
@@ -75,6 +74,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path:
"check-hook-update-cooldown",
"recheck-hook-update-cooldown",
"publish-post-release-pr",
+ "verify-frozen-hooks",
):
action = yaml.safe_load((source / action_name / "action.yml").read_text())
for index, step in enumerate(action["runs"]["steps"]):
@@ -86,63 +86,6 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path:
return scripts
-@pytest.fixture(scope="session")
-def test_package_lock(tmp_path_factory: pytest.TempPathFactory, nab_executable: Path) -> Path:
- fixture = tmp_path_factory.mktemp("fixture-lock")
- (fixture / "pyproject.toml").write_text(
- '[project]\nname = "release-fixture"\nversion = "0.1.0"\nrequires-python = ">=3.15,<3.16"\n'
- '[dependency-groups]\nfixture = ["check-wheel-contents>=0.6.3", "hatchling>=1.28", "twine>=7"]\n'
- '[tool.nab]\nmode = "universal"\nbase-group = "project-runtime"\n'
- 'default-groups = []\n[tool.nab.matrix]\npython = ">=3.15,<3.16"\nplatforms = ["linux_x86_64"]\n'
- )
- subprocess.run(
- [str(nab_executable), "lock", "--no-cache", "--groups", "fixture", "--output", "pylock.toml"],
- cwd=fixture,
- check=True,
- capture_output=True,
- )
- return fixture / "pylock.toml"
-
-
-@pytest.fixture
-def test_package_index(httpserver: HTTPServer, test_package_lock: Path, artifact_cache: dict[str, bytes]) -> str:
- packages = [
- *tomllib.loads((Path(__file__).resolve().parents[1] / "pylock.toml").read_text())["packages"],
- *tomllib.loads(test_package_lock.read_text())["packages"],
- ]
- listings: dict[str, list[str]] = {}
- artifacts: dict[str, tuple[str, str]] = {}
- for package in packages:
- links = listings.setdefault(canonicalize_name(package["name"]), [])
- for artifact in [*package.get("wheels", []), *([package["sdist"]] if "sdist" in package else [])]:
- filename = artifact.get("name") or urlsplit(artifact["url"]).path.rsplit("/", 1)[-1]
- digest = artifact["hashes"]["sha256"]
- artifacts[filename] = artifact["url"], digest
- links.append(
- f'{escape(filename)}'
- )
- for name, links in listings.items():
- httpserver.expect_request(f"/simple/{name}/").respond_with_data("\n".join(links), content_type="text/html")
- for filename, (url, digest) in artifacts.items():
-
- def serve(_request: Request, *, source: str = url, expected: str = digest) -> Response:
- if expected not in artifact_cache:
- with urlopen(source, timeout=30) as stream:
- artifact_cache[expected] = stream.read()
- content = artifact_cache[expected]
- assert sha256(content).hexdigest() == expected
- return Response(content, mimetype="application/octet-stream")
-
- httpserver.expect_request(f"/files/{filename}").respond_with_handler(serve)
- return httpserver.url_for("/simple/")
-
-
-@pytest.fixture(scope="session")
-def artifact_cache() -> dict[str, bytes]:
- return {}
-
-
@pytest.fixture(scope="session")
def pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path:
listings = _lock_metadata(Path(__file__).resolve().parents[1] / "pylock.toml")
@@ -361,6 +304,9 @@ def action(
and not (repository / "pyproject.toml").exists()
):
(repository / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n')
+ if name == "validate-release-patch" and (prepared := tmp_path / "release.patch").exists():
+ (tmp_path / "release-patch").mkdir(exist_ok=True)
+ shutil.copyfile(prepared, tmp_path / "release-patch/release.patch")
action_config = yaml.safe_load((Path(__file__).resolve().parents[1] / name / "action.yml").read_text())
head = run(repository, "git", "rev-parse", "HEAD").stdout.strip() if (repository / ".git").exists() else "0" * 40
input_values = {
@@ -371,7 +317,6 @@ def action(
"CHANGELOG": "CHANGELOG.md",
"FRAGMENTS": "changelog.d",
"FORMAT": action_config.get("inputs", {}).get("format", {}).get("default", "none"),
- "PATCH": str(tmp_path / "release.patch"),
"EXPECTED_SHA": head,
"CHECKS": "true",
"BRANCH": "main",
@@ -431,9 +376,6 @@ def _run_action(
continue
if used.startswith("actions/upload-artifact@"):
continue
- if not used.startswith("$/"):
- msg = f"unsupported nested action: {used}"
- raise ValueError(msg)
nested_name = used.removeprefix("$/")
nested_config = yaml.safe_load((source / nested_name / "action.yml").read_text())
nested_inputs = {
@@ -463,8 +405,6 @@ def _run_action(
if step_id := step.get("id"):
step_outputs[step_id] = outputs
continue
- if "run" not in step:
- continue
script = action_scripts / f"{name}-{index}.sh"
command = [*github_bash, str(script)]
output_path = Path(environment["GITHUB_OUTPUT"])
@@ -528,16 +468,17 @@ def _condition(
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(" && "))
- if expression.startswith("endsWith(") and expression.endswith(")"):
- arguments = expression.removeprefix("endsWith(").removesuffix(")").split(", ", 1)
- return _expression(arguments[0], environment, inputs, step_outputs).endswith(arguments[1].strip("'"))
+ for function in ("startsWith", "endsWith"):
+ if expression.startswith(f"{function}(") and expression.endswith(")"):
+ subject, affix = expression.removeprefix(f"{function}(").removesuffix(")").split(", ", 1)
+ value = _expression(subject, environment, inputs, step_outputs)
+ return value.startswith(affix.strip("'")) if function == "startsWith" else value.endswith(affix.strip("'"))
for operator in (" == ", " != "):
if operator in expression:
left, right = expression.split(operator, 1)
equal = _expression(left, environment, inputs, step_outputs) == right.strip("'")
return equal if operator == " == " else not equal
- msg = f"unsupported GitHub step condition: {expression}"
- raise ValueError(msg)
+ raise ValueError(f"unsupported GitHub step condition: {expression}") # pragma: no cover - new syntax only
def _safe_environment() -> dict[str, str]:
@@ -561,13 +502,10 @@ def _safe_environment() -> dict[str, str]:
)
if name in os.environ
}
- if "GW_PYPI_METADATA_FILE" in environment:
- environment["PYTHONPATH"] = str(Path(__file__).parent / "coverage_bootstrap")
- if "COVERAGE_PROCESS_CONFIG" in environment or "COVERAGE_PROCESS_START" in environment:
- import coverage
-
- environment["GW_COVERAGE_SITE_PACKAGES"] = str(Path(coverage.__file__).parent.parent)
- return environment
+ return environment | {
+ "PYTHONPATH": str(Path(__file__).parent / "coverage_bootstrap"),
+ "GW_COVERAGE_SITE_PACKAGES": str(Path(coverage.__file__).parent.parent),
+ }
def _inputs(
@@ -594,8 +532,7 @@ def _step_value(
while (start := remainder.find("${{")) != -1:
end = remainder.find("}}", start + 3)
if end == -1:
- msg = f"unterminated GitHub expression: {value}"
- raise ValueError(msg)
+ raise ValueError(f"unterminated GitHub expression: {value}") # pragma: no cover - new syntax only
parts.append(remainder[:start])
parts.append(_expression(remainder[start + 3 : end].strip(), environment, inputs, step_outputs))
remainder = remainder[end + 2 :]
@@ -637,20 +574,21 @@ def _expression(
return step_outputs.get(step_id, {}).get(output_name, "")
if expression == "runner.temp":
return environment["RUNNER_TEMP"]
+ if expression == "github.ref":
+ return environment.get("GITHUB_REF", "")
+ if expression == "github.ref_name":
+ return environment.get("GITHUB_REF_NAME", "")
if expression == "github.repository":
return environment["GITHUB_REPOSITORY"]
if expression == "github.base_ref":
return environment.get("BASE_REF", "")
- if expression == "github.event.pull_request.number":
- return environment.get("GITHUB_EVENT_PULL_REQUEST_NUMBER", "")
if expression == "github.token":
return environment.get("GH_TOKEN", environment.get("GH_ENTERPRISE_TOKEN", ""))
if expression == "github.event.pull_request.user.type":
return "Bot" if environment.get("BOT_AUTHOR") == "true" else "User"
if expression == "contains(github.event.pull_request.labels.*.name, 'skip news')":
return environment.get("SKIP_NEWS", "false")
- msg = f"unsupported GitHub step environment: {expression}"
- raise ValueError(msg)
+ raise ValueError(f"unsupported GitHub step environment: {expression}") # pragma: no cover - new syntax only
def _lock_metadata(lock: Path) -> dict[str, list[_PyPIFile]]:
diff --git a/tests/install_locked_tools/test_expose_commands.py b/tests/install_locked_tools/test_expose_commands.py
index 3054245..e093d4e 100644
--- a/tests/install_locked_tools/test_expose_commands.py
+++ b/tests/install_locked_tools/test_expose_commands.py
@@ -17,11 +17,12 @@ def test_expose_locked_commands_copies_windows_executable(tmp_path: Path, mocker
scripts.mkdir(parents=True)
(scripts / "release.exe").write_bytes(b"tool")
github_path = tmp_path / "github-path"
- platform = mocker.patch.object(expose_locked_commands, "os")
- platform.name = "nt"
- platform.environ = {"RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(github_path)}
- platform.X_OK = os.X_OK
+ platform = mocker.create_autospec(
+ os, environ={"RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(github_path)}, X_OK=os.X_OK
+ )
platform.access.return_value = True
+ platform.name = "nt" # Mock reserves the name keyword, so the platform name is set afterwards.
+ mocker.patch.object(expose_locked_commands, "os", platform)
mocker.patch.object(sys, "argv", ["expose_locked_commands", str(environment)])
expose_locked_commands.main()
diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py
index 0a0a74e..25afe66 100644
--- a/tests/install_locked_tools/test_install.py
+++ b/tests/install_locked_tools/test_install.py
@@ -808,14 +808,8 @@ def test_shared_lock_records_upload_times() -> None:
def test_shared_lock_respects_seven_day_cutoff() -> None:
source = Path(__file__).resolve().parents[2]
- policy = tomllib.loads((source / "pyproject.toml").read_text())["tool"]["nab"]
- floors = {
- name: datetime.fromisoformat(settings["uploaded-prior-to"])
- for name, settings in policy.get("packages", {}).items()
- if "uploaded-prior-to" in settings
- }
- for rule in policy.get("package-rules", []):
- floors.update(dict.fromkeys(rule["match"], datetime.fromisoformat(rule["uploaded-prior-to"])))
+ overrides = tomllib.loads((source / "pyproject.toml").read_text())["tool"]["release-creator"]["locks"]
+ floors = {name: datetime.fromisoformat(value) for name, value in overrides.get("exclude-newer-package", {}).items()}
lock = tomllib.loads((source / "pylock.toml").read_text())
cutoff = datetime.now(UTC) - timedelta(days=7)
packages = lock["packages"]
diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py
index 98ec875..de94c81 100644
--- a/tests/install_locked_tools/test_metadata.py
+++ b/tests/install_locked_tools/test_metadata.py
@@ -82,7 +82,9 @@ def pypi_listing() -> _PyPIListing:
def test_lock_metadata_matches_pypi(
lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
- request = mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode()))
+ request = mocker.patch(
+ "urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(pypi_listing).encode())
+ )
context = mocker.patch.object(ssl, "create_default_context", autospec=True)
_run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1]))
@@ -184,7 +186,7 @@ def test_lock_metadata_stops_after_five_network_errors(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
- request = mocker.patch("urllib.request.urlopen", side_effect=URLError("handshake timed out"))
+ request = mocker.patch("urllib.request.urlopen", autospec=True, side_effect=URLError("handshake timed out"))
mocker.patch("time.sleep")
with pytest.raises(SystemExit, match="2"):
@@ -290,7 +292,7 @@ def test_lock_metadata_rejects_invalid_listing(
pypi_listing["meta"]["api-version"] = "2.0"
else:
pypi_listing["name"] = "other"
- mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode()))
+ mocker.patch("urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(pypi_listing).encode()))
with pytest.raises(SystemExit, match="2"):
_run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1]))
@@ -313,7 +315,7 @@ def test_lock_metadata_rejects_non_object_listing(
capsys: pytest.CaptureFixture[str],
listing: object,
) -> None:
- mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(listing).encode()))
+ mocker.patch("urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(listing).encode()))
with pytest.raises(SystemExit, match="2"):
_run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1]))
@@ -334,6 +336,30 @@ def test_lock_metadata_allows_local_release_creator(
_run_check(lock_case, monkeypatch, "--local-tool-source", "--verify-pypi", "--project", str(lock_case[1]))
+@pytest.mark.parametrize(
+ ("name", "path"),
+ [pytest.param("release-creator", "../other", id="other-directory"), pytest.param("six", ".", id="other-package")],
+)
+def test_lock_metadata_limits_local_tool_exemption(
+ lock_case: tuple[Path, Path],
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+ name: str,
+ path: str,
+) -> None:
+ lock = lock_case[0]
+ lock.write_text(
+ lock.read_text().split("[[packages]]", 1)[0]
+ + f'[[packages]]\nname = "{name}"\nmarker = \'"release-check" in dependency_groups\'\n'
+ + f'directory = {{ path = "{path}" }}\n'
+ )
+
+ with pytest.raises(SystemExit, match="2"):
+ _run_check(lock_case, monkeypatch, "--local-tool-source")
+
+ assert "direct-source package" in capsys.readouterr().err
+
+
@pytest.mark.parametrize(
("source", "message"),
[
@@ -525,6 +551,51 @@ def test_lock_metadata_rejects_unapproved_indexes(
assert "index" in capsys.readouterr().err
+@pytest.mark.parametrize(
+ ("change", "group"),
+ [
+ pytest.param("lock", "release-check", id="lock-group"),
+ pytest.param("argument", "Release-Check", id="selected-group"),
+ ],
+)
+def test_lock_metadata_rejects_unnormalized_group(
+ lock_case: tuple[Path, Path],
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+ change: str,
+ group: str,
+) -> None:
+ lock = lock_case[0]
+ if change == "lock":
+ lock.write_text(
+ lock.read_text().replace(
+ 'dependency-groups = ["release-check"]', 'dependency-groups = ["release-check", "project_runtime"]'
+ )
+ )
+ source = Path(__file__).resolve().parents[2]
+ monkeypatch.syspath_prepend(str(source / "tools"))
+ monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock), group])
+
+ with pytest.raises(SystemExit, match="2"):
+ runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="__main__")
+
+ assert "dependency group names must be normalized" in capsys.readouterr().err
+
+
+def test_lock_metadata_rejects_non_table_producer(
+ lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ lock = lock_case[0]
+ lock.write_text(
+ lock.read_text().replace("[tool.nab]\ncreated-at = 2026-10-20T00:00:00Z\n", '[tool]\nnab = "oops"\n')
+ )
+
+ with pytest.raises(SystemExit, match="2"):
+ _run_check(lock_case, monkeypatch)
+
+ assert "lock producer settings must be a table" in capsys.readouterr().err
+
+
def _run_check(lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, *options: str) -> None:
source = Path(__file__).resolve().parents[2]
monkeypatch.syspath_prepend(str(source / "tools"))
diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py
index 52a6dd3..4e1f301 100644
--- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py
+++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py
@@ -269,6 +269,41 @@ def test_deleted_prior_run_deployment_blocks_upload(
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,
@@ -616,6 +651,79 @@ def test_changed_prior_artifact_blocks_retry(
assert not httpserver.log
+def test_reconciled_attempt_allows_another_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,
+ reconciled_attempt=True,
+ accepted_archive=signed_archive,
+ 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 not httpserver.log
+ assert any(request.path.endswith("/artifacts/62/zip") for request, _ in github_api[0].log)
+
+
+def test_receipt_from_unlisted_run_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,
+ receipt_changes={"sourceRunId": 30},
+ 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_fork_run_on_tag_named_branch_allows_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,
+ fork_run=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 not any("/runs/39/" in request.path for request, _ in github_api[0].log)
+
+
def publish_action(
repository: Path,
scripts: Path,
@@ -635,6 +743,8 @@ def publish_action(
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,
@@ -646,6 +756,8 @@ def publish_action(
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
@@ -662,6 +774,7 @@ def publish_action(
"id": 40,
"head_branch": "main",
"head_sha": "2" * 40,
+ "head_repository": {"full_name": "tox-dev/fixture"},
"path": ".github/workflows/check.yaml",
}
]
@@ -674,9 +787,20 @@ def publish_action(
"id": 41,
"head_branch": "v0.1.11",
"head_sha": previous_commit,
+ "head_repository": {"full_name": "tox-dev/fixture"},
"path": f".github/workflows/{previous_workflow}",
}
)
+ if fork_run:
+ runs.append(
+ {
+ "id": 39,
+ "head_branch": "v0.1.11",
+ "head_sha": "3" * 40,
+ "head_repository": {"full_name": "mallory/fixture"},
+ "path": ".github/workflows/release.yaml",
+ }
+ )
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:
@@ -693,7 +817,9 @@ def publish_action(
server.expect_request(f"/api/v3/repos/tox-dev/fixture/deployments/{deployment_id}/statuses").respond_with_json(
[
{
- "log_url": f"https://github.com/tox-dev/fixture/actions/runs/{run_id}/job/{job_id}",
+ "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",
}
]
@@ -704,7 +830,9 @@ def publish_action(
else:
job_request.respond_with_json(
{
- "run_id": run_id,
+ "run_id": previous_deployment_job_run
+ if prior and previous_deployment_job_run is not None
+ else run_id,
"run_attempt": attempt,
"steps": [
{
@@ -722,7 +850,12 @@ def publish_action(
)
if previous_run:
server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/41/jobs").respond_with_json(
- {"jobs": [{"run_attempt": 1, "steps": [{"name": previous_step, "started_at": "2026-10-23T00:00:00Z"}]}]}
+ {
+ "jobs": [
+ {"run_attempt": attempt, "steps": [{"name": previous_step, "started_at": "2026-10-23T00:00:00Z"}]}
+ for attempt in ((1, 2) if reconciled_attempt else (1,))
+ ]
+ }
)
artifacts = (
[
@@ -737,6 +870,7 @@ def publish_action(
}
for artifact_id, artifact_name in (
(61, "marketplace-acceptance-41-1"),
+ *([(62, "marketplace-acceptance-41-2")] if reconciled_attempt else []),
(51, "plugin-distribution"),
(52, "release-notes"),
)
@@ -766,6 +900,7 @@ def publish_action(
receipt.update(receipt_changes or {})
for artifact_id, filename, payload in (
(61, "other.json" if missing_receipt else "marketplace-acceptance.json", json.dumps(receipt).encode()),
+ (62, "marketplace-acceptance.json", json.dumps(receipt).encode()),
(
51,
accepted_archive.name,
diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py
index d0e8922..043604f 100644
--- a/tests/recheck_hook_update_cooldown/test_recheck.py
+++ b/tests/recheck_hook_update_cooldown/test_recheck.py
@@ -243,7 +243,7 @@ def test_recheck_continues_after_failed_rerun(
server.expect_request(
"/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs",
method="GET",
- query_string=f"event=pull_request&head_sha={str(number) * 40}&per_page=100",
+ query_string=f"branch=update-{number}&event=pull_request&per_page=100",
).respond_with_json(
{
"workflow_runs": [
@@ -285,3 +285,75 @@ def test_recheck_continues_after_failed_rerun(
"/api/v3/repos/tox-dev/fixture/actions/jobs/97/rerun",
"/api/v3/repos/tox-dev/fixture/actions/jobs/98/rerun",
]
+
+
+@pytest.mark.parametrize(
+ ("older_head_age", "reruns"),
+ [pytest.param(8, 1, id="older-head-matured"), pytest.param(3, 0, id="all-heads-recent")],
+)
+def test_recheck_counts_observations_on_earlier_heads(
+ github_api: tuple[HTTPServer, dict[str, str]],
+ action_scripts: Path,
+ tmp_path: Path,
+ older_head_age: int,
+ reruns: int,
+) -> None:
+ server, environment = github_api
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json(
+ [
+ {
+ "number": 7,
+ "head": {"sha": "a" * 40, "ref": "update", "repo": {"full_name": "tox-dev/fixture"}},
+ "base": {"repo": {"full_name": "tox-dev/fixture"}},
+ "created_at": (datetime.now(UTC) - timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+ ]
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json(
+ [{"filename": ".pre-commit-config.yaml"}]
+ )
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs",
+ method="GET",
+ query_string="branch=update&event=pull_request&per_page=100",
+ ).respond_with_json(
+ {
+ "workflow_runs": [
+ {
+ "id": run_id,
+ "event": "pull_request",
+ "head_repository": {"full_name": "tox-dev/fixture"},
+ "head_sha": head,
+ "head_branch": "update",
+ "pull_requests": [{"number": 7}],
+ "status": "completed",
+ "created_at": (datetime.now(UTC) - timedelta(days=age)).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+ for run_id, head, age in ((41, "b" * 40, older_head_age), (42, "a" * 40, 1))
+ ]
+ }
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json(
+ {"jobs": [{"id": 99, "name": "hook-cooldown", "conclusion": "failure"}]}
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99", method="GET").respond_with_json(
+ {"id": 99, "run_id": 42, "name": "hook-cooldown", "status": "completed", "conclusion": "failure"}
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun", method="POST").respond_with_data(
+ status=201
+ )
+
+ result = action(
+ tmp_path,
+ tmp_path,
+ action_scripts,
+ "recheck-hook-update-cooldown",
+ version="",
+ GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"],
+ **environment,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert [request.path for request, _ in server.log].count(
+ "/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun"
+ ) == reruns
diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py
index 1b2cc7f..5b9b38e 100644
--- a/tests/reconcile_pypi_artifacts/test_reconcile.py
+++ b/tests/reconcile_pypi_artifacts/test_reconcile.py
@@ -14,7 +14,7 @@
[
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, "release files require a directory", id="missing-distribution"),
+ pytest.param("fixture", "1.0", False, "distribution directory does not exist", id="missing-distribution"),
],
)
def test_reconcile_rejects_invalid_inputs(
diff --git a/tests/refresh_mise_pin/test_refresh.py b/tests/refresh_mise_pin/test_refresh.py
index 1cada0c..2dfd9a4 100644
--- a/tests/refresh_mise_pin/test_refresh.py
+++ b/tests/refresh_mise_pin/test_refresh.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import os
import subprocess
import sys
from datetime import UTC, datetime, timedelta
@@ -21,7 +22,7 @@ def _release(
prerelease: bool = False,
draft: bool = False,
asset: bool = True,
- digest: str = "sha256:" + "a" * 64,
+ digest: str | None = "sha256:" + "a" * 64,
asset_days: int | None = None,
archive: bool = True,
recent_archive: str = "",
@@ -414,28 +415,64 @@ def test_main_selects_release(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -
assert (tmp_path / "output").read_text() == f"version=2026.10.13\nsha256={'a' * 64}\n"
-def test_action_verifies_signed_checksum_and_exports_mise_path() -> None:
- action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text())
- steps = action["runs"]["steps"]
- installer_index = next(
- index for index, step in enumerate(steps) if step.get("uses", "").startswith("jdx/mise-action@")
+@pytest.mark.parametrize(
+ ("published", "accepted"),
+ [pytest.param(b"verified mise", True, id="selected-digest"), pytest.param(b"swapped", False, id="other-bytes")],
+)
+def test_action_checks_executable_digest_before_mise_runs(tmp_path: Path, published: bytes, accepted: bool) -> None:
+ steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text())[
+ "runs"
+ ]["steps"]
+ download = next(step for step in steps if step.get("name") == "Download the selected mise executable")
+ installer = next(step for step in steps if step.get("uses", "").startswith("jdx/mise-action@"))
+ assert steps.index(download) < steps.index(installer)
+ fake_bin = tmp_path / "fake-bin"
+ fake_bin.mkdir()
+ (tmp_path / "published").write_bytes(published)
+ gh = fake_bin / "gh"
+ gh.write_text(f'#!/bin/sh\ncp {tmp_path / "published"} "$9/mise-v2026.10.13-linux-x64"\n')
+ gh.chmod(0o755)
+ result = subprocess.run(
+ ["bash", "-e", "-o", "pipefail", "-c", download["run"]],
+ env=os.environ
+ | {
+ "PATH": f"{fake_bin}:{os.environ['PATH']}",
+ "RUNNER_TEMP": str(tmp_path),
+ "VERSION": "2026.10.13",
+ "EXPECTED_SHA256": sha256(b"verified mise").hexdigest(),
+ },
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ binary = tmp_path / "refresh-mise-pin" / "bin" / "mise"
+ assert (result.returncode == 0) is accepted, result.stderr
+ assert binary.exists() is accepted
+ if accepted:
+ assert os.access(binary, os.X_OK)
+ assert binary.with_name("mise-version").read_text() == "2026.10.13"
+
+
+def test_update_keeps_crlf_line_endings(tmp_path: Path, mise_binary: tuple[Path, str]) -> None:
+ binary, digest = mise_binary
+ workflow = tmp_path / ".github" / "workflows" / "release.yaml"
+ workflow.parent.mkdir(parents=True)
+ workflow.write_bytes(
+ ("jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64)).replace("\n", "\r\n").encode()
)
- assert installer_index < next(
- index for index, step in enumerate(steps) if step["name"] == "Update the workflow pin"
+ result = _run(
+ tmp_path,
+ "update",
+ ".github/workflows/release.yaml",
+ "🔧 Set up mise (JDKs)",
+ "2026.10.13",
+ digest,
+ str(binary),
+ )
+ assert result.returncode == 0, result.stderr
+ assert workflow.read_bytes() == (
+ ("jobs:\n build:\n steps:\n" + _step("2026.10.13", digest)).replace("\n", "\r\n").encode()
)
- assert steps[installer_index] == {
- "name": "Install signature-verified mise",
- "uses": "jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8",
- "with": {
- "version": "${{ steps.select.outputs.version }}",
- "mise_dir": "${{ runner.temp }}/refresh-mise-pin",
- "cache": "false",
- "install": "false",
- "env": "false",
- "export_path": "false",
- "add_shims_to_path": "false",
- },
- }
def test_action_keeps_linux_executable_and_archives_in_release_listing() -> None:
@@ -463,6 +500,7 @@ def test_action_keeps_linux_executable_and_archives_in_release_listing() -> None
pytest.param([_release("2026.10.13", days=8, asset=False)], "no unique Linux", id="missing-asset"),
pytest.param([_release("2026.10.13", days=8, archive=False)], "no unique Linux", id="missing-archive"),
pytest.param([_release("2026.10.13", days=8, digest="sha256:bad")], "no unique Linux", id="bad-asset-digest"),
+ pytest.param([_release("2026.10.13", days=8, digest=None)], "no unique Linux", id="null-asset-digest"),
],
)
def test_select_rejects_invalid_release(tmp_path: Path, releases: list[dict[str, object]], message: str) -> None:
diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py
index 24020cb..ee3c43f 100644
--- a/tests/refresh_release_lock/test_cutoffs.py
+++ b/tests/refresh_release_lock/test_cutoffs.py
@@ -168,6 +168,7 @@ def test_uv_resolver_cutoff_requires_review(cutoff: str, accepted: bool) -> None
("project_text", "accepted"),
[
pytest.param("[tool.release-creator.locks]\n", True, id="release-creator-only"),
+ pytest.param('[tool.nab]\nuploaded-prior-to = "P7D"\n', True, id="secondary-nab-only"),
pytest.param("[tool.other]\n", False, id="no-policy"),
],
)
@@ -598,7 +599,7 @@ def test_recent_cutoff_rejects_index_override() -> None:
"[tool.nab.index.pypi]\nuploaded-prior-to = false\n"
)
assert result.returncode != 0
- assert "Nab index cutoff for pypi" in result.stderr
+ assert "custom Nab indexes are not approved" in result.stderr
def test_recent_cutoff_rejects_custom_index() -> None:
diff --git a/tests/refresh_release_lock/test_provider.py b/tests/refresh_release_lock/test_provider.py
index 807d893..ce01b1c 100644
--- a/tests/refresh_release_lock/test_provider.py
+++ b/tests/refresh_release_lock/test_provider.py
@@ -36,23 +36,19 @@ def test_refresh_rejects_secondary_options_without_lock(tmp_path: Path, action_s
assert "secondary matrix inputs require additional-lock" in result.stderr
-def test_refresh_rejects_native_uv_lock_for_nab(tmp_path: Path, action_scripts: Path) -> None:
+def test_refresh_rejects_nab_primary_lock(tmp_path: Path, action_scripts: Path) -> None:
(tmp_path / "pylock.toml").write_text('[tool.nab]\ncommand-line = ["nab", "lock"]\n')
- result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="", KEEP_UV_LOCK="true")
+ result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="", MODE="check")
assert result.returncode != 0
- assert "keep-uv-lock requires a release-creator lock" in result.stderr
+ assert "pylock.toml must come from release-creator locks" in result.stderr
@pytest.mark.parametrize("mode", ["check", "refresh"])
def test_refresh_uses_the_locked_resolver(tmp_path: Path, action_scripts: Path, mode: str) -> None:
source = Path(__file__).resolve().parents[2]
steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"]
- resolver = next(step for step in steps if step.get("with", {}).get("groups") == "release-resolver")
- assert resolver["if"] == "steps.producer.outputs.name == 'release-creator'"
- assert resolver["with"]["lock-source"] == "shared"
- assert resolver["with"]["venv"] == "${{ runner.temp }}/release-resolver"
packages = tomllib.loads((source / "pylock.toml").read_text())["packages"]
pinned = next(package for package in packages if package["name"] == "uv")
assert Version(pinned["version"]) >= Version("0.13.5")
diff --git a/tests/refresh_release_lock/test_resolver_upgrade.py b/tests/refresh_release_lock/test_resolver_upgrade.py
index c1cc0c6..3c8dfef 100644
--- a/tests/refresh_release_lock/test_resolver_upgrade.py
+++ b/tests/refresh_release_lock/test_resolver_upgrade.py
@@ -46,24 +46,89 @@ def test_resolver_upgrade_rejects_invalid_state(
assert error in result.stderr
-def test_resolver_upgrade_import_has_no_side_effect() -> None:
- source = Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py"
+def test_resolver_widen_starts_at_the_current_pin(tmp_path: Path) -> None:
+ project = tmp_path / "pyproject.toml"
+ project.write_text('[dependency-groups]\nrelease-resolver = ["uv==0.13.6"]\n')
+ lock = tmp_path / "pylock.toml"
+ lock.write_text('dependency-groups = ["release-resolver"]\n' + _uv_package("0.13.6"))
- assert runpy.run_path(str(source), run_name="imported")["__all__"] == ["main"]
+ result = _resolver(tmp_path, "widen")
+ assert result.returncode == 0, result.stderr
+ assert tomllib.loads(project.read_text())["dependency-groups"]["release-resolver"] == ["uv>=0.13.6"]
+
+
+def test_resolver_pin_ignores_uv_from_other_groups(tmp_path: Path) -> None:
+ project = tmp_path / "pyproject.toml"
+ project.write_text('[dependency-groups]\nrelease-resolver = ["uv>=0.13.5"]\n')
+ lock = tmp_path / "pylock.toml"
+ lock.write_text(
+ 'dependency-groups = ["release-resolver", "test"]\n'
+ + _uv_package("0.13.6")
+ + "[[packages]]\nname = 'uv'\nversion = '0.14.0'\nmarker = '\"test\" in dependency_groups'\n"
+ )
+
+ result = _resolver(tmp_path, "pin")
+
+ assert result.returncode == 0, result.stderr
+ assert tomllib.loads(project.read_text())["dependency-groups"]["release-resolver"] == ["uv==0.13.6"]
+
+
+def _resolver(root: Path, mode: str) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ [
+ sys.executable,
+ str(Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py"),
+ mode,
+ str(root / "pyproject.toml"),
+ str(root / "pylock.toml"),
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
-def test_weekly_resolver_upgrade_is_opt_in() -> None:
- source = Path(__file__).resolve().parents[2]
- caller = yaml.safe_load((source / ".github/workflows/update-tools.yaml").read_text())
- reusable = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text())
- action = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())
- assert caller["jobs"]["update"]["with"]["upgrade-resolver"] is True
- refresh = next(
- step for step in reusable["jobs"]["generate-lock"]["steps"] if step.get("uses") == "$/refresh-release-lock"
+@pytest.mark.parametrize(
+ ("repository", "pin", "locked", "accepted"),
+ [
+ pytest.param("tox-dev/github-workflows", "uv==0.13.6", "0.13.6", True, id="shared-resolver"),
+ pytest.param("tox-dev/github-workflows", "uv==0.13.6", "0.13.5", False, id="lock-mismatch"),
+ pytest.param("tox-dev/github-workflows", "uv>=0.13.6", "0.13.6", False, id="range"),
+ pytest.param("tox-dev/other", "uv==0.13.6", "0.13.6", False, id="other-repository"),
+ ],
+)
+def test_writer_accepts_only_the_locked_resolver_pin(
+ tmp_path: Path, repository: str, pin: str, locked: str, accepted: bool
+) -> None:
+ project = tmp_path / "pyproject.toml"
+ project.write_text('[dependency-groups]\nrelease-resolver = ["uv==0.13.5"]\n[tool.release-creator.locks]\n')
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
+ subprocess.run(["git", "add", "pyproject.toml"], cwd=tmp_path, check=True)
+ subprocess.run(
+ ["git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-qm", "Pin"],
+ cwd=tmp_path,
+ check=True,
)
- assert refresh["with"]["upgrade-resolver"] == "${{ inputs.upgrade-resolver }}"
- assert action["inputs"]["upgrade-resolver"]["default"] == "false"
+ project.write_text(project.read_text().replace("uv==0.13.5", pin))
+ (tmp_path / "pylock.toml").write_text('dependency-groups = ["release-resolver"]\n' + _uv_package(locked))
+
+ result = subprocess.run(
+ [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/verify_cutoff_patch.py")],
+ cwd=tmp_path,
+ env=os.environ | {"GITHUB_REPOSITORY": repository},
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ assert (result.returncode == 0) is accepted, result.stderr
+
+
+def test_resolver_upgrade_import_has_no_side_effect() -> None:
+ source = Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py"
+
+ assert runpy.run_path(str(source), run_name="imported")["__all__"] == ["main"]
def test_resolver_upgrade_uses_candidate_to_regenerate_lock(tmp_path: Path, action_scripts: Path) -> None:
diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py
index 6d22ebc..e2fa411 100644
--- a/tests/release_workflow/test_graph.py
+++ b/tests/release_workflow/test_graph.py
@@ -10,76 +10,6 @@
import yaml
-def test_prepare_workflow_calls_reusable_preparation() -> None:
- workflow = yaml.safe_load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-release.yaml").read_text()
- )
- assert list(workflow["jobs"]) == ["prepare"]
- assert workflow["jobs"]["prepare"]["uses"] == "$/.github/workflows/prepare-towncrier-release.yaml"
-
-
-@pytest.mark.parametrize(
- ("workflow", "job", "step_name"),
- [
- pytest.param("check.yaml", "check", "Check repository formatting", id="pull-request"),
- pytest.param("release.yaml", "check", "Check tagged source", id="tagged-release"),
- pytest.param("validate-python-release.yaml", "source-check", "Run tagged source checks", id="distribution"),
- pytest.param("prepare-towncrier-release.yaml", "assemble", "Install prek hooks", id="prepare"),
- ],
-)
-def test_release_hook_installation_has_cooldown(workflow: str, job: str, step_name: str) -> None:
- document = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows" / workflow).read_text())
- step = next(item for item in document["jobs"][job]["steps"] if item.get("name") == step_name)
- assert step["env"]["UV_EXCLUDE_NEWER"] == "7 days"
-
-
-def test_tagged_release_checks_the_committed_lock() -> None:
- workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text())
- check = workflow["jobs"]["check"]
- assert next(step for step in check["steps"] if step.get("uses") == "$/refresh-release-lock")["with"] == {
- "mode": "check"
- }
- assert "check" in workflow["jobs"]["publish"]["needs"]
- assert check["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"}
- assert next(step for step in check["steps"] if "checkout" in step.get("uses", ""))["with"]["fetch-depth"] == 0
-
-
-def test_prepare_jobs_transfer_the_uploaded_patch() -> None:
- workflow = yaml.safe_load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text()
- )
- jobs = workflow["jobs"]
- assert list(jobs) == ["assemble", "validate", "push"]
- assert jobs["assemble"]["outputs"]["artifact-id"] == "${{ steps.patch.outputs.artifact-id }}"
- assert (
- next(step for step in jobs["assemble"]["steps"] if step.get("id") == "patch")["with"]["name"] == "release-patch"
- )
- for job in ("validate", "push"):
- assert (
- next(step for step in jobs[job]["steps"] if "download-artifact" in step.get("uses", ""))["with"][
- "artifact-ids"
- ]
- == "${{ needs.assemble.outputs.artifact-id }}"
- )
-
-
-def test_prepare_validates_uploaded_patch_before_writer() -> None:
- workflow = yaml.safe_load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text()
- )
- jobs = workflow["jobs"]
- assert jobs["validate"]["permissions"] == {"contents": "read"}
- assert jobs["validate"]["needs"] == "assemble"
- assert jobs["push"]["needs"] == ["assemble", "validate"]
- assert not any(step.get("uses") == "$/validate-release-patch" for step in jobs["assemble"]["steps"])
- validation = next(step for step in jobs["validate"]["steps"] if step.get("uses") == "$/validate-release-patch")
- assert validation["with"]["patch"] == "${{ runner.temp }}/release-patch/release.patch"
- assert (
- next(step for step in jobs["push"]["steps"] if step.get("uses") == "$/validate-release-patch")["with"]["checks"]
- == "false"
- )
-
-
@pytest.mark.parametrize(
("version", "prefix", "accepted"),
[
@@ -102,10 +32,28 @@ def test_prepare_validation_calculates_version_independently(
if prefix:
expected_arguments.extend(["--prefix", prefix])
command.write_text(
- f"#!{sys.executable}\nimport sys\nassert sys.argv[1:] == {expected_arguments!r}\nprint({f'{prefix}1.2.3'!r})\n"
+ 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"]],
@@ -128,77 +76,47 @@ def test_prepare_validation_calculates_version_independently(
assert "assembled release version differs from 1.2.3" in result.stderr
-@pytest.mark.parametrize("job", ["assemble", "validate"])
-def test_prepare_builds_local_tool_with_locked_backends(job: str) -> None:
- workflow = yaml.safe_load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text()
- )
- steps = workflow["jobs"][job]["steps"]
- builds = [step for step in steps if step.get("name") == "Build local release tool wheel"]
- assert len(builds) == 1
- assert builds[0]["env"]["BUILD_CONSTRAINTS"] == "${{ steps.tools.outputs.build-constraints }}"
- assert '--build-constraints "$BUILD_CONSTRAINTS"' in builds[0]["run"]
- assert "--require-build-hashes" in builds[0]["run"]
- assert any(
- step.get("name") == "Install local release tool wheel" and '"$RUNNER_TEMP/local-tool-dist/"*.whl' in step["run"]
- for step in steps
- )
-
-
-def test_prepare_exposes_local_command_without_shadowing_python(tmp_path: Path) -> None:
- workflow = yaml.safe_load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text()
- )
- steps = workflow["jobs"]["assemble"]["steps"]
- expose = next(step for step in steps if step.get("name") == "Expose local release command")
- assert expose["if"] == "inputs.local-tool-source"
- assert steps.index(expose) > next(
- index for index, step in enumerate(steps) if step.get("name") == "Install local release tool wheel"
+def test_local_release_creator_builds_with_locked_backends(tmp_path: Path) -> None:
+ step = yaml.safe_load(
+ (Path(__file__).resolve().parents[2] / "install-local-release-creator/action.yml").read_text()
+ )["runs"]["steps"][0]
+ fake_bin = tmp_path / "fake-bin"
+ fake_bin.mkdir()
+ (fake_bin / "uv").write_text(
+ '#!/bin/sh\necho "$@" >>"$RUNNER_TEMP/uv-calls"\n'
+ 'if [ "$1" = build ]; then mkdir -p "$RUNNER_TEMP/local-tool-dist" && touch "$RUNNER_TEMP/local-tool-dist/x.whl"; fi\n'
+ f'if [ "$1" = run ]; then shift 4; shift; exec {sys.executable} "$@"; fi\n'
)
- assert steps.index(expose) < next(index for index, step in enumerate(steps) if step.get("id") == "prepare")
+ (fake_bin / "uv").chmod(0o755)
command = tmp_path / ".venv/bin/release-creator"
command.parent.mkdir(parents=True)
command.symlink_to(sys.executable)
output = tmp_path / "github-path"
result = subprocess.run(
- ["bash", "-e", "-o", "pipefail", "-c", expose["run"]],
+ ["bash", "-e", "-o", "pipefail", "-c", step["run"]],
cwd=tmp_path,
- env={**os.environ, "GITHUB_WORKSPACE": str(tmp_path), "RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(output)},
+ env=os.environ
+ | {
+ "PATH": f"{fake_bin}:{os.environ['PATH']}",
+ "BUILD_CONSTRAINTS": "constraints.txt",
+ "GITHUB_WORKSPACE": str(tmp_path),
+ "RUNNER_TEMP": str(tmp_path),
+ "GITHUB_PATH": str(output),
+ "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "install-local-release-creator"),
+ },
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
+ build, install, _ = (tmp_path / "uv-calls").read_text().splitlines()
+ assert "--build-constraints constraints.txt --require-build-hashes" in build
+ assert install.endswith(f"--no-deps {tmp_path}/local-tool-dist/x.whl")
overlay = Path(output.read_text().strip())
+ assert [path.name for path in overlay.iterdir()] == ["release-creator"]
assert (overlay / "release-creator").resolve() == command.resolve()
- assert not (overlay / "python").exists()
- assert (
- subprocess.run([str(overlay / "release-creator"), "--version"], check=False, capture_output=True).returncode
- == 0
- )
-
-
-def test_prepare_installs_hooks_while_building_changelog() -> None:
- workflow = yaml.safe_load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text()
- )
- steps = workflow["jobs"]["assemble"]["steps"]
- hooks = next(step for step in steps if step.get("id") == "hooks")
- build = next(step for step in steps if step.get("id") == "prepare")
- wait = next(step for step in steps if step.get("wait") == "hooks")
- finalize = next(step for step in steps if step.get("name") == "Finalize release patch")
- assert hooks["background"] is True
- assert hooks["run"].splitlines()[-1] == "prek install-hooks"
- assert [build["with"]["phase"], finalize["with"]["phase"]] == ["build", "finalize"]
- assert steps.index(hooks) < steps.index(build) < steps.index(wait) < steps.index(finalize)
-
-
-def test_changelog_checkout_fetches_the_base_branch() -> None:
- workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/news.yaml").read_text())
- checkout = next(step for step in workflow["jobs"]["changelog"]["steps"] if "checkout" in step.get("uses", ""))
- assert checkout["with"]["fetch-depth"] == 0
def test_release_writer_has_only_notes_and_reconciler() -> None:
@@ -214,36 +132,6 @@ def test_release_writer_has_only_notes_and_reconciler() -> None:
assert publish["steps"][0]["with"]["artifact-ids"] == "${{ needs.notes.outputs.artifact-id }}"
-def test_release_notes_run_without_write_access() -> None:
- workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text())
- notes = workflow["jobs"]["notes"]
- assert notes["permissions"] == {"contents": "read"}
- assert notes["steps"][0]["with"]["fetch-depth"] == 1
- assert "$/render-release-notes" in [step.get("uses") for step in notes["steps"]]
- assert notes["outputs"]["artifact-id"] == "${{ steps.artifact.outputs.artifact-id }}"
- assert (
- next(step for step in notes["steps"] if "download-artifact" in step.get("uses", ""))["with"]["artifact-ids"]
- == "${{ needs.contributors.outputs.artifact-id }}"
- )
-
-
-def test_release_contributors_job_has_no_checkout() -> None:
- workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text())
- contributors = workflow["jobs"]["contributors"]
- assert contributors["permissions"] == {"contents": "write"}
- assert not any("checkout" in step.get("uses", "") for step in contributors["steps"])
- assert not any("setup-uv" in step.get("uses", "") for step in contributors["steps"])
-
-
-def test_release_contributors_job_uploads_generated_notes() -> None:
- workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text())
- contributors = workflow["jobs"]["contributors"]
- assert contributors["steps"][0]["name"] == "Fetch contributor metadata"
- assert "releases/generate-notes" in contributors["steps"][0]["run"]
- assert contributors["steps"][1]["with"]["path"] == "${{ runner.temp }}/generated-notes.md"
- assert contributors["outputs"]["artifact-id"] == "${{ steps.artifact.outputs.artifact-id }}"
-
-
def test_shfmt_hook_rejects_unformatted_inline_shell(tmp_path: Path) -> None:
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
script = tmp_path / "inline-shell-check.sh"
diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py
index ce35dad..8304e44 100644
--- a/tests/render_release_notes/test_notes.py
+++ b/tests/render_release_notes/test_notes.py
@@ -45,11 +45,10 @@ def test_render_release_notes_uses_tagged_changelog(
"render-release-notes",
version="0.2.0",
TAG="0.2.0",
- OUTPUT=str(tmp_path / "notes.md"),
ARTIFACT_URL="https://pypi.org/project/fixture/0.2.0/",
)
assert result.returncode == 0, result.stderr
- assert (tmp_path / "notes.md").read_text() == dedent(
+ assert (tmp_path / "release-notes.md").read_text() == dedent(
"""\
@@ -81,11 +80,10 @@ def test_render_release_notes_uses_generated_contributors(
"render-release-notes",
version="0.2.0",
TAG="0.2.0",
- OUTPUT=str(tmp_path / "notes.md"),
GENERATED_NOTES=str(generated),
)
assert result.returncode == 0, result.stderr
- assert "### New Contributors\n\n- @gaborbernat" in (tmp_path / "notes.md").read_text()
+ assert "### New Contributors\n\n- @gaborbernat" in (tmp_path / "release-notes.md").read_text()
def test_render_release_notes_accepts_first_release(
@@ -99,11 +97,10 @@ def test_render_release_notes_accepts_first_release(
"render-release-notes",
version="0.2.0",
TAG="0.2.0",
- OUTPUT=str(tmp_path / "notes.md"),
)
assert result.returncode == 0, result.stderr
- assert "### Features" in (tmp_path / "notes.md").read_text()
- assert "compare/" not in (tmp_path / "notes.md").read_text()
+ assert "### Features" in (tmp_path / "release-notes.md").read_text()
+ assert "compare/" not in (tmp_path / "release-notes.md").read_text()
@pytest.mark.parametrize(
@@ -124,10 +121,12 @@ def test_render_release_notes_converts_rst_reference(
version="0.2.0",
TAG="0.2.0",
CHANGELOG="CHANGELOG.rst",
- OUTPUT=str(tmp_path / "notes.md"),
)
assert result.returncode == 0, result.stderr
- assert f"Correct a bug ([#4](https://github.com/tox-dev/fixture/{path}/4))" in (tmp_path / "notes.md").read_text()
+ assert (
+ f"Correct a bug ([#4](https://github.com/tox-dev/fixture/{path}/4))"
+ in (tmp_path / "release-notes.md").read_text()
+ )
def test_render_release_notes_custom_artifact_label(
@@ -140,14 +139,13 @@ def test_render_release_notes_custom_artifact_label(
"render-release-notes",
version="0.2.0",
TAG="0.2.0",
- OUTPUT=str(tmp_path / "notes.md"),
ARTIFACT_URL="https://github.com/tox-dev/fixture/releases/download/0.2.0/fixture.pyz",
ARTIFACT_LABEL="Zipapp",
)
assert result.returncode == 0, result.stderr
assert (
"[Zipapp](https://github.com/tox-dev/fixture/releases/download/0.2.0/fixture.pyz)"
- in (tmp_path / "notes.md").read_text()
+ in (tmp_path / "release-notes.md").read_text()
)
@@ -176,7 +174,6 @@ def test_render_release_notes_rejects_invalid_source(
"render-release-notes",
version=tag,
TAG=tag,
- OUTPUT=str(tmp_path / "notes.md"),
)
assert result.returncode != 0
assert error in result.stderr
diff --git a/tests/test_conftest.py b/tests/test_conftest.py
new file mode 100644
index 0000000..0e0dea2
--- /dev/null
+++ b/tests/test_conftest.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from conftest import run
+
+
+def test_run_reports_failed_command(tmp_path: Path) -> None:
+ with pytest.raises(AssertionError, match="false failed with exit code 1"):
+ run(tmp_path, "false")
diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py
index 9069919..3fe25f4 100644
--- a/tests/upgrade_release_tools/test_upgrade.py
+++ b/tests/upgrade_release_tools/test_upgrade.py
@@ -48,79 +48,6 @@ def lock_repository(tmp_path: Path) -> Path:
return repository
-def test_upgrade_locks_only_selected_groups(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None:
- project = lock_repository / "pyproject.toml"
- project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]'))
- installed = action(
- lock_repository,
- tmp_path,
- upgrade_scripts,
- "install-locked-tools",
- version="",
- GROUPS="lock-tools",
- LOCK_SOURCE="shared",
- VENV=str(tmp_path / "lock-tools"),
- )
- assert installed.returncode == 0, installed.stderr
- resolved = execute(
- lock_repository,
- refresh_script(upgrade_scripts, "Resolve selected Nab groups"),
- tmp_path,
- LOCK_GROUPS="release-check",
- MODE="refresh",
- )
- assert resolved.returncode == 0, resolved.stderr
- lock = tomllib.loads((lock_repository / "pylock.toml").read_text())
- assert lock["dependency-groups"] == ["project-runtime", "release-check"]
- assert "test" not in {package["name"] for package in lock["packages"]}
-
-
-def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None:
- project = lock_repository / "pyproject.toml"
- project.write_text(
- project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')
- + '[tool.nab.packages.old]\nuploaded-prior-to = "2020-01-01T00:00:00Z"\n'
- )
- installed = action(
- lock_repository,
- tmp_path,
- upgrade_scripts,
- "install-locked-tools",
- version="",
- GROUPS="lock-tools",
- LOCK_SOURCE="shared",
- VENV=str(tmp_path / "lock-tools"),
- )
- assert installed.returncode == 0, installed.stderr
- refreshed = execute(
- lock_repository,
- refresh_script(upgrade_scripts, "Resolve selected Nab groups"),
- tmp_path,
- LOCK_GROUPS="release-check",
- MODE="refresh",
- )
- assert refreshed.returncode == 0, refreshed.stderr
- expected = (lock_repository / "pylock.toml").read_bytes()
- project_bytes = project.read_bytes()
- run(lock_repository, "git", "add", ".")
- run(lock_repository, "git", "commit", "-m", "Record lock")
- (lock_repository / "pylock.toml").write_bytes(expected.replace(b'"release-check"', b'"release-news"', 1))
- stale = (lock_repository / "pylock.toml").read_bytes()
- checked = action(
- lock_repository,
- tmp_path,
- upgrade_scripts,
- "refresh-release-lock",
- version="",
- LOCK_GROUPS="release-check",
- MODE="check",
- )
- assert checked.returncode != 0
- assert "regenerated Nab lock differs" in checked.stderr
- assert (lock_repository / "pylock.toml").read_bytes() != stale
- assert project.read_bytes() == project_bytes
-
-
def test_upgrade_preserves_deliberate_nab_hold(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None:
project = lock_repository / "pyproject.toml"
project.write_text(
@@ -310,25 +237,6 @@ def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path:
]
-@pytest.mark.parametrize("kind", ["lock", "hooks"], ids=["lock", "hooks"])
-def test_upgrade_downloads_the_generated_patch(kind: str) -> None:
- workflow = yaml.safe_load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text()
- )
- generator = workflow["jobs"][f"generate-{kind}"]
- download = next(
- step
- for step in workflow["jobs"]["publish"]["steps"]
- if step.get("with", {}).get("path") == f"${{{{ runner.temp }}}}/release-tools-{kind}"
- )
-
- assert generator["outputs"]["artifact-id"] == "${{ steps.upload.outputs.artifact-id }}"
- assert next(step for step in generator["steps"] if step.get("id") == "upload")["with"]["name"] == (
- f"release-tools-{kind}"
- )
- assert download["with"]["artifact-ids"] == f"${{{{ needs.generate-{kind}.outputs.artifact-id }}}}"
-
-
def test_upgrade_preserves_requested_pull_request_body(
lock_repository: Path, tmp_path: Path, publish_scripts: Path
) -> None:
diff --git a/tests/validate_news/test_lock_groups.py b/tests/validate_news/test_lock_groups.py
index de5308a..3e19182 100644
--- a/tests/validate_news/test_lock_groups.py
+++ b/tests/validate_news/test_lock_groups.py
@@ -4,205 +4,78 @@
import subprocess
import sys
from pathlib import Path
+from typing import Final
import pytest
+from tools.lock_groups import validate_workspace_sources
-def test_lock_groups_reads_recorded_selection(tmp_path: Path) -> None:
- (tmp_path / "pyproject.toml").write_text(
- "[dependency-groups]\nrelease = []\nrelease-news = []\nrelease-check = []\n"
+_TOOLS: Final = Path(__file__).resolve().parents[2] / "tools"
+
+
+def _producer(lock: Path) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ [sys.executable, str(_TOOLS / "lock_groups.py"), str(lock)], capture_output=True, text=True, check=False
)
+
+
+def test_lock_groups_accepts_release_creator_lock(tmp_path: Path) -> None:
lock = tmp_path / "pylock.toml"
- lock.write_text(
- '[tool.nab]\ncommand-line = ["nab", "lock", "--groups", "release-news", "release-check", '
- '"--output", "pylock.toml"]\n'
- )
+ lock.write_text("[tool.release-creator]\ncreated-at = 2026-10-24T00:00:00Z\n")
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
+ result = _producer(lock)
- assert result.returncode == 0
- assert result.stdout == "release-news release-check\n"
+ assert result.returncode == 0, result.stderr
@pytest.mark.parametrize(
- ("command", "message"),
+ ("content", "message"),
[
- pytest.param('["nab", "lock", "--output", "pylock.toml"]', "cannot read lock groups", id="missing-flag"),
- pytest.param('"nab lock --groups release-news"', "must be a list of strings", id="string-command"),
- pytest.param('["nab", 42]', "must be a list of strings", id="non-string-argument"),
- pytest.param(
- '["nab", "lock", "--groups", "--output", "pylock.toml"]',
- "Nab lock records no selected groups",
- id="empty-groups",
- ),
+ pytest.param('[tool.nab]\ncommand-line = ["nab", "lock"]\n', "must come from release-creator locks", id="nab"),
+ pytest.param('lock-version = "1.0"\n', "no recognized producer", id="unknown"),
],
)
-def test_lock_groups_rejects_missing_selection(tmp_path: Path, command: str, message: str) -> None:
+def test_lock_groups_rejects_other_producer(tmp_path: Path, content: str, message: str) -> None:
lock = tmp_path / "pylock.toml"
- lock.write_text(f"[tool.nab]\ncommand-line = {command}\n")
+ lock.write_text(content)
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
+ result = _producer(lock)
assert result.returncode != 0
assert message in result.stderr
def test_lock_groups_import_has_no_cli_side_effect(capsys: pytest.CaptureFixture[str]) -> None:
- runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), run_name="lock_groups")
+ runpy.run_path(str(_TOOLS / "lock_groups.py"), run_name="lock_groups")
assert capsys.readouterr() == ("", "")
-def test_lock_groups_reads_full_project_selection(tmp_path: Path) -> None:
- (tmp_path / "pyproject.toml").write_text(
- "[project.optional-dependencies]\nweb = []\n[dependency-groups]\nrelease-news = []\ntest = []\n"
- )
- lock = tmp_path / "pylock.toml"
- lock.write_text(
- 'dependency-groups = ["project-runtime", "release-news", "test"]\nextras = ["web"]\ndefault-groups = []\n'
- "[tool.nab]\ncreated-at = 2026-10-24T00:00:00Z\n"
- "[tool.release-creator]\ncreated-at = 2026-10-24T00:00:00Z\n"
- )
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert (result.returncode, result.stdout) == (0, "release-news\n")
-
-
-def test_lock_groups_includes_workspace_members_and_exclusions(tmp_path: Path) -> None:
- (tmp_path / "pyproject.toml").write_text(
- '[tool.uv.workspace]\nmembers = ["packages/*"]\nexclude = ["packages/unused"]\n'
- "[dependency-groups]\nrelease-news = []\n"
- )
- for name, groups, extras in (
- ("included", "test = []\nrelease-package = []\n", "web = []\n"),
- ("unused", "stale = []\n", "stale = []\n"),
- ):
- member = tmp_path / "packages" / name
- member.mkdir(parents=True)
- (member / "pyproject.toml").write_text(
- f"[dependency-groups]\n{groups}[project.optional-dependencies]\n{extras}"
- )
- lock = tmp_path / "pylock.toml"
- lock.write_text(
- 'dependency-groups = ["project-runtime", "release-news", "release-package", "test"]\n'
- 'extras = ["web"]\ndefault-groups = []\n[tool.release-creator]\n'
- )
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert (result.returncode, result.stdout) == (0, "release-news release-package\n")
-
-
-def test_lock_groups_accepts_declared_workspace_source(tmp_path: Path) -> None:
+def test_workspace_sources_accept_declared_member(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text(
- '[project]\nname = "bare"\n[tool.uv.workspace]\nmembers = ["packages/*"]\n'
- "[dependency-groups]\nrelease-news = []\n"
+ '[project]\nname = "bare"\n[tool.uv.workspace]\nmembers = ["packages/*"]\nexclude = ["packages/unused"]\n'
)
- for name in ("meta", "other"):
+ for name in ("meta", "other", "unused"):
member = tmp_path / "packages" / name
member.mkdir(parents=True)
(member / "pyproject.toml").write_text(
- f'[project]\nname = "{name}"\n[dependency-groups]\nrelease-{name} = []\n'
+ f'[project]\nname = "{name}"\n'
+ ("[tool.uv.sources]\nbare = { workspace = true }\n" if name == "meta" else "")
+ + ("[tool.uv.sources]\nmissing = { workspace = true }\n" if name == "unused" else "")
)
(tmp_path / "packages" / "notes.txt").write_text("not a project")
- lock = tmp_path / "pylock.toml"
- lock.write_text(
- 'dependency-groups = ["project-runtime", "release-news", "release-meta", "release-other"]\n'
- "extras = []\ndefault-groups = []\n[tool.release-creator]\n"
- )
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert (result.returncode, result.stdout) == (0, "release-news release-meta release-other\n")
-
-
-@pytest.mark.parametrize(
- ("groups", "extras", "message"),
- [
- pytest.param('"release-news"', "[]", "invalid dependency groups", id="groups-not-list"),
- pytest.param('["release-news", 1]', "[]", "invalid dependency groups", id="non-string-group"),
- pytest.param('["project-runtime", "release-news"]', '"web"', "invalid extras", id="extras-not-list"),
- pytest.param('["project-runtime", "release-news"]', "[1]", "invalid extras", id="non-string-extra"),
- pytest.param(
- '["project-runtime", "release-news"]',
- '["web", "unknown"]',
- "invalid or undeclared extras",
- id="undeclared-extra",
- ),
- pytest.param(
- '["project-runtime", "release-news"]',
- '["web", "web"]',
- "invalid or undeclared extras",
- id="duplicate-extra",
- ),
- ],
-)
-def test_lock_groups_rejects_malformed_project_selections(
- tmp_path: Path, groups: str, extras: str, message: str
-) -> None:
- (tmp_path / "pyproject.toml").write_text(
- "[dependency-groups]\nrelease-news = []\n[project.optional-dependencies]\nweb = []\n"
- )
- lock = tmp_path / "pylock.toml"
- lock.write_text(f"dependency-groups = {groups}\nextras = {extras}\ndefault-groups = []\n[tool.release-creator]\n")
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert result.returncode != 0
- assert message in result.stderr
+ validate_workspace_sources(tmp_path / "pyproject.toml")
-def test_lock_groups_rejects_invalid_workspace_patterns(tmp_path: Path) -> None:
+def test_workspace_sources_reject_invalid_patterns(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text("[tool.uv.workspace]\nmembers = [1]\n")
- lock = tmp_path / "pylock.toml"
- lock.write_text(
- 'dependency-groups = ["project-runtime"]\nextras = []\ndefault-groups = []\n[tool.release-creator]\n'
- )
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
- assert result.returncode != 0
- assert "workspace patterns must be strings" in result.stderr
+ with pytest.raises(ValueError, match="workspace patterns must be strings"):
+ validate_workspace_sources(tmp_path / "pyproject.toml")
-def test_lock_groups_rejects_workspace_member_outside_project(tmp_path: Path) -> None:
+def test_workspace_sources_reject_member_outside_project(tmp_path: Path) -> None:
project = tmp_path / "project"
project.mkdir()
(project / "pyproject.toml").write_text('[tool.uv.workspace]\nmembers = ["external"]\n')
@@ -210,21 +83,9 @@ def test_lock_groups_rejects_workspace_member_outside_project(tmp_path: Path) ->
outside.mkdir()
(outside / "pyproject.toml").write_text("[dependency-groups]\nrelease-hidden = []\n")
(project / "external").symlink_to(outside, target_is_directory=True)
- lock = project / "pylock.toml"
- lock.write_text(
- 'dependency-groups = ["project-runtime", "release-hidden"]\n'
- "extras = []\ndefault-groups = []\n[tool.release-creator]\n"
- )
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert result.returncode != 0
- assert "workspace members must be inside the project" in result.stderr
+ with pytest.raises(ValueError, match="workspace members must be inside the project"):
+ validate_workspace_sources(project / "pyproject.toml")
@pytest.mark.parametrize(
@@ -235,7 +96,7 @@ def test_lock_groups_rejects_workspace_member_outside_project(tmp_path: Path) ->
pytest.param('bare = { workspace = true, git = "https://example.org/bare" }', id="mixed-source"),
],
)
-def test_lock_groups_rejects_unapproved_workspace_sources_in_pr_and_release(tmp_path: Path, source: str) -> None:
+def test_lock_check_rejects_unapproved_workspace_sources(tmp_path: Path, source: str) -> None:
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "bare"\nrequires-python = ">=3.15"\n'
'[tool.uv.workspace]\nmembers = ["meta"]\n[dependency-groups]\nrelease-news = []\n'
@@ -251,135 +112,20 @@ def test_lock_groups_rejects_unapproved_workspace_sources_in_pr_and_release(tmp_
'dependency-groups = ["project-runtime", "release-news"]\n'
"default-groups = []\npackages = []\n[tool.release-creator]\ncreated-at = 2026-10-20T00:00:00Z\n"
)
- tools = Path(__file__).resolve().parents[2] / "tools"
- for command in (
- ["python3", str(tools / "lock_groups.py"), str(lock)],
+ result = subprocess.run(
[
sys.executable,
- str(tools / "check_lock_groups.py"),
+ str(_TOOLS / "check_lock_groups.py"),
str(lock),
"release-news",
"--project",
str(tmp_path / "pyproject.toml"),
],
- ):
- result = subprocess.run(command, capture_output=True, text=True, check=False)
- assert result.returncode != 0
- assert "uv source must name a workspace project" in result.stderr
-
-
-@pytest.mark.parametrize(
- ("groups", "extras", "message"),
- [
- pytest.param('["project-runtime", "release-news"]', '["web"]', "omits dependency groups", id="group"),
- pytest.param('["project-runtime", "release-news", "test"]', "[]", "omits extras", id="extra"),
- ],
-)
-def test_lock_groups_rejects_incomplete_project_lock(tmp_path: Path, groups: str, extras: str, message: str) -> None:
- (tmp_path / "pyproject.toml").write_text(
- "[project.optional-dependencies]\nweb = []\n[dependency-groups]\nrelease-news = []\ntest = []\n"
- )
- lock = tmp_path / "pylock.toml"
- lock.write_text(f"dependency-groups = {groups}\nextras = {extras}\ndefault-groups = []\n[tool.release-creator]\n")
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert result.returncode != 0
- assert message in result.stderr
-
-
-@pytest.mark.parametrize(
- ("extra_group", "message"),
- [
- pytest.param("release-x\\nchanged=false", "invalid dependency groups", id="output-injection"),
- pytest.param("release-other", "invalid or undeclared", id="undeclared"),
- pytest.param("release-news", "invalid or undeclared", id="duplicate"),
- ],
-)
-def test_lock_groups_rejects_extra_full_lock_group(tmp_path: Path, extra_group: str, message: str) -> None:
- (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\n")
- lock = tmp_path / "pylock.toml"
- lock.write_text(
- f'dependency-groups = ["project-runtime", "release-news", "{extra_group}"]\n'
- "extras = []\ndefault-groups = []\n[tool.release-creator]\n"
- )
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert result.returncode != 0
- assert message in result.stderr
-
-
-@pytest.mark.parametrize(
- "default_groups",
- [pytest.param("[]", id="empty"), pytest.param('["test"]', id="test"), pytest.param(None, id="missing")],
-)
-def test_lock_groups_requires_empty_defaults(tmp_path: Path, default_groups: str | None) -> None:
- (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\ntest = []\n")
- lock = tmp_path / "pylock.toml"
- defaults = f"default-groups = {default_groups}\n" if default_groups is not None else ""
- lock.write_text(
- 'dependency-groups = ["project-runtime", "release-news", "test"]\n'
- f"{defaults}[tool.release-creator]\ncreated-at = 2026-10-24T00:00:00Z\n"
- )
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert (result.returncode == 0) is (default_groups == "[]")
-
-
-def test_lock_groups_accepts_project_without_dependency_groups(tmp_path: Path) -> None:
- (tmp_path / "pyproject.toml").write_text('[project]\nname = "example"\nversion = "1"\n')
- lock = tmp_path / "pylock.toml"
- lock.write_text(
- 'dependency-groups = ["project-runtime"]\nextras = []\ndefault-groups = []\n[tool.release-creator]\n'
- )
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
- capture_output=True,
- text=True,
- check=False,
- )
-
- assert (result.returncode, result.stdout) == (0, "\n")
-
-
-@pytest.mark.parametrize(
- ("groups", "message"),
- [
- pytest.param('"release-news"', "omits release groups", id="omitted-release-group"),
- pytest.param('"release-news", "release-check", "unknown"', "invalid or undeclared", id="undeclared-group"),
- pytest.param('"release-news\\nmode=refresh", "release-check"', "invalid or undeclared", id="output-injection"),
- ],
-)
-def test_lock_groups_rejects_untrusted_selection(tmp_path: Path, groups: str, message: str) -> None:
- (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\nrelease-check = []\n")
- lock = tmp_path / "pylock.toml"
- lock.write_text(f'[tool.nab]\ncommand-line = ["nab", "lock", "--groups", {groups}]\n')
-
- result = subprocess.run(
- ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)],
capture_output=True,
text=True,
check=False,
)
assert result.returncode != 0
- assert message in result.stderr
+ assert "uv source must name a workspace project" in result.stderr
diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py
index 1389158..64d3b3e 100644
--- a/tests/validate_news/test_news.py
+++ b/tests/validate_news/test_news.py
@@ -5,7 +5,6 @@
from pathlib import Path
import pytest
-import yaml
from conftest import action, run
@@ -130,22 +129,15 @@ def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action
assert "news validation needs a PR base" in result.stderr
-def test_validate_news_forwards_secondary_lock_inputs() -> None:
- config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-news/action.yml").read_text())
- refresh = next(step for step in config["runs"]["steps"] if step.get("uses") == "$/refresh-release-lock")
- secondary = [name for name in config["inputs"] if name.startswith("additional-")]
-
- assert secondary
- assert all(refresh["with"][name] == "${{ inputs." + name + " }}" for name in secondary)
- assert refresh["with"]["mode"] == "check"
- assert refresh["with"]["keep-uv-lock"] == "${{ inputs.keep-uv-lock }}"
-
-
@pytest.mark.parametrize(
("additional_lock", "keep_uv_lock", "changed_path", "changed"),
[
pytest.param("pylock.zipapp.toml", False, "pylock.zipapp.toml", "true", id="selected-secondary-lock"),
pytest.param("", False, "pylock.zipapp.toml", "false", id="unselected-secondary-lock"),
+ pytest.param("", False, "pylock.toml", "true", id="primary-lock"),
+ pytest.param("", False, "pyproject.toml", "true", id="root-project"),
+ pytest.param("", False, ".github/workflows/release.yaml", "true", id="workflow-repin"),
+ pytest.param("", False, "README.md", "false", id="unrelated"),
pytest.param("", False, "nab.toml", "true", id="nab-policy"),
pytest.param("", False, "meta/pyproject.toml", "true", id="workspace-member"),
pytest.param("", True, "uv.lock", "true", id="selected-native-uv-lock"),
@@ -177,6 +169,8 @@ def test_validate_news_detects_release_input_change(
(repository / changed_path).write_text(
"[tool.nab]\nuploaded-prior-to = false\n"
if changed_path == "nab.toml"
+ else (repository / changed_path).read_text() + "\n"
+ if changed_path in {"pylock.toml", "pyproject.toml"}
else "lock-version = '1.0'\ncreated-by = 'test'\n"
)
run(repository, "git", "add", changed_path)
@@ -201,3 +195,32 @@ def test_validate_news_detects_release_input_change(
assert result.returncode == 0, result.stderr
assert f"changed={changed}\n" in output.read_text()
+
+
+def test_validate_news_rejects_removed_release_lock(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ (repository / "pylock.toml").write_text("lock-version = '1.0'\n")
+ run(repository, "git", "add", ".")
+ run(repository, "git", "commit", "-m", "Add release lock")
+ run(repository, "git", "push", "origin", "main")
+ run(repository, "git", "checkout", "-b", "remove-lock")
+ run(repository, "git", "rm", "-q", "pylock.toml")
+ run(repository, "git", "commit", "-m", "Remove release lock")
+
+ result = subprocess.run(
+ ["bash", str(action_scripts / "validate-news-2.sh")],
+ cwd=repository,
+ env=os.environ
+ | {
+ "BASE_REF": "main",
+ "ADDITIONAL_LOCK": "",
+ "KEEP_UV_LOCK": "false",
+ "GITHUB_OUTPUT": str(tmp_path / "lock-change-output"),
+ "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"),
+ },
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode != 0
+ assert "the release lock was removed" in result.stderr
diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py
index b84e4b7..4f39410 100644
--- a/tests/validate_python_release/test_tagged.py
+++ b/tests/validate_python_release/test_tagged.py
@@ -47,51 +47,24 @@ def build_constraints(build_repository: Path, tmp_path: Path) -> Path:
return path
-def test_validator_isolates_release_tools_from_build() -> None:
- workflow = yaml.load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text(),
- Loader=yaml.BaseLoader,
- )
- assert set(workflow["on"]["workflow_call"]["inputs"]) == {
- "source",
- "artifact-prefix",
- "keep-uv-lock",
- "additional-lock",
- "additional-lock-groups",
- "additional-lock-selection",
- "additional-matrix-python",
- "additional-matrix-platforms",
- "additional-matrix-implementations",
- }
- assert set(workflow["jobs"]) == {"source-check", "constraints", "build", "validate"}
- assert workflow["jobs"]["build"]["needs"] == ["source-check", "constraints"]
- lock_check = next(
- step for step in workflow["jobs"]["source-check"]["steps"] if step.get("uses") == "$/refresh-release-lock"
- )
- assert lock_check["with"]["mode"] == "check"
- for option in workflow["on"]["workflow_call"]["inputs"]:
- if option not in {"source", "artifact-prefix"}:
- assert lock_check["with"][option] == f"${{{{ inputs.{option} }}}}"
- assert not any(step.get("uses", "").startswith("$/install-") for step in workflow["jobs"]["build"]["steps"])
- assert workflow["jobs"]["validate"]["needs"] == "build"
- assert not any(step.get("uses") == "$/install-locked-tools" for step in workflow["jobs"]["validate"]["steps"])
- validator = next(
- step for step in workflow["jobs"]["validate"]["steps"] if step.get("name") == "Install distribution validators"
+@pytest.mark.parametrize(
+ ("workflow_name", "job"),
+ [
+ pytest.param("validate-python-release", "source-check", id="python-release"),
+ pytest.param("release", "check", id="self-release"),
+ ],
+)
+def test_tagged_source_has_history_and_read_access_for_hook_proof(workflow_name: str, job: str) -> None:
+ workflow = yaml.safe_load(
+ (Path(__file__).resolve().parents[2] / ".github/workflows" / f"{workflow_name}.yaml").read_text()
)
- assert "twine>=7" in validator["run"]
- assert "check-wheel-contents>=0.6.3" in validator["run"]
-
-
-def test_tagged_source_has_history_and_read_access_for_hook_proof(release_workflow: dict[str, Any]) -> None:
- source = release_workflow["jobs"]["source-check"]
+ source = workflow["jobs"][job]
assert source["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"}
assert next(step for step in source["steps"] if "checkout" in step.get("uses", ""))["with"]["fetch-depth"] == 0
- assert [step.get("uses") for step in source["steps"]].index("$/refresh-release-lock") < [
- step.get("uses") for step in source["steps"]
- ].index("$/verify-frozen-hooks")
+ assert "$/check-tagged-source" in [step.get("uses") for step in source["steps"]]
-def test_tagged_source_rejects_stale_project_requirement(tmp_path: Path, release_workflow: dict[str, Any]) -> None:
+def test_tagged_source_rejects_stale_project_requirement(tmp_path: Path) -> None:
source = Path(__file__).resolve().parents[2]
project = tmp_path / "pyproject.toml"
project.write_text(
@@ -117,10 +90,6 @@ def test_tagged_source_rejects_stale_project_requirement(tmp_path: Path, release
assert selected.returncode == 0, selected.stderr
assert freshness.returncode != 0
assert "prek>=999" in freshness.stderr
- assert any(
- step.get("uses") == "$/refresh-release-lock" and step.get("with", {}).get("mode") == "check"
- for step in release_workflow["jobs"]["source-check"]["steps"]
- )
def test_validator_builds_reproducible_distributions_and_checks_them(
@@ -192,7 +161,12 @@ def test_validator_rejects_backend_hash_mismatch(
pytest.param("off-main", "refs/tags/0.2.0", False, id="tagged-off-main-commit"),
],
)
-def test_validator_requires_exact_tag(repository: Path, tag_kind: str, event_ref: str, accepted: bool) -> None:
+@pytest.mark.parametrize(
+ "location", [pytest.param("check-tagged-source/action.yml", id="source-check"), pytest.param("build", id="build")]
+)
+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":
@@ -203,12 +177,15 @@ def test_validator_requires_exact_tag(repository: Path, tag_kind: str, event_ref
run(repository, "git", "tag", "-a", "-m", "Release", "0.2.0")
else:
run(repository, "git", "tag", "0.2.0")
- workflow = yaml.safe_load(
- (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text()
- )
- verify = next(
- step for step in workflow["jobs"]["build"]["steps"] if step.get("name") == "Verify the exact release tag"
- )
+ root = Path(__file__).resolve().parents[2]
+ steps = (
+ yaml.safe_load((root / location).read_text())["runs"]["steps"]
+ if location.endswith(".yml")
+ else yaml.safe_load((root / ".github/workflows/validate-python-release.yaml").read_text())["jobs"][location][
+ "steps"
+ ]
+ )
+ verify = next(step for step in steps if step.get("name") == "Verify the exact release tag")
result = subprocess.run(
["bash", "-c", verify["run"]],
cwd=repository,
diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py
index 5bba7f3..76844a3 100644
--- a/tests/validate_release_patch/test_validate.py
+++ b/tests/validate_release_patch/test_validate.py
@@ -4,7 +4,6 @@
from textwrap import dedent
import pytest
-import yaml
from conftest import action, run
@@ -28,12 +27,6 @@ def test_validate_applies_patch_in_fresh_worktree(repository: Path, tmp_path: Pa
assert run(repository, "git", "diff", "--cached", "--name-only").stdout == source_staged
-def test_validate_uses_current_checkout() -> None:
- config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-release-patch/action.yml").read_text())
- assert "working-directory" not in config.get("inputs", {})
- assert all("working-directory" not in step for step in config["runs"]["steps"])
-
-
def track_rst_changelog(repository: Path) -> None:
(repository / "CHANGELOG.rst").write_text("Changelog\n=========\n")
project = repository / "pyproject.toml"
diff --git a/tests/verify_frozen_hooks/test_frozen_hook_action.py b/tests/verify_frozen_hooks/test_frozen_hook_action.py
new file mode 100644
index 0000000..dd52974
--- /dev/null
+++ b/tests/verify_frozen_hooks/test_frozen_hook_action.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Final
+
+import pytest
+from conftest import action, run
+
+_LOCAL_HOOK: Final = (
+ "repos:\n - repo: local\n hooks:\n - id: noop\n name: noop\n entry: 'true'\n"
+ " language: system\n"
+)
+
+
+def _repository(tmp_path: Path, config: str, *extra: str) -> Path:
+ repository = tmp_path / "source"
+ repository.mkdir()
+ run(repository, "git", "init", "-q")
+ (repository / ".pre-commit-config.yaml").write_text(config)
+ for path in extra:
+ (repository / path).parent.mkdir(parents=True, exist_ok=True)
+ (repository / path).write_text(_LOCAL_HOOK)
+ run(repository, "git", "add", ".")
+ run(repository, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-qm", "Hooks")
+ return repository
+
+
+def test_verify_frozen_hooks_accepts_root_configuration(tmp_path: Path, action_scripts: Path) -> None:
+ repository = _repository(tmp_path, _LOCAL_HOOK)
+
+ result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="")
+
+ assert result.returncode == 0, result.stderr
+
+
+@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_verify_frozen_hooks_rejects_other_configuration(tmp_path: Path, action_scripts: Path, path: str) -> None:
+ repository = _repository(tmp_path, _LOCAL_HOOK, path)
+
+ result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="")
+
+ assert result.returncode != 0
+ assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr
+
+
+def test_verify_frozen_hooks_rejects_movable_revision(tmp_path: Path, action_scripts: Path) -> None:
+ repository = _repository(
+ tmp_path, "repos:\n - repo: https://github.com/example/hooks\n rev: v1\n hooks: []\n"
+ )
+
+ result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="")
+
+ assert result.returncode != 0
+ assert "full lowercase commit SHA" in result.stderr
+
+
+def test_verify_frozen_hooks_rejects_missing_managed_pins(tmp_path: Path, action_scripts: Path) -> None:
+ repository = _repository(
+ tmp_path,
+ "repos:\n - repo: local\n hooks:\n - id: fetch\n name: fetch\n entry: 'true'\n"
+ " language: python\n additional_dependencies: [requests==2.31.0]\n",
+ )
+
+ result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="")
+
+ assert result.returncode != 0
+ assert "pins do not match the resolved closure" in result.stderr
+
+
+def test_verify_frozen_hooks_proves_tagged_revisions_before_building_hooks(
+ tmp_path: Path, action_scripts: Path
+) -> None:
+ repository = _repository(
+ tmp_path,
+ "repos:\n - repo: local\n hooks:\n - id: fetch\n name: fetch\n entry: 'true'\n"
+ " language: python\n additional_dependencies: [requests==2.31.0]\n",
+ )
+
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "verify-frozen-hooks",
+ version="",
+ GITHUB_REF="refs/tags/v1.0.0",
+ GITHUB_REF_NAME="v1.0.0",
+ )
+
+ assert result.returncode != 0
+ assert "pins do not match the resolved closure" not in result.stderr
diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py
index 3ffbd25..dc06b58 100644
--- a/tests/verify_pypi_files/test_verify.py
+++ b/tests/verify_pypi_files/test_verify.py
@@ -3,6 +3,7 @@
import hashlib
import importlib.util
import io
+import itertools
import json
import runpy
import ssl
@@ -27,7 +28,7 @@
assert _SPEC is not None and _SPEC.loader is not None
_MODULE = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_MODULE)
-verify_files = cast("Callable[[str, str, Path, int], list[Path]]", _MODULE.verify_files)
+verify_files = cast("Callable[[str, str, Path], list[Path]]", _MODULE.verify_files)
type _Json = None | bool | int | float | str | list[_Json] | dict[str, _Json]
@@ -38,6 +39,17 @@ class _ListingFile(TypedDict):
yanked: bool | str
+@pytest.fixture(autouse=True)
+def expired_deadline(mocker: MockerFixture) -> None:
+ mocker.patch.object(_MODULE.time, "monotonic", side_effect=itertools.count(step=121))
+ mocker.patch.object(_MODULE.time, "sleep")
+
+
+@pytest.fixture
+def waiting_clock(mocker: MockerFixture) -> None:
+ mocker.patch.object(_MODULE.time, "monotonic", return_value=0)
+
+
@pytest.fixture
def distribution(tmp_path: Path) -> Path:
directory = tmp_path / "dist"
@@ -50,16 +62,16 @@ def distribution(tmp_path: Path) -> Path:
def test_verify_files_matches_complete_release(distribution: Path, mocker: MockerFixture) -> None:
_serve(mocker, distribution)
- result = verify_files("fixture", "1.0", distribution, 0)
+ result = verify_files("fixture", "1.0", distribution)
assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"]
def test_verify_files_uses_pinned_trust_store(distribution: Path, mocker: MockerFixture) -> None:
- request = mocker.patch.object(_MODULE, "urlopen", return_value=_body(_listing(distribution)))
+ request = mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(_listing(distribution)))
context = mocker.patch.object(ssl, "create_default_context", autospec=True)
- verify_files("fixture", "1.0", distribution, 0)
+ verify_files("fixture", "1.0", distribution)
assert request.call_args.kwargs["context"] is context.return_value
assert context.call_args.kwargs == {"cafile": certifi.where()}
@@ -72,7 +84,7 @@ def test_verify_files_rejects_unsupported_simple_api(distribution: Path, mocker:
return_value=io.BytesIO(json.dumps({"meta": {"api-version": "2.0"}, "files": _listing(distribution)}).encode()),
)
with pytest.raises(ValueError, match="unsupported PyPI Simple API version"):
- verify_files("fixture", "1.0", distribution, 0)
+ verify_files("fixture", "1.0", distribution)
def test_main_prints_distribution_paths(
@@ -98,13 +110,13 @@ def test_main_reports_invalid_directory(
cast("Callable[[], None]", _MODULE.main)()
assert raised.value.code == 2
- assert "release files require a directory" in capsys.readouterr().err
+ assert "distribution directory does not exist" in capsys.readouterr().err
def test_script_entrypoint_prints_distribution_paths(
distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str]
) -> None:
- mocker.patch("urllib.request.urlopen", return_value=_body(_listing(distribution)))
+ mocker.patch("urllib.request.urlopen", autospec=True, return_value=_body(_listing(distribution)))
mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)])
runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/verify_pypi_files.py"), run_name="__main__")
@@ -115,34 +127,29 @@ def test_script_entrypoint_prints_distribution_paths(
]
-@pytest.mark.parametrize(
- "change", [pytest.param("negative-wait", id="negative-wait"), pytest.param("missing-dir", id="missing-dir")]
-)
-def test_verify_files_rejects_invalid_arguments(tmp_path: Path, change: str) -> None:
- directory = tmp_path if change == "negative-wait" else tmp_path / "missing"
-
- with pytest.raises(ValueError, match="release files require a directory and nonnegative wait"):
- verify_files("fixture", "1.0", directory, -1 if change == "negative-wait" else 0)
+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, 0)
+ 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, 0)
+ 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})
- mocker.patch.object(_MODULE, "urlopen", return_value=_body(files))
+ mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(files))
- assert verify_files("fixture", "1.0", distribution, 0) == [
+ assert verify_files("fixture", "1.0", distribution) == [
distribution / "fixture-1.0-py3-none-any.whl",
distribution / "fixture-1.0.tar.gz",
]
@@ -162,16 +169,16 @@ def test_verify_files_propagates_http_error(distribution: Path, mocker: MockerFi
)
with pytest.raises(HTTPError) as raised:
- verify_files("fixture", "1.0", distribution, 0)
+ verify_files("fixture", "1.0", distribution)
assert raised.value.code == status
def test_verify_files_propagates_exhausted_connection_error(distribution: Path, mocker: MockerFixture) -> None:
- mocker.patch.object(_MODULE, "urlopen", side_effect=URLError("offline"))
+ mocker.patch.object(_MODULE, "urlopen", autospec=True, side_effect=URLError("offline"))
with pytest.raises(URLError, match="offline"):
- verify_files("fixture", "1.0", distribution, 0)
+ verify_files("fixture", "1.0", distribution)
def test_verify_files_reports_missing_release_after_404(distribution: Path, mocker: MockerFixture) -> None:
@@ -180,7 +187,7 @@ def test_verify_files_reports_missing_release_after_404(distribution: Path, mock
)
with pytest.raises(ValueError, match="PyPI is missing files"):
- verify_files("fixture", "1.0", distribution, 0)
+ verify_files("fixture", "1.0", distribution)
@pytest.mark.parametrize(
@@ -193,12 +200,12 @@ def test_verify_files_reports_missing_release_after_404(distribution: Path, mock
],
)
def test_verify_files_rejects_invalid_listing(distribution: Path, mocker: MockerFixture, payload: _Json) -> None:
- mocker.patch.object(_MODULE, "urlopen", return_value=io.BytesIO(json.dumps(payload).encode()))
+ mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=io.BytesIO(json.dumps(payload).encode()))
with pytest.raises(
(TypeError, ValueError), match="invalid Simple API response|unsupported PyPI Simple API version"
):
- verify_files("fixture", "1.0", distribution, 0)
+ verify_files("fixture", "1.0", distribution)
@pytest.mark.parametrize(
@@ -218,15 +225,15 @@ def test_verify_files_rejects_invalid_file_entry(distribution: Path, mocker: Moc
)
with pytest.raises(TypeError, match="invalid file entry"):
- verify_files("fixture", "1.0", distribution, 0)
+ verify_files("fixture", "1.0", distribution)
def test_verify_files_ignores_legacy_egg_from_older_release(distribution: Path, mocker: MockerFixture) -> None:
files = _listing(distribution)
files.append({"filename": "fixture-0.8.1-py2.4.egg", "hashes": {"sha256": "0" * 64}, "yanked": False})
- mocker.patch.object(_MODULE, "urlopen", return_value=_body(files))
+ mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(files))
- assert verify_files("fixture", "1.0", distribution, 0) == [
+ assert verify_files("fixture", "1.0", distribution) == [
distribution / "fixture-1.0-py3-none-any.whl",
distribution / "fixture-1.0.tar.gz",
]
@@ -247,9 +254,9 @@ def test_verify_files_ignores_legacy_files_from_other_versions(
path.rename(path.with_name(path.name.replace("1.0", "0.9")))
files = _listing(distribution)
files.append({"filename": filename, "hashes": {"sha256": "0" * 64}, "yanked": False})
- mocker.patch.object(_MODULE, "urlopen", return_value=_body(files))
+ mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(files))
- assert verify_files("fixture", "0.9", distribution, 0) == [
+ assert verify_files("fixture", "0.9", distribution) == [
distribution / "fixture-0.9-py3-none-any.whl",
distribution / "fixture-0.9.tar.gz",
]
@@ -273,7 +280,7 @@ def test_verify_files_rejects_bad_remote_release(
_serve(mocker, distribution, change=change)
with pytest.raises(ValueError, match=message):
- verify_files("fixture", "1.0", distribution, 0)
+ verify_files("fixture", "1.0", distribution)
@pytest.mark.parametrize(
@@ -293,9 +300,10 @@ def test_verify_files_rejects_bad_local_release(distribution: Path, change: str,
(distribution / ".hidden").write_bytes(b"other")
with pytest.raises(ValueError, match=message):
- verify_files("fixture", "1.0", distribution, 0)
+ 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)
response = mocker.patch.object(
@@ -303,9 +311,8 @@ def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: M
"urlopen",
side_effect=[_body(files[:-1]), _body(files)],
)
- mocker.patch.object(_MODULE.time, "sleep")
- result = verify_files("fixture", "1.0", distribution, 2)
+ result = verify_files("fixture", "1.0", distribution)
assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"]
assert response.call_count == 2
@@ -329,13 +336,17 @@ def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: M
pytest.param(json.JSONDecodeError("truncated", "{", 1), id="truncated-json"),
],
)
+@pytest.mark.usefixtures("waiting_clock")
def test_verify_files_retries_transient_listing_failure(
- distribution: Path, mocker: MockerFixture, failure: OSError | IncompleteRead | json.JSONDecodeError
+ distribution: Path,
+ mocker: MockerFixture,
+ failure: OSError | IncompleteRead | json.JSONDecodeError,
) -> None:
- response = mocker.patch.object(_MODULE, "urlopen", side_effect=[failure, _body(_listing(distribution))])
- mocker.patch.object(_MODULE.time, "sleep")
+ response = mocker.patch.object(
+ _MODULE, "urlopen", autospec=True, side_effect=[failure, _body(_listing(distribution))]
+ )
- assert verify_files("fixture", "1.0", distribution, 2) == [
+ assert verify_files("fixture", "1.0", distribution) == [
distribution / "fixture-1.0-py3-none-any.whl",
distribution / "fixture-1.0.tar.gz",
]
@@ -349,27 +360,27 @@ def test_verify_files_retries_transient_listing_failure(
pytest.param(IncompleteRead(b"partial", 10), id="incomplete-read"),
],
)
+@pytest.mark.usefixtures("waiting_clock")
def test_verify_files_retries_response_read_failure(
distribution: Path, mocker: MockerFixture, failure: TimeoutError | IncompleteRead
) -> None:
response = mocker.create_autospec(io.BytesIO, instance=True)
response.__enter__.return_value.read.side_effect = failure
- mocker.patch.object(_MODULE, "urlopen", side_effect=[response, _body(_listing(distribution))])
- mocker.patch.object(_MODULE.time, "sleep")
+ mocker.patch.object(_MODULE, "urlopen", autospec=True, side_effect=[response, _body(_listing(distribution))])
- assert verify_files("fixture", "1.0", distribution, 2) == [
+ assert verify_files("fixture", "1.0", distribution) == [
distribution / "fixture-1.0-py3-none-any.whl",
distribution / "fixture-1.0.tar.gz",
]
+@pytest.mark.usefixtures("waiting_clock")
def test_verify_files_retries_truncated_json_response(distribution: Path, mocker: MockerFixture) -> None:
response = mocker.patch.object(
_MODULE, "urlopen", side_effect=[io.BytesIO(b'{"meta":'), _body(_listing(distribution))]
)
- mocker.patch.object(_MODULE.time, "sleep")
- assert verify_files("fixture", "1.0", distribution, 2) == [
+ assert verify_files("fixture", "1.0", distribution) == [
distribution / "fixture-1.0-py3-none-any.whl",
distribution / "fixture-1.0.tar.gz",
]
@@ -379,7 +390,7 @@ def test_verify_files_retries_truncated_json_response(distribution: Path, mocker
def test_main_reports_exhausted_incomplete_read(
distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str]
) -> None:
- mocker.patch.object(_MODULE, "urlopen", side_effect=IncompleteRead(b"partial", 10))
+ mocker.patch.object(_MODULE, "urlopen", autospec=True, side_effect=IncompleteRead(b"partial", 10))
mocker.patch.object(_MODULE.time, "monotonic", side_effect=[0, 121])
mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)])
@@ -390,6 +401,7 @@ def test_main_reports_exhausted_incomplete_read(
assert "IncompleteRead" in capsys.readouterr().err
+@pytest.mark.usefixtures("waiting_clock")
def test_verify_files_backs_off_between_retries(distribution: Path, mocker: MockerFixture) -> None:
mocker.patch.object(
_MODULE,
@@ -402,7 +414,7 @@ def test_verify_files_backs_off_between_retries(distribution: Path, mocker: Mock
)
sleep = mocker.patch.object(_MODULE.time, "sleep")
- verify_files("fixture", "1.0", distribution, 5)
+ verify_files("fixture", "1.0", distribution)
assert [call.args for call in sleep.call_args_list] == [(1,), (2,)]
@@ -423,7 +435,7 @@ def _serve(mocker: MockerFixture, distribution: Path, *, change: str = "") -> No
files.append(files[0].copy())
elif change == "invalid-name":
files.append({"filename": "fixture-1.0.invalid", "hashes": {"sha256": "0" * 64}, "yanked": False})
- mocker.patch.object(_MODULE, "urlopen", return_value=_body(files))
+ mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(files))
def _listing(distribution: Path) -> list[_ListingFile]:
diff --git a/tools/check_frozen_hooks.py b/tools/check_frozen_hooks.py
index 26a3995..c3587ab 100644
--- a/tools/check_frozen_hooks.py
+++ b/tools/check_frozen_hooks.py
@@ -26,6 +26,13 @@ def check_frozen(path: Path) -> None:
for repository in config["repos"]:
if not isinstance(repository, dict) or not isinstance(repository.get("repo"), str):
raise TypeError("hook repository must be a mapping with a URL")
+ hooks = repository.get("hooks", [])
+ if not isinstance(hooks, list) or not all(isinstance(hook, dict) for hook in hooks):
+ raise TypeError(f"hooks must be a list of mappings: {repository['repo']}")
+ for hook in hooks:
+ # release-creator pins and cools only Python hook dependencies; other ecosystems would install unpinned.
+ if hook.get("additional_dependencies") and hook.get("language") not in {None, "python"}:
+ raise ValueError(f"only Python hooks may declare additional_dependencies: {hook.get('id')}")
if repository["repo"] in {"local", "meta"}:
continue
revision = repository.get("rev")
diff --git a/tools/check_hook_config.py b/tools/check_hook_config.py
index 608cdc2..65d2cc6 100644
--- a/tools/check_hook_config.py
+++ b/tools/check_hook_config.py
@@ -11,8 +11,7 @@
from copy import deepcopy
from datetime import UTC, datetime, timedelta
from pathlib import Path
-from re import Pattern
-from typing import Final, cast
+from typing import cast
from urllib.parse import quote, urlsplit
from urllib.request import Request, urlopen
@@ -23,15 +22,6 @@
from yaml import MappingNode, ScalarNode, SequenceNode, YAMLError, compose, safe_load
from yaml.error import Mark
-_MANAGED_MARKER: Final[Pattern[str]] = re.compile(
- r"""
- ^ \s* ,? \s*
- (?P [#] \s+ release-creator \s+ managed )
- (?: \s | $ )
- """,
- re.VERBOSE,
-)
-
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
@@ -61,10 +51,9 @@ def check_config(base: Path, proposed: Path, managed: bool, minimum_days: int, *
if human:
if not isinstance(old.get("repos"), list) or not isinstance(new.get("repos"), list):
raise TypeError("hook repositories must be lists")
- old_managed = _managed_pin_locations(base_source, old)
- new_managed = _managed_pin_locations(proposed_source, new)
- if old_managed and old_managed != new_managed:
- raise ValueError("managed hook dependencies require the release App")
+ added = _managed_pin_locations(proposed_source, new) - _managed_pin_locations(base_source, old)
+ for _, _, item in added:
+ _check_pypi(Requirement(item), minimum_days)
return
old_content = deepcopy(old)
new_content = deepcopy(new)
@@ -135,7 +124,18 @@ def _dependency_groups(source: str) -> Iterator[list[tuple[str, bool]]]:
def _managed_marker(source: str, item: ScalarNode) -> bool:
suffix = source[cast("Mark", item.end_mark).index :].split("\n", 1)[0]
- return _MANAGED_MARKER.match(suffix) is not None
+ return (
+ re.match(
+ r"""
+ ^ \s* ,? \s*
+ [#] \s+ release-creator \s+ managed
+ (?: \s | $ )
+ """,
+ suffix,
+ re.VERBOSE,
+ )
+ is not None
+ )
def _reject_duplicate_keys(source: str) -> None:
diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py
index 9568547..a2c7bd4 100644
--- a/tools/check_lock_groups.py
+++ b/tools/check_lock_groups.py
@@ -19,7 +19,7 @@
from urllib.request import Request, urlopen
import certifi
-from lock_groups import lock_provider, project_selections
+from lock_groups import lock_provider, validate_workspace_sources
from nab_cutoffs import reviewed_cutoff, validate_indexes
from packaging._parser import Variable
from packaging.markers import Marker
@@ -44,6 +44,10 @@ def main() -> None:
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 args.project:
if (args.project.parent / "nab.toml").exists():
raise ValueError("project nab.toml can override the reviewed index policy")
@@ -52,7 +56,7 @@ def main() -> None:
if "nab" in project.get("tool", {}):
validate_indexes(project["tool"]["nab"])
if lock_provider(lock.tool) == "release-creator":
- project_selections(args.project)
+ validate_workspace_sources(args.project)
if (missing := groups - set(lock.dependency_groups or ())) and not (
args.build_constraints and missing == {"build"}
):
@@ -75,9 +79,10 @@ def main() -> None:
def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = False) -> None:
- created = (
- (lock.tool or {}).get("nab" if lock_provider(lock.tool) == "nab" else "release-creator", {}).get("created-at")
- )
+ producer = (lock.tool or {}).get(lock_provider(lock.tool))
+ if not isinstance(producer, dict):
+ raise TypeError("lock producer settings must be a table")
+ created = producer.get("created-at")
if not isinstance(created, datetime) or created.tzinfo is None or created > datetime.now(tz=UTC):
raise ValueError("lock has no valid creation time or was created in the future")
threshold = created - timedelta(days=7)
@@ -164,10 +169,6 @@ def _selected_packages(lock: Pylock, groups: set[str], *, local_tool_source: boo
and package.directory is not None
and package.directory.path == "."
and package.index is None
- and package.archive is None
- and package.vcs is None
- and package.sdist is None
- and (package.wheels is None or len(package.wheels) == 0)
):
continue
yield package
diff --git a/tools/cooled_prek.sh b/tools/cooled_prek.sh
new file mode 100755
index 0000000..d7b9d9d
--- /dev/null
+++ b/tools/cooled_prek.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Hook environments build from PyPI under the seven-day cutoff, ignoring the repository's uv settings.
+set -euo pipefail
+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
+exec prek --config .pre-commit-config.yaml "$@"
diff --git a/tools/lock_groups.py b/tools/lock_groups.py
index bdec063..20c97fa 100644
--- a/tools/lock_groups.py
+++ b/tools/lock_groups.py
@@ -1,4 +1,4 @@
-"""Read the lock producer and its selected release groups."""
+"""Require a project lock produced by release-creator and check its workspace sources."""
from __future__ import annotations
@@ -11,62 +11,25 @@
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("lock", type=Path)
- parser.add_argument("--provider", action="store_true")
args = parser.parse_args()
try:
with args.lock.open("rb") as stream:
- lock = tomllib.load(stream)
- provider = lock_provider(lock.get("tool"))
- if args.provider:
- print(provider)
- return
- if provider == "release-creator":
- declared, declared_extras = project_selections(args.lock.with_name("pyproject.toml"))
- if lock.get("default-groups") != []:
- raise ValueError("project lock must not install default groups")
- selected = lock["dependency-groups"]
- if not isinstance(selected, list) or not all(isinstance(group, str) for group in selected):
- raise ValueError("project lock has invalid dependency groups")
- if not all(group.isascii() and group.replace("-", "").isalnum() for group in selected):
- raise ValueError("project lock has invalid dependency groups")
- required = declared | {"project-runtime"}
- if missing := required - set(selected):
- raise ValueError(f"project lock omits dependency groups: {', '.join(sorted(missing))}")
- if len(selected) != len(set(selected)) or set(selected) - required - {"build"}:
- raise ValueError("project lock has invalid or undeclared dependency groups")
- extras = lock.get("extras", [])
- if not isinstance(extras, list) or not all(isinstance(extra, str) for extra in extras):
- raise ValueError("project lock has invalid extras")
- if missing_extras := declared_extras - set(extras):
- raise ValueError(f"project lock omits extras: {', '.join(sorted(missing_extras))}")
- if len(extras) != len(set(extras)) or set(extras) != declared_extras:
- raise ValueError("project lock has invalid or undeclared extras")
- print(" ".join(group for group in selected if group.startswith("release-")))
- return
- command = lock["tool"]["nab"]["command-line"]
- if not isinstance(command, list) or not all(isinstance(value, str) for value in command):
- raise ValueError("Nab command-line must be a list of strings")
- start = command.index("--groups") + 1
- groups = command[
- start : next(
- (index for index in range(start, len(command)) if command[index].startswith("--")), len(command)
- )
- ]
- if not groups:
- parser.error("Nab lock records no selected groups")
- with args.lock.with_name("pyproject.toml").open("rb") as stream:
- declared = tomllib.load(stream).get("dependency-groups", {})
- if not all(group.isascii() and group.replace("-", "").isalnum() and group in declared for group in groups):
- raise ValueError("Nab lock selects an invalid or undeclared group")
- required = {name for name in declared if name.startswith("release-")}
- if missing := required - set(groups):
- raise ValueError(f"Nab lock omits release groups: {', '.join(sorted(missing))}")
- print(" ".join(groups))
- except (KeyError, OSError, TypeError, ValueError) as error:
- parser.error(f"cannot read lock groups: {error}")
-
-
-def project_selections(project: Path) -> tuple[set[str], set[str]]:
+ provider = lock_provider(tomllib.load(stream).get("tool"))
+ except (OSError, TypeError, ValueError) as error:
+ parser.error(f"cannot read the lock producer: {error}")
+ if provider != "release-creator":
+ parser.error(f"{args.lock} must come from release-creator locks; relock it")
+
+
+def lock_provider(tool: Mapping[str, object] | None) -> str:
+ if "release-creator" in (tool or {}):
+ return "release-creator"
+ if "nab" in (tool or {}):
+ return "nab"
+ raise ValueError("lock has no recognized producer")
+
+
+def validate_workspace_sources(project: Path) -> None:
with project.open("rb") as stream:
root = tomllib.load(stream)
workspace = root.get("tool", {}).get("uv", {}).get("workspace", {})
@@ -97,24 +60,12 @@ def matched(patterns: list[str]) -> set[Path]:
for name, source in item.get("tool", {}).get("uv", {}).get("sources", {}).items():
if source != {"workspace": True} or _normalized_name(name) not in names:
raise ValueError(f"uv source must name a workspace project: {name}")
- return (
- {group for item in projects for group in item.get("dependency-groups", {})},
- {extra for item in projects for extra in item.get("project", {}).get("optional-dependencies", {})},
- )
def _normalized_name(name: str) -> str:
return "-".join(filter(None, name.casefold().replace("_", "-").replace(".", "-").split("-")))
-def lock_provider(tool: Mapping[str, object] | None) -> str:
- if "release-creator" in (tool or {}):
- return "release-creator"
- if "nab" in (tool or {}):
- return "nab"
- raise ValueError("lock has no recognized producer")
-
-
if __name__ == "__main__":
main()
@@ -122,5 +73,5 @@ def lock_provider(tool: Mapping[str, object] | None) -> str:
__all__ = [
"lock_provider",
"main",
- "project_selections",
+ "validate_workspace_sources",
]
diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py
index 60cfdbc..3da0280 100644
--- a/tools/nab_cutoffs.py
+++ b/tools/nab_cutoffs.py
@@ -13,7 +13,6 @@
""",
re.VERBOSE,
)
-_MINIMUM_DAYS: Final = 7
_REVIEWED_VERSIONS: Final = {
**{
@@ -43,7 +42,7 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None:
global_cutoff = nab.get("uploaded-prior-to")
if not isinstance(global_cutoff, str) or (match := _DURATION.fullmatch(global_cutoff)) is None:
raise ValueError("global Nab cutoff must be a duration of at least P7D")
- if int(match["days"]) < _MINIMUM_DAYS:
+ if int(match["days"]) < 7:
raise ValueError("global Nab cutoff must be a duration of at least P7D")
candidates = [
(name, settings["uploaded-prior-to"])
@@ -56,15 +55,12 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None:
if "uploaded-prior-to" in rule
for name in rule["match"]
)
- for name, settings in nab.get("index", {}).items():
- if "uploaded-prior-to" in settings:
- raise ValueError(f"Nab index cutoff for {name} bypasses the seven-day policy")
validate_indexes(nab)
for name, value in candidates:
if value is False:
raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}")
if isinstance(value, str) and (match := _DURATION.fullmatch(value)) is not None:
- if int(match["days"]) < _MINIMUM_DAYS:
+ if int(match["days"]) < 7:
raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}")
continue
timestamp = _timestamp(name, value)
diff --git a/tools/refresh_mise_pin.py b/tools/refresh_mise_pin.py
index f02527e..156afb1 100644
--- a/tools/refresh_mise_pin.py
+++ b/tools/refresh_mise_pin.py
@@ -18,7 +18,7 @@
class _Asset(TypedDict):
name: str
- digest: str
+ digest: str | None
created_at: str
updated_at: str
@@ -91,6 +91,7 @@ def _select_release(path: Path) -> tuple[str, str]:
matches = [asset for asset in release["assets"] if asset["name"] == name]
if (
len(matches) != 1
+ or not isinstance(matches[0]["digest"], str)
or not matches[0]["digest"].startswith("sha256:")
or not _hex(matches[0]["digest"][7:], 64)
):
@@ -117,7 +118,8 @@ def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: s
with binary.open("rb") as stream:
if file_digest(stream, "sha256").hexdigest() != expected_sha256:
raise ValueError("verified mise binary differs from the GitHub release asset digest")
- source = workflow.read_text()
+ with workflow.open(newline="") as stream:
+ source = stream.read()
root = yaml.compose(source)
if not isinstance(root, MappingNode):
raise TypeError("workflow needs a jobs mapping")
@@ -181,7 +183,7 @@ def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: s
return False
descriptor, name = tempfile.mkstemp(dir=workflow.parent, prefix=f".{workflow.name}.")
try:
- with os.fdopen(descriptor, "w") as stream:
+ with os.fdopen(descriptor, "w", newline="") as stream:
stream.write(updated)
os.chmod(name, stat.S_IMODE(workflow.stat().st_mode))
os.replace(name, workflow)
diff --git a/tools/refresh_uv_resolver.py b/tools/refresh_uv_resolver.py
index 26555db..05b00df 100644
--- a/tools/refresh_uv_resolver.py
+++ b/tools/refresh_uv_resolver.py
@@ -7,6 +7,7 @@
from pathlib import Path
import tomlkit
+from packaging.markers import InvalidMarker, Marker
from tomlkit.exceptions import ParseError
@@ -24,19 +25,25 @@ def main() -> None:
if args.mode == "widen":
if not str(requirements[0]).startswith("uv=="):
raise ValueError("release-resolver must have an exact uv pin")
- requirements[0] = "uv>=0.13.5"
+ requirements[0] = f"uv>={str(requirements[0]).removeprefix('uv==')}"
else:
- if requirements[0] != "uv>=0.13.5":
+ if not str(requirements[0]).startswith("uv>="):
raise ValueError("release-resolver must have the temporary uv range")
lock = tomllib.loads(args.lock.read_text())
if "release-resolver" not in lock["dependency-groups"]:
raise ValueError("candidate lock omits release-resolver")
- versions = {package["version"] for package in lock["packages"] if package["name"] == "uv"}
+ selected = {"dependency_groups": frozenset({"release-resolver"}), "extras": frozenset()}
+ versions = {
+ package["version"]
+ for package in lock["packages"]
+ if package["name"] == "uv"
+ and ("marker" not in package or Marker(package["marker"]).evaluate(selected, "lock_file"))
+ }
if len(versions) != 1:
raise ValueError("candidate lock must select one uv resolver version")
requirements[0] = f"uv=={versions.pop()}"
args.project.write_text(tomlkit.dumps(project))
- except (OSError, KeyError, TypeError, ValueError, ParseError, tomllib.TOMLDecodeError) as error:
+ except (InvalidMarker, OSError, KeyError, TypeError, ValueError, ParseError, tomllib.TOMLDecodeError) as error:
parser.error(str(error))
diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py
index ee0d554..5242515 100644
--- a/tools/verify_cutoff_patch.py
+++ b/tools/verify_cutoff_patch.py
@@ -3,6 +3,8 @@
from __future__ import annotations
import argparse
+import os
+import re
import subprocess
import tomllib
from datetime import UTC, datetime, timedelta
@@ -43,6 +45,24 @@ def main() -> None:
removed_names = {name for name in names if name not in after.get("exclude-newer-package", {})}
if removed_names:
remove_release_creator_cutoffs(before, removed_names)
+ resolvers = [project.get("dependency-groups", {}).get("release-resolver") for project in (original, actual)]
+ if (
+ os.environ.get("GITHUB_REPOSITORY") == "tox-dev/github-workflows"
+ and resolvers[0] != resolvers[1]
+ and all(
+ isinstance(pins, list) and len(pins) == 1 and re.fullmatch(r"uv==[0-9.]+", str(pins[0]))
+ for pins in resolvers
+ )
+ ):
+ version = resolvers[1][0].removeprefix("uv==")
+ if not any(
+ package.get("name") == "uv"
+ and package.get("version") == version
+ and '"release-resolver" in dependency_groups' in package.get("marker", "")
+ for package in tomllib.loads(args.project.with_name("pylock.toml").read_text())["packages"]
+ ):
+ raise ValueError(f"pylock.toml does not lock the updated resolver uv {version}")
+ original["dependency-groups"]["release-resolver"] = resolvers[1]
if original != actual:
producer = "package" if "release-creator" in original["tool"] else "Nab"
parser.error(f"pyproject.toml has changes beyond expired {producer} cutoffs")
diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py
index ea7c970..56ee3fb 100644
--- a/tools/verify_pypi_files.py
+++ b/tools/verify_pypi_files.py
@@ -37,23 +37,15 @@ def main() -> None:
parser.add_argument("dist", type=Path)
args = parser.parse_args()
try:
- for path in verify_files(args.project, args.version, args.dist, 120):
+ for path in verify_files(args.project, args.version, args.dist):
print(path)
except (OSError, TypeError, ValueError, IncompleteRead) as error:
parser.error(str(error))
-def verify_files(project: str, version: str, dist: Path, wait_seconds: int) -> list[Path]:
- """Check the local files against PyPI.
-
- :param project: Published project name.
- :param version: Published release version.
- :param dist: Directory containing every expected distribution.
- :param wait_seconds: Maximum time to wait for PyPI's listing.
- :returns: Local distribution paths for attestation verification.
- """
- if wait_seconds < 0 or not dist.is_dir():
- raise ValueError("release files require a directory and nonnegative wait")
+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)
release = Version(version)
local: dict[str, str] = {}
@@ -66,7 +58,7 @@ def verify_files(project: str, version: str, dist: Path, wait_seconds: int) -> l
local[path.name] = hashlib.file_digest(stream, "sha256").hexdigest()
if not local:
raise ValueError(f"distribution directory is empty: {dist}")
- deadline = time.monotonic() + wait_seconds
+ deadline = time.monotonic() + 120
delay = 1
while True:
remote: dict[str, str] = {}
diff --git a/validate-news/action.yml b/validate-news/action.yml
index 4624ea0..185e240 100644
--- a/validate-news/action.yml
+++ b/validate-news/action.yml
@@ -49,7 +49,7 @@ runs:
ADDITIONAL_LOCK: ${{ inputs.additional-lock }}
KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }}
run: |
- paths=(pyproject.toml ':(glob)**/pyproject.toml' pylock.toml nab.toml)
+ paths=(':(glob)**/pyproject.toml' pylock.toml nab.toml ':(glob).github/workflows/**')
if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi
if [[ "$KEEP_UV_LOCK" == true ]]; then paths+=(uv.lock); fi
changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${paths[@]}")
@@ -65,14 +65,11 @@ runs:
echo 'changed=false' >>"$GITHUB_OUTPUT"
exit 0
fi
- groups=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml)
echo 'changed=true' >>"$GITHUB_OUTPUT"
- echo "groups=$groups" >>"$GITHUB_OUTPUT"
- name: Check the release lock against the project
if: steps.lock-change.outputs.changed == 'true'
uses: $/refresh-release-lock
with:
- lock-groups: ${{ steps.lock-change.outputs.groups }}
additional-lock: ${{ inputs.additional-lock }}
additional-lock-groups: ${{ inputs.additional-lock-groups }}
additional-lock-selection: ${{ inputs.additional-lock-selection }}
diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml
index 15e328e..7978932 100644
--- a/validate-release-patch/action.yml
+++ b/validate-release-patch/action.yml
@@ -1,12 +1,9 @@
name: Validate release patch
-description: Check and apply a prepared release patch, with optional release-tool validation
+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
- patch:
- description: Path to the downloaded patch
- required: true
checks:
description: Run release-creator and prek after applying the patch
default: "true"
@@ -25,7 +22,6 @@ runs:
- name: Apply release patch
shell: bash
env:
- PATCH: ${{ inputs.patch }}
CHECKS: ${{ inputs.checks }}
run: |
set -euo pipefail
@@ -33,7 +29,7 @@ runs:
echo '::error::checks must be true or false' >&2
exit 1
fi
- git apply --index "$PATCH"
+ git apply --index "$RUNNER_TEMP/release-patch/release.patch"
- id: config
uses: $/read-towncrier-config
- name: Check release patch paths
@@ -127,16 +123,8 @@ runs:
- name: Run prek on release patch
if: inputs.checks == 'true'
shell: bash
- env:
- UV_EXCLUDE_NEWER: 7 days
- UV_NO_CONFIG: "1"
- UV_NO_SOURCES: "1"
run: |
- 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
- set -euo pipefail
- if ! prek run --all-files; then
+ 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
diff --git a/verify-nab-cutoff-patch/action.yml b/verify-cutoff-patch/action.yml
similarity index 55%
rename from verify-nab-cutoff-patch/action.yml
rename to verify-cutoff-patch/action.yml
index e4d4fbf..9074fc0 100644
--- a/verify-nab-cutoff-patch/action.yml
+++ b/verify-cutoff-patch/action.yml
@@ -1,5 +1,5 @@
-name: Verify Nab cutoff patch
-description: Reject pyproject changes other than expired Nab package cutoffs
+name: Verify cutoff patch
+description: Reject pyproject changes other than expired package cutoffs and the shared resolver pin
runs:
using: composite
steps:
diff --git a/verify-frozen-hooks/action.yml b/verify-frozen-hooks/action.yml
index efb1b36..2224ecd 100644
--- a/verify-frozen-hooks/action.yml
+++ b/verify-frozen-hooks/action.yml
@@ -7,6 +7,16 @@ inputs:
runs:
using: composite
steps:
+ - name: Reject other hook configurations
+ 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
- uses: $/install-locked-tools
with:
groups: release-notes
@@ -17,9 +27,6 @@ runs:
run: |
uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" python \
"$GITHUB_ACTION_PATH/../tools/check_frozen_hooks.py" .pre-commit-config.yaml
- - name: Check Python hook dependencies
- shell: bash
- run: uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" release-creator hooks --check
- name: Check tagged hook revision provenance
if: inputs.tag != '' || startsWith(github.ref, 'refs/tags/')
shell: bash
@@ -32,3 +39,6 @@ runs:
uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" python \
"$GITHUB_ACTION_PATH/../tools/check_tagged_hook_age.py" \
"$GITHUB_REPOSITORY" "$TAG"
+ - name: Check Python hook dependencies
+ shell: bash
+ run: uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" release-creator hooks --check