diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index d344915..b97bd34 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -38,6 +38,8 @@ jobs: groups: release-check venv: .release-tools - name: Check repository formatting + env: + UV_EXCLUDE_NEWER: 7 days run: prek run --all-files shell: runs-on: ubuntu-26.04 @@ -105,12 +107,8 @@ jobs: with: groups: release-news release-shell venv: .release-tools - - name: Install current test tools - run: | - uv venv --python 3.15 .venv - uv pip install --python .venv/bin/python --group test - name: Run Action tests - run: .venv/bin/pytest -q -n auto + run: uvx --with tox-uv tox run -e test type: runs-on: ubuntu-26.04 permissions: @@ -139,6 +137,8 @@ jobs: pull-requests: read steps: - uses: $/check-hook-update-cooldown + with: + release-app-client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} release-tools-preview: if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 @@ -160,7 +160,7 @@ jobs: - uses: $/refresh-release-lock with: lock-groups: >- - build lock-tools release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check + build lock-tools lock-reader release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check mode: check gate: name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 471fb11..b8677c7 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -63,10 +63,10 @@ jobs: local-tool-source: ${{ inputs.local-tool-source }} - name: Install prek hooks id: hooks + env: + UV_EXCLUDE_NEWER: 7 days run: prek install-hooks background: true - - name: Create a fresh validation worktree - run: git worktree add --detach "$RUNNER_TEMP/release-validation" "$GITHUB_SHA" - name: Build local release tool wheel if: inputs.local-tool-source env: @@ -97,20 +97,78 @@ jobs: with: version: ${{ steps.prepare.outputs.version }} phase: finalize - - uses: $/validate-release-patch - with: - version: ${{ steps.prepare.outputs.version }} - patch: ${{ runner.temp }}/release.patch - working-directory: ${{ runner.temp }}/release-validation - id: patch uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-patch path: ${{ runner.temp }}/release.patch if-no-files-found: error - push: + validate: needs: assemble runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - id: tools + uses: $/install-locked-tools + with: + groups: release-validate${{ inputs.local-tool-source && ' project-runtime' || '' }} + venv: .venv + local-tool-source: ${{ inputs.local-tool-source }} + - name: Build local release tool wheel + if: inputs.local-tool-source + env: + BUILD_CONSTRAINTS: ${{ steps.tools.outputs.build-constraints }} + run: | + uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ + --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . + - name: Install local release tool wheel + if: inputs.local-tool-source + run: uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl + - name: Expose local release command + if: inputs.local-tool-source + run: | + mkdir -p "$RUNNER_TEMP/local-release-cli" + ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator" + echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH" + - id: config + uses: $/read-towncrier-config + - name: Verify selected release version + env: + BUMP: ${{ inputs.bump }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + FRAGMENTS: ${{ steps.config.outputs.fragments }} + VERSION: ${{ needs.assemble.outputs.version }} + run: | + set -euo pipefail + args=(--next "$BUMP" --directory "$FRAGMENTS") + if [[ -n "$TAG_PREFIX" ]]; then args+=(--prefix "$TAG_PREFIX"); fi + expected=$(git tag --list | release-creator tags "${args[@]}") + if [[ "$VERSION" != "$expected" ]]; then + echo "::error::assembled release version differs from $expected" >&2 + exit 1 + fi + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.assemble.outputs.artifact-id }} + path: ${{ runner.temp }}/release-patch + - uses: $/validate-release-patch + with: + version: ${{ needs.assemble.outputs.version }} + patch: ${{ runner.temp }}/release-patch/release.patch + push: + needs: [assemble, validate] + runs-on: ubuntu-26.04 environment: name: release-auth deployment: ${{ inputs.release-auth-deployment }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ba50213..39f9895 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,6 +26,8 @@ jobs: groups: release-check venv: .venv - name: Check tagged source + env: + UV_EXCLUDE_NEWER: 7 days run: .venv/bin/prek run --all-files source: runs-on: ubuntu-26.04 diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 49a6580..0f58f0d 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -13,7 +13,7 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: lock-groups: >- - build lock-tools release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check + build lock-tools lock-reader release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check commit-message: "🔧 build: refresh release tool lock" pull-request-title: "🔧 build: refresh release tool lock" pull-request-body: "Refresh the locked tools used by release preparation and CI." diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index da9ce44..ea03be3 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -55,6 +55,7 @@ jobs: contents: read outputs: changed: ${{ steps.lock.outputs.changed }} + artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -75,7 +76,8 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.lock.outputs.changed == 'true' with: name: release-tools-lock @@ -91,6 +93,7 @@ jobs: contents: read outputs: changed: ${{ steps.hooks.outputs.changed }} + artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -111,7 +114,7 @@ jobs: 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 + uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks - id: hooks name: Write hook patch run: | @@ -122,7 +125,8 @@ jobs: 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 + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.hooks.outputs.changed == 'true' with: name: release-tools-hooks @@ -147,22 +151,18 @@ jobs: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 if: needs.generate-lock.outputs.changed == 'true' with: - name: release-tools-lock + artifact-ids: ${{ needs.generate-lock.outputs.artifact-id }} path: ${{ runner.temp }}/release-tools-lock - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 if: needs.generate-hooks.outputs.changed == 'true' with: - name: release-tools-hooks + artifact-ids: ${{ needs.generate-hooks.outputs.artifact-id }} path: ${{ runner.temp }}/release-tools-hooks - - name: Apply lock patch - if: needs.generate-lock.outputs.changed == 'true' - 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 --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" - - name: Check dependency patch paths + - name: Check artifact patch paths env: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + LOCK_CHANGED: ${{ needs.generate-lock.outputs.changed }} + HOOKS_CHANGED: ${{ needs.generate-hooks.outputs.changed }} run: | set -euo pipefail if [[ -n "$ADDITIONAL_LOCK" && @@ -170,14 +170,33 @@ jobs: echo "::error::invalid additional lock: $ADDITIONAL_LOCK" >&2 exit 1 fi - git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/dependency-paths" - while IFS= read -r -d '' path; do - if [[ "$path" != pylock.toml && "$path" != pyproject.toml && - "$path" != .pre-commit-config.yaml && "$path" != "$ADDITIONAL_LOCK" ]]; then - echo "::error::unexpected dependency path: $path" >&2 - exit 1 + for artifact in lock hooks; do + if [[ "$artifact" == lock ]]; then + [[ "$LOCK_CHANGED" == true ]] || continue + patch="$RUNNER_TEMP/release-tools-lock/lock.patch" + else + [[ "$HOOKS_CHANGED" == true ]] || continue + patch="$RUNNER_TEMP/release-tools-hooks/hooks.patch" fi - done <"$RUNNER_TEMP/dependency-paths" + GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" git read-tree HEAD + GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" git apply --cached "$patch" + GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" \ + git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/$artifact-paths" + while IFS= read -r -d '' path; do + if [[ "$artifact" == lock && "$path" != pylock.toml && "$path" != pyproject.toml && + "$path" != "$ADDITIONAL_LOCK" ]] || + [[ "$artifact" == hooks && "$path" != .pre-commit-config.yaml ]]; then + echo "::error::unexpected $artifact patch path: $path" >&2 + exit 1 + fi + done <"$RUNNER_TEMP/$artifact-paths" + done + - name: Apply lock patch + if: needs.generate-lock.outputs.changed == 'true' + 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 --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" - name: Check dependency patch file mode run: | set -euo pipefail diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 13229c4..65905c1 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -1,23 +1,11 @@ name: Validate Python release on: workflow_call: + outputs: + distributions-artifact-id: + description: Upload ID for the validated wheel and sdist artifact + value: ${{ jobs.build.outputs.artifact-id }} inputs: - changelog: - description: Deprecated; notes are rendered by the caller - type: string - default: "" - artifact-base-url: - description: Deprecated; notes are rendered by the caller - type: string - default: "" - artifact-label: - description: Deprecated; notes are rendered by the caller - type: string - default: PyPI release - local-tool-source: - description: Deprecated; distribution builds do not install release-creator - type: boolean - default: false source: description: Package source directory default: . @@ -48,10 +36,14 @@ jobs: groups: release-check venv: ${{ runner.temp }}/release-tools - name: Run tagged source checks + env: + UV_EXCLUDE_NEWER: 7 days run: >- "$RUNNER_TEMP/release-tools/bin/prek" run --all-files constraints: runs-on: ubuntu-26.04 + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} permissions: contents: read steps: @@ -70,7 +62,8 @@ jobs: env: SOURCE: ${{ steps.constraints.outputs.build-constraints }} run: cp "$SOURCE" "$RUNNER_TEMP/build-constraints.txt" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact-prefix }}build-constraints path: ${{ runner.temp }}/build-constraints.txt @@ -78,6 +71,8 @@ jobs: build: needs: constraints runs-on: ubuntu-26.04 + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} permissions: contents: read steps: @@ -102,7 +97,7 @@ jobs: enable-cache: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: ${{ inputs.artifact-prefix }}build-constraints + artifact-ids: ${{ needs.constraints.outputs.artifact-id }} path: ${{ runner.temp }} - name: Require empty distribution directory run: test ! -d dist || test -z "$(find dist -mindepth 1 -maxdepth 1 -print -quit)" @@ -132,7 +127,8 @@ jobs: echo "::error::wheel and sdist versions must match $TAG" >&2 exit 1 fi - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact-prefix }}python-package-distributions path: dist/* @@ -154,7 +150,7 @@ jobs: enable-cache: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: ${{ inputs.artifact-prefix }}python-package-distributions + artifact-ids: ${{ needs.build.outputs.artifact-id }} path: dist - name: Install distribution validators run: | diff --git a/.gitignore b/.gitignore index 7e20f52..67691e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv/ __pycache__/ +.coverage* uv.lock diff --git a/README.md b/README.md index aad4266..11c7a4c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ constraints generated from the lock's `build` group under the runner's temporary caller's lock then needs the selected groups but does not need a release-creator package entry. - `constraints-lock`: lock used to export build constraints; defaults to the selected install lock. Use a secondary lock when the build matrix differs from the release-tool matrix. +- `verify-lock` and `verify-groups`: check another lock's selected packages against PyPI and the seven-day cutoff + without installing them. Use this before bundling packages from a separate runtime lock, such as a zipapp. - `target-platform` and `targets`: Nab platform ID and space-separated `full-version:implementation` pairs for build constraints. Pass both when exporting constraints for a specific build target; otherwise the runner selects the target. @@ -32,6 +34,20 @@ The `build-constraints` output names the hashed constraints file. The Action add `actions/checkout` and `astral-sh/setup-uv` with Python 3.15 before it. It bootstraps Nab from the shared, hash-checked lock. +For a zipapp that bundles wheels from a separate runtime lock, check that lock in the same job before building it: + +```yaml + - uses: tox-dev/github-workflows/install-locked-tools@SHA + with: + groups: release-build + venv: ${{ runner.temp }}/release-tools + verify-lock: pylock.zipapp.toml + verify-groups: project-runtime +``` + +`export-build-constraints` also accepts `verify-groups`. Pass it when its secondary lock supplies build tools outside +the `build` group, such as profile-guided optimization tools in a `pgo` group. + ## Check pull request news `validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the @@ -73,10 +89,11 @@ The Action expects a pull request base ref and a Markdown or reStructuredText ch 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 -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. +`additional_dependencies`. Each job uploads a patch. The lock patch may change `pylock.toml`, an explicitly configured +secondary lock, and `pyproject.toml` for expired Nab cutoffs. The hook patch may change only `.pre-commit-config.yaml`. +The writer checks each patch before applying it and verifies cutoff removals before minting the App token. It then opens +or updates one dependency PR from `automation/update-release-tools` to `main`. A lock or hook update runs the normal PR +checks before merge. The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this repository, set `RELEASE_APP_CLIENT_ID` as a repository variable, and store `RELEASE_APP_PRIVATE_KEY` in the @@ -101,18 +118,18 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: lock-groups: >- - build lock-tools release-notes release-news release-prepare + build release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check ``` The required `lock-groups` input lists the caller's release dependency groups, separated by spaces or newlines. Include -`build` and `lock-tools` where declared; exclude test, type, and documentation groups. This example lists this -repository's groups; a caller supplies its own. The project configures its Python and platform targets in `[tool.nab]`. -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. The updater also refreshes a caller's secondary build-matrix lock when `additional-lock` and its -matrix inputs are set. +`build` and the release groups used by the caller. `lock-tools` and `lock-reader` come from this Action's lock; include +them only when the caller also uses them. Exclude test, type, and documentation groups. The project configures 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. 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 @@ -123,6 +140,8 @@ For a second lock such as `pylock.build.toml`, set `additional-lock`, `additiona 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. +Pass the same `additional-lock` and matrix inputs to `validate-news` in the PR workflow. It checks changes to either +lock against the project, including a PR that changes only the secondary lock. `refresh-release-lock` is the read-only half of the updater. In `refresh` mode, it upgrades the selected release groups under Nab's configured cutoff and writes `$RUNNER_TEMP/lock.patch` if either lock or a reviewed expired Nab cutoff @@ -148,7 +167,7 @@ release-tools-preview: - uses: tox-dev/github-workflows/refresh-release-lock@SHA with: lock-groups: >- - build lock-tools release-notes release-news release-prepare + build lock-tools lock-reader release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check mode: check @@ -164,7 +183,9 @@ with `actions: read`, `contents: read`, and `pull-requests: read`, then include its PR number and head SHA from the event, compares them with the live PR, and requires each proposed revision to have appeared in a GitHub PR workflow run at least `minimum-days` earlier. The default is seven days. A force push does not reset an unchanged revision's age or give a new revision the old one's age. Human PRs pass without installing hook -tools. +tools. Other bot PRs may change hook revisions only. On `automation/update-release-tools`, the Action verifies the bot +against `RELEASE_APP_CLIENT_ID` and permits exact, cooled PyPI dependency pins generated by the release App. It keeps +the same seven-day revision observation rule, so current revisions may remain pending after the lock refresh. ```yaml hook-cooldown: @@ -176,42 +197,53 @@ hook-cooldown: pull-requests: read steps: - uses: tox-dev/github-workflows/check-hook-update-cooldown@SHA + with: + release-app-client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} ``` Run the PR workflow on `opened`, `reopened`, and `synchronize`; the cooldown check must succeed on PRs and may skip on push. The Action has no output or write side effect. Schedule `recheck-hook-update-cooldown` daily with `actions: write`, `contents: read`, and `pull-requests: read`. It reruns eligible failed cooldown jobs at the current head. GitHub accepts reruns for 30 days. A failed rerun is retried on the next schedule while eligible. The -[caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. Set `workflow` or -`job-name` only when the caller uses different names. +[caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. The Action reruns the +`hook-cooldown` job in `check.yaml`. + +If the job has a different name or workflow filename, pass both to the scheduled caller: + +```yaml +with: + workflow: ci.yaml + job-name: check-hook-updates +``` ## 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 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. +Towncrier workflow. Its `assemble` job uploads a patch; a separate keyless `validate` job downloads and checks that +artifact. The `push` job downloads the same 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. -Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. Both +Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. All jobs use `persist-credentials: false`. Assembly installs the caller's locked release tools and needs `contents: read` -and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while creating the validation worktree -and building the changelog, then waits for the hooks before formatting and validating the patch. The writer uses the -`release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that environment to -`main`. Install the App on this repository only, with Contents write and Metadata read, and grant the necessary branch -and tag ruleset bypass. +and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while building the changelog, then waits +for the hooks before formatting and uploading the patch. The validation job runs `prek` on that uploaded patch. The +writer uses the `release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that +environment to `main`. Install the App on this repository only, with Contents write and Metadata read, and grant the +necessary branch and tag ruleset bypass. ```text main + fragments │ ▼ -prepare-towncrier ──► validate in fresh worktree ──► upload patch by ID - │ - ▼ - verify patch ──► mint App token - │ - ▼ - commit-and-tag ──► main + tag +prepare-towncrier ──► upload patch ──► validate artifact + │ │ + └─────────┬─────────┘ + ▼ + verify paths and modes + │ + ▼ + mint App token ──► commit-and-tag ──► main + tag ``` The tag starts the caller's publisher. Keep PyPI trusted publishing in that top-level workflow, in a separate job with @@ -243,10 +275,10 @@ The caller needs `release-check` for prek and a `build` group for backend constr 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), -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. +using `artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }}`. The download checks the artifact digest and +selects the wheel and sdist from that build run. 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. ## Build a distribution for a pull request @@ -257,11 +289,9 @@ 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/`. +The Action builds the checkout root and has no named output; subsequent steps read `dist/`. ## Export constraints for a build matrix @@ -283,10 +313,11 @@ build package versions; otherwise the Action fails instead of writing an ambiguo ## 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, 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. +`prepare-towncrier-release.yaml` runs three jobs. `assemble` selects a tag and uploads a Towncrier patch without the App +key. `validate` calculates the next tag again from the checked-out source, then applies the uploaded artifact and runs +`prek`. `push` downloads that artifact by ID, checks its paths and file modes, reads the caller's `release-auth` +environment, and uses a repository-scoped App token to push the release commit and tag together. Only a dispatch from +`main` can reach the writer. ```yaml jobs: @@ -333,8 +364,8 @@ committed bytes so an untrusted release patch cannot change the allowed paths or run: printf '%s\n' "$CHANGELOG" ``` -`working-directory` defaults to `.`; pass a different checkout when validating a patch in a linked worktree. Outputs are -`changelog`, `fragments`, and `format` (`markdown` or `rst`). The Action does not modify the checkout or GitHub state. +Outputs are `changelog`, `fragments`, and `format` (`markdown` or `rst`). The Action does not modify the checkout or +GitHub state. ## Prepare Towncrier release @@ -389,8 +420,6 @@ and mode checks with `checks: "false"`, without installing release tools. - `version` **(required)**: Exact tag chosen by preparation. `release-creator notes` must find one valid section for it. - `patch` **(required)**: Local path of the downloaded patch artifact, not an artifact name or URL. -- `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. - `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, diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index a1a9492..bf28edb 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -1,9 +1,5 @@ name: Build Python distribution description: Build a wheel and sdist for a keyless PR check -inputs: - source: - description: Distribution source directory - default: . runs: using: composite steps: @@ -23,14 +19,13 @@ runs: - name: Build wheel and sdist shell: bash env: - SOURCE: ${{ inputs.source }} BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE build_python=$(uv python find "$RUNNER_TEMP/build-python") export SOURCE_DATE_EPOCH - SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) + SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) uv build --python "$build_python" \ --python-preference only-managed --sdist --wheel \ - --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes "$SOURCE" --out-dir dist + --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes . --out-dir dist diff --git a/changelog.d/+action-interface.breaking.md b/changelog.d/+action-interface.breaking.md new file mode 100644 index 0000000..93bf953 --- /dev/null +++ b/changelog.d/+action-interface.breaking.md @@ -0,0 +1 @@ +Remove the unused `build-python-distribution.source` input. The build Action uses the repository root. diff --git a/changelog.d/+artifact-ids.change.md b/changelog.d/+artifact-ids.change.md new file mode 100644 index 0000000..1da9314 --- /dev/null +++ b/changelog.d/+artifact-ids.change.md @@ -0,0 +1 @@ +Download tagged release build artifacts by the IDs produced by their upload jobs. diff --git a/changelog.d/+base-build-selection.security.md b/changelog.d/+base-build-selection.security.md new file mode 100644 index 0000000..e0527bf --- /dev/null +++ b/changelog.d/+base-build-selection.security.md @@ -0,0 +1,2 @@ +Check packages selected from a lock without dependency groups under the same marker selection used to export build +constraints. diff --git a/changelog.d/+cool-release-hooks.security.md b/changelog.d/+cool-release-hooks.security.md new file mode 100644 index 0000000..2acc387 --- /dev/null +++ b/changelog.d/+cool-release-hooks.security.md @@ -0,0 +1 @@ +Apply the seven-day package cutoff when release and CI jobs install prek hook environments. diff --git a/changelog.d/+cooling-marker.security.md b/changelog.d/+cooling-marker.security.md new file mode 100644 index 0000000..679ac14 --- /dev/null +++ b/changelog.d/+cooling-marker.security.md @@ -0,0 +1,2 @@ +Check release lock markers for the exact dependency groups each job installs, so a condition that excludes the build +group cannot bypass cooling. diff --git a/changelog.d/+finalize-version.bugfix.md b/changelog.d/+finalize-version.bugfix.md new file mode 100644 index 0000000..0bfdbbd --- /dev/null +++ b/changelog.d/+finalize-version.bugfix.md @@ -0,0 +1 @@ +Return the selected version from the Towncrier finalization phase and reject a finalization without one. diff --git a/changelog.d/+hook-config-cooldown.security.md b/changelog.d/+hook-config-cooldown.security.md new file mode 100644 index 0000000..7de81a8 --- /dev/null +++ b/changelog.d/+hook-config-cooldown.security.md @@ -0,0 +1 @@ +Reject bot hook updates that change configuration beyond revisions. diff --git a/changelog.d/+independent-version.security.md b/changelog.d/+independent-version.security.md new file mode 100644 index 0000000..bb6b620 --- /dev/null +++ b/changelog.d/+independent-version.security.md @@ -0,0 +1 @@ +Recalculate the release version in the keyless validation job before the App-token writer accepts an assembled patch. diff --git a/changelog.d/+lock-metadata.security.md b/changelog.d/+lock-metadata.security.md new file mode 100644 index 0000000..27b3628 --- /dev/null +++ b/changelog.d/+lock-metadata.security.md @@ -0,0 +1,2 @@ +Check release lock artifacts against PyPI metadata before installing release tools, including their hashes, upload +times, and index origin. Use a pinned CA bundle for the metadata request. diff --git a/changelog.d/+lock-reader.bugfix.md b/changelog.d/+lock-reader.bugfix.md new file mode 100644 index 0000000..7169c2c --- /dev/null +++ b/changelog.d/+lock-reader.bugfix.md @@ -0,0 +1 @@ +Keep the lock reader's certificate package installed when a job switches between Nab tools and other release groups. diff --git a/changelog.d/+managed-hook-pins.security.md b/changelog.d/+managed-hook-pins.security.md new file mode 100644 index 0000000..399fc8e --- /dev/null +++ b/changelog.d/+managed-hook-pins.security.md @@ -0,0 +1,2 @@ +Verify the configured release App before accepting its dependency update PR, and check changed hook pins against cooled +PyPI files while retaining hook revision cooldowns. diff --git a/changelog.d/+news-nab-policy.security.md b/changelog.d/+news-nab-policy.security.md new file mode 100644 index 0000000..311ab7c --- /dev/null +++ b/changelog.d/+news-nab-policy.security.md @@ -0,0 +1 @@ +Validate the lock when a pull request changes `nab.toml`, including callers that use `validate-news` directly. diff --git a/changelog.d/+pypi-listing-lag.bugfix.md b/changelog.d/+pypi-listing-lag.bugfix.md new file mode 100644 index 0000000..b10815f --- /dev/null +++ b/changelog.d/+pypi-listing-lag.bugfix.md @@ -0,0 +1 @@ +Retry a PyPI artifact listing or transient 404 that lags a new release before rejecting a matching release lock. diff --git a/changelog.d/+pypi-listing-shape.bugfix.md b/changelog.d/+pypi-listing-shape.bugfix.md new file mode 100644 index 0000000..b6442a6 --- /dev/null +++ b/changelog.d/+pypi-listing-shape.bugfix.md @@ -0,0 +1 @@ +Report malformed PyPI Simple API listings as lock verification errors instead of Python tracebacks. diff --git a/changelog.d/+pypi-metadata-retry.bugfix.md b/changelog.d/+pypi-metadata-retry.bugfix.md new file mode 100644 index 0000000..0eb83eb --- /dev/null +++ b/changelog.d/+pypi-metadata-retry.bugfix.md @@ -0,0 +1 @@ +Retry transient PyPI metadata connection errors before rejecting a release lock. diff --git a/changelog.d/+pypi-verifier-trust.security.md b/changelog.d/+pypi-verifier-trust.security.md new file mode 100644 index 0000000..d3b94a5 --- /dev/null +++ b/changelog.d/+pypi-verifier-trust.security.md @@ -0,0 +1 @@ +Use a pinned CA bundle when comparing published PyPI files with local release artifacts. diff --git a/changelog.d/+release-creator-067.change.md b/changelog.d/+release-creator-067.change.md new file mode 100644 index 0000000..469f1fb --- /dev/null +++ b/changelog.d/+release-creator-067.change.md @@ -0,0 +1 @@ +Refresh the package index when checking or updating a lock so a cached listing cannot hide a newly published version. diff --git a/changelog.d/+release-creator-100.change.md b/changelog.d/+release-creator-100.change.md new file mode 100644 index 0000000..d30c224 --- /dev/null +++ b/changelog.d/+release-creator-100.change.md @@ -0,0 +1,2 @@ +Lock the released release-creator 1.0.0 CLI used by the weekly hook updater and allow its reviewed artifacts during the +seven-day cooldown. diff --git a/changelog.d/+release-lock-preview.security.md b/changelog.d/+release-lock-preview.security.md new file mode 100644 index 0000000..12d3355 --- /dev/null +++ b/changelog.d/+release-lock-preview.security.md @@ -0,0 +1 @@ +Check changed release locks against project requirements in pull requests, including every declared release group. diff --git a/changelog.d/+release-validation.security.md b/changelog.d/+release-validation.security.md new file mode 100644 index 0000000..10b57e3 --- /dev/null +++ b/changelog.d/+release-validation.security.md @@ -0,0 +1 @@ +Validate the uploaded release patch in a separate keyless job before the App-key writer pushes it. diff --git a/changelog.d/+remove-unused-working-directory.breaking.md b/changelog.d/+remove-unused-working-directory.breaking.md new file mode 100644 index 0000000..4ec64c7 --- /dev/null +++ b/changelog.d/+remove-unused-working-directory.breaking.md @@ -0,0 +1,2 @@ +Remove the unused `working-directory` input from the release patch and Towncrier configuration Actions. They operate on +the current checkout. diff --git a/changelog.d/+removed-release-inputs.breaking.md b/changelog.d/+removed-release-inputs.breaking.md new file mode 100644 index 0000000..903446e --- /dev/null +++ b/changelog.d/+removed-release-inputs.breaking.md @@ -0,0 +1,3 @@ +Remove `recheck-hook-update-cooldown.minimum-days` and the unused `validate-python-release` inputs `changelog`, +`artifact-base-url`, `artifact-label`, and `local-tool-source`. Recheck uses a fixed seven-day minimum; release notes +are rendered by the caller's notes job. diff --git a/changelog.d/+secondary-pr-lock.security.md b/changelog.d/+secondary-pr-lock.security.md new file mode 100644 index 0000000..60b98dd --- /dev/null +++ b/changelog.d/+secondary-pr-lock.security.md @@ -0,0 +1,2 @@ +Check secondary release locks during pull request news validation when callers pass their secondary lock and matrix +inputs. diff --git a/changelog.d/+secondary-runtime-lock.feature.md b/changelog.d/+secondary-runtime-lock.feature.md new file mode 100644 index 0000000..0da5f2d --- /dev/null +++ b/changelog.d/+secondary-runtime-lock.feature.md @@ -0,0 +1,2 @@ +`install-locked-tools` and `export-build-constraints` can verify selected groups from a separate lock against PyPI and +the seven-day cutoff before its packages enter a release artifact. diff --git a/changelog.d/+small-lock-reader.performance.md b/changelog.d/+small-lock-reader.performance.md new file mode 100644 index 0000000..1d83d9f --- /dev/null +++ b/changelog.d/+small-lock-reader.performance.md @@ -0,0 +1 @@ +Install only the lock reader during release jobs; keep Nab in lock refresh jobs. diff --git a/changelog.d/+update-allowlists.security.md b/changelog.d/+update-allowlists.security.md new file mode 100644 index 0000000..37f6457 --- /dev/null +++ b/changelog.d/+update-allowlists.security.md @@ -0,0 +1 @@ +Apply separate path allowlists to lock and hook update patches before the App writes the dependency branch. diff --git a/changelog.d/+updater-hook-age.security.md b/changelog.d/+updater-hook-age.security.md new file mode 100644 index 0000000..b3fbbcf --- /dev/null +++ b/changelog.d/+updater-hook-age.security.md @@ -0,0 +1 @@ +Require the generated release-tool update PR to pass the same seven-day hook revision check as other bot PRs. diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index 0f698f1..963ffb6 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -4,6 +4,9 @@ inputs: minimum-days: description: Minimum age of a proposed hook revision default: "7" + release-app-client-id: + description: Client ID of the App allowed to update managed hook dependencies + default: "" runs: using: composite steps: @@ -13,6 +16,7 @@ runs: env: MINIMUM_DAYS: ${{ inputs.minimum-days }} GH_TOKEN: ${{ github.token }} + RELEASE_APP_CLIENT_ID: ${{ inputs.release-app-client-id }} run: | set -euo pipefail REPOSITORY=$GITHUB_REPOSITORY @@ -28,7 +32,7 @@ runs: gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" >"$RUNNER_TEMP/hook-update-pr.json" jq -e --arg repository "$REPOSITORY" --arg head "$HEAD_SHA" ' .state == "open" and .base.repo.full_name == $repository and - .head.sha == $head and + .head.repo.full_name == $repository and .head.sha == $head and (.user.login | type == "string") ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then @@ -37,6 +41,22 @@ runs: fi if [[ -f "$RUNNER_TEMP/hook-update-files" ]] && grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-update-files"; then + if [[ $(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json") == automation/update-release-tools ]]; then + author=$(jq -r .user.login "$RUNNER_TEMP/hook-update-pr.json") + [[ "$author" == *'[bot]' && -n "$RELEASE_APP_CLIENT_ID" ]] || { + echo '::error::release tool branch requires the configured release App' >&2 + exit 1 + } + slug=${author%'[bot]'} + gh api "apps/$slug" >"$RUNNER_TEMP/release-app.json" + jq -e --arg slug "$slug" --arg client "$RELEASE_APP_CLIENT_ID" ' + .slug == $slug and .client_id == $client + ' "$RUNNER_TEMP/release-app.json" >/dev/null || { + echo '::error::release tool branch has a different App identity' >&2 + exit 1 + } + echo 'managed=true' >>"$GITHUB_OUTPUT" + fi gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \ -f head_sha="$HEAD_SHA" -f per_page=100 >"$RUNNER_TEMP/hook-update-runs.jsonl" if jq -se --arg repository "$REPOSITORY" \ @@ -62,7 +82,7 @@ runs: - uses: $/install-locked-tools if: steps.identity.outputs.bot == 'true' with: - groups: release-hook-check + groups: release-hook-check lock-reader lock-source: shared venv: ${{ runner.temp }}/hook-tools - name: Read the base and proposed hook configurations @@ -81,6 +101,18 @@ runs: base64 --decode >"$RUNNER_TEMP/proposed-hooks.yaml" cp "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/eligible-hooks.yaml" git init -q "$RUNNER_TEMP/hook-cooldown" + - name: Reject non-revision hook changes + if: steps.identity.outputs.bot == 'true' + shell: bash + env: + MANAGED: ${{ steps.identity.outputs.managed }} + MINIMUM_DAYS: ${{ inputs.minimum-days }} + run: | + set -euo pipefail + uv run --no-project --python "$RUNNER_TEMP/hook-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_hook_config.py" \ + "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/proposed-hooks.yaml" \ + --managed "$MANAGED" --minimum-days "$MINIMUM_DAYS" - name: Select eligible hook revisions if: steps.identity.outputs.bot == 'true' shell: bash diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index 6d908a3..1304ebb 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -10,6 +10,9 @@ inputs: targets: description: Space-separated pairs such as 3.15.0:cpython; selected package versions must match default: "" + verify-groups: + description: Additional lock groups whose packages enter the release build + default: "" outputs: path: description: Path to the generated constraints for this lock selection @@ -22,5 +25,7 @@ runs: with: lock-source: shared constraints-lock: ${{ inputs.lock }} + verify-lock: ${{ inputs.verify-groups != '' && inputs.lock || '' }} + verify-groups: ${{ inputs.verify-groups }} target-platform: ${{ inputs.target-platform }} targets: ${{ inputs.targets }} diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index dd9ad1b..f626c0a 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -16,6 +16,12 @@ inputs: constraints-lock: description: Lock used to export build constraints; defaults to the selected install lock default: "" + verify-lock: + description: Another lock whose selected packages will be included in a release artifact + default: "" + verify-groups: + description: Whitespace-separated groups to verify in verify-lock + default: "" target-platform: description: Nab platform ID for the exported build constraints default: "" @@ -31,20 +37,20 @@ runs: steps: - name: Create lock-tool environment shell: bash - run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" + run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-reader" - 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 + if [[ -f "$RUNNER_TEMP/lock-reader/.lock-digest" && + $(cat "$RUNNER_TEMP/lock-reader/.lock-digest") == "${digest%% *}" ]]; then exit 0 fi - uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-reader" --group lock-reader \ --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" - printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-tools/.lock-digest" + printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-reader/.lock-digest" - id: selection name: Select locked groups and build constraints shell: bash @@ -58,7 +64,7 @@ runs: case "$LOCK_SOURCE" in caller) lock=pylock.toml - lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml") + lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml) ;; shared) lock="$GITHUB_ACTION_PATH/../pylock.toml" @@ -82,15 +88,36 @@ runs: 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 \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" 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 + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" --build-constraints \ + "${lock_check[@]}" fi printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" + - name: Verify additional release lock + if: inputs.verify-lock != '' || inputs.verify-groups != '' + shell: bash + env: + VERIFY_LOCK: ${{ inputs.verify-lock }} + VERIFY_GROUPS: ${{ inputs.verify-groups }} + run: | + set -euo pipefail + groups=() + while IFS= read -r line; do + read -ra selected <<<"$line" + groups+=("${selected[@]}") + done <<<"$VERIFY_GROUPS" + if [[ -z "$VERIFY_LOCK" || ${#groups[@]} -eq 0 ]]; then + echo '::error::verify-lock and verify-groups must be set together' >&2 + exit 1 + fi + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$VERIFY_LOCK" "${groups[@]}" \ + --verify-pypi --project pyproject.toml - id: constraints name: Export locked build requirements shell: bash @@ -102,8 +129,13 @@ runs: 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 + lock_check=() + if [[ "$CONSTRAINTS_LOCK" != "$GITHUB_ACTION_PATH/../pylock.toml" ]]; then + lock_check=(--verify-pypi --project pyproject.toml) + fi + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints \ + "${lock_check[@]}" fi if [[ -n "$CONSTRAINTS_LOCK" ]]; then BUILD_LOCK=$CONSTRAINTS_LOCK; fi args=() @@ -123,7 +155,7 @@ runs: digest=$(sha256sum <<<"$digest") output="$RUNNER_TEMP/build-constraints-${digest%% *}.txt" if [[ ! -f "$output" ]]; then - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \ "$output" "${locks[@]}" fi @@ -151,5 +183,5 @@ runs: env: VENV: ${{ inputs.venv }} run: | - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/expose_locked_commands.py" "$VENV" diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 34d5100..653ad4c 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -39,27 +39,32 @@ runs: uses: $/read-towncrier-config - id: version name: Select release version - if: inputs.phase != 'finalize' shell: bash env: BUMP: ${{ inputs.bump }} TAG_PREFIX: ${{ inputs.tag-prefix }} VERSION: ${{ inputs.version }} - EXPLICIT_VERSION: ${{ inputs.version }} FRAGMENTS: ${{ steps.config.outputs.fragments }} + PHASE: ${{ inputs.phase }} run: | set -euo pipefail - if [[ -z "$VERSION" ]]; then + if [[ "$PHASE" == finalize ]]; then + [[ -n "$VERSION" ]] || { + echo '::error::finalize requires a version from the build phase' >&2 + exit 1 + } + printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" + exit 0 + fi + if [[ -n "$VERSION" ]]; then + git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null + git tag --list | release-creator tags "$VERSION" --require-newer + else args=(--next "$BUMP" --directory "$FRAGMENTS") if [[ -n "$TAG_PREFIX" ]]; then args+=(--prefix "$TAG_PREFIX") fi VERSION=$(git tag --list | release-creator tags "${args[@]}") - else - git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null - fi - 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 diff --git a/pylock.toml b/pylock.toml index e5bdc17..39ff55a 100644 --- a/pylock.toml +++ b/pylock.toml @@ -10,6 +10,7 @@ environments = [ requires-python = ">=3.15" dependency-groups = [ "build", + "lock-reader", "lock-tools", "project-runtime", "release-attestations", @@ -110,7 +111,7 @@ sha256 = "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7" [[packages]] name = "certifi" version = "2026.7.22" -marker = "\"release-attestations\" in dependency_groups" +marker = "\"lock-reader\" in dependency_groups or \"release-attestations\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" @@ -1266,7 +1267,7 @@ sha256 = "6f77daa98279ef0eedb9e8f2248aee4c50e01b92b470b68886995e5056d79971" [[packages]] name = "packaging" version = "26.3" -marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"lock-reader\" in dependency_groups or \"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -1703,11 +1704,12 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "0.6.5" +version = "1.0.0" marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.15" dependencies = [ { name = "docutils" }, + { name = "markdown-it-py" }, { name = "mdformat" }, { name = "mdformat-gfm" }, { name = "packaging" }, @@ -1717,22 +1719,22 @@ dependencies = [ index = "https://pypi.org/simple/" [packages.sdist] -name = "release_creator-0.6.5.tar.gz" -upload-time = 2026-10-23 19:54:49.281646+00:00 -url = "https://files.pythonhosted.org/packages/a0/da/7195823b3d0ab066f518d8bd987f88a39e0e39da33f629e021346209a7cb/release_creator-0.6.5.tar.gz" -size = 135158 +name = "release_creator-1.0.0.tar.gz" +upload-time = 2026-10-24 06:07:45.789370+00:00 +url = "https://files.pythonhosted.org/packages/e2/92/dfc1c6e8f52d50d91a05f77f67592c2408cabd9d96079fedbe551f24530d/release_creator-1.0.0.tar.gz" +size = 134186 [packages.sdist.hashes] -sha256 = "954247b06b7b9a8c196a782252e3d980d33a9e9b7f9e5aea7647ef6c758d4275" +sha256 = "1174e3a3fd39e9117eb9c34e3fbc58a8e32c49f5f65d01f39195231b967807f6" [[packages.wheels]] -name = "release_creator-0.6.5-py3-none-any.whl" -upload-time = 2026-10-23 19:54:47.716775+00:00 -url = "https://files.pythonhosted.org/packages/f1/4c/6215690fc2b48f3d927699bb16f5c8bcf26b8862e378df33a2581a45215b/release_creator-0.6.5-py3-none-any.whl" -size = 46723 +name = "release_creator-1.0.0-py3-none-any.whl" +upload-time = 2026-10-24 06:07:44.366184+00:00 +url = "https://files.pythonhosted.org/packages/6a/17/b1cd851932786f3e9e9f2187d150bc0860e04adf76cbfd45e6531715bf97/release_creator-1.0.0-py3-none-any.whl" +size = 53396 [packages.wheels.hashes] -sha256 = "dd109f7fb64808a83833c332b8563c56bd943c4a8de79b440eab475b5ca9a73e" +sha256 = "613a26a0d1b0042d9dad60dfc00f3e78208d84ab9f78cec792a407b6a0a75c24" [[packages]] name = "requests" @@ -2483,14 +2485,16 @@ sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" [tool.nab] nab-version = "0.0.22" -created-at = 2026-10-23 19:55:55.131127+00:00 +created-at = 2026-10-24 06:08:31.473006+00:00 command-line = [ "nab", + "-vv", "lock", "--no-cache", "--groups", "build", "lock-tools", + "lock-reader", "release-notes", "release-news", "release-prepare", diff --git a/pyproject.toml b/pyproject.toml index 86e74b9..3507510 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,18 +10,20 @@ 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.5" ] -release-news = [ "release-creator>=0.6.5", "towncrier>=26.9" ] -release-prepare = [ "release-creator>=0.6.5", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=0.6.5", "prek>=0.2" ] +lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] +release-notes = [ "release-creator>=1.0.0" ] +release-news = [ "release-creator>=1.0.0", "towncrier>=26.9" ] +release-prepare = [ "release-creator>=1.0.0", "towncrier>=26.9", "prek>=0.2" ] +release-validate = [ "release-creator>=1.0.0", "prek>=0.2" ] release-check = [ "prek>=0.2" ] -release-attestations = [ "pypi-attestations>=0.0.30" ] +release-attestations = [ "certifi>=2026.7.22", "pypi-attestations>=0.0.30" ] release-shell = [ "prek>=0.2", "yq>=4.4.5" ] -release-hooks = [ "release-creator>=0.6.5", "prek>=0.2" ] +release-hooks = [ "release-creator>=1.0.0", "prek>=0.2" ] release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ "certifi", "check-wheel-contents>=0.6.3", + "coverage[toml]>=7.10", "hatchling>=1.28", "pytest>=9", "pytest-httpserver", @@ -66,11 +68,20 @@ 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-23T19:54:50Z" +uploaded-prior-to = "2026-10-24T06:07:46Z" [tool.ruff] line-length = 120 +[tool.coverage.run] +branch = true +patch = [ "subprocess" ] +source_dirs = [ "tools" ] + +[tool.coverage.report] +fail_under = 100 +show_missing = true + [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = [ "INP001" ] # Test modules intentionally have no package marker. diff --git a/read-towncrier-config/action.yml b/read-towncrier-config/action.yml index 070e73d..0cf8a4a 100644 --- a/read-towncrier-config/action.yml +++ b/read-towncrier-config/action.yml @@ -1,9 +1,5 @@ name: Read Towncrier configuration description: Read release paths, format, and fragment categories from the committed Towncrier configuration -inputs: - working-directory: - description: Checkout containing the configuration - default: . outputs: changelog: description: Changelog path relative to the repository @@ -20,7 +16,6 @@ runs: - name: Read committed Towncrier configuration id: config shell: bash - working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail config_dir=$(mktemp -d "$RUNNER_TEMP/towncrier-config.XXXXXX") diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml index ce1a2f0..c6ed2ec 100644 --- a/recheck-hook-update-cooldown/action.yml +++ b/recheck-hook-update-cooldown/action.yml @@ -2,14 +2,11 @@ name: Recheck cooled hook updates description: Recheck failed bot hook-update cooldown jobs each day inputs: workflow: - description: Workflow file containing the required hook-cooldown job + description: Workflow file containing the hook cooldown job default: check.yaml job-name: - description: Name of the required cooldown job to rerun + description: Name of the hook cooldown job default: hook-cooldown - minimum-days: - description: Minimum age of an eligible hook revision - default: "7" runs: using: composite steps: @@ -31,16 +28,9 @@ runs: GH_TOKEN: ${{ github.token }} WORKFLOW: ${{ inputs.workflow }} JOB_NAME: ${{ inputs.job-name }} - MINIMUM_DAYS: ${{ inputs.minimum-days }} run: | set -euo pipefail - case "$MINIMUM_DAYS" in - '' | 0* | *[!0-9]*) exit 1 ;; - esac - if ((MINIMUM_DAYS < 7)); then - echo '::error::hook cooldown must be at least seven days' >&2 - exit 1 - fi + [[ -n "$WORKFLOW" && -n "$JOB_NAME" ]] || exit 1 while IFS= read -r pull; do number=$(jq -r .number <<<"$pull") gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$number/files" --jq '.[].filename' \ @@ -59,8 +49,8 @@ runs: .head_branch == $branch and .head_sha == $head and any(.pull_requests[]; .number == $number) )]') - if ! jq -e --argjson minimum_days "$MINIMUM_DAYS" \ - '[.[].created_at | fromdateiso8601] | min <= now - $minimum_days * 86400' \ + if ! jq -e \ + '[.[].created_at | fromdateiso8601] | min <= now - 7 * 86400' \ <<<"$runs" >/dev/null; then continue fi @@ -73,8 +63,8 @@ runs: fi job=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$(jq -r .id <<<"$run")/jobs" \ -f filter=latest -f per_page=100 | - jq -sc --arg name "$JOB_NAME" \ - '[.[].jobs[] | select(.name == $name and .conclusion == "failure")] | first // empty') + jq -sc --arg job_name "$JOB_NAME" \ + '[.[].jobs[] | select(.name == $job_name and .conclusion == "failure")] | first // empty') if [[ -n "$job" ]]; then if ! gh run rerun "$(jq -r .id <<<"$run")" --job "$(jq -r .id <<<"$job")" \ --repo "$GITHUB_REPOSITORY"; then diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index b42272e..48ef3a2 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -13,14 +13,6 @@ inputs: runs: using: composite steps: - - name: Check artifact inputs - shell: bash - env: - PROJECT: ${{ inputs.project }} - VERSION: ${{ inputs.version }} - DIST: ${{ inputs.dist }} - run: | - [[ -n "$PROJECT" && -n "$VERSION" && -d "$DIST" ]] || exit 1 - name: Install locked provenance verifier uses: $/install-locked-tools with: diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 910ad15..44eb2e4 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -34,7 +34,9 @@ runs: steps: - uses: $/install-locked-tools with: + groups: lock-tools lock-source: shared + venv: ${{ runner.temp }}/lock-tools - 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 @@ -78,7 +80,7 @@ runs: exit 1 ;; esac - uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --groups "${groups[@]}" \ + uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --no-cache --groups "${groups[@]}" \ --output pylock.toml "${refresh[@]}" if [[ "$MODE" == check ]]; then python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \ @@ -135,7 +137,7 @@ runs: ;; esac cp "$ADDITIONAL_LOCK" "$RUNNER_TEMP/additional-lock-before.toml" - uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock pyproject.toml "${selection[@]}" \ + uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --no-cache pyproject.toml "${selection[@]}" \ --project-matrix-python "$MATRIX_PYTHON" \ --project-matrix-platforms "${platforms[@]}" \ --project-matrix-implementations "${implementations[@]}" \ diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 2fa286a..764321f 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -17,7 +17,6 @@ def test_build_python_distribution_creates_wheel_and_sdist( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(tmp_path), ) assert result.returncode == 0, result.stderr @@ -41,7 +40,6 @@ def test_build_python_distribution_repeats_exact_bytes( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(temporary), ) assert result.returncode == 0, result.stderr @@ -65,7 +63,6 @@ def test_build_python_distribution_rejects_existing_artifact( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(tmp_path), ) assert result.returncode != 0 @@ -89,12 +86,11 @@ def test_build_python_distribution_rejects_backend_hash_mismatch( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(tmp_path), UV_CACHE_DIR=str(tmp_path / "uv-cache"), ) assert result.returncode != 0 - assert "Hash mismatch" in result.stderr + assert "release lock differs from PyPI metadata: hatchling" in result.stderr def test_build_python_distribution_does_not_run_hooks( @@ -110,7 +106,6 @@ def test_build_python_distribution_does_not_run_hooks( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(tmp_path), ) assert result.returncode == 0, result.stderr diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index df5907c..d0c042b 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -21,6 +21,10 @@ def test_action_uses_scoped_job_token() -> None: "${{ github.token }}", "${{ github.token }}", ] + assert action["runs"]["steps"][0]["env"]["RELEASE_APP_CLIENT_ID"] == "${{ inputs.release-app-client-id }}" + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text()) + caller = workflow["jobs"]["hook-cooldown"]["steps"][0] + assert caller["with"]["release-app-client-id"] == "${{ vars.RELEASE_APP_CLIENT_ID }}" @pytest.mark.parametrize( @@ -99,6 +103,81 @@ def test_cooldown_skips_bot_without_hook_changes( ] +@pytest.mark.parametrize( + ("observed_days", "accepted"), + [pytest.param(0, False, id="new"), pytest.param(10, True, id="cooled")], +) +def test_cooldown_checks_generated_update_branch( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + observed_days: int, + accepted: bool, +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull( + server, author="release-app[bot]", branch="automation/update-release-tools", observed_days=observed_days + ) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + if not accepted: + expect_historical_runs(server, older_head=None) + + result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": "Iv1.release"}) + + assert (result.returncode == 0) is accepted, result.stderr + assert len(server.log) >= 4 + + +@pytest.mark.parametrize( + ("client_id", "head_repository"), + [ + pytest.param("Iv1.other", "tox-dev/fixture", id="another-app"), + pytest.param("Iv1.release", "other/fixture", id="fork-head"), + ], +) +def test_cooldown_rejects_untrusted_updater_identity( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + client_id: str, + head_repository: str, +) -> None: + server, environment = github_api + expect_pull( + server, + author="release-app[bot]", + branch="automation/update-release-tools", + app_client_id=client_id, + head_repository=head_repository, + ) + + result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": "Iv1.release"}) + + assert result.returncode != 0 + assert ( + "different App identity" in result.stderr + if head_repository == "tox-dev/fixture" + else [request.path for request, _ in server.log] == ["/api/v3/repos/tox-dev/fixture/pulls/7"] + ) + + +def test_cooldown_rejects_hook_configuration_change( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]") + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1", hook=" - id: sample\n entry: untrusted\n") + + result = run_cooldown(action_scripts, tmp_path, environment) + + assert result.returncode != 0 + assert "changes fields beyond revisions" in result.stderr + + def test_cooldown_rejects_changed_head( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: @@ -109,6 +188,37 @@ def test_cooldown_rejects_changed_head( assert len(server.log) == 1 +@pytest.mark.parametrize( + "minimum_days", + [ + pytest.param("6", id="short"), + pytest.param("07", id="leading-zero"), + pytest.param("bad", id="non-numeric"), + ], +) +def test_cooldown_rejects_invalid_minimum( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], minimum_days: str +) -> None: + server, environment = github_api + result = run_cooldown(action_scripts, tmp_path, environment, minimum_days=minimum_days) + assert result.returncode != 0 + assert not server.log + + +def test_cooldown_rejects_bot_without_observed_run( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]", observed_days=None) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + expect_historical_runs(server, older_head=None) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode != 0 + assert "hook revision has not been observed" in result.stderr + + def test_cooldown_rejects_backdated_tag_on_recent_pull_request( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: @@ -264,6 +374,7 @@ def run_cooldown( *, event_name: str = "pull_request", event_head: str = "a" * 40, + minimum_days: str = "7", ) -> subprocess.CompletedProcess[str]: path = tmp_path / "event.json" path.write_text(json.dumps({"number": 7, "pull_request": {"head": {"sha": event_head}}})) @@ -276,7 +387,7 @@ def run_cooldown( action_scripts, "check-hook-update-cooldown", version="", - MINIMUM_DAYS="7", + MINIMUM_DAYS=minimum_days, GITHUB_EVENT_NAME=event_name, GITHUB_EVENT_PATH=str(path), GITHUB_OUTPUT=str(tmp_path / "outputs"), @@ -289,15 +400,18 @@ def expect_pull( *, author: str, head: str = "a" * 40, - observed_days: int = 10, + observed_days: int | None = 10, updated_days: int = 0, hook_update: bool = True, + branch: str = "bot-update", + app_client_id: str = "Iv1.release", + head_repository: str = "tox-dev/fixture", ) -> None: server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json( { "state": "open", "base": {"repo": {"full_name": "tox-dev/fixture"}, "sha": "b" * 40}, - "head": {"sha": head, "ref": "bot-update"}, + "head": {"sha": head, "ref": branch, "repo": {"full_name": head_repository}}, "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"}, "updated_at": (datetime.now(UTC) - timedelta(days=updated_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), } @@ -307,17 +421,23 @@ def expect_pull( [{"filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}] ) if author.endswith("[bot]") and hook_update: + if branch == "automation/update-release-tools": + server.expect_request("/api/v3/apps/release-app", method="GET").respond_with_json( + {"slug": "release-app", "client_id": app_client_id} + ) server.expect_request( "/api/v3/repos/tox-dev/fixture/actions/runs", method="GET", query_string=f"event=pull_request&head_sha={head}&per_page=100", ).respond_with_json( { - "workflow_runs": [ + "workflow_runs": [] + if observed_days is None + else [ { "event": "pull_request", "head_repository": {"full_name": "tox-dev/fixture"}, - "head_branch": "bot-update", + "head_branch": branch, "head_sha": head, "pull_requests": [{"number": 7}], "created_at": (datetime.now(UTC) - timedelta(days=observed_days)).strftime( @@ -329,7 +449,14 @@ def expect_pull( ) -def expect_config(server: HTTPServer, sha: str, repository: Path, revision: str | tuple[str, ...]) -> None: +def expect_config( + server: HTTPServer, + sha: str, + repository: Path, + revision: str | tuple[str, ...], + *, + hook: str = " - id: sample\n", +) -> None: revisions = (revision,) if isinstance(revision, str) else revision server.expect_request( "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", method="GET", query_string=f"ref={sha}" @@ -339,8 +466,7 @@ def expect_config(server: HTTPServer, sha: str, repository: Path, revision: str ( "repos:\n" + "".join( - f" - repo: {repository.as_uri()}\n rev: {item}\n hooks:\n - id: sample\n" - for item in revisions + f" - repo: {repository.as_uri()}\n rev: {item}\n hooks:\n{hook}" for item in revisions ) ).encode() ).decode() diff --git a/tests/check_hook_update_cooldown/test_hook_config.py b/tests/check_hook_update_cooldown/test_hook_config.py new file mode 100644 index 0000000..01d5501 --- /dev/null +++ b/tests/check_hook_update_cooldown/test_hook_config.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import json +import runpy +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from io import BytesIO +from pathlib import Path +from typing import cast +from unittest.mock import MagicMock +from urllib.request import urlopen + +import pytest +from pytest_mock import MockerFixture + + +@pytest.fixture +def pypi_file() -> dict[str, object]: + return { + "url": "https://files.pythonhosted.org/example-1.2.0.whl", + "digests": {"sha256": "a" * 64}, + "upload_time_iso_8601": (datetime.now(UTC) - timedelta(days=10)).isoformat(), + } + + +@pytest.fixture +def pypi_request(mocker: MockerFixture, pypi_file: dict[str, object]) -> MagicMock: + return cast("MagicMock", mocker.create_autospec(urlopen, return_value=_response("example", pypi_file))) + + +def _response(name: str, pypi_file: dict[str, object]) -> BytesIO: + return BytesIO(json.dumps({"info": {"name": name, "version": "1.2.0"}, "urls": [pypi_file]}).encode()) + + +@pytest.fixture +def check_config(mocker: MockerFixture, pypi_request: MagicMock) -> Callable[[Path, Path, bool, int], None]: + namespace = runpy.run_path( + str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="check_hook_config" + ) + mocker.patch.dict(namespace["check_config"].__globals__, {"urlopen": pypi_request}) + return cast("Callable[[Path, Path, bool, int], None]", namespace["check_config"]) + + +@pytest.fixture +def configs(tmp_path: Path) -> tuple[Path, Path]: + base = tmp_path / "base.yaml" + proposed = tmp_path / "proposed.yaml" + base.write_text( + "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n" + " - id: sample\n additional_dependencies:\n - example==1.0.0\n" + ) + return base, proposed + + +def test_app_update_accepts_cooled_direct_pin( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("rev: old", "rev: new").replace("example==1.0.0", "example==1.2.0")) + + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/example/1.2.0/json" + + +@pytest.mark.parametrize( + ("field", "value"), + [ + pytest.param("upload_time_iso_8601", "recent", id="recent"), + pytest.param("yanked", True, id="yanked"), + pytest.param("url", "https://example.org/wheel.whl", id="foreign-host"), + pytest.param("digests", {}, id="missing-hash"), + ], +) +def test_app_update_rejects_untrusted_pypi_file( + configs: tuple[Path, Path], + check_config: Callable[..., None], + pypi_file: dict[str, object], + pypi_request: MagicMock, + field: str, + value: object, +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) + pypi_file[field] = (datetime.now(UTC) - timedelta(days=1)).isoformat() if value == "recent" else value + pypi_request.return_value = _response("example", pypi_file) + + with pytest.raises(ValueError, match="uncooled or untrusted PyPI artifact"): + check_config(base, proposed, True, 7) + + +def test_app_update_accepts_cooled_managed_pin( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + base, proposed = configs + proposed.write_text( + base.read_text().replace( + " - example==1.0.0\n", + ' - example==1.0.0\n - "other==1.2.0" # release-creator managed\n', + ) + ) + + pypi_request.return_value = _response("other", pypi_file) + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" + + +@pytest.mark.parametrize( + ("replacement", "message"), + [ + pytest.param("example>=1.2.0", "not an exact PyPI pin", id="range"), + pytest.param("other==1.2.0", "direct dependencies beyond an exact pin", id="another-package"), + pytest.param("example @ https://example.org/wheel.whl", "direct dependencies beyond an exact pin", id="url"), + ], +) +def test_app_update_rejects_unsafe_direct_change( + configs: tuple[Path, Path], check_config: Callable[..., None], replacement: str, message: str +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("example==1.0.0", replacement)) + + with pytest.raises(ValueError, match=message): + check_config(base, proposed, True, 7) + + +def test_other_bot_cannot_change_pin(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) + + with pytest.raises(ValueError, match="beyond revisions"): + check_config(base, proposed, False, 7) + + +def test_app_update_keeps_direct_range( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + base, proposed = configs + base.write_text(base.read_text().replace("example==1.0.0", "example>=1")) + proposed.write_text( + base.read_text().replace( + " - example>=1\n", ' - example>=1\n - "other==1.2.0" # release-creator managed\n' + ) + ) + + pypi_request.return_value = _response("other", pypi_file) + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" + + +def test_app_update_rejects_managed_pin_removal(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + base.write_text( + base.read_text().replace( + " - example==1.0.0\n", + ' - example==1.0.0\n - "other==1.0.0" # release-creator managed\n', + ) + ) + proposed.write_text(configs[0].read_text().replace(' - "other==1.0.0" # release-creator managed\n', "")) + + with pytest.raises(ValueError, match="removes managed dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_update_accepts_managed_pin_refresh( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + base, proposed = configs + base.write_text( + base.read_text().replace( + " - example==1.0.0\n", + ' - example==1.0.0\n - "other==1.0.0" # release-creator managed\n', + ) + ) + proposed.write_text(base.read_text().replace("other==1.0.0", "other==1.2.0")) + + pypi_request.return_value = _response("other", pypi_file) + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" + + +def test_app_update_accepts_flow_mapping_managed_marker( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + base, proposed = configs + proposed.write_text( + base.read_text().replace( + " - id: sample\n additional_dependencies:\n - example==1.0.0\n", + " - {id: sample, additional_dependencies: [\n" + " example==1.0.0,\n" + ' "other==1.2.0" # release-creator managed\n' + " ]}\n", + ) + ) + + pypi_request.return_value = _response("other", pypi_file) + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" + + +def test_app_update_rejects_flow_comment_as_pin_marker( + configs: tuple[Path, Path], check_config: Callable[..., None] +) -> None: + base, proposed = configs + proposed.write_text( + base.read_text().replace( + "additional_dependencies:\n - example==1.0.0", + "additional_dependencies: [example==1.0.0, other==1.2.0] # release-creator managed", + ) + ) + + with pytest.raises(ValueError, match="changes direct dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_update_rejects_duplicate_keys(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace(" rev: old", " rev: old\n rev: new")) + + with pytest.raises(ValueError, match="duplicate mapping keys"): + check_config(base, proposed, True, 7) + + +@pytest.mark.parametrize( + ("source", "message"), + [ + pytest.param("", "must be a mapping", id="empty-file"), + pytest.param("[]\n", "must be a mapping", id="list-root"), + pytest.param("repos: bad\n", "repositories must be lists", id="scalar-repos"), + pytest.param("repos:\n - bad\n", "repository must be a mapping", id="scalar-repository"), + pytest.param( + "repos:\n - repo: https://example.org/hook\n", "repository must contain hooks", id="missing-hooks" + ), + pytest.param( + "repos:\n - repo: https://example.org/hook\n hooks:\n - bad\n", + "hook must be a mapping", + id="scalar-hook", + ), + pytest.param( + "repos:\n - repo: https://example.org/hook\n hooks:\n" + " - id: sample\n additional_dependencies: bad\n", + "additional_dependencies must be a list", + id="scalar-dependencies", + ), + pytest.param( + "repos:\n - repo: https://example.org/hook\n hooks:\n" + " - id: sample\n additional_dependencies: [example==1.0.0, {name: bad}]\n", + "additional_dependencies must be a list", + id="mapping-dependency", + ), + ], +) +def test_app_update_rejects_malformed_hook_config( + configs: tuple[Path, Path], check_config: Callable[..., None], source: str, message: str +) -> None: + base, proposed = configs + proposed.write_text(source) + + with pytest.raises((TypeError, ValueError), match=message): + check_config(base, proposed, True, 7) + + +def test_app_update_rejects_changed_hook_count(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text() + " - id: added\n") + + with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_update_rejects_changed_aliased_hook(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + source = base.read_text().replace("- repo:", "- &repository\n repo:") + base.write_text(source.replace(" - example==1.0.0\n", " - example==1.0.0\n - *repository\n")) + proposed.write_text(base.read_text().replace("id: sample", "id: changed")) + + with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_update_rejects_duplicate_dependency(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text( + base.read_text().replace( + " - example==1.0.0\n", + ' - example==1.0.0\n - "example==1.2.0" # release-creator managed\n', + ) + ) + + with pytest.raises(ValueError, match="duplicates a dependency"): + check_config(base, proposed, True, 7) + + +@pytest.mark.parametrize( + "metadata", + [ + pytest.param({"info": {"name": "other", "version": "1.2.0"}, "urls": [{}]}, id="wrong-name"), + pytest.param({"info": {"name": "example", "version": "1.1.0"}, "urls": [{}]}, id="wrong-version"), + pytest.param({"info": {"name": "example", "version": "1.2.0"}, "urls": []}, id="no-files"), + pytest.param({"info": {"name": "example", "version": "1.2.0"}, "urls": "bad"}, id="files-type"), + ], +) +def test_app_update_rejects_invalid_pypi_listing( + configs: tuple[Path, Path], + check_config: Callable[..., None], + pypi_request: MagicMock, + metadata: dict[str, object], +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) + pypi_request.return_value = BytesIO(json.dumps(metadata).encode()) + + with pytest.raises(ValueError, match="invalid PyPI hook dependency"): + check_config(base, proposed, True, 7) + + +def test_app_cannot_change_hook_command(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("id: sample", "id: other")) + + with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_cannot_add_unmarked_dependency(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text() + " - example==1.2.0\n") + + with pytest.raises(ValueError, match="changes direct dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_cannot_shorter_cooldown(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text()) + + with pytest.raises(ValueError, match="at least seven days"): + check_config(base, proposed, True, 6) + + +def test_cli_accepts_revision_only_change( + configs: tuple[Path, Path], mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("rev: old", "rev: new")) + mocker.patch("sys.argv", ["check_hook_config.py", str(base), str(proposed), "--managed", "false"]) + + runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="__main__") + + assert capsys.readouterr() == ("", "") + + +def test_cli_reports_invalid_config( + configs: tuple[Path, Path], mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + base, proposed = configs + proposed.write_text("repos: bad\n") + mocker.patch("sys.argv", ["check_hook_config.py", str(base), str(proposed), "--managed", "true"]) + + with pytest.raises(SystemExit, match="2"): + runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="__main__") + + assert "hook repositories must be lists" in capsys.readouterr().err diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py index 983b7fe..8532de0 100644 --- a/tests/check_hook_update_cooldown/test_workflow.py +++ b/tests/check_hook_update_cooldown/test_workflow.py @@ -9,27 +9,6 @@ import yaml -def test_cooldown_job_is_read_only_and_required() -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - job = workflow["jobs"]["hook-cooldown"] - assert job["if"] == "github.event_name == 'pull_request'" - assert job["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"} - assert job["steps"][0] == {"uses": "$/check-hook-update-cooldown"} - assert "hook-cooldown" in workflow["jobs"]["gate"]["needs"] - - -def test_cooldown_installs_only_required_hook_tools() -> None: - action = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text()) - assert next(step for step in action["runs"]["steps"] if step.get("uses") == "$/install-locked-tools") == { - "uses": "$/install-locked-tools", - "if": "steps.identity.outputs.bot == 'true'", - "with": {"groups": "release-hook-check", "lock-source": "shared", "venv": "${{ runner.temp }}/hook-tools"}, - } - - @pytest.mark.parametrize( ("event_name", "cooldown_result", "accepted"), [ diff --git a/tests/conftest.py b/tests/conftest.py index 04a3f19..dbf1c1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from html import escape from pathlib import Path from textwrap import dedent -from typing import Final +from typing import Final, TypedDict, cast from urllib.parse import urlsplit from urllib.request import urlopen @@ -23,6 +23,11 @@ from pytest_httpserver import HTTPServer from werkzeug.wrappers import Request, Response +_PyPIFile = TypedDict( + "_PyPIFile", + {"filename": str, "url": str, "hashes": dict[str, str], "upload-time": str}, +) + @pytest.fixture def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]: @@ -138,6 +143,54 @@ def artifact_cache() -> dict[str, bytes]: return {} +@pytest.fixture(scope="session") +def pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path: + listings = _lock_metadata(Path(__file__).resolve().parents[1] / "pylock.toml") + for filename, url, digest, uploaded in ( + ( + "six-1.16.0-py2.py3-none-any.whl", + "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", + "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", + "2021-05-05T14:18:17.237677+00:00", + ), + ( + "packaging-25.0-py3-none-any.whl", + "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", + "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "2025-04-19T11:48:57.875608+00:00", + ), + ( + "example-1.0-py3-none-any.whl", + "https://files.pythonhosted.org/example-1.0-py3-none-any.whl", + "0" * 64, + "2026-10-01T00:00:00+00:00", + ), + ( + "example-2.0-py3-none-any.whl", + "https://files.pythonhosted.org/example-2.0-py3-none-any.whl", + "1" * 64, + "2026-10-01T00:00:00+00:00", + ), + ): + name = filename.split("-", 1)[0] + listings.setdefault(name, []).append( + { + "filename": filename, + "url": url, + "hashes": {"sha256": digest}, + "upload-time": uploaded, + } + ) + path = tmp_path_factory.mktemp("pypi-metadata") / "listings.json" + path.write_text(json.dumps(listings)) + return path + + +@pytest.fixture(autouse=True) +def pypi_metadata_environment(pypi_metadata: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GW_PYPI_METADATA_FILE", str(pypi_metadata)) + + @pytest.fixture(scope="session") def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path: root = Path(__file__).resolve().parents[1] @@ -277,6 +330,13 @@ def build_repository(source_project: Path, nab_executable: Path, monkeypatch: py "--output", "pylock.toml", ) + listings = cast("dict[str, list[_PyPIFile]]", json.loads(Path(os.environ["GW_PYPI_METADATA_FILE"]).read_text())) + for name, files in _lock_metadata(source_project / "pylock.toml").items(): + existing = {file["filename"] for file in listings.get(name, [])} + listings.setdefault(name, []).extend(file for file in files if file["filename"] not in existing) + metadata = source_project.parent / "pypi-metadata.json" + metadata.write_text(json.dumps(listings)) + monkeypatch.setenv("GW_PYPI_METADATA_FILE", str(metadata)) run(source_project, "git", "add", ".") run(source_project, "git", "commit", "-m", "Add package source") run(source_project, "git", "push", "origin", "main") @@ -294,6 +354,12 @@ def action( version: str, **extra_env: str, ) -> subprocess.CompletedProcess[str]: + if ( + name in {"install-locked-tools", "export-build-constraints"} + and (repository / "pylock.toml").exists() + and not (repository / "pyproject.toml").exists() + ): + (repository / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') action_config = yaml.safe_load((Path(__file__).resolve().parents[1] / name / "action.yml").read_text()) head = run(repository, "git", "rev-parse", "HEAD").stdout.strip() if (repository / ".git").exists() else "0" * 40 input_values = { @@ -472,7 +538,7 @@ def _condition( def _safe_environment() -> dict[str, str]: - return { + environment = { name: os.environ[name] for name in ( "HOME", @@ -486,9 +552,19 @@ def _safe_environment() -> dict[str, str]: "SSL_CERT_FILE", "GH_HOST", "GH_ENTERPRISE_TOKEN", + "COVERAGE_PROCESS_START", + "COVERAGE_PROCESS_CONFIG", + "GW_PYPI_METADATA_FILE", ) if name in os.environ } + if "GW_PYPI_METADATA_FILE" in environment: + environment["PYTHONPATH"] = str(Path(__file__).parent / "coverage_bootstrap") + if "COVERAGE_PROCESS_CONFIG" in environment or "COVERAGE_PROCESS_START" in environment: + import coverage + + environment["GW_COVERAGE_SITE_PACKAGES"] = str(Path(coverage.__file__).parent.parent) + return environment def _inputs( @@ -532,7 +608,14 @@ def _expression( if " && " in expression and " || " in expression: condition, alternatives = expression.split(" && ", 1) true_value, false_value = alternatives.split(" || ", 1) - return (true_value if _condition(condition, environment, inputs, step_outputs) else false_value).strip("'") + return _expression( + true_value if _condition(condition, environment, inputs, step_outputs) else false_value, + environment, + inputs, + step_outputs, + ) + if expression.startswith("'") and expression.endswith("'"): + return expression[1:-1] if " == " in expression or " != " in expression: return str(_condition(expression, environment, inputs, step_outputs)).lower() if " || " in expression: @@ -567,6 +650,21 @@ def _expression( raise ValueError(msg) +def _lock_metadata(lock: Path) -> dict[str, list[_PyPIFile]]: + listings: dict[str, dict[str, _PyPIFile]] = {} + for package in tomllib.loads(lock.read_text())["packages"]: + files = listings.setdefault(canonicalize_name(package["name"]), {}) + for artifact in [*package.get("wheels", []), *([package["sdist"]] if "sdist" in package else [])]: + filename = artifact.get("name") or urlsplit(artifact["url"]).path.rsplit("/", 1)[-1] + files[filename] = { + "filename": filename, + "url": artifact["url"], + "hashes": artifact["hashes"], + "upload-time": artifact["upload-time"].isoformat(), + } + return {name: list(files.values()) for name, files in listings.items()} + + __all__ = [ "action", "run", diff --git a/tests/coverage_bootstrap/sitecustomize.py b/tests/coverage_bootstrap/sitecustomize.py new file mode 100644 index 0000000..b26ea5c --- /dev/null +++ b/tests/coverage_bootstrap/sitecustomize.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +if "GW_PYPI_METADATA_FILE" in os.environ and Path(sys.argv[0]).name == "check_lock_groups.py": + import json + from io import BytesIO + from unittest.mock import patch + from urllib.parse import urlsplit + from urllib.request import Request + + _LISTINGS = json.loads(Path(os.environ["GW_PYPI_METADATA_FILE"]).read_text()) + + def _pypi_urlopen(request: Request, **_options: float) -> BytesIO: + url = urlsplit(request.full_url) + if url.scheme != "https" or url.netloc != "pypi.org" or not url.path.startswith("/simple/"): + raise ValueError(f"unexpected PyPI metadata request: {request.full_url}") + name = url.path.removeprefix("/simple/").strip("/") + return BytesIO( + json.dumps({"meta": {"api-version": "1.4"}, "name": name, "files": _LISTINGS.get(name, [])}).encode() + ) + + patch("urllib.request.urlopen", _pypi_urlopen).start() + +if "GW_COVERAGE_SITE_PACKAGES" in os.environ: + sys.path.append(os.environ["GW_COVERAGE_SITE_PACKAGES"]) + from coverage import process_startup + + process_startup() diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 623a436..83cce9b 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -1,9 +1,14 @@ from __future__ import annotations +import runpy import subprocess import sys +import tomllib +from collections.abc import Callable +from datetime import UTC, datetime from pathlib import Path from textwrap import dedent +from typing import cast import pytest import tomlkit @@ -13,6 +18,7 @@ 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()) + (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) outputs: dict[str, str] = {} result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", outputs, version="") assert result.returncode == 0, result.stderr @@ -66,12 +72,13 @@ def test_export_build_constraints_prefers_compatible_caller_build_pin( document = tomlkit.parse(shared.read_text()) for package in document["packages"]: if package["name"] == "setuptools": + current = package["version"] package["version"] = version for wheel in package["wheels"]: - wheel["name"] = wheel["name"].replace("84.0.0", version) - wheel["url"] = wheel["url"].replace("84.0.0", version) - package["sdist"]["name"] = package["sdist"]["name"].replace("84.0.0", version) - package["sdist"]["url"] = package["sdist"]["url"].replace("84.0.0", version) + wheel["name"] = wheel["name"].replace(current, version) + wheel["url"] = wheel["url"].replace(current, version) + package["sdist"]["name"] = package["sdist"]["name"].replace(current, version) + package["sdist"]["url"] = package["sdist"]["url"].replace(current, version) caller.write_text(tomlkit.dumps(document)) output = tmp_path / "constraints.txt" result = subprocess.run( @@ -126,8 +133,131 @@ 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" +@pytest.mark.parametrize( + ("arguments", "message"), + [ + pytest.param(["{output}", "{lock}", "{lock}", "{lock}"], "at most one shared", id="three-locks"), + pytest.param( + ["--shared-policy", "{project}", "{output}", "{lock}"], + "shared-policy requires a shared and caller lock", + id="policy-without-caller", + ), + pytest.param( + ["--target-platform", "unknown", "--targets", "3.15.0:cpython", "{output}", "{lock}"], + "unknown target platform", + id="unknown-platform", + ), + pytest.param( + ["--target-platform", "linux_x86_64", "--targets", "3.15:cpython", "{output}", "{lock}"], + "invalid target", + id="incomplete-python-version", + ), + pytest.param( + ["--target-platform", "linux_x86_64", "--targets", " , ", "{output}", "{lock}"], + "at least one target", + id="empty-targets", + ), + ], +) +def test_export_build_constraints_rejects_invalid_arguments( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + arguments: list[str], + message: str, +) -> None: + source = Path(__file__).resolve().parents[2] + lock = tmp_path / "pylock.build.toml" + write_matrix_lock(lock) + paths = {"output": str(tmp_path / "constraints.txt"), "lock": str(lock), "project": str(source / "pyproject.toml")} + script = source / "tools/build_constraints.py" + command = runpy.run_path(str(script), run_name="build_constraints_imported") + monkeypatch.setattr(sys, "argv", [str(script), *(argument.format_map(paths) for argument in arguments)]) + + with pytest.raises(SystemExit, match="2"): + cast("Callable[[], None]", command["main"])() + + assert message in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("change", "message"), + [ + pytest.param("missing-build-group", "dependency groups but no build group", id="missing-build-group"), + pytest.param("local-package", "must be an index version", id="local-package"), + pytest.param("missing-hash", "has no SHA256 hash", id="missing-hash"), + ], +) +def test_export_build_constraints_rejects_untrusted_build_packages(tmp_path: Path, change: str, message: str) -> None: + source = Path(__file__).resolve().parents[2] + lock = tmp_path / "pylock.build.toml" + write_matrix_lock(lock) + content = lock.read_text() + if change == "missing-build-group": + content = content.replace("[[packages]]", 'dependency-groups = ["release"]\n[[packages]]') + elif change == "local-package": + content = content.replace('index = "https://pypi.org/simple/"', 'directory = { path = "." }') + content = content.replace("[[packages.wheels]]", "") + content = ( + content[: content.index('name = "example-1.0-py3-none-any.whl"')] + + "[tool.nab]\ncreated-at = 2026-10-23T00:00:00Z\n" + ) + else: + content = content.replace('hashes.sha256 = "' + "0" * 64 + '"', 'hashes.sha512 = "' + "0" * 128 + '"') + lock.write_text(content) + + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + "--target-platform", + "macos_arm64", + "--targets", + "3.15.0:cpython", + str(tmp_path / "constraints.txt"), + str(lock), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert message in result.stderr + + +def test_export_build_constraints_rejects_conflicting_versions_without_policy(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + shared = source / "pylock.toml" + caller = tmp_path / "pylock.toml" + document = tomlkit.parse(shared.read_text()) + package = next(package for package in document["packages"] if package["name"] == "setuptools") + current = package["version"] + package["version"] = "81.0.0" + for artifact in [package["sdist"], *package["wheels"]]: + artifact["name"] = artifact["name"].replace(current, "81.0.0") + artifact["url"] = artifact["url"].replace(current, "81.0.0") + caller.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + str(tmp_path / "constraints.txt"), + str(shared), + str(caller), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "conflicting build dependency versions for setuptools" in result.stderr + + def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, action_scripts: Path) -> None: - write_matrix_lock(tmp_path / "pylock.build.toml") + expected = write_published_matrix_lock(tmp_path / "pylock.build.toml") outputs: dict[str, str] = {} result = action( @@ -144,7 +274,29 @@ def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, assert result.returncode == 0, result.stderr output = Path(outputs["path"]) - assert output.read_text() == f"example==1.0 --hash=sha256:{'0' * 64}\n" + assert output.read_text() == expected + + +def test_export_build_constraints_action_rejects_wrong_pypi_digest(tmp_path: Path, action_scripts: Path) -> None: + lock = tmp_path / "pylock.build.toml" + write_published_matrix_lock(lock) + document = tomlkit.parse(lock.read_text()) + document["packages"][0]["wheels"][0]["hashes"]["sha256"] = "0" * 64 + lock.write_text(tomlkit.dumps(document)) + + 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", + ) + + assert result.returncode != 0 + assert "release lock differs from PyPI metadata: packaging:" in result.stderr @pytest.mark.parametrize("groups", ["", "release-check"], ids=["export-only", "with-installed-group"]) @@ -152,8 +304,10 @@ 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")) + write_published_matrix_lock(lock) + document = tomlkit.parse(lock.read_text()) + document["packages"][0]["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) + lock.write_text(tomlkit.dumps(document)) result = action( tmp_path, tmp_path, @@ -172,21 +326,25 @@ def test_export_build_constraints_rejects_uncooled_secondary_lock( 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) + write_published_matrix_lock(lock) lock.write_text( - lock.read_text().replace('version = "1.0"', 'version = "1.0"\nmarker = \'implementation_name == "pypy"\'') + lock.read_text().replace( + 'index = "https://pypi.org/simple/"', + 'marker = \'implementation_name == "pypy"\'\nindex = "https://pypi.org/simple/"', + 1, + ) + dedent( """\ [[packages]] - name = "example" - version = "2.0" + name = "packaging" + version = "25.0" marker = 'implementation_name == "cpython"' index = "https://pypi.org/simple/" [[packages.wheels]] - name = "example-2.0-py3-none-any.whl" - url = "https://files.pythonhosted.org/example-2.0-py3-none-any.whl" - upload-time = 2026-10-01T00:00:00Z - hashes.sha256 = "1111111111111111111111111111111111111111111111111111111111111111" + name = "packaging-25.0-py3-none-any.whl" + url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl" + upload-time = 2025-04-19T11:48:57.875608Z + hashes.sha256 = "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" """, ), ) @@ -241,6 +399,25 @@ def test_export_build_constraints_uses_full_python_version(tmp_path: Path, versi assert ("example==1.0" in output.read_text()) == selected +def write_published_matrix_lock(path: Path) -> str: + write_matrix_lock(path) + source = Path(__file__).resolve().parents[2] + (path.parent / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) + package = next( + package + for package in tomllib.loads((source / "pylock.toml").read_text())["packages"] + if package["name"] == "packaging" + ) + document = tomlkit.parse(path.read_text()) + document["packages"][0]["name"] = package["name"] + document["packages"][0]["version"] = package["version"] + document["packages"][0]["wheels"] = package["wheels"] + document["packages"][0]["sdist"] = package["sdist"] + path.write_text(tomlkit.dumps(document)) + hashes = {artifact["hashes"]["sha256"] for artifact in [*package["wheels"], package["sdist"]]} + return f"packaging=={package['version']} {' '.join(f'--hash=sha256:{digest}' for digest in sorted(hashes))}\n" + + def write_matrix_lock(path: Path) -> None: path.write_text( dedent( diff --git a/tests/install_locked_tools/test_expose_commands.py b/tests/install_locked_tools/test_expose_commands.py new file mode 100644 index 0000000..3054245 --- /dev/null +++ b/tests/install_locked_tools/test_expose_commands.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest +from pytest_mock import MockerFixture + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tools import expose_locked_commands + + +def test_expose_locked_commands_copies_windows_executable(tmp_path: Path, mocker: MockerFixture) -> None: + environment = tmp_path / "environment" + scripts = environment / "Scripts" + scripts.mkdir(parents=True) + (scripts / "release.exe").write_bytes(b"tool") + github_path = tmp_path / "github-path" + platform = mocker.patch.object(expose_locked_commands, "os") + platform.name = "nt" + platform.environ = {"RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(github_path)} + platform.X_OK = os.X_OK + platform.access.return_value = True + mocker.patch.object(sys, "argv", ["expose_locked_commands", str(environment)]) + + expose_locked_commands.main() + + installed = Path(github_path.read_text().strip()) / "release.exe" + assert installed.read_bytes() == b"tool" + assert not installed.is_symlink() + + +def test_expose_locked_commands_keeps_system_git_and_gh(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + scripts = tmp_path / "environment/bin" + scripts.mkdir(parents=True) + for name in ("git", "gh", "prek"): + command = scripts / name + command.write_text("#!/bin/sh\nexit 0\n") + command.chmod(0o755) + github_path = tmp_path / "github-path" + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + monkeypatch.setenv("GITHUB_PATH", str(github_path)) + monkeypatch.setattr(sys, "argv", ["expose_locked_commands", str(scripts.parent)]) + + expose_locked_commands.main() + + overlay = Path(github_path.read_text().strip()) + assert sorted(path.name for path in overlay.iterdir()) == ["prek"] diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index d4689c5..183eed7 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -5,6 +5,7 @@ import subprocess import sys import tomllib +from copy import deepcopy from datetime import UTC, datetime, timedelta from pathlib import Path @@ -93,12 +94,12 @@ def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: assert "--hash=sha256:" in constraints -def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: +def test_install_lock_reader_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] 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") + package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "packaging") (tmp_path / "pylock.toml").write_text(lock.replace(package["wheels"][0]["hashes"]["sha256"], "0" * 64, 1)) result = action( tmp_path, @@ -122,6 +123,17 @@ def test_install_locked_tools_reuses_existing_environment(tmp_path: Path, action assert (tmp_path / "lock-tools" / ("Scripts/nab.exe" if os.name == "nt" else "bin/nab")).is_file() +def test_install_locked_tools_keeps_reader_after_nab_sync(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + first = install(source, action_scripts, tmp_path, groups="lock-tools", venv=tmp_path / "lock-tools") + second = install(source, action_scripts, tmp_path, groups="release-news", venv=tmp_path / "news-tools") + + assert first.returncode == second.returncode == 0, first.stderr + second.stderr + reader = tmp_path / "lock-reader" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + imported = subprocess.run([str(reader), "-c", "import certifi"], capture_output=True, text=True, check=False) + assert imported.returncode == 0, imported.stderr + + def test_install_locked_tools_exposes_release_commands(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] environment = tmp_path / "tools" @@ -135,6 +147,22 @@ def test_install_locked_tools_exposes_release_commands(tmp_path: Path, action_sc assert command.returncode == 0, command.stderr +def test_install_locked_tools_uses_minimal_reader(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + result = install(source, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") + + assert result.returncode == 0, result.stderr + reader = tmp_path / "lock-reader" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + packages = subprocess.run( + [str(reader), "-c", "import importlib.util; print(importlib.util.find_spec('nab') is None)"], + capture_output=True, + text=True, + check=False, + ) + assert packages.returncode == 0, packages.stderr + assert packages.stdout.strip() == "True" + + def test_install_locked_tools_preserves_runner_python(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] result = install(source, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") @@ -210,10 +238,17 @@ def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, loca assert (result.returncode == 0) is local_source, result.stderr -def test_local_tool_source_accepts_versionless_package(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ("package_name", "accepted"), + [ + pytest.param("release-creator", True, id="local-release-creator"), + pytest.param("towncrier", False, id="other-local-package"), + ], +) +def test_local_tool_source_only_accepts_release_creator(tmp_path: Path, package_name: str, accepted: bool) -> None: source = Path(__file__).resolve().parents[2] document = tomlkit.parse((source / "pylock.toml").read_text()) - package = next(item for item in document["packages"] if item["name"] == "release-creator") + package = next(item for item in document["packages"] if item["name"] == package_name) for field in ("version", "index", "sdist", "wheels"): package.pop(field) package["directory"] = {"path": "."} @@ -232,29 +267,35 @@ def test_local_tool_source_accepts_versionless_package(tmp_path: Path) -> None: capture_output=True, check=False, ) - assert result.returncode == 0, result.stderr + assert (result.returncode == 0) is accepted, result.stderr + if not accepted: + assert f"direct-source package: {package_name}" in result.stderr @pytest.mark.parametrize( - ("replace", "message"), + ("kind", "message"), [ pytest.param( - ("created-at = 2026-10-23", "created-at = 2099-10-23"), + "future-creation", "created in the future", id="future-creation", ), pytest.param( - ("upload-time = 2026-", "upload-time = 2099-"), + "recent-artifact", "uncooled artifact", id="recent-artifact", ), ], ) -def test_install_locked_tools_rejects_uncooled_lock(tmp_path: Path, replace: tuple[str, str], message: str) -> None: +def test_install_locked_tools_rejects_uncooled_lock(tmp_path: Path, kind: 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)) + document = tomlkit.parse((source / "pylock.toml").read_text()) + if kind == "future-creation": + document["tool"]["nab"]["created-at"] = datetime(2099, 10, 23, tzinfo=UTC) + else: + package = next(package for package in document["packages"] if package["name"] == "cython") + package["wheels"][0]["upload-time"] = datetime(2099, 10, 23, tzinfo=UTC) + (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) result = subprocess.run( ["python", str(source / "tools/check_lock_groups.py"), str(tmp_path / "pylock.toml"), "release-check"], @@ -356,12 +397,32 @@ def test_install_locked_tools_rejects_non_pypi_artifact(tmp_path: Path, source_k assert "non-PyPI artifact" in result.stderr +def test_install_locked_tools_checks_exact_group_marker(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["marker"] = '"release-check" in dependency_groups and "build" not in dependency_groups' + package["wheels"][0]["url"] = package["wheels"][0]["url"].replace("files.pythonhosted.org", "example.org") + 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 + 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") + artifact["url"] = artifact["url"].replace(str(package["version"]), "0.0.0") lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -380,10 +441,11 @@ def test_install_locked_tools_rejects_unreviewed_version_under_reviewed_floor(tm 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") + current_version = str(package["version"]) package["version"] = "0.6.3" for artifact in [package["sdist"], *package["wheels"]]: - artifact["name"] = artifact["name"].replace("0.6.5", "0.6.3") - artifact["url"] = artifact["url"].replace("0.6.5", "0.6.3") + artifact["name"] = artifact["name"].replace(current_version, "0.6.3") + artifact["url"] = artifact["url"].replace(current_version, "0.6.3") lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -403,10 +465,11 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, 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") + current_version = str(package["version"]) package["version"] = version for artifact in [package["sdist"], *package["wheels"]]: - artifact["name"] = artifact["name"].replace("0.6.5", version) - artifact["url"] = artifact["url"].replace("0.6.5", version) + artifact["name"] = artifact["name"].replace(current_version, version) + artifact["url"] = artifact["url"].replace(current_version, version) artifact["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -426,20 +489,22 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, ) assert result.returncode != 0 - assert "relock: release-creator 0.6.5 required" in result.stderr + assert f"relock: release-creator {current_version} required" 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) - ) + (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) + document = tomlkit.parse((source / "pylock.toml").read_text()) + document["tool"]["nab"]["created-at"] = datetime(2099, 10, 23, tzinfo=UTC) + (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) 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 / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') (tmp_path / "pylock.toml").write_text( 'lock-version = "1.0"\nrequires-python = ">=3.15"\ncreated-by = "test"\n' 'dependency-groups = ["project-runtime"]\npackages = []\n' @@ -481,6 +546,75 @@ def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None assert "relock: release-creator" in result.stderr +def test_caller_version_check_covers_other_platform(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(item for item in document["packages"] if item["name"] == "release-creator") + package["marker"] = '"release-shell" in dependency_groups and sys_platform == "win32"' + shared = tmp_path / "shared.toml" + shared.write_text(tomlkit.dumps(document)) + caller = tmp_path / "caller.toml" + 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( + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(caller), + "release-shell", + "--shared-lock", + str(shared), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "relock: release-creator" in result.stderr + + +def test_caller_version_check_uses_highest_shared_platform_floor(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(item for item in document["packages"] if item["name"] == "release-creator") + current = deepcopy(package) + current_version = str(package["version"]) + current["marker"] = '"release-news" in dependency_groups and sys_platform == "linux"' + package["marker"] = '"release-news" in dependency_groups and sys_platform == "win32"' + package["version"] = "0.1.0" + for artifact in [package["sdist"], *package["wheels"]]: + artifact["name"] = str(artifact["name"]).replace(current_version, "0.1.0") + artifact["url"] = str(artifact["url"]).replace(current_version, "0.1.0") + artifact["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) + caller = tmp_path / "caller.toml" + caller.write_text(tomlkit.dumps(document)) + document["packages"].append(current) + shared = tmp_path / "shared.toml" + shared.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(caller), + "release-news", + "--shared-lock", + str(shared), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert f"relock: release-creator {current_version} required" in result.stderr + + def test_install_locked_tools_shared_group_ignores_missing_caller_group(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] lock = (source / "pylock.toml").read_text().replace(' "release-attestations",\n', "", 1) @@ -556,7 +690,7 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, act ) assert result.returncode == 0, result.stderr imported = subprocess.run( - [str(tmp_path / "lock-tools/bin/python"), "-c", "import yaml"], + [str(tmp_path / "lock-reader/bin/python"), "-c", "import yaml"], text=True, capture_output=True, check=False, @@ -566,21 +700,22 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, act def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] + (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) (tmp_path / "pylock.toml").write_text( (tmp_path / "pylock.toml").read_text() + """ [[packages]] -name = "probe-only" -version = "1.0" +name = "six" +version = "1.16.0" marker = '\"build\" in dependency_groups' index = "https://pypi.org/simple/" [[packages.wheels]] -name = "probe_only-1.0-py3-none-any.whl" -url = "https://files.pythonhosted.org/probe_only-1.0-py3-none-any.whl" -upload-time = 2026-10-01T00:00:00Z -hashes.sha256 = """ - + f'"{"0" * 64}"\n', +name = "six-1.16.0-py2.py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl" +upload-time = 2021-05-05T14:18:17.237677Z +hashes.sha256 = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" +""", ) result = install( tmp_path, @@ -590,11 +725,12 @@ def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_pat venv=tmp_path / "tools", ) assert result.returncode == 0, result.stderr - assert "probe-only==1.0 " in next(tmp_path.glob("build-constraints-*.txt")).read_text() + assert "six==1.16.0 " in next(tmp_path.glob("build-constraints-*.txt")).read_text() def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] + (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) document = tomlkit.parse((source / "pylock.toml").read_text()) document["dependency-groups"].remove("build") (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py new file mode 100644 index 0000000..32be4e1 --- /dev/null +++ b/tests/install_locked_tools/test_metadata.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +import json +import runpy +import ssl +import sys +from email.message import Message +from io import BytesIO +from pathlib import Path +from textwrap import dedent +from typing import NotRequired, TypedDict +from urllib.error import HTTPError, URLError + +import certifi +import pytest +from pytest_mock import MockerFixture + +_PyPIFile = TypedDict( + "_PyPIFile", + { + "filename": str, + "url": str, + "upload-time": NotRequired[str], + "hashes": dict[str, str], + "yanked": NotRequired[bool], + }, +) + + +class _PyPIListing(TypedDict): + meta: dict[str, str] + name: str + files: list[_PyPIFile] + + +@pytest.fixture +def lock_case(tmp_path: Path) -> tuple[Path, Path]: + lock = tmp_path / "pylock.toml" + lock.write_text( + dedent( + """\ + lock-version = "1.0" + requires-python = ">=3.15" + dependency-groups = ["release-check"] + created-by = "test" + [tool.nab] + created-at = 2026-10-20T00:00:00Z + [[packages]] + name = "six" + version = "1.16.0" + marker = '"release-check" in dependency_groups' + index = "https://pypi.org/simple/" + [[packages.wheels]] + name = "six-1.16.0-py2.py3-none-any.whl" + url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl" + upload-time = 2021-05-05T14:18:17.237677Z + hashes.sha256 = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + """ + ) + ) + project = tmp_path / "pyproject.toml" + project.write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') + return lock, project + + +@pytest.fixture +def pypi_listing() -> _PyPIListing: + return { + "meta": {"api-version": "1.4"}, + "name": "six", + "files": [ + { + "filename": "six-1.16.0-py2.py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", + "upload-time": "2021-05-05T14:18:17.237677Z", + "hashes": {"sha256": "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + } + ], + } + + +def test_lock_metadata_matches_pypi( + lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + request = mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + context = mocker.patch.object(ssl, "create_default_context", autospec=True) + + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_args.args[0].full_url == "https://pypi.org/simple/six/" + assert context.call_args.kwargs == {"cafile": certifi.where()} + + +def test_lock_metadata_rejects_yanked_artifact( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + pypi_listing["files"][0]["yanked"] = True + mocker.patch( + "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) + ) + mocker.patch("time.sleep") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "release lock differs from PyPI metadata" in capsys.readouterr().err + + +def test_lock_metadata_retries_transient_network_error( + lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + request = mocker.patch( + "urllib.request.urlopen", + side_effect=[URLError("handshake timed out"), BytesIO(json.dumps(pypi_listing).encode())], + ) + sleep = mocker.patch("time.sleep") + + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == 2 + sleep.assert_called_once_with(1) + + +def test_lock_metadata_retries_stale_listing( + lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + stale = {**pypi_listing, "files": []} + request = mocker.patch( + "urllib.request.urlopen", + side_effect=[BytesIO(json.dumps(stale).encode()), BytesIO(json.dumps(pypi_listing).encode())], + ) + sleep = mocker.patch("time.sleep") + + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == 2 + sleep.assert_called_once_with(1) + + +def test_lock_metadata_stops_after_five_network_errors( + lock_case: tuple[Path, Path], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + request = mocker.patch("urllib.request.urlopen", side_effect=URLError("handshake timed out")) + mocker.patch("time.sleep") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == 5 + assert "handshake timed out" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "status", + [pytest.param(404, id="not-yet-listed"), pytest.param(429, id="rate-limit"), pytest.param(503, id="server-error")], +) +def test_lock_metadata_retries_transient_http_error( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + status: int, +) -> None: + request = mocker.patch( + "urllib.request.urlopen", + side_effect=[ + HTTPError("https://pypi.org/simple/six/", status, "transient", Message(), None), + BytesIO(json.dumps(pypi_listing).encode()), + ], + ) + mocker.patch("time.sleep") + + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == 2 + + +@pytest.mark.parametrize( + ("status", "calls"), + [pytest.param(404, 5, id="not-found"), pytest.param(503, 5, id="repeated-server-error")], +) +def test_lock_metadata_rejects_http_error( + lock_case: tuple[Path, Path], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + status: int, + calls: int, +) -> None: + request = mocker.patch( + "urllib.request.urlopen", + side_effect=HTTPError("https://pypi.org/simple/six/", status, "unavailable", Message(), None), + ) + mocker.patch("time.sleep") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == calls + assert f"HTTP Error {status}" in capsys.readouterr().err + + +def test_lock_metadata_requires_group( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + source = Path(__file__).resolve().parents[2] + monkeypatch.syspath_prepend(str(source / "tools")) + monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock_case[0])]) + command = runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="lock_check") + + with pytest.raises(SystemExit, match="2"): + command["main"]() + + assert "at least one dependency group is required" in capsys.readouterr().err + + +def test_lock_metadata_checks_base_selection_without_groups( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text() + .replace('dependency-groups = ["release-check"]\n', "") + .replace('"release-check" in dependency_groups', '"build" not in dependency_groups') + .replace("files.pythonhosted.org", "example.org") + ) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--build-constraints") + + assert "non-PyPI artifact" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "change", [pytest.param("api-version", id="api-version"), pytest.param("project-name", id="project-name")] +) +def test_lock_metadata_rejects_invalid_listing( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + change: str, +) -> None: + if change == "api-version": + pypi_listing["meta"]["api-version"] = "2.0" + else: + pypi_listing["name"] = "other" + mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "invalid PyPI listing for six" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "listing", + [ + pytest.param([], id="non-object-root"), + pytest.param({"meta": [], "files": []}, id="non-object-metadata"), + pytest.param({"meta": {"api-version": "1.4"}, "name": "six", "files": ["bad"]}, id="non-object-file"), + ], +) +def test_lock_metadata_rejects_non_object_listing( + lock_case: tuple[Path, Path], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + listing: object, +) -> None: + mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(listing).encode())) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "invalid PyPI listing for six" in capsys.readouterr().err + + +def test_lock_metadata_allows_local_release_creator( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text().split("[[packages]]", 1)[0] + + '[[packages]]\nname = "release-creator"\nmarker = \'"release-check" in dependency_groups\'\n' + + 'directory = { path = "." }\n' + ) + + _run_check(lock_case, monkeypatch, "--local-tool-source", "--verify-pypi", "--project", str(lock_case[1])) + + +@pytest.mark.parametrize( + ("source", "message"), + [ + pytest.param( + 'archive = { url = "https://evil.invalid/six.whl", hashes = { sha256 = "' + "0" * 64 + '" } }', + "direct-source", + id="archive", + ), + pytest.param('directory = { path = "." }', "direct-source", id="directory"), + pytest.param( + 'vcs = { type = "git", url = "https://evil.invalid/six.git", commit-id = "' + "a" * 40 + '" }', + "direct-source", + id="vcs", + ), + ], +) +def test_lock_metadata_rejects_unchecked_source( + lock_case: tuple[Path, Path], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + source: str, + message: str, +) -> None: + lock = lock_case[0] + lock.write_text(lock.read_text().split('index = "https://pypi.org/simple/"', 1)[0] + source + "\n") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert message in capsys.readouterr().err + + +def test_lock_metadata_rejects_indexless_wheel( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text(lock.read_text().replace('index = "https://pypi.org/simple/"\n', "")) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) + + assert "uncooled non-index package" in capsys.readouterr().err + + +def test_lock_metadata_checks_other_locked_platform( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text() + .replace( + 'dependency-groups = ["release-check"]', + 'dependency-groups = ["release-check"]\nenvironments = [\'sys_platform == "win32"\']', + ) + .replace( + "marker = '\"release-check\" in dependency_groups'", + 'marker = \'"release-check" in dependency_groups and sys_platform == "win32"\'', + ) + .replace("files.pythonhosted.org", "evil.invalid") + ) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) + + assert "non-PyPI artifact" in capsys.readouterr().err + + +def test_lock_metadata_checks_nested_group_marker( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text() + .replace( + "marker = '\"release-check\" in dependency_groups'", + 'marker = \'("release-check" in dependency_groups and sys_platform == "win32") or "test" in dependency_groups\'', + ) + .replace("files.pythonhosted.org", "evil.invalid") + ) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) + + assert "non-PyPI artifact" in capsys.readouterr().err + + +def test_lock_metadata_checks_each_duplicate_artifact( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + lock = lock_case[0] + head, package = lock.read_text().split("[[packages]]") + lock.write_text( + f"{head}[[packages]]{package.replace(pypi_listing['files'][0]['hashes']['sha256'], '0' * 64)}" + f"[[packages]]{package}" + ) + mocker.patch( + "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) + ) + mocker.patch("time.sleep") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "release lock differs from PyPI metadata" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "change", + ["missing-file", "missing-hash", "wrong-hash", "missing-date", "forged-date", "wrong-url"], +) +def test_lock_metadata_rejects_pypi_mismatch( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + change: str, +) -> None: + file = pypi_listing["files"][0] + if change == "missing-file": + pypi_listing["files"] = [] + elif change == "missing-hash": + file["hashes"] = {} + elif change == "wrong-hash": + file["hashes"]["sha256"] = "0" * 64 + elif change == "missing-date": + del file["upload-time"] + elif change == "forged-date": + lock = lock_case[0] + lock.write_text(lock.read_text().replace("2021-05-05T14:18:17.237677Z", "2020-05-05T14:18:17.237677Z")) + else: + file["url"] = "https://files.pythonhosted.org/other.whl" + mocker.patch( + "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) + ) + mocker.patch("time.sleep") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "release lock differs from PyPI metadata" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "change", ["lock-index", "project-index", "package-index", "rule-index", "index-settings", "nab-toml"] +) +def test_lock_metadata_rejects_unapproved_indexes( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], change: str +) -> None: + lock, project = lock_case + if change == "lock-index": + lock.write_text( + lock.read_text().replace('index = "https://pypi.org/simple/"', 'index = "https://example.org/"') + ) + elif change == "project-index": + project.write_text(project.read_text() + '[[tool.nab.indexes]]\nname = "other"\nurl = "https://example.org/"\n') + elif change == "package-index": + project.write_text(project.read_text() + '[tool.nab.packages.six]\nindex = "other"\n') + elif change == "rule-index": + project.write_text(project.read_text() + '[[tool.nab.package-rules]]\nmatch = ["six"]\nindex = "other"\n') + elif change == "index-settings": + project.write_text(project.read_text() + "[tool.nab.index.pypi]\nallow-insecure-host = true\n") + else: + (project.parent / "nab.toml").write_text("[tool.nab]\nuploaded-prior-to = false\n") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--project", str(project)) + + assert "index" in capsys.readouterr().err + + +def _run_check(lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, *options: str) -> None: + source = Path(__file__).resolve().parents[2] + monkeypatch.syspath_prepend(str(source / "tools")) + monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock_case[0]), "release-check", *options]) + runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="__main__") diff --git a/tests/install_locked_tools/test_secondary_lock.py b/tests/install_locked_tools/test_secondary_lock.py new file mode 100644 index 0000000..1860701 --- /dev/null +++ b/tests/install_locked_tools/test_secondary_lock.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest +from conftest import action + + +def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, action_scripts: Path) -> None: + lock = tmp_path / "pylock.zipapp.toml" + lock.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' + 'dependency-groups = ["project-runtime"]\ncreated-by = "test"\npackages = []\n' + "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" + ) + + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "install-locked-tools", + version="", + LOCK_SOURCE="shared", + VERIFY_LOCK=str(lock), + VERIFY_GROUPS="project-runtime", + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("action_name", "group"), + [ + pytest.param("install-locked-tools", "project-runtime", id="zipapp-runtime"), + pytest.param("export-build-constraints", "pgo", id="profile-build"), + ], +) +def test_verify_secondary_lock_rejects_direct_source( + tmp_path: Path, action_scripts: Path, action_name: str, group: str +) -> None: + lock = tmp_path / "pylock.zipapp.toml" + lock.write_text( + dedent( + f"""\ + lock-version = "1.0" + requires-python = ">=3.15" + dependency-groups = ["build", "{group}"] + created-by = "test" + [tool.nab] + created-at = 2026-10-19T00:00:00Z + [[packages]] + name = "six" + version = "1.16.0" + marker = '"{group}" in dependency_groups' + archive = {{ url = "https://evil.invalid/six.whl", hashes = {{ sha256 = "0000000000000000000000000000000000000000000000000000000000000000" }} }} + """ + ) + ) + + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + action_name, + version="", + VERIFY_GROUPS=group, + **({"LOCK_SOURCE": "shared", "VERIFY_LOCK": str(lock)} if group == "project-runtime" else {"LOCK": str(lock)}), + ) + + assert result.returncode != 0 + assert "direct-source package: six" in result.stderr + + +def test_verify_secondary_lock_requires_group(tmp_path: Path, action_scripts: Path) -> None: + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "install-locked-tools", + version="", + LOCK_SOURCE="shared", + VERIFY_LOCK="pylock.zipapp.toml", + ) + + assert result.returncode != 0 + assert "verify-lock and verify-groups must be set together" in result.stderr + + +def test_verify_secondary_lock_rejects_forged_pypi_hash( + tmp_path: Path, action_scripts: Path, pypi_metadata_environment: None +) -> None: + lock = tmp_path / "pylock.zipapp.toml" + lock.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' + 'dependency-groups = ["project-runtime"]\ncreated-by = "test"\n' + "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" + '[[packages]]\nname = "six"\nversion = "1.16.0"\n' + 'marker = "\'project-runtime\' in dependency_groups"\nindex = "https://pypi.org/simple/"\n' + '[[packages.wheels]]\nname = "six-1.16.0-py2.py3-none-any.whl"\n' + 'url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl"\n' + 'upload-time = 2021-05-05T14:18:17.237677Z\nhashes.sha256 = "' + "0" * 64 + '"\n' + ) + + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "install-locked-tools", + version="", + LOCK_SOURCE="shared", + VERIFY_LOCK=str(lock), + VERIFY_GROUPS="project-runtime", + ) + + assert result.returncode != 0 + assert "release lock differs from PyPI metadata" in result.stderr diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 3ad6f54..fa15c53 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -41,6 +41,26 @@ def test_prepare_finalize_phase_writes_patch(repository: Path, tmp_path: Path, a assert "0.2.0" in (tmp_path / "release.patch").read_text() +def test_prepare_finalize_phase_preserves_version_output( + repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") + assert result.returncode == 0, result.stderr + (tmp_path / "output").unlink() + + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="finalize") + + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == "version=0.2.0\n" + + +def test_prepare_finalize_phase_requires_version(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="", PHASE="finalize") + + assert result.returncode != 0 + assert "finalize requires a version" in result.stderr + + def test_prepare_custom_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: project = repository / "pyproject.toml" project.write_text(project.read_text().replace("CHANGELOG.md", "HISTORY.md")) diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py index 62f8920..0c1aeff 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -22,16 +22,24 @@ def test_recheck_schedule_has_only_required_permissions() -> None: @pytest.mark.parametrize( - ("observed_age", "job_age", "job_conclusion", "job_name", "minimum_days", "reruns"), + ("observed_age", "job_age", "job_conclusion", "job_name", "configured_job", "workflow", "reruns"), [ - pytest.param(8, 8, "failure", "hook-cooldown", 7, 1, id="matured-failure"), - pytest.param(8, 8, "failure", "Hook update cooldown", 7, 1, id="named-job"), - pytest.param(6, 6, "failure", "hook-cooldown", 7, 0, id="recent-pull"), - pytest.param(8, 0, "failure", "hook-cooldown", 7, 1, id="failed-rerun"), - pytest.param(31, 31, "failure", "hook-cooldown", 7, 0, id="expired-run"), - pytest.param(8, 8, "success", "hook-cooldown", 7, 0, id="successful-job"), - pytest.param(8, 8, "failure", "hook-cooldown", 14, 0, id="custom-cooldown-pending"), - pytest.param(15, 15, "failure", "hook-cooldown", 14, 1, id="custom-cooldown-matured"), + pytest.param(8, 8, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 1, id="matured-failure"), + pytest.param(8, 8, "failure", "Hook update cooldown", "hook-cooldown", "check.yaml", 0, id="other-job"), + pytest.param( + 8, + 8, + "failure", + "Hook update cooldown", + "Hook update cooldown", + "pr-gate.yaml", + 1, + id="configured-job-and-workflow", + ), + pytest.param(6, 6, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="recent-pull"), + pytest.param(8, 0, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 1, id="failed-rerun"), + pytest.param(31, 31, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="expired-run"), + pytest.param(8, 8, "success", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="successful-job"), ], ) @pytest.mark.parametrize( @@ -46,7 +54,8 @@ def test_recheck_reruns_failed_job( job_age: int, job_conclusion: str, job_name: str, - minimum_days: int, + configured_job: str, + workflow: str, reruns: int, bot_login: str, ) -> None: @@ -71,7 +80,7 @@ def test_recheck_reruns_failed_job( [{"filename": ".pre-commit-config.yaml"}] ) server.expect_request( - "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", method="GET" + f"/api/v3/repos/tox-dev/fixture/actions/workflows/{workflow}/runs", method="GET" ).respond_with_json( { "workflow_runs": [ @@ -88,7 +97,7 @@ def test_recheck_reruns_failed_job( ] } ) - if minimum_days <= observed_age < 30: + if observed_age >= 7 and observed_age < 30: server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( { "jobs": [ @@ -116,8 +125,8 @@ def test_recheck_reruns_failed_job( "recheck-hook-update-cooldown", version="", GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], - JOB_NAME=job_name, - MINIMUM_DAYS=str(minimum_days), + JOB_NAME=configured_job, + WORKFLOW=workflow, **environment, ) assert result.returncode == 0, result.stderr diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py index 9e0ae0b..1b2cc7f 100644 --- a/tests/reconcile_pypi_artifacts/test_reconcile.py +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -10,11 +10,11 @@ @pytest.mark.parametrize( - ("project", "version", "distribution_exists"), + ("project", "version", "distribution_exists", "error"), [ - pytest.param("", "1.0", True, id="missing-project"), - pytest.param("fixture", "", True, id="missing-version"), - pytest.param("fixture", "1.0", False, id="missing-distribution"), + pytest.param("", "1.0", True, "name is invalid", id="missing-project"), + pytest.param("fixture", "", True, "Invalid version", id="missing-version"), + pytest.param("fixture", "1.0", False, "release files require a directory", id="missing-distribution"), ], ) def test_reconcile_rejects_invalid_inputs( @@ -23,6 +23,7 @@ def test_reconcile_rejects_invalid_inputs( project: str, version: str, distribution_exists: bool, + error: str, ) -> None: distribution = tmp_path / "dist" if distribution_exists: @@ -37,6 +38,7 @@ def test_reconcile_rejects_invalid_inputs( DIST=str(distribution), ) assert result.returncode != 0 + assert error in result.stderr @pytest.mark.parametrize( diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index b426794..0f1c045 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -1,15 +1,21 @@ from __future__ import annotations +import importlib +import runpy import subprocess import sys import tomllib -from datetime import UTC, datetime +from collections.abc import Callable +from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import cast import pytest import tomlkit +from pytest_mock import MockerFixture sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tools.check_nab_lock import main as check_nab_lock from tools.nab_cutoffs import remove_expired_cutoffs @@ -69,6 +75,8 @@ def test_expire_cutoffs_removes_empty_package_table(cutoff_project: Path) -> Non [ pytest.param("2026-10-23T19:54:50Z", id="0.6.5"), pytest.param("2026-10-23T20:48:24Z", id="0.6.6"), + pytest.param("2026-10-23T21:40:06Z", id="0.6.7"), + pytest.param("2026-10-24T06:07:46Z", id="1.0.0"), ], ) def test_expire_cutoffs_removes_each_reviewed_release_creator_floor(reviewed: str) -> None: @@ -82,6 +90,23 @@ def test_expire_cutoffs_removes_each_reviewed_release_creator_floor(reviewed: st assert "packages" not in tomllib.loads(updated)["tool"]["nab"] +@pytest.mark.parametrize( + ("seconds_before", "expired"), + [ + pytest.param(1, False, id="before-threshold"), + pytest.param(0, True, id="at-threshold"), + ], +) +def test_expire_cutoffs_uses_reviewed_threshold(seconds_before: int, expired: bool) -> None: + project = ( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[tool.nab.packages.release-creator]\nuploaded-prior-to = "2026-10-23T19:54:50Z"\n' + ) + threshold = datetime(2026, 10, 23, 19, 54, 50, tzinfo=UTC) - timedelta(seconds=seconds_before) + updated = remove_expired_cutoffs(project, threshold) + assert ("packages" not in tomllib.loads(updated)["tool"]["nab"]) is expired + + def test_expire_cutoffs_accepts_toml_datetime(cutoff_project: Path) -> None: cutoff_project.write_text( cutoff_project.read_text().replace( @@ -111,6 +136,28 @@ def test_verify_cutoff_patch_rejects_deliberate_hold_removal(cutoff_project: Pat assert "changes beyond expired Nab cutoffs" in result.stderr +def test_verify_cutoff_patch_accepts_only_unchanged_metadata( + cutoff_project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + source = Path(__file__).resolve().parents[2] / "tools" + monkeypatch.syspath_prepend(str(source)) + script = source / "verify_cutoff_patch.py" + command = runpy.run_path(str(script), run_name="verify_cutoff_imported") + monkeypatch.chdir(cutoff_project.parent) + monkeypatch.setattr(sys, "argv", [str(script)]) + main = cast("Callable[[], None]", command["main"]) + + main() + cutoff_project.write_text(cutoff_project.read_text().replace('name = "example"', 'name = "intruder"')) + + with pytest.raises(SystemExit, match="2"): + main() + + assert "changes beyond expired Nab cutoffs" in capsys.readouterr().err + + def test_verify_cutoff_patch_rejects_other_metadata(cutoff_project: Path) -> None: source = Path(__file__).resolve().parents[2] / "tools" subprocess.run([sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], check=True) @@ -141,6 +188,88 @@ def test_verify_cutoff_patch_rejects_early_floor_removal(cutoff_project: Path) - assert "changes beyond expired Nab cutoffs" in result.stderr +@pytest.fixture +def nab_lock_pair(tmp_path: Path) -> tuple[Path, Path]: + committed = tmp_path / "committed.toml" + regenerated = tmp_path / "regenerated.toml" + committed.write_text( + 'environments = ["python_version == 3.15"]\npackages = ["safe"]\n' + '[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = ["--upgrade"]\n' + ) + regenerated.write_bytes(committed.read_bytes()) + return committed, regenerated + + +def test_check_nab_lock_restores_committed_bytes(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + regenerated.write_text(regenerated.read_text().replace('command-line = ["--upgrade"]', "command-line = []")) + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert regenerated.read_bytes() == committed.read_bytes() + + +def test_check_nab_lock_environments_only_preserves_other_changes( + nab_lock_pair: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + committed, regenerated = nab_lock_pair + changed = regenerated.read_text().replace('packages = ["safe"]', 'packages = ["changed"]') + regenerated.write_text(changed) + monkeypatch.setattr(sys, "argv", ["check-nab-lock", str(committed), str(regenerated)]) + with pytest.raises(SystemExit) as error: + check_nab_lock() + assert error.value.code == 2 + monkeypatch.setattr(sys, "argv", ["check-nab-lock", "--environments-only", str(committed), str(regenerated)]) + check_nab_lock() + assert regenerated.read_text() == changed + + +def test_check_nab_lock_environments_only_rejects_target_change(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + regenerated.write_text(regenerated.read_text().replace("python_version == 3.15", "python_version == 3.14")) + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + "--environments-only", + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "changed its target environments" in result.stderr + + +def test_check_nab_lock_reports_invalid_encoding(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + committed.write_bytes(b"\xff") + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "utf-8" in result.stderr + + def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" committed = tmp_path / "committed.toml" @@ -210,6 +339,10 @@ def test_check_nab_lock_rejects_forged_upload_time( pytest.param("release-creator", "2026-10-23T19:54:51Z", False, id="extended-release-creator-0.6.5"), pytest.param("release-creator", "2026-10-23T20:48:24Z", True, id="reviewed-release-creator-0.6.6"), pytest.param("release-creator", "2026-10-23T20:48:25Z", False, id="extended-release-creator-0.6.6"), + pytest.param("release-creator", "2026-10-23T21:40:06Z", True, id="reviewed-release-creator-0.6.7"), + pytest.param("release-creator", "2026-10-23T21:40:07Z", False, id="extended-release-creator-0.6.7"), + pytest.param("release-creator", "2026-10-24T06:07:46Z", True, id="reviewed-release-creator-1.0.0"), + pytest.param("release-creator", "2026-10-24T06:07:47Z", False, id="extended-release-creator-1.0.0"), pytest.param("unreviewed", "2026-10-22T00:00:00Z", False, id="new-package"), ], ) @@ -274,18 +407,39 @@ def test_recent_cutoff_rejects_index_override() -> None: assert "Nab index cutoff for pypi" in result.stderr -def test_recent_cutoff_rejects_project_nab_toml(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') - (tmp_path / "nab.toml").write_text("[packages.example]\nuploaded-prior-to = false\n") - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_recent_cutoffs.py"), "pyproject.toml"], - cwd=tmp_path, - text=True, - capture_output=True, - check=False, +def test_recent_cutoff_rejects_custom_index() -> None: + result = _check_recent_cutoffs( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[[tool.nab.indexes]]\nname = "other"\nurl = "https://example.org/simple/"\n' ) assert result.returncode != 0 - assert "project nab.toml can override" in result.stderr + assert "custom Nab indexes" in result.stderr + + +def test_recent_cutoff_rejects_index_settings_without_cutoff() -> None: + result = _check_recent_cutoffs( + '[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.index.pypi]\nallow-insecure-host = true\n' + ) + assert result.returncode != 0 + assert "custom Nab indexes" in result.stderr + + +def test_recent_cutoff_rejects_project_nab_toml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') + (tmp_path / "nab.toml").write_text("[packages.example]\nuploaded-prior-to = false\n") + source = Path(__file__).resolve().parents[2] / "tools" + monkeypatch.syspath_prepend(str(source)) + script = source / "check_recent_cutoffs.py" + command = runpy.run_path(str(script), run_name="recent_cutoffs_imported") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", [str(script), "pyproject.toml"]) + + with pytest.raises(SystemExit, match="2"): + cast("Callable[[], None]", command["main"])() + + assert "project nab.toml can override" in capsys.readouterr().err def test_verify_cutoff_patch_reports_invalid_boolean(cutoff_project: Path) -> None: @@ -321,6 +475,107 @@ def test_expire_cutoffs_preserves_disabled_package(cutoff_project: Path) -> None assert tomllib.loads(cutoff_project.read_text())["tool"]["nab"]["packages"]["new"]["uploaded-prior-to"] is False +@pytest.mark.parametrize( + ("value", "message"), + [ + pytest.param("true", "invalid Nab cutoff", id="boolean"), + pytest.param('"2026-01-01T00:00:00"', "has no timezone", id="naive-time"), + ], +) +def test_recent_cutoff_rejects_invalid_package_time(value: str, message: str) -> None: + result = _check_recent_cutoffs( + f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.example]\nuploaded-prior-to = {value}\n' + ) + assert result.returncode != 0 + assert message in result.stderr + + +def test_recent_cutoff_cli_rejects_missing_project(tmp_path: Path) -> None: + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_recent_cutoffs.py"), + str(tmp_path / "missing"), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "No such file" in result.stderr + + +def test_expire_cutoffs_removes_reviewed_rule() -> None: + project = ( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[[tool.nab.package-rules]]\nmatch = ["release-creator"]\n' + 'uploaded-prior-to = "2026-10-23T19:54:50Z"\n' + ) + updated = remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) + assert "package-rules" not in tomllib.loads(updated)["tool"]["nab"] + + +def test_expire_cutoffs_rejects_naive_reviewed_time() -> None: + project = "[tool.nab]\n[tool.nab.packages.nab]\nuploaded-prior-to = 2026-10-18T05:45:09\n" + with pytest.raises(ValueError, match="has no timezone"): + remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) + + +def test_expire_cutoffs_keeps_disabled_reviewed_package() -> None: + project = "[tool.nab]\n[tool.nab.packages.nab]\nuploaded-prior-to = false\n" + assert remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) == project + + +def test_expire_cutoffs_cli_writes_expired_rule( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "tools")) + module = importlib.import_module("expire_cutoffs") + project = tmp_path / "pyproject.toml" + project.write_text( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[[tool.nab.package-rules]]\nmatch = ["release-creator"]\n' + 'uploaded-prior-to = "2026-10-23T19:54:50Z"\n' + ) + mocker.patch.object( + module, "datetime", mocker.Mock(now=mocker.Mock(return_value=datetime(2026, 11, 1, tzinfo=UTC))) + ) + monkeypatch.setattr(sys, "argv", ["expire_cutoffs", str(project)]) + + module.main() + + assert "package-rules" not in tomllib.loads(project.read_text())["tool"]["nab"] + + +def test_expire_cutoffs_cli_rejects_missing_project(tmp_path: Path) -> None: + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/expire_cutoffs.py"), + str(tmp_path / "missing"), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "No such file" in result.stderr + + +def test_expire_cutoffs_retains_other_rule_settings() -> None: + project = ( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-10-18T05:45:09Z"\nindex = "pypi"\n' + '[[tool.nab.package-rules]]\nmatch = ["nab-index"]\n' + 'uploaded-prior-to = "2026-10-18T05:45:09Z"\npriority = 1\n' + ) + + updated = tomllib.loads(remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC))) + + assert updated["tool"]["nab"]["packages"]["nab"] == {"index": "pypi"} + assert updated["tool"]["nab"]["package-rules"] == [{"match": ["nab-index"], "priority": 1}] + + def _check_recent_cutoffs(project: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [ diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index a8b7b01..4418f06 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -6,6 +6,7 @@ import sys from pathlib import Path +import pytest import yaml @@ -17,45 +18,111 @@ def test_prepare_workflow_calls_reusable_preparation() -> None: assert workflow["jobs"]["prepare"]["uses"] == "$/.github/workflows/prepare-towncrier-release.yaml" +@pytest.mark.parametrize( + ("workflow", "job", "step_name"), + [ + pytest.param("check.yaml", "check", "Check repository formatting", id="pull-request"), + pytest.param("release.yaml", "check", "Check tagged source", id="tagged-release"), + pytest.param("validate-python-release.yaml", "source-check", "Run tagged source checks", id="distribution"), + pytest.param("prepare-towncrier-release.yaml", "assemble", "Install prek hooks", id="prepare"), + ], +) +def test_release_hook_installation_has_cooldown(workflow: str, job: str, step_name: str) -> None: + document = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows" / workflow).read_text()) + step = next(item for item in document["jobs"][job]["steps"] if item.get("name") == step_name) + assert step["env"]["UV_EXCLUDE_NEWER"] == "7 days" + + def test_prepare_jobs_transfer_the_uploaded_patch() -> None: workflow = yaml.safe_load( (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() ) jobs = workflow["jobs"] - assert list(jobs) == ["assemble", "push"] + assert list(jobs) == ["assemble", "validate", "push"] assert jobs["assemble"]["outputs"]["artifact-id"] == "${{ steps.patch.outputs.artifact-id }}" assert ( next(step for step in jobs["assemble"]["steps"] if step.get("id") == "patch")["with"]["name"] == "release-patch" ) + for job in ("validate", "push"): + assert ( + next(step for step in jobs[job]["steps"] if "download-artifact" in step.get("uses", ""))["with"][ + "artifact-ids" + ] + == "${{ needs.assemble.outputs.artifact-id }}" + ) + + +def test_prepare_validates_uploaded_patch_before_writer() -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() + ) + jobs = workflow["jobs"] + assert jobs["validate"]["permissions"] == {"contents": "read"} + assert jobs["validate"]["needs"] == "assemble" + assert jobs["push"]["needs"] == ["assemble", "validate"] + assert not any(step.get("uses") == "$/validate-release-patch" for step in jobs["assemble"]["steps"]) + validation = next(step for step in jobs["validate"]["steps"] if step.get("uses") == "$/validate-release-patch") + assert validation["with"]["patch"] == "${{ runner.temp }}/release-patch/release.patch" assert ( - next(step for step in jobs["push"]["steps"] if "download-artifact" in step.get("uses", ""))["with"][ - "artifact-ids" - ] - == "${{ needs.assemble.outputs.artifact-id }}" + next(step for step in jobs["push"]["steps"] if step.get("uses") == "$/validate-release-patch")["with"]["checks"] + == "false" ) -def test_prepare_validates_patch_in_fresh_worktree() -> None: +@pytest.mark.parametrize( + ("version", "prefix", "accepted"), + [ + pytest.param("1.2.3", "", True, id="calculated-version"), + pytest.param("v1.2.3", "v", True, id="prefixed-version"), + pytest.param("9.9.9", "", False, id="forged-version"), + ], +) +def test_prepare_validation_calculates_version_independently( + tmp_path: Path, version: str, prefix: str, accepted: bool +) -> None: workflow = yaml.safe_load( (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() ) - steps = workflow["jobs"]["assemble"]["steps"] - worktree = next(step for step in steps if step.get("name") == "Create a fresh validation worktree") - validation = next(step for step in steps if step.get("uses") == "$/validate-release-patch") - assert "git worktree add --detach" in worktree["run"] - assert validation["with"]["working-directory"] == "${{ runner.temp }}/release-validation" - assert ( - steps.index(worktree) - < steps.index(validation) - < next(index for index, step in enumerate(steps) if step.get("id") == "patch") + steps = workflow["jobs"]["validate"]["steps"] + verify = next(step for step in steps if step.get("name") == "Verify selected release version") + command = tmp_path / "bin/release-creator" + command.parent.mkdir() + expected_arguments = ["tags", "--next", "auto", "--directory", "changelog.d"] + if prefix: + expected_arguments.extend(["--prefix", prefix]) + command.write_text( + f"#!{sys.executable}\nimport sys\nassert sys.argv[1:] == {expected_arguments!r}\nprint({f'{prefix}1.2.3'!r})\n" + ) + command.chmod(0o755) + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", verify["run"]], + cwd=tmp_path, + env={ + **os.environ, + "PATH": f"{command.parent}{os.pathsep}{os.environ['PATH']}", + "BUMP": "auto", + "TAG_PREFIX": prefix, + "FRAGMENTS": "changelog.d", + "VERSION": version, + }, + capture_output=True, + text=True, + check=False, ) + assert (result.returncode == 0) is accepted + if not accepted: + assert "assembled release version differs from 1.2.3" in result.stderr -def test_prepare_builds_local_tool_once_with_locked_backends() -> None: + +@pytest.mark.parametrize("job", ["assemble", "validate"]) +def test_prepare_builds_local_tool_with_locked_backends(job: str) -> None: workflow = yaml.safe_load( (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() ) - steps = workflow["jobs"]["assemble"]["steps"] + steps = workflow["jobs"][job]["steps"] builds = [step for step in steps if step.get("name") == "Build local release tool wheel"] assert len(builds) == 1 assert builds[0]["env"]["BUILD_CONSTRAINTS"] == "${{ steps.tools.outputs.build-constraints }}" diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index e56e9b2..4724410 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -28,47 +28,8 @@ def publish_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: return scripts -def test_upgrade_pull_request_uses_repository_app() -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - publish = workflow["jobs"]["publish"] - assert workflow["on"]["workflow_call"]["inputs"]["lock-groups"]["required"] == "true" - assert workflow["jobs"]["generate-lock"]["steps"][2]["with"]["lock-groups"] == "${{ inputs.lock-groups }}" - assert publish["environment"] == "release-auth" - assert publish["permissions"] == {"contents": "read"} - token = next(step for step in publish["steps"] if step.get("id") == "token") - assert token["with"]["repositories"] == "${{ steps.repository.outputs.name }}" - assert token["with"]["permission-contents"] == "write" - assert token["with"]["permission-pull-requests"] == "write" - steps = publish["steps"] - assert next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-nab-cutoff-patch") < next( - index for index, step in enumerate(steps) if step.get("id") == "token" - ) - for step in publish["steps"]: - if step.get("name") in { - "Push dependency branch", - "Find dependency pull request", - "Update dependency pull request", - "Open dependency pull request", - }: - assert step["env"]["GH_TOKEN"] == "${{ steps.token.outputs.token }}" - - -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: +def lock_repository(tmp_path: Path) -> Path: repository = tmp_path / "repository" repository.mkdir() (repository / "pyproject.toml").write_text( @@ -77,8 +38,7 @@ def lock_repository(tmp_path: Path, test_package_index: str) -> Path: '[tool.nab]\nmode = "universal"\nuploaded-prior-to = "P7D"\n' 'decision-order = "stable"\nbase-group = "project-runtime"\ndefault-groups = []\n' '[tool.nab.matrix]\npython = ">=3.15,<3.16"\n' - 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n' - f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n', + 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n', ) run(repository, "git", "init", "-b", "main") run(repository, "git", "config", "user.name", "Release test") @@ -208,33 +168,28 @@ def test_upgrade_emits_patch_for_allowed_change( assert f"diff --git a/{changed_path} b/{changed_path}" in (tmp_path / "lock.patch").read_text() -def test_upgrade_check_mode_rejects_lock_drift(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: - (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n') - run(lock_repository, "git", "add", ".") - run(lock_repository, "git", "commit", "-m", "Add lock") - (lock_repository / "pylock.toml").write_text('lock-version = "1.1"\n') - result = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-4.sh", - tmp_path, - MODE="check", - GITHUB_OUTPUT=str(tmp_path / "output"), - ) - assert result.returncode != 0 - - @pytest.mark.parametrize( - ("path", "accepted"), + ("path", "artifact", "additional_lock", "accepted"), [ - pytest.param("pylock.toml", True, id="lock"), - pytest.param("pyproject.toml", True, id="nab-policy"), - pytest.param(".pre-commit-config.yaml", True, id="hooks"), - pytest.param("uv.lock", False, id="uv-lock"), - pytest.param("other.txt", False, id="unrelated-file"), + pytest.param("pylock.toml", "lock", "", True, id="lock"), + pytest.param("pyproject.toml", "lock", "", True, id="nab-policy"), + pytest.param("pylock.zipapp.toml", "lock", "pylock.zipapp.toml", True, id="secondary-lock"), + pytest.param(".pre-commit-config.yaml", "hooks", "", True, id="hooks"), + pytest.param(".pre-commit-config.yaml", "lock", "", False, id="hook-in-lock-artifact"), + pytest.param("pylock.toml", "hooks", "", False, id="lock-in-hook-artifact"), + pytest.param("pyproject.toml", "hooks", "", False, id="policy-in-hook-artifact"), + pytest.param("uv.lock", "lock", "", False, id="uv-lock"), + pytest.param("other.txt", "lock", "", False, id="unrelated-file"), ], ) -def test_upgrade_rejects_unrelated_patch( - lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, accepted: bool +def test_upgrade_checks_each_artifact_path( + lock_repository: Path, + tmp_path: Path, + publish_scripts: Path, + path: str, + artifact: str, + additional_lock: str, + accepted: bool, ) -> None: (lock_repository / path).write_text("before\n") run(lock_repository, "git", "add", ".") @@ -242,14 +197,20 @@ def test_upgrade_rejects_unrelated_patch( (lock_repository / path).write_text("after\n") patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout run(lock_repository, "git", "restore", path) - patch_dir = tmp_path / "release-tools-lock" + patch_dir = tmp_path / f"release-tools-{artifact}" patch_dir.mkdir() - (patch_dir / "lock.patch").write_text(patch) - applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) - assert applied.returncode == 0, applied.stderr - result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-paths.sh", tmp_path) + (patch_dir / f"{artifact}.patch").write_text(patch) + result = execute( + lock_repository, + publish_scripts / "publish-check-artifact-patch-paths.sh", + tmp_path, + ADDITIONAL_LOCK=additional_lock, + LOCK_CHANGED=str(artifact == "lock").lower(), + HOOKS_CHANGED=str(artifact == "hooks").lower(), + ) assert (result.returncode == 0) == accepted - assert ("unexpected dependency path" in result.stderr) == (not accepted) + assert (f"unexpected {artifact} patch path: {path}" in result.stderr) == (not accepted) + assert run(lock_repository, "git", "status", "--porcelain").stdout == "" @pytest.mark.parametrize("additional_lock", ["other.txt", "../pylock.other.toml"], ids=["arbitrary", "parent"]) @@ -264,9 +225,11 @@ def test_upgrade_rejects_unsafe_additional_lock( result = execute( lock_repository, - publish_scripts / "publish-check-dependency-patch-paths.sh", + publish_scripts / "publish-check-artifact-patch-paths.sh", tmp_path, ADDITIONAL_LOCK=additional_lock, + LOCK_CHANGED="true", + HOOKS_CHANGED="false", ) assert result.returncode != 0 @@ -327,13 +290,15 @@ def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: hooks_artifact.mkdir() (hooks_artifact / "hooks.patch").write_text(hooks_patch) - applied_lock = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) - applied_hooks = execute(lock_repository, publish_scripts / "publish-apply-hook-patch.sh", tmp_path) checked = execute( lock_repository, - publish_scripts / "publish-check-dependency-patch-paths.sh", + publish_scripts / "publish-check-artifact-patch-paths.sh", tmp_path, + LOCK_CHANGED="true", + HOOKS_CHANGED="true", ) + applied_lock = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) + applied_hooks = execute(lock_repository, publish_scripts / "publish-apply-hook-patch.sh", tmp_path) assert (applied_lock.returncode, applied_hooks.returncode, checked.returncode) == (0, 0, 0) assert run(lock_repository, "git", "diff", "--cached", "--name-only").stdout.splitlines() == [ @@ -342,6 +307,25 @@ def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: ] +@pytest.mark.parametrize("kind", ["lock", "hooks"], ids=["lock", "hooks"]) +def test_upgrade_downloads_the_generated_patch(kind: str) -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text() + ) + generator = workflow["jobs"][f"generate-{kind}"] + download = next( + step + for step in workflow["jobs"]["publish"]["steps"] + if step.get("with", {}).get("path") == f"${{{{ runner.temp }}}}/release-tools-{kind}" + ) + + assert generator["outputs"]["artifact-id"] == "${{ steps.upload.outputs.artifact-id }}" + assert next(step for step in generator["steps"] if step.get("id") == "upload")["with"]["name"] == ( + f"release-tools-{kind}" + ) + assert download["with"]["artifact-ids"] == f"${{{{ needs.generate-{kind}.outputs.artifact-id }}}}" + + def test_upgrade_preserves_requested_pull_request_body( lock_repository: Path, tmp_path: Path, publish_scripts: Path ) -> None: diff --git a/tests/validate_news/test_lock_groups.py b/tests/validate_news/test_lock_groups.py new file mode 100644 index 0000000..8756174 --- /dev/null +++ b/tests/validate_news/test_lock_groups.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import runpy +import subprocess +from pathlib import Path + +import pytest + + +def test_lock_groups_reads_recorded_selection(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + "[dependency-groups]\nrelease = []\nrelease-news = []\nrelease-check = []\n" + ) + lock = tmp_path / "pylock.toml" + lock.write_text( + '[tool.nab]\ncommand-line = ["nab", "lock", "--groups", "release-news", "release-check", ' + '"--output", "pylock.toml"]\n' + ) + + result = subprocess.run( + ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == "release-news release-check\n" + + +@pytest.mark.parametrize( + ("command", "message"), + [ + pytest.param('["nab", "lock", "--output", "pylock.toml"]', "cannot read Nab lock groups", id="missing-flag"), + pytest.param('"nab lock --groups release-news"', "must be a list of strings", id="string-command"), + pytest.param('["nab", 42]', "must be a list of strings", id="non-string-argument"), + pytest.param( + '["nab", "lock", "--groups", "--output", "pylock.toml"]', + "Nab lock records no selected groups", + id="empty-groups", + ), + ], +) +def test_lock_groups_rejects_missing_selection(tmp_path: Path, command: str, message: str) -> None: + lock = tmp_path / "pylock.toml" + lock.write_text(f"[tool.nab]\ncommand-line = {command}\n") + + result = subprocess.run( + ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert message in result.stderr + + +def test_lock_groups_import_has_no_cli_side_effect(capsys: pytest.CaptureFixture[str]) -> None: + runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), run_name="lock_groups") + + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize( + ("groups", "message"), + [ + pytest.param('"release-news"', "omits release groups", id="omitted-release-group"), + pytest.param('"release-news", "release-check", "unknown"', "invalid or undeclared", id="undeclared-group"), + pytest.param('"release-news\\nmode=refresh", "release-check"', "invalid or undeclared", id="output-injection"), + ], +) +def test_lock_groups_rejects_untrusted_selection(tmp_path: Path, groups: str, message: str) -> None: + (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\nrelease-check = []\n") + lock = tmp_path / "pylock.toml" + lock.write_text(f'[tool.nab]\ncommand-line = ["nab", "lock", "--groups", {groups}]\n') + + result = subprocess.run( + ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert message in result.stderr diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 3a67a5a..d5a45ca 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -1,8 +1,11 @@ from __future__ import annotations +import os +import subprocess from pathlib import Path import pytest +import yaml from conftest import action, run @@ -125,3 +128,63 @@ def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action ) assert result.returncode != 0 assert "news validation needs a PR base" in result.stderr + + +def test_validate_news_forwards_secondary_lock_inputs() -> None: + config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-news/action.yml").read_text()) + refresh = next(step for step in config["runs"]["steps"] if step.get("uses") == "$/refresh-release-lock") + secondary = [name for name in config["inputs"] if name.startswith("additional-")] + + assert secondary + assert all(refresh["with"][name] == "${{ inputs." + name + " }}" for name in secondary) + assert refresh["with"]["mode"] == "check" + + +@pytest.mark.parametrize( + ("additional_lock", "changed_path", "changed"), + [ + pytest.param("pylock.zipapp.toml", "pylock.zipapp.toml", "true", id="selected-secondary-lock"), + pytest.param("", "pylock.zipapp.toml", "false", id="unselected-secondary-lock"), + pytest.param("", "nab.toml", "true", id="nab-policy"), + ], +) +def test_validate_news_detects_release_input_change( + repository: Path, tmp_path: Path, action_scripts: Path, additional_lock: str, changed_path: str, changed: str +) -> None: + (repository / "pyproject.toml").write_text( + (repository / "pyproject.toml").read_text() + "\n[dependency-groups]\nrelease-news = []\n" + ) + (repository / "pylock.toml").write_text( + '[tool.nab]\ncommand-line = ["nab", "lock", "--groups", "release-news", "--output", "pylock.toml"]\n' + ) + (repository / "pylock.zipapp.toml").write_text("lock-version = '1.0'\n") + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Add release locks") + run(repository, "git", "push", "origin", "main") + run(repository, "git", "checkout", "-b", "change-secondary-lock") + (repository / changed_path).write_text( + "[tool.nab]\nuploaded-prior-to = false\n" + if changed_path == "nab.toml" + else "lock-version = '1.0'\ncreated-by = 'test'\n" + ) + run(repository, "git", "add", changed_path) + run(repository, "git", "commit", "-m", "Refresh secondary lock") + output = tmp_path / "lock-change-output" + + result = subprocess.run( + ["bash", str(action_scripts / "validate-news-2.sh")], + cwd=repository, + env=os.environ + | { + "BASE_REF": "main", + "ADDITIONAL_LOCK": additional_lock, + "GITHUB_OUTPUT": str(output), + "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"), + }, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert f"changed={changed}\n" in output.read_text() diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index c5a636b..245bf9b 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -52,14 +52,7 @@ def test_validator_isolates_release_tools_from_build() -> None: (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text(), Loader=yaml.BaseLoader, ) - assert set(workflow["on"]["workflow_call"]["inputs"]) == { - "source", - "artifact-prefix", - "changelog", - "artifact-base-url", - "artifact-label", - "local-tool-source", - } + assert set(workflow["on"]["workflow_call"]["inputs"]) == {"source", "artifact-prefix"} 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"]) diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index 0b64f7b..5bba7f3 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -28,13 +28,10 @@ def test_validate_applies_patch_in_fresh_worktree(repository: Path, tmp_path: Pa assert run(repository, "git", "diff", "--cached", "--name-only").stdout == source_staged -def test_validate_steps_honor_working_directory() -> None: +def test_validate_uses_current_checkout() -> None: config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-release-patch/action.yml").read_text()) - assert all( - step.get("working-directory", step.get("with", {}).get("working-directory")) - == "${{ inputs.working-directory }}" - for step in config["runs"]["steps"] - ) + assert "working-directory" not in config.get("inputs", {}) + assert all("working-directory" not in step for step in config["runs"]["steps"]) def track_rst_changelog(repository: Path) -> None: diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index 5b7d9dd..96d25cf 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -4,6 +4,7 @@ import importlib.util import io import json +import runpy import ssl from email.message import Message from http.client import IncompleteRead @@ -12,6 +13,7 @@ from typing import TYPE_CHECKING, TypedDict, cast from urllib.error import HTTPError, URLError +import certifi import pytest if TYPE_CHECKING: @@ -27,6 +29,8 @@ _SPEC.loader.exec_module(_MODULE) verify_files = cast("Callable[[str, str, Path, int], list[Path]]", _MODULE.verify_files) +type _Json = None | bool | int | float | str | list[_Json] | dict[str, _Json] + class _ListingFile(TypedDict): filename: str @@ -51,6 +55,172 @@ def test_verify_files_matches_complete_release(distribution: Path, mocker: Mocke assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"] +def test_verify_files_uses_pinned_trust_store(distribution: Path, mocker: MockerFixture) -> None: + request = mocker.patch.object(_MODULE, "urlopen", return_value=_body(_listing(distribution))) + context = mocker.patch.object(ssl, "create_default_context", autospec=True) + + verify_files("fixture", "1.0", distribution, 0) + + assert request.call_args.kwargs["context"] is context.return_value + assert context.call_args.kwargs == {"cafile": certifi.where()} + + +def test_verify_files_rejects_unsupported_simple_api(distribution: Path, mocker: MockerFixture) -> None: + mocker.patch.object( + _MODULE, + "urlopen", + return_value=io.BytesIO(json.dumps({"meta": {"api-version": "2.0"}, "files": _listing(distribution)}).encode()), + ) + with pytest.raises(ValueError, match="unsupported PyPI Simple API version"): + verify_files("fixture", "1.0", distribution, 0) + + +def test_main_prints_distribution_paths( + distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + _serve(mocker, distribution) + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)]) + + cast("Callable[[], None]", _MODULE.main)() + + assert capsys.readouterr().out.splitlines() == [ + str(distribution / "fixture-1.0-py3-none-any.whl"), + str(distribution / "fixture-1.0.tar.gz"), + ] + + +def test_main_reports_invalid_directory( + tmp_path: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(tmp_path / "missing")]) + + with pytest.raises(SystemExit) as raised: + cast("Callable[[], None]", _MODULE.main)() + + assert raised.value.code == 2 + assert "release files require a directory" in capsys.readouterr().err + + +def test_script_entrypoint_prints_distribution_paths( + distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + mocker.patch("urllib.request.urlopen", return_value=_body(_listing(distribution))) + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)]) + + runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/verify_pypi_files.py"), run_name="__main__") + + assert capsys.readouterr().out.splitlines() == [ + str(distribution / "fixture-1.0-py3-none-any.whl"), + str(distribution / "fixture-1.0.tar.gz"), + ] + + +@pytest.mark.parametrize( + "change", [pytest.param("negative-wait", id="negative-wait"), pytest.param("missing-dir", id="missing-dir")] +) +def test_verify_files_rejects_invalid_arguments(tmp_path: Path, change: str) -> None: + directory = tmp_path if change == "negative-wait" else tmp_path / "missing" + + with pytest.raises(ValueError, match="release files require a directory and nonnegative wait"): + verify_files("fixture", "1.0", directory, -1 if change == "negative-wait" else 0) + + +def test_verify_files_rejects_empty_directory(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="distribution directory is empty"): + verify_files("fixture", "1.0", tmp_path, 0) + + +def test_verify_files_rejects_directory_entry(distribution: Path) -> None: + (distribution / "nested").mkdir() + + with pytest.raises(ValueError, match="invalid distribution path"): + verify_files("fixture", "1.0", distribution, 0) + + +def test_verify_files_ignores_other_release_wheel(distribution: Path, mocker: MockerFixture) -> None: + files = _listing(distribution) + files.append({"filename": "fixture-0.9-py3-none-any.whl", "hashes": {"sha256": "0" * 64}, "yanked": False}) + mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + + assert verify_files("fixture", "1.0", distribution, 0) == [ + distribution / "fixture-1.0-py3-none-any.whl", + distribution / "fixture-1.0.tar.gz", + ] + + +@pytest.mark.parametrize( + "status", + [ + pytest.param(400, id="client-error"), + pytest.param(403, id="forbidden"), + pytest.param(503, id="server-timeout"), + ], +) +def test_verify_files_propagates_http_error(distribution: Path, mocker: MockerFixture, status: int) -> None: + mocker.patch.object( + _MODULE, "urlopen", side_effect=HTTPError("https://pypi.org/simple/fixture/", status, "error", Message(), None) + ) + + with pytest.raises(HTTPError) as raised: + verify_files("fixture", "1.0", distribution, 0) + + assert raised.value.code == status + + +def test_verify_files_propagates_exhausted_connection_error(distribution: Path, mocker: MockerFixture) -> None: + mocker.patch.object(_MODULE, "urlopen", side_effect=URLError("offline")) + + with pytest.raises(URLError, match="offline"): + verify_files("fixture", "1.0", distribution, 0) + + +def test_verify_files_reports_missing_release_after_404(distribution: Path, mocker: MockerFixture) -> None: + mocker.patch.object( + _MODULE, "urlopen", side_effect=HTTPError("https://pypi.org/simple/fixture/", 404, "missing", Message(), None) + ) + + with pytest.raises(ValueError, match="PyPI is missing files"): + verify_files("fixture", "1.0", distribution, 0) + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param([], id="not-object"), + pytest.param({"meta": {"api-version": "1.0"}, "files": {}}, id="files-not-list"), + pytest.param({"meta": None, "files": []}, id="missing-metadata"), + pytest.param({"meta": {"api-version": "2.0"}, "files": []}, id="unsupported-version"), + ], +) +def test_verify_files_rejects_invalid_listing(distribution: Path, mocker: MockerFixture, payload: _Json) -> None: + mocker.patch.object(_MODULE, "urlopen", return_value=io.BytesIO(json.dumps(payload).encode())) + + with pytest.raises( + (TypeError, ValueError), match="invalid Simple API response|unsupported PyPI Simple API version" + ): + verify_files("fixture", "1.0", distribution, 0) + + +@pytest.mark.parametrize( + "entry", + [ + pytest.param(None, id="not-object"), + pytest.param({"hashes": {"sha256": "0" * 64}}, id="missing-filename"), + pytest.param({"filename": "fixture-1.0.tar.gz", "hashes": None}, id="missing-hashes"), + pytest.param({"filename": "fixture-1.0.tar.gz", "hashes": {"sha256": None}}, id="missing-sha256"), + ], +) +def test_verify_files_rejects_invalid_file_entry(distribution: Path, mocker: MockerFixture, entry: _Json) -> None: + mocker.patch.object( + _MODULE, + "urlopen", + return_value=io.BytesIO(json.dumps({"meta": {"api-version": "1.0"}, "files": [entry]}).encode()), + ) + + with pytest.raises(TypeError, match="invalid file entry"): + verify_files("fixture", "1.0", distribution, 0) + + 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}) @@ -210,7 +380,8 @@ def test_main_reports_exhausted_incomplete_read( distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] ) -> None: mocker.patch.object(_MODULE, "urlopen", side_effect=IncompleteRead(b"partial", 10)) - mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution), "--wait-seconds", "0"]) + mocker.patch.object(_MODULE.time, "monotonic", side_effect=[0, 121]) + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)]) with pytest.raises(SystemExit) as raised: cast("Callable[[], None]", _MODULE.main)() diff --git a/tools/check_hook_config.py b/tools/check_hook_config.py new file mode 100644 index 0000000..bb6d131 --- /dev/null +++ b/tools/check_hook_config.py @@ -0,0 +1,218 @@ +"""Check the hook changes allowed in a bot pull request.""" + +from __future__ import annotations + +import argparse +import json +import re +import ssl +from collections.abc import Iterator +from copy import deepcopy +from datetime import UTC, datetime, timedelta +from pathlib import Path +from re import Pattern +from typing import Final, cast +from urllib.parse import quote, urlsplit +from urllib.request import Request, urlopen + +import certifi +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version +from yaml import MappingNode, ScalarNode, SequenceNode, YAMLError, compose, safe_load +from yaml.error import Mark + +_MANAGED_MARKER: Final[Pattern[str]] = re.compile( + r""" + ^ \s* ,? \s* + (?P [#] \s+ release-creator \s+ managed ) + (?: \s | $ ) + """, + re.VERBOSE, +) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("base", type=Path) + parser.add_argument("proposed", type=Path) + parser.add_argument("--managed", choices=("true", "false", ""), default="") + parser.add_argument("--minimum-days", type=int, default=7) + args = parser.parse_args() + try: + check_config(args.base, args.proposed, args.managed == "true", args.minimum_days) + except (InvalidRequirement, KeyError, OSError, TypeError, ValueError, YAMLError) as error: + parser.error(str(error)) + + +def check_config(base: Path, proposed: Path, managed: bool, minimum_days: int) -> None: + if minimum_days < 7: + raise ValueError("hook cooldown must be at least seven days") + base_source = base.read_text(encoding="utf-8") + proposed_source = proposed.read_text(encoding="utf-8") + _reject_duplicate_keys(base_source) + _reject_duplicate_keys(proposed_source) + old = safe_load(base_source) + new = safe_load(proposed_source) + if not isinstance(old, dict) or not isinstance(new, dict): + raise TypeError("hook configuration must be a mapping") + old_content = deepcopy(old) + new_content = deepcopy(new) + old_repos = old_content.get("repos") + new_repos = new_content.get("repos") + if not isinstance(old_repos, list) or not isinstance(new_repos, list): + raise TypeError("hook repositories must be lists") + for repository in (*old_repos, *new_repos): + if not isinstance(repository, dict): + raise TypeError("hook repository must be a mapping") + repository.pop("rev", None) + if managed: + old_pins = list(_dependency_groups(base_source)) + new_pins = list(_dependency_groups(proposed_source)) + if len(old_pins) != len(new_pins): + raise ValueError("hook update changes fields beyond revisions and managed dependencies") + for old_group, new_group in zip(old_pins, new_pins, strict=True): + _check_dependency_group(old_group, new_group, minimum_days) + for repository in (*old_repos, *new_repos): + for hook in repository.get("hooks", []): + hook.pop("additional_dependencies", None) + if old_content != new_content: + raise ValueError( + "hook update changes fields beyond revisions and managed dependencies" + if managed + else "hook update changes fields beyond revisions" + ) + + +def _dependency_groups(source: str) -> Iterator[list[tuple[str, bool]]]: + root = cast("MappingNode", compose(source)) + repositories = cast("SequenceNode", _value(root, "repos")) + for repository in repositories.value: + hooks = _value(cast("MappingNode", repository), "hooks") + if not isinstance(hooks, SequenceNode): + raise TypeError("hook repository must contain hooks") + for hook in hooks.value: + if not isinstance(hook, MappingNode): + raise TypeError("hook must be a mapping") + dependencies = _value(hook, "additional_dependencies") + if dependencies is None: + yield [] + continue + if not isinstance(dependencies, SequenceNode) or not all( + isinstance(item, ScalarNode) for item in dependencies.value + ): + raise TypeError("additional_dependencies must be a list of package strings") + yield [(item.value, _managed_marker(source, item)) for item in dependencies.value] + + +def _managed_marker(source: str, item: ScalarNode) -> bool: + suffix = source[cast("Mark", item.end_mark).index :].split("\n", 1)[0] + return _MANAGED_MARKER.match(suffix) is not None + + +def _reject_duplicate_keys(source: str) -> None: + root = compose(source) + pending = [root] + visited: set[int] = set() + while pending: + node = pending.pop() + if node is None or id(node) in visited: + continue + visited.add(id(node)) + if isinstance(node, MappingNode): + names = [key.value for key, _ in node.value if isinstance(key, ScalarNode)] + if len(names) != len(set(names)): + raise ValueError("hook configuration contains duplicate mapping keys") + pending.extend(child for pair in node.value for child in pair) + elif isinstance(node, SequenceNode): + pending.extend(node.value) + + +def _value(node: MappingNode, name: str) -> object: + return next((value for key, value in node.value if isinstance(key, ScalarNode) and key.value == name), None) + + +def _check_dependency_group(old: list[tuple[str, bool]], new: list[tuple[str, bool]], minimum_days: int) -> None: + old_direct = [item for item, managed in old if not managed] + new_direct = [item for item, managed in new if not managed] + if len(old_direct) != len(new_direct): + raise ValueError("hook update changes direct dependencies") + for previous, proposed in zip(old_direct, new_direct, strict=True): + if previous == proposed: + continue + old_requirement = Requirement(previous) + new_requirement = Requirement(proposed) + if ( + canonicalize_name(old_requirement.name) != canonicalize_name(new_requirement.name) + or old_requirement.extras != new_requirement.extras + or old_requirement.marker != new_requirement.marker + or old_requirement.url is not None + or new_requirement.url is not None + or not _exact(old_requirement) + ): + raise ValueError("hook update changes direct dependencies beyond an exact pin") + _check_pypi(new_requirement, minimum_days) + previous_managed = {_requirement_key(Requirement(item)) for item, marked in old if marked} + proposed_managed = {_requirement_key(Requirement(item)) for item, marked in new if marked} + if previous_managed - proposed_managed: + raise ValueError("hook update removes managed dependencies") + names = [canonicalize_name(Requirement(item).name) for item, _ in new] + if len(names) != len(set(names)): + raise ValueError("hook update duplicates a dependency") + for item, marked in new: + if marked and item not in {previous for previous, old_marked in old if old_marked}: + _check_pypi(Requirement(item), minimum_days) + + +def _requirement_key(requirement: Requirement) -> tuple[str, frozenset[str], str]: + return canonicalize_name(requirement.name), frozenset(requirement.extras), str(requirement.marker) + + +def _exact(requirement: Requirement) -> bool: + return ( + requirement.url is None + and len(requirement.specifier) == 1 + and (specifier := next(iter(requirement.specifier))).operator == "==" + and "*" not in specifier.version + ) + + +def _check_pypi(requirement: Requirement, minimum_days: int) -> None: + if not _exact(requirement): + raise ValueError(f"hook dependency is not an exact PyPI pin: {requirement}") + version = Version(next(iter(requirement.specifier)).version) + name = canonicalize_name(requirement.name) + request = Request( + f"https://pypi.org/pypi/{quote(name)}/{quote(str(version))}/json", + headers={"Cache-Control": "no-cache"}, + ) + with urlopen(request, timeout=10, context=ssl.create_default_context(cafile=certifi.where())) as response: + listing = json.load(response) + files = listing.get("urls") + if ( + canonicalize_name(listing.get("info", {}).get("name", "")) != name + or Version(listing.get("info", {}).get("version", "0")) != version + or not isinstance(files, list) + or not files + ): + raise ValueError(f"invalid PyPI hook dependency: {requirement}") + cutoff = datetime.now(UTC) - timedelta(days=minimum_days) + for file in files: + url = urlsplit(file.get("url", "")) + uploaded = datetime.fromisoformat(file.get("upload_time_iso_8601", "")) + if ( + file.get("yanked") + or url.scheme != "https" + or url.netloc != "files.pythonhosted.org" + or not file.get("digests", {}).get("sha256") + or uploaded.tzinfo is None + or uploaded > cutoff + ): + raise ValueError(f"hook dependency has an uncooled or untrusted PyPI artifact: {requirement}") + + +if __name__ == "__main__": + main() + + +__all__ = ["check_config", "main"] diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 31be2ce..492fb95 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -3,18 +3,29 @@ from __future__ import annotations import argparse +import json +import ssl +import time import tomllib +from collections import defaultdict +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta +from http.client import IncompleteRead from pathlib import Path +from typing import cast +from urllib.error import HTTPError, URLError from urllib.parse import urlsplit +from urllib.request import Request, urlopen -from nab_cutoffs import reviewed_cutoff -from packaging.pylock import Pylock, PylockValidationError +import certifi +from nab_cutoffs import reviewed_cutoff, validate_indexes +from packaging._parser import Variable +from packaging.markers import Marker +from packaging.pylock import Package, 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__) @@ -23,6 +34,8 @@ def main() -> None: parser.add_argument("--shared-lock", type=Path) parser.add_argument("--local-tool-source", action="store_true") parser.add_argument("--build-constraints", action="store_true") + parser.add_argument("--verify-pypi", action="store_true") + parser.add_argument("--project", type=Path) args = parser.parse_args() try: groups = {"build"} if args.build_constraints else set(args.groups) @@ -30,21 +43,29 @@ def main() -> None: parser.error("at least one dependency group is required") with args.lock.open("rb") as stream: lock = Pylock.from_dict(tomllib.load(stream)) + if args.project: + if (args.project.parent / "nab.toml").exists(): + raise ValueError("project nab.toml can override the reviewed index policy") + with args.project.open("rb") as stream: + validate_indexes(tomllib.load(stream)["tool"]["nab"]) 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.build_constraints and not lock.dependency_groups: + groups.clear() + _check_cooling(lock, groups, local_tool_source=args.local_tool_source) + if args.verify_pypi: + _verify_pypi(lock, groups, 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) + required = max(_selected_release_creators(shared, groups), default=None) if required is not None and not args.local_tool_source: - installed = _selected_release_creator(lock, groups) + installed = min(_selected_release_creators(lock, groups), default=None) if installed is None or installed < required: parser.error(f"relock: release-creator {required} required") - except (InvalidVersion, OSError, PylockValidationError, ValueError) as error: + except (IncompleteRead, InvalidVersion, KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: parser.error(str(error)) @@ -52,19 +73,15 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = 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 + threshold = created - timedelta(days=7) + for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): + if package.archive is not None or package.vcs is not None or package.directory is not None: + raise ValueError(f"release lock has a direct-source package: {package.name}") if package.index is None: raise ValueError(f"release lock has an uncooled non-index package: {package.name}") + if package.index != "https://pypi.org/simple/": + raise ValueError(f"release lock has an unapproved index: {package.name}: {package.index}") 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 "") @@ -84,16 +101,133 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = raise ValueError(f"release lock has an uncooled artifact: {artifact.name}") -def _selected_release_creator(lock: Pylock, groups: set[str]) -> Version | None: - return next( - ( - Version(str(package.version)) - for package in lock.packages - if package.name == "release-creator" - and (package.marker is None or package.marker.evaluate({"dependency_groups": groups}, context="lock_file")) - ), - None, +def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> None: + expected: dict[str, list[tuple[str, str | None, str, datetime | None]]] = defaultdict(list) + for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): + for artifact in ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()): + expected[canonicalize_name(package.name)].append( + ( + urlsplit(artifact.url or "").path.rsplit("/", 1)[-1], + artifact.url, + artifact.hashes["sha256"], + artifact.upload_time, + ) + ) + with ThreadPoolExecutor(max_workers=8) as pool: + for _ in pool.map(_verify_package_metadata, expected.items()): + pass + + +def _verify_package_metadata(item: tuple[str, list[tuple[str, str | None, str, datetime | None]]]) -> None: + name, artifacts = item + for attempt in range(5): + _, actual = _pypi_files(name) + mismatch = next( + ( + filename + for filename, url, digest, upload_time in artifacts + if actual.get(filename) != (url, digest, upload_time) + ), + None, + ) + if mismatch is None: + return + if attempt < 4: + time.sleep(2**attempt) + raise ValueError(f"release lock differs from PyPI metadata: {name}: {mismatch}") + + +def _selected_packages(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> Iterator[Package]: + selections = (groups, {"build"}) if "build" in (lock.dependency_groups or ()) else (groups,) + for package in lock.packages: + if package.marker is not None and not any( + _possibly_selected(package.marker, selection) for selection in selections + ): + continue + if ( + local_tool_source + and package.name == "release-creator" + and package.directory is not None + and package.directory.path == "." + and package.index is None + and package.archive is None + and package.vcs is None + and package.sdist is None + and not package.wheels + ): + continue + yield package + + +def _possibly_selected(marker: Marker, groups: set[str]) -> bool: + def expression(nodes: list[object]) -> bool: + alternatives: list[bool] = [] + current = True + for node in nodes: + if node == "or": + alternatives.append(current) + current = True + elif node != "and": + if isinstance(node, list): + current &= expression(node) + else: + left, operator, right = cast("tuple[object, object, object]", node) + if isinstance(right, Variable) and str(right) == "dependency_groups" and str(operator) == "in": + current &= str(left) in groups + elif ( + isinstance(right, Variable) and str(right) == "dependency_groups" and str(operator) == "not in" + ): + current &= str(left) not in groups + return any((*alternatives, current)) + + return expression(marker._markers) + + +def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, datetime | None]]]: + request = Request( + f"https://pypi.org/simple/{name}/", + headers={"Accept": "application/vnd.pypi.simple.v1+json", "Cache-Control": "no-cache"}, ) + attempt = 0 + while True: + try: + with urlopen(request, timeout=10, context=ssl.create_default_context(cafile=certifi.where())) as response: + listing = json.load(response) + break + except HTTPError as error: + if (error.code not in (404, 429) and error.code < 500) or attempt == 4: + raise + except IncompleteRead, TimeoutError, URLError: + if attempt == 4: + raise + time.sleep(2**attempt) + attempt += 1 + if ( + not isinstance(listing, dict) + or not isinstance(listing.get("meta"), dict) + or not isinstance(listing.get("files"), list) + or not str(listing["meta"].get("api-version", "")).startswith("1.") + or canonicalize_name(listing.get("name", "")) != name + ): + raise ValueError(f"invalid PyPI listing for {name}") + files: dict[str, tuple[str | None, str | None, datetime | None]] = {} + for file in listing["files"]: + if not isinstance(file, dict): + raise TypeError(f"invalid PyPI listing for {name}") + if file.get("yanked", False): + continue + files[file["filename"]] = ( + file.get("url"), + file.get("hashes", {}).get("sha256"), + datetime.fromisoformat(file["upload-time"]) if "upload-time" in file else None, + ) + return name, files + + +def _selected_release_creators(lock: Pylock, groups: set[str]) -> Iterator[Version]: + for package in lock.packages: + if package.name == "release-creator" and (package.marker is None or _possibly_selected(package.marker, groups)): + yield Version(str(package.version)) if __name__ == "__main__": diff --git a/tools/expose_locked_commands.py b/tools/expose_locked_commands.py index be65c4c..e78f6cb 100644 --- a/tools/expose_locked_commands.py +++ b/tools/expose_locked_commands.py @@ -17,7 +17,7 @@ def main() -> None: and (os.name != "nt" or command.suffix.casefold() == ".exe") and os.access(command, os.X_OK) and not name.startswith(("activate", "deactivate", "easy_install", "ensurepip", "pip", "pydoc", "python")) - and name not in {"uv", "uvx", "wheel"} + and name not in {"bash", "curl", "gh", "git", "openssl", "sh", "ssh", "uv", "uvx", "wheel"} ): if os.name == "nt": shutil.copy2(command, overlay / command.name) diff --git a/tools/lock_groups.py b/tools/lock_groups.py new file mode 100644 index 0000000..fbcf1d6 --- /dev/null +++ b/tools/lock_groups.py @@ -0,0 +1,41 @@ +"""Print the dependency groups used to create a Nab lock.""" + +from __future__ import annotations + +import argparse +import tomllib +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("lock", type=Path) + args = parser.parse_args() + try: + with args.lock.open("rb") as stream: + lock = tomllib.load(stream) + command = lock["tool"]["nab"]["command-line"] + if not isinstance(command, list) or not all(isinstance(value, str) for value in command): + raise ValueError("Nab command-line must be a list of strings") + start = command.index("--groups") + 1 + groups = command[ + start : next( + (index for index in range(start, len(command)) if command[index].startswith("--")), len(command) + ) + ] + if not groups: + parser.error("Nab lock records no selected groups") + with args.lock.with_name("pyproject.toml").open("rb") as stream: + declared = tomllib.load(stream)["dependency-groups"] + if not all(group.isascii() and group.replace("-", "").isalnum() and group in declared for group in groups): + raise ValueError("Nab lock selects an invalid or undeclared group") + required = {name for name in declared if name.startswith("release-")} + if missing := required - set(groups): + raise ValueError(f"Nab lock omits release groups: {', '.join(sorted(missing))}") + print(" ".join(groups)) + except (KeyError, OSError, TypeError, ValueError) as error: + parser.error(f"cannot read Nab lock groups: {error}") + + +if __name__ == "__main__": + main() diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index 7fb3366..3448e3d 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -22,6 +22,8 @@ }, ("release-creator", "0.6.5"): datetime(2026, 10, 23, 19, 54, 50, tzinfo=UTC), ("release-creator", "0.6.6"): datetime(2026, 10, 23, 20, 48, 24, tzinfo=UTC), + ("release-creator", "0.6.7"): datetime(2026, 10, 23, 21, 40, 6, tzinfo=UTC), + ("release-creator", "1.0.0"): datetime(2026, 10, 24, 6, 7, 46, tzinfo=UTC), ("uv-build", "0.13.4"): datetime(2026, 10, 21, tzinfo=UTC), } _REVIEWED_EXCEPTIONS: Final = { @@ -55,6 +57,7 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None: for name, settings in nab.get("index", {}).items(): if "uploaded-prior-to" in settings: raise ValueError(f"Nab index cutoff for {name} bypasses the seven-day policy") + validate_indexes(nab) for name, value in candidates: if value is False: raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}") @@ -73,6 +76,15 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None: raise ValueError(f"unreviewed recent Nab cutoff for {name}: {timestamp.isoformat()}") +def validate_indexes(nab: dict) -> None: + if any(index != {"name": "pypi", "url": "https://pypi.org/simple/"} for index in nab.get("indexes", [])): + raise ValueError("custom Nab indexes are not approved for release locks") + if nab.get("index") or any("index" in settings for settings in nab.get("packages", {}).values()): + raise ValueError("custom Nab indexes are not approved for release locks") + if any("index" in settings for settings in nab.get("package-rules", [])): + raise ValueError("custom Nab indexes are not approved for release locks") + + def remove_expired_cutoffs(project: str, threshold: datetime) -> str: import tomlkit @@ -143,5 +155,6 @@ def _expired(value: str | datetime | bool, threshold: datetime, reviewed: set[da "remove_cutoffs", "remove_expired_cutoffs", "reviewed_cutoff", + "validate_indexes", "validate_recent_cutoffs", ] diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index db34f12..ea7c970 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -13,6 +13,7 @@ from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +import certifi from packaging.utils import ( InvalidSdistFilename, InvalidWheelFilename, @@ -34,10 +35,9 @@ def main() -> None: 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): + for path in verify_files(args.project, args.version, args.dist, 120): print(path) except (OSError, TypeError, ValueError, IncompleteRead) as error: parser.error(str(error)) @@ -131,7 +131,9 @@ def _listing(project: str) -> list[_SimpleFile]: 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. + with urlopen( # ruff: ignore[suspicious-url-open-usage] # Fixed PyPI origin. + request, timeout=30, context=ssl.create_default_context(cafile=certifi.where()) + ) as response: payload = json.load(response) except HTTPError as error: if error.code == 404: diff --git a/tox.toml b/tox.toml index acea0f7..477131d 100644 --- a/tox.toml +++ b/tox.toml @@ -1,5 +1,17 @@ requires = ["tox>=4.64.7", "tox-uv>=1.36.1"] -env_list = ["type"] +env_list = ["test", "type"] + +[env.test] +description = "test Actions and measure Python tools" +base_python = "3.15" +skip_install = true +dependency_groups = ["test"] +commands = [ + ["coverage", "erase"], + ["coverage", "run", "-m", "pytest", "-q", "-n", "auto"], + ["coverage", "combine"], + ["coverage", "report", "-m"], +] [env.type] description = "type-check Action tests" diff --git a/validate-news/action.yml b/validate-news/action.yml index 83280f7..3b3f137 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -1,5 +1,24 @@ name: Validate changelog news description: Require a fragment for human changes and validate any fragment supplied by a bot +inputs: + additional-lock: + description: Secondary pylock basename to check on a pull request + default: "" + additional-lock-groups: + description: Groups selected by the secondary lock + default: "" + additional-lock-selection: + description: Project or build-or-groups selection for the secondary lock + default: build-or-groups + additional-matrix-python: + description: Python range for the secondary lock + default: "" + additional-matrix-platforms: + description: Space-separated Nab platform IDs for the secondary lock + default: "" + additional-matrix-implementations: + description: Space-separated implementations for the secondary lock + default: "" runs: using: composite steps: @@ -19,3 +38,40 @@ runs: echo '::error::add a changelog fragment or apply the skip news label' >&2 exit 1 fi + - id: lock-change + name: Check whether release inputs changed + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + run: | + paths=(pyproject.toml pylock.toml nab.toml) + if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi + changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${paths[@]}") + if [[ -z "$changed" ]]; then + echo 'changed=false' >>"$GITHUB_OUTPUT" + exit 0 + fi + if [[ ! -f pylock.toml ]]; then + if git cat-file -e "origin/$BASE_REF:pylock.toml" 2>/dev/null; then + echo '::error::the release lock was removed' >&2 + exit 1 + fi + echo 'changed=false' >>"$GITHUB_OUTPUT" + exit 0 + fi + groups=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml) + echo 'changed=true' >>"$GITHUB_OUTPUT" + echo "groups=$groups" >>"$GITHUB_OUTPUT" + - name: Check the release lock against the project + if: steps.lock-change.outputs.changed == 'true' + uses: $/refresh-release-lock + with: + lock-groups: ${{ steps.lock-change.outputs.groups }} + additional-lock: ${{ inputs.additional-lock }} + additional-lock-groups: ${{ inputs.additional-lock-groups }} + additional-lock-selection: ${{ inputs.additional-lock-selection }} + additional-matrix-python: ${{ inputs.additional-matrix-python }} + additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} + additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + mode: check diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index c7804a8..9031cb1 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -7,9 +7,6 @@ inputs: patch: description: Path to the downloaded patch required: true - working-directory: - description: Checkout to validate and apply the patch in - default: . checks: description: Run release-creator and prek after applying the patch default: "true" @@ -18,7 +15,6 @@ runs: steps: - name: Verify release source shell: bash - working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail git fetch origin main @@ -28,7 +24,6 @@ runs: fi - name: Apply release patch shell: bash - working-directory: ${{ inputs.working-directory }} env: PATCH: ${{ inputs.patch }} CHECKS: ${{ inputs.checks }} @@ -41,11 +36,8 @@ runs: git apply --index "$PATCH" - id: config uses: $/read-towncrier-config - with: - working-directory: ${{ inputs.working-directory }} - name: Check release patch paths shell: bash - working-directory: ${{ inputs.working-directory }} env: CHANGELOG: ${{ steps.config.outputs.changelog }} FRAGMENTS: ${{ steps.config.outputs.fragments }} @@ -73,11 +65,9 @@ runs: - name: Check consumed fragments if: inputs.checks == 'true' shell: bash - working-directory: ${{ inputs.working-directory }} run: release-creator validate-fragment-deletions - name: Check release patch file modes shell: bash - working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes" @@ -113,7 +103,6 @@ runs: - name: Check RST conflict markers if: inputs.checks == 'true' && steps.config.outputs.format == 'rst' shell: bash - working-directory: ${{ inputs.working-directory }} env: CHANGELOG: ${{ steps.config.outputs.changelog }} run: | @@ -131,7 +120,6 @@ runs: - name: Check rendered release section if: inputs.checks == 'true' shell: bash - working-directory: ${{ inputs.working-directory }} env: VERSION: ${{ inputs.version }} CHANGELOG: ${{ steps.config.outputs.changelog }} @@ -139,7 +127,6 @@ runs: - name: Run prek on release patch if: inputs.checks == 'true' shell: bash - working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail if ! prek run --all-files; then