From 0aa0cc7f82828d50a8549341d0da431629e4a68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 19 Oct 2026 16:53:02 -0700 Subject: [PATCH 001/167] =?UTF-8?q?=F0=9F=90=9B=20fix(release):=20verify?= =?UTF-8?q?=20PR=20credits=20in=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.2.0 release rendered bare fragment stems instead of pull request links and author credits. Correct its tracked changelog and make the runner rehearsal fail when either credit is missing. Use the unreleased release-creator commit for validation. Switch the lock back to PyPI and rerun the gate before merging. --- .github/workflows/check.yaml | 42 +++++++++++++++ CHANGELOG.md | 5 +- changelog.d/+fragment-validation.bugfix.md | 1 + pylock.toml | 5 +- pyproject.toml | 2 +- tests/conftest.py | 4 +- tests/prepare_towncrier/test_prepare.py | 61 ++++++++++++++++++---- 7 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 changelog.d/+fragment-validation.bugfix.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 67cf977..368244c 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -133,6 +133,36 @@ jobs: git remote set-url origin "$RUNNER_TEMP/release-origin.git" git push origin HEAD:main echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" + - name: Introduce an unsupported fragment name + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + git mv "changelog.d/+validation-${PR_NUMBER}.bugfix.md" \ + "changelog.d/validation-${PR_NUMBER}.bugfix.md" + git -c user.name=release-validation \ + -c user.email=release-validation@users.noreply.github.com \ + commit -m "Validate fragment rejection" + git push origin HEAD:main + - id: invalid_fragment + name: Reject an unsupported fragment name + uses: $/prepare-towncrier + continue-on-error: true + with: + changelog: CHANGELOG.md + format: markdown + patch: ${{ runner.temp }}/release.patch + - name: Check fragment rejection and restore the source + env: + OUTCOME: ${{ steps.invalid_fragment.outcome }} + SOURCE_SHA: ${{ steps.fixture.outputs.sha }} + run: | + test "$OUTCOME" = failure + if error=$(git tag --list | release-creator tags --next auto 2>&1); then + exit 1 + fi + [[ "$error" == *'unsupported changelog fragment name'* ]] + git reset --hard "$SOURCE_SHA" + git push --force origin HEAD:main - name: Dirty the release checkout run: touch untracked-release-file - id: dirty_prepare @@ -200,6 +230,9 @@ jobs: - name: Render tagged notes env: VERSION: ${{ steps.prepare.outputs.version }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} run: | set -euo pipefail test -n "$(git ls-remote origin "refs/tags/$VERSION")" @@ -212,6 +245,15 @@ jobs: --repository "$GITHUB_REPOSITORY" "${args[@]}" \ >"$RUNNER_TEMP/release-notes.md" test -s "$RUNNER_TEMP/release-notes.md" + grep -Fq "[#${PR_NUMBER}](https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER})" \ + "$RUNNER_TEMP/release-notes.md" + if [[ "$PR_AUTHOR_TYPE" == Bot ]]; then + if grep -Fq "by @${PR_AUTHOR}" "$RUNNER_TEMP/release-notes.md"; then + exit 1 + fi + else + grep -Fq "by @${PR_AUTHOR}" "$RUNNER_TEMP/release-notes.md" + fi - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-dry-run 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/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/pylock.toml b/pylock.toml index a1c2365..3bcfaef 100644 --- a/pylock.toml +++ b/pylock.toml @@ -101,9 +101,8 @@ sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd77 [[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.4.2.dev2+g893fb23f5" +vcs = { type = "git", url = "https://github.com/tox-dev/release-creator", requested-revision = "893fb23f53288972f0204ffa67a8cc14c0b0f865", commit-id = "893fb23f53288972f0204ffa67a8cc14c0b0f865" } [[packages]] name = "ruff" diff --git a/pyproject.toml b/pyproject.toml index bc4f0d0..23e9558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.15" dependencies = [] [dependency-groups] -release = [ "release-creator>=0.4.1", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] +release = [ "release-creator @ git+https://github.com/tox-dev/release-creator@893fb23f53288972f0204ffa67a8cc14c0b0f865", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] test = [ "pytest>=9", "pyyaml>=6", "ruff", "ty" ] [tool.ruff] diff --git a/tests/conftest.py b/tests/conftest.py index 50d3fea..6077550 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,9 +50,9 @@ def repository(tmp_path: Path) -> Path: 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") return worktree diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index d80ba3b..5002d33 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -2,6 +2,7 @@ import os from datetime import date +from textwrap import dedent lazy from pathlib import Path import pytest @@ -43,7 +44,7 @@ def test_prepare_custom_fragment_directory(repository: Path, tmp_path: Path, act (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, @@ -53,15 +54,28 @@ def test_prepare_custom_fragment_directory(repository: Path, tmp_path: Path, act version="", FRAGMENTS="docs/changelog.d", LINK_FRAGMENTS="true", + GITHUB_REPOSITORY="tox-dev/github-workflows", ) 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() + assert ( + "Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) " + "by [@gaborbernat](https://github.com/gaborbernat)." + ) in (repository / "CHANGELOG.md").read_text() + 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 + result = action( + repository, + tmp_path, + action_scripts, + "prepare-towncrier", + version="0.2.0", + LINK_FRAGMENTS="true", + GITHUB_REPOSITORY="tox-dev/github-workflows", + ) + assert result.returncode == 0, result.stderr result = run( repository, "release-creator", @@ -71,9 +85,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: @@ -149,9 +175,26 @@ def test_prepare_links_named_fragments(repository: Path, tmp_path: Path, action_ "prepare-towncrier", version="0.2.0", LINK_FRAGMENTS="true", + GITHUB_REPOSITORY="tox-dev/github-workflows", ) assert result.returncode == 0, result.stderr - assert "Add a new feature." in (repository / "CHANGELOG.md").read_text() + assert ( + "Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) " + "by [@gaborbernat](https://github.com/gaborbernat)." + ) 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, LINK_FRAGMENTS="true") + 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: @@ -169,7 +212,7 @@ 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") @@ -207,7 +250,7 @@ def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scrip " name: mdformat\n entry: mdformat --wrap 120\n" " language: system\n types: [markdown]\n", ) - (repository / "changelog.d/feature.feature.md").write_text("Add " + "a long sentence " * 15 + ".\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", "push", "origin", "main") From 653c0cfa4db819c39deda78e67847e8c09db09d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 19 Oct 2026 16:55:04 -0700 Subject: [PATCH 002/167] =?UTF-8?q?=F0=9F=91=B7=20ci:=20give=20Action=20te?= =?UTF-8?q?sts=20PR=20read=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 368244c..60eeb15 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -9,6 +9,9 @@ permissions: jobs: check: runs-on: ubuntu-26.04 + permissions: + contents: read + pull-requests: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -31,6 +34,8 @@ jobs: ruff check . --output-format=concise ty check tests - name: Measure Action shell coverage + env: + GH_TOKEN: ${{ github.token }} run: | sudo apt-get update sudo apt-get install -y kcov From f0c484a3cdf6a144312f4c8d31bc74ea745a2fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 19 Oct 2026 17:12:53 -0700 Subject: [PATCH 003/167] =?UTF-8?q?=E2=9C=A8=20feat(build):=20share=20Pyth?= =?UTF-8?q?on=20release=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen publisher workflows use the same uv wheel and sdist command. Put their locked-tool install, tagged-source prek check, and build in one Action so the publisher and PR rehearsal can run identical steps. --- CONTRIBUTING.md | 10 +- README.md | 39 ++++++- build-python-distribution/action.yml | 48 +++++++++ changelog.d/+python-distribution.feature.md | 1 + tests/build_python_distribution/test_build.py | 100 ++++++++++++++++++ tests/conftest.py | 1 + 6 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 build-python-distribution/action.yml create mode 100644 changelog.d/+python-distribution.feature.md create mode 100644 tests/build_python_distribution/test_build.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48db84a..60da444 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,12 +31,12 @@ coverage of those blocks. The PR rehearsal also executes the composite Actions o ## 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. +invokes the release preparation 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. -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 diff --git a/README.md b/README.md index 3c8c76c..6ace057 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 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. +Four composite Actions prepare a Towncrier release, verify its patch, push an App-authored commit and tag, and build a +Python wheel and sdist. They run as steps in the caller's workflow. Each repository keeps its dispatch form, publisher, +GitHub environment, and required check names. ## Start with a caller @@ -38,8 +38,10 @@ prepare-towncrier ── version + patch ──► upload artifact 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. +Run `prek` on the tagged source before publication. Repositories with the standard `uv` wheel and sdist build can use +`build-python-distribution` in both the tag publisher and the PR rehearsal. The +[self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem +uploads. ## Prepare Towncrier release @@ -142,6 +144,33 @@ Contents write. **Output:** No named output. **Remote effect:** One new commit on `branch` 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. +## Build Python distribution + +`build-python-distribution` installs the caller's release group from `pylock.toml`, runs `prek` on the tagged tree, then +builds a wheel and sdist with `uv`. Call it after `actions/checkout` and `astral-sh/setup-uv`. The caller verifies the +tag and changelog section before this Action. Use the same Action and inputs after the local tag in the PR rehearsal; +this executes the build steps used by the tag publisher without publishing the artifacts. + +```yaml + - uses: tox-dev/github-workflows/build-python-distribution@SHA + with: + lock: pylock.toml + python-version: '3.15' + source: . + out-dir: dist +``` + +- `lock`: Path to the caller's `pylock.toml`; default `pylock.toml`. Its `release` group must contain `prek` and every + package needed by the caller's hooks. +- `python-version`: Managed Python used for release tools and the build; default `3.15`. +- `source`: Source directory passed to `uv build`; default `.`. +- `out-dir`: Directory for the wheel and sdist; default `dist`. The caller uploads files from this directory. + +**Output:** No named output. **Working-tree effect:** Creates a release-tool environment under `runner.temp` and writes +a wheel and sdist to `out-dir`. The checkout must be clean before the Action starts; it fails if `prek` fails or changes +a tracked or untracked source file. Use a separate build path for repositories whose build command differs from +`uv build --sdist --wheel`. + ## Check a caller before switching The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. The diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml new file mode 100644 index 0000000..609f5f9 --- /dev/null +++ b/build-python-distribution/action.yml @@ -0,0 +1,48 @@ +name: Build Python distribution +description: Check the tagged source with locked tools, then build a wheel and sdist +inputs: + lock: + description: pylock.toml containing the release dependency group + default: pylock.toml + python-version: + description: Python version for release tooling and uv build + default: "3.15" + source: + description: Distribution source directory + default: . + out-dir: + description: Directory for the wheel and sdist + default: dist +runs: + using: composite + steps: + - name: Install locked release tools + shell: bash + env: + LOCK: ${{ inputs.lock }} + PYTHON_VERSION: ${{ inputs.python-version }} + run: | + set -euo pipefail + uv venv --python "$PYTHON_VERSION" "$RUNNER_TEMP/build-tools" + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/build-tools/bin/python" --group release "$LOCK" + - name: Check tagged source + shell: bash + run: | + set -euo pipefail + if [[ -n $(git status --porcelain) ]]; then + echo '::error::tagged source is dirty' >&2 + exit 1 + fi + "$RUNNER_TEMP/build-tools/bin/prek" run --all-files + if [[ -n $(git status --porcelain) ]]; then + echo '::error::prek changed the tagged source' >&2 + exit 1 + fi + - name: Build wheel and sdist + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python-version }} + SOURCE: ${{ inputs.source }} + OUT_DIR: ${{ inputs.out-dir }} + run: | + uv build --python "$PYTHON_VERSION" --python-preference only-managed --sdist --wheel "$SOURCE" --out-dir "$OUT_DIR" diff --git a/changelog.d/+python-distribution.feature.md b/changelog.d/+python-distribution.feature.md new file mode 100644 index 0000000..a378a58 --- /dev/null +++ b/changelog.d/+python-distribution.feature.md @@ -0,0 +1 @@ +Build a Python wheel and sdist from tagged source with the caller's locked release tools and `prek` checks. diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py new file mode 100644 index 0000000..f10db7a --- /dev/null +++ b/tests/build_python_distribution/test_build.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from textwrap import dedent +from typing import Final +lazy from pathlib import Path + +import pytest +from conftest import action, run + +_RELEASE_LOCK: Final[Path] = Path(__file__).resolve().parents[2] / "pylock.toml" + + +@pytest.fixture +def build_repository(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') + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Add package source") + run(repository, "git", "push", "origin", "main") + return repository + + +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", + LOCK=str(_RELEASE_LOCK), + PYTHON_VERSION="3.15", + SOURCE=".", + OUT_DIR=str(tmp_path / "dist"), + RUNNER_TEMP=str(tmp_path), + ) + assert result.returncode == 0, result.stderr + assert {path.name for path in (tmp_path / "dist").iterdir() if path.suffix in {".whl", ".gz"}} == { + "fixture-0.2.0-py3-none-any.whl", + "fixture-0.2.0.tar.gz", + } + + +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", + LOCK=str(_RELEASE_LOCK), + PYTHON_VERSION="3.15", + SOURCE=".", + OUT_DIR=str(tmp_path / "dist"), + RUNNER_TEMP=str(tmp_path), + ) + assert "tagged source is dirty" in result.stderr + + +def test_build_python_distribution_rejects_generated_source( + 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", + LOCK=str(_RELEASE_LOCK), + PYTHON_VERSION="3.15", + SOURCE=".", + OUT_DIR=str(tmp_path / "dist"), + RUNNER_TEMP=str(tmp_path), + ) + assert "prek changed the tagged source" in result.stderr diff --git a/tests/conftest.py b/tests/conftest.py index 6077550..c6f654e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "prepare-towncrier", "validate-release-patch", "commit-and-tag", + "build-python-distribution", ): action = yaml.safe_load((source / action_name / "action.yml").read_text()) for index, step in enumerate(action["runs"]["steps"]): From 47a08c1c289b68ec58548fa848e8c8c561ddc757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 19 Oct 2026 17:30:32 -0700 Subject: [PATCH 004/167] =?UTF-8?q?=E2=9C=A8=20feat(notes):=20share=20tagg?= =?UTF-8?q?ed=20note=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 26 +-- .github/workflows/release.yaml | 23 ++- CONTRIBUTING.md | 5 +- README.md | 43 ++++- changelog.d/+release-notes.feature.md | 1 + render-release-notes/action.yml | 74 +++++++++ tests/conftest.py | 7 + tests/render_release_notes/test_notes.py | 202 +++++++++++++++++++++++ 8 files changed, 359 insertions(+), 22 deletions(-) create mode 100644 changelog.d/+release-notes.feature.md create mode 100644 render-release-notes/action.yml create mode 100644 tests/render_release_notes/test_notes.py diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 60eeb15..14ee986 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -232,23 +232,29 @@ jobs: token: ${{ github.token }} app-slug: github-actions message: release ${{ steps.prepare.outputs.version }} - - name: Render tagged notes + - name: Prepare generated notes fixture env: VERSION: ${{ steps.prepare.outputs.version }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + test -n "$(git ls-remote origin "refs/tags/$VERSION")" + printf '* Validate release pipeline by @%s in https://github.com/%s/pull/%s\n' \ + "$PR_AUTHOR" "$GITHUB_REPOSITORY" "$PR_NUMBER" >"$RUNNER_TEMP/generated-notes.md" + - uses: $/render-release-notes + with: + tag: ${{ steps.prepare.outputs.version }} + changelog: CHANGELOG.md + repository: ${{ github.repository }} + generated-notes: ${{ runner.temp }}/generated-notes.md + output: ${{ runner.temp }}/release-notes.md + - name: Inspect tagged notes + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} 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" grep -Fq "[#${PR_NUMBER}](https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER})" \ "$RUNNER_TEMP/release-notes.md" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3c7a35d..1aaccc7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -29,18 +29,29 @@ jobs: run: | git tag --list | release-creator tags "$TAG" prek run --all-files - - name: Publish GitHub Release + - name: Fetch contributor metadata env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} run: | set -euo pipefail previous=$(git tag --list | release-creator tags "$TAG" --previous) - args=() + args=(-f "tag_name=$TAG") if [[ -n "$previous" ]]; then - args+=(--previous "$previous") + args+=(-f "previous_tag_name=$previous") fi - release-creator notes --changelog CHANGELOG.md --tag "$TAG" \ - --repository "$GITHUB_REPOSITORY" "${args[@]}" \ - >"$RUNNER_TEMP/release-notes.md" + gh api -X POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" "${args[@]}" --jq .body \ + >"$RUNNER_TEMP/generated-notes.md" + - uses: $/render-release-notes + with: + tag: ${{ github.ref_name }} + changelog: CHANGELOG.md + repository: ${{ github.repository }} + generated-notes: ${{ runner.temp }}/generated-notes.md + output: ${{ runner.temp }}/release-notes.md + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | gh release create "$TAG" --verify-tag --title "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60da444..5de103d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,9 +31,8 @@ coverage of those blocks. The PR rehearsal also executes the composite Actions o ## 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 the release preparation 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. +invokes the release 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. 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. diff --git a/README.md b/README.md index 6ace057..021ef95 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # GitHub workflows -Four composite Actions prepare a Towncrier release, verify its patch, push an App-authored commit and tag, and build a -Python wheel and sdist. They run as steps in the caller's workflow. Each repository keeps its dispatch form, publisher, -GitHub environment, and required check names. +Five composite Actions 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. ## Start with a caller @@ -171,6 +171,43 @@ a wheel and sdist to `out-dir`. The checkout must be clean before the Action sta a tracked or untracked source file. Use a separate build path for repositories whose build command differs from `uv build --sdist --wheel`. +## 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 local tag in a PR rehearsal and again 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 + repository: ${{ github.repository }} + 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. A local PR + rehearsal creates this tag without pushing it to GitHub. +- `changelog` **(required)**: Tracked Markdown or reStructuredText changelog in the format that `release-creator notes` + parses, with exactly one section for the tag. +- `repository` **(required)**: GitHub `owner/repository` used in PR and comparison links. +- `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. +- `issue-role`: For reStructuredText, whether `:issue:` links target GitHub issues (default) or pull requests. +- `generated-notes`: Path to GitHub's generated-notes body. The tag publisher fetches it after PyPI publication; a PR + rehearsal supplies a small fixture with its actual PR author and URL. This 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. + +**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. + ## Check a caller before switching The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. The diff --git a/changelog.d/+release-notes.feature.md b/changelog.d/+release-notes.feature.md new file mode 100644 index 0000000..cb48047 --- /dev/null +++ b/changelog.d/+release-notes.feature.md @@ -0,0 +1 @@ +Render GitHub Release notes from a tagged changelog with the same Action in PR rehearsals and tag publishers. diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml new file mode 100644 index 0000000..67eb9f1 --- /dev/null +++ b/render-release-notes/action.yml @@ -0,0 +1,74 @@ +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 + repository: + description: GitHub owner/repository + required: true + artifact-url: + description: Published ecosystem release URL + default: "" + artifact-label: + description: Link text for the published artifact + default: PyPI release + issue-role: + description: Target of reStructuredText issue roles, issue or pull + default: issue + output: + description: Path for the rendered Markdown body + required: true + generated-notes: + description: Existing GitHub generated-notes file with contributor metadata + default: "" +outputs: + previous: + description: Previous version tag, or an empty string for the first release + value: ${{ steps.version.outputs.previous }} +runs: + using: composite + steps: + - id: version + name: Check the release tag + shell: bash + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + release-creator tags "$TAG" + previous=$(git tag --list | release-creator tags "$TAG" --previous) + printf 'previous=%s\n' "$previous" >>"$GITHUB_OUTPUT" + - name: Render GitHub Release notes + shell: bash + env: + TAG: ${{ inputs.tag }} + CHANGELOG: ${{ inputs.changelog }} + REPOSITORY: ${{ inputs.repository }} + ARTIFACT_URL: ${{ inputs.artifact-url }} + ARTIFACT_LABEL: ${{ inputs.artifact-label }} + ISSUE_ROLE: ${{ inputs.issue-role }} + OUTPUT: ${{ inputs.output }} + GENERATED_NOTES: ${{ inputs.generated-notes }} + PREVIOUS: ${{ steps.version.outputs.previous }} + run: | + set -euo pipefail + args=(--changelog "$CHANGELOG" --tag "$TAG" --repository "$REPOSITORY" --issue-role "$ISSUE_ROLE") + 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" + if [[ ! -s "$OUTPUT" ]] || + [[ -n "$PREVIOUS" && $(grep -c '^\*\*Full Changelog\*\*:' "$OUTPUT") != 1 ]]; then + echo '::error::release notes must contain one comparison link' >&2 + exit 1 + fi diff --git a/tests/conftest.py b/tests/conftest.py index c6f654e..88d3819 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "validate-release-patch", "commit-and-tag", "build-python-distribution", + "render-release-notes", ): action = yaml.safe_load((source / action_name / "action.yml").read_text()) for index, step in enumerate(action["runs"]["steps"]): @@ -85,6 +86,10 @@ def action( "BRANCH": "main", "REMOTE": "origin", "ANNOTATED": "false", + "ARTIFACT_URL": "", + "ARTIFACT_LABEL": "PyPI release", + "GENERATED_NOTES": "", + "ISSUE_ROLE": "issue", } | extra_env ) @@ -115,6 +120,8 @@ def action( 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] + if name == "render-release-notes" and script.name == "render-release-notes-0.sh": + environment["PREVIOUS"] = (tmp_path / "output").read_text().split("previous=", 1)[1].splitlines()[0] return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr)) diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py new file mode 100644 index 0000000..b09c42f --- /dev/null +++ b/tests/render_release_notes/test_notes.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from textwrap import dedent +lazy from pathlib import Path + +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", + REPOSITORY="tox-dev/fixture", + 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", + REPOSITORY="tox-dev/fixture", + 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", + REPOSITORY="tox-dev/fixture", + 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() + + +def test_render_release_notes_converts_rst_pull_role( + tagged_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + (tagged_repository / "CHANGELOG.rst").write_text( + "0.2.0 (2026-10-20)\n==================\n\nFixed\n-----\n\n- Correct a bug (:issue:`4`).\n", + ) + result = action( + tagged_repository, + tmp_path, + action_scripts, + "render-release-notes", + version="0.2.0", + TAG="0.2.0", + CHANGELOG="CHANGELOG.rst", + REPOSITORY="tox-dev/fixture", + OUTPUT=str(tmp_path / "notes.md"), + ISSUE_ROLE="pull", + ) + assert result.returncode == 0, result.stderr + assert "Correct a bug ([#4](https://github.com/tox-dev/fixture/pull/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", + REPOSITORY="tox-dev/fixture", + 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, "invalid", 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, + REPOSITORY="tox-dev/fixture", + OUTPUT=str(tmp_path / "notes.md"), + ) + assert result.returncode != 0 + assert error in result.stderr + + +def test_render_release_notes_rejects_empty_output( + 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", + REPOSITORY="tox-dev/fixture", + OUTPUT="/dev/null", + ) + assert result.returncode != 0 + assert "release notes must contain one comparison link" in result.stderr From 662d014f87340ee1cb9d55ba27421abd35a34524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 19 Oct 2026 18:19:35 -0700 Subject: [PATCH 005/167] =?UTF-8?q?=F0=9F=94=A7=20build:=20pin=20corrected?= =?UTF-8?q?=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pylock.toml | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pylock.toml b/pylock.toml index 3bcfaef..ca82266 100644 --- a/pylock.toml +++ b/pylock.toml @@ -101,8 +101,8 @@ sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd77 [[packages]] name = "release-creator" -version = "0.4.2.dev2+g893fb23f5" -vcs = { type = "git", url = "https://github.com/tox-dev/release-creator", requested-revision = "893fb23f53288972f0204ffa67a8cc14c0b0f865", commit-id = "893fb23f53288972f0204ffa67a8cc14c0b0f865" } +version = "0.4.2.dev3+gd48fb3c91" +vcs = { type = "git", url = "https://github.com/tox-dev/release-creator", requested-revision = "d48fb3c91b9c41d320c403f65ddedf5cd0384cea", commit-id = "d48fb3c91b9c41d320c403f65ddedf5cd0384cea" } [[packages]] name = "ruff" diff --git a/pyproject.toml b/pyproject.toml index 23e9558..bf0fc1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.15" dependencies = [] [dependency-groups] -release = [ "release-creator @ git+https://github.com/tox-dev/release-creator@893fb23f53288972f0204ffa67a8cc14c0b0f865", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] +release = [ "release-creator @ git+https://github.com/tox-dev/release-creator@d48fb3c91b9c41d320c403f65ddedf5cd0384cea", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] test = [ "pytest>=9", "pyyaml>=6", "ruff", "ty" ] [tool.ruff] From d65cd553ce41d77661410335364e4b507a683975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 19 Oct 2026 22:12:11 -0700 Subject: [PATCH 006/167] =?UTF-8?q?=F0=9F=91=B7=20ci:=20rehearse=20tagged?= =?UTF-8?q?=20release=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the PR rehearsal through the read-only workflow used by tag releases. Keep GitHub Release creation in a separate tag-only job with Contents write. --- .github/workflows/check.yaml | 171 +------------------ .github/workflows/release.yaml | 50 ++---- .github/workflows/validate-release.yaml | 217 ++++++++++++++++++++++++ README.md | 4 +- 4 files changed, 232 insertions(+), 210 deletions(-) create mode 100644 .github/workflows/validate-release.yaml diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 14ee986..bdefeec 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -99,179 +99,10 @@ jobs: fi release-dry-run: if: github.event_name == 'pull_request' - runs-on: ubuntu-26.04 + uses: $/.github/workflows/validate-release.yaml permissions: contents: read pull-requests: 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: - 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: Introduce an unsupported fragment name - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - git mv "changelog.d/+validation-${PR_NUMBER}.bugfix.md" \ - "changelog.d/validation-${PR_NUMBER}.bugfix.md" - git -c user.name=release-validation \ - -c user.email=release-validation@users.noreply.github.com \ - commit -m "Validate fragment rejection" - git push origin HEAD:main - - id: invalid_fragment - name: Reject an unsupported fragment name - uses: $/prepare-towncrier - continue-on-error: true - with: - changelog: CHANGELOG.md - format: markdown - patch: ${{ runner.temp }}/release.patch - - name: Check fragment rejection and restore the source - env: - OUTCOME: ${{ steps.invalid_fragment.outcome }} - SOURCE_SHA: ${{ steps.fixture.outputs.sha }} - run: | - test "$OUTCOME" = failure - if error=$(git tag --list | release-creator tags --next auto 2>&1); then - exit 1 - fi - [[ "$error" == *'unsupported changelog fragment name'* ]] - git reset --hard "$SOURCE_SHA" - git push --force origin HEAD:main - - 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: Prepare generated notes fixture - env: - VERSION: ${{ steps.prepare.outputs.version }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - run: | - test -n "$(git ls-remote origin "refs/tags/$VERSION")" - printf '* Validate release pipeline by @%s in https://github.com/%s/pull/%s\n' \ - "$PR_AUTHOR" "$GITHUB_REPOSITORY" "$PR_NUMBER" >"$RUNNER_TEMP/generated-notes.md" - - uses: $/render-release-notes - with: - tag: ${{ steps.prepare.outputs.version }} - changelog: CHANGELOG.md - repository: ${{ github.repository }} - generated-notes: ${{ runner.temp }}/generated-notes.md - output: ${{ runner.temp }}/release-notes.md - - name: Inspect tagged notes - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} - run: | - set -euo pipefail - test -s "$RUNNER_TEMP/release-notes.md" - grep -Fq "[#${PR_NUMBER}](https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER})" \ - "$RUNNER_TEMP/release-notes.md" - if [[ "$PR_AUTHOR_TYPE" == Bot ]]; then - if grep -Fq "by @${PR_AUTHOR}" "$RUNNER_TEMP/release-notes.md"; then - exit 1 - fi - else - grep -Fq "by @${PR_AUTHOR}" "$RUNNER_TEMP/release-notes.md" - fi - - 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 gate: name: Required checks pass if: always() diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1aaccc7..3f24588 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,50 +5,22 @@ on: permissions: contents: read jobs: - release: + validate: + uses: $/.github/workflows/validate-release.yaml + permissions: + contents: read + pull-requests: read + publish: + if: startsWith(github.ref, 'refs/tags/') + needs: validate runs-on: ubuntu-26.04 permissions: contents: write 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" - - name: Check tagged source - env: - TAG: ${{ github.ref_name }} - run: | - git tag --list | release-creator tags "$TAG" - prek run --all-files - - name: Fetch contributor metadata - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - previous=$(git tag --list | release-creator tags "$TAG" --previous) - 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" - - uses: $/render-release-notes + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - tag: ${{ github.ref_name }} - changelog: CHANGELOG.md - repository: ${{ github.repository }} - generated-notes: ${{ runner.temp }}/generated-notes.md - output: ${{ runner.temp }}/release-notes.md + name: release-dry-run + path: ${{ runner.temp }} - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/validate-release.yaml b/.github/workflows/validate-release.yaml new file mode 100644 index 0000000..f545e92 --- /dev/null +++ b/.github/workflows/validate-release.yaml @@ -0,0 +1,217 @@ +name: Validate release +on: + workflow_call: +permissions: + contents: read +jobs: + release: + runs-on: ubuntu-26.04 + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - 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" + - name: Rehearse a squash merge + if: github.event_name == 'pull_request' + 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: Introduce an unsupported fragment name + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + git mv "changelog.d/+validation-${PR_NUMBER}.bugfix.md" \ + "changelog.d/validation-${PR_NUMBER}.bugfix.md" + git -c user.name=release-validation \ + -c user.email=release-validation@users.noreply.github.com \ + commit -m "Validate fragment rejection" + git push origin HEAD:main + - id: invalid_fragment + if: github.event_name == 'pull_request' + name: Reject an unsupported fragment name + uses: $/prepare-towncrier + continue-on-error: true + with: + changelog: CHANGELOG.md + format: markdown + patch: ${{ runner.temp }}/release.patch + - name: Check fragment rejection and restore the source + if: github.event_name == 'pull_request' + env: + OUTCOME: ${{ steps.invalid_fragment.outcome }} + SOURCE_SHA: ${{ steps.fixture.outputs.sha }} + run: | + test "$OUTCOME" = failure + if error=$(git tag --list | release-creator tags --next auto 2>&1); then + exit 1 + fi + [[ "$error" == *'unsupported changelog fragment name'* ]] + git reset --hard "$SOURCE_SHA" + git push --force origin HEAD:main + - name: Dirty the release checkout + if: github.event_name == 'pull_request' + run: touch untracked-release-file + - id: dirty_prepare + if: github.event_name == 'pull_request' + 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 + if: github.event_name == 'pull_request' + env: + OUTCOME: ${{ steps.dirty_prepare.outcome }} + run: | + test "$OUTCOME" = failure + rm untracked-release-file + - id: prepare + if: github.event_name == 'pull_request' + uses: $/prepare-towncrier + with: + changelog: CHANGELOG.md + format: markdown + patch: ${{ runner.temp }}/release.patch + - name: Restore the source commit + if: github.event_name == 'pull_request' + run: git reset --hard HEAD + - id: stale_validate + if: github.event_name == 'pull_request' + 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 + if: github.event_name == 'pull_request' + env: + OUTCOME: ${{ steps.stale_validate.outcome }} + run: test "$OUTCOME" = failure + - uses: $/validate-release-patch + if: github.event_name == 'pull_request' + 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 + if: github.event_name == 'pull_request' + 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 + if: github.event_name == 'pull_request' + env: + OUTCOME: ${{ steps.invalid_tag.outcome }} + run: test "$OUTCOME" = failure + - uses: $/commit-and-tag + if: github.event_name == 'pull_request' + with: + version: ${{ steps.prepare.outputs.version }} + token: ${{ github.token }} + app-slug: github-actions + message: release ${{ steps.prepare.outputs.version }} + - name: Check tagged source + env: + TAG: ${{ steps.prepare.outputs.version || github.ref_name }} + run: | + git tag --list | release-creator tags "$TAG" + prek run --all-files + - name: Fetch contributor metadata + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + previous=$(git tag --list | release-creator tags "$TAG" --previous) + 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" + - name: Prepare generated notes fixture + if: github.event_name == 'pull_request' + env: + VERSION: ${{ steps.prepare.outputs.version }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + test -n "$(git ls-remote origin "refs/tags/$VERSION")" + printf '* Validate release pipeline by @%s in https://github.com/%s/pull/%s\n' \ + "$PR_AUTHOR" "$GITHUB_REPOSITORY" "$PR_NUMBER" >"$RUNNER_TEMP/generated-notes.md" + - uses: $/render-release-notes + with: + tag: ${{ steps.prepare.outputs.version || github.ref_name }} + changelog: CHANGELOG.md + repository: ${{ github.repository }} + generated-notes: ${{ runner.temp }}/generated-notes.md + output: ${{ runner.temp }}/release-notes.md + - name: Inspect tagged notes + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + run: | + set -euo pipefail + test -s "$RUNNER_TEMP/release-notes.md" + grep -Fq "[#${PR_NUMBER}](https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER})" \ + "$RUNNER_TEMP/release-notes.md" + if [[ "$PR_AUTHOR_TYPE" == Bot ]]; then + if grep -Fq "by @${PR_AUTHOR}" "$RUNNER_TEMP/release-notes.md"; then + exit 1 + fi + else + grep -Fq "by @${PR_AUTHOR}" "$RUNNER_TEMP/release-notes.md" + fi + - 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 diff --git a/README.md b/README.md index 021ef95..40ae285 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ The tag starts the caller's publisher. Keep PyPI trusted publishing in that top- Run `prek` on the tagged source before publication. Repositories with the standard `uv` wheel and sdist build can use `build-python-distribution` in both the tag publisher and the PR rehearsal. The [self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem -uploads. +uploads. The [read-only validation workflow](.github/workflows/validate-release.yaml) is called from both this +repository's PR check and tag release. It builds a local candidate for PRs, checks the tagged source, and renders the +Release body. The tag caller alone has a Contents write job that creates the GitHub Release. ## Prepare Towncrier release From eebfb35f18a56bcb11ba6fe9fbfb478589253451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 19 Oct 2026 22:22:02 -0700 Subject: [PATCH 007/167] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20scope=20relea?= =?UTF-8?q?se=20metadata=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yaml | 35 ++++++++++++++++++++++--- .github/workflows/validate-release.yaml | 18 +++---------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3f24588..cd77e31 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,10 +17,39 @@ jobs: permissions: contents: write steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - name: release-dry-run - path: ${{ runner.temp }} + 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" + - name: Fetch contributor metadata + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + previous=$(git tag --list | release-creator tags "$TAG" --previous) + 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" + - uses: $/render-release-notes + with: + tag: ${{ github.ref_name }} + changelog: CHANGELOG.md + repository: ${{ github.repository }} + generated-notes: ${{ runner.temp }}/generated-notes.md + output: ${{ runner.temp }}/release-notes.md - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/validate-release.yaml b/.github/workflows/validate-release.yaml index f545e92..8312558 100644 --- a/.github/workflows/validate-release.yaml +++ b/.github/workflows/validate-release.yaml @@ -159,22 +159,9 @@ jobs: run: | git tag --list | release-creator tags "$TAG" prek run --all-files - - name: Fetch contributor metadata - if: github.event_name == 'push' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - previous=$(git tag --list | release-creator tags "$TAG" --previous) - 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" - name: Prepare generated notes fixture if: github.event_name == 'pull_request' + id: metadata env: VERSION: ${{ steps.prepare.outputs.version }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -183,12 +170,13 @@ jobs: test -n "$(git ls-remote origin "refs/tags/$VERSION")" printf '* Validate release pipeline by @%s in https://github.com/%s/pull/%s\n' \ "$PR_AUTHOR" "$GITHUB_REPOSITORY" "$PR_NUMBER" >"$RUNNER_TEMP/generated-notes.md" + echo "path=$RUNNER_TEMP/generated-notes.md" >>"$GITHUB_OUTPUT" - uses: $/render-release-notes with: tag: ${{ steps.prepare.outputs.version || github.ref_name }} changelog: CHANGELOG.md repository: ${{ github.repository }} - generated-notes: ${{ runner.temp }}/generated-notes.md + generated-notes: ${{ steps.metadata.outputs.path }} output: ${{ runner.temp }}/release-notes.md - name: Inspect tagged notes if: github.event_name == 'pull_request' From 0bd7b059320495167c7536864f9e2b7b7945f78d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 19 Oct 2026 22:26:14 -0700 Subject: [PATCH 008/167] =?UTF-8?q?=E2=9C=A8=20feat(ci):=20share=20Python?= =?UTF-8?q?=20release=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/validate-python-release.yaml | 185 ++++++++++++++++++ README.md | 53 +++++ changelog.d/+python-publisher.feature.md | 2 + 3 files changed, 240 insertions(+) create mode 100644 .github/workflows/validate-python-release.yaml create mode 100644 changelog.d/+python-publisher.feature.md diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml new file mode 100644 index 0000000..a689121 --- /dev/null +++ b/.github/workflows/validate-python-release.yaml @@ -0,0 +1,185 @@ +name: Validate Python release +on: + workflow_call: + inputs: + changelog: + description: Tracked changelog path + required: true + type: string + fragments: + description: Towncrier fragment directory + required: true + type: string + format: + description: Changelog format passed to prepare-towncrier + 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 + issue-role: + description: Target for reStructuredText issue roles + default: issue + type: string + lock: + description: Release-tool pylock.toml path + default: pylock.toml + type: string + python-version: + description: Python version for release tooling and package build + default: "3.15" + type: string + source: + description: Package source directory + default: . + type: string +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-26.04 + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + python-version: ${{ inputs.python-version }} + enable-cache: false + - name: Install locked release tools + env: + LOCK: ${{ inputs.lock }} + PYTHON_VERSION: ${{ inputs.python-version }} + run: | + set -euo pipefail + uv venv --python "$PYTHON_VERSION" "$RUNNER_TEMP/release-tools" + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/release-tools/bin/python" --group release "$LOCK" + echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH" + - name: Create a local release candidate + if: github.event_name == 'pull_request' + 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 }} + FRAGMENTS: ${{ inputs.fragments }} + 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' >"$FRAGMENTS/+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/rehearsal.git" + git remote add rehearsal "$RUNNER_TEMP/rehearsal.git" + git push rehearsal HEAD:main + echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" + - id: prepare + if: github.event_name == 'pull_request' + uses: tox-dev/github-workflows/prepare-towncrier@eebfb35f18a56bcb11ba6fe9fbfb478589253451 + with: + changelog: ${{ inputs.changelog }} + fragments: ${{ inputs.fragments }} + format: ${{ inputs.format }} + patch: ${{ runner.temp }}/release.patch + - name: Restore release candidate + if: github.event_name == 'pull_request' + run: git reset --hard HEAD + - if: github.event_name == 'pull_request' + uses: tox-dev/github-workflows/validate-release-patch@eebfb35f18a56bcb11ba6fe9fbfb478589253451 + with: + version: ${{ steps.prepare.outputs.version }} + patch: ${{ runner.temp }}/release.patch + changelog: ${{ inputs.changelog }} + fragments: ${{ inputs.fragments }} + remote: rehearsal + expected-sha: ${{ steps.fixture.outputs.sha }} + - name: Commit and tag the local candidate + if: github.event_name == 'pull_request' + env: + VERSION: ${{ steps.prepare.outputs.version }} + run: | + git -c user.name=release-validation -c user.email=release-validation@users.noreply.github.com commit -m "release $VERSION" + git tag "$VERSION" + - uses: tox-dev/github-workflows/build-python-distribution@eebfb35f18a56bcb11ba6fe9fbfb478589253451 + with: + lock: ${{ inputs.lock }} + python-version: ${{ inputs.python-version }} + source: ${{ inputs.source }} + - name: Prepare contributor fixture + if: github.event_name == 'pull_request' + id: metadata + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + printf '* Validate release pipeline by @%s in https://github.com/%s/pull/%s\n' \ + "$PR_AUTHOR" "$GITHUB_REPOSITORY" "$PR_NUMBER" >"$RUNNER_TEMP/generated-notes.md" + echo "path=$RUNNER_TEMP/generated-notes.md" >>"$GITHUB_OUTPUT" + - uses: tox-dev/github-workflows/render-release-notes@eebfb35f18a56bcb11ba6fe9fbfb478589253451 + with: + tag: ${{ steps.prepare.outputs.version || github.ref_name }} + changelog: ${{ inputs.changelog }} + repository: ${{ github.repository }} + artifact-url: ${{ inputs.artifact-base-url }}${{ steps.prepare.outputs.version || github.ref_name }}/ + artifact-label: ${{ inputs.artifact-label }} + issue-role: ${{ inputs.issue-role }} + generated-notes: ${{ steps.metadata.outputs.path }} + output: ${{ runner.temp }}/release-notes.md + - name: Inspect candidate artifacts + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + CHANGELOG: ${{ inputs.changelog }} + run: | + set -euo pipefail + test "$(grep -c '^\*\*Full Changelog\*\*:' "$RUNNER_TEMP/release-notes.md")" -eq 1 + grep -Fq "[#${PR_NUMBER}](https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER})" "$RUNNER_TEMP/release-notes.md" + if [[ "$PR_AUTHOR_TYPE" == Bot ]]; then + if grep -Fq "by @${PR_AUTHOR}" "$RUNNER_TEMP/release-notes.md"; then + echo '::error::bot credit in release notes' + exit 1 + fi + else + grep -Fq "by @${PR_AUTHOR}" "$RUNNER_TEMP/release-notes.md" + fi + if grep -Fq '\n\n" - '\n\n## 0.2.0 (2026-10-19)\n\n### Added\n\n- Add a feature.\n', - ) - run(repository, "git", "add", "CHANGELOG.md") - run(repository, "git", "commit", "-m", "Record existing notes") - 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']}", + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", str(action_scripts / "prepare-towncrier-8.sh")], + cwd=repository, + env=os.environ | {"PATCH": str(tmp_path / "release.patch")}, + text=True, + capture_output=True, + check=False, ) assert result.returncode != 0 assert "produced no patch" in result.stderr @@ -407,7 +397,7 @@ def test_prepare_avoids_rst_conflict_marker(repository: Path, tmp_path: Path, ac assert "Changes\n=======" in (repository / "CHANGELOG.rst").read_text() -@pytest.mark.parametrize("script", ["prepare-towncrier-8.sh", "validate-release-patch-5.sh"]) +@pytest.mark.parametrize("script", ["prepare-towncrier-7.sh", "validate-release-patch-5.sh"]) def test_prepare_rejects_rst_conflict_marker(repository: Path, action_scripts: Path, script: str) -> None: (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") run(repository, "git", "add", "CHANGELOG.rst") diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index cb42103..4b43e2f 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -31,33 +31,28 @@ def test_published_release_is_verified_without_writes( (assets / "fixture.zip").write_bytes(b"release archive") commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( + server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( {"object": {"type": "commit", "sha": commit}} ) - for _ in range(3): - server.expect_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [ - { - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": False, - "prerelease": False, - "assets": ( - [ - { - "name": "fixture.zip", - "digest": f"sha256:{sha256(b'release archive').hexdigest()}", - "state": "uploaded", - } - ] - if with_asset - else [] - ), - "target_commitish": "main", - }, - ], - ) + release = { + "id": 42, + "tag_name": "0.1.0", + "name": "0.1.0", + "body": notes.read_text(), + "draft": False, + "prerelease": False, + "assets": ( + [{"name": "fixture.zip", "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"}] + if with_asset + else [] + ), + "target_commitish": "main", + } + 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 or empty_directory)): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) result = action( repository, tmp_path, @@ -74,7 +69,11 @@ def test_published_release_is_verified_without_writes( **environment, ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET"] * (5 if with_asset or empty_directory else 4) + assert [request.path for request, _ in server.log] == [ + f"{prefix}/git/ref/tags/0.1.0", + f"{prefix}/releases", + *[f"{prefix}/releases/42"] * (2 + int(with_asset or empty_directory)), + ] @pytest.mark.parametrize( @@ -102,6 +101,7 @@ def test_existing_release_rejects_changed_content( commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() prefix = "/api/v3/repos/tox-dev/fixture" release = { + "id": 42, "tag_name": "0.1.0", "name": "0.1.0", "body": notes.read_text(), @@ -177,25 +177,26 @@ def test_annotated_tag_points_to_expected_commit( notes.write_text("Release notes.\n") commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( + server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( {"object": {"type": "tag", "sha": "2" * 40}}, ) - server.expect_request(f"{prefix}/git/tags/{'2' * 40}", method="GET").respond_with_json( + server.expect_ordered_request(f"{prefix}/git/tags/{'2' * 40}", method="GET").respond_with_json( {"object": {"type": "commit", "sha": commit}}, ) - for _ in range(3): - server.expect_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [ - { - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": False, - "prerelease": False, - "assets": [], - } - ], - ) + release = { + "id": 42, + "tag_name": "0.1.0", + "name": "0.1.0", + "body": notes.read_text(), + "draft": False, + "prerelease": False, + "assets": [], + } + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [release], + ) + for _ in range(2): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) result = action( repository, tmp_path, @@ -215,7 +216,8 @@ def test_annotated_tag_points_to_expected_commit( assert [request.path for request, _ in server.log] == [ f"{prefix}/git/ref/tags/0.1.0", f"{prefix}/git/tags/{'2' * 40}", - *[f"{prefix}/releases"] * 3, + f"{prefix}/releases", + *[f"{prefix}/releases/42"] * 2, ] @@ -351,10 +353,11 @@ def test_publish_rejects_incomplete_draft_without_upload( server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( {"object": {"type": "commit", "sha": commit}} ) - for _ in range(7): - 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", query_string="per_page=100", method="GET").respond_with_json( + [draft], + ) + for _ in range(6): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) result = action( repository, tmp_path, @@ -433,15 +436,13 @@ def test_matching_draft_is_published_without_recreation( 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", 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) if mode != "stage": server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( draft | {"draft": False} ) - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [draft | {"draft": mode == "stage"}], + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( + draft | {"draft": mode == "stage"}, ) result = action( repository, @@ -470,13 +471,12 @@ def test_matching_draft_is_published_without_recreation( @pytest.mark.parametrize( - ("is_prerelease", "mode", "delayed"), + ("is_prerelease", "mode"), [ - pytest.param(False, "stage", False, id="stable-stage"), - pytest.param(True, "stage", False, id="prerelease-stage"), - pytest.param(False, "complete", False, id="stable-complete"), - pytest.param(True, "complete", False, id="prerelease-complete"), - pytest.param(False, "complete", True, id="delayed-release-list"), + pytest.param(False, "stage", id="stable-stage"), + pytest.param(True, "stage", id="prerelease-stage"), + pytest.param(False, "complete", id="stable-complete"), + pytest.param(True, "complete", id="prerelease-complete"), ], ) def test_missing_release_is_created_and_published( @@ -486,7 +486,6 @@ def test_missing_release_is_created_and_published( github_api: tuple[HTTPServer, dict[str, str]], is_prerelease: bool, mode: str, - delayed: bool, ) -> None: server, environment = github_api notes = tmp_path / "notes.md" @@ -506,27 +505,14 @@ def test_missing_release_is_created_and_published( {"object": {"type": "commit", "sha": commit}} ) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) - server.expect_ordered_request("/api/graphql", method="POST").respond_with_json( - {"data": {"repository": {"ref": {"id": "REF_test"}}}}, - ) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(release, status=201) - if delayed: - server.expect_ordered_request( - f"{prefix}/releases", query_string="per_page=100", method="GET" - ).respond_with_json([]) - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release], - ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) if mode == "complete": server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( release | {"draft": False}, ) - if delayed: - server.expect_ordered_request( - f"{prefix}/releases", query_string="per_page=100", method="GET" - ).respond_with_json([release]) - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release | {"draft": mode == "stage"}], + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( + release | {"draft": mode == "stage"}, ) result = action( repository, @@ -550,13 +536,21 @@ def test_missing_release_is_created_and_published( "GET", "GET", "POST", - "POST", - *(["GET"] if delayed else []), "GET", *([] if mode == "stage" else ["PATCH"]), - *(["GET"] if delayed else []), "GET", ] + request = next( + request for request, _ in server.log if request.path == f"{prefix}/releases" and 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": is_prerelease, + } @pytest.mark.parametrize("mode", ["stage", "complete"]) @@ -595,24 +589,22 @@ def test_draft_release_resumes_missing_asset( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [release], ) - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release], - ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) server.expect_ordered_request( f"{prefix}/releases/42/assets", query_string=f"name={asset_name.replace('+', '%2B').replace(' ', '%20')}", method="POST", data=b"release archive", ).respond_with_json(asset, status=201) - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release | {"assets": [asset]}], + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( + release | {"assets": [asset]}, ) if mode == "complete": server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( release | {"draft": False}, ) - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release | {"draft": mode == "stage", "assets": [asset]}], + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( + release | {"draft": mode == "stage", "assets": [asset]}, ) result = action( repository, @@ -669,12 +661,10 @@ def test_failed_asset_upload_leaves_draft_unpublished( server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( {"object": {"type": "commit", "sha": commit}} ) - for _ in range(2): - 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", 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/assets", query_string="name=fixture.zip", diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index bec1b06..7901e46 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -117,10 +117,17 @@ def test_multipart_upload_uses_exact_archive_and_channel( def test_missing_token_fails_without_request( signed_archive: Path, tmp_path: Path, action_scripts: Path, httpserver: HTTPServer ) -> None: + script = tmp_path / "upload.sh" + script.write_text( + (action_scripts / "publish-jetbrains-marketplace-1.sh") + .read_text() + .replace("https://plugins.jetbrains.com/api/updates/upload", httpserver.url_for("/api/updates/upload")) + ) result = execute( - action_scripts / "publish-jetbrains-marketplace-1.sh", + script, tmp_path, ARCHIVE=str(signed_archive), + EXPECTED_SHA=sha256(signed_archive.read_bytes()).hexdigest(), PLUGIN_ID="29973", CHANNEL="", PUBLISH_TOKEN="", diff --git a/tests/publish_post_release_pr/test_post_release_pr.py b/tests/publish_post_release_pr/test_post_release_pr.py index bd50080..a26f2d9 100644 --- a/tests/publish_post_release_pr/test_post_release_pr.py +++ b/tests/publish_post_release_pr/test_post_release_pr.py @@ -223,7 +223,7 @@ def test_closed_unmerged_pr_blocks_retry_before_write( "BODY_FILE": str(body), "PATHS_FILE": str(paths), "ALLOWED_PATHS": "gradle.properties", - "BUNDLE": str(bundle), + "BUNDLE": str(bundle.relative_to(tmp_path)), "CANDIDATE_COMMIT": commit, "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), @@ -343,7 +343,7 @@ def test_open_branch_must_match_prepared_candidate( "BODY_FILE": str(body), "PATHS_FILE": str(paths), "ALLOWED_PATHS": "gradle.properties", - "BUNDLE": str(bundle), + "BUNDLE": str(bundle.relative_to(tmp_path)), "CANDIDATE_COMMIT": commit, "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py index 005826a..d78817d 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -29,6 +29,7 @@ def test_recheck_schedule_has_only_required_permissions() -> None: pytest.param(8, 8, "failure", "Hook update cooldown", 1, id="named-job"), pytest.param(6, 6, "failure", "hook-cooldown", 1, 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"), ], ) @@ -77,19 +78,20 @@ def test_recheck_reruns_failed_job( ] } ) - 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 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/rerun", method="POST").respond_with_data( status=201 diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 33c3668..1def777 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -1,5 +1,8 @@ from __future__ import annotations +import os +import shutil +import subprocess from pathlib import Path import yaml @@ -9,7 +12,7 @@ def test_release_writer_has_only_notes_and_reconciler() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) publish = workflow["jobs"]["publish"] assert publish["permissions"] == {"contents": "write"} - assert publish["needs"] == ["source", "notes"] + assert publish["needs"] == ["check", "source", "notes"] assert [step["uses"] for step in publish["steps"]] == [ "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333", "$/publish-github-release", @@ -46,3 +49,29 @@ def test_release_contributors_job_uploads_generated_notes() -> None: 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 diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 63b0276..fd9a4ef 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -353,14 +353,21 @@ def test_upgrade_checks_refreshed_lock( PYTHON_VERSION="3.15", ) assert exporter.returncode == 0, exporter.stderr - installed = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-6.sh", - tmp_path, - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - ) - assert installed.returncode == 0, installed.stderr + source = Path(__file__).resolve().parents[2] + refresh = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text()) + assert refresh["runs"]["steps"][6]["uses"] == "$/install-locked-tools" + for index in range(3): + installed = execute( + lock_repository, + upgrade_scripts / f"install-locked-tools-{index}.sh", + tmp_path, + GITHUB_ACTION_PATH=str(source / "install-locked-tools"), + LOCK="pylock.toml", + DEPENDENCY_GROUPS="release", + VENV=str(tmp_path / "release-tools"), + PYTHON_VERSION="3.15", + ) + assert installed.returncode == 0, installed.stderr checked = execute(lock_repository, upgrade_scripts / "refresh-release-lock-7.sh", tmp_path) assert checked.returncode == 0, checked.stderr result = execute( diff --git a/tox.toml b/tox.toml index 1343251..661593f 100644 --- a/tox.toml +++ b/tox.toml @@ -8,9 +8,7 @@ skip_install = true allowlist_externals = ["uv"] commands_pre = [ [ - "uv", "pip", "sync", "--preview-features", "pylock", "--preview-features", "build-dependency-hashes", - "--python", "{envpython}", "--group", "test", "--build-constraints", "release-build-constraints.txt", - "--require-build-hashes", "pylock.toml", + "uv", "pip", "install", "--python", "{envpython}", "--group", "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 index ec5d4a1..e528f41 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -29,7 +29,7 @@ runs: 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 + 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 From c709f158454751c4cc5ae2497fa8988f6e9a725f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:18:28 -0700 Subject: [PATCH 122/167] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20use=20uv-mana?= =?UTF-8?q?ged=20Python=20on=20runners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-uv installs the requested interpreter without adding python3.15 to PATH. Run embedded Python through uv so composite Actions work on GitHub runners and on Windows. --- install-locked-tools/action.yml | 9 +++++---- refresh-hook-pins/action.yml | 2 +- refresh-release-lock/action.yml | 5 +++-- tests/install_locked_tools/test_install.py | 21 +++++++++++++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index e23ceec..30886c6 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -21,7 +21,9 @@ runs: run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - python3.15 - "$GITHUB_ACTION_PATH/../pylock.toml" "$RUNNER_TEMP/lock-exporter-requirements.txt" <<'PY' + uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-exporter" + uv run --no-project --python "$RUNNER_TEMP/lock-exporter" python - "$GITHUB_ACTION_PATH/../pylock.toml" \ + "$RUNNER_TEMP/lock-exporter-requirements.txt" <<'PY' import sys import tomllib @@ -40,7 +42,6 @@ runs: with open(sys.argv[2], "w", encoding="utf-8") as output: output.writelines(requirements) PY - uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-exporter" uv pip sync --python "$RUNNER_TEMP/lock-exporter" --only-binary :all: --require-hashes \ "$RUNNER_TEMP/lock-exporter-requirements.txt" - name: Derive caller backend constraints @@ -50,7 +51,7 @@ runs: PYTHON_VERSION: ${{ inputs.python-version }} run: | set -euo pipefail - if python3.15 - "$LOCK" <<'PY' + if uv run --no-project --python "$RUNNER_TEMP/lock-exporter" python - "$LOCK" <<'PY' import sys import tomllib @@ -86,7 +87,7 @@ runs: done done <<<"$DEPENDENCY_GROUPS" test "${#groups[@]}" -gt 0 - python3.15 - "$LOCK" "${selected_groups[@]}" <<'PY' + uv run --no-project --python "$VENV" python - "$LOCK" "${selected_groups[@]}" <<'PY' import sys import tomllib diff --git a/refresh-hook-pins/action.yml b/refresh-hook-pins/action.yml index 80f45a5..8b0cfb5 100644 --- a/refresh-hook-pins/action.yml +++ b/refresh-hook-pins/action.yml @@ -30,7 +30,7 @@ runs: shell: bash run: | timestamp=$( - python3.15 - <<'PY' + uv run --no-project --python 3.15 python - <<'PY' from datetime import UTC, datetime, timedelta print((datetime.now(UTC) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")) diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 015060d..69dc628 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -49,7 +49,7 @@ runs: shell: bash run: | timestamp=$( - python3.15 - <<'PY' + uv run --no-project --python 3.15 python - <<'PY' from datetime import UTC, datetime, timedelta print((datetime.now(UTC) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")) @@ -85,7 +85,8 @@ runs: exit 1 ;; esac - if [[ "$timestamp" > "$now" ]] || ! python3.15 - "$timestamp" <<'PY' + if [[ "$timestamp" > "$now" ]] || ! uv run --no-project --python 3.15 \ + python - "$timestamp" <<'PY' import datetime import sys diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index ecb32dc..0ba5d86 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -152,6 +152,27 @@ def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Pa 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", "uv"): + executable = shutil.which(name) + assert executable is not None + (tools / name).symlink_to(executable) + result = install( + source, + action_scripts, + tmp_path, + lock=source / "pylock.toml", + 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( From 0edeb023517eba1b4cc9b3c1c9d7847958f78e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:21:51 -0700 Subject: [PATCH 123/167] =?UTF-8?q?=F0=9F=90=9B=20fix(test):=20include=20T?= =?UTF-8?q?owncrier=20in=20job=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Action tests run the release preparation command. The test job needs the locked Towncrier binary alongside release-creator, prek, and yq; the fresh test dependencies remain separate. --- pylock.toml | 8 ++++---- pyproject.toml | 2 +- tests/install_locked_tools/test_install.py | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pylock.toml b/pylock.toml index 4d3997e..0516ee2 100644 --- a/pylock.toml +++ b/pylock.toml @@ -271,7 +271,7 @@ sha256 = "d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692" name = "click" version = "8.5.0" index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" [packages.sdist] url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" @@ -876,7 +876,7 @@ sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" name = "jinja2" version = "3.1.6" index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" [packages.sdist] url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" @@ -920,7 +920,7 @@ sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" name = "markupsafe" version = "3.0.4" index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" [packages.sdist] url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" @@ -1709,7 +1709,7 @@ sha256 = "177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304" name = "towncrier" version = "26.9.0" index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" [packages.sdist] url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" diff --git a/pyproject.toml b/pyproject.toml index a80397a..f52fb41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ release-prepare = [ "release-creator>=0.5.1", "towncrier>=26.9", "prek>=0.2" ] release-validate = [ "release-creator>=0.5.1", "prek>=0.2" ] release-check = [ "prek>=0.2" ] release-shell = [ "prek>=0.2", "yq>=4.4.5" ] -release-hooks = [ "release-creator>=0.5.1", "prek>=0.2", "yq>=4.4.5" ] +release-hooks = [ "release-creator>=0.5.1", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] test = [ "pytest>=9", "pytest-httpserver", diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 0ba5d86..480bfae 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -46,6 +46,7 @@ def test_install_locked_tools_installs_groups(tmp_path: Path, action_scripts: Pa pytest.param("release-news", "towncrier", "prek", id="pr-news"), pytest.param("release-validate", "release-creator", "towncrier", id="patch-validation"), pytest.param("release-shell", "yq", "release-creator", id="inline-shell"), + pytest.param("release-hooks", "towncrier", "twine", id="action-tests"), ], ) def test_install_locked_tools_selects_job_tools( From bab4598d0294a77c469ecdda13b4d9c88cf812f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:24:48 -0700 Subject: [PATCH 124/167] =?UTF-8?q?=F0=9F=A7=AA=20test(release):=20isolate?= =?UTF-8?q?=20PR=20author=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fragment-link tests use the local GitHub API fixture. They now exercise author credits without relying on a maintainer login or a CI token. --- tests/prepare_towncrier/test_prepare.py | 34 +++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index c5fbc56..198c71b 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -9,6 +9,7 @@ import pytest import yaml from conftest import action, run +from pytest_httpserver import HTTPServer def test_prepare_builds_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -48,7 +49,9 @@ def test_prepare_custom_changelog_and_patch(repository: Path, tmp_path: Path, ac assert "HISTORY.md" in 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() @@ -65,6 +68,7 @@ def test_prepare_custom_fragment_directory(repository: Path, tmp_path: Path, act FRAGMENTS="docs/changelog.d", LINK_FRAGMENTS="true", 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() @@ -75,7 +79,9 @@ def test_prepare_custom_fragment_directory(repository: Path, tmp_path: Path, act 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: +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, @@ -84,6 +90,7 @@ def test_prepare_renders_github_notes(repository: Path, tmp_path: Path, action_s version="0.2.0", LINK_FRAGMENTS="true", GITHUB_REPOSITORY="tox-dev/github-workflows", + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr result = run( @@ -195,7 +202,9 @@ 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, @@ -204,6 +213,7 @@ def test_prepare_links_named_fragments(repository: Path, tmp_path: Path, action_ version="0.2.0", LINK_FRAGMENTS="true", GITHUB_REPOSITORY="tox-dev/github-workflows", + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr assert ( @@ -212,7 +222,9 @@ def test_prepare_links_named_fragments(repository: Path, tmp_path: Path, action_ ) in (repository / "CHANGELOG.md").read_text() -def test_prepare_links_numbered_pull_requests(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +def test_prepare_links_numbered_pull_requests( + 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") @@ -225,12 +237,15 @@ def test_prepare_links_numbered_pull_requests(repository: Path, tmp_path: Path, LINK_FRAGMENTS="true", NUMBERED_PRS="true", 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_keeps_default_numbered_issue(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +def test_prepare_keeps_default_numbered_issue( + 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") @@ -242,6 +257,7 @@ def test_prepare_keeps_default_numbered_issue(repository: Path, tmp_path: Path, 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 "[#3](https://github.com/tox-dev/github-workflows/pull/3)" in (repository / "CHANGELOG.md").read_text() @@ -414,3 +430,11 @@ def test_prepare_rejects_rst_conflict_marker(repository: Path, action_scripts: P ) assert result.returncode != 0 assert "conflict markers" in result.stderr + + +def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) -> dict[str, str]: + server, environment = github_api + server.expect_request(f"/api/v3/repos/tox-dev/github-workflows/pulls/{number}", method="GET").respond_with_json( + {"user": {"login": "gaborbernat", "type": "User"}}, + ) + return environment From 198d058942efeaaf86413eef74c9d4234d1a6d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:32:06 -0700 Subject: [PATCH 125/167] =?UTF-8?q?=E2=9A=A1=20perf(release):=20narrow=20P?= =?UTF-8?q?yPI=20tool=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++++++---- reconcile-pypi-artifacts/action.yml | 7 +++++-- tests/reconcile_pypi_artifacts/test_reconcile.py | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e8f8432..27424df 100644 --- a/README.md +++ b/README.md @@ -616,15 +616,17 @@ or extra files before GitHub publication. dist: dist mode: stage missing-dir: missing + tool-group: release-notes ``` `project`, `version`, `dist`, and `mode` are required. `mode` is `stage` or `verify`. `missing-dir` defaults to `missing` and must be a fresh directory outside `dist`. `wait-seconds` defaults to 120 for verify. `lock` defaults to -`pylock.toml`, whose `release` group supplies the command; `python-version` defaults to 3.15. Set +`pylock.toml`; `tool-group` selects the group containing `release-creator` and defaults to `release`. Use a narrower +group such as `release-notes` when the lock provides one. `python-version` defaults to 3.15. Set `local-tool-source: "true"` only when releasing release-creator itself: it installs the tagged checkout without -resolving dependencies after installing its locked release, build, and runtime groups. This Action writes only the -missing directory, manifest, and `missing-count`; it never uploads to PyPI. Use `mode: verify` after the OIDC job, -including when `missing-count` was zero. +resolving dependencies after installing its locked tool, build, and runtime groups. This Action writes only the missing +directory, manifest, and `missing-count`; it never uploads to PyPI. Use `mode: verify` after the OIDC job, including +when `missing-count` was zero. `select-pypi-artifacts` takes `dist`, `manifest`, `output-dir`, and `expected-count` from stage's `missing-count`. The output directory must be fresh and outside `dist`. It rejects unknown, duplicate, unsafe, or symlinked filenames and diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index e28a0c1..a78c606 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -20,8 +20,11 @@ inputs: description: Maximum PyPI JSON propagation wait in verify mode default: "120" lock: - description: Full project pylock containing the release group + description: Full project pylock containing release-creator default: pylock.toml + tool-group: + description: Dependency group containing release-creator + default: release python-version: description: Python version used for the locked release tool default: "3.15" @@ -59,7 +62,7 @@ runs: uses: $/install-locked-tools with: lock: ${{ inputs.lock }} - groups: ${{ inputs.local-tool-source == 'true' && 'release build project-runtime' || 'release' }} + groups: ${{ inputs.tool-group }}${{ inputs.local-tool-source == 'true' && ' build project-runtime' || '' }} python-version: ${{ inputs.python-version }} venv: ${{ runner.temp }}/pypi-reconcile - name: Install tagged release tool diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py index c83dc6d..bbae03e 100644 --- a/tests/reconcile_pypi_artifacts/test_reconcile.py +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -134,7 +134,7 @@ def install_reconcile(action_scripts: Path, repository: Path, tmp_path: Path, *, repository, tmp_path, tmp_path / "pypi-reconcile", - "release build project-runtime" if local_source else "release", + "release build project-runtime" if local_source else "release-notes", ) if local_source: result = execute( From 85dfe42af21a5e9170767e31a6c92855172904ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:32:12 -0700 Subject: [PATCH 126/167] =?UTF-8?q?=F0=9F=91=B7=20ci:=20cancel=20supersede?= =?UTF-8?q?d=20maintenance=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/label-bot-news.yaml | 3 +++ .github/workflows/recheck-hook-cooldown.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/label-bot-news.yaml b/.github/workflows/label-bot-news.yaml index d03b0e0..e7f9521 100644 --- a/.github/workflows/label-bot-news.yaml +++ b/.github/workflows/label-bot-news.yaml @@ -4,6 +4,9 @@ on: 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' diff --git a/.github/workflows/recheck-hook-cooldown.yaml b/.github/workflows/recheck-hook-cooldown.yaml index 328c4de..4686f4a 100644 --- a/.github/workflows/recheck-hook-cooldown.yaml +++ b/.github/workflows/recheck-hook-cooldown.yaml @@ -4,6 +4,9 @@ on: - cron: "17 7 * * *" workflow_dispatch: permissions: {} +concurrency: + group: recheck-hook-cooldown-${{ github.ref }} + cancel-in-progress: true jobs: recheck: runs-on: ubuntu-26.04 From 27485a7fde6b71674c18565d9c68d07786eb9363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:38:25 -0700 Subject: [PATCH 127/167] =?UTF-8?q?=F0=9F=93=9D=20docs:=20shorten=20PyPI?= =?UTF-8?q?=20workflow=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 27424df..d1fc950 100644 --- a/README.md +++ b/README.md @@ -298,13 +298,12 @@ prepare-towncrier ── version + patch ──► upload artifact ``` 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` in a separate read-only job before building the tagged source. 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. +`id-token: write` because +[PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Run `prek` in a separate +read-only job before building the tagged source. 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 From 6000bc0c8388be86c1f37df8a292d1b235605388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:38:27 -0700 Subject: [PATCH 128/167] =?UTF-8?q?=F0=9F=A7=AA=20test(release):=20exercis?= =?UTF-8?q?e=20PyPI=20group=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 9 +++-- .../test_reconcile.py | 34 +++++++++---------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1417e72..8cada7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -160,12 +160,17 @@ def action( command = list(_GITHUB_BASH) for index, step in enumerate(steps): if step.get("uses") == "$/install-locked-tools": - groups = environment.get("VALIDATION_GROUP", "release") + group_input = { + "build-python-distribution": "PLANNER_GROUP", + "reconcile-pypi-artifacts": "TOOL_GROUP", + "validate-python-distribution": "VALIDATION_GROUP", + }[name] + groups = environment.get(group_input, "pkg-meta" if name == "validate-python-distribution" else "release") if ( name in {"build-python-distribution", "reconcile-pypi-artifacts"} and environment.get("LOCAL_TOOL_SOURCE") == "true" ): - groups = "release build project-runtime" + groups = f"{groups} build project-runtime" destination = { "build-python-distribution": "build-planner", "reconcile-pypi-artifacts": "pypi-reconcile", diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py index bbae03e..9e9f3a8 100644 --- a/tests/reconcile_pypi_artifacts/test_reconcile.py +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -10,7 +10,7 @@ import pytest import yaml -from conftest import run +from conftest import action, run def execute(script: Path, directory: Path, **settings: str) -> subprocess.CompletedProcess[str]: @@ -36,24 +36,22 @@ def distribution(tmp_path: Path) -> Path: def test_stage_selects_missing_artifact(distribution: Path, tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] - environment = { - "PROJECT": "codex-fixture-never-publish", - "VERSION": "999999", - "DIST": str(distribution), - "MODE": "stage", - "LOCK": str(source / "pylock.toml"), - "PYTHON_VERSION": "3.15", - "LOCAL_TOOL_SOURCE": "false", - "RUNNER_TEMP": str(tmp_path), - "MISSING_DIR": str(tmp_path / "missing"), - "GITHUB_OUTPUT": str(tmp_path / "output"), - } - result = execute(action_scripts / "reconcile-pypi-artifacts-0.sh", source, **environment) + result = action( + source, + tmp_path, + action_scripts, + "reconcile-pypi-artifacts", + version="999999", + PROJECT="codex-fixture-never-publish", + DIST=str(distribution), + MODE="stage", + LOCK=str(source / "pylock.toml"), + PYTHON_VERSION="3.15", + LOCAL_TOOL_SOURCE="false", + TOOL_GROUP="release-notes", + MISSING_DIR=str(tmp_path / "missing"), + ) assert result.returncode == 0, result.stderr - install_reconcile(action_scripts, source, tmp_path) - for index in (3, 4): - result = execute(action_scripts / f"reconcile-pypi-artifacts-{index}.sh", source, **environment) - assert result.returncode == 0, result.stderr assert (tmp_path / "output").read_text().splitlines() == [ "missing_count=1", f"path={tmp_path}/pypi-missing-names.txt", From 9c25cb0994573101d60d2d8fd778c06499d04f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:43:06 -0700 Subject: [PATCH 129/167] =?UTF-8?q?=F0=9F=93=9D=20docs:=20describe=20narro?= =?UTF-8?q?w=20updater=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d1fc950..f8cbfd3 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,11 @@ jobs: python-version: '3.15' ``` -The project must declare a `release` group containing `prek` and `release-creator`. The lock also includes every other -group and extra. `lock` defaults to `pylock.toml`; `hook-config` defaults to `.pre-commit-config.yaml`; `base` defaults -to `main`; `branch` defaults to `automation/update-release-tools`. The caller owns the schedule, so repositories can use -different upgrade days without copying the jobs. `commit-message`, `pull-request-title`, and `pull-request-body` let a -caller retain its existing dependency PR wording. +The project must declare a group containing `prek` and pass it as `check-group`; the default is `release` for existing +callers. The lock also includes every other group and extra. `lock` defaults to `pylock.toml`; `hook-config` defaults to +`.pre-commit-config.yaml`; `base` defaults to `main`; `branch` defaults to `automation/update-release-tools`. The caller +owns the schedule, so repositories can use different upgrade days without copying the jobs. `commit-message`, +`pull-request-title`, and `pull-request-body` let a caller retain its existing dependency PR wording. Set `keep-uv-lock: true` only when the repository commits `uv.lock` for its own build. The lock job then refreshes both `uv.lock` and the selectable `pylock.toml` and includes both in one patch. The default rejects `uv.lock` changes. This @@ -163,7 +163,7 @@ PR. This repository passes `release-build-constraints.txt`. Each consumer's inst lock. Source-only packages need their build backend requirements in that lock; an absent backend fails the install. Use `build-requirements` for build dependencies that are not declared by the project's own build system, one requirement -per line. For example, a release group containing source-only PyYAML on Python 3.15 needs Cython and setuptools in the +per line. For example, a job group containing source-only PyYAML on Python 3.15 needs Cython and setuptools in the `build` group. The input affects the caller's lock only; it does not add these packages to other repositories. Set `exclude-newer-packages` to one reviewed `package=UTC cutoff` entry per line when a required version was published @@ -174,10 +174,10 @@ when `keep-uv-lock` is true. The dependency PR lists the reviewed floors. To check the same lock refresh on a PR, call `refresh-release-lock` in a job with Contents read permission. The reusable updater calls this Action in its lock generator job. It excludes dependencies published in the last seven days, resolves -the project through uv, rewrites the local `pylock.toml`, installs its `release` group, and runs `prek`. It rejects -changes to any other file; `uv.lock` is allowed only with `keep-uv-lock: true`, and a generated constraints file only -with `build-constraints`. Its `changed` output is `true` when a selected file differs from the checkout, and it writes -`$RUNNER_TEMP/lock.patch`. The Action does not push a branch, open a PR, or request a write token. +the project through uv, rewrites the local `pylock.toml`, installs its selected `check-group`, and runs `prek`. It +rejects changes to any other file; `uv.lock` is allowed only with `keep-uv-lock: true`, and a generated constraints file +only with `build-constraints`. Its `changed` output is `true` when a selected file differs from the checkout, and it +writes `$RUNNER_TEMP/lock.patch`. The Action does not push a branch, open a PR, or request a write token. ```yaml release-tools-preview: From 4e89972e90bd0ec2504a42dcccc424361c6eaf96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:47:07 -0700 Subject: [PATCH 130/167] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20?= =?UTF-8?q?type=20Action=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 12 +++++------- tests/resolve_pr_from_run/test_resolve.py | 7 +++++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8cada7f..da8586b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -210,7 +210,7 @@ def action( cwd=repository, env=environment | { - variable: _step_value(value, variable, environment, action_config) + variable: _step_value(value, variable, environment, action_config["inputs"]) for variable, value in step.get("env", {}).items() }, text=True, @@ -271,16 +271,14 @@ def _safe_environment() -> dict[str, str]: } -def _step_value(value: str, variable: str, environment: dict[str, str], action_config: dict[str, object]) -> str: +def _step_value( + value: str, variable: str, environment: dict[str, str], action_inputs: dict[str, dict[str, str | bool]] +) -> str: if not value.startswith("${{ ") or not value.endswith(" }}"): return value for expression in value[4:-3].split(" || "): if expression.startswith("inputs."): - defaults = action_config["inputs"] - assert isinstance(defaults, dict) - selected = defaults[expression[7:]] - assert isinstance(selected, dict) - resolved = environment.get(variable, str(selected.get("default", ""))) + resolved = environment.get(variable, str(action_inputs[expression[7:]].get("default", ""))) elif expression == "github.repository": resolved = environment["GITHUB_REPOSITORY"] elif expression == "github.base_ref": diff --git a/tests/resolve_pr_from_run/test_resolve.py b/tests/resolve_pr_from_run/test_resolve.py index 5c8aaa5..1a3a7d5 100644 --- a/tests/resolve_pr_from_run/test_resolve.py +++ b/tests/resolve_pr_from_run/test_resolve.py @@ -153,10 +153,10 @@ def test_resolve_pr_from_run_rejects_untrusted_source( action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], field: str, - value: object, + value: _JsonValue, ) -> None: server, environment = github_api - source: dict[str, object] = { + source: dict[str, _JsonValue] = { "id": 42, "repository": {"full_name": "tox-dev/fixture"}, "event": "pull_request", @@ -183,3 +183,6 @@ def test_resolve_pr_from_run_rejects_non_numeric_id( 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] From 2d37831748e9eebfac4e81250f41deb7df146520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:50:04 -0700 Subject: [PATCH 131/167] =?UTF-8?q?=F0=9F=93=9D=20docs:=20clarify=20releas?= =?UTF-8?q?e=20job=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f8cbfd3..d3f426f 100644 --- a/README.md +++ b/README.md @@ -274,12 +274,12 @@ fragment directory, commit message, and tag style. Replace this repository's rel 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. -All three jobs need a full checkout (`fetch-depth: 0`, `persist-credentials: false`). Only `assemble` and `validate` -install `release-creator`, Towncrier, and `prek` 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`. Restrict `release-auth` to the -release branch. Install the App on that repository only, with Contents write and Metadata read. Give it the necessary -branch and tag ruleset bypass. +All three jobs need a full checkout (`fetch-depth: 0`, `persist-credentials: false`). `assemble` installs +release-creator, Towncrier, and prek; `validate` installs release-creator and prek. Both use 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`. +Restrict `release-auth` to the release branch. Install the App on that repository only, with Contents write and Metadata +read. Give it the necessary branch and tag ruleset bypass. ```text main + fragments @@ -353,11 +353,12 @@ without the App key. The second applies that patch in another keyless checkout, last job checks the digest, 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 that job. -```text +```yaml jobs: prepare: if: github.ref == 'refs/heads/main' - uses: tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA + uses: + tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA permissions: contents: read pull-requests: read @@ -418,8 +419,8 @@ rendering, formatting, or checks fail, or if preparation creates untracked files - `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), `markdown`, or `rst`. `markdown` runs the caller's `mdformat` hook and `rst` runs its - `docstrfmt` hook through `prek` on the assembled changelog. Use `none` when no formatter hook applies. The full `prek` - suite runs for every option. + `docstrfmt` hook through `prek` on the assembled changelog. Use `none` when no formatter hook applies. The reusable + workflow's validation job runs the full `prek` suite for every option. - `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. - `numbered-prs`: `false` (default) leaves Towncrier's numeric fragment behavior intact. Set `true` only when every From fea018eb4e475f8834d4f0b904ef1890f2e2fe35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:51:46 -0700 Subject: [PATCH 132/167] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20?= =?UTF-8?q?keep=20bash=20command=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index da8586b..710c890 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,6 @@ import yaml from pytest_httpserver import HTTPServer -_GITHUB_BASH: Final[tuple[str, ...]] = ("bash", "--noprofile", "--norc", "-e", "-o", "pipefail") - @pytest.fixture def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]: @@ -157,7 +155,8 @@ def action( stdout = [] stderr = [] steps = action_config["runs"]["steps"] - command = list(_GITHUB_BASH) + github_bash: Final[tuple[str, ...]] = ("bash", "--noprofile", "--norc", "-e", "-o", "pipefail") + command = list(github_bash) for index, step in enumerate(steps): if step.get("uses") == "$/install-locked-tools": group_input = { @@ -177,7 +176,7 @@ def action( "validate-python-distribution": "package-validators", }[name] for install_index in range(3): - command = [*_GITHUB_BASH, str(action_scripts / f"install-locked-tools-{install_index}.sh")] + command = [*github_bash, str(action_scripts / f"install-locked-tools-{install_index}.sh")] result = subprocess.run( command, cwd=repository, @@ -204,7 +203,7 @@ def action( script = action_scripts / f"{name}-{index}.sh" if (condition := step.get("if")) is not None and not _condition(condition, environment): continue - command = [*_GITHUB_BASH, str(script)] + command = [*github_bash, str(script)] result = subprocess.run( command, cwd=repository, From 4a27eb739842e71a2aeece26417cf33fd7499cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:52:52 -0700 Subject: [PATCH 133/167] =?UTF-8?q?=F0=9F=93=9D=20docs:=20define=20remaini?= =?UTF-8?q?ng=20Action=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d3f426f..6640bad 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,13 @@ jobs: extension: rst ``` -`resolve-pr-from-run` needs the source run ID and PR workflow path. Its optional `repository` input defaults to the +`resolve-pr-from-run` takes the source `run-id` and `workflow-path`. Its optional `repository` input defaults to 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`; the default `towncrier` style -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. +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 @@ -239,6 +239,10 @@ hook-cooldown: The aggregate must require `hook-cooldown` to succeed on PRs and allow it to skip on non-PR events. Rehearse a young pre-commit.ci PR against the repository's actual required aggregate before relying on the gate. +`repository` identifies the PR's base repository, `pull-request` is its number, and `head-sha` must match the triggering +event's head. `minimum-days` defaults to 7. The Action has no output or write side effect; a rejected revision fails its +job. + Schedule `recheck-hook-update-cooldown` once a day in a separate workflow with `actions: write`, `contents: read`, and `pull-requests: read`. It reruns the current head's failed `hook-cooldown` job each day; that job checks the proposed revision's first verified observation. A failed rerun is tried again on the next schedule. The workflow does not check @@ -633,11 +637,11 @@ output directory must be fresh and outside `dist`. It rejects unknown, duplicate copies bytes only from `dist`. Its `selected-count` output controls the PyPI upload step. The manifest supplies names only; it is not a source of publishable bytes. -For a build matrix whose jobs each hold one distribution artifact, set `partitioned: "true"` and omit `expected-count`. -Each cell selects names present in its original artifact and uploads its `selected-manifest` as a separate artifact. -Before public release, download all cell manifests into one directory and run `verify-pypi-partitions` with the global -stage `manifest`, `partitions-dir`, and `expected-partitions`. It rejects duplicate, missing, and extra names. The -keyless full-set PyPI verification still checks the published bytes. +For a build matrix whose jobs each hold one distribution artifact, set `partitioned` to `"true"` and omit +`expected-count`. Each cell selects names present in its original artifact and uploads its `selected-manifest` as a +separate artifact. Before public release, download all cell manifests into one directory and run +`verify-pypi-partitions` with the global stage `manifest`, `partitions-dir`, and `expected-partitions`. It rejects +duplicate, missing, and extra names. The keyless full-set PyPI verification still checks the published bytes. ## Publish a GitHub Release @@ -710,8 +714,12 @@ steps. ``` The Action derives the bot identity from the writer's App slug and verifies the commit author, single-parent commit, -changed path set against the writer's `allowed-paths`, regular file modes, and blob identities before any write. 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 +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`, and the exact PR body in `body-file`. +`paths-file` contains one tab-separated path and desired Git blob SHA1 per line. `label` defaults to `skip news`. + +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 From 7a9bd0338891ec01b24ed4d3c2e5007c9b2b7e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:56:19 -0700 Subject: [PATCH 134/167] =?UTF-8?q?=F0=9F=93=9D=20docs:=20split=20long=20w?= =?UTF-8?q?orkflow=20explanations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6640bad..f3eaffd 100644 --- a/README.md +++ b/README.md @@ -215,10 +215,12 @@ autoupdate pull requests outside this seven-day path; apply a separate age check aggregate's `needs`. Pass the event PR number and head SHA. The Action verifies both against the live PR, reads the base and head hook configurations, and uses locked `prek` to select revisions eligible after seven days. It rejects a proposed revision that is newer than that selection. Each changed revision must have appeared in a GitHub PR workflow -run at least seven days earlier. The Action reads the hook config at that run's SHA, so a force push does not reset an -unchanged revision's age or grant a new revision the old one's age. Human PRs pass without installing the hook tools. -Keep `opened`, `synchronize`, and `reopened` among the caller's PR event types. The Action maps the job's `github.token` -to `GH_TOKEN` itself; the caller does not pass a token. +run at least seven days earlier. + +The Action reads the hook config at that run's SHA, so a force push does not reset an unchanged revision's age or grant +a new revision the old one's age. Human PRs pass without installing the hook tools. Keep `opened`, `synchronize`, and +`reopened` among the caller's PR event types. The Action maps the job's `github.token` to `GH_TOKEN` itself; the caller +does not pass a token. ```yaml hook-cooldown: @@ -332,6 +334,7 @@ build: `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`; `issue-role` selects whether reStructuredText `:issue:` references link to issues or pull requests. + `lock` defaults to `pylock.toml`. Set `check-group` to a group with `prek`, and `notes-group` to one with `release-creator`; both default to `release` for existing callers. `python-version` defaults to `3.15`; `source` defaults to the repository root for `uv build`. `artifact-prefix` defaults to empty; set it when a tag builds more than From fcba1cc52c8e8bbb8c3305ee5e4bc454b9ab0c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 11:58:21 -0700 Subject: [PATCH 135/167] =?UTF-8?q?=E2=9A=A1=20perf(ci):=20run=20shell=20c?= =?UTF-8?q?hecks=20separately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 24 +++++++++++++++++++-- changelog.d/+parallel-checks.performance.md | 1 + 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 changelog.d/+parallel-checks.performance.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index d15af39..d226a59 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -27,12 +27,32 @@ jobs: enable-cache: false - uses: $/install-locked-tools with: - groups: release-shell + 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 + shell: + runs-on: ubuntu-26.04 + permissions: + 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 + - 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 @@ -155,7 +175,7 @@ jobs: gate: name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} if: always() - needs: [check, test, type, changelog, hook-cooldown, release-tools-preview] + needs: [check, shell, test, type, changelog, hook-cooldown, release-tools-preview] runs-on: ubuntu-26.04 steps: - env: 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. From 70cfff4a67fa101a9dfff7435f6ca9a96eb6de72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 13:24:59 -0700 Subject: [PATCH 136/167] =?UTF-8?q?=F0=9F=90=9B=20fix(jetbrains):=20upload?= =?UTF-8?q?=20from=20safe=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+marketplace-form-path.bugfix.md | 1 + publish-jetbrains-marketplace/action.yml | 3 +- .../test_marketplace_upload.py | 64 +++++++++++-------- 3 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 changelog.d/+marketplace-form-path.bugfix.md 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/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index d1b4774..95d7899 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -45,7 +45,8 @@ runs: 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" 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" \ + --form "pluginId=$PLUGIN_ID" --form "file=@${upload_file##*/};filename=plugin.zip" --form "channel=$CHANNEL" \ https://plugins.jetbrains.com/api/updates/upload diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index 7901e46..d0d386c 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -10,29 +10,10 @@ from pytest_httpserver import HTTPServer -def execute(script: Path, tmp_path: Path, **settings: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", str(script)], - cwd=tmp_path, - env=os.environ | settings, - text=True, - capture_output=True, - check=False, - ) - - -@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 - - def test_signed_archive_passes_byte_and_structure_checks( signed_archive: Path, tmp_path: Path, action_scripts: Path ) -> None: - result = execute( + result = _execute( action_scripts / "publish-jetbrains-marketplace-0.sh", tmp_path, ARCHIVE=str(signed_archive), @@ -74,7 +55,7 @@ def test_invalid_archive_or_input_fails_before_upload( link = tmp_path / "linked.zip" link.symlink_to(signed_archive) settings["ARCHIVE"] = str(link) - result = execute(action_scripts / "publish-jetbrains-marketplace-0.sh", tmp_path, **settings) + result = _execute(action_scripts / "publish-jetbrains-marketplace-0.sh", tmp_path, **settings) assert result.returncode != 0 @@ -84,10 +65,19 @@ def test_invalid_archive_or_input_fails_before_upload( ["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, action_scripts: Path, httpserver: HTTPServer, status: int, archive_name: str + signed_archive: Path, + tmp_path: Path, + action_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("{}", status=status) script = tmp_path / "upload.sh" script.write_text( @@ -95,7 +85,7 @@ def test_multipart_upload_uses_exact_archive_and_channel( .read_text() .replace("https://plugins.jetbrains.com/api/updates/upload", httpserver.url_for("/api/updates/upload")) ) - result = execute( + result = _execute( script, tmp_path, ARCHIVE=str(signed_archive), @@ -103,7 +93,7 @@ def test_multipart_upload_uses_exact_archive_and_channel( PLUGIN_ID="29973", CHANNEL="beta", PUBLISH_TOKEN="fixture-token", - RUNNER_TEMP=str(tmp_path), + RUNNER_TEMP=str(runner_temp), ) assert (result.returncode == 0) is (status == 200) request = httpserver.log[0][0] @@ -123,7 +113,7 @@ def test_missing_token_fails_without_request( .read_text() .replace("https://plugins.jetbrains.com/api/updates/upload", httpserver.url_for("/api/updates/upload")) ) - result = execute( + result = _execute( script, tmp_path, ARCHIVE=str(signed_archive), @@ -135,3 +125,27 @@ def test_missing_token_fails_without_request( ) 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 + + +def _execute(script: Path, tmp_path: Path, **settings: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(script)], + cwd=tmp_path, + env={ + name: os.environ[name] + for name in ("HOME", "LANG", "PATH", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY") + if name in os.environ + } + | settings, + text=True, + capture_output=True, + check=False, + ) From c57e6d31e09e764c21068e7f188de33bab558010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 13:25:08 -0700 Subject: [PATCH 137/167] =?UTF-8?q?=F0=9F=A7=AA=20test(towncrier):=20cover?= =?UTF-8?q?=20first=20RST=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/prepare_towncrier/test_prepare.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 198c71b..367eb95 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -413,17 +413,28 @@ def test_prepare_avoids_rst_conflict_marker(repository: Path, tmp_path: Path, ac assert "Changes\n=======" in (repository / "CHANGELOG.rst").read_text() -@pytest.mark.parametrize("script", ["prepare-towncrier-7.sh", "validate-release-patch-5.sh"]) -def test_prepare_rejects_rst_conflict_marker(repository: Path, action_scripts: Path, script: str) -> None: - (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") - run(repository, "git", "add", "CHANGELOG.rst") - run(repository, "git", "commit", "-m", "Track RST changelog") +@pytest.mark.parametrize( + "script", + [ + pytest.param("prepare-towncrier-7.sh", id="prepare"), + pytest.param("validate-release-patch-5.sh", id="validate"), + ], +) +@pytest.mark.parametrize("tracked", [pytest.param(True, id="tracked"), pytest.param(False, id="first-release")]) +def test_prepare_rejects_rst_conflict_marker( + repository: Path, action_scripts: Path, script: str, tracked: bool +) -> None: + if tracked: + (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") + run(repository, "git", "add", "CHANGELOG.rst") + run(repository, "git", "commit", "-m", "Track RST changelog") (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n\n<<<<<<< HEAD\n") - run(repository, "git", "add", "CHANGELOG.rst") + run(repository, "git", "add", *(["-N"] if script.startswith("prepare") and not tracked else []), "CHANGELOG.rst") result = subprocess.run( ["bash", str(action_scripts / script)], cwd=repository, - env=os.environ | {"CHANGELOG": "CHANGELOG.rst"}, + env={name: os.environ[name] for name in ("HOME", "LANG", "PATH") if name in os.environ} + | {"CHANGELOG": "CHANGELOG.rst"}, text=True, capture_output=True, check=False, From eacb5bf6aaabf20c4def6bb3a9048ddb6093091a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:19:46 -0700 Subject: [PATCH 138/167] =?UTF-8?q?=F0=9F=90=9B=20fix(release):=20retry=20?= =?UTF-8?q?failed=20release=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+release-read-retry.bugfix.md | 1 + publish-github-release/action.yml | 27 +++++- tests/publish_github_release/test_publish.py | 88 ++++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+release-read-retry.bugfix.md 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/publish-github-release/action.yml b/publish-github-release/action.yml index 5227760..c8d3836 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -148,7 +148,14 @@ runs: shopt -s nullglob release_id=$(<"$RUNNER_TEMP/release-id") for attempt in {1..6}; do - gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-current.json" + 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 @@ -194,7 +201,14 @@ runs: set -euo pipefail release_id=$(<"$RUNNER_TEMP/release-id") for attempt in {1..6}; do - gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-complete.json" + 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 @@ -232,7 +246,14 @@ runs: set -euo pipefail release_id=$(<"$RUNNER_TEMP/release-id") for attempt in {1..6}; do - gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-published.json" + 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" ' diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 4b43e2f..5545d31 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -688,3 +688,91 @@ def test_failed_asset_upload_leaves_draft_unpublished( ) assert result.returncode != 0 assert [request.method for request, _ in server.log] == ["GET", "GET", "GET", "POST"] + + +@pytest.mark.parametrize( + "failed_read", + [ + pytest.param("upload", id="before-upload"), + pytest.param("complete", id="after-upload"), + pytest.param("published", id="after-publish"), + ], +) +def test_release_read_recovers_without_duplicate_writes( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + failed_read: str, +) -> None: + server, environment = github_api + notes = tmp_path / "notes.md" + notes.write_text("Release notes.\n") + assets = tmp_path / "assets" + assets.mkdir() + (assets / "fixture.zip").write_bytes(b"release archive") + commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() + prefix = "/api/v3/repos/tox-dev/fixture" + asset = {"name": "fixture.zip", "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"} + draft = { + "id": 42, + "tag_name": "0.1.0", + "name": "0.1.0", + "body": notes.read_text(), + "draft": True, + "prerelease": False, + "assets": [], + "upload_url": f"https://localhost:{server.port}{prefix}/releases/42/assets{{?name,label}}", + } + server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( + {"object": {"type": "commit", "sha": commit}} + ) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [draft] + ) + for stage, release in ( + ("upload", draft), + ("complete", draft | {"assets": [asset]}), + ("published", draft | {"draft": False, "assets": [asset]}), + ): + if stage == failed_read: + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( + {"message": "upstream failure"}, status=502 + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) + if stage == "upload": + server.expect_ordered_request( + f"{prefix}/releases/42/assets", query_string="name=fixture.zip", method="POST", data=b"release archive" + ).respond_with_json(asset, status=201) + elif stage == "complete": + server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( + draft | {"draft": False, "assets": [asset]} + ) + result = action( + repository, + tmp_path, + action_scripts, + "publish-github-release", + version="0.1.0", + GH_REPO="tox-dev/fixture", + TAG="0.1.0", + COMMIT=commit, + TITLE="0.1.0", + NOTES_FILE=str(notes), + ASSETS_DIRECTORY=str(assets), + PRERELEASE="false", + **environment, + ) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == [ + "GET", + "GET", + "GET", + *(["GET"] if failed_read == "upload" else []), + "POST", + "GET", + *(["GET"] if failed_read == "complete" else []), + "PATCH", + "GET", + *(["GET"] if failed_read == "published" else []), + ] From e928649fb42466a12ebbabfda69dac42b878957c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:19:51 -0700 Subject: [PATCH 139/167] =?UTF-8?q?=F0=9F=90=9B=20fix(hooks):=20cool=20App?= =?UTF-8?q?=20update=20pull=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+app-hook-cooldown.security.md | 1 + check-hook-update-cooldown/action.yml | 21 +++-- .../test_cooldown.py | 78 +++++++++++++++++-- 3 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 changelog.d/+app-hook-cooldown.security.md 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/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index 759fd95..d818136 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -56,7 +56,12 @@ runs: .head.sha == $head and (.user.login | type == "string") ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null - if [[ $(jq -r .user.login "$RUNNER_TEMP/hook-update-pr.json") == 'pre-commit-ci[bot]' ]]; then + 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" \ @@ -180,11 +185,13 @@ runs: cat "$RUNNER_TEMP/older-hooks-error" >&2 exit 1 fi - uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c ' + 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" | - jq -c 'to_entries[] | {index: .key, repo: .value.repo, rev: .value.rev}' \ - >>"$RUNNER_TEMP/observed-revisions.jsonl" + ' "$RUNNER_TEMP/older-hooks.yaml" >"$RUNNER_TEMP/older-revisions.json"; then + echo "::warning::skip malformed hook config at $head" >&2 + continue + fi + jq -c '.[]' "$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' @@ -194,12 +201,12 @@ runs: --slurpfile proposed "$RUNNER_TEMP/proposed-hooks.json" ' [$proposed[0] | to_entries[] | select(.value.rev != $base[0][.key].rev) | - {index: .key, repo: .value.repo, rev: .value.rev}] + {repo: .value.repo, rev: .value.rev}] ' >"$RUNNER_TEMP/proposed-revisions.json" if ! jq -se --slurpfile proposed "$RUNNER_TEMP/proposed-revisions.json" ' . as $observed | all($proposed[0][]; . as $revision | - any($observed[]; .index == $revision.index and .repo == $revision.repo and .rev == $revision.rev)) + any($observed[]; .repo == $revision.repo 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 diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index 7f8e617..d6b4285 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -34,6 +34,8 @@ def test_action_uses_scoped_job_token() -> None: 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( @@ -48,7 +50,7 @@ def test_cooldown_checks_hook_revision( server, environment = github_api repository = hook_repository(tmp_path) expect_pull(server, author=author) - if author == "pre-commit-ci[bot]": + if author.endswith("[bot]"): base_revision = ( subprocess.run( ["git", "-C", str(repository), "rev-parse", "v0^{}"], @@ -74,16 +76,30 @@ def test_cooldown_checks_hook_revision( "/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 == "pre-commit-ci[bot]" + 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: @@ -151,6 +167,48 @@ def test_cooldown_skips_unavailable_old_head( 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: @@ -267,18 +325,28 @@ def run_cooldown( def expect_pull( - server: HTTPServer, *, author: str, head: str = "a" * 40, observed_days: int = 10, updated_days: int = 0 + 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}, + "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 == "pre-commit-ci[bot]": + 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", From 7d525313b4c0c765b5be94e35b4e9b1165fa6688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:19:56 -0700 Subject: [PATCH 140/167] =?UTF-8?q?=F0=9F=91=B7=20ci:=20check=20all=20shel?= =?UTF-8?q?l=20blocks=20and=20reruns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 8 ++++---- .github/workflows/upgrade-release-tools.yaml | 2 +- changelog.d/+cancelled-gate.bugfix.md | 1 + changelog.d/+inline-shell-hidden.bugfix.md | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 changelog.d/+cancelled-gate.bugfix.md create mode 100644 changelog.d/+inline-shell-hidden.bugfix.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index d226a59..30fc199 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -8,8 +8,8 @@ on: permissions: contents: read concurrency: - group: check-${{ github.ref }} - cancel-in-progress: true + 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 @@ -64,7 +64,7 @@ jobs: >"$RUNNER_TEMP/run-blocks" index=0 while IFS= read -r -d '' script; do - script_file="$script_dir/${file//\//-}-$index.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" @@ -174,7 +174,7 @@ jobs: exclude-newer-packages: release-creator=2026-10-22T13:51:56Z gate: name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} - if: always() + if: ${{ !cancelled() }} needs: [check, shell, test, type, changelog, hook-cooldown, release-tools-preview] runs-on: ubuntu-26.04 steps: diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 51f8d57..764dae1 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -167,7 +167,7 @@ jobs: run: | set -euo pipefail while IFS= read -r path; do - if [[ "$path" != "$LOCK" && "$path" != "$CONFIG" && "$path" != "$BUILD_CONSTRAINTS" ]] && \ + if [[ "$path" != "$LOCK" && "$path" != "$CONFIG" && "$path" != "$BUILD_CONSTRAINTS" ]] && [[ "$KEEP_UV_LOCK" != true || "$path" != uv.lock ]]; then echo "::error::unexpected dependency path: $path" >&2 exit 1 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/+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. From c3f9d59d7b3990bc3fd0c992aa99cc02bb8ad96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:20:01 -0700 Subject: [PATCH 141/167] =?UTF-8?q?=F0=9F=90=9B=20fix(build):=20use=20comm?= =?UTF-8?q?it=20time=20for=20archives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-python-distribution/action.yml | 2 ++ changelog.d/+reproducible-builds.bugfix.md | 1 + 2 files changed, 3 insertions(+) create mode 100644 changelog.d/+reproducible-builds.bugfix.md diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index 4335628..c8e7c0e 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -80,6 +80,8 @@ runs: 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 "$RUNNER_TEMP/build-constraints.txt" --require-build-hashes "$SOURCE" --out-dir "$OUT_DIR" 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. From c3adc22fd4137a59573c4b0e93b49648710a75f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:20:07 -0700 Subject: [PATCH 142/167] =?UTF-8?q?=F0=9F=90=9B=20fix(prepare):=20check=20?= =?UTF-8?q?Git=20diff=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+release-patch-check.bugfix.md | 1 + prepare-towncrier/action.yml | 19 ----------- tests/prepare_towncrier/test_prepare.py | 32 +------------------ tests/validate_release_patch/test_validate.py | 24 ++++++++++++++ validate-release-patch/action.yml | 9 ++++-- 5 files changed, 32 insertions(+), 53 deletions(-) create mode 100644 changelog.d/+release-patch-check.bugfix.md 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/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index cf3090e..180cbed 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -151,25 +151,6 @@ runs: echo '::error::release preparation created untracked files' >&2 exit 1 fi - - name: Check RST conflict markers - if: inputs.format == 'rst' - shell: bash - env: - CHANGELOG: ${{ inputs.changelog }} - run: | - set -euo pipefail - diff_file=$(mktemp) - trap 'rm -f "$diff_file"' EXIT - git diff HEAD --unified=0 -- "$CHANGELOG" >"$diff_file" - while IFS= read -r line; do - case "$line" in - +'<<<<<<<'* | +'|||||||'* | +'>>>>>>>'*) - echo '::error::release changelog contains conflict markers' >&2 - exit 1 - ;; - esac - done <"$diff_file" - printf '%s conflict-marker-size=10\n' "$CHANGELOG" >>"$(git rev-parse --git-path info/attributes)" - name: Write release patch shell: bash env: diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 367eb95..2932e21 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -302,7 +302,7 @@ def test_prepare_rejects_empty_release(repository: Path, tmp_path: Path, action_ def test_prepare_rejects_empty_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: result = subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / "prepare-towncrier-8.sh")], + ["bash", "-e", "-o", "pipefail", str(action_scripts / "prepare-towncrier-7.sh")], cwd=repository, env=os.environ | {"PATCH": str(tmp_path / "release.patch")}, text=True, @@ -413,36 +413,6 @@ def test_prepare_avoids_rst_conflict_marker(repository: Path, tmp_path: Path, ac assert "Changes\n=======" in (repository / "CHANGELOG.rst").read_text() -@pytest.mark.parametrize( - "script", - [ - pytest.param("prepare-towncrier-7.sh", id="prepare"), - pytest.param("validate-release-patch-5.sh", id="validate"), - ], -) -@pytest.mark.parametrize("tracked", [pytest.param(True, id="tracked"), pytest.param(False, id="first-release")]) -def test_prepare_rejects_rst_conflict_marker( - repository: Path, action_scripts: Path, script: str, tracked: bool -) -> None: - if tracked: - (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") - run(repository, "git", "add", "CHANGELOG.rst") - run(repository, "git", "commit", "-m", "Track RST changelog") - (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n\n<<<<<<< HEAD\n") - run(repository, "git", "add", *(["-N"] if script.startswith("prepare") and not tracked else []), "CHANGELOG.rst") - result = subprocess.run( - ["bash", str(action_scripts / script)], - cwd=repository, - env={name: os.environ[name] for name in ("HOME", "LANG", "PATH") if name in os.environ} - | {"CHANGELOG": "CHANGELOG.rst"}, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "conflict markers" in result.stderr - - def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) -> dict[str, str]: server, environment = github_api server.expect_request(f"/api/v3/repos/tox-dev/github-workflows/pulls/{number}", method="GET").respond_with_json( diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index cf43b57..bbdf772 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import subprocess from hashlib import sha256 from textwrap import dedent lazy from pathlib import Path @@ -100,6 +102,28 @@ def test_validate_rejects_rst_conflict_marker( 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: + if tracked: + (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") + run(repository, "git", "add", "CHANGELOG.rst") + run(repository, "git", "commit", "-m", "Track RST changelog") + (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n\n<<<<<<< HEAD\n") + run(repository, "git", "add", "CHANGELOG.rst") + result = subprocess.run( + ["bash", str(action_scripts / "validate-release-patch-5.sh")], + cwd=repository, + env=os.environ | {"CHANGELOG": "CHANGELOG.rst", "RUNNER_TEMP": str(tmp_path)}, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "conflict markers" in result.stderr + + def test_validate_rejects_unchanged_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: fragment = repository / "changelog.d/+feature.feature.md" fragment.unlink() diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index ca989e6..a5c99bd 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -117,6 +117,7 @@ runs: for category in config['type']: print(category['directory']) PY + 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 @@ -148,7 +149,7 @@ runs: ;; '' | *[!0-9]*) reject_fragment ;; esac - done < <(git diff --cached --name-status --no-renames -z) + done <"$RUNNER_TEMP/release-paths" if git diff --cached --quiet -- "$CHANGELOG"; then echo "::error::release patch does not change $CHANGELOG" >&2 @@ -160,6 +161,7 @@ runs: FRAGMENTS: ${{ inputs.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 @@ -192,7 +194,7 @@ runs: echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2 exit 1 fi - done < <(git diff --cached --raw --no-renames -z) + done <"$RUNNER_TEMP/release-modes" - name: Check RST conflict markers if: endsWith(inputs.changelog, '.rst') shell: bash @@ -200,6 +202,7 @@ runs: CHANGELOG: ${{ inputs.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 +'<<<<<<<'* | +'|||||||'* | +'>>>>>>>'*) @@ -207,7 +210,7 @@ runs: exit 1 ;; esac - done < <(git diff --cached --unified=0 -- "$CHANGELOG") + 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' From e0687e32cb18750747877c1fc7c028953cb3ed4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:20:15 -0700 Subject: [PATCH 143/167] =?UTF-8?q?=F0=9F=90=9B=20fix(marketplace):=20guar?= =?UTF-8?q?d=20signer=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+marketplace-error.bugfix.md | 1 + changelog.d/+signer-guards.security.md | 1 + publish-jetbrains-marketplace/action.yml | 8 ++++++-- sign-jetbrains-plugin/action.yml | 12 +++++++++++- .../test_marketplace_upload.py | 6 +++++- tests/sign_jetbrains_plugin/test_sign.py | 13 +++++++++++++ 6 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 changelog.d/+marketplace-error.bugfix.md create mode 100644 changelog.d/+signer-guards.security.md 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/+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/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index 95d7899..d836708 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -46,7 +46,11 @@ runs: cp -- "$ARCHIVE" "$upload_file" test "$(sha256sum "$upload_file" | cut -d' ' -f1)" = "$EXPECTED_SHA" cd "$RUNNER_TEMP" - curl --fail-with-body --max-time 120 --silent --show-error --output "$RUNNER_TEMP/marketplace-response.json" \ + 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 + https://plugins.jetbrains.com/api/updates/upload; then + cat "$RUNNER_TEMP/marketplace-response.json" >&2 + exit 1 + fi diff --git a/sign-jetbrains-plugin/action.yml b/sign-jetbrains-plugin/action.yml index 4eface6..8bb29ff 100644 --- a/sign-jetbrains-plugin/action.yml +++ b/sign-jetbrains-plugin/action.yml @@ -36,7 +36,7 @@ runs: set -euo pipefail [[ -f "$ARCHIVE" && ! -L "$ARCHIVE" ]] || exit 1 case "$ARCHIVE" in *.zip) ;; *) exit 1 ;; esac - echo "$EXPECTED_SHA $ARCHIVE" | sha256sum --check + 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" @@ -67,6 +67,10 @@ runs: run: | set -euo pipefail umask 077 + if [[ -e "$SIGNED" || -L "$SIGNED" ]]; then + echo '::error::signed archive already exists' >&2 + exit 1 + fi trap 'rm -f "$RUNNER_TEMP"/signing-{key,key-decrypted,chain}.pem' EXIT if [[ "$CERTIFICATE_CHAIN" == *'-----BEGIN CERTIFICATE-----'* ]]; then printf '%s' "$CERTIFICATE_CHAIN" >"$RUNNER_TEMP/signing-chain.pem" @@ -78,10 +82,16 @@ runs: 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 \ diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index d0d386c..270f8e4 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -78,7 +78,9 @@ def test_multipart_upload_uses_exact_archive_and_channel( 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("{}", status=status) + httpserver.expect_request("/api/updates/upload", method="POST").respond_with_data( + "upload denied" if status == 403 else "{}", status=status + ) script = tmp_path / "upload.sh" script.write_text( (action_scripts / "publish-jetbrains-marketplace-1.sh") @@ -96,6 +98,8 @@ def test_multipart_upload_uses_exact_archive_and_channel( 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" diff --git a/tests/sign_jetbrains_plugin/test_sign.py b/tests/sign_jetbrains_plugin/test_sign.py index b0fbcdb..e476f98 100644 --- a/tests/sign_jetbrains_plugin/test_sign.py +++ b/tests/sign_jetbrains_plugin/test_sign.py @@ -150,6 +150,19 @@ def test_rejects_missing_java(action_scripts: Path, tmp_path: Path) -> None: assert run_step(action_scripts, 3, tmp_path, 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 = run_step(action_scripts, 4, tmp_path, SIGNED=str(signed), RUNNER_TEMP=str(tmp_path)) + assert "signed archive already exists" in result.stderr + + @pytest.fixture def unsigned_archive(tmp_path: Path) -> Path: archive = tmp_path / "Plugin 2-1.0.zip" From 5a77025fe73feb1d692ca3cf5f58d50e828c04e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:20:19 -0700 Subject: [PATCH 144/167] =?UTF-8?q?=F0=9F=90=9B=20fix(release):=20check=20?= =?UTF-8?q?tag=20ancestry=20and=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yaml | 1 + changelog.d/+label-pagination.bugfix.md | 1 + changelog.d/+source-tag.security.md | 1 + label-bot-news/action.yml | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+label-pagination.bugfix.md create mode 100644 changelog.d/+source-tag.security.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fab73b1..804f51d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -57,6 +57,7 @@ jobs: run: | 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" previous=$(git tag --list | .venv/bin/release-creator tags "$TAG" --previous) echo "previous=$previous" >>"$GITHUB_OUTPUT" 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/+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/label-bot-news/action.yml b/label-bot-news/action.yml index df140fa..3551dd2 100644 --- a/label-bot-news/action.yml +++ b/label-bot-news/action.yml @@ -86,7 +86,7 @@ runs: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }} - run: gh api "repos/$REPOSITORY/issues/$PR_NUMBER/labels" --jq '.[].name' >"$RUNNER_TEMP/bot-pr-labels" + 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 From f8e2d686529dde388aa93d8a533d802df527f1a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:25:14 -0700 Subject: [PATCH 145/167] =?UTF-8?q?=F0=9F=90=9B=20fix(lock):=20combine=20b?= =?UTF-8?q?ackend=20build=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../+caller-build-constraints.bugfix.md | 1 + install-locked-tools/action.yml | 7 +++++++ tests/install_locked_tools/test_install.py | 21 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 changelog.d/+caller-build-constraints.bugfix.md 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/install-locked-tools/action.yml b/install-locked-tools/action.yml index 30886c6..93d1602 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -63,6 +63,13 @@ runs: uv run --no-project --python "$RUNNER_TEMP/lock-exporter" release-creator build-constraints \ --lock "$LOCK" --group build \ --python-version "$PYTHON_VERSION" --output "$RUNNER_TEMP/caller-build-constraints.txt" + awk ' + NR == FNR { split($1, pin, "=="); caller[pin[1]] = 1; next } + { split($1, pin, "=="); if (!(pin[1] in caller)) print } + ' "$RUNNER_TEMP/caller-build-constraints.txt" \ + "$GITHUB_ACTION_PATH/../release-build-constraints.txt" >"$RUNNER_TEMP/caller-shared-constraints.txt" + cat "$RUNNER_TEMP/caller-build-constraints.txt" >>"$RUNNER_TEMP/caller-shared-constraints.txt" + LC_ALL=C sort "$RUNNER_TEMP/caller-shared-constraints.txt" >"$RUNNER_TEMP/caller-build-constraints.txt" else cp "$GITHUB_ACTION_PATH/../release-build-constraints.txt" "$RUNNER_TEMP/caller-build-constraints.txt" fi diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 480bfae..c187c20 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -203,6 +203,27 @@ def test_install_locked_tools_accepts_tool_only_lock(tmp_path: Path, action_scri assert (tmp_path / "tools/bin/release-creator").is_file() +def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + action_root = tmp_path / "action" + action_root.mkdir() + (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) + (tmp_path / "release-build-constraints.txt").write_text( + (source / "release-build-constraints.txt").read_text() + f"probe-only==1.0 --hash=sha256:{'0' * 64}\n" + ) + result = install( + source, + action_scripts, + tmp_path, + lock=source / "pylock.toml", + groups="release-notes", + venv=tmp_path / "tools", + action_root=action_root, + ) + assert result.returncode == 0, result.stderr + assert "probe-only==1.0 " in (tmp_path / "caller-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( From adb4be2d9418dfa931c54aa06852fa65978bad5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:27:06 -0700 Subject: [PATCH 146/167] =?UTF-8?q?=F0=9F=90=9B=20fix(hooks):=20recheck=20?= =?UTF-8?q?mature=20bot=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+hook-recheck.bugfix.md | 1 + recheck-hook-update-cooldown/action.yml | 20 +++- .../test_recheck.py | 94 ++++++++++++++++++- 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 changelog.d/+hook-recheck.bugfix.md 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/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml index 8cc0ac8..3235cb4 100644 --- a/recheck-hook-update-cooldown/action.yml +++ b/recheck-hook-update-cooldown/action.yml @@ -1,5 +1,5 @@ name: Recheck cooled hook updates -description: Recheck failed pre-commit.ci cooldown jobs each day +description: Recheck failed bot hook-update cooldown jobs each day inputs: workflow: description: Workflow file containing the required hook-cooldown job @@ -18,7 +18,7 @@ runs: set -euo pipefail gh api --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" | jq -c --arg repository "$GITHUB_REPOSITORY" ' - .[] | select(.user.login == "pre-commit-ci[bot]" and + .[] | 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" @@ -31,17 +31,26 @@ runs: run: | set -euo pipefail 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 "$(jq -r .number <<<"$pull")" ' + --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 '[.[].created_at | fromdateiso8601] | min <= now - 7 * 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 @@ -54,6 +63,9 @@ runs: jq -sc --arg name "$JOB_NAME" \ '[.[].jobs[] | select(.name == $name and .conclusion == "failure")] | first // empty') if [[ -n "$job" ]]; then - gh api -X POST "repos/$GITHUB_REPOSITORY/actions/jobs/$(jq -r .id <<<"$job")/rerun" >/dev/null + 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/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py index d78817d..cee8aba 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -27,12 +27,16 @@ def test_recheck_schedule_has_only_required_permissions() -> None: [ 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", 1, id="recent-pull"), + 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, @@ -42,6 +46,7 @@ def test_recheck_reruns_failed_job( 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") @@ -49,7 +54,7 @@ def test_recheck_reruns_failed_job( [ { "number": 7, - "user": {"login": "pre-commit-ci[bot]"}, + "user": {"login": bot_login, "type": "Bot"}, "head": { "sha": "a" * 40, "ref": "pre-commit-ci-update-config", @@ -60,6 +65,9 @@ def test_recheck_reruns_failed_job( } ] ) + 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( @@ -78,7 +86,7 @@ def test_recheck_reruns_failed_job( ] } ) - if observed_age < 30: + if 7 <= observed_age < 30: server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( { "jobs": [ @@ -93,6 +101,9 @@ def test_recheck_reruns_failed_job( } ) 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 ) @@ -123,3 +134,80 @@ def test_recheck_reruns_failed_job( 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) + values = ( + os.environ + | environment + | { + "GITHUB_REPOSITORY": "tox-dev/fixture", + "RUNNER_TEMP": str(tmp_path), + "WORKFLOW": "check.yaml", + "JOB_NAME": "hook-cooldown", + "GH_TOKEN": environment["GH_ENTERPRISE_TOKEN"], + } + ) + for index in (0, 1): + result = subprocess.run( + ["bash", str(action_scripts / f"recheck-hook-update-cooldown-{index}.sh")], + env=values, + text=True, + capture_output=True, + check=False, + ) + 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", + ] From ba7d2db46f129e770a03418ba460160cb2474e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:30:49 -0700 Subject: [PATCH 147/167] =?UTF-8?q?=F0=9F=90=9B=20fix(lock):=20merge=20pin?= =?UTF-8?q?s=20with=20managed=20Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install-locked-tools/action.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 93d1602..fa8240a 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -63,13 +63,19 @@ runs: uv run --no-project --python "$RUNNER_TEMP/lock-exporter" release-creator build-constraints \ --lock "$LOCK" --group build \ --python-version "$PYTHON_VERSION" --output "$RUNNER_TEMP/caller-build-constraints.txt" - awk ' - NR == FNR { split($1, pin, "=="); caller[pin[1]] = 1; next } - { split($1, pin, "=="); if (!(pin[1] in caller)) print } - ' "$RUNNER_TEMP/caller-build-constraints.txt" \ - "$GITHUB_ACTION_PATH/../release-build-constraints.txt" >"$RUNNER_TEMP/caller-shared-constraints.txt" - cat "$RUNNER_TEMP/caller-build-constraints.txt" >>"$RUNNER_TEMP/caller-shared-constraints.txt" - LC_ALL=C sort "$RUNNER_TEMP/caller-shared-constraints.txt" >"$RUNNER_TEMP/caller-build-constraints.txt" + uv run --no-project --python "$RUNNER_TEMP/lock-exporter" python - \ + "$GITHUB_ACTION_PATH/../release-build-constraints.txt" \ + "$RUNNER_TEMP/caller-build-constraints.txt" <<'PY' + import sys + from pathlib import Path + + pins = { + line.partition("==")[0]: line + for path in map(Path, sys.argv[1:]) + for line in path.read_text().splitlines() + } + Path(sys.argv[2]).write_text("".join(f"{line}\n" for _, line in sorted(pins.items()))) + PY else cp "$GITHUB_ACTION_PATH/../release-build-constraints.txt" "$RUNNER_TEMP/caller-build-constraints.txt" fi From da8c222d0f599fc882a595ece6b0980239392ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:30:52 -0700 Subject: [PATCH 148/167] =?UTF-8?q?=E2=9A=A1=20perf(hooks):=20filter=20old?= =?UTF-8?q?=20workflow=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+hook-run-filter.performance.md | 1 + check-hook-update-cooldown/action.yml | 3 ++- tests/check_hook_update_cooldown/test_cooldown.py | 7 ++++++- 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 changelog.d/+hook-run-filter.performance.md 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/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index d818136..29833d0 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -156,8 +156,9 @@ runs: run: | set -euo pipefail 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 per_page=100 >"$RUNNER_TEMP/hook-branch-runs.jsonl" + -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( diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index d6b4285..2155b7e 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -144,6 +144,12 @@ def test_cooldown_survives_force_push_without_accepting_new_revision( 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 @@ -395,7 +401,6 @@ def expect_historical_runs( server.expect_request( "/api/v3/repos/tox-dev/fixture/actions/runs", method="GET", - query_string="branch=bot-update&event=pull_request&per_page=100", ).respond_with_json( { "workflow_runs": [ From 1beb48aa96fb0e9a060a475a28406e6583f6d8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:30:55 -0700 Subject: [PATCH 149/167] =?UTF-8?q?=F0=9F=A7=AA=20test(build):=20compare?= =?UTF-8?q?=20repeat=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/build_python_distribution/test_build.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 56e44d0..0b59123 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -65,6 +65,29 @@ def test_build_python_distribution_creates_wheel_and_sdist( assert (tmp_path / "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", + PYTHON_VERSION="3.15", + SOURCE=".", + OUT_DIR=str(temporary / "dist"), + RUNNER_TEMP=str(temporary), + ) + assert result.returncode == 0, result.stderr + outputs.append({path.name: path.read_bytes() for path in (temporary / "dist").iterdir()}) + assert outputs[0] == outputs[1] + + def test_build_python_distribution_rejects_dirty_source( build_repository: Path, tmp_path: Path, action_scripts: Path ) -> None: From fdbb364382033090adfb70e9af4db1b0040496ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:30:57 -0700 Subject: [PATCH 150/167] =?UTF-8?q?=E2=9A=A1=20perf(ci):=20reuse=20hooks?= =?UTF-8?q?=20and=20shallow=20fetches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 22 +++++++++++-------- .../workflows/prepare-towncrier-release.yaml | 4 ++-- .github/workflows/upgrade-release-tools.yaml | 7 ++++-- changelog.d/+check-runtime.performance.md | 1 + changelog.d/+type-task.bugfix.md | 1 + tox.toml | 7 +----- 6 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 changelog.d/+check-runtime.performance.md create mode 100644 changelog.d/+type-task.bugfix.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 30fc199..e4fe97d 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -18,8 +18,12 @@ jobs: 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 @@ -40,8 +44,12 @@ jobs: 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 @@ -78,7 +86,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: @@ -110,19 +118,15 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - name: Install current type tools - run: | - uv venv --python 3.15 .venv - uv pip install --python .venv/bin/python --group test - name: Check Python types - run: .venv/bin/tox run -e type + run: uvx --with tox-uv tox run -e type changelog: if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 146b195..bc3e705 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -128,7 +128,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false ref: ${{ github.sha }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -175,7 +175,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 764dae1..a874359 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -70,7 +70,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: @@ -97,6 +97,9 @@ jobs: retention-days: 1 generate-hooks: runs-on: ubuntu-26.04 + env: + UV_EXCLUDE_NEWER: "" + UV_EXCLUDE_NEWER_PACKAGE: "" permissions: contents: read outputs: @@ -104,7 +107,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: 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/+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/tox.toml b/tox.toml index 661593f..acea0f7 100644 --- a/tox.toml +++ b/tox.toml @@ -5,10 +5,5 @@ env_list = ["type"] description = "type-check Action tests" base_python = "3.15" skip_install = true -allowlist_externals = ["uv"] -commands_pre = [ - [ - "uv", "pip", "install", "--python", "{envpython}", "--group", "test", - ], -] +dependency_groups = ["test"] commands = [["ty", "check", "--python-platform", "all", "--error", "all", "--output-format", "concise", "tests"]] From d719ab2b62f8ae4d456e2c09bc27636976ef5641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:33:48 -0700 Subject: [PATCH 151/167] =?UTF-8?q?=F0=9F=90=9B=20Fix=20prepare=20artifact?= =?UTF-8?q?=20handoff=20and=20news=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 2 +- .github/workflows/prepare-release.yaml | 8 +++--- .../workflows/prepare-towncrier-release.yaml | 8 +++--- changelog.d/+news-checkout.bugfix.md | 1 + changelog.d/+patch-artifact-id.bugfix.md | 1 + tests/release_workflow/test_graph.py | 26 +++++++++++++++++++ 6 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 changelog.d/+news-checkout.bugfix.md create mode 100644 changelog.d/+patch-artifact-id.bugfix.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index e4fe97d..03afe1b 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -126,7 +126,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 1 + fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index e0d1faa..eb2f5ca 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -21,6 +21,7 @@ jobs: pull-requests: read outputs: version: ${{ steps.prepare.outputs.version }} + artifact-id: ${{ steps.patch.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -44,7 +45,8 @@ jobs: changelog: CHANGELOG.md format: markdown patch: ${{ runner.temp }}/release.patch - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: patch + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-patch path: ${{ runner.temp }}/release.patch @@ -63,7 +65,7 @@ jobs: persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: release-patch + artifact-ids: ${{ needs.assemble.outputs.artifact-id }} path: ${{ runner.temp }}/release-patch - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: @@ -100,7 +102,7 @@ jobs: persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: release-patch + artifact-ids: ${{ needs.assemble.outputs.artifact-id }} path: ${{ runner.temp }}/release-patch - uses: $/validate-release-patch with: diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index bc3e705..21a3859 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -79,6 +79,7 @@ jobs: pull-requests: read outputs: version: ${{ steps.prepare.outputs.version }} + artifact-id: ${{ steps.patch.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -113,7 +114,8 @@ jobs: link-fragments: ${{ inputs.link-fragments }} numbered-prs: ${{ inputs.numbered-prs }} patch: ${{ runner.temp }}/release.patch - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: patch + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-patch path: ${{ runner.temp }}/release.patch @@ -133,7 +135,7 @@ jobs: ref: ${{ github.sha }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: release-patch + artifact-ids: ${{ needs.assemble.outputs.artifact-id }} path: ${{ runner.temp }}/release-patch - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: @@ -179,7 +181,7 @@ jobs: persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: release-patch + artifact-ids: ${{ needs.assemble.outputs.artifact-id }} path: ${{ runner.temp }}/release-patch - uses: $/validate-release-patch with: 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/+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/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 1def777..912cfc8 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -5,9 +5,35 @@ import subprocess from pathlib import Path +import pytest import yaml +@pytest.mark.parametrize("workflow_name", ["prepare-release", "prepare-towncrier-release"]) +def test_prepare_jobs_transfer_the_uploaded_patch(workflow_name: str) -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / f".github/workflows/{workflow_name}.yaml").read_text() + ) + jobs = workflow["jobs"] + assert jobs["assemble"]["outputs"]["artifact-id"] == "${{ steps.patch.outputs.artifact-id }}" + assert ( + next(step for step in jobs["assemble"]["steps"] if step.get("id") == "patch")["with"]["name"] == "release-patch" + ) + for job_name in ("validate", "push"): + assert ( + next(step for step in jobs[job_name]["steps"] if "download-artifact" in step.get("uses", ""))["with"][ + "artifact-ids" + ] + == "${{ needs.assemble.outputs.artifact-id }}" + ) + + +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"] From 7803089055026ae1d7c5bb6850a0e384a928c7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:39:41 -0700 Subject: [PATCH 152/167] =?UTF-8?q?=F0=9F=94=92=20Verify=20prepared=20comm?= =?UTF-8?q?it=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 ++++--- changelog.d/+action-test-inputs.bugfix.md | 1 + changelog.d/+license-metadata.packaging.md | 1 + changelog.d/+post-release-commit.security.md | 1 + publish-post-release-pr/action.yml | 21 ++++- pyproject.toml | 2 + tests/commit_and_tag/test_commit.py | 15 +++- tests/conftest.py | 28 ++++-- .../test_post_release_pr.py | 85 +++++++++++++------ 9 files changed, 133 insertions(+), 50 deletions(-) create mode 100644 changelog.d/+action-test-inputs.bugfix.md create mode 100644 changelog.d/+license-metadata.packaging.md create mode 100644 changelog.d/+post-release-commit.security.md diff --git a/README.md b/README.md index f3eaffd..d78d16d 100644 --- a/README.md +++ b/README.md @@ -280,12 +280,13 @@ fragment directory, commit message, and tag style. Replace this repository's rel 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. -All three jobs need a full checkout (`fetch-depth: 0`, `persist-credentials: false`). `assemble` installs -release-creator, Towncrier, and prek; `validate` installs release-creator and prek. Both use 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`. -Restrict `release-auth` to the release branch. Install the App on that repository only, with Contents write and Metadata -read. Give it the necessary branch and tag ruleset bypass. +The assembly job needs release history to select a tag. The validation and writer jobs can check out the source commit +with `fetch-depth: 1`. All three jobs use `persist-credentials: false`. `assemble` installs release-creator, Towncrier, +and prek; `validate` installs release-creator and prek. Both use 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`. Restrict `release-auth` +to the release branch. Install the App on that repository only, with Contents write and Metadata read. Give it the +necessary branch and tag ruleset bypass. ```text main + fragments @@ -399,8 +400,9 @@ tag-triggered ecosystem publisher stays in the caller repository. ## Prepare Towncrier release `prepare-towncrier` reads a clean checkout, chooses the next version, links 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. +Towncrier and the selected formatter hook through `prek`, then 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. ```yaml - id: prepare @@ -709,6 +711,7 @@ steps. 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 @@ -716,11 +719,13 @@ steps. candidate-commit: ${{ needs.prepare.outputs.candidate-commit }} ``` -The Action derives the bot identity from the writer's App slug and verifies the commit author, single-parent commit, -changed path set against the writer's `allowed-paths`, regular file modes, and blob identities before any write. +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`, and the exact PR body in `body-file`. -`paths-file` contains one tab-separated path and desired Git blob SHA1 per line. `label` defaults to `skip news`. +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. `label` +defaults to `skip news`. 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 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/+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/+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/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index 0a6730e..d3cd1d2 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -34,6 +34,9 @@ inputs: candidate-commit: description: Candidate commit SHA in the bundle, empty when the change is already merged default: "" + commit-message: + description: Exact subject of the prepared candidate commit + required: true label: description: Label to add to the pull request default: skip news @@ -57,6 +60,7 @@ runs: PATHS_FILE: ${{ inputs.paths-file }} BUNDLE: ${{ inputs.bundle }} CANDIDATE_COMMIT: ${{ inputs.candidate-commit }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} LABEL: ${{ inputs.label }} run: | set -euo pipefail @@ -85,6 +89,8 @@ runs: *[!0-9a-f]*) exit 1 ;; esac test -n "$TITLE" + test -n "$COMMIT_MESSAGE" + [[ "$COMMIT_MESSAGE" != *$'\n'* ]] test -s "$BODY_FILE" test -s "$PATHS_FILE" test -n "$LABEL" @@ -136,6 +142,7 @@ runs: 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 @@ -156,6 +163,9 @@ runs: 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" @@ -229,6 +239,7 @@ runs: 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 @@ -237,15 +248,21 @@ runs: pr_number=$(jq -r '.[0].number // empty' "$RUNNER_TEMP/post-release-prs.json") if branch_sha=$(gh api "repos/$REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha \ 2>"$RUNNER_TEMP/branch-error"); then + gh api "repos/$REPOSITORY/git/commits/$branch_sha" >"$RUNNER_TEMP/post-release-branch.json" read -r parent_count branch_parent branch_tree branch_author branch_email < <( - gh api "repos/$REPOSITORY/git/commits/$branch_sha" \ - --jq '[(.parents | length), .parents[0].sha, .tree.sha, .author.name, .author.email] | @tsv' + 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/$REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT" diff --git a/pyproject.toml b/pyproject.toml index f52fb41..1a0891b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,8 @@ 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.5.1", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] diff --git a/tests/commit_and_tag/test_commit.py b/tests/commit_and_tag/test_commit.py index 4f07195..8618ae0 100644 --- a/tests/commit_and_tag/test_commit.py +++ b/tests/commit_and_tag/test_commit.py @@ -41,7 +41,7 @@ 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, @@ -127,7 +127,7 @@ 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']}", ) @@ -143,7 +143,16 @@ def test_commit_and_tag_rejects_missing_token_without_remote_change( 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", GH_TOKEN="") + 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 diff --git a/tests/conftest.py b/tests/conftest.py index 710c890..4c78a56 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -155,6 +155,7 @@ def action( stdout = [] stderr = [] 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(steps): @@ -204,12 +205,14 @@ def action( if (condition := step.get("if")) is not None and not _condition(condition, environment): continue command = [*github_bash, str(script)] + output_path = Path(environment["GITHUB_OUTPUT"]) + prior_output = output_path.read_text() if output_path.exists() else "" result = subprocess.run( command, cwd=repository, env=environment | { - variable: _step_value(value, variable, environment, action_config["inputs"]) + variable: _step_value(value, environment, action_config["inputs"], step_outputs) for variable, value in step.get("env", {}).items() }, text=True, @@ -220,10 +223,12 @@ def action( 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-1.sh": - environment["VERSION"] = (tmp_path / "output").read_text().split("version=", 1)[1].splitlines()[0] - if name == "render-release-notes" and script.name == "render-release-notes-0.sh": - environment["PREVIOUS"] = (tmp_path / "output").read_text().split("previous=", 1)[1].splitlines()[0] + 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() + ) return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr)) @@ -271,13 +276,19 @@ def _safe_environment() -> dict[str, str]: def _step_value( - value: str, variable: str, environment: dict[str, str], action_inputs: dict[str, dict[str, str | bool]] + value: str, + environment: dict[str, str], + action_inputs: dict[str, dict[str, str | bool]], + step_outputs: dict[str, dict[str, str]], ) -> str: if not value.startswith("${{ ") or not value.endswith(" }}"): return value for expression in value[4:-3].split(" || "): if expression.startswith("inputs."): - resolved = environment.get(variable, str(action_inputs[expression[7:]].get("default", ""))) + input_name = expression[7:] + resolved = environment.get( + input_name.replace("-", "_").upper(), str(action_inputs[input_name].get("default", "")) + ) elif expression == "github.repository": resolved = environment["GITHUB_REPOSITORY"] elif expression == "github.base_ref": @@ -289,7 +300,8 @@ def _step_value( elif expression == "github.event.pull_request.user.type == 'Bot'": resolved = environment.get("BOT_AUTHOR", "false") elif expression.startswith("steps."): - resolved = environment.get(variable, "") + _, step_id, _, output_name = expression.split(".") + resolved = step_outputs.get(step_id, {}).get(output_name, "") else: msg = f"unsupported GitHub step environment: {expression}" raise ValueError(msg) diff --git a/tests/publish_post_release_pr/test_post_release_pr.py b/tests/publish_post_release_pr/test_post_release_pr.py index a26f2d9..e58828d 100644 --- a/tests/publish_post_release_pr/test_post_release_pr.py +++ b/tests/publish_post_release_pr/test_post_release_pr.py @@ -104,6 +104,7 @@ def publish( "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", @@ -174,6 +175,7 @@ def test_open_pull_request_metadata_change_fails( "BASE": "main", "BRANCH": "post-release-v2.4.7", "TITLE": "Bump version to 2.4.8-dev", + "COMMIT_MESSAGE": "Bump version", "BODY_FILE": str(body), "RUNNER_TEMP": str(tmp_path), }, @@ -220,6 +222,7 @@ def test_closed_unmerged_pr_blocks_retry_before_write( "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", @@ -242,7 +245,14 @@ def run_git(directory: Path, *args: str) -> str: ).stdout.strip() -def candidate(tmp_path: Path, *, extra_path: bool = False, wrong_author: bool = False) -> tuple[Path, str, str, str]: +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") @@ -260,7 +270,11 @@ def candidate(tmp_path: Path, *, extra_path: bool = False, wrong_author: bool = run_git(repository, "add", "other.txt") if wrong_author: run_git(repository, "config", "user.name", "Different author") - run_git(repository, "commit", "-qm", "Bump version") + 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" @@ -269,25 +283,23 @@ def candidate(tmp_path: Path, *, extra_path: bool = False, wrong_author: bool = @pytest.mark.parametrize( - ("branch_tree", "branch_author", "base_moved", "parent_count", "expected_success"), - [ - ("candidate", "fixture-app[bot]", False, 1, True), - ("other", "fixture-app[bot]", False, 1, False), - ("candidate", "Other", False, 1, False), - ("candidate", "fixture-app[bot]", True, 1, False), - ("candidate", "fixture-app[bot]", False, 2, False), + "branch_tamper", + ["none", "tree", "author", "committer", "message", "base", "parent"], + ids=[ + "exact-branch", + "divergent-tree", + "divergent-author", + "divergent-committer", + "divergent-message", + "moved-base", + "merge-commit", ], - ids=["exact-branch", "divergent-branch", "divergent-author", "moved-base", "merge-commit"], ) def test_open_branch_must_match_prepared_candidate( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], - branch_tree: str, - branch_author: str, - base_moved: bool, - parent_count: int, - expected_success: bool, + branch_tamper: str, ) -> None: server, environment = github_api expect_bot(server) @@ -310,21 +322,29 @@ def test_open_branch_must_match_prepared_candidate( ] ) server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/heads/main", method="GET").respond_with_json( - {"object": {"sha": "c" * 40 if base_moved else base}} + {"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 parent_count == 2 else []), - "author": {"name": branch_author, "email": "123+fixture-app[bot]@users.noreply.github.com"}, + "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_tree == "other" else run_git(tmp_path / "source", "rev-parse", "HEAD^{tree}") + "sha": "b" * 40 if branch_tamper == "tree" else run_git(tmp_path / "source", "rev-parse", "HEAD^{tree}") }, } ) - if expected_success: + if branch_tamper == "none": server.expect_request("/api/v3/repos/tox-dev/fixture/issues/7/labels", method="GET").respond_with_json( [{"name": "skip news"}] ) @@ -340,6 +360,7 @@ def test_open_branch_must_match_prepared_candidate( "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", @@ -350,8 +371,8 @@ def test_open_branch_must_match_prepared_candidate( "GITHUB_OUTPUT": str(tmp_path / "output"), }, ) - assert (result.returncode == 0) is expected_success, result.stderr - assert (tmp_path / "output").exists() is expected_success + assert (result.returncode == 0) is (branch_tamper == "none"), result.stderr + assert (tmp_path / "output").exists() is (branch_tamper == "none") assert all(request.method == "GET" for request, _ in server.log) @@ -420,6 +441,7 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( "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", @@ -438,8 +460,16 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( @pytest.mark.parametrize( "changed_input", - ["commit", "blob", "extra", "author", "unauthorized"], - ids=["stale-bundle", "changed-path-manifest", "extra-path", "wrong-author", "unauthorized-path"], + ["commit", "blob", "extra", "author", "committer", "message", "unauthorized"], + ids=[ + "stale-bundle", + "changed-path-manifest", + "extra-path", + "wrong-author", + "wrong-committer", + "wrong-message", + "unauthorized-path", + ], ) def test_prepared_candidate_identity_is_checked_before_api_write( tmp_path: Path, @@ -450,7 +480,11 @@ def test_prepared_candidate_identity_is_checked_before_api_write( 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" + 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") @@ -476,6 +510,7 @@ def test_prepared_candidate_identity_is_checked_before_api_write( "ALLOWED_PATHS": "gradle.properties", "BUNDLE": str(bundle), "CANDIDATE_COMMIT": "d" * 40 if changed_input == "commit" else commit, + "COMMIT_MESSAGE": "Bump version", "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), "GITHUB_OUTPUT": str(tmp_path / "output"), From ac3adfedcfb0464e791b21e3ed1d47a58bb7234f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 15:45:00 -0700 Subject: [PATCH 153/167] =?UTF-8?q?=F0=9F=A7=AA=20Align=20Action=20tests?= =?UTF-8?q?=20with=20declared=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +++++++++++--------- changelog.d/+empty-groups.bugfix.md | 1 + changelog.d/+release-docs.doc.md | 1 + install-locked-tools/action.yml | 5 +++- tests/conftest.py | 2 ++ tests/install_locked_tools/test_install.py | 14 +++++++--- tests/label_bot_news/test_label.py | 2 +- tests/publish_github_release/test_publish.py | 28 ++++++++++---------- 8 files changed, 49 insertions(+), 32 deletions(-) create mode 100644 changelog.d/+empty-groups.bugfix.md create mode 100644 changelog.d/+release-docs.doc.md diff --git a/README.md b/README.md index d78d16d..6d5b02c 100644 --- a/README.md +++ b/README.md @@ -154,8 +154,7 @@ owns the schedule, so repositories can use different upgrade days without copyin `pull-request-title`, and `pull-request-body` let a caller retain its existing dependency PR wording. Set `keep-uv-lock: true` only when the repository commits `uv.lock` for its own build. The lock job then refreshes both -`uv.lock` and the selectable `pylock.toml` and includes both in one patch. The default rejects `uv.lock` changes. This -is the path for toml-fmt's workspace lock. +`uv.lock` and the selectable `pylock.toml` and includes both in one patch. The default rejects `uv.lock` changes. The `build-constraints` input is for a repository that checks in a generated build constraints file beside its full lock. It makes the lock job regenerate that file from the lock's `build` group and include both paths in the dependency @@ -247,10 +246,11 @@ job. Schedule `recheck-hook-update-cooldown` once a day in a separate workflow with `actions: write`, `contents: read`, and `pull-requests: read`. It reruns the current head's failed `hook-cooldown` job each day; that job checks the proposed -revision's first verified observation. A failed rerun is tried again on the next schedule. The workflow does not check -out PR code or use the release App key. The [caller in this repository](.github/workflows/recheck-hook-cooldown.yaml) -shows the trigger and permissions. Set `workflow` when the PR check workflow is not named `check.yaml`. Set `job-name` -when the required job is not named `hook-cooldown`. +revision's first verified observation. GitHub accepts reruns for 30 days. A failed rerun is tried again on the next +schedule while it remains eligible. The workflow does not check out PR code or use the release App key. The +[caller in this repository](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. Set +`workflow` when the PR check workflow is not named `check.yaml`. Set `job-name` when the required job is not named +`hook-cooldown`. Run the hook preview in its own job so its path check sees only the hook configuration change: @@ -307,10 +307,11 @@ prepare-towncrier ── version + patch ──► upload artifact The tag starts the caller's publisher. Keep PyPI trusted publishing in that top-level workflow, in a separate job with `id-token: write` because [PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Run `prek` in a separate -read-only job before building the tagged source. 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. +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 @@ -515,8 +516,9 @@ branch and tag ruleset bypass. A stale branch, invalid tag, or ruleset rejection ## Build Python distribution -`build-python-distribution` builds a wheel and sdist with `uv`. Call it after a separate read-only `prek` job, -`actions/checkout`, and `astral-sh/setup-uv`. The caller verifies the tag and changelog section before this Action. +`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 @@ -682,7 +684,7 @@ until the second call succeeds. 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 GitHub's release listing for up to ten seconds after creating the draft, uploading +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 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/+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/install-locked-tools/action.yml b/install-locked-tools/action.yml index fa8240a..0465e97 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -99,7 +99,10 @@ runs: selected_groups+=("$group") done done <<<"$DEPENDENCY_GROUPS" - test "${#groups[@]}" -gt 0 + if ((${#groups[@]} == 0)); then + echo '::error::at least one dependency group is required' >&2 + exit 1 + fi uv run --no-project --python "$VENV" python - "$LOCK" "${selected_groups[@]}" <<'PY' import sys import tomllib diff --git a/tests/conftest.py b/tests/conftest.py index 4c78a56..065dffe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -293,6 +293,8 @@ def _step_value( resolved = environment["GITHUB_REPOSITORY"] elif expression == "github.base_ref": resolved = environment.get("BASE_REF", "") + elif expression == "github.event.pull_request.number": + resolved = environment.get("GITHUB_EVENT_PULL_REQUEST_NUMBER", "") elif expression == "github.token": resolved = environment.get("GH_TOKEN", environment.get("GH_ENTERPRISE_TOKEN", "")) elif expression == "contains(github.event.pull_request.labels.*.name, 'skip news')": diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index c187c20..6ccf313 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +import yaml @pytest.mark.parametrize( @@ -123,6 +124,7 @@ def test_install_locked_tools_rejects_empty_groups(tmp_path: Path, action_script 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: @@ -241,19 +243,25 @@ def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock(tmp_path def test_shared_lock_respects_seven_day_cutoff() -> None: source = Path(__file__).resolve().parents[2] cutoff = datetime.now(UTC) - timedelta(days=7) + workflow = yaml.safe_load((source / ".github/workflows/update-tools.yaml").read_text()) + floors = { + name: datetime.fromisoformat(value) + for name, value in ( + line.split("=", 1) for line in workflow["jobs"]["update"]["with"]["exclude-newer-packages"].splitlines() + ) + } packages = tomllib.loads((source / "pylock.toml").read_text())["packages"] younger = { package["name"] for package in packages - if package["name"] != "release-creator" - and ( + if ( times := [ artifact["upload-time"] for artifact in [package.get("sdist", {}), *package.get("wheels", [])] if "upload-time" in artifact ] ) - and min(times) > cutoff + and max(times) > max(cutoff, floors.get(package["name"], cutoff)) } assert younger == set() diff --git a/tests/label_bot_news/test_label.py b/tests/label_bot_news/test_label.py index 4971049..1cff1f6 100644 --- a/tests/label_bot_news/test_label.py +++ b/tests/label_bot_news/test_label.py @@ -123,7 +123,7 @@ def test_label_bot_news_updates_label( "label-bot-news", version="0.2.0", REPOSITORY="tox-dev/fixture", - PR_NUMBER="1", + GITHUB_EVENT_PULL_REQUEST_NUMBER="1", BOT_AUTHOR="true", FRAGMENTS=fragment_dirs, EXTENSION=extension, diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 5545d31..4be8b3a 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -59,7 +59,7 @@ def test_published_release_is_verified_without_writes( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -122,7 +122,7 @@ def test_existing_release_rejects_changed_content( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -153,7 +153,7 @@ def test_tag_must_point_to_expected_commit( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT="1" * 40, TITLE="0.1.0", @@ -203,7 +203,7 @@ def test_annotated_tag_points_to_expected_commit( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -243,7 +243,7 @@ def test_missing_tag_rejects_same_name_branch( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -278,7 +278,7 @@ def test_asset_name_must_be_safe( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -313,7 +313,7 @@ def test_publish_requires_an_existing_draft( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -364,7 +364,7 @@ def test_publish_rejects_incomplete_draft_without_upload( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -393,7 +393,7 @@ def test_invalid_mode_rejects_before_api_access( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT="0" * 40, TITLE="0.1.0", @@ -450,7 +450,7 @@ def test_matching_draft_is_published_without_recreation( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -520,7 +520,7 @@ def test_missing_release_is_created_and_published( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", GITHUB_REPOSITORY="other/checkout", TAG="0.1.0", COMMIT=commit, @@ -612,7 +612,7 @@ def test_draft_release_resumes_missing_asset( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -677,7 +677,7 @@ def test_failed_asset_upload_leaves_draft_unpublished( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", @@ -754,7 +754,7 @@ def test_release_read_recovers_without_duplicate_writes( action_scripts, "publish-github-release", version="0.1.0", - GH_REPO="tox-dev/fixture", + REPOSITORY="tox-dev/fixture", TAG="0.1.0", COMMIT=commit, TITLE="0.1.0", From ce0c6dead6aac95779a1c0477affe9c0650b500a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 17:03:47 -0700 Subject: [PATCH 154/167] feat(release): share Nab-backed release actions Use a universal lock and target-specific hashed build constraints across the shared actions. Validate the release patch in a keyless worktree before granting the App key. Upload complete PyPI distributions and verify Simple API files and provenance. Remove obsolete staging actions and single-value inputs. --- .github/workflows/check.yaml | 16 +- .github/workflows/prepare-release.yaml | 119 +- .../workflows/prepare-towncrier-release.yaml | 133 +- .github/workflows/release.yaml | 14 +- .github/workflows/update-tools.yaml | 9 +- .github/workflows/upgrade-release-tools.yaml | 131 +- .../workflows/validate-python-release.yaml | 54 +- CONTRIBUTING.md | 14 +- README.md | 480 ++- build-python-distribution/action.yml | 57 +- check-hook-update-cooldown/action.yml | 64 +- commit-and-tag/action.yml | 6 +- export-build-constraints/action.yml | 50 + install-locked-tools/action.yml | 121 +- label-bot-news/action.yml | 17 +- prepare-towncrier/action.yml | 101 +- publish-github-release/action.yml | 78 +- publish-post-release-pr/action.yml | 81 +- pylock.toml | 2636 ++++++++--------- pyproject.toml | 40 +- read-towncrier-config/action.yml | 78 + reconcile-pypi-artifacts/action.yml | 93 +- refresh-hook-pins/action.yml | 60 +- refresh-release-lock/action.yml | 301 +- release-build-constraints.txt | 2 - render-release-notes/action.yml | 50 +- resolve-pr-from-run/action.yml | 13 +- select-previous-tag/action.yml | 42 + select-pypi-artifacts/action.yml | 74 - tests/build_python_distribution/test_build.py | 134 +- .../test_cooldown.py | 92 +- .../test_workflow.py | 18 +- tests/commit_and_tag/test_commit.py | 12 +- tests/conftest.py | 383 ++- tests/export_build_constraints/test_export.py | 215 ++ tests/install_locked_tools/test_install.py | 185 +- tests/label_bot_news/test_label.py | 98 +- tests/prepare_towncrier/test_prepare.py | 157 +- tests/publish_github_release/test_publish.py | 898 +++--- .../test_marketplace_upload.py | 111 +- .../test_post_release_pr.py | 113 +- tests/read_towncrier_config/test_config.py | 82 + .../test_recheck.py | 64 +- .../test_reconcile.py | 270 +- tests/refresh_release_lock/test_cutoffs.py | 103 + .../test_security_cutoff.py | 74 - tests/release_workflow/test_graph.py | 75 +- tests/render_release_notes/test_notes.py | 118 +- tests/select_previous_tag/test_select.py | 74 + tests/select_pypi_artifacts/test_select.py | 110 - tests/sign_jetbrains_plugin/test_sign.py | 93 +- tests/upgrade_release_tools/test_hooks.py | 110 +- tests/upgrade_release_tools/test_upgrade.py | 513 ++-- tests/validate_news/test_news.py | 5 - .../test_distribution.py | 84 +- tests/validate_python_release/test_tagged.py | 16 +- tests/validate_release_patch/test_validate.py | 102 +- tests/verify_pypi_partitions/test_verify.py | 46 - tools/build_constraints.py | 160 + tools/check_lock_groups.py | 32 + tools/check_nab_lock.py | 39 + tools/expire_cutoffs.py | 47 + tools/nab_cutoffs.py | 33 + tools/verify_cutoff_patch.py | 52 + validate-news/action.yml | 7 +- validate-python-distribution/action.yml | 25 +- validate-release-patch/action.yml | 84 +- verify-nab-cutoff-patch/action.yml | 8 + verify-pypi-partitions/action.yml | 34 - 69 files changed, 4699 insertions(+), 5111 deletions(-) create mode 100644 export-build-constraints/action.yml create mode 100644 read-towncrier-config/action.yml delete mode 100644 release-build-constraints.txt create mode 100644 select-previous-tag/action.yml delete mode 100644 select-pypi-artifacts/action.yml create mode 100644 tests/export_build_constraints/test_export.py create mode 100644 tests/read_towncrier_config/test_config.py create mode 100644 tests/refresh_release_lock/test_cutoffs.py delete mode 100644 tests/refresh_release_lock/test_security_cutoff.py create mode 100644 tests/select_previous_tag/test_select.py delete mode 100644 tests/select_pypi_artifacts/test_select.py delete mode 100644 tests/verify_pypi_partitions/test_verify.py create mode 100644 tools/build_constraints.py create mode 100644 tools/check_lock_groups.py create mode 100644 tools/check_nab_lock.py create mode 100644 tools/expire_cutoffs.py create mode 100644 tools/nab_cutoffs.py create mode 100644 tools/verify_cutoff_patch.py create mode 100644 verify-nab-cutoff-patch/action.yml delete mode 100644 verify-pypi-partitions/action.yml diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 03afe1b..12c378b 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -95,7 +95,7 @@ jobs: enable-cache: false - uses: $/install-locked-tools with: - groups: release-hooks + groups: release-news release-shell venv: .release-tools - name: Add locked tools to PATH run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH" @@ -149,10 +149,6 @@ jobs: pull-requests: read steps: - uses: $/check-hook-update-cooldown - with: - repository: ${{ github.repository }} - pull-request: ${{ github.event.pull_request.number }} - head-sha: ${{ github.event.pull_request.head.sha }} release-tools-preview: if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 @@ -169,13 +165,9 @@ jobs: enable-cache: false - uses: $/refresh-release-lock with: - python-version: "3.15" - check-group: release-check - build-constraints: release-build-constraints.txt - build-requirements: |- - Cython>=3.0 - setuptools>=80 - exclude-newer-packages: release-creator=2026-10-22T13:51:56Z + 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: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} if: ${{ !cancelled() }} diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index eb2f5ca..88ba696 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -9,120 +9,13 @@ on: options: [auto, major, minor, patch] default: auto permissions: {} -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - 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 }} - artifact-id: ${{ steps.patch.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-prepare - venv: .venv - - name: Add locked tools to PATH - run: 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 - - id: patch - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-patch - path: ${{ runner.temp }}/release.patch - if-no-files-found: error - validate: - needs: assemble - runs-on: ubuntu-26.04 - permissions: - contents: read - outputs: - patch-sha: ${{ steps.digest.outputs.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - 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: 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-validate - venv: .venv - - name: Add locked tools to PATH - run: 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 - - name: Record validated patch digest - id: digest - run: | - read -r patch_sha _ < <(sha256sum "$RUNNER_TEMP/release-patch/release.patch") - echo "sha=$patch_sha" >>"$GITHUB_OUTPUT" - push: - needs: [assemble, validate] - 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: - 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 - changelog: CHANGELOG.md - fragments: changelog.d - expected-patch-sha: ${{ needs.validate.outputs.patch-sha }} - checks: "false" - - 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 index 21a3859..4385ba2 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -14,46 +14,10 @@ on: description: Optional v prefix for the Towncrier changelog heading type: string default: "" - changelog: - description: Tracked changelog path - required: true - type: string - fragments: - description: Towncrier fragment directory - required: true - type: string - format: - description: Changelog format for the repository's prek hook - type: string - default: none - lock: - description: Full project pylock path - type: string - default: pylock.toml - python-version: - description: Python version for the locked release tools - type: string - default: "3.15" - prepare-group: - description: Dependency group containing release-creator, Towncrier, and prek - type: string - default: release - validate-group: - description: Dependency group containing release-creator and prek - type: string - default: release local-tool-source: description: Install release-creator from the tagged checkout for its own release type: boolean default: false - link-fragments: - description: Link merged fragments to their pull requests - type: boolean - default: true - numbered-prs: - description: Treat numeric fragment names as original pull request numbers - type: boolean - default: false annotated: description: Create an annotated tag type: boolean @@ -80,6 +44,7 @@ jobs: 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: @@ -89,85 +54,60 @@ jobs: - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: version: latest-known - python-version: ${{ inputs.python-version }} + python-version: "3.15" enable-cache: false - uses: $/install-locked-tools with: - lock: ${{ inputs.lock }} - groups: ${{ inputs.prepare-group }}${{ inputs.local-tool-source && ' fix build project-runtime' || '' }} - python-version: ${{ inputs.python-version }} + groups: release-prepare${{ inputs.local-tool-source && ' project-runtime' || '' }} venv: .venv - name: Add locked tools to PATH run: echo "$PWD/.venv/bin" >>"$GITHUB_PATH" - - name: Install the tagged release tool + - 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: Build local release tool wheel if: inputs.local-tool-source - run: uv pip install --python .venv/bin/python --no-deps --no-build-isolation . + run: | + uv build --wheel --build-constraints "$RUNNER_TEMP/caller-build-constraints.txt" \ + --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 }} - changelog: ${{ inputs.changelog }} - fragments: ${{ inputs.fragments }} - format: ${{ inputs.format }} - link-fragments: ${{ inputs.link-fragments }} - numbered-prs: ${{ inputs.numbered-prs }} + 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 - validate: - needs: assemble - runs-on: ubuntu-26.04 - permissions: - contents: read - outputs: - patch-sha: ${{ steps.digest.outputs.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - persist-credentials: false - ref: ${{ github.sha }} - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: ${{ needs.assemble.outputs.artifact-id }} - path: ${{ runner.temp }}/release-patch - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - version: latest-known - python-version: ${{ inputs.python-version }} - enable-cache: false - - uses: $/install-locked-tools - with: - lock: ${{ inputs.lock }} - groups: ${{ inputs.validate-group }}${{ inputs.local-tool-source && ' fix build project-runtime' || '' }} - python-version: ${{ inputs.python-version }} - venv: .venv - - name: Add locked tools to PATH - run: echo "$PWD/.venv/bin" >>"$GITHUB_PATH" - - name: Install the tagged release tool - if: inputs.local-tool-source - run: uv pip install --python .venv/bin/python --no-deps --no-build-isolation . - - uses: $/validate-release-patch - with: - version: ${{ needs.assemble.outputs.version }} - patch: ${{ runner.temp }}/release-patch/release.patch - changelog: ${{ inputs.changelog }} - fragments: ${{ inputs.fragments }} - branch: main - expected-sha: ${{ github.sha }} - - name: Record validated patch digest - id: digest - run: | - read -r patch_sha _ < <(sha256sum "$RUNNER_TEMP/release-patch/release.patch") - echo "sha=$patch_sha" >>"$GITHUB_OUTPUT" push: - needs: [assemble, validate] + needs: assemble runs-on: ubuntu-26.04 environment: name: release-auth @@ -187,10 +127,7 @@ jobs: with: version: ${{ needs.assemble.outputs.version }} patch: ${{ runner.temp }}/release-patch/release.patch - changelog: ${{ inputs.changelog }} - fragments: ${{ inputs.fragments }} - expected-sha: ${{ github.sha }} - expected-patch-sha: ${{ needs.validate.outputs.patch-sha }} + expected-patch-sha: ${{ needs.assemble.outputs.patch-sha }} checks: "false" - name: Require release App key env: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 804f51d..dc15a72 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,7 +34,7 @@ jobs: permissions: contents: read outputs: - previous: ${{ steps.tag.outputs.previous }} + previous: ${{ steps.previous.outputs.previous }} commit: ${{ steps.tag.outputs.commit }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -50,7 +50,7 @@ jobs: with: groups: release-notes venv: .venv - - name: Verify tag and select previous release + - name: Verify tagged source id: tag env: TAG: ${{ github.ref_name }} @@ -59,8 +59,11 @@ jobs: 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" - previous=$(git tag --list | .venv/bin/release-creator tags "$TAG" --previous) - echo "previous=$previous" >>"$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 @@ -117,7 +120,6 @@ jobs: with: tag: ${{ github.ref_name }} changelog: CHANGELOG.md - repository: ${{ github.repository }} generated-notes: ${{ runner.temp }}/generated-notes.md output: ${{ runner.temp }}/release-notes.md - id: artifact @@ -129,6 +131,7 @@ jobs: publish: needs: [check, source, notes] runs-on: ubuntu-26.04 + environment: release permissions: contents: write steps: @@ -140,7 +143,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} with: - repository: ${{ github.repository }} tag: ${{ github.ref_name }} commit: ${{ needs.source.outputs.commit }} title: ${{ github.ref_name }} diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index a32bc4c..49a6580 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -12,13 +12,8 @@ jobs: secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: - python-version: "3.15" - check-group: release-check - build-constraints: release-build-constraints.txt - build-requirements: |- - Cython>=3.0 - setuptools>=80 - exclude-newer-packages: release-creator=2026-10-22T13:51:56Z + 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 index a874359..978de70 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -2,46 +2,30 @@ name: Upgrade release tools on: workflow_call: inputs: - lock: - description: Path to the checked-in pylock file - type: string - default: pylock.toml - hook-config: - description: Path to the pre-commit configuration - type: string - default: .pre-commit-config.yaml - python-version: - description: Python version used to run the exporter and release tools - type: string - default: "3.15" - check-group: - description: Dependency group containing prek + lock-groups: + description: Release dependency groups selected by the universal lock + required: true type: string - default: release - keep-uv-lock: - description: Also refresh a checked-in uv.lock used by the project build - type: boolean - default: false - build-constraints: - description: Generated backend constraints path kept with the project lock + additional-lock: + description: Optional secondary pylock basename for a separate build matrix type: string default: "" - build-requirements: - description: Additional build requirements, one per line + additional-lock-groups: + description: Groups for the secondary lock; empty selects build requirements type: string default: "" - exclude-newer-packages: - description: Reviewed package=UTC minimum cutoffs; each expires when the seven-day cutoff passes it + additional-matrix-python: + description: Python range for the secondary lock type: string default: "" - branch: - description: Branch used for the dependency pull request + additional-matrix-platforms: + description: Space-separated Nab platform IDs for the secondary lock type: string - default: automation/update-release-tools - base: - description: Target branch for the dependency pull request + default: "" + additional-matrix-implementations: + description: Space-separated implementations for the secondary lock type: string - default: main + default: "" commit-message: description: Commit message for the refreshed dependency files type: string @@ -75,19 +59,17 @@ jobs: - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: version: latest-known - python-version: ${{ inputs.python-version }} + python-version: "3.15" enable-cache: false - id: lock uses: $/refresh-release-lock with: - lock: ${{ inputs.lock }} - python-version: ${{ inputs.python-version }} - check-group: ${{ inputs.check-group }} - keep-uv-lock: ${{ inputs.keep-uv-lock }} - build-constraints: ${{ inputs.build-constraints }} - build-requirements: ${{ inputs.build-requirements }} - hook-config: ${{ inputs.hook-config }} - exclude-newer-packages: ${{ inputs.exclude-newer-packages }} + 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: @@ -112,13 +94,10 @@ jobs: - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: version: latest-known - python-version: ${{ inputs.python-version }} + python-version: "3.15" enable-cache: false - id: hooks uses: $/refresh-hook-pins - with: - config: ${{ inputs.hook-config }} - python-version: ${{ inputs.python-version }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.hooks.outputs.changed == 'true' with: @@ -130,7 +109,7 @@ jobs: needs: [generate-lock, generate-hooks] if: | (needs.generate-lock.outputs.changed == 'true' || needs.generate-hooks.outputs.changed == 'true') && - github.ref == format('refs/heads/{0}', inputs.base) + github.ref == 'refs/heads/main' runs-on: ubuntu-26.04 environment: release-auth permissions: @@ -163,30 +142,34 @@ jobs: git apply --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" - name: Check dependency patch paths env: - LOCK: ${{ inputs.lock }} - CONFIG: ${{ inputs.hook-config }} - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} - BUILD_CONSTRAINTS: ${{ inputs.build-constraints }} + ADDITIONAL_LOCK: ${{ inputs.additional-lock }} run: | set -euo pipefail - while IFS= read -r path; do - if [[ "$path" != "$LOCK" && "$path" != "$CONFIG" && "$path" != "$BUILD_CONSTRAINTS" ]] && - [[ "$KEEP_UV_LOCK" != true || "$path" != uv.lock ]]; then + 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 < <(git diff --cached --name-only) + 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 < <(git diff --cached --raw --no-renames -z) + done <"$RUNNER_TEMP/dependency-modes" + - uses: $/verify-nab-cutoff-patch - name: Commit refreshed tools env: COMMIT_MESSAGE: ${{ inputs.commit-message }} @@ -194,17 +177,6 @@ jobs: 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 dependency branch - env: - BRANCH: ${{ inputs.branch }} - BASE: ${{ inputs.base }} - run: | - set -euo pipefail - git check-ref-format --branch "$BRANCH" - if [[ "$BRANCH" == "$BASE" ]]; then - echo '::error::dependency branch must differ from the base branch' >&2 - exit 1 - fi - name: Check release App credentials env: RELEASE_APP_CLIENT_ID: ${{ vars.RELEASE_APP_CLIENT_ID }} @@ -229,36 +201,25 @@ jobs: - name: Push dependency branch env: GH_TOKEN: ${{ steps.token.outputs.token }} - BRANCH: ${{ inputs.branch }} run: | gh auth setup-git --hostname github.com --force - git push --force-with-lease origin "HEAD:refs/heads/$BRANCH" + 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 }} - EXCLUDE_NEWER_PACKAGES: ${{ inputs.exclude-newer-packages }} - run: | - printf '%s\n' "$PR_BODY" >"$RUNNER_TEMP/pull-request.md" - if [[ -n "$EXCLUDE_NEWER_PACKAGES" ]]; then - printf '\nReviewed package cutoff floors:\n' >>"$RUNNER_TEMP/pull-request.md" - while IFS= read -r entry; do - if [[ -n "$entry" ]]; then - printf -- '- %s\n' "\`$entry\`" >>"$RUNNER_TEMP/pull-request.md" - fi - done <<<"$EXCLUDE_NEWER_PACKAGES" - fi + 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 }} - BRANCH: ${{ inputs.branch }} - BASE: ${{ inputs.base }} run: | set -euo pipefail owner=${GITHUB_REPOSITORY%%/*} - gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open -f head="$owner:$BRANCH" -f base="$BASE" | - jq -r --arg repository "$GITHUB_REPOSITORY" --arg branch "$BRANCH" --arg base "$BASE" ' - [.[] | select(.head.repo.full_name == $repository and .head.ref == $branch and .base.ref == $base)] | + 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 @@ -276,9 +237,7 @@ jobs: if: steps.pr.outputs.number == '' env: GH_TOKEN: ${{ steps.token.outputs.token }} - BRANCH: ${{ inputs.branch }} - BASE: ${{ inputs.base }} PR_TITLE: ${{ inputs.pull-request-title }} run: | - gh pr create --base "$BASE" --head "$BRANCH" --title "$PR_TITLE" \ + 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 index 8a0d6ce..630fd34 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -14,30 +14,6 @@ on: description: Link text for the ecosystem release default: PyPI release type: string - issue-role: - description: Target for reStructuredText issue roles - default: issue - type: string - lock: - description: Release-tool pylock.toml path - default: pylock.toml - type: string - python-version: - description: Python version for release tooling and package build - default: "3.15" - type: string - check-group: - description: Dependency group containing prek - default: release - type: string - notes-group: - description: Dependency group containing release-creator - default: release - type: string - validation-group: - description: Dependency group containing Twine and check-wheel-contents - default: pkg-meta - type: string local-tool-source: description: Install release-creator from this checkout for its own release default: false @@ -65,13 +41,11 @@ jobs: - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: version: latest-known - python-version: ${{ inputs.python-version }} + python-version: "3.15" enable-cache: false - uses: $/install-locked-tools with: - lock: ${{ inputs.lock }} - groups: ${{ inputs.check-group }}${{ inputs.local-tool-source && ' fix' || '' }} - python-version: ${{ inputs.python-version }} + groups: release-check venv: ${{ runner.temp }}/release-tools - name: Run tagged source checks run: >- @@ -99,41 +73,29 @@ jobs: - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: version: latest-known - python-version: ${{ inputs.python-version }} + python-version: "3.15" enable-cache: false - uses: $/install-locked-tools with: - lock: ${{ inputs.lock }} - groups: ${{ inputs.notes-group }}${{ inputs.local-tool-source && ' fix build project-runtime' || '' }} - python-version: ${{ inputs.python-version }} + groups: release-notes${{ inputs.local-tool-source && ' project-runtime' || '' }} venv: ${{ runner.temp }}/release-tools - 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 --no-build-isolation . + 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 }} - repository: ${{ github.repository }} artifact-url: ${{ inputs.artifact-base-url }}${{ github.ref_name }}/ artifact-label: ${{ inputs.artifact-label }} - issue-role: ${{ inputs.issue-role }} output: ${{ runner.temp }}/release-notes.md - - uses: $/build-python-distribution - with: - lock: ${{ inputs.lock }} - planner-group: ${{ inputs.notes-group }} - python-version: ${{ inputs.python-version }} - source: ${{ inputs.source }} - local-tool-source: ${{ inputs.local-tool-source }} - uses: $/validate-python-distribution - with: - lock: ${{ inputs.lock }} - python-version: ${{ inputs.python-version }} - validation-group: ${{ inputs.validation-group }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact-prefix }}python-package-distributions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d65e48e..9871625 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,13 +8,17 @@ and test its inputs and outputs before opening a pull request. Use Python 3.15. Install the release tools from the lock and test tools from the current index: ```bash -uv venv --python 3.15 .release-tools +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 .release-tools/bin/python --group release-hooks \ - --build-constraints release-build-constraints.txt --require-build-hashes pylock.toml -uv venv --python 3.15 .venv + --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/.release-tools/bin:$PWD/.venv/bin:$PATH" +export PATH="$PWD/.venv/release-tools/bin:$PWD/.venv/bin:$PATH" ``` Run the checks before pushing: diff --git a/README.md b/README.md index 6d5b02c..fba3e1d 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,18 @@ repository keeps its dispatch form, publisher, GitHub environment, and required ## Install locked tools -`install-locked-tools` creates a virtual environment and installs selected groups from the caller's `pylock.toml`. It -derives build dependency hashes from that lock's `build` group when present. A tool-only lock without that group uses -the shared backend constraints needed for `release-creator`. A source-only dependency fails when its build requirement -is absent or has a different hash. The Action requires a wheel for `release-creator`, so its source distribution cannot -introduce an undeclared build backend. Its exporter installs only three hash-checked wheels: release-creator, packaging, -and tomli-w. It rejects missing groups, reuses its exporter when called twice in one job, and does not change tracked -files or write to GitHub. +`install-locked-tools` creates a virtual environment and installs selected groups from the caller's `pylock.toml`. 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. -- `lock`: caller's full project lock; default `pylock.toml`. -- `groups`: whitespace-separated dependency groups; default `release`. -- `python-version`: interpreter for the environment; default `3.15`. +- `groups` (required): whitespace-separated release dependency groups. 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` before this Action. -The shared weekly updater regenerates `release-build-constraints.txt` from the build group in this repository's full -lock and commits the two files together. +`GITHUB_PATH` when later steps call them by name. Run `actions/checkout` and `astral-sh/setup-uv` with Python 3.15 +before this Action. ## Check pull request news @@ -52,15 +47,15 @@ changelog: 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 ``` -Set `config` if Towncrier is configured in another Python project file. 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. +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 @@ -103,30 +98,29 @@ jobs: extension: rst ``` -`resolve-pr-from-run` takes the source `run-id` and `workflow-path`. Its optional `repository` input defaults to 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. +`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. Two read-only jobs run in parallel: one refreshes -the full project `pylock.toml`; the other updates frozen hook revisions and exact `additional_dependencies` pins. Both -exclude releases newer than seven days. They run `prek` and upload separate patches. The write job runs on the -configured base branch when either patch changes. It accepts only the configured lock and hook paths, then opens or -updates one `dependencies` and `skip news` pull request with the repository's release App. The App-created PR starts the -normal pull request checks. The App needs Contents and Pull requests write access on that repository only. Pass only the -named key secret to the reusable workflow. GitHub requires this mapping even when the key exists only in the callee -job's `release-auth` environment; that environment supplies the value at runtime. - -Before enabling the schedule, install that App on only this repository, set `RELEASE_APP_CLIENT_ID` as a repository -variable, and store `RELEASE_APP_PRIVATE_KEY` in the `release-auth` environment. Limit that environment to `main` (or -the chosen `base` branch), create `dependencies` and `skip news` labels, and allow the App to push its dependency -branch. The App must be allowed to start PR checks. A missing Pull requests grant makes token creation fail before any -branch is pushed. +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` 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 @@ -144,39 +138,25 @@ jobs: secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: - python-version: '3.15' + 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 project must declare a group containing `prek` and pass it as `check-group`; the default is `release` for existing -callers. The lock also includes every other group and extra. `lock` defaults to `pylock.toml`; `hook-config` defaults to -`.pre-commit-config.yaml`; `base` defaults to `main`; `branch` defaults to `automation/update-release-tools`. The caller -owns the schedule, so repositories can use different upgrade days without copying the jobs. `commit-message`, -`pull-request-title`, and `pull-request-body` let a caller retain its existing dependency PR wording. - -Set `keep-uv-lock: true` only when the repository commits `uv.lock` for its own build. The lock job then refreshes both -`uv.lock` and the selectable `pylock.toml` and includes both in one patch. The default rejects `uv.lock` changes. - -The `build-constraints` input is for a repository that checks in a generated build constraints file beside its full -lock. It makes the lock job regenerate that file from the lock's `build` group and include both paths in the dependency -PR. This repository passes `release-build-constraints.txt`. Each consumer's installs derive constraints from its own -lock. Source-only packages need their build backend requirements in that lock; an absent backend fails the install. - -Use `build-requirements` for build dependencies that are not declared by the project's own build system, one requirement -per line. For example, a job group containing source-only PyYAML on Python 3.15 needs Cython and setuptools in the -`build` group. The input affects the caller's lock only; it does not add these packages to other repositories. - -Set `exclude-newer-packages` to one reviewed `package=UTC cutoff` entry per line when a required version was published -within the past seven days. Set that cutoff just after the latest file of the reviewed version was uploaded. The Action -uses the later of that timestamp and the rolling seven-day cutoff. Once the rolling cutoff passes the reviewed -timestamp, the exception expires and that package resumes normal weekly updates. The same rule applies to both locks -when `keep-uv-lock` is true. The dependency PR lists the reviewed floors. - -To check the same lock refresh on a PR, call `refresh-release-lock` in a job with Contents read permission. The reusable -updater calls this Action in its lock generator job. It excludes dependencies published in the last seven days, resolves -the project through uv, rewrites the local `pylock.toml`, installs its selected `check-group`, and runs `prek`. It -rejects changes to any other file; `uv.lock` is allowed only with `keep-uv-lock: true`, and a generated constraints file -only with `build-constraints`. Its `changed` output is `true` when a selected file differs from the checkout, and it -writes `$RUNNER_TEMP/lock.patch`. The Action does not push a branch, open a PR, or request a write token. +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]`. +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. In `refresh` mode, it upgrades the selected release groups +under Nab's configured cutoff, runs `prek`, and writes `$RUNNER_TEMP/lock.patch` if the 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 lock 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: @@ -192,34 +172,28 @@ release-tools-preview: with: version: latest-known python-version: '3.15' - - id: lock - uses: tox-dev/github-workflows/refresh-release-lock@SHA + - uses: tox-dev/github-workflows/refresh-release-lock@SHA with: - python-version: '3.15' + 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 ``` -The caller must check out the PR head and install uv before the Action. The preview checks lock compilation and runs the -repository's hooks; the scheduled updater still owns the PR write path. A reusable workflow containing a write job -cannot be called with read-only permissions even when that job would skip, so use the composite Action for PR checks. - -`refresh-hook-pins` is the matching read-only Action for hook updates. It freezes eligible repository revisions with -`prek update --freeze --cooldown-days 7`, refreshes exact `additional_dependencies` under the same age cutoff, and runs -all hooks. Its `config` input selects the pre-commit file, and `python-version` selects the interpreter used to resolve -hook dependencies. Its `changed` output is `true` only when that file changes, and it writes `$RUNNER_TEMP/hooks.patch`. -It fails if a hook modifies another file. The Action does not write to GitHub. pre-commit.ci can open separate -autoupdate pull requests outside this seven-day path; apply a separate age check before merging those updates. - -`check-hook-update-cooldown` checks proposed revisions in pre-commit.ci PRs. Add it as a no-checkout job in the caller's -`pull_request` workflow with `actions: read`, `contents: read`, and `pull-requests: read`. Include it in the required -aggregate's `needs`. Pass the event PR number and head SHA. The Action verifies both against the live PR, reads the base -and head hook configurations, and uses locked `prek` to select revisions eligible after seven days. It rejects a -proposed revision that is newer than that selection. Each changed revision must have appeared in a GitHub PR workflow -run at least seven days earlier. - -The Action reads the hook config at that run's SHA, so a force push does not reset an unchanged revision's age or grant -a new revision the old one's age. Human PRs pass without installing the hook tools. Keep `opened`, `synchronize`, and -`reopened` among the caller's PR event types. The Action maps the job's `github.token` to `GH_TOKEN` itself; the caller -does not pass a token. +`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: @@ -231,28 +205,16 @@ hook-cooldown: pull-requests: read steps: - uses: tox-dev/github-workflows/check-hook-update-cooldown@SHA - with: - repository: ${{ github.repository }} - pull-request: ${{ github.event.pull_request.number }} - head-sha: ${{ github.event.pull_request.head.sha }} ``` -The aggregate must require `hook-cooldown` to succeed on PRs and allow it to skip on non-PR events. Rehearse a young -pre-commit.ci PR against the repository's actual required aggregate before relying on the gate. - -`repository` identifies the PR's base repository, `pull-request` is its number, and `head-sha` must match the triggering -event's head. `minimum-days` defaults to 7. The Action has no output or write side effect; a rejected revision fails its -job. +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. -Schedule `recheck-hook-update-cooldown` once a day in a separate workflow with `actions: write`, `contents: read`, and -`pull-requests: read`. It reruns the current head's failed `hook-cooldown` job each day; that job checks the proposed -revision's first verified observation. GitHub accepts reruns for 30 days. A failed rerun is tried again on the next -schedule while it remains eligible. The workflow does not check out PR code or use the release App key. The -[caller in this repository](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. Set -`workflow` when the PR check workflow is not named `check.yaml`. Set `job-name` when the required job is not named -`hook-cooldown`. - -Run the hook preview in its own job so its path check sees only the hook configuration change: +Run the hook preview in its own PR job so its patch check sees only the hook configuration: ```yaml hook-pins-preview: @@ -273,35 +235,31 @@ hook-pins-preview: ## 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, its `validate` job runs release checks without a key, and its `push` job verifies the -patch digest and file modes 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 assembly job needs release history to select a tag. The validation and writer jobs can check out the source commit -with `fetch-depth: 1`. All three jobs use `persist-credentials: false`. `assemble` installs release-creator, Towncrier, -and prek; `validate` installs release-creator and prek. Both use 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`. Restrict `release-auth` -to the release branch. Install the App on that repository only, with Contents write and Metadata read. Give it the -necessary branch and tag ruleset bypass. +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. + +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 - │ - validate + hash (no key) - │ - apply + verify hash - │ - 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 @@ -335,15 +293,14 @@ build: `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`; `issue-role` selects whether reStructuredText `:issue:` references link to issues or pull requests. +`PyPI release`. reStructuredText `:issue:` references link to issues; `:pr:` and `:pull:` link to pull requests. -`lock` defaults to `pylock.toml`. Set `check-group` to a group with `prek`, and `notes-group` to one with -`release-creator`; both default to `release` for existing callers. `python-version` defaults to `3.15`; `source` +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 jobs install the tagged checkout from its locked release, fix, build, and runtime groups instead of -using a published release-creator wheel. `validation-group` defaults to `pkg-meta`; set it to `release` when that group -contains locked Twine and check-wheel-contents, as in pipx. The installer rejects an absent group before syncing. +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` @@ -352,30 +309,27 @@ whose default fails on an artifact digest mismatch. Its notes lack GitHub contri 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 keyless `stage-pypi` job needs the build artifact only. Let it run alongside the GitHub notes jobs; the publisher -waits for both staging and the final notes before creating the GitHub Release. +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 three jobs. The first selects a tag and writes a Towncrier patch in a checkout -without the App key. The second applies that patch in another keyless checkout, runs `prek`, and records its digest. The -last job checks the digest, 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 that job. +`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: + uses: >- tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA permissions: contents: read pull-requests: read with: bump: ${{ inputs.bump }} - changelog: CHANGELOG.md - fragments: changelog.d - format: markdown tag-prefix: v annotated: true secrets: @@ -383,37 +337,50 @@ jobs: ``` 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 workflow requests Contents write only for the current repository. The caller -grants its GitHub token Contents and Pull requests read. The assembly job installs `prepare-group`, which needs -release-creator, Towncrier, and prek. The validation job installs `validate-group`, which needs release-creator and -prek. Both default to `release` for existing callers. Set `lock` and `python-version` when those paths or versions -differ. No job output crosses back to the caller. A successful run pushes a tag, which the caller's tag workflow -handles. - -`bump`, `tag-prefix`, `changelog-prefix`, `changelog`, `fragments`, `format`, `link-fragments`, `numbered-prs`, and -`annotated` have the same meanings as the `prepare-towncrier` inputs below. `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` installs release-creator from the source checkout with locked third-party dependencies in both -keyless jobs; 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. +`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`, `format` (`markdown` or `rst`), and `categories-file`, the temporary file with one allowed +category per line. The Action writes that file under `runner.temp` and does not modify the checkout or GitHub state. ## Prepare Towncrier release `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. 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. +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 @@ -424,20 +391,14 @@ patch. 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), `markdown`, or `rst`. `markdown` runs the caller's `mdformat` hook and `rst` runs its - `docstrfmt` hook through `prek` on the assembled changelog. Use `none` when no formatter hook applies. The reusable - workflow's validation job runs the full `prek` suite for every option. -- `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. -- `numbered-prs`: `false` (default) leaves Towncrier's numeric fragment behavior intact. Set `true` only when every - numeric fragment stem is the original pull request number; the linked PR and its authors come from that number. -- `patch` **(required)**: Writable path to the Git patch, usually under `runner.temp`. Upload this exact file as an - artifact for the validation job. -- `repository`: GitHub owner/repository used for fragment author links; defaults to the workflow caller. +- `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 @@ -445,8 +406,8 @@ 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 file modes, renders the requested version with +`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, renders the requested version with `release-creator`, 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. @@ -456,28 +417,20 @@ release tools. 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. `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 direct children of `fragments` - may change in the patch; fragment names must use the configured Towncrier categories and matching Markdown or RST - extension. -- `fragments` **(required)**: Same directory passed to preparation and recorded in the protected branch's - `pyproject.toml` Towncrier config. -- `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. -- `repository`: GitHub owner/repository used to check the changelog's links; defaults to the workflow caller. +- `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. A failed `prek` run, unexpected path -or mode, or modification by `prek` fails the keyless job. The key job accepts only the validated digest. +**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 @@ -507,11 +460,10 @@ Contents write. Put `commit-and-tag` last in the writer job: its Git credential - `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. ## Build Python distribution @@ -523,46 +475,28 @@ verifies the tag and changelog section before this Action. ```yaml - uses: tox-dev/github-workflows/build-python-distribution@SHA with: - python-version: '3.15' source: . - out-dir: dist ``` -- `python-version`: Managed Python used for the build; default `3.15`. -- `lock`: Full project lock containing the build backend; default `pylock.toml`. - `source`: Source directory passed to `uv build`; default `.`. -- `planner-group`: Group containing `release-creator`; default `release` for existing callers. -- `local-tool-source`: `false` (default). Set `true` only when the tagged source is release-creator itself; the keyless - planner installs it from that checkout after syncing locked backend and runtime dependencies. -- `out-dir`: Directory for the wheel and sdist; default `dist`. Validate files in this directory before upload. -**Output:** No named output. **Working-tree effect:** Writes a wheel and sdist to `out-dir`. The checkout must be clean -before the Action starts. The build backend comes from the lock's `build` selection with pinned versions and hashes; +**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 full project lock. Run it after `build-python-distribution` and before `upload-artifact`, -using the same output directory. +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 - with: - lock: pylock.toml - validation-group: pkg-meta - out-dir: dist ``` -- `lock`: Full project lock containing the validation group; default `pylock.toml`. -- `python-version`: Managed Python for the validators; default `3.15`. -- `validation-group`: Group containing Twine and check-wheel-contents; default `pkg-meta`. Use `release` for projects - that lock these tools there. -- `out-dir`: Directory containing the wheel and sdist; default `dist`. The Action checks these exact paths and has no - output artifact of its own. - -**Output:** No named output. **Working-tree effect:** None. An invalid sdist or wheel stops before the artifact upload. +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 @@ -575,7 +509,6 @@ creates the Release; this Action only writes a local Markdown file. with: tag: ${{ github.ref_name }} changelog: CHANGELOG.md - repository: ${{ github.repository }} artifact-url: https://pypi.org/project/example/${{ github.ref_name }}/ generated-notes: ${{ runner.temp }}/generated-notes.md output: ${{ runner.temp }}/release-notes.md @@ -584,71 +517,61 @@ creates the Release; this Action only writes a local Markdown file. - `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. -- `repository` **(required)**: GitHub `owner/repository` used in PR and comparison links. -- `package`: Package name for a repository with separate histories, such as `pyproject-fmt` in `toml-fmt`. Use it with - tags like `pyproject-fmt/2.30.3`; the Action selects that package's changelog section and previous tag only. - `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. -- `issue-role`: For reStructuredText, whether `:issue:` links target GitHub issues (default) or pull requests. - `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. -## Reconcile PyPI artifacts +## 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. -`reconcile-pypi-artifacts` compares the complete local distribution directory with PyPI's filenames and SHA256 digests. -Run `stage` in a read-only job before trusted publishing. It returns `missing-count` and a `manifest` path listing files -PyPI lacks. Upload only that small manifest between jobs, along with the original distribution artifact. Do not upload -the stage job's copied files. The OIDC job downloads the original files and manifest, then calls `select-pypi-artifacts` -to copy only named original bytes into a fresh upload directory. It needs no checkout or release-tool installation. Call -the pinned PyPI publisher on that directory only when `selected-count` is nonzero. +```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 -After upload, run `reconcile-pypi-artifacts` with `mode: verify` in another read-only job against the original complete -distribution set. It waits up to 120 seconds for a complete PyPI JSON response. A different digest, unexpected or yanked -file, or API error other than a missing release fails immediately. The GitHub Release writer must depend on this -verification. A full workflow rerun restages missing files. For a failed-jobs rerun after a partial upload, set PyPI -publisher `skip-existing: true` only for this selected subset; the separate full-set verification still rejects changed -or extra files before GitHub publication. +`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 directory with the pinned PyPI publisher, +without `skip-existing`. Run this Action in a read-only job after the publisher. A different digest, unexpected or +yanked file, malformed response, or invalid attestation fails. The GitHub Release writer must depend on this +verification. ```yaml - - id: pypi - uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA + - uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA with: project: example version: ${{ github.ref_name }} dist: dist - mode: stage - missing-dir: missing - tool-group: release-notes ``` -`project`, `version`, `dist`, and `mode` are required. `mode` is `stage` or `verify`. `missing-dir` defaults to -`missing` and must be a fresh directory outside `dist`. `wait-seconds` defaults to 120 for verify. `lock` defaults to -`pylock.toml`; `tool-group` selects the group containing `release-creator` and defaults to `release`. Use a narrower -group such as `release-notes` when the lock provides one. `python-version` defaults to 3.15. Set -`local-tool-source: "true"` only when releasing release-creator itself: it installs the tagged checkout without -resolving dependencies after installing its locked tool, build, and runtime groups. This Action writes only the missing -directory, manifest, and `missing-count`; it never uploads to PyPI. Use `mode: verify` after the OIDC job, including -when `missing-count` was zero. - -`select-pypi-artifacts` takes `dist`, `manifest`, `output-dir`, and `expected-count` from stage's `missing-count`. The -output directory must be fresh and outside `dist`. It rejects unknown, duplicate, unsafe, or symlinked filenames and -copies bytes only from `dist`. Its `selected-count` output controls the PyPI upload step. The manifest supplies names -only; it is not a source of publishable bytes. - -For a build matrix whose jobs each hold one distribution artifact, set `partitioned` to `"true"` and omit -`expected-count`. Each cell selects names present in its original artifact and uploads its `selected-manifest` as a -separate artifact. Before public release, download all cell manifests into one directory and run -`verify-pypi-partitions` with the global stage `manifest`, `partitions-dir`, and `expected-partitions`. It rejects -duplicate, missing, and extra names. The keyless full-set PyPI verification still checks the published bytes. +`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 @@ -664,17 +587,18 @@ until the second call succeeds. env: GH_TOKEN: ${{ github.token }} with: - repository: ${{ github.repository }} tag: ${{ github.ref_name }} commit: ${{ github.sha }} title: ${{ github.ref_name }} notes-file: ${{ runner.temp }}/release-notes.md ``` -- `repository`, `tag`, `commit`, `title`, and `notes-file` are required. `commit` is the commit the existing tag must - resolve to. `title` and the file bytes must match an existing Release exactly. +- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the 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 @@ -707,9 +631,7 @@ steps. env: GH_TOKEN: ${{ steps.app-token.outputs.token }} with: - repository: ${{ github.repository }} app-slug: ${{ steps.app-token.outputs.app-slug }} - base: main base-commit: ${{ needs.prepare.outputs.base-commit }} branch: post-release-${{ github.ref_name }} title: Bump version to 2.4.8-dev @@ -726,15 +648,15 @@ commit message, single-parent commit, changed path set against the writer's `all 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. `label` -defaults to `skip news`. +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. Its `pull-request` output is the verified PR number. +merged PR and current base. The Action has no named output. ## Sign a JetBrains plugin diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index c8e7c0e..6fbc329 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -1,24 +1,9 @@ name: Build Python distribution description: Build a wheel and sdist from source checked in a separate job inputs: - lock: - description: Full project pylock containing build backend dependencies - default: pylock.toml - python-version: - description: Python version for uv build - default: "3.15" - planner-group: - description: Dependency group containing release-creator - default: release source: description: Distribution source directory default: . - local-tool-source: - description: Install release-creator from this tagged checkout before planning the build - default: "false" - out-dir: - description: Directory for the wheel and sdist - default: dist runs: using: composite steps: @@ -31,50 +16,22 @@ runs: fi - name: Require empty output directory shell: bash - env: - OUT_DIR: ${{ inputs.out-dir }} run: | set -euo pipefail - if [[ -d "$OUT_DIR" && -n $(find "$OUT_DIR" -mindepth 1 -maxdepth 1 -print -quit) ]]; then - echo "::error::distribution output directory is not empty: $OUT_DIR" >&2 + 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: Install locked build planner - uses: $/install-locked-tools - with: - lock: ${{ inputs.lock }} - groups: ${{ inputs.planner-group }}${{ inputs.local-tool-source == 'true' && ' build project-runtime' || '' }} - python-version: "3.15" - venv: ${{ runner.temp }}/build-planner - name: Select build interpreter shell: bash - env: - PYTHON_VERSION: ${{ inputs.python-version }} - run: uv venv --python "$PYTHON_VERSION" "$RUNNER_TEMP/build-python" - - name: Install the tagged build planner - if: inputs.local-tool-source == 'true' - shell: bash - env: - LOCK: ${{ inputs.lock }} - SOURCE: ${{ inputs.source }} - run: | - uv pip install --python "$RUNNER_TEMP/build-planner" --no-deps --no-build-isolation "$SOURCE" - - name: Write backend constraints - shell: bash - env: - LOCK: ${{ inputs.lock }} - run: | - uv run --no-project --python "$RUNNER_TEMP/build-planner" release-creator build-constraints --lock "$LOCK" \ - --python-version "$( - uv run --no-project --python "$RUNNER_TEMP/build-python" \ - python -c 'import platform; print(platform.python_version())' - )" \ - --output "$RUNNER_TEMP/build-constraints.txt" + run: uv venv --python 3.15 "$RUNNER_TEMP/build-python" + - id: constraints + uses: $/export-build-constraints - name: Build wheel and sdist shell: bash env: SOURCE: ${{ inputs.source }} - OUT_DIR: ${{ inputs.out-dir }} + BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE @@ -84,4 +41,4 @@ runs: SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) uv build --python "$build_python" \ --python-preference only-managed --sdist --wheel \ - --build-constraints "$RUNNER_TEMP/build-constraints.txt" --require-build-hashes "$SOURCE" --out-dir "$OUT_DIR" + --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes "$SOURCE" --out-dir dist diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index 29833d0..7b82670 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -1,15 +1,6 @@ name: Check hook update cooldown description: Require seven days before a pre-commit.ci update pull request can merge inputs: - repository: - description: Repository containing the pull request - required: true - pull-request: - description: Pull request number - required: true - head-sha: - description: Exact pull request head from the triggering event - required: true minimum-days: description: Minimum age of a proposed hook revision default: "7" @@ -20,13 +11,13 @@ runs: name: Verify the pull request identity shell: bash env: - REPOSITORY: ${{ inputs.repository }} - PR_NUMBER: ${{ inputs.pull-request }} - HEAD_SHA: ${{ inputs.head-sha }} 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 ;; @@ -84,25 +75,20 @@ runs: version: latest-known python-version: "3.15" enable-cache: false - - name: Install locked hook tools + - uses: $/install-locked-tools if: steps.identity.outputs.bot == 'true' - shell: bash - run: | - unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - uv venv --python 3.15 "$RUNNER_TEMP/hook-tools" - uv pip sync --preview-features pylock --preview-features build-dependency-hashes --only-binary release-creator \ - --python "$RUNNER_TEMP/hook-tools" --group release-hooks \ - --build-constraints "$GITHUB_ACTION_PATH/../release-build-constraints.txt" --require-build-hashes \ - "$GITHUB_ACTION_PATH/../pylock.toml" + with: + groups: release-hook-check + venv: ${{ runner.temp }}/hook-tools - name: Read the base and proposed hook configurations if: steps.identity.outputs.bot == 'true' shell: bash env: - REPOSITORY: ${{ inputs.repository }} - HEAD_SHA: ${{ inputs.head-sha }} 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" @@ -142,19 +128,19 @@ runs: else [range(0; $new | length) as $index | $new[$index] | select(.rev != $old[$index].rev and .rev != $allowed[$index].rev) | - [.repo, .rev]] end + [.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: - REPOSITORY: ${{ inputs.repository }} - PR_NUMBER: ${{ inputs.pull-request }} 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 \ @@ -171,10 +157,10 @@ runs: if: steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true' shell: bash env: - REPOSITORY: ${{ inputs.repository }} 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 \ @@ -192,7 +178,11 @@ runs: echo "::warning::skip malformed hook config at $head" >&2 continue fi - jq -c '.[]' "$RUNNER_TEMP/older-revisions.json" >>"$RUNNER_TEMP/observed-revisions.jsonl" + 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' @@ -200,14 +190,16 @@ runs: run: | jq -n --slurpfile base "$RUNNER_TEMP/base-hooks.json" \ --slurpfile proposed "$RUNNER_TEMP/proposed-hooks.json" ' - [$proposed[0] | to_entries[] | + [$proposed[0] as $entries | $entries | to_entries[] | . as $entry | select(.value.rev != $base[0][.key].rev) | - {repo: .value.repo, rev: .value.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 .rev == $revision.rev)) + 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 @@ -219,7 +211,7 @@ runs: MINIMUM_DAYS: ${{ inputs.minimum-days }} run: | set -euo pipefail - while IFS=$'\t' read -r repository revision; do + while IFS=$'\t' read -r repository revision occurrence; do if [[ ${#revision} -eq 40 ]]; then case "$revision" in *[!0-9a-f]*) exit 1 ;; @@ -243,9 +235,11 @@ runs: --config "$RUNNER_TEMP/candidate-hooks.yaml" --repo "$repository" \ --repo-include-tag "$repository=$tag" --cooldown-days "$MINIMUM_DAYS" \ "${options[@]}" --no-progress - selected=$(HOOK_REPOSITORY="$repository" uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -r \ - '.repos[] | select(.repo == env.HOOK_REPOSITORY) | .rev' "$RUNNER_TEMP/candidate-hooks.yaml") - if [[ "$selected" == "$revision" ]]; then + 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 diff --git a/commit-and-tag/action.yml b/commit-and-tag/action.yml index 0820689..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" @@ -76,7 +73,6 @@ runs: env: VERSION: ${{ inputs.version }} GH_TOKEN: ${{ inputs.token }} - BRANCH: ${{ inputs.branch }} run: | gh auth setup-git --hostname github.com --force - git push --atomic origin "HEAD:refs/heads/$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..21395bd --- /dev/null +++ b/export-build-constraints/action.yml @@ -0,0 +1,50 @@ +name: Export build constraints +description: Write hashed build dependencies selected from the caller's pylock +inputs: + lock: + description: Caller pylock containing build dependencies + default: pylock.toml + target-platform: + description: Nab platform ID for cross-target build constraints + default: "" + targets: + description: Space-separated full Python version and implementation pairs + default: "" +outputs: + path: + description: Path to the generated build constraints + value: ${{ steps.constraints.outputs.path }} +runs: + using: composite + steps: + - name: Install locked pylock reader + shell: bash + run: | + set -euo pipefail + unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE + uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ + --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" + - 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 + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \ + "$RUNNER_TEMP/build-constraints.txt" "${locks[@]}" + echo "path=$RUNNER_TEMP/build-constraints.txt" >>"$GITHUB_OUTPUT" diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 0465e97..0a5d050 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -1,119 +1,60 @@ name: Install locked release tools description: Install selected pylock groups with hashed build dependencies inputs: - lock: - description: Full project pylock file - default: pylock.toml groups: description: Space-separated dependency groups to install - default: release - python-version: - description: Python version for the tool environment - default: "3.15" + required: true venv: description: Virtual environment directory required: true runs: using: composite steps: - - name: Install shared lock exporter + - name: Install locked pylock reader shell: bash run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-exporter" - uv run --no-project --python "$RUNNER_TEMP/lock-exporter" python - "$GITHUB_ACTION_PATH/../pylock.toml" \ - "$RUNNER_TEMP/lock-exporter-requirements.txt" <<'PY' - import sys - import tomllib - - with open(sys.argv[1], "rb") as source: - packages = tomllib.load(source)["packages"] - requirements = [] - for name in ("release-creator", "packaging", "tomli-w"): - selected = [package for package in packages if package["name"] == name] - if len(selected) != 1: - sys.exit(f"shared lock must contain exactly one {name} package") - package = selected[0] - wheels = [wheel for wheel in package.get("wheels", []) if wheel["url"].endswith("-py3-none-any.whl")] - if len(wheels) != 1: - sys.exit(f"shared lock must contain exactly one universal {name} wheel") - requirements.append(f"{name}=={package['version']} --hash=sha256:{wheels[0]['hashes']['sha256']}\n") - with open(sys.argv[2], "w", encoding="utf-8") as output: - output.writelines(requirements) - PY - uv pip sync --python "$RUNNER_TEMP/lock-exporter" --only-binary :all: --require-hashes \ - "$RUNNER_TEMP/lock-exporter-requirements.txt" - - name: Derive caller backend constraints + uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ + --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" + - name: Select locked groups and build constraints shell: bash env: - LOCK: ${{ inputs.lock }} - PYTHON_VERSION: ${{ inputs.python-version }} - run: | - set -euo pipefail - if uv run --no-project --python "$RUNNER_TEMP/lock-exporter" python - "$LOCK" <<'PY' - import sys - import tomllib - - with open(sys.argv[1], "rb") as source: - groups = tomllib.load(source).get("dependency-groups", []) - sys.exit("build" not in groups) - PY - then - uv run --no-project --python "$RUNNER_TEMP/lock-exporter" release-creator build-constraints \ - --lock "$LOCK" --group build \ - --python-version "$PYTHON_VERSION" --output "$RUNNER_TEMP/caller-build-constraints.txt" - uv run --no-project --python "$RUNNER_TEMP/lock-exporter" python - \ - "$GITHUB_ACTION_PATH/../release-build-constraints.txt" \ - "$RUNNER_TEMP/caller-build-constraints.txt" <<'PY' - import sys - from pathlib import Path - - pins = { - line.partition("==")[0]: line - for path in map(Path, sys.argv[1:]) - for line in path.read_text().splitlines() - } - Path(sys.argv[2]).write_text("".join(f"{line}\n" for _, line in sorted(pins.items()))) - PY - else - cp "$GITHUB_ACTION_PATH/../release-build-constraints.txt" "$RUNNER_TEMP/caller-build-constraints.txt" - fi - - name: Install locked groups - shell: bash - env: - LOCK: ${{ inputs.lock }} DEPENDENCY_GROUPS: ${{ inputs.groups }} - PYTHON_VERSION: ${{ inputs.python-version }} VENV: ${{ inputs.venv }} run: | set -euo pipefail - unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - uv venv --allow-existing --python "$PYTHON_VERSION" "$VENV" + uv venv --allow-existing --python 3.15 "$VENV" groups=() - selected_groups=() while IFS= read -r line; do read -ra selected <<<"$line" - for group in "${selected[@]}"; do - groups+=(--group "$group") - selected_groups+=("$group") - done + groups+=("${selected[@]}") done <<<"$DEPENDENCY_GROUPS" if ((${#groups[@]} == 0)); then echo '::error::at least one dependency group is required' >&2 exit 1 fi - uv run --no-project --python "$VENV" python - "$LOCK" "${selected_groups[@]}" <<'PY' - import sys - import tomllib - - with open(sys.argv[1], "rb") as source: - available = set(tomllib.load(source).get("dependency-groups", [])) - missing = set(sys.argv[2:]) - available - if missing: - sys.exit(f"missing lock dependency groups: {', '.join(sorted(missing))}") - PY - uv pip sync --preview-features pylock --preview-features build-dependency-hashes --only-binary release-creator \ - --python "$VENV" "${groups[@]}" \ - --build-constraints "$RUNNER_TEMP/caller-build-constraints.txt" \ - --require-build-hashes "$LOCK" + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" pylock.toml "${groups[@]}" + package_path=$(uv run --no-project --python "$RUNNER_TEMP/lock-tools" python -c \ + 'from pathlib import Path; import packaging; print(Path(packaging.__file__).parent.parent)') + PYTHONPATH="$package_path" uv run --no-project --python "$VENV" python \ + "$GITHUB_ACTION_PATH/../tools/build_constraints.py" \ + --shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml" "$RUNNER_TEMP/caller-build-constraints.txt" \ + "$GITHUB_ACTION_PATH/../pylock.toml" pylock.toml + printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" + - name: Install selected groups + shell: bash + env: + VENV: ${{ inputs.venv }} + 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 "$RUNNER_TEMP/caller-build-constraints.txt" \ + --require-build-hashes pylock.toml diff --git a/label-bot-news/action.yml b/label-bot-news/action.yml index 3551dd2..d07adf2 100644 --- a/label-bot-news/action.yml +++ b/label-bot-news/action.yml @@ -55,6 +55,7 @@ runs: 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" @@ -72,7 +73,17 @@ runs: case "$path" in "$directory"/*.*."$EXTENSION") fragment=${path#"$directory"/} - if [[ "$fragment" != */* ]]; then has_fragment=true; fi + 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 @@ -101,7 +112,7 @@ runs: has_label=true fi if [[ "$has_fragment" == false && "$has_label" == false ]]; then - gh api -X POST "repos/$REPOSITORY/issues/$PR_NUMBER/labels" -f 'labels[]=skip news' >/dev/null + gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --add-label 'skip news' >/dev/null elif [[ "$has_fragment" == true && "$has_label" == true ]]; then - gh api -X DELETE "repos/$REPOSITORY/issues/$PR_NUMBER/labels/skip%20news" >/dev/null + 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 180cbed..b389f67 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -13,27 +13,9 @@ inputs: 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, markdown, rst, or none - default: none - link-fragments: - description: Resolve named fragments and authors with release-creator - default: "true" - numbered-prs: - description: Treat numeric fragment names as original pull request numbers - default: "false" - patch: - description: Path for the generated Git patch - required: true - repository: - description: GitHub owner/repository used to resolve fragment authors - default: "" + phase: + description: Run the full preparation, changelog build, or finalization + default: all outputs: version: description: Resolved release version @@ -41,21 +23,37 @@ 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 [[ -z "$VERSION" ]]; then @@ -64,35 +62,23 @@ runs: 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 }} - NUMBERED_PRS: ${{ inputs.numbered-prs }} - FRAGMENTS: ${{ inputs.fragments }} - REPOSITORY: ${{ inputs.repository || github.repository }} + 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 [[ "$NUMBERED_PRS" != true && "$NUMBERED_PRS" != false ]]; then - echo '::error::numbered-prs must be true or false' >&2 - exit 1 - fi - if [[ "$LINK_FRAGMENTS" == true ]]; then - args=(--directory "$FRAGMENTS" --repository "$REPOSITORY" --git --resolve-authors) - if [[ "$NUMBERED_PRS" == true ]]; then - args+=(--numbered-prs) - fi - release-creator fragments "${args[@]}" - fi + release-creator fragments --directory "$FRAGMENTS" --repository "$REPOSITORY" - name: Build changelog + if: inputs.phase != 'finalize' shell: bash env: VERSION: ${{ steps.version.outputs.version }} @@ -108,41 +94,39 @@ runs: esac towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}" - name: Format changelog + if: inputs.phase != 'build' shell: bash env: - CHANGELOG: ${{ inputs.changelog }} - FORMAT: ${{ inputs.format }} + CHANGELOG: ${{ steps.config.outputs.changelog }} + FORMAT: ${{ steps.config.outputs.format }} run: | set -euo pipefail case "$FORMAT" in markdown) formatter=mdformat ;; rst) formatter=docstrfmt ;; - none) formatter="" ;; *) - echo '::error::format must be markdown, rst, or none' >&2 + echo '::error::unsupported changelog format' >&2 exit 1 ;; esac - if [[ -n "$formatter" ]] && ! prek run "$formatter" --files "$CHANGELOG"; then + if ! prek run "$formatter" --files "$CHANGELOG"; then prek run "$formatter" --files "$CHANGELOG" fi - name: Check rendered release notes + if: inputs.phase != 'build' shell: bash env: - VERSION: ${{ steps.version.outputs.version }} - CHANGELOG: ${{ inputs.changelog }} - REPOSITORY: ${{ inputs.repository || github.repository }} + VERSION: ${{ inputs.version || steps.version.outputs.version }} + CHANGELOG: ${{ steps.config.outputs.changelog }} + REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - notes=$(release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$REPOSITORY") - if [[ "$notes" != *'### '* ]]; then - echo '::error::release preparation has no release notes' >&2 - exit 1 - fi + release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$REPOSITORY" >/dev/null - name: Check generated files + if: inputs.phase != 'build' shell: bash env: - CHANGELOG: ${{ inputs.changelog }} + CHANGELOG: ${{ steps.config.outputs.changelog }} run: | if [[ -f "$CHANGELOG" ]] && ! git ls-files --error-unmatch "$CHANGELOG" >/dev/null 2>&1; then git add -N -- "$CHANGELOG" @@ -152,14 +136,13 @@ runs: exit 1 fi - name: Write release patch + if: inputs.phase != 'build' shell: bash - env: - PATCH: ${{ inputs.patch }} 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 index c8d3836..6cec98d 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -1,9 +1,6 @@ name: Publish GitHub Release description: Resume a matching draft or verify an already published release inputs: - repository: - description: Owner and repository containing the tag - required: true tag: description: Existing Git tag for the release required: true @@ -31,7 +28,7 @@ runs: - name: Verify the tagged release request shell: bash env: - GH_REPO: ${{ inputs.repository }} + GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} COMMIT: ${{ inputs.commit }} TITLE: ${{ inputs.title }} @@ -50,16 +47,8 @@ runs: exit 1 ;; esac - read -r object_type object_sha < <( - gh api "repos/$GH_REPO/git/ref/tags/$TAG" --jq '[.object.type, .object.sha] | @tsv' - ) - while [[ "$object_type" == tag ]]; do - read -r object_type object_sha < <( - gh api "repos/$GH_REPO/git/tags/$object_sha" --jq '[.object.type, .object.sha] | @tsv' - ) - done - test "$object_type" == commit - test "$object_sha" == "$COMMIT" + 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 @@ -70,7 +59,7 @@ runs: test -f "$asset" name=${asset##*/} case "$name" in - .* | *[[:cntrl:]]*) + .* | *#* | *[[:cntrl:]]*) echo "::error::unsafe release asset name: $name" exit 1 ;; @@ -84,7 +73,7 @@ runs: - name: Inspect any existing release shell: bash env: - GH_REPO: ${{ inputs.repository }} + GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} TITLE: ${{ inputs.title }} NOTES_FILE: ${{ inputs.notes-file }} @@ -114,7 +103,7 @@ runs: - name: Create a missing draft shell: bash env: - GH_REPO: ${{ inputs.repository }} + GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} COMMIT: ${{ inputs.commit }} TITLE: ${{ inputs.title }} @@ -135,7 +124,7 @@ runs: - name: Upload missing draft assets shell: bash env: - GH_REPO: ${{ inputs.repository }} + GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} TITLE: ${{ inputs.title }} NOTES_FILE: ${{ inputs.notes-file }} @@ -173,18 +162,27 @@ runs: sleep 2 done if jq -e '.[0].draft' "$RUNNER_TEMP/release-current.json" >/dev/null; then - upload_url=$(jq -r '.[0].upload_url | split("{")[0]' "$RUNNER_TEMP/release-current.json") 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 - gh api -X POST "$upload_url?name=$(jq -nr --arg name "$name" '$name | @uri')" --input "$asset" \ - -H 'Content-Type: application/octet-stream' \ - >"$RUNNER_TEMP/release-uploaded.json" - jq -e --arg name "$name" --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' - .name == $name and .state == "uploaded" and - (.digest as $digest | any($assets[0][]; .name == $name and .digest == $digest)) - ' "$RUNNER_TEMP/release-uploaded.json" + 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 @@ -192,7 +190,7 @@ runs: - name: Verify the complete asset set shell: bash env: - GH_REPO: ${{ inputs.repository }} + GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} TITLE: ${{ inputs.title }} NOTES_FILE: ${{ inputs.notes-file }} @@ -225,7 +223,7 @@ runs: - name: Publish the draft shell: bash env: - GH_REPO: ${{ inputs.repository }} + GH_REPO: ${{ github.repository }} MODE: ${{ inputs.mode }} run: | set -euo pipefail @@ -236,7 +234,7 @@ runs: - name: Verify the final release state shell: bash env: - GH_REPO: ${{ inputs.repository }} + GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} TITLE: ${{ inputs.title }} NOTES_FILE: ${{ inputs.notes-file }} @@ -268,3 +266,25 @@ runs: 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-post-release-pr/action.yml b/publish-post-release-pr/action.yml index d3cd1d2..5066c85 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -1,15 +1,9 @@ name: Publish a prepared pull request description: Reconcile a prepared branch and pull request without checking out project code inputs: - repository: - description: Repository containing the base and pull request - required: true app-slug: description: Slug of the GitHub App whose bot authors the prepared commit required: true - base: - description: Protected base branch - default: main base-commit: description: Base commit used by the keyless preparation job required: true @@ -37,22 +31,14 @@ inputs: commit-message: description: Exact subject of the prepared candidate commit required: true - label: - description: Label to add to the pull request - default: skip news -outputs: - pull-request: - description: Reconciled pull request number - value: ${{ steps.publish.outputs.pull-request }} runs: using: composite steps: - name: Validate the prepared pull request inputs shell: bash env: - REPOSITORY: ${{ inputs.repository }} APP_SLUG: ${{ inputs.app-slug }} - BASE: ${{ inputs.base }} + BASE: main BASE_COMMIT: ${{ inputs.base-commit }} BRANCH: ${{ inputs.branch }} TITLE: ${{ inputs.title }} @@ -61,18 +47,8 @@ runs: BUNDLE: ${{ inputs.bundle }} CANDIDATE_COMMIT: ${{ inputs.candidate-commit }} COMMIT_MESSAGE: ${{ inputs.commit-message }} - LABEL: ${{ inputs.label }} run: | set -euo pipefail - case "$REPOSITORY" in - */*) ;; - *) exit 1 ;; - esac - for part in "${REPOSITORY%%/*}" "${REPOSITORY#*/}"; do - case "$part" in - '' | *[!A-Za-z0-9_.-]*) exit 1 ;; - esac - done case "$APP_SLUG" in '' | *[!A-Za-z0-9-]*) exit 1 ;; esac @@ -93,7 +69,6 @@ runs: [[ "$COMMIT_MESSAGE" != *$'\n'* ]] test -s "$BODY_FILE" test -s "$PATHS_FILE" - test -n "$LABEL" if [[ -n "$BUNDLE" ]]; then test -f "$BUNDLE" test "${#CANDIDATE_COMMIT}" -eq 40 @@ -182,17 +157,16 @@ runs: - name: Inspect the existing pull request shell: bash env: - REPOSITORY: ${{ inputs.repository }} - BASE: ${{ inputs.base }} + BASE: main BRANCH: ${{ inputs.branch }} TITLE: ${{ inputs.title }} BODY_FILE: ${{ inputs.body-file }} run: | set -euo pipefail - head_owner=${REPOSITORY%%/*} - gh api -X GET "repos/$REPOSITORY/pulls" -f state=all -f "head=$head_owner:$BRANCH" -f per_page=100 \ + 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 "$REPOSITORY" --arg title "$TITLE" --arg base "$BASE" \ + 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 @@ -208,33 +182,31 @@ runs: - name: Verify an already merged pull request shell: bash env: - REPOSITORY: ${{ inputs.repository }} - BASE: ${{ inputs.base }} + 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/$REPOSITORY/git/ref/heads/$BASE" --jq .object.sha) + 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/$REPOSITORY/contents/$path?ref=$BASE" --jq '[.type, .sha] | @tsv') + 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/$REPOSITORY/pulls/$pr_number/files" --paginate --jq '.[].filename' | + 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: - REPOSITORY: ${{ inputs.repository }} APP_SLUG: ${{ inputs.app-slug }} - BASE: ${{ inputs.base }} + BASE: main BASE_COMMIT: ${{ inputs.base-commit }} BRANCH: ${{ inputs.branch }} BUNDLE: ${{ inputs.bundle }} @@ -244,11 +216,11 @@ runs: 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/$REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT" + 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/$REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha \ + if branch_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha \ 2>"$RUNNER_TEMP/branch-error"); then - gh api "repos/$REPOSITORY/git/commits/$branch_sha" >"$RUNNER_TEMP/post-release-branch.json" + 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" @@ -265,12 +237,12 @@ runs: "$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/$REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT" + 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}/$REPOSITORY.git" + "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/$REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha) + 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 @@ -281,8 +253,7 @@ runs: - name: Create or verify the pull request shell: bash env: - REPOSITORY: ${{ inputs.repository }} - BASE: ${{ inputs.base }} + BASE: main BRANCH: ${{ inputs.branch }} TITLE: ${{ inputs.title }} BODY_FILE: ${{ inputs.body-file }} @@ -291,36 +262,32 @@ runs: 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/$REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)" = "$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/$REPOSITORY/pulls" -f "head=$BRANCH" -f "base=$BASE" \ + 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/$REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)" = "$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 - - id: publish - name: Ensure the maintenance label + - name: Ensure the maintenance label shell: bash env: - REPOSITORY: ${{ inputs.repository }} - LABEL: ${{ inputs.label }} run: | set -euo pipefail pr_number=$(cat "$RUNNER_TEMP/post-release-number") - labels=$(gh api "repos/$REPOSITORY/issues/$pr_number/labels" --jq '.[].name') - if ! grep -Fxq "$LABEL" <<<"$labels"; then + 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/$REPOSITORY/issues/$pr_number/labels" -f "labels[]=$LABEL" >/dev/null + gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" -f 'labels[]=skip news' >/dev/null fi - printf 'pull-request=%s\n' "$pr_number" >>"$GITHUB_OUTPUT" diff --git a/pylock.toml b/pylock.toml index 0516ee2..dd9bb9e 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1,289 +1,307 @@ lock-version = "1.0" -created-by = "release-creator (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" -extras = [] dependency-groups = [ + "build", + "lock-tools", "project-runtime", - "release", - "release-notes", + "release-attestations", + "release-check", + "release-hook-check", + "release-hooks", "release-news", + "release-notes", "release-prepare", - "release-validate", - "release-check", "release-shell", - "release-hooks", - "test", - "build", + "release-validate", +] +default-groups = [ + "project-runtime", ] -default-groups = [] +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" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-shell\" in dependency_groups" +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" -upload-time = 2026-08-06 04:53:21+00:00 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" -upload-time = 2026-08-06 04:53:20+00:00 size = 43294 [packages.wheels.hashes] sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" [[packages]] -name = "cachetools" -version = "7.2.1" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/31/44/71476a5812da1ddf2c9a3efd31ae76d01480a1cf03ed13ac28aa8f2402e4/cachetools-7.2.1.tar.gz" -upload-time = 2026-10-05 18:40:06+00:00 -size = 41357 +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 = "b1a7537025c06abf96fcc1443e496af9a3fb95e774e70e1f0af226f73f7f2dcc" +sha256 = "51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f0/c9/2a61d784caf0d869a3326728c57c7203f50cc53f3cca2ee76bf924769eb4/cachetools-7.2.1-py3-none-any.whl" -upload-time = 2026-10-05 18:40:04+00:00 -size = 17006 +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 = "63aa53dfe7473c10cccdd5a01dedf76ef2c4b73a58840d9396e7d0752cbdac3b" +sha256 = "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7" [[packages]] -name = "cffi" -version = "2.1.1" -index = "https://pypi.org/simple" -marker = "(\"test\" in dependency_groups and (platform_python_implementation != 'PyPy'))" +name = "certifi" +version = "2026.7.22" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz" -upload-time = 2026-08-03 21:21:18+00:00 -size = 530807 +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 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" +sha256 = "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl" -upload-time = 2026-08-03 21:20:48+00:00 -size = 194043 +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 = "dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl" -upload-time = 2026-08-03 21:20:49+00:00 -size = 196737 +sha256 = "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" -[packages.wheels.hashes] -sha256 = "da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b" +[[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.wheels]] -url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl" -upload-time = 2026-08-03 21:20:50+00:00 -size = 184933 +[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.wheels.hashes] -sha256 = "fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4" +[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" -upload-time = 2026-08-03 21:20:52+00:00 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" -upload-time = 2026-08-03 21:20:53+00:00 size = 222271 [packages.wheels.hashes] sha256 = "507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl" -upload-time = 2026-08-03 21:20:54+00:00 -size = 209919 - -[packages.wheels.hashes] -sha256 = "246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl" -upload-time = 2026-08-03 21:20:56+00:00 -size = 208529 - -[packages.wheels.hashes] -sha256 = "471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779" - -[[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" -upload-time = 2026-08-03 21:20:57+00:00 size = 221630 [packages.wheels.hashes] sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl" -upload-time = 2026-08-03 21:20:58+00:00 -size = 225134 - -[packages.wheels.hashes] -sha256 = "42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl" -upload-time = 2026-08-03 21:20:59+00:00 -size = 223197 - -[packages.wheels.hashes] -sha256 = "cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl" -upload-time = 2026-08-03 21:21:14+00:00 -size = 177683 - -[packages.wheels.hashes] -sha256 = "4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac" - -[[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" -upload-time = 2026-08-03 21:21:16+00:00 size = 187897 [packages.wheels.hashes] sha256 = "e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl" -upload-time = 2026-08-03 21:21:17+00:00 -size = 182935 - -[packages.wheels.hashes] -sha256 = "27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl" -upload-time = 2026-08-03 21:21:01+00:00 -size = 188464 - -[packages.wheels.hashes] -sha256 = "c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc" +[[packages]] +name = "charset-normalizer" +version = "3.5.2" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-08-03 21:21:02+00:00 -size = 188262 +[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.wheels.hashes] -sha256 = "4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab" +[packages.sdist.hashes] +sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-08-03 21:21:03+00:00 -size = 223779 +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 = "937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e" +sha256 = "87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl" -upload-time = 2026-08-03 21:21:04+00:00 -size = 211520 +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 = "df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358" +sha256 = "9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl" -upload-time = 2026-08-03 21:21:06+00:00 -size = 210673 +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 = "a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231" +sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-08-03 21:21:07+00:00 -size = 223835 +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 = "363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6" +sha256 = "89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl" -upload-time = 2026-08-03 21:21:08+00:00 -size = 226705 +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 = "770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94" +sha256 = "4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl" -upload-time = 2026-08-03 21:21:09+00:00 -size = 225539 +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 = "7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5" +sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl" -upload-time = 2026-08-03 21:21:11+00:00 -size = 182707 +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 = "06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66" +sha256 = "d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl" -upload-time = 2026-08-03 21:21:12+00:00 -size = 193772 +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 = "d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3" +sha256 = "9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl" -upload-time = 2026-08-03 21:21:13+00:00 -size = 186360 +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 = "d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692" +sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" [[packages]] name = "click" version = "8.5.0" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-prepare\" 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" -upload-time = 2026-08-26 13:33:14+00:00 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" -upload-time = 2026-08-26 13:33:12+00:00 size = 125251 [packages.wheels.hashes] @@ -292,20 +310,23 @@ sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" [[packages]] name = "colorama" version = "0.4.6" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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" -upload-time = 2022-10-25 02:36:22+00:00 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" -upload-time = 2022-10-25 02:36:20+00:00 size = 25335 [packages.wheels.hashes] @@ -314,324 +335,233 @@ sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" [[packages]] name = "cryptography" version = "50.0.2" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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" -upload-time = 2026-09-30 15:30:04+00:00 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" -upload-time = 2026-09-30 14:43:44+00:00 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" -upload-time = 2026-09-30 14:43:47+00:00 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" -upload-time = 2026-09-30 14:43:49+00:00 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" -upload-time = 2026-09-30 14:43:50+00:00 size = 4738340 [packages.wheels.hashes] sha256 = "f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl" -upload-time = 2026-09-30 14:43:52+00:00 -size = 5367029 - -[packages.wheels.hashes] -sha256 = "a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134" - -[[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" -upload-time = 2026-09-30 14:43:55+00:00 size = 4753050 [packages.wheels.hashes] sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl" -upload-time = 2026-09-30 14:43:57+00:00 -size = 4376724 - -[packages.wheels.hashes] -sha256 = "ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e" - -[[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" -upload-time = 2026-09-30 14:43:59+00:00 size = 4737859 [packages.wheels.hashes] sha256 = "87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl" -upload-time = 2026-09-30 14:44:01+00:00 -size = 5324103 - -[packages.wheels.hashes] -sha256 = "f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc" - -[[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" -upload-time = 2026-09-30 14:44:04+00:00 size = 4752576 [packages.wheels.hashes] sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 14:44:06+00:00 -size = 4870819 - -[packages.wheels.hashes] -sha256 = "25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 14:44:09+00:00 -size = 5030152 - -[packages.wheels.hashes] -sha256 = "85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93" - -[[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" -upload-time = 2026-09-30 14:44:11+00:00 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" -upload-time = 2026-09-30 14:44:41+00:00 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" -upload-time = 2026-09-30 14:44:43+00:00 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" -upload-time = 2026-09-30 14:44:45+00:00 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" -upload-time = 2026-09-30 14:44:48+00:00 size = 4959356 [packages.wheels.hashes] sha256 = "e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl" -upload-time = 2026-09-30 14:44:50+00:00 -size = 5548822 - -[packages.wheels.hashes] -sha256 = "b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d" - -[[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" -upload-time = 2026-09-30 14:44:53+00:00 size = 5001199 [packages.wheels.hashes] sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl" -upload-time = 2026-09-30 14:44:55+00:00 -size = 4629333 - -[packages.wheels.hashes] -sha256 = "d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408" - -[[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" -upload-time = 2026-09-30 14:44:59+00:00 size = 4958822 [packages.wheels.hashes] sha256 = "fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl" -upload-time = 2026-09-30 14:45:02+00:00 -size = 5506351 - -[packages.wheels.hashes] -sha256 = "cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd" - -[[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" -upload-time = 2026-09-30 14:45:05+00:00 size = 5000859 [packages.wheels.hashes] sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 15:29:15+00:00 -size = 5092151 - -[packages.wheels.hashes] -sha256 = "6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 15:29:18+00:00 -size = 5286120 - -[packages.wheels.hashes] -sha256 = "c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020" - -[[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" -upload-time = 2026-09-30 15:29:20+00:00 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" -upload-time = 2026-09-30 15:29:22+00:00 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" -upload-time = 2026-09-30 15:29:24+00:00 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" -upload-time = 2026-09-30 15:29:26+00:00 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" -upload-time = 2026-09-30 15:29:28+00:00 size = 4763638 [packages.wheels.hashes] sha256 = "dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl" -upload-time = 2026-09-30 15:29:30+00:00 -size = 5382217 - -[packages.wheels.hashes] -sha256 = "58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd" - -[[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" -upload-time = 2026-09-30 15:29:32+00:00 size = 4781387 [packages.wheels.hashes] sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl" -upload-time = 2026-09-30 15:29:34+00:00 -size = 4403790 - -[packages.wheels.hashes] -sha256 = "9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107" - -[[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" -upload-time = 2026-09-30 15:29:36+00:00 size = 4764319 [packages.wheels.hashes] sha256 = "0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl" -upload-time = 2026-09-30 15:29:39+00:00 -size = 5338560 - -[packages.wheels.hashes] -sha256 = "a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227" - -[[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" -upload-time = 2026-09-30 15:29:41+00:00 size = 4780973 [packages.wheels.hashes] sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 15:29:43+00:00 -size = 4897738 - -[packages.wheels.hashes] -sha256 = "7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 15:29:44+00:00 -size = 5058280 - -[packages.wheels.hashes] -sha256 = "ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94" - -[[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" -upload-time = 2026-09-30 15:29:46+00:00 size = 3854095 [packages.wheels.hashes] @@ -641,254 +571,218 @@ sha256 = "4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de" 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" -upload-time = 2026-08-22 05:16:39+00:00 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" -upload-time = 2026-08-22 05:17:32+00:00 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" -upload-time = 2026-08-22 05:17:34+00:00 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" -upload-time = 2026-08-22 05:17:36+00:00 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" -upload-time = 2026-08-22 05:17:38+00:00 size = 2924709 [packages.wheels.hashes] sha256 = "b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/14/59/bc1a84b434cb5bebb0cd6f50da8f239d35a5c141b20fdeafc2817fd87778/cython-3.3.0-cp39-abi3-macosx_10_9_x86_64.whl" -upload-time = 2026-08-22 05:17:40+00:00 -size = 3063660 - -[packages.wheels.hashes] -sha256 = "e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ba/6d/542e32908fb421d88354f327ed6450e14240f9825d25393065bc65f4723f/cython-3.3.0-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl" -upload-time = 2026-08-22 05:17:43+00:00 -size = 3358395 - -[packages.wheels.hashes] -sha256 = "169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9c/7c/ddaf197bc65b581e1891657940bc4f7cb1f740e822115e828920b3a119ce/cython-3.3.0-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl" -upload-time = 2026-08-22 05:17:44+00:00 -size = 3041760 - -[packages.wheels.hashes] -sha256 = "29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/19/a7/ae5ec3e34d43da846ed4c425734752d83aae0dae49feb929f09c90fc9afa/cython-3.3.0-cp39-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-08-22 05:17:46+00:00 -size = 3156152 - -[packages.wheels.hashes] -sha256 = "75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/31/44/c60b601fc43f0b08e9d6f14b94e0dd02eb0ca8d60f46e242ace7191ac1be/cython-3.3.0-cp39-abi3-musllinux_1_2_armv7l.whl" -upload-time = 2026-08-22 05:17:48+00:00 -size = 3046690 - -[packages.wheels.hashes] -sha256 = "b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b0/9e/d735c26ed907563d3365534006acb263651c2d3b87fee804f7a483dd1714/cython-3.3.0-cp39-abi3-musllinux_1_2_i686.whl" -upload-time = 2026-08-22 05:17:50+00:00 -size = 3373534 - -[packages.wheels.hashes] -sha256 = "c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e0/e8/aa7b4f3a28d6e8117c76e2cf78a0df7a503486cdf7243c5b53200c9533a1/cython-3.3.0-cp39-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-08-22 05:17:52+00:00 -size = 3265110 - -[packages.wheels.hashes] -sha256 = "311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9c/66/37892a8999d6bbd3f92d691a9701cb720c8ddd6171e16f5148eee6e8cb7f/cython-3.3.0-cp39-abi3-win32.whl" -upload-time = 2026-08-22 05:17:54+00:00 -size = 2587733 - -[packages.wheels.hashes] -sha256 = "90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/19/a2/5f4d305cbd4489d21570e5491ad5c483c478cdab032853e2125c280e3bd5/cython-3.3.0-cp39-abi3-win_arm64.whl" -upload-time = 2026-08-22 05:17:56+00:00 -size = 2608078 - -[packages.wheels.hashes] -sha256 = "dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd" - -[[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" -upload-time = 2026-08-22 05:16:36+00:00 size = 1349151 [packages.wheels.hashes] sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1" [[packages]] -name = "distlib" -version = "0.4.3" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +name = "dnspython" +version = "2.9.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz" -upload-time = 2026-06-12 08:04:52+00:00 -size = 615141 +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 = "f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed" +sha256 = "b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl" -upload-time = 2026-06-12 08:04:50+00:00 -size = 470628 +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 = "4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b" +sha256 = "9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9" [[packages]] -name = "execnet" -version = "2.1.2" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz" -upload-time = 2025-11-12 09:56:37+00:00 -size = 166622 +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 = "63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd" +sha256 = "9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl" -upload-time = 2025-11-12 09:56:36+00:00 -size = 40708 +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 = "67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec" +sha256 = "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" [[packages]] -name = "filelock" -version = "4.2.0" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz" -upload-time = 2026-10-14 20:57:13+00:00 -size = 569667 +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 = "7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e" +sha256 = "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl" -upload-time = 2026-10-14 20:57:11+00:00 -size = 135032 +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 = "2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238" +sha256 = "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca" [[packages]] name = "idna" version = "3.20" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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" -upload-time = 2026-09-17 14:11:04+00:00 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" -upload-time = 2026-09-17 14:11:03+00:00 size = 69583 [packages.wheels.hashes] sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" [[packages]] -name = "iniconfig" -version = "2.3.1" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +name = "installer" +version = "1.1.0" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz" -upload-time = 2026-10-06 22:48:38+00:00 -size = 21209 +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 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" +sha256 = "5adc0c01ce62d809989e246987cd68944fe5df8af562d0358f65b2ab15cb5de7" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl" -upload-time = 2026-10-06 22:48:36+00:00 -size = 7552 +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 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" +sha256 = "f55f5728ff61d3dd54dbc5a397cf3f7307cb64b180c20b72afbebf01c9e2c8a0" [[packages]] name = "jinja2" version = "3.1.6" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-prepare\" 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" -upload-time = 2025-03-05 20:05:02+00:00 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" -upload-time = 2025-03-05 20:05:00+00:00 size = 134899 [packages.wheels.hashes] @@ -897,20 +791,26 @@ sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" [[packages]] name = "markdown-it-py" version = "4.2.0" -index = "https://pypi.org/simple" -marker = "\"release\" 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" +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" -upload-time = 2026-05-07 12:08:28+00:00 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" -upload-time = 2026-05-07 12:08:27+00:00 size = 91687 [packages.wheels.hashes] @@ -919,402 +819,371 @@ sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" version = "3.0.4" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-news\" in dependency_groups or \"release-prepare\" 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" -upload-time = 2026-10-02 23:07:22+00:00 size = 153777 [packages.sdist.hashes] sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl" -upload-time = 2026-10-02 23:06:10+00:00 -size = 12791 - -[packages.wheels.hashes] -sha256 = "737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl" -upload-time = 2026-10-02 23:06:11+00:00 -size = 12527 - -[packages.wheels.hashes] -sha256 = "489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl" -upload-time = 2026-10-02 23:06:13+00:00 -size = 11602 - -[packages.wheels.hashes] -sha256 = "077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl" -upload-time = 2026-10-02 23:06:14+00:00 -size = 11817 - -[packages.wheels.hashes] -sha256 = "9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl" -upload-time = 2026-10-02 23:06:15+00:00 -size = 11446 - -[packages.wheels.hashes] -sha256 = "b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl" -upload-time = 2026-10-02 23:06:16+00:00 -size = 11719 - -[packages.wheels.hashes] -sha256 = "799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8" - -[[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" -upload-time = 2026-10-02 23:06:17+00:00 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" -upload-time = 2026-10-02 23:06:18+00:00 size = 24656 [packages.wheels.hashes] sha256 = "4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl" -upload-time = 2026-10-02 23:06:20+00:00 -size = 27737 - -[packages.wheels.hashes] -sha256 = "3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl" -upload-time = 2026-10-02 23:06:21+00:00 -size = 25003 - -[packages.wheels.hashes] -sha256 = "04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653" - -[[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" -upload-time = 2026-10-02 23:06:22+00:00 size = 23111 [packages.wheels.hashes] sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" -upload-time = 2026-10-02 23:06:23+00:00 -size = 22403 +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 = "f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19" +sha256 = "d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-02 23:06:25+00:00 -size = 24083 +[[packages]] +name = "mdformat" +version = "1.0.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.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" -[packages.wheels.hashes] -sha256 = "e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e" +[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]] -url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl" -upload-time = 2026-10-02 23:06:26+00:00 -size = 22931 +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 = "55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811" +sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl" -upload-time = 2026-10-02 23:06:27+00:00 -size = 24299 +[[packages]] +name = "mdformat-gfm" +version = "1.0.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.10" +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdit-py-plugins" }, + { name = "wcwidth" }, +] +index = "https://pypi.org/simple/" -[packages.wheels.hashes] -sha256 = "2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea" +[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]] -url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl" -upload-time = 2026-10-02 23:06:29+00:00 -size = 21948 +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 = "94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916" +sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-02 23:06:30+00:00 -size = 23150 +[[packages]] +name = "mdit-py-plugins" +version = "0.6.1" +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.wheels.hashes] -sha256 = "2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741" +[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]] -url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl" -upload-time = 2026-10-02 23:06:31+00:00 -size = 14269 +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 = "03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl" -upload-time = 2026-10-02 23:06:33+00:00 -size = 14496 - -[packages.wheels.hashes] -sha256 = "d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl" -upload-time = 2026-10-02 23:06:34+00:00 -size = 14361 - -[packages.wheels.hashes] -sha256 = "353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl" -upload-time = 2026-10-02 23:06:35+00:00 -size = 11760 - -[packages.wheels.hashes] -sha256 = "c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-10-02 23:06:37+00:00 -size = 12108 - -[packages.wheels.hashes] -sha256 = "1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" -upload-time = 2026-10-02 23:06:38+00:00 -size = 25762 - -[packages.wheels.hashes] -sha256 = "02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl" -upload-time = 2026-10-02 23:06:39+00:00 -size = 28400 - -[packages.wheels.hashes] -sha256 = "05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl" -upload-time = 2026-10-02 23:06:41+00:00 -size = 25946 - -[packages.wheels.hashes] -sha256 = "be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" -upload-time = 2026-10-02 23:06:42+00:00 -size = 23634 - -[packages.wheels.hashes] -sha256 = "26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" -upload-time = 2026-10-02 23:06:44+00:00 -size = 23488 +sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" -[packages.wheels.hashes] -sha256 = "f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe" +[[packages]] +name = "mdurl" +version = "0.1.2" +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.wheels]] -url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-02 23:06:45+00:00 -size = 25037 +[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.wheels.hashes] -sha256 = "436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2" +[packages.sdist.hashes] +sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl" -upload-time = 2026-10-02 23:06:47+00:00 -size = 23543 +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 = "4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl" -upload-time = 2026-10-02 23:06:48+00:00 -size = 25282 +sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" -[packages.wheels.hashes] -sha256 = "da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494" +[[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.wheels]] -url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl" -upload-time = 2026-10-02 23:06:50+00:00 -size = 22993 +[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.wheels.hashes] -sha256 = "aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d" +[packages.sdist.hashes] +sha256 = "a0da54ee96bf1eb1997581566620c250c8a3557139e9467ccaced2a12e9e71ac" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-02 23:06:51+00:00 -size = 23712 +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 = "b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894" +sha256 = "f62fcf75b28786fbf58627f6d7eaa735daefbc7f736715403bc4357acb57e7bb" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl" -upload-time = 2026-10-02 23:06:53+00:00 -size = 14300 - -[packages.wheels.hashes] -sha256 = "e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78" +[[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.wheels]] -url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl" -upload-time = 2026-10-02 23:06:54+00:00 -size = 14522 +[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.wheels.hashes] -sha256 = "4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c" +[packages.sdist.hashes] +sha256 = "5900ea5ad298512379cfddff1a73a544590f0b04620ef58ff345f99a7c59688e" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl" -upload-time = 2026-10-02 23:06:56+00:00 -size = 14417 +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 = "39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba" +sha256 = "c89ffa607dfe4246dc59e9f5236d54413da3d310183ffa3298ba51a48e7c2ec7" [[packages]] -name = "mdformat" -version = "1.0.0" -index = "https://pypi.org/simple" -marker = "\"release\" 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" +name = "nab-markersets" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" -upload-time = 2025-10-16 12:05:03+00:00 -size = 56953 +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 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" +sha256 = "38212664f517f75d798808ecfe902bfd11fef340042ab0203100c7e06e78eea5" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" -upload-time = 2025-10-16 12:05:02+00:00 -size = 53288 +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 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" +sha256 = "ad268dcece034495b2ea31a70250cd43cc4f6123039a548762c7ec8826e5d3bd" [[packages]] -name = "mdformat-gfm" -version = "1.0.0" -index = "https://pypi.org/simple" -marker = "\"release\" 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" +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] -url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" -upload-time = 2025-10-16 09:12:22+00:00 -size = 10112 +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 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" +sha256 = "5f7a1dee5b618149267b2b6233c3a220e45cd6f97bd5ec7f3f48fa7570e9f053" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" -upload-time = 2025-10-16 09:12:21+00:00 -size = 10970 +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 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" +sha256 = "cb09fa143105fd99a60609b3ff4830924b0d4eaf5abad48678467ec4b73e577f" [[packages]] -name = "mdit-py-plugins" -version = "0.6.1" -index = "https://pypi.org/simple" -marker = "\"release\" 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" +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] -url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" -upload-time = 2026-05-13 09:03:38+00:00 -size = 56114 +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 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" +sha256 = "3f129ab7394bf5eb031152c3535ee74073e2a6b6ae081fb7f5a4d269fcf5f8a0" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" -upload-time = 2026-05-13 09:03:37+00:00 -size = 66663 +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 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" +sha256 = "1450c9dc58b23da1a7bde7fdb43a72b043327bd0e3ef20435254ffcb8b7eae6b" [[packages]] -name = "mdurl" -version = "0.1.2" -index = "https://pypi.org/simple" -marker = "\"release\" 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" +name = "nab-resolver" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" -upload-time = 2022-08-14 12:40:10+00:00 -size = 8729 +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 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" +sha256 = "e005b225213e032858cf4aef3850961aaa1d4ea521428da731f33175ebd3b05f" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" -upload-time = 2022-08-14 12:40:09+00:00 -size = 9979 +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 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" +sha256 = "6f77daa98279ef0eedb9e8f2248aee4c50e01b92b470b68886995e5056d79971" [[packages]] name = "packaging" version = "26.3" -index = "https://pypi.org/simple" -marker = "\"release\" 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 or \"test\" in dependency_groups" +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" -upload-time = 2026-08-04 18:15:28+00:00 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" -upload-time = 2026-08-04 18:15:27+00:00 size = 129956 [packages.wheels.hashes] @@ -1322,297 +1191,376 @@ sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" [[packages]] name = "platformdirs" -version = "4.13.0" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz" -upload-time = 2026-10-11 02:05:24+00:00 -size = 61094 - -[packages.sdist.hashes] -sha256 = "1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl" -upload-time = 2026-10-11 02:05:22+00:00 -size = 32724 - -[packages.wheels.hashes] -sha256 = "3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1" - -[[packages]] -name = "pluggy" -version = "1.7.0" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +version = "4.13.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz" -upload-time = 2026-10-15 09:50:58+00:00 -size = 123304 +name = "platformdirs-4.13.1.tar.gz" +upload-time = 2026-10-15 21:56:08.441933+00:00 +url = "https://files.pythonhosted.org/packages/16/35/146546aa5b9286a83248d29c51781476bbb4b0a608523361120059e0f7ed/platformdirs-4.13.1.tar.gz" +size = 61458 [packages.sdist.hashes] -sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" +sha256 = "ee77cf2d36d5a515b2acf68ede6434cf3a8fc70b822bb6b3264d01b96c69a8e0" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl" -upload-time = 2026-10-15 09:50:56+00:00 -size = 27082 +name = "platformdirs-4.13.1-py3-none-any.whl" +upload-time = 2026-10-15 21:56:07.059862+00:00 +url = "https://files.pythonhosted.org/packages/aa/0f/ac79a4e8394193bb228abaaf1c520f629b6ada7fcb75e58cdc8c6acce321/platformdirs-4.13.1-py3-none-any.whl" +size = 32737 [packages.wheels.hashes] -sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" +sha256 = "4902b4ad355cb0b202c746b29580ab67c5c2d918e30e3d006353465b82ec3ecc" [[packages]] name = "prek" version = "0.5.5" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-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" +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" -upload-time = 2026-10-05 06:08:59+00:00 size = 570428 [packages.sdist.hashes] sha256 = "16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2e/77/189e554ea92baf6c1d33733b3e9d1ed819ab7d77e57eb2e3ead0bb1d8060/prek-0.5.5-py3-none-macosx_10_12_x86_64.whl" -upload-time = 2026-10-05 06:08:39+00:00 -size = 6038669 - -[packages.wheels.hashes] -sha256 = "50fbceb0f728b504023f6e6c9037ac0656436949d94f7a85bac5542e6c1a4a75" - -[[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" -upload-time = 2026-10-05 06:08:41+00:00 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" -upload-time = 2026-10-05 06:08:44+00:00 size = 5848228 [packages.wheels.hashes] sha256 = "120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/aa/f9/1279e1c83819de5904db1a09f4afc589932a2e62c7063713b1fe3a54ee46/prek-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-05 06:08:46+00:00 -size = 6371296 - -[packages.wheels.hashes] -sha256 = "2f6c453a12c21757b23bbbc97415aef78527841ad3f9e61f52cb35cf012f93e5" - -[[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" -upload-time = 2026-10-05 06:08:48+00:00 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" -upload-time = 2026-10-05 06:08:50+00:00 size = 5861518 [packages.wheels.hashes] sha256 = "76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b7/03/690fba9543033e25ea2e7b2ae1cca1ae8d8c2e32295f0d66f4b50934b8c7/prek-0.5.5-py3-none-musllinux_1_1_x86_64.whl" -upload-time = 2026-10-05 06:08:52+00:00 -size = 6383685 - -[packages.wheels.hashes] -sha256 = "c69f60c2b3b4d481e38d0eab3b97ac05ec05d7648ae690925ae95ca9d22c7643" - -[[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" -upload-time = 2026-10-05 06:08:55+00:00 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]] -url = "https://files.pythonhosted.org/packages/41/d9/bb6811e438d3c74d26cb5d72e6343606e0a7a6283fee87521189f4ba1191/prek-0.5.5-py3-none-win_arm64.whl" -upload-time = 2026-10-05 06:08:57+00:00 -size = 5517355 +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 = "3db3191d2509830c75438e20956a47c2259867d18a4106064ed75088b968a0c6" +sha256 = "deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b" [[packages]] name = "pycparser" version = "3.11" -index = "https://pypi.org/simple" -marker = "(\"test\" in dependency_groups and (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'))" +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" -upload-time = 2026-10-09 12:56:59+00:00 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" -upload-time = 2026-10-09 12:56:58+00:00 size = 51178 [packages.wheels.hashes] sha256 = "51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80" [[packages]] -name = "pygments" -version = "2.21.0" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" -upload-time = 2026-08-17 08:02:48+00:00 -size = 5005329 +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 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" +sha256 = "94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" -upload-time = 2026-08-17 08:02:44+00:00 -size = 1250147 +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 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" +sha256 = "9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454" [[packages]] -name = "pyproject-api" -version = "1.11.6" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/9c/ef/7e422ead671468b094299f54a1e0be12cf47a1c291b25c6001e60b1d1814/pyproject_api-1.11.6.tar.gz" -upload-time = 2026-10-14 07:08:56+00:00 -size = 25938 +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 = "c275cb4600f831ac5337d719b2bc547f0656173c05a923a66dce741d91f9e09d" +sha256 = "e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/53/28/7e78bf8836755945c0147b5521e1f7d7a60d83154ab13b85c66b4c47a917/pyproject_api-1.11.6-py3-none-any.whl" -upload-time = 2026-10-14 07:08:55+00:00 -size = 13580 +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 = "7601f5f7b738b9007f02004347c7a831c579ba943dab18123f852c193766ecae" +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 = "pytest" -version = "9.1.1" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +name = "pygments" +version = "2.21.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz" -upload-time = 2026-06-19 10:58:32+00:00 -size = 1636369 +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 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" +sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl" -upload-time = 2026-06-19 10:58:31+00:00 -size = 386536 +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 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" +sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" [[packages]] -name = "pytest-httpserver" -version = "1.2.0" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +name = "pyjwt" +version = "2.15.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/37/ff/81c955e8fbb0323e817d178dd653de90c81896e819a5df65c1e1c327155a/pytest_httpserver-1.2.0.tar.gz" -upload-time = 2026-10-05 19:00:25+00:00 -size = 71860 +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 = "d07744ce2efaafa0575ed3081d416b999c773b09e84aa95065138bedc3e12352" +sha256 = "4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8d/34/aa11718873f8cd3d9b3c58938e537af0c2d9cb108160357f5db2af6ddd66/pytest_httpserver-1.2.0-py3-none-any.whl" -upload-time = 2026-10-05 19:00:26+00:00 -size = 23392 +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 = "749937892dbf909c8b703503b491be66fc24073e7988bf9b4d783af4f33da942" +sha256 = "42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193" [[packages]] -name = "pytest-xdist" -version = "3.8.0" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz" -upload-time = 2025-07-01 13:30:59+00:00 -size = 88069 +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 = "7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" +sha256 = "28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl" -upload-time = 2025-07-01 13:30:56+00:00 -size = 46396 +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 = "202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88" +sha256 = "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c" [[packages]] -name = "python-discovery" -version = "1.6.2" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/b0/73/54993df8fc57e906dc9b7e01d1d9b0b2d3eb0ce3190639e33dccb12853f7/python_discovery-1.6.2.tar.gz" -upload-time = 2026-10-10 01:24:27+00:00 -size = 86921 +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 = "cd1738ca1d37c86ef9d0b654dd46fcee1e41c97c6add12575e8501ced39afdb4" +sha256 = "14ff13c76bbef02a483c2b77532da02bfd746b5141b881f355e06b9807434423" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/61/3b/50ed32583bfffa737df67d33b9c15d7b25df232c55ab606d059f610ef6ca/python_discovery-1.6.2-py3-none-any.whl" -upload-time = 2026-10-10 01:24:26+00:00 -size = 38912 +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 = "f0c697f95a3aaec4174a6e5e58f5885e25768684bafc76f1afde384709ebdcf2" +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" -index = "https://pypi.org/simple" -marker = "\"release\" 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 or \"test\" in dependency_groups" +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" -upload-time = 2025-09-25 21:33:16+00:00 size = 130960 [packages.sdist.hashes] @@ -1621,735 +1569,701 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" version = "0.5.1" -index = "https://pypi.org/simple" -marker = "\"release\" 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" +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 = "mdformat" }, + { name = "mdformat-gfm" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tomli-w" }, +] +index = "https://pypi.org/simple/" [packages.sdist] +name = "release_creator-0.5.1.tar.gz" +upload-time = 2026-10-22 13:51:55.787829+00:00 url = "https://files.pythonhosted.org/packages/65/d3/83211b4ebfbaf87383392aed6ef3aa98d65e0ade613a2aa0ea0f222cb6b1/release_creator-0.5.1.tar.gz" -upload-time = 2026-10-22 13:51:55+00:00 size = 158621 [packages.sdist.hashes] sha256 = "755f8d460a493789756ab1bd4bbda0dd90145699288239085dd712cba2d84251" [[packages.wheels]] +name = "release_creator-0.5.1-py3-none-any.whl" +upload-time = 2026-10-22 13:51:54.135682+00:00 url = "https://files.pythonhosted.org/packages/5f/88/fab14c09530c8691040ec040613476d645414e2db1123c35b3818d291cea/release_creator-0.5.1-py3-none-any.whl" -upload-time = 2026-10-22 13:51:54+00:00 size = 50983 [packages.wheels.hashes] sha256 = "7aa5f28c5195f6f5e0493abede9c041a58d015bc3a837feee5669cc2062d6053" [[packages]] -name = "setuptools" -version = "84.0.0" -marker = "\"build\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz" -upload-time = 2026-08-08 18:27:58+00:00 -size = 1168449 +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 = "f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" +sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl" -upload-time = 2026-08-08 18:27:56+00:00 -size = 818216 +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 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" +sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" [[packages]] -name = "tomli-w" -version = "1.2.0" -index = "https://pypi.org/simple" -marker = "\"release\" 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 or \"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz" -upload-time = 2025-01-15 12:07:24+00:00 -size = 7184 +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 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021" +sha256 = "66997415086d2c6e5d57a2d315157b32a9c3f4f1312877ca8eb55e2e0ddf8ce4" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl" -upload-time = 2025-01-15 12:07:22+00:00 -size = 6675 +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 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" +sha256 = "a7004edfbf1bb7ec7801978a39fa7a7fe385ca59673df90d7578079b6eba3b24" -[[packages]] -name = "tomlkit" -version = "0.15.1" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-shell\" in dependency_groups" +[[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.sdist] -url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz" -upload-time = 2026-07-17 01:48:04+00:00 -size = 180129 +[packages.wheels.hashes] +sha256 = "7b703b233dbee7228d117d5f1c118363c61d523fa6f018c9217c79e3c6684f86" -[packages.sdist.hashes] -sha256 = "e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97" +[[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]] -url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl" -upload-time = 2026-07-17 01:48:05+00:00 -size = 49449 +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 = "177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304" +sha256 = "908f6a775da4bdce1d39f825a4431f389542696078ef3e4ed911a068bcaf1792" [[packages]] -name = "towncrier" -version = "26.9.0" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +name = "rfc3986" +version = "2.0.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" -upload-time = 2026-09-04 12:57:25+00:00 -size = 79338 +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 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" +sha256 = "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" -upload-time = 2026-09-04 12:57:23+00:00 -size = 67030 +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 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" +sha256 = "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" [[packages]] -name = "tox" -version = "4.65.5" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +name = "rfc8785" +version = "0.1.4" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/cf/da/fede1f079b72862a9383eeff8508fa87c26c2386427cc97eab0a589fd058/tox-4.65.5.tar.gz" -upload-time = 2026-10-14 01:12:12+00:00 -size = 343779 +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 = "9b7296cc43f358736665afecb9154b04309e500c1ebe6aafe86632211b8cad41" +sha256 = "e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f1/90/8141bb641eb19fd806d680b1250c45f0b6bd25643ad8546cb431efbd5ad4/tox-4.65.5-py3-none-any.whl" -upload-time = 2026-10-14 01:12:10+00:00 -size = 237614 +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 = "17baefa138f8f4c6a552f6afc136008e7bce11a94f4b8e47c87b22dd33fc80f8" +sha256 = "520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48" [[packages]] -name = "tox-uv" -version = "1.37.0" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2a/65/7cf72148bff33fd0a572e620c7b1573d6a5e4812e9207878d4e29e4a938a/tox_uv-1.37.0-py3-none-any.whl" -upload-time = 2026-10-11 00:33:49+00:00 -size = 6564 - -[packages.wheels.hashes] -sha256 = "f2e7de8f12d7ffd850d2a3dcebf278cf72a8952a9abe75d84cfb54a76ee17ae4" - -[[packages]] -name = "tox-uv-bare" -version = "1.37.0" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/31/27/203b4dbe73cc6dadc2480e319631310981540dabe95893de983106d7b103/tox_uv_bare-1.37.0.tar.gz" -upload-time = 2026-10-11 00:33:52+00:00 -size = 32961 +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 = "97418f2febec7da57d010ca6ffed8c7d56be9d425f381b74c7fe8d0213fccad9" +sha256 = "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/52/4c/9596668bb448b868c684a3df9190103e9bc2f7de13e646628ac914f928be/tox_uv_bare-1.37.0-py3-none-any.whl" -upload-time = 2026-10-11 00:33:51+00:00 -size = 22322 +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 = "a596c0726690e2182cc85dfbc2f39e6fddacb8f7ea08bf8bace122a17397cd13" +sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" [[packages]] -name = "trustme" -version = "1.2.1" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +name = "securesystemslib" +version = "1.5.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = "~=3.10" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/4b/c5/931476f4cf1cd9e736f32651005078061a50dc164a2569fb874e00eb2786/trustme-1.2.1.tar.gz" -upload-time = 2025-01-02 01:55:32+00:00 -size = 26844 +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 = "6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f" +sha256 = "4b8d00abd93707ead10b69eb2b8582376a1364de3b0a71077de534c2ef4985e0" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b5/f3/c34dbabf6da5eda56fe923226769d40e11806952cd7f46655dd06e10f018/trustme-1.2.1-py3-none-any.whl" -upload-time = 2025-01-02 01:55:30+00:00 -size = 16530 +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 = "d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a" +sha256 = "ada8bdf817da29ece4ba91654f6a162ce7cfbadbc3ae3f840f7313f9d22675de" [[packages]] -name = "ty" -version = "0.0.88" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +name = "setuptools" +version = "84.0.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/f5/e8/303e333a45289c2f1f006661c9aa535de83b316a6bfc2d2f97ba815be143/ty-0.0.88.tar.gz" -upload-time = 2026-10-15 09:41:07+00:00 -size = 7968779 +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 = "5e3165a05b3757edd5a08b1fc6cb4a00c3b067100416d50afb2d13a5ec102206" +sha256 = "f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/de/7e/a62ddf5c56cf0f96fdc268bb0ec4bfe16e28b50d9158de8e7038b004e515/ty-0.0.88-py3-none-linux_armv6l.whl" -upload-time = 2026-10-15 09:40:23+00:00 -size = 14240767 +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 = "19f24e69e97d9edfb05fb2e1c7fe2b6fcce275338415e2f7de23d74627f2b436" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/77/b0/ca23cf93931c6dcf790376c2dac6d2cbcbc1c5fbbead6a536c52eae6ac22/ty-0.0.88-py3-none-macosx_10_12_x86_64.whl" -upload-time = 2026-10-15 09:40:26+00:00 -size = 13755861 +sha256 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" -[packages.wheels.hashes] -sha256 = "957f0db83b9b0f837b86fe4a0a8fb98db8da89444555246871e6eb2820629c1b" +[[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.wheels]] -url = "https://files.pythonhosted.org/packages/8c/a6/91d2aa967a69535845445660d3a9412f7f4f746597c650e5d3a799c9e286/ty-0.0.88-py3-none-macosx_11_0_arm64.whl" -upload-time = 2026-10-15 09:40:28+00:00 -size = 12771934 +[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.wheels.hashes] -sha256 = "021bd4a6d01d4a39e48a87e308161e7adea851b8cab9f4c71452dde6e365aa15" +[packages.sdist.hashes] +sha256 = "020d3e07f622b2916bf453e66ff6ff0711e1fdc5ab69e8bd8902f71d9fcb316f" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2e/a4/266f2dacbded7fac1f567292363ad05d4d03b246a8bd5ccd68206d0c9ce0/ty-0.0.88-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" -upload-time = 2026-10-15 09:40:31+00:00 -size = 13638979 +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 = "bcd956e6c3c8b038a3725f2bf255a45a284ece64a012a4e790fbf23db862af84" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/18/c9/4d7ec25675e9d2d70d9a8635d47b4737540818852e35fc6b7ca47b3e7f99/ty-0.0.88-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-10-15 09:40:34+00:00 -size = 13971852 +sha256 = "f045b207f2e12605cf775ec38e89c5eda625d71ffa7830477db65e47ec2bc8b2" -[packages.wheels.hashes] -sha256 = "a8beb5fe8ae4ee8853891647d27cec7b65f6547532e9250cd3439181993aa973" +[[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.wheels]] -url = "https://files.pythonhosted.org/packages/57/5d/0753dd860127f2601333156948ce2680ac2f65534ee39c81e9d4623f51aa/ty-0.0.88-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl" -upload-time = 2026-10-15 09:40:36+00:00 -size = 14876774 +[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.wheels.hashes] -sha256 = "6aa805d8d4ab6512ca9591c221f828d818c000cdd5bd5108923eb21405eefebd" +[packages.sdist.hashes] +sha256 = "c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/26/8b/706b59938c4c83caed1df9a74f9b6d1e9d7d196b5006f3655b618daab5da/ty-0.0.88-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-15 09:40:39+00:00 -size = 15249187 +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 = "7dfb625a98f8fb02995aa3e3fb762c910158f1419bf38d1d1dcb97f35526e4b0" +sha256 = "5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/09/1a/1a214f674e66c65347380cc8b637ffbb50624ab4ff7dc0d6f0bf34c64303/ty-0.0.88-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl" -upload-time = 2026-10-15 09:40:42+00:00 -size = 15202315 - -[packages.wheels.hashes] -sha256 = "6784a5398c69ea39ad43512ba5fa7850599d8f371b7caa974dc8603b9634e63f" +[[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.wheels]] -url = "https://files.pythonhosted.org/packages/e4/9e/a8a0af89ce94e87f6b4fdec62dce473d2858223ba7298276911c568f413c/ty-0.0.88-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2026-10-15 09:40:44+00:00 -size = 14447671 +[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.wheels.hashes] -sha256 = "6ec0c364abad7e81013c1b4ec788d811078b9b20a8a2e07ac8613499eb6f104f" +[packages.sdist.hashes] +sha256 = "19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/17/74/be3cd8bee9ad01615449a1c22c6be2cc9a5d652eb6c1e9b2e993dc8b9141/ty-0.0.88-py3-none-manylinux_2_31_riscv64.whl" -upload-time = 2026-10-15 09:40:47+00:00 -size = 14728283 +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 = "96940e5393ec00732bfaa747825979ebd971f0bafe538538d164bcba4cec5408" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/84/e7/5918d99b7aff20c4a55f50d64620186af822a023ca767b65d965a3fd4858/ty-0.0.88-py3-none-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-15 09:40:49+00:00 -size = 13637751 +sha256 = "b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78" -[packages.wheels.hashes] -sha256 = "c245fab62af678d3df2e8bf26550c0f81f5567b72de670863e300cab07d0b891" +[[packages]] +name = "tomli" +version = "2.5.0" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c5/df/2eb1ddfafd986fdfa7be9941f2665945fe6dbcc952309195ba536f6394fd/ty-0.0.88-py3-none-musllinux_1_2_armv7l.whl" -upload-time = 2026-10-15 09:40:52+00:00 -size = 13985403 +[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.wheels.hashes] -sha256 = "90407c5c7e0387295aab0e2d4de0ed7a7942f9793a4628942f367257d431e55c" +[packages.sdist.hashes] +sha256 = "264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/67/12/f9fc5e038dd2f0372be8ffbf5b1e429ef084035efdede5288a6fae74c52f/ty-0.0.88-py3-none-musllinux_1_2_i686.whl" -upload-time = 2026-10-15 09:40:54+00:00 -size = 14305076 +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 = "591ef09e37bec260399cd526d694a815b8f1dab55dc841a44bd9ccb78cca84a1" +sha256 = "9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5e/5a/8a5e7c43b5d981d1f383602ddeaea91e0fde18900d1808ee74e1c894570c/ty-0.0.88-py3-none-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-15 09:40:57+00:00 -size = 14637327 +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 = "092522f2fa87becfb3b098a1f58ec1b82dfd7c87eaa0533c5ce500a743d8e79e" +sha256 = "752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5f/16/426272696ea754575305868787b35f18c23a3851c363827f27c7c8093cff/ty-0.0.88-py3-none-win32.whl" -upload-time = 2026-10-15 09:41:00+00:00 -size = 13178338 +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 = "954cc19cd68015d4e92eb3f9218760d324dbdf67daae0e0da2c0179b644f450f" +sha256 = "c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/49/a1/db4113fa72d44a28aeeccf3a71f9a803b278d945b11fa7b070a703d8bffd/ty-0.0.88-py3-none-win_amd64.whl" -upload-time = 2026-10-15 09:41:02+00:00 -size = 13690632 +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 = "c76f1c65a492fed594bc14a1c70a1fdacd8a3165440a1eaa5af7f3c248fee963" +sha256 = "d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/70/16/bc82e209f0d4de6213d41f58dd619b3e65d24cb58a6da0cd7c6665dd6654/ty-0.0.88-py3-none-win_arm64.whl" -upload-time = 2026-10-15 09:41:04+00:00 -size = 13251351 +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 = "9b4289f6dec4123cf671a5b3d9512cdbf03889996d6895efc595bdc50573eafc" +sha256 = "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b" [[packages]] -name = "uv" -version = "0.13.1" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +name = "tomli-w" +version = "1.2.0" +marker = "\"lock-tools\" 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] -url = "https://files.pythonhosted.org/packages/ef/10/81f499de381ac81e09961e1c19255c5cf49563ac418464def4190b6c7400/uv-0.13.1.tar.gz" -upload-time = 2026-10-14 05:13:35+00:00 -size = 7434239 +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 = "6df50a86b09be66a413f8f1b16258d585d4dd1cbff591077ae1e6f65c26de2eb" +sha256 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c8/2b/79ed9d3a78a43e83ef685bf1e1c84f6c63fe25a0e765b24a169727dd87f1/uv-0.13.1-py3-none-linux_armv6l.whl" -upload-time = 2026-10-14 05:12:50+00:00 -size = 18575444 +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 = "23dfbdcfd9ff0dc7fefb1b91a6dbca2d492267976becd7c46501f89fc353fdc7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/db/3d/6ac43d7a15be1d589aae1e15289507f5ccebeac14f28c37882058f2474a3/uv-0.13.1-py3-none-macosx_10_12_x86_64.whl" -upload-time = 2026-10-14 05:12:53+00:00 -size = 17663254 +sha256 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" -[packages.wheels.hashes] -sha256 = "7e60223e3e9636a3734d6c11b3552aeed987c40f1ca2497a546af58f2f883d56" +[[packages]] +name = "tomlkit" +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.wheels]] -url = "https://files.pythonhosted.org/packages/3b/31/fad7fd2ac81954d02d33765bf8535eb320fcc2d71f2603a5c1ba0b13b8ef/uv-0.13.1-py3-none-macosx_11_0_arm64.whl" -upload-time = 2026-10-14 05:12:56+00:00 -size = 14391237 +[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.wheels.hashes] -sha256 = "cfa719fbbd23f26713394dea2692b7f146aa1b5713ce5e776e878701214ea52c" +[packages.sdist.hashes] +sha256 = "e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9f/6a/ff4c51a245345a167eed090e8cea27d5979e8b665c97617805bb8748966f/uv-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" -upload-time = 2026-10-14 05:12:58+00:00 -size = 18047204 +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 = "f2518450da2f6d0ea8a943c9f402abda63d5a66acc9999216c2792115b922ba4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5f/04/268c9c17d9a808f41b71241f9d61030c4f262213815eeaa329bae1c76c3f/uv-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl" -upload-time = 2026-10-14 05:13:00+00:00 -size = 18122326 +sha256 = "177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304" -[packages.wheels.hashes] -sha256 = "29b9d0dbe9c44d3dcea8af9e7b4ea2444c7a0c9ec0a11e2f2d357fb3afa2c05b" +[[packages]] +name = "towncrier" +version = "26.9.0" +marker = "\"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +index = "https://pypi.org/simple/" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/44/88/33b72d5c4aed94a504fe92b7312f76c54e16a00b695045053f7a72953815/uv-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-10-14 05:13:03+00:00 -size = 18139075 +[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.wheels.hashes] -sha256 = "7161cfb854f7353fd351e5e735e3659ed48412352b1edd4d93212c82ec0cbdf0" +[packages.sdist.hashes] +sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/54/59/866ca1f07f899f007fcc006663d341157c5dbdae7271e5004c5a120022ee/uv-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl" -upload-time = 2026-10-14 05:13:05+00:00 -size = 18840481 +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 = "d6aaec25ff6900ee30a29a2e7a3335aff19a2305c60488bb4e0f29cf1d6ada9f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/dd/a9/928d7435a00d1171f4557212704d71d709cf4b6024f2ee6b0f582251c761/uv-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-14 05:13:07+00:00 -size = 20106013 +sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" -[packages.wheels.hashes] -sha256 = "81345a4733737a378db988aea48fd5d1c76c092cf231e6ddfdb6db225b4b21e5" +[[packages]] +name = "truststore" +version = "0.10.5" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3c/70/ac84eee888c4a11e12c361c10fdf3f726ef6f8f1178302c80a9dee5189a3/uv-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl" -upload-time = 2026-10-14 05:13:10+00:00 -size = 19840034 +[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.wheels.hashes] -sha256 = "0f9991b8eedca00b6aeb9a8fc6b225f6afa2e24ec9de6b6ef72845f3a545639c" +[packages.sdist.hashes] +sha256 = "30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/14/0b/6602a613533602e7a083e908c4375f9b0e78a94ba2e21c29b21c41a0a611/uv-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2026-10-14 05:13:13+00:00 -size = 17279385 +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 = "696dcf6d807d85da978f4db8472da81dccc1254b18b52cbd1367c4d923c421e0" +sha256 = "9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/09/f6/52e2136653a1f3a4dc489da83444f062ed588570cc3e5242fda50996951a/uv-0.13.1-py3-none-manylinux_2_28_aarch64.whl" -upload-time = 2026-10-14 05:13:15+00:00 -size = 16577060 - -[packages.wheels.hashes] -sha256 = "ccbe233d4bce39865af7c30ec1fb28b0cc353f9d51711629f54fca71b8db7382" +[[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.wheels]] -url = "https://files.pythonhosted.org/packages/df/7c/a2f4ff386673a4bed33be69fdb857341eaa6b9f31eed3005ffc15df94d7f/uv-0.13.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl" -upload-time = 2026-10-14 05:13:18+00:00 -size = 18417071 +[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.wheels.hashes] -sha256 = "ec1bdc90e2cccc32f51dcea2fa4ec9082b321c77f3fe51a0407d40e8a44f540f" +[packages.sdist.hashes] +sha256 = "5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4a/6e/a2d375f8fcb453e75caf6d1503f7e149e76a3403091a2a77ada4d42482c1/uv-0.13.1-py3-none-manylinux_2_31_riscv64.whl" -upload-time = 2026-10-14 05:13:20+00:00 -size = 18329037 +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 = "b5e1b930cbb463c0d8f6b925743ddbae31277841f16c324d14e1a815b0c3b824" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0c/51/070341c84c141e73933f6087dcc9a5fd276cb034bdc0316f2d2dffd0cd44/uv-0.13.1-py3-none-musllinux_1_1_i686.whl" -upload-time = 2026-10-14 05:13:23+00:00 -size = 18130710 +sha256 = "d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c" -[packages.wheels.hashes] -sha256 = "4af921c96f5f85c456c7fa93a9bc13514a2a5bbab11ce63dbf490d6e5bfef503" +[[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.wheels]] -url = "https://files.pythonhosted.org/packages/b9/0d/529a302582685e77e5e2d16de554e7c230b45c6175b0b0f234af9795d981/uv-0.13.1-py3-none-musllinux_1_1_x86_64.whl" -upload-time = 2026-10-14 05:13:25+00:00 -size = 19307551 +[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.wheels.hashes] -sha256 = "6f1f391756385e2cd1521fe4a198e0ca764ef498dd677cb214b95a69a7ce3608" +[packages.sdist.hashes] +sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7f/7f/c81ec77c0eb772ec4fc515e733625969c64df55ee2f7b6b3fb1e07c2ee26/uv-0.13.1-py3-none-win32.whl" -upload-time = 2026-10-14 05:13:28+00:00 -size = 16434446 +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 = "1f6ae6f843ee01a1bb69e2e7a881a452890eb9acc686a8c37e8e95093a58f68e" +sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2a/8e/d23278bb327d2a75dc1d033c81b7fe23bbb28e363e02e7f306abd047924a/uv-0.13.1-py3-none-win_amd64.whl" -upload-time = 2026-10-14 05:13:30+00:00 -size = 15404994 +[[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.wheels.hashes] -sha256 = "4f693cd4feb52f08f30474c4612f24be2050472609be65b9960b635f286da284" +[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]] -url = "https://files.pythonhosted.org/packages/0a/e0/f27f161ee2ad242cb714fe7185f63cc2a88b83c4d8b375ca8c580f9f23b7/uv-0.13.1-py3-none-win_arm64.whl" -upload-time = 2026-10-14 05:13:32+00:00 -size = 15899284 +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 = "b173413a5dc18437b2d28266a02be636f35ac7e97741773aed163ed2143abfaf" +sha256 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" [[packages]] -name = "virtualenv" -version = "21.14.7" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" +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] -url = "https://files.pythonhosted.org/packages/dc/3f/935cf1757643381fb7f7cc98197b4bc119223d9c2cf37dc3b03ad2a4455f/virtualenv-21.14.7.tar.gz" -upload-time = 2026-10-12 01:27:31+00:00 -size = 5471741 +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 = "5f427d56f39eb7e7447d641dd4b7ab1e9c92793f498f35f26da2e4972c5546ae" +sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/52/5b/799c3e5b8e04d5fa1c9b61a4c1f5079603e747205c7311b92abd30283df5/virtualenv-21.14.7-py3-none-any.whl" -upload-time = 2026-10-12 01:27:29+00:00 -size = 5487587 +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 = "3769219a308c5d2f093e7729621ad12a6346b5767a7ec4338414e2a0fb0c526b" +sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" [[packages]] name = "wcwidth" version = "0.9.2" -index = "https://pypi.org/simple" -marker = "\"release\" 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" +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" -upload-time = 2026-10-05 00:24:05+00:00 size = 955039 [packages.sdist.hashes] sha256 = "ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl" -upload-time = 2026-10-05 00:23:22+00:00 -size = 605343 - -[packages.wheels.hashes] -sha256 = "7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07" - -[[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" -upload-time = 2026-10-05 00:23:27+00:00 size = 609960 [packages.wheels.hashes] sha256 = "ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a4/80/15ad05d40bfa99155639fb9e13b3d77083aa0fab893c816db2543d29005c/wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl" -upload-time = 2026-10-05 00:23:38+00:00 -size = 762566 - -[packages.wheels.hashes] -sha256 = "9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79" - -[[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" -upload-time = 2026-10-05 00:23:32+00:00 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" -upload-time = 2026-10-05 00:23:40+00:00 size = 769763 [packages.wheels.hashes] sha256 = "751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-05 00:23:33+00:00 -size = 782134 - -[packages.wheels.hashes] -sha256 = "c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl" -upload-time = 2026-10-05 00:23:41+00:00 -size = 782851 - -[packages.wheels.hashes] -sha256 = "0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-05 00:23:43+00:00 -size = 784621 - -[packages.wheels.hashes] -sha256 = "67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl" -upload-time = 2026-10-05 00:23:55+00:00 -size = 593123 - -[packages.wheels.hashes] -sha256 = "ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4" - -[[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" -upload-time = 2026-10-05 00:23:57+00:00 size = 597827 [packages.wheels.hashes] sha256 = "2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl" -upload-time = 2026-10-05 00:23:51+00:00 -size = 598415 - -[packages.wheels.hashes] -sha256 = "b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl" -upload-time = 2026-10-05 00:23:25+00:00 -size = 606575 - -[packages.wheels.hashes] -sha256 = "42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-10-05 00:23:30+00:00 -size = 611045 - -[packages.wheels.hashes] -sha256 = "138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ec/8a/d39964f8f8c019d7d439b9b501d3e7bb42fee69f00354040ba0b27b5824c/wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl" -upload-time = 2026-10-05 00:23:47+00:00 -size = 778143 - -[packages.wheels.hashes] -sha256 = "5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-10-05 00:23:36+00:00 -size = 786630 - -[packages.wheels.hashes] -sha256 = "e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-10-05 00:23:49+00:00 -size = 784839 - -[packages.wheels.hashes] -sha256 = "196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl" -upload-time = 2026-10-05 00:24:02+00:00 -size = 600845 - -[packages.wheels.hashes] -sha256 = "0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl" -upload-time = 2026-10-05 00:24:04+00:00 -size = 605441 - -[packages.wheels.hashes] -sha256 = "33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl" -upload-time = 2026-10-05 00:23:54+00:00 -size = 605885 - -[packages.wheels.hashes] -sha256 = "48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8" - -[[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" -upload-time = 2026-10-05 00:23:21+00:00 size = 301667 [packages.wheels.hashes] sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e" -[[packages]] -name = "werkzeug" -version = "3.1.9" -index = "https://pypi.org/simple" -marker = "\"test\" in dependency_groups" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz" -upload-time = 2026-09-27 18:33:41+00:00 -size = 940188 - -[packages.sdist.hashes] -sha256 = "55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl" -upload-time = 2026-09-27 18:33:39+00:00 -size = 228700 - -[packages.wheels.hashes] -sha256 = "6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab" - [[packages]] name = "xmltodict" version = "1.0.4" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-shell\" in dependency_groups" +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" -upload-time = 2026-02-22 02:21:22+00:00 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" -upload-time = 2026-02-22 02:21:21+00:00 size = 13580 [packages.wheels.hashes] @@ -2358,21 +2272,61 @@ sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a" [[packages]] name = "yq" version = "4.4.5" -index = "https://pypi.org/simple" -marker = "\"release\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-shell\" in dependency_groups" +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" -upload-time = 2026-10-11 02:20:56+00:00 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" -upload-time = 2026-10-11 02:20:55+00:00 size = 30255 [packages.wheels.hashes] sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" + +[tool.nab] +nab-version = "0.0.22" +created-at = 2026-10-22 23:17:10.754894+00:00 +command-line = [ + "nab", + "lock", + "--groups", + "build", + "lock-tools", + "release-notes", + "release-news", + "release-prepare", + "release-validate", + "release-check", + "release-attestations", + "release-shell", + "release-hooks", + "release-hook-check", + "--output", + "pylock.toml", +] +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 1a0891b..c679e34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,25 +8,61 @@ license = "MIT" license-files = [ "LICENSE" ] [dependency-groups] -release = [ "release-creator>=0.5.1", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] +build = [ "Cython>=3.0", "setuptools>=80" ] +lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] release-notes = [ "release-creator>=0.5.1" ] release-news = [ "release-creator>=0.5.1", "towncrier>=26.9" ] release-prepare = [ "release-creator>=0.5.1", "towncrier>=26.9", "prek>=0.2" ] release-validate = [ "release-creator>=0.5.1", "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.5.1", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] +release-hooks = [ "release-creator>=0.5.1", "prek>=0.2" ] +release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ "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-22T13:51:56Z" + [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..66ed440 --- /dev/null +++ b/read-towncrier-config/action.yml @@ -0,0 +1,78 @@ +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 }} + categories-file: + description: File containing one allowed fragment category per line + value: ${{ steps.config.outputs.categories-file }} +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" "$config_dir/categories" "$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") + Path(sys.argv[2]).write_text("".join(f"{category}\n" for category in categories), encoding="utf-8") + with Path(sys.argv[3]).open("a", encoding="utf-8") as output: + output.write(f"changelog={changelog}\nfragments={config['directory']}\nformat={format_name}\n") + output.write(f"categories-file={sys.argv[2]}\n") + PY diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index a78c606..cfcfe3b 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -1,5 +1,5 @@ -name: Reconcile PyPI artifacts -description: Stage missing files or verify the exact published distribution set +name: Verify PyPI artifacts +description: Compare every built file with PyPI and verify its trusted-publisher attestation inputs: project: description: PyPI project name @@ -10,103 +10,52 @@ inputs: dist: description: Directory containing the complete expected distribution set required: true - mode: - description: Stage missing files or verify the complete release - required: true - missing-dir: - description: Fresh output directory for files absent from PyPI in stage mode - default: missing - wait-seconds: - description: Maximum PyPI JSON propagation wait in verify mode - default: "120" - lock: - description: Full project pylock containing release-creator - default: pylock.toml - tool-group: - description: Dependency group containing release-creator - default: release - python-version: - description: Python version used for the locked release tool - default: "3.15" local-tool-source: - description: Install release-creator from this tagged checkout instead of the lock's project wheel + description: Install release-creator from the tagged checkout default: "false" -outputs: - missing-count: - description: Number of files absent from PyPI before staging - value: ${{ steps.stage.outputs.missing_count }} - manifest: - description: Path to the filename allowlist produced in stage mode - value: ${{ steps.manifest.outputs.path }} runs: using: composite steps: - - name: Check reconciliation inputs + - name: Check artifact inputs shell: bash env: PROJECT: ${{ inputs.project }} VERSION: ${{ inputs.version }} DIST: ${{ inputs.dist }} - MODE: ${{ inputs.mode }} run: | - set -euo pipefail [[ -n "$PROJECT" && -n "$VERSION" && -d "$DIST" ]] || exit 1 - case "$MODE" in - stage | verify) ;; - *) - echo "::error::invalid PyPI reconciliation mode: $MODE" >&2 - exit 1 - ;; - esac - name: Install locked release tool uses: $/install-locked-tools with: - lock: ${{ inputs.lock }} - groups: ${{ inputs.tool-group }}${{ inputs.local-tool-source == 'true' && ' build project-runtime' || '' }} - python-version: ${{ inputs.python-version }} + groups: release-notes${{ inputs.local-tool-source == 'true' && ' project-runtime' || '' }} venv: ${{ runner.temp }}/pypi-reconcile - name: Install tagged release tool if: inputs.local-tool-source == 'true' shell: bash - run: uv pip install --python "$RUNNER_TEMP/pypi-reconcile" --no-deps --no-build-isolation . - - id: stage - name: Stage files missing from PyPI - if: inputs.mode == 'stage' + 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 }} - MISSING_DIR: ${{ inputs.missing-dir }} run: | uv run --no-project --python "$RUNNER_TEMP/pypi-reconcile" release-creator artifacts --project "$PROJECT" \ - --version "$VERSION" --dist "$DIST" --mode stage --missing-dir "$MISSING_DIR" \ - --github-output "$GITHUB_OUTPUT" - - id: manifest - name: Record missing artifact names - if: inputs.mode == 'stage' + --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 + - name: Verify trusted-publisher attestations shell: bash env: - MISSING_DIR: ${{ inputs.missing-dir }} + REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - test -d "$MISSING_DIR" - test -z "$(find "$MISSING_DIR" -mindepth 1 -maxdepth 1 ! -type f -print -quit)" - shopt -s nullglob - : >"$RUNNER_TEMP/pypi-missing-names.txt" - for artifact in "$MISSING_DIR"/*; do - printf '%s\n' "${artifact##*/}" >>"$RUNNER_TEMP/pypi-missing-names.txt" - done - LC_ALL=C sort -o "$RUNNER_TEMP/pypi-missing-names.txt" "$RUNNER_TEMP/pypi-missing-names.txt" - echo "path=$RUNNER_TEMP/pypi-missing-names.txt" >>"$GITHUB_OUTPUT" - - name: Verify files published on PyPI - if: inputs.mode == 'verify' - shell: bash - env: - PROJECT: ${{ inputs.project }} - VERSION: ${{ inputs.version }} - DIST: ${{ inputs.dist }} - WAIT_SECONDS: ${{ inputs.wait-seconds }} - run: | - uv run --no-project --python "$RUNNER_TEMP/pypi-reconcile" release-creator artifacts --project "$PROJECT" \ - --version "$VERSION" --dist "$DIST" --mode verify --wait-seconds "$WAIT_SECONDS" + 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 index 8b0cfb5..2393b70 100644 --- a/refresh-hook-pins/action.yml +++ b/refresh-hook-pins/action.yml @@ -1,12 +1,5 @@ name: Refresh pre-commit hook pins description: Update cooled frozen hook revisions and exact dependencies without GitHub writes -inputs: - config: - description: Path to the pre-commit configuration - default: .pre-commit-config.yaml - python-version: - description: Python version used by the hooks - default: "3.15" outputs: changed: description: Whether the hook configuration differs from the checkout @@ -14,74 +7,43 @@ outputs: runs: using: composite steps: - - name: Install locked release tools - shell: bash - env: - PYTHON_VERSION: ${{ inputs.python-version }} - run: | - unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - uv venv --python "$PYTHON_VERSION" "$RUNNER_TEMP/hook-tools" - uv pip sync --preview-features pylock --preview-features build-dependency-hashes --only-binary release-creator \ - --python "$RUNNER_TEMP/hook-tools" --group release-hooks \ - --build-constraints "$GITHUB_ACTION_PATH/../release-build-constraints.txt" --require-build-hashes \ - "$GITHUB_ACTION_PATH/../pylock.toml" - - id: cutoff - name: Set dependency age cutoff - shell: bash - run: | - timestamp=$( - uv run --no-project --python 3.15 python - <<'PY' - from datetime import UTC, datetime, timedelta - - print((datetime.now(UTC) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")) - PY - ) - echo "timestamp=$timestamp" >>"$GITHUB_OUTPUT" + - uses: $/install-locked-tools + with: + groups: release-hooks + venv: ${{ runner.temp }}/hook-tools - name: Refresh frozen hook revisions shell: bash - env: - CONFIG: ${{ inputs.config }} run: | - uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update --config "$CONFIG" \ + 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 - env: - CONFIG: ${{ inputs.config }} - PYTHON_VERSION: ${{ inputs.python-version }} - CUTOFF: ${{ steps.cutoff.outputs.timestamp }} run: | - uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks --config "$CONFIG" \ - --exclude-newer "$CUTOFF" --python-version "$PYTHON_VERSION" + 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 - env: - CONFIG: ${{ inputs.config }} run: >- - uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek run --config "$CONFIG" --all-files + uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek run --config .pre-commit-config.yaml --all-files - name: Check updated paths shell: bash - env: - CONFIG: ${{ inputs.config }} run: | set -euo pipefail while IFS= read -r entry; do path=${entry:3} - if [[ "$path" != "$CONFIG" ]]; then + 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 - env: - CONFIG: ${{ inputs.config }} run: | set -euo pipefail - if git diff --quiet -- "$CONFIG"; then + 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 -- "$CONFIG" >"$RUNNER_TEMP/hooks.patch" + 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 index 69dc628..5aff035 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -1,234 +1,173 @@ name: Refresh a release-tool lock -description: Compile and check a local release-tool lock without GitHub writes +description: Refresh or check the Nab lock for selected release groups inputs: - lock: - description: Path to the checked-in full project pylock file - default: pylock.toml - python-version: - description: Python version used to run the exporter and release tools - default: "3.15" - check-group: - description: Dependency group containing prek - default: release - keep-uv-lock: - description: Also refresh a checked-in uv.lock for projects that use it for their build - default: "false" - build-constraints: - description: Optional generated backend constraints path kept with this repository's lock + 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: "" - build-requirements: - description: Additional build requirements, one per line + additional-lock-groups: + description: Groups for the secondary lock; empty selects build requirements default: "" - hook-config: - description: Pre-commit configuration path to protect from generated-file overwrite - default: .pre-commit-config.yaml - exclude-newer-packages: - description: Reviewed package=UTC minimum cutoffs; each expires when the seven-day cutoff passes it + 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 differs from the checkout + description: Whether the lock or expired package cutoffs differ from the checkout value: ${{ steps.lock.outputs.changed }} runs: using: composite steps: - - name: Install lock exporter + - name: Install locked Nab shell: bash - env: - PYTHON_VERSION: ${{ inputs.python-version }} run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - uv venv --allow-existing --python "$PYTHON_VERSION" "$RUNNER_TEMP/lock-exporter" - uv pip sync --preview-features pylock --preview-features build-dependency-hashes --only-binary release-creator \ - --python "$RUNNER_TEMP/lock-exporter" --group release-notes \ - --build-constraints "$GITHUB_ACTION_PATH/../release-build-constraints.txt" --require-build-hashes \ - "$GITHUB_ACTION_PATH/../pylock.toml" - - id: cutoff - name: Set dependency age cutoff - shell: bash - run: | - timestamp=$( - uv run --no-project --python 3.15 python - <<'PY' - from datetime import UTC, datetime, timedelta - - print((datetime.now(UTC) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")) - PY - ) - echo "timestamp=$timestamp" >>"$GITHUB_OUTPUT" - - id: exceptions - name: Select active package cutoffs - shell: bash - env: - EXCLUDE_NEWER_PACKAGES: ${{ inputs.exclude-newer-packages }} - CUTOFF: ${{ steps.cutoff.outputs.timestamp }} - run: | - set -euo pipefail - now=$(date -u '+%Y-%m-%dT%H:%M:%SZ') - : >"$RUNNER_TEMP/active-package-cutoffs" - while IFS= read -r entry; do - if [[ -z "$entry" ]]; then - continue - fi - package=${entry%%=*} - timestamp=${entry#*=} - case "$package" in - '' | *[!a-zA-Z0-9._-]*) - echo "::error::invalid package cutoff: $entry" >&2 - exit 1 - ;; - esac - case "$timestamp" in - ????-??-??T??:??:??Z) ;; - *) - echo "::error::invalid package cutoff: $entry" >&2 - exit 1 - ;; - esac - if [[ "$timestamp" > "$now" ]] || ! uv run --no-project --python 3.15 \ - python - "$timestamp" <<'PY' - import datetime - import sys - - datetime.datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ") - PY - then - echo "::error::invalid package cutoff: $entry" >&2 - exit 1 - fi - if [[ "$timestamp" > "$CUTOFF" ]]; then - printf '%s\n' "$entry" >>"$RUNNER_TEMP/active-package-cutoffs" - fi - done <<<"$EXCLUDE_NEWER_PACKAGES" - echo "path=$RUNNER_TEMP/active-package-cutoffs" >>"$GITHUB_OUTPUT" - - name: Refresh full project lock - shell: bash - env: - LOCK: ${{ inputs.lock }} - PYTHON_VERSION: ${{ inputs.python-version }} - UV_EXCLUDE_NEWER: ${{ steps.cutoff.outputs.timestamp }} - ACTIVE_PACKAGE_CUTOFFS: ${{ steps.exceptions.outputs.path }} - BUILD_REQUIREMENTS: ${{ inputs.build-requirements }} - run: | - set -euo pipefail - exceptions=() - while IFS= read -r entry; do - if [[ -z "$entry" ]]; then - continue - fi - exceptions+=(--exclude-newer-package "$entry") - done <"$ACTIVE_PACKAGE_CUTOFFS" - requirements=() - while IFS= read -r requirement; do - if [[ -n "$requirement" ]]; then - requirements+=(--build-requirement "$requirement") - fi - done <<<"$BUILD_REQUIREMENTS" - uv run --no-project --python "$RUNNER_TEMP/lock-exporter" release-creator locks --output "$LOCK" \ - --tool-python ">=$PYTHON_VERSION" "${requirements[@]}" "${exceptions[@]}" - - name: Refresh uv lock - if: inputs.keep-uv-lock == 'true' + uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ + --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" + - name: Expire reviewed package cutoffs shell: bash - env: - UV_EXCLUDE_NEWER: ${{ steps.cutoff.outputs.timestamp }} - ACTIVE_PACKAGE_CUTOFFS: ${{ steps.exceptions.outputs.path }} run: | set -euo pipefail - exceptions=() - while IFS= read -r entry; do - if [[ -z "$entry" ]]; then - continue - fi - exceptions+=(--exclude-newer-package "$entry") - done <"$ACTIVE_PACKAGE_CUTOFFS" - uv lock --upgrade "${exceptions[@]}" - - name: Refresh backend constraints - if: inputs.build-constraints != '' + 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: ${{ inputs.lock }} - BUILD_CONSTRAINTS: ${{ inputs.build-constraints }} - HOOK_CONFIG: ${{ inputs.hook-config }} - PYTHON_VERSION: ${{ inputs.python-version }} + 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 - case "$BUILD_CONSTRAINTS" in - /* | . | .. | ./* | */./* | */. | ../* | */../* | */.. | .git/* | */.git/* | *[!a-zA-Z0-9_./-]*) - echo "::error::invalid backend constraints path: $BUILD_CONSTRAINTS" >&2 + 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" ;; - esac - case "${BUILD_CONSTRAINTS##*/}" in - *build-constraints.txt) ;; + refresh) refresh=(--upgrade) ;; *) - echo "::error::backend constraints filename must end in build-constraints.txt" >&2 + echo "::error::invalid lock mode: $MODE" >&2 exit 1 ;; esac - if [[ "$BUILD_CONSTRAINTS" == "$LOCK" || "$BUILD_CONSTRAINTS" == "$HOOK_CONFIG" || - "$BUILD_CONSTRAINTS" == pyproject.toml || "$BUILD_CONSTRAINTS" == uv.lock || - "$BUILD_CONSTRAINTS" == pylock.toml ]]; then - echo "::error::backend constraints path collides with a project file: $BUILD_CONSTRAINTS" >&2 - exit 1 + 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 - prefix=. - IFS=/ read -ra parts <<<"$BUILD_CONSTRAINTS" - for part in "${parts[@]}"; do - prefix="$prefix/$part" - if [[ -L "$prefix" ]]; then - echo "::error::backend constraints path contains a symlink: $BUILD_CONSTRAINTS" >&2 + 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 - done - uv run --no-project --python "$RUNNER_TEMP/lock-exporter" release-creator \ - build-constraints --lock "$LOCK" --group build \ - --python-version "$PYTHON_VERSION" --output "$BUILD_CONSTRAINTS" - - name: Install refreshed release tools + 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: - lock: ${{ inputs.lock }} - groups: ${{ inputs.check-group }} - python-version: ${{ inputs.python-version }} + 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 lock paths + - name: Check refreshed paths shell: bash env: - LOCK: ${{ inputs.lock }} - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} - BUILD_CONSTRAINTS: ${{ inputs.build-constraints }} + 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" != "$LOCK" && "$path" != "$BUILD_CONSTRAINTS" ]] && - [[ "$KEEP_UV_LOCK" != true || "$path" != uv.lock ]]; then - echo "::error::prek changed file outside the lock file: $path" >&2 + 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: - LOCK: ${{ inputs.lock }} - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} - BUILD_CONSTRAINTS: ${{ inputs.build-constraints }} + MODE: ${{ inputs.mode }} + ADDITIONAL_LOCK: ${{ inputs.additional-lock }} run: | set -euo pipefail - paths=("$LOCK") - if [[ "$KEEP_UV_LOCK" == true ]]; then - paths+=(uv.lock) - fi - if [[ -n "$BUILD_CONSTRAINTS" ]]; then - paths+=("$BUILD_CONSTRAINTS") - git add -N -- "$BUILD_CONSTRAINTS" - fi - if git diff --quiet -- "${paths[@]}"; then + 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" - exit 0 + 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 - echo 'changed=true' >>"$GITHUB_OUTPUT" - git diff --binary -- "${paths[@]}" >"$RUNNER_TEMP/lock.patch" - test -s "$RUNNER_TEMP/lock.patch" diff --git a/release-build-constraints.txt b/release-build-constraints.txt deleted file mode 100644 index a4aa482..0000000 --- a/release-build-constraints.txt +++ /dev/null @@ -1,2 +0,0 @@ -cython==3.3.0 --hash=sha256:169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f --hash=sha256:29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6 --hash=sha256:311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b --hash=sha256:4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d --hash=sha256:596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66 --hash=sha256:75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570 --hash=sha256:90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081 --hash=sha256:9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1 --hash=sha256:b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616 --hash=sha256:b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef --hash=sha256:b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38 --hash=sha256:c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a --hash=sha256:dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd --hash=sha256:e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc --hash=sha256:eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8 -setuptools==84.0.0 --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index d129d5b..29e5118 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -7,21 +7,12 @@ inputs: changelog: description: Tracked changelog path required: true - repository: - description: GitHub owner/repository - required: true - package: - description: Package name for changelogs and tags with separate release namespaces - default: "" artifact-url: description: Published ecosystem release URL default: "" artifact-label: description: Link text for the published artifact default: PyPI release - issue-role: - description: Target of reStructuredText issue roles, issue or pull - default: issue output: description: Path for the rendered Markdown body required: true @@ -31,44 +22,27 @@ inputs: outputs: previous: description: Previous version tag, or an empty string for the first release - value: ${{ steps.version.outputs.previous }} + value: ${{ steps.previous.outputs.previous }} runs: using: composite steps: - - id: version - name: Check the release tag - shell: bash - env: - TAG: ${{ inputs.tag }} - PACKAGE: ${{ inputs.package }} - run: | - set -euo pipefail - args=() - if [[ -n "$PACKAGE" ]]; then - args+=(--package "$PACKAGE") - fi - release-creator tags "$TAG" "${args[@]}" - previous=$(git tag --list | release-creator tags "$TAG" --previous "${args[@]}") - printf 'previous=%s\n' "$previous" >>"$GITHUB_OUTPUT" + - id: previous + uses: $/select-previous-tag + with: + tag: ${{ inputs.tag }} - name: Render GitHub Release notes shell: bash env: TAG: ${{ inputs.tag }} - PACKAGE: ${{ inputs.package }} CHANGELOG: ${{ inputs.changelog }} - REPOSITORY: ${{ inputs.repository }} ARTIFACT_URL: ${{ inputs.artifact-url }} ARTIFACT_LABEL: ${{ inputs.artifact-label }} - ISSUE_ROLE: ${{ inputs.issue-role }} OUTPUT: ${{ inputs.output }} GENERATED_NOTES: ${{ inputs.generated-notes }} - PREVIOUS: ${{ steps.version.outputs.previous }} + PREVIOUS: ${{ steps.previous.outputs.previous }} run: | set -euo pipefail - args=(--changelog "$CHANGELOG" --tag "$TAG" --repository "$REPOSITORY" --issue-role "$ISSUE_ROLE") - if [[ -n "$PACKAGE" ]]; then - args+=(--package "$PACKAGE") - fi + args=(--changelog "$CHANGELOG" --tag "$TAG" --repository "$GITHUB_REPOSITORY") if [[ -n "$PREVIOUS" ]]; then args+=(--previous "$PREVIOUS") fi @@ -79,13 +53,3 @@ runs: args+=(--generated-notes "$GENERATED_NOTES") fi release-creator notes "${args[@]}" >"$OUTPUT" - comparisons=0 - while IFS= read -r line; do - case "$line" in - '**Full Changelog**:'*) comparisons=$((comparisons + 1)) ;; - esac - done <"$OUTPUT" - if [[ ! -s "$OUTPUT" ]] || [[ -n "$PREVIOUS" && "$comparisons" -ne 1 ]]; then - echo '::error::release notes must contain one comparison link' >&2 - exit 1 - fi diff --git a/resolve-pr-from-run/action.yml b/resolve-pr-from-run/action.yml index b661d2b..69fa8b5 100644 --- a/resolve-pr-from-run/action.yml +++ b/resolve-pr-from-run/action.yml @@ -1,9 +1,6 @@ name: Resolve a pull request from its CI run description: Verify a completed read-only PR run and find its current pull request inputs: - repository: - description: GitHub owner/repository containing the source run; defaults to the caller repository - default: "" run-id: description: GitHub Actions run ID from the workflow_run event required: true @@ -24,7 +21,6 @@ runs: shell: bash env: GH_TOKEN: ${{ github.token }} - REPOSITORY: ${{ inputs.repository || github.repository }} RUN_ID: ${{ inputs.run-id }} WORKFLOW_PATH: ${{ inputs.workflow-path }} run: | @@ -35,8 +31,8 @@ runs: exit 1 ;; esac - gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" >"$RUNNER_TEMP/source-run.json" - if ! jq -e --arg repository "$REPOSITORY" --arg workflow "$WORKFLOW_PATH" --argjson run_id "$RUN_ID" ' + 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 @@ -55,16 +51,15 @@ runs: shell: bash env: GH_TOKEN: ${{ github.token }} - REPOSITORY: ${{ inputs.repository || github.repository }} 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/$REPOSITORY/pulls" -f state=open -f "head=$head_owner:$head_branch" -f per_page=100 \ + 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 "$REPOSITORY" --arg head_repository "$head_repository" \ + matches=$(jq --arg repository "$GITHUB_REPOSITORY" --arg head_repository "$head_repository" \ --arg head_sha "$head_sha" ' [.[] | select( .state == "open" and 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/select-pypi-artifacts/action.yml b/select-pypi-artifacts/action.yml deleted file mode 100644 index 486bab7..0000000 --- a/select-pypi-artifacts/action.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Select PyPI artifacts -description: Copy validated build files named by the keyless PyPI stage -inputs: - dist: - description: Original validated distribution directory - required: true - manifest: - description: File listing the staged missing artifact names - required: true - output-dir: - description: Fresh directory passed to the PyPI upload action - required: true - expected-count: - description: Expected local selection count; required unless partitioned - required: false - partitioned: - description: Select only names present in this matrix cell's validated distribution - default: "false" -outputs: - selected-count: - description: Number of original distribution files selected for upload - value: ${{ steps.select.outputs.count }} - selected-manifest: - description: File listing this cell's selected names for a keyless partition check - value: ${{ steps.select.outputs.manifest }} -runs: - using: composite - steps: - - id: select - name: Copy selected validated files - shell: bash - env: - DIST: ${{ inputs.dist }} - MANIFEST: ${{ inputs.manifest }} - OUTPUT_DIR: ${{ inputs.output-dir }} - EXPECTED_COUNT: ${{ inputs.expected-count }} - PARTITIONED: ${{ inputs.partitioned }} - run: | - set -euo pipefail - [[ -d "$DIST" && -f "$MANIFEST" && ! -e "$OUTPUT_DIR" ]] || exit 1 - dist_path=$(python3 -c 'import pathlib, sys; print(pathlib.Path(sys.argv[1]).resolve())' "$DIST") - output_path=$(python3 -c 'import pathlib, sys; print(pathlib.Path(sys.argv[1]).resolve())' "$OUTPUT_DIR") - case "$output_path" in - "$dist_path" | "$dist_path"/*) - echo '::error::output directory overlaps the validated distribution' >&2 - exit 1 - ;; - esac - mkdir "$OUTPUT_DIR" - selected_manifest=$(mktemp "$RUNNER_TEMP/pypi-selected.XXXXXX") - count=0 - while IFS= read -r name; do - case "$name" in - '' | .* | *[!a-zA-Z0-9._+-]*) - echo "::error::invalid distribution filename: $name" >&2 - exit 1 - ;; - esac - if [[ "$PARTITIONED" == true && ! -e "$DIST/$name" && ! -L "$DIST/$name" ]]; then - continue - fi - [[ -f "$DIST/$name" && ! -L "$DIST/$name" && ! -e "$OUTPUT_DIR/$name" ]] || exit 1 - cp -- "$DIST/$name" "$OUTPUT_DIR/$name" - cmp -s "$DIST/$name" "$OUTPUT_DIR/$name" - printf '%s\n' "$name" >>"$selected_manifest" - count=$((count + 1)) - done <"$MANIFEST" - if [[ -n "$EXPECTED_COUNT" ]]; then - test "$count" = "$EXPECTED_COUNT" - else - test "$PARTITIONED" == true - fi - echo "count=$count" >>"$GITHUB_OUTPUT" - echo "manifest=$selected_manifest" >>"$GITHUB_OUTPUT" diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 0b59123..0644547 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -2,45 +2,41 @@ import json import os -import subprocess +import shutil import tomllib -from textwrap import dedent -lazy from pathlib import Path +from pathlib import Path import pytest from conftest import action, run @pytest.fixture -def build_repository(repository: Path) -> Path: - project = repository / "pyproject.toml" +def build_repository(source_project: Path) -> Path: + project = source_project / "pyproject.toml" release_requirement = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())[ "dependency-groups" - ]["release"][0] + ]["release-notes"][0] 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" - """, - ) - + f"\n[dependency-groups]\nrelease = [{json.dumps(release_requirement)}]\n", + + 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, + "uvx", + "--from", + "nab==0.0.22", + "nab", + "lock", + "--groups", + "release-notes", + "--output", + "pylock.toml", ) - package = repository / "src/fixture" - package.mkdir(parents=True) - (package / "__init__.py").write_text('"""Fixture package."""\n') - run(repository, "release-creator", "locks", "--output", "pylock.toml", "--tool-python", ">=3.15") - run(repository, "git", "add", ".") - run(repository, "git", "commit", "-m", "Add package source") - run(repository, "git", "push", "origin", "main") - return repository + 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( @@ -52,13 +48,11 @@ def test_build_python_distribution_creates_wheel_and_sdist( action_scripts, "build-python-distribution", version="0.2.0", - PYTHON_VERSION="3.15", SOURCE=".", - OUT_DIR=str(tmp_path / "dist"), RUNNER_TEMP=str(tmp_path), ) assert result.returncode == 0, result.stderr - assert {path.name for path in (tmp_path / "dist").iterdir() if path.suffix in {".whl", ".gz"}} == { + 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", } @@ -78,13 +72,12 @@ def test_build_python_distribution_repeats_exact_bytes( action_scripts, "build-python-distribution", version="0.2.0", - PYTHON_VERSION="3.15", SOURCE=".", - OUT_DIR=str(temporary / "dist"), RUNNER_TEMP=str(temporary), ) assert result.returncode == 0, result.stderr - outputs.append({path.name: path.read_bytes() for path in (temporary / "dist").iterdir()}) + outputs.append({path.name: path.read_bytes() for path in (build_repository / "dist").iterdir()}) + shutil.rmtree(build_repository / "dist") assert outputs[0] == outputs[1] @@ -98,9 +91,7 @@ def test_build_python_distribution_rejects_dirty_source( action_scripts, "build-python-distribution", version="0.2.0", - PYTHON_VERSION="3.15", SOURCE=".", - OUT_DIR=str(tmp_path / "dist"), RUNNER_TEMP=str(tmp_path), ) assert "tagged source is dirty" in result.stderr @@ -109,7 +100,10 @@ def test_build_python_distribution_rejects_dirty_source( def test_build_python_distribution_rejects_existing_artifact( build_repository: Path, tmp_path: Path, action_scripts: Path ) -> None: - output = tmp_path / "dist" + (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( @@ -118,45 +112,36 @@ def test_build_python_distribution_rejects_existing_artifact( action_scripts, "build-python-distribution", version="0.2.0", - PYTHON_VERSION="3.15", SOURCE=".", - OUT_DIR=str(output), RUNNER_TEMP=str(tmp_path), ) assert result.returncode != 0 assert "distribution output directory is not empty" in result.stderr -def test_build_python_distribution_uses_interpreter_patch_for_constraints( +def test_build_python_distribution_rejects_backend_hash_mismatch( build_repository: Path, tmp_path: Path, action_scripts: Path ) -> None: - project = build_repository / "pyproject.toml" - project.write_text( - project.read_text() - .replace('requires-python = ">=3.15"', 'requires-python = ">=3.14"') - .replace( - 'requires = ["hatchling"]', - "requires = [\"hatchling\", \"colorama; python_full_version >= '3.14.1' and python_version < '3.15'\"]", - ) - + '\n[tool.uv]\ndependency-groups.release = { requires-python = ">=3.15" }\n', - ) - run(build_repository, "release-creator", "locks", "--output", "pylock.toml", "--tool-python", ">=3.15") - run(build_repository, "git", "add", ".") - run(build_repository, "git", "commit", "-m", "Add patch-specific backend dependency") + 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", - PYTHON_VERSION="3.14", SOURCE=".", - OUT_DIR=str(tmp_path / "dist"), RUNNER_TEMP=str(tmp_path), + UV_CACHE_DIR=str(tmp_path / "uv-cache"), ) - assert result.returncode == 0, result.stderr - assert "colorama==" in (tmp_path / "build-constraints.txt").read_text() - assert (tmp_path / "dist/fixture-0.2.0-py3-none-any.whl").is_file() + assert result.returncode != 0 + assert "Hash mismatch" in result.stderr def test_build_python_distribution_does_not_run_hooks( @@ -172,36 +157,33 @@ def test_build_python_distribution_does_not_run_hooks( action_scripts, "build-python-distribution", version="0.2.0", - PYTHON_VERSION="3.15", SOURCE=".", - OUT_DIR=str(tmp_path / "dist"), 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(tmp_path: Path, action_scripts: Path) -> None: +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 ]]; then exit 19; fi\n' - 'printf "build called\\n" >>"$RUNNER_TEMP/build-calls"\n', + '#!/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 = subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / "build-python-distribution-6.sh")], - cwd=tmp_path, - env=os.environ - | { - "PATH": f"{tools}:{os.environ['PATH']}", - "RUNNER_TEMP": str(tmp_path), - "SOURCE": ".", - "OUT_DIR": "dist", - }, - text=True, - capture_output=True, - check=False, + result = action( + build_repository, + tmp_path, + action_scripts, + "build-python-distribution", + version="0.2.0", + PATH=f"{tools}:{os.environ['PATH']}", ) - assert result.returncode != 0 + 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 index 2155b7e..e8c35c8 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -3,14 +3,13 @@ import base64 import json import os -import shutil import subprocess -import sys from datetime import UTC, datetime, timedelta from pathlib import Path import pytest import yaml +from conftest import action from pytest_httpserver import HTTPServer @@ -243,20 +242,32 @@ def test_cooldown_checks_each_duplicate_repository( assert "hook revision is not eligible" in result.stderr -@pytest.mark.parametrize("event_name", ["workflow_dispatch", "pull_request"], ids=["wrong-event", "wrong-event-head"]) -def test_cooldown_rejects_other_event_or_head_before_api( +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]], - event_name: str, ) -> None: server, environment = github_api result = run_cooldown( action_scripts, tmp_path, environment, - event_name=event_name, - event_head="c" * 40, + event_name="workflow_dispatch", ) assert result.returncode != 0 assert not server.log @@ -272,62 +283,19 @@ def run_cooldown( ) -> subprocess.CompletedProcess[str]: path = tmp_path / "event.json" path.write_text(json.dumps({"number": 7, "pull_request": {"head": {"sha": event_head}}})) - subprocess.run( - ["uv", "venv", "--python", sys.executable, str(tmp_path / "hook-tools")], check=True, capture_output=True - ) - tools = tmp_path / "hook-tools" / "bin" - for name in ("prek", "yq"): - executable = shutil.which(name) - assert executable is not None - (tools / name).symlink_to(executable) - steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text())[ - "runs" - ]["steps"] - values = ( - {name: os.environ[name] for name in ("HOME", "PATH", "LANG", "UV_CACHE_DIR") if name in os.environ} - | environment - | { - "REPOSITORY": "tox-dev/fixture", - "PR_NUMBER": "7", - "HEAD_SHA": "a" * 40, - "MINIMUM_DAYS": "7", - "RUNNER_TEMP": str(tmp_path), - "GITHUB_EVENT_NAME": event_name, - "GITHUB_EVENT_PATH": str(path), - "GITHUB_OUTPUT": str(tmp_path / "outputs"), - "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "check-hook-update-cooldown"), - "GH_TOKEN": environment["GH_ENTERPRISE_TOKEN"], - } + (tmp_path / "pylock.toml").write_bytes((Path(__file__).resolve().parents[2] / "pylock.toml").read_bytes()) + 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, ) - result = subprocess.CompletedProcess[str]([], 0, "", "") - for index, step in enumerate(steps): - if "run" not in step or step.get("name") == "Install locked hook tools": - continue - condition = step.get("if") - if condition not in { - None, - "steps.identity.outputs.bot == 'true'", - "steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true'", - }: - msg = f"unsupported cooldown condition: {condition}" - raise ValueError(msg) - if condition and (not (tmp_path / "outputs").exists() or "bot=true" not in (tmp_path / "outputs").read_text()): - continue - if condition == "steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true'" and ( - "cooled=true" in (tmp_path / "outputs").read_text() - ): - continue - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / f"check-hook-update-cooldown-{index}.sh")], - cwd=tmp_path, - env=values, - text=True, - capture_output=True, - check=False, - ) - if result.returncode: - break - return result def expect_pull( diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py index b7251da..052dd83 100644 --- a/tests/check_hook_update_cooldown/test_workflow.py +++ b/tests/check_hook_update_cooldown/test_workflow.py @@ -17,17 +17,19 @@ def test_cooldown_job_is_read_only_and_required() -> None: 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", - "with": { - "repository": "${{ github.repository }}", - "pull-request": "${{ github.event.pull_request.number }}", - "head-sha": "${{ github.event.pull_request.head.sha }}", - }, - } + 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", "venv": "${{ runner.temp }}/hook-tools"}, + } + + @pytest.mark.parametrize( ("event_name", "cooldown_result", "accepted"), [ diff --git a/tests/commit_and_tag/test_commit.py b/tests/commit_and_tag/test_commit.py index 8618ae0..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 ) @@ -44,12 +39,11 @@ def test_commit_and_tag_pushes_atomically( 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" ) diff --git a/tests/conftest.py b/tests/conftest.py index 065dffe..bf91a36 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,8 +4,9 @@ import ssl import subprocess import sys -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from pathlib import Path +from textwrap import dedent from typing import Final import pytest @@ -24,6 +25,8 @@ def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]: 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, { @@ -47,16 +50,17 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "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", + "export-build-constraints", + "read-towncrier-config", "refresh-hook-pins", "publish-github-release", "reconcile-pypi-artifacts", - "select-pypi-artifacts", - "verify-pypi-partitions", "publish-jetbrains-marketplace", "sign-jetbrains-plugin", "check-hook-update-cooldown", @@ -74,7 +78,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: @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" @@ -90,7 +94,9 @@ 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() @@ -101,9 +107,45 @@ def repository(tmp_path: Path) -> Path: run(worktree, "git", "add", ".") 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) + fragment_commit = run(worktree, "git", "rev-parse", "HEAD").stdout.strip() + for name in ("fixture", "github-workflows"): + server.expect_request( + f"/api/v3/repos/tox-dev/{name}/commits/{fragment_commit}/pulls", + method="GET", + query_string="per_page=100", + ).respond_with_json( + [{"number": 2, "merge_commit_sha": fragment_commit, "user": {"login": "gaborbernat", "type": "User"}}], + ) 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, @@ -114,107 +156,113 @@ def action( **extra_env: str, ) -> subprocess.CompletedProcess[str]: action_config = yaml.safe_load((Path(__file__).resolve().parents[1] / name / "action.yml").read_text()) - environment = ( + 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", - "TAG_PREFIX": "", - "CHANGELOG_PREFIX": "", - "CHANGELOG": "CHANGELOG.md", - "FRAGMENTS": "changelog.d", - "FORMAT": action_config["inputs"].get("format", {}).get("default", "none"), - "LINK_FRAGMENTS": "false", - "NUMBERED_PRS": "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(), - "EXPECTED_PATCH_SHA": "", - "CHECKS": "true", - "BRANCH": "main", - "REMOTE": "origin", - "ANNOTATED": "false", - "ARTIFACT_URL": "", - "ARTIFACT_LABEL": "PyPI release", - "PACKAGE": "", - "LOCK": "pylock.toml", - "GENERATED_NOTES": "", - "ISSUE_ROLE": "issue", - "MODE": "complete", - "RUN_ID": "42", - "WORKFLOW_PATH": ".github/workflows/check.yaml", + "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} ) - environment["REPOSITORY"] = extra_env.get("REPOSITORY", environment["GITHUB_REPOSITORY"]) - environment["PATH"] = f"{Path(sys.executable).parent}:{environment['PATH']}" - stdout = [] - stderr = [] - steps = action_config["runs"]["steps"] + 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(steps): - if step.get("uses") == "$/install-locked-tools": - group_input = { - "build-python-distribution": "PLANNER_GROUP", - "reconcile-pypi-artifacts": "TOOL_GROUP", - "validate-python-distribution": "VALIDATION_GROUP", - }[name] - groups = environment.get(group_input, "pkg-meta" if name == "validate-python-distribution" else "release") - if ( - name in {"build-python-distribution", "reconcile-pypi-artifacts"} - and environment.get("LOCAL_TOOL_SOURCE") == "true" - ): - groups = f"{groups} build project-runtime" - destination = { - "build-python-distribution": "build-planner", - "reconcile-pypi-artifacts": "pypi-reconcile", - "validate-python-distribution": "package-validators", - }[name] - for install_index in range(3): - command = [*github_bash, str(action_scripts / f"install-locked-tools-{install_index}.sh")] - result = subprocess.run( - command, - cwd=repository, - env=environment - | { - "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[1] / "install-locked-tools"), - "DEPENDENCY_GROUPS": groups, - "VENV": str(tmp_path / destination), - "PYTHON_VERSION": "3.15" - if name == "build-python-distribution" - else environment["PYTHON_VERSION"], - }, - text=True, - capture_output=True, - check=False, + 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, ) - stdout.append(result.stdout) - stderr.append(result.stderr) - if result.returncode: - return subprocess.CompletedProcess(command, result.returncode, "".join(stdout), "".join(stderr)) + 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)}, + 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" - if (condition := step.get("if")) is not None and not _condition(condition, environment): - continue 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 - | { - variable: _step_value(value, environment, action_config["inputs"], step_outputs) - for variable, value in step.get("env", {}).items() - }, + cwd=repository / _step_value(str(step.get("working-directory", ".")), environment, inputs, step_outputs), + env=step_environment, text=True, capture_output=True, check=False, @@ -222,14 +270,18 @@ def action( stdout.append(result.stdout) stderr.append(result.stderr) if result.returncode: - return subprocess.CompletedProcess(command, result.returncode, "".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() ) - return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr)) + 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]: @@ -237,79 +289,132 @@ def run(directory: Path, *command: str) -> subprocess.CompletedProcess[str]: return subprocess.run( command, cwd=directory, - env=environment | {"PATH": f"{Path(sys.executable).parent}:{environment['PATH']}"}, + env=environment | {"PATH": _tools_path(environment)}, text=True, capture_output=True, check=True, ) -def _condition(expression: str, environment: dict[str, str]) -> bool: - conditions = { - "inputs.local-tool-source == 'true'": environment.get("LOCAL_TOOL_SOURCE") == "true", - "inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'": environment.get("BOT_AUTHOR") - == "true", - "inputs.checks == 'true'": environment["CHECKS"] == "true", - "inputs.format == 'rst'": environment["FORMAT"] == "rst", - "endsWith(inputs.changelog, '.rst')": environment["CHANGELOG"].endswith(".rst"), - "inputs.keep-uv-lock == 'true'": environment.get("KEEP_UV_LOCK") == "true", - "inputs.build-constraints != ''": bool(environment.get("BUILD_CONSTRAINTS")), - "inputs.mode == 'stage'": environment.get("MODE") == "stage", - "inputs.mode == 'verify'": environment.get("MODE") == "verify", - "steps.identity.outputs.bot == 'true'": environment.get("BOT_AUTHOR") == "true", - "steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true'": environment.get("BOT_AUTHOR") - == "true" - and environment.get("COOLED") != "true", - } - if expression not in conditions: - msg = f"unsupported GitHub step condition: {expression}" - raise ValueError(msg) - return conditions[expression] +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") + 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], - action_inputs: dict[str, dict[str, str | bool]], + inputs: dict[str, str], step_outputs: dict[str, dict[str, str]], ) -> str: - if not value.startswith("${{ ") or not value.endswith(" }}"): - return value - for expression in value[4:-3].split(" || "): - if expression.startswith("inputs."): - input_name = expression[7:] - resolved = environment.get( - input_name.replace("-", "_").upper(), str(action_inputs[input_name].get("default", "")) - ) - elif expression == "github.repository": - resolved = environment["GITHUB_REPOSITORY"] - elif expression == "github.base_ref": - resolved = environment.get("BASE_REF", "") - elif expression == "github.event.pull_request.number": - resolved = environment.get("GITHUB_EVENT_PULL_REQUEST_NUMBER", "") - elif expression == "github.token": - resolved = environment.get("GH_TOKEN", environment.get("GH_ENTERPRISE_TOKEN", "")) - elif expression == "contains(github.event.pull_request.labels.*.name, 'skip news')": - resolved = environment.get("SKIP_NEWS", "false") - elif expression == "github.event.pull_request.user.type == 'Bot'": - resolved = environment.get("BOT_AUTHOR", "false") - elif expression.startswith("steps."): - _, step_id, _, output_name = expression.split(".") - resolved = step_outputs.get(step_id, {}).get(output_name, "") - else: - msg = f"unsupported GitHub step environment: {expression}" + 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) - if resolved: - return resolved - return "" + 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..0f402d0 --- /dev/null +++ b/tests/export_build_constraints/test_export.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from textwrap import dedent + +import pytest +import tomlkit +import yaml + + +def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) + output = tmp_path / "output" + steps = yaml.safe_load((source / "export-build-constraints/action.yml").read_text())["runs"]["steps"] + for step in steps: + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", step["run"]], + cwd=tmp_path, + env=os.environ + | { + "GITHUB_ACTION_PATH": str(source / "export-build-constraints"), + "GITHUB_OUTPUT": str(output), + "RUNNER_TEMP": str(tmp_path), + "BUILD_LOCK": "pylock.toml", + "TARGET_PLATFORM": "", + "TARGETS": "", + }, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + constraints = (tmp_path / "build-constraints.txt").read_text() + assert "cython==" in constraints + assert "setuptools==" in constraints + assert "pytest==" not in constraints + assert output.read_text() == f"path={tmp_path / 'build-constraints.txt'}\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 index 6ccf313..6a78f3f 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import shutil import subprocess import tomllib @@ -8,15 +7,15 @@ from pathlib import Path import pytest -import yaml +from conftest import action @pytest.mark.parametrize( ("groups", "module"), [ - pytest.param("release", "yaml", id="release"), - pytest.param("release test", "pytest", id="multiple-groups"), - pytest.param("release\ntest", "pytest", id="multiline-groups"), + 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: @@ -26,7 +25,6 @@ def test_install_locked_tools_installs_groups(tmp_path: Path, action_scripts: Pa source, action_scripts, tmp_path, - lock=source / "pylock.toml", groups=groups, venv=environment, ) @@ -47,7 +45,8 @@ def test_install_locked_tools_installs_groups(tmp_path: Path, action_scripts: Pa pytest.param("release-news", "towncrier", "prek", id="pr-news"), pytest.param("release-validate", "release-creator", "towncrier", id="patch-validation"), pytest.param("release-shell", "yq", "release-creator", id="inline-shell"), - pytest.param("release-hooks", "towncrier", "twine", id="action-tests"), + pytest.param("release-hooks", "release-creator", "towncrier", id="action-tests"), + pytest.param("release-hook-check", "yq", "towncrier", id="hook-cooldown"), ], ) def test_install_locked_tools_selects_job_tools( @@ -58,7 +57,6 @@ def test_install_locked_tools_selects_job_tools( source, action_scripts, tmp_path, - lock=source / "pylock.toml", groups=group, venv=tmp_path / "tools", ) @@ -69,27 +67,27 @@ def test_install_locked_tools_selects_job_tools( def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - executable = shutil.which("release-creator") - assert executable is not None generated = subprocess.run( [ - executable, - "build-constraints", - "--lock", - str(source / "pylock.toml"), - "--group", - "build", - "--python-version", - "3.15", - "--output", + "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 - assert (tmp_path / "constraints.txt").read_bytes() == (source / "release-build-constraints.txt").read_bytes() + constraints = (tmp_path / "constraints.txt").read_text() + assert "cython==" in constraints + assert "setuptools==" in constraints + assert "--hash=sha256:" in constraints def test_install_locked_tools_rejects_exporter_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: @@ -97,14 +95,13 @@ def test_install_locked_tools_rejects_exporter_hash_mismatch(tmp_path: Path, act action_root = tmp_path / "action" action_root.mkdir() lock = (source / "pylock.toml").read_text() - package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "release-creator") + 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 = install( source, action_scripts, tmp_path, - lock=source / "pylock.toml", - groups="release", + groups="release-notes", venv=tmp_path / "tools", action_root=action_root, extra_env={"UV_CACHE_DIR": str(tmp_path / "cache")}, @@ -119,7 +116,6 @@ def test_install_locked_tools_rejects_empty_groups(tmp_path: Path, action_script source, action_scripts, tmp_path, - lock=source / "pylock.toml", groups="", venv=tmp_path / "tools", ) @@ -133,10 +129,10 @@ def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scrip source, action_scripts, tmp_path, - lock=source / "pylock.toml", groups="relesae", venv=tmp_path / "tools", ) + assert result.returncode != 0 assert "missing lock dependency groups: relesae" in result.stderr @@ -147,7 +143,6 @@ def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Pa source, action_scripts, tmp_path, - lock=source / "pylock.toml", groups="release-notes", venv=tmp_path / target, ) @@ -167,7 +162,6 @@ def test_install_locked_tools_uses_managed_python(tmp_path: Path, action_scripts source, action_scripts, tmp_path, - lock=source / "pylock.toml", groups="release-check", venv=tmp_path / "tools", extra_env={"PATH": str(tools)}, @@ -182,13 +176,12 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, act source, action_scripts, tmp_path, - lock=source / "pylock.toml", groups="release-notes", venv=tmp_path / "tools", ) assert result.returncode == 0, result.stderr imported = subprocess.run( - [str(tmp_path / "lock-exporter/bin/python"), "-c", "import yaml"], + [str(tmp_path / "lock-tools/bin/python"), "-c", "import yaml"], text=True, capture_output=True, check=False, @@ -196,61 +189,106 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, act assert "No module named 'yaml'" in imported.stderr -def test_install_locked_tools_accepts_tool_only_lock(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - lock = tmp_path / "pylock.tool-only.toml" - lock.write_text((source / "pylock.toml").read_text().replace(' "build",\n', "", 1)) - result = install(source, action_scripts, tmp_path, lock=lock, groups="release-notes", venv=tmp_path / "tools") - assert result.returncode == 0, result.stderr - assert (tmp_path / "tools/bin/release-creator").is_file() - - def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] - action_root = tmp_path / "action" - action_root.mkdir() (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) - (tmp_path / "release-build-constraints.txt").write_text( - (source / "release-build-constraints.txt").read_text() + f"probe-only==1.0 --hash=sha256:{'0' * 64}\n" + (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( - source, + tmp_path, action_scripts, tmp_path, - lock=source / "pylock.toml", groups="release-notes", venv=tmp_path / "tools", - action_root=action_root, ) assert result.returncode == 0, result.stderr assert "probe-only==1.0 " in (tmp_path / "caller-build-constraints.txt").read_text() +def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, action_scripts: 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( + ["uvx", "--from", "nab==0.0.22", "nab", "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 (tmp_path / "caller-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, - lock=source / "pylock.toml", - groups="release", + 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] - cutoff = datetime.now(UTC) - timedelta(days=7) - workflow = yaml.safe_load((source / ".github/workflows/update-tools.yaml").read_text()) + policy = tomllib.loads((source / "pyproject.toml").read_text())["tool"]["nab"] floors = { - name: datetime.fromisoformat(value) - for name, value in ( - line.split("=", 1) for line in workflow["jobs"]["update"]["with"]["exclude-newer-packages"].splitlines() - ) + name: datetime.fromisoformat(settings["uploaded-prior-to"]) + for name, settings in policy.get("packages", {}).items() + if "uploaded-prior-to" in settings } - packages = tomllib.loads((source / "pylock.toml").read_text())["packages"] + 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 @@ -261,9 +299,9 @@ def test_shared_lock_respects_seven_day_cutoff() -> None: if "upload-time" in artifact ] ) - and max(times) > max(cutoff, floors.get(package["name"], cutoff)) + and max(times) > floors.get(package["name"], cutoff) } - assert younger == set() + assert not younger, f"packages newer than the seven-day cutoff: {sorted(younger)}" def install( @@ -271,34 +309,19 @@ def install( action_scripts: Path, tmp_path: Path, *, - lock: Path, groups: str, venv: Path, action_root: Path | None = None, extra_env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: - source = Path(__file__).resolve().parents[2] - environment = ( - os.environ - | { - "LOCK": str(lock), - "DEPENDENCY_GROUPS": groups, - "PYTHON_VERSION": "3.15", - "VENV": str(venv), - "RUNNER_TEMP": str(tmp_path), - "GITHUB_ACTION_PATH": str(action_root or source / "install-locked-tools"), - } - | (extra_env or {}) + return action( + repository, + tmp_path, + action_scripts, + "install-locked-tools", + version="", + GROUPS=groups, + VENV=str(venv), + **({"GITHUB_ACTION_PATH": str(action_root)} if action_root is not None else {}), + **(extra_env or {}), ) - for index in range(3): - result = subprocess.run( - ["bash", str(action_scripts / f"install-locked-tools-{index}.sh")], - cwd=repository, - env=environment, - text=True, - capture_output=True, - check=False, - ) - if result.returncode: - return result - return result diff --git a/tests/label_bot_news/test_label.py b/tests/label_bot_news/test_label.py index 1cff1f6..0976e56 100644 --- a/tests/label_bot_news/test_label.py +++ b/tests/label_bot_news/test_label.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import shutil from pathlib import Path import pytest @@ -9,13 +11,13 @@ @pytest.mark.parametrize( - ("files", "labels", "write_method", "fragment_dirs", "extension", "fragment_style"), + ("files", "labels", "edit_option", "fragment_dirs", "extension", "fragment_style"), [ - pytest.param([], [], "POST", "changelog.d", "md", "towncrier", id="add"), + pytest.param([], [], "--add-label", "changelog.d", "md", "towncrier", id="add"), pytest.param( [{"filename": "changelog.d/+old.feature.md", "status": "removed"}], [], - "POST", + "--add-label", "changelog.d", "md", "towncrier", @@ -25,7 +27,7 @@ pytest.param( [{"filename": "changelog.d/+fix.bugfix.md"}], [{"name": "skip news"}], - "DELETE", + "--remove-label", "changelog.d", "md", "towncrier", @@ -40,10 +42,64 @@ "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"}], - "DELETE", + "--remove-label", "changelog.d,docs/changelog.d", "md", "towncrier", @@ -52,13 +108,13 @@ pytest.param( [{"filename": "docs/changelog/+fix.bugfix.rst"}], [{"name": "skip news"}], - "DELETE", + "--remove-label", "docs/changelog", "rst", "towncrier", id="rst-fragment-removes-label", ), - pytest.param([], [], "POST", ".changeset", "md", "changeset", id="changeset-add"), + pytest.param([], [], "--add-label", ".changeset", "md", "changeset", id="changeset-add"), pytest.param( [{"filename": "changelog.d/nested/+fix.bugfix.md"}], [{"name": "skip news"}], @@ -80,7 +136,7 @@ pytest.param( [{"filename": ".changeset/fix-something.md"}], [{"name": "skip news"}], - "DELETE", + "--remove-label", ".changeset", "md", "changeset", @@ -104,7 +160,7 @@ def test_label_bot_news_updates_label( github_api: tuple[HTTPServer, dict[str, str]], files: list[dict[str, str]], labels: list[dict[str, str]], - write_method: str | None, + edit_option: str | None, fragment_dirs: str, extension: str, fragment_style: str, @@ -113,9 +169,17 @@ def test_label_bot_news_updates_label( 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) - if write_method is not None: - uri = f"{prefix}/issues/1/labels" + ("/skip news" if write_method == "DELETE" else "") - server.expect_request(uri, method=write_method).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, @@ -129,11 +193,17 @@ def test_label_bot_news_updates_label( 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"] + ( - [write_method] if write_method is not None else [] + 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 [] ) diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 2932e21..035f15f 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -1,13 +1,10 @@ from __future__ import annotations -import os -import subprocess from datetime import date +from pathlib import Path from textwrap import dedent -lazy from pathlib import Path import pytest -import yaml from conftest import action, run from pytest_httpserver import HTTPServer @@ -19,34 +16,42 @@ 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_workflow_preserves_unformatted_default() -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - assert workflow["on"]["workflow_call"]["inputs"]["format"]["default"] == "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_and_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +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( @@ -65,17 +70,16 @@ def test_prepare_custom_fragment_directory( action_scripts, "prepare-towncrier", version="", - FRAGMENTS="docs/changelog.d", - LINK_FRAGMENTS="true", - REPOSITORY="tox-dev/github-workflows", - **_author_lookup(github_api, 2), + GITHUB_REPOSITORY="tox-dev/github-workflows", + **_author_lookup(repository, github_api, 2), ) assert result.returncode == 0, result.stderr assert "version=0.2.0" in (tmp_path / "output").read_text() - assert ( + expected = ( "Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) " "by [@gaborbernat](https://github.com/gaborbernat)." - ) in (repository / "CHANGELOG.md").read_text() + ) + assert expected in " ".join((repository / "CHANGELOG.md").read_text().split()) assert "docs/changelog.d/+feature.feature.md" in (tmp_path / "release.patch").read_text() @@ -88,9 +92,8 @@ def test_prepare_renders_github_notes( action_scripts, "prepare-towncrier", version="0.2.0", - LINK_FRAGMENTS="true", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(github_api, 2), + **_author_lookup(repository, github_api, 2), ) assert result.returncode == 0, result.stderr result = run( @@ -129,15 +132,8 @@ def test_prepare_rejects_dirty_checkout(repository: Path, tmp_path: Path, action @pytest.mark.parametrize( ("option", "value", "error"), [ - pytest.param("FORMAT", "yaml", "format must be markdown, rst, or none", id="format"), - pytest.param( - "LINK_FRAGMENTS", - "maybe", - "link-fragments must be true or false", - id="fragment-linking", - ), - pytest.param("NUMBERED_PRS", "maybe", "numbered-prs must be true or false", id="numbered-prs"), 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( @@ -211,18 +207,18 @@ def test_prepare_links_named_fragments( action_scripts, "prepare-towncrier", version="0.2.0", - LINK_FRAGMENTS="true", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(github_api, 2), + **_author_lookup(repository, github_api, 2), ) assert result.returncode == 0, result.stderr - assert ( + expected = ( "Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) " "by [@gaborbernat](https://github.com/gaborbernat)." - ) in (repository / "CHANGELOG.md").read_text() + ) + assert expected in " ".join((repository / "CHANGELOG.md").read_text().split()) -def test_prepare_links_numbered_pull_requests( +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") @@ -234,16 +230,14 @@ def test_prepare_links_numbered_pull_requests( action_scripts, "prepare-towncrier", version="0.2.0", - LINK_FRAGMENTS="true", - NUMBERED_PRS="true", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(github_api, 2), + **_author_lookup(repository, 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_keeps_default_numbered_issue( +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") @@ -255,9 +249,8 @@ def test_prepare_keeps_default_numbered_issue( action_scripts, "prepare-towncrier", version="0.2.0", - LINK_FRAGMENTS="true", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(github_api, 3), + **_author_lookup(repository, github_api, 3), ) assert result.returncode == 0, result.stderr assert "[#3](https://github.com/tox-dev/github-workflows/pull/3)" in (repository / "CHANGELOG.md").read_text() @@ -270,7 +263,7 @@ def test_prepare_rejects_unlinked_fragment_name( 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, LINK_FRAGMENTS="true") + 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() @@ -285,7 +278,7 @@ def test_prepare_rejects_untracked_output(repository: Path, tmp_path: Path, acti run(repository, "git", "add", ".") run(repository, "git", "commit", "-m", "Write untracked output") run(repository, "git", "push", "origin", "main") - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", FORMAT="markdown") + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") assert result.returncode != 0 assert "created untracked files" in result.stderr @@ -297,46 +290,33 @@ def test_prepare_rejects_empty_release(repository: Path, tmp_path: Path, action_ 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: - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / "prepare-towncrier-7.sh")], - cwd=repository, - env=os.environ | {"PATCH": str(tmp_path / "release.patch")}, - text=True, - capture_output=True, - check=False, - ) + 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 / ".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", - ) (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", "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", - FORMAT="markdown", - ) + 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) -> None: +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") @@ -359,14 +339,15 @@ def test_prepare_formats_rst(repository: Path, tmp_path: Path, action_scripts: P action_scripts, "prepare-towncrier", version="0.2.0", - FORMAT="rst", - CHANGELOG="CHANGELOG.rst", + **_author_lookup(repository, github_api, 2), ) assert result.returncode == 0, result.stderr - assert "Add a After feature." in (repository / "CHANGELOG.rst").read_text() + assert "Add a After feature (:pr:`2`) by :user:`gaborbernat`." in (repository / "CHANGELOG.rst").read_text() -def test_prepare_avoids_rst_conflict_marker(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() @@ -396,8 +377,7 @@ def test_prepare_avoids_rst_conflict_marker(repository: Path, tmp_path: Path, ac action_scripts, "prepare-towncrier", version="0.2.0", - FORMAT="rst", - CHANGELOG="CHANGELOG.rst", + **_author_lookup(repository, github_api, 2), ) assert result.returncode == 0, result.stderr run(repository, "git", "reset", "--hard", "HEAD") @@ -407,15 +387,20 @@ def test_prepare_avoids_rst_conflict_marker(repository: Path, tmp_path: Path, ac action_scripts, "validate-release-patch", version="0.2.0", - CHANGELOG="CHANGELOG.rst", ) 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]: +def _author_lookup(repository: Path, github_api: tuple[HTTPServer, dict[str, str]], number: int) -> dict[str, str]: server, environment = github_api - server.expect_request(f"/api/v3/repos/tox-dev/github-workflows/pulls/{number}", method="GET").respond_with_json( - {"user": {"login": "gaborbernat", "type": "User"}}, - ) + commit = run(repository, "git", "rev-parse", "HEAD").stdout.strip() + for name in ("fixture", "github-workflows"): + server.expect_request( + f"/api/v3/repos/tox-dev/{name}/commits/{commit}/pulls", + method="GET", + query_string="per_page=100", + ).respond_with_json( + [{"number": number, "merge_commit_sha": commit, "user": {"login": "gaborbernat", "type": "User"}}], + ) return environment diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 4be8b3a..9c95ae5 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -1,5 +1,8 @@ from __future__ import annotations +import os +import shutil +import subprocess from hashlib import sha256 from pathlib import Path @@ -8,771 +11,540 @@ from pytest_httpserver import HTTPServer -@pytest.mark.parametrize( - ("with_asset", "empty_directory"), - [(False, False), (False, True), (True, False)], - ids=["notes-only", "empty-directory", "with-asset"], -) -def test_published_release_is_verified_without_writes( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - with_asset: bool, - empty_directory: bool, -) -> None: - server, environment = github_api - notes = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - assets = tmp_path / "assets" - if with_asset or empty_directory: - assets.mkdir() - if with_asset: - (assets / "fixture.zip").write_bytes(b"release archive") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}} - ) - release = { - "id": 42, - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": False, - "prerelease": False, - "assets": ( - [{"name": "fixture.zip", "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"}] - if with_asset - else [] - ), - "target_commitish": "main", +@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"), } - 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 or empty_directory)): - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) - result = action( - repository, - tmp_path, - action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY=str(assets) if with_asset or empty_directory else "", - PRERELEASE="false", - **environment, + + +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 [] ) - assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log] == [ - f"{prefix}/git/ref/tags/0.1.0", - f"{prefix}/releases", - *[f"{prefix}/releases/42"] * (2 + int(with_asset or empty_directory)), - ] -@pytest.mark.parametrize( - ("changed_field", "changed_value"), - [ - pytest.param("name", "Other title", id="title"), - pytest.param("body", "Different notes.\n", id="body"), - pytest.param("prerelease", True, id="prerelease"), - pytest.param("assets", [{"name": "extra.zip", "digest": "sha256:deadbeef", "state": "uploaded"}], id="asset"), - ], -) -@pytest.mark.parametrize("is_draft", [False, True], ids=["published", "draft"]) -def test_existing_release_rejects_changed_content( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - changed_field: str, - changed_value: str | bool | list[dict[str, str]], - is_draft: bool, -) -> None: - server, environment = github_api +def _notes_and_commit(repository: Path, tmp_path: Path) -> tuple[Path, str]: notes = tmp_path / "notes.md" notes.write_text("Release notes.\n") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - prefix = "/api/v3/repos/tox-dev/fixture" - release = { + 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": is_draft, + "draft": draft, "prerelease": False, - "assets": [], + "assets": assets or [], } - release[changed_field] = changed_value - server.expect_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}} - ) - server.expect_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release], - ) - result = action( - repository, - tmp_path, - action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY="", - PRERELEASE="false", - **environment, - ) - assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET", "GET"] -def test_tag_must_point_to_expected_commit( +def _request( repository: Path, tmp_path: Path, action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], -) -> None: - server, environment = github_api - notes = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": "0" * 40}}, - ) - result = action( + 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", - REPOSITORY="tox-dev/fixture", TAG="0.1.0", - COMMIT="1" * 40, + COMMIT=commit, TITLE="0.1.0", NOTES_FILE=str(notes), - ASSETS_DIRECTORY="", + ASSETS_DIRECTORY=str(assets) if assets is not None else "", PRERELEASE="false", + MODE=mode, **environment, ) - assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] -def test_annotated_tag_points_to_expected_commit( +@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 = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "tag", "sha": "2" * 40}}, - ) - server.expect_ordered_request(f"{prefix}/git/tags/{'2' * 40}", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}}, + 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 [] + ), ) - release = { - "id": 42, - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": False, - "prerelease": False, - "assets": [], - } + 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], + [release] ) - for _ in range(2): + for _ in range(2 + int(with_asset)): server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) - result = action( + result = _request( repository, tmp_path, action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY="", - PRERELEASE="false", - **environment, + notes, + commit, + environment | release_cli, + assets=assets if with_asset else None, ) assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log] == [ - f"{prefix}/git/ref/tags/0.1.0", - f"{prefix}/git/tags/{'2' * 40}", - f"{prefix}/releases", - *[f"{prefix}/releases/42"] * 2, - ] + 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 [] + ) -def test_missing_tag_rejects_same_name_branch( +@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 = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - run(repository, "git", "branch", "0.1.0", commit) + 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_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"message": "Not Found"}, - status=404, - ) - result = action( - repository, - tmp_path, - action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY="", - PRERELEASE="false", - **environment, + 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.path for request, _ in server.log] == [f"{prefix}/git/ref/tags/0.1.0"] + assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert not _release_calls(tmp_path) -def test_asset_name_must_be_safe( +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 = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - assets = tmp_path / "assets" - assets.mkdir() - (assets / "unsafe\nname.zip").write_bytes(b"release archive") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}}, - ) - result = action( - repository, - tmp_path, - action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY=str(assets), - PRERELEASE="false", - **environment, + 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 "unsafe release asset name" in result.stdout assert [request.method for request, _ in server.log] == ["GET"] -def test_publish_requires_an_existing_draft( +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 = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}} - ) - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) - result = action( - repository, - tmp_path, - action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY="", - PRERELEASE="false", - MODE="publish", - **environment, + 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", "GET"] + assert [request.method for request, _ in server.log] == ["GET"] -def test_publish_rejects_incomplete_draft_without_upload( +@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 = tmp_path / "notes.md" - notes.write_text("Release notes.\n") + notes, commit = _notes_and_commit(repository, tmp_path) assets = tmp_path / "assets" assets.mkdir() - (assets / "fixture.zip").write_bytes(b"release archive") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - prefix = "/api/v3/repos/tox-dev/fixture" - draft = { - "id": 42, - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": True, - "prerelease": False, - "assets": [], - } - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}} - ) - server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [draft], - ) - for _ in range(6): - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) - result = action( - repository, - tmp_path, - action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY=str(assets), - PRERELEASE="false", - MODE="publish", - **environment, + (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 [request.method for request, _ in server.log] == ["GET"] * 8 + assert "unsafe release asset name" in result.stdout + assert [request.method for request, _ in server.log] == ["GET"] -def test_invalid_mode_rejects_before_api_access( +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 = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - result = action( - repository, - tmp_path, - action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT="0" * 40, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY="", - PRERELEASE="false", - MODE="unknown", - **environment, - ) + 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 "invalid release mode" in result.stdout - assert server.log == [] + assert [request.method for request, _ in server.log] == ["GET", "GET"] -@pytest.mark.parametrize("mode", ["stage", "publish", "complete"]) -def test_matching_draft_is_published_without_recreation( +@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 = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() + notes, commit = _notes_and_commit(repository, tmp_path) + draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" - draft = { - "id": 42, - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": True, - "prerelease": False, - "assets": [], - } - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "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}/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 != "stage": + 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 = action( - repository, - tmp_path, - action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY="", - PRERELEASE="false", - MODE=mode, - **environment, + 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", - *([] if mode == "stage" else ["PATCH"]), + *(["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( - ("is_prerelease", "mode"), + "asset_name", [ - pytest.param(False, "stage", id="stable-stage"), - pytest.param(True, "stage", id="prerelease-stage"), - pytest.param(False, "complete", id="stable-complete"), - pytest.param(True, "complete", id="prerelease-complete"), + pytest.param("fixture.zip", id="plain"), + pytest.param("fixture+linux.zip", id="plus"), + pytest.param("Fixture signed.zip", id="space"), ], ) -def test_missing_release_is_created_and_published( +def test_draft_release_uploads_missing_asset( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], - is_prerelease: bool, + release_cli: dict[str, str], mode: str, + asset_name: str, ) -> None: server, environment = github_api - notes = tmp_path / "notes.md" - notes.write_text("Release notes.\n") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() + 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" - release = { - "id": 42, - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": True, - "prerelease": is_prerelease, - "assets": [], - } - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}} + 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", query_string="per_page=100", method="GET").respond_with_json([]) - server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(release, status=201) - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) + 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( - release | {"draft": False}, + complete | {"draft": False} ) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( - release | {"draft": mode == "stage"}, + complete | {"draft": mode == "stage"} ) - result = action( + result = _request( repository, tmp_path, action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - GITHUB_REPOSITORY="other/checkout", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY="", - PRERELEASE=str(is_prerelease).lower(), - MODE=mode, - **environment, + 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", - "POST", "GET", - *([] if mode == "stage" else ["PATCH"]), + "GET", + *(["PATCH"] if mode == "complete" else []), "GET", ] - request = next( - request for request, _ in server.log if request.path == f"{prefix}/releases" and 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": is_prerelease, - } -@pytest.mark.parametrize("mode", ["stage", "complete"]) -@pytest.mark.parametrize("asset_name", ["fixture.zip", "fixture+linux.zip", "Fixture signed.zip"]) -def test_draft_release_resumes_missing_asset( +def test_failed_asset_upload_leaves_draft_unpublished( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], - mode: str, - asset_name: str, + release_cli: dict[str, str], ) -> None: server, environment = github_api - notes = tmp_path / "notes.md" - notes.write_text("Release notes.\n") + notes, commit = _notes_and_commit(repository, tmp_path) assets = tmp_path / "assets" assets.mkdir() - (assets / asset_name).write_bytes(b"release archive") - digest = f"sha256:{sha256(b'release archive').hexdigest()}" - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() + (assets / "fixture.zip").write_bytes(b"release archive") + draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" - asset = {"name": asset_name, "digest": digest, "state": "uploaded"} - release = { - "id": 42, - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": True, - "prerelease": False, - "assets": [], - "upload_url": f"https://localhost:{server.port}{prefix}/releases/42/assets{{?name,label}}", - } - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}} - ) + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( - [release], - ) - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) - server.expect_ordered_request( - f"{prefix}/releases/42/assets", - query_string=f"name={asset_name.replace('+', '%2B').replace(' ', '%20')}", - method="POST", - data=b"release archive", - ).respond_with_json(asset, status=201) - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( - release | {"assets": [asset]}, - ) - if mode == "complete": - server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( - release | {"draft": False}, - ) - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( - release | {"draft": mode == "stage", "assets": [asset]}, + [draft] ) - result = action( + 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, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY=str(assets), - PRERELEASE="false", - MODE=mode, - **environment, + 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 ) - assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == [ - "GET", - "GET", - "GET", - "POST", - "GET", - *([] if mode == "stage" else ["PATCH"]), - "GET", - ] -def test_failed_asset_upload_leaves_draft_unpublished( +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 = tmp_path / "notes.md" - notes.write_text("Release notes.\n") + notes, commit = _notes_and_commit(repository, tmp_path) assets = tmp_path / "assets" assets.mkdir() - (assets / "fixture.zip").write_bytes(b"release archive") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - prefix = "/api/v3/repos/tox-dev/fixture" - draft = { - "id": 42, - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": True, - "prerelease": False, - "assets": [], - "upload_url": f"https://localhost:{server.port}{prefix}/releases/42/assets{{?name,label}}", + 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", } - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}} - ) + 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], + [draft] ) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) - server.expect_ordered_request( - f"{prefix}/releases/42/assets", - query_string="name=fixture.zip", - method="POST", - data=b"release archive", - ).respond_with_json({"message": "upstream failure"}, status=502) - result = action( + 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, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY=str(assets), - PRERELEASE="false", - **environment, + notes, + commit, + environment | release_cli | {"GH_UPLOAD_FAILURE": "once"}, + assets=assets, ) - assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET", "GET", "GET", "POST"] + 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"), + ] -@pytest.mark.parametrize( - "failed_read", - [ - pytest.param("upload", id="before-upload"), - pytest.param("complete", id="after-upload"), - pytest.param("published", id="after-publish"), - ], -) -def test_release_read_recovers_without_duplicate_writes( +def test_failed_asset_attestation_is_reported( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], - failed_read: str, + release_cli: dict[str, str], ) -> None: server, environment = github_api - notes = tmp_path / "notes.md" - notes.write_text("Release notes.\n") + notes, commit = _notes_and_commit(repository, tmp_path) assets = tmp_path / "assets" assets.mkdir() - (assets / "fixture.zip").write_bytes(b"release archive") - commit = run(repository, "git", "rev-parse", "0.1.0").stdout.strip() - prefix = "/api/v3/repos/tox-dev/fixture" - asset = {"name": "fixture.zip", "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"} - draft = { - "id": 42, - "tag_name": "0.1.0", - "name": "0.1.0", - "body": notes.read_text(), - "draft": True, - "prerelease": False, - "assets": [], - "upload_url": f"https://localhost:{server.port}{prefix}/releases/42/assets{{?name,label}}", - } - server.expect_ordered_request(f"{prefix}/git/ref/tags/0.1.0", method="GET").respond_with_json( - {"object": {"type": "commit", "sha": commit}} + 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( - [draft] + [release] ) - for stage, release in ( - ("upload", draft), - ("complete", draft | {"assets": [asset]}), - ("published", draft | {"draft": False, "assets": [asset]}), - ): - if stage == failed_read: - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( - {"message": "upstream failure"}, status=502 - ) + for _ in range(3): server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) - if stage == "upload": - server.expect_ordered_request( - f"{prefix}/releases/42/assets", query_string="name=fixture.zip", method="POST", data=b"release archive" - ).respond_with_json(asset, status=201) - elif stage == "complete": - server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( - draft | {"draft": False, "assets": [asset]} - ) - result = action( + result = _request( repository, tmp_path, action_scripts, - "publish-github-release", - version="0.1.0", - REPOSITORY="tox-dev/fixture", - TAG="0.1.0", - COMMIT=commit, - TITLE="0.1.0", - NOTES_FILE=str(notes), - ASSETS_DIRECTORY=str(assets), - PRERELEASE="false", - **environment, + notes, + commit, + environment | release_cli | {"GH_VERIFY_FAILURE": "true"}, + assets=assets, ) - assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == [ - "GET", - "GET", - "GET", - *(["GET"] if failed_read == "upload" else []), - "POST", - "GET", - *(["GET"] if failed_read == "complete" else []), - "PATCH", - "GET", - *(["GET"] if failed_read == "published" else []), - ] + 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 index 270f8e4..e7a443a 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -1,41 +1,38 @@ from __future__ import annotations -import os -import subprocess +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 -def test_signed_archive_passes_byte_and_structure_checks( - signed_archive: Path, tmp_path: Path, action_scripts: Path -) -> None: - result = _execute( - action_scripts / "publish-jetbrains-marketplace-0.sh", - tmp_path, - ARCHIVE=str(signed_archive), - EXPECTED_SHA=sha256(signed_archive.read_bytes()).hexdigest(), - PLUGIN_ID="29973", - CHANNEL="", - ) - assert result.returncode == 0, result.stderr - - -@pytest.mark.parametrize("changed", ["digest", "plugin-id", "channel", "extension", "corrupt", "duplicate", "symlink"]) +@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, action_scripts: Path, changed: str + signed_archive: Path, tmp_path: Path, marketplace_scripts: Path, httpserver: HTTPServer, changed: str ) -> None: settings = { "ARCHIVE": str(signed_archive), - "EXPECTED_SHA": sha256(signed_archive.read_bytes()).hexdigest(), + "SHA256": sha256(signed_archive.read_bytes()).hexdigest(), "PLUGIN_ID": "29973", "CHANNEL": "beta", } if changed == "digest": - settings["EXPECTED_SHA"] = "0" * 64 + settings["SHA256"] = "0" * 64 elif changed == "plugin-id": settings["PLUGIN_ID"] = "fish" elif changed == "channel": @@ -46,20 +43,29 @@ def test_invalid_archive_or_input_fails_before_upload( settings["ARCHIVE"] = str(wrong_extension) elif changed == "corrupt": signed_archive.write_bytes(b"not a zip") - settings["EXPECTED_SHA"] = sha256(signed_archive.read_bytes()).hexdigest() + 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["EXPECTED_SHA"] = sha256(signed_archive.read_bytes()).hexdigest() + settings["SHA256"] = sha256(signed_archive.read_bytes()).hexdigest() else: link = tmp_path / "linked.zip" link.symlink_to(signed_archive) settings["ARCHIVE"] = str(link) - result = _execute(action_scripts / "publish-jetbrains-marketplace-0.sh", tmp_path, **settings) + 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", [200, 403]) +@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"], @@ -69,7 +75,7 @@ def test_invalid_archive_or_input_fails_before_upload( def test_multipart_upload_uses_exact_archive_and_channel( signed_archive: Path, tmp_path: Path, - action_scripts: Path, + marketplace_scripts: Path, httpserver: HTTPServer, status: int, archive_name: str, @@ -81,17 +87,14 @@ def test_multipart_upload_uses_exact_archive_and_channel( httpserver.expect_request("/api/updates/upload", method="POST").respond_with_data( "upload denied" if status == 403 else "{}", status=status ) - script = tmp_path / "upload.sh" - script.write_text( - (action_scripts / "publish-jetbrains-marketplace-1.sh") - .read_text() - .replace("https://plugins.jetbrains.com/api/updates/upload", httpserver.url_for("/api/updates/upload")) - ) - result = _execute( - script, + result = action( tmp_path, + runner_temp, + marketplace_scripts, + "publish-jetbrains-marketplace", + version="", ARCHIVE=str(signed_archive), - EXPECTED_SHA=sha256(signed_archive.read_bytes()).hexdigest(), + SHA256=sha256(signed_archive.read_bytes()).hexdigest(), PLUGIN_ID="29973", CHANNEL="beta", PUBLISH_TOKEN="fixture-token", @@ -109,19 +112,16 @@ def test_multipart_upload_uses_exact_archive_and_channel( def test_missing_token_fails_without_request( - signed_archive: Path, tmp_path: Path, action_scripts: Path, httpserver: HTTPServer + signed_archive: Path, tmp_path: Path, marketplace_scripts: Path, httpserver: HTTPServer ) -> None: - script = tmp_path / "upload.sh" - script.write_text( - (action_scripts / "publish-jetbrains-marketplace-1.sh") - .read_text() - .replace("https://plugins.jetbrains.com/api/updates/upload", httpserver.url_for("/api/updates/upload")) - ) - result = _execute( - script, + result = action( tmp_path, + tmp_path, + marketplace_scripts, + "publish-jetbrains-marketplace", + version="", ARCHIVE=str(signed_archive), - EXPECTED_SHA=sha256(signed_archive.read_bytes()).hexdigest(), + SHA256=sha256(signed_archive.read_bytes()).hexdigest(), PLUGIN_ID="29973", CHANNEL="", PUBLISH_TOKEN="", @@ -139,17 +139,14 @@ def signed_archive(tmp_path: Path) -> Path: return archive -def _execute(script: Path, tmp_path: Path, **settings: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", str(script)], - cwd=tmp_path, - env={ - name: os.environ[name] - for name in ("HOME", "LANG", "PATH", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY") - if name in os.environ - } - | settings, - text=True, - capture_output=True, - check=False, +@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 index e58828d..8ddaf19 100644 --- a/tests/publish_post_release_pr/test_post_release_pr.py +++ b/tests/publish_post_release_pr/test_post_release_pr.py @@ -6,6 +6,7 @@ import pytest import yaml +from conftest import action from pytest_httpserver import HTTPServer @@ -15,29 +16,24 @@ def test_action_maps_writer_app_slug() -> None: assert action["inputs"]["allowed-paths"]["required"] assert action["runs"]["steps"][0]["env"]["APP_SLUG"] == "${{ inputs.app-slug }}" assert action["runs"]["steps"][1]["env"]["ALLOWED_PATHS"] == "${{ inputs.allowed-paths }}" - assert action["runs"]["steps"][-1]["id"] == "publish" + 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]: - steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "publish-post-release-pr/action.yml").read_text())[ - "runs" - ]["steps"] - action_variables = {name for step in steps for name in step["env"]} - base_env = {name: value for name, value in env.items() if name not in action_variables} - result = None - for index, step in enumerate(steps): - result = subprocess.run( - ["bash", str(action_scripts / f"publish-post-release-pr-{index}.sh")], - cwd=cwd, - env=base_env | {name: env[name] for name in step["env"]}, - text=True, - capture_output=True, - check=False, - ) - if result.returncode: - return result - assert result is not None - return result + 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: @@ -98,7 +94,7 @@ def publish( env=os.environ | environment | { - "REPOSITORY": "tox-dev/fixture", + "GITHUB_REPOSITORY": "tox-dev/fixture", "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": "a" * 40, @@ -112,7 +108,6 @@ def publish( "CANDIDATE_COMMIT": "", "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), - "GITHUB_OUTPUT": str(tmp_path / "output"), }, ) return result, server @@ -123,7 +118,6 @@ def test_merged_branch_deleted_and_main_matches_is_complete( ) -> 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 (tmp_path / "output").read_text() == "pull-request=7\n" assert [request.method for request, _ in server.log] == ["GET"] * 6 @@ -142,7 +136,6 @@ def test_merged_branch_deleted_and_later_main_change_fails( ) -> None: result, server = publish(tmp_path, action_scripts, github_api, main_blob="c" * 40, expected_blob="b" * 40) assert result.returncode != 0 - assert not (tmp_path / "output").exists() assert [request.method for request, _ in server.log] == ["GET"] * 4 @@ -150,8 +143,11 @@ 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( [ { @@ -165,23 +161,26 @@ def test_open_pull_request_metadata_change_fails( } ] ) - result = subprocess.run( - ["bash", str(action_scripts / "publish-post-release-pr-3.sh")], + result = run_publish( + action_scripts, cwd=tmp_path, env=os.environ | environment | { - "REPOSITORY": "tox-dev/fixture", + "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), }, - text=True, - capture_output=True, - check=False, ) assert result.returncode != 0 assert "open pull request metadata changed" in result.stderr @@ -216,7 +215,7 @@ def test_closed_unmerged_pr_blocks_retry_before_write( env=os.environ | environment | { - "REPOSITORY": "tox-dev/fixture", + "GITHUB_REPOSITORY": "tox-dev/fixture", "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": base, @@ -230,13 +229,11 @@ def test_closed_unmerged_pr_blocks_retry_before_write( "CANDIDATE_COMMIT": commit, "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), - "GITHUB_OUTPUT": str(tmp_path / "output"), }, ) assert result.returncode != 0 assert "post-release pull request was closed without merging; reopen it to retry" in result.stderr assert [request.method for request, _ in server.log] == ["GET", "GET"] - assert not (tmp_path / "output").exists() def run_git(directory: Path, *args: str) -> str: @@ -284,15 +281,14 @@ def candidate( @pytest.mark.parametrize( "branch_tamper", - ["none", "tree", "author", "committer", "message", "base", "parent"], - ids=[ - "exact-branch", - "divergent-tree", - "divergent-author", - "divergent-committer", - "divergent-message", - "moved-base", - "merge-commit", + [ + 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( @@ -354,7 +350,7 @@ def test_open_branch_must_match_prepared_candidate( env=os.environ | environment | { - "REPOSITORY": "tox-dev/fixture", + "GITHUB_REPOSITORY": "tox-dev/fixture", "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": base, @@ -368,15 +364,15 @@ def test_open_branch_must_match_prepared_candidate( "CANDIDATE_COMMIT": commit, "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), - "GITHUB_OUTPUT": str(tmp_path / "output"), }, ) assert (result.returncode == 0) is (branch_tamper == "none"), result.stderr - assert (tmp_path / "output").exists() is (branch_tamper == "none") assert all(request.method == "GET" for request, _ in server.log) -@pytest.mark.parametrize("matching_head", [True, False], ids=["prepared-head", "changed-head"]) +@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: @@ -435,7 +431,7 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( env=os.environ | environment | { - "REPOSITORY": "tox-dev/fixture", + "GITHUB_REPOSITORY": "tox-dev/fixture", "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": base, @@ -449,26 +445,23 @@ def test_absent_branch_pushes_prepared_commit_and_opens_pr( "CANDIDATE_COMMIT": commit, "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), - "GITHUB_OUTPUT": str(tmp_path / "output"), "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 - assert (tmp_path / "output").exists() is matching_head @pytest.mark.parametrize( "changed_input", - ["commit", "blob", "extra", "author", "committer", "message", "unauthorized"], - ids=[ - "stale-bundle", - "changed-path-manifest", - "extra-path", - "wrong-author", - "wrong-committer", - "wrong-message", - "unauthorized-path", + [ + 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( @@ -499,7 +492,7 @@ def test_prepared_candidate_identity_is_checked_before_api_write( env=os.environ | environment | { - "REPOSITORY": "tox-dev/fixture", + "GITHUB_REPOSITORY": "tox-dev/fixture", "APP_SLUG": "fixture-app", "BASE": "main", "BASE_COMMIT": base, @@ -513,9 +506,7 @@ def test_prepared_candidate_identity_is_checked_before_api_write( "COMMIT_MESSAGE": "Bump version", "LABEL": "skip news", "RUNNER_TEMP": str(tmp_path), - "GITHUB_OUTPUT": str(tmp_path / "output"), }, ) assert result.returncode != 0 assert [request.method for request, _ in server.log] == ["GET"] - assert not (tmp_path / "output").exists() diff --git a/tests/read_towncrier_config/test_config.py b/tests/read_towncrier_config/test_config.py new file mode 100644 index 0000000..ff26ed8 --- /dev/null +++ b/tests/read_towncrier_config/test_config.py @@ -0,0 +1,82 @@ +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 {key: value for key, value in outputs.items() if key != "categories-file"} == { + "changelog": "CHANGELOG.md", + "fragments": "changelog.d", + "format": "markdown", + } + assert Path(outputs["categories-file"]).read_text() == "feature\n" + + +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 {key: value for key, value in outputs.items() if key != "categories-file"} == { + "changelog": "docs/NEWS.rst", + "fragments": "docs/news", + "format": "rst", + } + assert Path(outputs["categories-file"]).read_text() == "feature\nbugfix\n" + + +@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 index cee8aba..af4ef0e 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -1,12 +1,11 @@ from __future__ import annotations -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 @@ -107,30 +106,17 @@ def test_recheck_reruns_failed_job( server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun", method="POST").respond_with_data( status=201 ) - values = ( - os.environ - | environment - | { - "GITHUB_REPOSITORY": "tox-dev/fixture", - "RUNNER_TEMP": str(tmp_path), - "MINIMUM_DAYS": "7", - "WORKFLOW": "check.yaml", - "JOB_NAME": job_name, - "GH_TOKEN": environment["GH_ENTERPRISE_TOKEN"], - } + result = action( + tmp_path, + tmp_path, + action_scripts, + "recheck-hook-update-cooldown", + version="", + GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], + JOB_NAME=job_name, + **environment, ) - action_path = Path(__file__).resolve().parents[2] / "recheck-hook-update-cooldown/action.yml" - steps = yaml.safe_load(action_path.read_text())["runs"]["steps"] - assert [step["name"] for step in steps] == ["Find hook update pull requests", "Rerun the failed hook checks"] - for index, _ in enumerate(steps): - result = subprocess.run( - ["bash", str(action_scripts / f"recheck-hook-update-cooldown-{index}.sh")], - env=values, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr + 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 @@ -186,26 +172,16 @@ def test_recheck_continues_after_failed_rerun( 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) - values = ( - os.environ - | environment - | { - "GITHUB_REPOSITORY": "tox-dev/fixture", - "RUNNER_TEMP": str(tmp_path), - "WORKFLOW": "check.yaml", - "JOB_NAME": "hook-cooldown", - "GH_TOKEN": environment["GH_ENTERPRISE_TOKEN"], - } + result = action( + tmp_path, + tmp_path, + action_scripts, + "recheck-hook-update-cooldown", + version="", + GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], + **environment, ) - for index in (0, 1): - result = subprocess.run( - ["bash", str(action_scripts / f"recheck-hook-update-cooldown-{index}.sh")], - env=values, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr + 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", diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py index 9e9f3a8..6f640b6 100644 --- a/tests/reconcile_pypi_artifacts/test_reconcile.py +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -1,159 +1,147 @@ from __future__ import annotations -import json -import os -import shutil -import subprocess -import tomllib +import hashlib from pathlib import Path -from urllib.request import urlopen +from textwrap import dedent import pytest -import yaml -from conftest import action, run - - -def execute(script: Path, directory: Path, **settings: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", str(script)], - cwd=directory, - env=os.environ - | {"GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "reconcile-pypi-artifacts")} - | settings, - text=True, - capture_output=True, - check=False, - ) - - -@pytest.fixture -def distribution(tmp_path: Path) -> Path: - dist = tmp_path / "dist" - dist.mkdir() - (dist / "codex_fixture_never_publish-999999-py3-none-any.whl").write_bytes(b"release fixture") - return dist - - -def test_stage_selects_missing_artifact(distribution: Path, tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +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( - source, + Path(__file__).resolve().parents[2], tmp_path, action_scripts, "reconcile-pypi-artifacts", - version="999999", - PROJECT="codex-fixture-never-publish", + version=version, + PROJECT=project, DIST=str(distribution), - MODE="stage", - LOCK=str(source / "pylock.toml"), - PYTHON_VERSION="3.15", - LOCAL_TOOL_SOURCE="false", - TOOL_GROUP="release-notes", - MISSING_DIR=str(tmp_path / "missing"), - ) - assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text().splitlines() == [ - "missing_count=1", - f"path={tmp_path}/pypi-missing-names.txt", - ] - assert (tmp_path / "missing" / next(distribution.iterdir()).name).read_bytes() == b"release fixture" - assert (tmp_path / "pypi-missing-names.txt").read_text() == f"{next(distribution.iterdir()).name}\n" - - -@pytest.mark.parametrize("mode", ["invalid", "verify"]) -def test_invalid_input_fails_before_install( - distribution: Path, tmp_path: Path, action_scripts: Path, mode: str -) -> None: - result = execute( - action_scripts / "reconcile-pypi-artifacts-0.sh", - tmp_path, - PROJECT="codex-fixture-never-publish", - VERSION="999999", - DIST=str(distribution if mode == "invalid" else tmp_path / "missing"), - MODE=mode, ) assert result.returncode != 0 -def test_verify_checks_complete_pypi_release(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - install_reconcile(action_scripts, source, tmp_path) - with urlopen("https://pypi.org/pypi/release-creator/0.4.1/json", timeout=30) as response: - payload = json.load(response) - dist = tmp_path / "dist" - dist.mkdir() - for item in payload["urls"]: - with urlopen(item["url"], timeout=30) as response: - (dist / item["filename"]).write_bytes(response.read()) - result = execute( - action_scripts / "reconcile-pypi-artifacts-5.sh", - tmp_path, - RUNNER_TEMP=str(tmp_path), - PROJECT="release-creator", - VERSION="0.4.1", - DIST=str(dist), - WAIT_SECONDS="0", - ) - assert result.returncode == 0, result.stderr - - -def test_local_source_install_uses_checked_out_project(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - with (source / "pylock.toml").open("rb") as stream: - version = next( - item["version"] for item in tomllib.load(stream)["packages"] if item["name"] == "release-creator" +@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, + }, ) - project = tmp_path / "release-creator" - run(tmp_path, "git", "clone", "--quiet", "https://github.com/tox-dev/release-creator", str(project)) - run(project, "git", "checkout", "--quiet", version) - install_reconcile(action_scripts, project, tmp_path, local_source=True) - version = run(tmp_path, str(tmp_path / "pypi-reconcile/bin/release-creator"), "--help").stdout - assert "artifacts" in version - workflow = yaml.safe_load((source / ".github/workflows/prepare-towncrier-release.yaml").read_text()) - for job in ("assemble", "validate"): - runner_temp = tmp_path / job - runner_temp.mkdir() - steps = workflow["jobs"][job]["steps"] - assert any(step.get("uses") == "$/install-locked-tools" for step in steps) - install_locked_tools( - action_scripts, project, runner_temp, project / ".venv", "release fix build project-runtime" + 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 ) - script = tmp_path / "prepare-install.sh" - script.write_text(next(step["run"] for step in steps if step.get("name") == "Install the tagged release tool")) - result = execute(script, project) - assert result.returncode == 0, result.stderr - assert "artifacts" in run(project, str(project / ".venv/bin/release-creator"), "--help").stdout - shutil.rmtree(project / ".venv") - - -def install_reconcile(action_scripts: Path, repository: Path, tmp_path: Path, *, local_source: bool = False) -> None: - install_locked_tools( - action_scripts, - repository, + 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, - tmp_path / "pypi-reconcile", - "release build project-runtime" if local_source else "release-notes", + 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", ) - if local_source: - result = execute( - action_scripts / "reconcile-pypi-artifacts-2.sh", - repository, - RUNNER_TEMP=str(tmp_path), - ) - assert result.returncode == 0, result.stderr - - -def install_locked_tools(action_scripts: Path, repository: Path, runner_temp: Path, venv: Path, groups: str) -> None: - source = Path(__file__).resolve().parents[2] - for index in range(3): - result = execute( - action_scripts / f"install-locked-tools-{index}.sh", - repository, - LOCK=str(repository / "pylock.toml"), - DEPENDENCY_GROUPS=groups, - PYTHON_VERSION="3.15", - RUNNER_TEMP=str(runner_temp), - VENV=str(venv), - GITHUB_ACTION_PATH=str(source / "install-locked-tools"), - ) - assert result.returncode == 0, result.stderr + 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..aa6b44f --- /dev/null +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -0,0 +1,103 @@ +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_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_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 diff --git a/tests/refresh_release_lock/test_security_cutoff.py b/tests/refresh_release_lock/test_security_cutoff.py deleted file mode 100644 index a2ed742..0000000 --- a/tests/refresh_release_lock/test_security_cutoff.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -from pathlib import Path - -import pytest - - -def select_cutoffs( - action_scripts: Path, tmp_path: Path, *, cutoff: str, entries: str -) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", str(action_scripts / "refresh-release-lock-2.sh")], - cwd=tmp_path, - env=os.environ - | { - "CUTOFF": cutoff, - "EXCLUDE_NEWER_PACKAGES": entries, - "RUNNER_TEMP": str(tmp_path), - "GITHUB_OUTPUT": str(tmp_path / "output"), - }, - text=True, - capture_output=True, - check=False, - ) - - -def test_reviewed_security_floor_expires_after_rolling_cutoff(action_scripts: Path, tmp_path: Path) -> None: - entries = "certifi=2026-10-20T02:25:00Z\nuv=2026-10-20T15:13:00Z" - result = select_cutoffs(action_scripts, tmp_path, cutoff="2026-10-14T15:05:32Z", entries=entries) - assert result.returncode == 0, result.stderr - assert (tmp_path / "active-package-cutoffs").read_text() == f"{entries}\n" - - result = select_cutoffs(action_scripts, tmp_path, cutoff="2026-10-27T15:13:00Z", entries=entries) - assert result.returncode == 0, result.stderr - assert (tmp_path / "active-package-cutoffs").read_text() == "" - - -def test_unrelated_package_keeps_rolling_cutoff(action_scripts: Path, tmp_path: Path) -> None: - result = select_cutoffs( - action_scripts, - tmp_path, - cutoff="2026-10-14T15:05:32Z", - entries="certifi=2026-10-20T02:25:00Z", - ) - assert result.returncode == 0, result.stderr - assert (tmp_path / "active-package-cutoffs").read_text() == "certifi=2026-10-20T02:25:00Z\n" - - -@pytest.mark.parametrize( - "entry", - [ - pytest.param("bad name=2026-10-20T02:25:00Z", id="invalid-package"), - pytest.param("certifi=October 20", id="invalid-format"), - pytest.param("certifi=2026-10-40T02:25:00Z", id="invalid-date"), - pytest.param("certifi=2099-01-01T00:00:00Z", id="future-date"), - ], -) -def test_invalid_reviewed_floor_fails_closed(action_scripts: Path, tmp_path: Path, entry: str) -> None: - result = select_cutoffs(action_scripts, tmp_path, cutoff="2026-10-14T15:05:32Z", entries=entry) - assert result.returncode != 0 - assert not (tmp_path / "output").exists() - - -def test_empty_lines_are_ignored(action_scripts: Path, tmp_path: Path) -> None: - result = select_cutoffs( - action_scripts, - tmp_path, - cutoff="2026-10-14T15:05:32Z", - entries="\ncertifi=2026-10-20T02:25:00Z\n", - ) - assert result.returncode == 0, result.stderr - assert (tmp_path / "active-package-cutoffs").read_text() == "certifi=2026-10-20T02:25:00Z\n" diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 912cfc8..9f0bd7f 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -5,27 +5,80 @@ import subprocess from pathlib import Path -import pytest import yaml -@pytest.mark.parametrize("workflow_name", ["prepare-release", "prepare-towncrier-release"]) -def test_prepare_jobs_transfer_the_uploaded_patch(workflow_name: str) -> None: +def test_prepare_workflow_calls_reusable_preparation() -> None: workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / f".github/workflows/{workflow_name}.yaml").read_text() + (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" ) - for job_name in ("validate", "push"): - assert ( - next(step for step in jobs[job_name]["steps"] if "download-artifact" in step.get("uses", ""))["with"][ - "artifact-ids" - ] - == "${{ needs.assemble.outputs.artifact-id }}" - ) + 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 '--build-constraints "$RUNNER_TEMP/caller-build-constraints.txt"' 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: diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py index aae355f..05fe274 100644 --- a/tests/render_release_notes/test_notes.py +++ b/tests/render_release_notes/test_notes.py @@ -1,7 +1,7 @@ from __future__ import annotations +from pathlib import Path from textwrap import dedent -lazy from pathlib import Path import pytest from conftest import action, run @@ -45,7 +45,6 @@ def test_render_release_notes_uses_tagged_changelog( "render-release-notes", version="0.2.0", TAG="0.2.0", - REPOSITORY="tox-dev/fixture", OUTPUT=str(tmp_path / "notes.md"), ARTIFACT_URL="https://pypi.org/project/fixture/0.2.0/", ) @@ -82,7 +81,6 @@ def test_render_release_notes_uses_generated_contributors( "render-release-notes", version="0.2.0", TAG="0.2.0", - REPOSITORY="tox-dev/fixture", OUTPUT=str(tmp_path / "notes.md"), GENERATED_NOTES=str(generated), ) @@ -101,7 +99,6 @@ def test_render_release_notes_accepts_first_release( "render-release-notes", version="0.2.0", TAG="0.2.0", - REPOSITORY="tox-dev/fixture", OUTPUT=str(tmp_path / "notes.md"), ) assert result.returncode == 0, result.stderr @@ -109,11 +106,15 @@ def test_render_release_notes_accepts_first_release( assert "compare/" not in (tmp_path / "notes.md").read_text() -def test_render_release_notes_converts_rst_pull_role( - tagged_repository: Path, tmp_path: Path, action_scripts: Path +@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( - "0.2.0 (2026-10-20)\n==================\n\nFixed\n-----\n\n- Correct a bug (:issue:`4`).\n", + f"0.2.0 (2026-10-20)\n==================\n\nFixed\n-----\n\n- Correct a bug (:{role}:`4`).\n", ) result = action( tagged_repository, @@ -123,12 +124,10 @@ def test_render_release_notes_converts_rst_pull_role( version="0.2.0", TAG="0.2.0", CHANGELOG="CHANGELOG.rst", - REPOSITORY="tox-dev/fixture", OUTPUT=str(tmp_path / "notes.md"), - ISSUE_ROLE="pull", ) assert result.returncode == 0, result.stderr - assert "Correct a bug ([#4](https://github.com/tox-dev/fixture/pull/4))" in (tmp_path / "notes.md").read_text() + assert f"Correct a bug ([#4](https://github.com/tox-dev/fixture/{path}/4))" in (tmp_path / "notes.md").read_text() def test_render_release_notes_custom_artifact_label( @@ -141,7 +140,6 @@ def test_render_release_notes_custom_artifact_label( "render-release-notes", version="0.2.0", TAG="0.2.0", - REPOSITORY="tox-dev/fixture", OUTPUT=str(tmp_path / "notes.md"), ARTIFACT_URL="https://github.com/tox-dev/fixture/releases/download/0.2.0/fixture.pyz", ARTIFACT_LABEL="Zipapp", @@ -153,88 +151,10 @@ def test_render_release_notes_custom_artifact_label( ) -@pytest.mark.parametrize( - ("package", "version", "previous", "entry"), - [ - pytest.param("pyproject-fmt", "2.30.3", "2.30.2", "Format pyprojects.", id="pyproject"), - pytest.param("tox-toml-fmt", "1.11.1", "1.11.0", "Format tox files.", id="tox"), - pytest.param("toml-fmt-common", "1.3.5", "1.3.4", "Share TOML rules.", id="common"), - ], -) -def test_render_release_notes_selects_package_namespace( - tagged_repository: Path, - tmp_path: Path, - action_scripts: Path, - package: str, - version: str, - previous: str, - entry: str, -) -> None: - (tagged_repository / "CHANGELOG.md").write_text( - dedent( - """\ - # Changelog - - ## pyproject-fmt - - ### 2.30.3 (2026-10-20) - - #### Added - - - Format pyprojects. - - ## tox-toml-fmt - - ### 1.11.1 (2026-10-20) - - #### Added - - - Format tox files. - - ## toml-fmt-common - - ### 1.3.5 (2026-10-20) - - #### Added - - - Share TOML rules. - """, - ), - ) - run(tagged_repository, "git", "add", "CHANGELOG.md") - run(tagged_repository, "git", "commit", "-m", "Release all packages") - for name, old, current in ( - ("pyproject-fmt", "2.30.2", "2.30.3"), - ("tox-toml-fmt", "1.11.0", "1.11.1"), - ("toml-fmt-common", "1.3.4", "1.3.5"), - ): - run(tagged_repository, "git", "tag", f"{name}/{old}", "HEAD^") - run(tagged_repository, "git", "tag", f"{name}/{current}") - result = action( - tagged_repository, - tmp_path, - action_scripts, - "render-release-notes", - version=f"{package}/{version}", - TAG=f"{package}/{version}", - PACKAGE=package, - REPOSITORY="tox-dev/toml-fmt", - OUTPUT=str(tmp_path / "notes.md"), - ARTIFACT_URL=f"https://pypi.org/project/{package}/{version}/", - ) - assert result.returncode == 0, result.stderr - assert (tmp_path / "notes.md").read_text() == ( - f"### Features\n\n- {entry}\n\n[PyPI release](https://pypi.org/project/{package}/{version}/)\n\n" - f"**Full Changelog**: https://github.com/tox-dev/toml-fmt/compare/" - f"{package}/{previous}...{package}/{version}\n" - ) - assert f"previous={package}/{previous}" in (tmp_path / "output").read_text() - - @pytest.mark.parametrize( ("tag", "remove_heading", "error"), [ - pytest.param("invalid tag", False, "invalid", id="invalid-tag"), + 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"), ], ) @@ -256,25 +176,7 @@ def test_render_release_notes_rejects_invalid_source( "render-release-notes", version=tag, TAG=tag, - REPOSITORY="tox-dev/fixture", OUTPUT=str(tmp_path / "notes.md"), ) assert result.returncode != 0 assert error in result.stderr - - -def test_render_release_notes_rejects_empty_output( - 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", - REPOSITORY="tox-dev/fixture", - OUTPUT="/dev/null", - ) - assert result.returncode != 0 - assert "release notes must contain one comparison link" in result.stderr 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/select_pypi_artifacts/test_select.py b/tests/select_pypi_artifacts/test_select.py deleted file mode 100644 index 0ec27dc..0000000 --- a/tests/select_pypi_artifacts/test_select.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -from pathlib import Path - -import pytest - - -def select( - tmp_path: Path, - action_scripts: Path, - dist: Path, - manifest: Path, - output: Path, - expected_count: str = "1", - partitioned: str = "false", -) -> subprocess.CompletedProcess[str]: - script = action_scripts / "select-pypi-artifacts-0.sh" - return subprocess.run( - ["bash", str(script)], - cwd=tmp_path, - env=os.environ - | { - "DIST": str(dist), - "MANIFEST": str(manifest), - "OUTPUT_DIR": str(output), - "EXPECTED_COUNT": expected_count, - "PARTITIONED": partitioned, - "GITHUB_OUTPUT": str(tmp_path / "output"), - "RUNNER_TEMP": str(tmp_path), - }, - text=True, - capture_output=True, - check=False, - ) - - -@pytest.fixture -def artifact(tmp_path: Path) -> tuple[Path, Path]: - dist = tmp_path / "dist" - dist.mkdir() - (dist / "fixture-1.0-py3-none-any.whl").write_bytes(b"original artifact") - manifest = tmp_path / "missing.txt" - manifest.write_text("fixture-1.0-py3-none-any.whl\n") - return dist, manifest - - -def test_select_copies_original_bytes(artifact: tuple[Path, Path], tmp_path: Path, action_scripts: Path) -> None: - dist, manifest = artifact - result = select(tmp_path, action_scripts, dist, manifest, tmp_path / "selected") - assert result.returncode == 0, result.stderr - assert (tmp_path / "selected/fixture-1.0-py3-none-any.whl").read_bytes() == b"original artifact" - assert (tmp_path / "output").read_text().startswith("count=1\nmanifest=") - - -def test_empty_selection_uploads_nothing(artifact: tuple[Path, Path], tmp_path: Path, action_scripts: Path) -> None: - dist, manifest = artifact - manifest.write_text("") - result = select(tmp_path, action_scripts, dist, manifest, tmp_path / "selected", "0") - assert result.returncode == 0, result.stderr - assert list((tmp_path / "selected").iterdir()) == [] - assert (tmp_path / "output").read_text().startswith("count=0\nmanifest=") - - -def test_partitioned_selection_copies_only_local_bytes( - artifact: tuple[Path, Path], tmp_path: Path, action_scripts: Path -) -> None: - dist, manifest = artifact - manifest.write_text("fixture-1.0-py3-none-any.whl\nfixture-1.0.tar.gz\n") - result = select(tmp_path, action_scripts, dist, manifest, tmp_path / "selected", "", "true") - assert result.returncode == 0, result.stderr - assert [path.name for path in (tmp_path / "selected").iterdir()] == ["fixture-1.0-py3-none-any.whl"] - selected_manifest = Path((tmp_path / "output").read_text().split("manifest=", 1)[1].strip()) - assert selected_manifest.read_text() == "fixture-1.0-py3-none-any.whl\n" - - -@pytest.mark.parametrize( - "name", ["../secret", "other.whl", "fixture-1.0-py3-none-any.whl\nfixture-1.0-py3-none-any.whl"] -) -def test_select_rejects_untrusted_manifest( - artifact: tuple[Path, Path], tmp_path: Path, action_scripts: Path, name: str -) -> None: - dist, manifest = artifact - manifest.write_text(f"{name}\n") - result = select(tmp_path, action_scripts, dist, manifest, tmp_path / "selected") - assert result.returncode != 0 - - -def test_select_rejects_overlap(artifact: tuple[Path, Path], tmp_path: Path, action_scripts: Path) -> None: - dist, manifest = artifact - result = select(tmp_path, action_scripts, dist, manifest, dist / "selected") - assert result.returncode != 0 - assert "overlaps" in result.stderr - - -def test_select_rejects_count_mismatch(artifact: tuple[Path, Path], tmp_path: Path, action_scripts: Path) -> None: - dist, manifest = artifact - result = select(tmp_path, action_scripts, dist, manifest, tmp_path / "selected", "2") - assert result.returncode != 0 - - -def test_select_rejects_symlink(artifact: tuple[Path, Path], tmp_path: Path, action_scripts: Path) -> None: - dist, manifest = artifact - (tmp_path / "outside").write_bytes(b"real target") - (dist / "fixture-1.0-py3-none-any.whl").unlink() - (dist / "fixture-1.0-py3-none-any.whl").symlink_to(tmp_path / "outside") - result = select(tmp_path, action_scripts, dist, manifest, tmp_path / "selected") - assert result.returncode != 0 - assert not (tmp_path / "selected/fixture-1.0-py3-none-any.whl").exists() diff --git a/tests/sign_jetbrains_plugin/test_sign.py b/tests/sign_jetbrains_plugin/test_sign.py index e476f98..5d3aeef 100644 --- a/tests/sign_jetbrains_plugin/test_sign.py +++ b/tests/sign_jetbrains_plugin/test_sign.py @@ -2,13 +2,13 @@ import base64 import hashlib -import os import shutil import subprocess from pathlib import Path from zipfile import ZIP_DEFLATED, ZipFile import pytest +from conftest import action @pytest.mark.parametrize( @@ -55,33 +55,27 @@ def test_signs_and_verifies_exact_archive( if credential_format == "base64": key_text = base64.b64encode(key_text.encode()).decode() chain_text = base64.b64encode(chain_text.encode()).decode() - output = tmp_path / "output" - output.touch() java = shutil.which("java") assert java is not None - settings = { - "ARCHIVE": str(unsigned_archive), - "EXPECTED_SHA": hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(), - "GITHUB_OUTPUT": str(output), - "RUNNER_TEMP": str(tmp_path), - "JAVA_HOME_17_X64": str(Path(java).resolve().parents[1]), - "SIGNER_BASE_URL": "https://repo.maven.apache.org/maven2/org/jetbrains/marketplace-zip-signer/0.1.43", - "SIGNER_SHA256": "2958a0f42221d6062b50f22754e413ae5cc42f60c441467202c6700df2e22f44", - "CERTIFICATE_CHAIN": chain_text, - "PRIVATE_KEY": key_text, - "PRIVATE_KEY_PASSWORD": password, - } - for index in range(4): - assert run_step(action_scripts, index, tmp_path, **settings).returncode == 0 - settings["SIGNED"] = output.read_text().split("archive=", 1)[1].splitlines()[0] - assert run_step(action_scripts, 4, tmp_path, **settings).returncode == 0 + 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() - output.write_text("") - assert run_step(action_scripts, 5, tmp_path, **settings).returncode == 0 - signed = Path(settings["SIGNED"]) - assert dict(line.split("=", 1) for line in output.read_text().splitlines()) == { + 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(), } @@ -120,36 +114,35 @@ def test_rejects_invalid_unsigned_archive( archive = tmp_path / "linked.zip" archive.symlink_to(unsigned_archive) assert ( - run_step( - action_scripts, - 0, + action( + tmp_path, tmp_path, + action_scripts, + "sign-jetbrains-plugin", + version="", ARCHIVE=str(archive), - EXPECTED_SHA="0" * 64 if invalid == "digest" else hashlib.sha256(archive.read_bytes()).hexdigest(), - GITHUB_OUTPUT=str(tmp_path / "output"), + SHA256="0" * 64 if invalid == "digest" else hashlib.sha256(archive.read_bytes()).hexdigest(), ).returncode != 0 ) -def test_rejects_changed_signer_jar(action_scripts: Path, tmp_path: Path) -> None: - (tmp_path / "marketplace-signer-cli.jar").write_bytes(b"not the signer") +def test_rejects_missing_java(action_scripts: Path, unsigned_archive: Path, tmp_path: Path) -> None: assert ( - run_step( - action_scripts, - 2, + action( tmp_path, - RUNNER_TEMP=str(tmp_path), - SIGNER_SHA256="2958a0f42221d6062b50f22754e413ae5cc42f60c441467202c6700df2e22f44", + 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 ) -def test_rejects_missing_java(action_scripts: Path, tmp_path: Path) -> None: - assert run_step(action_scripts, 3, tmp_path, 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 @@ -159,7 +152,16 @@ def test_rejects_existing_signed_archive( signed.symlink_to(unsigned_archive) else: signed.write_bytes(b"existing") - result = run_step(action_scripts, 4, tmp_path, SIGNED=str(signed), RUNNER_TEMP=str(tmp_path)) + 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 @@ -197,14 +199,3 @@ def certificate(tmp_path: Path) -> tuple[Path, Path]: text=True, ) return private_key, chain - - -def run_step(action_scripts: Path, index: int, directory: Path, **settings: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", str(action_scripts / f"sign-jetbrains-plugin-{index}.sh")], - cwd=directory, - env=os.environ | settings, - text=True, - capture_output=True, - check=False, - ) diff --git a/tests/upgrade_release_tools/test_hooks.py b/tests/upgrade_release_tools/test_hooks.py index cdf3555..7f6189f 100644 --- a/tests/upgrade_release_tools/test_hooks.py +++ b/tests/upgrade_release_tools/test_hooks.py @@ -2,18 +2,18 @@ import os import subprocess -from datetime import UTC, datetime, timedelta from pathlib import Path import pytest import yaml -from conftest import run +from conftest import action, run @pytest.fixture def hook_repository(tmp_path: Path) -> Path: repository = tmp_path / "repository" repository.mkdir() + (repository / "pylock.toml").write_bytes((Path(__file__).resolve().parents[2] / "pylock.toml").read_bytes()) (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" @@ -26,25 +26,14 @@ def hook_repository(tmp_path: Path) -> Path: def test_hook_refresh_resolves_exact_pin(hook_repository: Path, tmp_path: Path, action_scripts: Path) -> None: - installed = execute(hook_repository, action_scripts / "refresh-hook-pins-0.sh", tmp_path, PYTHON_VERSION="3.15") - assert installed.returncode == 0, installed.stderr - frozen = execute( - hook_repository, action_scripts / "refresh-hook-pins-2.sh", tmp_path, CONFIG=".pre-commit-config.yaml" - ) - assert frozen.returncode == 0, frozen.stderr - refreshed = execute( + result = action( hook_repository, - action_scripts / "refresh-hook-pins-3.sh", tmp_path, - CONFIG=".pre-commit-config.yaml", - PYTHON_VERSION="3.15", - CUTOFF="2026-10-14T00:00:00Z", - ) - assert refreshed.returncode == 0, refreshed.stderr - checked = execute( - hook_repository, action_scripts / "refresh-hook-pins-4.sh", tmp_path, CONFIG=".pre-commit-config.yaml" + action_scripts, + "refresh-hook-pins", + version="", ) - assert checked.returncode == 0, checked.stderr + 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" @@ -78,10 +67,12 @@ def test_hook_refresh_freezes_only_cooled_revisions( (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 = execute(hook_repository, action_scripts / "refresh-hook-pins-0.sh", tmp_path, PYTHON_VERSION="3.15") - assert result.returncode == 0, result.stderr - result = execute( - hook_repository, action_scripts / "refresh-hook-pins-2.sh", tmp_path, CONFIG=".pre-commit-config.yaml" + 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 @@ -89,60 +80,51 @@ def test_hook_refresh_freezes_only_cooled_revisions( @pytest.mark.parametrize( ("path", "accepted"), - [(None, True), (".pre-commit-config.yaml", True), ("other.txt", False)], - ids=["clean", "hook-config", "other-file"], + [ + 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: - (hook_repository / path).write_text("updated\n") - result = execute( - hook_repository, action_scripts / "refresh-hook-pins-5.sh", tmp_path, CONFIG=".pre-commit-config.yaml" - ) + 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", [False, True], ids=["unchanged", "changed"]) +@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 changed: - (hook_repository / ".pre-commit-config.yaml").write_text("repos: []\n") - result = execute( - hook_repository, - action_scripts / "refresh-hook-pins-6.sh", - tmp_path, - CONFIG=".pre-commit-config.yaml", - GITHUB_OUTPUT=str(tmp_path / "output"), - ) + 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_seven_day_cutoff(hook_repository: Path, tmp_path: Path, action_scripts: Path) -> None: - result = execute( - hook_repository, - action_scripts / "refresh-hook-pins-1.sh", - tmp_path, - GITHUB_OUTPUT=str(tmp_path / "output"), - ) - assert result.returncode == 0, result.stderr - cutoff = datetime.fromisoformat((tmp_path / "output").read_text().removeprefix("timestamp=").strip()) - assert abs(datetime.now(UTC) - cutoff - timedelta(days=7)) < timedelta(minutes=1) - - -def execute(repository: Path, script: Path, temporary: Path, **environment: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", str(script)], - cwd=repository, - env=os.environ - | { - "RUNNER_TEMP": str(temporary), - "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-hook-pins"), - } - | environment, - text=True, - capture_output=True, - check=False, - ) +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 index fd9a4ef..6c3629e 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -3,7 +3,6 @@ import os import subprocess import tomllib -from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -30,16 +29,23 @@ def publish_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: def test_upgrade_pull_request_uses_repository_app() -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text() + 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", @@ -57,6 +63,10 @@ def lock_repository(tmp_path: Path) -> Path: (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") @@ -66,334 +76,225 @@ def lock_repository(tmp_path: Path) -> Path: return repository -def test_upgrade_exports_every_group(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: - installed = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-0.sh", - tmp_path, - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - ) +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 = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) assert installed.returncode == 0, installed.stderr - result = execute( + resolved = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", + upgrade_scripts / "refresh-release-lock-2.sh", tmp_path, - LOCK="pylock.toml", - PYTHON_VERSION="3.15", + LOCK_GROUPS="release-check", + MODE="refresh", ) - assert result.returncode == 0, result.stderr + assert resolved.returncode == 0, resolved.stderr lock = tomllib.loads((lock_repository / "pylock.toml").read_text()) - assert (lock["requires-python"], lock["dependency-groups"]) == ( - ">=3.15", - ["project-runtime", "release", "test", "build"], - ) + assert lock["dependency-groups"] == ["project-runtime", "release-check"] + assert "test" not in {package["name"] for package in lock["packages"]} -def test_upgrade_refreshes_backend_constraints(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: - installed = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path, PYTHON_VERSION="3.15") +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 = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) assert installed.returncode == 0, installed.stderr - locked = execute( + refreshed = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", - tmp_path, - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - ) - assert locked.returncode == 0, locked.stderr - generated = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-5.sh", + upgrade_scripts / "refresh-release-lock-2.sh", tmp_path, - LOCK="pylock.toml", - BUILD_CONSTRAINTS="release-build-constraints.txt", - PYTHON_VERSION="3.15", + LOCK_GROUPS="release-check", + MODE="refresh", ) - assert generated.returncode == 0, generated.stderr - constraints = (lock_repository / "release-build-constraints.txt").read_text() - assert "cython==" in constraints - assert "setuptools==" in constraints - - -def test_upgrade_does_not_force_shared_build_requirements( - lock_repository: Path, tmp_path: Path, upgrade_scripts: Path -) -> None: - installed = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path, PYTHON_VERSION="3.15") - assert installed.returncode == 0, installed.stderr - result = execute( + assert refreshed.returncode == 0, refreshed.stderr + expected = (lock_repository / "pylock.toml").read_bytes() + checked = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", + upgrade_scripts / "refresh-release-lock-2.sh", tmp_path, - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - BUILD_REQUIREMENTS="", + LOCK_GROUPS="release-check", + MODE="check", ) - assert result.returncode == 0, result.stderr - packages = {item["name"] for item in tomllib.loads((lock_repository / "pylock.toml").read_text())["packages"]} - assert "cython" not in packages - assert "setuptools" not in packages + assert checked.returncode == 0, checked.stderr + assert (lock_repository / "pylock.toml").read_bytes() == expected @pytest.mark.parametrize( - "path", - ["pyproject.toml", "../build-constraints.txt", "/tmp/build-constraints.txt", ".git/build-constraints.txt"], - ids=["project-file", "parent", "absolute", "git-internals"], + ("groups", "selection"), + [ + pytest.param("", "--build-requirements", id="build-only"), + pytest.param("release-check", "--groups", id="selected-group"), + ], ) -def test_upgrade_rejects_unsafe_constraints_path( - lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, path: str +def test_upgrade_preserves_secondary_lock_matrix( + lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, groups: str, selection: str ) -> None: - result = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-5.sh", - tmp_path, - BUILD_CONSTRAINTS=path, - LOCK="pylock.toml", - PYTHON_VERSION="3.15", + 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', ) - assert result.returncode != 0 - assert "backend constraints" in result.stderr - - -def test_upgrade_includes_new_constraints_file_in_patch( - 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 / "release-build-constraints.txt").write_text("new\n") - result = execute( + installed = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) + assert installed.returncode == 0, installed.stderr + primary = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-9.sh", + upgrade_scripts / "refresh-release-lock-2.sh", tmp_path, - BUILD_CONSTRAINTS="release-build-constraints.txt", - LOCK="pylock.toml", - KEEP_UV_LOCK="false", - GITHUB_OUTPUT=str(tmp_path / "output"), + LOCK_GROUPS="release-check", + MODE="refresh", ) - assert result.returncode == 0, result.stderr - assert "new file mode 100644" in (tmp_path / "lock.patch").read_text() - - -def test_upgrade_sets_seven_day_cutoff(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: - result = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-1.sh", - tmp_path, - GITHUB_OUTPUT=str(tmp_path / "output"), + 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), + ], ) - assert result.returncode == 0, result.stderr - cutoff = datetime.fromisoformat((tmp_path / "output").read_text().removeprefix("timestamp=").strip()) - assert abs(datetime.now(UTC) - cutoff - timedelta(days=7)) < timedelta(minutes=1) + 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-2.sh", tmp_path, **environment) + assert checked.returncode == 0, checked.stderr + assert additional.read_bytes() == expected -def test_upgrade_exempts_only_named_young_package(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: +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 = ["release-creator>=0.4.1,<0.4.2", "certifi"]') - ) - installed = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path, PYTHON_VERSION="3.15") + project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')) + installed = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) assert installed.returncode == 0, installed.stderr - blocked = execute( + initial = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", - tmp_path, - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - UV_EXCLUDE_NEWER="2026-10-14T00:00:00Z", - EXCLUDE_NEWER_PACKAGES="", - ) - assert blocked.returncode != 0 - allowed = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", + upgrade_scripts / "refresh-release-lock-2.sh", tmp_path, - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - UV_EXCLUDE_NEWER="2026-10-14T00:00:00Z", - EXCLUDE_NEWER_PACKAGES="release-creator=2026-10-19T20:26:31Z", + LOCK_GROUPS="release-check", + MODE="refresh", ) - assert allowed.returncode == 0, allowed.stderr - packages = { - package["name"]: package for package in tomllib.loads((lock_repository / "pylock.toml").read_text())["packages"] - } - assert packages["release-creator"]["version"] == "0.4.1" - assert packages["certifi"]["version"] == "2026.7.22" - - -@pytest.mark.parametrize( - "entry", - [ - "release-creator; true=2026-10-19T20:26:31Z", - "release-creator", - "release-creator=tomorrow", - "release-creator=2999-01-01T00:00:00Z", - ], - ids=["invalid-name", "missing-date", "invalid-date", "future-date"], -) -def test_upgrade_rejects_invalid_exception( - lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, entry: str -) -> None: + 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-2.sh", tmp_path, - EXCLUDE_NEWER_PACKAGES=entry, + 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 "invalid package cutoff" in result.stderr + assert "changed its target environments" in result.stderr -def test_upgrade_refreshes_uv_lock_with_same_exception( - lock_repository: Path, tmp_path: Path, upgrade_scripts: Path -) -> None: +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().replace("release = []", 'release = ["release-creator>=0.4.1,<0.4.2", "certifi"]') - ) - result = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-4.sh", - tmp_path, - UV_EXCLUDE_NEWER="2026-10-14T00:00:00Z", - EXCLUDE_NEWER_PACKAGES="release-creator=2026-10-19T20:26:31Z", + project.read_text() + + '[[tool.nab.package-rules]]\nmatch = ["nab"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', ) - assert result.returncode == 0, result.stderr - packages = { - package["name"]: package for package in tomllib.loads((lock_repository / "uv.lock").read_text())["package"] - } - assert packages["release-creator"]["version"] == "0.4.1" - assert packages["certifi"]["version"] == "2026.7.22" + installed = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) + assert installed.returncode == 0, installed.stderr + expired = execute(lock_repository, upgrade_scripts / "refresh-release-lock-1.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", "keep_uv_lock", "build_constraints", "expected"), - [ - (None, False, False, False), - ("pylock.toml", False, False, True), - ("uv.lock", False, False, False), - ("uv.lock", True, False, True), - ("release-build-constraints.txt", False, True, True), - ], - ids=["unchanged", "pylock", "ignored-uv-lock", "retained-uv-lock", "backend-constraints"], + "changed_path", + [pytest.param("pylock.toml", id="lock"), pytest.param("pyproject.toml", id="expired-floor")], ) -def test_upgrade_emits_patch_only_for_change( - lock_repository: Path, - tmp_path: Path, - upgrade_scripts: Path, - changed_path: str | None, - keep_uv_lock: bool, - build_constraints: bool, - expected: bool, +def test_upgrade_emits_patch_for_allowed_change( + lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, changed_path: str ) -> None: - lock = lock_repository / "pylock.toml" - lock.write_text('lock-version = "1.0"\n') - (lock_repository / "uv.lock").write_text("before\n") - (lock_repository / "release-build-constraints.txt").write_text("before\n") + (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n') run(lock_repository, "git", "add", ".") run(lock_repository, "git", "commit", "-m", "Add lock") - if changed_path is not None: - (lock_repository / changed_path).write_text("after\n") + (lock_repository / changed_path).write_text((lock_repository / changed_path).read_text() + "\n") result = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-9.sh", + upgrade_scripts / "refresh-release-lock-6.sh", tmp_path, - LOCK="pylock.toml", - KEEP_UV_LOCK=str(keep_uv_lock).lower(), - BUILD_CONSTRAINTS="release-build-constraints.txt" if build_constraints else "", + MODE="refresh", GITHUB_OUTPUT=str(tmp_path / "output"), ) - assert result.returncode == 0 - assert (tmp_path / "output").read_text() == f"changed={str(expected).lower()}\n" - assert (tmp_path / "lock.patch").exists() == expected - if expected: - assert f"diff --git a/{changed_path} b/{changed_path}" in (tmp_path / "lock.patch").read_text() + 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() -@pytest.mark.parametrize( - ("unrelated", "keep_uv_lock", "accepted"), - [(None, False, True), ("other.txt", False, False), ("uv.lock", False, False), ("uv.lock", True, True)], - ids=["clean", "unrelated-file", "uv-lock-rejected", "uv-lock-retained"], -) -def test_upgrade_checks_refreshed_lock( - lock_repository: Path, - tmp_path: Path, - upgrade_scripts: Path, - unrelated: str | None, - keep_uv_lock: bool, - accepted: bool, -) -> None: - project = lock_repository / "pyproject.toml" - project.write_text(project.read_text().replace("release = []", 'release = ["prek"]')) - (lock_repository / ".pre-commit-config.yaml").write_text( - "repos:\n - repo: local\n hooks:\n - id: check\n" - " name: check\n entry: 'true'\n language: system\n pass_filenames: false\n" - ) - (lock_repository / "other.txt").write_text("before\n") - run( - lock_repository, - "release-creator", - "locks", - "--output", - "pylock.toml", - "--tool-python", - ">=3.15", - "--build-requirement", - "Cython>=3.0", - "--build-requirement", - "setuptools>=80", - ) +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 release tools") - if unrelated is not None: - (lock_repository / unrelated).write_text("after\n") - exporter = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-0.sh", - tmp_path, - PYTHON_VERSION="3.15", - ) - assert exporter.returncode == 0, exporter.stderr - source = Path(__file__).resolve().parents[2] - refresh = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text()) - assert refresh["runs"]["steps"][6]["uses"] == "$/install-locked-tools" - for index in range(3): - installed = execute( - lock_repository, - upgrade_scripts / f"install-locked-tools-{index}.sh", - tmp_path, - GITHUB_ACTION_PATH=str(source / "install-locked-tools"), - LOCK="pylock.toml", - DEPENDENCY_GROUPS="release", - VENV=str(tmp_path / "release-tools"), - PYTHON_VERSION="3.15", - ) - assert installed.returncode == 0, installed.stderr - checked = execute(lock_repository, upgrade_scripts / "refresh-release-lock-7.sh", tmp_path) - assert checked.returncode == 0, checked.stderr + 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-8.sh", + upgrade_scripts / "refresh-release-lock-6.sh", tmp_path, - LOCK="pylock.toml", - KEEP_UV_LOCK=str(keep_uv_lock).lower(), + MODE="check", + GITHUB_OUTPUT=str(tmp_path / "output"), ) - assert (result.returncode == 0) == accepted, result.stderr - assert ("prek changed file outside the lock file" in result.stderr) == (not accepted) + assert result.returncode != 0 @pytest.mark.parametrize( - ("path", "keep_uv_lock", "accepted"), + "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-5.sh", tmp_path) + assert result.returncode != 0 + assert "outside the lock and cutoff policy" in result.stderr + + +@pytest.mark.parametrize( + ("path", "accepted"), [ - ("pylock.toml", False, True), - (".pre-commit-config.yaml", False, True), - ("uv.lock", False, False), - ("uv.lock", True, True), - ("other.txt", True, False), + 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"), ], - ids=["pylock", "hooks", "uv-lock-rejected", "uv-lock-retained", "unrelated-file"], ) def test_upgrade_rejects_unrelated_patch( - lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, keep_uv_lock: bool, accepted: bool + 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", ".") @@ -406,22 +307,47 @@ def test_upgrade_rejects_unrelated_patch( (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( + 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, - LOCK="pylock.toml", - CONFIG=".pre-commit-config.yaml", - KEEP_UV_LOCK=str(keep_uv_lock).lower(), + ADDITIONAL_LOCK="pylock.build.toml", ) - assert (result.returncode == 0) == accepted - assert ("unexpected dependency path" in result.stderr) == (not accepted) + 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"), - [("pylock.toml", "executable"), (".pre-commit-config.yaml", "symlink")], - ids=["executable-lock", "symlink-hook-config"], + [ + 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 @@ -476,9 +402,6 @@ def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: lock_repository, publish_scripts / "publish-check-dependency-patch-paths.sh", tmp_path, - LOCK="pylock.toml", - CONFIG=".pre-commit-config.yaml", - KEEP_UV_LOCK="false", ) assert (applied_lock.returncode, applied_hooks.returncode, checked.returncode) == (0, 0, 0) @@ -488,7 +411,7 @@ def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: ] -def test_upgrade_lists_cutoff_exceptions_in_pull_request( +def test_upgrade_preserves_requested_pull_request_body( lock_repository: Path, tmp_path: Path, publish_scripts: Path ) -> None: result = execute( @@ -496,33 +419,9 @@ def test_upgrade_lists_cutoff_exceptions_in_pull_request( publish_scripts / "publish-write-dependency-pull-request-body.sh", tmp_path, PR_BODY="Refresh release tools.", - EXCLUDE_NEWER_PACKAGES="release-creator=2026-10-19T20:26:31Z\nuv-build=2026-10-20T15:11:07Z", ) assert result.returncode == 0, result.stderr - assert (tmp_path / "pull-request.md").read_text() == ( - "Refresh release tools.\n\n" - "Reviewed package cutoff floors:\n" - "- `release-creator=2026-10-19T20:26:31Z`\n" - "- `uv-build=2026-10-20T15:11:07Z`\n" - ) - - -@pytest.mark.parametrize( - ("branch", "base", "accepted"), - [("automation/update-release-tools", "main", True), ("main", "main", False), ("bad branch", "main", False)], - ids=["automation-branch", "base-branch", "invalid-ref"], -) -def test_upgrade_rejects_unsafe_branch( - lock_repository: Path, tmp_path: Path, publish_scripts: Path, branch: str, base: str, accepted: bool -) -> None: - result = execute( - lock_repository, - publish_scripts / "publish-check-dependency-branch.sh", - tmp_path, - BRANCH=branch, - BASE=base, - ) - assert (result.returncode == 0) == accepted + assert (tmp_path / "pull-request.md").read_text() == "Refresh release tools.\n" def test_upgrade_finds_only_own_dependency_pull_request( @@ -557,16 +456,12 @@ def test_upgrade_finds_only_own_dependency_pull_request( **environment, GITHUB_REPOSITORY="tox-dev/fixture", GITHUB_OUTPUT=str(tmp_path / "output"), - BRANCH="automation/update-release-tools", - BASE="main", ) 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]: - active_cutoffs = tmp_path / "active-package-cutoffs" - active_cutoffs.write_text(f"{environment.get('EXCLUDE_NEWER_PACKAGES', '')}\n") return subprocess.run( ["bash", str(script)], cwd=repository, @@ -574,11 +469,11 @@ def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) | { "RUNNER_TEMP": str(tmp_path), "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"), - "EXCLUDE_NEWER_PACKAGES": "", - "BUILD_CONSTRAINTS": "", - "BUILD_REQUIREMENTS": "Cython>=3.0\nsetuptools>=80", - "HOOK_CONFIG": ".pre-commit-config.yaml", - "ACTIVE_PACKAGE_CUTOFFS": str(active_cutoffs), + "ADDITIONAL_LOCK": "", + "ADDITIONAL_GROUPS": "", + "MATRIX_PYTHON": "", + "MATRIX_PLATFORMS": "", + "MATRIX_IMPLEMENTATIONS": "", } | environment, text=True, diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 90cb72a..3a67a5a 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -23,7 +23,6 @@ def test_validate_news_without_fragment( action_scripts, "validate-news", version="0.2.0", - CONFIG="pyproject.toml", BASE_REF="main", BOT_AUTHOR=bot, SKIP_NEWS=skip, @@ -51,7 +50,6 @@ def test_validate_news_checks_bot_fragment(repository: Path, tmp_path: Path, act action_scripts, "validate-news", version="0.2.0", - CONFIG="pyproject.toml", BASE_REF="main", BOT_AUTHOR="true", SKIP_NEWS="true", @@ -78,7 +76,6 @@ def test_validate_news_reads_rst_categories(repository: Path, tmp_path: Path, ac action_scripts, "validate-news", version="0.2.0", - CONFIG="pyproject.toml", BASE_REF="main", BOT_AUTHOR="false", SKIP_NEWS="false", @@ -107,7 +104,6 @@ def test_validate_news_rejects_bad_fragment( action_scripts, "validate-news", version="0.2.0", - CONFIG="pyproject.toml", BASE_REF="main", BOT_AUTHOR="true", SKIP_NEWS="false", @@ -123,7 +119,6 @@ def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action action_scripts, "validate-news", version="0.2.0", - CONFIG="pyproject.toml", BASE_REF="", BOT_AUTHOR="true", SKIP_NEWS="false", diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py index 4d8bb23..5edc4cd 100644 --- a/tests/validate_python_distribution/test_distribution.py +++ b/tests/validate_python_distribution/test_distribution.py @@ -1,79 +1,47 @@ from __future__ import annotations -from textwrap import dedent +from pathlib import Path from zipfile import ZipFile -lazy from pathlib import Path import pytest from conftest import action, run @pytest.fixture -def distribution(repository: Path, tmp_path: Path) -> tuple[Path, Path]: - project = repository / "pyproject.toml" +def distribution(source_project: Path) -> tuple[Path, Path]: + project = source_project / "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" - - [dependency-groups] - pkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"] - """, - ), + project.read_text() + '\n[dependency-groups]\npkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"]\n', ) - package = repository / "src/fixture" - package.mkdir(parents=True) - (package / "__init__.py").write_text('"""Fixture package."""\n') - run(repository, "release-creator", "locks", "--output", "pylock.toml", "--tool-python", ">=3.15") - output = tmp_path / "dist" - run(repository, "uv", "build", "--python", "3.15", "--sdist", "--wheel", "--out-dir", str(output)) - return repository, output - - -def test_validate_python_distribution_accepts_built_files( - distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path -) -> None: - repository, output = distribution - result = action( - repository, - tmp_path, - action_scripts, - "validate-python-distribution", - version="0.2.0", - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - VALIDATION_GROUP="pkg-meta", - OUT_DIR=str(output), + run( + source_project, + "uvx", + "--python", + "3.15", + "--from", + "nab==0.0.22", + "nab", + "lock", + "--python", + "3.15", + "--groups", + "pkg-meta", ) - assert result.returncode == 0, result.stderr - assert "PASSED" in result.stdout + output = source_project / "dist" + run(source_project, "uv", "build", "--python", "3.15", "--sdist", "--wheel", "--out-dir", str(output)) + return source_project, output -def test_validate_python_distribution_selects_nondefault_group( +def test_validate_python_distribution_accepts_built_files( distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path ) -> None: - repository, output = distribution - project = repository / "pyproject.toml" - project.write_text(project.read_text().replace("pkg-meta =", "release =")) - run(repository, "release-creator", "locks", "--output", "pylock.toml", "--tool-python", ">=3.15") + repository = distribution[0] result = action( repository, tmp_path, action_scripts, "validate-python-distribution", version="0.2.0", - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - VALIDATION_GROUP="release", - OUT_DIR=str(output), ) assert result.returncode == 0, result.stderr assert "PASSED" in result.stdout @@ -90,10 +58,6 @@ def test_validate_python_distribution_rejects_invalid_sdist( action_scripts, "validate-python-distribution", version="0.2.0", - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - VALIDATION_GROUP="pkg-meta", - OUT_DIR=str(output), ) assert result.returncode != 0 @@ -117,10 +81,6 @@ def test_validate_python_distribution_rejects_bad_wheel_contents( action_scripts, "validate-python-distribution", version="0.2.0", - LOCK="pylock.toml", - PYTHON_VERSION="3.15", - VALIDATION_GROUP="pkg-meta", - OUT_DIR=str(output), ) 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 index 7a521eb..016d5b7 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -9,16 +9,20 @@ from conftest import run -def test_validator_uses_locked_metadata_group() -> None: +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, ) - check = next( - step for step in workflow["jobs"]["validate"]["steps"] if step.get("uses") == "$/validate-python-distribution" - ) - assert workflow["on"]["workflow_call"]["inputs"]["validation-group"]["default"] == "pkg-meta" - assert check["with"]["validation-group"] == "${{ inputs.validation-group }}" + 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"} diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index bbdf772..f6eeb0c 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -1,12 +1,11 @@ from __future__ import annotations -import os -import subprocess 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 @@ -19,23 +18,24 @@ 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(repository, "git", "diff", "--cached", "--name-only").stdout.strip() + 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: @@ -73,9 +73,7 @@ def test_validate_rst_heading(repository: Path, tmp_path: Path, action_scripts: ) (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", CHANGELOG="CHANGELOG.rst" - ) + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode == 0, result.stderr @@ -95,9 +93,7 @@ def test_validate_rejects_rst_conflict_marker( 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", CHANGELOG="CHANGELOG.rst" - ) + 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 @@ -106,20 +102,18 @@ def test_validate_rejects_rst_conflict_marker( def test_validate_rejects_staged_rst_conflict_marker( repository: Path, tmp_path: Path, action_scripts: Path, tracked: bool ) -> None: - if tracked: - (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") - run(repository, "git", "add", "CHANGELOG.rst") - run(repository, "git", "commit", "-m", "Track RST changelog") - (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n\n<<<<<<< HEAD\n") - run(repository, "git", "add", "CHANGELOG.rst") - result = subprocess.run( - ["bash", str(action_scripts / "validate-release-patch-5.sh")], - cwd=repository, - env=os.environ | {"CHANGELOG": "CHANGELOG.rst", "RUNNER_TEMP": str(tmp_path)}, - text=True, - capture_output=True, - check=False, - ) + track_rst_changelog(repository) + changelog = repository / "CHANGELOG.rst" + if not tracked: + run(repository, "git", "rm", "CHANGELOG.rst") + run(repository, "git", "commit", "-m", "Remove RST changelog") + run(repository, "git", "push", "origin", "main") + changelog.write_text("Changelog\n=========\n\n<<<<<<< HEAD\n") + if not tracked: + run(repository, "git", "add", "-N", "CHANGELOG.rst") + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 assert "conflict markers" in result.stderr @@ -143,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 @@ -290,13 +284,13 @@ def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Pa @pytest.mark.parametrize( ("before", "after", "error"), [ - pytest.param("[tool.towncrier]", "[tool.other]", "configuration and categories", id="missing-config"), - pytest.param('directory = "changelog.d"\n', "", "paths do not match", id="missing-directory"), - pytest.param('filename = "CHANGELOG.md"\n', "", "paths do not match", id="missing-filename"), + 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', "", - "configuration and categories", + "Towncrier fragment categories are required", id="missing-categories", ), ], @@ -397,11 +391,12 @@ def test_validate_rejects_embedded_heading(repository: Path, tmp_path: Path, act 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") @@ -421,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/tests/verify_pypi_partitions/test_verify.py b/tests/verify_pypi_partitions/test_verify.py deleted file mode 100644 index e692632..0000000 --- a/tests/verify_pypi_partitions/test_verify.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -from pathlib import Path - -import pytest - - -def verify(tmp_path: Path, action_scripts: Path, names: tuple[str, ...]) -> subprocess.CompletedProcess[str]: - manifest = tmp_path / "manifest.txt" - manifest.write_text("alpha.whl\nbeta.whl\n") - partitions = tmp_path / "partitions" - partitions.mkdir() - for index, name in enumerate(names): - (partitions / f"{index}.txt").write_text(name) - return subprocess.run( - ["bash", str(action_scripts / "verify-pypi-partitions-0.sh")], - cwd=tmp_path, - env=os.environ - | { - "MANIFEST": str(manifest), - "PARTITIONS_DIR": str(partitions), - "EXPECTED_PARTITIONS": "2", - "RUNNER_TEMP": str(tmp_path), - }, - text=True, - capture_output=True, - check=False, - ) - - -@pytest.mark.parametrize( - ("names", "accepted"), - [ - pytest.param(("alpha.whl\n", "beta.whl\n"), True, id="complete"), - pytest.param(("alpha.whl\n", "alpha.whl\n"), False, id="duplicate-missing"), - pytest.param(("alpha.whl\n", ""), False, id="missing"), - pytest.param(("alpha.whl\n", "beta.whl\nextra.whl\n"), False, id="extra"), - ], -) -def test_verify_partitions_requires_exact_unique_union( - tmp_path: Path, action_scripts: Path, names: tuple[str, ...], accepted: bool -) -> None: - result = verify(tmp_path, action_scripts, names) - assert (result.returncode == 0) is accepted 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..d3ed5ce --- /dev/null +++ b/tools/check_lock_groups.py @@ -0,0 +1,32 @@ +"""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 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("lock", type=Path) + parser.add_argument("groups", nargs="+") + args = parser.parse_args() + try: + with args.lock.open("rb") as stream: + available = Pylock.from_dict(tomllib.load(stream)).dependency_groups + if missing := set(args.groups) - set(available or ()): + parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") + 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/expire_cutoffs.py b/tools/expire_cutoffs.py new file mode 100644 index 0000000..3c456bf --- /dev/null +++ b/tools/expire_cutoffs.py @@ -0,0 +1,47 @@ +"""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 + +import tomlkit +from nab_cutoffs import 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() + names, positions = expired_cutoffs(source, datetime.now(UTC) - timedelta(days=7)) + if names or positions: + document = tomlkit.parse(source) + nab = document["tool"]["nab"] + for name in names: + settings = nab["packages"][name] + del settings["uploaded-prior-to"] + if not settings: + del nab["packages"][name] + 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"] + args.project.write_text(tomlkit.dumps(document)) + 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..1ef1b51 --- /dev/null +++ b/tools/nab_cutoffs.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import tomllib +from datetime import datetime + + +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, threshold: datetime) -> bool: + if value.startswith("P"): + return False + timestamp = datetime.fromisoformat(value) + if timestamp.tzinfo is None: + raise ValueError(f"Nab package cutoff has no timezone: {value}") + return timestamp <= threshold + + +__all__ = [ + "expired_cutoffs", +] diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py new file mode 100644 index 0000000..02bdd0f --- /dev/null +++ b/tools/verify_cutoff_patch.py @@ -0,0 +1,52 @@ +"""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 + + +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 + expected = tomllib.loads(baseline) + names, positions = expired_cutoffs(baseline, datetime.now(UTC) - timedelta(days=7)) + nab = expected["tool"]["nab"] + for name in names: + settings = nab["packages"][name] + del settings["uploaded-prior-to"] + if not settings: + del nab["packages"][name] + 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"] + if expected != tomllib.loads(args.project.read_text()): + 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/validate-news/action.yml b/validate-news/action.yml index e528f41..69758b6 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -1,18 +1,13 @@ name: Validate changelog news description: Require a fragment for human changes and validate any fragment supplied by a bot -inputs: - config: - description: Python project file with the Towncrier configuration - default: pyproject.toml runs: using: composite steps: - name: Count changed fragments shell: bash env: - CONFIG: ${{ inputs.config }} BASE_REF: ${{ github.base_ref }} - run: release-creator validate-news --config "$CONFIG" --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" + run: release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" - name: Check changed fragments with Towncrier shell: bash env: diff --git a/validate-python-distribution/action.yml b/validate-python-distribution/action.yml index 91015f9..2fc7b19 100644 --- a/validate-python-distribution/action.yml +++ b/validate-python-distribution/action.yml @@ -1,35 +1,16 @@ name: Validate Python distribution description: Check the exact wheel and sdist before uploading them -inputs: - lock: - description: Full project pylock path - default: pylock.toml - python-version: - description: Python version for locked package validators - default: "3.15" - validation-group: - description: Dependency group containing Twine and check-wheel-contents - default: pkg-meta - out-dir: - description: Directory containing the distribution files - default: dist runs: using: composite steps: - name: Install locked package validators uses: $/install-locked-tools with: - lock: ${{ inputs.lock }} - groups: ${{ inputs.validation-group }} - python-version: ${{ inputs.python-version }} + groups: pkg-meta venv: ${{ runner.temp }}/package-validators - name: Check distribution metadata shell: bash - env: - OUT_DIR: ${{ inputs.out-dir }} - run: uv run --no-project --python "$RUNNER_TEMP/package-validators" twine check "$OUT_DIR"/* + run: uv run --no-project --python "$RUNNER_TEMP/package-validators" twine check dist/* - name: Check wheel contents shell: bash - env: - OUT_DIR: ${{ inputs.out-dir }} - run: uv run --no-project --python "$RUNNER_TEMP/package-validators" check-wheel-contents --no-config "$OUT_DIR" + 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 a5c99bd..8f2fd9a 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -7,24 +7,9 @@ inputs: patch: description: Path to the downloaded patch required: true - changelog: - description: Changelog path - required: true - fragments: - description: Fragment directory - required: true - 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 - default: "" - repository: - description: GitHub owner/repository for the rendered changelog - default: "" + 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: "" @@ -36,6 +21,7 @@ runs: steps: - name: Require a clean release checkout shell: bash + working-directory: ${{ inputs.working-directory }} run: | if [[ -n $(git status --porcelain) ]]; then echo '::error::release validation requires a clean checkout' >&2 @@ -43,19 +29,17 @@ runs: fi - name: Verify release source shell: bash - env: - BRANCH: ${{ inputs.branch }} - REMOTE: ${{ inputs.remote }} - EXPECTED_SHA: ${{ inputs.expected-sha }} + working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail - 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 + git fetch origin main + if [[ $(git rev-parse origin/main) != "$GITHUB_SHA" ]]; then + echo '::error::main changed during release preparation; dispatch again' >&2 exit 1 fi - name: Apply release patch shell: bash + working-directory: ${{ inputs.working-directory }} env: PATCH: ${{ inputs.patch }} EXPECTED_PATCH_SHA: ${{ inputs.expected-patch-sha }} @@ -81,11 +65,17 @@ runs: echo '::error::release patch contains no changes' >&2 exit 1 fi + - 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: ${{ inputs.changelog }} - FRAGMENTS: ${{ inputs.fragments }} + CHANGELOG: ${{ steps.config.outputs.changelog }} + FRAGMENTS: ${{ steps.config.outputs.fragments }} + CATEGORIES: ${{ steps.config.outputs.categories-file }} run: | set -euo pipefail reject_fragment() { @@ -97,26 +87,6 @@ runs: *.rst) fragment_extension=rst ;; *) exit 1 ;; esac - if git cat-file -e HEAD:towncrier.toml 2>/dev/null; then - git show HEAD:towncrier.toml >"$RUNNER_TEMP/release-towncrier.toml" - else - git show HEAD:pyproject.toml >"$RUNNER_TEMP/release-towncrier.toml" - fi - python3 - "$RUNNER_TEMP/release-towncrier.toml" "$FRAGMENTS" "$CHANGELOG" \ - >"$RUNNER_TEMP/release-fragment-categories" <<'PY' - import sys - import tomllib - from pathlib import Path - - document = tomllib.loads(Path(sys.argv[1]).read_text()) - config = document.get('tool', {}).get('towncrier') - if not config or not config.get('type'): - raise SystemExit('Towncrier configuration and categories are required') - if config.get('directory') != sys.argv[2] or config.get('filename') != sys.argv[3]: - raise SystemExit('Towncrier paths do not match release inputs') - for category in config['type']: - print(category['directory']) - PY 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 @@ -138,9 +108,7 @@ runs: fragment=${fragment%."$fragment_extension"} category=${fragment##*.} stem=${fragment%."$category"} - if [[ -s "$RUNNER_TEMP/release-fragment-categories" ]]; then - grep -Fqx -- "$category" "$RUNNER_TEMP/release-fragment-categories" || reject_fragment - fi + grep -Fqx -- "$category" "$CATEGORIES" || reject_fragment case "$stem" in +*) case "${stem#+}" in @@ -157,8 +125,9 @@ runs: fi - name: Check release patch file modes shell: bash + working-directory: ${{ inputs.working-directory }} env: - FRAGMENTS: ${{ inputs.fragments }} + FRAGMENTS: ${{ steps.config.outputs.fragments }} run: | set -euo pipefail git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes" @@ -196,10 +165,11 @@ runs: fi done <"$RUNNER_TEMP/release-modes" - name: Check RST conflict markers - if: endsWith(inputs.changelog, '.rst') + if: steps.config.outputs.format == 'rst' shell: bash + working-directory: ${{ inputs.working-directory }} env: - CHANGELOG: ${{ inputs.changelog }} + CHANGELOG: ${{ steps.config.outputs.changelog }} run: | set -euo pipefail git diff --cached --unified=0 -- "$CHANGELOG" >"$RUNNER_TEMP/release-rst-diff" @@ -215,18 +185,20 @@ runs: - name: Check rendered release section if: inputs.checks == 'true' shell: bash + working-directory: ${{ inputs.working-directory }} env: VERSION: ${{ inputs.version }} - CHANGELOG: ${{ inputs.changelog }} - REPOSITORY: ${{ inputs.repository || github.repository }} + CHANGELOG: ${{ steps.config.outputs.changelog }} run: | - if ! release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$REPOSITORY" >/dev/null; then + 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" diff --git a/verify-pypi-partitions/action.yml b/verify-pypi-partitions/action.yml deleted file mode 100644 index 12a514f..0000000 --- a/verify-pypi-partitions/action.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Verify PyPI artifact partitions -description: Require matrix selections to cover each staged filename exactly once -inputs: - manifest: - description: Global filename allowlist from the keyless stage - required: true - partitions-dir: - description: Directory containing one selected-name manifest per matrix cell - required: true - expected-partitions: - description: Number of matrix cells that supplied a manifest - required: true -runs: - using: composite - steps: - - name: Compare matrix selections with staged filenames - shell: bash - env: - MANIFEST: ${{ inputs.manifest }} - PARTITIONS_DIR: ${{ inputs.partitions-dir }} - EXPECTED_PARTITIONS: ${{ inputs.expected-partitions }} - run: | - set -euo pipefail - [[ -f "$MANIFEST" && -d "$PARTITIONS_DIR" ]] || exit 1 - shopt -s nullglob - partitions=("$PARTITIONS_DIR"/*) - test "${#partitions[@]}" -eq "$EXPECTED_PARTITIONS" - for partition in "${partitions[@]}"; do - [[ -f "$partition" && ! -L "$partition" ]] || exit 1 - done - cat -- "${partitions[@]}" | LC_ALL=C sort >"$RUNNER_TEMP/pypi-partition-names.txt" - LC_ALL=C sort -u "$RUNNER_TEMP/pypi-partition-names.txt" >"$RUNNER_TEMP/pypi-partition-unique.txt" - cmp -s "$RUNNER_TEMP/pypi-partition-names.txt" "$RUNNER_TEMP/pypi-partition-unique.txt" - cmp -s "$RUNNER_TEMP/pypi-partition-unique.txt" "$MANIFEST" From 1a6a0951d2bdd057cd6f47d392dd14309814794b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 17:48:58 -0700 Subject: [PATCH 155/167] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20align=20relea?= =?UTF-8?q?se=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the shared lock for shared-only tools and check staged Towncrier deletions through release-creator. Require release tags to be on main, and use one pinned Nab bootstrap across the Actions. --- .../workflows/validate-python-release.yaml | 1 + README.md | 87 ++++++++++++++----- check-hook-update-cooldown/action.yml | 1 + export-build-constraints/action.yml | 17 ++-- install-lock-tools/action.yml | 15 ++++ install-locked-tools/action.yml | 42 ++++++--- read-towncrier-config/action.yml | 9 +- reconcile-pypi-artifacts/action.yml | 1 + refresh-hook-pins/action.yml | 1 + refresh-release-lock/action.yml | 9 +- .../test_cooldown.py | 4 +- .../test_workflow.py | 2 +- tests/conftest.py | 15 +++- tests/export_build_constraints/test_export.py | 29 ++----- tests/install_locked_tools/test_install.py | 52 ++++++++--- tests/read_towncrier_config/test_config.py | 6 +- tests/upgrade_release_tools/test_hooks.py | 4 +- tests/validate_python_release/test_tagged.py | 5 ++ tests/validate_release_patch/test_validate.py | 4 +- validate-release-patch/action.yml | 36 ++------ 20 files changed, 200 insertions(+), 140 deletions(-) create mode 100644 install-lock-tools/action.yml diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 630fd34..1353540 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -70,6 +70,7 @@ jobs: 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 diff --git a/README.md b/README.md index fba3e1d..6fd758d 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,27 @@ repository keeps its dispatch form, publisher, GitHub environment, and required ## Install locked tools -`install-locked-tools` creates a virtual environment and installs selected groups from the caller's `pylock.toml`. 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. +`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. 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-lock-tools` 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 @@ -111,10 +119,10 @@ against the settled label state. 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` 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. +`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 @@ -147,16 +155,48 @@ jobs: 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. In `refresh` mode, it upgrades the selected release groups -under Nab's configured cutoff, runs `prek`, and writes `$RUNNER_TEMP/lock.patch` if the 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 lock 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: +`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: @@ -274,9 +314,9 @@ 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. It checks that the fully qualified tag ref resolves to the checked-out commit -before building. The workflow uses `$/` for its own Actions, so a caller pinned to a workflow commit runs those Actions -from the same commit. +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: @@ -365,8 +405,7 @@ committed bytes so an untrusted release patch cannot change the allowed paths or ``` `working-directory` defaults to `.`; pass a different checkout when validating a patch in a linked worktree. Outputs are -`changelog`, `fragments`, `format` (`markdown` or `rst`), and `categories-file`, the temporary file with one allowed -category per line. The Action writes that file under `runner.temp` and does not modify the checkout or GitHub state. +`changelog`, `fragments`, and `format` (`markdown` or `rst`). The Action does not modify the checkout or GitHub state. ## Prepare Towncrier release @@ -407,10 +446,10 @@ those changes. The checkout must be clean before this Action starts. ## Validate release patch `validate-release-patch` runs in the caller's fresh checkout of the original branch commit. It rejects a `main` branch -that moved, applies the uploaded patch, checks its changed paths and file modes, renders the requested version with -`release-creator`, 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. +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 diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index 7b82670..e0165f2 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -79,6 +79,7 @@ runs: 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' diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index 21395bd..f1433bd 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -2,29 +2,22 @@ name: Export build constraints description: Write hashed build dependencies selected from the caller's pylock inputs: lock: - description: Caller pylock containing build dependencies + description: pylock.toml merges shared and caller pins; another lock uses only its build pins default: pylock.toml target-platform: - description: Nab platform ID for cross-target build constraints + description: Nab platform ID such as linux_aarch64; required with targets default: "" targets: - description: Space-separated full Python version and implementation pairs + description: Space-separated pairs such as 3.15.0:cpython; selected package versions must match default: "" outputs: path: - description: Path to the generated build constraints + description: Path to the generated constraints; each call replaces the file value: ${{ steps.constraints.outputs.path }} runs: using: composite steps: - - name: Install locked pylock reader - shell: bash - run: | - set -euo pipefail - unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" - uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ - --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" + - uses: $/install-lock-tools - id: constraints name: Export locked build requirements shell: bash diff --git a/install-lock-tools/action.yml b/install-lock-tools/action.yml new file mode 100644 index 0000000..23f26e8 --- /dev/null +++ b/install-lock-tools/action.yml @@ -0,0 +1,15 @@ +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: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" + - name: Install lock reader + shell: bash + run: | + set -euo pipefail + unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ + --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 0a5d050..6f90365 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -7,25 +7,39 @@ inputs: venv: description: Virtual environment directory required: true + lock-source: + description: Install groups from the caller or the shared Action lock + default: caller runs: using: composite steps: - - name: Install locked pylock reader - shell: bash - run: | - set -euo pipefail - unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" - uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ - --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" - - name: Select locked groups and build constraints + - uses: $/install-lock-tools + - id: selection + name: Select locked groups and build constraints shell: bash env: DEPENDENCY_GROUPS: ${{ inputs.groups }} VENV: ${{ inputs.venv }} + LOCK_SOURCE: ${{ inputs.lock-source }} run: | set -euo pipefail uv venv --allow-existing --python 3.15 "$VENV" + case "$LOCK_SOURCE" in + caller) + lock=pylock.toml + locks=("$GITHUB_ACTION_PATH/../pylock.toml" "$lock") + policy=(--shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml") + ;; + shared) + lock="$GITHUB_ACTION_PATH/../pylock.toml" + locks=("$lock") + policy=() + ;; + *) + echo "::error::unknown lock source: $LOCK_SOURCE" >&2 + exit 1 + ;; + esac groups=() while IFS= read -r line; do read -ra selected <<<"$line" @@ -36,18 +50,20 @@ runs: exit 1 fi uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" pylock.toml "${groups[@]}" + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" package_path=$(uv run --no-project --python "$RUNNER_TEMP/lock-tools" python -c \ 'from pathlib import Path; import packaging; print(Path(packaging.__file__).parent.parent)') PYTHONPATH="$package_path" uv run --no-project --python "$VENV" python \ "$GITHUB_ACTION_PATH/../tools/build_constraints.py" \ - --shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml" "$RUNNER_TEMP/caller-build-constraints.txt" \ - "$GITHUB_ACTION_PATH/../pylock.toml" pylock.toml + "${policy[@]}" "$RUNNER_TEMP/caller-build-constraints.txt" \ + "${locks[@]}" printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" + printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" - name: Install selected groups shell: bash env: VENV: ${{ inputs.venv }} + LOCK: ${{ steps.selection.outputs.lock }} run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE @@ -57,4 +73,4 @@ runs: done <"$RUNNER_TEMP/selected-lock-groups" uv pip sync --preview-features pylock --preview-features build-dependency-hashes --python "$VENV" \ "${groups[@]}" --build-constraints "$RUNNER_TEMP/caller-build-constraints.txt" \ - --require-build-hashes pylock.toml + --require-build-hashes "$LOCK" diff --git a/read-towncrier-config/action.yml b/read-towncrier-config/action.yml index 66ed440..070e73d 100644 --- a/read-towncrier-config/action.yml +++ b/read-towncrier-config/action.yml @@ -14,9 +14,6 @@ outputs: format: description: Changelog formatter, markdown or rst value: ${{ steps.config.outputs.format }} - categories-file: - description: File containing one allowed fragment category per line - value: ${{ steps.config.outputs.categories-file }} runs: using: composite steps: @@ -32,7 +29,7 @@ runs: else git show HEAD:pyproject.toml >"$config_dir/config.toml" fi - python3 - "$config_dir/config.toml" "$config_dir/categories" "$GITHUB_OUTPUT" <<'PY' + python3 - "$config_dir/config.toml" "$GITHUB_OUTPUT" <<'PY' import sys import tomllib from pathlib import Path, PurePosixPath @@ -71,8 +68,6 @@ runs: for category in categories ): raise SystemExit("Towncrier fragment categories must be printable names") - Path(sys.argv[2]).write_text("".join(f"{category}\n" for category in categories), encoding="utf-8") - with Path(sys.argv[3]).open("a", encoding="utf-8") as output: + with Path(sys.argv[2]).open("a", encoding="utf-8") as output: output.write(f"changelog={changelog}\nfragments={config['directory']}\nformat={format_name}\n") - output.write(f"categories-file={sys.argv[2]}\n") PY diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index cfcfe3b..285a1d3 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -49,6 +49,7 @@ runs: with: groups: release-attestations venv: ${{ runner.temp }}/pypi-attestations + lock-source: shared - name: Verify trusted-publisher attestations shell: bash env: diff --git a/refresh-hook-pins/action.yml b/refresh-hook-pins/action.yml index 2393b70..e19e5db 100644 --- a/refresh-hook-pins/action.yml +++ b/refresh-hook-pins/action.yml @@ -10,6 +10,7 @@ runs: - uses: $/install-locked-tools with: groups: release-hooks + lock-source: shared venv: ${{ runner.temp }}/hook-tools - name: Refresh frozen hook revisions shell: bash diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 5aff035..1070864 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -29,14 +29,7 @@ outputs: runs: using: composite steps: - - name: Install locked Nab - shell: bash - run: | - set -euo pipefail - unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" - uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ - --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" + - uses: $/install-lock-tools - name: Expire reviewed package cutoffs shell: bash run: | diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index e8c35c8..0c11af3 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -283,7 +283,9 @@ def run_cooldown( ) -> 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_bytes((Path(__file__).resolve().parents[2] / "pylock.toml").read_bytes()) + (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, diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py index 052dd83..983b7fe 100644 --- a/tests/check_hook_update_cooldown/test_workflow.py +++ b/tests/check_hook_update_cooldown/test_workflow.py @@ -26,7 +26,7 @@ def test_cooldown_installs_only_required_hook_tools() -> None: 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", "venv": "${{ runner.temp }}/hook-tools"}, + "with": {"groups": "release-hook-check", "lock-source": "shared", "venv": "${{ runner.temp }}/hook-tools"}, } diff --git a/tests/conftest.py b/tests/conftest.py index bf91a36..39896ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "resolve-pr-from-run", "refresh-release-lock", "install-locked-tools", + "install-lock-tools", "export-build-constraints", "read-towncrier-config", "refresh-hook-pins", @@ -239,7 +240,11 @@ def _run_action( repository, action_scripts, nested_name, - environment | {"GITHUB_ACTION_PATH": str(source / 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) @@ -286,14 +291,18 @@ def _run_action( def run(directory: Path, *command: str) -> subprocess.CompletedProcess[str]: environment = _safe_environment() - return subprocess.run( + result = subprocess.run( command, cwd=directory, env=environment | {"PATH": _tools_path(environment)}, text=True, capture_output=True, - check=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: diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 0f402d0..02432df 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import subprocess import sys from pathlib import Path @@ -8,37 +7,19 @@ import pytest import tomlkit -import yaml +from conftest import action -def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path) -> None: +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()) - output = tmp_path / "output" - steps = yaml.safe_load((source / "export-build-constraints/action.yml").read_text())["runs"]["steps"] - for step in steps: - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", "-c", step["run"]], - cwd=tmp_path, - env=os.environ - | { - "GITHUB_ACTION_PATH": str(source / "export-build-constraints"), - "GITHUB_OUTPUT": str(output), - "RUNNER_TEMP": str(tmp_path), - "BUILD_LOCK": "pylock.toml", - "TARGET_PLATFORM": "", - "TARGETS": "", - }, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr + result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", version="") + assert result.returncode == 0, result.stderr constraints = (tmp_path / "build-constraints.txt").read_text() assert "cython==" in constraints assert "setuptools==" in constraints assert "pytest==" not in constraints - assert output.read_text() == f"path={tmp_path / 'build-constraints.txt'}\n" + assert (tmp_path / "output").read_text() == f"path={tmp_path / 'build-constraints.txt'}\n" @pytest.mark.parametrize( diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 6a78f3f..6509702 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -90,21 +90,21 @@ def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: assert "--hash=sha256:" in constraints -def test_install_locked_tools_rejects_exporter_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: +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 / "action" + action_root = tmp_path / "install-lock-tools" 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 = install( - source, - action_scripts, + result = action( tmp_path, - groups="release-notes", - venv=tmp_path / "tools", - action_root=action_root, - extra_env={"UV_CACHE_DIR": str(tmp_path / "cache")}, + tmp_path, + action_scripts, + "install-lock-tools", + 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 @@ -136,6 +136,36 @@ def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scrip assert "missing lock dependency groups: relesae" in 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"): @@ -311,7 +341,7 @@ def install( *, groups: str, venv: Path, - action_root: Path | None = None, + lock_source: str = "caller", extra_env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: return action( @@ -322,6 +352,6 @@ def install( version="", GROUPS=groups, VENV=str(venv), - **({"GITHUB_ACTION_PATH": str(action_root)} if action_root is not None else {}), + LOCK_SOURCE=lock_source, **(extra_env or {}), ) diff --git a/tests/read_towncrier_config/test_config.py b/tests/read_towncrier_config/test_config.py index ff26ed8..f7db56d 100644 --- a/tests/read_towncrier_config/test_config.py +++ b/tests/read_towncrier_config/test_config.py @@ -11,12 +11,11 @@ def test_reader_uses_configured_type_array(repository: Path, tmp_path: Path, act 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 {key: value for key, value in outputs.items() if key != "categories-file"} == { + assert outputs == { "changelog": "CHANGELOG.md", "fragments": "changelog.d", "format": "markdown", } - assert Path(outputs["categories-file"]).read_text() == "feature\n" def test_reader_prefers_towncrier_fragment_tables(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -40,12 +39,11 @@ def test_reader_prefers_towncrier_fragment_tables(repository: Path, tmp_path: Pa 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 {key: value for key, value in outputs.items() if key != "categories-file"} == { + assert outputs == { "changelog": "docs/NEWS.rst", "fragments": "docs/news", "format": "rst", } - assert Path(outputs["categories-file"]).read_text() == "feature\nbugfix\n" @pytest.mark.parametrize("path", [pytest.param("../NEWS.md", id="parent"), pytest.param("/NEWS.md", id="absolute")]) diff --git a/tests/upgrade_release_tools/test_hooks.py b/tests/upgrade_release_tools/test_hooks.py index 7f6189f..a18df25 100644 --- a/tests/upgrade_release_tools/test_hooks.py +++ b/tests/upgrade_release_tools/test_hooks.py @@ -13,7 +13,9 @@ def hook_repository(tmp_path: Path) -> Path: repository = tmp_path / "repository" repository.mkdir() - (repository / "pylock.toml").write_bytes((Path(__file__).resolve().parents[2] / "pylock.toml").read_bytes()) + (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" diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index 016d5b7..a88f794 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -33,11 +33,16 @@ def test_validator_uses_shared_distribution_action() -> None: 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: diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index f6eeb0c..8fdcf40 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -255,7 +255,7 @@ def test_validate_rejects_unsupported_fragment_path( EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), ) assert result.returncode != 0 - assert "unsupported release fragment" in result.stderr + assert "unexpected release path status" in result.stderr def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -278,7 +278,7 @@ def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Pa EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), ) assert result.returncode != 0 - assert "unsupported release fragment" in result.stderr + assert "unexpected release path status" in result.stderr @pytest.mark.parametrize( diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 8f2fd9a..f048ed1 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -75,18 +75,8 @@ runs: env: CHANGELOG: ${{ steps.config.outputs.changelog }} FRAGMENTS: ${{ steps.config.outputs.fragments }} - CATEGORIES: ${{ steps.config.outputs.categories-file }} run: | set -euo pipefail - reject_fragment() { - echo "::error::unsupported release fragment: $path" >&2 - exit 1 - } - case "$CHANGELOG" in - *.md) fragment_extension=md ;; - *.rst) fragment_extension=rst ;; - *) exit 1 ;; - esac 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 @@ -97,32 +87,20 @@ runs: exit 1 fi if [[ "$status" != D ]]; then - reject_fragment + echo "::error::unexpected release path status: $status $path" >&2 + exit 1 fi - fragment=${path#"$FRAGMENTS/"} - case "$fragment" in - '' | */*) reject_fragment ;; - *."$fragment_extension") ;; - *) reject_fragment ;; - esac - fragment=${fragment%."$fragment_extension"} - category=${fragment##*.} - stem=${fragment%."$category"} - grep -Fqx -- "$category" "$CATEGORIES" || reject_fragment - case "$stem" in - +*) - case "${stem#+}" in - '' | [!A-Za-z0-9]* | *[!A-Za-z0-9._-]*) reject_fragment ;; - esac - ;; - '' | *[!0-9]*) reject_fragment ;; - esac done <"$RUNNER_TEMP/release-paths" if git diff --cached --quiet -- "$CHANGELOG"; then echo "::error::release patch does not change $CHANGELOG" >&2 exit 1 fi + - name: Check consumed fragments + if: inputs.checks == 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: release-creator validate-fragment-deletions - name: Check release patch file modes shell: bash working-directory: ${{ inputs.working-directory }} From e4432d537d6fb7bb74b162c372b40c91ac17c393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 18:45:02 -0700 Subject: [PATCH 156/167] =?UTF-8?q?=F0=9F=90=9B=20fix(actions):=20close=20?= =?UTF-8?q?reviewed=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 30 +++++-- .../workflows/prepare-towncrier-release.yaml | 8 +- .github/workflows/release.yaml | 3 +- .github/workflows/upgrade-release-tools.yaml | 4 +- README.md | 10 +-- build-python-distribution/action.yml | 2 +- check-hook-update-cooldown/action.yml | 4 + export-build-constraints/action.yml | 20 +++-- install-locked-tools/action.yml | 26 +++---- .../action.yml | 11 ++- publish-github-release/action.yml | 12 ++- publish-post-release-pr/action.yml | 1 - recheck-hook-update-cooldown/action.yml | 15 +++- refresh-release-lock/action.yml | 7 +- render-release-notes/action.yml | 18 ++++- tests/build_python_distribution/test_build.py | 9 +-- tests/conftest.py | 57 +++++++++++--- tests/export_build_constraints/test_export.py | 5 +- tests/install_locked_tools/test_install.py | 16 ++-- tests/publish_github_release/test_publish.py | 53 +++++++++++++ tests/refresh_release_lock/test_cutoffs.py | 78 +++++++++++++++++++ tests/release_workflow/test_graph.py | 10 ++- tests/upgrade_release_tools/test_upgrade.py | 34 ++++---- .../test_distribution.py | 9 +-- tools/check_lock_groups.py | 17 +++- tools/check_recent_cutoffs.py | 28 +++++++ tools/expire_cutoffs.py | 23 +----- tools/nab_cutoffs.py | 70 ++++++++++++++++- tools/verify_cutoff_patch.py | 29 ++++--- 29 files changed, 476 insertions(+), 133 deletions(-) rename {install-lock-tools => install-nab}/action.yml (52%) create mode 100644 tools/check_recent_cutoffs.py diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 12c378b..b95d043 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -28,7 +28,10 @@ jobs: with: version: latest-known python-version: "3.15" - enable-cache: false + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml - uses: $/install-locked-tools with: groups: release-check @@ -54,7 +57,10 @@ jobs: with: version: latest-known python-version: "3.15" - enable-cache: false + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml - uses: $/install-locked-tools with: groups: release-shell @@ -92,7 +98,10 @@ jobs: with: version: latest-known python-version: "3.15" - enable-cache: false + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml - uses: $/install-locked-tools with: groups: release-news release-shell @@ -117,7 +126,10 @@ jobs: with: version: latest-known python-version: "3.15" - enable-cache: false + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml - name: Check Python types run: uvx --with tox-uv tox run -e type changelog: @@ -132,7 +144,10 @@ jobs: with: version: latest-known python-version: "3.15" - enable-cache: false + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml - uses: $/install-locked-tools with: groups: release-news @@ -162,7 +177,10 @@ jobs: with: version: latest-known python-version: "3.15" - enable-cache: false + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml - uses: $/refresh-release-lock with: lock-groups: >- diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 4385ba2..16232c3 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -68,10 +68,16 @@ jobs: 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 "$RUNNER_TEMP/caller-build-constraints.txt" \ + 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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index dc15a72..6305178 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -90,7 +90,7 @@ jobs: path: ${{ runner.temp }}/generated-notes.md if-no-files-found: error notes: - needs: contributors + needs: [source, contributors] runs-on: ubuntu-26.04 permissions: contents: read @@ -119,6 +119,7 @@ jobs: - 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 diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 978de70..142ea12 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -76,7 +76,7 @@ jobs: name: release-tools-lock path: ${{ runner.temp }}/lock.patch if-no-files-found: error - retention-days: 1 + retention-days: 7 generate-hooks: runs-on: ubuntu-26.04 env: @@ -104,7 +104,7 @@ jobs: name: release-tools-hooks path: ${{ runner.temp }}/hooks.patch if-no-files-found: error - retention-days: 1 + retention-days: 7 publish: needs: [generate-lock, generate-hooks] if: | diff --git a/README.md b/README.md index 6fd758d..f257cc8 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ There is no named output. The installed commands are in `venv/bin` or Windows `v `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-lock-tools` is the shared bootstrap used by `install-locked-tools`, `export-build-constraints`, and +`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. @@ -594,10 +594,10 @@ 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 directory with the pinned PyPI publisher, -without `skip-existing`. Run this Action in a read-only job after the publisher. A different digest, unexpected or -yanked file, malformed response, or invalid attestation fails. The GitHub Release writer must depend on this -verification. +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 diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index 6fbc329..c2ec053 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -24,7 +24,7 @@ runs: fi - name: Select build interpreter shell: bash - run: uv venv --python 3.15 "$RUNNER_TEMP/build-python" + run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/build-python" - id: constraints uses: $/export-build-constraints - name: Build wheel and sdist diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index e0165f2..bb5d7e3 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -37,6 +37,10 @@ runs: 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 diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index f1433bd..9778354 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -12,12 +12,12 @@ inputs: default: "" outputs: path: - description: Path to the generated constraints; each call replaces the file + description: Path to the generated constraints for this lock selection value: ${{ steps.constraints.outputs.path }} runs: using: composite steps: - - uses: $/install-lock-tools + - uses: $/install-nab - id: constraints name: Export locked build requirements shell: bash @@ -37,7 +37,15 @@ runs: if [[ -n "$TARGET_PLATFORM" || -n "$TARGETS" ]]; then args+=(--target-platform "$TARGET_PLATFORM" --targets "$TARGETS") fi - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ - "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \ - "$RUNNER_TEMP/build-constraints.txt" "${locks[@]}" - echo "path=$RUNNER_TEMP/build-constraints.txt" >>"$GITHUB_OUTPUT" + 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 index 6f90365..106809b 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -13,7 +13,7 @@ inputs: runs: using: composite steps: - - uses: $/install-lock-tools + - uses: $/install-nab - id: selection name: Select locked groups and build constraints shell: bash @@ -27,13 +27,11 @@ runs: case "$LOCK_SOURCE" in caller) lock=pylock.toml - locks=("$GITHUB_ACTION_PATH/../pylock.toml" "$lock") - policy=(--shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml") + lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml") ;; shared) lock="$GITHUB_ACTION_PATH/../pylock.toml" - locks=("$lock") - policy=() + lock_check=() ;; *) echo "::error::unknown lock source: $LOCK_SOURCE" >&2 @@ -50,20 +48,20 @@ runs: exit 1 fi uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" - package_path=$(uv run --no-project --python "$RUNNER_TEMP/lock-tools" python -c \ - 'from pathlib import Path; import packaging; print(Path(packaging.__file__).parent.parent)') - PYTHONPATH="$package_path" uv run --no-project --python "$VENV" python \ - "$GITHUB_ACTION_PATH/../tools/build_constraints.py" \ - "${policy[@]}" "$RUNNER_TEMP/caller-build-constraints.txt" \ - "${locks[@]}" + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" \ + "${lock_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 @@ -72,5 +70,5 @@ runs: 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 "$RUNNER_TEMP/caller-build-constraints.txt" \ - --require-build-hashes "$LOCK" + "${groups[@]}" --build-constraints "$BUILD_CONSTRAINTS" \ + --require-build-hashes --require-hashes "$LOCK" diff --git a/install-lock-tools/action.yml b/install-nab/action.yml similarity index 52% rename from install-lock-tools/action.yml rename to install-nab/action.yml index 23f26e8..e2d0788 100644 --- a/install-lock-tools/action.yml +++ b/install-nab/action.yml @@ -5,11 +5,20 @@ runs: steps: - name: Create lock-tool environment shell: bash - run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" + 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/publish-github-release/action.yml b/publish-github-release/action.yml index 6cec98d..352cf94 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -89,10 +89,13 @@ runs: 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[]; .state == "uploaded" and - (.name as $name | .digest as $digest | + 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)) ) @@ -136,6 +139,11 @@ runs: if [[ "$MODE" != publish && -n "$ASSETS_DIRECTORY" ]]; then shopt -s nullglob release_id=$(<"$RUNNER_TEMP/release-id") + if jq -e 'length == 1 and .[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + 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 diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml index 5066c85..8c76361 100644 --- a/publish-post-release-pr/action.yml +++ b/publish-post-release-pr/action.yml @@ -279,7 +279,6 @@ runs: fi - name: Ensure the maintenance label shell: bash - env: run: | set -euo pipefail pr_number=$(cat "$RUNNER_TEMP/post-release-number") diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml index 3235cb4..ce1a2f0 100644 --- a/recheck-hook-update-cooldown/action.yml +++ b/recheck-hook-update-cooldown/action.yml @@ -7,6 +7,9 @@ inputs: 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: @@ -28,8 +31,16 @@ runs: 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' \ @@ -48,7 +59,9 @@ runs: .head_branch == $branch and .head_sha == $head and any(.pull_requests[]; .number == $number) )]') - if ! jq -e '[.[].created_at | fromdateiso8601] | min <= now - 7 * 86400' <<<"$runs" >/dev/null; then + 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") diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 1070864..f35f3ad 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -29,8 +29,12 @@ outputs: runs: using: composite steps: - - uses: $/install-lock-tools + - 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 @@ -132,6 +136,7 @@ runs: 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 }} diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index 29e5118..44fce5b 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -19,6 +19,9 @@ inputs: 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 @@ -26,10 +29,23 @@ outputs: runs: using: composite steps: - - id: previous + - 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: diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 0644547..ee5f37f 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -11,7 +11,7 @@ @pytest.fixture -def build_repository(source_project: Path) -> Path: +def build_repository(source_project: Path, nab_executable: Path) -> Path: project = source_project / "pyproject.toml" release_requirement = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())[ "dependency-groups" @@ -23,10 +23,7 @@ def build_repository(source_project: Path) -> Path: ) run( source_project, - "uvx", - "--from", - "nab==0.0.22", - "nab", + str(nab_executable), "lock", "--groups", "release-notes", @@ -56,7 +53,7 @@ def test_build_python_distribution_creates_wheel_and_sdist( "fixture-0.2.0-py3-none-any.whl", "fixture-0.2.0.tar.gz", } - assert (tmp_path / "build-constraints.txt").read_text().count("hatchling==") == 1 + assert next(tmp_path.glob("build-constraints-*.txt")).read_text().count("hatchling==") == 1 def test_build_python_distribution_repeats_exact_bytes( diff --git a/tests/conftest.py b/tests/conftest.py index 39896ba..e3138d5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,7 +56,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "resolve-pr-from-run", "refresh-release-lock", "install-locked-tools", - "install-lock-tools", + "install-nab", "export-build-constraints", "read-towncrier-config", "refresh-hook-pins", @@ -78,6 +78,33 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: 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, github_api: tuple[HTTPServer, dict[str, str]], monkeypatch: pytest.MonkeyPatch) -> Path: remote = tmp_path / "remote.git" @@ -112,14 +139,26 @@ def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], mo for name, value in environment.items(): monkeypatch.setenv(name, value) fragment_commit = run(worktree, "git", "rev-parse", "HEAD").stdout.strip() - for name in ("fixture", "github-workflows"): - server.expect_request( - f"/api/v3/repos/tox-dev/{name}/commits/{fragment_commit}/pulls", - method="GET", - query_string="per_page=100", - ).respond_with_json( - [{"number": 2, "merge_commit_sha": fragment_commit, "user": {"login": "gaborbernat", "type": "User"}}], - ) + server.expect_request("/api/graphql", method="POST").respond_with_json( + { + "data": { + "repository": { + "c0": { + "associatedPullRequests": { + "nodes": [ + { + "number": 2, + "mergedAt": "2026-10-22T00:00:00Z", + "mergeCommit": {"oid": fragment_commit}, + "author": {"login": "gaborbernat", "__typename": "User"}, + } + ] + } + } + } + } + } + ) return worktree diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 02432df..e4e7a5e 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -15,11 +15,12 @@ def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path, action_s (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 - constraints = (tmp_path / "build-constraints.txt").read_text() + 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={tmp_path / 'build-constraints.txt'}\n" + assert (tmp_path / "output").read_text() == f"path={output}\n" @pytest.mark.parametrize( diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 6509702..faf4a4a 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -92,7 +92,7 @@ def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: 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-lock-tools" + 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") @@ -101,7 +101,7 @@ def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts tmp_path, tmp_path, action_scripts, - "install-lock-tools", + "install-nab", version="0.2.0", GITHUB_ACTION_PATH=str(action_root), UV_CACHE_DIR=str(tmp_path / "cache"), @@ -184,7 +184,7 @@ def test_install_locked_tools_uses_managed_python(tmp_path: Path, action_scripts source = Path(__file__).resolve().parents[2] tools = tmp_path / "bin" tools.mkdir() - for name in ("bash", "uv"): + for name in ("bash", "sha256sum", "uv"): executable = shutil.which(name) assert executable is not None (tools / name).symlink_to(executable) @@ -244,17 +244,19 @@ def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_pat venv=tmp_path / "tools", ) assert result.returncode == 0, result.stderr - assert "probe-only==1.0 " in (tmp_path / "caller-build-constraints.txt").read_text() + 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) -> None: +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( - ["uvx", "--from", "nab==0.0.22", "nab", "lock", "--groups", "release-check", "--output", "pylock.toml"], + [str(nab_executable), "lock", "--groups", "release-check", "--output", "pylock.toml"], cwd=tmp_path, text=True, capture_output=True, @@ -264,7 +266,7 @@ def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, a 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 (tmp_path / "caller-build-constraints.txt").read_text() + 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: diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 9c95ae5..1822fa4 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -383,6 +383,59 @@ def test_draft_release_uploads_missing_asset( ] +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, diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index aa6b44f..440c2da 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -44,6 +44,36 @@ def test_expire_cutoffs_removes_only_expired_rules(cutoff_project: Path) -> None 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) @@ -57,6 +87,22 @@ def test_verify_cutoff_patch_accepts_expired_only(cutoff_project: Path) -> None: 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) @@ -101,3 +147,35 @@ def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: ) 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("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 index 9f0bd7f..cc8247c 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -58,7 +58,8 @@ def test_prepare_builds_local_tool_once_with_locked_backends() -> None: 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 '--build-constraints "$RUNNER_TEMP/caller-build-constraints.txt"' in builds[0]["run"] + 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"] @@ -154,3 +155,10 @@ def test_shfmt_hook_rejects_unformatted_inline_shell(tmp_path: Path) -> None: ) 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/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 6c3629e..dcff75e 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -7,7 +7,7 @@ import pytest import yaml -from conftest import run +from conftest import action, run from pytest_httpserver import HTTPServer @@ -79,11 +79,11 @@ def lock_repository(tmp_path: Path) -> Path: 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 = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) + 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-2.sh", + upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, LOCK_GROUPS="release-check", MODE="refresh", @@ -97,11 +97,11 @@ def test_upgrade_locks_only_selected_groups(lock_repository: Path, tmp_path: Pat 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 = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) + 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-2.sh", + upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, LOCK_GROUPS="release-check", MODE="refresh", @@ -110,7 +110,7 @@ def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path expected = (lock_repository / "pylock.toml").read_bytes() checked = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-2.sh", + upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, LOCK_GROUPS="release-check", MODE="check", @@ -134,11 +134,11 @@ def test_upgrade_preserves_secondary_lock_matrix( project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]') + '[build-system]\nrequires = ["setuptools>=80"]\nbuild-backend = "setuptools.build_meta"\n', ) - installed = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) + 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-2.sh", + upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, LOCK_GROUPS="release-check", MODE="refresh", @@ -183,7 +183,7 @@ def test_upgrade_preserves_secondary_lock_matrix( "MATRIX_PLATFORMS": "linux_x86_64 macos_arm64", "MATRIX_IMPLEMENTATIONS": "cpython", } - checked = execute(lock_repository, upgrade_scripts / "refresh-release-lock-2.sh", tmp_path, **environment) + 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 @@ -191,11 +191,11 @@ def test_upgrade_preserves_secondary_lock_matrix( 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 = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) + 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-2.sh", + upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, LOCK_GROUPS="release-check", MODE="refresh", @@ -204,7 +204,7 @@ def test_upgrade_rejects_secondary_matrix_change(lock_repository: Path, tmp_path (lock_repository / "pylock.build.toml").write_bytes((lock_repository / "pylock.toml").read_bytes()) result = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-2.sh", + upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, LOCK_GROUPS="release-check", MODE="refresh", @@ -224,9 +224,9 @@ def test_upgrade_expires_old_nab_floor(lock_repository: Path, tmp_path: Path, up project.read_text() + '[[tool.nab.package-rules]]\nmatch = ["nab"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', ) - installed = execute(lock_repository, upgrade_scripts / "refresh-release-lock-0.sh", tmp_path) + 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-1.sh", tmp_path) + 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"] @@ -244,7 +244,7 @@ def test_upgrade_emits_patch_for_allowed_change( (lock_repository / changed_path).write_text((lock_repository / changed_path).read_text() + "\n") result = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-6.sh", + upgrade_scripts / "refresh-release-lock-7.sh", tmp_path, MODE="refresh", GITHUB_OUTPUT=str(tmp_path / "output"), @@ -261,7 +261,7 @@ def test_upgrade_check_mode_rejects_lock_drift(lock_repository: Path, tmp_path: (lock_repository / "pylock.toml").write_text('lock-version = "1.1"\n') result = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-6.sh", + upgrade_scripts / "refresh-release-lock-7.sh", tmp_path, MODE="check", GITHUB_OUTPUT=str(tmp_path / "output"), @@ -278,7 +278,7 @@ def test_upgrade_rejects_unrelated_change( ) -> 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-5.sh", tmp_path) + 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 diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py index 5edc4cd..a3de552 100644 --- a/tests/validate_python_distribution/test_distribution.py +++ b/tests/validate_python_distribution/test_distribution.py @@ -8,19 +8,14 @@ @pytest.fixture -def distribution(source_project: Path) -> tuple[Path, Path]: +def distribution(source_project: Path, nab_executable: Path) -> tuple[Path, Path]: 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, - "uvx", - "--python", - "3.15", - "--from", - "nab==0.0.22", - "nab", + str(nab_executable), "lock", "--python", "3.15", diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index d3ed5ce..8737349 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -7,18 +7,31 @@ 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) args = parser.parse_args() try: with args.lock.open("rb") as stream: - available = Pylock.from_dict(tomllib.load(stream)).dependency_groups - if missing := set(args.groups) - set(available or ()): + 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 (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)) 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 index 3c456bf..7b26a44 100644 --- a/tools/expire_cutoffs.py +++ b/tools/expire_cutoffs.py @@ -6,8 +6,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path -import tomlkit -from nab_cutoffs import expired_cutoffs +from nab_cutoffs import remove_expired_cutoffs from tomlkit.exceptions import ParseError @@ -17,23 +16,9 @@ def main() -> None: args = parser.parse_args() try: source = args.project.read_text() - names, positions = expired_cutoffs(source, datetime.now(UTC) - timedelta(days=7)) - if names or positions: - document = tomlkit.parse(source) - nab = document["tool"]["nab"] - for name in names: - settings = nab["packages"][name] - del settings["uploaded-prior-to"] - if not settings: - del nab["packages"][name] - 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"] - args.project.write_text(tomlkit.dumps(document)) + 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)) diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index 1ef1b51..4d67cf4 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -1,7 +1,66 @@ from __future__ import annotations import tomllib -from datetime import datetime +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, 22, 13, 51, 56, 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]]: @@ -19,10 +78,10 @@ def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[in return packages, rules -def _expired(value: str, threshold: datetime) -> bool: - if value.startswith("P"): +def _expired(value: str | datetime, threshold: datetime) -> bool: + if isinstance(value, str) and value.startswith("P"): return False - timestamp = datetime.fromisoformat(value) + 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 @@ -30,4 +89,7 @@ def _expired(value: str, threshold: datetime) -> bool: __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 index 02bdd0f..57c57d5 100644 --- a/tools/verify_cutoff_patch.py +++ b/tools/verify_cutoff_patch.py @@ -8,7 +8,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path -from nab_cutoffs import expired_cutoffs +from nab_cutoffs import expired_cutoffs, remove_cutoffs def main() -> None: @@ -22,22 +22,19 @@ def main() -> None: capture_output=True, text=True, ).stdout - expected = tomllib.loads(baseline) + original = tomllib.loads(baseline) + actual = tomllib.loads(args.project.read_text()) names, positions = expired_cutoffs(baseline, datetime.now(UTC) - timedelta(days=7)) - nab = expected["tool"]["nab"] - for name in names: - settings = nab["packages"][name] - del settings["uploaded-prior-to"] - if not settings: - del nab["packages"][name] - 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"] - if expected != tomllib.loads(args.project.read_text()): + 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)) From cb26da5373b437caf946b2ea7eb40f092f235168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 18:48:09 -0700 Subject: [PATCH 157/167] =?UTF-8?q?=F0=9F=A7=AA=20test(release):=20model?= =?UTF-8?q?=20squash=20PR=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 45 +++++++++++++++---------- tests/prepare_towncrier/test_prepare.py | 28 ++++++--------- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e3138d5..c21ef57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os import ssl import subprocess @@ -13,6 +14,7 @@ import trustme import yaml from pytest_httpserver import HTTPServer +from werkzeug.wrappers import Request, Response @pytest.fixture @@ -138,27 +140,36 @@ def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], mo server, environment = github_api for name, value in environment.items(): monkeypatch.setenv(name, value) - fragment_commit = run(worktree, "git", "rev-parse", "HEAD").stdout.strip() - server.expect_request("/api/graphql", method="POST").respond_with_json( - { - "data": { - "repository": { - "c0": { - "associatedPullRequests": { - "nodes": [ - { - "number": 2, - "mergedAt": "2026-10-22T00:00:00Z", - "mergeCommit": {"oid": fragment_commit}, - "author": {"login": "gaborbernat", "__typename": "User"}, + + 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 diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 035f15f..0a94266 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -71,7 +71,7 @@ def test_prepare_custom_fragment_directory( "prepare-towncrier", version="", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(repository, github_api, 2), + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr assert "version=0.2.0" in (tmp_path / "output").read_text() @@ -93,7 +93,7 @@ def test_prepare_renders_github_notes( "prepare-towncrier", version="0.2.0", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(repository, github_api, 2), + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr result = run( @@ -208,7 +208,7 @@ def test_prepare_links_named_fragments( "prepare-towncrier", version="0.2.0", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(repository, github_api, 2), + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr expected = ( @@ -231,7 +231,7 @@ def test_prepare_links_numeric_fragment_to_merged_pull_request( "prepare-towncrier", version="0.2.0", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(repository, github_api, 2), + **_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() @@ -250,7 +250,7 @@ def test_prepare_resolves_numeric_fragment_from_commit( "prepare-towncrier", version="0.2.0", GITHUB_REPOSITORY="tox-dev/github-workflows", - **_author_lookup(repository, github_api, 3), + **_author_lookup(github_api, 3), ) assert result.returncode == 0, result.stderr assert "[#3](https://github.com/tox-dev/github-workflows/pull/3)" in (repository / "CHANGELOG.md").read_text() @@ -339,7 +339,7 @@ def test_prepare_formats_rst( action_scripts, "prepare-towncrier", version="0.2.0", - **_author_lookup(repository, github_api, 2), + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr assert "Add a After feature (:pr:`2`) by :user:`gaborbernat`." in (repository / "CHANGELOG.rst").read_text() @@ -377,7 +377,7 @@ def test_prepare_avoids_rst_conflict_marker( action_scripts, "prepare-towncrier", version="0.2.0", - **_author_lookup(repository, github_api, 2), + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr run(repository, "git", "reset", "--hard", "HEAD") @@ -392,15 +392,7 @@ def test_prepare_avoids_rst_conflict_marker( assert "Changes\n=======" in (repository / "CHANGELOG.rst").read_text() -def _author_lookup(repository: Path, github_api: tuple[HTTPServer, dict[str, str]], number: int) -> dict[str, str]: - server, environment = github_api - commit = run(repository, "git", "rev-parse", "HEAD").stdout.strip() - for name in ("fixture", "github-workflows"): - server.expect_request( - f"/api/v3/repos/tox-dev/{name}/commits/{commit}/pulls", - method="GET", - query_string="per_page=100", - ).respond_with_json( - [{"number": number, "merge_commit_sha": commit, "user": {"login": "gaborbernat", "type": "User"}}], - ) +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 From 0f92337bc3d1cce565de481fba604cb748acb022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 18:50:09 -0700 Subject: [PATCH 158/167] =?UTF-8?q?=F0=9F=92=84=20style(shell):=20format?= =?UTF-8?q?=20inline=20Actions=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install-locked-tools/action.yml | 24 ++++++++++++------------ validate-news/action.yml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 106809b..2ab5002 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -25,18 +25,18 @@ runs: 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 - ;; + 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 diff --git a/validate-news/action.yml b/validate-news/action.yml index 69758b6..5cc8e5b 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -24,7 +24,7 @@ runs: 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 + 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 From 253474cef08d09ebcb4060cb4d3c59898a763753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 18:51:45 -0700 Subject: [PATCH 159/167] =?UTF-8?q?=F0=9F=92=84=20style(news):=20match=20p?= =?UTF-8?q?inned=20shell=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- validate-news/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/validate-news/action.yml b/validate-news/action.yml index 5cc8e5b..69758b6 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -24,7 +24,7 @@ runs: 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 + 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 From dfef54b6416c8e8539e5c8664ee1f8f58aedfe3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 18:54:51 -0700 Subject: [PATCH 160/167] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20trust=20runne?= =?UTF-8?q?r=20TLS=20roots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 2 ++ build-python-distribution/action.yml | 1 + tests/conftest.py | 1 + 3 files changed, 4 insertions(+) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index b95d043..50a376e 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -89,6 +89,8 @@ jobs: runs-on: ubuntu-26.04 permissions: contents: read + env: + UV_SYSTEM_CERTS: "true" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index c2ec053..9878d13 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -32,6 +32,7 @@ runs: env: SOURCE: ${{ inputs.source }} BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} + UV_SYSTEM_CERTS: "true" run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE diff --git a/tests/conftest.py b/tests/conftest.py index c21ef57..831b51b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -389,6 +389,7 @@ def _safe_environment() -> dict[str, str]: "LANG", "PATH", "UV_CACHE_DIR", + "UV_SYSTEM_CERTS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", From 8269cd84ea994e179069a92150ab1710a03226d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 18:57:18 -0700 Subject: [PATCH 161/167] =?UTF-8?q?=F0=9F=90=9B=20fix(release):=20trust=20?= =?UTF-8?q?runner=20TLS=20roots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/prepare-towncrier-release.yaml | 1 + install-locked-tools/action.yml | 1 + install-nab/action.yml | 2 ++ 3 files changed, 4 insertions(+) diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 16232c3..5d20cc2 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -76,6 +76,7 @@ jobs: if: inputs.local-tool-source env: BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} + UV_SYSTEM_CERTS: "true" run: | uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 2ab5002..0c0900a 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -62,6 +62,7 @@ runs: VENV: ${{ inputs.venv }} LOCK: ${{ steps.selection.outputs.lock }} BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} + UV_SYSTEM_CERTS: "true" run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE diff --git a/install-nab/action.yml b/install-nab/action.yml index e2d0788..c30709b 100644 --- a/install-nab/action.yml +++ b/install-nab/action.yml @@ -11,6 +11,8 @@ runs: fi - name: Install lock reader shell: bash + env: + UV_SYSTEM_CERTS: "true" run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE From 7148732ae4cb12bcb03c03bf5480ba59ee2b12b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 19:00:07 -0700 Subject: [PATCH 162/167] =?UTF-8?q?=F0=9F=90=9B=20fix(tests):=20use=20inst?= =?UTF-8?q?alled=20build=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 2 -- .github/workflows/prepare-towncrier-release.yaml | 1 - build-python-distribution/action.yml | 1 - install-locked-tools/action.yml | 1 - install-nab/action.yml | 2 -- pyproject.toml | 1 + tests/conftest.py | 1 - .../test_distribution.py | 14 +++++++++++++- 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 50a376e..b95d043 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -89,8 +89,6 @@ jobs: runs-on: ubuntu-26.04 permissions: contents: read - env: - UV_SYSTEM_CERTS: "true" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 5d20cc2..16232c3 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -76,7 +76,6 @@ jobs: if: inputs.local-tool-source env: BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} - UV_SYSTEM_CERTS: "true" run: | uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index 9878d13..c2ec053 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -32,7 +32,6 @@ runs: env: SOURCE: ${{ inputs.source }} BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} - UV_SYSTEM_CERTS: "true" run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 0c0900a..2ab5002 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -62,7 +62,6 @@ runs: VENV: ${{ inputs.venv }} LOCK: ${{ steps.selection.outputs.lock }} BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} - UV_SYSTEM_CERTS: "true" run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE diff --git a/install-nab/action.yml b/install-nab/action.yml index c30709b..e2d0788 100644 --- a/install-nab/action.yml +++ b/install-nab/action.yml @@ -11,8 +11,6 @@ runs: fi - name: Install lock reader shell: bash - env: - UV_SYSTEM_CERTS: "true" run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE diff --git a/pyproject.toml b/pyproject.toml index c679e34..f7f85e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ release-shell = [ "prek>=0.2", "yq>=4.4.5" ] release-hooks = [ "release-creator>=0.5.1", "prek>=0.2" ] release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ + "hatchling>=1.28", "pytest>=9", "pytest-httpserver", "pytest-xdist>=3", diff --git a/tests/conftest.py b/tests/conftest.py index 831b51b..c21ef57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -389,7 +389,6 @@ def _safe_environment() -> dict[str, str]: "LANG", "PATH", "UV_CACHE_DIR", - "UV_SYSTEM_CERTS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py index a3de552..d4a93f8 100644 --- a/tests/validate_python_distribution/test_distribution.py +++ b/tests/validate_python_distribution/test_distribution.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from pathlib import Path from zipfile import ZipFile @@ -23,7 +24,18 @@ def distribution(source_project: Path, nab_executable: Path) -> tuple[Path, Path "pkg-meta", ) output = source_project / "dist" - run(source_project, "uv", "build", "--python", "3.15", "--sdist", "--wheel", "--out-dir", str(output)) + run( + source_project, + "uv", + "build", + "--no-build-isolation", + "--python", + sys.executable, + "--sdist", + "--wheel", + "--out-dir", + str(output), + ) return source_project, output From b29d250ff5400a08050925a57e418bee1be05651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 19:02:53 -0700 Subject: [PATCH 163/167] =?UTF-8?q?=F0=9F=90=9B=20fix(tests):=20isolate=20?= =?UTF-8?q?build=20TLS=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/build_python_distribution/test_build.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index ee5f37f..76322fd 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -11,7 +11,8 @@ @pytest.fixture -def build_repository(source_project: Path, nab_executable: Path) -> Path: +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" From 6657e5374d3aa63fb5a3890279ada74ea336b97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 19:29:19 -0700 Subject: [PATCH 164/167] =?UTF-8?q?=F0=9F=90=9B=20fix(release):=20allow=20?= =?UTF-8?q?local=20tool=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/prepare-towncrier-release.yaml | 1 + .../workflows/validate-python-release.yaml | 1 + README.md | 2 ++ install-locked-tools/action.yml | 8 ++++++- tests/install_locked_tools/test_install.py | 22 +++++++++++++++++++ tools/check_lock_groups.py | 7 +++++- 6 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 16232c3..320b9a9 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -60,6 +60,7 @@ jobs: 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 diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 1353540..fca00f1 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -80,6 +80,7 @@ jobs: 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 diff --git a/README.md b/README.md index f257cc8..a460f71 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ constraints generated from the lock's `build` group under the runner's temporary - `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 diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 2ab5002..63def2f 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -10,6 +10,9 @@ inputs: 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: @@ -21,6 +24,7 @@ runs: 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" @@ -47,9 +51,11 @@ runs: 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[@]}" + "${lock_check[@]}" "${source_check[@]}" printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" - id: constraints diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index faf4a4a..af53ac9 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -136,6 +136,28 @@ def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scrip 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) diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 8737349..f6341f4 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -15,6 +15,7 @@ def main() -> None: 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: @@ -30,7 +31,11 @@ def main() -> None: set(args.groups) & {"release-news", "release-notes", "release-prepare", "release-validate", "release-hooks"} ) - if needs_release_creator and (installed is None or Version(str(installed)) < Version(str(required))): + 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)) From c549001c2970bdc4e2bb5ab8b7df45e0642d8bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 19:39:34 -0700 Subject: [PATCH 165/167] =?UTF-8?q?=F0=9F=90=9B=20fix(pypi):=20pass=20loca?= =?UTF-8?q?l=20tool=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reconcile-pypi-artifacts/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index 285a1d3..62404e8 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -29,6 +29,7 @@ runs: 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 From 553df965e1b6b9538d569c33122f72b0b49c0077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 19:47:15 -0700 Subject: [PATCH 166/167] =?UTF-8?q?=F0=9F=94=A7=20build(release):=20lock?= =?UTF-8?q?=20creator=200.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pylock.toml | 97 ++++++++++++++-------- pyproject.toml | 12 +-- tests/install_locked_tools/test_install.py | 4 +- tests/refresh_release_lock/test_cutoffs.py | 2 + tools/nab_cutoffs.py | 2 +- 5 files changed, 73 insertions(+), 44 deletions(-) diff --git a/pylock.toml b/pylock.toml index dd9bb9e..198455d 100644 --- a/pylock.toml +++ b/pylock.toml @@ -285,7 +285,7 @@ sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" [[packages]] name = "click" version = "8.5.0" -marker = "\"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +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/" @@ -653,6 +653,31 @@ 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" @@ -763,7 +788,7 @@ sha256 = "f55f5728ff61d3dd54dbc5a397cf3f7307cb64b180c20b72afbebf01c9e2c8a0" [[packages]] name = "jinja2" version = "3.1.6" -marker = "\"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +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" }, @@ -819,7 +844,7 @@ sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" version = "3.0.4" -marker = "\"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +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/" @@ -1191,28 +1216,28 @@ sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" [[packages]] name = "platformdirs" -version = "4.13.1" +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.1.tar.gz" -upload-time = 2026-10-15 21:56:08.441933+00:00 -url = "https://files.pythonhosted.org/packages/16/35/146546aa5b9286a83248d29c51781476bbb4b0a608523361120059e0f7ed/platformdirs-4.13.1.tar.gz" -size = 61458 +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 = "ee77cf2d36d5a515b2acf68ede6434cf3a8fc70b822bb6b3264d01b96c69a8e0" +sha256 = "5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce" [[packages.wheels]] -name = "platformdirs-4.13.1-py3-none-any.whl" -upload-time = 2026-10-15 21:56:07.059862+00:00 -url = "https://files.pythonhosted.org/packages/aa/0f/ac79a4e8394193bb228abaaf1c520f629b6ada7fcb75e58cdc8c6acce321/platformdirs-4.13.1-py3-none-any.whl" -size = 32737 +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 = "4902b4ad355cb0b202c746b29580ab67c5c2d918e30e3d006353465b82ec3ecc" +sha256 = "f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4" [[packages]] name = "prek" @@ -1568,35 +1593,36 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "0.5.1" +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 = "tomli-w" }, + { name = "towncrier" }, ] index = "https://pypi.org/simple/" [packages.sdist] -name = "release_creator-0.5.1.tar.gz" -upload-time = 2026-10-22 13:51:55.787829+00:00 -url = "https://files.pythonhosted.org/packages/65/d3/83211b4ebfbaf87383392aed6ef3aa98d65e0ade613a2aa0ea0f222cb6b1/release_creator-0.5.1.tar.gz" -size = 158621 +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 = "755f8d460a493789756ab1bd4bbda0dd90145699288239085dd712cba2d84251" +sha256 = "58afd3cd0fc0b0e47ded23a735a1ba9189a7b0adad410628c90c96cf74457ed6" [[packages.wheels]] -name = "release_creator-0.5.1-py3-none-any.whl" -upload-time = 2026-10-22 13:51:54.135682+00:00 -url = "https://files.pythonhosted.org/packages/5f/88/fab14c09530c8691040ec040613476d645414e2db1123c35b3818d291cea/release_creator-0.5.1-py3-none-any.whl" -size = 50983 +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 = "7aa5f28c5195f6f5e0493abede9c041a58d015bc3a837feee5669cc2062d6053" +sha256 = "4b9cd6187c87fcb7bf3aca5f58c67c90703e58875643af633661a0448756d45f" [[packages]] name = "requests" @@ -1975,7 +2001,7 @@ sha256 = "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b" [[packages]] name = "tomli-w" version = "1.2.0" -marker = "\"lock-tools\" 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" +marker = "\"lock-tools\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -2025,7 +2051,7 @@ sha256 = "177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304" [[packages]] name = "towncrier" version = "26.9.0" -marker = "\"release-news\" in dependency_groups or \"release-prepare\" in dependency_groups" +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" }, @@ -2302,24 +2328,25 @@ sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" [tool.nab] nab-version = "0.0.22" -created-at = 2026-10-22 23:17:10.754894+00:00 +created-at = 2026-10-23 02:46:01.225053+00:00 command-line = [ "nab", "lock", "--groups", "build", "lock-tools", - "release-notes", + "release-attestations", + "release-check", + "release-hook-check", + "release-hooks", "release-news", + "release-notes", "release-prepare", - "release-validate", - "release-check", - "release-attestations", "release-shell", - "release-hooks", - "release-hook-check", + "release-validate", "--output", "pylock.toml", + "--upgrade", ] input-path = "pyproject.toml" mode = "universal" diff --git a/pyproject.toml b/pyproject.toml index f7f85e2..b435dec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,14 +10,14 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] -release-notes = [ "release-creator>=0.5.1" ] -release-news = [ "release-creator>=0.5.1", "towncrier>=26.9" ] -release-prepare = [ "release-creator>=0.5.1", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=0.5.1", "prek>=0.2" ] +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.5.1", "prek>=0.2" ] +release-hooks = [ "release-creator>=0.6.0", "prek>=0.2" ] release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ "hatchling>=1.28", @@ -62,7 +62,7 @@ uploaded-prior-to = "2026-10-18T05:45:09Z" uploaded-prior-to = "2026-10-18T05:45:09Z" [tool.nab.packages.release-creator] -uploaded-prior-to = "2026-10-22T13:51:56Z" +uploaded-prior-to = "2026-10-23T02:44:40Z" [tool.ruff] line-length = 120 diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index af53ac9..3cc274b 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -43,9 +43,9 @@ def test_install_locked_tools_installs_groups(tmp_path: Path, action_scripts: Pa [ 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", "towncrier", id="patch-validation"), + 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", "towncrier", id="action-tests"), + pytest.param("release-hooks", "release-creator", "yq", id="action-tests"), pytest.param("release-hook-check", "yq", "towncrier", id="hook-cooldown"), ], ) diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 440c2da..cd42887 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -154,6 +154,8 @@ def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: [ 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"), ], ) diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index 4d67cf4..d65df7f 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -8,7 +8,7 @@ ("nab", "nab-index", "nab-markersets", "nab-project", "nab-provider", "nab-resolver"), datetime(2026, 10, 19, tzinfo=UTC), ), - "release-creator": datetime(2026, 10, 22, 13, 51, 56, tzinfo=UTC), + "release-creator": datetime(2026, 10, 23, 2, 44, 40, tzinfo=UTC), } From 40612059e6674f06b291d21564fee9565f91658e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 22 Oct 2026 19:50:07 -0700 Subject: [PATCH 167/167] =?UTF-8?q?=F0=9F=A7=AA=20test(release):=20use=20r?= =?UTF-8?q?unner=20trust=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/validate_python_distribution/test_distribution.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py index d4a93f8..48edea9 100644 --- a/tests/validate_python_distribution/test_distribution.py +++ b/tests/validate_python_distribution/test_distribution.py @@ -9,7 +9,8 @@ @pytest.fixture -def distribution(source_project: Path, nab_executable: Path) -> tuple[Path, Path]: +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',