diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 72af74e..d344915 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -3,7 +3,7 @@ on: push: branches: [main] pull_request: - types: [opened, reopened, synchronize, labeled, unlabeled] + types: [opened, reopened, synchronize] workflow_dispatch: permissions: contents: read @@ -37,8 +37,6 @@ jobs: with: groups: release-check venv: .release-tools - - name: Add locked tools to PATH - run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH" - name: Check repository formatting run: prek run --all-files shell: @@ -67,8 +65,6 @@ jobs: with: groups: release-shell venv: .release-tools - - name: Add locked tools to PATH - run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH" - name: Check inline shell run: | set -euo pipefail @@ -109,8 +105,6 @@ jobs: with: groups: release-news release-shell venv: .release-tools - - name: Add locked tools to PATH - run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH" - name: Install current test tools run: | uv venv --python 3.15 .venv @@ -136,30 +130,6 @@ jobs: pyproject.toml - name: Check Python types run: uvx --with tox-uv tox run -e type - changelog: - if: github.event_name == 'pull_request' - runs-on: ubuntu-26.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - version: latest-known - python-version: "3.15" - enable-cache: true - cache-suffix: ${{ github.job }} - cache-dependency-glob: | - pylock.toml - pyproject.toml - - uses: $/install-locked-tools - with: - groups: release-news - venv: ${{ runner.temp }}/release-tools - - name: Add locked tools to PATH - run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH" - - uses: $/validate-news hook-cooldown: if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 @@ -195,7 +165,7 @@ jobs: gate: name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} if: ${{ !cancelled() }} - needs: [check, shell, test, type, changelog, hook-cooldown, release-tools-preview] + needs: [check, shell, test, type, hook-cooldown, release-tools-preview] runs-on: ubuntu-26.04 steps: - env: @@ -207,6 +177,6 @@ jobs: to_entries[]; .value.result == "success" or ($ENV.EVENT_NAME != "pull_request" and .value.result == "skipped" and - (.key == "changelog" or .key == "hook-cooldown" or .key == "release-tools-preview")) + (.key == "hook-cooldown" or .key == "release-tools-preview")) ) ' <<<"$NEEDS_JSON" diff --git a/.github/workflows/label-bot-news.yaml b/.github/workflows/label-bot-news.yaml deleted file mode 100644 index e7f9521..0000000 --- a/.github/workflows/label-bot-news.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: Label bot news -on: - workflow_run: # zizmor: ignore[dangerous-triggers] Verifies source CI and PR metadata without checkout or artifacts. - workflows: [Check] - types: [completed] -permissions: {} -concurrency: - group: label-bot-news-${{ github.event.workflow_run.head_sha }} - cancel-in-progress: true -jobs: - label: - if: github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-26.04 - permissions: - actions: read - issues: write - pull-requests: read - steps: - - id: pr - uses: $/resolve-pr-from-run - with: - run-id: ${{ github.event.workflow_run.id }} - workflow-path: .github/workflows/check.yaml - - if: steps.pr.outputs.bot-author == 'true' - uses: $/label-bot-news - with: - pull-request: ${{ steps.pr.outputs.number }} - bot-author: "true" diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml new file mode 100644 index 0000000..3eb526d --- /dev/null +++ b/.github/workflows/news.yaml @@ -0,0 +1,28 @@ +name: Changelog +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] +permissions: + contents: read +jobs: + changelog: + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: true + cache-suffix: ${{ github.job }} + cache-dependency-glob: | + pylock.toml + pyproject.toml + - uses: $/install-locked-tools + with: + groups: release-news + venv: ${{ runner.temp }}/release-tools + - uses: $/validate-news diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 320b9a9..f81ed50 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -44,7 +44,6 @@ jobs: outputs: version: ${{ steps.prepare.outputs.version }} artifact-id: ${{ steps.patch.outputs.artifact-id }} - patch-sha: ${{ steps.digest.outputs.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -56,27 +55,22 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/install-locked-tools + - id: tools + uses: $/install-locked-tools with: groups: release-prepare${{ inputs.local-tool-source && ' project-runtime' || '' }} venv: .venv local-tool-source: ${{ inputs.local-tool-source }} - - name: Add locked tools to PATH - run: echo "$PWD/.venv/bin" >>"$GITHUB_PATH" - name: Install prek hooks id: hooks run: prek install-hooks background: true - name: Create a fresh validation worktree run: git worktree add --detach "$RUNNER_TEMP/release-validation" "$GITHUB_SHA" - - name: Export local build constraints - if: inputs.local-tool-source - id: constraints - uses: $/export-build-constraints - name: Build local release tool wheel if: inputs.local-tool-source env: - BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} + BUILD_CONSTRAINTS: ${{ steps.tools.outputs.build-constraints }} run: | uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . @@ -102,11 +96,6 @@ jobs: version: ${{ steps.prepare.outputs.version }} patch: ${{ runner.temp }}/release.patch working-directory: ${{ runner.temp }}/release-validation - - name: Record validated patch digest - id: digest - run: | - patch_sha=$(sha256sum "$RUNNER_TEMP/release.patch") - printf 'sha=%s\n' "${patch_sha%% *}" >>"$GITHUB_OUTPUT" - id: patch uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -134,16 +123,7 @@ jobs: with: version: ${{ needs.assemble.outputs.version }} patch: ${{ runner.temp }}/release-patch/release.patch - expected-patch-sha: ${{ needs.assemble.outputs.patch-sha }} checks: "false" - - name: Require release App key - env: - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - run: | - if [[ -z "$RELEASE_APP_PRIVATE_KEY" ]]; then - echo '::error::RELEASE_APP_PRIVATE_KEY is missing from release-auth' >&2 - exit 1 - fi - id: token uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6305178..ba50213 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -27,15 +27,13 @@ jobs: venv: .venv - name: Check tagged source run: .venv/bin/prek run --all-files - - name: Check source remains clean - run: test -z "$(git status --porcelain)" source: runs-on: ubuntu-26.04 permissions: contents: read outputs: previous: ${{ steps.previous.outputs.previous }} - commit: ${{ steps.tag.outputs.commit }} + commit: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -51,19 +49,16 @@ jobs: groups: release-notes venv: .venv - name: Verify tagged source - id: tag env: TAG: ${{ github.ref_name }} run: | set -euo pipefail test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" git merge-base --is-ancestor HEAD origin/main - echo "commit=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - id: previous uses: $/select-previous-tag with: tag: ${{ github.ref_name }} - executable: .venv/bin/release-creator contributors: needs: source runs-on: ubuntu-26.04 @@ -99,7 +94,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: @@ -110,8 +105,6 @@ jobs: with: groups: release-notes venv: .venv - - name: Add locked tools to PATH - run: echo "$PWD/.venv/bin" >>"$GITHUB_PATH" - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.contributors.outputs.artifact-id }} diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 3668ce8..da9ce44 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -101,8 +101,27 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false + - uses: $/install-locked-tools + with: + groups: release-hooks + lock-source: shared + venv: ${{ runner.temp }}/hook-tools + - name: Refresh frozen hook revisions + run: >- + uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update --config .pre-commit-config.yaml --freeze --cooldown-days 7 --no-progress + - name: Refresh exact hook dependencies + run: >- + uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks --exclude-newer '7 days' --python-version 3.15 - id: hooks - uses: $/refresh-hook-pins + name: Write hook patch + run: | + set -euo pipefail + if git diff --quiet -- .pre-commit-config.yaml; then + echo 'changed=false' >>"$GITHUB_OUTPUT" + exit 0 + fi + echo 'changed=true' >>"$GITHUB_OUTPUT" + git diff --binary -- .pre-commit-config.yaml >"$RUNNER_TEMP/hooks.patch" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.hooks.outputs.changed == 'true' with: @@ -137,21 +156,18 @@ jobs: path: ${{ runner.temp }}/release-tools-hooks - name: Apply lock patch if: needs.generate-lock.outputs.changed == 'true' - run: | - git apply --check "$RUNNER_TEMP/release-tools-lock/lock.patch" - git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch" + run: git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch" - name: Apply hook patch if: needs.generate-hooks.outputs.changed == 'true' - run: | - git apply --check "$RUNNER_TEMP/release-tools-hooks/hooks.patch" - git apply --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" + run: git apply --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" - name: Check dependency patch paths env: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} run: | set -euo pipefail - if [[ -n "$ADDITIONAL_LOCK" && ("$ADDITIONAL_LOCK" != pylock.*.toml || "$ADDITIONAL_LOCK" == */*) ]]; then - echo '::error::invalid additional lock basename' >&2 + if [[ -n "$ADDITIONAL_LOCK" && + ("$ADDITIONAL_LOCK" != pylock.*.toml || "$ADDITIONAL_LOCK" == */*) ]]; then + echo "::error::invalid additional lock: $ADDITIONAL_LOCK" >&2 exit 1 fi git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/dependency-paths" @@ -162,7 +178,6 @@ jobs: exit 1 fi done <"$RUNNER_TEMP/dependency-paths" - git diff --cached --check - name: Check dependency patch file mode run: | set -euo pipefail @@ -182,13 +197,6 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git commit -m "$COMMIT_MESSAGE" - - name: Check release App credentials - env: - RELEASE_APP_CLIENT_ID: ${{ vars.RELEASE_APP_CLIENT_ID }} - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - run: | - test -n "$RELEASE_APP_CLIENT_ID" - test -n "$RELEASE_APP_PRIVATE_KEY" - id: repository name: Select the current repository env: diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index fca00f1..13229c4 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -3,21 +3,21 @@ on: workflow_call: inputs: changelog: - description: Tracked changelog path - required: true + description: Deprecated; notes are rendered by the caller type: string + default: "" artifact-base-url: - description: Ecosystem release URL without the version - required: true + description: Deprecated; notes are rendered by the caller type: string + default: "" artifact-label: - description: Link text for the ecosystem release - default: PyPI release + description: Deprecated; notes are rendered by the caller type: string + default: PyPI release local-tool-source: - description: Install release-creator from this checkout for its own release - default: false + description: Deprecated; distribution builds do not install release-creator type: boolean + default: false source: description: Package source directory default: . @@ -50,9 +50,33 @@ jobs: - name: Run tagged source checks run: >- "$RUNNER_TEMP/release-tools/bin/prek" run --all-files - - name: Check source remains clean - run: test -z "$(git status --porcelain)" - validate: + constraints: + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - id: constraints + uses: $/install-locked-tools + - name: Stage build constraints + env: + SOURCE: ${{ steps.constraints.outputs.build-constraints }} + run: cp "$SOURCE" "$RUNNER_TEMP/build-constraints.txt" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.artifact-prefix }}build-constraints + path: ${{ runner.temp }}/build-constraints.txt + if-no-files-found: error + build: + needs: constraints runs-on: ubuntu-26.04 permissions: contents: read @@ -76,35 +100,67 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/install-locked-tools - with: - groups: release-notes${{ inputs.local-tool-source && ' project-runtime' || '' }} - venv: ${{ runner.temp }}/release-tools - local-tool-source: ${{ inputs.local-tool-source }} - - name: Add locked tools to PATH - run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH" - - uses: $/build-python-distribution + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - source: ${{ inputs.source }} - - name: Install the local release tool - if: inputs.local-tool-source - run: uv pip install --python "$RUNNER_TEMP/release-tools/bin/python" --no-deps dist/*.whl - - name: Render tagged-source notes - uses: $/render-release-notes - with: - tag: ${{ github.ref_name }} - changelog: ${{ inputs.changelog }} - artifact-url: ${{ inputs.artifact-base-url }}${{ github.ref_name }}/ - artifact-label: ${{ inputs.artifact-label }} - output: ${{ runner.temp }}/release-notes.md - - uses: $/validate-python-distribution + name: ${{ inputs.artifact-prefix }}build-constraints + path: ${{ runner.temp }} + - name: Require empty distribution directory + run: test ! -d dist || test -z "$(find dist -mindepth 1 -maxdepth 1 -print -quit)" + - name: Select build interpreter + run: uv venv --python 3.15 "$RUNNER_TEMP/build-python" + - name: Build wheel and sdist + env: + SOURCE: ${{ inputs.source }} + run: | + set -euo pipefail + build_python=$(uv python find "$RUNNER_TEMP/build-python") + export SOURCE_DATE_EPOCH + SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) + uv build --python "$build_python" --python-preference only-managed --sdist --wheel \ + --build-constraints "$RUNNER_TEMP/build-constraints.txt" --require-build-hashes "$SOURCE" --out-dir dist + - name: Match distribution version to tag + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + shopt -s nullglob + version=${TAG#v} + wheels=(dist/*-"$version"-*.whl) + sdists=(dist/*-"$version".tar.gz) + files=(dist/*) + if [[ ${#wheels[@]} -ne 1 || ${#sdists[@]} -ne 1 || ${#files[@]} -ne 2 ]]; then + echo "::error::wheel and sdist versions must match $TAG" >&2 + exit 1 + fi - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact-prefix }}python-package-distributions path: dist/* if-no-files-found: error - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + validate: + needs: build + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - name: ${{ inputs.artifact-prefix }}release-validation-notes - path: ${{ runner.temp }}/release-notes.md - if-no-files-found: error + persist-credentials: false + ref: ${{ github.sha }} + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ inputs.artifact-prefix }}python-package-distributions + path: dist + - name: Install distribution validators + run: | + uv venv --python 3.15 "$RUNNER_TEMP/package-validators" + uv pip install --python "$RUNNER_TEMP/package-validators" 'twine>=7' 'check-wheel-contents>=0.6.3' + - name: Check distribution metadata + run: uv run --no-project --python "$RUNNER_TEMP/package-validators" twine check dist/* + - name: Check wheel contents + run: uv run --no-project --python "$RUNNER_TEMP/package-validators" check-wheel-contents --no-config dist diff --git a/README.md b/README.md index 95e4349..219b48f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # GitHub workflows -Composite Actions check PR news, prepare a Towncrier release, verify its patch, push an App-authored commit and tag, -build a Python wheel and sdist, and render GitHub Release notes. They run as steps in the caller's workflow. Each -repository keeps its dispatch form, publisher, GitHub environment, and required check names. +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. ## Install locked tools @@ -13,21 +13,18 @@ and Windows. It resolves dependencies under the seven-day cutoff in `[tool.nab]` dependencies resolve fresh. The Action rejects missing groups and leaves tracked files unchanged. Source builds use constraints generated from the lock's `build` group under the runner's temporary directory. -- `venv` (required): directory for the virtual environment. -- `groups` (required): whitespace-separated release dependency groups. +- `venv`: directory for the virtual environment when `groups` is set. +- `groups`: whitespace-separated release dependency groups. Leave empty to export build constraints without installing + release tools. - `lock-source`: `caller` (default) installs from the checkout's lock; `shared` installs from the pinned Action's lock. Both paths use the pinned Action's lock-tool bootstrap. The Action checks that every selected group exists in the selected lock and applies hashed build constraints. - `local-tool-source`: set to `true` only when release-creator builds and installs its own wheel after this Action. The caller's lock then needs the selected groups but does not need a release-creator package entry. -There is no named output. The installed commands are in `venv/bin` or Windows `venv/Scripts`; add that directory to -`GITHUB_PATH` when later steps call them by name. Run `actions/checkout` and `astral-sh/setup-uv` with Python 3.15 -before this Action. - -`install-nab` is the shared bootstrap used by `install-locked-tools`, `export-build-constraints`, and -`refresh-release-lock`. It takes no inputs and has no outputs. It creates `$RUNNER_TEMP/lock-tools` and installs the -hash-checked `lock-tools` group from this repository's pinned `pylock.toml`; it does not install caller dependencies. +The `build-constraints` output names the hashed constraints file. The Action adds installed commands to `GITHUB_PATH` +for later steps. Run `actions/checkout` and `astral-sh/setup-uv` with Python 3.15 before it. It bootstraps Nab from the +shared, hash-checked lock. ## Check pull request news @@ -37,12 +34,12 @@ configured directory against the configured categories, filename format, and non not. A bot's supplied fragment is checked and included in the normal release flow. The Action reads repository files and Git history; it does not add labels or write to GitHub. -Keep the caller's existing `changelog` job name and full checkout. Install Towncrier from the caller's release lock -before calling the Action: +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: ```yaml changelog: - if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 permissions: contents: read @@ -59,71 +56,20 @@ changelog: with: groups: release-news venv: ${{ runner.temp }}/release-tools - - name: Expose release tools - run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH" - uses: tox-dev/github-workflows/validate-news@SHA ``` The Action expects a pull request base ref and a Markdown or reStructuredText changelog. The caller should also run on `labeled` and `unlabeled` events, so a label added after opening the PR updates the required check. -`label-bot-news` adds `skip news` to a bot PR with no fragment and removes it when the bot adds one. It reads the PR -file list through GitHub's API and never checks out PR code. Keep it in a separate `workflow_run` caller so the -read-only PR workflow does not receive Issues write access. `resolve-pr-from-run` verifies that a completed run came -from that PR workflow, then looks up the current PR by its source owner, branch, repository, and exact head SHA. It -returns no PR when the branch has moved. This also handles fork PRs, whose workflow-run payload may omit the PR number. - -The [caller in this repository](.github/workflows/label-bot-news.yaml) shows the event and permissions. It runs after -the read-only `Check` workflow finishes, even if its tests fail. Set `workflows` to that caller's PR workflow name and -`workflow-path` to its file. The label action accepts `fragments` and `extension` for an RST repository, and `fragments` -accepts comma-separated directories when the caller also recognizes a legacy fragment path: - -```yaml -name: Label bot news -on: - workflow_run: - workflows: [Check] - types: [completed] -permissions: {} -jobs: - label: - if: github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-26.04 - permissions: - actions: read - issues: write - pull-requests: read - steps: - - id: pr - uses: tox-dev/github-workflows/resolve-pr-from-run@SHA - with: - run-id: ${{ github.event.workflow_run.id }} - workflow-path: .github/workflows/check.yaml - - if: steps.pr.outputs.bot-author == 'true' - uses: tox-dev/github-workflows/label-bot-news@SHA - with: - pull-request: ${{ steps.pr.outputs.number }} - bot-author: 'true' - fragments: docs/changelog - extension: rst -``` - -`resolve-pr-from-run` takes the source `run-id` and `workflow-path` in the caller repository. It outputs `number` and -`bot-author`; `number` is empty when the run's head is no longer the open PR head. It reads GitHub metadata and writes -no repository files. `label-bot-news` takes that verified number and bot flag. For `.changeset/.md`, pass -`fragments: .changeset` and `fragment-style: changeset`. `fragment-style` defaults to `towncrier`, which expects -`..md` or RST. It only changes the `skip news` label when the bot's fragment state and label disagree. -Label writes made with `GITHUB_TOKEN` do not trigger PR CI. The next contributor or bot update runs the news check -against the settled label state. - ## Refresh release tools and hooks Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only jobs refresh the universal `pylock.toml` and the frozen pre-commit hooks in parallel. Nab reads the seven-day package cutoff and any reviewed package exceptions from `[tool.nab]`. The hook updater applies the same cooldown to hook revisions and their -`additional_dependencies`. Each job uploads a patch. The writer accepts changes to `pylock.toml`, an explicitly named -secondary lock, and `.pre-commit-config.yaml`, plus removal of expired Nab cutoffs in `pyproject.toml`. It independently -checks those cutoff removals before minting the App token, then opens or updates one dependency PR from +`additional_dependencies`. Each job uploads a patch. The writer accepts changes to `pylock.toml`, an explicitly +configured secondary lock, and `.pre-commit-config.yaml`, plus removal of expired Nab cutoffs in `pyproject.toml`. It +independently checks those cutoff removals before minting the App token, then opens or updates one dependency PR from `automation/update-release-tools` to `main`. A lock or hook update runs the normal PR checks before merge. The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this @@ -159,51 +105,25 @@ The required `lock-groups` input lists the caller's release dependency groups, s repository's groups; a caller supplies its own. The project configures its Python and platform targets in `[tool.nab]`. Tests install test, type, and documentation groups afresh so they detect upstream dependency changes. The Action tests fetch immutable, hash-checked release-tool artifacts from the shared lock; their separate package fixture resolves -test-only tools afresh. For a separately checked-in build matrix lock, also pass `additional-lock` with an existing -`pylock.*.toml` basename, `additional-matrix-python` with its Python range, and `additional-matrix-platforms` and -`additional-matrix-implementations` as space-separated Nab values. The secondary lock keeps its specified environment -matrix during refresh. `additional-lock-groups` selects dependency groups for it; when empty, Nab locks the project's -build requirements. Set `additional-lock-selection: project` to include the project's runtime dependencies and any -configured build group. The consumer must select its runtime group when reading that lock. This selection cannot be -combined with `additional-lock-groups`. Secondary-lock inputs without `additional-lock` fail. The writer accepts only -the named secondary lock path. Use `project` only when those runtime dependencies become part of the published artifact, -such as a bundled zipapp; tests still install runtime dependencies afresh. - -For a repository that already commits `pylock.build.toml`, the complete caller is: - -```yaml -name: Upgrade release tools -on: - workflow_dispatch: - schedule: - - cron: 0 8 * * 2 -permissions: {} -jobs: - upgrade: - uses: >- - tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@SHA - permissions: - contents: read - secrets: - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - with: - lock-groups: build lock-tools release-news release-prepare release-check - additional-lock: pylock.build.toml - additional-matrix-python: '>=3.15,<3.16' - additional-matrix-platforms: linux_x86_64 macos_arm64 windows_amd64 - additional-matrix-implementations: cpython -``` +test-only tools afresh. The updater also refreshes a caller's secondary build-matrix lock when `additional-lock` and its +matrix inputs are set. The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The workflow does not update `uv.lock` or a checked-in constraints file. Build constraints are generated from the lock's `build` group at runtime. -`refresh-release-lock` is the read-only half of the updater. It accepts the same secondary-lock inputs. In `refresh` -mode, it upgrades the selected release groups under Nab's configured cutoff, runs `prek`, and writes -`$RUNNER_TEMP/lock.patch` if either lock or an expired Nab cutoff changes. In `check` mode, it regenerates without -upgrading and fails if the checked-in files differ; the caller's normal formatting job runs `prek`. It rejects changes -outside the selected locks and expired cutoff policy. Its `changed` output is `true` only when the refresh produces a -patch. Neither mode pushes a branch or requests a write token. Check it on a PR in a job with Contents read permission: +For a second lock such as `pylock.build.toml`, set `additional-lock`, `additional-matrix-python`, +`additional-matrix-platforms`, and `additional-matrix-implementations`. `additional-lock-groups` selects dependency +groups in that lock; with no groups, the updater selects build requirements. Set `additional-lock-selection: project` +for a lock that selects the project's runtime dependencies instead. The lock must already exist, and the updater writes +both locks in the same dependency PR. The writer accepts only those locks, `pyproject.toml`, and the hook configuration. + +`refresh-release-lock` is the read-only half of the updater. In `refresh` mode, it upgrades the selected release groups +under Nab's configured cutoff and writes `$RUNNER_TEMP/lock.patch` if either lock or a reviewed expired Nab cutoff +changes. In `check` mode, it regenerates without upgrading and fails if the checked-in files differ. 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. Check it on a PR in a job with +Contents read permission: ```yaml release-tools-preview: @@ -228,12 +148,10 @@ release-tools-preview: mode: check ``` -`refresh-hook-pins` is the matching read-only Action. It freezes eligible revisions with -`prek update --freeze --cooldown-days 7`. For selected Python hooks, it reads each hook repository's package metadata -and pins both the hook package dependencies and `additional_dependencies` under the seven-day cutoff. Non-Python hooks -keep their frozen revisions. It then runs the hooks. Its `changed` output is `true` only when `.pre-commit-config.yaml` -changes; the patch is `$RUNNER_TEMP/hooks.patch`. It fails when a hook changes another tracked file. The Action does not -write to GitHub. +The weekly updater freezes eligible hook revisions with `prek update --freeze --cooldown-days 7`. It then uses +`release-creator hooks` to pin Python hook dependencies under the same cutoff and writes a patch when +`.pre-commit-config.yaml` changes. The generation job has read-only repository permissions; the separate publish job +checks the patch paths and file modes before obtaining an App token. `check-hook-update-cooldown` checks proposed hook revisions on bot PRs. Put it in the caller's `pull_request` workflow with `actions: read`, `contents: read`, and `pull-requests: read`, then include it in the required aggregate. It reads @@ -261,30 +179,11 @@ head. GitHub accepts reruns for 30 days. A failed rerun is retried on the next s [caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. Set `workflow` or `job-name` only when the caller uses different names. -Run the hook preview in its own PR job so its patch check sees only the hook configuration: - -```yaml -hook-pins-preview: - if: github.event_name == 'pull_request' - runs-on: ubuntu-26.04 - permissions: - contents: read - steps: - - uses: actions/checkout@CHECKOUT_SHA - with: - persist-credentials: false - - uses: astral-sh/setup-uv@SETUP_UV_SHA - with: - version: latest-known - python-version: '3.15' - - uses: tox-dev/github-workflows/refresh-hook-pins@SHA -``` - ## Start with a caller The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable -Towncrier workflow. Its `assemble` job creates a patch, validates it in a fresh worktree without the App key, and -records the patch digest. Its `push` job checks the patch and digest again before minting a repository-scoped App token. +Towncrier workflow. Its `assemble` job creates a patch and validates it in a fresh worktree without the App key. Its +`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. @@ -300,7 +199,7 @@ and tag ruleset bypass. main + fragments │ ▼ -prepare-towncrier ──► validate in fresh worktree ──► upload patch + digest +prepare-towncrier ──► validate in fresh worktree ──► upload patch by ID │ ▼ verify patch ──► mint App token @@ -313,19 +212,16 @@ The tag starts the caller's publisher. Keep PyPI trusted publishing in that top- `id-token: write` because [PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Run `prek` in a separate read-only job alongside the tagged-source build, and make publishing depend on both. Repositories with the standard `uv` -wheel and sdist build can use `build-python-distribution` in the tag publisher. The +wheel and sdist build can call `validate-python-release.yaml` in the tag publisher. The [self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem uploads. The read-only build workflow checks the tagged source and renders the Release body. The tag caller alone has a Contents write job that creates the GitHub Release. ## Validate a Python release -`validate-python-release.yaml` runs read-only source checks, notes rendering, a hash-constrained wheel and sdist build, -and distribution checks on a release tag. Before building, it requires the fully qualified tag ref to resolve to the -checked-out commit and that commit to be on `main`. The workflow uses `$/` for its own Actions, so a caller pinned to a -workflow commit runs those Actions from the same commit. - -Call the pinned workflow from the tag publisher's build job: +`validate-python-release.yaml` checks the tagged source, exports hashed build constraints in a separate job, builds and +uploads the wheel and sdist, then checks the uploaded files with Twine and check-wheel-contents. The build job runs no +release tool. It requires the tag ref to resolve to the checked-out commit on `main`. ```yaml build: @@ -333,38 +229,58 @@ build: tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA permissions: contents: read - with: - changelog: CHANGELOG.md - artifact-base-url: https://pypi.org/project/example/ ``` -`changelog` is the rendered changelog path. `artifact-base-url` ends just before the version; the workflow appends -`1.2.3/` for tag `1.2.3`. Set it to the package's actual PyPI project path. `artifact-label` changes the link text from -`PyPI release`. reStructuredText `:issue:` references link to issues; `:pr:` and `:pull:` link to pull requests. +The caller needs `release-check` for prek and a `build` group for backend constraints. `source` defaults to `.`. Use +`artifact-prefix` when one tag produces more than one distribution path; each call then writes a distinct +`python-package-distributions` artifact. A caller pinned to a workflow commit runs that commit's local Actions. -The workflow uses Python 3.15 and `pylock.toml`. The caller declares `release-check` for `prek`, `release-notes` for -`release-creator`, `pkg-meta` for Twine and check-wheel-contents, and a `build` group for backend constraints. `source` -defaults to the repository root for `uv build`. `artifact-prefix` defaults to empty; set it when a tag builds more than -one distribution path so the jobs use distinct artifact names. Set `local-tool-source: true` for release-creator's own -release; the keyless job builds its tagged wheel and installs it with locked third-party dependencies. The installer -rejects an absent group before syncing. - -The workflow uploads `python-package-distributions` with the wheel and sdist, and `release-validation-notes` with the -body rendered from the tagged source. Callers download the distribution with pinned `download-artifact` +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), -whose default fails on an artifact digest mismatch. Its notes lack GitHub contributor metadata. The tag-only GitHub -Release job must fetch generated notes with Contents write, rerender with `render-release-notes`, and create the Release -after the ecosystem upload. PyPI OIDC stays in the caller's separate `release` environment job. +which checks the artifact digest. Render GitHub notes in the caller's tag workflow after fetching GitHub's contributor +metadata; this reusable workflow does not render notes. Keep PyPI OIDC in the caller's `release` environment job. Older +callers may still pass `changelog`, `artifact-base-url`, `artifact-label`, or `local-tool-source`. These inputs are +accepted for compatibility but have no effect; remove them when updating the caller workflow. + +## Build a distribution for a pull request + +`build-python-distribution` remains available for existing keyless PR build jobs. It checks that `dist/` is empty, +exports hashed build constraints from the caller's lock, and writes a wheel and sdist to `dist/`. It does not upload or +publish them. Tagged release jobs use `validate-python-release.yaml` so lock and release tools run in a separate job +from the distribution build. + +```yaml + - uses: tox-dev/github-workflows/build-python-distribution@SHA + with: + source: . +``` + +`source` defaults to `.`. The Action has no named output; subsequent steps read `dist/`. + +## Export constraints for a build matrix + +`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. -The PyPI publisher downloads the complete validated distribution artifact. It can run alongside GitHub note rendering -once the build succeeds. Verify PyPI and its attestations before publishing the GitHub Release. +```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 +``` + +`targets` is a space-separated list of `full-version:implementation` pairs. All selected targets must resolve the same +build package versions; otherwise the Action fails instead of writing an ambiguous constraints file. ## Prepare and tag with one reusable workflow `prepare-towncrier-release.yaml` runs two jobs. `assemble` selects a tag and writes a Towncrier patch without the App -key, applies it in a fresh worktree, runs `prek`, and records its digest. `push` checks the digest and patch paths, -reads the caller's `release-auth` environment, and uses a repository-scoped App token to push the release commit and tag -together. Only a dispatch from `main` can reach the writer. +key, applies it in a fresh worktree, and runs `prek`. `push` downloads the patch by artifact ID and checks its paths and +file modes, reads the caller's `release-auth` environment, and uses a repository-scoped App token to push the release +commit and tag together. Only a dispatch from `main` can reach the writer. ```yaml jobs: @@ -416,11 +332,11 @@ committed bytes so an untrusted release patch cannot change the allowed paths or ## Prepare Towncrier release -`prepare-towncrier` reads a clean checkout, chooses the next version, links fragments to their squash PRs, runs -Towncrier and the selected formatter hook through `prek`, then writes a binary Git patch at -`$RUNNER_TEMP/release.patch`. It reads the changelog path, fragment directory, categories, and Markdown or RST format -from committed Towncrier configuration through `read-towncrier-config`. It does not commit or push. It fails if version -selection, notes rendering, formatting, or checks fail, or if preparation creates untracked files or no patch. +`prepare-towncrier` chooses the next version, links fragments to their squash PRs, runs Towncrier and the selected +formatter hook through `prek`, then writes a binary Git patch at `$RUNNER_TEMP/release.patch`. It reads the changelog +path, fragment directory, categories, and Markdown or RST format from committed Towncrier configuration through +`read-towncrier-config`. It does not commit or push. Version selection, fragment linking, the Towncrier build, and +formatting must all succeed. ```yaml - id: prepare @@ -448,15 +364,15 @@ ID. **Output:** `version` is the selected tag string. Use `steps.prepare.outputs.version` as a job output and pass it to both later Actions. **Working-tree effect:** Towncrier changes the changelog and consumes fragments; the patch records -those changes. The checkout must be clean before this Action starts. +those changes. ## Validate release patch `validate-release-patch` runs in the caller's fresh checkout of the original branch commit. It rejects a `main` branch that moved, applies the uploaded patch, checks its changed paths and file modes, then asks `release-creator` to verify that every deleted file is a configured Towncrier fragment. It renders the requested version and runs `prek`. It leaves -the patch staged for the commit Action. Run it in a keyless validation job. The App-key job repeats the branch, digest, -path, and mode checks with `checks: "false"`, without installing release tools. +the patch staged for the commit Action. Run it in a keyless validation job. The App-key job repeats the branch, path, +and mode checks with `checks: "false"`, without installing release tools. ```yaml - uses: tox-dev/github-workflows/validate-release-patch@SHA @@ -469,14 +385,11 @@ path, and mode checks with `checks: "false"`, without installing release tools. - `patch` **(required)**: Local path of the downloaded patch artifact, not an artifact name or URL. - `working-directory`: Checkout where the patch is applied; default `.`. Assembly uses a fresh linked worktree here so validation cannot pass because of files left by preparation. -- `expected-patch-sha`: SHA-256 digest of the patch from a separate keyless validation job. The Action rejects a - different downloaded patch before applying it. - `checks`: `true` (default) runs `release-creator` and `prek`; `false` only applies and checks the validated patch. **Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path, fragment directory, and categories from committed Towncrier configuration. Only the changelog and direct fragment -deletions may appear in the patch. A failed `prek` run, unexpected path or mode, or modification by `prek` fails the -keyless job. The key job accepts only the validated digest. +deletions may appear in the patch. A failed `prek` run or an unexpected path or mode fails the keyless job. ## Commit and tag release @@ -512,38 +425,6 @@ Contents write. Put `commit-and-tag` last in the writer job: its Git credential **Output:** No named output. **Remote effect:** One new commit on `main` and one tag at that commit. The App needs a branch and tag ruleset bypass. A stale branch, invalid tag, or ruleset rejection fails the push. -## Build Python distribution - -`build-python-distribution` builds a wheel and sdist with `uv`. The build job checks out the tagged source and sets up -`uv` first. Run `prek` in a separate read-only job alongside the build, and require both before publishing. The caller -verifies the tag and changelog section before this Action. - -```yaml - - uses: tox-dev/github-workflows/build-python-distribution@SHA - with: - source: . -``` - -- `source`: Source directory passed to `uv build`; default `.`. - -**Output:** No named output. **Working-tree effect:** Writes a wheel and sdist to `dist`. The checkout must be clean -before the Action starts. The Action exports hashed backend constraints from the lock's `build` group at runtime; -`uv build` rejects a missing hash. Use a separate build path for repositories whose build command differs from -`uv build --sdist --wheel`. - -## Validate Python distribution - -`validate-python-distribution` runs Twine and check-wheel-contents on the files the build job will upload. It installs -both tools from the caller's locked `pkg-meta` group. Run it after `build-python-distribution` and before -`upload-artifact`. - -```yaml - - uses: tox-dev/github-workflows/validate-python-distribution@SHA -``` - -The Action takes no inputs and checks `dist` on Python 3.15. **Output:** No named output. **Working-tree effect:** None. -An invalid sdist or wheel stops before the artifact upload. - ## Render GitHub Release notes `render-release-notes` checks the tag and its changelog section, finds the previous tag, and renders an unwrapped GitHub @@ -575,10 +456,10 @@ creates the Release; this Action only writes a local Markdown file. The Action reads `GITHUB_REPOSITORY` for PR and comparison links. A repository with separate release namespaces, such as toml-fmt, calls `release-creator notes --package` directly for its package changelog. -**Output:** `previous` is the nearest earlier version tag, or empty for the first release. **Working-tree effect:** The -Action writes only `output`. It rejects an invalid tag, a missing heading, an empty body, or a missing comparison link -when an earlier tag exists. It neither fetches generated notes nor writes a GitHub Release, so a PR job needs only read -permissions. Fetching generated notes uses GitHub's release endpoint and needs Contents write in the tag publisher. +**Output:** No named output. **Working-tree effect:** The Action writes only `output`. It rejects an invalid tag, a +missing heading, an empty body, or a missing comparison link when an earlier tag exists. It neither fetches generated +notes nor writes a GitHub Release, so a PR job needs only read permissions. Fetching generated notes uses GitHub's +release endpoint and needs Contents write in the tag publisher. ## Select the previous release tag @@ -593,9 +474,8 @@ checkout or GitHub state. tag: ${{ github.ref_name }} ``` -`tag` is required. Set `package` for a namespace such as `pyproject-fmt/2.30.3`; set `stable-only: "true"` when a stable -release must skip prerelease tags. `executable` defaults to `release-creator` on `PATH` and accepts a path to a locked -installation. The `previous` output is empty when this is the first release. Pass it as `previous_tag_name` to GitHub's +`tag` is required. `executable` defaults to `release-creator` on `PATH` and accepts a path to a locked installation. The +`previous` output is empty when this is the first matching release. Pass it as `previous_tag_name` to GitHub's generated-notes endpoint only when nonempty. ## Reconcile PyPI artifacts @@ -614,10 +494,9 @@ invalid attestation fails. The GitHub Release writer must depend on this verific dist: dist ``` -`project`, `version`, and `dist` are required. The Action installs locked `release-notes` and `release-attestations` -groups on Python 3.15 and waits up to 120 seconds for PyPI's listing to propagate. Set `local-tool-source: "true"` only -when releasing release-creator itself; it installs the wheel already built in `dist`. The Action prints no credentials; -it checks the complete local file set and each published file's attestation against the caller repository. +`project`, `version`, and `dist` are required. The Action installs the locked `release-attestations` group on Python +3.15 and waits up to 120 seconds for PyPI's listing to propagate. It checks the complete local file set, then runs +`pypi-attestations verify pypi` on each local file against the caller repository. It prints no credentials. ## Publish a GitHub Release @@ -626,7 +505,9 @@ file and the tag's commit SHA. A retry resumes a matching draft, or succeeds wit already matches. Run it in a Contents-write job that downloads the notes and optional assets; it needs no checkout or tool installation. For a repository that must upload GitHub assets before PyPI, call it with `mode: stage`, verify PyPI's exact artifact set, then call it with `mode: publish` and the same `assets-directory`. The draft stays private -until the second call succeeds. +until the second call succeeds. Enable immutable releases in the repository before using this Action. Drafts can still +be edited, so the Action checks them again before upload and publication. A matching published release whose API record +is immutable needs no write or second metadata fetch. Older mutable releases retain the recheck. ```yaml - uses: tox-dev/github-workflows/publish-github-release@SHA @@ -643,8 +524,7 @@ until the second call succeeds. the existing tag must resolve to. `title` and the file bytes must match an existing Release exactly. - `assets-directory`: Optional directory containing only the assets to attach. The Action compares each filename and SHA256 digest with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. - After publication it verifies GitHub's release attestation for each local asset. Enable immutable releases in the - repository before using this option. + After publication it verifies GitHub's release attestation for each local asset. - `prerelease`: `true` or `false`; default `false`. It must match an existing Release. - `mode`: `complete` (default) creates or resumes a draft and publishes it in one call. `stage` creates or resumes a draft and uploads assets without publishing. `publish` requires an existing draft and publishes it after checking the @@ -653,9 +533,9 @@ until the second call succeeds. **Output:** No named output. **Remote effect:** Depending on `mode`, the Action creates or resumes a draft, uploads missing assets, and publishes only after the complete asset set matches. A matching published Release causes no write. A different body, title, tag commit, prerelease flag, or asset set fails without changing the existing Release. A failed -upload leaves the draft for a later retry. An asset in GitHub's incomplete `starter` state requires manual cleanup -before retrying. The Action retries reading the release by ID for up to ten seconds after creating the draft, uploading -assets, or publishing; a mismatch still fails when that window ends. +upload leaves the draft for a later retry. The Action removes incomplete `starter` assets before retrying. It retries +reading the release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that +window ends. ## Reconcile a post-release pull request diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index c2ec053..a1a9492 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -1,5 +1,5 @@ name: Build Python distribution -description: Build a wheel and sdist from source checked in a separate job +description: Build a wheel and sdist for a keyless PR check inputs: source: description: Distribution source directory @@ -7,13 +7,6 @@ inputs: runs: using: composite steps: - - name: Require clean tagged source - shell: bash - run: | - if [[ -n $(git status --porcelain) ]]; then - echo '::error::tagged source is dirty' >&2 - exit 1 - fi - name: Require empty output directory shell: bash run: | @@ -36,7 +29,6 @@ runs: set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE build_python=$(uv python find "$RUNNER_TEMP/build-python") - test -n "$build_python" export SOURCE_DATE_EPOCH SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) uv build --python "$build_python" \ diff --git a/changelog.d/+check-committed-locks.bugfix.md b/changelog.d/+check-committed-locks.bugfix.md new file mode 100644 index 0000000..6bdd792 --- /dev/null +++ b/changelog.d/+check-committed-locks.bugfix.md @@ -0,0 +1,2 @@ +Reject future-dated locks and release dependencies that were uploaded too recently. Keep deliberate package holds during +weekly lock refreshes. diff --git a/changelog.d/+isolate-distribution-build.change.md b/changelog.d/+isolate-distribution-build.change.md new file mode 100644 index 0000000..23303e3 --- /dev/null +++ b/changelog.d/+isolate-distribution-build.change.md @@ -0,0 +1 @@ +Run release-tool checks and build-constraint export outside the job that builds distribution files. diff --git a/changelog.d/+namespaced-previous-tag.bugfix.md b/changelog.d/+namespaced-previous-tag.bugfix.md new file mode 100644 index 0000000..6db69b2 --- /dev/null +++ b/changelog.d/+namespaced-previous-tag.bugfix.md @@ -0,0 +1 @@ +Select the previous release tag from the same package when a repository uses package-prefixed tags. diff --git a/changelog.d/+remove-obsolete-actions.breaking.md b/changelog.d/+remove-obsolete-actions.breaking.md new file mode 100644 index 0000000..57edd78 --- /dev/null +++ b/changelog.d/+remove-obsolete-actions.breaking.md @@ -0,0 +1,4 @@ +Remove the `install-nab`, `label-bot-news`, `refresh-hook-pins`, `resolve-pr-from-run`, and +`validate-python-distribution` Actions. `select-previous-tag` no longer accepts `package`, `executable`, or +`stable-only`; `validate-release-patch` no longer accepts `expected-patch-sha`; and `render-release-notes` no longer +emits `previous`. The release validation workflow no longer uploads the `release-validation-notes` artifact. diff --git a/changelog.d/+separate-news-check.change.md b/changelog.d/+separate-news-check.change.md new file mode 100644 index 0000000..d678898 --- /dev/null +++ b/changelog.d/+separate-news-check.change.md @@ -0,0 +1 @@ +Run changelog checks in a separate workflow so label changes do not restart the test matrix. diff --git a/changelog.d/+verify-pypi-release.bugfix.md b/changelog.d/+verify-pypi-release.bugfix.md new file mode 100644 index 0000000..71981d1 --- /dev/null +++ b/changelog.d/+verify-pypi-release.bugfix.md @@ -0,0 +1,2 @@ +Compare the complete local distribution set with PyPI, reject yanked or changed files, and verify each published file's +attestation. diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index bb5d7e3..0f698f1 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -18,22 +18,6 @@ runs: REPOSITORY=$GITHUB_REPOSITORY PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH") HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH") - case "$REPOSITORY" in - */*) ;; - *) exit 1 ;; - esac - for part in "${REPOSITORY%%/*}" "${REPOSITORY#*/}"; do - case "$part" in - '' | *[!A-Za-z0-9_.-]*) exit 1 ;; - esac - done - case "$PR_NUMBER" in - '' | 0* | *[!0-9]*) exit 1 ;; - esac - test "${#HEAD_SHA}" -eq 40 - case "$HEAD_SHA" in - *[!0-9a-f]*) exit 1 ;; - esac case "$MINIMUM_DAYS" in '' | 0* | *[!0-9]*) exit 1 ;; esac @@ -41,10 +25,6 @@ runs: echo '::error::hook cooldown must be at least seven days' >&2 exit 1 fi - test "$GITHUB_EVENT_NAME" = pull_request - jq -e --argjson number "$PR_NUMBER" --arg head "$HEAD_SHA" ' - .number == $number and .pull_request.head.sha == $head - ' "$GITHUB_EVENT_PATH" >/dev/null gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" >"$RUNNER_TEMP/hook-update-pr.json" jq -e --arg repository "$REPOSITORY" --arg head "$HEAD_SHA" ' .state == "open" and .base.repo.full_name == $repository and diff --git a/commit-and-tag/action.yml b/commit-and-tag/action.yml index 2c005ca..8b15129 100644 --- a/commit-and-tag/action.yml +++ b/commit-and-tag/action.yml @@ -22,7 +22,6 @@ runs: - name: Check staged release shell: bash env: - VERSION: ${{ inputs.version }} ANNOTATED: ${{ inputs.annotated }} run: | set -euo pipefail @@ -34,10 +33,6 @@ runs: echo '::error::annotated must be true or false' >&2 exit 1 fi - if ! git check-ref-format "refs/tags/$VERSION"; then - echo "::error::invalid release tag: $VERSION" >&2 - exit 1 - fi - name: Set App commit identity shell: bash env: diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index 9778354..6d908a3 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -13,39 +13,14 @@ inputs: outputs: path: description: Path to the generated constraints for this lock selection - value: ${{ steps.constraints.outputs.path }} + value: ${{ steps.tools.outputs.build-constraints }} runs: using: composite steps: - - uses: $/install-nab - - id: constraints - name: Export locked build requirements - shell: bash - env: - BUILD_LOCK: ${{ inputs.lock }} - TARGET_PLATFORM: ${{ inputs.target-platform }} - TARGETS: ${{ inputs.targets }} - run: | - set -euo pipefail - args=() - if [[ "$BUILD_LOCK" == pylock.toml ]]; then - args+=(--shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml") - locks=("$GITHUB_ACTION_PATH/../pylock.toml" pylock.toml) - else - locks=("$BUILD_LOCK") - fi - if [[ -n "$TARGET_PLATFORM" || -n "$TARGETS" ]]; then - args+=(--target-platform "$TARGET_PLATFORM" --targets "$TARGETS") - fi - digest=$( - sha256sum "${locks[@]}" - printf '%s\n' "$TARGET_PLATFORM" "$TARGETS" "${args[@]}" - ) - digest=$(sha256sum <<<"$digest") - output="$RUNNER_TEMP/build-constraints-${digest%% *}.txt" - if [[ ! -f "$output" ]]; then - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ - "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \ - "$output" "${locks[@]}" - fi - echo "path=$output" >>"$GITHUB_OUTPUT" + - id: tools + uses: $/install-locked-tools + with: + lock-source: shared + constraints-lock: ${{ inputs.lock }} + target-platform: ${{ inputs.target-platform }} + targets: ${{ inputs.targets }} diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 63def2f..b05a4e1 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -3,20 +3,48 @@ description: Install selected pylock groups with hashed build dependencies inputs: groups: description: Space-separated dependency groups to install - required: true + default: "" venv: description: Virtual environment directory - required: true + default: "" lock-source: description: Install groups from the caller or the shared Action lock default: caller local-tool-source: description: The caller builds release-creator from its own checkout after installing locked third-party tools default: "false" + constraints-lock: + description: Lock used to export build constraints; defaults to the selected install lock + default: "" + target-platform: + description: Nab platform ID for the exported build constraints + default: "" + targets: + description: Space-separated full-version:implementation pairs for the exported build constraints + default: "" +outputs: + build-constraints: + description: Hashed build requirements selected from the lock + value: ${{ steps.constraints.outputs.path }} runs: using: composite steps: - - uses: $/install-nab + - name: Create lock-tool environment + shell: bash + run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" + - name: Install lock reader + shell: bash + run: | + set -euo pipefail + unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE + digest=$(sha256sum "$GITHUB_ACTION_PATH/../pylock.toml") + if [[ -f "$RUNNER_TEMP/lock-tools/.lock-digest" && + $(cat "$RUNNER_TEMP/lock-tools/.lock-digest") == "${digest%% *}" ]]; then + exit 0 + fi + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ + --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" + printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-tools/.lock-digest" - id: selection name: Select locked groups and build constraints shell: bash @@ -27,7 +55,6 @@ runs: LOCAL_TOOL_SOURCE: ${{ inputs.local-tool-source }} run: | set -euo pipefail - uv venv --allow-existing --python 3.15 "$VENV" case "$LOCK_SOURCE" in caller) lock=pylock.toml @@ -47,22 +74,62 @@ runs: read -ra selected <<<"$line" groups+=("${selected[@]}") done <<<"$DEPENDENCY_GROUPS" - if ((${#groups[@]} == 0)); then - echo '::error::at least one dependency group is required' >&2 - exit 1 + if ((${#groups[@]})); then + [[ -n "$VENV" ]] || { + echo '::error::venv is required when installing groups' >&2 + exit 1 + } + uv venv --allow-existing --python 3.15 "$VENV" + source_check=() + if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" \ + "${lock_check[@]}" "${source_check[@]}" + printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" + else + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" --build-constraints fi - source_check=() - if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" \ - "${lock_check[@]}" "${source_check[@]}" - printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" - id: constraints - uses: $/export-build-constraints - with: - lock: ${{ steps.selection.outputs.lock }} + name: Export locked build requirements + shell: bash + env: + BUILD_LOCK: ${{ steps.selection.outputs.lock }} + CONSTRAINTS_LOCK: ${{ inputs.constraints-lock }} + TARGET_PLATFORM: ${{ inputs.target-platform }} + TARGETS: ${{ inputs.targets }} + run: | + set -euo pipefail + if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$BUILD_LOCK" ]]; then + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints + fi + if [[ -n "$CONSTRAINTS_LOCK" ]]; then BUILD_LOCK=$CONSTRAINTS_LOCK; fi + args=() + if [[ "$BUILD_LOCK" == pylock.toml ]]; then + args+=(--shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml") + locks=("$GITHUB_ACTION_PATH/../pylock.toml" pylock.toml) + else + locks=("$BUILD_LOCK") + fi + if [[ -n "$TARGET_PLATFORM" || -n "$TARGETS" ]]; then + args+=(--target-platform "$TARGET_PLATFORM" --targets "$TARGETS") + fi + digest=$( + sha256sum "${locks[@]}" + printf '%s\n' "$TARGET_PLATFORM" "$TARGETS" "${args[@]}" + ) + digest=$(sha256sum <<<"$digest") + output="$RUNNER_TEMP/build-constraints-${digest%% *}.txt" + if [[ ! -f "$output" ]]; then + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \ + "$output" "${locks[@]}" + fi + echo "path=$output" >>"$GITHUB_OUTPUT" - name: Install selected groups + if: inputs.groups != '' shell: bash env: VENV: ${{ inputs.venv }} @@ -78,3 +145,14 @@ runs: uv pip sync --preview-features pylock --preview-features build-dependency-hashes --python "$VENV" \ "${groups[@]}" --build-constraints "$BUILD_CONSTRAINTS" \ --require-build-hashes --require-hashes "$LOCK" + - name: Expose installed commands + if: inputs.groups != '' + shell: bash + env: + VENV: ${{ inputs.venv }} + run: | + if [[ -d "$VENV/Scripts" ]]; then + (cd "$VENV/Scripts" && pwd) >>"$GITHUB_PATH" + else + (cd "$VENV/bin" && pwd) >>"$GITHUB_PATH" + fi diff --git a/install-nab/action.yml b/install-nab/action.yml deleted file mode 100644 index eea0a4a..0000000 --- a/install-nab/action.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Install locked Nab -description: Install the lock reader from the shared pylock -runs: - using: composite - steps: - - name: Create lock-tool environment - shell: bash - run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" - - name: Install lock reader - shell: bash - run: | - set -euo pipefail - unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - digest=$(sha256sum "$GITHUB_ACTION_PATH/../pylock.toml") - if [[ -f "$RUNNER_TEMP/lock-tools/.lock-digest" && - $(cat "$RUNNER_TEMP/lock-tools/.lock-digest") == "${digest%% *}" ]]; then - exit 0 - fi - uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ - --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" - printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-tools/.lock-digest" diff --git a/label-bot-news/action.yml b/label-bot-news/action.yml deleted file mode 100644 index d07adf2..0000000 --- a/label-bot-news/action.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: Label bot pull request news -description: Keep skip news on bot PRs without a changelog fragment -inputs: - pull-request: - description: Verified pull request number from a workflow-run resolver, or current PR event when empty - default: "" - bot-author: - description: Whether the verified pull request author is a bot, or current PR author when empty - default: "" - fragments: - description: Comma-separated directories containing changelog fragments - default: changelog.d - extension: - description: Fragment extension without the dot, either md or rst - default: md - fragment-style: - description: Filename convention, towncrier or changeset - default: towncrier -runs: - using: composite - steps: - - name: Validate bot fragment format - if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' - shell: bash - env: - EXTENSION: ${{ inputs.extension }} - FRAGMENT_STYLE: ${{ inputs.fragment-style }} - run: | - if [[ "$EXTENSION" != md && "$EXTENSION" != rst ]]; then - echo '::error::fragment extension must be md or rst' >&2 - exit 1 - fi - if [[ "$FRAGMENT_STYLE" != towncrier && "$FRAGMENT_STYLE" != changeset ]]; then - echo '::error::fragment style must be towncrier or changeset' >&2 - exit 1 - fi - if [[ "$FRAGMENT_STYLE" == changeset && "$EXTENSION" != md ]]; then - echo '::error::changeset fragments must use md' >&2 - exit 1 - fi - - name: Read bot pull request files - if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }} - run: | - gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" \ - --jq '.[] | select(.status != "removed") | .filename' >"$RUNNER_TEMP/bot-pr-files" - - name: Find bot news fragments - if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' - shell: bash - env: - FRAGMENTS: ${{ inputs.fragments }} - EXTENSION: ${{ inputs.extension }} - FRAGMENT_STYLE: ${{ inputs.fragment-style }} - LC_ALL: C - run: | - has_fragment=false - mapfile -t files <"$RUNNER_TEMP/bot-pr-files" - read -ra directories <<<"${FRAGMENTS//,/ }" - for path in "${files[@]}"; do - for directory in "${directories[@]}"; do - if [[ "$FRAGMENT_STYLE" == changeset ]]; then - case "$path" in - "$directory"/*.md) - fragment=${path#"$directory"/} - if [[ "$fragment" != */* ]]; then has_fragment=true; fi - ;; - esac - else - case "$path" in - "$directory"/*.*."$EXTENSION") - fragment=${path#"$directory"/} - if [[ "$fragment" == */* ]]; then continue; fi - stem=${fragment%."$EXTENSION"} - counter=${stem##*.} - if [[ "$stem" == *.*.* && -n "$counter" && "$counter" != *[!0-9]* ]]; then - stem=${stem%.*} - fi - if [[ "$stem" != *.* ]]; then continue; fi - identifier=${stem%.*} - if [[ "$identifier" == +?* ]] || [[ -n "$identifier" && "$identifier" != *[!0-9]* ]]; then - has_fragment=true - fi - ;; - esac - fi - done - done - echo "$has_fragment" >"$RUNNER_TEMP/bot-has-fragment" - - name: Read bot pull request labels - if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }} - run: gh api --paginate "repos/$REPOSITORY/issues/$PR_NUMBER/labels" --jq '.[].name' >"$RUNNER_TEMP/bot-pr-labels" - - name: Set skip news for a bot PR - if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }} - run: | - read -r has_fragment <"$RUNNER_TEMP/bot-has-fragment" - has_label=false - if grep -Fxq 'skip news' "$RUNNER_TEMP/bot-pr-labels"; then - has_label=true - fi - if [[ "$has_fragment" == false && "$has_label" == false ]]; then - gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --add-label 'skip news' >/dev/null - elif [[ "$has_fragment" == true && "$has_label" == true ]]; then - gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --remove-label 'skip news' >/dev/null - fi diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index b389f67..34d5100 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -35,14 +35,6 @@ runs: exit 1 ;; esac - - name: Require a clean checkout - if: inputs.phase != 'finalize' - shell: bash - run: | - if [[ -n $(git status --porcelain) ]]; then - echo '::error::release preparation requires a clean checkout' >&2 - exit 1 - fi - id: config uses: $/read-towncrier-config - id: version @@ -53,6 +45,7 @@ runs: BUMP: ${{ inputs.bump }} TAG_PREFIX: ${{ inputs.tag-prefix }} VERSION: ${{ inputs.version }} + EXPLICIT_VERSION: ${{ inputs.version }} FRAGMENTS: ${{ steps.config.outputs.fragments }} run: | set -euo pipefail @@ -65,7 +58,9 @@ runs: else git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null fi - git tag --list | release-creator tags "$VERSION" --require-newer + if [[ -n "$EXPLICIT_VERSION" ]]; then + git tag --list | release-creator tags "$VERSION" --require-newer + fi printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" - name: Link fragments and authors if: inputs.phase != 'finalize' @@ -112,16 +107,6 @@ runs: if ! prek run "$formatter" --files "$CHANGELOG"; then prek run "$formatter" --files "$CHANGELOG" fi - - name: Check rendered release notes - if: inputs.phase != 'build' - shell: bash - env: - VERSION: ${{ inputs.version || steps.version.outputs.version }} - CHANGELOG: ${{ steps.config.outputs.changelog }} - REPOSITORY: ${{ github.repository }} - run: | - set -euo pipefail - release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$REPOSITORY" >/dev/null - name: Check generated files if: inputs.phase != 'build' shell: bash @@ -131,18 +116,9 @@ runs: if [[ -f "$CHANGELOG" ]] && ! git ls-files --error-unmatch "$CHANGELOG" >/dev/null 2>&1; then git add -N -- "$CHANGELOG" fi - if [[ -n $(git ls-files --others --exclude-standard) ]]; then - echo '::error::release preparation created untracked files' >&2 - exit 1 - fi - name: Write release patch if: inputs.phase != 'build' shell: bash run: | set -euo pipefail - git diff --check git diff HEAD --binary >"$RUNNER_TEMP/release.patch" - if [[ ! -s "$RUNNER_TEMP/release.patch" ]]; then - echo '::error::release preparation produced no patch' >&2 - exit 1 - fi diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index f313aac..bb4859e 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -71,6 +71,7 @@ runs: "$RUNNER_TEMP/release-assets.tsv" >"$RUNNER_TEMP/release-assets.json" fi - name: Inspect any existing release + id: inspect shell: bash env: GH_REPO: ${{ github.repository }} @@ -86,23 +87,14 @@ runs: if jq -e 'length == 1' "$RUNNER_TEMP/release-existing.json" >/dev/null; then jq -er '.[0].id' "$RUNNER_TEMP/release-existing.json" >"$RUNNER_TEMP/release-id" fi - jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ - --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' - length <= 1 and all(.[]; - . as $release | - .name == $title and .body == $body and .prerelease == $prerelease and - ((.assets | map(.name) | unique | length) == (.assets | length)) and - all(.assets[]; - .name as $name | .digest as $digest | - any($assets[0][]; .name == $name) and - (.state != "uploaded" and $release.draft or - any($assets[0][]; .name == $name and .digest == $digest))) and - (.draft or (.assets | length) == ($assets[0] | length)) - ) - ' "$RUNNER_TEMP/release-existing.json" + jq -e --arg phase existing --arg title "$TITLE" --rawfile body "$NOTES_FILE" \ + --argjson prerelease "$PRERELEASE" --slurpfile assets "$RUNNER_TEMP/release-assets.json" \ + -f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-existing.json" if [[ "$MODE" == publish ]]; then jq -e 'length == 1' "$RUNNER_TEMP/release-existing.json" >/dev/null fi + printf 'immutable=%s\n' "$(jq -r 'any(.[]; .draft == false and .immutable == true)' \ + "$RUNNER_TEMP/release-existing.json")" >>"$GITHUB_OUTPUT" - name: Create a missing draft shell: bash env: @@ -125,6 +117,7 @@ runs: jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id" fi - name: Upload missing draft assets + if: steps.inspect.outputs.immutable != 'true' shell: bash env: GH_REPO: ${{ github.repository }} @@ -136,6 +129,8 @@ runs: MODE: ${{ inputs.mode }} run: | set -euo pipefail + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/retry.sh" if [[ "$MODE" != publish && -n "$ASSETS_DIRECTORY" ]]; then shopt -s nullglob release_id=$(<"$RUNNER_TEMP/release-id") @@ -144,31 +139,16 @@ runs: gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" >/dev/null done < <(jq -r '.[0].assets[] | select(.state != "uploaded") | .id' "$RUNNER_TEMP/release-existing.json") fi - for attempt in {1..6}; do - if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-current.json"; then - if ((attempt == 6)); then - echo '::error::draft release could not be read' >&2 - exit 1 - fi - sleep 2 - continue - fi - if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ - --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' - length == 1 and .[0].name == $title and .[0].body == $body and - .[0].prerelease == $prerelease and - all(.[0].assets[]; .state == "uploaded" and - (.name as $name | .digest as $digest | - any($assets[0][]; .name == $name and .digest == $digest))) - ' "$RUNNER_TEMP/release-current.json" >/dev/null; then - break - fi - if ((attempt == 6)); then - echo '::error::draft release did not become visible' >&2 - exit 1 - fi - sleep 2 - done + read_draft() { + gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-current.json" && + jq -e --arg phase draft --arg title "$TITLE" --rawfile body "$NOTES_FILE" \ + --argjson prerelease "$PRERELEASE" --slurpfile assets "$RUNNER_TEMP/release-assets.json" \ + -f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-current.json" >/dev/null + } + if ! retry read_draft; then + echo '::error::draft release did not become visible' >&2 + exit 1 + fi if jq -e '.[0].draft' "$RUNNER_TEMP/release-current.json" >/dev/null; then for asset in "$ASSETS_DIRECTORY"/*; do name=${asset##*/} @@ -208,6 +188,7 @@ runs: fi fi - name: Verify the complete asset set + if: steps.inspect.outputs.immutable != 'true' shell: bash env: GH_REPO: ${{ github.repository }} @@ -217,30 +198,21 @@ runs: PRERELEASE: ${{ inputs.prerelease }} run: | set -euo pipefail + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/retry.sh" release_id=$(<"$RUNNER_TEMP/release-id") - for attempt in {1..6}; do - if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-complete.json"; then - if ((attempt == 6)); then - echo '::error::release could not be read after upload' >&2 - exit 1 - fi - sleep 2 - continue - fi - if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ - --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' - length == 1 and .[0].name == $title and .[0].body == $body and - .[0].prerelease == $prerelease and - ([.[0].assets[] | select(.state == "uploaded") | {name, digest}] | sort_by(.name)) == - ($assets[0] | sort_by(.name)) - ' "$RUNNER_TEMP/release-complete.json" >/dev/null; then - exit 0 - fi - if ((attempt < 6)); then sleep 2; fi - done - echo '::error::release metadata or assets did not match after publication' >&2 - exit 1 + read_complete() { + gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-complete.json" && + jq -e --arg phase complete --arg title "$TITLE" --rawfile body "$NOTES_FILE" \ + --argjson prerelease "$PRERELEASE" --slurpfile assets "$RUNNER_TEMP/release-assets.json" \ + -f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-complete.json" >/dev/null + } + if ! retry read_complete; then + echo '::error::release metadata or assets did not match after publication' >&2 + exit 1 + fi - name: Publish the draft + if: steps.inspect.outputs.immutable != 'true' shell: bash env: GH_REPO: ${{ github.repository }} @@ -251,41 +223,6 @@ runs: release_id=$(<"$RUNNER_TEMP/release-id") gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false >/dev/null fi - - name: Verify the final release state - shell: bash - env: - GH_REPO: ${{ github.repository }} - TAG: ${{ inputs.tag }} - TITLE: ${{ inputs.title }} - NOTES_FILE: ${{ inputs.notes-file }} - PRERELEASE: ${{ inputs.prerelease }} - MODE: ${{ inputs.mode }} - run: | - set -euo pipefail - release_id=$(<"$RUNNER_TEMP/release-id") - for attempt in {1..6}; do - if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-published.json"; then - if ((attempt == 6)); then - echo '::error::published release could not be read' >&2 - exit 1 - fi - sleep 2 - continue - fi - if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ - --arg mode "$MODE" \ - --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' - length == 1 and ($mode == "stage" or .[0].draft == false) and .[0].name == $title and - .[0].body == $body and .[0].prerelease == $prerelease and - ([.[0].assets[] | select(.state == "uploaded") | {name, digest}] | sort_by(.name)) == - ($assets[0] | sort_by(.name)) - ' "$RUNNER_TEMP/release-published.json" >/dev/null; then - exit 0 - fi - if ((attempt < 6)); then sleep 2; fi - done - echo '::error::final release state did not match after publication' >&2 - exit 1 - name: Verify published asset attestations if: inputs.mode != 'stage' && inputs.assets-directory != '' shell: bash @@ -295,16 +232,12 @@ runs: ASSETS_DIRECTORY: ${{ inputs.assets-directory }} run: | set -euo pipefail + # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. + source "$GITHUB_ACTION_PATH/retry.sh" shopt -s nullglob for asset in "$ASSETS_DIRECTORY"/*; do - for attempt in {1..6}; do - if gh release verify-asset "$TAG" "$asset" --repo "$GH_REPO"; then - break - fi - if ((attempt == 6)); then - echo "::error::release asset attestation could not be verified: ${asset##*/}" >&2 - exit 1 - fi - sleep 2 - done + if ! retry gh release verify-asset "$TAG" "$asset" --repo "$GH_REPO"; then + echo "::error::release asset attestation could not be verified: ${asset##*/}" >&2 + exit 1 + fi done diff --git a/publish-github-release/match.jq b/publish-github-release/match.jq new file mode 100644 index 0000000..f71446f --- /dev/null +++ b/publish-github-release/match.jq @@ -0,0 +1,29 @@ +def metadata: + .name == $title and .body == $body and .prerelease == $prerelease; + +def expected_asset: + .name as $name | .digest as $digest | + any($assets[0][]; .name == $name and .digest == $digest); + +def allowed_asset($draft): + .name as $name | + any($assets[0][]; .name == $name) and + (if .state == "uploaded" then expected_asset else $draft end); + +if $phase == "existing" then + length <= 1 and all(.[]; + .draft as $draft | + metadata and + ([.assets[].name] | unique | length) == (.assets | length) and + all(.assets[]; allowed_asset($draft)) and + (.draft or (.assets | length) == ($assets[0] | length)) + ) +elif $phase == "draft" then + length == 1 and .[0].name == $title and .[0].body == $body and + .[0].prerelease == $prerelease and all(.[0].assets[]; .state == "uploaded" and expected_asset) +else + length == 1 and .[0].name == $title and .[0].body == $body and + .[0].prerelease == $prerelease and + ([.[0].assets[] | select(.state == "uploaded") | {name, digest}] | sort_by(.name)) == + ($assets[0] | sort_by(.name)) +end diff --git a/publish-github-release/retry.sh b/publish-github-release/retry.sh new file mode 100644 index 0000000..8cf2ff2 --- /dev/null +++ b/publish-github-release/retry.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +retry() { + local attempt + for attempt in {1..6}; do + if "$@"; then return 0; fi + if ((attempt == 6)); then return 1; fi + sleep 2 + done +} diff --git a/pylock.toml b/pylock.toml index 7fcbd24..41e133a 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1703,7 +1703,7 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "0.6.0" +version = "0.6.4" marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.15" dependencies = [ @@ -1717,22 +1717,22 @@ dependencies = [ index = "https://pypi.org/simple/" [packages.sdist] -name = "release_creator-0.6.0.tar.gz" -upload-time = 2026-10-23 02:44:39.588714+00:00 -url = "https://files.pythonhosted.org/packages/17/11/c0126b406a17370db72d8a08afa1a5823204e69c44938de796cc98df0d02/release_creator-0.6.0.tar.gz" -size = 135957 +name = "release_creator-0.6.4.tar.gz" +upload-time = 2026-10-23 16:52:05.496598+00:00 +url = "https://files.pythonhosted.org/packages/97/91/9e1cac72d6bac1b0c5bce41a9231340c9d8640063102461c0fd33817ca50/release_creator-0.6.4.tar.gz" +size = 133464 [packages.sdist.hashes] -sha256 = "58afd3cd0fc0b0e47ded23a735a1ba9189a7b0adad410628c90c96cf74457ed6" +sha256 = "801c15dfc79e862298037fe1f02b52f3b2c3035acc02bebff9fa916c3b3fdce0" [[packages.wheels]] -name = "release_creator-0.6.0-py3-none-any.whl" -upload-time = 2026-10-23 02:44:37.874747+00:00 -url = "https://files.pythonhosted.org/packages/ef/91/ad1ce7d5c65892866893879fb0f407898f2d5c808bbd266ca0a82513a205/release_creator-0.6.0-py3-none-any.whl" -size = 49478 +name = "release_creator-0.6.4-py3-none-any.whl" +upload-time = 2026-10-23 16:52:04.227467+00:00 +url = "https://files.pythonhosted.org/packages/fb/86/fb3fb2b9b5cf3f6ea6d67b711c9d7915a2cec5ed1542bcf6bde51e85cd77/release_creator-0.6.4-py3-none-any.whl" +size = 46339 [packages.wheels.hashes] -sha256 = "4b9cd6187c87fcb7bf3aca5f58c67c90703e58875643af633661a0448756d45f" +sha256 = "cf41e5d6d50b489fbc666a82790463f810caff4f91f18c9da02ec72dc96223a0" [[packages]] name = "requests" @@ -2162,28 +2162,28 @@ sha256 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" [[packages]] name = "tomlkit" -version = "0.15.1" +version = "0.15.2" marker = "\"lock-tools\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] -name = "tomlkit-0.15.1.tar.gz" -upload-time = 2026-07-17 01:48:04.562015+00:00 -url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz" -size = 180129 +name = "tomlkit-0.15.2.tar.gz" +upload-time = 2026-10-16 04:07:08.166827+00:00 +url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz" +size = 190522 [packages.sdist.hashes] -sha256 = "e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97" +sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" [[packages.wheels]] -name = "tomlkit-0.15.1-py3-none-any.whl" -upload-time = 2026-07-17 01:48:05.728578+00:00 -url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl" -size = 49449 +name = "tomlkit-0.15.2-py3-none-any.whl" +upload-time = 2026-10-16 04:07:09.216014+00:00 +url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl" +size = 52225 [packages.wheels.hashes] -sha256 = "177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304" +sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" [[packages]] name = "towncrier" @@ -2483,24 +2483,25 @@ sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" [tool.nab] nab-version = "0.0.22" -created-at = 2026-10-23 02:46:01.225053+00:00 +created-at = 2026-10-23 16:58:48.863323+00:00 command-line = [ "nab", "lock", "--groups", "build", "lock-tools", - "release-attestations", - "release-check", - "release-hook-check", - "release-hooks", - "release-news", "release-notes", + "release-news", "release-prepare", - "release-shell", "release-validate", + "release-check", + "release-attestations", + "release-shell", + "release-hooks", + "release-hook-check", "--output", "pylock.toml", + "--upgrade", ] input-path = "pyproject.toml" mode = "universal" diff --git a/pyproject.toml b/pyproject.toml index dbaa5dd..317fbc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,14 +10,14 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] -release-notes = [ "release-creator>=0.6.0" ] -release-news = [ "release-creator>=0.6.0", "towncrier>=26.9" ] -release-prepare = [ "release-creator>=0.6.0", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=0.6.0", "prek>=0.2" ] +release-notes = [ "release-creator>=0.6.4" ] +release-news = [ "release-creator>=0.6.4", "towncrier>=26.9" ] +release-prepare = [ "release-creator>=0.6.4", "towncrier>=26.9", "prek>=0.2" ] +release-validate = [ "release-creator>=0.6.4", "prek>=0.2" ] release-check = [ "prek>=0.2" ] release-attestations = [ "pypi-attestations>=0.0.30" ] release-shell = [ "prek>=0.2", "yq>=4.4.5" ] -release-hooks = [ "release-creator>=0.6.0", "prek>=0.2" ] +release-hooks = [ "release-creator>=0.6.4", "prek>=0.2" ] release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ "certifi", @@ -25,6 +25,7 @@ test = [ "hatchling>=1.28", "pytest>=9", "pytest-httpserver", + "pytest-mock>=3.17", "pytest-xdist>=3", "pyyaml>=6", "tomlkit>=0.13", @@ -65,7 +66,7 @@ uploaded-prior-to = "2026-10-18T05:45:09Z" uploaded-prior-to = "2026-10-18T05:45:09Z" [tool.nab.packages.release-creator] -uploaded-prior-to = "2026-10-23T02:44:40Z" +uploaded-prior-to = "2026-10-23T16:52:06Z" [tool.ruff] line-length = 120 diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index 62404e8..b42272e 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -10,9 +10,6 @@ inputs: dist: description: Directory containing the complete expected distribution set required: true - local-tool-source: - description: Install release-creator from the tagged checkout - default: "false" runs: using: composite steps: @@ -24,18 +21,12 @@ runs: DIST: ${{ inputs.dist }} run: | [[ -n "$PROJECT" && -n "$VERSION" && -d "$DIST" ]] || exit 1 - - name: Install locked release tool + - name: Install locked provenance verifier uses: $/install-locked-tools with: - groups: release-notes${{ inputs.local-tool-source == 'true' && ' project-runtime' || '' }} - venv: ${{ runner.temp }}/pypi-reconcile - local-tool-source: ${{ inputs.local-tool-source }} - - name: Install tagged release tool - if: inputs.local-tool-source == 'true' - shell: bash - env: - DIST: ${{ inputs.dist }} - run: uv pip install --python "$RUNNER_TEMP/pypi-reconcile" --no-deps "$DIST"/release_creator-*.whl + groups: release-attestations + venv: ${{ runner.temp }}/pypi-attestations + lock-source: shared - name: Compare published files shell: bash env: @@ -43,21 +34,16 @@ runs: VERSION: ${{ inputs.version }} DIST: ${{ inputs.dist }} run: | - uv run --no-project --python "$RUNNER_TEMP/pypi-reconcile" release-creator artifacts --project "$PROJECT" \ - --version "$VERSION" --dist "$DIST" --wait-seconds 120 >"$RUNNER_TEMP/pypi-file-urls" - - name: Install locked provenance verifier - uses: $/install-locked-tools - with: - groups: release-attestations - venv: ${{ runner.temp }}/pypi-attestations - lock-source: shared + uv run --no-project --python "$RUNNER_TEMP/pypi-attestations" python \ + "$GITHUB_ACTION_PATH/../tools/verify_pypi_files.py" "$PROJECT" "$VERSION" "$DIST" \ + >"$RUNNER_TEMP/pypi-local-files" - name: Verify trusted-publisher attestations shell: bash env: REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - while IFS= read -r url; do + while IFS= read -r path; do uv run --no-project --python "$RUNNER_TEMP/pypi-attestations" pypi-attestations verify pypi \ - --repository "https://github.com/$REPOSITORY" "$url" - done <"$RUNNER_TEMP/pypi-file-urls" + --repository "https://github.com/$REPOSITORY" "$path" + done <"$RUNNER_TEMP/pypi-local-files" diff --git a/refresh-hook-pins/action.yml b/refresh-hook-pins/action.yml deleted file mode 100644 index e19e5db..0000000 --- a/refresh-hook-pins/action.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Refresh pre-commit hook pins -description: Update cooled frozen hook revisions and exact dependencies without GitHub writes -outputs: - changed: - description: Whether the hook configuration differs from the checkout - value: ${{ steps.patch.outputs.changed }} -runs: - using: composite - steps: - - uses: $/install-locked-tools - with: - groups: release-hooks - lock-source: shared - venv: ${{ runner.temp }}/hook-tools - - name: Refresh frozen hook revisions - shell: bash - run: | - uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update --config .pre-commit-config.yaml \ - --freeze --cooldown-days 7 --no-progress - - name: Refresh exact hook dependencies - shell: bash - run: | - uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks \ - --exclude-newer '7 days' --python-version 3.15 - - name: Run repository checks - shell: bash - run: >- - uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek run --config .pre-commit-config.yaml --all-files - - name: Check updated paths - shell: bash - run: | - set -euo pipefail - while IFS= read -r entry; do - path=${entry:3} - if [[ "$path" != .pre-commit-config.yaml ]]; then - echo "::error::hook update changed unexpected path: $path" >&2 - exit 1 - fi - done < <(git status --porcelain=v1 --untracked-files=all) - - id: patch - shell: bash - run: | - set -euo pipefail - if git diff --quiet -- .pre-commit-config.yaml; then - echo 'changed=false' >>"$GITHUB_OUTPUT" - exit 0 - fi - echo 'changed=true' >>"$GITHUB_OUTPUT" - git diff --binary -- .pre-commit-config.yaml >"$RUNNER_TEMP/hooks.patch" - test -s "$RUNNER_TEMP/hooks.patch" diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 3b8480c..910ad15 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -32,7 +32,9 @@ outputs: runs: using: composite steps: - - uses: $/install-nab + - uses: $/install-locked-tools + with: + lock-source: shared - name: Check recent package cutoffs shell: bash run: uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/check_recent_cutoffs.py" pyproject.toml @@ -43,7 +45,6 @@ runs: set -euo pipefail uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ "$GITHUB_ACTION_PATH/../tools/expire_cutoffs.py" pyproject.toml - cp pyproject.toml "$RUNNER_TEMP/project-after-expiry.toml" - name: Resolve release groups shell: bash env: @@ -145,31 +146,6 @@ runs: python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \ "$RUNNER_TEMP/additional-lock-before.toml" "$ADDITIONAL_LOCK" fi - - name: Install refreshed release checks - if: inputs.mode == 'refresh' - uses: $/install-locked-tools - with: - groups: release-check - venv: ${{ runner.temp }}/release-tools - - name: Run repository checks - if: inputs.mode == 'refresh' - shell: bash - run: uv run --no-project --python "$RUNNER_TEMP/release-tools" prek run --all-files - - name: Check refreshed paths - if: inputs.mode == 'refresh' - shell: bash - env: - ADDITIONAL_LOCK: ${{ inputs.additional-lock }} - run: | - set -euo pipefail - cmp pyproject.toml "$RUNNER_TEMP/project-after-expiry.toml" - while IFS= read -r entry; do - path=${entry:3} - if [[ "$path" != pylock.toml && "$path" != pyproject.toml && "$path" != "${ADDITIONAL_LOCK:-}" ]]; then - echo "::error::prek changed file outside the lock and cutoff policy: $path" >&2 - exit 1 - fi - done < <(git status --porcelain=v1 --untracked-files=all) - id: lock shell: bash env: @@ -178,7 +154,7 @@ runs: run: | set -euo pipefail paths=(pylock.toml pyproject.toml) - if [[ -n "${ADDITIONAL_LOCK:-}" ]]; then paths+=("$ADDITIONAL_LOCK"); fi + if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi if [[ "$MODE" == check ]]; then git diff --exit-code -- "${paths[@]}" echo 'changed=false' >>"$GITHUB_OUTPUT" @@ -187,5 +163,4 @@ runs: else echo 'changed=true' >>"$GITHUB_OUTPUT" git diff --binary -- "${paths[@]}" >"$RUNNER_TEMP/lock.patch" - test -s "$RUNNER_TEMP/lock.patch" fi diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index 44fce5b..050f998 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -22,10 +22,6 @@ inputs: previous: description: Previous tag, or auto to calculate it from Git history default: auto -outputs: - previous: - description: Previous version tag, or an empty string for the first release - value: ${{ steps.previous.outputs.previous }} runs: using: composite steps: diff --git a/resolve-pr-from-run/action.yml b/resolve-pr-from-run/action.yml deleted file mode 100644 index 69fa8b5..0000000 --- a/resolve-pr-from-run/action.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Resolve a pull request from its CI run -description: Verify a completed read-only PR run and find its current pull request -inputs: - run-id: - description: GitHub Actions run ID from the workflow_run event - required: true - workflow-path: - description: Repository-relative path of the read-only PR workflow - required: true -outputs: - number: - description: Current matching pull request number, or empty for a stale run - value: ${{ steps.resolve.outputs.number }} - bot-author: - description: Whether the matching pull request author is a bot - value: ${{ steps.resolve.outputs.bot-author }} -runs: - using: composite - steps: - - name: Verify the source run - shell: bash - env: - GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ inputs.run-id }} - WORKFLOW_PATH: ${{ inputs.workflow-path }} - run: | - set -euo pipefail - case "$RUN_ID" in - '' | *[!0-9]*) - echo '::error::run-id must be a decimal integer' >&2 - exit 1 - ;; - esac - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID" >"$RUNNER_TEMP/source-run.json" - if ! jq -e --arg repository "$GITHUB_REPOSITORY" --arg workflow "$WORKFLOW_PATH" --argjson run_id "$RUN_ID" ' - .id == $run_id and - .repository.full_name == $repository and - .event == "pull_request" and - .path == $workflow and - .status == "completed" and - (.head_repository.full_name | type == "string") and - (.head_repository.owner.login | type == "string") and - (.head_branch | type == "string") and - (.head_sha | length == 40) - ' "$RUNNER_TEMP/source-run.json" >/dev/null; then - echo '::error::source must be a completed pull_request run from the expected workflow' >&2 - exit 1 - fi - - id: resolve - name: Match the current pull request - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - head_owner=$(jq -r .head_repository.owner.login "$RUNNER_TEMP/source-run.json") - head_branch=$(jq -r .head_branch "$RUNNER_TEMP/source-run.json") - head_repository=$(jq -r .head_repository.full_name "$RUNNER_TEMP/source-run.json") - head_sha=$(jq -r .head_sha "$RUNNER_TEMP/source-run.json") - gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open -f "head=$head_owner:$head_branch" -f per_page=100 \ - >"$RUNNER_TEMP/source-pulls.json" - matches=$(jq --arg repository "$GITHUB_REPOSITORY" --arg head_repository "$head_repository" \ - --arg head_sha "$head_sha" ' - [.[] | select( - .state == "open" and - .head.sha == $head_sha and - .head.repo.full_name == $head_repository and - .base.repo.full_name == $repository - )] - ' "$RUNNER_TEMP/source-pulls.json") - count=$(jq 'length' <<<"$matches") - if [[ "$count" == 0 ]]; then - echo 'Source run no longer matches an open pull request' - exit 0 - fi - if [[ "$count" != 1 ]]; then - echo '::error::source run matches multiple pull requests' >&2 - exit 1 - fi - jq -r '"number=\(.[0].number)", "bot-author=\(.[0].user.type == "Bot")"' <<<"$matches" >>"$GITHUB_OUTPUT" diff --git a/select-previous-tag/action.yml b/select-previous-tag/action.yml index e41b2cd..9692b53 100644 --- a/select-previous-tag/action.yml +++ b/select-previous-tag/action.yml @@ -4,15 +4,6 @@ inputs: tag: description: Current release tag required: true - package: - description: Package namespace in a multi-package repository - default: "" - executable: - description: Path to the installed release-creator executable - default: release-creator - stable-only: - description: Ignore prerelease tags when finding the predecessor - default: "false" outputs: previous: description: Preceding tag, or empty for the first release @@ -24,19 +15,17 @@ runs: shell: bash env: TAG: ${{ inputs.tag }} - PACKAGE: ${{ inputs.package }} - EXECUTABLE: ${{ inputs.executable }} - STABLE_ONLY: ${{ inputs.stable-only }} run: | set -euo pipefail - target="$TAG" - if [[ -n "$PACKAGE" && "$TAG" != */* ]]; then target="$PACKAGE/$TAG"; fi - if ! git show-ref --verify --quiet "refs/tags/$target"; then - echo "::error::release tag not found: $target" >&2 + if ! git show-ref --verify --quiet "refs/tags/$TAG"; then + echo "::error::release tag not found: $TAG" >&2 exit 1 fi - args=(tags "$TAG" --previous) - if [[ -n "$PACKAGE" ]]; then args+=(--package "$PACKAGE"); fi - if [[ "$STABLE_ONLY" == true ]]; then args+=(--stable-only); fi - previous=$(git tag --list | "$EXECUTABLE" "${args[@]}") + if [[ "$TAG" == */*/* ]]; then + echo "::error::release tag must contain at most one namespace separator: $TAG" >&2 + exit 1 + fi + args=() + if [[ "$TAG" == */* ]]; then args+=(--package "${TAG%%/*}"); fi + previous=$(git tag --list | release-creator tags "$TAG" --previous "${args[@]}") printf 'previous=%s\n' "$previous" >>"$GITHUB_OUTPUT" diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 97f7255..2fa286a 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -1,46 +1,13 @@ from __future__ import annotations -import json import os import shutil import tomllib from pathlib import Path -import pytest from conftest import action, run -@pytest.fixture -def build_repository( - source_project: Path, nab_executable: Path, test_package_index: str, monkeypatch: pytest.MonkeyPatch -) -> Path: - monkeypatch.delenv("SSL_CERT_FILE", raising=False) - monkeypatch.setenv("UV_INDEX_URL", test_package_index) - project = source_project / "pyproject.toml" - release_requirement = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())[ - "dependency-groups" - ]["release-notes"][0] - project.write_text( - project.read_text() - + f"\n[dependency-groups]\nrelease-notes = [{json.dumps(release_requirement)}]\n" - + '\n[tool.nab]\nbase-group = "project-runtime"\nbuild-group = "build"\n' - + f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n' - ) - run( - source_project, - str(nab_executable), - "lock", - "--groups", - "release-notes", - "--output", - "pylock.toml", - ) - run(source_project, "git", "add", ".") - run(source_project, "git", "commit", "-m", "Add package source") - run(source_project, "git", "push", "origin", "main") - return source_project - - def test_build_python_distribution_creates_wheel_and_sdist( build_repository: Path, tmp_path: Path, action_scripts: Path ) -> None: @@ -58,7 +25,7 @@ def test_build_python_distribution_creates_wheel_and_sdist( "fixture-0.2.0-py3-none-any.whl", "fixture-0.2.0.tar.gz", } - assert next(tmp_path.glob("build-constraints-*.txt")).read_text().count("hatchling==") == 1 + assert any("hatchling==" in path.read_text() for path in tmp_path.glob("build-constraints-*.txt")) def test_build_python_distribution_repeats_exact_bytes( @@ -83,22 +50,6 @@ def test_build_python_distribution_repeats_exact_bytes( assert outputs[0] == outputs[1] -def test_build_python_distribution_rejects_dirty_source( - build_repository: Path, tmp_path: Path, action_scripts: Path -) -> None: - (build_repository / "pyproject.toml").write_text((build_repository / "pyproject.toml").read_text() + "\n") - result = action( - build_repository, - tmp_path, - action_scripts, - "build-python-distribution", - version="0.2.0", - SOURCE=".", - RUNNER_TEMP=str(tmp_path), - ) - assert "tagged source is dirty" in result.stderr - - def test_build_python_distribution_rejects_existing_artifact( build_repository: Path, tmp_path: Path, action_scripts: Path ) -> None: diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index 0c11af3..df5907c 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -257,22 +257,6 @@ def test_cooldown_does_not_use_another_duplicate_repository_observation( assert "hook revision has not been observed" in result.stderr -def test_cooldown_rejects_other_event_before_api( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], -) -> None: - server, environment = github_api - result = run_cooldown( - action_scripts, - tmp_path, - environment, - event_name="workflow_dispatch", - ) - assert result.returncode != 0 - assert not server.log - - def run_cooldown( action_scripts: Path, tmp_path: Path, diff --git a/tests/commit_and_tag/test_commit.py b/tests/commit_and_tag/test_commit.py index e352271..3efc6ac 100644 --- a/tests/commit_and_tag/test_commit.py +++ b/tests/commit_and_tag/test_commit.py @@ -54,11 +54,24 @@ def test_commit_and_tag_pushes_atomically( def test_commit_and_tag_rejects_invalid_tag(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", "add", "CHANGELOG.md") - original_head = run(repository, "git", "rev-parse", "HEAD").stdout - result = action(repository, tmp_path, action_scripts, "commit-and-tag", version="bad tag") + original_remote = run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout + gh = tmp_path / "gh" + gh.write_text('#!/usr/bin/env bash\nif [[ "$1" == api ]]; then echo 123; fi\n') + gh.chmod(0o755) + result = action( + repository, + tmp_path, + action_scripts, + "commit-and-tag", + version="bad tag", + APP_SLUG="release-test", + TOKEN="test-token", # ruff: ignore[hardcoded-password-func-arg] # Local fixture never sends this token. + MESSAGE="release bad tag", + PATH=f"{tmp_path}:{os.environ['PATH']}", + ) assert result.returncode != 0 - assert "invalid release tag" in result.stderr - assert run(repository, "git", "rev-parse", "HEAD").stdout == original_head + assert "not a valid tag name" in result.stderr + assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == original_remote def test_commit_and_tag_rejects_empty_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index d7c5851..f28be7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,19 +55,14 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "prepare-towncrier", "validate-release-patch", "commit-and-tag", - "build-python-distribution", - "validate-python-distribution", "render-release-notes", "select-previous-tag", "validate-news", - "label-bot-news", - "resolve-pr-from-run", "refresh-release-lock", "install-locked-tools", - "install-nab", "export-build-constraints", + "build-python-distribution", "read-towncrier-config", - "refresh-hook-pins", "publish-github-release", "reconcile-pypi-artifacts", "publish-jetbrains-marketplace", @@ -260,11 +255,32 @@ def source_project(repository: Path) -> Path: return repository +@pytest.fixture +def build_repository(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + project = source_project / "pyproject.toml" + release_requirement = tomllib.loads((Path(__file__).resolve().parents[1] / "pyproject.toml").read_text())[ + "dependency-groups" + ]["release-notes"][0] + project.write_text( + project.read_text() + + f"\n[dependency-groups]\nrelease-notes = [{json.dumps(release_requirement)}]\n" + + '\n[tool.nab]\nbase-group = "project-runtime"\nbuild-group = "build"\n' + ) + run(source_project, str(nab_executable), "lock", "--groups", "release-notes", "--output", "pylock.toml") + run(source_project, "git", "add", ".") + run(source_project, "git", "commit", "-m", "Add package source") + run(source_project, "git", "push", "origin", "main") + return source_project + + def action( repository: Path, tmp_path: Path, action_scripts: Path, name: str, + captured_outputs: dict[str, str] | None = None, + /, *, version: str, **extra_env: str, @@ -281,7 +297,6 @@ def action( "FORMAT": action_config.get("inputs", {}).get("format", {}).get("default", "none"), "PATCH": str(tmp_path / "release.patch"), "EXPECTED_SHA": head, - "EXPECTED_PATCH_SHA": "", "CHECKS": "true", "BRANCH": "main", "REMOTE": "origin", @@ -301,6 +316,7 @@ def action( _safe_environment() | { "GITHUB_OUTPUT": str(tmp_path / "output"), + "GITHUB_PATH": str(tmp_path / "github-path"), "GITHUB_REPOSITORY": "tox-dev/fixture", "GITHUB_SHA": head, "RUNNER_TEMP": str(tmp_path), @@ -310,7 +326,9 @@ def action( ) if "PATH" not in extra_env: environment["PATH"] = _tools_path(environment) - result, _ = _run_action(repository, action_scripts, name, environment, inputs) + result, outputs = _run_action(repository, action_scripts, name, environment, inputs) + if captured_outputs is not None: + captured_outputs.update(outputs) return result diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 5476e47..623a436 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -13,14 +13,15 @@ def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) - result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", version="") + outputs: dict[str, str] = {} + result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", outputs, version="") assert result.returncode == 0, result.stderr - output = next(tmp_path.glob("build-constraints-*.txt")) + output = Path(outputs["path"]) constraints = output.read_text() assert "cython==" in constraints assert "setuptools==" in constraints assert "pytest==" not in constraints - assert (tmp_path / "output").read_text() == f"path={output}\n" + assert output == next(tmp_path.glob("build-constraints-*.txt")) @pytest.mark.parametrize( @@ -125,8 +126,51 @@ def test_export_build_constraints_selects_explicit_targets(tmp_path: Path, platf assert output.read_text() == f"example==1.0 --hash=sha256:{'0' * 64}\n" -def test_export_build_constraints_rejects_different_target_versions(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, action_scripts: Path) -> None: + write_matrix_lock(tmp_path / "pylock.build.toml") + + outputs: dict[str, str] = {} + result = action( + tmp_path, + tmp_path, + action_scripts, + "export-build-constraints", + outputs, + version="", + LOCK="pylock.build.toml", + TARGET_PLATFORM="macos_arm64", + TARGETS="3.15.0:cpython", + ) + + assert result.returncode == 0, result.stderr + output = Path(outputs["path"]) + assert output.read_text() == f"example==1.0 --hash=sha256:{'0' * 64}\n" + + +@pytest.mark.parametrize("groups", ["", "release-check"], ids=["export-only", "with-installed-group"]) +def test_export_build_constraints_rejects_uncooled_secondary_lock( + tmp_path: Path, action_scripts: Path, groups: str +) -> None: + lock = tmp_path / "pylock.build.toml" + write_matrix_lock(lock) + lock.write_text(lock.read_text().replace("2026-10-01T00:00:00Z", "2026-10-22T00:00:00Z")) + result = action( + tmp_path, + tmp_path, + action_scripts, + "install-locked-tools" if groups else "export-build-constraints", + version="", + LOCK="pylock.build.toml", + LOCK_SOURCE="shared", + CONSTRAINTS_LOCK="pylock.build.toml", + GROUPS=groups, + VENV=str(tmp_path / "tools"), + ) + assert result.returncode != 0 + assert "uncooled artifact" in result.stderr + + +def test_export_build_constraints_rejects_different_target_versions(tmp_path: Path, action_scripts: Path) -> None: lock = tmp_path / "pylock.build.toml" write_matrix_lock(lock) lock.write_text( @@ -141,24 +185,20 @@ def test_export_build_constraints_rejects_different_target_versions(tmp_path: Pa [[packages.wheels]] name = "example-2.0-py3-none-any.whl" url = "https://files.pythonhosted.org/example-2.0-py3-none-any.whl" + upload-time = 2026-10-01T00:00:00Z hashes.sha256 = "1111111111111111111111111111111111111111111111111111111111111111" """, ), ) - result = subprocess.run( - [ - sys.executable, - str(source / "tools/build_constraints.py"), - "--target-platform", - "macos_arm64", - "--targets", - "3.15.0:cpython 3.11.0:pypy", - str(tmp_path / "constraints.txt"), - str(lock), - ], - text=True, - capture_output=True, - check=False, + result = action( + tmp_path, + tmp_path, + action_scripts, + "export-build-constraints", + version="", + LOCK="pylock.build.toml", + TARGET_PLATFORM="macos_arm64", + TARGETS="3.15.0:cpython 3.11.0:pypy", ) assert result.returncode != 0 assert "versions differ across requested targets" in result.stderr @@ -220,7 +260,10 @@ def write_matrix_lock(path: Path) -> None: [[packages.wheels]] name = "example-1.0-py3-none-any.whl" url = "https://files.pythonhosted.org/example-1.0-py3-none-any.whl" + upload-time = 2026-10-01T00:00:00Z hashes.sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + [tool.nab] + created-at = 2026-10-23T00:00:00Z """, ), ) diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 1854e1f..7deb105 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +import tomlkit from conftest import action @@ -93,7 +94,7 @@ def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] - action_root = tmp_path / "install-nab" + action_root = tmp_path / "install-locked-tools" action_root.mkdir() lock = (source / "pylock.toml").read_text() package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "nab") @@ -102,7 +103,7 @@ def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts tmp_path, tmp_path, action_scripts, - "install-nab", + "install-locked-tools", version="0.2.0", GITHUB_ACTION_PATH=str(action_root), UV_CACHE_DIR=str(tmp_path / "cache"), @@ -111,20 +112,20 @@ def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts assert "Hash mismatch" in result.stderr -def test_install_nab_reuses_existing_environment(tmp_path: Path, action_scripts: Path) -> None: +def test_install_locked_tools_reuses_existing_environment(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] - environment = {"GITHUB_ACTION_PATH": str(source / "install-nab")} - first = action(source, tmp_path, action_scripts, "install-nab", version="", **environment) - second = action(source, tmp_path, action_scripts, "install-nab", version="", **environment) + environment = {"GROUPS": "lock-tools", "VENV": str(tmp_path / "lock-tools"), "LOCK_SOURCE": "shared"} + first = action(source, tmp_path, action_scripts, "install-locked-tools", version="", **environment) + second = action(source, tmp_path, action_scripts, "install-locked-tools", version="", **environment) assert first.returncode == second.returncode == 0, first.stderr + second.stderr assert (tmp_path / "lock-tools" / ("Scripts/nab.exe" if os.name == "nt" else "bin/nab")).is_file() -def test_install_nab_reuses_windows_shaped_environment(tmp_path: Path, action_scripts: Path) -> None: +def test_install_locked_tools_reuses_windows_shaped_environment(tmp_path: Path, action_scripts: Path) -> None: subprocess.run(["uv", "venv", "--python", "3.15", str(tmp_path / "lock-tools")], check=True, capture_output=True) (tmp_path / "lock-tools" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).unlink() result = subprocess.run( - ["bash", str(action_scripts / "install-nab-0.sh")], + ["bash", str(action_scripts / "install-locked-tools-0.sh")], env=os.environ | {"RUNNER_TEMP": str(tmp_path)}, text=True, capture_output=True, @@ -134,19 +135,6 @@ def test_install_nab_reuses_windows_shaped_environment(tmp_path: Path, action_sc assert (tmp_path / "lock-tools/pyvenv.cfg").is_file() -def test_install_locked_tools_rejects_empty_groups(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - result = install( - source, - action_scripts, - tmp_path, - groups="", - venv=tmp_path / "tools", - ) - assert result.returncode != 0 - assert "at least one dependency group is required" in result.stderr - - def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] result = install( @@ -166,6 +154,7 @@ def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, loca lock.write_text( 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' 'dependency-groups = ["release-prepare"]\ncreated-by = "test"\npackages = []\n' + "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" ) source = Path(__file__).resolve().parents[2] command = [ @@ -205,6 +194,190 @@ def test_local_tool_source_accepts_versionless_package(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr +@pytest.mark.parametrize( + ("replace", "message"), + [ + pytest.param( + ("created-at = 2026-10-23", "created-at = 2099-10-23"), + "created in the future", + id="future-creation", + ), + pytest.param( + ("upload-time = 2026-", "upload-time = 2099-"), + "uncooled artifact", + id="recent-artifact", + ), + ], +) +def test_install_locked_tools_rejects_uncooled_lock(tmp_path: Path, replace: tuple[str, str], message: str) -> None: + source = Path(__file__).resolve().parents[2] + lock = (source / "pylock.toml").read_text() + assert replace[0] in lock + (tmp_path / "pylock.toml").write_text(lock.replace(*replace)) + + result = subprocess.run( + ["python", str(source / "tools/check_lock_groups.py"), str(tmp_path / "pylock.toml"), "release-check"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert message in result.stderr + + +def test_install_locked_tools_checks_build_group_with_selected_groups(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(package for package in document["packages"] if package["name"] == "cython") + package["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) + lock = tmp_path / "pylock.toml" + lock.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "uncooled artifact" in result.stderr + + +@pytest.mark.parametrize("change", ["missing", "naive"], ids=["missing", "naive"]) +def test_install_locked_tools_rejects_undated_artifact(tmp_path: Path, change: str) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(package for package in document["packages"] if package["name"] == "cython") + if change == "missing": + del package["wheels"][0]["upload-time"] + else: + package["wheels"][0]["upload-time"] = datetime(2026, 10, 1) + lock = tmp_path / "pylock.toml" + lock.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "uncooled artifact" in result.stderr + + +def test_install_locked_tools_skips_unselected_package(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(package for package in document["packages"] if package["name"] == "release-creator") + package["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) + lock = tmp_path / "pylock.toml" + lock.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-check"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "source_kind", ["foreign-host", "insecure-url", "local-path"], ids=["foreign-host", "insecure-url", "local-path"] +) +def test_install_locked_tools_rejects_non_pypi_artifact(tmp_path: Path, source_kind: str) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(package for package in document["packages"] if package["name"] == "cython") + artifact = package["wheels"][0] + if source_kind == "foreign-host": + artifact["url"] = artifact["url"].replace("files.pythonhosted.org", "example.org") + elif source_kind == "insecure-url": + artifact["url"] = artifact["url"].replace("https://", "http://") + else: + del artifact["url"] + artifact["path"] = "cython.whl" + lock = tmp_path / "pylock.toml" + lock.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "non-PyPI artifact" in result.stderr + + +def test_install_locked_tools_rejects_artifact_version_mismatch(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(package for package in document["packages"] if package["name"] == "setuptools") + artifact = package["wheels"][0] + artifact["url"] = artifact["url"].replace("84.0.0", "0.0.0") + lock = tmp_path / "pylock.toml" + lock.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "artifact differs from package version" in result.stderr + + +def test_install_locked_tools_rejects_unreviewed_version_under_reviewed_floor(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(package for package in document["packages"] if package["name"] == "release-creator") + package["version"] = "0.6.3" + for artifact in [package["sdist"], *package["wheels"]]: + artifact["name"] = artifact["name"].replace("0.6.4", "0.6.3") + artifact["url"] = artifact["url"].replace("0.6.4", "0.6.3") + lock = tmp_path / "pylock.toml" + lock.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "uncooled artifact" in result.stderr + + +def test_install_locked_tools_constraints_only_checks_lock_age(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + (tmp_path / "pylock.toml").write_text( + (source / "pylock.toml").read_text().replace("created-at = 2026-10-23", "created-at = 2099-10-23", 1) + ) + result = action(tmp_path, tmp_path, action_scripts, "install-locked-tools", version="") + assert result.returncode != 0 + assert "created in the future" in result.stderr + + +def test_install_locked_tools_constraints_only_accepts_no_build_group(tmp_path: Path, action_scripts: Path) -> None: + (tmp_path / "pylock.toml").write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\ncreated-by = "test"\n' + 'dependency-groups = ["project-runtime"]\npackages = []\n' + "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" + ) + result = action(tmp_path, tmp_path, action_scripts, "install-locked-tools", version="") + assert result.returncode == 0, result.stderr + assert "setuptools==" in next(tmp_path.glob("build-constraints-*.txt")).read_text() + + def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] shared = tmp_path / "shared.toml" @@ -217,6 +390,7 @@ def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None caller.write_text( 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' 'dependency-groups = ["release-shell"]\ncreated-by = "test"\npackages = []\n' + "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" ) result = subprocess.run( [ @@ -332,6 +506,7 @@ def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_pat [[packages.wheels]] name = "probe_only-1.0-py3-none-any.whl" url = "https://files.pythonhosted.org/probe_only-1.0-py3-none-any.whl" +upload-time = 2026-10-01T00:00:00Z hashes.sha256 = """ + f'"{"0" * 64}"\n', ) @@ -346,24 +521,11 @@ def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_pat assert "probe-only==1.0 " in next(tmp_path.glob("build-constraints-*.txt")).read_text() -def test_install_locked_tools_accepts_lock_without_build_group( - tmp_path: Path, action_scripts: Path, nab_executable: Path, test_package_index: str -) -> None: - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "tool-only"\nversion = "0.1.0"\nrequires-python = ">=3.15"\n' - '[dependency-groups]\nrelease-check = ["prek>=0.2"]\n' - '[tool.nab]\nbase-group = "project-runtime"\ndefault-groups = []\n' - f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n', - ) - generated = subprocess.run( - [str(nab_executable), "lock", "--groups", "release-check", "--output", "pylock.toml"], - cwd=tmp_path, - text=True, - capture_output=True, - check=False, - ) - assert generated.returncode == 0, generated.stderr - assert "build" not in tomllib.loads((tmp_path / "pylock.toml").read_text())["dependency-groups"] +def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + document["dependency-groups"].remove("build") + (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) result = install(tmp_path, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") assert result.returncode == 0, result.stderr assert "setuptools==" in next(tmp_path.glob("build-constraints-*.txt")).read_text() diff --git a/tests/label_bot_news/test_label.py b/tests/label_bot_news/test_label.py deleted file mode 100644 index 0976e56..0000000 --- a/tests/label_bot_news/test_label.py +++ /dev/null @@ -1,283 +0,0 @@ -from __future__ import annotations - -import os -import shutil -from pathlib import Path - -import pytest -import yaml -from conftest import action -from pytest_httpserver import HTTPServer - - -@pytest.mark.parametrize( - ("files", "labels", "edit_option", "fragment_dirs", "extension", "fragment_style"), - [ - pytest.param([], [], "--add-label", "changelog.d", "md", "towncrier", id="add"), - pytest.param( - [{"filename": "changelog.d/+old.feature.md", "status": "removed"}], - [], - "--add-label", - "changelog.d", - "md", - "towncrier", - id="deleted-fragment-needs-label", - ), - pytest.param([], [{"name": "skip news"}], None, "changelog.d", "md", "towncrier", id="already-labeled"), - pytest.param( - [{"filename": "changelog.d/+fix.bugfix.md"}], - [{"name": "skip news"}], - "--remove-label", - "changelog.d", - "md", - "towncrier", - id="remove", - ), - pytest.param( - [{"filename": "changelog.d/+fix.bugfix.md"}], - [], - None, - "changelog.d", - "md", - "towncrier", - id="fragment-without-label", - ), - pytest.param( - [{"filename": "changelog.d/+fix.bugfix.2.md"}], - [{"name": "skip news"}], - "--remove-label", - "changelog.d", - "md", - "towncrier", - id="named-fragment-counter", - ), - pytest.param( - [{"filename": "changelog.d/123.bugfix.md"}], - [{"name": "skip news"}], - "--remove-label", - "changelog.d", - "md", - "towncrier", - id="numeric-fragment", - ), - pytest.param( - [{"filename": "changelog.d/123.bugfix.2.md"}], - [{"name": "skip news"}], - "--remove-label", - "changelog.d", - "md", - "towncrier", - id="numeric-fragment-counter", - ), - pytest.param( - [{"filename": "changelog.d/topic.feature.md"}], - [{"name": "skip news"}], - None, - "changelog.d", - "md", - "towncrier", - id="bare-name-not-a-fragment", - ), - pytest.param( - [{"filename": "changelog.d/123a.feature.md"}], - [{"name": "skip news"}], - None, - "changelog.d", - "md", - "towncrier", - id="mixed-number-not-a-fragment", - ), - pytest.param( - [{"filename": "changelog.d/+.feature.md"}], - [{"name": "skip news"}], - None, - "changelog.d", - "md", - "towncrier", - id="empty-named-stem-not-a-fragment", - ), - pytest.param( - [{"filename": "docs/changelog.d/+fix.bugfix.md"}], - [{"name": "skip news"}], - "--remove-label", - "changelog.d,docs/changelog.d", - "md", - "towncrier", - id="legacy-fragment-path", - ), - pytest.param( - [{"filename": "docs/changelog/+fix.bugfix.rst"}], - [{"name": "skip news"}], - "--remove-label", - "docs/changelog", - "rst", - "towncrier", - id="rst-fragment-removes-label", - ), - pytest.param([], [], "--add-label", ".changeset", "md", "changeset", id="changeset-add"), - pytest.param( - [{"filename": "changelog.d/nested/+fix.bugfix.md"}], - [{"name": "skip news"}], - None, - "changelog.d", - "md", - "towncrier", - id="nested-towncrier-is-not-a-fragment", - ), - pytest.param( - [{"filename": ".changeset/nested/fix-something.md"}], - [{"name": "skip news"}], - None, - ".changeset", - "md", - "changeset", - id="nested-changeset-is-not-a-fragment", - ), - pytest.param( - [{"filename": ".changeset/fix-something.md"}], - [{"name": "skip news"}], - "--remove-label", - ".changeset", - "md", - "changeset", - id="changeset-remove", - ), - pytest.param( - [{"filename": ".changeset/fix-something.md"}], - [], - None, - ".changeset", - "md", - "changeset", - id="changeset-fragment-without-label", - ), - ], -) -def test_label_bot_news_updates_label( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - files: list[dict[str, str]], - labels: list[dict[str, str]], - edit_option: str | None, - fragment_dirs: str, - extension: str, - fragment_style: str, -) -> None: - server, environment = github_api - prefix = "/api/v3/repos/tox-dev/fixture" - server.expect_request(f"{prefix}/pulls/1/files", method="GET").respond_with_json(files) - server.expect_request(f"{prefix}/issues/1/labels", method="GET").respond_with_json(labels) - real_gh = shutil.which("gh") - assert real_gh is not None - executable = tmp_path / "gh-shim" / "gh" - executable.parent.mkdir() - executable.write_text( - "#!/usr/bin/env bash\n" - 'if [[ $1 == pr && $2 == edit ]]; then printf "%s\\0" "$@" >>"$GH_EDIT_LOG"; exit 0; fi\n' - 'exec "$GH_REAL" "$@"\n', - ) - executable.chmod(0o755) - edit_log = tmp_path / "gh-edit-args" - result = action( - repository, - tmp_path, - action_scripts, - "label-bot-news", - version="0.2.0", - REPOSITORY="tox-dev/fixture", - GITHUB_EVENT_PULL_REQUEST_NUMBER="1", - BOT_AUTHOR="true", - FRAGMENTS=fragment_dirs, - EXTENSION=extension, - FRAGMENT_STYLE=fragment_style, - RUNNER_TEMP=str(tmp_path), - PATH=f"{executable.parent}:{os.environ['PATH']}", - GH_REAL=real_gh, - GH_EDIT_LOG=str(edit_log), - **environment, - ) - assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET"] - assert (edit_log.read_bytes().split(b"\0")[:-1] if edit_log.exists() else []) == ( - [part.encode() for part in ("pr", "edit", "1", "--repo", "tox-dev/fixture", edit_option, "skip news")] - if edit_option is not None - else [] - ) - - -def test_label_bot_news_ignores_human( - repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "label-bot-news/action.yml").read_text())["runs"][ - "steps" - ] - assert all( - step.get("if") == "inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'" - for step in steps - ) - server, environment = github_api - result = action( - repository, - tmp_path, - action_scripts, - "label-bot-news", - version="0.2.0", - BOT_AUTHOR="false", - **environment, - ) - assert result.returncode == 0 - assert not server.log - - -def test_label_bot_news_rejects_extension( - repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - result = action( - repository, - tmp_path, - action_scripts, - "label-bot-news", - version="0.2.0", - BOT_AUTHOR="true", - EXTENSION="txt", - **environment, - ) - assert result.returncode != 0 - assert "fragment extension must be md or rst" in result.stderr - assert not server.log - - -@pytest.mark.parametrize( - ("style", "extension", "error"), - [ - pytest.param("unknown", "md", "fragment style must be towncrier or changeset", id="unknown-style"), - pytest.param("changeset", "rst", "changeset fragments must use md", id="rst-changeset"), - ], -) -def test_label_bot_news_rejects_invalid_style( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - style: str, - extension: str, - error: str, -) -> None: - server, environment = github_api - result = action( - repository, - tmp_path, - action_scripts, - "label-bot-news", - version="0.2.0", - BOT_AUTHOR="true", - FRAGMENT_STYLE=style, - EXTENSION=extension, - **environment, - ) - assert result.returncode != 0 - assert error in result.stderr - assert not server.log diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 0a94266..3ad6f54 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -117,18 +117,11 @@ def test_prepare_renders_github_notes( - Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) by @gaborbernat. - **Full Changelog**: https://github.com/tox-dev/github-workflows/compare/0.1.0...0.2.0 + [Full Changelog](https://github.com/tox-dev/github-workflows/compare/0.1.0...0.2.0) """, ) -def test_prepare_rejects_dirty_checkout(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "CHANGELOG.md").write_text("changed\n") - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") - assert result.returncode != 0 - assert "clean checkout" in result.stderr - - @pytest.mark.parametrize( ("option", "value", "error"), [ @@ -269,20 +262,6 @@ def test_prepare_rejects_unlinked_fragment_name( assert not (tmp_path / "release.patch").exists() -def test_prepare_rejects_untracked_output(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / ".pre-commit-config.yaml").write_text( - "repos:\n - repo: local\n hooks:\n - id: mdformat\n" - " name: mdformat\n entry: bash -c 'touch extra-output'\n" - " language: system\n pass_filenames: false\n", - ) - run(repository, "git", "add", ".") - run(repository, "git", "commit", "-m", "Write untracked output") - run(repository, "git", "push", "origin", "main") - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") - assert result.returncode != 0 - assert "created untracked files" in result.stderr - - def test_prepare_rejects_empty_release(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "changelog.d/+feature.feature.md").unlink() run(repository, "git", "add", "-u") @@ -293,16 +272,6 @@ def test_prepare_rejects_empty_release(repository: Path, tmp_path: Path, action_ assert "no changelog fragments to release" in result.stderr -def test_prepare_rejects_empty_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - built = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") - assert built.returncode == 0, built.stderr - run(repository, "git", "add", "-A") - run(repository, "git", "commit", "-m", "Store rendered notes") - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="finalize") - assert result.returncode != 0 - assert "produced no patch" in result.stderr - - def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "changelog.d/+feature.feature.md").write_text("Add " + "a long sentence " * 15 + ".\n") run(repository, "git", "add", ".") diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index ba0e4d8..82e882d 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -58,7 +58,7 @@ def _notes_and_commit(repository: Path, tmp_path: Path) -> tuple[Path, str]: def _release( - notes: Path, *, draft: bool, assets: list[dict[str, str]] | None = None + notes: Path, *, draft: bool, assets: list[dict[str, str]] | None = None, immutable: bool = False ) -> dict[str, str | int | bool | list[dict[str, str]]]: return { "id": 42, @@ -67,6 +67,7 @@ def _release( "body": notes.read_text(), "draft": draft, "prerelease": False, + "immutable": immutable, "assets": assets or [], } @@ -117,6 +118,7 @@ def test_existing_published_release_is_verified_without_writes( release = _release( notes, draft=False, + immutable=True, assets=( [{"name": "fixture.zip", "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"}] if with_asset @@ -128,8 +130,6 @@ def test_existing_published_release_is_verified_without_writes( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [release] ) - for _ in range(2 + int(with_asset)): - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) result = _request( repository, tmp_path, @@ -140,7 +140,7 @@ def test_existing_published_release_is_verified_without_writes( assets=assets if with_asset else None, ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET"] * (4 + int(with_asset)) + assert [request.method for request, _ in server.log] == ["GET", "GET"] assert _release_calls(tmp_path) == ( [("release", "verify-asset", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] if with_asset @@ -185,6 +185,28 @@ def test_existing_release_rejects_changed_content( assert not _release_calls(tmp_path) +def test_mutable_published_release_is_rechecked( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + release = _release(notes, draft=False) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [release] + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET", "GET", "GET"] + assert not _release_calls(tmp_path) + + def test_tag_endpoint_must_return_expected_commit( repository: Path, tmp_path: Path, @@ -267,6 +289,47 @@ def test_publish_requires_an_existing_draft( assert [request.method for request, _ in server.log] == ["GET", "GET"] +def test_staged_release_publishes_without_reupload( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + asset_path = assets / "fixture.zip" + asset_path.write_bytes(b"release archive") + draft = _release( + notes, + draft=True, + assets=[ + { + "name": asset_path.name, + "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}", + "state": "uploaded", + } + ], + ) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [draft] + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(draft | {"draft": False}) + result = _request( + repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets, mode="publish" + ) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET", "GET", "GET", "PATCH"] + assert _release_calls(tmp_path) == [ + ("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture") + ] + + @pytest.mark.parametrize("mode", [pytest.param("stage", id="draft"), pytest.param("complete", id="published")]) def test_missing_release_is_created_then_optionally_published( repository: Path, @@ -288,9 +351,6 @@ def test_missing_release_is_created_then_optionally_published( server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( draft | {"draft": False} ) - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( - draft | {"draft": mode == "stage"} - ) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode=mode) assert result.returncode == 0, result.stderr assert [request.method for request, _ in server.log] == [ @@ -299,7 +359,6 @@ def test_missing_release_is_created_then_optionally_published( "POST", "GET", *(["PATCH"] if mode == "complete" else []), - "GET", ] assert not _release_calls(tmp_path) request = next(request for request, _ in server.log if request.method == "POST") @@ -351,9 +410,6 @@ def test_draft_release_uploads_missing_asset( server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( complete | {"draft": False} ) - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( - complete | {"draft": mode == "stage"} - ) result = _request( repository, tmp_path, @@ -379,7 +435,6 @@ def test_draft_release_uploads_missing_asset( "GET", "GET", *(["PATCH"] if mode == "complete" else []), - "GET", ] @@ -420,7 +475,6 @@ def test_draft_release_replaces_incomplete_asset( server.expect_ordered_request(f"{prefix}/releases/assets/21", method="DELETE").respond_with_json({}) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(incomplete | {"assets": []}) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(uploaded) - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(uploaded) result = _request( repository, tmp_path, @@ -433,7 +487,35 @@ def test_draft_release_replaces_incomplete_asset( ) assert result.returncode == 0, result.stderr assert ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture") in _release_calls(tmp_path) - assert [request.method for request, _ in server.log] == ["GET", "GET", "DELETE", "GET", "GET", "GET"] + assert [request.method for request, _ in server.log] == ["GET", "GET", "DELETE", "GET", "GET"] + + +def test_changed_draft_stops_before_upload( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + (assets / "fixture.zip").write_bytes(b"release archive") + draft = _release(notes, draft=True) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [draft] + ) + for _ in range(6): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( + draft | {"body": "Human-edited notes.\n"} + ) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets) + assert result.returncode != 0 + assert not _release_calls(tmp_path) + assert [request.method for request, _ in server.log] == ["GET"] * 8 def test_failed_upload_deletes_partial_asset_before_retry( @@ -472,7 +554,7 @@ def test_failed_upload_deletes_partial_asset_before_retry( for release in (draft, partial, partial): server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) server.expect_ordered_request(f"{prefix}/releases/assets/21", method="DELETE").respond_with_json({}) - for _ in range(3): + for _ in range(2): server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) result = _request( @@ -486,7 +568,7 @@ def test_failed_upload_deletes_partial_asset_before_retry( mode="stage", ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET"] * 5 + ["DELETE"] + ["GET"] * 2 + assert [request.method for request, _ in server.log] == ["GET"] * 5 + ["DELETE", "GET"] assert _release_calls(tmp_path) == [ ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), @@ -630,6 +712,7 @@ def test_failed_asset_attestation_is_reported( release = _release( notes, draft=False, + immutable=True, assets=[ { "name": asset_path.name, @@ -643,8 +726,6 @@ def test_failed_asset_attestation_is_reported( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [release] ) - for _ in range(3): - server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) result = _request( repository, tmp_path, @@ -656,6 +737,7 @@ def test_failed_asset_attestation_is_reported( ) assert result.returncode != 0 assert "release asset attestation could not be verified" in result.stderr + assert [request.method for request, _ in server.log] == ["GET", "GET"] assert ( _release_calls(tmp_path) == [("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] * 6 diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py index 6f640b6..9e0ae0b 100644 --- a/tests/reconcile_pypi_artifacts/test_reconcile.py +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -144,4 +144,6 @@ def route_request(self, method, url, *args, **kwargs): assert result.returncode != 0 assert expected_error in result.stderr if not remote_extra and not wrong_digest: - assert (tmp_path / "pypi-file-urls").read_text() == f"{url}\n{sdist_url}\n" + assert ( + tmp_path / "pypi-local-files" + ).read_text() == f"{distribution / wheel_name}\n{distribution / sdist_name}\n" diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 212ae23..c10f8e3 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -3,9 +3,14 @@ import subprocess import sys import tomllib +from datetime import UTC, datetime from pathlib import Path import pytest +import tomlkit + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tools.nab_cutoffs import remove_expired_cutoffs @pytest.fixture @@ -14,8 +19,10 @@ def cutoff_project(tmp_path: Path) -> Path: project.write_text( '[project]\nname = "example"\nversion = "0.1"\n' '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' '[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' '[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n' + '[[tool.nab.package-rules]]\nmatch = ["nab-index"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' '[[tool.nab.package-rules]]\nmatch = ["old-rule"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' '[[tool.nab.package-rules]]\nmatch = ["new-rule"]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', ) @@ -30,7 +37,7 @@ def cutoff_project(tmp_path: Path) -> Path: return project -def test_expire_cutoffs_removes_only_expired_rules(cutoff_project: Path) -> None: +def test_expire_cutoffs_preserves_older_deliberate_holds(cutoff_project: Path) -> None: source = Path(__file__).resolve().parents[2] / "tools" result = subprocess.run( [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], @@ -40,56 +47,50 @@ def test_expire_cutoffs_removes_only_expired_rules(cutoff_project: Path) -> None ) assert result.returncode == 0, result.stderr nab = tomllib.loads(cutoff_project.read_text())["tool"]["nab"] - assert set(nab["packages"]) == {"new"} - assert [rule["match"] for rule in nab["package-rules"]] == [["new-rule"]] + assert set(nab["packages"]) == {"nab", "old", "new"} + assert [rule["match"] for rule in nab["package-rules"]] == [["nab-index"], ["old-rule"], ["new-rule"]] def test_expire_cutoffs_removes_empty_package_table(cutoff_project: Path) -> None: - cutoff_project.write_text( - cutoff_project.read_text().replace('[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', "") - ) - source = Path(__file__).resolve().parents[2] / "tools" - result = subprocess.run( - [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], - text=True, - capture_output=True, - check=False, + project = cutoff_project.read_text().replace( + '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', + '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-10-18T05:45:09Z"\n', ) + project = project.replace('[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', "") + project = project.replace('[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', "") - assert result.returncode == 0, result.stderr - assert "packages" not in tomllib.loads(cutoff_project.read_text())["tool"]["nab"] + updated = remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) + assert "packages" not in tomllib.loads(updated)["tool"]["nab"] -def test_expire_cutoffs_accepts_toml_datetime(cutoff_project: Path) -> None: - cutoff_project.write_text(cutoff_project.read_text().replace('"2026-01-01T00:00:00Z"', "2026-01-01T00:00:00Z")) - source = Path(__file__).resolve().parents[2] / "tools" - result = subprocess.run( - [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], - text=True, - capture_output=True, - check=False, + +@pytest.mark.parametrize("reviewed", ["2026-10-23T02:44:40Z", "2026-10-23T16:52:06Z"]) +def test_expire_cutoffs_removes_each_reviewed_release_creator_floor(reviewed: str) -> None: + project = ( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + f'[tool.nab.packages.release-creator]\nuploaded-prior-to = "{reviewed}"\n' ) - assert result.returncode == 0, result.stderr - assert "old" not in tomllib.loads(cutoff_project.read_text())["tool"]["nab"]["packages"] + updated = remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) + assert "packages" not in tomllib.loads(updated)["tool"]["nab"] -def test_verify_cutoff_patch_accepts_expired_only(cutoff_project: Path) -> None: - source = Path(__file__).resolve().parents[2] / "tools" - subprocess.run([sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], check=True) - result = subprocess.run( - [sys.executable, str(source / "verify_cutoff_patch.py")], - cwd=cutoff_project.parent, - text=True, - capture_output=True, - check=False, + +def test_expire_cutoffs_accepts_toml_datetime(cutoff_project: Path) -> None: + cutoff_project.write_text( + cutoff_project.read_text().replace( + '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-01-01T00:00:00Z"', + "[tool.nab.packages.nab]\nuploaded-prior-to = 2026-10-18T05:45:09Z", + ), ) - assert result.returncode == 0, result.stderr + updated = remove_expired_cutoffs(cutoff_project.read_text(), datetime(2026, 11, 1, tzinfo=UTC)) + assert "nab" not in tomllib.loads(updated)["tool"]["nab"]["packages"] -def test_verify_cutoff_patch_accepts_subset_expired(cutoff_project: Path) -> None: + +def test_verify_cutoff_patch_rejects_deliberate_hold_removal(cutoff_project: Path) -> None: cutoff_project.write_text( - cutoff_project.read_text().replace('[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', "") + cutoff_project.read_text().replace('[tool.nab.packages.nab]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', "") ) source = Path(__file__).resolve().parents[2] / "tools" result = subprocess.run( @@ -100,7 +101,8 @@ def test_verify_cutoff_patch_accepts_subset_expired(cutoff_project: Path) -> Non check=False, ) - assert result.returncode == 0, result.stderr + assert result.returncode != 0 + assert "changes beyond expired Nab cutoffs" in result.stderr def test_verify_cutoff_patch_rejects_other_metadata(cutoff_project: Path) -> None: @@ -137,8 +139,10 @@ def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" committed = tmp_path / "committed.toml" generated = tmp_path / "generated.toml" - committed.write_text('packages = ["safe"]\n[tool.nab]\ncommand-line = ["--upgrade"]\n') - generated.write_text('packages = ["changed"]\n[tool.nab]\ncommand-line = []\n') + committed.write_text( + 'packages = ["safe"]\n[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = ["--upgrade"]\n' + ) + generated.write_text('packages = ["changed"]\n[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = []\n') result = subprocess.run( [sys.executable, str(source), str(committed), str(generated)], text=True, @@ -149,13 +153,53 @@ def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: assert "regenerated Nab lock differs" in result.stderr +def test_check_nab_lock_rejects_future_creation(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" + lock = tmp_path / "pylock.toml" + lock.write_text("[tool.nab]\ncreated-at = 2099-01-01T00:00:00Z\n") + result = subprocess.run( + [sys.executable, str(source), str(lock), str(lock)], text=True, capture_output=True, check=False + ) + assert result.returncode != 0 + assert "created in the future" in result.stderr + + +def test_check_nab_lock_rejects_forged_upload_time( + build_repository: Path, nab_executable: Path, tmp_path: Path +) -> None: + source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" + lock = build_repository / "pylock.toml" + document = tomlkit.parse(lock.read_text()) + package = next(item for item in document["packages"] if item["name"] == "hatchling") + package["wheels"][0]["upload-time"] = "2020-01-01T00:00:00Z" + lock.write_text(tomlkit.dumps(document)) + committed = tmp_path / "committed.toml" + committed.write_bytes(lock.read_bytes()) + + regenerated = subprocess.run( + [str(nab_executable), "lock", "--groups", "release-notes", "--output", "pylock.toml"], + cwd=build_repository, + text=True, + capture_output=True, + check=False, + ) + assert regenerated.returncode == 0, regenerated.stderr + result = subprocess.run( + [sys.executable, str(source), str(committed), str(lock)], text=True, capture_output=True, check=False + ) + assert result.returncode != 0 + assert "regenerated Nab lock differs" in result.stderr + + @pytest.mark.parametrize( ("package", "timestamp", "accepted"), [ - pytest.param("nab", "2026-10-19T00:00:00Z", True, id="reviewed-nab"), + pytest.param("nab", "2026-10-18T05:45:09Z", True, id="reviewed-nab"), + pytest.param("nab", "2026-10-19T00:00:00Z", False, id="unreviewed-nab"), pytest.param("nab", "2026-10-20T00:00:00Z", False, id="extended-nab"), pytest.param("release-creator", "2026-10-23T02:44:40Z", True, id="reviewed-release-creator"), - pytest.param("release-creator", "2026-10-23T02:44:41Z", False, id="extended-release-creator"), + pytest.param("release-creator", "2026-10-23T16:52:06Z", True, id="reviewed-release-creator-0.6.4"), + pytest.param("release-creator", "2026-10-23T16:52:07Z", False, id="extended-release-creator"), pytest.param("unreviewed", "2026-10-22T00:00:00Z", False, id="new-package"), ], ) diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index cc8247c..eba31b8 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -23,7 +23,6 @@ def test_prepare_jobs_transfer_the_uploaded_patch() -> None: jobs = workflow["jobs"] assert list(jobs) == ["assemble", "push"] assert jobs["assemble"]["outputs"]["artifact-id"] == "${{ steps.patch.outputs.artifact-id }}" - assert jobs["assemble"]["outputs"]["patch-sha"] == "${{ steps.digest.outputs.sha }}" assert ( next(step for step in jobs["assemble"]["steps"] if step.get("id") == "patch")["with"]["name"] == "release-patch" ) @@ -58,7 +57,7 @@ def test_prepare_builds_local_tool_once_with_locked_backends() -> None: steps = workflow["jobs"]["assemble"]["steps"] builds = [step for step in steps if step.get("name") == "Build local release tool wheel"] assert len(builds) == 1 - assert builds[0]["env"]["BUILD_CONSTRAINTS"] == "${{ steps.constraints.outputs.path }}" + assert builds[0]["env"]["BUILD_CONSTRAINTS"] == "${{ steps.tools.outputs.build-constraints }}" assert '--build-constraints "$BUILD_CONSTRAINTS"' in builds[0]["run"] assert "--require-build-hashes" in builds[0]["run"] assert any( @@ -83,7 +82,7 @@ def test_prepare_installs_hooks_while_building_changelog() -> None: def test_changelog_checkout_fetches_the_base_branch() -> None: - workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text()) + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/news.yaml").read_text()) checkout = next(step for step in workflow["jobs"]["changelog"]["steps"] if "checkout" in step.get("uses", "")) assert checkout["with"]["fetch-depth"] == 0 @@ -105,7 +104,7 @@ def test_release_notes_run_without_write_access() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) notes = workflow["jobs"]["notes"] assert notes["permissions"] == {"contents": "read"} - assert notes["steps"][0]["with"]["fetch-depth"] == 0 + assert notes["steps"][0]["with"]["fetch-depth"] == 1 assert "$/render-release-notes" in [step.get("uses") for step in notes["steps"]] assert notes["outputs"]["artifact-id"] == "${{ steps.artifact.outputs.artifact-id }}" assert ( diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py index 05fe274..ce35dad 100644 --- a/tests/render_release_notes/test_notes.py +++ b/tests/render_release_notes/test_notes.py @@ -59,7 +59,7 @@ def test_render_release_notes_uses_tagged_changelog( [PyPI release](https://pypi.org/project/fixture/0.2.0/) - **Full Changelog**: https://github.com/tox-dev/fixture/compare/0.1.0...0.2.0 + [Full Changelog](https://github.com/tox-dev/fixture/compare/0.1.0...0.2.0) """, ) assert "previous=0.1.0" in (tmp_path / "output").read_text() diff --git a/tests/resolve_pr_from_run/test_resolve.py b/tests/resolve_pr_from_run/test_resolve.py deleted file mode 100644 index 1a3a7d5..0000000 --- a/tests/resolve_pr_from_run/test_resolve.py +++ /dev/null @@ -1,188 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import action -from pytest_httpserver import HTTPServer - - -@pytest.mark.parametrize( - ("head_repository", "head_owner", "branch", "author_type", "conclusion"), - [ - pytest.param("tox-dev/fixture", "tox-dev", "dependabot/uv", "Bot", "success", id="same-repo-bot"), - pytest.param("external/fixture", "external", "fix/news", "Bot", "failure", id="fork-bot-failed-ci"), - pytest.param("external/fixture", "external", "fix/news", "User", "success", id="human"), - ], -) -def test_resolve_pr_from_run_matches_current_head( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - head_repository: str, - head_owner: str, - branch: str, - author_type: str, - conclusion: str, -) -> None: - server, environment = github_api - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json( - { - "id": 42, - "repository": {"full_name": "tox-dev/fixture"}, - "event": "pull_request", - "path": ".github/workflows/check.yaml", - "status": "completed", - "conclusion": conclusion, - "head_repository": {"full_name": head_repository, "owner": {"login": head_owner}}, - "head_branch": branch, - "head_sha": "a" * 40, - "pull_requests": [], - }, - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json( - [ - { - "number": 7, - "state": "open", - "user": {"type": author_type}, - "head": {"sha": "a" * 40, "repo": {"full_name": head_repository}}, - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - }, - ], - ) - result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment) - assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text() == f"number=7\nbot-author={str(author_type == 'Bot').lower()}\n" - assert server.log[1][0].args["head"] == f"{head_owner}:{branch}" - - -@pytest.mark.parametrize( - ("head_sha", "head_repository", "response"), - [ - pytest.param("b" * 40, "external/fixture", "stale-head", id="stale-head"), - pytest.param("a" * 40, "other/fixture", "wrong-fork", id="wrong-fork"), - pytest.param("a" * 40, "external/fixture", "missing", id="no-pr"), - ], -) -def test_resolve_pr_from_run_skips_unmatched_pr( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - head_sha: str, - head_repository: str, - response: str, -) -> None: - server, environment = github_api - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json( - { - "id": 42, - "repository": {"full_name": "tox-dev/fixture"}, - "event": "pull_request", - "path": ".github/workflows/check.yaml", - "status": "completed", - "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}}, - "head_branch": "fix/news", - "head_sha": "a" * 40, - }, - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json( - [] - if response == "missing" - else [ - { - "number": 7, - "state": "open", - "user": {"type": "Bot"}, - "head": {"sha": head_sha, "repo": {"full_name": head_repository}}, - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - }, - ], - ) - result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment) - assert result.returncode == 0, result.stderr - assert not (tmp_path / "output").exists() - - -def test_resolve_pr_from_run_rejects_ambiguous_pr( - repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json( - { - "id": 42, - "repository": {"full_name": "tox-dev/fixture"}, - "event": "pull_request", - "path": ".github/workflows/check.yaml", - "status": "completed", - "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}}, - "head_branch": "fix/news", - "head_sha": "a" * 40, - }, - ) - candidate = { - "number": 7, - "state": "open", - "user": {"type": "Bot"}, - "head": {"sha": "a" * 40, "repo": {"full_name": "external/fixture"}}, - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - } - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json( - [candidate, candidate | {"number": 8}], - ) - result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment) - assert result.returncode != 0 - assert "source run matches multiple pull requests" in result.stderr - - -@pytest.mark.parametrize( - ("field", "value"), - [ - pytest.param("event", "pull_request_target", id="privileged-event"), - pytest.param("path", ".github/workflows/release.yaml", id="wrong-workflow"), - pytest.param("status", "in_progress", id="unfinished-run"), - pytest.param("repository", {"full_name": "other/fixture"}, id="wrong-repository"), - pytest.param("id", 43, id="wrong-run-id"), - ], -) -def test_resolve_pr_from_run_rejects_untrusted_source( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - field: str, - value: _JsonValue, -) -> None: - server, environment = github_api - source: dict[str, _JsonValue] = { - "id": 42, - "repository": {"full_name": "tox-dev/fixture"}, - "event": "pull_request", - "path": ".github/workflows/check.yaml", - "status": "completed", - "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}}, - "head_branch": "fix/news", - "head_sha": "a" * 40, - } - source[field] = value - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json(source) - result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment) - assert result.returncode != 0 - assert len(server.log) == 1 - - -def test_resolve_pr_from_run_rejects_non_numeric_id( - repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - result = action( - repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", RUN_ID="../42", **environment - ) - assert result.returncode != 0 - assert "run-id must be a decimal integer" in result.stderr - assert not server.log - - -type _JsonValue = str | int | dict[str, _JsonValue] diff --git a/tests/select_previous_tag/test_select.py b/tests/select_previous_tag/test_select.py index 57c2ddc..92a6be5 100644 --- a/tests/select_previous_tag/test_select.py +++ b/tests/select_previous_tag/test_select.py @@ -1,74 +1,64 @@ from pathlib import Path -import pytest from conftest import action, run def test_select_previous_tag_uses_version_order(repository: Path, tmp_path: Path, action_scripts: Path) -> None: for tag in ("0.2.0", "0.10.0", "1.0.0"): run(repository, "git", "tag", tag) + result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="1.0.0", TAG="1.0.0") + assert result.returncode == 0, result.stderr assert (tmp_path / "output").read_text() == "previous=0.10.0\n" def test_select_previous_tag_accepts_first_release(repository: Path, tmp_path: Path, action_scripts: Path) -> None: result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="0.1.0", TAG="0.1.0") + assert result.returncode == 0, result.stderr assert (tmp_path / "output").read_text() == "previous=\n" -@pytest.mark.parametrize( - "target", - [ - pytest.param("1.1.0", id="bare-version"), - pytest.param("pyproject-fmt/1.1.0", id="qualified-tag"), - ], -) -def test_select_previous_tag_scopes_package( - repository: Path, tmp_path: Path, action_scripts: Path, target: str -) -> None: - for tag in ("pyproject-fmt/1.0.0", "pyproject-fmt/1.1.0", "tox-toml-fmt/1.0.5"): +def test_select_previous_tag_includes_prerelease(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + for tag in ("v1.0.0", "v1.1.0rc1", "v1.1.0"): run(repository, "git", "tag", tag) - result = action( - repository, - tmp_path, - action_scripts, - "select-previous-tag", - version=target, - TAG=target, - PACKAGE="pyproject-fmt", - ) + + result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="v1.1.0", TAG="v1.1.0") + assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text() == "previous=pyproject-fmt/1.0.0\n" - - -@pytest.mark.parametrize( - ("stable_only", "previous"), - [ - pytest.param("false", "v1.1.0rc1", id="include-prerelease"), - pytest.param("true", "v1.0.0", id="stable-only"), - ], -) -def test_select_previous_tag_filters_prereleases( - repository: Path, tmp_path: Path, action_scripts: Path, stable_only: str, previous: str -) -> None: - for tag in ("v1.0.0", "v1.1.0rc1", "v1.1.0"): + assert (tmp_path / "output").read_text() == "previous=v1.1.0rc1\n" + + +def test_select_previous_tag_scopes_namespaced_tags(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + for tag in ("pyproject-fmt/1.1.0", "tox-toml-fmt/5.0.0", "pyproject-fmt/1.2.0"): run(repository, "git", "tag", tag) + result = action( repository, tmp_path, action_scripts, "select-previous-tag", - version="v1.1.0", - TAG="v1.1.0", - STABLE_ONLY=stable_only, + version="pyproject-fmt/1.2.0", + TAG="pyproject-fmt/1.2.0", ) + assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text() == f"previous={previous}\n" + assert (tmp_path / "output").read_text() == "previous=pyproject-fmt/1.1.0\n" def test_select_previous_tag_requires_current_tag(repository: Path, tmp_path: Path, action_scripts: Path) -> None: result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="0.2.0", TAG="0.2.0") + + assert result.returncode != 0 + assert not (tmp_path / "output").exists() + + +def test_select_previous_tag_rejects_nested_namespace(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + run(repository, "git", "tag", "a/b/1.0") + + result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="a/b/1.0", TAG="a/b/1.0") + assert result.returncode != 0 + assert "at most one namespace separator" in result.stderr assert not (tmp_path / "output").exists() diff --git a/tests/upgrade_release_tools/test_hooks.py b/tests/upgrade_release_tools/test_hooks.py deleted file mode 100644 index a18df25..0000000 --- a/tests/upgrade_release_tools/test_hooks.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -from pathlib import Path - -import pytest -import yaml -from conftest import action, run - - -@pytest.fixture -def hook_repository(tmp_path: Path) -> Path: - repository = tmp_path / "repository" - repository.mkdir() - (repository / "pylock.toml").write_text( - 'lock-version = "1.0"\ncreated-by = "test"\nrequires-python = ">=3.15"\npackages = []\n', - ) - (repository / ".pre-commit-config.yaml").write_text( - "repos:\n - repo: local\n hooks:\n - id: test\n name: test\n" - " entry: python -c 'pass'\n language: python\n" - " additional_dependencies: [mdformat-gfm==0.1.0]\n" - ) - run(repository, "git", "init", "-b", "main") - run(repository, "git", "add", ".") - run(repository, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "Initial") - return repository - - -def test_hook_refresh_resolves_exact_pin(hook_repository: Path, tmp_path: Path, action_scripts: Path) -> None: - result = action( - hook_repository, - tmp_path, - action_scripts, - "refresh-hook-pins", - version="", - ) - assert result.returncode == 0, result.stderr - config = yaml.safe_load((hook_repository / ".pre-commit-config.yaml").read_text()) - dependencies = config["repos"][0]["hooks"][0]["additional_dependencies"] - assert dependencies[0] == "mdformat-gfm==1.0.0" - assert any(item.startswith("markdown-it-py==") for item in dependencies) - assert all("==" in item for item in dependencies) - - -def test_hook_refresh_freezes_only_cooled_revisions( - hook_repository: Path, tmp_path: Path, action_scripts: Path -) -> None: - remote = tmp_path / "remote" - remote.mkdir() - run(remote, "git", "init", "-b", "main") - (remote / ".pre-commit-hooks.yaml").write_text( - "- id: fixture\n name: fixture\n entry: true\n language: system\n" - ) - run(remote, "git", "add", ".") - subprocess.run( - ["git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "Old hook"], - cwd=remote, - env=os.environ | {"GIT_AUTHOR_DATE": "2020-01-01T00:00:00Z", "GIT_COMMITTER_DATE": "2020-01-01T00:00:00Z"}, - check=True, - capture_output=True, - ) - run(remote, "git", "tag", "v1.0.0") - old_commit = run(remote, "git", "rev-parse", "HEAD").stdout.strip() - (remote / "version.txt").write_text("new\n") - run(remote, "git", "add", ".") - run(remote, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "New hook") - run(remote, "git", "tag", "v2.0.0") - (hook_repository / ".pre-commit-config.yaml").write_text( - f"repos:\n - repo: {remote.as_uri()}\n rev: v1.0.0\n hooks:\n - id: fixture\n" - ) - result = action( - hook_repository, - tmp_path, - action_scripts, - "refresh-hook-pins", - version="", - ) - assert result.returncode == 0, result.stderr - assert yaml.safe_load((hook_repository / ".pre-commit-config.yaml").read_text())["repos"][0]["rev"] == old_commit - - -@pytest.mark.parametrize( - ("path", "accepted"), - [ - pytest.param(None, True, id="clean"), - pytest.param(".pre-commit-config.yaml", True, id="hook-config"), - pytest.param("other.txt", False, id="other-file"), - ], -) -def test_hook_refresh_checks_paths( - hook_repository: Path, tmp_path: Path, action_scripts: Path, path: str | None, accepted: bool -) -> None: - if path is not None: - if path == ".pre-commit-config.yaml": - (hook_repository / path).write_text((hook_repository / path).read_text() + "# updated\n") - else: - (hook_repository / path).write_text("updated\n") - result = action(hook_repository, tmp_path, action_scripts, "refresh-hook-pins", version="") - assert (result.returncode == 0) == accepted - assert ("unexpected path" in result.stderr) == (not accepted) - - -@pytest.mark.parametrize("changed", [pytest.param(False, id="unchanged"), pytest.param(True, id="changed")]) -def test_hook_refresh_emits_patch(hook_repository: Path, tmp_path: Path, action_scripts: Path, changed: bool) -> None: - if not changed: - (hook_repository / ".pre-commit-config.yaml").write_text( - "repos:\n - repo: local\n hooks:\n - id: test\n name: test\n" - " entry: python -c 'pass'\n language: system\n" - ) - run(hook_repository, "git", "add", ".pre-commit-config.yaml") - run( - hook_repository, - "git", - "-c", - "user.name=Test", - "-c", - "user.email=test@example.org", - "commit", - "-m", - "Empty hooks", - ) - result = action(hook_repository, tmp_path, action_scripts, "refresh-hook-pins", version="") - assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text() == f"changed={str(changed).lower()}\n" - assert (tmp_path / "hooks.patch").exists() == changed - - -def test_hook_refresh_uses_relative_cutoff() -> None: - action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-hook-pins/action.yml").read_text()) - refresh = next(step for step in action["runs"]["steps"] if step.get("name") == "Refresh exact hook dependencies") - assert "--exclude-newer '7 days'" in refresh["run"] diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 6c58d70..e56e9b2 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -56,6 +56,17 @@ def test_upgrade_pull_request_uses_repository_app() -> None: assert step["env"]["GH_TOKEN"] == "${{ steps.token.outputs.token }}" +def test_hook_refresh_uses_seven_day_cutoff_for_hooks_and_dependencies() -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text() + ) + steps = workflow["jobs"]["generate-hooks"]["steps"] + revisions = next(step for step in steps if step.get("name") == "Refresh frozen hook revisions") + dependencies = next(step for step in steps if step.get("name") == "Refresh exact hook dependencies") + assert "--cooldown-days 7" in revisions["run"] + assert "--exclude-newer '7 days'" in dependencies["run"] + + @pytest.fixture def lock_repository(tmp_path: Path, test_package_index: str) -> Path: repository = tmp_path / "repository" @@ -80,7 +91,16 @@ def lock_repository(tmp_path: Path, test_package_index: str) -> Path: def test_upgrade_locks_only_selected_groups(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: project = lock_repository / "pyproject.toml" project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')) - installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") + installed = action( + lock_repository, + tmp_path, + upgrade_scripts, + "install-locked-tools", + version="", + GROUPS="lock-tools", + LOCK_SOURCE="shared", + VENV=str(tmp_path / "lock-tools"), + ) assert installed.returncode == 0, installed.stderr resolved = execute( lock_repository, @@ -101,7 +121,16 @@ def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]') + '[tool.nab.packages.old]\nuploaded-prior-to = "2020-01-01T00:00:00Z"\n' ) - installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") + installed = action( + lock_repository, + tmp_path, + upgrade_scripts, + "install-locked-tools", + version="", + GROUPS="lock-tools", + LOCK_SOURCE="shared", + VENV=str(tmp_path / "lock-tools"), + ) assert installed.returncode == 0, installed.stderr refreshed = execute( lock_repository, @@ -132,125 +161,28 @@ def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path assert project.read_bytes() == project_bytes -@pytest.mark.parametrize( - ("groups", "selection", "source"), - [ - pytest.param("", "--build-requirements", "build-or-groups", id="build-only"), - pytest.param("release-check", "--groups", "build-or-groups", id="selected-group"), - pytest.param("", "", "project", id="project-runtime"), - ], -) -def test_upgrade_preserves_secondary_lock_matrix( - lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, groups: str, selection: str, source: str -) -> None: +def test_upgrade_preserves_deliberate_nab_hold(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: project = lock_repository / "pyproject.toml" project.write_text( project.read_text() - .replace('version = "0.1.0"', 'version = "0.1.0"\ndependencies = ["packaging>=26"]') - .replace("release = []", 'release-check = ["prek>=0.2"]') - + '[build-system]\nrequires = ["setuptools>=80"]\nbuild-backend = "setuptools.build_meta"\n', - ) - installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") - assert installed.returncode == 0, installed.stderr - primary = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", - tmp_path, - LOCK_GROUPS="release-check", - MODE="refresh", - ) - assert primary.returncode == 0, primary.stderr - additional = lock_repository / "pylock.build.toml" - command = [ - "uv", - "run", - "--no-project", - "--python", - str(tmp_path / "lock-tools"), - "nab", - "lock", - "pyproject.toml", - ] - if selection: - command.append(selection) - if groups: - command.append(groups) - command.extend( - [ - "--project-matrix-python", - ">=3.15,<3.16", - "--project-matrix-platforms", - "linux_x86_64", - "macos_arm64", - "--project-matrix-implementations", - "cpython", - "--output", - str(additional), - ], - ) - initial = subprocess.run(command, cwd=lock_repository, text=True, capture_output=True, check=False) - assert initial.returncode == 0, initial.stderr - expected = additional.read_bytes() - environment = { - "LOCK_GROUPS": "release-check", - "MODE": "check", - "ADDITIONAL_LOCK": additional.name, - "ADDITIONAL_GROUPS": groups, - "ADDITIONAL_SELECTION": source, - "MATRIX_PYTHON": ">=3.15,<3.16", - "MATRIX_PLATFORMS": "linux_x86_64 macos_arm64", - "MATRIX_IMPLEMENTATIONS": "cpython", - } - checked = execute(lock_repository, upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, **environment) - assert checked.returncode == 0, checked.stderr - assert additional.read_bytes() == expected - if source == "project": - names = {package["name"] for package in tomllib.loads(expected.decode())["packages"]} - assert "packaging" in names - assert "setuptools" not in names - - -def test_upgrade_rejects_secondary_matrix_change(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: - project = lock_repository / "pyproject.toml" - project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')) - installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") - assert installed.returncode == 0, installed.stderr - initial = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", - tmp_path, - LOCK_GROUPS="release-check", - MODE="refresh", + + '[[tool.nab.package-rules]]\nmatch = ["nab"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', ) - assert initial.returncode == 0, initial.stderr - (lock_repository / "pylock.build.toml").write_bytes((lock_repository / "pylock.toml").read_bytes()) - result = execute( + installed = action( lock_repository, - upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, - LOCK_GROUPS="release-check", - MODE="refresh", - ADDITIONAL_LOCK="pylock.build.toml", - ADDITIONAL_GROUPS="release-check", - MATRIX_PYTHON=">=3.15,<3.16", - MATRIX_PLATFORMS="linux_x86_64", - MATRIX_IMPLEMENTATIONS="cpython", - ) - assert result.returncode != 0 - assert "changed its target environments" in result.stderr - - -def test_upgrade_expires_old_nab_floor(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: - project = lock_repository / "pyproject.toml" - project.write_text( - project.read_text() - + '[[tool.nab.package-rules]]\nmatch = ["nab"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', + upgrade_scripts, + "install-locked-tools", + version="", + GROUPS="lock-tools", + LOCK_SOURCE="shared", + VENV=str(tmp_path / "lock-tools"), ) - installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") assert installed.returncode == 0, installed.stderr expired = execute(lock_repository, upgrade_scripts / "refresh-release-lock-2.sh", tmp_path) assert expired.returncode == 0, expired.stderr - assert "package-rules" not in tomllib.loads(project.read_text())["tool"]["nab"] + assert tomllib.loads(project.read_text())["tool"]["nab"]["package-rules"][0]["uploaded-prior-to"] == ( + "2026-01-01T00:00:00Z" + ) @pytest.mark.parametrize( @@ -266,7 +198,7 @@ def test_upgrade_emits_patch_for_allowed_change( (lock_repository / changed_path).write_text((lock_repository / changed_path).read_text() + "\n") result = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-7.sh", + upgrade_scripts / "refresh-release-lock-4.sh", tmp_path, MODE="refresh", GITHUB_OUTPUT=str(tmp_path / "output"), @@ -283,7 +215,7 @@ def test_upgrade_check_mode_rejects_lock_drift(lock_repository: Path, tmp_path: (lock_repository / "pylock.toml").write_text('lock-version = "1.1"\n') result = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-7.sh", + upgrade_scripts / "refresh-release-lock-4.sh", tmp_path, MODE="check", GITHUB_OUTPUT=str(tmp_path / "output"), @@ -291,20 +223,6 @@ def test_upgrade_check_mode_rejects_lock_drift(lock_repository: Path, tmp_path: assert result.returncode != 0 -@pytest.mark.parametrize( - "path", - [pytest.param("other.txt", id="unrelated-file"), pytest.param("uv.lock", id="uv-lock")], -) -def test_upgrade_rejects_unrelated_change( - lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, path: str -) -> None: - (tmp_path / "project-after-expiry.toml").write_bytes((lock_repository / "pyproject.toml").read_bytes()) - (lock_repository / path).write_text("changed\n") - result = execute(lock_repository, upgrade_scripts / "refresh-release-lock-6.sh", tmp_path) - assert result.returncode != 0 - assert "outside the lock and cutoff policy" in result.stderr - - @pytest.mark.parametrize( ("path", "accepted"), [ @@ -334,34 +252,25 @@ def test_upgrade_rejects_unrelated_patch( assert ("unexpected dependency path" in result.stderr) == (not accepted) -def test_upgrade_allows_only_named_secondary_lock(lock_repository: Path, tmp_path: Path, publish_scripts: Path) -> None: - for name in ("pylock.build.toml", "pylock.pgo.toml"): - (lock_repository / name).write_text("before\n") +@pytest.mark.parametrize("additional_lock", ["other.txt", "../pylock.other.toml"], ids=["arbitrary", "parent"]) +def test_upgrade_rejects_unsafe_additional_lock( + lock_repository: Path, tmp_path: Path, publish_scripts: Path, additional_lock: str +) -> None: + (lock_repository / "pylock.toml").write_text("before\n") run(lock_repository, "git", "add", ".") - run(lock_repository, "git", "commit", "-m", "Add secondary locks") - (lock_repository / "pylock.build.toml").write_text("after\n") - patch = run(lock_repository, "git", "diff", "--binary", "--", "pylock.build.toml").stdout - run(lock_repository, "git", "restore", "pylock.build.toml") - patch_dir = tmp_path / "release-tools-lock" - patch_dir.mkdir() - (patch_dir / "lock.patch").write_text(patch) - applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) - assert applied.returncode == 0, applied.stderr - accepted = execute( - lock_repository, - publish_scripts / "publish-check-dependency-patch-paths.sh", - tmp_path, - ADDITIONAL_LOCK="pylock.build.toml", - ) - assert accepted.returncode == 0, accepted.stderr - rejected = execute( + run(lock_repository, "git", "commit", "-m", "Add lock") + (lock_repository / "pylock.toml").write_text("after\n") + run(lock_repository, "git", "add", "pylock.toml") + + result = execute( lock_repository, publish_scripts / "publish-check-dependency-patch-paths.sh", tmp_path, - ADDITIONAL_LOCK="pylock.pgo.toml", + ADDITIONAL_LOCK=additional_lock, ) - assert rejected.returncode != 0 - assert "unexpected dependency path: pylock.build.toml" in rejected.stderr + + assert result.returncode != 0 + assert "invalid additional lock" in result.stderr @pytest.mark.parametrize( diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py deleted file mode 100644 index c124fc8..0000000 --- a/tests/validate_python_distribution/test_distribution.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path -from zipfile import ZipFile - -import pytest -from conftest import action, run - - -@pytest.fixture -def distribution( - source_project: Path, nab_executable: Path, test_package_index: str, monkeypatch: pytest.MonkeyPatch -) -> tuple[Path, Path]: - monkeypatch.delenv("SSL_CERT_FILE", raising=False) - project = source_project / "pyproject.toml" - project.write_text( - project.read_text() - + '\n[dependency-groups]\npkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"]\n' - + f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n', - ) - run( - source_project, - str(nab_executable), - "lock", - "--python", - "3.15", - "--groups", - "pkg-meta", - ) - output = source_project / "dist" - run( - source_project, - "uv", - "build", - "--no-build-isolation", - "--python", - sys.executable, - "--sdist", - "--wheel", - "--out-dir", - str(output), - ) - return source_project, output - - -def test_validate_python_distribution_accepts_built_files( - distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path -) -> None: - repository = distribution[0] - result = action( - repository, - tmp_path, - action_scripts, - "validate-python-distribution", - version="0.2.0", - ) - assert result.returncode == 0, result.stderr - assert "PASSED" in result.stdout - - -def test_validate_python_distribution_rejects_invalid_sdist( - distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path -) -> None: - repository, output = distribution - next(output.glob("*.tar.gz")).write_bytes(b"invalid sdist") - result = action( - repository, - tmp_path, - action_scripts, - "validate-python-distribution", - version="0.2.0", - ) - assert result.returncode != 0 - - -def test_validate_python_distribution_rejects_bad_wheel_contents( - distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path -) -> None: - repository, output = distribution - wheel = next(output.glob("*.whl")) - with ZipFile(wheel) as archive: - contents = {name: archive.read(name) for name in archive.namelist()} - record = next(name for name in contents if name.endswith(".dist-info/RECORD")) - contents["fixture/compiled.pyc"] = b"invalid bytecode" - contents[record] += b"fixture/compiled.pyc,,\n" - with ZipFile(wheel, "w") as archive: - for name, content in contents.items(): - archive.writestr(name, content) - result = action( - repository, - tmp_path, - action_scripts, - "validate-python-distribution", - version="0.2.0", - ) - assert result.returncode != 0 - assert "W001" in result.stdout diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index a88f794..c5a636b 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -1,29 +1,134 @@ from __future__ import annotations import os +import shutil import subprocess +import sys from pathlib import Path +from typing import Any import pytest import yaml from conftest import run -def test_validator_uses_shared_distribution_action() -> None: +@pytest.fixture(scope="module") +def release_workflow() -> dict[str, Any]: + return yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text() + ) + + +def run_release_step( + workflow: dict[str, Any], job: str, name: str, repository: Path, **environment: str +) -> subprocess.CompletedProcess[str]: + step = next(item for item in workflow["jobs"][job]["steps"] if item.get("name") == name) + return subprocess.run( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]], + cwd=repository, + env=os.environ | environment, + text=True, + capture_output=True, + check=False, + ) + + +@pytest.fixture +def build_constraints(build_repository: Path, tmp_path: Path) -> Path: + source = Path(__file__).resolve().parents[2] + path = tmp_path / "build-constraints.txt" + result = subprocess.run( + [sys.executable, str(source / "tools/build_constraints.py"), str(path), str(build_repository / "pylock.toml")], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return path + + +def test_validator_isolates_release_tools_from_build() -> None: workflow = yaml.load( (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text(), Loader=yaml.BaseLoader, ) - assert any(step.get("uses") == "$/validate-python-distribution" for step in workflow["jobs"]["validate"]["steps"]) assert set(workflow["on"]["workflow_call"]["inputs"]) == { + "source", + "artifact-prefix", "changelog", "artifact-base-url", "artifact-label", "local-tool-source", - "source", - "artifact-prefix", } - assert set(workflow["jobs"]) == {"source-check", "validate"} + assert set(workflow["jobs"]) == {"source-check", "constraints", "build", "validate"} + assert workflow["jobs"]["build"]["needs"] == "constraints" + assert not any(step.get("uses", "").startswith("$/install-") for step in workflow["jobs"]["build"]["steps"]) + assert workflow["jobs"]["validate"]["needs"] == "build" + assert not any(step.get("uses") == "$/install-locked-tools" for step in workflow["jobs"]["validate"]["steps"]) + validator = next( + step for step in workflow["jobs"]["validate"]["steps"] if step.get("name") == "Install distribution validators" + ) + assert "twine>=7" in validator["run"] + assert "check-wheel-contents>=0.6.3" in validator["run"] + + +def test_validator_builds_reproducible_distributions_and_checks_them( + build_repository: Path, build_constraints: Path, tmp_path: Path, release_workflow: dict[str, Any] +) -> None: + assert "hatchling==" in build_constraints.read_text() + environment = {"RUNNER_TEMP": str(tmp_path), "SOURCE": "."} + interpreter = run_release_step( + release_workflow, "build", "Select build interpreter", build_repository, **environment + ) + assert interpreter.returncode == 0, interpreter.stderr + outputs: list[dict[str, bytes]] = [] + for _ in range(2): + result = run_release_step(release_workflow, "build", "Build wheel and sdist", build_repository, **environment) + assert result.returncode == 0, result.stderr + outputs.append( + {path.name: path.read_bytes() for path in (build_repository / "dist").iterdir() if path.suffix != ""} + ) + shutil.rmtree(build_repository / "dist") + assert set(outputs[0]) == {"fixture-0.2.0-py3-none-any.whl", "fixture-0.2.0.tar.gz"} + assert outputs[0] == outputs[1] + for name, content in outputs[0].items(): + path = build_repository / "dist" / name + path.parent.mkdir(exist_ok=True) + path.write_bytes(content) + validators = run_release_step( + release_workflow, "validate", "Install distribution validators", build_repository, **environment + ) + assert validators.returncode == 0, validators.stderr + for name in ("Check distribution metadata", "Check wheel contents"): + result = run_release_step(release_workflow, "validate", name, build_repository, **environment) + assert result.returncode == 0, result.stderr + + +def test_validator_rejects_nonempty_distribution_directory(tmp_path: Path, release_workflow: dict[str, Any]) -> None: + (tmp_path / "dist").mkdir() + (tmp_path / "dist" / "old.whl").write_bytes(b"old") + result = run_release_step(release_workflow, "build", "Require empty distribution directory", tmp_path) + assert result.returncode != 0 + + +def test_validator_rejects_backend_hash_mismatch( + build_repository: Path, build_constraints: Path, tmp_path: Path, release_workflow: dict[str, Any] +) -> None: + lines = build_constraints.read_text().splitlines() + for index, line in enumerate(lines): + if line.startswith("hatchling=="): + lines[index] = " ".join( + "--hash=sha256:" + "0" * 64 if part.startswith("--hash=sha256:") else part for part in line.split() + ) + build_constraints.write_text("\n".join(lines) + "\n") + environment = {"RUNNER_TEMP": str(tmp_path), "SOURCE": ".", "UV_CACHE_DIR": str(tmp_path / "uv-cache")} + interpreter = run_release_step( + release_workflow, "build", "Select build interpreter", build_repository, **environment + ) + assert interpreter.returncode == 0, interpreter.stderr + result = run_release_step(release_workflow, "build", "Build wheel and sdist", build_repository, **environment) + assert result.returncode != 0 + assert "Hash mismatch" in result.stderr @pytest.mark.parametrize( @@ -31,8 +136,8 @@ def test_validator_uses_shared_distribution_action() -> None: [ pytest.param("lightweight", "refs/tags/0.2.0", True, id="lightweight-tag"), pytest.param("annotated", "refs/tags/0.2.0", True, id="annotated-tag"), + pytest.param("lightweight", "refs/heads/0.2.0", False, id="branch-event-on-tagged-commit"), pytest.param("branch", "refs/tags/0.2.0", False, id="same-name-branch"), - pytest.param("lightweight", "refs/heads/main", False, id="branch-event"), pytest.param("off-main", "refs/tags/0.2.0", False, id="tagged-off-main-commit"), ], ) @@ -51,7 +156,7 @@ def test_validator_requires_exact_tag(repository: Path, tag_kind: str, event_ref (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text() ) verify = next( - step for step in workflow["jobs"]["validate"]["steps"] if step.get("name") == "Verify the exact release tag" + step for step in workflow["jobs"]["build"]["steps"] if step.get("name") == "Verify the exact release tag" ) result = subprocess.run( ["bash", "-c", verify["run"]], @@ -62,3 +167,41 @@ def test_validator_requires_exact_tag(repository: Path, tag_kind: str, event_ref check=False, ) assert (result.returncode == 0) is accepted, result.stderr + + +@pytest.mark.parametrize( + ("tag", "filenames", "accepted"), + [ + pytest.param("1.2.3", ("example-1.2.3-py3-none-any.whl", "example-1.2.3.tar.gz"), True, id="matching"), + pytest.param("v1.2.3", ("example-1.2.3-py3-none-any.whl", "example-1.2.3.tar.gz"), True, id="v-prefix"), + pytest.param("1.2.3", ("example-1.2.4-py3-none-any.whl", "example-1.2.3.tar.gz"), False, id="wrong-wheel"), + pytest.param("1.2.3", ("example-1.2.3-py3-none-any.whl", "example-1.2.4.tar.gz"), False, id="wrong-sdist"), + pytest.param( + "1.2.3", + ("example-1.2.3-py3-none-any.whl", "example-1.2.3.tar.gz", "unexpected.txt"), + False, + id="extra-file", + ), + ], +) +def test_validator_matches_built_version_to_tag( + tmp_path: Path, tag: str, filenames: tuple[str, ...], accepted: bool +) -> None: + (tmp_path / "dist").mkdir() + for filename in filenames: + (tmp_path / "dist" / filename).touch() + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text() + ) + check = next( + step for step in workflow["jobs"]["build"]["steps"] if step.get("name") == "Match distribution version to tag" + ) + result = subprocess.run( + ["bash", "-c", check["run"]], + cwd=tmp_path, + env=os.environ | {"TAG": tag}, + text=True, + capture_output=True, + check=False, + ) + assert (result.returncode == 0) is accepted, result.stderr diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index 8fdcf40..0b64f7b 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -1,6 +1,5 @@ from __future__ import annotations -from hashlib import sha256 from pathlib import Path from textwrap import dedent @@ -143,52 +142,6 @@ def test_validate_rejects_stale_branch(repository: Path, tmp_path: Path, action_ assert "changed during release preparation" in result.stderr -def test_validate_rejects_dirty_checkout(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "CHANGELOG.md").write_text("Changed before validation.\n") - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") - assert result.returncode != 0 - assert "clean checkout" in result.stderr - - -def test_validate_rejects_noop_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (tmp_path / "release.patch").write_text( - dedent("""\ - diff --git a/CHANGELOG.md b/CHANGELOG.md - --- a/CHANGELOG.md - +++ b/CHANGELOG.md - @@ -3 +3 @@ - - - + - """), - ) - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") - assert result.returncode != 0 - assert "release patch contains no changes" in result.stderr - - -def test_validate_rejects_changed_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - result = action( - repository, - tmp_path, - action_scripts, - "validate-release-patch", - version="0.2.0", - EXPECTED_PATCH_SHA="0" * 64, - ) - assert result.returncode != 0 - assert "release patch differs from the validated artifact" in result.stderr - - -def test_validate_requires_digest_when_checks_skipped(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", CHECKS="false") - assert result.returncode != 0 - assert "skipping release checks requires a validated patch digest" in result.stderr - - def test_validate_rejects_unknown_check_mode(repository: Path, tmp_path: Path, action_scripts: Path) -> None: assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 run(repository, "git", "reset", "--hard", "HEAD") @@ -207,7 +160,6 @@ def test_validate_key_job_uses_only_checked_patch(repository: Path, tmp_path: Pa "validate-release-patch", version="0.2.0", CHECKS="false", - EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), PATH="/usr/bin:/bin", ) assert result.returncode == 0, result.stderr @@ -252,7 +204,6 @@ def test_validate_rejects_unsupported_fragment_path( "validate-release-patch", version="0.2.0", CHECKS="false", - EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), ) assert result.returncode != 0 assert "unexpected release path status" in result.stderr @@ -275,7 +226,6 @@ def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Pa "validate-release-patch", version="0.2.0", CHECKS="false", - EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), ) assert result.returncode != 0 assert "unexpected release path status" in result.stderr @@ -313,7 +263,6 @@ def test_validate_requires_explicit_towncrier_config( "validate-release-patch", version="0.2.0", CHECKS="false", - EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), ) assert result.returncode != 0 assert error in result.stderr @@ -337,7 +286,7 @@ def test_validate_rejects_unsafe_file_mode( run(repository, "git", "reset", "--hard", "HEAD") result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 - assert "unexpected release file mode" in result.stderr + assert ("unexpected release path" if file_mode == "symlink" else "unexpected release file mode") in result.stderr def test_validate_rejects_nonregular_deleted_fragment(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -357,7 +306,6 @@ def test_validate_rejects_nonregular_deleted_fragment(repository: Path, tmp_path "validate-release-patch", version="0.2.0", CHECKS="false", - EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), ) assert result.returncode != 0 assert "unexpected previous release file mode" in result.stderr @@ -369,7 +317,7 @@ def test_validate_rejects_removed_changelog(repository: Path, tmp_path: Path, ac run(repository, "git", "reset", "--hard", "HEAD") result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 - assert "unexpected release file mode in CHANGELOG.md" in result.stderr + assert "unexpected release path" in result.stderr def test_validate_rejects_missing_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -378,7 +326,7 @@ def test_validate_rejects_missing_heading(repository: Path, tmp_path: Path, acti run(repository, "git", "restore", ".") result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 - assert "has no valid section" in result.stderr + assert "expected one changelog section for 0.2.0, found 0" in result.stderr def test_validate_rejects_embedded_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -387,7 +335,7 @@ def test_validate_rejects_embedded_heading(repository: Path, tmp_path: Path, act run(repository, "git", "restore", ".") result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 - assert "has no valid section" in result.stderr + assert "expected one changelog section for 0.2.0, found 0" in result.stderr def test_validate_rejects_prek_changes(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -413,28 +361,3 @@ def test_validate_rejects_prek_changes(repository: Path, tmp_path: Path, action_ ) assert result.returncode != 0 assert "prek failed" in result.stderr - - -def test_validate_rejects_untracked_prek_output(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - config = repository / ".pre-commit-config.yaml" - config.write_text( - config.read_text().replace( - "entry: bash -c true", - "entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then touch extra-output; fi'", - ), - ) - run(repository, "git", "add", ".") - run(repository, "git", "commit", "-m", "Check release tree") - run(repository, "git", "push", "origin", "main") - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - result = action( - repository, - tmp_path, - action_scripts, - "validate-release-patch", - version="0.2.0", - VALIDATE_REHEARSAL="1", - ) - assert result.returncode != 0 - assert "prek changed the validated release patch" in result.stderr diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py new file mode 100644 index 0000000..be88a21 --- /dev/null +++ b/tests/verify_pypi_files/test_verify.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +from email.message import Message +from pathlib import Path +from socket import gaierror +from typing import TYPE_CHECKING, TypedDict, cast +from urllib.error import HTTPError, URLError + +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable + + from pytest_mock import MockerFixture + +_SPEC = importlib.util.spec_from_file_location( + "verify_pypi_files", Path(__file__).resolve().parents[2] / "tools/verify_pypi_files.py" +) +assert _SPEC is not None and _SPEC.loader is not None +_MODULE = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_MODULE) +verify_files = cast("Callable[[str, str, Path, int], list[Path]]", _MODULE.verify_files) + + +class _ListingFile(TypedDict): + filename: str + hashes: dict[str, str] + yanked: bool + + +@pytest.fixture +def distribution(tmp_path: Path) -> Path: + directory = tmp_path / "dist" + directory.mkdir() + (directory / "fixture-1.0-py3-none-any.whl").write_bytes(b"wheel") + (directory / "fixture-1.0.tar.gz").write_bytes(b"sdist") + return directory + + +def test_verify_files_matches_complete_release(distribution: Path, mocker: MockerFixture) -> None: + _serve(mocker, distribution) + + result = verify_files("fixture", "1.0", distribution, 0) + + assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"] + + +def test_verify_files_ignores_legacy_egg_from_older_release(distribution: Path, mocker: MockerFixture) -> None: + files = _listing(distribution) + files.append({"filename": "fixture-0.8.1-py2.4.egg", "hashes": {"sha256": "0" * 64}, "yanked": False}) + mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + + assert verify_files("fixture", "1.0", distribution, 0) == [ + distribution / "fixture-1.0-py3-none-any.whl", + distribution / "fixture-1.0.tar.gz", + ] + + +def test_verify_files_ignores_legacy_egg_from_longer_version(distribution: Path, mocker: MockerFixture) -> None: + for path in distribution.iterdir(): + path.rename(path.with_name(path.name.replace("1.0", "0.9"))) + files = _listing(distribution) + files.append({"filename": "fixture-0.9.1-py2.7.egg", "hashes": {"sha256": "0" * 64}, "yanked": False}) + mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + + assert verify_files("fixture", "0.9", distribution, 0) == [ + distribution / "fixture-0.9-py3-none-any.whl", + distribution / "fixture-0.9.tar.gz", + ] + + +@pytest.mark.parametrize( + ("change", "message"), + [ + pytest.param("extra", "unexpected files", id="extra"), + pytest.param("digest", "different SHA256", id="digest"), + pytest.param("yanked", "yanked PyPI file", id="yanked"), + pytest.param("missing", "missing files", id="missing"), + pytest.param("duplicate", "duplicate or yanked", id="duplicate"), + pytest.param("invalid-name", "unrecognized distribution filename", id="invalid-name"), + ], +) +def test_verify_files_rejects_bad_remote_release( + distribution: Path, mocker: MockerFixture, change: str, message: str +) -> None: + _serve(mocker, distribution, change=change) + + with pytest.raises(ValueError, match=message): + verify_files("fixture", "1.0", distribution, 0) + + +@pytest.mark.parametrize( + ("change", "message"), + [ + pytest.param("wrong-version", "another release", id="wrong-version"), + pytest.param("symlink", "invalid distribution path", id="symlink"), + pytest.param("hidden", "invalid distribution path", id="hidden"), + ], +) +def test_verify_files_rejects_bad_local_release(distribution: Path, change: str, message: str) -> None: + if change == "wrong-version": + (distribution / "fixture-2.0-py3-none-any.whl").write_bytes(b"other") + elif change == "symlink": + (distribution / "link.whl").symlink_to(distribution / "fixture-1.0-py3-none-any.whl") + else: + (distribution / ".hidden").write_bytes(b"other") + + with pytest.raises(ValueError, match=message): + verify_files("fixture", "1.0", distribution, 0) + + +def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: MockerFixture) -> None: + files = _listing(distribution) + response = mocker.patch.object( + _MODULE, + "urlopen", + side_effect=[_body(files[:-1]), _body(files)], + ) + mocker.patch.object(_MODULE.time, "sleep") + + result = verify_files("fixture", "1.0", distribution, 2) + + assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"] + assert response.call_count == 2 + + +@pytest.mark.parametrize( + "failure", + [ + pytest.param( + HTTPError("https://pypi.org/simple/fixture/", 429, "rate limit", Message(), None), id="rate-limit" + ), + pytest.param( + HTTPError("https://pypi.org/simple/fixture/", 503, "unavailable", Message(), None), id="server-error" + ), + pytest.param(URLError(TimeoutError("timeout")), id="timeout"), + pytest.param(URLError(gaierror("DNS failure")), id="dns"), + pytest.param(ConnectionResetError("connection reset"), id="reset"), + pytest.param(TimeoutError("read timeout"), id="read-timeout"), + ], +) +def test_verify_files_retries_transient_listing_failure( + distribution: Path, mocker: MockerFixture, failure: OSError +) -> None: + response = mocker.patch.object(_MODULE, "urlopen", side_effect=[failure, _body(_listing(distribution))]) + mocker.patch.object(_MODULE.time, "sleep") + + assert verify_files("fixture", "1.0", distribution, 2) == [ + distribution / "fixture-1.0-py3-none-any.whl", + distribution / "fixture-1.0.tar.gz", + ] + assert response.call_count == 2 + + +def test_verify_files_retries_response_read_timeout(distribution: Path, mocker: MockerFixture) -> None: + response = mocker.MagicMock() + response.__enter__.return_value.read.side_effect = TimeoutError("read timeout") + mocker.patch.object(_MODULE, "urlopen", side_effect=[response, _body(_listing(distribution))]) + mocker.patch.object(_MODULE.time, "sleep") + + assert verify_files("fixture", "1.0", distribution, 2) == [ + distribution / "fixture-1.0-py3-none-any.whl", + distribution / "fixture-1.0.tar.gz", + ] + + +def test_verify_files_backs_off_between_retries(distribution: Path, mocker: MockerFixture) -> None: + mocker.patch.object( + _MODULE, + "urlopen", + side_effect=[ + URLError(TimeoutError("timeout")), + URLError(TimeoutError("timeout")), + _body(_listing(distribution)), + ], + ) + sleep = mocker.patch.object(_MODULE.time, "sleep") + + verify_files("fixture", "1.0", distribution, 5) + + assert [call.args for call in sleep.call_args_list] == [(1,), (2,)] + + +def _serve(mocker: MockerFixture, distribution: Path, *, change: str = "") -> None: + files = _listing(distribution) + if change == "extra": + files.append({"filename": "fixture-1.0-py2-none-any.whl", "hashes": {"sha256": "0" * 64}, "yanked": False}) + elif change == "digest": + files[0]["hashes"]["sha256"] = "0" * 64 + elif change == "yanked": + files[0]["yanked"] = True + elif change == "missing": + files.pop() + elif change == "duplicate": + files.append(files[0].copy()) + elif change == "invalid-name": + files.append({"filename": "fixture-1.0.invalid", "hashes": {"sha256": "0" * 64}, "yanked": False}) + mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + + +def _listing(distribution: Path) -> list[_ListingFile]: + return [ + {"filename": path.name, "hashes": {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}, "yanked": False} + for path in distribution.iterdir() + ] + + +def _body(files: list[_ListingFile]) -> io.BytesIO: + return io.BytesIO(json.dumps({"meta": {"api-version": "1.0"}, "files": files}).encode()) diff --git a/tools/build_constraints.py b/tools/build_constraints.py index 046a4ae..7377e9a 100644 --- a/tools/build_constraints.py +++ b/tools/build_constraints.py @@ -22,8 +22,6 @@ def main() -> None: parser.add_argument("--target-platform") parser.add_argument("--targets") args = parser.parse_args() - if bool(args.target_platform) != bool(args.targets): - parser.error("target-platform and targets must be supplied together") if len(args.locks) > 2: parser.error("at most one shared and one caller lock are supported") if args.shared_policy and len(args.locks) != 2: diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 9a73156..31be2ce 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -4,37 +4,86 @@ import argparse import tomllib +from datetime import UTC, datetime, timedelta from pathlib import Path +from urllib.parse import urlsplit +from nab_cutoffs import reviewed_cutoff from packaging.pylock import Pylock, PylockValidationError +from packaging.utils import canonicalize_name, parse_sdist_filename, parse_wheel_filename from packaging.version import InvalidVersion, Version +_COOLDOWN = timedelta(days=7) + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("lock", type=Path) - parser.add_argument("groups", nargs="+") + parser.add_argument("groups", nargs="*") parser.add_argument("--shared-lock", type=Path) parser.add_argument("--local-tool-source", action="store_true") + parser.add_argument("--build-constraints", action="store_true") args = parser.parse_args() try: - groups = set(args.groups) + groups = {"build"} if args.build_constraints else set(args.groups) + if not groups: + parser.error("at least one dependency group is required") with args.lock.open("rb") as stream: lock = Pylock.from_dict(tomllib.load(stream)) - if missing := groups - set(lock.dependency_groups or ()): + if (missing := groups - set(lock.dependency_groups or ())) and not ( + args.build_constraints and missing == {"build"} + ): parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") + _check_cooling(lock, groups | {"build"}, local_tool_source=args.local_tool_source) if args.shared_lock: with args.shared_lock.open("rb") as stream: shared = Pylock.from_dict(tomllib.load(stream)) + _check_cooling(shared, groups | {"build"}) required = _selected_release_creator(shared, groups) if required is not None and not args.local_tool_source: installed = _selected_release_creator(lock, groups) if installed is None or installed < required: parser.error(f"relock: release-creator {required} required") - except (InvalidVersion, OSError, PylockValidationError) as error: + except (InvalidVersion, OSError, PylockValidationError, ValueError) as error: parser.error(str(error)) +def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = False) -> None: + created = lock.tool.get("nab", {}).get("created-at") if lock.tool else None + if not isinstance(created, datetime) or created.tzinfo is None or created > datetime.now(tz=UTC): + raise ValueError("lock has no valid creation time or was created in the future") + threshold = created - _COOLDOWN + for package in lock.packages: + if package.marker is not None and not package.marker.evaluate( + {"dependency_groups": groups}, context="lock_file" + ): + continue + if local_tool_source and package.name == "release-creator" and package.index is None: + continue + if package.index is None: + raise ValueError(f"release lock has an uncooled non-index package: {package.name}") + artifacts = ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()) + if not artifacts: + raise ValueError(f"release lock has no dated artifacts: {package.name}") + cutoff = max(threshold, reviewed_cutoff(package.name, str(package.version)) or threshold) + for artifact in artifacts: + url = urlsplit(artifact.url or "") + if artifact.path is not None or url.scheme != "https" or url.netloc != "files.pythonhosted.org": + raise ValueError(f"release lock has a non-PyPI artifact: {artifact.name}") + filename = url.path.rsplit("/", 1)[-1] + identity = ( + parse_wheel_filename(filename)[:2] if filename.endswith(".whl") else parse_sdist_filename(filename) + ) + if identity != (canonicalize_name(package.name), Version(str(package.version))): + raise ValueError(f"release lock artifact differs from package version: {filename}") + if ( + artifact.upload_time is None + or artifact.upload_time.tzinfo is None + or artifact.upload_time > min(cutoff, created) + ): + raise ValueError(f"release lock has an uncooled artifact: {artifact.name}") + + def _selected_release_creator(lock: Pylock, groups: set[str]) -> Version | None: return next( ( diff --git a/tools/check_nab_lock.py b/tools/check_nab_lock.py index cadca68..ff25116 100644 --- a/tools/check_nab_lock.py +++ b/tools/check_nab_lock.py @@ -4,6 +4,7 @@ import argparse import tomllib +from datetime import UTC, datetime from pathlib import Path @@ -17,6 +18,9 @@ def main() -> None: before = args.committed.read_bytes() old = tomllib.loads(before.decode()) new = tomllib.loads(args.regenerated.read_text()) + created = old["tool"]["nab"].get("created-at") + if not isinstance(created, datetime) or created.tzinfo is None or created > datetime.now(UTC): + parser.error("committed Nab lock has no valid creation time or was created in the future") if args.environments_only: if old.get("environments") != new.get("environments"): parser.error("regenerated Nab lock changed its target environments") diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index a4a3907..4c3b56e 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -15,14 +15,23 @@ ) _MINIMUM_DAYS: Final = 7 -_REVIEWED_EXCEPTIONS = { - **dict.fromkeys( - ("nab", "nab-index", "nab-markersets", "nab-project", "nab-provider", "nab-resolver"), - datetime(2026, 10, 19, tzinfo=UTC), - ), - "release-creator": datetime(2026, 10, 23, 2, 44, 40, tzinfo=UTC), - "uv-build": datetime(2026, 10, 21, tzinfo=UTC), # toml-fmt requires 0.13.4 until the cutoff matures. +_REVIEWED_VERSIONS: Final = { + **{ + (name, "0.0.22"): datetime(2026, 10, 18, 5, 45, 9, tzinfo=UTC) + for name in ("nab", "nab-index", "nab-markersets", "nab-project", "nab-provider", "nab-resolver") + }, + ("release-creator", "0.6.0"): datetime(2026, 10, 23, 2, 44, 40, tzinfo=UTC), + ("release-creator", "0.6.4"): datetime(2026, 10, 23, 16, 52, 6, tzinfo=UTC), + ("uv-build", "0.13.4"): datetime(2026, 10, 21, tzinfo=UTC), } +_REVIEWED_EXCEPTIONS: Final = { + name: min(reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name) + for name, _ in _REVIEWED_VERSIONS +} + + +def reviewed_cutoff(name: str, version: str) -> datetime | None: + return _REVIEWED_VERSIONS.get((name, version)) def validate_recent_cutoffs(project: str, threshold: datetime) -> None: @@ -58,7 +67,9 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None: timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value if timestamp.tzinfo is None: raise ValueError(f"Nab package cutoff has no timezone: {value}") - if timestamp > threshold and timestamp > _REVIEWED_EXCEPTIONS.get(name, datetime.min.replace(tzinfo=UTC)): + if timestamp > threshold and timestamp not in { + reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name + }: raise ValueError(f"unreviewed recent Nab cutoff for {name}: {timestamp.isoformat()}") @@ -95,17 +106,28 @@ def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[in packages = { str(name) for name, settings in nab.get("packages", {}).items() - if (value := settings.get("uploaded-prior-to")) is not None and _expired(value, threshold) + if name in _REVIEWED_EXCEPTIONS + and (value := settings.get("uploaded-prior-to")) is not None + and _expired( + value, threshold, {reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name} + ) } rules = { index for index, rule in enumerate(nab.get("package-rules", [])) - if (value := rule.get("uploaded-prior-to")) is not None and _expired(value, threshold) + if set(rule["match"]) <= _REVIEWED_EXCEPTIONS.keys() + and (value := rule.get("uploaded-prior-to")) is not None + and all( + _expired( + value, threshold, {reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name} + ) + for name in rule["match"] + ) } return packages, rules -def _expired(value: str | datetime | bool, threshold: datetime) -> bool: +def _expired(value: str | datetime | bool, threshold: datetime, reviewed: set[datetime]) -> bool: if value is False or isinstance(value, str) and _DURATION.fullmatch(value) is not None: return False if isinstance(value, bool): @@ -113,12 +135,13 @@ def _expired(value: str | datetime | bool, threshold: datetime) -> bool: timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value if timestamp.tzinfo is None: raise ValueError(f"Nab package cutoff has no timezone: {value}") - return timestamp <= threshold + return timestamp in reviewed and timestamp <= threshold __all__ = [ "expired_cutoffs", "remove_cutoffs", "remove_expired_cutoffs", + "reviewed_cutoff", "validate_recent_cutoffs", ] diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py new file mode 100644 index 0000000..ae74ae3 --- /dev/null +++ b/tools/verify_pypi_files.py @@ -0,0 +1,153 @@ +"""Compare a complete local release with PyPI's Simple JSON listing.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import time +from pathlib import Path +from typing import NotRequired, TypedDict, cast +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from packaging.utils import ( + InvalidSdistFilename, + InvalidWheelFilename, + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, +) +from packaging.version import Version + + +class _SimpleFile(TypedDict): + filename: str + hashes: dict[str, str] + yanked: NotRequired[bool | str] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("project") + parser.add_argument("version") + parser.add_argument("dist", type=Path) + parser.add_argument("--wait-seconds", type=int, default=120) + args = parser.parse_args() + try: + for path in verify_files(args.project, args.version, args.dist, args.wait_seconds): + print(path) + except (OSError, TypeError, ValueError) as error: + parser.error(str(error)) + + +def verify_files(project: str, version: str, dist: Path, wait_seconds: int) -> list[Path]: + """Check the local files against PyPI. + + :param project: Published project name. + :param version: Published release version. + :param dist: Directory containing every expected distribution. + :param wait_seconds: Maximum time to wait for PyPI's listing. + :returns: Local distribution paths for attestation verification. + """ + if wait_seconds < 0 or not dist.is_dir(): + raise ValueError("release files require a directory and nonnegative wait") + name = canonicalize_name(project, validate=True) + release = Version(version) + local: dict[str, str] = {} + for path in dist.iterdir(): + if path.is_symlink() or not path.is_file() or path.name.startswith("."): + raise ValueError(f"invalid distribution path: {path}") + if _identity(path.name) != (name, release): + raise ValueError(f"distribution belongs to another release: {path.name}") + with path.open("rb") as stream: + local[path.name] = hashlib.file_digest(stream, "sha256").hexdigest() + if not local: + raise ValueError(f"distribution directory is empty: {dist}") + deadline = time.monotonic() + wait_seconds + delay = 1 + while True: + remote: dict[str, str] = {} + try: + files = _listing(name) + except HTTPError as error: + if error.code != 429 and not 500 <= error.code < 600: + raise + if deadline - time.monotonic() <= 0: + raise + files = [] + except TimeoutError, ConnectionError, URLError: + if deadline - time.monotonic() <= 0: + raise + files = [] + for file in files: + if (identity := _identity(file["filename"])) is None: + prefix, separator, suffix = file["filename"].rpartition(f"-{release}") + if ( + separator + and (not suffix or suffix[0] in ".-_") + and not (suffix.startswith(".") and suffix[1:2].isdigit()) + and canonicalize_name(prefix) == name + ): + raise ValueError(f"PyPI has an unrecognized distribution filename: {file['filename']}") + continue + if identity != (name, release): + continue + filename = file["filename"] + if filename in remote or file.get("yanked"): + raise ValueError(f"duplicate or yanked PyPI file: {filename}") + remote[filename] = file["hashes"]["sha256"] + if extra := remote.keys() - local.keys(): + raise ValueError(f"PyPI has unexpected files: {', '.join(sorted(extra))}") + if changed := {filename for filename in remote if remote[filename] != local[filename]}: + raise ValueError(f"PyPI has different SHA256 digests: {', '.join(sorted(changed))}") + if not (missing := local.keys() - remote.keys()): + return [dist / filename for filename in sorted(local)] + if (remaining := deadline - time.monotonic()) <= 0: + raise ValueError(f"PyPI is missing files: {', '.join(sorted(missing))}") + time.sleep(min(delay, remaining)) + delay = min(delay * 2, 30) + + +def _identity(filename: str) -> tuple[str, Version] | None: + try: + return parse_wheel_filename(filename)[:2] if filename.endswith(".whl") else parse_sdist_filename(filename) + except InvalidSdistFilename, InvalidWheelFilename: + return None + + +def _listing(project: str) -> list[_SimpleFile]: + request = Request( + f"https://pypi.org/simple/{project}/", + headers={"Accept": "application/vnd.pypi.simple.v1+json", "Cache-Control": "no-cache"}, + ) + try: + with urlopen(request, timeout=30) as response: # ruff: ignore[suspicious-url-open-usage] # Fixed PyPI origin. + payload = json.load(response) + except HTTPError as error: + if error.code == 404: + return [] + raise + if not isinstance(payload, dict) or not isinstance(payload.get("files"), list): + raise TypeError("PyPI returned an invalid Simple API response") + if not isinstance((meta := payload.get("meta")), dict) or not str(meta.get("api-version", "")).startswith("1."): + raise ValueError("unsupported PyPI Simple API version") + for file in payload["files"]: + if ( + not isinstance(file, dict) + or not isinstance(file.get("filename"), str) + or not isinstance((hashes := file.get("hashes")), dict) + or not isinstance(hashes.get("sha256"), str) + ): + raise TypeError("PyPI returned an invalid file entry") + return cast("list[_SimpleFile]", payload["files"]) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", + "verify_files", +] diff --git a/validate-news/action.yml b/validate-news/action.yml index 69758b6..83280f7 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -8,15 +8,6 @@ runs: env: BASE_REF: ${{ github.base_ref }} run: release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" - - name: Check changed fragments with Towncrier - shell: bash - env: - BASE_REF: ${{ github.base_ref }} - run: | - read -r fragments <"$RUNNER_TEMP/news-fragment-count" - if ((fragments)); then - towncrier check --compare-with "origin/$BASE_REF" - fi - name: Require news for human changes shell: bash env: diff --git a/validate-python-distribution/action.yml b/validate-python-distribution/action.yml deleted file mode 100644 index 2fc7b19..0000000 --- a/validate-python-distribution/action.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Validate Python distribution -description: Check the exact wheel and sdist before uploading them -runs: - using: composite - steps: - - name: Install locked package validators - uses: $/install-locked-tools - with: - groups: pkg-meta - venv: ${{ runner.temp }}/package-validators - - name: Check distribution metadata - shell: bash - run: uv run --no-project --python "$RUNNER_TEMP/package-validators" twine check dist/* - - name: Check wheel contents - shell: bash - run: uv run --no-project --python "$RUNNER_TEMP/package-validators" check-wheel-contents --no-config dist diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index f048ed1..c7804a8 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -10,23 +10,12 @@ inputs: working-directory: description: Checkout to validate and apply the patch in default: . - expected-patch-sha: - description: SHA-256 digest from a separate keyless validation job - default: "" checks: description: Run release-creator and prek after applying the patch default: "true" runs: using: composite steps: - - name: Require a clean release checkout - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - if [[ -n $(git status --porcelain) ]]; then - echo '::error::release validation requires a clean checkout' >&2 - exit 1 - fi - name: Verify release source shell: bash working-directory: ${{ inputs.working-directory }} @@ -42,7 +31,6 @@ runs: working-directory: ${{ inputs.working-directory }} env: PATCH: ${{ inputs.patch }} - EXPECTED_PATCH_SHA: ${{ inputs.expected-patch-sha }} CHECKS: ${{ inputs.checks }} run: | set -euo pipefail @@ -50,21 +38,7 @@ runs: echo '::error::checks must be true or false' >&2 exit 1 fi - if [[ "$CHECKS" == false && -z "$EXPECTED_PATCH_SHA" ]]; then - echo '::error::skipping release checks requires a validated patch digest' >&2 - exit 1 - fi - read -r patch_sha _ < <(sha256sum "$PATCH") - if [[ -n "$EXPECTED_PATCH_SHA" && "$patch_sha" != "$EXPECTED_PATCH_SHA" ]]; then - echo '::error::release patch differs from the validated artifact' >&2 - exit 1 - fi - git apply --check "$PATCH" git apply --index "$PATCH" - if git diff --cached --quiet; then - echo '::error::release patch contains no changes' >&2 - exit 1 - fi - id: config uses: $/read-towncrier-config with: @@ -79,7 +53,7 @@ runs: set -euo pipefail git diff --cached --name-status --no-renames -z >"$RUNNER_TEMP/release-paths" while IFS= read -r -d '' status && IFS= read -r -d '' path; do - if [[ "$path" == "$CHANGELOG" ]]; then + if [[ "$path" == "$CHANGELOG" && ("$status" == A || "$status" == M) ]]; then continue fi if [[ "$path" != "$FRAGMENTS/"* ]]; then @@ -104,8 +78,6 @@ runs: - name: Check release patch file modes shell: bash working-directory: ${{ inputs.working-directory }} - env: - FRAGMENTS: ${{ steps.config.outputs.fragments }} run: | set -euo pipefail git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes" @@ -123,10 +95,6 @@ runs: D) old_mode=100644 new_mode=000000 - if [[ "$path" != "$FRAGMENTS/"* ]]; then - echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2 - exit 1 - fi ;; *) echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2 @@ -143,7 +111,7 @@ runs: fi done <"$RUNNER_TEMP/release-modes" - name: Check RST conflict markers - if: steps.config.outputs.format == 'rst' + if: inputs.checks == 'true' && steps.config.outputs.format == 'rst' shell: bash working-directory: ${{ inputs.working-directory }} env: @@ -167,27 +135,14 @@ runs: env: VERSION: ${{ inputs.version }} CHANGELOG: ${{ steps.config.outputs.changelog }} - run: | - if ! release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$GITHUB_REPOSITORY" \ - >/dev/null; then - echo "::error::$CHANGELOG has no valid section for $VERSION" >&2 - exit 1 - fi + run: release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$GITHUB_REPOSITORY" >/dev/null - name: Run prek on release patch if: inputs.checks == 'true' shell: bash working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail - git diff --cached --check - staged_patch_sha=$(git diff --cached --binary | sha256sum) if ! prek run --all-files; then echo '::error::prek failed on the validated release patch' >&2 exit 1 fi - if [[ $(git diff --cached --binary | sha256sum) != "$staged_patch_sha" ]] || - ! git diff --quiet || - [[ -n $(git ls-files --others --exclude-standard) ]]; then - echo '::error::prek changed the validated release patch' >&2 - exit 1 - fi