diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index f9f1e3c..9d79c41 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -10,3 +10,13 @@ updates:
labels:
- dependencies
- skip news
+ - package-ecosystem: github-actions
+ directory: /check-hook-update-cooldown
+ cooldown:
+ default-days: 7
+ schedule:
+ interval: weekly
+ day: tuesday
+ labels:
+ - dependencies
+ - skip news
diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
index 67cf977..b95d043 100644
--- a/.github/workflows/check.yaml
+++ b/.github/workflows/check.yaml
@@ -3,71 +3,135 @@ on:
push:
branches: [main]
pull_request:
+ types: [opened, reopened, synchronize, labeled, unlabeled]
workflow_dispatch:
permissions:
contents: read
+concurrency:
+ group: check-${{ github.ref }}-${{ github.run_attempt > 1 && github.run_id || 'current' }}
+ cancel-in-progress: ${{ github.run_attempt == 1 }}
jobs:
check:
runs-on: ubuntu-26.04
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- fetch-depth: 0
+ fetch-depth: 1
persist-credentials: false
+ - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/prek
+ key: prek-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.pre-commit-config.yaml') }}
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
+ version: latest-known
python-version: "3.15"
- enable-cache: false
- - name: Install locked tools
- run: |
- uv venv --python 3.15 .venv
- uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml
- echo "$PWD/.venv/bin" >>"$GITHUB_PATH"
+ enable-cache: true
+ cache-dependency-glob: |
+ pylock.toml
+ pyproject.toml
+ - uses: $/install-locked-tools
+ with:
+ groups: release-check
+ venv: .release-tools
+ - name: Add locked tools to PATH
+ run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH"
- name: Check repository formatting
run: prek run --all-files
- - name: Check Python tests
- run: |
- ruff format --check .
- ruff check . --output-format=concise
- ty check tests
- - name: Measure Action shell coverage
- run: |
- sudo apt-get update
- sudo apt-get install -y kcov
- KCOV_BIN=kcov KCOV_OUTPUT="$RUNNER_TEMP/shell-coverage" pytest -q
- kcov --merge "$RUNNER_TEMP/shell-coverage-merged" "$RUNNER_TEMP/shell-coverage"/*
- python - <<'PY'
- import json
- import os
- from pathlib import Path
-
- import yaml
-
- report = next(Path(os.environ["RUNNER_TEMP"], "shell-coverage-merged").rglob("codecov.json"))
- coverage = json.loads(report.read_text())["coverage"]
- expected = {
- f"{path.parent.name}-{index}.sh"
- for path in Path().glob("*/action.yml")
- for index, _ in enumerate(yaml.safe_load(path.read_text())["runs"]["steps"])
- }
- if set(coverage) != expected or any(not lines or not all(lines.values()) for lines in coverage.values()):
- raise SystemExit(f"Action shell coverage is below 100%: {coverage}")
- PY
+ shell:
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+ - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/prek
+ key: prek-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.pre-commit-config.yaml') }}
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: true
+ cache-dependency-glob: |
+ pylock.toml
+ pyproject.toml
+ - uses: $/install-locked-tools
+ with:
+ groups: release-shell
+ venv: .release-tools
+ - name: Add locked tools to PATH
+ run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH"
- name: Check inline shell
run: |
set -euo pipefail
+ script_dir=./inline-shell-check
+ mkdir "$script_dir"
+ trap 'rm -rf "$script_dir"' EXIT
for file in .github/workflows/*.yaml */action.yml; do
yq -rj '[.. | objects | select(has("run")) | .run] | .[] | . + "\u0000"' "$file" \
>"$RUNNER_TEMP/run-blocks"
index=0
while IFS= read -r -d '' script; do
- echo "$file: run block $index"
- printf '#!/usr/bin/env bash\n%s\n' "${script%$'\n'}" >"$RUNNER_TEMP/inline-shell.sh"
- prek run shellcheck --files "$RUNNER_TEMP/inline-shell.sh"
- prek run shfmt --files "$RUNNER_TEMP/inline-shell.sh"
+ script_file="$script_dir/run-${file//\//-}-$index.sh"
+ printf '#!/usr/bin/env bash\n%s\n' "${script%$'\n'}" >"$script_file"
index=$((index + 1))
done <"$RUNNER_TEMP/run-blocks"
done
+ prek run shellcheck --files "$script_dir"/*.sh
+ prek run shfmt --files "$script_dir"/*.sh
+ test:
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: true
+ cache-dependency-glob: |
+ pylock.toml
+ pyproject.toml
+ - uses: $/install-locked-tools
+ with:
+ groups: release-news release-shell
+ venv: .release-tools
+ - name: Add locked tools to PATH
+ run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH"
+ - name: Install current test tools
+ run: |
+ uv venv --python 3.15 .venv
+ uv pip install --python .venv/bin/python --group test
+ - name: Run Action tests
+ run: .venv/bin/pytest -q -n auto
+ type:
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: true
+ cache-dependency-glob: |
+ pylock.toml
+ pyproject.toml
+ - name: Check Python types
+ run: uvx --with tox-uv tox run -e type
changelog:
if: github.event_name == 'pull_request'
runs-on: ubuntu-26.04
@@ -78,161 +142,65 @@ jobs:
persist-credentials: false
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
+ version: latest-known
python-version: "3.15"
- enable-cache: false
- - name: Require release notes
- env:
- SKIP_NEWS: ${{ contains(github.event.pull_request.labels.*.name, 'skip news') }}
- BOT_AUTHOR: ${{ github.event.pull_request.user.type == 'Bot' }}
- run: |
- set -euo pipefail
- if git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD" -- 'changelog.d/*.md' | grep -q .; then
- uv run --no-default-groups --group release towncrier check --compare-with "origin/${GITHUB_BASE_REF}"
- elif [[ "$SKIP_NEWS" != true && "$BOT_AUTHOR" != true ]]; then
- echo '::error::add a changelog fragment or apply the skip news label'
- exit 1
- fi
- release-dry-run:
+ enable-cache: true
+ cache-dependency-glob: |
+ pylock.toml
+ pyproject.toml
+ - uses: $/install-locked-tools
+ with:
+ groups: release-news
+ venv: ${{ runner.temp }}/release-tools
+ - name: Add locked tools to PATH
+ run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH"
+ - uses: $/validate-news
+ hook-cooldown:
if: github.event_name == 'pull_request'
runs-on: ubuntu-26.04
permissions:
+ actions: read
contents: read
pull-requests: read
+ steps:
+ - uses: $/check-hook-update-cooldown
+ release-tools-preview:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- fetch-depth: 0
persist-credentials: false
- ref: ${{ github.event.pull_request.head.sha }}
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
+ version: latest-known
python-version: "3.15"
- enable-cache: false
- - name: Install locked release tools
- run: |
- uv venv --python 3.15 .venv
- uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml
- echo "$PWD/.venv/bin" >>"$GITHUB_PATH"
- - name: Rehearse a squash merge
- id: fixture
- env:
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- run: |
- set -euo pipefail
- git checkout -B release-validation "$BASE_SHA"
- git merge --squash "$HEAD_SHA"
- printf 'Validate the release pipeline on this pull request.\n' \
- >"changelog.d/+validation-${PR_NUMBER}.bugfix.md"
- git add -A
- git -c user.name=release-validation \
- -c user.email=release-validation@users.noreply.github.com \
- commit -m "Validate release pipeline (#${PR_NUMBER})"
- git init --bare "$RUNNER_TEMP/release-origin.git"
- git remote set-url origin "$RUNNER_TEMP/release-origin.git"
- git push origin HEAD:main
- echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
- - name: Dirty the release checkout
- run: touch untracked-release-file
- - id: dirty_prepare
- name: Reject a dirty release checkout
- uses: $/prepare-towncrier
- continue-on-error: true
- with:
- changelog: CHANGELOG.md
- format: markdown
- patch: ${{ runner.temp }}/release.patch
- - name: Check dirty checkout rejection
- env:
- OUTCOME: ${{ steps.dirty_prepare.outcome }}
- run: |
- test "$OUTCOME" = failure
- rm untracked-release-file
- - id: prepare
- uses: $/prepare-towncrier
- with:
- changelog: CHANGELOG.md
- format: markdown
- patch: ${{ runner.temp }}/release.patch
- - name: Restore the source commit
- run: git reset --hard HEAD
- - id: stale_validate
- name: Reject a stale release branch
- uses: $/validate-release-patch
- continue-on-error: true
- with:
- version: ${{ steps.prepare.outputs.version }}
- patch: ${{ runner.temp }}/release.patch
- changelog: CHANGELOG.md
- fragments: changelog.d
- expected-sha: "0000000000000000000000000000000000000000"
- - name: Check stale branch rejection
- env:
- OUTCOME: ${{ steps.stale_validate.outcome }}
- run: test "$OUTCOME" = failure
- - uses: $/validate-release-patch
- with:
- version: ${{ steps.prepare.outputs.version }}
- patch: ${{ runner.temp }}/release.patch
- changelog: CHANGELOG.md
- fragments: changelog.d
- expected-sha: ${{ steps.fixture.outputs.sha }}
- - id: invalid_tag
- name: Reject an invalid release tag
- uses: $/commit-and-tag
- continue-on-error: true
- with:
- version: bad tag
- token: ${{ github.token }}
- app-slug: github-actions
- message: invalid release
- - name: Check invalid tag rejection
- env:
- OUTCOME: ${{ steps.invalid_tag.outcome }}
- run: test "$OUTCOME" = failure
- - uses: $/commit-and-tag
- with:
- version: ${{ steps.prepare.outputs.version }}
- token: ${{ github.token }}
- app-slug: github-actions
- message: release ${{ steps.prepare.outputs.version }}
- - name: Render tagged notes
- env:
- VERSION: ${{ steps.prepare.outputs.version }}
- run: |
- set -euo pipefail
- test -n "$(git ls-remote origin "refs/tags/$VERSION")"
- previous=$(git tag --list | release-creator tags "$VERSION" --previous)
- args=()
- if [[ -n "$previous" ]]; then
- args+=(--previous "$previous")
- fi
- release-creator notes --changelog CHANGELOG.md --tag "$VERSION" \
- --repository "$GITHUB_REPOSITORY" "${args[@]}" \
- >"$RUNNER_TEMP/release-notes.md"
- test -s "$RUNNER_TEMP/release-notes.md"
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: release-dry-run
- path: |
- ${{ runner.temp }}/release.patch
- ${{ runner.temp }}/release-notes.md
- if-no-files-found: error
+ enable-cache: true
+ cache-dependency-glob: |
+ pylock.toml
+ pyproject.toml
+ - uses: $/refresh-release-lock
+ with:
+ lock-groups: >-
+ build lock-tools release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check
+ mode: check
gate:
- name: Required checks pass
- if: always()
- needs: [check, changelog, release-dry-run]
+ name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }}
+ if: ${{ !cancelled() }}
+ needs: [check, shell, test, type, changelog, hook-cooldown, release-tools-preview]
runs-on: ubuntu-26.04
steps:
- env:
NEEDS_JSON: ${{ toJSON(needs) }}
+ EVENT_NAME: ${{ github.event_name }}
run: |
jq -e '
all(
to_entries[];
.value.result == "success" or
- (.key == "changelog" and .value.result == "skipped") or
- (.key == "release-dry-run" and .value.result == "skipped")
+ ($ENV.EVENT_NAME != "pull_request" and .value.result == "skipped" and
+ (.key == "changelog" or .key == "hook-cooldown" or .key == "release-tools-preview"))
)
' <<<"$NEEDS_JSON"
diff --git a/.github/workflows/label-bot-news.yaml b/.github/workflows/label-bot-news.yaml
new file mode 100644
index 0000000..e7f9521
--- /dev/null
+++ b/.github/workflows/label-bot-news.yaml
@@ -0,0 +1,28 @@
+name: Label bot news
+on:
+ workflow_run: # zizmor: ignore[dangerous-triggers] Verifies source CI and PR metadata without checkout or artifacts.
+ workflows: [Check]
+ types: [completed]
+permissions: {}
+concurrency:
+ group: label-bot-news-${{ github.event.workflow_run.head_sha }}
+ cancel-in-progress: true
+jobs:
+ label:
+ if: github.event.workflow_run.event == 'pull_request'
+ runs-on: ubuntu-26.04
+ permissions:
+ actions: read
+ issues: write
+ pull-requests: read
+ steps:
+ - id: pr
+ uses: $/resolve-pr-from-run
+ with:
+ run-id: ${{ github.event.workflow_run.id }}
+ workflow-path: .github/workflows/check.yaml
+ - if: steps.pr.outputs.bot-author == 'true'
+ uses: $/label-bot-news
+ with:
+ pull-request: ${{ steps.pr.outputs.number }}
+ bot-author: "true"
diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml
index 2a991bf..88ba696 100644
--- a/.github/workflows/prepare-release.yaml
+++ b/.github/workflows/prepare-release.yaml
@@ -9,85 +9,13 @@ on:
options: [auto, major, minor, patch]
default: auto
permissions: {}
-concurrency:
- group: prepare-release
- cancel-in-progress: false
jobs:
- assemble:
- if: github.ref == 'refs/heads/main'
- runs-on: ubuntu-26.04
+ prepare:
+ uses: $/.github/workflows/prepare-towncrier-release.yaml
permissions:
contents: read
pull-requests: read
- outputs:
- version: ${{ steps.prepare.outputs.version }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- fetch-depth: 0
- persist-credentials: false
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- with:
- python-version: "3.15"
- enable-cache: false
- - name: Install locked release tools
- run: |
- uv venv --python 3.15 .venv
- uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml
- echo "$PWD/.venv/bin" >>"$GITHUB_PATH"
- - id: prepare
- uses: $/prepare-towncrier
- with:
- bump: ${{ inputs.bump }}
- changelog: CHANGELOG.md
- format: markdown
- patch: ${{ runner.temp }}/release.patch
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: release-patch
- path: ${{ runner.temp }}/release.patch
- if-no-files-found: error
- push:
- needs: assemble
- runs-on: ubuntu-26.04
- environment: release-auth
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- fetch-depth: 0
- persist-credentials: false
- - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
- with:
- name: release-patch
- path: ${{ runner.temp }}/release-patch
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- with:
- python-version: "3.15"
- enable-cache: false
- - name: Install locked release tools
- run: |
- uv venv --python 3.15 .venv
- uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml
- echo "$PWD/.venv/bin" >>"$GITHUB_PATH"
- - uses: $/validate-release-patch
- with:
- version: ${{ needs.assemble.outputs.version }}
- patch: ${{ runner.temp }}/release-patch/release.patch
- changelog: CHANGELOG.md
- fragments: changelog.d
- - id: token
- uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
- with:
- client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
- private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- owner: ${{ github.repository_owner }}
- repositories: ${{ github.event.repository.name }}
- permission-contents: write
- - uses: $/commit-and-tag
- with:
- version: ${{ needs.assemble.outputs.version }}
- token: ${{ steps.token.outputs.token }}
- app-slug: ${{ steps.token.outputs.app-slug }}
- message: release ${{ needs.assemble.outputs.version }}
+ with:
+ bump: ${{ inputs.bump }}
+ secrets:
+ RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml
new file mode 100644
index 0000000..320b9a9
--- /dev/null
+++ b/.github/workflows/prepare-towncrier-release.yaml
@@ -0,0 +1,161 @@
+name: Prepare Towncrier release
+on:
+ workflow_call:
+ inputs:
+ bump:
+ description: Version selection mode
+ type: string
+ default: auto
+ tag-prefix:
+ description: Optional v prefix for the tag
+ type: string
+ default: ""
+ changelog-prefix:
+ description: Optional v prefix for the Towncrier changelog heading
+ type: string
+ default: ""
+ local-tool-source:
+ description: Install release-creator from the tagged checkout for its own release
+ type: boolean
+ default: false
+ annotated:
+ description: Create an annotated tag
+ type: boolean
+ default: false
+ release-auth-deployment:
+ description: Create a GitHub Deployment for the App-key job
+ type: boolean
+ default: true
+ secrets:
+ RELEASE_APP_PRIVATE_KEY:
+ description: Key held by the caller's release-auth environment
+ required: true
+permissions: {}
+concurrency:
+ group: prepare-release-${{ github.repository }}
+ cancel-in-progress: false
+jobs:
+ assemble:
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ version: ${{ steps.prepare.outputs.version }}
+ artifact-id: ${{ steps.patch.outputs.artifact-id }}
+ patch-sha: ${{ steps.digest.outputs.sha }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: ${{ github.sha }}
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: false
+ - uses: $/install-locked-tools
+ with:
+ groups: release-prepare${{ inputs.local-tool-source && ' project-runtime' || '' }}
+ venv: .venv
+ local-tool-source: ${{ inputs.local-tool-source }}
+ - name: Add locked tools to PATH
+ run: echo "$PWD/.venv/bin" >>"$GITHUB_PATH"
+ - name: Install prek hooks
+ id: hooks
+ run: prek install-hooks
+ background: true
+ - name: Create a fresh validation worktree
+ run: git worktree add --detach "$RUNNER_TEMP/release-validation" "$GITHUB_SHA"
+ - name: Export local build constraints
+ if: inputs.local-tool-source
+ id: constraints
+ uses: $/export-build-constraints
+ - name: Build local release tool wheel
+ if: inputs.local-tool-source
+ env:
+ BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }}
+ 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
+ - id: prepare
+ name: Build changelog
+ uses: $/prepare-towncrier
+ with:
+ bump: ${{ inputs.bump }}
+ tag-prefix: ${{ inputs.tag-prefix }}
+ changelog-prefix: ${{ inputs.changelog-prefix }}
+ phase: build
+ - wait: hooks
+ - name: Finalize release patch
+ uses: $/prepare-towncrier
+ with:
+ version: ${{ steps.prepare.outputs.version }}
+ phase: finalize
+ - uses: $/validate-release-patch
+ with:
+ version: ${{ steps.prepare.outputs.version }}
+ patch: ${{ runner.temp }}/release.patch
+ working-directory: ${{ runner.temp }}/release-validation
+ - name: Record validated patch digest
+ id: digest
+ run: |
+ patch_sha=$(sha256sum "$RUNNER_TEMP/release.patch")
+ printf 'sha=%s\n' "${patch_sha%% *}" >>"$GITHUB_OUTPUT"
+ - id: patch
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: release-patch
+ path: ${{ runner.temp }}/release.patch
+ if-no-files-found: error
+ push:
+ needs: assemble
+ runs-on: ubuntu-26.04
+ environment:
+ name: release-auth
+ deployment: ${{ inputs.release-auth-deployment }}
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ artifact-ids: ${{ needs.assemble.outputs.artifact-id }}
+ path: ${{ runner.temp }}/release-patch
+ - uses: $/validate-release-patch
+ with:
+ version: ${{ needs.assemble.outputs.version }}
+ patch: ${{ runner.temp }}/release-patch/release.patch
+ expected-patch-sha: ${{ needs.assemble.outputs.patch-sha }}
+ checks: "false"
+ - name: Require release App key
+ env:
+ RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+ run: |
+ if [[ -z "$RELEASE_APP_PRIVATE_KEY" ]]; then
+ echo '::error::RELEASE_APP_PRIVATE_KEY is missing from release-auth' >&2
+ exit 1
+ fi
+ - id: token
+ uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
+ with:
+ client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+ repositories: ${{ github.event.repository.name }}
+ permission-contents: write
+ - uses: $/commit-and-tag
+ with:
+ version: ${{ needs.assemble.outputs.version }}
+ token: ${{ steps.token.outputs.token }}
+ app-slug: ${{ steps.token.outputs.app-slug }}
+ message: release ${{ needs.assemble.outputs.version }}
+ annotated: ${{ inputs.annotated }}
diff --git a/.github/workflows/recheck-hook-cooldown.yaml b/.github/workflows/recheck-hook-cooldown.yaml
new file mode 100644
index 0000000..4686f4a
--- /dev/null
+++ b/.github/workflows/recheck-hook-cooldown.yaml
@@ -0,0 +1,18 @@
+name: Recheck hook cooldown
+on:
+ schedule:
+ - cron: "17 7 * * *"
+ workflow_dispatch:
+permissions: {}
+concurrency:
+ group: recheck-hook-cooldown-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ recheck:
+ runs-on: ubuntu-26.04
+ permissions:
+ actions: write
+ contents: read
+ pull-requests: read
+ steps:
+ - uses: $/recheck-hook-update-cooldown
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 3c7a35d..6305178 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -4,43 +4,147 @@ on:
tags: ["*"]
permissions:
contents: read
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
jobs:
- release:
+ check:
runs-on: ubuntu-26.04
permissions:
- contents: write
+ contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- fetch-depth: 0
persist-credentials: false
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
+ version: latest-known
python-version: "3.15"
enable-cache: false
- - name: Install locked release tools
- run: |
- uv venv --python 3.15 .venv
- uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml
- echo "$PWD/.venv/bin" >>"$GITHUB_PATH"
+ - uses: $/install-locked-tools
+ with:
+ groups: release-check
+ venv: .venv
- name: Check tagged source
+ run: .venv/bin/prek run --all-files
+ - name: Check source remains clean
+ run: test -z "$(git status --porcelain)"
+ source:
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ outputs:
+ previous: ${{ steps.previous.outputs.previous }}
+ commit: ${{ steps.tag.outputs.commit }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: false
+ - uses: $/install-locked-tools
+ with:
+ groups: release-notes
+ venv: .venv
+ - name: Verify tagged source
+ id: tag
env:
TAG: ${{ github.ref_name }}
run: |
- git tag --list | release-creator tags "$TAG"
- prek run --all-files
- - name: Publish GitHub Release
+ set -euo pipefail
+ test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)"
+ git merge-base --is-ancestor HEAD origin/main
+ echo "commit=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
+ - id: previous
+ uses: $/select-previous-tag
+ with:
+ tag: ${{ github.ref_name }}
+ executable: .venv/bin/release-creator
+ contributors:
+ needs: source
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: write
+ outputs:
+ artifact-id: ${{ steps.artifact.outputs.artifact-id }}
+ steps:
+ - name: Fetch contributor metadata
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
+ PREVIOUS: ${{ needs.source.outputs.previous }}
run: |
set -euo pipefail
- previous=$(git tag --list | release-creator tags "$TAG" --previous)
- args=()
- if [[ -n "$previous" ]]; then
- args+=(--previous "$previous")
- fi
- release-creator notes --changelog CHANGELOG.md --tag "$TAG" \
- --repository "$GITHUB_REPOSITORY" "${args[@]}" \
- >"$RUNNER_TEMP/release-notes.md"
- gh release create "$TAG" --verify-tag --title "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md"
+ args=(-f "tag_name=$TAG")
+ if [[ -n "$PREVIOUS" ]]; then args+=(-f "previous_tag_name=$PREVIOUS"); fi
+ gh api -X POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" "${args[@]}" --jq .body \
+ >"$RUNNER_TEMP/generated-notes.md"
+ - id: artifact
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: generated-notes
+ path: ${{ runner.temp }}/generated-notes.md
+ if-no-files-found: error
+ notes:
+ needs: [source, contributors]
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ outputs:
+ artifact-id: ${{ steps.artifact.outputs.artifact-id }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: false
+ - uses: $/install-locked-tools
+ with:
+ groups: release-notes
+ venv: .venv
+ - name: Add locked tools to PATH
+ run: echo "$PWD/.venv/bin" >>"$GITHUB_PATH"
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ artifact-ids: ${{ needs.contributors.outputs.artifact-id }}
+ path: ${{ runner.temp }}
+ - uses: $/render-release-notes
+ with:
+ tag: ${{ github.ref_name }}
+ 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:
+ name: release-notes
+ path: ${{ runner.temp }}/release-notes.md
+ if-no-files-found: error
+ publish:
+ needs: [check, source, notes]
+ runs-on: ubuntu-26.04
+ environment: release
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ artifact-ids: ${{ needs.notes.outputs.artifact-id }}
+ path: ${{ runner.temp }}
+ - uses: $/publish-github-release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ with:
+ tag: ${{ github.ref_name }}
+ commit: ${{ needs.source.outputs.commit }}
+ title: ${{ github.ref_name }}
+ notes-file: ${{ runner.temp }}/release-notes.md
diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml
index 5dd7065..49a6580 100644
--- a/.github/workflows/update-tools.yaml
+++ b/.github/workflows/update-tools.yaml
@@ -3,38 +3,17 @@ on:
workflow_dispatch:
schedule:
- cron: "17 9 * * 2"
-permissions:
- contents: write
- pull-requests: write
- actions: write
+permissions: {}
jobs:
update:
- runs-on: ubuntu-26.04
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- with:
- python-version: "3.15"
- enable-cache: false
- - name: Refresh the tool lock
- run: |
- uv pip compile --upgrade --group release --group test \
- --python-version 3.15 --format pylock.toml \
- --output-file pylock.toml pyproject.toml
- - id: update
- uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
- with:
- token: ${{ github.token }}
- branch: automation/update-release-tools
- add-paths: pylock.toml
- commit-message: "🔧 build: refresh release tool lock"
- title: "🔧 build: refresh release tool lock"
- body: "Refresh the locked tools used by release preparation and CI."
- - name: Run checks on the update branch
- if: contains(fromJSON('["created","updated"]'), steps.update.outputs.pull-request-operation)
- env:
- GH_TOKEN: ${{ github.token }}
- BRANCH: ${{ steps.update.outputs.pull-request-branch }}
- run: gh workflow run check.yaml --ref "$BRANCH"
+ uses: $/.github/workflows/upgrade-release-tools.yaml
+ permissions:
+ contents: read
+ secrets:
+ RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+ with:
+ lock-groups: >-
+ build lock-tools release-notes 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
new file mode 100644
index 0000000..142ea12
--- /dev/null
+++ b/.github/workflows/upgrade-release-tools.yaml
@@ -0,0 +1,243 @@
+name: Upgrade release tools
+on:
+ workflow_call:
+ inputs:
+ lock-groups:
+ description: Release dependency groups selected by the universal lock
+ required: true
+ type: string
+ additional-lock:
+ description: Optional secondary pylock basename for a separate build matrix
+ type: string
+ default: ""
+ additional-lock-groups:
+ description: Groups for the secondary lock; empty selects build requirements
+ type: string
+ default: ""
+ additional-matrix-python:
+ description: Python range for the secondary lock
+ type: string
+ default: ""
+ additional-matrix-platforms:
+ description: Space-separated Nab platform IDs for the secondary lock
+ type: string
+ default: ""
+ additional-matrix-implementations:
+ description: Space-separated implementations for the secondary lock
+ type: string
+ default: ""
+ commit-message:
+ description: Commit message for the refreshed dependency files
+ type: string
+ default: "🔧 build(deps): refresh release tools"
+ pull-request-title:
+ description: Title for the dependency pull request
+ type: string
+ default: "🔧 build(deps): refresh release tools"
+ pull-request-body:
+ description: Body for the dependency pull request
+ type: string
+ default: |-
+ Refresh the project lock and exact pre-commit hook dependency pins.
+ secrets:
+ RELEASE_APP_PRIVATE_KEY:
+ description: Repo-only release App key
+ required: true
+permissions: {}
+jobs:
+ generate-lock:
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ outputs:
+ changed: ${{ steps.lock.outputs.changed }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: false
+ - 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-matrix-python: ${{ inputs.additional-matrix-python }}
+ additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }}
+ additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }}
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ if: steps.lock.outputs.changed == 'true'
+ with:
+ name: release-tools-lock
+ path: ${{ runner.temp }}/lock.patch
+ if-no-files-found: error
+ retention-days: 7
+ generate-hooks:
+ runs-on: ubuntu-26.04
+ env:
+ UV_EXCLUDE_NEWER: ""
+ UV_EXCLUDE_NEWER_PACKAGE: ""
+ permissions:
+ contents: read
+ outputs:
+ changed: ${{ steps.hooks.outputs.changed }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: false
+ - id: hooks
+ uses: $/refresh-hook-pins
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ if: steps.hooks.outputs.changed == 'true'
+ with:
+ name: release-tools-hooks
+ path: ${{ runner.temp }}/hooks.patch
+ if-no-files-found: error
+ retention-days: 7
+ publish:
+ needs: [generate-lock, generate-hooks]
+ if: |
+ (needs.generate-lock.outputs.changed == 'true' || needs.generate-hooks.outputs.changed == 'true') &&
+ github.ref == 'refs/heads/main'
+ runs-on: ubuntu-26.04
+ environment: release-auth
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ if: needs.generate-lock.outputs.changed == 'true'
+ with:
+ name: release-tools-lock
+ path: ${{ runner.temp }}/release-tools-lock
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ if: needs.generate-hooks.outputs.changed == 'true'
+ with:
+ name: release-tools-hooks
+ path: ${{ runner.temp }}/release-tools-hooks
+ - name: Apply lock patch
+ if: needs.generate-lock.outputs.changed == 'true'
+ run: |
+ git apply --check "$RUNNER_TEMP/release-tools-lock/lock.patch"
+ git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch"
+ - name: Apply hook patch
+ if: needs.generate-hooks.outputs.changed == 'true'
+ run: |
+ git apply --check "$RUNNER_TEMP/release-tools-hooks/hooks.patch"
+ git apply --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch"
+ - name: Check dependency patch paths
+ env:
+ ADDITIONAL_LOCK: ${{ inputs.additional-lock }}
+ run: |
+ set -euo pipefail
+ if [[ -n "$ADDITIONAL_LOCK" && ("$ADDITIONAL_LOCK" != pylock.*.toml || "$ADDITIONAL_LOCK" == */*) ]]; then
+ echo '::error::invalid additional lock basename' >&2
+ exit 1
+ fi
+ git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/dependency-paths"
+ while IFS= read -r -d '' path; do
+ if [[ "$path" != pylock.toml && "$path" != pyproject.toml &&
+ "$path" != .pre-commit-config.yaml && "$path" != "$ADDITIONAL_LOCK" ]]; then
+ echo "::error::unexpected dependency path: $path" >&2
+ exit 1
+ fi
+ done <"$RUNNER_TEMP/dependency-paths"
+ git diff --cached --check
+ - name: Check dependency patch file mode
+ run: |
+ set -euo pipefail
+ git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/dependency-modes"
+ while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do
+ read -ra fields <<<"$metadata"
+ if [[ "${fields[1]}" != 100644 ]]; then
+ echo "::error::unexpected dependency file mode in $path: ${fields[1]}" >&2
+ exit 1
+ fi
+ done <"$RUNNER_TEMP/dependency-modes"
+ - uses: $/verify-nab-cutoff-patch
+ - name: Commit refreshed tools
+ env:
+ COMMIT_MESSAGE: ${{ inputs.commit-message }}
+ run: |
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git commit -m "$COMMIT_MESSAGE"
+ - name: Check release App credentials
+ env:
+ RELEASE_APP_CLIENT_ID: ${{ vars.RELEASE_APP_CLIENT_ID }}
+ RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+ run: |
+ test -n "$RELEASE_APP_CLIENT_ID"
+ test -n "$RELEASE_APP_PRIVATE_KEY"
+ - id: repository
+ name: Select the current repository
+ env:
+ REPOSITORY: ${{ github.repository }}
+ run: echo "name=${REPOSITORY#*/}" >>"$GITHUB_OUTPUT"
+ - id: token
+ uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
+ with:
+ client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+ repositories: ${{ steps.repository.outputs.name }}
+ permission-contents: write
+ permission-pull-requests: write
+ - name: Push dependency branch
+ env:
+ GH_TOKEN: ${{ steps.token.outputs.token }}
+ run: |
+ gh auth setup-git --hostname github.com --force
+ git push --force-with-lease origin HEAD:refs/heads/automation/update-release-tools
+ - name: Write dependency pull request body
+ env:
+ PR_BODY: ${{ inputs.pull-request-body }}
+ run: printf '%s\n' "$PR_BODY" >"$RUNNER_TEMP/pull-request.md"
+ - name: Find dependency pull request
+ id: pr
+ env:
+ GH_TOKEN: ${{ steps.token.outputs.token }}
+ run: |
+ set -euo pipefail
+ owner=${GITHUB_REPOSITORY%%/*}
+ gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open \
+ -f head="$owner:automation/update-release-tools" -f base=main |
+ jq -r --arg repository "$GITHUB_REPOSITORY" '
+ [.[] | select(.head.repo.full_name == $repository and
+ .head.ref == "automation/update-release-tools" and .base.ref == "main")] |
+ if length == 0 then "number="
+ elif length == 1 then "number=\(.[0].number)"
+ else error("multiple dependency pull requests") end
+ ' >>"$GITHUB_OUTPUT"
+ - name: Update dependency pull request
+ if: steps.pr.outputs.number != ''
+ env:
+ GH_TOKEN: ${{ steps.token.outputs.token }}
+ PR_NUMBER: ${{ steps.pr.outputs.number }}
+ PR_TITLE: ${{ inputs.pull-request-title }}
+ run: |
+ gh pr edit "$PR_NUMBER" --title "$PR_TITLE" --body-file "$RUNNER_TEMP/pull-request.md" \
+ --add-label dependencies --add-label 'skip news'
+ - name: Open dependency pull request
+ if: steps.pr.outputs.number == ''
+ env:
+ GH_TOKEN: ${{ steps.token.outputs.token }}
+ PR_TITLE: ${{ inputs.pull-request-title }}
+ run: |
+ gh pr create --base main --head automation/update-release-tools --title "$PR_TITLE" \
+ --body-file "$RUNNER_TEMP/pull-request.md" --label dependencies --label 'skip news'
diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml
new file mode 100644
index 0000000..fca00f1
--- /dev/null
+++ b/.github/workflows/validate-python-release.yaml
@@ -0,0 +1,110 @@
+name: Validate Python release
+on:
+ workflow_call:
+ inputs:
+ changelog:
+ description: Tracked changelog path
+ required: true
+ type: string
+ artifact-base-url:
+ description: Ecosystem release URL without the version
+ required: true
+ type: string
+ artifact-label:
+ description: Link text for the ecosystem release
+ default: PyPI release
+ type: string
+ local-tool-source:
+ description: Install release-creator from this checkout for its own release
+ default: false
+ type: boolean
+ source:
+ description: Package source directory
+ default: .
+ type: string
+ artifact-prefix:
+ description: Prefix for distribution artifacts in a multi-package release
+ default: ""
+ type: string
+permissions:
+ contents: read
+jobs:
+ source-check:
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ ref: ${{ github.sha }}
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: false
+ - uses: $/install-locked-tools
+ with:
+ groups: release-check
+ venv: ${{ runner.temp }}/release-tools
+ - name: Run tagged source checks
+ run: >-
+ "$RUNNER_TEMP/release-tools/bin/prek" run --all-files
+ - name: Check source remains clean
+ run: test -z "$(git status --porcelain)"
+ validate:
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: ${{ github.sha }}
+ - name: Verify the exact release tag
+ env:
+ TAG: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+ test "$GITHUB_REF" = "refs/tags/$TAG"
+ git check-ref-format "refs/tags/$TAG"
+ test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)"
+ git merge-base --is-ancestor HEAD origin/main
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: false
+ - uses: $/install-locked-tools
+ with:
+ groups: release-notes${{ inputs.local-tool-source && ' project-runtime' || '' }}
+ venv: ${{ runner.temp }}/release-tools
+ local-tool-source: ${{ inputs.local-tool-source }}
+ - name: Add locked tools to PATH
+ run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH"
+ - uses: $/build-python-distribution
+ with:
+ source: ${{ inputs.source }}
+ - name: Install the local release tool
+ if: inputs.local-tool-source
+ run: uv pip install --python "$RUNNER_TEMP/release-tools/bin/python" --no-deps dist/*.whl
+ - name: Render tagged-source notes
+ uses: $/render-release-notes
+ with:
+ tag: ${{ github.ref_name }}
+ changelog: ${{ inputs.changelog }}
+ artifact-url: ${{ inputs.artifact-base-url }}${{ github.ref_name }}/
+ artifact-label: ${{ inputs.artifact-label }}
+ output: ${{ runner.temp }}/release-notes.md
+ - uses: $/validate-python-distribution
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: ${{ inputs.artifact-prefix }}python-package-distributions
+ path: dist/*
+ if-no-files-found: error
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: ${{ inputs.artifact-prefix }}release-validation-notes
+ path: ${{ runner.temp }}/release-notes.md
+ if-no-files-found: error
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index aa516b0..0810075 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,10 +2,17 @@ default_language_version:
python: python3.15
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v6.0.0
+ rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: 38bfb07c0c64c304f47eb52090f8288150c77730 # v0.17.1
+ hooks:
+ - id: ruff-check
+ args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"]
+ - id: ruff-format
+ types_or: [python, pyi]
- repo: local
hooks:
- id: mdformat
@@ -13,21 +20,21 @@ repos:
entry: mdformat --wrap 120
language: python
types: [markdown]
- additional_dependencies: [mdformat-gfm>=1]
+ additional_dependencies: [mdformat-gfm==1.0.0]
- repo: https://github.com/scop/pre-commit-shfmt
- rev: v3.14.1-1
+ rev: 479be5958357ba5ab65ce47172117348516860e9 # v3.14.1-1
hooks:
- id: shfmt
- args: ["-i", "2"]
+ args: ["-i", "2", "-d"]
- repo: https://github.com/shellcheck-py/shellcheck-py
- rev: v0.11.0.1
+ rev: 745eface02aef23e168a8afb6b5737818efbea95 # v0.11.0.1
hooks:
- id: shellcheck
- repo: https://github.com/google/yamlfmt
- rev: v0.21.0
+ rev: 21ca5323a9c87ee37a434e0ca908efc0a89daa07 # v0.21.0
hooks:
- id: yamlfmt
- repo: https://github.com/zizmorcore/zizmor-pre-commit
- rev: v1.31.1
+ rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # v1.31.1
hooks:
- id: zizmor
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe0ea3e..c8abb81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,13 +11,14 @@
### Added
- Support Towncrier fragment directories outside the repository root during version selection and PR linking.
- (fragment-directory)
+ ([#2](https://github.com/tox-dev/github-workflows/pull/2)) by [@gaborbernat](https://github.com/gaborbernat).
### Documentation
-- Put the Action setup and input reference in the repository README, with examples for each step. (readme)
+- Put the Action setup and input reference in the repository README, with examples for each step
+ ([#2](https://github.com/tox-dev/github-workflows/pull/2)) by [@gaborbernat](https://github.com/gaborbernat).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 48db84a..9871625 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,49 +1,52 @@
# Contributing
Changes to a shared Action can affect every caller pinned to its next release. Describe the behavior you are changing
-and exercise it through the PR release rehearsal on a GitHub runner.
+and test its inputs and outputs before opening a pull request.
## Set up
-Use Python 3.15 and the checked-in release-tool lock:
+Use Python 3.15. Install the release tools from the lock and test tools from the current index:
```bash
-uv venv --python 3.15 .venv
-uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml
-export PATH="$PWD/.venv/bin:$PATH"
+uv venv --allow-existing --python 3.15 .venv
+uv venv --allow-existing --python 3.15 .venv/lock-tools
+uv pip sync --preview-features pylock --python .venv/lock-tools/bin/python --group lock-tools \
+ --only-binary :all: --require-hashes pylock.toml
+.venv/lock-tools/bin/python tools/build_constraints.py .venv/build-constraints.txt pylock.toml
+uv venv --allow-existing --python 3.15 .venv/release-tools
+uv pip sync --preview-features pylock --preview-features build-dependency-hashes \
+ --python .venv/release-tools/bin/python --group release-hooks \
+ --build-constraints .venv/build-constraints.txt --require-build-hashes pylock.toml
+uv pip install --python .venv/bin/python --group test
+export PATH="$PWD/.venv/release-tools/bin:$PWD/.venv/bin:$PATH"
```
Run the checks before pushing:
```bash
prek run --all-files
-ruff format --check .
-ruff check .
-ty check tests
-pytest -q
+.venv/bin/tox run -e type
+.venv/bin/pytest -q -n auto
```
`prek` formats YAML with `yamlfmt` and runs zizmor. Run it again after accepting formatter changes. CI extracts inline
shell from the workflows and Actions, then runs ShellCheck and `shfmt` on each block. The tests extract the exact `run:`
-blocks from each composite Action and execute them in temporary Git repositories. CI uses `kcov` to require 100% line
-coverage of those blocks. The PR rehearsal also executes the composite Actions on a GitHub runner to check their wiring.
+blocks from each composite Action and execute them in temporary Git repositories.
## Change an Action
-Keep its `action.yml` inputs and step behavior in sync with the [README](README.md). The PR `release-dry-run` job
-invokes all three composite Actions, checks rejected inputs, and uploads the patch and rendered GitHub notes. Inspect
-its steps and artifacts when changing input mapping or release output. Add a runner failure case when changing input
-validation.
+Keep its `action.yml` inputs and step behavior in sync with the [README](README.md). Test rejected inputs and inspect
+the patch and rendered notes when changing release output. Add a failure case when changing input validation.
-The release preparation steps must work without a write token. Keep repository-specific build and publisher steps in the
-caller. Do not add a secret to an Action input when a short-lived, repository-scoped token is sufficient.
+The release preparation and Python build steps must work without a write token. Keep custom build and publisher steps in
+the caller. Do not add a secret to an Action input when a short-lived, repository-scoped token is sufficient.
## Open a pull request
-For a user-visible change, add a fragment such as `changelog.d/+explain-inputs.doc.md`. Use the category directories
-listed in `pyproject.toml`; the filename needs no issue or PR number. A squash merge lets `release-creator` link the
-fragment to the PR and credit its authors. Put each distinct change in its own fragment. Write the effect on users,
-without contributor credits or bot-generated changes that have no user-facing effect.
+For a user-visible change, add a fragment such as `changelog.d/+explain-inputs.doc.md`. Use the category suffixes listed
+in `pyproject.toml`; the filename needs no issue or PR number. A squash merge lets `release-creator` link the fragment
+to the PR and credit its authors. Put each distinct change in its own fragment. Write the effect on users, without
+contributor credits or bot-generated changes that have no user-facing effect.
For internal-only work, apply the `skip news` label. It may be applied after the PR opens. Bot PRs need no fragment
unless they supply one; any supplied fragment is checked. PRs are squash-merged after the required checks pass. The
diff --git a/README.md b/README.md
index 3c8c76c..a460f71 100644
--- a/README.md
+++ b/README.md
@@ -1,77 +1,445 @@
# GitHub workflows
-Three composite Actions prepare a Towncrier release, verify its patch, and push an App-authored commit and tag. They run
-as steps in the caller's workflow. Each repository keeps its dispatch form, build, publisher, GitHub environment, and
-required check names.
+Composite Actions check PR news, prepare a Towncrier release, verify its patch, push an App-authored commit and tag,
+build a Python wheel and sdist, and render GitHub Release notes. They run as steps in the caller's workflow. Each
+repository keeps its dispatch form, publisher, GitHub environment, and required check names.
+
+## Install locked tools
+
+`install-locked-tools` creates a virtual environment and installs selected groups from the caller's `pylock.toml` by
+default. `lock-source: shared` selects this Action repository's `pylock.toml` for shared jobs such as hook maintenance
+that do not need the caller's release tools. Nab's universal lock records the selected release groups for Linux, macOS,
+and Windows. It resolves dependencies under the seven-day cutoff in `[tool.nab]`; test, type, and documentation
+dependencies resolve fresh. The Action rejects missing groups and leaves tracked files unchanged. Source builds use
+constraints generated from the lock's `build` group under the runner's temporary directory.
+
+- `venv` (required): directory for the virtual environment.
+- `groups` (required): whitespace-separated release dependency groups.
+- `lock-source`: `caller` (default) installs from the checkout's lock; `shared` installs from the pinned Action's lock.
+ Both paths use the pinned Action's lock-tool bootstrap. The Action checks that every selected group exists in the
+ selected lock and applies hashed build constraints.
+- `local-tool-source`: set to `true` only when release-creator builds and installs its own wheel after this Action. The
+ caller's lock then needs the selected groups but does not need a release-creator package entry.
+
+There is no named output. The installed commands are in `venv/bin` or Windows `venv/Scripts`; add that directory to
+`GITHUB_PATH` when later steps call them by name. Run `actions/checkout` and `astral-sh/setup-uv` with Python 3.15
+before this Action.
+
+`install-nab` is the shared bootstrap used by `install-locked-tools`, `export-build-constraints`, and
+`refresh-release-lock`. It takes no inputs and has no outputs. It creates `$RUNNER_TEMP/lock-tools` and installs the
+hash-checked `lock-tools` group from this repository's pinned `pylock.toml`; it does not install caller dependencies.
+
+## Check pull request news
+
+`validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the
+configured directory against the configured categories, filename format, and nonempty content. A valid fragment runs
+`towncrier check` against the PR base. A human PR without a fragment needs `skip news`; a bot PR without a fragment does
+not. A bot's supplied fragment is checked and included in the normal release flow. The Action reads repository files and
+Git history; it does not add labels or write to GitHub.
+
+Keep the caller's existing `changelog` job name and full checkout. Install Towncrier from the caller's release lock
+before calling the Action:
+
+```yaml
+changelog:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@CHECKOUT_SHA
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@SETUP_UV_SHA
+ with:
+ version: latest-known
+ python-version: '3.15'
+ - uses: tox-dev/github-workflows/install-locked-tools@SHA
+ with:
+ groups: release-news
+ venv: ${{ runner.temp }}/release-tools
+ - name: Expose release tools
+ run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH"
+ - uses: tox-dev/github-workflows/validate-news@SHA
+```
+
+The Action expects a pull request base ref and a Markdown or reStructuredText changelog. The caller should also run on
+`labeled` and `unlabeled` events, so a label added after opening the PR updates the required check.
+
+`label-bot-news` adds `skip news` to a bot PR with no fragment and removes it when the bot adds one. It reads the PR
+file list through GitHub's API and never checks out PR code. Keep it in a separate `workflow_run` caller so the
+read-only PR workflow does not receive Issues write access. `resolve-pr-from-run` verifies that a completed run came
+from that PR workflow, then looks up the current PR by its source owner, branch, repository, and exact head SHA. It
+returns no PR when the branch has moved. This also handles fork PRs, whose workflow-run payload may omit the PR number.
+
+The [caller in this repository](.github/workflows/label-bot-news.yaml) shows the event and permissions. It runs after
+the read-only `Check` workflow finishes, even if its tests fail. Set `workflows` to that caller's PR workflow name and
+`workflow-path` to its file. The label action accepts `fragments` and `extension` for an RST repository, and `fragments`
+accepts comma-separated directories when the caller also recognizes a legacy fragment path:
+
+```yaml
+name: Label bot news
+on:
+ workflow_run:
+ workflows: [Check]
+ types: [completed]
+permissions: {}
+jobs:
+ label:
+ if: github.event.workflow_run.event == 'pull_request'
+ runs-on: ubuntu-26.04
+ permissions:
+ actions: read
+ issues: write
+ pull-requests: read
+ steps:
+ - id: pr
+ uses: tox-dev/github-workflows/resolve-pr-from-run@SHA
+ with:
+ run-id: ${{ github.event.workflow_run.id }}
+ workflow-path: .github/workflows/check.yaml
+ - if: steps.pr.outputs.bot-author == 'true'
+ uses: tox-dev/github-workflows/label-bot-news@SHA
+ with:
+ pull-request: ${{ steps.pr.outputs.number }}
+ bot-author: 'true'
+ fragments: docs/changelog
+ extension: rst
+```
+
+`resolve-pr-from-run` takes the source `run-id` and `workflow-path` in the caller repository. It outputs `number` and
+`bot-author`; `number` is empty when the run's head is no longer the open PR head. It reads GitHub metadata and writes
+no repository files. `label-bot-news` takes that verified number and bot flag. For `.changeset/.md`, pass
+`fragments: .changeset` and `fragment-style: changeset`. `fragment-style` defaults to `towncrier`, which expects
+`..md` or RST. It only changes the `skip news` label when the bot's fragment state and label disagree.
+Label writes made with `GITHUB_TOKEN` do not trigger PR CI. The next contributor or bot update runs the news check
+against the settled label state.
+
+## Refresh release tools and hooks
+
+Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only jobs refresh the universal
+`pylock.toml` and the frozen pre-commit hooks in parallel. Nab reads the seven-day package cutoff and any reviewed
+package exceptions from `[tool.nab]`. The hook updater applies the same cooldown to hook revisions and their
+`additional_dependencies`. Each job uploads a patch. The writer accepts changes to `pylock.toml`, an explicitly named
+secondary lock, and `.pre-commit-config.yaml`, plus removal of expired Nab cutoffs in `pyproject.toml`. It independently
+checks those cutoff removals before minting the App token, 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
+repository, set `RELEASE_APP_CLIENT_ID` as a repository variable, and store `RELEASE_APP_PRIVATE_KEY` in the
+`release-auth` environment. Restrict that environment to `main`. Create `dependencies` and `skip news` labels, and allow
+the App to push the dependency branch. The caller supplies the secret by name; GitHub requires this mapping even when
+the key exists only in the writer job's environment.
+
+```yaml
+name: Upgrade release tools
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: 0 8 * * 2
+permissions: {}
+jobs:
+ upgrade:
+ uses: >-
+ tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@SHA
+ permissions:
+ contents: read
+ secrets:
+ RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+ with:
+ lock-groups: >-
+ build lock-tools release-notes release-news release-prepare
+ release-validate release-check release-attestations release-shell
+ release-hooks release-hook-check
+```
+
+The required `lock-groups` input lists the caller's release dependency groups, separated by spaces or newlines. Include
+`build` and `lock-tools` where declared; exclude test, type, and documentation groups. This example lists this
+repository's groups; a caller supplies its own. The project configures its Python and platform targets in `[tool.nab]`.
+For a separately checked-in build matrix lock, also pass `additional-lock` with an existing `pylock.*.toml` basename,
+`additional-matrix-python` with its Python range, and `additional-matrix-platforms` and
+`additional-matrix-implementations` as space-separated Nab values. The secondary lock keeps its specified environment
+matrix during refresh. `additional-lock-groups` selects dependency groups for it; when empty, Nab locks the project's
+build requirements. These inputs have no effect when `additional-lock` is absent, and matrix inputs without that lock
+fail. The writer accepts only the named secondary lock path.
+
+For a repository that already commits `pylock.build.toml`, the complete caller is:
+
+```yaml
+name: Upgrade release tools
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: 0 8 * * 2
+permissions: {}
+jobs:
+ upgrade:
+ uses: >-
+ tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@SHA
+ permissions:
+ contents: read
+ secrets:
+ RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+ with:
+ lock-groups: build lock-tools release-news release-prepare release-check
+ additional-lock: pylock.build.toml
+ additional-matrix-python: '>=3.15,<3.16'
+ additional-matrix-platforms: linux_x86_64 macos_arm64 windows_amd64
+ additional-matrix-implementations: cpython
+```
+
+The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The
+workflow does not update `uv.lock` or a checked-in constraints file. Build constraints are generated from the lock's
+`build` group at runtime.
+
+`refresh-release-lock` is the read-only half of the updater. It accepts the same secondary-lock inputs. In `refresh`
+mode, it upgrades the selected release groups under Nab's configured cutoff, runs `prek`, and writes
+`$RUNNER_TEMP/lock.patch` if either lock or an expired Nab cutoff changes. In `check` mode, it regenerates without
+upgrading and fails if the checked-in files differ; the caller's normal formatting job runs `prek`. It rejects changes
+outside the selected locks and expired cutoff policy. Its `changed` output is `true` only when the refresh produces a
+patch. Neither mode pushes a branch or requests a write token. Check it on a PR in a job with Contents read permission:
+
+```yaml
+release-tools-preview:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@CHECKOUT_SHA
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@SETUP_UV_SHA
+ with:
+ version: latest-known
+ python-version: '3.15'
+ - uses: tox-dev/github-workflows/refresh-release-lock@SHA
+ with:
+ lock-groups: >-
+ build lock-tools release-notes release-news release-prepare
+ release-validate release-check release-attestations release-shell
+ release-hooks release-hook-check
+ mode: check
+```
+
+`refresh-hook-pins` is the matching read-only Action. It freezes eligible revisions with
+`prek update --freeze --cooldown-days 7`. For selected Python hooks, it reads each hook repository's package metadata
+and pins both the hook package dependencies and `additional_dependencies` under the seven-day cutoff. Non-Python hooks
+keep their frozen revisions. It then runs the hooks. Its `changed` output is `true` only when `.pre-commit-config.yaml`
+changes; the patch is `$RUNNER_TEMP/hooks.patch`. It fails when a hook changes another tracked file. The Action does not
+write to GitHub.
+
+`check-hook-update-cooldown` checks proposed hook revisions on bot PRs. Put it in the caller's `pull_request` workflow
+with `actions: read`, `contents: read`, and `pull-requests: read`, then include it in the required aggregate. It reads
+its PR number and head SHA from the event, compares them with the live PR, and requires each proposed revision to have
+appeared in a GitHub PR workflow run at least `minimum-days` earlier. The default is seven days. A force push does not
+reset an unchanged revision's age or give a new revision the old one's age. Human PRs pass without installing hook
+tools.
+
+```yaml
+hook-cooldown:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-26.04
+ permissions:
+ actions: read
+ contents: read
+ pull-requests: read
+ steps:
+ - uses: tox-dev/github-workflows/check-hook-update-cooldown@SHA
+```
+
+Run the PR workflow on `opened`, `reopened`, and `synchronize`; the cooldown check must succeed on PRs and may skip on
+push. The Action has no output or write side effect. Schedule `recheck-hook-update-cooldown` daily with
+`actions: write`, `contents: read`, and `pull-requests: read`. It reruns eligible failed cooldown jobs at the current
+head. GitHub accepts reruns for 30 days. A failed rerun is retried on the next schedule while eligible. The
+[caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. Set `workflow` or
+`job-name` only when the caller uses different names.
+
+Run the hook preview in its own PR job so its patch check sees only the hook configuration:
+
+```yaml
+hook-pins-preview:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@CHECKOUT_SHA
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@SETUP_UV_SHA
+ with:
+ version: latest-known
+ python-version: '3.15'
+ - uses: tox-dev/github-workflows/refresh-hook-pins@SHA
+```
## Start with a caller
-The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository is a complete caller. Its
-`assemble` job creates a patch without a write credential. Its `push` job applies and checks that patch before minting a
-repository-scoped App token. Copy those jobs, then set the changelog path, fragment directory, commit message, and tag
-style. Replace this repository's relative `uses: $/…` references with a full release commit SHA. In the examples below,
-`SHA` means that release commit and `TOKEN_ACTION_SHA` means the pinned token Action commit in the linked workflow.
+The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable
+Towncrier workflow. Its `assemble` job creates a patch, validates it in a fresh worktree without the App key, and
+records the patch digest. Its `push` job checks the patch and digest again before minting a repository-scoped App token.
+Set the tag style in the caller and the changelog path and fragment directory in committed Towncrier configuration. Pin
+the reusable workflow to a release commit SHA; `SHA` in these examples means that commit.
-Both jobs need a full checkout (`fetch-depth: 0`, `persist-credentials: false`) and `release-creator`, Towncrier, and
-`prek` installed from the caller's checked-in `pylock.toml`. Preparation needs `contents: read` and
-`pull-requests: read` to resolve PR authors. The `push` job uses the `release-auth` environment with
-`RELEASE_APP_PRIVATE_KEY` and the repository variable `RELEASE_APP_CLIENT_ID`. Install the App on that repository only,
-with Contents write and Metadata read. Give it the necessary branch and tag ruleset bypass.
+Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. Both
+jobs use `persist-credentials: false`. Assembly installs the caller's locked release tools and needs `contents: read`
+and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while creating the validation worktree
+and building the changelog, then waits for the hooks before formatting and validating the patch. The writer uses the
+`release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that environment to
+`main`. Install the App on this repository only, with Contents write and Metadata read, and grant the necessary branch
+and tag ruleset bypass.
```text
main + fragments
│
â–¼
-prepare-towncrier ── version + patch ──► upload artifact
- │
- download artifact
- │
- â–¼
- validate-release-patch
- │
- mint App token
- │
- â–¼
- commit-and-tag ──► main + tag
+prepare-towncrier ──► validate in fresh worktree ──► upload patch + digest
+ │
+ â–¼
+ verify patch ──► mint App token
+ │
+ â–¼
+ commit-and-tag ──► main + tag
```
The tag starts the caller's publisher. Keep PyPI trusted publishing in that top-level workflow, in a separate job with
-`id-token: write`:
-[PyPI does not support trusted publishing inside reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github).
-Run `prek` on the tagged source before publication. The [self-release workflow](.github/workflows/release.yaml) shows
-the GitHub Release path; callers keep their ecosystem builds and uploads.
+`id-token: write` because
+[PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Run `prek` in a separate
+read-only job alongside the tagged-source build, and make publishing depend on both. Repositories with the standard `uv`
+wheel and sdist build can use `build-python-distribution` in the tag publisher. The
+[self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem
+uploads. The read-only build workflow checks the tagged source and renders the Release body. The tag caller alone has a
+Contents write job that creates the GitHub Release.
+
+## Validate a Python release
+
+`validate-python-release.yaml` runs read-only source checks, notes rendering, a hash-constrained wheel and sdist build,
+and distribution checks on a release tag. Before building, it requires the fully qualified tag ref to resolve to the
+checked-out commit and that commit to be on `main`. The workflow uses `$/` for its own Actions, so a caller pinned to a
+workflow commit runs those Actions from the same commit.
+
+Call the pinned workflow from the tag publisher's build job:
+
+```yaml
+build:
+ uses: >-
+ tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA
+ permissions:
+ contents: read
+ with:
+ changelog: CHANGELOG.md
+ artifact-base-url: https://pypi.org/project/example/
+```
+
+`changelog` is the rendered changelog path. `artifact-base-url` ends just before the version; the workflow appends
+`1.2.3/` for tag `1.2.3`. Set it to the package's actual PyPI project path. `artifact-label` changes the link text from
+`PyPI release`. reStructuredText `:issue:` references link to issues; `:pr:` and `:pull:` link to pull requests.
+
+The workflow uses Python 3.15 and `pylock.toml`. The caller declares `release-check` for `prek`, `release-notes` for
+`release-creator`, `pkg-meta` for Twine and check-wheel-contents, and a `build` group for backend constraints. `source`
+defaults to the repository root for `uv build`. `artifact-prefix` defaults to empty; set it when a tag builds more than
+one distribution path so the jobs use distinct artifact names. Set `local-tool-source: true` for release-creator's own
+release; the keyless job builds its tagged wheel and installs it with locked third-party dependencies. The installer
+rejects an absent group before syncing.
+
+The workflow uploads `python-package-distributions` with the wheel and sdist, and `release-validation-notes` with the
+body rendered from the tagged source. Callers download the distribution with pinned `download-artifact`
+[v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45),
+whose default fails on an artifact digest mismatch. Its notes lack GitHub contributor metadata. The tag-only GitHub
+Release job must fetch generated notes with Contents write, rerender with `render-release-notes`, and create the Release
+after the ecosystem upload. PyPI OIDC stays in the caller's separate `release` environment job.
+
+The PyPI publisher downloads the complete validated distribution artifact. It can run alongside GitHub note rendering
+once the build succeeds. Verify PyPI and its attestations before publishing the GitHub Release.
+
+## Prepare and tag with one reusable workflow
+
+`prepare-towncrier-release.yaml` runs two jobs. `assemble` selects a tag and writes a Towncrier patch without the App
+key, applies it in a fresh worktree, runs `prek`, and records its digest. `push` checks the digest and patch paths,
+reads the caller's `release-auth` environment, and uses a repository-scoped App token to push the release commit and tag
+together. Only a dispatch from `main` can reach the writer.
+
+```yaml
+jobs:
+ prepare:
+ if: github.ref == 'refs/heads/main'
+ uses: >-
+ tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA
+ permissions:
+ contents: read
+ pull-requests: read
+ with:
+ bump: ${{ inputs.bump }}
+ tag-prefix: v
+ annotated: true
+ secrets:
+ RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+```
+
+The caller supplies the `release-auth` environment secret by name; the App client ID comes from that environment's
+`RELEASE_APP_CLIENT_ID` variable. The App token requests Contents write for this repository only. The caller grants its
+GitHub token Contents and Pull requests read. Assembly installs `release-prepare`, which contains release-creator,
+Towncrier, and prek. No job output crosses back to the caller. A successful run pushes a tag, which the caller's tag
+workflow handles.
+
+`bump`, `tag-prefix`, and `changelog-prefix` have the same meanings as the `prepare-towncrier` inputs below. `annotated`
+selects an annotated tag. `release-auth-deployment` defaults to `true`; set it to `false` if the current App-key job
+uses `release-auth` without creating a GitHub Deployment. `local-tool-source: true` builds release-creator's wheel from
+the checkout and installs it with locked third-party dependencies in assembly; use it for release-creator itself. The
+caller can place repository-specific checks in a preceding job and make this job depend on it. One dispatch prepares one
+tag; run separate dispatches for separate packages. The tag-triggered ecosystem publisher stays in the caller
+repository.
+
+## Read Towncrier configuration
+
+`read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`. It requires
+normalized changelog and fragment paths, a Markdown or RST changelog suffix, and explicit fragment categories. It reads
+committed bytes so an untrusted release patch cannot change the allowed paths or categories.
+
+```yaml
+ - id: towncrier
+ uses: tox-dev/github-workflows/read-towncrier-config@SHA
+ - env:
+ CHANGELOG: ${{ steps.towncrier.outputs.changelog }}
+ run: printf '%s\n' "$CHANGELOG"
+```
+
+`working-directory` defaults to `.`; pass a different checkout when validating a patch in a linked worktree. Outputs are
+`changelog`, `fragments`, and `format` (`markdown` or `rst`). The Action does not modify the checkout or GitHub state.
## Prepare Towncrier release
-`prepare-towncrier` reads a clean checkout, chooses the next version, links named fragments to their squash PRs, runs
-Towncrier and `prek`, and writes a binary Git patch. It does not commit or push. It fails if version selection, notes
-rendering, formatting, or checks fail, or if preparation creates untracked files or no patch.
+`prepare-towncrier` reads a clean checkout, chooses the next version, links fragments to their squash PRs, runs
+Towncrier and the selected formatter hook through `prek`, then writes a binary Git patch at
+`$RUNNER_TEMP/release.patch`. It reads the changelog path, fragment directory, categories, and Markdown or RST format
+from committed Towncrier configuration through `read-towncrier-config`. It does not commit or push. It fails if version
+selection, notes rendering, formatting, or checks fail, or if preparation creates untracked files or no patch.
```yaml
- id: prepare
uses: tox-dev/github-workflows/prepare-towncrier@SHA
with:
bump: ${{ inputs.bump }}
- changelog: docs/changelog.md
- fragments: docs/changelog.d
- format: markdown
- patch: ${{ runner.temp }}/release.patch
```
- `bump`: `auto` (default), `major`, `minor`, `patch`, or `calver`. Passed to `release-creator tags --next`; `auto` uses
the pending fragments. The caller's dispatch form decides which choices to expose.
+- `tag-prefix`: Empty by default. Use `v` when release tags are spelled `v1.2.3`; version comparison still includes
+ unprefixed numeric tags in the repository's history. This does not set the changelog heading prefix.
+- `changelog-prefix`: Empty by default, so Towncrier writes `1.2.3` even if the tag is `v1.2.3`. Set `v` for a changelog
+ whose release headings use `v1.2.3`.
- `version`: Explicit version for a caller with its own version selector. It takes precedence over `bump`; the Action
still checks that it is newer than existing tags.
-- `changelog` **(required)**: Tracked file Towncrier writes, such as `CHANGELOG.md` or `docs/changelog.rst`. This must
- match `tool.towncrier.filename` in the caller's `pyproject.toml`.
-- `fragments`: Fragment directory relative to the checkout root; default `changelog.d`. Set this to the caller's
- `tool.towncrier.directory`. Version selection and PR linking both read this directory.
-- `format`: `none` (default) or `markdown`. `markdown` runs the caller's `mdformat` prek hook on the assembled
- changelog. Use `none` for RST or when that hook does not exist. The full `prek` suite runs in either case.
-- `link-fragments`: `true` (default) resolves named fragments and authors through GitHub; `false` skips that step for a
- caller whose fragments already contain release links and credits.
-- `patch` **(required)**: Writable path to the Git patch, usually under `runner.temp`. Upload this exact file as an
- artifact for the validation job.
+- `phase`: `all` (default) runs the complete Action. The reusable workflow uses `build` to select the version, link
+ authors, and build the changelog while hooks install; it then uses `finalize` to format, render, and write the patch.
+ Pass the version selected by `build` to `finalize`.
+
+The committed `tool.towncrier` configuration must give a normalized `filename`, `directory`, and explicit fragment
+categories. The filename must end in `.md` or `.rst`; `mdformat` or `docstrfmt` must be a configured `prek` hook for
+that format. The Action writes the patch at `$RUNNER_TEMP/release.patch`; the reusable workflow uploads it by artifact
+ID.
**Output:** `version` is the selected tag string. Use `steps.prepare.outputs.version` as a job output and pass it to
both later Actions. **Working-tree effect:** Towncrier changes the changelog and consumes fragments; the patch records
@@ -79,40 +447,38 @@ those changes. The checkout must be clean before this Action starts.
## Validate release patch
-`validate-release-patch` starts from a fresh checkout of the original branch commit. It rejects a branch that moved,
-applies the uploaded patch, checks its changed paths and version heading, and runs `prek`. It leaves the patch staged
-for the commit Action. It does not need a write token.
+`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, digest,
+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
- changelog: docs/changelog.md
- fragments: docs/changelog.d
```
-- `version` **(required)**: Exact tag chosen by preparation. The changelog must contain a heading for it.
+- `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.
-- `changelog` **(required)**: Same tracked path passed to preparation. Only this file and files below `fragments` may
- change in the patch.
-- `fragments` **(required)**: Same directory passed to preparation. Towncrier may consume or rename its files.
-- `heading-prefix`: Text before the version in its heading; default `## `. For `Version 1.2.3 (date)`, use `Version `.
- The Action expects the version to be followed by ` (`.
-- `branch`: Protected release branch; default `main`.
-- `remote`: Git remote fetched for the freshness check; default `origin`.
-- `expected-sha`: Commit that preparation checked out. By default the Action uses `GITHUB_SHA`; set this when
- preparation used another commit.
-
-**Output:** No named output. **Working-tree effect:** Applies and stages the patch. A failed `prek` run, unexpected
-path, or modification by `prek` fails the job before a write credential is minted.
+- `working-directory`: Checkout where the patch is applied; default `.`. Assembly uses a fresh linked worktree here so
+ validation cannot pass because of files left by preparation.
+- `expected-patch-sha`: SHA-256 digest of the patch from a separate keyless validation job. The Action rejects a
+ different downloaded patch before applying it.
+- `checks`: `true` (default) runs `release-creator` and `prek`; `false` only applies and checks the validated patch.
+
+**Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path,
+fragment directory, and categories from committed Towncrier configuration. Only the changelog and direct fragment
+deletions may appear in the patch. A failed `prek` run, unexpected path or mode, or modification by `prek` fails the
+keyless job. The key job accepts only the validated digest.
## Commit and tag release
`commit-and-tag` accepts only a staged release patch in a clean working tree. It commits with the GitHub App bot
identity, creates the tag, and atomically pushes the branch and tag to `origin`. A rejected push leaves neither remote
ref updated. Mint the token after validation with `actions/create-github-app-token`, narrowed to this repository and
-Contents write.
+Contents write. Put `commit-and-tag` last in the writer job: its Git credential helper remains available to later steps.
```yaml
- id: token
@@ -135,20 +501,277 @@ Contents write.
- `token` **(required)**: Short-lived installation token from the repository-only App. The Action never reads the key.
- `app-slug` **(required)**: `app-slug` output from the token Action, used to resolve the bot's Git identity.
- `message` **(required)**: Commit message. It is also the annotated tag message when `annotated` is `true`.
-- `branch`: Target branch; default `main`. The checkout must still be at its validated commit.
- `annotated`: `false` (default) creates a lightweight tag; `true` creates an annotated tag. Match the repository's
existing release history.
-**Output:** No named output. **Remote effect:** One new commit on `branch` and one tag at that commit. The App needs a
+**Output:** No named output. **Remote effect:** One new commit on `main` and one tag at that commit. The App needs a
branch and tag ruleset bypass. A stale branch, invalid tag, or ruleset rejection fails the push.
-## Check a caller before switching
+## Build Python distribution
+
+`build-python-distribution` builds a wheel and sdist with `uv`. The build job checks out the tagged source and sets up
+`uv` first. Run `prek` in a separate read-only job alongside the build, and require both before publishing. The caller
+verifies the tag and changelog section before this Action.
+
+```yaml
+ - uses: tox-dev/github-workflows/build-python-distribution@SHA
+ with:
+ source: .
+```
+
+- `source`: Source directory passed to `uv build`; default `.`.
+
+**Output:** No named output. **Working-tree effect:** Writes a wheel and sdist to `dist`. The checkout must be clean
+before the Action starts. The Action exports hashed backend constraints from the lock's `build` group at runtime;
+`uv build` rejects a missing hash. Use a separate build path for repositories whose build command differs from
+`uv build --sdist --wheel`.
+
+## Validate Python distribution
+
+`validate-python-distribution` runs Twine and check-wheel-contents on the files the build job will upload. It installs
+both tools from the caller's locked `pkg-meta` group. Run it after `build-python-distribution` and before
+`upload-artifact`.
+
+```yaml
+ - uses: tox-dev/github-workflows/validate-python-distribution@SHA
+```
+
+The Action takes no inputs and checks `dist` on Python 3.15. **Output:** No named output. **Working-tree effect:** None.
+An invalid sdist or wheel stops before the artifact upload.
+
+## Render GitHub Release notes
+
+`render-release-notes` checks the tag and its changelog section, finds the previous tag, and renders an unwrapped GitHub
+Release body with `release-creator`. Call it on the actual tag. The publisher still fetches GitHub's generated notes and
+creates the Release; this Action only writes a local Markdown file.
+
+```yaml
+ - uses: tox-dev/github-workflows/render-release-notes@SHA
+ with:
+ tag: ${{ github.ref_name }}
+ 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.
+- `changelog` **(required)**: Tracked Markdown or reStructuredText changelog in the format that `release-creator notes`
+ parses, with exactly one section for the tag.
+- `artifact-url`: Ecosystem release URL, such as a PyPI version page. Omit it for a GitHub-only project.
+- `artifact-label`: Link text for `artifact-url`; default `PyPI release`. Use `Zipapp` or another product name for a
+ different artifact.
+- `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:** `previous` is the nearest earlier version tag, or empty for the first release. **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.
+
+## Select the previous release tag
+
+`select-previous-tag` finds the preceding numeric tag using release-creator's version rules. The caller must check out
+the tag history and install release-creator first. It reads Git tags and writes one step output; it does not change the
+checkout or GitHub state.
+
+```yaml
+ - id: previous
+ uses: tox-dev/github-workflows/select-previous-tag@SHA
+ with:
+ tag: ${{ github.ref_name }}
+```
+
+`tag` is required. Set `package` for a namespace such as `pyproject-fmt/2.30.3`; set `stable-only: "true"` when a stable
+release must skip prerelease tags. `executable` defaults to `release-creator` on `PATH` and accepts a path to a locked
+installation. The `previous` output is empty when this is the first release. Pass it as `previous_tag_name` to GitHub's
+generated-notes endpoint only when nonempty.
+
+## Reconcile PyPI artifacts
+
+`reconcile-pypi-artifacts` compares the complete validated distribution directory with PyPI's Simple JSON API and
+verifies a trusted-publisher attestation for each file. Upload the complete validated directory with the pinned PyPI
+publisher and `skip-existing: true`. A rerun sends only files missing from PyPI. Run this Action in a read-only job
+after the publisher. A different digest in an already published file, unexpected or yanked file, malformed response, or
+invalid attestation fails. The GitHub Release writer must depend on this verification.
+
+```yaml
+ - uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA
+ with:
+ project: example
+ version: ${{ github.ref_name }}
+ dist: dist
+```
+
+`project`, `version`, and `dist` are required. The Action installs locked `release-notes` and `release-attestations`
+groups on Python 3.15 and waits up to 120 seconds for PyPI's listing to propagate. Set `local-tool-source: "true"` only
+when releasing release-creator itself; it installs the wheel already built in `dist`. The Action prints no credentials;
+it checks the complete local file set and each published file's attestation against the caller repository.
+
+## Publish a GitHub Release
+
+`publish-github-release` verifies the tag and its rendered notes before writing a GitHub Release. Pass the exact notes
+file and the tag's commit SHA. A retry resumes a matching draft, or succeeds without a write when the published Release
+already matches. Run it in a Contents-write job that downloads the notes and optional assets; it needs no checkout or
+tool installation. For a repository that must upload GitHub assets before PyPI, call it with `mode: stage`, verify
+PyPI's exact artifact set, then call it with `mode: publish` and the same `assets-directory`. The draft stays private
+until the second call succeeds.
+
+```yaml
+ - uses: tox-dev/github-workflows/publish-github-release@SHA
+ env:
+ GH_TOKEN: ${{ github.token }}
+ with:
+ tag: ${{ github.ref_name }}
+ commit: ${{ github.sha }}
+ title: ${{ github.ref_name }}
+ notes-file: ${{ runner.temp }}/release-notes.md
+```
+
+- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the commit
+ the existing tag must resolve to. `title` and the file bytes must match an existing Release exactly.
+- `assets-directory`: Optional directory containing only the assets to attach. The Action compares each filename and
+ SHA256 digest with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets.
+ After publication it verifies GitHub's release attestation for each local asset. Enable immutable releases in the
+ repository before using this option.
+- `prerelease`: `true` or `false`; default `false`. It must match an existing Release.
+- `mode`: `complete` (default) creates or resumes a draft and publishes it in one call. `stage` creates or resumes a
+ draft and uploads assets without publishing. `publish` requires an existing draft and publishes it after checking the
+ tag, notes, and complete asset set again.
+
+**Output:** No named output. **Remote effect:** Depending on `mode`, the Action creates or resumes a draft, uploads
+missing assets, and publishes only after the complete asset set matches. A matching published Release causes no write. A
+different body, title, tag commit, prerelease flag, or asset set fails without changing the existing Release. A failed
+upload leaves the draft for a later retry. An asset in GitHub's incomplete `starter` state requires manual cleanup
+before retrying. The Action retries reading the release by ID for up to ten seconds after creating the draft, uploading
+assets, or publishing; a mismatch still fails when that window ends.
+
+## Reconcile a post-release pull request
+
+`publish-post-release-pr` consumes a candidate commit prepared without an App token. The preparation job records the
+base commit, desired branch, and a tab-separated file of changed paths and Git blob SHA1 values, then creates a Git
+bundle containing `refs/heads/release-candidate` and its full history. A thin bundle fails verification in the writer's
+empty bare repository. Upload the bundle, path file, and body as one artifact. Pass the upload step's artifact ID to the
+writer's download step, so a rerun cannot select an older artifact with the same name. The writer runs without checkout
+under the caller's `release-auth` environment and needs Contents, Pull requests, and Issues write access for its App
+token. Put `publish-post-release-pr` last in the writer job because its Git credential helper remains available to later
+steps.
+
+```yaml
+ - uses: actions/download-artifact@DOWNLOAD_SHA
+ with:
+ artifact-ids: ${{ needs.prepare.outputs.artifact-id }}
+ path: prepared/
+ - uses: tox-dev/github-workflows/publish-post-release-pr@SHA
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ with:
+ app-slug: ${{ steps.app-token.outputs.app-slug }}
+ base-commit: ${{ needs.prepare.outputs.base-commit }}
+ branch: post-release-${{ github.ref_name }}
+ title: Bump version to 2.4.8-dev
+ commit-message: Bump version to 2.4.8-dev after 2.4.7
+ body-file: prepared/body.md
+ paths-file: prepared/paths.tsv
+ allowed-paths: gradle.properties
+ bundle: prepared/candidate.bundle
+ candidate-commit: ${{ needs.prepare.outputs.candidate-commit }}
+```
+
+The Action derives the bot identity from the writer's App slug and verifies the commit author and committer, exact
+commit message, single-parent commit, changed path set against the writer's `allowed-paths`, regular file modes, and
+blob identities before any write.
+
+Pass the keyless job's `base-commit`, the desired `branch` and `title`, its exact one-line `commit-message`, and the
+exact PR body in `body-file`. `paths-file` contains one tab-separated path and desired Git blob SHA1 per line. The
+Action targets `main` in `GITHUB_REPOSITORY` and applies `skip news` to the PR.
+
+A matching open branch and PR need no second push. If a matching branch has no PR, it creates one. A merged PR with a
+deleted branch succeeds only when the current base still has the expected file bytes. A closed, unmerged PR fails before
+the Action creates a replacement branch or PR; reopen the PR before retrying. A different branch, PR title, body, base,
+path set, or merged file state fails without a force-push. When the prepared change has already merged and produced no
+new commit, pass empty `bundle` and `candidate-commit` values with the same path manifest; the Action still checks the
+merged PR and current base. The Action has no named output.
+
+## Sign a JetBrains plugin
+
+`sign-jetbrains-plugin` signs an unsigned plugin ZIP after a keyless Gradle `buildPlugin` job. Run it in a fresh job on
+`ubuntu-26.04` with the signing environment, no checkout, and no Gradle invocation. Download the unsigned artifact and
+pass the SHA256 emitted before that artifact was uploaded. The Action checks the file and its digest, downloads
+JetBrains Marketplace ZIP Signer 0.1.43 from Maven Central, verifies its pinned SHA256, and uses the runner's Java 17
+installation. It accepts plain PEM or base64 credentials and an encrypted private key with its password. The private key
+is mapped only into the sign step's environment. Its temporary file is removed when that step ends.
+
+```yaml
+sign:
+ needs: build-unsigned
+ runs-on: ubuntu-26.04
+ environment: release
+ permissions: {}
+ outputs:
+ archive: ${{ steps.signed.outputs.archive }}
+ sha256: ${{ steps.signed.outputs.sha256 }}
+ steps:
+ - uses: actions/download-artifact@DOWNLOAD_SHA
+ with:
+ name: unsigned-plugin
+ path: dist/
+ - id: signed
+ uses: tox-dev/github-workflows/sign-jetbrains-plugin@SHA
+ with:
+ archive: dist/Plugin-1.2.3.zip
+ sha256: ${{ needs.build-unsigned.outputs.archive_sha }}
+ certificate-chain: ${{ secrets.CERTIFICATE_CHAIN }}
+ private-key: ${{ secrets.PRIVATE_KEY }}
+ private-key-password: ${{ secrets.PRIVATE_KEY_PASSWORD }}
+ - uses: actions/upload-artifact@UPLOAD_SHA
+ with:
+ name: plugin-distribution
+ path: ${{ steps.signed.outputs.archive }}
+ if-no-files-found: error
+```
+
+`archive` and `sha256` identify the unsigned ZIP. `certificate-chain` and `private-key` supply the signing identity;
+`private-key-password` is needed only for an encrypted key. The outputs are `archive`, the signed ZIP path, and
+`sha256`, the digest to check after download in the publisher and GitHub Release jobs. **Remote effect:** downloads the
+pinned signer JAR. It makes no GitHub or Marketplace write. The caller uploads the signed ZIP as a GitHub Actions
+artifact after the Action succeeds. Keep the Marketplace token in a different no-checkout job.
+
+## Publish a JetBrains plugin
+
+`publish-jetbrains-marketplace` uploads one signed ZIP through the
+[Marketplace upload API](https://plugins.jetbrains.com/docs/marketplace/plugin-upload.html). Run it in a fresh job
+without checkout or Gradle. Download the ZIP produced by the signing job and pass that job's SHA256 output; the Action
+checks the downloaded bytes, ZIP integrity, and member names before the token-bearing upload step.
+
+```yaml
+ - uses: tox-dev/github-workflows/publish-jetbrains-marketplace@SHA
+ env:
+ PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
+ with:
+ archive: dist/Fish Shell 2-0.1.11-signed.zip
+ sha256: ${{ needs.sign.outputs.sha256 }}
+ plugin-id: '29973'
+ channel: ''
+```
+
+`archive`, `sha256`, and numeric `plugin-id` are required. `channel` is empty for Stable; use a Marketplace channel name
+for a prerelease. **Output:** None. **Remote effect:** One multipart upload to JetBrains Marketplace. The Action does
+not check whether this version was published already; use the keyless release-creator `marketplace --mode stage` check
+before the upload, and `--mode verify` afterward. A retry must compare the published ZIP's member digests before
+skipping an upload because Marketplace can repackage the outer ZIP.
+
+## Test a caller
-The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. The
-[`release-dry-run` job](.github/workflows/check.yaml) invokes the composite Actions on a GitHub runner against a local
-bare remote. It cannot prove that another repository's Towncrier template, release hooks, package build, or publisher
-works. Run that caller's prepare and validation path at its PR head without pushing a GitHub ref or publishing an
-artifact. Inspect the version, patch, changelog, notes, and built package before changing its live release workflow.
+The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's
+Towncrier template, release hooks, package build, and publisher against representative source data before changing its
+live release workflow. Inspect the version, patch, changelog, notes, and built package.
This repository releases its own Actions with the same prepare and tag path. See [Contributing](CONTRIBUTING.md) for
local checks and news fragments, and [Changelog](CHANGELOG.md) for released changes.
diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml
new file mode 100644
index 0000000..c2ec053
--- /dev/null
+++ b/build-python-distribution/action.yml
@@ -0,0 +1,44 @@
+name: Build Python distribution
+description: Build a wheel and sdist from source checked in a separate job
+inputs:
+ source:
+ description: Distribution source directory
+ default: .
+runs:
+ using: composite
+ steps:
+ - name: Require clean tagged source
+ shell: bash
+ run: |
+ if [[ -n $(git status --porcelain) ]]; then
+ echo '::error::tagged source is dirty' >&2
+ exit 1
+ fi
+ - name: Require empty output directory
+ shell: bash
+ run: |
+ set -euo pipefail
+ if [[ -d dist && -n $(find dist -mindepth 1 -maxdepth 1 -print -quit) ]]; then
+ echo '::error::distribution output directory is not empty: dist' >&2
+ exit 1
+ fi
+ - name: Select build interpreter
+ shell: bash
+ run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/build-python"
+ - id: constraints
+ uses: $/export-build-constraints
+ - name: Build wheel and sdist
+ shell: bash
+ env:
+ SOURCE: ${{ inputs.source }}
+ BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }}
+ run: |
+ set -euo pipefail
+ unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE
+ build_python=$(uv python find "$RUNNER_TEMP/build-python")
+ test -n "$build_python"
+ export SOURCE_DATE_EPOCH
+ SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct)
+ uv build --python "$build_python" \
+ --python-preference only-managed --sdist --wheel \
+ --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes "$SOURCE" --out-dir dist
diff --git a/changelog.d/+action-test-inputs.bugfix.md b/changelog.d/+action-test-inputs.bugfix.md
new file mode 100644
index 0000000..c482286
--- /dev/null
+++ b/changelog.d/+action-test-inputs.bugfix.md
@@ -0,0 +1 @@
+Resolve Action inputs and step outputs by their declared names in the local test runner.
diff --git a/changelog.d/+app-hook-cooldown.security.md b/changelog.d/+app-hook-cooldown.security.md
new file mode 100644
index 0000000..4026e0f
--- /dev/null
+++ b/changelog.d/+app-hook-cooldown.security.md
@@ -0,0 +1 @@
+Apply the seven-day hook revision check to App-authored dependency pull requests.
diff --git a/changelog.d/+bot-news-label.feature.md b/changelog.d/+bot-news-label.feature.md
new file mode 100644
index 0000000..22c3d17
--- /dev/null
+++ b/changelog.d/+bot-news-label.feature.md
@@ -0,0 +1 @@
+Label bot pull requests without news fragments from a no-checkout workflow, and remove the label when a bot adds news.
diff --git a/changelog.d/+bot-workflow-run.feature.md b/changelog.d/+bot-workflow-run.feature.md
new file mode 100644
index 0000000..ae558d8
--- /dev/null
+++ b/changelog.d/+bot-workflow-run.feature.md
@@ -0,0 +1 @@
+Label bot pull requests after read-only CI, including forks and failed checks.
diff --git a/changelog.d/+caller-build-constraints.bugfix.md b/changelog.d/+caller-build-constraints.bugfix.md
new file mode 100644
index 0000000..ca2f78d
--- /dev/null
+++ b/changelog.d/+caller-build-constraints.bugfix.md
@@ -0,0 +1 @@
+Combine shared build pins with a caller's build group so source dependencies can install on Python 3.15.
diff --git a/changelog.d/+cancelled-gate.bugfix.md b/changelog.d/+cancelled-gate.bugfix.md
new file mode 100644
index 0000000..0c6d91b
--- /dev/null
+++ b/changelog.d/+cancelled-gate.bugfix.md
@@ -0,0 +1 @@
+Keep the required check from reporting a failure after its run is cancelled.
diff --git a/changelog.d/+changelog-prefix.bugfix.md b/changelog.d/+changelog-prefix.bugfix.md
new file mode 100644
index 0000000..4ec6626
--- /dev/null
+++ b/changelog.d/+changelog-prefix.bugfix.md
@@ -0,0 +1 @@
+Let Towncrier callers preserve `v`-prefixed changelog headings independently of their release tag prefix.
diff --git a/changelog.d/+check-runtime.performance.md b/changelog.d/+check-runtime.performance.md
new file mode 100644
index 0000000..f4c7edd
--- /dev/null
+++ b/changelog.d/+check-runtime.performance.md
@@ -0,0 +1 @@
+Cache pre-commit hooks and use shallow checkouts where release checks need no history.
diff --git a/changelog.d/+empty-groups.bugfix.md b/changelog.d/+empty-groups.bugfix.md
new file mode 100644
index 0000000..1e682d2
--- /dev/null
+++ b/changelog.d/+empty-groups.bugfix.md
@@ -0,0 +1 @@
+Report an empty dependency group selection before trying to install release tools.
diff --git a/changelog.d/+fragment-validation.bugfix.md b/changelog.d/+fragment-validation.bugfix.md
new file mode 100644
index 0000000..fc8fe1b
--- /dev/null
+++ b/changelog.d/+fragment-validation.bugfix.md
@@ -0,0 +1 @@
+Reject fragment filenames that would publish without a pull request link or author credit.
diff --git a/changelog.d/+github-release-retry.feature.md b/changelog.d/+github-release-retry.feature.md
new file mode 100644
index 0000000..dc054af
--- /dev/null
+++ b/changelog.d/+github-release-retry.feature.md
@@ -0,0 +1,2 @@
+Stage and resume a draft GitHub Release, then publish it after ecosystem verification. Tag reruns verify the exact notes
+and assets before succeeding.
diff --git a/changelog.d/+hook-recheck.bugfix.md b/changelog.d/+hook-recheck.bugfix.md
new file mode 100644
index 0000000..b758a06
--- /dev/null
+++ b/changelog.d/+hook-recheck.bugfix.md
@@ -0,0 +1 @@
+Recheck mature bot hook updates each day and continue after a failed GitHub job rerun.
diff --git a/changelog.d/+hook-run-filter.performance.md b/changelog.d/+hook-run-filter.performance.md
new file mode 100644
index 0000000..f73a43f
--- /dev/null
+++ b/changelog.d/+hook-run-filter.performance.md
@@ -0,0 +1 @@
+Limit hook cooldown history requests to workflow runs older than the required waiting period.
diff --git a/changelog.d/+hook-update-cooldown.feature.md b/changelog.d/+hook-update-cooldown.feature.md
new file mode 100644
index 0000000..c911e94
--- /dev/null
+++ b/changelog.d/+hook-update-cooldown.feature.md
@@ -0,0 +1 @@
+Check the age of the current `pre-commit.ci` pull request head before a required CI gate permits a hook update.
diff --git a/changelog.d/+inline-shell-hidden.bugfix.md b/changelog.d/+inline-shell-hidden.bugfix.md
new file mode 100644
index 0000000..b8a854d
--- /dev/null
+++ b/changelog.d/+inline-shell-hidden.bugfix.md
@@ -0,0 +1 @@
+Check shell commands from workflows in `.github` with ShellCheck and shfmt.
diff --git a/changelog.d/+jetbrains-marketplace.feature.md b/changelog.d/+jetbrains-marketplace.feature.md
new file mode 100644
index 0000000..dbca85f
--- /dev/null
+++ b/changelog.d/+jetbrains-marketplace.feature.md
@@ -0,0 +1 @@
+Add a credential-only JetBrains Marketplace upload Action for a verified signed ZIP.
diff --git a/changelog.d/+jetbrains-signer.feature.md b/changelog.d/+jetbrains-signer.feature.md
new file mode 100644
index 0000000..e38314f
--- /dev/null
+++ b/changelog.d/+jetbrains-signer.feature.md
@@ -0,0 +1 @@
+Sign a JetBrains plugin ZIP in an isolated job after verifying the unsigned build artifact and pinned signing tool.
diff --git a/changelog.d/+label-pagination.bugfix.md b/changelog.d/+label-pagination.bugfix.md
new file mode 100644
index 0000000..41c7cda
--- /dev/null
+++ b/changelog.d/+label-pagination.bugfix.md
@@ -0,0 +1 @@
+Read every label before changing the skip news label on a bot pull request.
diff --git a/changelog.d/+license-metadata.packaging.md b/changelog.d/+license-metadata.packaging.md
new file mode 100644
index 0000000..ac77c9e
--- /dev/null
+++ b/changelog.d/+license-metadata.packaging.md
@@ -0,0 +1 @@
+Declare the MIT license and its file in project metadata.
diff --git a/changelog.d/+linked-rst.bugfix.md b/changelog.d/+linked-rst.bugfix.md
new file mode 100644
index 0000000..720697d
--- /dev/null
+++ b/changelog.d/+linked-rst.bugfix.md
@@ -0,0 +1 @@
+Validate linked reStructuredText release headings when applying a release patch.
diff --git a/changelog.d/+marketplace-error.bugfix.md b/changelog.d/+marketplace-error.bugfix.md
new file mode 100644
index 0000000..02a8f11
--- /dev/null
+++ b/changelog.d/+marketplace-error.bugfix.md
@@ -0,0 +1 @@
+Show the JetBrains Marketplace response when an upload fails.
diff --git a/changelog.d/+marketplace-form-path.bugfix.md b/changelog.d/+marketplace-form-path.bugfix.md
new file mode 100644
index 0000000..2f8abd7
--- /dev/null
+++ b/changelog.d/+marketplace-form-path.bugfix.md
@@ -0,0 +1 @@
+Upload signed JetBrains plugins when the runner temporary path contains commas or semicolons.
diff --git a/changelog.d/+news-checkout.bugfix.md b/changelog.d/+news-checkout.bugfix.md
new file mode 100644
index 0000000..b5b7814
--- /dev/null
+++ b/changelog.d/+news-checkout.bugfix.md
@@ -0,0 +1 @@
+Fetch the PR base branch for changelog checks, which compare the PR head with `origin/main`.
diff --git a/changelog.d/+news-validation.feature.md b/changelog.d/+news-validation.feature.md
new file mode 100644
index 0000000..b173d63
--- /dev/null
+++ b/changelog.d/+news-validation.feature.md
@@ -0,0 +1,2 @@
+Validate pull request changelog fragments in a shared Action, with bot author handling that does not depend on label
+timing.
diff --git a/changelog.d/+package-notes.feature.md b/changelog.d/+package-notes.feature.md
new file mode 100644
index 0000000..7e299c5
--- /dev/null
+++ b/changelog.d/+package-notes.feature.md
@@ -0,0 +1 @@
+Render GitHub Release notes for independently tagged packages in one repository.
diff --git a/changelog.d/+parallel-checks.performance.md b/changelog.d/+parallel-checks.performance.md
new file mode 100644
index 0000000..cf946d0
--- /dev/null
+++ b/changelog.d/+parallel-checks.performance.md
@@ -0,0 +1 @@
+Run formatting, inline shell checks, tests, and type checks in parallel.
diff --git a/changelog.d/+patch-artifact-id.bugfix.md b/changelog.d/+patch-artifact-id.bugfix.md
new file mode 100644
index 0000000..9d9419a
--- /dev/null
+++ b/changelog.d/+patch-artifact-id.bugfix.md
@@ -0,0 +1 @@
+Download the release patch by its uploaded artifact ID, so a same-named artifact cannot replace it in a later job.
diff --git a/changelog.d/+post-release-commit.security.md b/changelog.d/+post-release-commit.security.md
new file mode 100644
index 0000000..f648414
--- /dev/null
+++ b/changelog.d/+post-release-commit.security.md
@@ -0,0 +1 @@
+Check the prepared commit's committer and message before opening a post-release pull request.
diff --git a/changelog.d/+post-release-pr-retry.feature.md b/changelog.d/+post-release-pr-retry.feature.md
new file mode 100644
index 0000000..8fff0d2
--- /dev/null
+++ b/changelog.d/+post-release-pr-retry.feature.md
@@ -0,0 +1,2 @@
+Create or resume an App-authored post-release pull request, verifying the branch and changed files before a retry
+succeeds.
diff --git a/changelog.d/+pypi-retry.feature.md b/changelog.d/+pypi-retry.feature.md
new file mode 100644
index 0000000..496cc99
--- /dev/null
+++ b/changelog.d/+pypi-retry.feature.md
@@ -0,0 +1,2 @@
+Stage only PyPI files absent from an identical release, then verify the complete published artifact set before writing
+the GitHub Release.
diff --git a/changelog.d/+python-distribution.feature.md b/changelog.d/+python-distribution.feature.md
new file mode 100644
index 0000000..98a0664
--- /dev/null
+++ b/changelog.d/+python-distribution.feature.md
@@ -0,0 +1,2 @@
+Build a Python wheel and sdist after a separate tagged-source check, using hashed build backend pins from the full
+project lock. Check the exact wheel and sdist with Twine and check-wheel-contents before uploading them.
diff --git a/changelog.d/+python-publisher.feature.md b/changelog.d/+python-publisher.feature.md
new file mode 100644
index 0000000..9604b80
--- /dev/null
+++ b/changelog.d/+python-publisher.feature.md
@@ -0,0 +1 @@
+Offer a read-only tagged-source workflow for notes, wheel and sdist builds, and distribution checks before publishing.
diff --git a/changelog.d/+release-docs.doc.md b/changelog.d/+release-docs.doc.md
new file mode 100644
index 0000000..c9a7705
--- /dev/null
+++ b/changelog.d/+release-docs.doc.md
@@ -0,0 +1 @@
+Correct the release job order, checkout requirements, retry behavior, and hook rerun window in the Action guide.
diff --git a/changelog.d/+release-notes.feature.md b/changelog.d/+release-notes.feature.md
new file mode 100644
index 0000000..78a82b0
--- /dev/null
+++ b/changelog.d/+release-notes.feature.md
@@ -0,0 +1 @@
+Render GitHub Release notes from a tagged changelog in tag publishers.
diff --git a/changelog.d/+release-patch-check.bugfix.md b/changelog.d/+release-patch-check.bugfix.md
new file mode 100644
index 0000000..070a7de
--- /dev/null
+++ b/changelog.d/+release-patch-check.bugfix.md
@@ -0,0 +1 @@
+Fail release patch validation when Git cannot list the changed paths, modes, or RST lines.
diff --git a/changelog.d/+release-patch.security.md b/changelog.d/+release-patch.security.md
new file mode 100644
index 0000000..d3ec904
--- /dev/null
+++ b/changelog.d/+release-patch.security.md
@@ -0,0 +1 @@
+Validate release patches without the App key, then recheck their digest, paths, and file modes before minting a token.
diff --git a/changelog.d/+release-read-retry.bugfix.md b/changelog.d/+release-read-retry.bugfix.md
new file mode 100644
index 0000000..c6fcdcc
--- /dev/null
+++ b/changelog.d/+release-read-retry.bugfix.md
@@ -0,0 +1 @@
+Retry failed GitHub Release reads without repeating asset uploads or publication.
diff --git a/changelog.d/+reproducible-builds.bugfix.md b/changelog.d/+reproducible-builds.bugfix.md
new file mode 100644
index 0000000..fa9d64a
--- /dev/null
+++ b/changelog.d/+reproducible-builds.bugfix.md
@@ -0,0 +1 @@
+Use the tagged commit time when building Python distributions so a retry produces the same bytes.
diff --git a/changelog.d/+signer-guards.security.md b/changelog.d/+signer-guards.security.md
new file mode 100644
index 0000000..add1cb1
--- /dev/null
+++ b/changelog.d/+signer-guards.security.md
@@ -0,0 +1 @@
+Reject an existing signed ZIP and mask decoded JetBrains signing key material.
diff --git a/changelog.d/+source-tag.security.md b/changelog.d/+source-tag.security.md
new file mode 100644
index 0000000..f86a575
--- /dev/null
+++ b/changelog.d/+source-tag.security.md
@@ -0,0 +1 @@
+Require a release tag to point to a commit on main before publishing.
diff --git a/changelog.d/+tool-updater.feature.md b/changelog.d/+tool-updater.feature.md
new file mode 100644
index 0000000..28c3862
--- /dev/null
+++ b/changelog.d/+tool-updater.feature.md
@@ -0,0 +1,2 @@
+Share the release-tool lock updater across repositories while keeping each caller's schedule and checks, and expose its
+read-only generator for PR validation.
diff --git a/changelog.d/+type-task.bugfix.md b/changelog.d/+type-task.bugfix.md
new file mode 100644
index 0000000..12222a8
--- /dev/null
+++ b/changelog.d/+type-task.bugfix.md
@@ -0,0 +1 @@
+Run the type-check tox environment with current test dependencies without installing them twice.
diff --git a/changelog.d/+v-prefix.feature.md b/changelog.d/+v-prefix.feature.md
new file mode 100644
index 0000000..45b48c4
--- /dev/null
+++ b/changelog.d/+v-prefix.feature.md
@@ -0,0 +1 @@
+Let release callers calculate `v`-prefixed tags without typing a version.
diff --git a/changelog.d/+weekly-hook-pins.feature.md b/changelog.d/+weekly-hook-pins.feature.md
new file mode 100644
index 0000000..e46b816
--- /dev/null
+++ b/changelog.d/+weekly-hook-pins.feature.md
@@ -0,0 +1,3 @@
+Refresh exact pre-commit hook dependency pins in the weekly release-tool PR, with an opt-in to retain a project's
+checked-in `uv.lock` alongside its selectable `pylock.toml`. Allow explicit package exceptions to the seven-day cutoff
+when a declared minimum is newer than that cutoff.
diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml
new file mode 100644
index 0000000..bb5d7e3
--- /dev/null
+++ b/check-hook-update-cooldown/action.yml
@@ -0,0 +1,256 @@
+name: Check hook update cooldown
+description: Require seven days before a pre-commit.ci update pull request can merge
+inputs:
+ minimum-days:
+ description: Minimum age of a proposed hook revision
+ default: "7"
+runs:
+ using: composite
+ steps:
+ - id: identity
+ name: Verify the pull request identity
+ shell: bash
+ env:
+ MINIMUM_DAYS: ${{ inputs.minimum-days }}
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ REPOSITORY=$GITHUB_REPOSITORY
+ PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH")
+ HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH")
+ case "$REPOSITORY" in
+ */*) ;;
+ *) exit 1 ;;
+ esac
+ for part in "${REPOSITORY%%/*}" "${REPOSITORY#*/}"; do
+ case "$part" in
+ '' | *[!A-Za-z0-9_.-]*) exit 1 ;;
+ esac
+ done
+ case "$PR_NUMBER" in
+ '' | 0* | *[!0-9]*) exit 1 ;;
+ esac
+ test "${#HEAD_SHA}" -eq 40
+ case "$HEAD_SHA" in
+ *[!0-9a-f]*) exit 1 ;;
+ esac
+ case "$MINIMUM_DAYS" in
+ '' | 0* | *[!0-9]*) exit 1 ;;
+ esac
+ if ((MINIMUM_DAYS < 7)); then
+ echo '::error::hook cooldown must be at least seven days' >&2
+ exit 1
+ fi
+ test "$GITHUB_EVENT_NAME" = pull_request
+ jq -e --argjson number "$PR_NUMBER" --arg head "$HEAD_SHA" '
+ .number == $number and .pull_request.head.sha == $head
+ ' "$GITHUB_EVENT_PATH" >/dev/null
+ gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" >"$RUNNER_TEMP/hook-update-pr.json"
+ jq -e --arg repository "$REPOSITORY" --arg head "$HEAD_SHA" '
+ .state == "open" and .base.repo.full_name == $repository and
+ .head.sha == $head and
+ (.user.login | type == "string")
+ ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null
+ if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then
+ gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' \
+ >"$RUNNER_TEMP/hook-update-files"
+ fi
+ if [[ -f "$RUNNER_TEMP/hook-update-files" ]] &&
+ grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-update-files"; then
+ gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \
+ -f head_sha="$HEAD_SHA" -f per_page=100 >"$RUNNER_TEMP/hook-update-runs.jsonl"
+ if jq -se --arg repository "$REPOSITORY" \
+ --arg branch "$(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json")" \
+ --arg head "$HEAD_SHA" --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" '
+ [.[].workflow_runs[] | select(
+ .event == "pull_request" and .head_repository.full_name == $repository and
+ .head_branch == $branch and .head_sha == $head and
+ any(.pull_requests[]; .number == $number)
+ ) | .created_at | fromdateiso8601] as $observed |
+ ($observed | length) > 0 and ($observed | min) <= now - ($days * 86400)
+ ' "$RUNNER_TEMP/hook-update-runs.jsonl" >/dev/null; then
+ echo 'cooled=true' >>"$GITHUB_OUTPUT"
+ fi
+ echo 'bot=true' >>"$GITHUB_OUTPUT"
+ fi
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
+ if: steps.identity.outputs.bot == 'true'
+ with:
+ version: latest-known
+ python-version: "3.15"
+ enable-cache: false
+ - uses: $/install-locked-tools
+ if: steps.identity.outputs.bot == 'true'
+ with:
+ groups: release-hook-check
+ lock-source: shared
+ venv: ${{ runner.temp }}/hook-tools
+ - name: Read the base and proposed hook configurations
+ if: steps.identity.outputs.bot == 'true'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ REPOSITORY=$GITHUB_REPOSITORY
+ HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH")
+ base_sha=$(jq -r .base.sha "$RUNNER_TEMP/hook-update-pr.json")
+ gh api "repos/$REPOSITORY/contents/.pre-commit-config.yaml?ref=$base_sha" --jq .content |
+ base64 --decode >"$RUNNER_TEMP/base-hooks.yaml"
+ gh api "repos/$REPOSITORY/contents/.pre-commit-config.yaml?ref=$HEAD_SHA" --jq .content |
+ base64 --decode >"$RUNNER_TEMP/proposed-hooks.yaml"
+ cp "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/eligible-hooks.yaml"
+ git init -q "$RUNNER_TEMP/hook-cooldown"
+ - name: Select eligible hook revisions
+ if: steps.identity.outputs.bot == 'true'
+ shell: bash
+ env:
+ MINIMUM_DAYS: ${{ inputs.minimum-days }}
+ run: |
+ uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update -C "$RUNNER_TEMP/hook-cooldown" \
+ --config "$RUNNER_TEMP/eligible-hooks.yaml" \
+ --cooldown-days "$MINIMUM_DAYS" --no-progress
+ - name: Compare proposed hook revisions
+ if: steps.identity.outputs.bot == 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+ for config in base proposed eligible; do
+ uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c \
+ '[.repos[] | select(.repo != "local" and .repo != "meta") | {repo, rev}]' \
+ "$RUNNER_TEMP/$config-hooks.yaml" >"$RUNNER_TEMP/$config-hooks.json"
+ done
+ jq -e -n \
+ --slurpfile base "$RUNNER_TEMP/base-hooks.json" \
+ --slurpfile proposed "$RUNNER_TEMP/proposed-hooks.json" \
+ --slurpfile eligible "$RUNNER_TEMP/eligible-hooks.json" '
+ $base[0] as $old |
+ $eligible[0] as $allowed |
+ $proposed[0] as $new |
+ if ($old | map(.repo)) != ($new | map(.repo)) or
+ ($old | map(.repo)) != ($allowed | map(.repo))
+ then error("hook repository entries changed")
+ else [range(0; $new | length) as $index |
+ $new[$index] |
+ select(.rev != $old[$index].rev and .rev != $allowed[$index].rev) |
+ [.repo, .rev, ([$new[:$index][] | select(.repo == $new[$index].repo)] | length)]] end
+ ' >"$RUNNER_TEMP/older-hook-revisions.json"
+ jq -r '.[] | @tsv' "$RUNNER_TEMP/older-hook-revisions.json" >"$RUNNER_TEMP/older-hook-revisions"
+ - name: Find prior pull request heads
+ if: steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true'
+ shell: bash
+ env:
+ MINIMUM_DAYS: ${{ inputs.minimum-days }}
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ REPOSITORY=$GITHUB_REPOSITORY
+ PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH")
+ branch=$(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json")
+ cutoff=$(jq -nr --argjson days "$MINIMUM_DAYS" 'now - $days * 86400 | todateiso8601')
+ gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \
+ -f branch="$branch" -f "created=<$cutoff" -f per_page=100 >"$RUNNER_TEMP/hook-branch-runs.jsonl"
+ jq -sr --arg repository "$REPOSITORY" --arg branch "$branch" \
+ --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" '
+ [.[].workflow_runs[] | select(
+ .event == "pull_request" and .head_repository.full_name == $repository and
+ .head_branch == $branch and any(.pull_requests[]; .number == $number) and
+ (.created_at | fromdateiso8601) <= now - ($days * 86400)
+ ) | .head_sha] | unique[]
+ ' "$RUNNER_TEMP/hook-branch-runs.jsonl" >"$RUNNER_TEMP/older-heads"
+ - name: Read prior hook revisions
+ if: steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ REPOSITORY=$GITHUB_REPOSITORY
+ : >"$RUNNER_TEMP/observed-revisions.jsonl"
+ while IFS= read -r head; do
+ if gh api "repos/$REPOSITORY/contents/.pre-commit-config.yaml?ref=$head" --jq .content \
+ >"$RUNNER_TEMP/older-hooks.b64" 2>"$RUNNER_TEMP/older-hooks-error"; then
+ base64 --decode <"$RUNNER_TEMP/older-hooks.b64" >"$RUNNER_TEMP/older-hooks.yaml"
+ elif grep -Fq '(HTTP 404)' "$RUNNER_TEMP/older-hooks-error"; then
+ continue
+ else
+ cat "$RUNNER_TEMP/older-hooks-error" >&2
+ exit 1
+ fi
+ if ! uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c '
+ [.repos[] | select(.repo != "local" and .repo != "meta") | {repo, rev}]
+ ' "$RUNNER_TEMP/older-hooks.yaml" >"$RUNNER_TEMP/older-revisions.json"; then
+ echo "::warning::skip malformed hook config at $head" >&2
+ continue
+ fi
+ jq -c '
+ . as $entries | to_entries[] | . as $entry |
+ {repo: .value.repo, rev: .value.rev,
+ occurrence: ([$entries[:$entry.key][] | select(.repo == $entry.value.repo)] | length)}
+ ' "$RUNNER_TEMP/older-revisions.json" >>"$RUNNER_TEMP/observed-revisions.jsonl"
+ done <"$RUNNER_TEMP/older-heads"
+ - name: Verify the first GitHub observation of each revision
+ if: steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true'
+ shell: bash
+ run: |
+ jq -n --slurpfile base "$RUNNER_TEMP/base-hooks.json" \
+ --slurpfile proposed "$RUNNER_TEMP/proposed-hooks.json" '
+ [$proposed[0] as $entries | $entries | to_entries[] | . as $entry |
+ select(.value.rev != $base[0][.key].rev) |
+ {repo: .value.repo, rev: .value.rev,
+ occurrence: ([$entries[:$entry.key][] | select(.repo == $entry.value.repo)] | length)}]
+ ' >"$RUNNER_TEMP/proposed-revisions.json"
+ if ! jq -se --slurpfile proposed "$RUNNER_TEMP/proposed-revisions.json" '
+ . as $observed |
+ all($proposed[0][]; . as $revision |
+ any($observed[];
+ .repo == $revision.repo and .occurrence == $revision.occurrence and .rev == $revision.rev))
+ ' "$RUNNER_TEMP/observed-revisions.jsonl" >/dev/null; then
+ echo '::error::hook revision has not been observed on this pull request for seven days' >&2
+ exit 1
+ fi
+ - name: Verify older pinned revisions
+ if: steps.identity.outputs.bot == 'true'
+ shell: bash
+ env:
+ MINIMUM_DAYS: ${{ inputs.minimum-days }}
+ run: |
+ set -euo pipefail
+ while IFS=$'\t' read -r repository revision occurrence; do
+ if [[ ${#revision} -eq 40 ]]; then
+ case "$revision" in
+ *[!0-9a-f]*) exit 1 ;;
+ esac
+ tags=()
+ while read -r commit ref; do
+ if [[ "$commit" == "$revision" ]]; then
+ tag=${ref#refs/tags/}
+ tags+=("${tag%"^{}"}")
+ fi
+ done < <(git ls-remote --tags "$repository")
+ options=(--freeze)
+ else
+ tags=("$revision")
+ options=()
+ fi
+ accepted=false
+ for tag in "${tags[@]}"; do
+ cp "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/candidate-hooks.yaml"
+ uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update -C "$RUNNER_TEMP/hook-cooldown" \
+ --config "$RUNNER_TEMP/candidate-hooks.yaml" --repo "$repository" \
+ --repo-include-tag "$repository=$tag" --cooldown-days "$MINIMUM_DAYS" \
+ "${options[@]}" --no-progress
+ selected_revisions=$(uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -r \
+ --arg repository "$repository" \
+ ".repos[] | select(.repo == \$repository) | .rev" "$RUNNER_TEMP/candidate-hooks.yaml")
+ mapfile -t selected <<<"$selected_revisions"
+ if [[ "${selected[$occurrence]:-}" == "$revision" ]]; then
+ accepted=true
+ break
+ fi
+ done
+ if [[ "$accepted" != true ]]; then
+ echo "::error::hook revision is not eligible: $repository $revision" >&2
+ exit 1
+ fi
+ done <"$RUNNER_TEMP/older-hook-revisions"
diff --git a/commit-and-tag/action.yml b/commit-and-tag/action.yml
index a211bd8..2c005ca 100644
--- a/commit-and-tag/action.yml
+++ b/commit-and-tag/action.yml
@@ -13,9 +13,6 @@ inputs:
message:
description: Release commit message
required: true
- branch:
- description: Protected release branch
- default: main
annotated:
description: Create an annotated tag
default: "false"
@@ -41,24 +38,41 @@ runs:
echo "::error::invalid release tag: $VERSION" >&2
exit 1
fi
- - name: Commit, tag, and push
+ - name: Set App commit identity
shell: bash
env:
- VERSION: ${{ inputs.version }}
GH_TOKEN: ${{ inputs.token }}
APP_SLUG: ${{ inputs.app-slug }}
- MESSAGE: ${{ inputs.message }}
- BRANCH: ${{ inputs.branch }}
- ANNOTATED: ${{ inputs.annotated }}
run: |
set -euo pipefail
+ if [[ -z "$GH_TOKEN" || -z "$APP_SLUG" ]]; then
+ echo '::error::repository App token and slug are required' >&2
+ exit 1
+ fi
app_user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)
git config user.name "${APP_SLUG}[bot]"
git config user.email "${app_user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
- git commit -m "$MESSAGE"
+ - name: Commit release patch
+ shell: bash
+ env:
+ MESSAGE: ${{ inputs.message }}
+ run: git commit -m "$MESSAGE"
+ - name: Create release tag
+ shell: bash
+ env:
+ VERSION: ${{ inputs.version }}
+ MESSAGE: ${{ inputs.message }}
+ ANNOTATED: ${{ inputs.annotated }}
+ run: |
case "$ANNOTATED" in
true) git tag -a -m "$MESSAGE" -- "$VERSION" ;;
false) git tag -- "$VERSION" ;;
esac
+ - name: Push release commit and tag
+ shell: bash
+ env:
+ VERSION: ${{ inputs.version }}
+ GH_TOKEN: ${{ inputs.token }}
+ run: |
gh auth setup-git --hostname github.com --force
- git push --atomic origin "HEAD:$BRANCH" "refs/tags/$VERSION"
+ git push --atomic origin HEAD:refs/heads/main "refs/tags/$VERSION"
diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml
new file mode 100644
index 0000000..9778354
--- /dev/null
+++ b/export-build-constraints/action.yml
@@ -0,0 +1,51 @@
+name: Export build constraints
+description: Write hashed build dependencies selected from the caller's pylock
+inputs:
+ lock:
+ description: pylock.toml merges shared and caller pins; another lock uses only its build pins
+ default: pylock.toml
+ target-platform:
+ description: Nab platform ID such as linux_aarch64; required with targets
+ default: ""
+ targets:
+ description: Space-separated pairs such as 3.15.0:cpython; selected package versions must match
+ default: ""
+outputs:
+ path:
+ description: Path to the generated constraints for this lock selection
+ value: ${{ steps.constraints.outputs.path }}
+runs:
+ using: composite
+ steps:
+ - uses: $/install-nab
+ - id: constraints
+ name: Export locked build requirements
+ shell: bash
+ env:
+ BUILD_LOCK: ${{ inputs.lock }}
+ TARGET_PLATFORM: ${{ inputs.target-platform }}
+ TARGETS: ${{ inputs.targets }}
+ run: |
+ set -euo pipefail
+ args=()
+ if [[ "$BUILD_LOCK" == pylock.toml ]]; then
+ args+=(--shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml")
+ locks=("$GITHUB_ACTION_PATH/../pylock.toml" pylock.toml)
+ else
+ locks=("$BUILD_LOCK")
+ fi
+ if [[ -n "$TARGET_PLATFORM" || -n "$TARGETS" ]]; then
+ args+=(--target-platform "$TARGET_PLATFORM" --targets "$TARGETS")
+ fi
+ digest=$(
+ sha256sum "${locks[@]}"
+ printf '%s\n' "$TARGET_PLATFORM" "$TARGETS" "${args[@]}"
+ )
+ digest=$(sha256sum <<<"$digest")
+ output="$RUNNER_TEMP/build-constraints-${digest%% *}.txt"
+ if [[ ! -f "$output" ]]; then
+ uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \
+ "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \
+ "$output" "${locks[@]}"
+ fi
+ echo "path=$output" >>"$GITHUB_OUTPUT"
diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml
new file mode 100644
index 0000000..63def2f
--- /dev/null
+++ b/install-locked-tools/action.yml
@@ -0,0 +1,80 @@
+name: Install locked release tools
+description: Install selected pylock groups with hashed build dependencies
+inputs:
+ groups:
+ description: Space-separated dependency groups to install
+ required: true
+ venv:
+ description: Virtual environment directory
+ required: true
+ lock-source:
+ description: Install groups from the caller or the shared Action lock
+ default: caller
+ local-tool-source:
+ description: The caller builds release-creator from its own checkout after installing locked third-party tools
+ default: "false"
+runs:
+ using: composite
+ steps:
+ - uses: $/install-nab
+ - id: selection
+ name: Select locked groups and build constraints
+ shell: bash
+ env:
+ DEPENDENCY_GROUPS: ${{ inputs.groups }}
+ VENV: ${{ inputs.venv }}
+ LOCK_SOURCE: ${{ inputs.lock-source }}
+ LOCAL_TOOL_SOURCE: ${{ inputs.local-tool-source }}
+ run: |
+ set -euo pipefail
+ uv venv --allow-existing --python 3.15 "$VENV"
+ case "$LOCK_SOURCE" in
+ caller)
+ lock=pylock.toml
+ lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml")
+ ;;
+ shared)
+ lock="$GITHUB_ACTION_PATH/../pylock.toml"
+ lock_check=()
+ ;;
+ *)
+ echo "::error::unknown lock source: $LOCK_SOURCE" >&2
+ exit 1
+ ;;
+ esac
+ groups=()
+ while IFS= read -r line; do
+ read -ra selected <<<"$line"
+ groups+=("${selected[@]}")
+ done <<<"$DEPENDENCY_GROUPS"
+ if ((${#groups[@]} == 0)); then
+ echo '::error::at least one dependency group is required' >&2
+ exit 1
+ fi
+ source_check=()
+ if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi
+ uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \
+ "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" \
+ "${lock_check[@]}" "${source_check[@]}"
+ printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups"
+ printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT"
+ - id: constraints
+ uses: $/export-build-constraints
+ with:
+ lock: ${{ steps.selection.outputs.lock }}
+ - name: Install selected groups
+ shell: bash
+ env:
+ VENV: ${{ inputs.venv }}
+ LOCK: ${{ steps.selection.outputs.lock }}
+ BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }}
+ run: |
+ set -euo pipefail
+ unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE
+ groups=()
+ while IFS= read -r group; do
+ groups+=(--group "$group")
+ done <"$RUNNER_TEMP/selected-lock-groups"
+ uv pip sync --preview-features pylock --preview-features build-dependency-hashes --python "$VENV" \
+ "${groups[@]}" --build-constraints "$BUILD_CONSTRAINTS" \
+ --require-build-hashes --require-hashes "$LOCK"
diff --git a/install-nab/action.yml b/install-nab/action.yml
new file mode 100644
index 0000000..e2d0788
--- /dev/null
+++ b/install-nab/action.yml
@@ -0,0 +1,24 @@
+name: Install locked Nab
+description: Install the lock reader from the shared pylock
+runs:
+ using: composite
+ steps:
+ - name: Create lock-tool environment
+ shell: bash
+ run: |
+ if [[ ! -x "$RUNNER_TEMP/lock-tools/bin/python" ]]; then
+ uv venv --python 3.15 "$RUNNER_TEMP/lock-tools"
+ fi
+ - name: Install lock reader
+ shell: bash
+ run: |
+ set -euo pipefail
+ unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE
+ digest=$(sha256sum "$GITHUB_ACTION_PATH/../pylock.toml")
+ if [[ -f "$RUNNER_TEMP/lock-tools/.lock-digest" &&
+ $(cat "$RUNNER_TEMP/lock-tools/.lock-digest") == "${digest%% *}" ]]; then
+ exit 0
+ fi
+ uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \
+ --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml"
+ printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-tools/.lock-digest"
diff --git a/label-bot-news/action.yml b/label-bot-news/action.yml
new file mode 100644
index 0000000..d07adf2
--- /dev/null
+++ b/label-bot-news/action.yml
@@ -0,0 +1,118 @@
+name: Label bot pull request news
+description: Keep skip news on bot PRs without a changelog fragment
+inputs:
+ pull-request:
+ description: Verified pull request number from a workflow-run resolver, or current PR event when empty
+ default: ""
+ bot-author:
+ description: Whether the verified pull request author is a bot, or current PR author when empty
+ default: ""
+ fragments:
+ description: Comma-separated directories containing changelog fragments
+ default: changelog.d
+ extension:
+ description: Fragment extension without the dot, either md or rst
+ default: md
+ fragment-style:
+ description: Filename convention, towncrier or changeset
+ default: towncrier
+runs:
+ using: composite
+ steps:
+ - name: Validate bot fragment format
+ if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'
+ shell: bash
+ env:
+ EXTENSION: ${{ inputs.extension }}
+ FRAGMENT_STYLE: ${{ inputs.fragment-style }}
+ run: |
+ if [[ "$EXTENSION" != md && "$EXTENSION" != rst ]]; then
+ echo '::error::fragment extension must be md or rst' >&2
+ exit 1
+ fi
+ if [[ "$FRAGMENT_STYLE" != towncrier && "$FRAGMENT_STYLE" != changeset ]]; then
+ echo '::error::fragment style must be towncrier or changeset' >&2
+ exit 1
+ fi
+ if [[ "$FRAGMENT_STYLE" == changeset && "$EXTENSION" != md ]]; then
+ echo '::error::changeset fragments must use md' >&2
+ exit 1
+ fi
+ - name: Read bot pull request files
+ if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPOSITORY: ${{ github.repository }}
+ PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }}
+ run: |
+ gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" \
+ --jq '.[] | select(.status != "removed") | .filename' >"$RUNNER_TEMP/bot-pr-files"
+ - name: Find bot news fragments
+ if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'
+ shell: bash
+ env:
+ FRAGMENTS: ${{ inputs.fragments }}
+ EXTENSION: ${{ inputs.extension }}
+ FRAGMENT_STYLE: ${{ inputs.fragment-style }}
+ LC_ALL: C
+ run: |
+ has_fragment=false
+ mapfile -t files <"$RUNNER_TEMP/bot-pr-files"
+ read -ra directories <<<"${FRAGMENTS//,/ }"
+ for path in "${files[@]}"; do
+ for directory in "${directories[@]}"; do
+ if [[ "$FRAGMENT_STYLE" == changeset ]]; then
+ case "$path" in
+ "$directory"/*.md)
+ fragment=${path#"$directory"/}
+ if [[ "$fragment" != */* ]]; then has_fragment=true; fi
+ ;;
+ esac
+ else
+ case "$path" in
+ "$directory"/*.*."$EXTENSION")
+ fragment=${path#"$directory"/}
+ if [[ "$fragment" == */* ]]; then continue; fi
+ stem=${fragment%."$EXTENSION"}
+ counter=${stem##*.}
+ if [[ "$stem" == *.*.* && -n "$counter" && "$counter" != *[!0-9]* ]]; then
+ stem=${stem%.*}
+ fi
+ if [[ "$stem" != *.* ]]; then continue; fi
+ identifier=${stem%.*}
+ if [[ "$identifier" == +?* ]] || [[ -n "$identifier" && "$identifier" != *[!0-9]* ]]; then
+ has_fragment=true
+ fi
+ ;;
+ esac
+ fi
+ done
+ done
+ echo "$has_fragment" >"$RUNNER_TEMP/bot-has-fragment"
+ - name: Read bot pull request labels
+ if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPOSITORY: ${{ github.repository }}
+ PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }}
+ run: gh api --paginate "repos/$REPOSITORY/issues/$PR_NUMBER/labels" --jq '.[].name' >"$RUNNER_TEMP/bot-pr-labels"
+ - name: Set skip news for a bot PR
+ if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPOSITORY: ${{ github.repository }}
+ PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }}
+ run: |
+ read -r has_fragment <"$RUNNER_TEMP/bot-has-fragment"
+ has_label=false
+ if grep -Fxq 'skip news' "$RUNNER_TEMP/bot-pr-labels"; then
+ has_label=true
+ fi
+ if [[ "$has_fragment" == false && "$has_label" == false ]]; then
+ gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --add-label 'skip news' >/dev/null
+ elif [[ "$has_fragment" == true && "$has_label" == true ]]; then
+ gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --remove-label 'skip news' >/dev/null
+ fi
diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml
index a508bfb..b389f67 100644
--- a/prepare-towncrier/action.yml
+++ b/prepare-towncrier/action.yml
@@ -1,27 +1,21 @@
name: Prepare Towncrier release
-description: Resolve a version, build the changelog, run prek, and write a release patch
+description: Resolve a version, build and format the changelog, and write a release patch
inputs:
bump:
description: Version bump passed to release-creator tags
default: auto
+ tag-prefix:
+ description: Prefix for calculated release tags, either empty or v
+ default: ""
+ changelog-prefix:
+ description: Prefix for the Towncrier changelog version, either empty or v
+ default: ""
version:
description: Explicit version for repositories with custom version selection
default: ""
- changelog:
- description: Changelog path
- required: true
- fragments:
- description: Towncrier fragment directory, relative to the repository root
- default: changelog.d
- format:
- description: Changelog format, either markdown or none
- default: none
- link-fragments:
- description: Resolve named fragments and authors with release-creator
- default: "true"
- patch:
- description: Path for the generated Git patch
- required: true
+ phase:
+ description: Run the full preparation, changelog build, or finalization
+ default: all
outputs:
version:
description: Resolved release version
@@ -29,76 +23,126 @@ outputs:
runs:
using: composite
steps:
+ - name: Require a valid preparation phase
+ shell: bash
+ env:
+ PHASE: ${{ inputs.phase }}
+ run: |
+ case "$PHASE" in
+ all | build | finalize) ;;
+ *)
+ echo '::error::phase must be all, build, or finalize' >&2
+ exit 1
+ ;;
+ esac
+ - name: Require a clean checkout
+ if: inputs.phase != 'finalize'
+ shell: bash
+ run: |
+ if [[ -n $(git status --porcelain) ]]; then
+ echo '::error::release preparation requires a clean checkout' >&2
+ exit 1
+ fi
+ - id: config
+ uses: $/read-towncrier-config
- id: version
name: Select release version
+ if: inputs.phase != 'finalize'
shell: bash
env:
BUMP: ${{ inputs.bump }}
+ TAG_PREFIX: ${{ inputs.tag-prefix }}
VERSION: ${{ inputs.version }}
- FRAGMENTS: ${{ inputs.fragments }}
+ FRAGMENTS: ${{ steps.config.outputs.fragments }}
run: |
set -euo pipefail
- if [[ -n $(git status --porcelain) ]]; then
- echo '::error::release preparation requires a clean checkout' >&2
- exit 1
- fi
if [[ -z "$VERSION" ]]; then
- VERSION=$(git tag --list | release-creator tags --next "$BUMP" --directory "$FRAGMENTS")
+ args=(--next "$BUMP" --directory "$FRAGMENTS")
+ if [[ -n "$TAG_PREFIX" ]]; then
+ args+=(--prefix "$TAG_PREFIX")
+ fi
+ VERSION=$(git tag --list | release-creator tags "${args[@]}")
+ else
+ git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null
fi
git tag --list | release-creator tags "$VERSION" --require-newer
printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT"
- name: Link fragments and authors
+ if: inputs.phase != 'finalize'
shell: bash
env:
- LINK_FRAGMENTS: ${{ inputs.link-fragments }}
- FRAGMENTS: ${{ inputs.fragments }}
+ FRAGMENTS: ${{ steps.config.outputs.fragments }}
+ REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
- if [[ "$LINK_FRAGMENTS" != true && "$LINK_FRAGMENTS" != false ]]; then
- echo '::error::link-fragments must be true or false' >&2
- exit 1
- fi
- if [[ "$LINK_FRAGMENTS" == true ]]; then
- release-creator fragments --directory "$FRAGMENTS" --repository "$GITHUB_REPOSITORY" --git --resolve-authors
- fi
- - name: Build and format changelog
+ release-creator fragments --directory "$FRAGMENTS" --repository "$REPOSITORY"
+ - name: Build changelog
+ if: inputs.phase != 'finalize'
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
- CHANGELOG: ${{ inputs.changelog }}
- FORMAT: ${{ inputs.format }}
+ CHANGELOG_PREFIX: ${{ inputs.changelog-prefix }}
run: |
set -euo pipefail
- towncrier build --yes --version "$VERSION"
- if [[ "$FORMAT" != markdown && "$FORMAT" != none ]]; then
- echo '::error::format must be markdown or none' >&2
+ case "$CHANGELOG_PREFIX" in
+ '' | v) ;;
+ *)
+ echo '::error::changelog-prefix must be empty or v' >&2
exit 1
- fi
- if [[ "$FORMAT" == markdown ]]; then
- if ! prek run mdformat --files "$CHANGELOG"; then
- prek run mdformat --files "$CHANGELOG"
- fi
- fi
- notes=$(release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$GITHUB_REPOSITORY")
- if [[ "$notes" != *'### '* ]]; then
- echo '::error::release preparation has no release notes' >&2
+ ;;
+ esac
+ towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}"
+ - name: Format changelog
+ if: inputs.phase != 'build'
+ shell: bash
+ env:
+ CHANGELOG: ${{ steps.config.outputs.changelog }}
+ FORMAT: ${{ steps.config.outputs.format }}
+ run: |
+ set -euo pipefail
+ case "$FORMAT" in
+ markdown) formatter=mdformat ;;
+ rst) formatter=docstrfmt ;;
+ *)
+ echo '::error::unsupported changelog format' >&2
exit 1
+ ;;
+ esac
+ if ! prek run "$formatter" --files "$CHANGELOG"; then
+ prek run "$formatter" --files "$CHANGELOG"
fi
- - name: Run checks and write patch
+ - name: Check rendered release notes
+ if: inputs.phase != 'build'
shell: bash
env:
- PATCH: ${{ inputs.patch }}
+ VERSION: ${{ inputs.version || steps.version.outputs.version }}
+ CHANGELOG: ${{ steps.config.outputs.changelog }}
+ REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
- prek run --all-files
+ release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$REPOSITORY" >/dev/null
+ - name: Check generated files
+ if: inputs.phase != 'build'
+ shell: bash
+ env:
+ CHANGELOG: ${{ steps.config.outputs.changelog }}
+ run: |
+ if [[ -f "$CHANGELOG" ]] && ! git ls-files --error-unmatch "$CHANGELOG" >/dev/null 2>&1; then
+ git add -N -- "$CHANGELOG"
+ fi
if [[ -n $(git ls-files --others --exclude-standard) ]]; then
echo '::error::release preparation created untracked files' >&2
exit 1
fi
+ - name: Write release patch
+ if: inputs.phase != 'build'
+ shell: bash
+ run: |
+ set -euo pipefail
git diff --check
- git diff HEAD --binary >"$PATCH"
- if [[ ! -s "$PATCH" ]]; then
+ git diff HEAD --binary >"$RUNNER_TEMP/release.patch"
+ if [[ ! -s "$RUNNER_TEMP/release.patch" ]]; then
echo '::error::release preparation produced no patch' >&2
exit 1
fi
diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml
new file mode 100644
index 0000000..352cf94
--- /dev/null
+++ b/publish-github-release/action.yml
@@ -0,0 +1,298 @@
+name: Publish GitHub Release
+description: Resume a matching draft or verify an already published release
+inputs:
+ tag:
+ description: Existing Git tag for the release
+ required: true
+ commit:
+ description: Commit expected at the tag
+ required: true
+ title:
+ description: Exact GitHub Release title
+ required: true
+ notes-file:
+ description: File containing the exact GitHub Release body
+ required: true
+ assets-directory:
+ description: Directory containing only the intended Release assets
+ default: ""
+ prerelease:
+ description: Whether the release is a prerelease
+ default: "false"
+ mode:
+ description: Stage a draft, publish an existing draft, or complete both phases
+ default: complete
+runs:
+ using: composite
+ steps:
+ - name: Verify the tagged release request
+ shell: bash
+ env:
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ COMMIT: ${{ inputs.commit }}
+ TITLE: ${{ inputs.title }}
+ NOTES_FILE: ${{ inputs.notes-file }}
+ ASSETS_DIRECTORY: ${{ inputs.assets-directory }}
+ PRERELEASE: ${{ inputs.prerelease }}
+ MODE: ${{ inputs.mode }}
+ run: |
+ set -euo pipefail
+ [[ -n "$GH_REPO" && -n "$TAG" && -n "$TITLE" && -s "$NOTES_FILE" ]] || exit 1
+ test "$PRERELEASE" = true || test "$PRERELEASE" = false
+ case "$MODE" in
+ stage | publish | complete) ;;
+ *)
+ echo "::error::invalid release mode: $MODE"
+ exit 1
+ ;;
+ esac
+ tag_commit=$(gh api "repos/$GH_REPO/commits/refs/tags/$TAG" --jq .sha)
+ test "$tag_commit" == "$COMMIT"
+ printf '[]\n' >"$RUNNER_TEMP/release-assets.json"
+ if [[ -n "$ASSETS_DIRECTORY" ]]; then
+ shopt -s nullglob dotglob
+ test -d "$ASSETS_DIRECTORY"
+ test -z "$(find "$ASSETS_DIRECTORY" -mindepth 1 -maxdepth 1 ! -type f -print -quit)"
+ : >"$RUNNER_TEMP/release-assets.tsv"
+ for asset in "$ASSETS_DIRECTORY"/*; do
+ test -f "$asset"
+ name=${asset##*/}
+ case "$name" in
+ .* | *#* | *[[:cntrl:]]*)
+ echo "::error::unsafe release asset name: $name"
+ exit 1
+ ;;
+ esac
+ printf '%s\tsha256:%s\n' "$name" "$(sha256sum "$asset" | cut -d' ' -f1)" \
+ >>"$RUNNER_TEMP/release-assets.tsv"
+ done
+ jq -R -s '[split("\n")[] | select(length > 0) | split("\t") | {name: .[0], digest: .[1]}]' \
+ "$RUNNER_TEMP/release-assets.tsv" >"$RUNNER_TEMP/release-assets.json"
+ fi
+ - name: Inspect any existing release
+ shell: bash
+ env:
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ TITLE: ${{ inputs.title }}
+ NOTES_FILE: ${{ inputs.notes-file }}
+ PRERELEASE: ${{ inputs.prerelease }}
+ MODE: ${{ inputs.mode }}
+ run: |
+ set -euo pipefail
+ gh api --paginate "repos/$GH_REPO/releases?per_page=100" |
+ jq -s --arg tag "$TAG" '[.[][] | select(.tag_name == $tag)]' >"$RUNNER_TEMP/release-existing.json"
+ if jq -e 'length == 1' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
+ jq -er '.[0].id' "$RUNNER_TEMP/release-existing.json" >"$RUNNER_TEMP/release-id"
+ fi
+ jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
+ --slurpfile assets "$RUNNER_TEMP/release-assets.json" '
+ length <= 1 and all(.[];
+ . as $release |
+ .name == $title and .body == $body and .prerelease == $prerelease and
+ ((.assets | map(.name) | unique | length) == (.assets | length)) and
+ all(.assets[];
+ .name as $name | .digest as $digest |
+ any($assets[0][]; .name == $name) and
+ (.state != "uploaded" and $release.draft or
+ any($assets[0][]; .name == $name and .digest == $digest))) and
+ (.draft or (.assets | length) == ($assets[0] | length))
+ )
+ ' "$RUNNER_TEMP/release-existing.json"
+ if [[ "$MODE" == publish ]]; then
+ jq -e 'length == 1' "$RUNNER_TEMP/release-existing.json" >/dev/null
+ fi
+ - name: Create a missing draft
+ shell: bash
+ env:
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ COMMIT: ${{ inputs.commit }}
+ TITLE: ${{ inputs.title }}
+ NOTES_FILE: ${{ inputs.notes-file }}
+ PRERELEASE: ${{ inputs.prerelease }}
+ MODE: ${{ inputs.mode }}
+ run: |
+ set -euo pipefail
+ if [[ "$MODE" != publish ]] && jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
+ jq -n --arg tag "$TAG" --arg commit "$COMMIT" --arg title "$TITLE" \
+ --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
+ '{tag_name: $tag, target_commitish: $commit, name: $title, body: $body,
+ draft: true, prerelease: $prerelease}' >"$RUNNER_TEMP/release-request.json"
+ gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \
+ >"$RUNNER_TEMP/release-created.json"
+ jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id"
+ fi
+ - name: Upload missing draft assets
+ shell: bash
+ env:
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ TITLE: ${{ inputs.title }}
+ NOTES_FILE: ${{ inputs.notes-file }}
+ ASSETS_DIRECTORY: ${{ inputs.assets-directory }}
+ PRERELEASE: ${{ inputs.prerelease }}
+ MODE: ${{ inputs.mode }}
+ run: |
+ set -euo pipefail
+ if [[ "$MODE" != publish && -n "$ASSETS_DIRECTORY" ]]; then
+ shopt -s nullglob
+ release_id=$(<"$RUNNER_TEMP/release-id")
+ if jq -e 'length == 1 and .[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
+ while IFS= read -r asset_id; do
+ gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" >/dev/null
+ done < <(jq -r '.[0].assets[] | select(.state != "uploaded") | .id' "$RUNNER_TEMP/release-existing.json")
+ fi
+ for attempt in {1..6}; do
+ if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-current.json"; then
+ if ((attempt == 6)); then
+ echo '::error::draft release could not be read' >&2
+ exit 1
+ fi
+ sleep 2
+ continue
+ fi
+ if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
+ --slurpfile assets "$RUNNER_TEMP/release-assets.json" '
+ length == 1 and .[0].name == $title and .[0].body == $body and
+ .[0].prerelease == $prerelease and
+ all(.[0].assets[]; .state == "uploaded" and
+ (.name as $name | .digest as $digest |
+ any($assets[0][]; .name == $name and .digest == $digest)))
+ ' "$RUNNER_TEMP/release-current.json" >/dev/null; then
+ break
+ fi
+ if ((attempt == 6)); then
+ echo '::error::draft release did not become visible' >&2
+ exit 1
+ fi
+ sleep 2
+ done
+ if jq -e '.[0].draft' "$RUNNER_TEMP/release-current.json" >/dev/null; then
+ for asset in "$ASSETS_DIRECTORY"/*; do
+ name=${asset##*/}
+ if ! jq -e --arg name "$name" 'any(.[0].assets[]; .name == $name)' \
+ "$RUNNER_TEMP/release-current.json" >/dev/null; then
+ for attempt in {1..6}; do
+ if gh release upload "$TAG" "$asset" --repo "$GH_REPO"; then
+ break
+ fi
+ if gh api "repos/$GH_REPO/releases/$release_id" | jq -e --arg name "$name" \
+ --slurpfile assets "$RUNNER_TEMP/release-assets.json" '
+ any(.assets[]; .name == $name and .state == "uploaded" and
+ (.digest as $digest | any($assets[0][]; .name == $name and .digest == $digest)))
+ ' >/dev/null; then
+ break
+ fi
+ if ((attempt == 6)); then
+ echo "::error::could not upload release asset: $name" >&2
+ exit 1
+ fi
+ sleep 2
+ done
+ fi
+ done
+ fi
+ fi
+ - name: Verify the complete asset set
+ shell: bash
+ env:
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ TITLE: ${{ inputs.title }}
+ NOTES_FILE: ${{ inputs.notes-file }}
+ PRERELEASE: ${{ inputs.prerelease }}
+ run: |
+ set -euo pipefail
+ release_id=$(<"$RUNNER_TEMP/release-id")
+ for attempt in {1..6}; do
+ if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-complete.json"; then
+ if ((attempt == 6)); then
+ echo '::error::release could not be read after upload' >&2
+ exit 1
+ fi
+ sleep 2
+ continue
+ fi
+ if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
+ --slurpfile assets "$RUNNER_TEMP/release-assets.json" '
+ length == 1 and .[0].name == $title and .[0].body == $body and
+ .[0].prerelease == $prerelease and
+ ([.[0].assets[] | select(.state == "uploaded") | {name, digest}] | sort_by(.name)) ==
+ ($assets[0] | sort_by(.name))
+ ' "$RUNNER_TEMP/release-complete.json" >/dev/null; then
+ exit 0
+ fi
+ if ((attempt < 6)); then sleep 2; fi
+ done
+ echo '::error::release metadata or assets did not match after publication' >&2
+ exit 1
+ - name: Publish the draft
+ shell: bash
+ env:
+ GH_REPO: ${{ github.repository }}
+ MODE: ${{ inputs.mode }}
+ run: |
+ set -euo pipefail
+ if [[ "$MODE" != stage ]] && jq -e '.[0].draft' "$RUNNER_TEMP/release-complete.json" >/dev/null; then
+ release_id=$(<"$RUNNER_TEMP/release-id")
+ gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false >/dev/null
+ fi
+ - name: Verify the final release state
+ shell: bash
+ env:
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ TITLE: ${{ inputs.title }}
+ NOTES_FILE: ${{ inputs.notes-file }}
+ PRERELEASE: ${{ inputs.prerelease }}
+ MODE: ${{ inputs.mode }}
+ run: |
+ set -euo pipefail
+ release_id=$(<"$RUNNER_TEMP/release-id")
+ for attempt in {1..6}; do
+ if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-published.json"; then
+ if ((attempt == 6)); then
+ echo '::error::published release could not be read' >&2
+ exit 1
+ fi
+ sleep 2
+ continue
+ fi
+ if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
+ --arg mode "$MODE" \
+ --slurpfile assets "$RUNNER_TEMP/release-assets.json" '
+ length == 1 and ($mode == "stage" or .[0].draft == false) and .[0].name == $title and
+ .[0].body == $body and .[0].prerelease == $prerelease and
+ ([.[0].assets[] | select(.state == "uploaded") | {name, digest}] | sort_by(.name)) ==
+ ($assets[0] | sort_by(.name))
+ ' "$RUNNER_TEMP/release-published.json" >/dev/null; then
+ exit 0
+ fi
+ if ((attempt < 6)); then sleep 2; fi
+ done
+ echo '::error::final release state did not match after publication' >&2
+ exit 1
+ - name: Verify published asset attestations
+ if: inputs.mode != 'stage' && inputs.assets-directory != ''
+ shell: bash
+ env:
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ ASSETS_DIRECTORY: ${{ inputs.assets-directory }}
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ for asset in "$ASSETS_DIRECTORY"/*; do
+ for attempt in {1..6}; do
+ if gh release verify-asset "$TAG" "$asset" --repo "$GH_REPO"; then
+ break
+ fi
+ if ((attempt == 6)); then
+ echo "::error::release asset attestation could not be verified: ${asset##*/}" >&2
+ exit 1
+ fi
+ sleep 2
+ done
+ done
diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml
new file mode 100644
index 0000000..d836708
--- /dev/null
+++ b/publish-jetbrains-marketplace/action.yml
@@ -0,0 +1,56 @@
+name: Publish JetBrains Marketplace plugin
+description: Upload one verified signed ZIP without checking out or building source
+inputs:
+ archive:
+ description: Signed plugin ZIP from the validated build artifact
+ required: true
+ sha256:
+ description: SHA256 recorded by the signing job before artifact upload
+ required: true
+ plugin-id:
+ description: Numeric JetBrains Marketplace plugin ID
+ required: true
+ channel:
+ description: Marketplace channel; empty selects Stable
+ default: ""
+runs:
+ using: composite
+ steps:
+ - name: Verify signed ZIP bytes
+ shell: bash
+ env:
+ ARCHIVE: ${{ inputs.archive }}
+ EXPECTED_SHA: ${{ inputs.sha256 }}
+ PLUGIN_ID: ${{ inputs.plugin-id }}
+ CHANNEL: ${{ inputs.channel }}
+ run: |
+ set -euo pipefail
+ [[ -f "$ARCHIVE" && ! -L "$ARCHIVE" ]] || exit 1
+ case "$ARCHIVE" in *.zip) ;; *) exit 1 ;; esac
+ case "$PLUGIN_ID" in '' | *[!0-9]*) exit 1 ;; esac
+ case "$CHANNEL" in *[!a-zA-Z0-9._-]*) exit 1 ;; esac
+ test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA"
+ unzip -tq "$ARCHIVE" >/dev/null
+ test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)"
+ - name: Upload signed ZIP
+ shell: bash
+ env:
+ ARCHIVE: ${{ inputs.archive }}
+ EXPECTED_SHA: ${{ inputs.sha256 }}
+ PLUGIN_ID: ${{ inputs.plugin-id }}
+ CHANNEL: ${{ inputs.channel }}
+ run: |
+ set -euo pipefail
+ test -n "$PUBLISH_TOKEN"
+ upload_file=$(mktemp "$RUNNER_TEMP/marketplace-upload.XXXXXX.zip")
+ cp -- "$ARCHIVE" "$upload_file"
+ test "$(sha256sum "$upload_file" | cut -d' ' -f1)" = "$EXPECTED_SHA"
+ cd "$RUNNER_TEMP"
+ if ! curl --fail-with-body --max-time 120 --silent --show-error \
+ --output "$RUNNER_TEMP/marketplace-response.json" \
+ --config <(printf 'header = "Authorization: Bearer %s"\n' "$PUBLISH_TOKEN") \
+ --form "pluginId=$PLUGIN_ID" --form "file=@${upload_file##*/};filename=plugin.zip" --form "channel=$CHANNEL" \
+ https://plugins.jetbrains.com/api/updates/upload; then
+ cat "$RUNNER_TEMP/marketplace-response.json" >&2
+ exit 1
+ fi
diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml
new file mode 100644
index 0000000..8c76361
--- /dev/null
+++ b/publish-post-release-pr/action.yml
@@ -0,0 +1,292 @@
+name: Publish a prepared pull request
+description: Reconcile a prepared branch and pull request without checking out project code
+inputs:
+ app-slug:
+ description: Slug of the GitHub App whose bot authors the prepared commit
+ required: true
+ base-commit:
+ description: Base commit used by the keyless preparation job
+ required: true
+ branch:
+ description: Stable branch name for the pull request
+ required: true
+ title:
+ description: Exact pull request title
+ required: true
+ body-file:
+ description: File containing the exact pull request body
+ required: true
+ paths-file:
+ description: Tab-separated changed path and desired Git blob SHA on each line
+ required: true
+ allowed-paths:
+ description: Newline-separated paths the writer permits the prepared commit to change
+ required: true
+ bundle:
+ description: Bundle containing refs/heads/release-candidate, empty when the change is already merged
+ default: ""
+ candidate-commit:
+ description: Candidate commit SHA in the bundle, empty when the change is already merged
+ default: ""
+ commit-message:
+ description: Exact subject of the prepared candidate commit
+ required: true
+runs:
+ using: composite
+ steps:
+ - name: Validate the prepared pull request inputs
+ shell: bash
+ env:
+ APP_SLUG: ${{ inputs.app-slug }}
+ BASE: main
+ BASE_COMMIT: ${{ inputs.base-commit }}
+ BRANCH: ${{ inputs.branch }}
+ TITLE: ${{ inputs.title }}
+ BODY_FILE: ${{ inputs.body-file }}
+ PATHS_FILE: ${{ inputs.paths-file }}
+ BUNDLE: ${{ inputs.bundle }}
+ CANDIDATE_COMMIT: ${{ inputs.candidate-commit }}
+ COMMIT_MESSAGE: ${{ inputs.commit-message }}
+ run: |
+ set -euo pipefail
+ case "$APP_SLUG" in
+ '' | *[!A-Za-z0-9-]*) exit 1 ;;
+ esac
+ bot_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)
+ case "$bot_id" in
+ '' | 0* | *[!0-9]*) exit 1 ;;
+ esac
+ printf '%s\n' "$bot_id" >"$RUNNER_TEMP/post-release-bot-id"
+ git check-ref-format --branch "$BASE"
+ git check-ref-format --branch "$BRANCH"
+ test "$BASE" != "$BRANCH"
+ test "${#BASE_COMMIT}" -eq 40
+ case "$BASE_COMMIT" in
+ *[!0-9a-f]*) exit 1 ;;
+ esac
+ test -n "$TITLE"
+ test -n "$COMMIT_MESSAGE"
+ [[ "$COMMIT_MESSAGE" != *$'\n'* ]]
+ test -s "$BODY_FILE"
+ test -s "$PATHS_FILE"
+ if [[ -n "$BUNDLE" ]]; then
+ test -f "$BUNDLE"
+ test "${#CANDIDATE_COMMIT}" -eq 40
+ case "$CANDIDATE_COMMIT" in
+ *[!0-9a-f]*) exit 1 ;;
+ esac
+ else
+ test -z "$CANDIDATE_COMMIT"
+ fi
+ - name: Check the allowed path manifest
+ shell: bash
+ env:
+ PATHS_FILE: ${{ inputs.paths-file }}
+ ALLOWED_PATHS: ${{ inputs.allowed-paths }}
+ run: |
+ set -euo pipefail
+ trusted_paths=()
+ while IFS= read -r path || [[ -n "$path" ]]; do
+ case "$path" in
+ '' | /* | *[!A-Za-z0-9._/-]* | . | .. | ./* | ../* | */. | */.. | */./* | */../*) exit 1 ;;
+ esac
+ trusted_paths+=("$path")
+ done < <(printf '%s' "$ALLOWED_PATHS")
+ test "${#trusted_paths[@]}" -gt 0
+ printf '%s\n' "${trusted_paths[@]}" | LC_ALL=C sort -u >"$RUNNER_TEMP/allowed-paths"
+ test "$(wc -l <"$RUNNER_TEMP/allowed-paths")" -eq "${#trusted_paths[@]}"
+ paths=()
+ while IFS=$'\t' read -r path blob; do
+ case "$path" in
+ '' | /* | *[!A-Za-z0-9._/-]* | . | .. | ./* | ../* | */. | */.. | */./* | */../*) exit 1 ;;
+ esac
+ grep -Fqx -- "$path" "$RUNNER_TEMP/allowed-paths"
+ test "${#blob}" -eq 40
+ case "$blob" in
+ *[!0-9a-f]*) exit 1 ;;
+ esac
+ paths+=("$path")
+ done <"$PATHS_FILE"
+ test "${#paths[@]}" -gt 0
+ test "$(printf '%s\n' "${paths[@]}" | LC_ALL=C sort -u | wc -l)" -eq "${#paths[@]}"
+ printf '%s\n' "${paths[@]}" | LC_ALL=C sort >"$RUNNER_TEMP/expected-paths"
+ - name: Verify the prepared commit
+ shell: bash
+ env:
+ APP_SLUG: ${{ inputs.app-slug }}
+ BASE_COMMIT: ${{ inputs.base-commit }}
+ BUNDLE: ${{ inputs.bundle }}
+ CANDIDATE_COMMIT: ${{ inputs.candidate-commit }}
+ COMMIT_MESSAGE: ${{ inputs.commit-message }}
+ PATHS_FILE: ${{ inputs.paths-file }}
+ run: |
+ set -euo pipefail
+ if [[ -n "$BUNDLE" ]]; then
+ BUNDLE=$(python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$BUNDLE")
+ git init --bare "$RUNNER_TEMP/post-release.git" >/dev/null
+ git -C "$RUNNER_TEMP/post-release.git" bundle verify "$BUNDLE" >/dev/null
+ git -C "$RUNNER_TEMP/post-release.git" fetch "$BUNDLE" \
+ refs/heads/release-candidate:refs/heads/release-candidate >/dev/null
+ test "$(git -C "$RUNNER_TEMP/post-release.git" rev-parse refs/heads/release-candidate)" = "$CANDIDATE_COMMIT"
+ read -r commit parent extra < <(
+ git -C "$RUNNER_TEMP/post-release.git" rev-list --parents -n 1 "$CANDIDATE_COMMIT"
+ )
+ test "$commit" = "$CANDIDATE_COMMIT"
+ test "$parent" = "$BASE_COMMIT"
+ test -z "${extra:-}"
+ author_name="${APP_SLUG}[bot]"
+ author_email="$(cat "$RUNNER_TEMP/post-release-bot-id")+${author_name}@users.noreply.github.com"
+ test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%an "$CANDIDATE_COMMIT")" = "$author_name"
+ test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%ae "$CANDIDATE_COMMIT")" = "$author_email"
+ test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%cn "$CANDIDATE_COMMIT")" = "$author_name"
+ test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%ce "$CANDIDATE_COMMIT")" = "$author_email"
+ test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%B "$CANDIDATE_COMMIT")" = "$COMMIT_MESSAGE"
+ git -C "$RUNNER_TEMP/post-release.git" rev-parse "$CANDIDATE_COMMIT^{tree}" >"$RUNNER_TEMP/candidate-tree"
+ git -C "$RUNNER_TEMP/post-release.git" diff --name-only --no-renames "$BASE_COMMIT" "$CANDIDATE_COMMIT" |
+ LC_ALL=C sort >"$RUNNER_TEMP/candidate-paths"
+ cmp -s "$RUNNER_TEMP/expected-paths" "$RUNNER_TEMP/candidate-paths"
+ while IFS=$'\t' read -r path blob; do
+ read -r mode object actual _ < <(
+ git -C "$RUNNER_TEMP/post-release.git" ls-tree "$CANDIDATE_COMMIT" -- "$path"
+ )
+ test "$mode" = 100644
+ test "$object" = blob
+ test "$actual" = "$blob"
+ done <"$PATHS_FILE"
+ fi
+ - name: Inspect the existing pull request
+ shell: bash
+ env:
+ BASE: main
+ BRANCH: ${{ inputs.branch }}
+ TITLE: ${{ inputs.title }}
+ BODY_FILE: ${{ inputs.body-file }}
+ run: |
+ set -euo pipefail
+ head_owner=${GITHUB_REPOSITORY%%/*}
+ gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=all -f "head=$head_owner:$BRANCH" -f per_page=100 \
+ >"$RUNNER_TEMP/post-release-all-prs.json"
+ jq -e --arg repository "$GITHUB_REPOSITORY" --arg title "$TITLE" --arg base "$BASE" \
+ --arg branch "$BRANCH" --rawfile body "$BODY_FILE" '
+ [.[] | select(.head.repo.full_name == $repository and .head.ref == $branch)] as $existing |
+ if any($existing[]; .state == "closed" and .merged_at == null) then
+ error("post-release pull request was closed without merging; reopen it to retry")
+ elif any($existing[]; .state == "open" and
+ (.title != $title or .body != $body or .base.ref != $base)) then
+ error("open pull request metadata changed")
+ else [$existing[] | select(
+ .title == $title and .body == $body and .base.ref == $base and
+ (.state == "open" or .merged_at != null)
+ )] | if length <= 1 then . else error("multiple matching pull requests") end end
+ ' "$RUNNER_TEMP/post-release-all-prs.json" >"$RUNNER_TEMP/post-release-prs.json"
+ - name: Verify an already merged pull request
+ shell: bash
+ env:
+ BASE: main
+ PATHS_FILE: ${{ inputs.paths-file }}
+ run: |
+ set -euo pipefail
+ if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then
+ base_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)
+ test "${#base_sha}" -eq 40
+ case "$base_sha" in
+ *[!0-9a-f]*) exit 1 ;;
+ esac
+ pr_number=$(jq -r '.[0].number' "$RUNNER_TEMP/post-release-prs.json")
+ while IFS=$'\t' read -r path blob; do
+ read -r object actual < <(gh api "repos/$GITHUB_REPOSITORY/contents/$path?ref=$BASE" --jq '[.type, .sha] | @tsv')
+ test "$object" = file
+ test "$actual" = "$blob"
+ done <"$PATHS_FILE"
+ gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number/files" --paginate --jq '.[].filename' |
+ LC_ALL=C sort | cmp -s - "$RUNNER_TEMP/expected-paths"
+ printf '%s\n' "$pr_number" >"$RUNNER_TEMP/post-release-number"
+ fi
+ - name: Publish or verify the prepared branch
+ shell: bash
+ env:
+ APP_SLUG: ${{ inputs.app-slug }}
+ BASE: main
+ BASE_COMMIT: ${{ inputs.base-commit }}
+ BRANCH: ${{ inputs.branch }}
+ BUNDLE: ${{ inputs.bundle }}
+ CANDIDATE_COMMIT: ${{ inputs.candidate-commit }}
+ COMMIT_MESSAGE: ${{ inputs.commit-message }}
+ run: |
+ set -euo pipefail
+ if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") != true ]]; then
+ test -n "$BUNDLE"
+ test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT"
+ pr_number=$(jq -r '.[0].number // empty' "$RUNNER_TEMP/post-release-prs.json")
+ if branch_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha \
+ 2>"$RUNNER_TEMP/branch-error"); then
+ gh api "repos/$GITHUB_REPOSITORY/git/commits/$branch_sha" >"$RUNNER_TEMP/post-release-branch.json"
+ read -r parent_count branch_parent branch_tree branch_author branch_email < <(
+ jq -r '[(.parents | length), .parents[0].sha, .tree.sha, .author.name, .author.email] | @tsv' \
+ "$RUNNER_TEMP/post-release-branch.json"
+ )
+ test "$parent_count" = 1
+ test "$branch_parent" = "$BASE_COMMIT"
+ test "$branch_tree" = "$(cat "$RUNNER_TEMP/candidate-tree")"
+ test "$branch_author" = "${APP_SLUG}[bot]"
+ test "$branch_email" = "$(cat "$RUNNER_TEMP/post-release-bot-id")+${APP_SLUG}[bot]@users.noreply.github.com"
+ jq -e --arg name "${APP_SLUG}[bot]" \
+ --arg email "$(cat "$RUNNER_TEMP/post-release-bot-id")+${APP_SLUG}[bot]@users.noreply.github.com" \
+ --arg message "$COMMIT_MESSAGE" \
+ '.committer.name == $name and .committer.email == $email and .message == $message' \
+ "$RUNNER_TEMP/post-release-branch.json" >/dev/null
+ elif grep -Fq '(HTTP 404)' "$RUNNER_TEMP/branch-error"; then
+ test -z "$pr_number"
+ test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT"
+ gh auth setup-git --hostname "${GH_HOST:-github.com}" --force
+ git -C "$RUNNER_TEMP/post-release.git" remote add origin \
+ "https://${GH_HOST:-github.com}/$GITHUB_REPOSITORY.git"
+ git -C "$RUNNER_TEMP/post-release.git" push origin "refs/heads/release-candidate:refs/heads/$BRANCH"
+ branch_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)
+ test "$branch_sha" = "$CANDIDATE_COMMIT"
+ else
+ cat "$RUNNER_TEMP/branch-error" >&2
+ exit 1
+ fi
+ printf '%s\n' "$branch_sha" >"$RUNNER_TEMP/post-release-branch-sha"
+ fi
+ - name: Create or verify the pull request
+ shell: bash
+ env:
+ BASE: main
+ BRANCH: ${{ inputs.branch }}
+ TITLE: ${{ inputs.title }}
+ BODY_FILE: ${{ inputs.body-file }}
+ run: |
+ set -euo pipefail
+ if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") != true ]]; then
+ pr_number=$(jq -r '.[0].number // empty' "$RUNNER_TEMP/post-release-prs.json")
+ branch_sha=$(cat "$RUNNER_TEMP/post-release-branch-sha")
+ test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)" = "$branch_sha"
+ if [[ -n "$pr_number" ]]; then
+ test "$(jq -r '.[0].head.sha' "$RUNNER_TEMP/post-release-prs.json")" = "$branch_sha"
+ else
+ gh api -X POST "repos/$GITHUB_REPOSITORY/pulls" -f "head=$BRANCH" -f "base=$BASE" \
+ -f "title=$TITLE" -F "body=@$BODY_FILE" >"$RUNNER_TEMP/post-release-created.json"
+ pr_number=$(jq -r .number "$RUNNER_TEMP/post-release-created.json")
+ case "$pr_number" in
+ '' | 0* | *[!0-9]*) exit 1 ;;
+ esac
+ test "$(jq -r .head.sha "$RUNNER_TEMP/post-release-created.json")" = "$branch_sha"
+ test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)" = "$branch_sha"
+ fi
+ printf '%s\n' "$pr_number" >"$RUNNER_TEMP/post-release-number"
+ fi
+ - name: Ensure the maintenance label
+ shell: bash
+ run: |
+ set -euo pipefail
+ pr_number=$(cat "$RUNNER_TEMP/post-release-number")
+ labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" --jq '.[].name')
+ if ! grep -Fxq 'skip news' <<<"$labels"; then
+ if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then
+ echo '::error::merged pull request lost its required label' >&2
+ exit 1
+ fi
+ gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" -f 'labels[]=skip news' >/dev/null
+ fi
diff --git a/pylock.toml b/pylock.toml
index a1c2365..198455d 100644
--- a/pylock.toml
+++ b/pylock.toml
@@ -1,151 +1,2359 @@
-# This file was autogenerated by uv via the following command:
-# uv pip compile --group release --group test --python-version 3.15 --format pylock.toml --output-file pylock.toml pyproject.toml
lock-version = "1.0"
-created-by = "uv"
+environments = [
+ "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"x86_64\" and implementation_name == \"cpython\" and os_name == \"posix\" and platform_python_implementation == \"CPython\" and platform_system == \"Linux\"",
+ "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"aarch64\" and implementation_name == \"cpython\" and os_name == \"posix\" and platform_python_implementation == \"CPython\" and platform_system == \"Linux\"",
+ "python_version == \"3.15\" and sys_platform == \"darwin\" and platform_machine == \"arm64\" and implementation_name == \"cpython\" and os_name == \"posix\" and platform_python_implementation == \"CPython\" and platform_system == \"Darwin\"",
+ "python_version == \"3.15\" and sys_platform == \"win32\" and platform_machine == \"AMD64\" and implementation_name == \"cpython\" and os_name == \"nt\" and platform_python_implementation == \"CPython\" and platform_system == \"Windows\"",
+]
requires-python = ">=3.15"
+dependency-groups = [
+ "build",
+ "lock-tools",
+ "project-runtime",
+ "release-attestations",
+ "release-check",
+ "release-hook-check",
+ "release-hooks",
+ "release-news",
+ "release-notes",
+ "release-prepare",
+ "release-shell",
+ "release-validate",
+]
+default-groups = [
+ "project-runtime",
+]
+created-by = "nab"
+
+[[packages]]
+name = "annotated-types"
+version = "0.8.0"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "annotated_types-0.8.0.tar.gz"
+upload-time = 2026-07-23 20:16:13.995706+00:00
+url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz"
+size = 15893
+
+[packages.sdist.hashes]
+sha256 = "13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"
+
+[[packages.wheels]]
+name = "annotated_types-0.8.0-py3-none-any.whl"
+upload-time = 2026-07-23 20:16:12.938651+00:00
+url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl"
+size = 13427
+
+[packages.wheels.hashes]
+sha256 = "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"
[[packages]]
name = "argcomplete"
version = "3.7.2"
-sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", upload-time = 2026-08-06T04:53:21Z, size = 74473, hashes = { sha256 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", upload-time = 2026-08-06T04:53:20Z, size = 43294, hashes = { sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" } }]
+marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "argcomplete-3.7.2.tar.gz"
+upload-time = 2026-08-06 04:53:21.662042+00:00
+url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz"
+size = 74473
+
+[packages.sdist.hashes]
+sha256 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9"
+
+[[packages.wheels]]
+name = "argcomplete-3.7.2-py3-none-any.whl"
+upload-time = 2026-08-06 04:53:20.246664+00:00
+url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl"
+size = 43294
+
+[packages.wheels.hashes]
+sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19"
+
+[[packages]]
+name = "build"
+version = "1.6.1"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "colorama" },
+ { name = "packaging" },
+ { name = "pyproject-hooks" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "build-1.6.1.tar.gz"
+upload-time = 2026-09-10 07:56:00.417084+00:00
+url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz"
+size = 113427
+
+[packages.sdist.hashes]
+sha256 = "51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53"
+
+[[packages.wheels]]
+name = "build-1.6.1-py3-none-any.whl"
+upload-time = 2026-09-10 07:55:59.148398+00:00
+url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl"
+size = 31179
+
+[packages.wheels.hashes]
+sha256 = "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7"
+
+[[packages]]
+name = "certifi"
+version = "2026.7.22"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.7"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "certifi-2026.7.22.tar.gz"
+upload-time = 2026-07-22 03:35:12.644458+00:00
+url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz"
+size = 138112
+
+[packages.sdist.hashes]
+sha256 = "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"
+
+[[packages.wheels]]
+name = "certifi-2026.7.22-py3-none-any.whl"
+upload-time = 2026-07-22 03:35:11.276376+00:00
+url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl"
+size = 136983
+
+[packages.wheels.hashes]
+sha256 = "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"
+
+[[packages]]
+name = "cffi"
+version = "2.1.1"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "pycparser" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "cffi-2.1.1.tar.gz"
+upload-time = 2026-08-03 21:21:18.939988+00:00
+url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz"
+size = 530807
+
+[packages.sdist.hashes]
+sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"
+
+[[packages.wheels]]
+name = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl"
+upload-time = 2026-08-03 21:20:52.173342+00:00
+url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl"
+size = 185002
+
+[packages.wheels.hashes]
+sha256 = "2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"
+
+[[packages.wheels]]
+name = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+upload-time = 2026-08-03 21:20:53.462549+00:00
+url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+size = 222271
+
+[packages.wheels.hashes]
+sha256 = "507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"
+
+[[packages.wheels]]
+name = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+upload-time = 2026-08-03 21:20:57.336082+00:00
+url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+size = 221630
+
+[packages.wheels.hashes]
+sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"
+
+[[packages.wheels]]
+name = "cffi-2.1.1-cp315-cp315-win_amd64.whl"
+upload-time = 2026-08-03 21:21:16.108179+00:00
+url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl"
+size = 187897
+
+[packages.wheels.hashes]
+sha256 = "e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"
+
+[[packages]]
+name = "charset-normalizer"
+version = "3.5.2"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.7"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "charset_normalizer-3.5.2.tar.gz"
+upload-time = 2026-09-30 04:39:23.398219+00:00
+url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz"
+size = 172659
+
+[packages.sdist.hashes]
+sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl"
+upload-time = 2026-09-30 04:37:28.329851+00:00
+url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl"
+size = 367676
+
+[packages.wheels.hashes]
+sha256 = "87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+upload-time = 2026-09-30 04:37:30.167187+00:00
+url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+size = 248414
+
+[packages.wheels.hashes]
+sha256 = "9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
+upload-time = 2026-09-30 04:37:37.397915+00:00
+url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
+size = 257336
+
+[packages.wheels.hashes]
+sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl"
+upload-time = 2026-09-30 04:37:53.427053+00:00
+url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl"
+size = 210858
+
+[packages.wheels.hashes]
+sha256 = "89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl"
+upload-time = 2026-09-30 04:38:26.216534+00:00
+url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl"
+size = 331815
+
+[packages.wheels.hashes]
+sha256 = "4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl"
+upload-time = 2026-09-30 04:38:28.032254+00:00
+url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl"
+size = 253276
+
+[packages.wheels.hashes]
+sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+upload-time = 2026-09-30 04:38:29.732000+00:00
+url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+size = 241239
+
+[packages.wheels.hashes]
+sha256 = "d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl"
+upload-time = 2026-09-30 04:38:49.707190+00:00
+url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl"
+size = 200092
+
+[packages.wheels.hashes]
+sha256 = "9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036"
+
+[[packages.wheels]]
+name = "charset_normalizer-3.5.2-py3-none-any.whl"
+upload-time = 2026-09-30 04:39:21.828049+00:00
+url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl"
+size = 68872
+
+[packages.wheels.hashes]
+sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685"
[[packages]]
name = "click"
version = "8.5.0"
-sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", upload-time = 2026-08-26T13:33:14Z, size = 382235, hashes = { sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", upload-time = 2026-08-26T13:33:12Z, size = 125251, hashes = { sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" } }]
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "click-8.5.0.tar.gz"
+upload-time = 2026-08-26 13:33:14.560531+00:00
+url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz"
+size = 382235
+
+[packages.sdist.hashes]
+sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"
+
+[[packages.wheels]]
+name = "click-8.5.0-py3-none-any.whl"
+upload-time = 2026-08-26 13:33:12.928043+00:00
+url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl"
+size = 125251
+
+[packages.wheels.hashes]
+sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"
+
+[[packages]]
+name = "colorama"
+version = "0.4.6"
+marker = "\"lock-tools\" in dependency_groups and sys_platform == \"win32\""
+requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "colorama-0.4.6.tar.gz"
+upload-time = 2022-10-25 02:36:22.414799+00:00
+url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz"
+size = 27697
+
+[packages.sdist.hashes]
+sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"
+
+[[packages.wheels]]
+name = "colorama-0.4.6-py2.py3-none-any.whl"
+upload-time = 2022-10-25 02:36:20.889702+00:00
+url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl"
+size = 25335
+
+[packages.wheels.hashes]
+sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"
+
+[[packages]]
+name = "cryptography"
+version = "50.0.2"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = "!=3.9.0,!=3.9.1,>=3.9"
+dependencies = [
+ { name = "cffi" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "cryptography-50.0.2.tar.gz"
+upload-time = 2026-09-30 15:30:04.884601+00:00
+url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz"
+size = 880623
+
+[packages.sdist.hashes]
+sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl"
+upload-time = 2026-09-30 14:43:44.339921+00:00
+url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl"
+size = 3914904
+
+[packages.wheels.hashes]
+sha256 = "fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+upload-time = 2026-09-30 14:43:47.113016+00:00
+url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+size = 4731146
+
+[packages.wheels.hashes]
+sha256 = "79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+upload-time = 2026-09-30 14:43:49.010540+00:00
+url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+size = 4719841
+
+[packages.wheels.hashes]
+sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl"
+upload-time = 2026-09-30 14:43:50.932452+00:00
+url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl"
+size = 4738340
+
+[packages.wheels.hashes]
+sha256 = "f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl"
+upload-time = 2026-09-30 14:43:55.272150+00:00
+url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl"
+size = 4753050
+
+[packages.wheels.hashes]
+sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl"
+upload-time = 2026-09-30 14:43:59.541693+00:00
+url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl"
+size = 4737859
+
+[packages.wheels.hashes]
+sha256 = "87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl"
+upload-time = 2026-09-30 14:44:04.545137+00:00
+url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl"
+size = 4752576
+
+[packages.wheels.hashes]
+sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp311-abi3-win_amd64.whl"
+upload-time = 2026-09-30 14:44:11.671809+00:00
+url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl"
+size = 3824692
+
+[packages.wheels.hashes]
+sha256 = "7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl"
+upload-time = 2026-09-30 14:44:41.807195+00:00
+url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl"
+size = 4133708
+
+[packages.wheels.hashes]
+sha256 = "edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+upload-time = 2026-09-30 14:44:43.693364+00:00
+url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+size = 4956267
+
+[packages.wheels.hashes]
+sha256 = "d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+upload-time = 2026-09-30 14:44:45.769143+00:00
+url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+size = 4966465
+
+[packages.wheels.hashes]
+sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl"
+upload-time = 2026-09-30 14:44:48.211032+00:00
+url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl"
+size = 4959356
+
+[packages.wheels.hashes]
+sha256 = "e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl"
+upload-time = 2026-09-30 14:44:53.379605+00:00
+url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl"
+size = 5001199
+
+[packages.wheels.hashes]
+sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl"
+upload-time = 2026-09-30 14:44:59.639056+00:00
+url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl"
+size = 4958822
+
+[packages.wheels.hashes]
+sha256 = "fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl"
+upload-time = 2026-09-30 14:45:05.009031+00:00
+url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl"
+size = 5000859
+
+[packages.wheels.hashes]
+sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl"
+upload-time = 2026-09-30 15:29:20.155896+00:00
+url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl"
+size = 4111557
+
+[packages.wheels.hashes]
+sha256 = "c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl"
+upload-time = 2026-09-30 15:29:22.265815+00:00
+url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl"
+size = 3943588
+
+[packages.wheels.hashes]
+sha256 = "0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+upload-time = 2026-09-30 15:29:24.580797+00:00
+url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+size = 4756166
+
+[packages.wheels.hashes]
+sha256 = "c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+upload-time = 2026-09-30 15:29:26.807001+00:00
+url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+size = 4749145
+
+[packages.wheels.hashes]
+sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl"
+upload-time = 2026-09-30 15:29:28.588698+00:00
+url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl"
+size = 4763638
+
+[packages.wheels.hashes]
+sha256 = "dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl"
+upload-time = 2026-09-30 15:29:32.605292+00:00
+url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl"
+size = 4781387
+
+[packages.wheels.hashes]
+sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl"
+upload-time = 2026-09-30 15:29:36.149639+00:00
+url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl"
+size = 4764319
+
+[packages.wheels.hashes]
+sha256 = "0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl"
+upload-time = 2026-09-30 15:29:41.251208+00:00
+url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl"
+size = 4780973
+
+[packages.wheels.hashes]
+sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c"
+
+[[packages.wheels]]
+name = "cryptography-50.0.2-cp39-abi3-win_amd64.whl"
+upload-time = 2026-09-30 15:29:46.782322+00:00
+url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl"
+size = 3854095
+
+[packages.wheels.hashes]
+sha256 = "4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de"
[[packages]]
-name = "iniconfig"
-version = "2.3.1"
-sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", upload-time = 2026-10-06T22:48:38Z, size = 21209, hashes = { sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", upload-time = 2026-10-06T22:48:36Z, size = 7552, hashes = { sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" } }]
+name = "cython"
+version = "3.3.0"
+marker = "\"build\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "cython-3.3.0.tar.gz"
+upload-time = 2026-08-22 05:16:39.493040+00:00
+url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz"
+size = 3727515
+
+[packages.sdist.hashes]
+sha256 = "eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8"
+
+[[packages.wheels]]
+name = "cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl"
+upload-time = 2026-08-22 05:17:32.523944+00:00
+url = "https://files.pythonhosted.org/packages/ea/45/7f6988070013e16918e39b1b3dab9c5f2c8e404253a7fd10ee685bbd6902/cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl"
+size = 3177681
+
+[packages.wheels.hashes]
+sha256 = "596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66"
+
+[[packages.wheels]]
+name = "cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+upload-time = 2026-08-22 05:17:34.511844+00:00
+url = "https://files.pythonhosted.org/packages/6e/5d/afb6866ab10236bb208dff0f172ea4b397c9693c5250280c4d9d26057218/cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+size = 3328312
+
+[packages.wheels.hashes]
+sha256 = "4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d"
+
+[[packages.wheels]]
+name = "cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
+upload-time = 2026-08-22 05:17:36.538454+00:00
+url = "https://files.pythonhosted.org/packages/c9/aa/4c0b6773ecf6bc1ec6cda7db8312a566611b330fb6dae87d740e44a47822/cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
+size = 3454285
+
+[packages.wheels.hashes]
+sha256 = "b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616"
+
+[[packages.wheels]]
+name = "cython-3.3.0-cp315-cp315-win_amd64.whl"
+upload-time = 2026-08-22 05:17:38.650619+00:00
+url = "https://files.pythonhosted.org/packages/44/bb/3e2631122f96300723d6fc42b9cf65550bdcda570a6ad5c4e0226e2e787c/cython-3.3.0-cp315-cp315-win_amd64.whl"
+size = 2924709
+
+[packages.wheels.hashes]
+sha256 = "b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef"
+
+[[packages.wheels]]
+name = "cython-3.3.0-py3-none-any.whl"
+upload-time = 2026-08-22 05:16:36.741632+00:00
+url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl"
+size = 1349151
+
+[packages.wheels.hashes]
+sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1"
+
+[[packages]]
+name = "dnspython"
+version = "2.9.0"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.11"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "dnspython-2.9.0.tar.gz"
+upload-time = 2026-10-09 00:07:24.352654+00:00
+url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz"
+size = 423560
+
+[packages.sdist.hashes]
+sha256 = "b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1"
+
+[[packages.wheels]]
+name = "dnspython-2.9.0-py3-none-any.whl"
+upload-time = 2026-10-09 00:07:22.622887+00:00
+url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl"
+size = 354822
+
+[packages.wheels.hashes]
+sha256 = "9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9"
+
+[[packages]]
+name = "docutils"
+version = "0.23"
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "docutils-0.23.tar.gz"
+upload-time = 2026-05-27 17:41:06.934109+00:00
+url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz"
+size = 2303823
+
+[packages.sdist.hashes]
+sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e"
+
+[[packages.wheels]]
+name = "docutils-0.23-py3-none-any.whl"
+upload-time = 2026-05-27 17:40:58.442528+00:00
+url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl"
+size = 634701
+
+[packages.wheels.hashes]
+sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea"
+
+[[packages]]
+name = "email-validator"
+version = "2.3.0"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.8"
+dependencies = [
+ { name = "dnspython" },
+ { name = "idna" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "email_validator-2.3.0.tar.gz"
+upload-time = 2025-08-26 13:09:06.831581+00:00
+url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz"
+size = 51238
+
+[packages.sdist.hashes]
+sha256 = "9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"
+
+[[packages.wheels]]
+name = "email_validator-2.3.0-py3-none-any.whl"
+upload-time = 2025-08-26 13:09:05.858779+00:00
+url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl"
+size = 35604
+
+[packages.wheels.hashes]
+sha256 = "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"
+
+[[packages]]
+name = "id"
+version = "1.6.1"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.9"
+dependencies = [
+ { name = "urllib3" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "id-1.6.1.tar.gz"
+upload-time = 2026-02-04 16:19:41.260133+00:00
+url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz"
+size = 18088
+
+[packages.sdist.hashes]
+sha256 = "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069"
+
+[[packages.wheels]]
+name = "id-1.6.1-py3-none-any.whl"
+upload-time = 2026-02-04 16:19:40.051745+00:00
+url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl"
+size = 14689
+
+[packages.wheels.hashes]
+sha256 = "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca"
+
+[[packages]]
+name = "idna"
+version = "3.20"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "idna-3.20.tar.gz"
+upload-time = 2026-09-17 14:11:04.752193+00:00
+url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz"
+size = 216463
+
+[packages.sdist.hashes]
+sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44"
+
+[[packages.wheels]]
+name = "idna-3.20-py3-none-any.whl"
+upload-time = 2026-09-17 14:11:03.168153+00:00
+url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl"
+size = 69583
+
+[packages.wheels.hashes]
+sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c"
+
+[[packages]]
+name = "installer"
+version = "1.1.0"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "installer-1.1.0.tar.gz"
+upload-time = 2026-10-12 19:35:52.851170+00:00
+url = "https://files.pythonhosted.org/packages/56/d3/2fab33d15004d41ae8e7b5d3e310510f7f34a7e1dd032a3e60d8b38d8fba/installer-1.1.0.tar.gz"
+size = 481257
+
+[packages.sdist.hashes]
+sha256 = "5adc0c01ce62d809989e246987cd68944fe5df8af562d0358f65b2ab15cb5de7"
+
+[[packages.wheels]]
+name = "installer-1.1.0-py3-none-any.whl"
+upload-time = 2026-10-12 19:35:51.096942+00:00
+url = "https://files.pythonhosted.org/packages/d7/7a/63547a792a12148882205d79e07644091d5b7690f40c2bb9d41452a8bdc2/installer-1.1.0-py3-none-any.whl"
+size = 464466
+
+[packages.wheels.hashes]
+sha256 = "f55f5728ff61d3dd54dbc5a397cf3f7307cb64b180c20b72afbebf01c9e2c8a0"
[[packages]]
name = "jinja2"
version = "3.1.6"
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", upload-time = 2025-03-05T20:05:02Z, size = 245115, hashes = { sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", upload-time = 2025-03-05T20:05:00Z, size = 134899, hashes = { sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" } }]
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.7"
+dependencies = [
+ { name = "markupsafe" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "jinja2-3.1.6.tar.gz"
+upload-time = 2025-03-05 20:05:02.478302+00:00
+url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz"
+size = 245115
+
+[packages.sdist.hashes]
+sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"
+
+[[packages.wheels]]
+name = "jinja2-3.1.6-py3-none-any.whl"
+upload-time = 2025-03-05 20:05:00.369721+00:00
+url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl"
+size = 134899
+
+[packages.wheels.hashes]
+sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"
[[packages]]
name = "markdown-it-py"
version = "4.2.0"
-sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", upload-time = 2026-05-07T12:08:28Z, size = 82454, hashes = { sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", upload-time = 2026-05-07T12:08:27Z, size = 91687, hashes = { sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" } }]
+marker = "\"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "mdurl" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "markdown_it_py-4.2.0.tar.gz"
+upload-time = 2026-05-07 12:08:28.360612+00:00
+url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz"
+size = 82454
+
+[packages.sdist.hashes]
+sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"
+
+[[packages.wheels]]
+name = "markdown_it_py-4.2.0-py3-none-any.whl"
+upload-time = 2026-05-07 12:08:27.182789+00:00
+url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl"
+size = 91687
+
+[packages.wheels.hashes]
+sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"
[[packages]]
name = "markupsafe"
version = "3.0.4"
-sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", upload-time = 2026-10-02T23:07:22Z, size = 153777, hashes = { sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-10-02T23:06:22Z, size = 23111, hashes = { sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" } }]
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "markupsafe-3.0.4.tar.gz"
+upload-time = 2026-10-02 23:07:22.290270+00:00
+url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz"
+size = 153777
+
+[packages.sdist.hashes]
+sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6"
+
+[[packages.wheels]]
+name = "markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl"
+upload-time = 2026-10-02 23:06:17.855823+00:00
+url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl"
+size = 12075
+
+[packages.wheels.hashes]
+sha256 = "ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97"
+
+[[packages.wheels]]
+name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+upload-time = 2026-10-02 23:06:18.963942+00:00
+url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+size = 24656
+
+[packages.wheels.hashes]
+sha256 = "4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b"
+
+[[packages.wheels]]
+name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
+upload-time = 2026-10-02 23:06:22.524416+00:00
+url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
+size = 23111
+
+[packages.wheels.hashes]
+sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369"
+
+[[packages.wheels]]
+name = "markupsafe-3.0.4-cp315-cp315-win_amd64.whl"
+upload-time = 2026-10-02 23:06:33.258900+00:00
+url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl"
+size = 14496
+
+[packages.wheels.hashes]
+sha256 = "d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214"
[[packages]]
name = "mdformat"
version = "1.0.0"
-sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", upload-time = 2025-10-16T12:05:03Z, size = 56953, hashes = { sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", upload-time = 2025-10-16T12:05:02Z, size = 53288, hashes = { sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" } }]
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "markdown-it-py" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "mdformat-1.0.0.tar.gz"
+upload-time = 2025-10-16 12:05:03.695033+00:00
+url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz"
+size = 56953
+
+[packages.sdist.hashes]
+sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928"
+
+[[packages.wheels]]
+name = "mdformat-1.0.0-py3-none-any.whl"
+upload-time = 2025-10-16 12:05:02.607432+00:00
+url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl"
+size = 53288
+
+[packages.wheels.hashes]
+sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b"
[[packages]]
name = "mdformat-gfm"
version = "1.0.0"
-sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", upload-time = 2025-10-16T09:12:22Z, size = 10112, hashes = { sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", upload-time = 2025-10-16T09:12:21Z, size = 10970, hashes = { sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" } }]
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+ { name = "wcwidth" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "mdformat_gfm-1.0.0.tar.gz"
+upload-time = 2025-10-16 09:12:22.402429+00:00
+url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz"
+size = 10112
+
+[packages.sdist.hashes]
+sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623"
+
+[[packages.wheels]]
+name = "mdformat_gfm-1.0.0-py3-none-any.whl"
+upload-time = 2025-10-16 09:12:21.276157+00:00
+url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl"
+size = 10970
+
+[packages.wheels.hashes]
+sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005"
[[packages]]
name = "mdit-py-plugins"
version = "0.6.1"
-sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", upload-time = 2026-05-13T09:03:38Z, size = 56114, hashes = { sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", upload-time = 2026-05-13T09:03:37Z, size = 66663, hashes = { sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" } }]
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "markdown-it-py" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "mdit_py_plugins-0.6.1.tar.gz"
+upload-time = 2026-05-13 09:03:38.910230+00:00
+url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz"
+size = 56114
+
+[packages.sdist.hashes]
+sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"
+
+[[packages.wheels]]
+name = "mdit_py_plugins-0.6.1-py3-none-any.whl"
+upload-time = 2026-05-13 09:03:37.760000+00:00
+url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl"
+size = 66663
+
+[packages.wheels.hashes]
+sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d"
[[packages]]
name = "mdurl"
version = "0.1.2"
-sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", upload-time = 2022-08-14T12:40:10Z, size = 8729, hashes = { sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", upload-time = 2022-08-14T12:40:09Z, size = 9979, hashes = { sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" } }]
+marker = "\"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.7"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "mdurl-0.1.2.tar.gz"
+upload-time = 2022-08-14 12:40:10.846281+00:00
+url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz"
+size = 8729
+
+[packages.sdist.hashes]
+sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
+
+[[packages.wheels]]
+name = "mdurl-0.1.2-py3-none-any.whl"
+upload-time = 2022-08-14 12:40:09.779676+00:00
+url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl"
+size = 9979
+
+[packages.wheels.hashes]
+sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"
+
+[[packages]]
+name = "nab"
+version = "0.0.22"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "nab-index" },
+ { name = "nab-markersets" },
+ { name = "nab-project" },
+ { name = "nab-provider" },
+ { name = "nab-resolver" },
+ { name = "tomli" },
+ { name = "typing-extensions" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "nab-0.0.22.tar.gz"
+upload-time = 2026-10-18 05:45:08.513928+00:00
+url = "https://files.pythonhosted.org/packages/25/3d/e4c8f4b2890efbf2fa6eef82acf57ecc0ef39b0a1e6843d306d2053a107c/nab-0.0.22.tar.gz"
+size = 286128
+
+[packages.sdist.hashes]
+sha256 = "a0da54ee96bf1eb1997581566620c250c8a3557139e9467ccaced2a12e9e71ac"
+
+[[packages.wheels]]
+name = "nab-0.0.22-py3-none-any.whl"
+upload-time = 2026-10-18 05:45:06.803974+00:00
+url = "https://files.pythonhosted.org/packages/56/4b/77baa0ddb0b7578b6475b2b578fa4579207b0f97e0b3b80480e01dcb3d6f/nab-0.0.22-py3-none-any.whl"
+size = 137802
+
+[packages.wheels.hashes]
+sha256 = "f62fcf75b28786fbf58627f6d7eaa735daefbc7f736715403bc4357acb57e7bb"
+
+[[packages]]
+name = "nab-index"
+version = "0.0.22"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "nab-provider" },
+ { name = "packaging" },
+ { name = "truststore" },
+ { name = "typing-extensions" },
+ { name = "urllib3" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "nab_index-0.0.22.tar.gz"
+upload-time = 2026-10-18 05:44:43.857925+00:00
+url = "https://files.pythonhosted.org/packages/2a/83/8d2483239b7dc5ac8d9f758db1cc19d4129d0143d34c2e1ab8cd48ea71fb/nab_index-0.0.22.tar.gz"
+size = 110621
+
+[packages.sdist.hashes]
+sha256 = "5900ea5ad298512379cfddff1a73a544590f0b04620ef58ff345f99a7c59688e"
+
+[[packages.wheels]]
+name = "nab_index-0.0.22-py3-none-any.whl"
+upload-time = 2026-10-18 05:44:42.188007+00:00
+url = "https://files.pythonhosted.org/packages/c0/58/c06ada29934a78479b989675829f26083972f38bf1e8a2503e01101cb970/nab_index-0.0.22-py3-none-any.whl"
+size = 88373
+
+[packages.wheels.hashes]
+sha256 = "c89ffa607dfe4246dc59e9f5236d54413da3d310183ffa3298ba51a48e7c2ec7"
+
+[[packages]]
+name = "nab-markersets"
+version = "0.0.22"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "nab_markersets-0.0.22.tar.gz"
+upload-time = 2026-10-18 05:44:19.580614+00:00
+url = "https://files.pythonhosted.org/packages/fc/b7/3997893dfdada9998d4dc786c10445a1361e197b4aed54ddd866c84dd538/nab_markersets-0.0.22.tar.gz"
+size = 109258
+
+[packages.sdist.hashes]
+sha256 = "38212664f517f75d798808ecfe902bfd11fef340042ab0203100c7e06e78eea5"
+
+[[packages.wheels]]
+name = "nab_markersets-0.0.22-py3-none-any.whl"
+upload-time = 2026-10-18 05:44:18.237004+00:00
+url = "https://files.pythonhosted.org/packages/5e/9e/9744adff3a66633a811f8253faaa8b91a447c99199edba16f2790e3647ea/nab_markersets-0.0.22-py3-none-any.whl"
+size = 44360
+
+[packages.wheels.hashes]
+sha256 = "ad268dcece034495b2ea31a70250cd43cc4f6123039a548762c7ec8826e5d3bd"
+
+[[packages]]
+name = "nab-project"
+version = "0.0.22"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "build" },
+ { name = "installer" },
+ { name = "nab-index" },
+ { name = "nab-markersets" },
+ { name = "nab-provider" },
+ { name = "nab-resolver" },
+ { name = "packaging" },
+ { name = "pyproject-hooks" },
+ { name = "tomli" },
+ { name = "tomli-w" },
+ { name = "typing-extensions" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "nab_project-0.0.22.tar.gz"
+upload-time = 2026-10-18 05:44:55.821489+00:00
+url = "https://files.pythonhosted.org/packages/65/84/f5197d4044ceda9c6ee3e7aefce154ab616fec632fa1d89cdfaacc665c07/nab_project-0.0.22.tar.gz"
+size = 774409
+
+[packages.sdist.hashes]
+sha256 = "5f7a1dee5b618149267b2b6233c3a220e45cd6f97bd5ec7f3f48fa7570e9f053"
+
+[[packages.wheels]]
+name = "nab_project-0.0.22-py3-none-any.whl"
+upload-time = 2026-10-18 05:44:53.956127+00:00
+url = "https://files.pythonhosted.org/packages/de/c9/62affa4df6cabce48751307c4c3adfbaa8c54e402d464d015897a16a6dba/nab_project-0.0.22-py3-none-any.whl"
+size = 148262
+
+[packages.wheels.hashes]
+sha256 = "cb09fa143105fd99a60609b3ff4830924b0d4eaf5abad48678467ec4b73e577f"
+
+[[packages]]
+name = "nab-provider"
+version = "0.0.22"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "nab-markersets" },
+ { name = "nab-resolver" },
+ { name = "typing-extensions" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "nab_provider-0.0.22.tar.gz"
+upload-time = 2026-10-18 05:44:31.579358+00:00
+url = "https://files.pythonhosted.org/packages/94/0d/64fbadae562fefc00257c4f5ec988171d6e6b3ea4363f1da2e8886e80228/nab_provider-0.0.22.tar.gz"
+size = 409367
+
+[packages.sdist.hashes]
+sha256 = "3f129ab7394bf5eb031152c3535ee74073e2a6b6ae081fb7f5a4d269fcf5f8a0"
+
+[[packages.wheels]]
+name = "nab_provider-0.0.22-py3-none-any.whl"
+upload-time = 2026-10-18 05:44:29.803721+00:00
+url = "https://files.pythonhosted.org/packages/30/67/32bda18d34f0f8ec098f8e4a0f792164ef1a4b2aa61d435d20f4645572af/nab_provider-0.0.22-py3-none-any.whl"
+size = 339242
+
+[packages.wheels.hashes]
+sha256 = "1450c9dc58b23da1a7bde7fdb43a72b043327bd0e3ef20435254ffcb8b7eae6b"
+
+[[packages]]
+name = "nab-resolver"
+version = "0.0.22"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "nab_resolver-0.0.22.tar.gz"
+upload-time = 2026-10-18 05:44:08.017044+00:00
+url = "https://files.pythonhosted.org/packages/84/bc/dcbfe5163538b1cc8421797e3f8ff77d79321cbc9943af8065e2c8ae1c09/nab_resolver-0.0.22.tar.gz"
+size = 167648
+
+[packages.sdist.hashes]
+sha256 = "e005b225213e032858cf4aef3850961aaa1d4ea521428da731f33175ebd3b05f"
+
+[[packages.wheels]]
+name = "nab_resolver-0.0.22-py3-none-any.whl"
+upload-time = 2026-10-18 05:44:06.445864+00:00
+url = "https://files.pythonhosted.org/packages/22/a8/6e30b25ae12e0c662dc9b181a7f32fa8cd22f26016f12dc1d10176f62bba/nab_resolver-0.0.22-py3-none-any.whl"
+size = 58597
+
+[packages.wheels.hashes]
+sha256 = "6f77daa98279ef0eedb9e8f2248aee4c50e01b92b470b68886995e5056d79971"
[[packages]]
name = "packaging"
version = "26.3"
-sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", upload-time = 2026-08-04T18:15:28Z, size = 313412, hashes = { sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", upload-time = 2026-08-04T18:15:27Z, size = 129956, hashes = { sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" } }]
+marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "packaging-26.3.tar.gz"
+upload-time = 2026-08-04 18:15:28.737071+00:00
+url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz"
+size = 313412
+
+[packages.sdist.hashes]
+sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"
+
+[[packages.wheels]]
+name = "packaging-26.3-py3-none-any.whl"
+upload-time = 2026-08-04 18:15:27.159957+00:00
+url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl"
+size = 129956
+
+[packages.wheels.hashes]
+sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"
[[packages]]
-name = "pluggy"
-version = "1.7.0"
-sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", upload-time = 2026-10-15T09:50:58Z, size = 123304, hashes = { sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", upload-time = 2026-10-15T09:50:56Z, size = 27082, hashes = { sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" } }]
+name = "platformdirs"
+version = "4.13.3"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.11"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "platformdirs-4.13.3.tar.gz"
+upload-time = 2026-10-16 01:16:16.573321+00:00
+url = "https://files.pythonhosted.org/packages/da/91/4a03cfdb03314cfca262921797fff04ff4054bd86a160aed76eddf8aa12b/platformdirs-4.13.3.tar.gz"
+size = 61559
+
+[packages.sdist.hashes]
+sha256 = "5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce"
+
+[[packages.wheels]]
+name = "platformdirs-4.13.3-py3-none-any.whl"
+upload-time = 2026-10-16 01:16:15.051988+00:00
+url = "https://files.pythonhosted.org/packages/45/b8/fd1af06b079af236f5423f7c1821264419cc8f6b4803f79353acbb8bfa53/platformdirs-4.13.3-py3-none-any.whl"
+size = 32762
+
+[packages.wheels.hashes]
+sha256 = "f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4"
[[packages]]
name = "prek"
-version = "0.5.6"
-sdist = { url = "https://files.pythonhosted.org/packages/4f/96/c4062bcaebdabfa945d07c424fb8c05d53a72d4f3fdcf255b1188abbb150/prek-0.5.6.tar.gz", upload-time = 2026-10-19T05:46:52Z, size = 579962, hashes = { sha256 = "87a8c19f873b96be809b4368959ed908cfa06255dba15082d0fb91173e9293bc" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/e1/f7/04e08d2343ce1d54c5da879e2dfea7df20f3140b1b2023a937fac87fa545/prek-0.5.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-10-19T05:46:42Z, size = 6165929, hashes = { sha256 = "aabec01beeb372bcdc48f6b339021bc33c07edf477b337fc92dc5d3b01f99150" } }]
+version = "0.5.5"
+marker = "\"release-check\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.8"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "prek-0.5.5.tar.gz"
+upload-time = 2026-10-05 06:08:59.165911+00:00
+url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz"
+size = 570428
+
+[packages.sdist.hashes]
+sha256 = "16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a"
+
+[[packages.wheels]]
+name = "prek-0.5.5-py3-none-macosx_11_0_arm64.whl"
+upload-time = 2026-10-05 06:08:41.428822+00:00
+url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl"
+size = 5548955
+
+[packages.wheels.hashes]
+sha256 = "2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e"
+
+[[packages.wheels]]
+name = "prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl"
+upload-time = 2026-10-05 06:08:44.051128+00:00
+url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl"
+size = 5848228
+
+[packages.wheels.hashes]
+sha256 = "120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b"
+
+[[packages.wheels]]
+name = "prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+upload-time = 2026-10-05 06:08:48.645837+00:00
+url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+size = 6269922
+
+[packages.wheels.hashes]
+sha256 = "73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850"
+
+[[packages.wheels]]
+name = "prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl"
+upload-time = 2026-10-05 06:08:50.808055+00:00
+url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl"
+size = 5861518
+
+[packages.wheels.hashes]
+sha256 = "76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99"
+
+[[packages.wheels]]
+name = "prek-0.5.5-py3-none-win_amd64.whl"
+upload-time = 2026-10-05 06:08:55.056244+00:00
+url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl"
+size = 5784759
+
+[packages.wheels.hashes]
+sha256 = "15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874"
+
+[[packages]]
+name = "pyasn1"
+version = "0.6.4"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.8"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pyasn1-0.6.4.tar.gz"
+upload-time = 2026-07-09 01:12:33.988388+00:00
+url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz"
+size = 151262
+
+[packages.sdist.hashes]
+sha256 = "9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81"
+
+[[packages.wheels]]
+name = "pyasn1-0.6.4-py3-none-any.whl"
+upload-time = 2026-07-09 01:12:32.920042+00:00
+url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl"
+size = 84410
+
+[packages.wheels.hashes]
+sha256 = "deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b"
+
+[[packages]]
+name = "pycparser"
+version = "3.11"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pycparser-3.11.tar.gz"
+upload-time = 2026-10-09 12:56:59.539345+00:00
+url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz"
+size = 113796
+
+[packages.sdist.hashes]
+sha256 = "d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc"
+
+[[packages.wheels]]
+name = "pycparser-3.11-py3-none-any.whl"
+upload-time = 2026-10-09 12:56:58.131173+00:00
+url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl"
+size = 51178
+
+[packages.wheels.hashes]
+sha256 = "51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80"
+
+[[packages]]
+name = "pydantic"
+version = "2.14.1"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "annotated-types" },
+ { name = "email-validator" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pydantic-2.14.1.tar.gz"
+upload-time = 2026-10-11 18:37:55.396662+00:00
+url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz"
+size = 900362
+
+[packages.sdist.hashes]
+sha256 = "94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26"
+
+[[packages.wheels]]
+name = "pydantic-2.14.1-py3-none-any.whl"
+upload-time = 2026-10-11 18:37:53.437059+00:00
+url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl"
+size = 488106
+
+[packages.wheels.hashes]
+sha256 = "9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454"
+
+[[packages]]
+name = "pydantic-core"
+version = "2.50.1"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "typing-extensions" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pydantic_core-2.50.1.tar.gz"
+upload-time = 2026-10-11 18:35:44.820993+00:00
+url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz"
+size = 513515
+
+[packages.sdist.hashes]
+sha256 = "e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09"
+
+[[packages.wheels]]
+name = "pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl"
+upload-time = 2026-10-11 18:34:05.901276+00:00
+url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl"
+size = 2024191
+
+[packages.wheels.hashes]
+sha256 = "84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c"
+
+[[packages.wheels]]
+name = "pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"
+upload-time = 2026-10-11 18:34:07.757584+00:00
+url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"
+size = 2048252
+
+[packages.wheels.hashes]
+sha256 = "23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7"
+
+[[packages.wheels]]
+name = "pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+upload-time = 2026-10-11 18:34:15.560294+00:00
+url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+size = 2159961
+
+[packages.wheels.hashes]
+sha256 = "7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa"
+
+[[packages.wheels]]
+name = "pydantic_core-2.50.1-cp315-cp315-win_amd64.whl"
+upload-time = 2026-10-11 18:34:29.508272+00:00
+url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl"
+size = 2171681
+
+[packages.wheels.hashes]
+sha256 = "983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed"
[[packages]]
name = "pygments"
version = "2.21.0"
-sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", upload-time = 2026-08-17T08:02:48Z, size = 5005329, hashes = { sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", upload-time = 2026-08-17T08:02:44Z, size = 1250147, hashes = { sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" } }]
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pygments-2.21.0.tar.gz"
+upload-time = 2026-08-17 08:02:48.824391+00:00
+url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz"
+size = 5005329
+
+[packages.sdist.hashes]
+sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"
+
+[[packages.wheels]]
+name = "pygments-2.21.0-py3-none-any.whl"
+upload-time = 2026-08-17 08:02:44.912148+00:00
+url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl"
+size = 1250147
+
+[packages.wheels.hashes]
+sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"
+
+[[packages]]
+name = "pyjwt"
+version = "2.15.1"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pyjwt-2.15.1.tar.gz"
+upload-time = 2026-09-28 18:40:42.598678+00:00
+url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz"
+size = 121252
+
+[packages.sdist.hashes]
+sha256 = "4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8"
+
+[[packages.wheels]]
+name = "pyjwt-2.15.1-py3-none-any.whl"
+upload-time = 2026-09-28 18:40:41.429003+00:00
+url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl"
+size = 33860
+
+[packages.wheels.hashes]
+sha256 = "42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193"
[[packages]]
-name = "pytest"
-version = "9.1.1"
-sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", upload-time = 2026-06-19T10:58:32Z, size = 1636369, hashes = { sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", upload-time = 2026-06-19T10:58:31Z, size = 386536, hashes = { sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" } }]
+name = "pyopenssl"
+version = "26.4.0"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.9"
+dependencies = [
+ { name = "cryptography" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pyopenssl-26.4.0.tar.gz"
+upload-time = 2026-08-01 19:50:50.512064+00:00
+url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz"
+size = 182046
+
+[packages.sdist.hashes]
+sha256 = "28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7"
+
+[[packages.wheels]]
+name = "pyopenssl-26.4.0-py3-none-any.whl"
+upload-time = 2026-08-01 19:50:48.940195+00:00
+url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl"
+size = 56026
+
+[packages.wheels.hashes]
+sha256 = "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c"
+
+[[packages]]
+name = "pypi-attestations"
+version = "0.0.30"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "cryptography" },
+ { name = "packaging" },
+ { name = "pyasn1" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "rfc3986" },
+ { name = "sigstore" },
+ { name = "sigstore-models" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pypi_attestations-0.0.30.tar.gz"
+upload-time = 2026-07-28 14:08:35.011386+00:00
+url = "https://files.pythonhosted.org/packages/97/1e/eb8a0233cdcaf01acb18076ac8b8a057714177b3717baf26b78a11b8d929/pypi_attestations-0.0.30.tar.gz"
+size = 139108
+
+[packages.sdist.hashes]
+sha256 = "14ff13c76bbef02a483c2b77532da02bfd746b5141b881f355e06b9807434423"
+
+[[packages.wheels]]
+name = "pypi_attestations-0.0.30-py3-none-any.whl"
+upload-time = 2026-07-28 14:08:33.453745+00:00
+url = "https://files.pythonhosted.org/packages/5f/24/e59078318b5e2bca59be5a21957d70e082e6eb4f478adffe373f3b74daf4/pypi_attestations-0.0.30-py3-none-any.whl"
+size = 23015
+
+[packages.wheels.hashes]
+sha256 = "b3a9c53f6cb89e5e7b5b70e6cfca97cfc66008c1ed54087355e06e40071cef21"
+
+[[packages]]
+name = "pyproject-hooks"
+version = "1.3.3"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.8"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pyproject_hooks-1.3.3.tar.gz"
+upload-time = 2026-09-16 08:58:03.999217+00:00
+url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz"
+size = 21013
+
+[packages.sdist.hashes]
+sha256 = "defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43"
+
+[[packages.wheels]]
+name = "pyproject_hooks-1.3.3-py3-none-any.whl"
+upload-time = 2026-09-16 08:58:02.960279+00:00
+url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl"
+size = 10724
+
+[packages.wheels.hashes]
+sha256 = "5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b"
[[packages]]
name = "pyyaml"
version = "6.0.3"
-sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", upload-time = 2025-09-25T21:33:16Z, size = 130960, hashes = { sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } }
+marker = "\"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.8"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "pyyaml-6.0.3.tar.gz"
+upload-time = 2025-09-25 21:33:16.546886+00:00
+url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz"
+size = 130960
+
+[packages.sdist.hashes]
+sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"
[[packages]]
name = "release-creator"
-version = "0.4.1"
-sdist = { url = "https://files.pythonhosted.org/packages/98/3e/22a3249fecc6ee0e166ef81227a4ba8cb5c83e0bddd5e57b081050ca2bcd/release_creator-0.4.1.tar.gz", upload-time = 2026-10-19T20:26:30Z, size = 63943, hashes = { sha256 = "80f064352f1c0546f12e27ef643fed35e91059b095def95cb5f5baa44357a0d2" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/7b/a2/acae8a1ef434bf427f9e0726f66ddf6ee4913704e4f31891a280a712a58d/release_creator-0.4.1-py3-none-any.whl", upload-time = 2026-10-19T20:26:29Z, size = 20115, hashes = { sha256 = "0328c9044719b5e011312628a2b2cb818288f862e6ac0933510778e412b5e569" } }]
+version = "0.6.0"
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.15"
+dependencies = [
+ { name = "docutils" },
+ { name = "mdformat" },
+ { name = "mdformat-gfm" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "towncrier" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "release_creator-0.6.0.tar.gz"
+upload-time = 2026-10-23 02:44:39.588714+00:00
+url = "https://files.pythonhosted.org/packages/17/11/c0126b406a17370db72d8a08afa1a5823204e69c44938de796cc98df0d02/release_creator-0.6.0.tar.gz"
+size = 135957
+
+[packages.sdist.hashes]
+sha256 = "58afd3cd0fc0b0e47ded23a735a1ba9189a7b0adad410628c90c96cf74457ed6"
+
+[[packages.wheels]]
+name = "release_creator-0.6.0-py3-none-any.whl"
+upload-time = 2026-10-23 02:44:37.874747+00:00
+url = "https://files.pythonhosted.org/packages/ef/91/ad1ce7d5c65892866893879fb0f407898f2d5c808bbd266ca0a82513a205/release_creator-0.6.0-py3-none-any.whl"
+size = 49478
+
+[packages.wheels.hashes]
+sha256 = "4b9cd6187c87fcb7bf3aca5f58c67c90703e58875643af633661a0448756d45f"
[[packages]]
-name = "ruff"
-version = "0.17.1"
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", upload-time = 2026-10-15T15:23:18Z, size = 5323349, hashes = { sha256 = "5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-10-15T15:22:54Z, size = 10350811, hashes = { sha256 = "a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189" } }]
+name = "requests"
+version = "2.34.2"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "requests-2.34.2.tar.gz"
+upload-time = 2026-05-14 19:25:27.735762+00:00
+url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz"
+size = 142856
+
+[packages.sdist.hashes]
+sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"
+
+[[packages.wheels]]
+name = "requests-2.34.2-py3-none-any.whl"
+upload-time = 2026-05-14 19:25:26.443000+00:00
+url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl"
+size = 73075
+
+[packages.wheels.hashes]
+sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"
+
+[[packages]]
+name = "rfc3161-client"
+version = "1.0.9"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.9"
+dependencies = [
+ { name = "cryptography" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "rfc3161_client-1.0.9.tar.gz"
+upload-time = 2026-09-14 19:32:29.113898+00:00
+url = "https://files.pythonhosted.org/packages/56/53/e2f526fb6957023180c928448e2dd71b03b35038c315aed2d12d9a77c3af/rfc3161_client-1.0.9.tar.gz"
+size = 112825
+
+[packages.sdist.hashes]
+sha256 = "66997415086d2c6e5d57a2d315157b32a9c3f4f1312877ca8eb55e2e0ddf8ce4"
+
+[[packages.wheels]]
+name = "rfc3161_client-1.0.9-cp39-abi3-macosx_11_0_arm64.whl"
+upload-time = 2026-09-14 19:32:10.339509+00:00
+url = "https://files.pythonhosted.org/packages/88/cf/bcfb66eaeccdc076457451a6213360c840bcacd0e938e8507951be29373f/rfc3161_client-1.0.9-cp39-abi3-macosx_11_0_arm64.whl"
+size = 2439706
+
+[packages.wheels.hashes]
+sha256 = "a7004edfbf1bb7ec7801978a39fa7a7fe385ca59673df90d7578079b6eba3b24"
+
+[[packages.wheels]]
+name = "rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"
+upload-time = 2026-09-14 19:32:12.025939+00:00
+url = "https://files.pythonhosted.org/packages/8e/f3/4b69d47fd7f79238bf8db20902a30d28c908efc0d906fb57abcc89ac75e3/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"
+size = 2651455
+
+[packages.wheels.hashes]
+sha256 = "7b703b233dbee7228d117d5f1c118363c61d523fa6f018c9217c79e3c6684f86"
+
+[[packages.wheels]]
+name = "rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+upload-time = 2026-09-14 19:32:17.185308+00:00
+url = "https://files.pythonhosted.org/packages/11/ae/19139fee184916065705d660bad203b676dc40b449afff36f79818c59c4f/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+size = 2415566
+
+[packages.wheels.hashes]
+sha256 = "fac3f440a507555e684dc5daba75e33dc08f0f45fdefa48448f19001233a6b21"
+
+[[packages.wheels]]
+name = "rfc3161_client-1.0.9-cp39-abi3-win_amd64.whl"
+upload-time = 2026-09-14 19:32:27.621426+00:00
+url = "https://files.pythonhosted.org/packages/d2/a1/b71d9f2071660a914e039061d3cdcad77b1cbccf7276bff073f95e8b4d1a/rfc3161_client-1.0.9-cp39-abi3-win_amd64.whl"
+size = 2431351
+
+[packages.wheels.hashes]
+sha256 = "908f6a775da4bdce1d39f825a4431f389542696078ef3e4ed911a068bcaf1792"
+
+[[packages]]
+name = "rfc3986"
+version = "2.0.0"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.7"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "rfc3986-2.0.0.tar.gz"
+upload-time = 2022-01-10 00:52:30.832978+00:00
+url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz"
+size = 49026
+
+[packages.sdist.hashes]
+sha256 = "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"
+
+[[packages.wheels]]
+name = "rfc3986-2.0.0-py2.py3-none-any.whl"
+upload-time = 2022-01-10 00:52:29.594625+00:00
+url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl"
+size = 31326
+
+[packages.wheels.hashes]
+sha256 = "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"
+
+[[packages]]
+name = "rfc8785"
+version = "0.1.4"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.8"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "rfc8785-0.1.4.tar.gz"
+upload-time = 2024-09-27 16:33:31.206853+00:00
+url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz"
+size = 14321
+
+[packages.sdist.hashes]
+sha256 = "e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da"
+
+[[packages.wheels]]
+name = "rfc8785-0.1.4-py3-none-any.whl"
+upload-time = 2024-09-27 16:33:29.683421+00:00
+url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl"
+size = 9240
+
+[packages.wheels.hashes]
+sha256 = "520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48"
+
+[[packages]]
+name = "rich"
+version = "15.0.0"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.9.0"
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "rich-15.0.0.tar.gz"
+upload-time = 2026-04-12 08:24:00.750018+00:00
+url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz"
+size = 230680
+
+[packages.sdist.hashes]
+sha256 = "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"
+
+[[packages.wheels]]
+name = "rich-15.0.0-py3-none-any.whl"
+upload-time = 2026-04-12 08:24:02.830783+00:00
+url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl"
+size = 310654
+
+[packages.wheels.hashes]
+sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"
+
+[[packages]]
+name = "securesystemslib"
+version = "1.5.1"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = "~=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "securesystemslib-1.5.1.tar.gz"
+upload-time = 2026-09-04 06:55:32.066349+00:00
+url = "https://files.pythonhosted.org/packages/81/d4/f9323507144693d869f30f13694035438ab6642153b94e3057945cf0584f/securesystemslib-1.5.1.tar.gz"
+size = 947087
+
+[packages.sdist.hashes]
+sha256 = "4b8d00abd93707ead10b69eb2b8582376a1364de3b0a71077de534c2ef4985e0"
+
+[[packages.wheels]]
+name = "securesystemslib-1.5.1-py3-none-any.whl"
+upload-time = 2026-09-04 06:55:30.393643+00:00
+url = "https://files.pythonhosted.org/packages/38/fc/c71335a5046d46bf3b761a8c7fa092b40a7721429a5613538f0a28ae80a6/securesystemslib-1.5.1-py3-none-any.whl"
+size = 876390
+
+[packages.wheels.hashes]
+sha256 = "ada8bdf817da29ece4ba91654f6a162ce7cfbadbc3ae3f840f7313f9d22675de"
+
+[[packages]]
+name = "setuptools"
+version = "84.0.0"
+marker = "\"build\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "setuptools-84.0.0.tar.gz"
+upload-time = 2026-08-08 18:27:58.365696+00:00
+url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz"
+size = 1168449
+
+[packages.sdist.hashes]
+sha256 = "f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73"
+
+[[packages.wheels]]
+name = "setuptools-84.0.0-py3-none-any.whl"
+upload-time = 2026-08-08 18:27:56.719708+00:00
+url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl"
+size = 818216
+
+[packages.wheels.hashes]
+sha256 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670"
+
+[[packages]]
+name = "sigstore"
+version = "4.5.0"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "cryptography" },
+ { name = "id" },
+ { name = "platformdirs" },
+ { name = "pyasn1" },
+ { name = "pydantic" },
+ { name = "pyjwt" },
+ { name = "pyopenssl" },
+ { name = "requests" },
+ { name = "rfc3161-client" },
+ { name = "rfc8785" },
+ { name = "rich" },
+ { name = "sigstore-models" },
+ { name = "sigstore-rekor-types" },
+ { name = "tuf" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "sigstore-4.5.0.tar.gz"
+upload-time = 2026-07-28 07:34:01.717744+00:00
+url = "https://files.pythonhosted.org/packages/18/e0/279419065e2d7102413605b3456122adbbccbc42e010b499c7b882fc01f8/sigstore-4.5.0.tar.gz"
+size = 90969
+
+[packages.sdist.hashes]
+sha256 = "020d3e07f622b2916bf453e66ff6ff0711e1fdc5ab69e8bd8902f71d9fcb316f"
+
+[[packages.wheels]]
+name = "sigstore-4.5.0-py3-none-any.whl"
+upload-time = 2026-07-28 07:34:00.211729+00:00
+url = "https://files.pythonhosted.org/packages/bf/7f/51dc313c06dd3ec6c5b308e334492cfdea0b02c9184a0da7b2db8c2a30a1/sigstore-4.5.0-py3-none-any.whl"
+size = 111724
+
+[packages.wheels.hashes]
+sha256 = "f045b207f2e12605cf775ec38e89c5eda625d71ffa7830477db65e47ec2bc8b2"
+
+[[packages]]
+name = "sigstore-models"
+version = "0.0.6"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "sigstore_models-0.0.6.tar.gz"
+upload-time = 2025-11-26 19:18:12.610187+00:00
+url = "https://files.pythonhosted.org/packages/c6/ed/5c0ff809f90b19f4e971e17c1ed11f4df60082c6010b32a82054087e91e0/sigstore_models-0.0.6.tar.gz"
+size = 7037
+
+[packages.sdist.hashes]
+sha256 = "c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849"
+
+[[packages.wheels]]
+name = "sigstore_models-0.0.6-py3-none-any.whl"
+upload-time = 2025-11-26 19:18:11.365736+00:00
+url = "https://files.pythonhosted.org/packages/12/dc/7a19864a7bc9f43121ab459e12768ab0080590e3e1b60a29b2f2eb01fb85/sigstore_models-0.0.6-py3-none-any.whl"
+size = 13213
+
+[packages.wheels.hashes]
+sha256 = "5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e"
+
+[[packages]]
+name = "sigstore-rekor-types"
+version = "0.0.18"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.8"
+dependencies = [
+ { name = "pydantic" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "sigstore_rekor_types-0.0.18.tar.gz"
+upload-time = 2024-11-22 13:59:54.009830+00:00
+url = "https://files.pythonhosted.org/packages/b4/54/102e772445c5e849b826fbdcd44eb9ad7b3d10fda17b08964658ec7027dc/sigstore_rekor_types-0.0.18.tar.gz"
+size = 15687
+
+[packages.sdist.hashes]
+sha256 = "19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5"
+
+[[packages.wheels]]
+name = "sigstore_rekor_types-0.0.18-py3-none-any.whl"
+upload-time = 2024-11-22 13:59:52.684229+00:00
+url = "https://files.pythonhosted.org/packages/45/7c/f0b4e19fd424df4cc964f5d454e1d814fd2dc3b386342e6040441024318f/sigstore_rekor_types-0.0.18-py3-none-any.whl"
+size = 20610
+
+[packages.wheels.hashes]
+sha256 = "b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78"
+
+[[packages]]
+name = "tomli"
+version = "2.5.0"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.8"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "tomli-2.5.0.tar.gz"
+upload-time = 2026-10-07 12:23:37.892554+00:00
+url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz"
+size = 17662
+
+[packages.sdist.hashes]
+sha256 = "264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6"
+
+[[packages.wheels]]
+name = "tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl"
+upload-time = 2026-10-07 12:23:15.215497+00:00
+url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl"
+size = 161406
+
+[packages.wheels.hashes]
+sha256 = "9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276"
+
+[[packages.wheels]]
+name = "tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+upload-time = 2026-10-07 12:23:16.471112+00:00
+url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"
+size = 273855
+
+[packages.wheels.hashes]
+sha256 = "752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52"
+
+[[packages.wheels]]
+name = "tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
+upload-time = 2026-10-07 12:23:18.166140+00:00
+url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
+size = 284910
+
+[packages.wheels.hashes]
+sha256 = "c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7"
+
+[[packages.wheels]]
+name = "tomli-2.5.0-cp315-cp315-win_amd64.whl"
+upload-time = 2026-10-07 12:23:23.098344+00:00
+url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl"
+size = 114589
+
+[packages.wheels.hashes]
+sha256 = "d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5"
+
+[[packages.wheels]]
+name = "tomli-2.5.0-py3-none-any.whl"
+upload-time = 2026-10-07 12:23:36.875825+00:00
+url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl"
+size = 14765
+
+[packages.wheels.hashes]
+sha256 = "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b"
+
+[[packages]]
+name = "tomli-w"
+version = "1.2.0"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "tomli_w-1.2.0.tar.gz"
+upload-time = 2025-01-15 12:07:24.262974+00:00
+url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz"
+size = 7184
+
+[packages.sdist.hashes]
+sha256 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"
+
+[[packages.wheels]]
+name = "tomli_w-1.2.0-py3-none-any.whl"
+upload-time = 2025-01-15 12:07:22.074224+00:00
+url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl"
+size = 6675
+
+[packages.wheels.hashes]
+sha256 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"
[[packages]]
name = "tomlkit"
-version = "0.15.2"
-sdist = { url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz", upload-time = 2026-10-16T04:07:08Z, size = 190522, hashes = { sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl", upload-time = 2026-10-16T04:07:09Z, size = 52225, hashes = { sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" } }]
+version = "0.15.1"
+marker = "\"lock-tools\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "tomlkit-0.15.1.tar.gz"
+upload-time = 2026-07-17 01:48:04.562015+00:00
+url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz"
+size = 180129
+
+[packages.sdist.hashes]
+sha256 = "e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"
+
+[[packages.wheels]]
+name = "tomlkit-0.15.1-py3-none-any.whl"
+upload-time = 2026-07-17 01:48:05.728578+00:00
+url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl"
+size = 49449
+
+[packages.wheels.hashes]
+sha256 = "177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304"
[[packages]]
name = "towncrier"
version = "26.9.0"
-sdist = { url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz", upload-time = 2026-09-04T12:57:25Z, size = 79338, hashes = { sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl", upload-time = 2026-09-04T12:57:23Z, size = 67030, hashes = { sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" } }]
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.9"
+dependencies = [
+ { name = "click" },
+ { name = "jinja2" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "towncrier-26.9.0.tar.gz"
+upload-time = 2026-09-04 12:57:25.341701+00:00
+url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz"
+size = 79338
+
+[packages.sdist.hashes]
+sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55"
+
+[[packages.wheels]]
+name = "towncrier-26.9.0-py3-none-any.whl"
+upload-time = 2026-09-04 12:57:23.986160+00:00
+url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl"
+size = 67030
+
+[packages.wheels.hashes]
+sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660"
[[packages]]
-name = "ty"
-version = "0.0.89"
-sdist = { url = "https://files.pythonhosted.org/packages/67/ea/dfb9bdbc168c50a568809466596751afa0c7da60da03b8c12906d1decde4/ty-0.0.89.tar.gz", upload-time = 2026-10-16T10:35:05Z, size = 7978241, hashes = { sha256 = "bc1027b1ea95cca25c2a3a0b41433c809cf4b86a384d2b2434eba4bcd34548d4" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/3e/61/da22c714fd4f4dff642d60fb0d8bf8da1985d126f21cc89237f8bf707c92/ty-0.0.89-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-10-16T10:34:44Z, size = 14510395, hashes = { sha256 = "95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc" } }]
+name = "truststore"
+version = "0.10.5"
+marker = "\"lock-tools\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "truststore-0.10.5.tar.gz"
+upload-time = 2026-10-12 22:27:31.808128+00:00
+url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz"
+size = 28091
+
+[packages.sdist.hashes]
+sha256 = "30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd"
+
+[[packages.wheels]]
+name = "truststore-0.10.5-py3-none-any.whl"
+upload-time = 2026-10-12 22:27:30.377491+00:00
+url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl"
+size = 19017
+
+[packages.wheels.hashes]
+sha256 = "9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c"
+
+[[packages]]
+name = "tuf"
+version = "7.0.1"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "securesystemslib" },
+ { name = "urllib3" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "tuf-7.0.1.tar.gz"
+upload-time = 2026-09-02 06:19:52.071733+00:00
+url = "https://files.pythonhosted.org/packages/9c/dd/52e7390cbac308e6b1cfe6be9bc3a96fe26326d5893cc297a52bb72792a9/tuf-7.0.1.tar.gz"
+size = 274148
+
+[packages.sdist.hashes]
+sha256 = "5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31"
+
+[[packages.wheels]]
+name = "tuf-7.0.1-py3-none-any.whl"
+upload-time = 2026-09-02 06:19:50.608554+00:00
+url = "https://files.pythonhosted.org/packages/a2/10/171e306e4a19ff980b9029ce5a523881e34bc6a2a52750fb1853f49f0fb9/tuf-7.0.1-py3-none-any.whl"
+size = 56253
+
+[packages.wheels.hashes]
+sha256 = "d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c"
+
+[[packages]]
+name = "typing-extensions"
+version = "4.16.0"
+marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "typing_extensions-4.16.0.tar.gz"
+upload-time = 2026-07-02 08:40:05.920538+00:00
+url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz"
+size = 113555
+
+[packages.sdist.hashes]
+sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"
+
+[[packages.wheels]]
+name = "typing_extensions-4.16.0-py3-none-any.whl"
+upload-time = 2026-07-02 08:40:04.659120+00:00
+url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl"
+size = 45571
+
+[packages.wheels.hashes]
+sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"
+
+[[packages]]
+name = "typing-inspection"
+version = "0.4.4"
+marker = "\"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+dependencies = [
+ { name = "typing-extensions" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "typing_inspection-0.4.4.tar.gz"
+upload-time = 2026-08-12 12:37:25.997329+00:00
+url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz"
+size = 76928
+
+[packages.sdist.hashes]
+sha256 = "547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"
+
+[[packages.wheels]]
+name = "typing_inspection-0.4.4-py3-none-any.whl"
+upload-time = 2026-08-12 12:37:24.648475+00:00
+url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl"
+size = 14750
+
+[packages.wheels.hashes]
+sha256 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"
+
+[[packages]]
+name = "urllib3"
+version = "2.8.0"
+marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups"
+requires-python = ">=3.10"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "urllib3-2.8.0.tar.gz"
+upload-time = 2026-09-15 19:29:36.253420+00:00
+url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz"
+size = 458972
+
+[packages.sdist.hashes]
+sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"
+
+[[packages.wheels]]
+name = "urllib3-2.8.0-py3-none-any.whl"
+upload-time = 2026-09-15 19:29:34.577402+00:00
+url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl"
+size = 135717
+
+[packages.wheels.hashes]
+sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"
[[packages]]
name = "wcwidth"
version = "0.9.2"
-sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz", upload-time = 2026-10-05T00:24:05Z, size = 955039, hashes = { sha256 = "ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b" } }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", upload-time = 2026-10-05T00:23:40Z, size = 769763, hashes = { sha256 = "751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389" } },
- { url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl", upload-time = 2026-10-05T00:23:21Z, size = 301667, hashes = { sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e" } },
-]
+marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "wcwidth-0.9.2.tar.gz"
+upload-time = 2026-10-05 00:24:05.521972+00:00
+url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz"
+size = 955039
+
+[packages.sdist.hashes]
+sha256 = "ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b"
+
+[[packages.wheels]]
+name = "wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl"
+upload-time = 2026-10-05 00:23:27.049292+00:00
+url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl"
+size = 609960
+
+[packages.wheels.hashes]
+sha256 = "ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17"
+
+[[packages.wheels]]
+name = "wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+upload-time = 2026-10-05 00:23:32.448318+00:00
+url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"
+size = 771632
+
+[packages.wheels.hashes]
+sha256 = "674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724"
+
+[[packages.wheels]]
+name = "wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+upload-time = 2026-10-05 00:23:40.175985+00:00
+url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
+size = 769763
+
+[packages.wheels.hashes]
+sha256 = "751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389"
+
+[[packages.wheels]]
+name = "wcwidth-0.9.2-cp310-abi3-win_amd64.whl"
+upload-time = 2026-10-05 00:23:57.489330+00:00
+url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl"
+size = 597827
+
+[packages.wheels.hashes]
+sha256 = "2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec"
+
+[[packages.wheels]]
+name = "wcwidth-0.9.2-py3-none-any.whl"
+upload-time = 2026-10-05 00:23:21.097514+00:00
+url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl"
+size = 301667
+
+[packages.wheels.hashes]
+sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e"
[[packages]]
name = "xmltodict"
version = "1.0.4"
-sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", upload-time = 2026-02-22T02:21:22Z, size = 26124, hashes = { sha256 = "6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", upload-time = 2026-02-22T02:21:21Z, size = 13580, hashes = { sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a" } }]
+marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups"
+requires-python = ">=3.9"
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "xmltodict-1.0.4.tar.gz"
+upload-time = 2026-02-22 02:21:22.074263+00:00
+url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz"
+size = 26124
+
+[packages.sdist.hashes]
+sha256 = "6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61"
+
+[[packages.wheels]]
+name = "xmltodict-1.0.4-py3-none-any.whl"
+upload-time = 2026-02-22 02:21:21.039836+00:00
+url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl"
+size = 13580
+
+[packages.wheels.hashes]
+sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a"
[[packages]]
name = "yq"
version = "4.4.5"
-sdist = { url = "https://files.pythonhosted.org/packages/fd/be/ae37199d28a0a99eb79084f91da94f6e7e58550552361a2397c5032d5d37/yq-4.4.5.tar.gz", upload-time = 2026-10-11T02:20:56Z, size = 54535, hashes = { sha256 = "e17b21a927d04ae33f61a7284351b69b0a539bfb73b852a04a907b79bcd15de2" } }
-wheels = [{ url = "https://files.pythonhosted.org/packages/fd/38/cca2b04f284c788ded19a9401e3049fd1f82d0467c1d6bd361915a77c063/yq-4.4.5-py3-none-any.whl", upload-time = 2026-10-11T02:20:55Z, size = 30255, hashes = { sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" } }]
+marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups"
+requires-python = ">=3.8"
+dependencies = [
+ { name = "argcomplete" },
+ { name = "pyyaml" },
+ { name = "tomlkit" },
+ { name = "xmltodict" },
+]
+index = "https://pypi.org/simple/"
+
+[packages.sdist]
+name = "yq-4.4.5.tar.gz"
+upload-time = 2026-10-11 02:20:56.748595+00:00
+url = "https://files.pythonhosted.org/packages/fd/be/ae37199d28a0a99eb79084f91da94f6e7e58550552361a2397c5032d5d37/yq-4.4.5.tar.gz"
+size = 54535
+
+[packages.sdist.hashes]
+sha256 = "e17b21a927d04ae33f61a7284351b69b0a539bfb73b852a04a907b79bcd15de2"
+
+[[packages.wheels]]
+name = "yq-4.4.5-py3-none-any.whl"
+upload-time = 2026-10-11 02:20:55.272902+00:00
+url = "https://files.pythonhosted.org/packages/fd/38/cca2b04f284c788ded19a9401e3049fd1f82d0467c1d6bd361915a77c063/yq-4.4.5-py3-none-any.whl"
+size = 30255
+
+[packages.wheels.hashes]
+sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f"
+
+[tool.nab]
+nab-version = "0.0.22"
+created-at = 2026-10-23 02:46:01.225053+00:00
+command-line = [
+ "nab",
+ "lock",
+ "--groups",
+ "build",
+ "lock-tools",
+ "release-attestations",
+ "release-check",
+ "release-hook-check",
+ "release-hooks",
+ "release-news",
+ "release-notes",
+ "release-prepare",
+ "release-shell",
+ "release-validate",
+ "--output",
+ "pylock.toml",
+ "--upgrade",
+]
+input-path = "pyproject.toml"
+mode = "universal"
+python-specifier = ">=3.15,<3.16"
+platforms = [
+ "linux_x86_64",
+ "linux_aarch64",
+ "macos_arm64",
+ "windows_amd64",
+]
diff --git a/pyproject.toml b/pyproject.toml
index bc4f0d0..b435dec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,10 +4,65 @@ version = "0.0.0"
description = "Shared release actions for tox-dev repositories"
requires-python = ">=3.15"
dependencies = []
+license = "MIT"
+license-files = [ "LICENSE" ]
[dependency-groups]
-release = [ "release-creator>=0.4.1", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ]
-test = [ "pytest>=9", "pyyaml>=6", "ruff", "ty" ]
+build = [ "Cython>=3.0", "setuptools>=80" ]
+lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ]
+release-notes = [ "release-creator>=0.6.0" ]
+release-news = [ "release-creator>=0.6.0", "towncrier>=26.9" ]
+release-prepare = [ "release-creator>=0.6.0", "towncrier>=26.9", "prek>=0.2" ]
+release-validate = [ "release-creator>=0.6.0", "prek>=0.2" ]
+release-check = [ "prek>=0.2" ]
+release-attestations = [ "pypi-attestations>=0.0.30" ]
+release-shell = [ "prek>=0.2", "yq>=4.4.5" ]
+release-hooks = [ "release-creator>=0.6.0", "prek>=0.2" ]
+release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ]
+test = [
+ "hatchling>=1.28",
+ "pytest>=9",
+ "pytest-httpserver",
+ "pytest-xdist>=3",
+ "pyyaml>=6",
+ "tomlkit>=0.13",
+ "tox>=4.64.7",
+ "tox-uv>=1.36.1",
+ "trustme",
+ "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_arm64", "windows_amd64" ]
+
+[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-23T02:44:40Z"
[tool.ruff]
line-length = 120
diff --git a/read-towncrier-config/action.yml b/read-towncrier-config/action.yml
new file mode 100644
index 0000000..070e73d
--- /dev/null
+++ b/read-towncrier-config/action.yml
@@ -0,0 +1,73 @@
+name: Read Towncrier configuration
+description: Read release paths, format, and fragment categories from the committed Towncrier configuration
+inputs:
+ working-directory:
+ description: Checkout containing the configuration
+ default: .
+outputs:
+ changelog:
+ description: Changelog path relative to the repository
+ value: ${{ steps.config.outputs.changelog }}
+ fragments:
+ description: Fragment directory relative to the repository
+ value: ${{ steps.config.outputs.fragments }}
+ format:
+ description: Changelog formatter, markdown or rst
+ value: ${{ steps.config.outputs.format }}
+runs:
+ using: composite
+ steps:
+ - name: Read committed Towncrier configuration
+ id: config
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ set -euo pipefail
+ config_dir=$(mktemp -d "$RUNNER_TEMP/towncrier-config.XXXXXX")
+ if git cat-file -e HEAD:towncrier.toml 2>/dev/null; then
+ git show HEAD:towncrier.toml >"$config_dir/config.toml"
+ else
+ git show HEAD:pyproject.toml >"$config_dir/config.toml"
+ fi
+ python3 - "$config_dir/config.toml" "$GITHUB_OUTPUT" <<'PY'
+ import sys
+ import tomllib
+ from pathlib import Path, PurePosixPath
+
+ document = tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
+ config = document.get("tool", {}).get("towncrier")
+ if not isinstance(config, dict):
+ raise SystemExit("Towncrier configuration is required")
+ types = config.get("type")
+ fragments = config.get("fragment")
+ if types is not None and fragments is not None:
+ raise SystemExit("Towncrier type and fragment tables cannot be combined")
+ for key in ("filename", "directory"):
+ value = config.get(key)
+ if not isinstance(value, str) or not value.isprintable() or not value or value != str(PurePosixPath(value)):
+ raise SystemExit("Towncrier paths must be normalized relative paths")
+ if PurePosixPath(value).is_absolute() or ".." in value.split("/"):
+ raise SystemExit("Towncrier paths must stay within the repository")
+ changelog = config["filename"]
+ if changelog.endswith(".md"):
+ format_name = "markdown"
+ elif changelog.endswith(".rst"):
+ format_name = "rst"
+ else:
+ raise SystemExit("Towncrier changelog must use Markdown or RST")
+ if types is not None:
+ if not isinstance(types, list) or not types or any(not isinstance(item, dict) for item in types):
+ raise SystemExit("Towncrier type categories are required")
+ categories = [category.get("directory") for category in types]
+ elif isinstance(fragments, dict) and fragments and all(isinstance(value, dict) for value in fragments.values()):
+ categories = list(fragments)
+ else:
+ raise SystemExit("Towncrier fragment categories are required")
+ if not all(
+ isinstance(category, str) and category.isprintable() and category and "/" not in category
+ for category in categories
+ ):
+ raise SystemExit("Towncrier fragment categories must be printable names")
+ with Path(sys.argv[2]).open("a", encoding="utf-8") as output:
+ output.write(f"changelog={changelog}\nfragments={config['directory']}\nformat={format_name}\n")
+ PY
diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml
new file mode 100644
index 0000000..ce1a2f0
--- /dev/null
+++ b/recheck-hook-update-cooldown/action.yml
@@ -0,0 +1,84 @@
+name: Recheck cooled hook updates
+description: Recheck failed bot hook-update cooldown jobs each day
+inputs:
+ workflow:
+ description: Workflow file containing the required hook-cooldown job
+ default: check.yaml
+ job-name:
+ description: Name of the required cooldown job to rerun
+ default: hook-cooldown
+ minimum-days:
+ description: Minimum age of an eligible hook revision
+ default: "7"
+runs:
+ using: composite
+ steps:
+ - name: Find hook update pull requests
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ gh api --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" |
+ jq -c --arg repository "$GITHUB_REPOSITORY" '
+ .[] | select(.user.type == "Bot" and
+ .head.repo.full_name == $repository and .base.repo.full_name == $repository) |
+ {number, head: .head.sha, branch: .head.ref}
+ ' >"$RUNNER_TEMP/hook-pulls.jsonl"
+ - name: Rerun the failed hook checks
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ WORKFLOW: ${{ inputs.workflow }}
+ JOB_NAME: ${{ inputs.job-name }}
+ MINIMUM_DAYS: ${{ inputs.minimum-days }}
+ run: |
+ set -euo pipefail
+ case "$MINIMUM_DAYS" in
+ '' | 0* | *[!0-9]*) exit 1 ;;
+ esac
+ if ((MINIMUM_DAYS < 7)); then
+ echo '::error::hook cooldown must be at least seven days' >&2
+ exit 1
+ fi
+ while IFS= read -r pull; do
+ number=$(jq -r .number <<<"$pull")
+ gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$number/files" --jq '.[].filename' \
+ >"$RUNNER_TEMP/hook-files"
+ if ! grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-files"; then
+ continue
+ fi
+ head=$(jq -r .head <<<"$pull")
+ branch=$(jq -r .branch <<<"$pull")
+ 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 "$GITHUB_REPOSITORY" --arg branch "$branch" --arg head "$head" \
+ --argjson number "$number" '
+ [.[].workflow_runs[] | select(
+ .event == "pull_request" and .head_repository.full_name == $repository and
+ .head_branch == $branch and .head_sha == $head and
+ any(.pull_requests[]; .number == $number)
+ )]')
+ if ! jq -e --argjson minimum_days "$MINIMUM_DAYS" \
+ '[.[].created_at | fromdateiso8601] | min <= now - $minimum_days * 86400' \
+ <<<"$runs" >/dev/null; then
+ continue
+ fi
+ run=$(jq -c '[.[] | select(.status == "completed")] | sort_by(.created_at) | last // empty' <<<"$runs")
+ if [[ -z "$run" ]]; then
+ continue
+ fi
+ if ! jq -e '(.created_at | fromdateiso8601) >= now - 30 * 86400' <<<"$run" >/dev/null; then
+ continue
+ fi
+ job=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$(jq -r .id <<<"$run")/jobs" \
+ -f filter=latest -f per_page=100 |
+ jq -sc --arg name "$JOB_NAME" \
+ '[.[].jobs[] | select(.name == $name and .conclusion == "failure")] | first // empty')
+ if [[ -n "$job" ]]; then
+ if ! gh run rerun "$(jq -r .id <<<"$run")" --job "$(jq -r .id <<<"$job")" \
+ --repo "$GITHUB_REPOSITORY"; then
+ echo "::warning::could not rerun hook cooldown for pull request #$number" >&2
+ fi
+ fi
+ done <"$RUNNER_TEMP/hook-pulls.jsonl"
diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml
new file mode 100644
index 0000000..62404e8
--- /dev/null
+++ b/reconcile-pypi-artifacts/action.yml
@@ -0,0 +1,63 @@
+name: Verify PyPI artifacts
+description: Compare every built file with PyPI and verify its trusted-publisher attestation
+inputs:
+ project:
+ description: PyPI project name
+ required: true
+ version:
+ description: PyPI release version
+ required: true
+ dist:
+ description: Directory containing the complete expected distribution set
+ required: true
+ local-tool-source:
+ description: Install release-creator from the tagged checkout
+ default: "false"
+runs:
+ using: composite
+ steps:
+ - name: Check artifact inputs
+ shell: bash
+ env:
+ PROJECT: ${{ inputs.project }}
+ VERSION: ${{ inputs.version }}
+ DIST: ${{ inputs.dist }}
+ run: |
+ [[ -n "$PROJECT" && -n "$VERSION" && -d "$DIST" ]] || exit 1
+ - name: Install locked release tool
+ uses: $/install-locked-tools
+ with:
+ groups: release-notes${{ inputs.local-tool-source == 'true' && ' project-runtime' || '' }}
+ venv: ${{ runner.temp }}/pypi-reconcile
+ local-tool-source: ${{ inputs.local-tool-source }}
+ - name: Install tagged release tool
+ if: inputs.local-tool-source == 'true'
+ shell: bash
+ env:
+ DIST: ${{ inputs.dist }}
+ run: uv pip install --python "$RUNNER_TEMP/pypi-reconcile" --no-deps "$DIST"/release_creator-*.whl
+ - name: Compare published files
+ shell: bash
+ env:
+ PROJECT: ${{ inputs.project }}
+ VERSION: ${{ inputs.version }}
+ DIST: ${{ inputs.dist }}
+ run: |
+ uv run --no-project --python "$RUNNER_TEMP/pypi-reconcile" release-creator artifacts --project "$PROJECT" \
+ --version "$VERSION" --dist "$DIST" --wait-seconds 120 >"$RUNNER_TEMP/pypi-file-urls"
+ - name: Install locked provenance verifier
+ uses: $/install-locked-tools
+ with:
+ groups: release-attestations
+ venv: ${{ runner.temp }}/pypi-attestations
+ lock-source: shared
+ - name: Verify trusted-publisher attestations
+ shell: bash
+ env:
+ REPOSITORY: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ while IFS= read -r url; do
+ uv run --no-project --python "$RUNNER_TEMP/pypi-attestations" pypi-attestations verify pypi \
+ --repository "https://github.com/$REPOSITORY" "$url"
+ done <"$RUNNER_TEMP/pypi-file-urls"
diff --git a/refresh-hook-pins/action.yml b/refresh-hook-pins/action.yml
new file mode 100644
index 0000000..e19e5db
--- /dev/null
+++ b/refresh-hook-pins/action.yml
@@ -0,0 +1,50 @@
+name: Refresh pre-commit hook pins
+description: Update cooled frozen hook revisions and exact dependencies without GitHub writes
+outputs:
+ changed:
+ description: Whether the hook configuration differs from the checkout
+ value: ${{ steps.patch.outputs.changed }}
+runs:
+ using: composite
+ steps:
+ - uses: $/install-locked-tools
+ with:
+ groups: release-hooks
+ lock-source: shared
+ venv: ${{ runner.temp }}/hook-tools
+ - name: Refresh frozen hook revisions
+ shell: bash
+ run: |
+ uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update --config .pre-commit-config.yaml \
+ --freeze --cooldown-days 7 --no-progress
+ - name: Refresh exact hook dependencies
+ shell: bash
+ run: |
+ uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks \
+ --exclude-newer '7 days' --python-version 3.15
+ - name: Run repository checks
+ shell: bash
+ run: >-
+ uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek run --config .pre-commit-config.yaml --all-files
+ - name: Check updated paths
+ shell: bash
+ run: |
+ set -euo pipefail
+ while IFS= read -r entry; do
+ path=${entry:3}
+ if [[ "$path" != .pre-commit-config.yaml ]]; then
+ echo "::error::hook update changed unexpected path: $path" >&2
+ exit 1
+ fi
+ done < <(git status --porcelain=v1 --untracked-files=all)
+ - id: patch
+ shell: bash
+ run: |
+ set -euo pipefail
+ if git diff --quiet -- .pre-commit-config.yaml; then
+ echo 'changed=false' >>"$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo 'changed=true' >>"$GITHUB_OUTPUT"
+ git diff --binary -- .pre-commit-config.yaml >"$RUNNER_TEMP/hooks.patch"
+ test -s "$RUNNER_TEMP/hooks.patch"
diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml
new file mode 100644
index 0000000..f35f3ad
--- /dev/null
+++ b/refresh-release-lock/action.yml
@@ -0,0 +1,171 @@
+name: Refresh a release-tool lock
+description: Refresh or check the Nab lock for selected release groups
+inputs:
+ lock-groups:
+ description: Space-separated dependency groups recorded in pylock.toml
+ required: true
+ additional-lock:
+ description: Optional secondary pylock basename for a separate build matrix
+ default: ""
+ additional-lock-groups:
+ description: Groups for the secondary lock; empty selects build requirements
+ default: ""
+ 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 Python implementations for the secondary lock
+ default: ""
+ mode:
+ description: Refresh the lock or check that the committed lock is current
+ default: refresh
+outputs:
+ changed:
+ description: Whether the lock or expired package cutoffs differ from the checkout
+ value: ${{ steps.lock.outputs.changed }}
+runs:
+ using: composite
+ steps:
+ - uses: $/install-nab
+ - name: Check recent package cutoffs
+ shell: bash
+ run: uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/check_recent_cutoffs.py" pyproject.toml
+ - name: Expire reviewed package cutoffs
+ if: inputs.mode == 'refresh'
+ shell: bash
+ run: |
+ set -euo pipefail
+ uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \
+ "$GITHUB_ACTION_PATH/../tools/expire_cutoffs.py" pyproject.toml
+ cp pyproject.toml "$RUNNER_TEMP/project-after-expiry.toml"
+ - name: Resolve release groups
+ shell: bash
+ env:
+ LOCK_GROUPS: ${{ inputs.lock-groups }}
+ MODE: ${{ inputs.mode }}
+ ADDITIONAL_LOCK: ${{ inputs.additional-lock }}
+ ADDITIONAL_GROUPS: ${{ inputs.additional-lock-groups }}
+ MATRIX_PYTHON: ${{ inputs.additional-matrix-python }}
+ MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }}
+ MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }}
+ 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 --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
+ if [[ -z "$ADDITIONAL_LOCK" ]]; then
+ if [[ -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS" ]]; then
+ echo '::error::secondary matrix inputs require additional-lock' >&2
+ exit 1
+ fi
+ exit 0
+ fi
+ if [[ "$ADDITIONAL_LOCK" != pylock.*.toml || "$ADDITIONAL_LOCK" == */* ||
+ ! -f "$ADDITIONAL_LOCK" || -z "$MATRIX_PYTHON" ||
+ -z "$MATRIX_PLATFORMS" || -z "$MATRIX_IMPLEMENTATIONS" ]]; then
+ echo '::error::additional-lock needs an existing pylock.*.toml and a complete matrix' >&2
+ exit 1
+ fi
+ platforms=()
+ implementations=()
+ selected_groups=()
+ while IFS= read -r line; do
+ read -ra words <<<"$line"
+ platforms+=("${words[@]}")
+ done <<<"$MATRIX_PLATFORMS"
+ while IFS= read -r line; do
+ read -ra words <<<"$line"
+ implementations+=("${words[@]}")
+ done <<<"$MATRIX_IMPLEMENTATIONS"
+ while IFS= read -r line; do
+ read -ra words <<<"$line"
+ selected_groups+=("${words[@]}")
+ done <<<"$ADDITIONAL_GROUPS"
+ if ((${#platforms[@]} == 0 || ${#implementations[@]} == 0)); then
+ echo '::error::secondary matrix needs platforms and implementations' >&2
+ exit 1
+ fi
+ selection=(--build-requirements)
+ if ((${#selected_groups[@]})); then selection=(--groups "${selected_groups[@]}"); fi
+ cp "$ADDITIONAL_LOCK" "$RUNNER_TEMP/additional-lock-before.toml"
+ uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock pyproject.toml "${selection[@]}" \
+ --project-matrix-python "$MATRIX_PYTHON" \
+ --project-matrix-platforms "${platforms[@]}" \
+ --project-matrix-implementations "${implementations[@]}" \
+ --output "$ADDITIONAL_LOCK" "${refresh[@]}"
+ python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" --environments-only \
+ "$RUNNER_TEMP/additional-lock-before.toml" "$ADDITIONAL_LOCK"
+ if [[ "$MODE" == check ]]; then
+ python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \
+ "$RUNNER_TEMP/additional-lock-before.toml" "$ADDITIONAL_LOCK"
+ fi
+ - name: Install refreshed release checks
+ if: inputs.mode == 'refresh'
+ uses: $/install-locked-tools
+ with:
+ groups: release-check
+ venv: ${{ runner.temp }}/release-tools
+ - name: Run repository checks
+ if: inputs.mode == 'refresh'
+ shell: bash
+ run: uv run --no-project --python "$RUNNER_TEMP/release-tools" prek run --all-files
+ - name: Check refreshed paths
+ if: inputs.mode == 'refresh'
+ shell: bash
+ env:
+ ADDITIONAL_LOCK: ${{ inputs.additional-lock }}
+ run: |
+ set -euo pipefail
+ cmp pyproject.toml "$RUNNER_TEMP/project-after-expiry.toml"
+ while IFS= read -r entry; do
+ path=${entry:3}
+ if [[ "$path" != pylock.toml && "$path" != pyproject.toml && "$path" != "${ADDITIONAL_LOCK:-}" ]]; then
+ echo "::error::prek changed file outside the lock and cutoff policy: $path" >&2
+ exit 1
+ fi
+ done < <(git status --porcelain=v1 --untracked-files=all)
+ - id: lock
+ shell: bash
+ env:
+ MODE: ${{ inputs.mode }}
+ ADDITIONAL_LOCK: ${{ inputs.additional-lock }}
+ run: |
+ set -euo pipefail
+ paths=(pylock.toml pyproject.toml)
+ if [[ -n "${ADDITIONAL_LOCK:-}" ]]; then paths+=("$ADDITIONAL_LOCK"); fi
+ if [[ "$MODE" == check ]]; then
+ git diff --exit-code -- "${paths[@]}"
+ echo 'changed=false' >>"$GITHUB_OUTPUT"
+ elif git diff --quiet -- "${paths[@]}"; then
+ echo 'changed=false' >>"$GITHUB_OUTPUT"
+ else
+ echo 'changed=true' >>"$GITHUB_OUTPUT"
+ git diff --binary -- "${paths[@]}" >"$RUNNER_TEMP/lock.patch"
+ test -s "$RUNNER_TEMP/lock.patch"
+ fi
diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml
new file mode 100644
index 0000000..44fce5b
--- /dev/null
+++ b/render-release-notes/action.yml
@@ -0,0 +1,71 @@
+name: Render release notes
+description: Validate a tagged changelog and render its GitHub Release body
+inputs:
+ tag:
+ description: Existing release tag
+ required: true
+ changelog:
+ description: Tracked changelog path
+ required: true
+ artifact-url:
+ description: Published ecosystem release URL
+ default: ""
+ 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: ""
+ previous:
+ description: Previous tag, or auto to calculate it from Git history
+ default: auto
+outputs:
+ previous:
+ description: Previous version tag, or an empty string for the first release
+ value: ${{ steps.previous.outputs.previous }}
+runs:
+ using: composite
+ steps:
+ - id: selected
+ if: inputs.previous == 'auto'
+ uses: $/select-previous-tag
+ with:
+ tag: ${{ inputs.tag }}
+ - id: previous
+ name: Select previous tag
+ shell: bash
+ env:
+ INPUT_PREVIOUS: ${{ inputs.previous }}
+ SELECTED_PREVIOUS: ${{ steps.selected.outputs.previous }}
+ run: |
+ if [[ "$INPUT_PREVIOUS" == auto ]]; then
+ echo "previous=$SELECTED_PREVIOUS" >>"$GITHUB_OUTPUT"
+ else
+ echo "previous=$INPUT_PREVIOUS" >>"$GITHUB_OUTPUT"
+ fi
+ - name: Render GitHub Release notes
+ shell: bash
+ env:
+ TAG: ${{ inputs.tag }}
+ 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: |
+ set -euo pipefail
+ args=(--changelog "$CHANGELOG" --tag "$TAG" --repository "$GITHUB_REPOSITORY")
+ if [[ -n "$PREVIOUS" ]]; then
+ args+=(--previous "$PREVIOUS")
+ fi
+ if [[ -n "$ARTIFACT_URL" ]]; then
+ args+=(--artifact-url "$ARTIFACT_URL" --artifact-label "$ARTIFACT_LABEL")
+ fi
+ if [[ -n "$GENERATED_NOTES" ]]; then
+ args+=(--generated-notes "$GENERATED_NOTES")
+ fi
+ release-creator notes "${args[@]}" >"$OUTPUT"
diff --git a/resolve-pr-from-run/action.yml b/resolve-pr-from-run/action.yml
new file mode 100644
index 0000000..69fa8b5
--- /dev/null
+++ b/resolve-pr-from-run/action.yml
@@ -0,0 +1,80 @@
+name: Resolve a pull request from its CI run
+description: Verify a completed read-only PR run and find its current pull request
+inputs:
+ run-id:
+ description: GitHub Actions run ID from the workflow_run event
+ required: true
+ workflow-path:
+ description: Repository-relative path of the read-only PR workflow
+ required: true
+outputs:
+ number:
+ description: Current matching pull request number, or empty for a stale run
+ value: ${{ steps.resolve.outputs.number }}
+ bot-author:
+ description: Whether the matching pull request author is a bot
+ value: ${{ steps.resolve.outputs.bot-author }}
+runs:
+ using: composite
+ steps:
+ - name: Verify the source run
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_ID: ${{ inputs.run-id }}
+ WORKFLOW_PATH: ${{ inputs.workflow-path }}
+ run: |
+ set -euo pipefail
+ case "$RUN_ID" in
+ '' | *[!0-9]*)
+ echo '::error::run-id must be a decimal integer' >&2
+ exit 1
+ ;;
+ esac
+ gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID" >"$RUNNER_TEMP/source-run.json"
+ if ! jq -e --arg repository "$GITHUB_REPOSITORY" --arg workflow "$WORKFLOW_PATH" --argjson run_id "$RUN_ID" '
+ .id == $run_id and
+ .repository.full_name == $repository and
+ .event == "pull_request" and
+ .path == $workflow and
+ .status == "completed" and
+ (.head_repository.full_name | type == "string") and
+ (.head_repository.owner.login | type == "string") and
+ (.head_branch | type == "string") and
+ (.head_sha | length == 40)
+ ' "$RUNNER_TEMP/source-run.json" >/dev/null; then
+ echo '::error::source must be a completed pull_request run from the expected workflow' >&2
+ exit 1
+ fi
+ - id: resolve
+ name: Match the current pull request
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ head_owner=$(jq -r .head_repository.owner.login "$RUNNER_TEMP/source-run.json")
+ head_branch=$(jq -r .head_branch "$RUNNER_TEMP/source-run.json")
+ head_repository=$(jq -r .head_repository.full_name "$RUNNER_TEMP/source-run.json")
+ head_sha=$(jq -r .head_sha "$RUNNER_TEMP/source-run.json")
+ gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open -f "head=$head_owner:$head_branch" -f per_page=100 \
+ >"$RUNNER_TEMP/source-pulls.json"
+ matches=$(jq --arg repository "$GITHUB_REPOSITORY" --arg head_repository "$head_repository" \
+ --arg head_sha "$head_sha" '
+ [.[] | select(
+ .state == "open" and
+ .head.sha == $head_sha and
+ .head.repo.full_name == $head_repository and
+ .base.repo.full_name == $repository
+ )]
+ ' "$RUNNER_TEMP/source-pulls.json")
+ count=$(jq 'length' <<<"$matches")
+ if [[ "$count" == 0 ]]; then
+ echo 'Source run no longer matches an open pull request'
+ exit 0
+ fi
+ if [[ "$count" != 1 ]]; then
+ echo '::error::source run matches multiple pull requests' >&2
+ exit 1
+ fi
+ jq -r '"number=\(.[0].number)", "bot-author=\(.[0].user.type == "Bot")"' <<<"$matches" >>"$GITHUB_OUTPUT"
diff --git a/select-previous-tag/action.yml b/select-previous-tag/action.yml
new file mode 100644
index 0000000..e41b2cd
--- /dev/null
+++ b/select-previous-tag/action.yml
@@ -0,0 +1,42 @@
+name: Select previous release tag
+description: Select the preceding numeric tag in the release namespace
+inputs:
+ tag:
+ description: Current release tag
+ required: true
+ package:
+ description: Package namespace in a multi-package repository
+ default: ""
+ executable:
+ description: Path to the installed release-creator executable
+ default: release-creator
+ stable-only:
+ description: Ignore prerelease tags when finding the predecessor
+ default: "false"
+outputs:
+ previous:
+ description: Preceding tag, or empty for the first release
+ value: ${{ steps.select.outputs.previous }}
+runs:
+ using: composite
+ steps:
+ - id: select
+ shell: bash
+ env:
+ TAG: ${{ inputs.tag }}
+ PACKAGE: ${{ inputs.package }}
+ EXECUTABLE: ${{ inputs.executable }}
+ STABLE_ONLY: ${{ inputs.stable-only }}
+ run: |
+ set -euo pipefail
+ target="$TAG"
+ if [[ -n "$PACKAGE" && "$TAG" != */* ]]; then target="$PACKAGE/$TAG"; fi
+ if ! git show-ref --verify --quiet "refs/tags/$target"; then
+ echo "::error::release tag not found: $target" >&2
+ exit 1
+ fi
+ args=(tags "$TAG" --previous)
+ if [[ -n "$PACKAGE" ]]; then args+=(--package "$PACKAGE"); fi
+ if [[ "$STABLE_ONLY" == true ]]; then args+=(--stable-only); fi
+ previous=$(git tag --list | "$EXECUTABLE" "${args[@]}")
+ printf 'previous=%s\n' "$previous" >>"$GITHUB_OUTPUT"
diff --git a/sign-jetbrains-plugin/action.yml b/sign-jetbrains-plugin/action.yml
new file mode 100644
index 0000000..8bb29ff
--- /dev/null
+++ b/sign-jetbrains-plugin/action.yml
@@ -0,0 +1,114 @@
+name: Sign JetBrains plugin
+description: Sign one verified plugin ZIP in a job without checkout or Gradle
+inputs:
+ archive:
+ description: Unsigned plugin ZIP from the keyless build
+ required: true
+ sha256:
+ description: SHA256 recorded by the keyless build before artifact upload
+ required: true
+ certificate-chain:
+ description: JetBrains signing certificate chain in PEM or base64 form
+ required: true
+ private-key:
+ description: Signing private key in PEM or base64 form
+ required: true
+ private-key-password:
+ description: Password for an encrypted signing private key
+ required: false
+outputs:
+ archive:
+ description: Path to the signed plugin ZIP
+ value: ${{ steps.digest.outputs.archive }}
+ sha256:
+ description: SHA256 of the signed plugin ZIP
+ value: ${{ steps.digest.outputs.sha256 }}
+runs:
+ using: composite
+ steps:
+ - name: Verify unsigned plugin ZIP
+ id: unsigned
+ shell: bash
+ env:
+ ARCHIVE: ${{ inputs.archive }}
+ EXPECTED_SHA: ${{ inputs.sha256 }}
+ run: |
+ set -euo pipefail
+ [[ -f "$ARCHIVE" && ! -L "$ARCHIVE" ]] || exit 1
+ case "$ARCHIVE" in *.zip) ;; *) exit 1 ;; esac
+ test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA"
+ unzip -tq "$ARCHIVE" >/dev/null
+ test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)"
+ echo "archive=${ARCHIVE%.zip}-signed.zip" >>"$GITHUB_OUTPUT"
+ - name: Download pinned Marketplace signer
+ shell: bash
+ env:
+ SIGNER_BASE_URL: https://repo.maven.apache.org/maven2/org/jetbrains/marketplace-zip-signer/0.1.43
+ run: |
+ curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
+ --output "$RUNNER_TEMP/marketplace-signer-cli.jar" \
+ "$SIGNER_BASE_URL/marketplace-zip-signer-0.1.43-cli.jar"
+ - name: Verify Marketplace signer digest
+ shell: bash
+ env:
+ SIGNER_SHA256: 2958a0f42221d6062b50f22754e413ae5cc42f60c441467202c6700df2e22f44
+ run: echo "$SIGNER_SHA256 $RUNNER_TEMP/marketplace-signer-cli.jar" | sha256sum --check
+ - name: Check Java 17
+ shell: bash
+ run: test -x "$JAVA_HOME_17_X64/bin/java"
+ - name: Sign and verify plugin ZIP
+ shell: bash
+ env:
+ ARCHIVE: ${{ inputs.archive }}
+ SIGNED: ${{ steps.unsigned.outputs.archive }}
+ CERTIFICATE_CHAIN: ${{ inputs.certificate-chain }}
+ PRIVATE_KEY: ${{ inputs.private-key }}
+ PRIVATE_KEY_PASSWORD: ${{ inputs.private-key-password }}
+ run: |
+ set -euo pipefail
+ umask 077
+ if [[ -e "$SIGNED" || -L "$SIGNED" ]]; then
+ echo '::error::signed archive already exists' >&2
+ exit 1
+ fi
+ trap 'rm -f "$RUNNER_TEMP"/signing-{key,key-decrypted,chain}.pem' EXIT
+ if [[ "$CERTIFICATE_CHAIN" == *'-----BEGIN CERTIFICATE-----'* ]]; then
+ printf '%s' "$CERTIFICATE_CHAIN" >"$RUNNER_TEMP/signing-chain.pem"
+ else
+ printf '%s' "$CERTIFICATE_CHAIN" | base64 --decode >"$RUNNER_TEMP/signing-chain.pem"
+ fi
+ if [[ "$PRIVATE_KEY" == *'-----BEGIN '* ]]; then
+ printf '%s' "$PRIVATE_KEY" >"$RUNNER_TEMP/signing-key.pem"
+ else
+ printf '%s' "$PRIVATE_KEY" | base64 --decode >"$RUNNER_TEMP/signing-key.pem"
+ fi
+ while IFS= read -r line; do
+ [[ -z "$line" ]] || echo "::add-mask::$line"
+ done <"$RUNNER_TEMP/signing-key.pem"
+ signing_key="$RUNNER_TEMP/signing-key.pem"
+ if [[ -n "$PRIVATE_KEY_PASSWORD" ]]; then
+ openssl pkey -in "$signing_key" -out "$RUNNER_TEMP/signing-key-decrypted.pem" \
+ -passin env:PRIVATE_KEY_PASSWORD
+ while IFS= read -r line; do
+ [[ -z "$line" ]] || echo "::add-mask::$line"
+ done <"$RUNNER_TEMP/signing-key-decrypted.pem"
+ signing_key="$RUNNER_TEMP/signing-key-decrypted.pem"
+ fi
+ "$JAVA_HOME_17_X64/bin/java" -jar "$RUNNER_TEMP/marketplace-signer-cli.jar" sign \
+ -in "$ARCHIVE" -out "$SIGNED" -cert-file "$RUNNER_TEMP/signing-chain.pem" \
+ -key-file "$signing_key"
+ "$JAVA_HOME_17_X64/bin/java" -jar "$RUNNER_TEMP/marketplace-signer-cli.jar" verify \
+ -in "$SIGNED" -cert "$RUNNER_TEMP/signing-chain.pem"
+ - name: Record signed plugin ZIP
+ id: digest
+ shell: bash
+ env:
+ SIGNED: ${{ steps.unsigned.outputs.archive }}
+ run: |
+ set -euo pipefail
+ [[ -f "$SIGNED" && ! -L "$SIGNED" ]] || exit 1
+ unzip -tq "$SIGNED" >/dev/null
+ test -z "$(zipinfo -1 "$SIGNED" | LC_ALL=C sort | uniq -d)"
+ read -r digest _ < <(sha256sum "$SIGNED")
+ echo "archive=$SIGNED" >>"$GITHUB_OUTPUT"
+ echo "sha256=$digest" >>"$GITHUB_OUTPUT"
diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py
new file mode 100644
index 0000000..76322fd
--- /dev/null
+++ b/tests/build_python_distribution/test_build.py
@@ -0,0 +1,187 @@
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import tomllib
+from pathlib import Path
+
+import pytest
+from conftest import action, run
+
+
+@pytest.fixture
+def build_repository(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ monkeypatch.delenv("SSL_CERT_FILE", raising=False)
+ project = source_project / "pyproject.toml"
+ release_requirement = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())[
+ "dependency-groups"
+ ]["release-notes"][0]
+ project.write_text(
+ project.read_text()
+ + f"\n[dependency-groups]\nrelease-notes = [{json.dumps(release_requirement)}]\n"
+ + '\n[tool.nab]\nbase-group = "project-runtime"\nbuild-group = "build"\n'
+ )
+ run(
+ source_project,
+ str(nab_executable),
+ "lock",
+ "--groups",
+ "release-notes",
+ "--output",
+ "pylock.toml",
+ )
+ run(source_project, "git", "add", ".")
+ run(source_project, "git", "commit", "-m", "Add package source")
+ run(source_project, "git", "push", "origin", "main")
+ return source_project
+
+
+def test_build_python_distribution_creates_wheel_and_sdist(
+ build_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ result = action(
+ build_repository,
+ tmp_path,
+ action_scripts,
+ "build-python-distribution",
+ version="0.2.0",
+ SOURCE=".",
+ RUNNER_TEMP=str(tmp_path),
+ )
+ assert result.returncode == 0, result.stderr
+ assert {path.name for path in (build_repository / "dist").iterdir() if path.suffix in {".whl", ".gz"}} == {
+ "fixture-0.2.0-py3-none-any.whl",
+ "fixture-0.2.0.tar.gz",
+ }
+ assert next(tmp_path.glob("build-constraints-*.txt")).read_text().count("hatchling==") == 1
+
+
+def test_build_python_distribution_repeats_exact_bytes(
+ build_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ outputs: list[dict[str, bytes]] = []
+ for name in ("first", "second"):
+ temporary = tmp_path / name
+ temporary.mkdir()
+ result = action(
+ build_repository,
+ temporary,
+ action_scripts,
+ "build-python-distribution",
+ version="0.2.0",
+ SOURCE=".",
+ RUNNER_TEMP=str(temporary),
+ )
+ assert result.returncode == 0, result.stderr
+ outputs.append({path.name: path.read_bytes() for path in (build_repository / "dist").iterdir()})
+ shutil.rmtree(build_repository / "dist")
+ assert outputs[0] == outputs[1]
+
+
+def test_build_python_distribution_rejects_dirty_source(
+ build_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ (build_repository / "pyproject.toml").write_text((build_repository / "pyproject.toml").read_text() + "\n")
+ result = action(
+ build_repository,
+ tmp_path,
+ action_scripts,
+ "build-python-distribution",
+ version="0.2.0",
+ SOURCE=".",
+ RUNNER_TEMP=str(tmp_path),
+ )
+ assert "tagged source is dirty" in result.stderr
+
+
+def test_build_python_distribution_rejects_existing_artifact(
+ build_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ (build_repository / ".gitignore").write_text("dist/\n")
+ run(build_repository, "git", "add", ".gitignore")
+ run(build_repository, "git", "commit", "-m", "Ignore build output")
+ output = build_repository / "dist"
+ output.mkdir()
+ (output / "old.whl").write_bytes(b"old")
+ result = action(
+ build_repository,
+ tmp_path,
+ action_scripts,
+ "build-python-distribution",
+ version="0.2.0",
+ SOURCE=".",
+ RUNNER_TEMP=str(tmp_path),
+ )
+ assert result.returncode != 0
+ assert "distribution output directory is not empty" in result.stderr
+
+
+def test_build_python_distribution_rejects_backend_hash_mismatch(
+ build_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ lock = build_repository / "pylock.toml"
+ contents = lock.read_text()
+ package = next(package for package in tomllib.loads(contents)["packages"] if package["name"] == "hatchling")
+ for artifact in [package["sdist"], *package["wheels"]]:
+ contents = contents.replace(artifact["hashes"]["sha256"], "0" * 64, 1)
+ lock.write_text(contents)
+ run(build_repository, "git", "add", "pylock.toml")
+ run(build_repository, "git", "commit", "-m", "Corrupt backend hash")
+ result = action(
+ build_repository,
+ tmp_path,
+ action_scripts,
+ "build-python-distribution",
+ version="0.2.0",
+ SOURCE=".",
+ RUNNER_TEMP=str(tmp_path),
+ UV_CACHE_DIR=str(tmp_path / "uv-cache"),
+ )
+ assert result.returncode != 0
+ assert "Hash mismatch" in result.stderr
+
+
+def test_build_python_distribution_does_not_run_hooks(
+ build_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ config = build_repository / ".pre-commit-config.yaml"
+ config.write_text(config.read_text().replace("entry: bash -c true", "entry: bash -c 'touch generated-file'"))
+ run(build_repository, "git", "add", ".")
+ run(build_repository, "git", "commit", "-m", "Create an untracked output")
+ result = action(
+ build_repository,
+ tmp_path,
+ action_scripts,
+ "build-python-distribution",
+ version="0.2.0",
+ SOURCE=".",
+ RUNNER_TEMP=str(tmp_path),
+ )
+ assert result.returncode == 0, result.stderr
+ assert not (build_repository / "generated-file").exists()
+
+
+def test_build_python_distribution_stops_when_interpreter_lookup_fails(
+ build_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ tools = tmp_path / "bin"
+ tools.mkdir()
+ uv = shutil.which("uv")
+ assert uv is not None
+ (tools / "uv").write_text(
+ '#!/usr/bin/env bash\nif [[ "$1" == python && "$2" == find ]]; then exit 19; fi\n'
+ 'if [[ "$1" == build ]]; then printf "build called\\n" >>"$RUNNER_TEMP/build-calls"; fi\n'
+ f'exec "{uv}" "$@"\n',
+ )
+ (tools / "uv").chmod(0o755)
+ result = action(
+ build_repository,
+ tmp_path,
+ action_scripts,
+ "build-python-distribution",
+ version="0.2.0",
+ PATH=f"{tools}:{os.environ['PATH']}",
+ )
+ assert result.returncode == 19, result.stderr
+ assert not (tmp_path / "build-calls").exists()
diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py
new file mode 100644
index 0000000..0c11af3
--- /dev/null
+++ b/tests/check_hook_update_cooldown/test_cooldown.py
@@ -0,0 +1,422 @@
+from __future__ import annotations
+
+import base64
+import json
+import os
+import subprocess
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+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 }}",
+ ]
+
+
+@pytest.mark.parametrize(
+ ("author", "revision", "frozen_base", "accepted"),
+ [
+ pytest.param("gaborbernat", "v2", False, True, id="human"),
+ pytest.param("pre-commit-ci[bot]", "v1", False, True, id="cooled-revision-on-new-head"),
+ pytest.param("pre-commit-ci[bot]", "v0.5", False, True, id="older-cooled-revision"),
+ pytest.param("pre-commit-ci[bot]", "1.0", False, True, id="cooled-alias"),
+ pytest.param("pre-commit-ci[bot]", "sha-v0.5", True, True, id="older-frozen-revision"),
+ pytest.param("pre-commit-ci[bot]", "v1", True, True, id="frozen-base"),
+ pytest.param("pre-commit-ci[bot]", "v2", False, False, id="young-revision-on-old-pr"),
+ pytest.param("release-app[bot]", "v1", False, True, id="app-cooled-revision"),
+ pytest.param("release-app[bot]", "v2", False, False, id="app-young-revision"),
+ ],
+)
+def test_cooldown_checks_hook_revision(
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ author: str,
+ revision: str,
+ frozen_base: bool,
+ accepted: bool,
+) -> None:
+ server, environment = github_api
+ repository = hook_repository(tmp_path)
+ expect_pull(server, author=author)
+ if author.endswith("[bot]"):
+ base_revision = (
+ subprocess.run(
+ ["git", "-C", str(repository), "rev-parse", "v0^{}"],
+ text=True,
+ capture_output=True,
+ check=True,
+ ).stdout.strip()
+ if frozen_base
+ else "v0"
+ )
+ if revision == "sha-v0.5":
+ revision = subprocess.run(
+ ["git", "-C", str(repository), "rev-parse", "v0.5^{}"],
+ text=True,
+ capture_output=True,
+ check=True,
+ ).stdout.strip()
+ expect_config(server, "b" * 40, repository, base_revision)
+ expect_config(server, "a" * 40, repository, revision)
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert (result.returncode == 0) is accepted, result.stderr
+ assert [request.path for request, _ in server.log] == [
+ "/api/v3/repos/tox-dev/fixture/pulls/7",
+ *(
+ [
+ "/api/v3/repos/tox-dev/fixture/pulls/7/files",
+ "/api/v3/repos/tox-dev/fixture/actions/runs",
+ "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml",
+ "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml",
+ ]
+ if author.endswith("[bot]")
+ else []
+ ),
+ ]
+
+
+def test_cooldown_skips_bot_without_hook_changes(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ expect_pull(server, author="dependabot[bot]", hook_update=False)
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode == 0, result.stderr
+ assert [request.path for request, _ in server.log] == [
+ "/api/v3/repos/tox-dev/fixture/pulls/7",
+ "/api/v3/repos/tox-dev/fixture/pulls/7/files",
+ ]
+
+
+def test_cooldown_rejects_changed_head(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ expect_pull(server, author="pre-commit-ci[bot]", head="c" * 40)
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode != 0
+ assert len(server.log) == 1
+
+
+def test_cooldown_rejects_backdated_tag_on_recent_pull_request(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ expect_pull(server, author="pre-commit-ci[bot]", observed_days=0, updated_days=10)
+ repository = hook_repository(tmp_path)
+ expect_config(server, "b" * 40, repository, "v0")
+ expect_config(server, "a" * 40, repository, "v2")
+ expect_historical_runs(server, older_head=None)
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode != 0
+ assert "hook revision has not been observed" in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("historical_revision", "accepted"),
+ [pytest.param("v1", True, id="same-revision"), pytest.param("v0", False, id="new-revision")],
+)
+def test_cooldown_survives_force_push_without_accepting_new_revision(
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ historical_revision: str,
+ accepted: bool,
+) -> None:
+ server, environment = github_api
+ repository = hook_repository(tmp_path)
+ expect_pull(server, author="pre-commit-ci[bot]", observed_days=0)
+ expect_config(server, "b" * 40, repository, "v0")
+ expect_config(server, "a" * 40, repository, "v1")
+ expect_historical_runs(server, older_head="c" * 40)
+ expect_config(server, "c" * 40, repository, historical_revision)
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert (result.returncode == 0) is accepted, result.stderr
+ branch_runs = [
+ request for request, _ in server.log if request.path.endswith("/actions/runs") and "branch" in request.args
+ ]
+ assert len(branch_runs) == 1
+ cutoff = datetime.fromisoformat(branch_runs[0].args["created"].removeprefix("<"))
+ assert timedelta(days=6, hours=23) < datetime.now(UTC) - cutoff < timedelta(days=7, hours=1)
+ if not accepted:
+ assert "hook revision has not been observed" in result.stderr
+
+
+def test_cooldown_skips_unavailable_old_head(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ repository = hook_repository(tmp_path)
+ expect_pull(server, author="pre-commit-ci[bot]", observed_days=0)
+ expect_config(server, "b" * 40, repository, "v0")
+ expect_config(server, "a" * 40, repository, "v1")
+ expect_historical_runs(server, older_head=("c" * 40, "d" * 40))
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml",
+ method="GET",
+ query_string=f"ref={'c' * 40}",
+ ).respond_with_json({"message": "Not Found"}, status=404)
+ expect_config(server, "d" * 40, repository, "v1")
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode == 0, result.stderr
+
+
+def test_cooldown_skips_malformed_old_head(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ repository = hook_repository(tmp_path)
+ expect_pull(server, author="pre-commit-ci[bot]", observed_days=0)
+ expect_config(server, "b" * 40, repository, "v0")
+ expect_config(server, "a" * 40, repository, "v1")
+ expect_historical_runs(server, older_head=("c" * 40, "d" * 40))
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml",
+ method="GET",
+ query_string=f"ref={'c' * 40}",
+ ).respond_with_json({"content": base64.b64encode(b"repos: [").decode()})
+ expect_config(server, "d" * 40, repository, "v1")
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode == 0, result.stderr
+
+
+def test_cooldown_matches_revision_after_repository_list_change(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ repository = hook_repository(tmp_path)
+ expect_pull(server, author="pre-commit-ci[bot]", observed_days=0)
+ expect_config(server, "b" * 40, repository, "v0")
+ expect_config(server, "a" * 40, repository, "v1")
+ expect_historical_runs(server, older_head="c" * 40)
+ historical = (
+ "repos:\n"
+ " - repo: https://example.invalid/another-hook\n rev: v2\n hooks: []\n"
+ f" - repo: {repository.as_uri()}\n rev: v1\n hooks:\n - id: sample\n"
+ )
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml",
+ method="GET",
+ query_string=f"ref={'c' * 40}",
+ ).respond_with_json({"content": base64.b64encode(historical.encode()).decode()})
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode == 0, result.stderr
+
+
+def test_cooldown_ignores_another_pull_request_history(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ repository = hook_repository(tmp_path)
+ expect_pull(server, author="pre-commit-ci[bot]", observed_days=0)
+ expect_config(server, "b" * 40, repository, "v0")
+ expect_config(server, "a" * 40, repository, "v1")
+ expect_historical_runs(server, older_head="c" * 40, pull_request=8)
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode != 0
+ assert "hook revision has not been observed" in result.stderr
+
+
+def test_cooldown_checks_each_duplicate_repository(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ repository = hook_repository(tmp_path)
+ expect_pull(server, author="pre-commit-ci[bot]")
+ expect_config(server, "b" * 40, repository, ("v0", "v0"))
+ expect_config(server, "a" * 40, repository, ("v2", "v0"))
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode != 0
+ assert "hook repository entries changed" not in result.stderr
+ assert "hook revision is not eligible" in result.stderr
+
+
+def test_cooldown_does_not_use_another_duplicate_repository_observation(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ repository = hook_repository(tmp_path)
+ expect_pull(server, author="pre-commit-ci[bot]", observed_days=0)
+ expect_config(server, "b" * 40, repository, ("v0", "v0"))
+ expect_config(server, "a" * 40, repository, ("v1", "v0"))
+ expect_historical_runs(server, older_head="c" * 40)
+ expect_config(server, "c" * 40, repository, ("v0", "v1"))
+ result = run_cooldown(action_scripts, tmp_path, environment)
+ assert result.returncode != 0
+ assert "hook revision has not been observed" in result.stderr
+
+
+def test_cooldown_rejects_other_event_before_api(
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+) -> None:
+ server, environment = github_api
+ result = run_cooldown(
+ action_scripts,
+ tmp_path,
+ environment,
+ event_name="workflow_dispatch",
+ )
+ assert result.returncode != 0
+ assert not server.log
+
+
+def run_cooldown(
+ action_scripts: Path,
+ tmp_path: Path,
+ environment: dict[str, str],
+ *,
+ event_name: str = "pull_request",
+ event_head: str = "a" * 40,
+) -> subprocess.CompletedProcess[str]:
+ path = tmp_path / "event.json"
+ path.write_text(json.dumps({"number": 7, "pull_request": {"head": {"sha": event_head}}}))
+ (tmp_path / "pylock.toml").write_text(
+ 'lock-version = "1.0"\ncreated-by = "test"\nrequires-python = ">=3.15"\npackages = []\n',
+ )
+ return action(
+ tmp_path,
+ tmp_path,
+ action_scripts,
+ "check-hook-update-cooldown",
+ version="",
+ MINIMUM_DAYS="7",
+ GITHUB_EVENT_NAME=event_name,
+ GITHUB_EVENT_PATH=str(path),
+ GITHUB_OUTPUT=str(tmp_path / "outputs"),
+ **environment,
+ )
+
+
+def expect_pull(
+ server: HTTPServer,
+ *,
+ author: str,
+ head: str = "a" * 40,
+ observed_days: int = 10,
+ updated_days: int = 0,
+ hook_update: bool = True,
+) -> None:
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json(
+ {
+ "state": "open",
+ "base": {"repo": {"full_name": "tox-dev/fixture"}, "sha": "b" * 40},
+ "head": {"sha": head, "ref": "bot-update"},
+ "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"},
+ "updated_at": (datetime.now(UTC) - timedelta(days=updated_days)).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+ )
+ if author.endswith("[bot]"):
+ 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"}]
+ )
+ if author.endswith("[bot]") and hook_update:
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/actions/runs",
+ method="GET",
+ query_string=f"event=pull_request&head_sha={head}&per_page=100",
+ ).respond_with_json(
+ {
+ "workflow_runs": [
+ {
+ "event": "pull_request",
+ "head_repository": {"full_name": "tox-dev/fixture"},
+ "head_branch": "bot-update",
+ "head_sha": head,
+ "pull_requests": [{"number": 7}],
+ "created_at": (datetime.now(UTC) - timedelta(days=observed_days)).strftime(
+ "%Y-%m-%dT%H:%M:%SZ"
+ ),
+ }
+ ]
+ }
+ )
+
+
+def expect_config(server: HTTPServer, sha: str, repository: Path, revision: str | tuple[str, ...]) -> None:
+ revisions = (revision,) if isinstance(revision, str) else revision
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", method="GET", query_string=f"ref={sha}"
+ ).respond_with_json(
+ {
+ "content": base64.b64encode(
+ (
+ "repos:\n"
+ + "".join(
+ f" - repo: {repository.as_uri()}\n rev: {item}\n hooks:\n - id: sample\n"
+ for item in revisions
+ )
+ ).encode()
+ ).decode()
+ }
+ )
+
+
+def expect_historical_runs(
+ server: HTTPServer, *, older_head: str | tuple[str, ...] | None, pull_request: int = 7
+) -> None:
+ heads = () if older_head is None else (older_head,) if isinstance(older_head, str) else older_head
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/actions/runs",
+ method="GET",
+ ).respond_with_json(
+ {
+ "workflow_runs": [
+ {
+ "event": "pull_request",
+ "head_repository": {"full_name": "tox-dev/fixture"},
+ "head_branch": "bot-update",
+ "head_sha": head,
+ "pull_requests": [{"number": pull_request}],
+ "created_at": (datetime.now(UTC) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+ for head in heads
+ ]
+ }
+ )
+
+
+def hook_repository(tmp_path: Path) -> Path:
+ repository = tmp_path / "hook-repo"
+ repository.mkdir()
+ subprocess.run(["git", "init", "-q", str(repository)], check=True)
+ subprocess.run(["git", "-C", str(repository), "config", "user.name", "Hook test"], check=True)
+ subprocess.run(["git", "-C", str(repository), "config", "user.email", "hooks@example.org"], check=True)
+ (repository / ".pre-commit-hooks.yaml").write_text(
+ "- id: sample\n name: sample\n entry: true\n language: system\n"
+ )
+ subprocess.run(["git", "-C", str(repository), "add", "."], check=True)
+ subprocess.run(["git", "-C", str(repository), "commit", "-qm", "Add hook"], check=True)
+ for name, days in (("v0", 20), ("v0.5", 15), ("v1", 10), ("v2", 1)):
+ date = (datetime.now(UTC) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
+ (repository / "version").write_text(name)
+ subprocess.run(["git", "-C", str(repository), "add", "version"], check=True)
+ subprocess.run(
+ ["git", "-C", str(repository), "commit", "-qm", name],
+ env=os.environ | {"GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date},
+ check=True,
+ )
+ subprocess.run(
+ ["git", "-C", str(repository), "tag", "-a", name, "-m", name],
+ env=os.environ | {"GIT_COMMITTER_DATE": date},
+ check=True,
+ )
+ subprocess.run(
+ ["git", "-C", str(repository), "tag", "-a", "1.0", "v1^{}", "-m", "1.0"],
+ env=os.environ
+ | {"GIT_COMMITTER_DATE": (datetime.now(UTC) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%SZ")},
+ check=True,
+ )
+ return repository
diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py
new file mode 100644
index 0000000..983b7fe
--- /dev/null
+++ b/tests/check_hook_update_cooldown/test_workflow.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+import yaml
+
+
+def test_cooldown_job_is_read_only_and_required() -> None:
+ workflow = yaml.load(
+ (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(),
+ Loader=yaml.BaseLoader,
+ )
+ job = workflow["jobs"]["hook-cooldown"]
+ assert job["if"] == "github.event_name == 'pull_request'"
+ assert job["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"}
+ assert job["steps"][0] == {"uses": "$/check-hook-update-cooldown"}
+ assert "hook-cooldown" in workflow["jobs"]["gate"]["needs"]
+
+
+def test_cooldown_installs_only_required_hook_tools() -> None:
+ action = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text())
+ assert next(step for step in action["runs"]["steps"] if step.get("uses") == "$/install-locked-tools") == {
+ "uses": "$/install-locked-tools",
+ "if": "steps.identity.outputs.bot == 'true'",
+ "with": {"groups": "release-hook-check", "lock-source": "shared", "venv": "${{ runner.temp }}/hook-tools"},
+ }
+
+
+@pytest.mark.parametrize(
+ ("event_name", "cooldown_result", "accepted"),
+ [
+ pytest.param("pull_request", "success", True, id="cooled-pr"),
+ pytest.param("pull_request", "skipped", False, id="skipped-pr"),
+ pytest.param("pull_request", "failure", False, id="young-bot-pr"),
+ pytest.param("push", "skipped", True, id="push"),
+ ],
+)
+def test_required_gate_checks_cooldown_result(event_name: str, cooldown_result: str, accepted: bool) -> None:
+ workflow = yaml.load(
+ (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(),
+ Loader=yaml.BaseLoader,
+ )
+ script = workflow["jobs"]["gate"]["steps"][0]["run"]
+ needs = {
+ name: {
+ "result": cooldown_result
+ if name == "hook-cooldown"
+ else "skipped"
+ if event_name == "push" and name in {"changelog", "hook-cooldown", "release-tools-preview"}
+ else "success"
+ }
+ for name in workflow["jobs"]["gate"]["needs"]
+ }
+ result = subprocess.run(
+ ["bash", "-c", script],
+ env=os.environ | {"NEEDS_JSON": json.dumps(needs), "EVENT_NAME": event_name},
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert (result.returncode == 0) is accepted, result.stderr
diff --git a/tests/commit_and_tag/test_commit.py b/tests/commit_and_tag/test_commit.py
index c52fc6c..e352271 100644
--- a/tests/commit_and_tag/test_commit.py
+++ b/tests/commit_and_tag/test_commit.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import os
-lazy from pathlib import Path
+from pathlib import Path
import pytest
from conftest import action, run
@@ -11,13 +11,9 @@
"annotated",
[pytest.param("false", id="lightweight"), pytest.param("true", id="annotated")],
)
-@pytest.mark.parametrize("branch", [pytest.param("main", id="main"), pytest.param("release", id="release-branch")])
def test_commit_and_tag_pushes_atomically(
- repository: Path, tmp_path: Path, action_scripts: Path, annotated: str, branch: str
+ repository: Path, tmp_path: Path, action_scripts: Path, annotated: str
) -> None:
- if branch != "main":
- run(repository, "git", "checkout", "-b", branch)
- run(repository, "git", "push", "origin", f"HEAD:{branch}")
assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
run(repository, "git", "reset", "--hard", "HEAD")
assert (
@@ -27,7 +23,6 @@ def test_commit_and_tag_pushes_atomically(
action_scripts,
"validate-release-patch",
version="0.2.0",
- BRANCH=branch,
).returncode
== 0
)
@@ -41,15 +36,14 @@ def test_commit_and_tag_pushes_atomically(
"commit-and-tag",
version="0.2.0",
APP_SLUG="release-test",
- GH_TOKEN="test-token", # ruff: ignore[hardcoded-password-func-arg] # Local Git fixture never sends this token.
+ TOKEN="test-token", # ruff: ignore[hardcoded-password-func-arg] # Local Git fixture never sends this token.
MESSAGE="release 0.2.0",
ANNOTATED=annotated,
- BRANCH=branch,
PATH=f"{tmp_path}:{os.environ['PATH']}",
)
assert result.returncode == 0, result.stderr
assert run(repository, "git", "ls-remote", "origin", "refs/tags/0.2.0").stdout.strip()
- assert run(repository, "git", "ls-remote", "origin", f"refs/heads/{branch}").stdout.strip()
+ assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout.strip()
assert run(repository, "git", "cat-file", "-t", "refs/tags/0.2.0").stdout.strip() == (
"tag" if annotated == "true" else "commit"
)
@@ -127,10 +121,33 @@ def test_commit_and_tag_keeps_remote_unchanged_on_rejection(
"commit-and-tag",
version="0.2.0",
APP_SLUG="release-test",
- GH_TOKEN="test-token",
+ TOKEN="test-token",
MESSAGE="release 0.2.0",
PATH=f"{tmp_path}:{os.environ['PATH']}",
)
assert result.returncode != 0
assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == before
assert not run(repository, "git", "ls-remote", "origin", "refs/tags/0.2.0").stdout
+
+
+def test_commit_and_tag_rejects_missing_token_without_remote_change(
+ repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+ run(repository, "git", "reset", "--hard", "HEAD")
+ assert action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0").returncode == 0
+ before = run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "commit-and-tag",
+ version="0.2.0",
+ APP_SLUG="release-test",
+ TOKEN="",
+ GH_TOKEN="ambient-test-token", # ruff: ignore[hardcoded-password-func-arg] # No token reaches GitHub.
+ )
+ assert result.returncode != 0
+ assert "repository App token and slug are required" in result.stderr
+ assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == before
+ assert not run(repository, "git", "ls-remote", "origin", "refs/tags/0.2.0").stdout
diff --git a/tests/conftest.py b/tests/conftest.py
index 50d3fea..c21ef57 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,12 +1,44 @@
from __future__ import annotations
+import json
import os
+import ssl
import subprocess
+import sys
+from collections.abc import Iterator, Mapping
from pathlib import Path
-from uuid import uuid4
+from textwrap import dedent
+from typing import Final
import pytest
+import trustme
import yaml
+from pytest_httpserver import HTTPServer
+from werkzeug.wrappers import Request, Response
+
+
+@pytest.fixture
+def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]:
+ authority = trustme.CA()
+ certificate = authority.issue_cert("localhost")
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ certificate.configure_cert(context)
+ server = HTTPServer(host="localhost", ssl_context=context)
+ server.start()
+ bundle = tmp_path / "ca.pem"
+ authority.cert_pem.write_to_path(bundle)
+ if system_certs := ssl.get_default_verify_paths().cafile:
+ bundle.write_bytes(bundle.read_bytes() + Path(system_certs).read_bytes())
+ yield (
+ server,
+ {
+ "GH_HOST": f"localhost:{server.port}",
+ "GH_ENTERPRISE_TOKEN": "test-token",
+ "SSL_CERT_FILE": str(bundle),
+ "NO_PROXY": "localhost,127.0.0.1",
+ },
+ )
+ server.stop()
@pytest.fixture(scope="session")
@@ -17,17 +49,66 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path:
"prepare-towncrier",
"validate-release-patch",
"commit-and-tag",
+ "build-python-distribution",
+ "validate-python-distribution",
+ "render-release-notes",
+ "select-previous-tag",
+ "validate-news",
+ "label-bot-news",
+ "resolve-pr-from-run",
+ "refresh-release-lock",
+ "install-locked-tools",
+ "install-nab",
+ "export-build-constraints",
+ "read-towncrier-config",
+ "refresh-hook-pins",
+ "publish-github-release",
+ "reconcile-pypi-artifacts",
+ "publish-jetbrains-marketplace",
+ "sign-jetbrains-plugin",
+ "check-hook-update-cooldown",
+ "recheck-hook-update-cooldown",
+ "publish-post-release-pr",
):
action = yaml.safe_load((source / action_name / "action.yml").read_text())
for index, step in enumerate(action["runs"]["steps"]):
+ if "run" not in step:
+ continue
path = scripts / f"{action_name}-{index}.sh"
path.write_text(f"#!/usr/bin/env bash\n{step['run']}")
path.chmod(0o755)
return scripts
+@pytest.fixture(scope="session")
+def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ root = Path(__file__).resolve().parents[1]
+ environment = tmp_path_factory.mktemp("nab-tools")
+ subprocess.run(["uv", "venv", "--python", "3.15", str(environment)], check=True, capture_output=True)
+ subprocess.run(
+ [
+ "uv",
+ "pip",
+ "sync",
+ "--preview-features",
+ "pylock",
+ "--python",
+ str(environment / "bin/python"),
+ "--group",
+ "lock-tools",
+ "--only-binary",
+ ":all:",
+ "--require-hashes",
+ str(root / "pylock.toml"),
+ ],
+ check=True,
+ capture_output=True,
+ )
+ return environment / "bin/nab"
+
+
@pytest.fixture
-def repository(tmp_path: Path) -> Path:
+def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], monkeypatch: pytest.MonkeyPatch) -> Path:
remote = tmp_path / "remote.git"
run(tmp_path, "git", "init", "--bare", str(remote))
worktree = tmp_path / "worktree"
@@ -43,20 +124,79 @@ def repository(tmp_path: Path) -> Path:
(worktree / ".pre-commit-config.yaml").write_text(
"repos:\n - repo: local\n hooks:\n - id: release-check\n"
" name: release-check\n entry: bash -c true\n"
- " language: system\n pass_filenames: false\n",
+ " language: system\n pass_filenames: false\n"
+ " - id: mdformat\n name: mdformat\n entry: mdformat --wrap 120\n"
+ " language: system\n types: [markdown]\n",
)
(worktree / "CHANGELOG.md").write_text("# Changelog\n\n\n")
(worktree / "changelog.d").mkdir()
run(worktree, "git", "add", ".")
run(worktree, "git", "commit", "-m", "Initial files")
run(worktree, "git", "tag", "0.1.0")
- (worktree / "changelog.d/feature.feature.md").write_text("Add a new feature.\n")
+ (worktree / "changelog.d/+feature.feature.md").write_text("Add a new feature.\n")
run(worktree, "git", "add", ".")
- run(worktree, "git", "commit", "-m", "Add feature")
+ run(worktree, "git", "commit", "-m", "Add feature (#2)")
run(worktree, "git", "push", "-u", "origin", "main", "--tags")
+ server, environment = github_api
+ for name, value in environment.items():
+ monkeypatch.setenv(name, value)
+
+ def associated_pulls(request: Request) -> Response:
+ query = request.get_json()["query"]
+ commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:])
+ return Response(
+ json.dumps(
+ {
+ "data": {
+ "repository": {
+ f"c{index}": {
+ "associatedPullRequests": {
+ "nodes": [
+ {
+ "number": int(environment.get("TEST_PULL_NUMBER", "2")),
+ "mergedAt": "2026-10-22T00:00:00Z",
+ "mergeCommit": {"oid": commit},
+ "author": {"login": "gaborbernat", "__typename": "User"},
+ }
+ ]
+ }
+ }
+ for index, commit in enumerate(commits)
+ }
+ }
+ }
+ ),
+ mimetype="application/json",
+ )
+
+ server.expect_request("/api/graphql", method="POST").respond_with_handler(associated_pulls)
return worktree
+@pytest.fixture
+def source_project(repository: Path) -> Path:
+ project = repository / "pyproject.toml"
+ project.write_text(
+ project.read_text()
+ + dedent(
+ """
+ [project]
+ name = "fixture"
+ version = "0.2.0"
+ requires-python = ">=3.15"
+
+ [build-system]
+ requires = ["hatchling"]
+ build-backend = "hatchling.build"
+ """,
+ ),
+ )
+ package = repository / "src/fixture"
+ package.mkdir(parents=True)
+ (package / "__init__.py").write_text('"""Fixture package."""\n')
+ return repository
+
+
def action(
repository: Path,
tmp_path: Path,
@@ -66,44 +206,118 @@ def action(
version: str,
**extra_env: str,
) -> subprocess.CompletedProcess[str]:
- environment = (
- os.environ
+ 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 = {
+ "VERSION": version,
+ "BUMP": "auto",
+ "TAG_PREFIX": "",
+ "CHANGELOG_PREFIX": "",
+ "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,
+ "EXPECTED_PATCH_SHA": "",
+ "CHECKS": "true",
+ "BRANCH": "main",
+ "REMOTE": "origin",
+ "ANNOTATED": "false",
+ "ARTIFACT_URL": "",
+ "ARTIFACT_LABEL": "PyPI release",
+ "LOCK": "pylock.toml",
+ "GENERATED_NOTES": "",
+ "MODE": "complete",
+ "RUN_ID": "42",
+ "WORKFLOW_PATH": ".github/workflows/check.yaml",
+ } | extra_env
+ input_values["REPOSITORY"] = extra_env.get("REPOSITORY", "tox-dev/fixture")
+ inputs = _inputs(action_config, input_values)
+ input_names = {input_name.replace("-", "_").upper() for input_name in action_config.get("inputs", {})}
+ environment: dict[str, str] = (
+ _safe_environment()
| {
- "VERSION": version,
- "BUMP": "auto",
- "CHANGELOG": "CHANGELOG.md",
- "FRAGMENTS": "changelog.d",
- "FORMAT": "none",
- "LINK_FRAGMENTS": "false",
- "PATCH": str(tmp_path / "release.patch"),
"GITHUB_OUTPUT": str(tmp_path / "output"),
"GITHUB_REPOSITORY": "tox-dev/fixture",
- "GITHUB_SHA": run(repository, "git", "rev-parse", "HEAD").stdout.strip(),
- "EXPECTED_SHA": run(repository, "git", "rev-parse", "HEAD").stdout.strip(),
- "HEADING_PREFIX": "## ",
- "BRANCH": "main",
- "REMOTE": "origin",
- "ANNOTATED": "false",
+ "GITHUB_SHA": head,
+ "RUNNER_TEMP": str(tmp_path),
+ "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[1] / name),
}
- | extra_env
+ | {key: value for key, value in extra_env.items() if key not in input_names}
)
- stdout = []
- stderr = []
- for script in sorted(action_scripts.glob(f"{name}-*.sh")):
- command = ["bash", str(script)]
- if coverage_binary := os.environ.get("KCOV_BIN"):
- output = Path(os.environ["KCOV_OUTPUT"])
- output.mkdir(parents=True, exist_ok=True)
- command = [
- coverage_binary,
- f"--include-path={action_scripts}",
- str(output / uuid4().hex),
- str(script),
- ]
+ if "PATH" not in extra_env:
+ environment["PATH"] = _tools_path(environment)
+ result, _ = _run_action(repository, action_scripts, name, environment, inputs)
+ return result
+
+
+def _run_action(
+ repository: Path,
+ action_scripts: Path,
+ name: str,
+ environment: dict[str, str],
+ inputs: dict[str, str],
+) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]:
+ source = Path(__file__).resolve().parents[1]
+ action_config = yaml.safe_load((source / name / "action.yml").read_text())
+ stdout: list[str] = []
+ stderr: list[str] = []
+ action_steps = action_config["runs"]["steps"]
+ step_outputs: dict[str, dict[str, str]] = {}
+ github_bash: Final[tuple[str, ...]] = ("bash", "--noprofile", "--norc", "-e", "-o", "pipefail")
+ command = list(github_bash)
+ for index, step in enumerate(action_steps):
+ if (condition := step.get("if")) is not None and not _condition(condition, environment, inputs, step_outputs):
+ continue
+ if (used := step.get("uses")) is not None:
+ if used.startswith("astral-sh/setup-uv@"):
+ 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 = {
+ input_name: _step_value(
+ str(step.get("with", {}).get(input_name, definition.get("default", ""))),
+ environment,
+ inputs,
+ step_outputs,
+ )
+ for input_name, definition in nested_config.get("inputs", {}).items()
+ }
+ result, outputs = _run_action(
+ repository,
+ action_scripts,
+ nested_name,
+ environment
+ | {
+ "GITHUB_ACTION_PATH": str(source / nested_name),
+ "GITHUB_OUTPUT": str(Path(environment["RUNNER_TEMP"]) / f"nested-{name}-{index}-output"),
+ },
+ nested_inputs,
+ )
+ stdout.append(result.stdout)
+ stderr.append(result.stderr)
+ if result.returncode:
+ return subprocess.CompletedProcess(result.args, result.returncode, "".join(stdout), "".join(stderr)), {}
+ 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"])
+ prior_output = output_path.read_text() if output_path.exists() else ""
+ step_environment: dict[str, str] = environment | {
+ variable: _step_value(str(value), environment, inputs, step_outputs)
+ for variable, value in (step.get("env") or {}).items()
+ }
result = subprocess.run(
command,
- cwd=repository,
- env=environment,
+ cwd=repository / _step_value(str(step.get("working-directory", ".")), environment, inputs, step_outputs),
+ env=step_environment,
text=True,
capture_output=True,
check=False,
@@ -111,14 +325,155 @@ def action(
stdout.append(result.stdout)
stderr.append(result.stderr)
if result.returncode:
- return subprocess.CompletedProcess(command, result.returncode, "".join(stdout), "".join(stderr))
- if name == "prepare-towncrier" and script.name == "prepare-towncrier-0.sh":
- environment["VERSION"] = (tmp_path / "output").read_text().split("version=", 1)[1].splitlines()[0]
- return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr))
+ return subprocess.CompletedProcess(command, result.returncode, "".join(stdout), "".join(stderr)), {}
+ if step_id := step.get("id"):
+ current_output = output_path.read_text() if output_path.exists() else ""
+ assert current_output.startswith(prior_output)
+ step_outputs[step_id] = dict(
+ line.split("=", 1) for line in current_output[len(prior_output) :].splitlines()
+ )
+ outputs: dict[str, str] = {
+ output_name: _step_value(definition["value"], environment, inputs, step_outputs)
+ for output_name, definition in action_config.get("outputs", {}).items()
+ }
+ return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr)), outputs
def run(directory: Path, *command: str) -> subprocess.CompletedProcess[str]:
- return subprocess.run(command, cwd=directory, text=True, capture_output=True, check=True)
+ environment = _safe_environment()
+ result = subprocess.run(
+ command,
+ cwd=directory,
+ env=environment | {"PATH": _tools_path(environment)},
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ if result.returncode:
+ msg = f"{' '.join(command)} failed with exit code {result.returncode}:\n{result.stderr}"
+ raise AssertionError(msg)
+ return result
+
+
+def _tools_path(environment: dict[str, str]) -> str:
+ return f"{Path(__file__).resolve().parents[1] / '.release-tools/bin'}:{Path(sys.executable).parent}:{environment['PATH']}"
+
+
+def _condition(
+ expression: str,
+ environment: dict[str, str],
+ inputs: dict[str, str],
+ step_outputs: dict[str, dict[str, str]],
+) -> bool:
+ if " || " in expression:
+ return any(_condition(part, environment, inputs, step_outputs) for part in expression.split(" || "))
+ if " && " in expression:
+ return all(_condition(part, environment, inputs, step_outputs) for part in expression.split(" && "))
+ 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 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)
+
+
+def _safe_environment() -> dict[str, str]:
+ return {
+ name: os.environ[name]
+ for name in (
+ "HOME",
+ "LANG",
+ "PATH",
+ "UV_CACHE_DIR",
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "NO_PROXY",
+ "SSL_CERT_FILE",
+ "GH_HOST",
+ "GH_ENTERPRISE_TOKEN",
+ )
+ if name in os.environ
+ }
+
+
+def _inputs(
+ configuration: Mapping[str, Mapping[str, Mapping[str, str | bool]]], environment: dict[str, str]
+) -> dict[str, str]:
+ return {
+ name: environment.get(name.replace("-", "_").upper(), _input_default(definition.get("default", "")))
+ for name, definition in configuration.get("inputs", {}).items()
+ }
+
+
+def _input_default(value: str | bool) -> str:
+ return str(value).lower() if isinstance(value, bool) else value
+
+
+def _step_value(
+ value: str,
+ environment: dict[str, str],
+ inputs: dict[str, str],
+ step_outputs: dict[str, dict[str, str]],
+) -> str:
+ parts: list[str] = []
+ remainder = value
+ while (start := remainder.find("${{")) != -1:
+ end = remainder.find("}}", start + 3)
+ if end == -1:
+ msg = f"unterminated GitHub expression: {value}"
+ raise ValueError(msg)
+ parts.append(remainder[:start])
+ parts.append(_expression(remainder[start + 3 : end].strip(), environment, inputs, step_outputs))
+ remainder = remainder[end + 2 :]
+ return "".join([*parts, remainder])
+
+
+def _expression(
+ expression: str,
+ environment: dict[str, str],
+ inputs: dict[str, str],
+ step_outputs: dict[str, dict[str, str]],
+) -> str:
+ if " && " in expression and " || " in expression:
+ condition, alternatives = expression.split(" && ", 1)
+ true_value, false_value = alternatives.split(" || ", 1)
+ return (true_value if _condition(condition, environment, inputs, step_outputs) else false_value).strip("'")
+ if " == " in expression or " != " in expression:
+ return str(_condition(expression, environment, inputs, step_outputs)).lower()
+ if " || " in expression:
+ return next(
+ (
+ value
+ for part in expression.split(" || ")
+ if (value := _expression(part, environment, inputs, step_outputs))
+ ),
+ "",
+ )
+ if expression.startswith("inputs."):
+ return inputs[expression.removeprefix("inputs.")]
+ if expression.startswith("steps."):
+ _, step_id, _, output_name = expression.split(".")
+ return step_outputs.get(step_id, {}).get(output_name, "")
+ if expression == "runner.temp":
+ return environment["RUNNER_TEMP"]
+ 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)
__all__ = [
diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py
new file mode 100644
index 0000000..e4e7a5e
--- /dev/null
+++ b/tests/export_build_constraints/test_export.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+from textwrap import dedent
+
+import pytest
+import tomlkit
+from conftest import action
+
+
+def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes())
+ result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", version="")
+ assert result.returncode == 0, result.stderr
+ output = next(tmp_path.glob("build-constraints-*.txt"))
+ constraints = output.read_text()
+ assert "cython==" in constraints
+ assert "setuptools==" in constraints
+ assert "pytest==" not in constraints
+ assert (tmp_path / "output").read_text() == f"path={output}\n"
+
+
+@pytest.mark.parametrize(
+ ("version", "accepted"),
+ [pytest.param("81.0.0", True, id="compatible"), pytest.param("79.0.0", False, id="incompatible")],
+)
+def test_export_build_constraints_prefers_compatible_caller_build_pin(
+ tmp_path: Path, version: str, accepted: bool
+) -> None:
+ source = Path(__file__).resolve().parents[2]
+ shared = source / "pylock.toml"
+ caller = tmp_path / "pylock.toml"
+ document = tomlkit.parse(shared.read_text())
+ for package in document["packages"]:
+ if package["name"] == "setuptools":
+ package["version"] = version
+ for wheel in package["wheels"]:
+ wheel["name"] = wheel["name"].replace("84.0.0", version)
+ wheel["url"] = wheel["url"].replace("84.0.0", version)
+ package["sdist"]["name"] = package["sdist"]["name"].replace("84.0.0", version)
+ package["sdist"]["url"] = package["sdist"]["url"].replace("84.0.0", version)
+ caller.write_text(tomlkit.dumps(document))
+ output = tmp_path / "constraints.txt"
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(source / "tools/build_constraints.py"),
+ "--shared-policy",
+ str(source / "pyproject.toml"),
+ str(output),
+ str(shared),
+ str(caller),
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert (result.returncode == 0) == accepted
+ if accepted:
+ assert f"setuptools=={version} " in output.read_text()
+ else:
+ assert "violates setuptools>=80" in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("platform", "targets"),
+ [
+ pytest.param("macos_arm64", "3.15.0:cpython 3.11.0:pypy", id="macos-pypy"),
+ pytest.param("windows_arm64", "3.15.0:cpython", id="windows-arm"),
+ ],
+)
+def test_export_build_constraints_selects_explicit_targets(tmp_path: Path, platform: str, targets: str) -> None:
+ source = Path(__file__).resolve().parents[2]
+ lock = tmp_path / "pylock.build.toml"
+ write_matrix_lock(lock)
+ output = tmp_path / "constraints.txt"
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(source / "tools/build_constraints.py"),
+ "--target-platform",
+ platform,
+ "--targets",
+ targets,
+ str(output),
+ str(lock),
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ assert output.read_text() == f"example==1.0 --hash=sha256:{'0' * 64}\n"
+
+
+def test_export_build_constraints_rejects_different_target_versions(tmp_path: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ lock = tmp_path / "pylock.build.toml"
+ write_matrix_lock(lock)
+ lock.write_text(
+ lock.read_text().replace('version = "1.0"', 'version = "1.0"\nmarker = \'implementation_name == "pypy"\'')
+ + dedent(
+ """\
+ [[packages]]
+ name = "example"
+ version = "2.0"
+ marker = 'implementation_name == "cpython"'
+ index = "https://pypi.org/simple/"
+ [[packages.wheels]]
+ name = "example-2.0-py3-none-any.whl"
+ url = "https://files.pythonhosted.org/example-2.0-py3-none-any.whl"
+ hashes.sha256 = "1111111111111111111111111111111111111111111111111111111111111111"
+ """,
+ ),
+ )
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(source / "tools/build_constraints.py"),
+ "--target-platform",
+ "macos_arm64",
+ "--targets",
+ "3.15.0:cpython 3.11.0:pypy",
+ str(tmp_path / "constraints.txt"),
+ str(lock),
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode != 0
+ assert "versions differ across requested targets" in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("version", "selected"),
+ [
+ pytest.param("3.15.1", True, id="full-version-selected"),
+ pytest.param("3.15.0", False, id="full-version-excluded"),
+ ],
+)
+def test_export_build_constraints_uses_full_python_version(tmp_path: Path, version: str, selected: bool) -> None:
+ source = Path(__file__).resolve().parents[2]
+ lock = tmp_path / "pylock.build.toml"
+ write_matrix_lock(lock)
+ lock.write_text(
+ lock.read_text().replace(
+ 'index = "https://pypi.org/simple/"',
+ 'marker = \'python_full_version >= "3.15.1"\'\nindex = "https://pypi.org/simple/"',
+ ),
+ )
+ output = tmp_path / "constraints.txt"
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(source / "tools/build_constraints.py"),
+ "--target-platform",
+ "macos_arm64",
+ "--targets",
+ f"{version}:cpython",
+ str(output),
+ str(lock),
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ assert ("example==1.0" in output.read_text()) == selected
+
+
+def write_matrix_lock(path: Path) -> None:
+ path.write_text(
+ dedent(
+ """\
+ lock-version = "1.0"
+ created-by = "test"
+ requires-python = ">=3.11"
+ environments = [
+ 'python_version == "3.11" and sys_platform == "darwin" and platform_machine == "arm64" and implementation_name == "pypy"',
+ 'python_version == "3.15" and sys_platform == "darwin" and platform_machine == "arm64" and implementation_name == "cpython"',
+ 'python_version == "3.15" and sys_platform == "win32" and platform_machine == "ARM64" and implementation_name == "cpython"',
+ ]
+ [[packages]]
+ name = "example"
+ version = "1.0"
+ index = "https://pypi.org/simple/"
+ [[packages.wheels]]
+ name = "example-1.0-py3-none-any.whl"
+ url = "https://files.pythonhosted.org/example-1.0-py3-none-any.whl"
+ hashes.sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
+ """,
+ ),
+ )
diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py
new file mode 100644
index 0000000..3cc274b
--- /dev/null
+++ b/tests/install_locked_tools/test_install.py
@@ -0,0 +1,381 @@
+from __future__ import annotations
+
+import shutil
+import subprocess
+import tomllib
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+import pytest
+from conftest import action
+
+
+@pytest.mark.parametrize(
+ ("groups", "module"),
+ [
+ pytest.param("release-news", "yaml", id="release-news"),
+ pytest.param("release-check release-notes", "release_creator", id="multiple-groups"),
+ pytest.param("release-check\nrelease-notes", "release_creator", id="multiline-groups"),
+ ],
+)
+def test_install_locked_tools_installs_groups(tmp_path: Path, action_scripts: Path, groups: str, module: str) -> None:
+ source = Path(__file__).resolve().parents[2]
+ environment = tmp_path / "tools"
+ result = install(
+ source,
+ action_scripts,
+ tmp_path,
+ groups=groups,
+ venv=environment,
+ )
+ assert result.returncode == 0, result.stderr
+ imported = subprocess.run(
+ [str(environment / "bin/python"), "-c", "import importlib, sys; importlib.import_module(sys.argv[1])", module],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert imported.returncode == 0, imported.stderr
+
+
+@pytest.mark.parametrize(
+ ("group", "present", "absent"),
+ [
+ pytest.param("release-check", "prek", "release-creator", id="source-check"),
+ pytest.param("release-news", "towncrier", "prek", id="pr-news"),
+ pytest.param("release-validate", "release-creator", "yq", id="patch-validation"),
+ pytest.param("release-shell", "yq", "release-creator", id="inline-shell"),
+ pytest.param("release-hooks", "release-creator", "yq", id="action-tests"),
+ pytest.param("release-hook-check", "yq", "towncrier", id="hook-cooldown"),
+ ],
+)
+def test_install_locked_tools_selects_job_tools(
+ tmp_path: Path, action_scripts: Path, group: str, present: str, absent: str
+) -> None:
+ source = Path(__file__).resolve().parents[2]
+ result = install(
+ source,
+ action_scripts,
+ tmp_path,
+ groups=group,
+ venv=tmp_path / "tools",
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "tools/bin" / present).exists()
+ assert not (tmp_path / "tools/bin" / absent).exists()
+
+
+def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ generated = subprocess.run(
+ [
+ "uv",
+ "run",
+ "--no-project",
+ "--with",
+ "packaging>=26.1",
+ "python",
+ str(source / "tools/build_constraints.py"),
+ str(tmp_path / "constraints.txt"),
+ str(source / "pylock.toml"),
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert generated.returncode == 0, generated.stderr
+ constraints = (tmp_path / "constraints.txt").read_text()
+ assert "cython==" in constraints
+ assert "setuptools==" in constraints
+ assert "--hash=sha256:" in constraints
+
+
+def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ action_root = tmp_path / "install-nab"
+ action_root.mkdir()
+ lock = (source / "pylock.toml").read_text()
+ package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "nab")
+ (tmp_path / "pylock.toml").write_text(lock.replace(package["wheels"][0]["hashes"]["sha256"], "0" * 64, 1))
+ result = action(
+ tmp_path,
+ tmp_path,
+ action_scripts,
+ "install-nab",
+ version="0.2.0",
+ GITHUB_ACTION_PATH=str(action_root),
+ UV_CACHE_DIR=str(tmp_path / "cache"),
+ )
+ assert result.returncode != 0
+ assert "Hash mismatch" in result.stderr
+
+
+def test_install_locked_tools_rejects_empty_groups(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ result = install(
+ source,
+ action_scripts,
+ tmp_path,
+ groups="",
+ venv=tmp_path / "tools",
+ )
+ assert result.returncode != 0
+ assert "at least one dependency group is required" in result.stderr
+
+
+def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ result = install(
+ source,
+ action_scripts,
+ tmp_path,
+ groups="relesae",
+ venv=tmp_path / "tools",
+ )
+ assert result.returncode != 0
+ assert "missing lock dependency groups: relesae" in result.stderr
+
+
+@pytest.mark.parametrize("local_source", [False, True], ids=["locked-caller", "local-source"])
+def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, local_source: bool) -> None:
+ lock = tmp_path / "pylock.toml"
+ lock.write_text(
+ 'lock-version = "1.0"\nrequires-python = ">=3.15"\n'
+ 'dependency-groups = ["release-prepare"]\ncreated-by = "test"\npackages = []\n'
+ )
+ source = Path(__file__).resolve().parents[2]
+ command = [
+ "python",
+ str(source / "tools/check_lock_groups.py"),
+ str(lock),
+ "release-prepare",
+ "--shared-lock",
+ str(source / "pylock.toml"),
+ ]
+ if local_source:
+ command.append("--local-tool-source")
+ result = subprocess.run(command, text=True, capture_output=True, check=False)
+ assert (result.returncode == 0) is local_source, result.stderr
+
+
+def test_install_locked_tools_shared_group_ignores_missing_caller_group(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ lock = (source / "pylock.toml").read_text().replace(' "release-attestations",\n', "", 1)
+ (tmp_path / "pylock.toml").write_text(lock)
+ assert "release-attestations" not in tomllib.loads(lock)["dependency-groups"]
+ result = install(
+ tmp_path,
+ action_scripts,
+ tmp_path,
+ groups="release-attestations",
+ venv=tmp_path / "tools",
+ lock_source="shared",
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "tools/bin/pypi-attestations").is_file()
+
+
+def test_install_locked_tools_rejects_unknown_lock_source(tmp_path: Path, action_scripts: Path) -> None:
+ result = install(
+ Path(__file__).resolve().parents[2],
+ action_scripts,
+ tmp_path,
+ groups="release-notes",
+ venv=tmp_path / "tools",
+ lock_source="unknown",
+ )
+ assert result.returncode != 0
+ assert "unknown lock source: unknown" in result.stderr
+
+
+def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ for target in ("notes", "validators"):
+ result = install(
+ source,
+ action_scripts,
+ tmp_path,
+ groups="release-notes",
+ venv=tmp_path / target,
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "validators/bin/release-creator").is_file()
+
+
+def test_install_locked_tools_uses_managed_python(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ tools = tmp_path / "bin"
+ tools.mkdir()
+ for name in ("bash", "sha256sum", "uv"):
+ executable = shutil.which(name)
+ assert executable is not None
+ (tools / name).symlink_to(executable)
+ result = install(
+ source,
+ action_scripts,
+ tmp_path,
+ groups="release-check",
+ venv=tmp_path / "tools",
+ extra_env={"PATH": str(tools)},
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "tools/bin/prek").is_file()
+
+
+def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ result = install(
+ source,
+ action_scripts,
+ tmp_path,
+ groups="release-notes",
+ venv=tmp_path / "tools",
+ )
+ assert result.returncode == 0, result.stderr
+ imported = subprocess.run(
+ [str(tmp_path / "lock-tools/bin/python"), "-c", "import yaml"],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert "No module named 'yaml'" in imported.stderr
+
+
+def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes())
+ (tmp_path / "pylock.toml").write_text(
+ (tmp_path / "pylock.toml").read_text()
+ + """
+[[packages]]
+name = "probe-only"
+version = "1.0"
+marker = '\"build\" in dependency_groups'
+index = "https://pypi.org/simple/"
+[[packages.wheels]]
+name = "probe_only-1.0-py3-none-any.whl"
+url = "https://files.pythonhosted.org/probe_only-1.0-py3-none-any.whl"
+hashes.sha256 = """
+ + f'"{"0" * 64}"\n',
+ )
+ result = install(
+ tmp_path,
+ action_scripts,
+ tmp_path,
+ groups="release-notes",
+ venv=tmp_path / "tools",
+ )
+ assert result.returncode == 0, result.stderr
+ assert "probe-only==1.0 " in next(tmp_path.glob("build-constraints-*.txt")).read_text()
+
+
+def test_install_locked_tools_accepts_lock_without_build_group(
+ tmp_path: Path, action_scripts: Path, nab_executable: Path
+) -> None:
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname = "tool-only"\nversion = "0.1.0"\nrequires-python = ">=3.15"\n'
+ '[dependency-groups]\nrelease-check = ["prek>=0.2"]\n'
+ '[tool.nab]\nbase-group = "project-runtime"\ndefault-groups = []\n',
+ )
+ generated = subprocess.run(
+ [str(nab_executable), "lock", "--groups", "release-check", "--output", "pylock.toml"],
+ cwd=tmp_path,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert generated.returncode == 0, generated.stderr
+ assert "build" not in tomllib.loads((tmp_path / "pylock.toml").read_text())["dependency-groups"]
+ result = install(tmp_path, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools")
+ assert result.returncode == 0, result.stderr
+ assert "setuptools==" in next(tmp_path.glob("build-constraints-*.txt")).read_text()
+
+
+def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ result = install(
+ source,
+ action_scripts,
+ tmp_path,
+ groups="release-notes",
+ venv=tmp_path / "tools",
+ extra_env={"UV_EXCLUDE_NEWER": "1970-01-01T00:00:00Z"},
+ )
+ assert result.returncode == 0, result.stderr
+ locked_version = next(
+ package["version"]
+ for package in tomllib.loads((source / "pylock.toml").read_text())["packages"]
+ if package["name"] == "release-creator"
+ )
+ installed_version = subprocess.run(
+ [
+ str(tmp_path / "tools/bin/python"),
+ "-c",
+ "from importlib.metadata import version; print(version('release-creator'))",
+ ],
+ text=True,
+ capture_output=True,
+ check=True,
+ ).stdout.strip()
+ assert installed_version == locked_version
+
+
+def test_shared_lock_records_upload_times() -> None:
+ packages = tomllib.loads((Path(__file__).resolve().parents[2] / "pylock.toml").read_text())["packages"]
+ missing = {
+ package["name"]
+ for package in packages
+ if not any("upload-time" in artifact for artifact in [package.get("sdist", {}), *package.get("wheels", [])])
+ }
+ assert not missing, f"packages without upload times: {sorted(missing)}"
+
+
+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"])))
+ lock = tomllib.loads((source / "pylock.toml").read_text())
+ cutoff = datetime.now(UTC) - timedelta(days=7)
+ packages = lock["packages"]
+ younger = {
+ package["name"]
+ for package in packages
+ if (
+ times := [
+ artifact["upload-time"]
+ for artifact in [package.get("sdist", {}), *package.get("wheels", [])]
+ if "upload-time" in artifact
+ ]
+ )
+ and max(times) > floors.get(package["name"], cutoff)
+ }
+ assert not younger, f"packages newer than the seven-day cutoff: {sorted(younger)}"
+
+
+def install(
+ repository: Path,
+ action_scripts: Path,
+ tmp_path: Path,
+ *,
+ groups: str,
+ venv: Path,
+ lock_source: str = "caller",
+ extra_env: dict[str, str] | None = None,
+) -> subprocess.CompletedProcess[str]:
+ return action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "install-locked-tools",
+ version="",
+ GROUPS=groups,
+ VENV=str(venv),
+ LOCK_SOURCE=lock_source,
+ **(extra_env or {}),
+ )
diff --git a/tests/label_bot_news/test_label.py b/tests/label_bot_news/test_label.py
new file mode 100644
index 0000000..0976e56
--- /dev/null
+++ b/tests/label_bot_news/test_label.py
@@ -0,0 +1,283 @@
+from __future__ import annotations
+
+import os
+import shutil
+from pathlib import Path
+
+import pytest
+import yaml
+from conftest import action
+from pytest_httpserver import HTTPServer
+
+
+@pytest.mark.parametrize(
+ ("files", "labels", "edit_option", "fragment_dirs", "extension", "fragment_style"),
+ [
+ pytest.param([], [], "--add-label", "changelog.d", "md", "towncrier", id="add"),
+ pytest.param(
+ [{"filename": "changelog.d/+old.feature.md", "status": "removed"}],
+ [],
+ "--add-label",
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="deleted-fragment-needs-label",
+ ),
+ pytest.param([], [{"name": "skip news"}], None, "changelog.d", "md", "towncrier", id="already-labeled"),
+ pytest.param(
+ [{"filename": "changelog.d/+fix.bugfix.md"}],
+ [{"name": "skip news"}],
+ "--remove-label",
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="remove",
+ ),
+ pytest.param(
+ [{"filename": "changelog.d/+fix.bugfix.md"}],
+ [],
+ None,
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="fragment-without-label",
+ ),
+ pytest.param(
+ [{"filename": "changelog.d/+fix.bugfix.2.md"}],
+ [{"name": "skip news"}],
+ "--remove-label",
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="named-fragment-counter",
+ ),
+ pytest.param(
+ [{"filename": "changelog.d/123.bugfix.md"}],
+ [{"name": "skip news"}],
+ "--remove-label",
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="numeric-fragment",
+ ),
+ pytest.param(
+ [{"filename": "changelog.d/123.bugfix.2.md"}],
+ [{"name": "skip news"}],
+ "--remove-label",
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="numeric-fragment-counter",
+ ),
+ pytest.param(
+ [{"filename": "changelog.d/topic.feature.md"}],
+ [{"name": "skip news"}],
+ None,
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="bare-name-not-a-fragment",
+ ),
+ pytest.param(
+ [{"filename": "changelog.d/123a.feature.md"}],
+ [{"name": "skip news"}],
+ None,
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="mixed-number-not-a-fragment",
+ ),
+ pytest.param(
+ [{"filename": "changelog.d/+.feature.md"}],
+ [{"name": "skip news"}],
+ None,
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="empty-named-stem-not-a-fragment",
+ ),
+ pytest.param(
+ [{"filename": "docs/changelog.d/+fix.bugfix.md"}],
+ [{"name": "skip news"}],
+ "--remove-label",
+ "changelog.d,docs/changelog.d",
+ "md",
+ "towncrier",
+ id="legacy-fragment-path",
+ ),
+ pytest.param(
+ [{"filename": "docs/changelog/+fix.bugfix.rst"}],
+ [{"name": "skip news"}],
+ "--remove-label",
+ "docs/changelog",
+ "rst",
+ "towncrier",
+ id="rst-fragment-removes-label",
+ ),
+ pytest.param([], [], "--add-label", ".changeset", "md", "changeset", id="changeset-add"),
+ pytest.param(
+ [{"filename": "changelog.d/nested/+fix.bugfix.md"}],
+ [{"name": "skip news"}],
+ None,
+ "changelog.d",
+ "md",
+ "towncrier",
+ id="nested-towncrier-is-not-a-fragment",
+ ),
+ pytest.param(
+ [{"filename": ".changeset/nested/fix-something.md"}],
+ [{"name": "skip news"}],
+ None,
+ ".changeset",
+ "md",
+ "changeset",
+ id="nested-changeset-is-not-a-fragment",
+ ),
+ pytest.param(
+ [{"filename": ".changeset/fix-something.md"}],
+ [{"name": "skip news"}],
+ "--remove-label",
+ ".changeset",
+ "md",
+ "changeset",
+ id="changeset-remove",
+ ),
+ pytest.param(
+ [{"filename": ".changeset/fix-something.md"}],
+ [],
+ None,
+ ".changeset",
+ "md",
+ "changeset",
+ id="changeset-fragment-without-label",
+ ),
+ ],
+)
+def test_label_bot_news_updates_label(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ files: list[dict[str, str]],
+ labels: list[dict[str, str]],
+ edit_option: str | None,
+ fragment_dirs: str,
+ extension: str,
+ fragment_style: str,
+) -> None:
+ server, environment = github_api
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_request(f"{prefix}/pulls/1/files", method="GET").respond_with_json(files)
+ server.expect_request(f"{prefix}/issues/1/labels", method="GET").respond_with_json(labels)
+ real_gh = shutil.which("gh")
+ assert real_gh is not None
+ executable = tmp_path / "gh-shim" / "gh"
+ executable.parent.mkdir()
+ executable.write_text(
+ "#!/usr/bin/env bash\n"
+ 'if [[ $1 == pr && $2 == edit ]]; then printf "%s\\0" "$@" >>"$GH_EDIT_LOG"; exit 0; fi\n'
+ 'exec "$GH_REAL" "$@"\n',
+ )
+ executable.chmod(0o755)
+ edit_log = tmp_path / "gh-edit-args"
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "label-bot-news",
+ version="0.2.0",
+ REPOSITORY="tox-dev/fixture",
+ GITHUB_EVENT_PULL_REQUEST_NUMBER="1",
+ BOT_AUTHOR="true",
+ FRAGMENTS=fragment_dirs,
+ EXTENSION=extension,
+ FRAGMENT_STYLE=fragment_style,
+ RUNNER_TEMP=str(tmp_path),
+ PATH=f"{executable.parent}:{os.environ['PATH']}",
+ GH_REAL=real_gh,
+ GH_EDIT_LOG=str(edit_log),
+ **environment,
+ )
+ assert result.returncode == 0, result.stderr
+ assert [request.method for request, _ in server.log] == ["GET", "GET"]
+ assert (edit_log.read_bytes().split(b"\0")[:-1] if edit_log.exists() else []) == (
+ [part.encode() for part in ("pr", "edit", "1", "--repo", "tox-dev/fixture", edit_option, "skip news")]
+ if edit_option is not None
+ else []
+ )
+
+
+def test_label_bot_news_ignores_human(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "label-bot-news/action.yml").read_text())["runs"][
+ "steps"
+ ]
+ assert all(
+ step.get("if") == "inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'"
+ for step in steps
+ )
+ server, environment = github_api
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "label-bot-news",
+ version="0.2.0",
+ BOT_AUTHOR="false",
+ **environment,
+ )
+ assert result.returncode == 0
+ assert not server.log
+
+
+def test_label_bot_news_rejects_extension(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "label-bot-news",
+ version="0.2.0",
+ BOT_AUTHOR="true",
+ EXTENSION="txt",
+ **environment,
+ )
+ assert result.returncode != 0
+ assert "fragment extension must be md or rst" in result.stderr
+ assert not server.log
+
+
+@pytest.mark.parametrize(
+ ("style", "extension", "error"),
+ [
+ pytest.param("unknown", "md", "fragment style must be towncrier or changeset", id="unknown-style"),
+ pytest.param("changeset", "rst", "changeset fragments must use md", id="rst-changeset"),
+ ],
+)
+def test_label_bot_news_rejects_invalid_style(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ style: str,
+ extension: str,
+ error: str,
+) -> None:
+ server, environment = github_api
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "label-bot-news",
+ version="0.2.0",
+ BOT_AUTHOR="true",
+ FRAGMENT_STYLE=style,
+ EXTENSION=extension,
+ **environment,
+ )
+ assert result.returncode != 0
+ assert error in result.stderr
+ assert not server.log
diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py
index d80ba3b..0a94266 100644
--- a/tests/prepare_towncrier/test_prepare.py
+++ b/tests/prepare_towncrier/test_prepare.py
@@ -1,11 +1,12 @@
from __future__ import annotations
-import os
from datetime import date
-lazy from pathlib import Path
+from pathlib import Path
+from textwrap import dedent
import pytest
from conftest import action, run
+from pytest_httpserver import HTTPServer
def test_prepare_builds_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
@@ -15,35 +16,53 @@ def test_prepare_builds_patch(repository: Path, tmp_path: Path, action_scripts:
assert "0.2.0" in (tmp_path / "release.patch").read_text()
-def test_prepare_custom_changelog_and_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+def test_prepare_includes_new_changelog_in_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ run(repository, "git", "rm", "CHANGELOG.md")
+ run(repository, "git", "commit", "-m", "Remove old changelog")
+ run(repository, "git", "push", "origin", "main")
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0")
+ assert result.returncode == 0, result.stderr
+ assert "new file mode 100644" in (tmp_path / "release.patch").read_text()
+ assert "+++ b/CHANGELOG.md" in (tmp_path / "release.patch").read_text()
+
+
+def test_prepare_build_phase_defers_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build")
+ assert result.returncode == 0, result.stderr
+ assert "0.2.0" in (repository / "CHANGELOG.md").read_text()
+ assert not (tmp_path / "release.patch").exists()
+
+
+def test_prepare_finalize_phase_writes_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build")
+ assert result.returncode == 0, result.stderr
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="finalize")
+ assert result.returncode == 0, result.stderr
+ assert "0.2.0" in (tmp_path / "release.patch").read_text()
+
+
+def test_prepare_custom_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
project = repository / "pyproject.toml"
project.write_text(project.read_text().replace("CHANGELOG.md", "HISTORY.md"))
(repository / "CHANGELOG.md").rename(repository / "HISTORY.md")
run(repository, "git", "add", "-A")
run(repository, "git", "commit", "-m", "Use a custom changelog")
run(repository, "git", "push", "origin", "main")
- patch = tmp_path / "custom.patch"
- result = action(
- repository,
- tmp_path,
- action_scripts,
- "prepare-towncrier",
- version="0.2.0",
- CHANGELOG="HISTORY.md",
- PATCH=str(patch),
- )
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0")
assert result.returncode == 0, result.stderr
assert "0.2.0" in (repository / "HISTORY.md").read_text()
- assert "HISTORY.md" in patch.read_text()
+ assert "HISTORY.md" in (tmp_path / "release.patch").read_text()
-def test_prepare_custom_fragment_directory(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+def test_prepare_custom_fragment_directory(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
project = repository / "pyproject.toml"
project.write_text(project.read_text().replace('directory = "changelog.d"', 'directory = "docs/changelog.d"'))
(repository / "docs").mkdir()
(repository / "changelog.d").rename(repository / "docs/changelog.d")
run(repository, "git", "add", "-A")
- run(repository, "git", "commit", "-m", "Move release fragments")
+ run(repository, "git", "commit", "-m", "Move release fragments (#2)")
run(repository, "git", "push", "origin", "main")
result = action(
repository,
@@ -51,17 +70,32 @@ def test_prepare_custom_fragment_directory(repository: Path, tmp_path: Path, act
action_scripts,
"prepare-towncrier",
version="",
- FRAGMENTS="docs/changelog.d",
- LINK_FRAGMENTS="true",
+ GITHUB_REPOSITORY="tox-dev/github-workflows",
+ **_author_lookup(github_api, 2),
)
assert result.returncode == 0, result.stderr
assert "version=0.2.0" in (tmp_path / "output").read_text()
- assert "Add a new feature." in (repository / "CHANGELOG.md").read_text()
- assert "docs/changelog.d/feature.feature.md" in (tmp_path / "release.patch").read_text()
+ expected = (
+ "Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) "
+ "by [@gaborbernat](https://github.com/gaborbernat)."
+ )
+ assert expected in " ".join((repository / "CHANGELOG.md").read_text().split())
+ assert "docs/changelog.d/+feature.feature.md" in (tmp_path / "release.patch").read_text()
-def test_prepare_renders_github_notes(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
- assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+def test_prepare_renders_github_notes(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "prepare-towncrier",
+ version="0.2.0",
+ GITHUB_REPOSITORY="tox-dev/github-workflows",
+ **_author_lookup(github_api, 2),
+ )
+ assert result.returncode == 0, result.stderr
result = run(
repository,
"release-creator",
@@ -71,9 +105,21 @@ def test_prepare_renders_github_notes(repository: Path, tmp_path: Path, action_s
"--tag",
"0.2.0",
"--repository",
- "tox-dev/fixture",
+ "tox-dev/github-workflows",
+ "--previous",
+ "0.1.0",
+ )
+ assert result.stdout == dedent(
+ """\
+
+
+ ### Features
+
+ - Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) by @gaborbernat.
+
+ **Full Changelog**: https://github.com/tox-dev/github-workflows/compare/0.1.0...0.2.0
+ """,
)
- assert "### Features" in result.stdout
def test_prepare_rejects_dirty_checkout(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
@@ -86,13 +132,8 @@ def test_prepare_rejects_dirty_checkout(repository: Path, tmp_path: Path, action
@pytest.mark.parametrize(
("option", "value", "error"),
[
- pytest.param("FORMAT", "rst", "format must be markdown or none", id="format"),
- pytest.param(
- "LINK_FRAGMENTS",
- "maybe",
- "link-fragments must be true or false",
- id="fragment-linking",
- ),
+ pytest.param("CHANGELOG_PREFIX", "x", "changelog-prefix must be empty or v", id="changelog-prefix"),
+ pytest.param("PHASE", "unknown", "phase must be all, build, or finalize", id="phase"),
],
)
def test_prepare_rejects_invalid_option(
@@ -133,6 +174,22 @@ def test_prepare_selects_version(
assert f"version={expected}" in (tmp_path / "output").read_text()
+def test_prepare_selects_prefixed_version(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="", TAG_PREFIX="v")
+ assert result.returncode == 0, result.stderr
+ assert "version=v0.2.0" in (tmp_path / "output").read_text()
+ assert "## 0.2.0 (" in (repository / "CHANGELOG.md").read_text()
+
+
+def test_prepare_preserves_prefixed_changelog_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(
+ repository, tmp_path, action_scripts, "prepare-towncrier", version="", TAG_PREFIX="v", CHANGELOG_PREFIX="v"
+ )
+ assert result.returncode == 0, result.stderr
+ assert "version=v0.2.0" in (tmp_path / "output").read_text()
+ assert "## v0.2.0 (" in (repository / "CHANGELOG.md").read_text()
+
+
@pytest.mark.parametrize("version", [pytest.param("0.1.0", id="existing"), pytest.param("0.0.1", id="older")])
def test_prepare_rejects_reused_version(repository: Path, tmp_path: Path, action_scripts: Path, version: str) -> None:
result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version=version)
@@ -141,23 +198,81 @@ def test_prepare_rejects_reused_version(repository: Path, tmp_path: Path, action
assert not (tmp_path / "release.patch").exists()
-def test_prepare_links_named_fragments(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+def test_prepare_links_named_fragments(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "prepare-towncrier",
+ version="0.2.0",
+ GITHUB_REPOSITORY="tox-dev/github-workflows",
+ **_author_lookup(github_api, 2),
+ )
+ assert result.returncode == 0, result.stderr
+ expected = (
+ "Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) "
+ "by [@gaborbernat](https://github.com/gaborbernat)."
+ )
+ assert expected in " ".join((repository / "CHANGELOG.md").read_text().split())
+
+
+def test_prepare_links_numeric_fragment_to_merged_pull_request(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ run(repository, "git", "mv", "changelog.d/+feature.feature.md", "changelog.d/2.feature.md")
+ run(repository, "git", "commit", "-m", "Use an original PR fragment")
+ run(repository, "git", "push", "origin", "main")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "prepare-towncrier",
+ version="0.2.0",
+ GITHUB_REPOSITORY="tox-dev/github-workflows",
+ **_author_lookup(github_api, 2),
+ )
+ assert result.returncode == 0, result.stderr
+ assert "[#2](https://github.com/tox-dev/github-workflows/pull/2)" in (repository / "CHANGELOG.md").read_text()
+
+
+def test_prepare_resolves_numeric_fragment_from_commit(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ run(repository, "git", "mv", "changelog.d/+feature.feature.md", "changelog.d/2.feature.md")
+ run(repository, "git", "commit", "-m", "Backfill an earlier fragment (#3)")
+ run(repository, "git", "push", "origin", "main")
result = action(
repository,
tmp_path,
action_scripts,
"prepare-towncrier",
version="0.2.0",
- LINK_FRAGMENTS="true",
+ GITHUB_REPOSITORY="tox-dev/github-workflows",
+ **_author_lookup(github_api, 3),
)
assert result.returncode == 0, result.stderr
- assert "Add a new feature." in (repository / "CHANGELOG.md").read_text()
+ assert "[#3](https://github.com/tox-dev/github-workflows/pull/3)" in (repository / "CHANGELOG.md").read_text()
+
+
+@pytest.mark.parametrize("version", [pytest.param("", id="automatic"), pytest.param("0.2.0", id="explicit")])
+def test_prepare_rejects_unlinked_fragment_name(
+ repository: Path, tmp_path: Path, action_scripts: Path, version: str
+) -> None:
+ run(repository, "git", "mv", "changelog.d/+feature.feature.md", "changelog.d/feature.feature.md")
+ run(repository, "git", "commit", "-m", "Use a bare fragment name")
+ run(repository, "git", "push", "origin", "main")
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version=version)
+ assert result.returncode != 0
+ assert "unsupported changelog fragment name" in result.stderr
+ assert not (tmp_path / "release.patch").exists()
def test_prepare_rejects_untracked_output(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
(repository / ".pre-commit-config.yaml").write_text(
- "repos:\n - repo: local\n hooks:\n - id: release-check\n"
- " name: release-check\n entry: bash -c 'touch extra-output'\n"
+ "repos:\n - repo: local\n hooks:\n - id: mdformat\n"
+ " name: mdformat\n entry: bash -c 'touch extra-output'\n"
" language: system\n pass_filenames: false\n",
)
run(repository, "git", "add", ".")
@@ -169,47 +284,92 @@ def test_prepare_rejects_untracked_output(repository: Path, tmp_path: Path, acti
def test_prepare_rejects_empty_release(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
- (repository / "changelog.d/feature.feature.md").unlink()
+ (repository / "changelog.d/+feature.feature.md").unlink()
run(repository, "git", "add", "-u")
run(repository, "git", "commit", "-m", "Remove unused fragment")
run(repository, "git", "push", "origin", "main")
result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0")
assert result.returncode != 0
- assert "has no release notes" in result.stderr
+ assert "no changelog fragments to release" in result.stderr
def test_prepare_rejects_empty_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
- (repository / "CHANGELOG.md").write_text(
- "# Changelog\n\n\n\n"
- '\n\n## 0.2.0 (2026-10-19)\n\n### Added\n\n- Add a feature.\n',
+ built = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build")
+ assert built.returncode == 0, built.stderr
+ run(repository, "git", "add", "-A")
+ run(repository, "git", "commit", "-m", "Store rendered notes")
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="finalize")
+ assert result.returncode != 0
+ assert "produced no patch" in result.stderr
+
+
+def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ (repository / "changelog.d/+feature.feature.md").write_text("Add " + "a long sentence " * 15 + ".\n")
+ run(repository, "git", "add", ".")
+ run(repository, "git", "commit", "-m", "Add long changelog entry")
+ run(repository, "git", "push", "origin", "main")
+ result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0")
+ assert result.returncode == 0, result.stderr
+ changelog_lines = (repository / "CHANGELOG.md").read_text().splitlines()
+ assert max(map(len, changelog_lines)) <= 120 # ruff: ignore[magic-value-comparison] # Changelog width.
+
+
+def test_prepare_formats_rst(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ project = repository / "pyproject.toml"
+ project.write_text(
+ project.read_text().replace("CHANGELOG.md", "CHANGELOG.rst").replace("template.jinja2", "template.rst.jinja2")
+ )
+ (repository / "CHANGELOG.md").rename(repository / "CHANGELOG.rst")
+ (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n\n.. towncrier release notes start\n")
+ (repository / "changelog.d/+feature.feature.md").rename(repository / "changelog.d/+feature.feature.rst")
+ (repository / "changelog.d/+feature.feature.rst").write_text("Add a Before feature.\n")
+ (repository / ".pre-commit-config.yaml").write_text(
+ "repos:\n - repo: local\n hooks:\n - id: docstrfmt\n"
+ " name: docstrfmt\n entry: sed -i s/Before/After/\n"
+ " language: system\n files: CHANGELOG.rst\n"
)
- run(repository, "git", "add", "CHANGELOG.md")
- run(repository, "git", "commit", "-m", "Record existing notes")
+ run(repository, "git", "add", "-A")
+ run(repository, "git", "commit", "-m", "Set RST hook")
run(repository, "git", "push", "origin", "main")
- tools = tmp_path / "bin"
- tools.mkdir()
- (tools / "towncrier").symlink_to("/usr/bin/true")
result = action(
repository,
tmp_path,
action_scripts,
"prepare-towncrier",
version="0.2.0",
- PATH=f"{tools}:{os.environ['PATH']}",
+ **_author_lookup(github_api, 2),
)
- assert result.returncode != 0
- assert "produced no patch" in result.stderr
+ assert result.returncode == 0, result.stderr
+ assert "Add a After feature (:pr:`2`) by :user:`gaborbernat`." in (repository / "CHANGELOG.rst").read_text()
-def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+def test_prepare_avoids_rst_conflict_marker(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ project = repository / "pyproject.toml"
+ project.write_text(
+ project.read_text()
+ .replace("CHANGELOG.md", "CHANGELOG.rst")
+ .replace("template.jinja2", "template.rst.jinja2")
+ .replace('name = "Added"', 'name = "Changes"')
+ .replace("title_format = false", 'underlines = ["*", "=", "-"]\ntitle_format = false')
+ )
+ (repository / "CHANGELOG.md").rename(repository / "CHANGELOG.rst")
+ (repository / "CHANGELOG.rst").write_text(
+ "Changelog\n=========\n\n.. towncrier release notes start\n\n"
+ "0.1.0 (2026-01-01)\n******************\n\nChanges\n=======\n\n"
+ "- ``>>>>>>> historical branch`` remains quoted.\n"
+ )
+ (repository / "changelog.d/+feature.feature.md").rename(repository / "changelog.d/+feature.feature.rst")
(repository / ".pre-commit-config.yaml").write_text(
- "repos:\n - repo: local\n hooks:\n - id: mdformat\n"
- " name: mdformat\n entry: mdformat --wrap 120\n"
- " language: system\n types: [markdown]\n",
+ "repos:\n - repo: local\n hooks:\n - id: docstrfmt\n"
+ " name: docstrfmt\n entry: bash -c true\n"
+ " language: system\n files: CHANGELOG.rst\n"
)
- (repository / "changelog.d/feature.feature.md").write_text("Add " + "a long sentence " * 15 + ".\n")
- run(repository, "git", "add", ".")
- run(repository, "git", "commit", "-m", "Set markdown hook")
+ run(repository, "git", "add", "-A")
+ run(repository, "git", "commit", "-m", "Set RST category")
run(repository, "git", "push", "origin", "main")
result = action(
repository,
@@ -217,8 +377,22 @@ def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scrip
action_scripts,
"prepare-towncrier",
version="0.2.0",
- FORMAT="markdown",
+ **_author_lookup(github_api, 2),
)
assert result.returncode == 0, result.stderr
- changelog_lines = (repository / "CHANGELOG.md").read_text().splitlines()
- assert max(map(len, changelog_lines)) <= 120 # ruff: ignore[magic-value-comparison] # Changelog width.
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-release-patch",
+ version="0.2.0",
+ )
+ assert result.returncode == 0, result.stderr
+ assert "Changes\n=======" in (repository / "CHANGELOG.rst").read_text()
+
+
+def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) -> dict[str, str]:
+ _, environment = github_api
+ environment["TEST_PULL_NUMBER"] = str(number)
+ return environment
diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py
new file mode 100644
index 0000000..1822fa4
--- /dev/null
+++ b/tests/publish_github_release/test_publish.py
@@ -0,0 +1,603 @@
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from hashlib import sha256
+from pathlib import Path
+
+import pytest
+from conftest import action, run
+from pytest_httpserver import HTTPServer
+
+
+@pytest.fixture
+def release_cli(tmp_path: Path) -> dict[str, str]:
+ real_gh = shutil.which("gh")
+ assert real_gh is not None
+ executable = tmp_path / "bin" / "gh"
+ executable.parent.mkdir()
+ executable.write_text(
+ "#!/usr/bin/env bash\n"
+ "if [[ $1 == release ]]; then\n"
+ ' printf "%s\\0" "$@" >>"$GH_RELEASE_LOG"\n'
+ ' printf "\\n" >>"$GH_RELEASE_LOG"\n'
+ " if [[ $2 == upload && ${GH_UPLOAD_FAILURE:-false} == true ]]; then exit 1; fi\n"
+ " if [[ $2 == upload && ${GH_UPLOAD_FAILURE:-false} == once && ! -f $GH_FAILURE_MARKER ]]; then\n"
+ ' touch "$GH_FAILURE_MARKER"; exit 1\n'
+ " fi\n"
+ " if [[ $2 == verify-asset && ${GH_VERIFY_FAILURE:-false} == true ]]; then exit 1; fi\n"
+ " exit 0\n"
+ "fi\n"
+ 'exec "$GH_REAL" "$@"\n',
+ )
+ executable.chmod(0o755)
+ (executable.parent / "sleep").write_text("#!/usr/bin/env bash\nexit 0\n")
+ (executable.parent / "sleep").chmod(0o755)
+ return {
+ "PATH": f"{executable.parent}:{os.environ['PATH']}",
+ "GH_REAL": real_gh,
+ "GH_RELEASE_LOG": str(tmp_path / "release-cli-arguments"),
+ "GH_FAILURE_MARKER": str(tmp_path / "release-cli-failed-once"),
+ }
+
+
+def _release_calls(tmp_path: Path) -> list[tuple[str, ...]]:
+ path = tmp_path / "release-cli-arguments"
+ return (
+ [tuple(part.decode() for part in line.split(b"\0")[:-1]) for line in path.read_bytes().splitlines()]
+ if path.exists()
+ else []
+ )
+
+
+def _notes_and_commit(repository: Path, tmp_path: Path) -> tuple[Path, str]:
+ notes = tmp_path / "notes.md"
+ notes.write_text("Release notes.\n")
+ return notes, run(repository, "git", "rev-parse", "0.1.0").stdout.strip()
+
+
+def _release(
+ notes: Path, *, draft: bool, assets: list[dict[str, str]] | None = None
+) -> dict[str, str | int | bool | list[dict[str, str]]]:
+ return {
+ "id": 42,
+ "tag_name": "0.1.0",
+ "name": "0.1.0",
+ "body": notes.read_text(),
+ "draft": draft,
+ "prerelease": False,
+ "assets": assets or [],
+ }
+
+
+def _request(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ notes: Path,
+ commit: str,
+ environment: dict[str, str],
+ *,
+ assets: Path | None = None,
+ mode: str = "complete",
+) -> subprocess.CompletedProcess[str]:
+ return action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "publish-github-release",
+ version="0.1.0",
+ TAG="0.1.0",
+ COMMIT=commit,
+ TITLE="0.1.0",
+ NOTES_FILE=str(notes),
+ ASSETS_DIRECTORY=str(assets) if assets is not None else "",
+ PRERELEASE="false",
+ MODE=mode,
+ **environment,
+ )
+
+
+@pytest.mark.parametrize("with_asset", [pytest.param(False, id="notes-only"), pytest.param(True, id="with-asset")])
+def test_existing_published_release_is_verified_without_writes(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+ with_asset: bool,
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ if with_asset:
+ assets.mkdir()
+ (assets / "fixture.zip").write_bytes(b"release archive")
+ release = _release(
+ notes,
+ draft=False,
+ assets=(
+ [{"name": "fixture.zip", "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"}]
+ if with_asset
+ else []
+ ),
+ )
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [release]
+ )
+ for _ in range(2 + int(with_asset)):
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release)
+ result = _request(
+ repository,
+ tmp_path,
+ action_scripts,
+ notes,
+ commit,
+ environment | release_cli,
+ assets=assets if with_asset else None,
+ )
+ assert result.returncode == 0, result.stderr
+ assert [request.method for request, _ in server.log] == ["GET"] * (4 + int(with_asset))
+ assert _release_calls(tmp_path) == (
+ [("release", "verify-asset", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")]
+ if with_asset
+ else []
+ )
+
+
+@pytest.mark.parametrize(
+ "changed_field",
+ [
+ pytest.param("name", id="title"),
+ pytest.param("body", id="notes"),
+ pytest.param("prerelease", id="prerelease"),
+ pytest.param("assets", id="assets"),
+ ],
+)
+def test_existing_release_rejects_changed_content(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+ changed_field: str,
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ release = _release(notes, draft=False)
+ release[changed_field] = {
+ "name": "Wrong title",
+ "body": "Wrong notes.\n",
+ "prerelease": True,
+ "assets": [{"name": "extra.zip", "digest": "sha256:deadbeef", "state": "uploaded"}],
+ }[changed_field]
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [release]
+ )
+ result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli)
+ assert result.returncode != 0
+ assert [request.method for request, _ in server.log] == ["GET", "GET"]
+ assert not _release_calls(tmp_path)
+
+
+def test_tag_endpoint_must_return_expected_commit(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json(
+ {"sha": "0" * 40}
+ )
+ result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli)
+ assert result.returncode != 0
+ assert [request.method for request, _ in server.log] == ["GET"]
+
+
+def test_missing_tag_rejects_same_name_branch(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ run(repository, "git", "branch", "0.1.0", commit)
+ server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json(
+ {"message": "Not Found"}, status=404
+ )
+ result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli)
+ assert result.returncode != 0
+ assert [request.method for request, _ in server.log] == ["GET"]
+
+
+@pytest.mark.parametrize(
+ "asset_name",
+ [
+ pytest.param(".hidden.zip", id="hidden"),
+ pytest.param("unsafe#label.zip", id="label-delimiter"),
+ pytest.param("unsafe\nname.zip", id="newline"),
+ ],
+)
+def test_unsafe_asset_names_are_rejected(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+ asset_name: str,
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ (assets / asset_name).write_bytes(b"release archive")
+ server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json(
+ {"sha": commit}
+ )
+ result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets)
+ assert result.returncode != 0
+ assert "unsafe release asset name" in result.stdout
+ assert [request.method for request, _ in server.log] == ["GET"]
+
+
+def test_publish_requires_an_existing_draft(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([])
+ result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="publish")
+ assert result.returncode != 0
+ assert [request.method for request, _ in server.log] == ["GET", "GET"]
+
+
+@pytest.mark.parametrize("mode", [pytest.param("stage", id="draft"), pytest.param("complete", id="published")])
+def test_missing_release_is_created_then_optionally_published(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+ mode: str,
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ draft = _release(notes, draft=True)
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([])
+ server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201)
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft)
+ if mode == "complete":
+ server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(
+ draft | {"draft": False}
+ )
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(
+ draft | {"draft": mode == "stage"}
+ )
+ result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode=mode)
+ assert result.returncode == 0, result.stderr
+ assert [request.method for request, _ in server.log] == [
+ "GET",
+ "GET",
+ "POST",
+ "GET",
+ *(["PATCH"] if mode == "complete" else []),
+ "GET",
+ ]
+ assert not _release_calls(tmp_path)
+ request = next(request for request, _ in server.log if request.method == "POST")
+ assert request.get_json() == {
+ "tag_name": "0.1.0",
+ "target_commitish": commit,
+ "name": "0.1.0",
+ "body": notes.read_text(),
+ "draft": True,
+ "prerelease": False,
+ }
+
+
+@pytest.mark.parametrize("mode", [pytest.param("stage", id="draft"), pytest.param("complete", id="published")])
+@pytest.mark.parametrize(
+ "asset_name",
+ [
+ pytest.param("fixture.zip", id="plain"),
+ pytest.param("fixture+linux.zip", id="plus"),
+ pytest.param("Fixture signed.zip", id="space"),
+ ],
+)
+def test_draft_release_uploads_missing_asset(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+ mode: str,
+ asset_name: str,
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ asset_path = assets / asset_name
+ asset_path.write_bytes(b"release archive")
+ asset = {"name": asset_name, "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"}
+ draft = _release(notes, draft=True)
+ complete = _release(notes, draft=True, assets=[asset])
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [draft]
+ )
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft)
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete)
+ if mode == "complete":
+ server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(
+ complete | {"draft": False}
+ )
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(
+ complete | {"draft": mode == "stage"}
+ )
+ result = _request(
+ repository,
+ tmp_path,
+ action_scripts,
+ notes,
+ commit,
+ environment | release_cli,
+ assets=assets,
+ mode=mode,
+ )
+ assert result.returncode == 0, result.stderr
+ assert _release_calls(tmp_path) == [
+ ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"),
+ *(
+ [("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")]
+ if mode == "complete"
+ else []
+ ),
+ ]
+ assert [request.method for request, _ in server.log] == [
+ "GET",
+ "GET",
+ "GET",
+ "GET",
+ *(["PATCH"] if mode == "complete" else []),
+ "GET",
+ ]
+
+
+def test_draft_release_replaces_incomplete_asset(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ asset_path = assets / "fixture.zip"
+ asset_path.write_bytes(b"release archive")
+ incomplete = _release(
+ notes,
+ draft=True,
+ assets=[{"id": "21", "name": asset_path.name, "digest": "", "state": "starter"}],
+ )
+ uploaded = _release(
+ notes,
+ draft=True,
+ assets=[
+ {
+ "name": asset_path.name,
+ "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}",
+ "state": "uploaded",
+ }
+ ],
+ )
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [incomplete]
+ )
+ server.expect_ordered_request(f"{prefix}/releases/assets/21", method="DELETE").respond_with_json({})
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(incomplete | {"assets": []})
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(uploaded)
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(uploaded)
+ result = _request(
+ repository,
+ tmp_path,
+ action_scripts,
+ notes,
+ commit,
+ environment | release_cli,
+ assets=assets,
+ mode="stage",
+ )
+ assert result.returncode == 0, result.stderr
+ assert ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture") in _release_calls(tmp_path)
+ assert [request.method for request, _ in server.log] == ["GET", "GET", "DELETE", "GET", "GET", "GET"]
+
+
+def test_failed_asset_upload_leaves_draft_unpublished(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ (assets / "fixture.zip").write_bytes(b"release archive")
+ draft = _release(notes, draft=True)
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [draft]
+ )
+ for _ in range(7):
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft)
+ result = _request(
+ repository,
+ tmp_path,
+ action_scripts,
+ notes,
+ commit,
+ environment | release_cli | {"GH_UPLOAD_FAILURE": "true"},
+ assets=assets,
+ )
+ assert result.returncode != 0
+ assert [request.method for request, _ in server.log] == ["GET"] * 9
+ assert (
+ _release_calls(tmp_path)
+ == [("release", "upload", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] * 6
+ )
+
+
+def test_upload_failure_recovers_when_asset_is_present(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ asset_path = assets / "fixture.zip"
+ asset_path.write_bytes(b"release archive")
+ asset = {
+ "name": asset_path.name,
+ "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}",
+ "state": "uploaded",
+ }
+ draft = _release(notes, draft=True)
+ complete = _release(notes, draft=True, assets=[asset])
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [draft]
+ )
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft)
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete)
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete)
+ server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(
+ complete | {"draft": False}
+ )
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete | {"draft": False})
+ result = _request(
+ repository,
+ tmp_path,
+ action_scripts,
+ notes,
+ commit,
+ environment | release_cli | {"GH_UPLOAD_FAILURE": "once"},
+ assets=assets,
+ )
+ assert result.returncode == 0, result.stderr
+ assert _release_calls(tmp_path) == [
+ ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"),
+ ("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"),
+ ]
+
+
+def test_failed_asset_attestation_is_reported(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ asset_path = assets / "fixture.zip"
+ asset_path.write_bytes(b"release archive")
+ release = _release(
+ notes,
+ draft=False,
+ assets=[
+ {
+ "name": asset_path.name,
+ "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}",
+ "state": "uploaded",
+ }
+ ],
+ )
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [release]
+ )
+ for _ in range(3):
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release)
+ result = _request(
+ repository,
+ tmp_path,
+ action_scripts,
+ notes,
+ commit,
+ environment | release_cli | {"GH_VERIFY_FAILURE": "true"},
+ assets=assets,
+ )
+ assert result.returncode != 0
+ assert "release asset attestation could not be verified" in result.stderr
+ assert (
+ _release_calls(tmp_path)
+ == [("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] * 6
+ )
+
+
+def test_duplicate_releases_are_rejected(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [_release(notes, draft=True), _release(notes, draft=True)]
+ )
+ result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli)
+ assert result.returncode != 0
+ assert [request.method for request, _ in server.log] == ["GET", "GET"]
+
+
+def test_invalid_mode_rejects_before_api_access(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="unknown")
+ assert result.returncode != 0
+ assert "invalid release mode" in result.stdout
+ assert not server.log
diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py
new file mode 100644
index 0000000..e7a443a
--- /dev/null
+++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import shutil
+from hashlib import sha256
+from pathlib import Path
+from zipfile import ZIP_DEFLATED, ZipFile
+
+import pytest
+from conftest import action
+from pytest_httpserver import HTTPServer
+
+
+@pytest.mark.parametrize(
+ "changed",
+ [
+ pytest.param("digest", id="digest"),
+ pytest.param("plugin-id", id="plugin-id"),
+ pytest.param("channel", id="channel"),
+ pytest.param("extension", id="extension"),
+ pytest.param("corrupt", id="corrupt"),
+ pytest.param("duplicate", id="duplicate"),
+ pytest.param("symlink", id="symlink"),
+ ],
+)
+def test_invalid_archive_or_input_fails_before_upload(
+ signed_archive: Path, tmp_path: Path, marketplace_scripts: Path, httpserver: HTTPServer, changed: str
+) -> None:
+ settings = {
+ "ARCHIVE": str(signed_archive),
+ "SHA256": sha256(signed_archive.read_bytes()).hexdigest(),
+ "PLUGIN_ID": "29973",
+ "CHANNEL": "beta",
+ }
+ if changed == "digest":
+ settings["SHA256"] = "0" * 64
+ elif changed == "plugin-id":
+ settings["PLUGIN_ID"] = "fish"
+ elif changed == "channel":
+ settings["CHANNEL"] = "beta release"
+ elif changed == "extension":
+ wrong_extension = tmp_path / "plugin.tar"
+ wrong_extension.write_bytes(signed_archive.read_bytes())
+ settings["ARCHIVE"] = str(wrong_extension)
+ elif changed == "corrupt":
+ signed_archive.write_bytes(b"not a zip")
+ settings["SHA256"] = sha256(signed_archive.read_bytes()).hexdigest()
+ elif changed == "duplicate":
+ with ZipFile(signed_archive, "a") as content:
+ content.writestr("Fish Shell/lib/plugin.jar", b"duplicate")
+ settings["SHA256"] = sha256(signed_archive.read_bytes()).hexdigest()
+ else:
+ link = tmp_path / "linked.zip"
+ link.symlink_to(signed_archive)
+ settings["ARCHIVE"] = str(link)
+ result = action(
+ tmp_path,
+ tmp_path,
+ marketplace_scripts,
+ "publish-jetbrains-marketplace",
+ version="",
+ PUBLISH_TOKEN="fixture-token",
+ **settings,
+ )
+ assert result.returncode != 0
+ assert not httpserver.log
+
+
+@pytest.mark.parametrize("status", [pytest.param(200, id="accepted"), pytest.param(403, id="denied")])
+@pytest.mark.parametrize(
+ "archive_name",
+ ["Fish Shell 2-0.1.11-signed.zip", "Fish Shell, 2; 0.1.11-signed.zip"],
+ ids=["plain-path", "multipart-delimiters"],
+)
+@pytest.mark.parametrize("temporary_name", ["runner-temp", "runner,; temp"], ids=["plain-temp", "multipart-temp"])
+def test_multipart_upload_uses_exact_archive_and_channel(
+ signed_archive: Path,
+ tmp_path: Path,
+ marketplace_scripts: Path,
+ httpserver: HTTPServer,
+ status: int,
+ archive_name: str,
+ temporary_name: str,
+) -> None:
+ signed_archive = signed_archive.rename(tmp_path / archive_name)
+ runner_temp = tmp_path / temporary_name
+ runner_temp.mkdir()
+ httpserver.expect_request("/api/updates/upload", method="POST").respond_with_data(
+ "upload denied" if status == 403 else "{}", status=status
+ )
+ result = action(
+ tmp_path,
+ runner_temp,
+ marketplace_scripts,
+ "publish-jetbrains-marketplace",
+ version="",
+ ARCHIVE=str(signed_archive),
+ SHA256=sha256(signed_archive.read_bytes()).hexdigest(),
+ PLUGIN_ID="29973",
+ CHANNEL="beta",
+ PUBLISH_TOKEN="fixture-token",
+ RUNNER_TEMP=str(runner_temp),
+ )
+ assert (result.returncode == 0) is (status == 200)
+ if status == 403:
+ assert "upload denied" in result.stderr
+ request = httpserver.log[0][0]
+ assert request.headers["Authorization"] == "Bearer fixture-token"
+ assert request.form["pluginId"] == "29973"
+ assert request.form["channel"] == "beta"
+ assert request.files["file"].filename == "plugin.zip"
+ assert request.files["file"].read() == signed_archive.read_bytes()
+
+
+def test_missing_token_fails_without_request(
+ signed_archive: Path, tmp_path: Path, marketplace_scripts: Path, httpserver: HTTPServer
+) -> None:
+ result = action(
+ tmp_path,
+ tmp_path,
+ marketplace_scripts,
+ "publish-jetbrains-marketplace",
+ version="",
+ ARCHIVE=str(signed_archive),
+ SHA256=sha256(signed_archive.read_bytes()).hexdigest(),
+ PLUGIN_ID="29973",
+ CHANNEL="",
+ PUBLISH_TOKEN="",
+ RUNNER_TEMP=str(tmp_path),
+ )
+ assert result.returncode != 0
+ assert not httpserver.log
+
+
+@pytest.fixture
+def signed_archive(tmp_path: Path) -> Path:
+ archive = tmp_path / "Fish Shell 2-0.1.11-signed.zip"
+ with ZipFile(archive, "w", ZIP_DEFLATED) as content:
+ content.writestr("Fish Shell/lib/plugin.jar", b"signed plugin")
+ return archive
+
+
+@pytest.fixture
+def marketplace_scripts(action_scripts: Path, tmp_path: Path, httpserver: HTTPServer) -> Path:
+ scripts = tmp_path / "scripts"
+ shutil.copytree(action_scripts, scripts)
+ upload = scripts / "publish-jetbrains-marketplace-1.sh"
+ upload.write_text(
+ upload.read_text().replace(
+ "https://plugins.jetbrains.com/api/updates/upload", httpserver.url_for("/api/updates/upload")
+ )
+ )
+ return scripts
diff --git a/tests/publish_post_release_pr/test_post_release_pr.py b/tests/publish_post_release_pr/test_post_release_pr.py
new file mode 100644
index 0000000..8ddaf19
--- /dev/null
+++ b/tests/publish_post_release_pr/test_post_release_pr.py
@@ -0,0 +1,512 @@
+from __future__ import annotations
+
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+import yaml
+from conftest import action
+from pytest_httpserver import HTTPServer
+
+
+def test_action_maps_writer_app_slug() -> None:
+ action = yaml.safe_load((Path(__file__).resolve().parents[2] / "publish-post-release-pr/action.yml").read_text())
+ assert action["inputs"]["app-slug"]["required"]
+ assert action["inputs"]["allowed-paths"]["required"]
+ assert action["runs"]["steps"][0]["env"]["APP_SLUG"] == "${{ inputs.app-slug }}"
+ assert action["runs"]["steps"][1]["env"]["ALLOWED_PATHS"] == "${{ inputs.allowed-paths }}"
+ assert "outputs" not in action
+ assert {"repository", "base", "label"}.isdisjoint(action["inputs"])
+
+
+def run_publish(action_scripts: Path, *, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
+ settings = {
+ name: value
+ for name, value in env.items()
+ if name not in {"BASE", "LABEL"} and (name not in os.environ or os.environ[name] != value)
+ }
+ return action(
+ cwd,
+ Path(env["RUNNER_TEMP"]),
+ action_scripts,
+ "publish-post-release-pr",
+ version="",
+ **settings,
+ )
+
+
+def expect_bot(server: HTTPServer) -> None:
+ server.expect_request("/api/v3/users/fixture-app[bot]", method="GET").respond_with_json({"id": 123})
+
+
+def publish(
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ *,
+ main_blob: str,
+ expected_blob: str,
+ additional_blob: str | None = None,
+) -> tuple[subprocess.CompletedProcess[str], HTTPServer]:
+ server, environment = github_api
+ expect_bot(server)
+ body = tmp_path / "body.md"
+ body.write_text("Set the next development version.\n")
+ paths = tmp_path / "paths.tsv"
+ paths.write_text(f"gradle.properties\t{expected_blob}\n")
+ if additional_blob is not None:
+ with paths.open("a") as stream:
+ stream.write(f"other.txt\t{additional_blob}\n")
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json(
+ [
+ {
+ "number": 7,
+ "state": "closed",
+ "merged_at": "2026-10-20T00:00:00Z",
+ "title": "Bump version to 2.4.8-dev",
+ "body": body.read_text(),
+ "base": {"ref": "main"},
+ "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7"},
+ }
+ ]
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/heads/main", method="GET").respond_with_json(
+ {"object": {"sha": "a" * 40}}
+ )
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/contents/gradle.properties", query_string="ref=main", method="GET"
+ ).respond_with_json({"type": "file", "sha": main_blob})
+ if additional_blob is not None and main_blob == expected_blob:
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/contents/other.txt", query_string="ref=main", method="GET"
+ ).respond_with_json({"type": "file", "sha": additional_blob})
+ if main_blob == expected_blob:
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json(
+ [{"filename": "gradle.properties"}, *([{"filename": "other.txt"}] if additional_blob is not None else [])]
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/issues/7/labels", method="GET").respond_with_json(
+ [{"name": "skip news"}]
+ )
+ result = run_publish(
+ action_scripts,
+ cwd=tmp_path,
+ env=os.environ
+ | environment
+ | {
+ "GITHUB_REPOSITORY": "tox-dev/fixture",
+ "APP_SLUG": "fixture-app",
+ "BASE": "main",
+ "BASE_COMMIT": "a" * 40,
+ "BRANCH": "post-release-v2.4.7",
+ "TITLE": "Bump version to 2.4.8-dev",
+ "COMMIT_MESSAGE": "Bump version",
+ "BODY_FILE": str(body),
+ "PATHS_FILE": str(paths),
+ "ALLOWED_PATHS": "gradle.properties\nother.txt\n" if additional_blob is not None else "gradle.properties",
+ "BUNDLE": "",
+ "CANDIDATE_COMMIT": "",
+ "LABEL": "skip news",
+ "RUNNER_TEMP": str(tmp_path),
+ },
+ )
+ return result, server
+
+
+def test_merged_branch_deleted_and_main_matches_is_complete(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ result, server = publish(tmp_path, action_scripts, github_api, main_blob="b" * 40, expected_blob="b" * 40)
+ assert result.returncode == 0, result.stderr
+ assert [request.method for request, _ in server.log] == ["GET"] * 6
+
+
+def test_multiline_allowed_paths_accepts_trailing_newline(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ result, server = publish(
+ tmp_path, action_scripts, github_api, main_blob="b" * 40, expected_blob="b" * 40, additional_blob="c" * 40
+ )
+ assert result.returncode == 0, result.stderr
+ assert [request.method for request, _ in server.log] == ["GET"] * 7
+
+
+def test_merged_branch_deleted_and_later_main_change_fails(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ result, server = publish(tmp_path, action_scripts, github_api, main_blob="c" * 40, expected_blob="b" * 40)
+ assert result.returncode != 0
+ assert [request.method for request, _ in server.log] == ["GET"] * 4
+
+
+def test_open_pull_request_metadata_change_fails(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ expect_bot(server)
+ body = tmp_path / "body.md"
+ body.write_text("Set the next development version.\n")
+ paths = tmp_path / "paths.tsv"
+ paths.write_text(f"gradle.properties\t{'b' * 40}\n")
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json(
+ [
+ {
+ "number": 7,
+ "state": "open",
+ "merged_at": None,
+ "title": "Different title",
+ "body": body.read_text(),
+ "base": {"ref": "main"},
+ "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7"},
+ }
+ ]
+ )
+ result = run_publish(
+ action_scripts,
+ cwd=tmp_path,
+ env=os.environ
+ | environment
+ | {
+ "GITHUB_REPOSITORY": "tox-dev/fixture",
+ "APP_SLUG": "fixture-app",
+ "BASE": "main",
+ "BASE_COMMIT": "a" * 40,
+ "BRANCH": "post-release-v2.4.7",
+ "TITLE": "Bump version to 2.4.8-dev",
+ "COMMIT_MESSAGE": "Bump version",
+ "BODY_FILE": str(body),
+ "PATHS_FILE": str(paths),
+ "ALLOWED_PATHS": "gradle.properties",
+ "BUNDLE": "",
+ "CANDIDATE_COMMIT": "",
+ "RUNNER_TEMP": str(tmp_path),
+ },
+ )
+ assert result.returncode != 0
+ assert "open pull request metadata changed" in result.stderr
+
+
+def test_closed_unmerged_pr_blocks_retry_before_write(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ expect_bot(server)
+ bundle, base, commit, blob = candidate(tmp_path)
+ body = tmp_path / "body.md"
+ body.write_text("Set the next development version.\n")
+ paths = tmp_path / "paths.tsv"
+ paths.write_text(f"gradle.properties\t{blob}\n")
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json(
+ [
+ {
+ "number": 7,
+ "state": "closed",
+ "merged_at": None,
+ "title": "Bump version to 2.4.8-dev",
+ "body": body.read_text(),
+ "base": {"ref": "main"},
+ "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7"},
+ }
+ ]
+ )
+ result = run_publish(
+ action_scripts,
+ cwd=tmp_path,
+ env=os.environ
+ | environment
+ | {
+ "GITHUB_REPOSITORY": "tox-dev/fixture",
+ "APP_SLUG": "fixture-app",
+ "BASE": "main",
+ "BASE_COMMIT": base,
+ "BRANCH": "post-release-v2.4.7",
+ "TITLE": "Bump version to 2.4.8-dev",
+ "COMMIT_MESSAGE": "Bump version",
+ "BODY_FILE": str(body),
+ "PATHS_FILE": str(paths),
+ "ALLOWED_PATHS": "gradle.properties",
+ "BUNDLE": str(bundle.relative_to(tmp_path)),
+ "CANDIDATE_COMMIT": commit,
+ "LABEL": "skip news",
+ "RUNNER_TEMP": str(tmp_path),
+ },
+ )
+ assert result.returncode != 0
+ assert "post-release pull request was closed without merging; reopen it to retry" in result.stderr
+ assert [request.method for request, _ in server.log] == ["GET", "GET"]
+
+
+def run_git(directory: Path, *args: str) -> str:
+ return subprocess.run(
+ ["git", "-C", str(directory), *args], capture_output=True, text=True, check=True
+ ).stdout.strip()
+
+
+def candidate(
+ tmp_path: Path,
+ *,
+ extra_path: bool = False,
+ wrong_author: bool = False,
+ wrong_committer: bool = False,
+ wrong_message: bool = False,
+) -> tuple[Path, str, str, str]:
+ repository = tmp_path / "source"
+ repository.mkdir()
+ run_git(repository, "init", "-q", "-b", "main")
+ run_git(repository, "config", "user.name", "fixture-app[bot]")
+ run_git(repository, "config", "user.email", "123+fixture-app[bot]@users.noreply.github.com")
+ (repository / "gradle.properties").write_text("pluginVersion=2.4.7\n")
+ run_git(repository, "add", "gradle.properties")
+ run_git(repository, "commit", "-qm", "Base")
+ base = run_git(repository, "rev-parse", "HEAD")
+ run_git(repository, "checkout", "-qb", "release-candidate")
+ (repository / "gradle.properties").write_text("pluginVersion=2.4.8-dev\n")
+ run_git(repository, "add", "gradle.properties")
+ if extra_path:
+ (repository / "other.txt").write_text("unexpected\n")
+ run_git(repository, "add", "other.txt")
+ if wrong_author:
+ run_git(repository, "config", "user.name", "Different author")
+ subprocess.run(
+ ["git", "-C", str(repository), "commit", "-qm", "Different message" if wrong_message else "Bump version"],
+ env=os.environ | ({"GIT_COMMITTER_NAME": "Different committer"} if wrong_committer else {}),
+ check=True,
+ )
+ commit = run_git(repository, "rev-parse", "HEAD")
+ blob = run_git(repository, "rev-parse", "HEAD:gradle.properties")
+ bundle = tmp_path / "candidate.bundle"
+ run_git(repository, "bundle", "create", str(bundle), "refs/heads/release-candidate")
+ return bundle, base, commit, blob
+
+
+@pytest.mark.parametrize(
+ "branch_tamper",
+ [
+ pytest.param("none", id="exact-branch"),
+ pytest.param("tree", id="divergent-tree"),
+ pytest.param("author", id="divergent-author"),
+ pytest.param("committer", id="divergent-committer"),
+ pytest.param("message", id="divergent-message"),
+ pytest.param("base", id="moved-base"),
+ pytest.param("parent", id="merge-commit"),
+ ],
+)
+def test_open_branch_must_match_prepared_candidate(
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ branch_tamper: str,
+) -> None:
+ server, environment = github_api
+ expect_bot(server)
+ bundle, base, commit, blob = candidate(tmp_path)
+ body = tmp_path / "body.md"
+ body.write_text("Set the next development version.\n")
+ paths = tmp_path / "paths.tsv"
+ paths.write_text(f"gradle.properties\t{blob}\n")
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json(
+ [
+ {
+ "number": 7,
+ "state": "open",
+ "merged_at": None,
+ "title": "Bump version to 2.4.8-dev",
+ "body": body.read_text(),
+ "base": {"ref": "main"},
+ "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7", "sha": commit},
+ }
+ ]
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/heads/main", method="GET").respond_with_json(
+ {"object": {"sha": "c" * 40 if branch_tamper == "base" else base}}
+ )
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/git/ref/heads/post-release-v2.4.7", method="GET"
+ ).respond_with_json({"object": {"sha": commit}})
+ server.expect_request(f"/api/v3/repos/tox-dev/fixture/git/commits/{commit}", method="GET").respond_with_json(
+ {
+ "parents": [{"sha": base}] + ([{"sha": "d" * 40}] if branch_tamper == "parent" else []),
+ "author": {
+ "name": "Other" if branch_tamper == "author" else "fixture-app[bot]",
+ "email": "123+fixture-app[bot]@users.noreply.github.com",
+ },
+ "committer": {
+ "name": "Other" if branch_tamper == "committer" else "fixture-app[bot]",
+ "email": "123+fixture-app[bot]@users.noreply.github.com",
+ },
+ "message": "Other" if branch_tamper == "message" else "Bump version",
+ "tree": {
+ "sha": "b" * 40 if branch_tamper == "tree" else run_git(tmp_path / "source", "rev-parse", "HEAD^{tree}")
+ },
+ }
+ )
+ if branch_tamper == "none":
+ server.expect_request("/api/v3/repos/tox-dev/fixture/issues/7/labels", method="GET").respond_with_json(
+ [{"name": "skip news"}]
+ )
+ result = run_publish(
+ action_scripts,
+ cwd=tmp_path,
+ env=os.environ
+ | environment
+ | {
+ "GITHUB_REPOSITORY": "tox-dev/fixture",
+ "APP_SLUG": "fixture-app",
+ "BASE": "main",
+ "BASE_COMMIT": base,
+ "BRANCH": "post-release-v2.4.7",
+ "TITLE": "Bump version to 2.4.8-dev",
+ "COMMIT_MESSAGE": "Bump version",
+ "BODY_FILE": str(body),
+ "PATHS_FILE": str(paths),
+ "ALLOWED_PATHS": "gradle.properties",
+ "BUNDLE": str(bundle.relative_to(tmp_path)),
+ "CANDIDATE_COMMIT": commit,
+ "LABEL": "skip news",
+ "RUNNER_TEMP": str(tmp_path),
+ },
+ )
+ assert (result.returncode == 0) is (branch_tamper == "none"), result.stderr
+ assert all(request.method == "GET" for request, _ in server.log)
+
+
+@pytest.mark.parametrize(
+ "matching_head", [pytest.param(True, id="prepared-head"), pytest.param(False, id="changed-head")]
+)
+def test_absent_branch_pushes_prepared_commit_and_opens_pr(
+ tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], matching_head: bool
+) -> None:
+ server, environment = github_api
+ server.expect_ordered_request("/api/v3/users/fixture-app[bot]", method="GET").respond_with_json({"id": 123})
+ bundle, base, commit, blob = candidate(tmp_path)
+ remote = tmp_path / "remote.git"
+ subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True)
+ git_config = tmp_path / "gitconfig"
+ subprocess.run(
+ [
+ "git",
+ "config",
+ "--file",
+ str(git_config),
+ f"url.{remote}.insteadOf",
+ f"https://{environment['GH_HOST']}/tox-dev/fixture.git",
+ ],
+ check=True,
+ )
+ body = tmp_path / "body.md"
+ body.write_text("Set the next development version.\n")
+ paths = tmp_path / "paths.tsv"
+ paths.write_text(f"gradle.properties\t{blob}\n")
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/pulls", method="GET").respond_with_json([])
+ server.expect_ordered_request(f"{prefix}/git/ref/heads/main", method="GET").respond_with_json(
+ {"object": {"sha": base}}
+ )
+ server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json(
+ {"message": "Not Found"}, status=404
+ )
+ server.expect_ordered_request(f"{prefix}/git/ref/heads/main", method="GET").respond_with_json(
+ {"object": {"sha": base}}
+ )
+ server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json(
+ {"object": {"sha": commit}}
+ )
+ server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json(
+ {"object": {"sha": commit}}
+ )
+ server.expect_ordered_request(f"{prefix}/pulls", method="POST").respond_with_json(
+ {"number": 7, "head": {"sha": commit if matching_head else "b" * 40}}, status=201
+ )
+ if matching_head:
+ server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json(
+ {"object": {"sha": commit}}
+ )
+ server.expect_ordered_request(f"{prefix}/issues/7/labels", method="GET").respond_with_json([])
+ server.expect_ordered_request(f"{prefix}/issues/7/labels", method="POST").respond_with_json(
+ [{"name": "skip news"}], status=200
+ )
+ result = run_publish(
+ action_scripts,
+ cwd=tmp_path,
+ env=os.environ
+ | environment
+ | {
+ "GITHUB_REPOSITORY": "tox-dev/fixture",
+ "APP_SLUG": "fixture-app",
+ "BASE": "main",
+ "BASE_COMMIT": base,
+ "BRANCH": "post-release-v2.4.7",
+ "TITLE": "Bump version to 2.4.8-dev",
+ "COMMIT_MESSAGE": "Bump version",
+ "BODY_FILE": str(body),
+ "PATHS_FILE": str(paths),
+ "ALLOWED_PATHS": "gradle.properties",
+ "BUNDLE": str(bundle),
+ "CANDIDATE_COMMIT": commit,
+ "LABEL": "skip news",
+ "RUNNER_TEMP": str(tmp_path),
+ "GIT_CONFIG_GLOBAL": str(git_config),
+ },
+ )
+ assert (result.returncode == 0) is matching_head, result.stderr
+ assert run_git(remote, "rev-parse", "refs/heads/post-release-v2.4.7") == commit
+
+
+@pytest.mark.parametrize(
+ "changed_input",
+ [
+ pytest.param("commit", id="stale-bundle"),
+ pytest.param("blob", id="changed-path-manifest"),
+ pytest.param("extra", id="extra-path"),
+ pytest.param("author", id="wrong-author"),
+ pytest.param("committer", id="wrong-committer"),
+ pytest.param("message", id="wrong-message"),
+ pytest.param("unauthorized", id="unauthorized-path"),
+ ],
+)
+def test_prepared_candidate_identity_is_checked_before_api_write(
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ changed_input: str,
+) -> None:
+ server, environment = github_api
+ expect_bot(server)
+ bundle, base, commit, blob = candidate(
+ tmp_path,
+ extra_path=changed_input in {"extra", "unauthorized"},
+ wrong_author=changed_input == "author",
+ wrong_committer=changed_input == "committer",
+ wrong_message=changed_input == "message",
+ )
+ body = tmp_path / "body.md"
+ body.write_text("Set the next development version.\n")
+ paths = tmp_path / "paths.tsv"
+ paths.write_text(f"gradle.properties\t{'c' * 40 if changed_input == 'blob' else blob}\n")
+ if changed_input == "unauthorized":
+ with paths.open("a") as stream:
+ stream.write(f"other.txt\t{run_git(tmp_path / 'source', 'rev-parse', 'HEAD:other.txt')}\n")
+ result = run_publish(
+ action_scripts,
+ cwd=tmp_path,
+ env=os.environ
+ | environment
+ | {
+ "GITHUB_REPOSITORY": "tox-dev/fixture",
+ "APP_SLUG": "fixture-app",
+ "BASE": "main",
+ "BASE_COMMIT": base,
+ "BRANCH": "post-release-v2.4.7",
+ "TITLE": "Bump version to 2.4.8-dev",
+ "BODY_FILE": str(body),
+ "PATHS_FILE": str(paths),
+ "ALLOWED_PATHS": "gradle.properties",
+ "BUNDLE": str(bundle),
+ "CANDIDATE_COMMIT": "d" * 40 if changed_input == "commit" else commit,
+ "COMMIT_MESSAGE": "Bump version",
+ "LABEL": "skip news",
+ "RUNNER_TEMP": str(tmp_path),
+ },
+ )
+ assert result.returncode != 0
+ assert [request.method for request, _ in server.log] == ["GET"]
diff --git a/tests/read_towncrier_config/test_config.py b/tests/read_towncrier_config/test_config.py
new file mode 100644
index 0000000..f7db56d
--- /dev/null
+++ b/tests/read_towncrier_config/test_config.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from pathlib import Path
+from textwrap import dedent
+
+import pytest
+from conftest import action, run
+
+
+def test_reader_uses_configured_type_array(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="")
+ assert result.returncode == 0, result.stderr
+ outputs = dict(line.split("=", 1) for line in (tmp_path / "output").read_text().splitlines())
+ assert outputs == {
+ "changelog": "CHANGELOG.md",
+ "fragments": "changelog.d",
+ "format": "markdown",
+ }
+
+
+def test_reader_prefers_towncrier_fragment_tables(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ (repository / "towncrier.toml").write_text(
+ dedent(
+ """\
+ [tool.towncrier]
+ directory = "docs/news"
+ filename = "docs/NEWS.rst"
+
+ [tool.towncrier.fragment.feature]
+ name = "Added"
+
+ [tool.towncrier.fragment.bugfix]
+ name = "Fixed"
+ """
+ )
+ )
+ run(repository, "git", "add", "towncrier.toml")
+ run(repository, "git", "commit", "-m", "Configure Towncrier in a separate file")
+ result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="")
+ assert result.returncode == 0, result.stderr
+ outputs = dict(line.split("=", 1) for line in (tmp_path / "output").read_text().splitlines())
+ assert outputs == {
+ "changelog": "docs/NEWS.rst",
+ "fragments": "docs/news",
+ "format": "rst",
+ }
+
+
+@pytest.mark.parametrize("path", [pytest.param("../NEWS.md", id="parent"), pytest.param("/NEWS.md", id="absolute")])
+def test_reader_rejects_unsafe_changelog_path(
+ repository: Path, tmp_path: Path, action_scripts: Path, path: str
+) -> None:
+ project = repository / "pyproject.toml"
+ project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', f'filename = "{path}"'))
+ run(repository, "git", "add", "pyproject.toml")
+ run(repository, "git", "commit", "-m", "Set changelog path")
+ result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="")
+ assert result.returncode != 0
+ assert "Towncrier paths must stay within the repository" in result.stderr
+
+
+def test_reader_rejects_mixed_fragment_styles(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ project = repository / "pyproject.toml"
+ project.write_text(project.read_text() + '\n[tool.towncrier.fragment.bugfix]\nname = "Fixed"\n')
+ run(repository, "git", "add", "pyproject.toml")
+ run(repository, "git", "commit", "-m", "Mix Towncrier fragment styles")
+ result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="")
+ assert result.returncode != 0
+ assert "type and fragment tables cannot be combined" in result.stderr
+
+
+def test_reader_rejects_non_table_fragment(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ (repository / "towncrier.toml").write_text(
+ '[tool.towncrier]\ndirectory = "changelog.d"\nfilename = "CHANGELOG.md"\nfragment = { feature = "Added" }\n'
+ )
+ run(repository, "git", "add", "towncrier.toml")
+ run(repository, "git", "commit", "-m", "Set an invalid fragment definition")
+ result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="")
+ assert result.returncode != 0
+ assert "Towncrier fragment categories are required" in result.stderr
diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py
new file mode 100644
index 0000000..af4ef0e
--- /dev/null
+++ b/tests/recheck_hook_update_cooldown/test_recheck.py
@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+import pytest
+import yaml
+from conftest import action
+from pytest_httpserver import HTTPServer
+
+
+def test_recheck_schedule_has_only_required_permissions() -> None:
+ workflow = yaml.load(
+ (Path(__file__).resolve().parents[2] / ".github/workflows/recheck-hook-cooldown.yaml").read_text(),
+ Loader=yaml.BaseLoader,
+ )
+ assert workflow["jobs"]["recheck"] == {
+ "runs-on": "ubuntu-26.04",
+ "permissions": {"actions": "write", "contents": "read", "pull-requests": "read"},
+ "steps": [{"uses": "$/recheck-hook-update-cooldown"}],
+ }
+
+
+@pytest.mark.parametrize(
+ ("observed_age", "job_age", "job_conclusion", "job_name", "reruns"),
+ [
+ pytest.param(8, 8, "failure", "hook-cooldown", 1, id="matured-failure"),
+ pytest.param(8, 8, "failure", "Hook update cooldown", 1, id="named-job"),
+ pytest.param(6, 6, "failure", "hook-cooldown", 0, id="recent-pull"),
+ pytest.param(8, 0, "failure", "hook-cooldown", 1, id="failed-rerun"),
+ pytest.param(31, 31, "failure", "hook-cooldown", 0, id="expired-run"),
+ pytest.param(8, 8, "success", "hook-cooldown", 0, id="successful-job"),
+ ],
+)
+@pytest.mark.parametrize(
+ "bot_login",
+ [pytest.param("pre-commit-ci[bot]", id="pre-commit-ci"), pytest.param("release-app[bot]", id="app")],
+)
+def test_recheck_reruns_failed_job(
+ github_api: tuple[HTTPServer, dict[str, str]],
+ action_scripts: Path,
+ tmp_path: Path,
+ observed_age: int,
+ job_age: int,
+ job_conclusion: str,
+ job_name: str,
+ reruns: int,
+ bot_login: str,
+) -> None:
+ server, environment = github_api
+ observed_at = (datetime.now(UTC) - timedelta(days=observed_age)).strftime("%Y-%m-%dT%H:%M:%SZ")
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json(
+ [
+ {
+ "number": 7,
+ "user": {"login": bot_login, "type": "Bot"},
+ "head": {
+ "sha": "a" * 40,
+ "ref": "pre-commit-ci-update-config",
+ "repo": {"full_name": "tox-dev/fixture"},
+ },
+ "base": {"repo": {"full_name": "tox-dev/fixture"}},
+ "updated_at": datetime.now(UTC).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"
+ ).respond_with_json(
+ {
+ "workflow_runs": [
+ {
+ "id": 42,
+ "event": "pull_request",
+ "head_repository": {"full_name": "tox-dev/fixture"},
+ "head_sha": "a" * 40,
+ "head_branch": "pre-commit-ci-update-config",
+ "pull_requests": [{"number": 7}],
+ "status": "completed",
+ "created_at": observed_at,
+ }
+ ]
+ }
+ )
+ if 7 <= observed_age < 30:
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json(
+ {
+ "jobs": [
+ {"id": 98, "name": "Other failed job", "conclusion": "failure", "completed_at": observed_at},
+ {
+ "id": 99,
+ "name": job_name,
+ "conclusion": job_conclusion,
+ "completed_at": (datetime.now(UTC) - timedelta(days=job_age)).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ },
+ ]
+ }
+ )
+ if reruns:
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99", method="GET").respond_with_json(
+ {"id": 99, "run_id": 42, "name": job_name, "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"],
+ JOB_NAME=job_name,
+ **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
+
+
+def test_recheck_continues_after_failed_rerun(
+ github_api: tuple[HTTPServer, dict[str, str]], action_scripts: Path, tmp_path: Path
+) -> None:
+ server, environment = github_api
+ observed_at = (datetime.now(UTC) - timedelta(days=8)).strftime("%Y-%m-%dT%H:%M:%SZ")
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json(
+ [
+ {
+ "number": number,
+ "user": {"login": "release-app[bot]", "type": "Bot"},
+ "head": {"sha": str(number) * 40, "ref": f"update-{number}", "repo": {"full_name": "tox-dev/fixture"}},
+ "base": {"repo": {"full_name": "tox-dev/fixture"}},
+ }
+ for number in (7, 8)
+ ]
+ )
+ for number in (7, 8):
+ server.expect_request(f"/api/v3/repos/tox-dev/fixture/pulls/{number}/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=f"event=pull_request&head_sha={str(number) * 40}&per_page=100",
+ ).respond_with_json(
+ {
+ "workflow_runs": [
+ {
+ "id": number + 40,
+ "event": "pull_request",
+ "head_repository": {"full_name": "tox-dev/fixture"},
+ "head_sha": str(number) * 40,
+ "head_branch": f"update-{number}",
+ "pull_requests": [{"number": number}],
+ "status": "completed",
+ "created_at": observed_at,
+ }
+ ]
+ }
+ )
+ job = number + 90
+ server.expect_request(
+ f"/api/v3/repos/tox-dev/fixture/actions/runs/{number + 40}/jobs", method="GET"
+ ).respond_with_json({"jobs": [{"id": job, "name": "hook-cooldown", "conclusion": "failure"}]})
+ server.expect_request(f"/api/v3/repos/tox-dev/fixture/actions/jobs/{job}", method="GET").respond_with_json(
+ {"id": job, "run_id": number + 40, "name": "hook-cooldown", "status": "completed"}
+ )
+ server.expect_request(
+ f"/api/v3/repos/tox-dev/fixture/actions/jobs/{job}/rerun", method="POST"
+ ).respond_with_data(status=500 if number == 7 else 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 "could not rerun hook cooldown for pull request #7" in result.stderr
+ assert [request.path for request, _ in server.log if request.method == "POST"] == [
+ "/api/v3/repos/tox-dev/fixture/actions/jobs/97/rerun",
+ "/api/v3/repos/tox-dev/fixture/actions/jobs/98/rerun",
+ ]
diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py
new file mode 100644
index 0000000..6f640b6
--- /dev/null
+++ b/tests/reconcile_pypi_artifacts/test_reconcile.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+from textwrap import dedent
+
+import pytest
+from conftest import action
+from pytest_httpserver import HTTPServer
+
+
+@pytest.mark.parametrize(
+ ("project", "version", "distribution_exists"),
+ [
+ pytest.param("", "1.0", True, id="missing-project"),
+ pytest.param("fixture", "", True, id="missing-version"),
+ pytest.param("fixture", "1.0", False, id="missing-distribution"),
+ ],
+)
+def test_reconcile_rejects_invalid_inputs(
+ tmp_path: Path,
+ action_scripts: Path,
+ project: str,
+ version: str,
+ distribution_exists: bool,
+) -> None:
+ distribution = tmp_path / "dist"
+ if distribution_exists:
+ distribution.mkdir()
+ result = action(
+ Path(__file__).resolve().parents[2],
+ tmp_path,
+ action_scripts,
+ "reconcile-pypi-artifacts",
+ version=version,
+ PROJECT=project,
+ DIST=str(distribution),
+ )
+ assert result.returncode != 0
+
+
+@pytest.mark.parametrize(
+ ("remote_extra", "wrong_digest", "expected_error"),
+ [
+ pytest.param(False, False, "Provenance for file", id="matching-files-require-attestation"),
+ pytest.param(True, False, "PyPI has unexpected files", id="unexpected-remote-file"),
+ pytest.param(False, True, "PyPI has different SHA256 digests", id="digest-mismatch"),
+ ],
+)
+def test_reconcile_checks_complete_release_before_attestations(
+ tmp_path: Path,
+ action_scripts: Path,
+ httpserver: HTTPServer,
+ remote_extra: bool,
+ wrong_digest: bool,
+ expected_error: str,
+) -> None:
+ distribution = tmp_path / "dist"
+ distribution.mkdir()
+ wheel_name = "fixture-1.0-py3-none-any.whl"
+ wheel = distribution / wheel_name
+ wheel.write_bytes(b"wheel contents")
+ url = f"https://files.pythonhosted.org/packages/a/{wheel_name}"
+ sdist_name = "fixture-1.0.tar.gz"
+ sdist_url = f"https://files.pythonhosted.org/packages/b/{sdist_name}"
+ if not remote_extra and not wrong_digest:
+ (distribution / sdist_name).write_bytes(b"sdist contents")
+ files = [
+ {
+ "filename": wheel_name,
+ "url": url,
+ "hashes": {"sha256": "0" * 64 if wrong_digest else hashlib.sha256(wheel.read_bytes()).hexdigest()},
+ "yanked": False,
+ },
+ ]
+ if not wrong_digest:
+ files.append(
+ {
+ "filename": sdist_name,
+ "url": sdist_url,
+ "hashes": {
+ "sha256": "1" * 64
+ if remote_extra
+ else hashlib.sha256((distribution / sdist_name).read_bytes()).hexdigest()
+ },
+ "yanked": False,
+ },
+ )
+ httpserver.expect_request("/simple/fixture/").respond_with_json({"meta": {"api-version": "1.0"}, "files": files})
+ if not remote_extra and not wrong_digest:
+ httpserver.expect_request(f"/packages/a/{wheel_name}").respond_with_data(wheel.read_bytes())
+ httpserver.expect_request(f"/integrity/fixture/1.0/{wheel_name}/provenance").respond_with_json(
+ {"message": "missing"}, status=404
+ )
+ python_path = tmp_path / "pythonpath"
+ python_path.mkdir()
+ (python_path / "sitecustomize.py").write_text(
+ dedent(
+ """
+ import os
+ from urllib.parse import urlsplit
+ import urllib.request
+
+ base = os.environ["TEST_PYPI_SERVER"]
+ original_open = urllib.request.urlopen
+
+ def route_open(request, *args, **kwargs):
+ parsed = urlsplit(request.full_url)
+ if parsed.hostname == "pypi.org":
+ request = urllib.request.Request(f"{base}{parsed.path}", headers=dict(request.header_items()))
+ return original_open(request, *args, **kwargs)
+
+ urllib.request.urlopen = route_open
+
+ try:
+ import requests
+ except ImportError:
+ pass
+ else:
+ original_request = requests.Session.request
+
+ def route_request(self, method, url, *args, **kwargs):
+ parsed = urlsplit(url)
+ if parsed.hostname in {"pypi.org", "files.pythonhosted.org"}:
+ url = f"{base}{parsed.path}"
+ return original_request(self, method, url, *args, **kwargs)
+
+ requests.Session.request = route_request
+ """
+ ),
+ )
+ result = action(
+ Path(__file__).resolve().parents[2],
+ tmp_path,
+ action_scripts,
+ "reconcile-pypi-artifacts",
+ version="1.0",
+ PROJECT="fixture",
+ DIST=str(distribution),
+ PYTHONPATH=str(python_path),
+ TEST_PYPI_SERVER=httpserver.url_for("").rstrip("/"),
+ NO_PROXY="localhost,127.0.0.1",
+ )
+ assert result.returncode != 0
+ assert expected_error in result.stderr
+ if not remote_extra and not wrong_digest:
+ assert (tmp_path / "pypi-file-urls").read_text() == f"{url}\n{sdist_url}\n"
diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py
new file mode 100644
index 0000000..cd42887
--- /dev/null
+++ b/tests/refresh_release_lock/test_cutoffs.py
@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+import tomllib
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture
+def cutoff_project(tmp_path: Path) -> Path:
+ project = tmp_path / "pyproject.toml"
+ project.write_text(
+ '[project]\nname = "example"\nversion = "0.1"\n'
+ '[tool.nab]\nuploaded-prior-to = "P7D"\n'
+ '[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n'
+ '[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n'
+ '[[tool.nab.package-rules]]\nmatch = ["old-rule"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n'
+ '[[tool.nab.package-rules]]\nmatch = ["new-rule"]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n',
+ )
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True)
+ subprocess.run(["git", "add", "pyproject.toml"], cwd=tmp_path, check=True, capture_output=True)
+ subprocess.run(
+ ["git", "-c", "user.name=Release test", "-c", "user.email=release@example.org", "commit", "-m", "Add policy"],
+ cwd=tmp_path,
+ check=True,
+ capture_output=True,
+ )
+ return project
+
+
+def test_expire_cutoffs_removes_only_expired_rules(cutoff_project: Path) -> None:
+ source = Path(__file__).resolve().parents[2] / "tools"
+ result = subprocess.run(
+ [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ nab = tomllib.loads(cutoff_project.read_text())["tool"]["nab"]
+ assert set(nab["packages"]) == {"new"}
+ assert [rule["match"] for rule in nab["package-rules"]] == [["new-rule"]]
+
+
+def test_expire_cutoffs_removes_empty_package_table(cutoff_project: Path) -> None:
+ cutoff_project.write_text(
+ cutoff_project.read_text().replace('[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', "")
+ )
+ source = Path(__file__).resolve().parents[2] / "tools"
+ result = subprocess.run(
+ [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert "packages" not in tomllib.loads(cutoff_project.read_text())["tool"]["nab"]
+
+
+def test_expire_cutoffs_accepts_toml_datetime(cutoff_project: Path) -> None:
+ cutoff_project.write_text(cutoff_project.read_text().replace('"2026-01-01T00:00:00Z"', "2026-01-01T00:00:00Z"))
+ source = Path(__file__).resolve().parents[2] / "tools"
+ result = subprocess.run(
+ [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert "old" not in tomllib.loads(cutoff_project.read_text())["tool"]["nab"]["packages"]
+
+
+def test_verify_cutoff_patch_accepts_expired_only(cutoff_project: Path) -> None:
+ source = Path(__file__).resolve().parents[2] / "tools"
+ subprocess.run([sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], check=True)
+ result = subprocess.run(
+ [sys.executable, str(source / "verify_cutoff_patch.py")],
+ cwd=cutoff_project.parent,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def test_verify_cutoff_patch_accepts_subset_expired(cutoff_project: Path) -> None:
+ cutoff_project.write_text(
+ cutoff_project.read_text().replace('[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', "")
+ )
+ source = Path(__file__).resolve().parents[2] / "tools"
+ result = subprocess.run(
+ [sys.executable, str(source / "verify_cutoff_patch.py")],
+ cwd=cutoff_project.parent,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+
+
+def test_verify_cutoff_patch_rejects_other_metadata(cutoff_project: Path) -> None:
+ source = Path(__file__).resolve().parents[2] / "tools"
+ subprocess.run([sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], check=True)
+ cutoff_project.write_text(cutoff_project.read_text().replace('name = "example"', 'name = "intruder"'))
+ result = subprocess.run(
+ [sys.executable, str(source / "verify_cutoff_patch.py")],
+ cwd=cutoff_project.parent,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode != 0
+ assert "changes beyond expired Nab cutoffs" in result.stderr
+
+
+def test_verify_cutoff_patch_rejects_early_floor_removal(cutoff_project: Path) -> None:
+ source = Path(__file__).resolve().parents[2] / "tools"
+ text = cutoff_project.read_text()
+ cutoff_project.write_text(text.replace('[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', ""))
+ result = subprocess.run(
+ [sys.executable, str(source / "verify_cutoff_patch.py")],
+ cwd=cutoff_project.parent,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode != 0
+ assert "changes beyond expired Nab cutoffs" in result.stderr
+
+
+def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None:
+ source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"
+ committed = tmp_path / "committed.toml"
+ generated = tmp_path / "generated.toml"
+ committed.write_text('packages = ["safe"]\n[tool.nab]\ncommand-line = ["--upgrade"]\n')
+ generated.write_text('packages = ["changed"]\n[tool.nab]\ncommand-line = []\n')
+ result = subprocess.run(
+ [sys.executable, str(source), str(committed), str(generated)],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode != 0
+ assert "regenerated Nab lock differs" in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("package", "timestamp", "accepted"),
+ [
+ pytest.param("nab", "2026-10-19T00:00:00Z", True, id="reviewed-nab"),
+ pytest.param("nab", "2026-10-20T00:00:00Z", False, id="extended-nab"),
+ pytest.param("release-creator", "2026-10-23T02:44:40Z", True, id="reviewed-release-creator"),
+ pytest.param("release-creator", "2026-10-23T02:44:41Z", False, id="extended-release-creator"),
+ pytest.param("unreviewed", "2026-10-22T00:00:00Z", False, id="new-package"),
+ ],
+)
+def test_recent_cutoff_requires_review(package: str, timestamp: str, accepted: bool) -> None:
+ project = (
+ f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.{package}]\nuploaded-prior-to = "{timestamp}"\n'
+ )
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ (
+ "from datetime import UTC, datetime; from nab_cutoffs import validate_recent_cutoffs; "
+ "import sys; validate_recent_cutoffs(sys.stdin.read(), datetime(2026, 10, 16, tzinfo=UTC))"
+ ),
+ ],
+ cwd=Path(__file__).resolve().parents[2] / "tools",
+ input=project,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert (result.returncode == 0) == accepted
+ if not accepted:
+ assert "unreviewed recent Nab cutoff" in result.stderr
diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py
new file mode 100644
index 0000000..cc8247c
--- /dev/null
+++ b/tests/release_workflow/test_graph.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+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"
+
+
+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", "push"]
+ assert jobs["assemble"]["outputs"]["artifact-id"] == "${{ steps.patch.outputs.artifact-id }}"
+ assert jobs["assemble"]["outputs"]["patch-sha"] == "${{ steps.digest.outputs.sha }}"
+ assert (
+ next(step for step in jobs["assemble"]["steps"] if step.get("id") == "patch")["with"]["name"] == "release-patch"
+ )
+ assert (
+ next(step for step in jobs["push"]["steps"] if "download-artifact" in step.get("uses", ""))["with"][
+ "artifact-ids"
+ ]
+ == "${{ needs.assemble.outputs.artifact-id }}"
+ )
+
+
+def test_prepare_validates_patch_in_fresh_worktree() -> None:
+ workflow = yaml.safe_load(
+ (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text()
+ )
+ steps = workflow["jobs"]["assemble"]["steps"]
+ worktree = next(step for step in steps if step.get("name") == "Create a fresh validation worktree")
+ validation = next(step for step in steps if step.get("uses") == "$/validate-release-patch")
+ assert "git worktree add --detach" in worktree["run"]
+ assert validation["with"]["working-directory"] == "${{ runner.temp }}/release-validation"
+ assert (
+ steps.index(worktree)
+ < steps.index(validation)
+ < next(index for index, step in enumerate(steps) if step.get("id") == "patch")
+ )
+
+
+def test_prepare_builds_local_tool_once_with_locked_backends() -> None:
+ workflow = yaml.safe_load(
+ (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text()
+ )
+ steps = workflow["jobs"]["assemble"]["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.constraints.outputs.path }}"
+ 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_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"] == "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/check.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:
+ workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text())
+ publish = workflow["jobs"]["publish"]
+ assert publish["permissions"] == {"contents": "write"}
+ assert publish["needs"] == ["check", "source", "notes"]
+ assert [step["uses"] for step in publish["steps"]] == [
+ "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333",
+ "$/publish-github-release",
+ ]
+ assert publish["steps"][-1]["with"]["commit"] == "${{ needs.source.outputs.commit }}"
+ 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"] == 0
+ 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"
+ script.write_text("#!/usr/bin/env bash\nif true\nthen\necho x\nfi\n")
+ executable = shutil.which("prek")
+ assert executable is not None
+ result = subprocess.run(
+ [
+ executable,
+ "run",
+ "shfmt",
+ "--config",
+ str(Path(__file__).resolve().parents[2] / ".pre-commit-config.yaml"),
+ "--files",
+ str(script),
+ ],
+ cwd=tmp_path,
+ env={name: os.environ[name] for name in ("HOME", "PATH")},
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode != 0
+ assert "diff" in result.stdout
+
+
+def test_action_manifests_have_no_empty_environment_mapping() -> None:
+ root = Path(__file__).resolve().parents[2]
+ for path in root.glob("*/action.yml"):
+ action = yaml.safe_load(path.read_text())
+ assert all("env" not in step or isinstance(step["env"], dict) for step in action["runs"]["steps"]), path
diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py
new file mode 100644
index 0000000..05fe274
--- /dev/null
+++ b/tests/render_release_notes/test_notes.py
@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+from pathlib import Path
+from textwrap import dedent
+
+import pytest
+from conftest import action, run
+
+
+@pytest.fixture
+def tagged_repository(repository: Path) -> Path:
+ (repository / "CHANGELOG.md").write_text(
+ dedent(
+ """\
+ # Changelog
+
+
+
+
+
+ ## 0.2.0 (2026-10-20)
+
+
+
+ ### Added
+
+ - Add a new feature ([#2](https://github.com/tox-dev/fixture/pull/2)) by
+ [@gaborbernat](https://github.com/gaborbernat).
+ """,
+ ),
+ )
+ run(repository, "git", "add", "CHANGELOG.md")
+ run(repository, "git", "commit", "-m", "Release 0.2.0")
+ run(repository, "git", "tag", "0.2.0")
+ return repository
+
+
+def test_render_release_notes_uses_tagged_changelog(
+ tagged_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ result = action(
+ tagged_repository,
+ tmp_path,
+ action_scripts,
+ "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(
+ """\
+
+
+ ### Features
+
+ - Add a new feature ([#2](https://github.com/tox-dev/fixture/pull/2)) by @gaborbernat.
+
+ [PyPI release](https://pypi.org/project/fixture/0.2.0/)
+
+ **Full Changelog**: https://github.com/tox-dev/fixture/compare/0.1.0...0.2.0
+ """,
+ )
+ assert "previous=0.1.0" in (tmp_path / "output").read_text()
+
+
+def test_render_release_notes_uses_generated_contributors(
+ tagged_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ generated = tmp_path / "generated.md"
+ generated.write_text(
+ "* Add a new feature by @gaborbernat in https://github.com/tox-dev/fixture/pull/2\n"
+ "\n## New Contributors\n* @gaborbernat made their first contribution in "
+ "https://github.com/tox-dev/fixture/pull/2\n",
+ )
+ result = action(
+ tagged_repository,
+ tmp_path,
+ action_scripts,
+ "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()
+
+
+def test_render_release_notes_accepts_first_release(
+ tagged_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ run(tagged_repository, "git", "tag", "-d", "0.1.0")
+ result = action(
+ tagged_repository,
+ tmp_path,
+ action_scripts,
+ "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()
+
+
+@pytest.mark.parametrize(
+ ("role", "path"),
+ [pytest.param("pr", "pull", id="pull-request"), pytest.param("issue", "issues", id="issue")],
+)
+def test_render_release_notes_converts_rst_reference(
+ tagged_repository: Path, tmp_path: Path, action_scripts: Path, role: str, path: str
+) -> None:
+ (tagged_repository / "CHANGELOG.rst").write_text(
+ f"0.2.0 (2026-10-20)\n==================\n\nFixed\n-----\n\n- Correct a bug (:{role}:`4`).\n",
+ )
+ result = action(
+ tagged_repository,
+ tmp_path,
+ action_scripts,
+ "render-release-notes",
+ 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()
+
+
+def test_render_release_notes_custom_artifact_label(
+ tagged_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ result = action(
+ tagged_repository,
+ tmp_path,
+ action_scripts,
+ "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()
+ )
+
+
+@pytest.mark.parametrize(
+ ("tag", "remove_heading", "error"),
+ [
+ pytest.param("invalid tag", False, "release tag not found", id="invalid-tag"),
+ pytest.param("0.2.0", True, "expected one changelog section", id="missing-heading"),
+ ],
+)
+def test_render_release_notes_rejects_invalid_source(
+ tagged_repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ tag: str,
+ remove_heading: bool,
+ error: str,
+) -> None:
+ if remove_heading:
+ changelog = tagged_repository / "CHANGELOG.md"
+ changelog.write_text(changelog.read_text().replace("## 0.2.0 (", "0.2.0 ("))
+ result = action(
+ tagged_repository,
+ tmp_path,
+ action_scripts,
+ "render-release-notes",
+ version=tag,
+ TAG=tag,
+ OUTPUT=str(tmp_path / "notes.md"),
+ )
+ assert result.returncode != 0
+ assert error in result.stderr
diff --git a/tests/resolve_pr_from_run/test_resolve.py b/tests/resolve_pr_from_run/test_resolve.py
new file mode 100644
index 0000000..1a3a7d5
--- /dev/null
+++ b/tests/resolve_pr_from_run/test_resolve.py
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from conftest import action
+from pytest_httpserver import HTTPServer
+
+
+@pytest.mark.parametrize(
+ ("head_repository", "head_owner", "branch", "author_type", "conclusion"),
+ [
+ pytest.param("tox-dev/fixture", "tox-dev", "dependabot/uv", "Bot", "success", id="same-repo-bot"),
+ pytest.param("external/fixture", "external", "fix/news", "Bot", "failure", id="fork-bot-failed-ci"),
+ pytest.param("external/fixture", "external", "fix/news", "User", "success", id="human"),
+ ],
+)
+def test_resolve_pr_from_run_matches_current_head(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ head_repository: str,
+ head_owner: str,
+ branch: str,
+ author_type: str,
+ conclusion: str,
+) -> None:
+ server, environment = github_api
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json(
+ {
+ "id": 42,
+ "repository": {"full_name": "tox-dev/fixture"},
+ "event": "pull_request",
+ "path": ".github/workflows/check.yaml",
+ "status": "completed",
+ "conclusion": conclusion,
+ "head_repository": {"full_name": head_repository, "owner": {"login": head_owner}},
+ "head_branch": branch,
+ "head_sha": "a" * 40,
+ "pull_requests": [],
+ },
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json(
+ [
+ {
+ "number": 7,
+ "state": "open",
+ "user": {"type": author_type},
+ "head": {"sha": "a" * 40, "repo": {"full_name": head_repository}},
+ "base": {"repo": {"full_name": "tox-dev/fixture"}},
+ },
+ ],
+ )
+ result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment)
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "output").read_text() == f"number=7\nbot-author={str(author_type == 'Bot').lower()}\n"
+ assert server.log[1][0].args["head"] == f"{head_owner}:{branch}"
+
+
+@pytest.mark.parametrize(
+ ("head_sha", "head_repository", "response"),
+ [
+ pytest.param("b" * 40, "external/fixture", "stale-head", id="stale-head"),
+ pytest.param("a" * 40, "other/fixture", "wrong-fork", id="wrong-fork"),
+ pytest.param("a" * 40, "external/fixture", "missing", id="no-pr"),
+ ],
+)
+def test_resolve_pr_from_run_skips_unmatched_pr(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ head_sha: str,
+ head_repository: str,
+ response: str,
+) -> None:
+ server, environment = github_api
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json(
+ {
+ "id": 42,
+ "repository": {"full_name": "tox-dev/fixture"},
+ "event": "pull_request",
+ "path": ".github/workflows/check.yaml",
+ "status": "completed",
+ "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}},
+ "head_branch": "fix/news",
+ "head_sha": "a" * 40,
+ },
+ )
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json(
+ []
+ if response == "missing"
+ else [
+ {
+ "number": 7,
+ "state": "open",
+ "user": {"type": "Bot"},
+ "head": {"sha": head_sha, "repo": {"full_name": head_repository}},
+ "base": {"repo": {"full_name": "tox-dev/fixture"}},
+ },
+ ],
+ )
+ result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment)
+ assert result.returncode == 0, result.stderr
+ assert not (tmp_path / "output").exists()
+
+
+def test_resolve_pr_from_run_rejects_ambiguous_pr(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json(
+ {
+ "id": 42,
+ "repository": {"full_name": "tox-dev/fixture"},
+ "event": "pull_request",
+ "path": ".github/workflows/check.yaml",
+ "status": "completed",
+ "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}},
+ "head_branch": "fix/news",
+ "head_sha": "a" * 40,
+ },
+ )
+ candidate = {
+ "number": 7,
+ "state": "open",
+ "user": {"type": "Bot"},
+ "head": {"sha": "a" * 40, "repo": {"full_name": "external/fixture"}},
+ "base": {"repo": {"full_name": "tox-dev/fixture"}},
+ }
+ server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json(
+ [candidate, candidate | {"number": 8}],
+ )
+ result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment)
+ assert result.returncode != 0
+ assert "source run matches multiple pull requests" in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ pytest.param("event", "pull_request_target", id="privileged-event"),
+ pytest.param("path", ".github/workflows/release.yaml", id="wrong-workflow"),
+ pytest.param("status", "in_progress", id="unfinished-run"),
+ pytest.param("repository", {"full_name": "other/fixture"}, id="wrong-repository"),
+ pytest.param("id", 43, id="wrong-run-id"),
+ ],
+)
+def test_resolve_pr_from_run_rejects_untrusted_source(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ field: str,
+ value: _JsonValue,
+) -> None:
+ server, environment = github_api
+ source: dict[str, _JsonValue] = {
+ "id": 42,
+ "repository": {"full_name": "tox-dev/fixture"},
+ "event": "pull_request",
+ "path": ".github/workflows/check.yaml",
+ "status": "completed",
+ "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}},
+ "head_branch": "fix/news",
+ "head_sha": "a" * 40,
+ }
+ source[field] = value
+ server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json(source)
+ result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment)
+ assert result.returncode != 0
+ assert len(server.log) == 1
+
+
+def test_resolve_pr_from_run_rejects_non_numeric_id(
+ repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
+) -> None:
+ server, environment = github_api
+ result = action(
+ repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", RUN_ID="../42", **environment
+ )
+ assert result.returncode != 0
+ assert "run-id must be a decimal integer" in result.stderr
+ assert not server.log
+
+
+type _JsonValue = str | int | dict[str, _JsonValue]
diff --git a/tests/select_previous_tag/test_select.py b/tests/select_previous_tag/test_select.py
new file mode 100644
index 0000000..57c2ddc
--- /dev/null
+++ b/tests/select_previous_tag/test_select.py
@@ -0,0 +1,74 @@
+from pathlib import Path
+
+import pytest
+from conftest import action, run
+
+
+def test_select_previous_tag_uses_version_order(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ for tag in ("0.2.0", "0.10.0", "1.0.0"):
+ run(repository, "git", "tag", tag)
+ result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="1.0.0", TAG="1.0.0")
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "output").read_text() == "previous=0.10.0\n"
+
+
+def test_select_previous_tag_accepts_first_release(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="0.1.0", TAG="0.1.0")
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "output").read_text() == "previous=\n"
+
+
+@pytest.mark.parametrize(
+ "target",
+ [
+ pytest.param("1.1.0", id="bare-version"),
+ pytest.param("pyproject-fmt/1.1.0", id="qualified-tag"),
+ ],
+)
+def test_select_previous_tag_scopes_package(
+ repository: Path, tmp_path: Path, action_scripts: Path, target: str
+) -> None:
+ for tag in ("pyproject-fmt/1.0.0", "pyproject-fmt/1.1.0", "tox-toml-fmt/1.0.5"):
+ run(repository, "git", "tag", tag)
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "select-previous-tag",
+ version=target,
+ TAG=target,
+ PACKAGE="pyproject-fmt",
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "output").read_text() == "previous=pyproject-fmt/1.0.0\n"
+
+
+@pytest.mark.parametrize(
+ ("stable_only", "previous"),
+ [
+ pytest.param("false", "v1.1.0rc1", id="include-prerelease"),
+ pytest.param("true", "v1.0.0", id="stable-only"),
+ ],
+)
+def test_select_previous_tag_filters_prereleases(
+ repository: Path, tmp_path: Path, action_scripts: Path, stable_only: str, previous: str
+) -> None:
+ for tag in ("v1.0.0", "v1.1.0rc1", "v1.1.0"):
+ run(repository, "git", "tag", tag)
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "select-previous-tag",
+ version="v1.1.0",
+ TAG="v1.1.0",
+ STABLE_ONLY=stable_only,
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "output").read_text() == f"previous={previous}\n"
+
+
+def test_select_previous_tag_requires_current_tag(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="0.2.0", TAG="0.2.0")
+ assert result.returncode != 0
+ assert not (tmp_path / "output").exists()
diff --git a/tests/sign_jetbrains_plugin/test_sign.py b/tests/sign_jetbrains_plugin/test_sign.py
new file mode 100644
index 0000000..5d3aeef
--- /dev/null
+++ b/tests/sign_jetbrains_plugin/test_sign.py
@@ -0,0 +1,201 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import shutil
+import subprocess
+from pathlib import Path
+from zipfile import ZIP_DEFLATED, ZipFile
+
+import pytest
+from conftest import action
+
+
+@pytest.mark.parametrize(
+ "credential_format",
+ [
+ pytest.param("pem", id="pem"),
+ pytest.param("base64", id="base64"),
+ pytest.param("encrypted", id="encrypted"),
+ ],
+)
+def test_signs_and_verifies_exact_archive(
+ action_scripts: Path,
+ certificate: tuple[Path, Path],
+ unsigned_archive: Path,
+ tmp_path: Path,
+ credential_format: str,
+) -> None:
+ private_key, chain = certificate
+ password = ""
+ if credential_format == "encrypted":
+ encrypted_key = tmp_path / "encrypted.pem"
+ subprocess.run(
+ [
+ "openssl",
+ "pkcs8",
+ "-topk8",
+ "-v2",
+ "aes-256-cbc",
+ "-in",
+ str(private_key),
+ "-out",
+ str(encrypted_key),
+ "-passout",
+ "pass:fixture-pass",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ private_key = encrypted_key
+ password = "fixture-pass"
+ key_text = private_key.read_text()
+ chain_text = chain.read_text()
+ if credential_format == "base64":
+ key_text = base64.b64encode(key_text.encode()).decode()
+ chain_text = base64.b64encode(chain_text.encode()).decode()
+ java = shutil.which("java")
+ assert java is not None
+ result = action(
+ tmp_path,
+ tmp_path,
+ action_scripts,
+ "sign-jetbrains-plugin",
+ version="",
+ ARCHIVE=str(unsigned_archive),
+ SHA256=hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(),
+ JAVA_HOME_17_X64=str(Path(java).resolve().parents[1]),
+ CERTIFICATE_CHAIN=chain_text,
+ PRIVATE_KEY=key_text,
+ PRIVATE_KEY_PASSWORD=password,
+ )
+ assert result.returncode == 0, result.stderr
+ assert not (tmp_path / "signing-key.pem").exists()
+ assert not (tmp_path / "signing-key-decrypted.pem").exists()
+ assert not (tmp_path / "signing-chain.pem").exists()
+ signed = unsigned_archive.with_name("Plugin 2-1.0-signed.zip")
+ assert dict(line.split("=", 1) for line in (tmp_path / "output").read_text().splitlines()) == {
+ "archive": str(signed),
+ "sha256": hashlib.sha256(signed.read_bytes()).hexdigest(),
+ }
+ with ZipFile(unsigned_archive) as unsigned, ZipFile(signed) as signed_zip:
+ assert {name: unsigned.read(name) for name in unsigned.namelist()} == {
+ name: signed_zip.read(name) for name in signed_zip.namelist()
+ }
+
+
+@pytest.mark.parametrize(
+ "invalid",
+ [
+ pytest.param("digest", id="wrong-digest"),
+ pytest.param("extension", id="wrong-extension"),
+ pytest.param("corrupt", id="corrupt-zip"),
+ pytest.param("duplicate", id="duplicate-member"),
+ pytest.param("symlink", id="symlink"),
+ ],
+)
+def test_rejects_invalid_unsigned_archive(
+ action_scripts: Path,
+ unsigned_archive: Path,
+ tmp_path: Path,
+ invalid: str,
+) -> None:
+ archive = unsigned_archive
+ if invalid == "extension":
+ archive = tmp_path / "plugin.tar"
+ archive.write_bytes(unsigned_archive.read_bytes())
+ elif invalid == "corrupt":
+ archive.write_bytes(b"not a ZIP")
+ elif invalid == "duplicate":
+ with ZipFile(archive, "a") as content:
+ content.writestr("Plugin/lib/Plugin.jar", b"changed")
+ elif invalid == "symlink":
+ archive = tmp_path / "linked.zip"
+ archive.symlink_to(unsigned_archive)
+ assert (
+ action(
+ tmp_path,
+ tmp_path,
+ action_scripts,
+ "sign-jetbrains-plugin",
+ version="",
+ ARCHIVE=str(archive),
+ SHA256="0" * 64 if invalid == "digest" else hashlib.sha256(archive.read_bytes()).hexdigest(),
+ ).returncode
+ != 0
+ )
+
+
+def test_rejects_missing_java(action_scripts: Path, unsigned_archive: Path, tmp_path: Path) -> None:
+ assert (
+ action(
+ tmp_path,
+ tmp_path,
+ action_scripts,
+ "sign-jetbrains-plugin",
+ version="",
+ ARCHIVE=str(unsigned_archive),
+ SHA256=hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(),
+ JAVA_HOME_17_X64=str(tmp_path / "missing"),
+ ).returncode
+ != 0
+ )
+
+
+@pytest.mark.parametrize("existing", [pytest.param("file", id="file"), pytest.param("symlink", id="symlink")])
+def test_rejects_existing_signed_archive(
+ action_scripts: Path, unsigned_archive: Path, tmp_path: Path, existing: str
+) -> None:
+ signed = tmp_path / "Plugin 2-1.0-signed.zip"
+ if existing == "symlink":
+ signed.symlink_to(unsigned_archive)
+ else:
+ signed.write_bytes(b"existing")
+ result = action(
+ tmp_path,
+ tmp_path,
+ action_scripts,
+ "sign-jetbrains-plugin",
+ version="",
+ ARCHIVE=str(unsigned_archive),
+ SHA256=hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(),
+ )
+ assert result.returncode != 0
+ assert "signed archive already exists" in result.stderr
+
+
+@pytest.fixture
+def unsigned_archive(tmp_path: Path) -> Path:
+ archive = tmp_path / "Plugin 2-1.0.zip"
+ with ZipFile(archive, "w", ZIP_DEFLATED) as content:
+ content.writestr("Plugin/lib/Plugin.jar", b"plugin payload")
+ return archive
+
+
+@pytest.fixture
+def certificate(tmp_path: Path) -> tuple[Path, Path]:
+ private_key = tmp_path / "key.pem"
+ chain = tmp_path / "chain.pem"
+ subprocess.run(
+ [
+ "openssl",
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-nodes",
+ "-keyout",
+ str(private_key),
+ "-out",
+ str(chain),
+ "-subj",
+ "/CN=JetBrains test",
+ "-days",
+ "1",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ return private_key, chain
diff --git a/tests/upgrade_release_tools/test_hooks.py b/tests/upgrade_release_tools/test_hooks.py
new file mode 100644
index 0000000..a18df25
--- /dev/null
+++ b/tests/upgrade_release_tools/test_hooks.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+import yaml
+from conftest import action, run
+
+
+@pytest.fixture
+def hook_repository(tmp_path: Path) -> Path:
+ repository = tmp_path / "repository"
+ repository.mkdir()
+ (repository / "pylock.toml").write_text(
+ 'lock-version = "1.0"\ncreated-by = "test"\nrequires-python = ">=3.15"\npackages = []\n',
+ )
+ (repository / ".pre-commit-config.yaml").write_text(
+ "repos:\n - repo: local\n hooks:\n - id: test\n name: test\n"
+ " entry: python -c 'pass'\n language: python\n"
+ " additional_dependencies: [mdformat-gfm==0.1.0]\n"
+ )
+ run(repository, "git", "init", "-b", "main")
+ run(repository, "git", "add", ".")
+ run(repository, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "Initial")
+ return repository
+
+
+def test_hook_refresh_resolves_exact_pin(hook_repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(
+ hook_repository,
+ tmp_path,
+ action_scripts,
+ "refresh-hook-pins",
+ version="",
+ )
+ assert result.returncode == 0, result.stderr
+ config = yaml.safe_load((hook_repository / ".pre-commit-config.yaml").read_text())
+ dependencies = config["repos"][0]["hooks"][0]["additional_dependencies"]
+ assert dependencies[0] == "mdformat-gfm==1.0.0"
+ assert any(item.startswith("markdown-it-py==") for item in dependencies)
+ assert all("==" in item for item in dependencies)
+
+
+def test_hook_refresh_freezes_only_cooled_revisions(
+ hook_repository: Path, tmp_path: Path, action_scripts: Path
+) -> None:
+ remote = tmp_path / "remote"
+ remote.mkdir()
+ run(remote, "git", "init", "-b", "main")
+ (remote / ".pre-commit-hooks.yaml").write_text(
+ "- id: fixture\n name: fixture\n entry: true\n language: system\n"
+ )
+ run(remote, "git", "add", ".")
+ subprocess.run(
+ ["git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "Old hook"],
+ cwd=remote,
+ env=os.environ | {"GIT_AUTHOR_DATE": "2020-01-01T00:00:00Z", "GIT_COMMITTER_DATE": "2020-01-01T00:00:00Z"},
+ check=True,
+ capture_output=True,
+ )
+ run(remote, "git", "tag", "v1.0.0")
+ old_commit = run(remote, "git", "rev-parse", "HEAD").stdout.strip()
+ (remote / "version.txt").write_text("new\n")
+ run(remote, "git", "add", ".")
+ run(remote, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "New hook")
+ run(remote, "git", "tag", "v2.0.0")
+ (hook_repository / ".pre-commit-config.yaml").write_text(
+ f"repos:\n - repo: {remote.as_uri()}\n rev: v1.0.0\n hooks:\n - id: fixture\n"
+ )
+ result = action(
+ hook_repository,
+ tmp_path,
+ action_scripts,
+ "refresh-hook-pins",
+ version="",
+ )
+ assert result.returncode == 0, result.stderr
+ assert yaml.safe_load((hook_repository / ".pre-commit-config.yaml").read_text())["repos"][0]["rev"] == old_commit
+
+
+@pytest.mark.parametrize(
+ ("path", "accepted"),
+ [
+ pytest.param(None, True, id="clean"),
+ pytest.param(".pre-commit-config.yaml", True, id="hook-config"),
+ pytest.param("other.txt", False, id="other-file"),
+ ],
+)
+def test_hook_refresh_checks_paths(
+ hook_repository: Path, tmp_path: Path, action_scripts: Path, path: str | None, accepted: bool
+) -> None:
+ if path is not None:
+ if path == ".pre-commit-config.yaml":
+ (hook_repository / path).write_text((hook_repository / path).read_text() + "# updated\n")
+ else:
+ (hook_repository / path).write_text("updated\n")
+ result = action(hook_repository, tmp_path, action_scripts, "refresh-hook-pins", version="")
+ assert (result.returncode == 0) == accepted
+ assert ("unexpected path" in result.stderr) == (not accepted)
+
+
+@pytest.mark.parametrize("changed", [pytest.param(False, id="unchanged"), pytest.param(True, id="changed")])
+def test_hook_refresh_emits_patch(hook_repository: Path, tmp_path: Path, action_scripts: Path, changed: bool) -> None:
+ if not changed:
+ (hook_repository / ".pre-commit-config.yaml").write_text(
+ "repos:\n - repo: local\n hooks:\n - id: test\n name: test\n"
+ " entry: python -c 'pass'\n language: system\n"
+ )
+ run(hook_repository, "git", "add", ".pre-commit-config.yaml")
+ run(
+ hook_repository,
+ "git",
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.org",
+ "commit",
+ "-m",
+ "Empty hooks",
+ )
+ result = action(hook_repository, tmp_path, action_scripts, "refresh-hook-pins", version="")
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "output").read_text() == f"changed={str(changed).lower()}\n"
+ assert (tmp_path / "hooks.patch").exists() == changed
+
+
+def test_hook_refresh_uses_relative_cutoff() -> None:
+ action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-hook-pins/action.yml").read_text())
+ refresh = next(step for step in action["runs"]["steps"] if step.get("name") == "Refresh exact hook dependencies")
+ assert "--exclude-newer '7 days'" in refresh["run"]
diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py
new file mode 100644
index 0000000..dcff75e
--- /dev/null
+++ b/tests/upgrade_release_tools/test_upgrade.py
@@ -0,0 +1,482 @@
+from __future__ import annotations
+
+import os
+import subprocess
+import tomllib
+from pathlib import Path
+
+import pytest
+import yaml
+from conftest import action, run
+from pytest_httpserver import HTTPServer
+
+
+@pytest.fixture(scope="session")
+def upgrade_scripts(action_scripts: Path) -> Path:
+ return action_scripts
+
+
+@pytest.fixture(scope="session")
+def publish_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ source = Path(__file__).resolve().parents[2]
+ scripts = tmp_path_factory.mktemp("publish-steps")
+ workflow = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text())
+ for step in workflow["jobs"]["publish"]["steps"]:
+ if "run" in step:
+ name = step.get("id", step.get("name", "step")).replace(" ", "-").lower()
+ (scripts / f"publish-{name}.sh").write_text(step["run"])
+ return scripts
+
+
+def test_upgrade_pull_request_uses_repository_app() -> None:
+ workflow = yaml.load(
+ (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text(),
+ Loader=yaml.BaseLoader,
+ )
+ publish = workflow["jobs"]["publish"]
+ assert workflow["on"]["workflow_call"]["inputs"]["lock-groups"]["required"] == "true"
+ assert workflow["jobs"]["generate-lock"]["steps"][2]["with"]["lock-groups"] == "${{ inputs.lock-groups }}"
+ assert publish["environment"] == "release-auth"
+ assert publish["permissions"] == {"contents": "read"}
+ token = next(step for step in publish["steps"] if step.get("id") == "token")
+ assert token["with"]["repositories"] == "${{ steps.repository.outputs.name }}"
+ assert token["with"]["permission-contents"] == "write"
+ assert token["with"]["permission-pull-requests"] == "write"
+ steps = publish["steps"]
+ assert next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-nab-cutoff-patch") < next(
+ index for index, step in enumerate(steps) if step.get("id") == "token"
+ )
+ for step in publish["steps"]:
+ if step.get("name") in {
+ "Push dependency branch",
+ "Find dependency pull request",
+ "Update dependency pull request",
+ "Open dependency pull request",
+ }:
+ assert step["env"]["GH_TOKEN"] == "${{ steps.token.outputs.token }}"
+
+
+@pytest.fixture
+def lock_repository(tmp_path: Path) -> Path:
+ repository = tmp_path / "repository"
+ repository.mkdir()
+ (repository / "pyproject.toml").write_text(
+ '[project]\nname = "example"\nversion = "0.1.0"\nrequires-python = ">=3.15"\n'
+ "[dependency-groups]\nrelease = []\ntest = []\n"
+ '[tool.nab]\nmode = "universal"\nuploaded-prior-to = "P7D"\n'
+ 'decision-order = "stable"\nbase-group = "project-runtime"\ndefault-groups = []\n'
+ '[tool.nab.matrix]\npython = ">=3.15,<3.16"\n'
+ 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n',
+ )
+ run(repository, "git", "init", "-b", "main")
+ run(repository, "git", "config", "user.name", "Release test")
+ run(repository, "git", "config", "user.email", "release@example.org")
+ run(repository, "git", "add", ".")
+ run(repository, "git", "commit", "-m", "Initial files")
+ 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-nab", version="")
+ assert installed.returncode == 0, installed.stderr
+ resolved = execute(
+ lock_repository,
+ upgrade_scripts / "refresh-release-lock-3.sh",
+ 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"]'))
+ installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="")
+ assert installed.returncode == 0, installed.stderr
+ refreshed = execute(
+ lock_repository,
+ upgrade_scripts / "refresh-release-lock-3.sh",
+ tmp_path,
+ LOCK_GROUPS="release-check",
+ MODE="refresh",
+ )
+ assert refreshed.returncode == 0, refreshed.stderr
+ expected = (lock_repository / "pylock.toml").read_bytes()
+ checked = execute(
+ lock_repository,
+ upgrade_scripts / "refresh-release-lock-3.sh",
+ tmp_path,
+ LOCK_GROUPS="release-check",
+ MODE="check",
+ )
+ assert checked.returncode == 0, checked.stderr
+ assert (lock_repository / "pylock.toml").read_bytes() == expected
+
+
+@pytest.mark.parametrize(
+ ("groups", "selection"),
+ [
+ pytest.param("", "--build-requirements", id="build-only"),
+ pytest.param("release-check", "--groups", id="selected-group"),
+ ],
+)
+def test_upgrade_preserves_secondary_lock_matrix(
+ lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, groups: str, selection: str
+) -> None:
+ project = lock_repository / "pyproject.toml"
+ project.write_text(
+ project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')
+ + '[build-system]\nrequires = ["setuptools>=80"]\nbuild-backend = "setuptools.build_meta"\n',
+ )
+ installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="")
+ assert installed.returncode == 0, installed.stderr
+ primary = execute(
+ lock_repository,
+ upgrade_scripts / "refresh-release-lock-3.sh",
+ tmp_path,
+ LOCK_GROUPS="release-check",
+ MODE="refresh",
+ )
+ assert primary.returncode == 0, primary.stderr
+ additional = lock_repository / "pylock.build.toml"
+ command = [
+ "uv",
+ "run",
+ "--no-project",
+ "--python",
+ str(tmp_path / "lock-tools"),
+ "nab",
+ "lock",
+ "pyproject.toml",
+ selection,
+ ]
+ if groups:
+ command.append(groups)
+ command.extend(
+ [
+ "--project-matrix-python",
+ ">=3.15,<3.16",
+ "--project-matrix-platforms",
+ "linux_x86_64",
+ "macos_arm64",
+ "--project-matrix-implementations",
+ "cpython",
+ "--output",
+ str(additional),
+ ],
+ )
+ initial = subprocess.run(command, cwd=lock_repository, text=True, capture_output=True, check=False)
+ assert initial.returncode == 0, initial.stderr
+ expected = additional.read_bytes()
+ environment = {
+ "LOCK_GROUPS": "release-check",
+ "MODE": "check",
+ "ADDITIONAL_LOCK": additional.name,
+ "ADDITIONAL_GROUPS": groups,
+ "MATRIX_PYTHON": ">=3.15,<3.16",
+ "MATRIX_PLATFORMS": "linux_x86_64 macos_arm64",
+ "MATRIX_IMPLEMENTATIONS": "cpython",
+ }
+ checked = execute(lock_repository, upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, **environment)
+ assert checked.returncode == 0, checked.stderr
+ assert additional.read_bytes() == expected
+
+
+def test_upgrade_rejects_secondary_matrix_change(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-nab", version="")
+ assert installed.returncode == 0, installed.stderr
+ initial = execute(
+ lock_repository,
+ upgrade_scripts / "refresh-release-lock-3.sh",
+ tmp_path,
+ LOCK_GROUPS="release-check",
+ MODE="refresh",
+ )
+ assert initial.returncode == 0, initial.stderr
+ (lock_repository / "pylock.build.toml").write_bytes((lock_repository / "pylock.toml").read_bytes())
+ result = execute(
+ lock_repository,
+ upgrade_scripts / "refresh-release-lock-3.sh",
+ tmp_path,
+ LOCK_GROUPS="release-check",
+ MODE="refresh",
+ ADDITIONAL_LOCK="pylock.build.toml",
+ ADDITIONAL_GROUPS="release-check",
+ MATRIX_PYTHON=">=3.15,<3.16",
+ MATRIX_PLATFORMS="linux_x86_64",
+ MATRIX_IMPLEMENTATIONS="cpython",
+ )
+ assert result.returncode != 0
+ assert "changed its target environments" in result.stderr
+
+
+def test_upgrade_expires_old_nab_floor(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None:
+ project = lock_repository / "pyproject.toml"
+ project.write_text(
+ project.read_text()
+ + '[[tool.nab.package-rules]]\nmatch = ["nab"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n',
+ )
+ installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="")
+ assert installed.returncode == 0, installed.stderr
+ expired = execute(lock_repository, upgrade_scripts / "refresh-release-lock-2.sh", tmp_path)
+ assert expired.returncode == 0, expired.stderr
+ assert "package-rules" not in tomllib.loads(project.read_text())["tool"]["nab"]
+
+
+@pytest.mark.parametrize(
+ "changed_path",
+ [pytest.param("pylock.toml", id="lock"), pytest.param("pyproject.toml", id="expired-floor")],
+)
+def test_upgrade_emits_patch_for_allowed_change(
+ lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, changed_path: str
+) -> None:
+ (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n')
+ run(lock_repository, "git", "add", ".")
+ run(lock_repository, "git", "commit", "-m", "Add lock")
+ (lock_repository / changed_path).write_text((lock_repository / changed_path).read_text() + "\n")
+ result = execute(
+ lock_repository,
+ upgrade_scripts / "refresh-release-lock-7.sh",
+ tmp_path,
+ MODE="refresh",
+ GITHUB_OUTPUT=str(tmp_path / "output"),
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "output").read_text() == "changed=true\n"
+ assert f"diff --git a/{changed_path} b/{changed_path}" in (tmp_path / "lock.patch").read_text()
+
+
+def test_upgrade_check_mode_rejects_lock_drift(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None:
+ (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n')
+ run(lock_repository, "git", "add", ".")
+ run(lock_repository, "git", "commit", "-m", "Add lock")
+ (lock_repository / "pylock.toml").write_text('lock-version = "1.1"\n')
+ result = execute(
+ lock_repository,
+ upgrade_scripts / "refresh-release-lock-7.sh",
+ tmp_path,
+ MODE="check",
+ GITHUB_OUTPUT=str(tmp_path / "output"),
+ )
+ assert result.returncode != 0
+
+
+@pytest.mark.parametrize(
+ "path",
+ [pytest.param("other.txt", id="unrelated-file"), pytest.param("uv.lock", id="uv-lock")],
+)
+def test_upgrade_rejects_unrelated_change(
+ lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, path: str
+) -> None:
+ (tmp_path / "project-after-expiry.toml").write_bytes((lock_repository / "pyproject.toml").read_bytes())
+ (lock_repository / path).write_text("changed\n")
+ result = execute(lock_repository, upgrade_scripts / "refresh-release-lock-6.sh", tmp_path)
+ assert result.returncode != 0
+ assert "outside the lock and cutoff policy" in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("path", "accepted"),
+ [
+ pytest.param("pylock.toml", True, id="lock"),
+ pytest.param("pyproject.toml", True, id="nab-policy"),
+ pytest.param(".pre-commit-config.yaml", True, id="hooks"),
+ pytest.param("uv.lock", False, id="uv-lock"),
+ pytest.param("other.txt", False, id="unrelated-file"),
+ ],
+)
+def test_upgrade_rejects_unrelated_patch(
+ lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, accepted: bool
+) -> None:
+ (lock_repository / path).write_text("before\n")
+ run(lock_repository, "git", "add", ".")
+ run(lock_repository, "git", "commit", "-m", "Add file")
+ (lock_repository / path).write_text("after\n")
+ patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout
+ run(lock_repository, "git", "restore", path)
+ patch_dir = tmp_path / "release-tools-lock"
+ patch_dir.mkdir()
+ (patch_dir / "lock.patch").write_text(patch)
+ applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path)
+ assert applied.returncode == 0, applied.stderr
+ result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-paths.sh", tmp_path)
+ assert (result.returncode == 0) == accepted
+ assert ("unexpected dependency path" in result.stderr) == (not accepted)
+
+
+def test_upgrade_allows_only_named_secondary_lock(lock_repository: Path, tmp_path: Path, publish_scripts: Path) -> None:
+ for name in ("pylock.build.toml", "pylock.pgo.toml"):
+ (lock_repository / name).write_text("before\n")
+ run(lock_repository, "git", "add", ".")
+ run(lock_repository, "git", "commit", "-m", "Add secondary locks")
+ (lock_repository / "pylock.build.toml").write_text("after\n")
+ patch = run(lock_repository, "git", "diff", "--binary", "--", "pylock.build.toml").stdout
+ run(lock_repository, "git", "restore", "pylock.build.toml")
+ patch_dir = tmp_path / "release-tools-lock"
+ patch_dir.mkdir()
+ (patch_dir / "lock.patch").write_text(patch)
+ applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path)
+ assert applied.returncode == 0, applied.stderr
+ accepted = execute(
+ lock_repository,
+ publish_scripts / "publish-check-dependency-patch-paths.sh",
+ tmp_path,
+ ADDITIONAL_LOCK="pylock.build.toml",
+ )
+ assert accepted.returncode == 0, accepted.stderr
+ rejected = execute(
+ lock_repository,
+ publish_scripts / "publish-check-dependency-patch-paths.sh",
+ tmp_path,
+ ADDITIONAL_LOCK="pylock.pgo.toml",
+ )
+ assert rejected.returncode != 0
+ assert "unexpected dependency path: pylock.build.toml" in rejected.stderr
+
+
+@pytest.mark.parametrize(
+ ("path", "unsafe_mode"),
+ [
+ pytest.param("pylock.toml", "executable", id="executable-lock"),
+ pytest.param(".pre-commit-config.yaml", "symlink", id="symlink-hook-config"),
+ ],
+)
+def test_upgrade_rejects_unsafe_file_mode(
+ lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, unsafe_mode: str
+) -> None:
+ target = lock_repository / path
+ target.write_text("before\n")
+ run(lock_repository, "git", "add", path)
+ run(lock_repository, "git", "commit", "-m", "Add lock")
+ if unsafe_mode == "executable":
+ target.chmod(0o755)
+ else:
+ target.unlink()
+ target.symlink_to("other.txt")
+ patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout
+ run(lock_repository, "git", "restore", path)
+ patch_dir = tmp_path / ("release-tools-lock" if path == "pylock.toml" else "release-tools-hooks")
+ patch_dir.mkdir()
+ (patch_dir / ("lock.patch" if path == "pylock.toml" else "hooks.patch")).write_text(patch)
+ applied = execute(
+ lock_repository,
+ publish_scripts / ("publish-apply-lock-patch.sh" if path == "pylock.toml" else "publish-apply-hook-patch.sh"),
+ tmp_path,
+ )
+ assert applied.returncode == 0, applied.stderr
+ result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-file-mode.sh", tmp_path)
+ assert result.returncode != 0
+ assert "unexpected dependency file mode" in result.stderr
+
+
+def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: Path, publish_scripts: Path) -> None:
+ lock = lock_repository / "pylock.toml"
+ config = lock_repository / ".pre-commit-config.yaml"
+ lock.write_text('lock-version = "1.0"\n')
+ config.write_text("repos: []\n")
+ run(lock_repository, "git", "add", ".")
+ run(lock_repository, "git", "commit", "-m", "Add dependency files")
+ lock.write_text('lock-version = "1.1"\n')
+ lock_patch = run(lock_repository, "git", "diff", "--binary", "--", "pylock.toml").stdout
+ config.write_text("repos: [local]\n")
+ hooks_patch = run(lock_repository, "git", "diff", "--binary", "--", ".pre-commit-config.yaml").stdout
+ run(lock_repository, "git", "restore", ".")
+ lock_artifact = tmp_path / "release-tools-lock"
+ lock_artifact.mkdir()
+ (lock_artifact / "lock.patch").write_text(lock_patch)
+ hooks_artifact = tmp_path / "release-tools-hooks"
+ hooks_artifact.mkdir()
+ (hooks_artifact / "hooks.patch").write_text(hooks_patch)
+
+ applied_lock = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path)
+ applied_hooks = execute(lock_repository, publish_scripts / "publish-apply-hook-patch.sh", tmp_path)
+ checked = execute(
+ lock_repository,
+ publish_scripts / "publish-check-dependency-patch-paths.sh",
+ tmp_path,
+ )
+
+ assert (applied_lock.returncode, applied_hooks.returncode, checked.returncode) == (0, 0, 0)
+ assert run(lock_repository, "git", "diff", "--cached", "--name-only").stdout.splitlines() == [
+ ".pre-commit-config.yaml",
+ "pylock.toml",
+ ]
+
+
+def test_upgrade_preserves_requested_pull_request_body(
+ lock_repository: Path, tmp_path: Path, publish_scripts: Path
+) -> None:
+ result = execute(
+ lock_repository,
+ publish_scripts / "publish-write-dependency-pull-request-body.sh",
+ tmp_path,
+ PR_BODY="Refresh release tools.",
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "pull-request.md").read_text() == "Refresh release tools.\n"
+
+
+def test_upgrade_finds_only_own_dependency_pull_request(
+ lock_repository: Path,
+ tmp_path: Path,
+ publish_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+) -> None:
+ server, environment = github_api
+ server.expect_request(
+ "/api/v3/repos/tox-dev/fixture/pulls",
+ method="GET",
+ query_string="base=main&head=tox-dev%3Aautomation%2Fupdate-release-tools&state=open",
+ ).respond_with_json(
+ [
+ {
+ "number": 17,
+ "head": {"repo": {"full_name": "attacker/fork"}, "ref": "automation/update-release-tools"},
+ "base": {"ref": "main"},
+ },
+ {
+ "number": 18,
+ "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "automation/update-release-tools"},
+ "base": {"ref": "main"},
+ },
+ ]
+ )
+ result = execute(
+ lock_repository,
+ publish_scripts / "publish-pr.sh",
+ tmp_path,
+ **environment,
+ GITHUB_REPOSITORY="tox-dev/fixture",
+ GITHUB_OUTPUT=str(tmp_path / "output"),
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "output").read_text() == "number=18\n"
+
+
+def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ ["bash", str(script)],
+ cwd=repository,
+ env=os.environ
+ | {
+ "RUNNER_TEMP": str(tmp_path),
+ "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"),
+ "ADDITIONAL_LOCK": "",
+ "ADDITIONAL_GROUPS": "",
+ "MATRIX_PYTHON": "",
+ "MATRIX_PLATFORMS": "",
+ "MATRIX_IMPLEMENTATIONS": "",
+ }
+ | environment,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py
new file mode 100644
index 0000000..3a67a5a
--- /dev/null
+++ b/tests/validate_news/test_news.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from conftest import action, run
+
+
+@pytest.mark.parametrize(
+ ("bot", "skip", "accepted"),
+ [
+ pytest.param("false", "false", False, id="human-needs-fragment"),
+ pytest.param("true", "false", True, id="bot-needs-no-fragment"),
+ pytest.param("false", "true", True, id="human-can-skip-news"),
+ ],
+)
+def test_validate_news_without_fragment(
+ repository: Path, tmp_path: Path, action_scripts: Path, bot: str, skip: str, accepted: bool
+) -> None:
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-news",
+ version="0.2.0",
+ BASE_REF="main",
+ BOT_AUTHOR=bot,
+ SKIP_NEWS=skip,
+ )
+ assert (result.returncode == 0) == accepted
+ if not accepted:
+ assert "add a changelog fragment" in result.stderr
+
+
+@pytest.mark.parametrize(
+ "name",
+ [
+ pytest.param("+addition.feature.md", id="named"),
+ pytest.param("+addition.feature.1.md", id="named-multiple"),
+ pytest.param("123.feature.2.md", id="numbered-multiple"),
+ ],
+)
+def test_validate_news_checks_bot_fragment(repository: Path, tmp_path: Path, action_scripts: Path, name: str) -> None:
+ (repository / "changelog.d" / name).write_text("Add another feature.\n")
+ run(repository, "git", "add", ".")
+ run(repository, "git", "commit", "-m", "Add news")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-news",
+ version="0.2.0",
+ BASE_REF="main",
+ BOT_AUTHOR="true",
+ SKIP_NEWS="true",
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def test_validate_news_reads_rst_categories(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ project = repository / "pyproject.toml"
+ project.write_text(
+ project.read_text()
+ .replace('directory = "changelog.d"', 'directory = "docs/changelog"')
+ .replace('filename = "CHANGELOG.md"', 'filename = "CHANGELOG.rst"')
+ .replace('directory = "feature", name = "Added"', 'directory = "fix", name = "Fixed"')
+ )
+ (repository / "docs").mkdir()
+ (repository / "docs/changelog").mkdir()
+ (repository / "docs/changelog/+repair.fix.rst").write_text("Repair a user-facing bug.\n")
+ run(repository, "git", "add", ".")
+ run(repository, "git", "commit", "-m", "Add RST news")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-news",
+ version="0.2.0",
+ BASE_REF="main",
+ BOT_AUTHOR="false",
+ SKIP_NEWS="false",
+ )
+ assert result.returncode == 0, result.stderr
+
+
+@pytest.mark.parametrize(
+ ("name", "content", "error"),
+ [
+ pytest.param("+addition.unknown.md", "Add a feature.\n", "unsupported changelog fragment", id="category"),
+ pytest.param("addition.feature.md", "Add a feature.\n", "unsupported changelog fragment", id="name"),
+ pytest.param("+addition.feature.x.md", "Add a feature.\n", "unsupported changelog fragment", id="ordinal"),
+ pytest.param("+addition.feature.md", " \n", "empty changelog fragment", id="empty"),
+ ],
+)
+def test_validate_news_rejects_bad_fragment(
+ repository: Path, tmp_path: Path, action_scripts: Path, name: str, content: str, error: str
+) -> None:
+ (repository / "changelog.d" / name).write_text(content)
+ run(repository, "git", "add", ".")
+ run(repository, "git", "commit", "-m", "Add news")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-news",
+ version="0.2.0",
+ BASE_REF="main",
+ BOT_AUTHOR="true",
+ SKIP_NEWS="false",
+ )
+ assert result.returncode != 0
+ assert error in result.stderr
+
+
+def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-news",
+ version="0.2.0",
+ BASE_REF="",
+ BOT_AUTHOR="true",
+ SKIP_NEWS="false",
+ )
+ assert result.returncode != 0
+ assert "news validation needs a PR base" in result.stderr
diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py
new file mode 100644
index 0000000..48edea9
--- /dev/null
+++ b/tests/validate_python_distribution/test_distribution.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from zipfile import ZipFile
+
+import pytest
+from conftest import action, run
+
+
+@pytest.fixture
+def distribution(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
+ monkeypatch.delenv("SSL_CERT_FILE", raising=False)
+ project = source_project / "pyproject.toml"
+ project.write_text(
+ project.read_text() + '\n[dependency-groups]\npkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"]\n',
+ )
+ run(
+ source_project,
+ str(nab_executable),
+ "lock",
+ "--python",
+ "3.15",
+ "--groups",
+ "pkg-meta",
+ )
+ output = source_project / "dist"
+ run(
+ source_project,
+ "uv",
+ "build",
+ "--no-build-isolation",
+ "--python",
+ sys.executable,
+ "--sdist",
+ "--wheel",
+ "--out-dir",
+ str(output),
+ )
+ return source_project, output
+
+
+def test_validate_python_distribution_accepts_built_files(
+ distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path
+) -> None:
+ repository = distribution[0]
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-python-distribution",
+ version="0.2.0",
+ )
+ assert result.returncode == 0, result.stderr
+ assert "PASSED" in result.stdout
+
+
+def test_validate_python_distribution_rejects_invalid_sdist(
+ distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path
+) -> None:
+ repository, output = distribution
+ next(output.glob("*.tar.gz")).write_bytes(b"invalid sdist")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-python-distribution",
+ version="0.2.0",
+ )
+ assert result.returncode != 0
+
+
+def test_validate_python_distribution_rejects_bad_wheel_contents(
+ distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path
+) -> None:
+ repository, output = distribution
+ wheel = next(output.glob("*.whl"))
+ with ZipFile(wheel) as archive:
+ contents = {name: archive.read(name) for name in archive.namelist()}
+ record = next(name for name in contents if name.endswith(".dist-info/RECORD"))
+ contents["fixture/compiled.pyc"] = b"invalid bytecode"
+ contents[record] += b"fixture/compiled.pyc,,\n"
+ with ZipFile(wheel, "w") as archive:
+ for name, content in contents.items():
+ archive.writestr(name, content)
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-python-distribution",
+ version="0.2.0",
+ )
+ assert result.returncode != 0
+ assert "W001" in result.stdout
diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py
new file mode 100644
index 0000000..a88f794
--- /dev/null
+++ b/tests/validate_python_release/test_tagged.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+import yaml
+from conftest import run
+
+
+def test_validator_uses_shared_distribution_action() -> None:
+ workflow = yaml.load(
+ (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text(),
+ Loader=yaml.BaseLoader,
+ )
+ assert any(step.get("uses") == "$/validate-python-distribution" for step in workflow["jobs"]["validate"]["steps"])
+ assert set(workflow["on"]["workflow_call"]["inputs"]) == {
+ "changelog",
+ "artifact-base-url",
+ "artifact-label",
+ "local-tool-source",
+ "source",
+ "artifact-prefix",
+ }
+ assert set(workflow["jobs"]) == {"source-check", "validate"}
+
+
+@pytest.mark.parametrize(
+ ("tag_kind", "event_ref", "accepted"),
+ [
+ pytest.param("lightweight", "refs/tags/0.2.0", True, id="lightweight-tag"),
+ pytest.param("annotated", "refs/tags/0.2.0", True, id="annotated-tag"),
+ pytest.param("branch", "refs/tags/0.2.0", False, id="same-name-branch"),
+ pytest.param("lightweight", "refs/heads/main", False, id="branch-event"),
+ 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:
+ if tag_kind == "branch":
+ run(repository, "git", "branch", "0.2.0")
+ elif tag_kind == "off-main":
+ run(repository, "git", "switch", "-c", "release-candidate")
+ run(repository, "git", "commit", "--allow-empty", "-m", "Unmerged change")
+ run(repository, "git", "tag", "0.2.0")
+ elif tag_kind == "annotated":
+ run(repository, "git", "tag", "-a", "-m", "Release", "0.2.0")
+ else:
+ run(repository, "git", "tag", "0.2.0")
+ 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"]["validate"]["steps"] if step.get("name") == "Verify the exact release tag"
+ )
+ result = subprocess.run(
+ ["bash", "-c", verify["run"]],
+ cwd=repository,
+ env=os.environ | {"GITHUB_REF": event_ref, "TAG": "0.2.0"},
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert (result.returncode == 0) is accepted, result.stderr
diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py
index 3c27abf..8fdcf40 100644
--- a/tests/validate_release_patch/test_validate.py
+++ b/tests/validate_release_patch/test_validate.py
@@ -1,8 +1,11 @@
from __future__ import annotations
+from hashlib import sha256
+from pathlib import Path
from textwrap import dedent
-lazy from pathlib import Path
+import pytest
+import yaml
from conftest import action, run
@@ -15,38 +18,114 @@ def test_validate_applies_patch(repository: Path, tmp_path: Path, action_scripts
assert run(repository, "git", "diff", "--cached", "--name-only").stdout.strip()
-def test_validate_custom_remote_branch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+def test_validate_applies_patch_in_fresh_worktree(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
- run(repository, "git", "reset", "--hard", "HEAD")
- run(repository, "git", "remote", "add", "release-remote", str(tmp_path / "remote.git"))
- run(repository, "git", "push", "release-remote", "HEAD:release")
- result = action(
- repository,
- tmp_path,
- action_scripts,
- "validate-release-patch",
- version="0.2.0",
- BRANCH="release",
- REMOTE="release-remote",
- EXPECTED_SHA="",
+ source_staged = run(repository, "git", "diff", "--cached", "--name-only").stdout
+ validation = tmp_path / "validation"
+ run(repository, "git", "worktree", "add", "--detach", str(validation), "HEAD")
+ result = action(validation, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
+ assert result.returncode == 0, result.stderr
+ assert run(validation, "git", "diff", "--cached", "--name-only").stdout.strip()
+ assert run(repository, "git", "diff", "--cached", "--name-only").stdout == source_staged
+
+
+def test_validate_steps_honor_working_directory() -> None:
+ config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-release-patch/action.yml").read_text())
+ assert all(
+ step.get("working-directory", step.get("with", {}).get("working-directory"))
+ == "${{ inputs.working-directory }}"
+ 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"
+ project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', 'filename = "CHANGELOG.rst"'))
+ run(repository, "git", "add", "CHANGELOG.rst", "pyproject.toml")
+ run(repository, "git", "commit", "-m", "Track RST changelog")
+ run(repository, "git", "push", "origin", "main")
+
+
+@pytest.mark.parametrize(
+ "heading",
+ [
+ pytest.param("0.2.0 (2026-10-20)", id="plain"),
+ pytest.param("`0.2.0 `_ - 2026-10-20", id="linked"),
+ ],
+)
+def test_validate_rst_heading(repository: Path, tmp_path: Path, action_scripts: Path, heading: str) -> None:
+ changelog = repository / "CHANGELOG.rst"
+ track_rst_changelog(repository)
+ changelog.write_text(
+ dedent("""\
+ Changelog
+ =========
+
+ {heading}
+ {underline}
+
+ Changes
+ =======
+
+ - Add a feature.
+ """).format(heading=heading, underline="=" * len(heading)),
)
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
assert result.returncode == 0, result.stderr
- assert run(repository, "git", "diff", "--cached", "--name-only").stdout.strip()
-def test_validate_rejects_wrong_heading_prefix(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
- assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+@pytest.mark.parametrize(
+ "marker",
+ [
+ pytest.param("<<<<<<< HEAD", id="ours"),
+ pytest.param("||||||| base", id="base"),
+ pytest.param(">>>>>>> branch", id="theirs"),
+ ],
+)
+def test_validate_rejects_rst_conflict_marker(
+ repository: Path, tmp_path: Path, action_scripts: Path, marker: str
+) -> None:
+ changelog = repository / "CHANGELOG.rst"
+ track_rst_changelog(repository)
+ changelog.write_text(f"Changelog\n=========\n\n0.2.0\n=====\n\n{marker}\n")
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
run(repository, "git", "reset", "--hard", "HEAD")
- result = action(
- repository,
- tmp_path,
- action_scripts,
- "validate-release-patch",
- version="0.2.0",
- HEADING_PREFIX="### ",
- )
+ result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
+ assert result.returncode != 0
+ assert "release changelog contains conflict markers" in result.stderr
+
+
+@pytest.mark.parametrize("tracked", [pytest.param(True, id="tracked"), pytest.param(False, id="first-release")])
+def test_validate_rejects_staged_rst_conflict_marker(
+ repository: Path, tmp_path: Path, action_scripts: Path, tracked: bool
+) -> None:
+ track_rst_changelog(repository)
+ changelog = repository / "CHANGELOG.rst"
+ if not tracked:
+ run(repository, "git", "rm", "CHANGELOG.rst")
+ run(repository, "git", "commit", "-m", "Remove RST changelog")
+ run(repository, "git", "push", "origin", "main")
+ changelog.write_text("Changelog\n=========\n\n<<<<<<< HEAD\n")
+ if not tracked:
+ run(repository, "git", "add", "-N", "CHANGELOG.rst")
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
assert result.returncode != 0
- assert "has no section" in result.stderr
+ assert "conflict markers" in result.stderr
+
+
+def test_validate_rejects_unchanged_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ fragment = repository / "changelog.d/+feature.feature.md"
+ fragment.unlink()
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
+ assert result.returncode != 0
+ assert "release patch does not change CHANGELOG.md" in result.stderr
def test_validate_rejects_stale_branch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
@@ -58,7 +137,7 @@ def test_validate_rejects_stale_branch(repository: Path, tmp_path: Path, action_
action_scripts,
"validate-release-patch",
version="0.2.0",
- EXPECTED_SHA="0000000000000000000000000000000000000000",
+ GITHUB_SHA="0000000000000000000000000000000000000000",
)
assert result.returncode != 0
assert "changed during release preparation" in result.stderr
@@ -87,6 +166,54 @@ def test_validate_rejects_noop_patch(repository: Path, tmp_path: Path, action_sc
assert "release patch contains no changes" in result.stderr
+def test_validate_rejects_changed_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-release-patch",
+ version="0.2.0",
+ EXPECTED_PATCH_SHA="0" * 64,
+ )
+ assert result.returncode != 0
+ assert "release patch differs from the validated artifact" in result.stderr
+
+
+def test_validate_requires_digest_when_checks_skipped(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", CHECKS="false")
+ assert result.returncode != 0
+ assert "skipping release checks requires a validated patch digest" in result.stderr
+
+
+def test_validate_rejects_unknown_check_mode(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", CHECKS="sometimes")
+ assert result.returncode != 0
+ assert "checks must be true or false" in result.stderr
+
+
+def test_validate_key_job_uses_only_checked_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-release-patch",
+ version="0.2.0",
+ CHECKS="false",
+ EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(),
+ PATH="/usr/bin:/bin",
+ )
+ assert result.returncode == 0, result.stderr
+ assert run(repository, "git", "diff", "--cached", "--name-only").stdout.strip()
+
+
def test_validate_rejects_unexpected_path(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
(repository / "pyproject.toml").write_text((repository / "pyproject.toml").read_text() + "\n# changed\n")
(tmp_path / "release.patch").write_text(run(repository, "git", "diff", "--binary").stdout)
@@ -96,13 +223,162 @@ def test_validate_rejects_unexpected_path(repository: Path, tmp_path: Path, acti
assert "unexpected release path" in result.stderr
+@pytest.mark.parametrize(
+ "fragment",
+ [
+ pytest.param("evil.py", id="wrong-extension"),
+ pytest.param("nested/7.feature.md", id="nested"),
+ pytest.param("evil.feature.md", id="unsupported-stem"),
+ pytest.param("7.bugfix.md", id="unconfigured-category"),
+ pytest.param("7.feature.rst", id="wrong-format"),
+ pytest.param("7.feature.md", id="added-valid-fragment"),
+ ],
+)
+def test_validate_rejects_unsupported_fragment_path(
+ repository: Path, tmp_path: Path, action_scripts: Path, fragment: str
+) -> None:
+ assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+ path = repository / "changelog.d" / fragment
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("Unexpected release fragment.\n")
+ run(repository, "git", "add", str(path.relative_to(repository)))
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ run(repository, "git", "clean", "-fd")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-release-patch",
+ version="0.2.0",
+ CHECKS="false",
+ EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(),
+ )
+ assert result.returncode != 0
+ assert "unexpected release path status" in result.stderr
+
+
+def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ source = repository / "changelog.d/template.jinja"
+ source.write_text("Add a feature.\n")
+ run(repository, "git", "add", str(source.relative_to(repository)))
+ run(repository, "git", "commit", "-m", "Track release template")
+ run(repository, "git", "push", "origin", "main")
+ run(repository, "git", "mv", str(source.relative_to(repository)), "changelog.d/1.feature.md")
+ (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-22)\n\n- Add a feature.\n")
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-release-patch",
+ version="0.2.0",
+ CHECKS="false",
+ EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(),
+ )
+ assert result.returncode != 0
+ assert "unexpected release path status" in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("before", "after", "error"),
+ [
+ pytest.param("[tool.towncrier]", "[tool.other]", "Towncrier configuration is required", id="missing-config"),
+ pytest.param('directory = "changelog.d"\n', "", "normalized relative paths", id="missing-directory"),
+ pytest.param('filename = "CHANGELOG.md"\n', "", "normalized relative paths", id="missing-filename"),
+ pytest.param(
+ 'type = [{ directory = "feature", name = "Added", showcontent = true }]\n',
+ "",
+ "Towncrier fragment categories are required",
+ id="missing-categories",
+ ),
+ ],
+)
+def test_validate_requires_explicit_towncrier_config(
+ repository: Path, tmp_path: Path, action_scripts: Path, before: str, after: str, error: str
+) -> None:
+ project = repository / "pyproject.toml"
+ project.write_text(project.read_text().replace(before, after))
+ run(repository, "git", "add", "pyproject.toml")
+ run(repository, "git", "commit", "-m", "Remove release configuration")
+ run(repository, "git", "push", "origin", "main")
+ (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-22)\n\n- Add a feature.\n")
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-release-patch",
+ version="0.2.0",
+ CHECKS="false",
+ EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(),
+ )
+ assert result.returncode != 0
+ assert error in result.stderr
+
+
+@pytest.mark.parametrize(
+ "file_mode",
+ [pytest.param("executable", id="executable"), pytest.param("symlink", id="symlink")],
+)
+def test_validate_rejects_unsafe_file_mode(
+ repository: Path, tmp_path: Path, action_scripts: Path, file_mode: str
+) -> None:
+ assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0
+ if file_mode == "executable":
+ (repository / "CHANGELOG.md").chmod(0o755)
+ else:
+ (repository / "CHANGELOG.md").unlink()
+ (repository / "CHANGELOG.md").symlink_to("pyproject.toml")
+ run(repository, "git", "add", "CHANGELOG.md")
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
+ assert result.returncode != 0
+ assert "unexpected release file mode" in result.stderr
+
+
+def test_validate_rejects_nonregular_deleted_fragment(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ fragment = repository / "changelog.d/7.feature.md"
+ fragment.symlink_to("../CHANGELOG.md")
+ run(repository, "git", "add", "changelog.d/7.feature.md")
+ run(repository, "git", "commit", "-m", "Track linked fragment")
+ run(repository, "git", "push", "origin", "main")
+ (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-21)\n\n- Add a feature.\n")
+ fragment.unlink()
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(
+ repository,
+ tmp_path,
+ action_scripts,
+ "validate-release-patch",
+ version="0.2.0",
+ CHECKS="false",
+ EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(),
+ )
+ assert result.returncode != 0
+ assert "unexpected previous release file mode" in result.stderr
+
+
+def test_validate_rejects_removed_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
+ (repository / "CHANGELOG.md").unlink()
+ (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout)
+ run(repository, "git", "reset", "--hard", "HEAD")
+ result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
+ assert result.returncode != 0
+ assert "unexpected release file mode in CHANGELOG.md" in result.stderr
+
+
def test_validate_rejects_missing_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
(repository / "CHANGELOG.md").write_text("# Changelog\n\nOther notes.\n")
(tmp_path / "release.patch").write_text(run(repository, "git", "diff", "--binary").stdout)
run(repository, "git", "restore", ".")
result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
assert result.returncode != 0
- assert "has no section" in result.stderr
+ assert "has no valid section" in result.stderr
def test_validate_rejects_embedded_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
@@ -111,15 +387,16 @@ def test_validate_rejects_embedded_heading(repository: Path, tmp_path: Path, act
run(repository, "git", "restore", ".")
result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0")
assert result.returncode != 0
- assert "has no section" in result.stderr
+ assert "has no valid section" in result.stderr
def test_validate_rejects_prek_changes(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
- (repository / ".pre-commit-config.yaml").write_text(
- "repos:\n - repo: local\n hooks:\n - id: release-check\n"
- " name: release-check\n"
- " entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then printf changed >> CHANGELOG.md; fi'\n"
- " language: system\n pass_filenames: false\n",
+ config = repository / ".pre-commit-config.yaml"
+ config.write_text(
+ config.read_text().replace(
+ "entry: bash -c true",
+ "entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then printf changed >> CHANGELOG.md; fi'",
+ ),
)
run(repository, "git", "add", ".")
run(repository, "git", "commit", "-m", "Check release tree")
@@ -139,11 +416,12 @@ def test_validate_rejects_prek_changes(repository: Path, tmp_path: Path, action_
def test_validate_rejects_untracked_prek_output(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
- (repository / ".pre-commit-config.yaml").write_text(
- "repos:\n - repo: local\n hooks:\n - id: release-check\n"
- " name: release-check\n"
- " entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then touch extra-output; fi'\n"
- " language: system\n pass_filenames: false\n",
+ config = repository / ".pre-commit-config.yaml"
+ config.write_text(
+ config.read_text().replace(
+ "entry: bash -c true",
+ "entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then touch extra-output; fi'",
+ ),
)
run(repository, "git", "add", ".")
run(repository, "git", "commit", "-m", "Check release tree")
diff --git a/tools/build_constraints.py b/tools/build_constraints.py
new file mode 100644
index 0000000..046a4ae
--- /dev/null
+++ b/tools/build_constraints.py
@@ -0,0 +1,160 @@
+"""Export hashed build constraints from selected PEP 751 lock targets."""
+
+from __future__ import annotations
+
+import argparse
+import tomllib
+from pathlib import Path
+
+from packaging.markers import Environment, default_environment
+from packaging.pylock import Pylock, PylockSelectError, PylockValidationError
+from packaging.requirements import Requirement
+from packaging.tags import Tag
+from packaging.utils import canonicalize_name, parse_wheel_filename
+from packaging.version import Version
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("output", type=Path)
+ parser.add_argument("locks", nargs="+", type=Path)
+ parser.add_argument("--shared-policy", type=Path)
+ parser.add_argument("--target-platform")
+ parser.add_argument("--targets")
+ args = parser.parse_args()
+ if bool(args.target_platform) != bool(args.targets):
+ parser.error("target-platform and targets must be supplied together")
+ if len(args.locks) > 2:
+ parser.error("at most one shared and one caller lock are supported")
+ if args.shared_policy and len(args.locks) != 2:
+ parser.error("shared-policy requires a shared and caller lock")
+ try:
+ locks = []
+ for path in args.locks:
+ with path.open("rb") as stream:
+ locks.append(Pylock.from_dict(tomllib.load(stream)))
+ requirements = _shared_requirements(args.shared_policy) if args.shared_policy else {}
+ targets = _target_environments(args.target_platform, args.targets) if args.targets else [None]
+ selected_by_target = [_selected_build(locks, environment, requirements) for environment in targets]
+ versions = [{name: version for name, (version, _) in selected.items()} for selected in selected_by_target]
+ if any(other != versions[0] for other in versions[1:]):
+ parser.error("build package versions differ across requested targets")
+ selected = selected_by_target[0]
+ for other in selected_by_target[1:]:
+ for name, (_, hashes) in other.items():
+ selected[name][1].update(hashes)
+ args.output.write_text(
+ "".join(
+ f"{name}=={version} {' '.join(f'--hash=sha256:{digest}' for digest in sorted(hashes))}\n"
+ for name, (version, hashes) in sorted(selected.items())
+ ),
+ )
+ except (
+ OSError,
+ ValueError,
+ KeyError,
+ PylockValidationError,
+ PylockSelectError,
+ ) as error:
+ parser.error(str(error))
+
+
+def _shared_requirements(path: Path) -> dict[str, Requirement]:
+ with path.open("rb") as stream:
+ project = tomllib.load(stream)
+ requirements = (Requirement(value) for value in project["dependency-groups"]["build"])
+ return {canonicalize_name(requirement.name): requirement for requirement in requirements}
+
+
+def _target_environments(platform: str, values: str) -> list[Environment]:
+ platforms = {
+ "linux_x86_64": ("linux", "Linux", "x86_64", "posix"),
+ "linux_aarch64": ("linux", "Linux", "aarch64", "posix"),
+ "macos_x86_64": ("darwin", "Darwin", "x86_64", "posix"),
+ "macos_arm64": ("darwin", "Darwin", "arm64", "posix"),
+ "windows_amd64": ("win32", "Windows", "AMD64", "nt"),
+ "windows_arm64": ("win32", "Windows", "ARM64", "nt"),
+ }
+ if platform not in platforms:
+ raise ValueError(f"unknown target platform: {platform}")
+ environments = []
+ for value in values.replace(",", " ").split():
+ version_text, separator, implementation = value.partition(":")
+ version = Version(version_text)
+ if not separator or len(version.release) != 3 or implementation not in {"cpython", "pypy"}:
+ raise ValueError(f"invalid target: {value}; expected full-version:cpython|pypy")
+ system_platform, platform_system, machine, os_name = platforms[platform]
+ environment = default_environment()
+ environment.update(
+ python_version=f"{version.major}.{version.minor}",
+ python_full_version=str(version),
+ implementation_version=str(version),
+ implementation_name=implementation,
+ platform_python_implementation="CPython" if implementation == "cpython" else "PyPy",
+ sys_platform=system_platform,
+ platform_system=platform_system,
+ platform_machine=machine,
+ os_name=os_name,
+ platform_release="",
+ platform_version="",
+ )
+ environments.append(environment)
+ if not environments:
+ raise ValueError("at least one target is required")
+ return environments
+
+
+def _selected_build(
+ locks: list[Pylock], environment: Environment | None, requirements: dict[str, Requirement]
+) -> dict[str, tuple[Version, set[str]]]:
+ selected: dict[str, tuple[Version, set[str]]] = {}
+ for index, lock in enumerate(locks):
+ if "build" not in (lock.dependency_groups or ()):
+ if len(locks) == 2:
+ continue
+ if lock.dependency_groups:
+ raise ValueError("custom lock has dependency groups but no build group")
+ groups: set[str] = set()
+ else:
+ groups = {"build"}
+ # Artifact choice is left to the installer; constraints carry every locked hash.
+ tags: list[Tag] | None = None
+ if environment is not None:
+ tags = sorted(
+ {
+ tag
+ for package in lock.packages
+ for wheel in package.wheels or ()
+ for tag in parse_wheel_filename(wheel.filename)[3]
+ },
+ key=str,
+ )
+ for package, _ in lock.select(environment=environment, tags=tags, dependency_groups=groups):
+ if package.version is None or package.index is None:
+ raise ValueError(f"build dependency {package.name} must be an index version")
+ name = canonicalize_name(package.name)
+ hashes = {
+ artifact.hashes["sha256"]
+ for artifact in [*(package.wheels or ()), *((package.sdist,) if package.sdist else ())]
+ if "sha256" in artifact.hashes
+ }
+ if not hashes:
+ raise ValueError(f"build dependency {package.name} has no SHA256 hash")
+ if (existing := selected.get(name)) is not None and existing[0] != package.version:
+ if index != 1 or not (requirement := requirements.get(name)):
+ raise ValueError(f"conflicting build dependency versions for {name}")
+ if package.version not in requirement.specifier:
+ raise ValueError(f"caller build dependency {name}=={package.version} violates {requirement}")
+ elif existing is not None:
+ hashes.update(existing[1])
+ selected[name] = (package.version, hashes)
+ return selected
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = [
+ "main",
+]
diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py
new file mode 100644
index 0000000..f6341f4
--- /dev/null
+++ b/tools/check_lock_groups.py
@@ -0,0 +1,50 @@
+"""Reject dependency groups absent from a PEP 751 lock."""
+
+from __future__ import annotations
+
+import argparse
+import tomllib
+from pathlib import Path
+
+from packaging.pylock import Pylock, PylockValidationError
+from packaging.version import Version
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("lock", type=Path)
+ parser.add_argument("groups", nargs="+")
+ parser.add_argument("--shared-lock", type=Path)
+ parser.add_argument("--local-tool-source", action="store_true")
+ args = parser.parse_args()
+ try:
+ with args.lock.open("rb") as stream:
+ lock = Pylock.from_dict(tomllib.load(stream))
+ if missing := set(args.groups) - set(lock.dependency_groups or ()):
+ parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}")
+ if args.shared_lock:
+ with args.shared_lock.open("rb") as stream:
+ shared = Pylock.from_dict(tomllib.load(stream))
+ required = next(package.version for package in shared.packages if package.name == "release-creator")
+ installed = next((package.version for package in lock.packages if package.name == "release-creator"), None)
+ needs_release_creator = bool(
+ set(args.groups)
+ & {"release-news", "release-notes", "release-prepare", "release-validate", "release-hooks"}
+ )
+ if (
+ needs_release_creator
+ and not args.local_tool_source
+ and (installed is None or Version(str(installed)) < Version(str(required)))
+ ):
+ parser.error(f"relock: release-creator {required} required")
+ except (OSError, PylockValidationError) as error:
+ parser.error(str(error))
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = [
+ "main",
+]
diff --git a/tools/check_nab_lock.py b/tools/check_nab_lock.py
new file mode 100644
index 0000000..cadca68
--- /dev/null
+++ b/tools/check_nab_lock.py
@@ -0,0 +1,39 @@
+"""Compare a regenerated Nab lock while ignoring the command used to regenerate it."""
+
+from __future__ import annotations
+
+import argparse
+import tomllib
+from pathlib import Path
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--environments-only", action="store_true")
+ parser.add_argument("committed", type=Path)
+ parser.add_argument("regenerated", type=Path)
+ args = parser.parse_args()
+ try:
+ before = args.committed.read_bytes()
+ old = tomllib.loads(before.decode())
+ new = tomllib.loads(args.regenerated.read_text())
+ if args.environments_only:
+ if old.get("environments") != new.get("environments"):
+ parser.error("regenerated Nab lock changed its target environments")
+ return
+ old["tool"]["nab"].pop("command-line", None)
+ new["tool"]["nab"].pop("command-line", None)
+ if old != new:
+ parser.error("regenerated Nab lock differs from the committed lock")
+ args.regenerated.write_bytes(before)
+ except (OSError, UnicodeDecodeError, KeyError, tomllib.TOMLDecodeError) as error:
+ parser.error(str(error))
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = [
+ "main",
+]
diff --git a/tools/check_recent_cutoffs.py b/tools/check_recent_cutoffs.py
new file mode 100644
index 0000000..a1b6cec
--- /dev/null
+++ b/tools/check_recent_cutoffs.py
@@ -0,0 +1,28 @@
+"""Reject recent Nab cutoffs without an explicit reviewed exception."""
+
+from __future__ import annotations
+
+import argparse
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+from nab_cutoffs import validate_recent_cutoffs
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("project", type=Path)
+ args = parser.parse_args()
+ try:
+ validate_recent_cutoffs(args.project.read_text(), datetime.now(UTC) - timedelta(days=7))
+ except (OSError, KeyError, ValueError) as error:
+ parser.error(str(error))
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = [
+ "main",
+]
diff --git a/tools/expire_cutoffs.py b/tools/expire_cutoffs.py
new file mode 100644
index 0000000..7b26a44
--- /dev/null
+++ b/tools/expire_cutoffs.py
@@ -0,0 +1,32 @@
+"""Remove reviewed Nab cutoffs once the global seven-day window covers them."""
+
+from __future__ import annotations
+
+import argparse
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+from nab_cutoffs import remove_expired_cutoffs
+from tomlkit.exceptions import ParseError
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("project", type=Path)
+ args = parser.parse_args()
+ try:
+ source = args.project.read_text()
+ updated = remove_expired_cutoffs(source, datetime.now(UTC) - timedelta(days=7))
+ if source != updated:
+ args.project.write_text(updated)
+ except (OSError, KeyError, ValueError, ParseError) as error:
+ parser.error(str(error))
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = [
+ "main",
+]
diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py
new file mode 100644
index 0000000..d65df7f
--- /dev/null
+++ b/tools/nab_cutoffs.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import tomllib
+from datetime import UTC, datetime
+
+_REVIEWED_EXCEPTIONS = {
+ **dict.fromkeys(
+ ("nab", "nab-index", "nab-markersets", "nab-project", "nab-provider", "nab-resolver"),
+ datetime(2026, 10, 19, tzinfo=UTC),
+ ),
+ "release-creator": datetime(2026, 10, 23, 2, 44, 40, tzinfo=UTC),
+}
+
+
+def validate_recent_cutoffs(project: str, threshold: datetime) -> None:
+ nab = tomllib.loads(project)["tool"]["nab"]
+ candidates = [
+ (name, settings["uploaded-prior-to"])
+ for name, settings in nab.get("packages", {}).items()
+ if "uploaded-prior-to" in settings
+ ]
+ candidates.extend(
+ (name, rule["uploaded-prior-to"])
+ for rule in nab.get("package-rules", [])
+ if "uploaded-prior-to" in rule
+ for name in rule["match"]
+ )
+ for name, value in candidates:
+ if isinstance(value, str) and value.startswith("P"):
+ continue
+ timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value
+ if timestamp.tzinfo is None:
+ raise ValueError(f"Nab package cutoff has no timezone: {value}")
+ if timestamp > threshold and timestamp > _REVIEWED_EXCEPTIONS.get(name, datetime.min.replace(tzinfo=UTC)):
+ raise ValueError(f"unreviewed recent Nab cutoff for {name}: {timestamp.isoformat()}")
+
+
+def remove_expired_cutoffs(project: str, threshold: datetime) -> str:
+ import tomlkit
+
+ names, positions = expired_cutoffs(project, threshold)
+ if not names and not positions:
+ return project
+ document = tomlkit.parse(project)
+ remove_cutoffs(document["tool"]["nab"], names, positions)
+ return tomlkit.dumps(document)
+
+
+def remove_cutoffs(nab: dict, names: set[str], positions: set[int]) -> None:
+ for name in names:
+ settings = nab["packages"][name]
+ del settings["uploaded-prior-to"]
+ if not settings:
+ del nab["packages"][name]
+ if "packages" in nab and not nab["packages"]:
+ del nab["packages"]
+ for index in sorted(positions, reverse=True):
+ rule = nab["package-rules"][index]
+ del rule["uploaded-prior-to"]
+ if set(rule) == {"match"}:
+ del nab["package-rules"][index]
+ if "package-rules" in nab and not nab["package-rules"]:
+ del nab["package-rules"]
+
+
+def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[int]]:
+ nab = tomllib.loads(project)["tool"]["nab"]
+ packages = {
+ name
+ for name, settings in nab.get("packages", {}).items()
+ if (value := settings.get("uploaded-prior-to")) is not None and _expired(value, threshold)
+ }
+ rules = {
+ index
+ for index, rule in enumerate(nab.get("package-rules", []))
+ if (value := rule.get("uploaded-prior-to")) is not None and _expired(value, threshold)
+ }
+ return packages, rules
+
+
+def _expired(value: str | datetime, threshold: datetime) -> bool:
+ if isinstance(value, str) and value.startswith("P"):
+ return False
+ timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value
+ if timestamp.tzinfo is None:
+ raise ValueError(f"Nab package cutoff has no timezone: {value}")
+ return timestamp <= threshold
+
+
+__all__ = [
+ "expired_cutoffs",
+ "remove_cutoffs",
+ "remove_expired_cutoffs",
+ "validate_recent_cutoffs",
+]
diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py
new file mode 100644
index 0000000..57c57d5
--- /dev/null
+++ b/tools/verify_cutoff_patch.py
@@ -0,0 +1,49 @@
+"""Allow only expired Nab upload cutoffs to change in pyproject.toml."""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import tomllib
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+from nab_cutoffs import expired_cutoffs, remove_cutoffs
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("project", nargs="?", type=Path, default=Path("pyproject.toml"))
+ args = parser.parse_args()
+ try:
+ baseline = subprocess.run(
+ ["git", "show", f"HEAD:{args.project.as_posix()}"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout
+ original = tomllib.loads(baseline)
+ actual = tomllib.loads(args.project.read_text())
+ names, positions = expired_cutoffs(baseline, datetime.now(UTC) - timedelta(days=7))
+ before = original["tool"]["nab"]
+ after = actual["tool"]["nab"]
+ removed_names = {
+ name for name in names if after.get("packages", {}).get(name, {}).get("uploaded-prior-to") is None
+ }
+ removed_positions = {
+ index for index in positions if before["package-rules"][index] not in after.get("package-rules", [])
+ }
+ remove_cutoffs(before, removed_names, removed_positions)
+ if original != actual:
+ parser.error("pyproject.toml has changes beyond expired Nab cutoffs")
+ except (OSError, KeyError, ValueError, subprocess.CalledProcessError, tomllib.TOMLDecodeError) as error:
+ parser.error(str(error))
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = [
+ "main",
+]
diff --git a/tox.toml b/tox.toml
new file mode 100644
index 0000000..acea0f7
--- /dev/null
+++ b/tox.toml
@@ -0,0 +1,9 @@
+requires = ["tox>=4.64.7", "tox-uv>=1.36.1"]
+env_list = ["type"]
+
+[env.type]
+description = "type-check Action tests"
+base_python = "3.15"
+skip_install = true
+dependency_groups = ["test"]
+commands = [["ty", "check", "--python-platform", "all", "--error", "all", "--output-format", "concise", "tests"]]
diff --git a/validate-news/action.yml b/validate-news/action.yml
new file mode 100644
index 0000000..69758b6
--- /dev/null
+++ b/validate-news/action.yml
@@ -0,0 +1,30 @@
+name: Validate changelog news
+description: Require a fragment for human changes and validate any fragment supplied by a bot
+runs:
+ using: composite
+ steps:
+ - name: Count changed fragments
+ shell: bash
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ run: release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count"
+ - name: Check changed fragments with Towncrier
+ shell: bash
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ run: |
+ read -r fragments <"$RUNNER_TEMP/news-fragment-count"
+ if ((fragments)); then
+ towncrier check --compare-with "origin/$BASE_REF"
+ fi
+ - name: Require news for human changes
+ shell: bash
+ env:
+ SKIP_NEWS: ${{ contains(github.event.pull_request.labels.*.name, 'skip news') }}
+ BOT_AUTHOR: ${{ github.event.pull_request.user.type == 'Bot' }}
+ run: |
+ read -r fragments <"$RUNNER_TEMP/news-fragment-count"
+ if ((! fragments)) && [[ "$SKIP_NEWS" != true && "$BOT_AUTHOR" != true ]]; then
+ echo '::error::add a changelog fragment or apply the skip news label' >&2
+ exit 1
+ fi
diff --git a/validate-python-distribution/action.yml b/validate-python-distribution/action.yml
new file mode 100644
index 0000000..2fc7b19
--- /dev/null
+++ b/validate-python-distribution/action.yml
@@ -0,0 +1,16 @@
+name: Validate Python distribution
+description: Check the exact wheel and sdist before uploading them
+runs:
+ using: composite
+ steps:
+ - name: Install locked package validators
+ uses: $/install-locked-tools
+ with:
+ groups: pkg-meta
+ venv: ${{ runner.temp }}/package-validators
+ - name: Check distribution metadata
+ shell: bash
+ run: uv run --no-project --python "$RUNNER_TEMP/package-validators" twine check dist/*
+ - name: Check wheel contents
+ shell: bash
+ run: uv run --no-project --python "$RUNNER_TEMP/package-validators" check-wheel-contents --no-config dist
diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml
index 0bd6a46..f048ed1 100644
--- a/validate-release-patch/action.yml
+++ b/validate-release-patch/action.yml
@@ -1,5 +1,5 @@
name: Validate release patch
-description: Apply the prepared patch to the original main commit and rerun prek
+description: Check and apply a prepared release patch, with optional release-tool validation
inputs:
version:
description: Release version
@@ -7,81 +7,176 @@ inputs:
patch:
description: Path to the downloaded patch
required: true
- changelog:
- description: Changelog path
- required: true
- fragments:
- description: Fragment directory
- required: true
- heading-prefix:
- description: Literal prefix of the version heading
- default: "## "
- branch:
- description: Protected release branch
- default: main
- remote:
- description: Git remote containing the release branch
- default: origin
- expected-sha:
- description: Exact branch commit used for release preparation
+ working-directory:
+ description: Checkout to validate and apply the patch in
+ default: .
+ expected-patch-sha:
+ description: SHA-256 digest from a separate keyless validation job
default: ""
+ checks:
+ description: Run release-creator and prek after applying the patch
+ default: "true"
runs:
using: composite
steps:
- - name: Verify release source
+ - name: Require a clean release checkout
shell: bash
- env:
- BRANCH: ${{ inputs.branch }}
- REMOTE: ${{ inputs.remote }}
- EXPECTED_SHA: ${{ inputs.expected-sha }}
+ working-directory: ${{ inputs.working-directory }}
run: |
- set -euo pipefail
if [[ -n $(git status --porcelain) ]]; then
echo '::error::release validation requires a clean checkout' >&2
exit 1
fi
- git fetch "$REMOTE" "$BRANCH"
- if [[ $(git rev-parse "$REMOTE/$BRANCH") != "${EXPECTED_SHA:-$GITHUB_SHA}" ]]; then
- echo "::error::$BRANCH changed during release preparation; dispatch again" >&2
+ - name: Verify release source
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ set -euo pipefail
+ git fetch origin main
+ if [[ $(git rev-parse origin/main) != "$GITHUB_SHA" ]]; then
+ echo '::error::main changed during release preparation; dispatch again' >&2
exit 1
fi
- - name: Apply and inspect release patch
+ - name: Apply release patch
shell: bash
+ working-directory: ${{ inputs.working-directory }}
env:
- VERSION: ${{ inputs.version }}
PATCH: ${{ inputs.patch }}
- CHANGELOG: ${{ inputs.changelog }}
- FRAGMENTS: ${{ inputs.fragments }}
- HEADING_PREFIX: ${{ inputs.heading-prefix }}
+ EXPECTED_PATCH_SHA: ${{ inputs.expected-patch-sha }}
+ CHECKS: ${{ inputs.checks }}
run: |
set -euo pipefail
+ if [[ "$CHECKS" != true && "$CHECKS" != false ]]; then
+ echo '::error::checks must be true or false' >&2
+ exit 1
+ fi
+ if [[ "$CHECKS" == false && -z "$EXPECTED_PATCH_SHA" ]]; then
+ echo '::error::skipping release checks requires a validated patch digest' >&2
+ exit 1
+ fi
+ read -r patch_sha _ < <(sha256sum "$PATCH")
+ if [[ -n "$EXPECTED_PATCH_SHA" && "$patch_sha" != "$EXPECTED_PATCH_SHA" ]]; then
+ echo '::error::release patch differs from the validated artifact' >&2
+ exit 1
+ fi
git apply --check "$PATCH"
git apply --index "$PATCH"
if git diff --cached --quiet; then
echo '::error::release patch contains no changes' >&2
exit 1
fi
- mapfile -d '' -t paths < <(git diff --cached --name-only -z)
- for path in "${paths[@]}"; do
- if [[ "$path" != "$CHANGELOG" && "$path" != "$FRAGMENTS/"* ]]; then
+ - id: config
+ uses: $/read-towncrier-config
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ - name: Check release patch paths
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ env:
+ CHANGELOG: ${{ steps.config.outputs.changelog }}
+ FRAGMENTS: ${{ steps.config.outputs.fragments }}
+ run: |
+ set -euo pipefail
+ git diff --cached --name-status --no-renames -z >"$RUNNER_TEMP/release-paths"
+ while IFS= read -r -d '' status && IFS= read -r -d '' path; do
+ if [[ "$path" == "$CHANGELOG" ]]; then
+ continue
+ fi
+ if [[ "$path" != "$FRAGMENTS/"* ]]; then
echo "::error::unexpected release path: $path" >&2
exit 1
fi
- done
+ if [[ "$status" != D ]]; then
+ echo "::error::unexpected release path status: $status $path" >&2
+ exit 1
+ fi
+ done <"$RUNNER_TEMP/release-paths"
- heading_found=false
- mapfile -t changelog_lines <"$CHANGELOG"
- for line in "${changelog_lines[@]}"; do
- if [[ "$line" == "$HEADING_PREFIX$VERSION ("* ]]; then
- heading_found=true
+ if git diff --cached --quiet -- "$CHANGELOG"; then
+ echo "::error::release patch does not change $CHANGELOG" >&2
+ exit 1
+ fi
+ - name: Check consumed fragments
+ if: inputs.checks == 'true'
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: release-creator validate-fragment-deletions
+ - name: Check release patch file modes
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ env:
+ FRAGMENTS: ${{ steps.config.outputs.fragments }}
+ run: |
+ set -euo pipefail
+ git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes"
+ while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do
+ read -ra fields <<<"$metadata"
+ case "${fields[4]}" in
+ A)
+ old_mode=000000
+ new_mode=100644
+ ;;
+ M)
+ old_mode=100644
+ new_mode=100644
+ ;;
+ D)
+ old_mode=100644
+ new_mode=000000
+ if [[ "$path" != "$FRAGMENTS/"* ]]; then
+ echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2
+ exit 1
+ fi
+ ;;
+ *)
+ echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2
+ exit 1
+ ;;
+ esac
+ if [[ "${fields[0]#\:}" != "$old_mode" ]]; then
+ echo "::error::unexpected previous release file mode in $path: ${fields[0]#\:}" >&2
+ exit 1
fi
- done
- if [[ "$heading_found" != true ]]; then
- echo "::error::$CHANGELOG has no section for $VERSION" >&2
+ if [[ "${fields[1]}" != "$new_mode" ]]; then
+ echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2
+ exit 1
+ fi
+ done <"$RUNNER_TEMP/release-modes"
+ - name: Check RST conflict markers
+ if: steps.config.outputs.format == 'rst'
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ env:
+ CHANGELOG: ${{ steps.config.outputs.changelog }}
+ run: |
+ set -euo pipefail
+ git diff --cached --unified=0 -- "$CHANGELOG" >"$RUNNER_TEMP/release-rst-diff"
+ while IFS= read -r line; do
+ case "$line" in
+ +'<<<<<<<'* | +'|||||||'* | +'>>>>>>>'*)
+ echo '::error::release changelog contains conflict markers' >&2
+ exit 1
+ ;;
+ esac
+ done <"$RUNNER_TEMP/release-rst-diff"
+ printf '%s conflict-marker-size=10\n' "$CHANGELOG" >>"$(git rev-parse --git-path info/attributes)"
+ - name: Check rendered release section
+ if: inputs.checks == 'true'
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ env:
+ VERSION: ${{ inputs.version }}
+ CHANGELOG: ${{ steps.config.outputs.changelog }}
+ run: |
+ if ! release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$GITHUB_REPOSITORY" \
+ >/dev/null; then
+ echo "::error::$CHANGELOG has no valid section for $VERSION" >&2
exit 1
fi
- name: Run prek on release patch
+ if: inputs.checks == 'true'
shell: bash
+ working-directory: ${{ inputs.working-directory }}
run: |
set -euo pipefail
git diff --cached --check
diff --git a/verify-nab-cutoff-patch/action.yml b/verify-nab-cutoff-patch/action.yml
new file mode 100644
index 0000000..e4d4fbf
--- /dev/null
+++ b/verify-nab-cutoff-patch/action.yml
@@ -0,0 +1,8 @@
+name: Verify Nab cutoff patch
+description: Reject pyproject changes other than expired Nab package cutoffs
+runs:
+ using: composite
+ steps:
+ - name: Verify pyproject policy
+ shell: bash
+ run: python3 "$GITHUB_ACTION_PATH/../tools/verify_cutoff_patch.py"