diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 00180a0..2e149ed 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -24,7 +24,7 @@ jobs: with: path: ~/.cache/prek key: prek-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.pre-commit-config.yaml') }} - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" @@ -60,7 +60,7 @@ jobs: with: path: ~/.cache/prek key: prek-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.pre-commit-config.yaml') }} - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" @@ -107,7 +107,7 @@ jobs: with: fetch-depth: 1 persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" @@ -130,7 +130,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml index 3eb526d..f42c886 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -12,7 +12,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 0b9b7a4..54c9f9d 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -18,6 +18,34 @@ on: description: Install release-creator from the tagged checkout for its own release type: boolean default: false + keep-uv-lock: + description: Check a committed native uv.lock alongside pylock.toml + type: boolean + default: false + additional-lock: + description: Secondary Nab lock to check before release preparation + type: string + default: "" + additional-lock-groups: + description: Groups selected by the secondary lock + type: string + default: "" + additional-lock-selection: + description: Project or build-or-groups selection for the secondary lock + type: string + default: build-or-groups + additional-matrix-python: + description: Python range for the secondary lock + type: string + default: "" + additional-matrix-platforms: + description: Space-separated Nab platform IDs for the secondary lock + type: string + default: "" + additional-matrix-implementations: + description: Space-separated implementations for the secondary lock + type: string + default: "" annotated: description: Create an annotated tag type: boolean @@ -50,11 +78,21 @@ jobs: fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false + - uses: $/refresh-release-lock + with: + mode: check + keep-uv-lock: ${{ inputs.keep-uv-lock }} + 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 }} - id: tools uses: $/install-locked-tools with: @@ -121,7 +159,7 @@ jobs: fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a01019b..43de07c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -11,16 +11,22 @@ jobs: check: runs-on: ubuntu-26.04 permissions: + actions: read contents: read + pull-requests: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false + - uses: $/refresh-release-lock + with: + mode: check - uses: $/install-locked-tools with: groups: release-check @@ -48,7 +54,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" @@ -105,7 +111,7 @@ jobs: with: fetch-depth: 1 persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 0f58f0d..b09934e 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -12,8 +12,9 @@ jobs: secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: + upgrade-resolver: true lock-groups: >- - build lock-tools lock-reader 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-resolver 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 e91eafe..78aaaf2 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -30,6 +30,14 @@ on: description: Space-separated implementations for the secondary lock type: string default: "" + upgrade-resolver: + description: Upgrade this repository's exact shared uv resolver pin with its lock + type: boolean + default: false + keep-uv-lock: + description: Refresh and check uv.lock alongside pylock.toml + type: boolean + default: false commit-message: description: Commit message for the refreshed dependency files type: string @@ -61,7 +69,7 @@ jobs: with: fetch-depth: 1 persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" @@ -76,6 +84,8 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + upgrade-resolver: ${{ inputs.upgrade-resolver }} + keep-uv-lock: ${{ inputs.keep-uv-lock }} - id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.lock.outputs.changed == 'true' @@ -99,7 +109,7 @@ jobs: with: fetch-depth: 1 persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" @@ -161,6 +171,7 @@ jobs: - name: Check artifact patch paths env: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} LOCK_CHANGED: ${{ needs.generate-lock.outputs.changed }} HOOKS_CHANGED: ${{ needs.generate-hooks.outputs.changed }} run: | @@ -184,7 +195,7 @@ jobs: 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" ]] || + "$path" != "$ADDITIONAL_LOCK" && ("$path" != uv.lock || "$KEEP_UV_LOCK" != true) ]] || [[ "$artifact" == hooks && "$path" != .pre-commit-config.yaml ]]; then echo "::error::unexpected $artifact patch path: $path" >&2 exit 1 diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 236103e..a8f5b6e 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -14,23 +14,64 @@ on: description: Prefix for distribution artifacts in a multi-package release default: "" type: string + keep-uv-lock: + description: Check a committed native uv.lock alongside pylock.toml + default: false + type: boolean + additional-lock: + description: Secondary Nab lock to check against the tagged project + default: "" + type: string + additional-lock-groups: + description: Groups selected by the secondary lock + default: "" + type: string + additional-lock-selection: + description: Project or build-or-groups selection for the secondary lock + default: build-or-groups + type: string + additional-matrix-python: + description: Python range for the secondary lock + default: "" + type: string + additional-matrix-platforms: + description: Space-separated Nab platform IDs for the secondary lock + default: "" + type: string + additional-matrix-implementations: + description: Space-separated implementations for the secondary lock + default: "" + type: string permissions: contents: read jobs: source-check: runs-on: ubuntu-26.04 permissions: + actions: read contents: read + pull-requests: 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 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false + - uses: $/refresh-release-lock + with: + mode: check + keep-uv-lock: ${{ inputs.keep-uv-lock }} + 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 }} - uses: $/install-locked-tools with: groups: release-check @@ -57,7 +98,7 @@ jobs: with: persist-credentials: false ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" @@ -75,7 +116,7 @@ jobs: path: ${{ runner.temp }}/build-constraints.txt if-no-files-found: error build: - needs: constraints + needs: [source-check, constraints] runs-on: ubuntu-26.04 outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} @@ -96,7 +137,7 @@ jobs: git check-ref-format "refs/tags/$TAG" test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" git merge-base --is-ancestor HEAD origin/main - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" @@ -149,7 +190,7 @@ jobs: with: persist-credentials: false ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" diff --git a/README.md b/README.md index 2f9eac9..2a79e47 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,14 @@ 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. `release-creator locks` exports runtime dependencies, all extras, and all dependency groups with a seven-day package cutoff. Reviewed exceptions live in -`[tool.release-creator.locks.exclude-newer-package]`. The hook updater applies the same cooldown to hook revisions and -their `additional_dependencies`. Each job uploads a patch. The lock patch may change `pylock.toml`, an explicitly -configured secondary Nab lock, and `pyproject.toml` for expired reviewed 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. +`[tool.release-creator.locks.exclude-newer-package]`. Both lock checks and weekly refresh install uv from the shared +`release-resolver` group, so a setup-uv default change cannot alter the generated lock. The shared weekly updater +refreshes this direct pin and the lock together; consumer locks use their pinned shared resolver. The hook updater +applies the same cooldown to hook revisions and their `additional_dependencies`. Each job uploads a patch. The lock +patch may change `pylock.toml`, an explicitly configured secondary Nab lock, and `pyproject.toml` for expired reviewed +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 @@ -131,16 +133,17 @@ dependency changes. The updater also refreshes a caller's secondary Nab build-ma matrix inputs are set. The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The -workflow does not update `uv.lock` or a checked-in constraints file. Build constraints are generated from the lock's -`build` group at runtime. +workflow updates a committed `uv.lock` only when `keep-uv-lock: true`; pass that input to `validate-news` too. Both +locks are then checked with the same resolver and allowed in the dependency patch. The default leaves `uv.lock` alone. +Build constraints are generated from the lock's `build` group at runtime. For a second lock such as `pylock.build.toml`, set `additional-lock`, `additional-matrix-python`, `additional-matrix-platforms`, and `additional-matrix-implementations`. `additional-lock-groups` selects dependency 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. +both locks in the same dependency PR. The writer accepts only those locks, an opted-in `uv.lock`, `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 full primary lock and writes `$RUNNER_TEMP/lock.patch` if a lock or reviewed cutoff changes. In `check` mode, it regenerates at the primary @@ -160,7 +163,9 @@ required aggregate. It reads its PR number and head SHA from the event, compares proposed configuration from that exact head, including a fork head. It checks the complete Python hook dependency closure at the configured `minimum-days` cutoff and requires each changed revision to have appeared in a GitHub PR workflow run at least that many 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 may add hooks or change their options under the same +revision's age or give a new revision the old one's age. GitHub can omit pull request links from workflow runs. For +those runs, the Action observes the exact head repository, branch, commit, and workflow after the PR opened; two PRs +from the same head can share that code observation. Human PRs may add hooks or change their options under the same revision and dependency checks. A human PR may add the first managed pins when the base configuration has none; the complete closure check still applies. Once the base has managed pins, only the release App may change them. Other bot PRs may change revisions only. On `automation/update-release-tools`, the Action verifies the bot against @@ -169,6 +174,43 @@ revision must be a full lowercase commit SHA. A revision with new Python depende updates its managed pins. Tagged source jobs check frozen revisions and the complete Python dependency closure before running `prek`. +On a tag, `verify-frozen-hooks` also checks when each new external `(repository, commit)` pair was first present in +GitHub. It compares the tag with the previous published release in the same tag stream and on the first-parent release +line. A new pair must come from a merged PR, and either its merge or a PR run containing the exact pair must be at least +seven days old. The first release has no baseline: every external pair needs that proof. Directly pushed hook changes +cannot acquire it. This read-only Action has no outputs; it fails before `prek` runs. The tagged source job needs +`actions: read`, `contents: read`, `pull-requests: read`, and a checkout with `fetch-depth: 0`. + +For a release dispatched from `main` with a tag input, pass that tag to the Action. It checks that the checkout is the +tag's commit; a dispatch on `main` with the wrong checkout fails. + +```yaml + - uses: tox-dev/github-workflows/verify-frozen-hooks@SHA + with: + tag: ${{ inputs.tag }} +``` + +The shared prepare and tagged validator run `refresh-release-lock` in `check` mode before release checks or publishing. +Pass `keep-uv-lock: true` when the repository commits a native `uv.lock`. For a secondary Nab lock, pass its path, +selected groups, selection type, and matrix inputs to both workflows. A stale primary, native, or secondary lock stops +the release before artifacts are built. + +For example, a tagged source job that also publishes from a PGO lock checks both files before installing build tools: + +```yaml + - uses: tox-dev/github-workflows/refresh-release-lock@SHA + with: + mode: check + additional-lock: pylock.pgo.toml + additional-lock-groups: pgo + additional-matrix-python: '>=3.11,<3.16' + additional-matrix-platforms: linux_x86_64 linux_aarch64 + additional-matrix-implementations: cpython pypy +``` + +For a committed native uv lock, set `keep-uv-lock: true` in this step. The full project lock and `uv.lock` must then +agree with the same project configuration. + ```yaml hook-cooldown: if: github.event_name == 'pull_request' @@ -248,7 +290,9 @@ build: uses: >- tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA permissions: + actions: read contents: read + pull-requests: read ``` The caller needs `release-check` for prek and a `build` group for backend constraints. `source` defaults to `.`. Use diff --git a/changelog.d/+fork-hook-observation.bugfix.md b/changelog.d/+fork-hook-observation.bugfix.md new file mode 100644 index 0000000..9089a78 --- /dev/null +++ b/changelog.d/+fork-hook-observation.bugfix.md @@ -0,0 +1,2 @@ +Count a fork PR's hook revision after seven days when GitHub omits the PR link from its workflow run. The run must match +the fork repository, branch, commit, and workflow, and occur after the PR opened. diff --git a/changelog.d/+patched-uv-resolver.security.md b/changelog.d/+patched-uv-resolver.security.md new file mode 100644 index 0000000..313111f --- /dev/null +++ b/changelog.d/+patched-uv-resolver.security.md @@ -0,0 +1,3 @@ +Use uv 0.13.5 or newer to avoid the compromised certificate root in earlier releases. Install the exact, hash-checked +resolver from the shared lock for lock checks and weekly refresh, and update setup-uv to a release that boots patched +uv. diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index f11d37d..b4bdbc9 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -66,13 +66,18 @@ runs: 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" + workflow_path=${GITHUB_WORKFLOW_REF#"$GITHUB_REPOSITORY/"} + workflow_path=${workflow_path%@*} if jq -se --arg repository "$(jq -r .head.repo.full_name "$RUNNER_TEMP/hook-update-pr.json")" \ --arg branch "$(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json")" \ - --arg head "$HEAD_SHA" --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" ' + --arg head "$HEAD_SHA" --arg workflow "$workflow_path" \ + --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" \ + --argjson opened "$(jq -r '.created_at | fromdateiso8601' "$RUNNER_TEMP/hook-update-pr.json")" ' [.[].workflow_runs[] | select( .event == "pull_request" and .head_repository.full_name == $repository and - .head_branch == $branch and .head_sha == $head and - any(.pull_requests[]; .number == $number) + .head_branch == $branch and .head_sha == $head and (.path | split("@")[0]) == $workflow and + (any(.pull_requests[]; .number == $number) or + (.pull_requests == [] and (.created_at | fromdateiso8601) >= $opened)) ) | .created_at | fromdateiso8601] as $observed | ($observed | length) > 0 and ($observed | min) <= now - ($days * 86400) ' "$RUNNER_TEMP/hook-update-runs.jsonl" >/dev/null; then @@ -80,7 +85,7 @@ runs: fi echo 'changed=true' >>"$GITHUB_OUTPUT" fi - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 if: steps.identity.outputs.changed == 'true' with: version: latest-known @@ -182,12 +187,17 @@ runs: cutoff=$(jq -nr --argjson days "$MINIMUM_DAYS" 'now - $days * 86400 | todateiso8601') gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \ -f branch="$branch" -f "created=<$cutoff" -f per_page=100 >"$RUNNER_TEMP/hook-branch-runs.jsonl" + workflow_path=${GITHUB_WORKFLOW_REF#"$GITHUB_REPOSITORY/"} + workflow_path=${workflow_path%@*} jq -sr --arg repository "$(jq -r .head.repo.full_name "$RUNNER_TEMP/hook-update-pr.json")" \ - --arg branch "$branch" \ - --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" ' + --arg branch "$branch" --arg workflow "$workflow_path" \ + --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" \ + --argjson opened "$(jq -r '.created_at | fromdateiso8601' "$RUNNER_TEMP/hook-update-pr.json")" ' [.[].workflow_runs[] | select( .event == "pull_request" and .head_repository.full_name == $repository and - .head_branch == $branch and any(.pull_requests[]; .number == $number) and + .head_branch == $branch and (.path | split("@")[0]) == $workflow and + (any(.pull_requests[]; .number == $number) or + (.pull_requests == [] and (.created_at | fromdateiso8601) >= $opened)) and (.created_at | fromdateiso8601) <= now - ($days * 86400) ) | .head_sha] | unique[] ' "$RUNNER_TEMP/hook-branch-runs.jsonl" >"$RUNNER_TEMP/older-heads" diff --git a/pylock.toml b/pylock.toml index f136c08..8bac29b 100644 --- a/pylock.toml +++ b/pylock.toml @@ -8,6 +8,7 @@ dependency-groups = [ "lock-tools", "lock-reader", "release-notes", + "release-resolver", "release-news", "release-prepare", "release-validate", @@ -4404,161 +4405,161 @@ sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" [[packages]] name = "uv" -version = "0.13.1" -marker = "\"test\" in dependency_groups" +version = "0.13.5" +marker = "\"release-resolver\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] -url = "https://files.pythonhosted.org/packages/ef/10/81f499de381ac81e09961e1c19255c5cf49563ac418464def4190b6c7400/uv-0.13.1.tar.gz" -upload-time = 2026-10-14 05:13:35+00:00 -size = 7434239 +url = "https://files.pythonhosted.org/packages/de/f0/7565e80415f841c3500ed69d86516c7676e3b24470a218ab61b1f43071a2/uv-0.13.5.tar.gz" +upload-time = 2026-10-22 01:32:33+00:00 +size = 7513309 [packages.sdist.hashes] -sha256 = "6df50a86b09be66a413f8f1b16258d585d4dd1cbff591077ae1e6f65c26de2eb" +sha256 = "979409b8c9a78df140ffb19c53f511b7a73526da8d1d5e36558833055e371c6c" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c8/2b/79ed9d3a78a43e83ef685bf1e1c84f6c63fe25a0e765b24a169727dd87f1/uv-0.13.1-py3-none-linux_armv6l.whl" -upload-time = 2026-10-14 05:12:50+00:00 -size = 18575444 +url = "https://files.pythonhosted.org/packages/24/ff/e30478710f53cf388c892e27e90662aa2acac7a7998bdb18023c595213c1/uv-0.13.5-py3-none-linux_armv6l.whl" +upload-time = 2026-10-22 01:31:37+00:00 +size = 18325046 [packages.wheels.hashes] -sha256 = "23dfbdcfd9ff0dc7fefb1b91a6dbca2d492267976becd7c46501f89fc353fdc7" +sha256 = "8d092d07d2389909027c2ed4965ade1bd46bbcecceb26686ff40927684f8c457" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/db/3d/6ac43d7a15be1d589aae1e15289507f5ccebeac14f28c37882058f2474a3/uv-0.13.1-py3-none-macosx_10_12_x86_64.whl" -upload-time = 2026-10-14 05:12:53+00:00 -size = 17663254 +url = "https://files.pythonhosted.org/packages/e3/2a/dcae794203261e49564bb9ff7c4b64a093f69ac73476cc8ce54c1deed3a1/uv-0.13.5-py3-none-macosx_10_12_x86_64.whl" +upload-time = 2026-10-22 01:31:40+00:00 +size = 17335724 [packages.wheels.hashes] -sha256 = "7e60223e3e9636a3734d6c11b3552aeed987c40f1ca2497a546af58f2f883d56" +sha256 = "337a14829bb499e114989df4f908f134ff37f1fb6c33497541cfb05b28080e39" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3b/31/fad7fd2ac81954d02d33765bf8535eb320fcc2d71f2603a5c1ba0b13b8ef/uv-0.13.1-py3-none-macosx_11_0_arm64.whl" -upload-time = 2026-10-14 05:12:56+00:00 -size = 14391237 +url = "https://files.pythonhosted.org/packages/d2/26/9ac45211123244ea68be852775093af487a64debc13621a2de239f8ebe73/uv-0.13.5-py3-none-macosx_11_0_arm64.whl" +upload-time = 2026-10-22 01:31:43+00:00 +size = 14470685 [packages.wheels.hashes] -sha256 = "cfa719fbbd23f26713394dea2692b7f146aa1b5713ce5e776e878701214ea52c" +sha256 = "a42c85fa8156e1e5e2cecdbc77bb818ea1dcf5f78b8e2a8f84c7fe4f16f0702e" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9f/6a/ff4c51a245345a167eed090e8cea27d5979e8b665c97617805bb8748966f/uv-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" -upload-time = 2026-10-14 05:12:58+00:00 -size = 18047204 +url = "https://files.pythonhosted.org/packages/e8/2e/5bf9acf9d11fb790420e258a875e13bba36bc890858350642aca185efe0e/uv-0.13.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" +upload-time = 2026-10-22 01:31:46+00:00 +size = 17784583 [packages.wheels.hashes] -sha256 = "f2518450da2f6d0ea8a943c9f402abda63d5a66acc9999216c2792115b922ba4" +sha256 = "2386c2dc9b59fc1d39b1c500e4319d71d3268232eb3b38e29cd6775d6269e8d4" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5f/04/268c9c17d9a808f41b71241f9d61030c4f262213815eeaa329bae1c76c3f/uv-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl" -upload-time = 2026-10-14 05:13:00+00:00 -size = 18122326 +url = "https://files.pythonhosted.org/packages/f3/70/b02fdcbae77346e3ec1310e7de9dde541252e709f6641db3fbd32cd4da79/uv-0.13.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl" +upload-time = 2026-10-22 01:31:50+00:00 +size = 17866408 [packages.wheels.hashes] -sha256 = "29b9d0dbe9c44d3dcea8af9e7b4ea2444c7a0c9ec0a11e2f2d357fb3afa2c05b" +sha256 = "4f62680fe58c5a83fd539e38f1956c2b577b1f5939c9020aa4c7f0addda2f4ad" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/44/88/33b72d5c4aed94a504fe92b7312f76c54e16a00b695045053f7a72953815/uv-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-10-14 05:13:03+00:00 -size = 18139075 +url = "https://files.pythonhosted.org/packages/fd/b9/6663095851e0943e05e47318d7600e567bc172a1e7a26b21b8e6608e8491/uv-0.13.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" +upload-time = 2026-10-22 01:31:53+00:00 +size = 17753574 [packages.wheels.hashes] -sha256 = "7161cfb854f7353fd351e5e735e3659ed48412352b1edd4d93212c82ec0cbdf0" +sha256 = "0e03f5717bdaaf5ab4e2df361d2395e36b515946f56ef4bed88801aeb9959fb0" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/54/59/866ca1f07f899f007fcc006663d341157c5dbdae7271e5004c5a120022ee/uv-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl" -upload-time = 2026-10-14 05:13:05+00:00 -size = 18840481 +url = "https://files.pythonhosted.org/packages/e5/84/1bb89a94486b813bbeaa1b8b491e69a25fa864626711944127b186f903de/uv-0.13.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl" +upload-time = 2026-10-22 01:31:56+00:00 +size = 18502087 [packages.wheels.hashes] -sha256 = "d6aaec25ff6900ee30a29a2e7a3335aff19a2305c60488bb4e0f29cf1d6ada9f" +sha256 = "f867c5ec24c563afaae730a13e46cdf74a1a0b64c134286ff99762a96e2abf40" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/dd/a9/928d7435a00d1171f4557212704d71d709cf4b6024f2ee6b0f582251c761/uv-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-14 05:13:07+00:00 -size = 20106013 +url = "https://files.pythonhosted.org/packages/a4/a1/b0b0c7247f3bb0f307eae8a28679ecd0d4b0e2757fcf6c87dea6f679a099/uv-0.13.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" +upload-time = 2026-10-22 01:31:59+00:00 +size = 19927049 [packages.wheels.hashes] -sha256 = "81345a4733737a378db988aea48fd5d1c76c092cf231e6ddfdb6db225b4b21e5" +sha256 = "d3fd5db55c20feb672ea457361aba29ab68b9ae69a14d98ad6c138b5f86dcca4" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3c/70/ac84eee888c4a11e12c361c10fdf3f726ef6f8f1178302c80a9dee5189a3/uv-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl" -upload-time = 2026-10-14 05:13:10+00:00 -size = 19840034 +url = "https://files.pythonhosted.org/packages/b4/44/6bf11de78ce18869849a14178534b900dbe4e310270b2f60df6503c03137/uv-0.13.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl" +upload-time = 2026-10-22 01:32:03+00:00 +size = 19662743 [packages.wheels.hashes] -sha256 = "0f9991b8eedca00b6aeb9a8fc6b225f6afa2e24ec9de6b6ef72845f3a545639c" +sha256 = "e01481ae5212e45ee7c85fad5fc67f06b0ee0329a7c3c289dc529da884b03881" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/14/0b/6602a613533602e7a083e908c4375f9b0e78a94ba2e21c29b21c41a0a611/uv-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2026-10-14 05:13:13+00:00 -size = 17279385 +url = "https://files.pythonhosted.org/packages/3a/ad/3c2a81d66d1839063662e5b6c0002ec31f8ee987dbc5295747627ca6160a/uv-0.13.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-22 01:32:06+00:00 +size = 17211825 [packages.wheels.hashes] -sha256 = "696dcf6d807d85da978f4db8472da81dccc1254b18b52cbd1367c4d923c421e0" +sha256 = "fa8d26e08e34c29c0c4e820cf3181757086d547240b4f4d31ff1d8bd810ca946" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/09/f6/52e2136653a1f3a4dc489da83444f062ed588570cc3e5242fda50996951a/uv-0.13.1-py3-none-manylinux_2_28_aarch64.whl" -upload-time = 2026-10-14 05:13:15+00:00 -size = 16577060 +url = "https://files.pythonhosted.org/packages/4c/92/d83d019c63c1ccc6ff17df2bfe76270412f9766e7e71aa4d4ccbf018683f/uv-0.13.5-py3-none-manylinux_2_28_aarch64.whl" +upload-time = 2026-10-22 01:32:10+00:00 +size = 16654136 [packages.wheels.hashes] -sha256 = "ccbe233d4bce39865af7c30ec1fb28b0cc353f9d51711629f54fca71b8db7382" +sha256 = "91e58e304b63b6c91bac5561459ab0c3ea24f17bc01ea9359d69fed72142978f" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/df/7c/a2f4ff386673a4bed33be69fdb857341eaa6b9f31eed3005ffc15df94d7f/uv-0.13.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl" -upload-time = 2026-10-14 05:13:18+00:00 -size = 18417071 +url = "https://files.pythonhosted.org/packages/b8/5a/d42f9d93b322ad36647eb3bf516cc528847c240f630f6df213b6221c5262/uv-0.13.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl" +upload-time = 2026-10-22 01:32:13+00:00 +size = 17921105 [packages.wheels.hashes] -sha256 = "ec1bdc90e2cccc32f51dcea2fa4ec9082b321c77f3fe51a0407d40e8a44f540f" +sha256 = "309a74d3c488bfaa3ba7bff759da73193d8781729c0a01274f4940fd32bb5712" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4a/6e/a2d375f8fcb453e75caf6d1503f7e149e76a3403091a2a77ada4d42482c1/uv-0.13.1-py3-none-manylinux_2_31_riscv64.whl" -upload-time = 2026-10-14 05:13:20+00:00 -size = 18329037 +url = "https://files.pythonhosted.org/packages/97/79/c5c2c39c78cf7a1dd6e041b3f7aa2cc4258d351d42dea06f6fee0bde5daa/uv-0.13.5-py3-none-manylinux_2_31_riscv64.whl" +upload-time = 2026-10-22 01:32:16+00:00 +size = 18103266 [packages.wheels.hashes] -sha256 = "b5e1b930cbb463c0d8f6b925743ddbae31277841f16c324d14e1a815b0c3b824" +sha256 = "469cca66de1b17c75b9b9de806a64cffb57cba5e42b1e30d0bba6fa72a146f58" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0c/51/070341c84c141e73933f6087dcc9a5fd276cb034bdc0316f2d2dffd0cd44/uv-0.13.1-py3-none-musllinux_1_1_i686.whl" -upload-time = 2026-10-14 05:13:23+00:00 -size = 18130710 +url = "https://files.pythonhosted.org/packages/01/2f/e5c70a05013077e5ecadb804418ddbab33f7279f96d052f79f565369b2a7/uv-0.13.5-py3-none-musllinux_1_1_i686.whl" +upload-time = 2026-10-22 01:32:19+00:00 +size = 17842061 [packages.wheels.hashes] -sha256 = "4af921c96f5f85c456c7fa93a9bc13514a2a5bbab11ce63dbf490d6e5bfef503" +sha256 = "2386e8dbfe110ff2f8ec8bd344dd18bf39eb9eb54e1edeeb87c3b85c34229cd3" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b9/0d/529a302582685e77e5e2d16de554e7c230b45c6175b0b0f234af9795d981/uv-0.13.1-py3-none-musllinux_1_1_x86_64.whl" -upload-time = 2026-10-14 05:13:25+00:00 -size = 19307551 +url = "https://files.pythonhosted.org/packages/4f/b0/7dba2cb636d7f58e0d74e948ad1afc6b7923e84e82de025e9951a68055db/uv-0.13.5-py3-none-musllinux_1_1_x86_64.whl" +upload-time = 2026-10-22 01:32:22+00:00 +size = 18963145 [packages.wheels.hashes] -sha256 = "6f1f391756385e2cd1521fe4a198e0ca764ef498dd677cb214b95a69a7ce3608" +sha256 = "d162669e3a7811c584b03690eaa11e796ad1d309aa8229d5dfe3cbcd68a90400" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7f/7f/c81ec77c0eb772ec4fc515e733625969c64df55ee2f7b6b3fb1e07c2ee26/uv-0.13.1-py3-none-win32.whl" -upload-time = 2026-10-14 05:13:28+00:00 -size = 16434446 +url = "https://files.pythonhosted.org/packages/00/7c/e38b312abaae1bd59ff5886126f48e9e223fbb9a8edf9e236ede976f67cb/uv-0.13.5-py3-none-win32.whl" +upload-time = 2026-10-22 01:32:25+00:00 +size = 16240919 [packages.wheels.hashes] -sha256 = "1f6ae6f843ee01a1bb69e2e7a881a452890eb9acc686a8c37e8e95093a58f68e" +sha256 = "f4e55a6e2a1089fe74ed50fbdf2df0f6f336ee148ad232b05932db5deee8007e" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2a/8e/d23278bb327d2a75dc1d033c81b7fe23bbb28e363e02e7f306abd047924a/uv-0.13.1-py3-none-win_amd64.whl" -upload-time = 2026-10-14 05:13:30+00:00 -size = 15404994 +url = "https://files.pythonhosted.org/packages/32/ea/771744a784b16aa21d195cb5d5d5a2878d72fa00fc26f11ea3f9646994fe/uv-0.13.5-py3-none-win_amd64.whl" +upload-time = 2026-10-22 01:32:28+00:00 +size = 15358650 [packages.wheels.hashes] -sha256 = "4f693cd4feb52f08f30474c4612f24be2050472609be65b9960b635f286da284" +sha256 = "1fb29fc46ba12033e2ef95a95ac79d649e71bbb1358419bbbcbd19d86026ef2f" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0a/e0/f27f161ee2ad242cb714fe7185f63cc2a88b83c4d8b375ca8c580f9f23b7/uv-0.13.1-py3-none-win_arm64.whl" -upload-time = 2026-10-14 05:13:32+00:00 -size = 15899284 +url = "https://files.pythonhosted.org/packages/52/67/1f31243f75ece410f2db1afdfc9a9f9722fdee2385e4a347c3ca1dee6d13/uv-0.13.5-py3-none-win_arm64.whl" +upload-time = 2026-10-22 01:32:31+00:00 +size = 15533668 [packages.wheels.hashes] -sha256 = "b173413a5dc18437b2d28266a02be636f35ac7e97741773aed163ed2143abfaf" +sha256 = "0548460d46c8667488b1f089e936f29f0edfb1cc753f9152ffbd8bc84a46aa20" [[packages]] name = "virtualenv" @@ -4845,7 +4846,7 @@ size = 30255 sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" [tool.release-creator] -created-at = 2026-10-24 09:10:11.953943+00:00 +created-at = 2026-10-24 09:55:17.406208+00:00 [tool.nab] -created-at = 2026-10-24 09:10:11.953943+00:00 +created-at = 2026-10-24 09:55:17.406208+00:00 diff --git a/pyproject.toml b/pyproject.toml index 950aad5..f2e5b39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ build = [ "Cython>=3.0", "setuptools>=80" ] lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] release-notes = [ "release-creator>=1.1.0" ] +release-resolver = [ "uv==0.13.5" ] release-news = [ "release-creator>=1.1.0", "towncrier>=26.9" ] release-prepare = [ "release-creator>=1.1.0", "towncrier>=26.9", "prek>=0.2" ] release-validate = [ "release-creator>=1.1.0", "prek>=0.2" ] @@ -70,6 +71,9 @@ uploaded-prior-to = "2026-10-18T05:45:09Z" [tool.nab.packages.release-creator] uploaded-prior-to = "2026-10-24T09:08:40Z" +[tool.nab.packages.uv] +uploaded-prior-to = "2026-10-22T01:32:34Z" + [tool.release-creator.locks.exclude-newer-package] nab = "2026-10-18T05:45:09Z" nab-index = "2026-10-18T05:45:09Z" @@ -78,6 +82,7 @@ nab-project = "2026-10-18T05:45:09Z" nab-provider = "2026-10-18T05:45:09Z" nab-resolver = "2026-10-18T05:45:09Z" release-creator = "2026-10-24T09:08:40Z" +uv = "2026-10-22T01:32:34Z" [tool.ruff] line-length = 120 diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml index f60f3c2..d33c8e8 100644 --- a/recheck-hook-update-cooldown/action.yml +++ b/recheck-hook-update-cooldown/action.yml @@ -19,7 +19,7 @@ runs: gh api --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" | jq -c --arg repository "$GITHUB_REPOSITORY" ' .[] | select(.head.repo.full_name != null and .base.repo.full_name == $repository) | - {number, head: .head.sha, branch: .head.ref, headRepository: .head.repo.full_name} + {number, head: .head.sha, branch: .head.ref, headRepository: .head.repo.full_name, opened: .created_at} ' >"$RUNNER_TEMP/hook-pulls.jsonl" - name: Rerun the failed hook checks shell: bash @@ -40,14 +40,16 @@ runs: head=$(jq -r .head <<<"$pull") branch=$(jq -r .branch <<<"$pull") head_repository=$(jq -r .headRepository <<<"$pull") + opened=$(jq -r '.opened | fromdateiso8601' <<<"$pull") runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW/runs" \ -f event=pull_request -f head_sha="$head" -f per_page=100 | jq -sc --arg repository "$head_repository" --arg branch "$branch" --arg head "$head" \ - --argjson number "$number" ' + --argjson number "$number" --argjson opened "$opened" ' [.[].workflow_runs[] | select( .event == "pull_request" and .head_repository.full_name == $repository and .head_branch == $branch and .head_sha == $head and - any(.pull_requests[]; .number == $number) + (any(.pull_requests[]; .number == $number) or + (.pull_requests == [] and (.created_at | fromdateiso8601) >= $opened)) )]') if ! jq -e \ '[.[].created_at | fromdateiso8601] | min <= now - 7 * 86400' \ diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 352499d..f959a2b 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -25,6 +25,12 @@ inputs: mode: description: Refresh the lock or check that the committed lock is current default: refresh + keep-uv-lock: + description: Refresh and check uv.lock alongside pylock.toml for projects that use both + default: "false" + upgrade-resolver: + description: Upgrade the shared uv pin before refreshing this repository's lock + default: "false" outputs: changed: description: Whether the lock or expired package cutoffs differ from the checkout @@ -42,6 +48,7 @@ runs: MATRIX_PYTHON: ${{ inputs.additional-matrix-python }} MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }} MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }} + KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | set -euo pipefail if [[ -z "$ADDITIONAL_LOCK" && @@ -51,7 +58,16 @@ runs: exit 1 fi provider=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml --provider) + if [[ "$KEEP_UV_LOCK" == true && "$provider" != release-creator ]]; then + echo '::error::keep-uv-lock requires a release-creator lock' >&2 + exit 1 + fi + if [[ "$KEEP_UV_LOCK" == true ]] && ! git ls-files --error-unmatch uv.lock >/dev/null 2>&1; then + echo '::error::keep-uv-lock requires a committed uv.lock' >&2 + exit 1 + fi echo "name=$provider" >>"$GITHUB_OUTPUT" + echo "groups=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml)" >>"$GITHUB_OUTPUT" - uses: $/install-locked-tools with: groups: lock-tools @@ -63,6 +79,12 @@ runs: groups: release-notes lock-source: shared venv: ${{ runner.temp }}/project-lock-tools + - uses: $/install-locked-tools + if: steps.producer.outputs.name == 'release-creator' + with: + groups: release-resolver + lock-source: shared + venv: ${{ runner.temp }}/release-resolver - name: Check recent package cutoffs shell: bash run: python3 "$GITHUB_ACTION_PATH/../tools/check_recent_cutoffs.py" pyproject.toml @@ -71,11 +93,22 @@ runs: shell: bash run: >- uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/expire_cutoffs.py" pyproject.toml + - name: Widen the shared uv pin + if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true' + shell: bash + run: | + set -euo pipefail + [[ "$GITHUB_REPOSITORY" == tox-dev/github-workflows ]] || { + echo '::error::only github-workflows can upgrade the shared resolver' >&2 + exit 1 + } + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" widen pyproject.toml pylock.toml - name: Resolve selected Nab groups if: steps.producer.outputs.name == 'nab' shell: bash env: - LOCK_GROUPS: ${{ inputs.lock-groups }} + LOCK_GROUPS: ${{ inputs.lock-groups || steps.producer.outputs.groups }} MODE: ${{ inputs.mode }} run: | set -euo pipefail @@ -110,16 +143,42 @@ runs: shell: bash env: MODE: ${{ inputs.mode }} + KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | set -euo pipefail + export PATH="$RUNNER_TEMP/release-resolver/bin:$PATH" + options=() + if [[ "$KEEP_UV_LOCK" == true ]]; then options+=(--keep-uv-lock); fi case "$MODE" in - check) uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks --check ;; - refresh) uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks ;; + check) uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks --check "${options[@]}" ;; + refresh) uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks "${options[@]}" ;; *) echo "::error::invalid lock mode: $MODE" >&2 exit 1 ;; esac + - name: Pin the updated uv resolver + if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true' + shell: bash + run: >- + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" pin pyproject.toml pylock.toml + - uses: $/install-locked-tools + if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true' + with: + groups: release-resolver + venv: ${{ runner.temp }}/updated-release-resolver + - name: Resolve with the updated uv resolver + if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true' + shell: bash + env: + KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} + run: | + set -euo pipefail + export PATH="$RUNNER_TEMP/updated-release-resolver/bin:$PATH" + options=() + if [[ "$KEEP_UV_LOCK" == true ]]; then options+=(--keep-uv-lock); fi + uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks "${options[@]}" + uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks --check "${options[@]}" - name: Resolve optional Nab matrix if: inputs.additional-lock != '' shell: bash @@ -200,10 +259,12 @@ runs: env: MODE: ${{ inputs.mode }} ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | set -euo pipefail paths=(pylock.toml pyproject.toml) if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi + if [[ "$KEEP_UV_LOCK" == true ]]; then paths+=(uv.lock); fi if [[ "$MODE" == check ]]; then git diff --exit-code -- "${paths[@]}" echo 'changed=false' >>"$GITHUB_OUTPUT" diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index fb707b9..feeceba 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -159,6 +159,144 @@ def test_cooldown_checks_fork_head_configuration( ] +@pytest.mark.parametrize( + ("head_repository", "observed_repository", "opened_days", "observed_days", "workflow_path", "accepted"), + [ + pytest.param( + "gaborbernat/fixture", None, 20, 10, ".github/workflows/check.yaml", True, id="fork-observed-after-open" + ), + pytest.param( + "gaborbernat/fixture", None, 20, 0, ".github/workflows/check.yaml", False, id="fork-observed-too-recently" + ), + pytest.param( + "gaborbernat/fixture", None, 5, 10, ".github/workflows/check.yaml", False, id="old-run-before-new-pr" + ), + pytest.param( + "tox-dev/fixture", None, 20, 10, ".github/workflows/check.yaml", True, id="same-repository-unlinked-run" + ), + pytest.param( + "gaborbernat/fixture", + "another/fixture", + 20, + 10, + ".github/workflows/check.yaml", + False, + id="same-branch-other-fork", + ), + pytest.param( + "gaborbernat/fixture", None, 20, 10, ".github/workflows/news.yaml", False, id="unrelated-workflow" + ), + ], +) +def test_cooldown_checks_unlinked_run_identity( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + head_repository: str, + observed_repository: str | None, + opened_days: int, + observed_days: int, + workflow_path: str, + accepted: bool, +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull( + server, + author="gaborbernat", + head_repository=head_repository, + opened_days=opened_days, + observed_days=observed_days, + associated_pull_request=None, + observed_repository=observed_repository, + workflow_path=workflow_path, + ) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1", source_repository=head_repository) + if not accepted: + expect_historical_runs(server, older_head=None) + + result = run_cooldown(action_scripts, tmp_path, environment) + + assert (result.returncode == 0) is accepted, result.stderr + if not accepted: + assert "hook revision has not been observed" in result.stderr + + +@pytest.mark.parametrize( + ("head_repository", "opened_days", "observed_repository", "workflow_path", "accepted"), + [ + pytest.param( + "gaborbernat/fixture", 20, "gaborbernat/fixture", ".github/workflows/check.yaml", True, id="fork-history" + ), + pytest.param( + "tox-dev/fixture", 20, "tox-dev/fixture", ".github/workflows/check.yaml", True, id="same-repository-history" + ), + pytest.param( + "gaborbernat/fixture", + 5, + "gaborbernat/fixture", + ".github/workflows/check.yaml", + False, + id="earlier-pr-same-branch", + ), + pytest.param( + "gaborbernat/fixture", + 20, + "another/fixture", + ".github/workflows/check.yaml", + False, + id="same-branch-other-fork", + ), + pytest.param( + "gaborbernat/fixture", + 20, + "gaborbernat/fixture", + ".github/workflows/news.yaml", + False, + id="unrelated-workflow", + ), + ], +) +def test_cooldown_checks_unlinked_history( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + head_repository: str, + opened_days: int, + observed_repository: str, + workflow_path: str, + accepted: bool, +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull( + server, + author="gaborbernat", + head_repository=head_repository, + opened_days=opened_days, + observed_days=0, + associated_pull_request=None, + ) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1", source_repository=head_repository) + expect_historical_runs( + server, + older_head="c" * 40, + head_repository=observed_repository, + pull_request=None, + workflow_path=workflow_path, + ) + if accepted: + expect_config(server, "c" * 40, repository, "v1", source_repository=head_repository) + + result = run_cooldown(action_scripts, tmp_path, environment) + + assert (result.returncode == 0) is accepted, result.stderr + if not accepted: + assert "hook revision has not been observed" in result.stderr + + @pytest.mark.parametrize( ("minimum_days", "accepted"), [pytest.param("7", True, id="default"), pytest.param("1000", False, id="configured-cooldown")], @@ -584,6 +722,7 @@ def run_cooldown( GITHUB_EVENT_NAME=event_name, GITHUB_EVENT_PATH=str(path), GITHUB_OUTPUT=str(tmp_path / "outputs"), + GITHUB_WORKFLOW_REF="tox-dev/fixture/.github/workflows/check.yaml@refs/pull/7/merge", **environment, ) @@ -599,6 +738,10 @@ def expect_pull( branch: str = "bot-update", app_client_id: str = "Iv1.release", head_repository: str = "tox-dev/fixture", + opened_days: int = 20, + associated_pull_request: int | None = 7, + observed_repository: str | None = None, + workflow_path: str = ".github/workflows/check.yaml", ) -> None: server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json( { @@ -606,6 +749,7 @@ def expect_pull( "base": {"repo": {"full_name": "tox-dev/fixture"}, "sha": "b" * 40}, "head": {"sha": head, "ref": branch, "repo": {"full_name": head_repository}}, "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"}, + "created_at": (datetime.now(UTC) - timedelta(days=opened_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), "updated_at": (datetime.now(UTC) - timedelta(days=updated_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), } ) @@ -628,10 +772,13 @@ def expect_pull( else [ { "event": "pull_request", - "head_repository": {"full_name": head_repository}, + "head_repository": {"full_name": observed_repository or head_repository}, "head_branch": branch, "head_sha": head, - "pull_requests": [{"number": 7}], + "pull_requests": [] + if associated_pull_request is None + else [{"number": associated_pull_request}], + "path": workflow_path, "created_at": (datetime.now(UTC) - timedelta(days=observed_days)).strftime( "%Y-%m-%dT%H:%M:%SZ" ), @@ -648,6 +795,7 @@ def expect_config( revision: str | tuple[str, ...], *, hook: str = " - id: sample\n", + source_repository: str = "tox-dev/fixture", ) -> None: revisions = (revision,) if isinstance(revision, str) else revision frozen = tuple( @@ -662,7 +810,9 @@ def expect_config( for item in revisions ) server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", method="GET", query_string=f"ref={sha}" + f"/api/v3/repos/{source_repository}/contents/.pre-commit-config.yaml", + method="GET", + query_string=f"ref={sha}", ).respond_with_json( { "content": base64.b64encode( @@ -678,7 +828,12 @@ def expect_config( def expect_historical_runs( - server: HTTPServer, *, older_head: str | tuple[str, ...] | None, pull_request: int = 7 + server: HTTPServer, + *, + older_head: str | tuple[str, ...] | None, + pull_request: int | None = 7, + head_repository: str = "tox-dev/fixture", + workflow_path: str = ".github/workflows/check.yaml", ) -> None: heads = () if older_head is None else (older_head,) if isinstance(older_head, str) else older_head server.expect_request( @@ -689,10 +844,11 @@ def expect_historical_runs( "workflow_runs": [ { "event": "pull_request", - "head_repository": {"full_name": "tox-dev/fixture"}, + "head_repository": {"full_name": head_repository}, "head_branch": "bot-update", "head_sha": head, - "pull_requests": [{"number": pull_request}], + "pull_requests": [] if pull_request is None else [{"number": pull_request}], + "path": workflow_path, "created_at": (datetime.now(UTC) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), } for head in heads diff --git a/tests/check_hook_update_cooldown/test_tagged_age.py b/tests/check_hook_update_cooldown/test_tagged_age.py new file mode 100644 index 0000000..7279550 --- /dev/null +++ b/tests/check_hook_update_cooldown/test_tagged_age.py @@ -0,0 +1,529 @@ +from __future__ import annotations + +import base64 +import os +import subprocess +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import coverage +import pytest +import yaml +from pytest_httpserver import HTTPServer + +from tools.check_tagged_hook_age import check_tagged_hooks + +TOOL = Path(__file__).resolve().parents[2] / "tools/check_tagged_hook_age.py" +CONFIG = ".pre-commit-config.yaml" +OLD = "a" * 40 +NEW = "b" * 40 + + +def _config(revision: str) -> str: + return f"repos:\n - repo: https://github.com/example/hook\n rev: {revision}\n hooks: []\n" + + +def _git(repository: Path, *arguments: str) -> str: + return subprocess.run( + ["git", *arguments], cwd=repository, text=True, capture_output=True, check=True + ).stdout.strip() + + +def _repository(tmp_path: Path, *, reintroduce: bool = False) -> tuple[Path, str]: + repository = tmp_path / "source" + repository.mkdir() + _git(repository, "init", "-q") + _git(repository, "config", "user.name", "Test") + _git(repository, "config", "user.email", "test@example.com") + (repository / CONFIG).write_text(_config(OLD)) + _git(repository, "add", CONFIG) + _git(repository, "commit", "-qm", "Initial hooks") + _git(repository, "tag", "v1.0.0") + (repository / CONFIG).write_text(_config(NEW)) + _git(repository, "commit", "-qam", "Change hooks") + introduction = _git(repository, "rev-parse", "HEAD") + if reintroduce: + (repository / CONFIG).write_text(_config(OLD)) + _git(repository, "commit", "-qam", "Remove new hook") + (repository / CONFIG).write_text(_config(NEW)) + _git(repository, "commit", "-qam", "Restore new hook directly") + _git(repository, "tag", "v1.1.0") + return repository, introduction + + +def _timestamp(days_ago: int) -> str: + return (datetime.now(UTC) - timedelta(days=days_ago)).isoformat().replace("+00:00", "Z") + + +def _expect_release(server: HTTPServer, tag: str = "v1.0.0") -> None: + server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_json( + [{"tag_name": tag, "draft": False, "published_at": _timestamp(30)}] + ) + + +def _expect_pull(server: HTTPServer, introduction: str, *, merged_days: int, revision: str = NEW) -> None: + server.expect_request( + f"/api/v3/repos/tox-dev/fixture/commits/{introduction}/pulls", query_string="per_page=100" + ).respond_with_json( + [ + { + "number": 7, + "merge_commit_sha": introduction, + "merged_at": _timestamp(merged_days), + "created_at": _timestamp(20), + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + "head": {"repo": {"full_name": "gaborbernat/fixture"}, "ref": "feature", "sha": "c" * 40}, + } + ] + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", query_string="per_page=100").respond_with_json( + [{"filename": CONFIG}] + ) + if merged_days < 7: + server.expect_request( + f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'c' * 40}" + ).respond_with_json({"content": base64.b64encode(_config(revision).encode()).decode()}) + + +def _run(repository: Path, environment: dict[str, str], tag: str = "v1.1.0") -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(TOOL.parents[1] / ".release-tools/bin/python"), str(TOOL), "tox-dev/fixture", tag], + cwd=repository, + env=os.environ + | environment + | { + "PYTHONPATH": str(TOOL.parents[1] / "tests/coverage_bootstrap"), + "GW_COVERAGE_SITE_PACKAGES": str(Path(coverage.__file__).parent.parent), + }, + text=True, + capture_output=True, + check=False, + ) + + +@pytest.mark.parametrize( + ("merged_days", "head_revision", "accepted"), + [ + pytest.param(10, NEW, True, id="old-merged-pr-survives-deleted-fork"), + pytest.param(0, OLD, False, id="recent-pr-head-lacks-pair"), + ], +) +def test_tagged_hook_age_binds_introducing_pull_request( + tmp_path: Path, + github_api: tuple[HTTPServer, dict[str, str]], + merged_days: int, + head_revision: str, + accepted: bool, +) -> None: + server, environment = github_api + repository, introduction = _repository(tmp_path) + _expect_release(server) + _expect_pull(server, introduction, merged_days=merged_days, revision=head_revision) + + result = _run(repository, environment) + + assert (result.returncode == 0) is accepted, result.stderr + server.check_assertions() + + +def test_tagged_hook_age_rejects_direct_reintroduction( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository, introduction = _repository(tmp_path, reintroduce=True) + _expect_release(server) + server.expect_request( + f"/api/v3/repos/tox-dev/fixture/commits/{_git(repository, 'rev-parse', 'HEAD')}/pulls", + query_string="per_page=100", + ).respond_with_json([]) + + result = _run(repository, environment) + + assert result.returncode != 0 + assert "seven-day GitHub observation" in result.stderr + assert all(introduction not in request.path for request, _ in server.log) + + +@pytest.mark.parametrize( + ("observed_days", "observed_revision", "accepted"), + [ + pytest.param(10, NEW, True, id="old-run-with-pair"), + pytest.param(0, NEW, False, id="recent-run-with-pair"), + pytest.param(10, OLD, False, id="old-run-before-pair"), + ], +) +def test_tagged_hook_age_checks_observed_run_content( + tmp_path: Path, + github_api: tuple[HTTPServer, dict[str, str]], + observed_days: int, + observed_revision: str, + accepted: bool, +) -> None: + server, environment = github_api + repository, introduction = _repository(tmp_path) + _expect_release(server) + _expect_pull(server, introduction, merged_days=0) + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs").respond_with_json( + { + "workflow_runs": [ + { + "event": "pull_request", + "head_repository": {"full_name": "gaborbernat/fixture"}, + "head_branch": "feature", + "head_sha": "d" * 40, + "created_at": _timestamp(observed_days), + "pull_requests": [], + } + ] + } + ) + if observed_days >= 7: + server.expect_request( + f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'d' * 40}" + ).respond_with_json({"content": base64.b64encode(_config(observed_revision).encode()).decode()}) + + result = _run(repository, environment) + + assert (result.returncode == 0) is accepted, result.stderr + server.check_assertions() + + +def test_tagged_hook_age_tries_another_observed_head_after_404( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository, introduction = _repository(tmp_path) + _expect_release(server) + _expect_pull(server, introduction, merged_days=0) + runs = [ + { + "event": "pull_request", + "head_repository": {"full_name": "gaborbernat/fixture"}, + "head_branch": "feature", + "head_sha": revision * 40, + "created_at": _timestamp(10), + "pull_requests": [], + } + for revision in ("e", "d") + ] + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs").respond_with_json({"workflow_runs": runs}) + server.expect_request( + f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'e' * 40}" + ).respond_with_data("Not Found", status=404) + server.expect_request( + f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'d' * 40}" + ).respond_with_json({"content": base64.b64encode(_config(NEW).encode()).decode()}) + + result = _run(repository, environment) + + assert result.returncode == 0, result.stderr + server.check_assertions() + + +def test_tagged_hook_age_rejects_shallow_checkout(tmp_path: Path) -> None: + repository, _ = _repository(tmp_path) + shallow = tmp_path / "shallow" + subprocess.run( + ["git", "clone", "--quiet", "--depth", "1", repository.as_uri(), str(shallow)], + check=True, + capture_output=True, + ) + + result = _run(shallow, {}) + + assert result.returncode != 0 + assert "complete git history and tags" in result.stderr + + +@pytest.mark.parametrize( + ("previous_tag", "foreign_tag", "current_tag"), + [ + pytest.param("tox-toml-fmt/1.0.0", "pyproject-fmt/9.0.0", "tox-toml-fmt/1.1.0", id="package-stream"), + pytest.param("v1.0.0", "v1.1.0", "v1.2.0", id="side-branch-release"), + ], +) +def test_tagged_hook_age_ignores_other_release_lineage( + tmp_path: Path, + github_api: tuple[HTTPServer, dict[str, str]], + previous_tag: str, + foreign_tag: str, + current_tag: str, +) -> None: + server, environment = github_api + repository, introduction = _repository(tmp_path) + _git(repository, "tag", "-d", "v1.0.0", "v1.1.0") + _git(repository, "tag", previous_tag, "HEAD^") + if foreign_tag.startswith("v"): + main_branch = _git(repository, "branch", "--show-current") + _git(repository, "branch", "side", "HEAD^") + _git(repository, "checkout", "-q", "side") + (repository / CONFIG).write_text(_config(NEW)) + _git(repository, "commit", "-qam", "Add hook on side branch") + _git(repository, "tag", foreign_tag) + _git(repository, "checkout", "-q", main_branch) + else: + _git(repository, "tag", foreign_tag) + _git(repository, "tag", current_tag) + server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_json( + [ + {"tag_name": foreign_tag, "draft": False, "published_at": _timestamp(20)}, + {"tag_name": previous_tag, "draft": False, "published_at": _timestamp(30)}, + ] + ) + server.expect_request( + f"/api/v3/repos/tox-dev/fixture/commits/{introduction}/pulls", query_string="per_page=100" + ).respond_with_json([]) + + result = _run(repository, environment, current_tag) + + assert result.returncode != 0 + assert "seven-day GitHub observation" in result.stderr + server.check_assertions() + + +def test_tagged_hook_age_ignores_public_tag_on_merged_side_branch( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository, side_commit = _repository(tmp_path) + main_branch = _git(repository, "branch", "--show-current") + _git(repository, "tag", "-d", "v1.1.0") + _git(repository, "branch", "side") + _git(repository, "reset", "--hard", "HEAD^") + _git(repository, "checkout", "-q", "side") + _git(repository, "tag", "v1.1.0") + _git(repository, "checkout", "-q", main_branch) + _git(repository, "merge", "--no-ff", "-m", "Merge side hooks", "side") + merge = _git(repository, "rev-parse", "HEAD") + _git(repository, "tag", "v1.2.0") + server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_json( + [ + {"tag_name": "v1.1.0", "draft": False, "published_at": _timestamp(20)}, + {"tag_name": "v1.0.0", "draft": False, "published_at": _timestamp(30)}, + ] + ) + server.expect_request( + f"/api/v3/repos/tox-dev/fixture/commits/{merge}/pulls", query_string="per_page=100" + ).respond_with_json([]) + + result = _run(repository, environment, "v1.2.0") + + assert result.returncode != 0 + assert "seven-day GitHub observation" in result.stderr + assert all(side_commit not in request.path for request, _ in server.log) + + +@pytest.mark.parametrize( + ("tag", "accepted"), + [pytest.param("v1.1.0", True, id="dispatched-tag"), pytest.param("v1.0.0", False, id="wrong-checkout")], +) +def test_dispatched_release_requires_checkout_at_tag(tmp_path: Path, tag: str, accepted: bool) -> None: + repository, _ = _repository(tmp_path) + action = yaml.safe_load((TOOL.parents[1] / "verify-frozen-hooks/action.yml").read_text()) + step = next(item for item in action["runs"]["steps"] if item.get("name") == "Check tagged hook revision provenance") + assert step["if"] == "inputs.tag != '' || startsWith(github.ref, 'refs/tags/')" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_uv = fake_bin / "uv" + fake_uv.write_text("#!/bin/sh\nexit 0\n") + fake_uv.chmod(0o755) + + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", step["run"]], + cwd=repository, + env=os.environ + | { + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "TAG": tag, + "RUNNER_TEMP": str(tmp_path), + "GITHUB_ACTION_PATH": str(TOOL.parent), + "GITHUB_REPOSITORY": "tox-dev/fixture", + }, + text=True, + capture_output=True, + check=False, + ) + + assert (result.returncode == 0) is accepted, result.stderr + + +@pytest.mark.parametrize( + ("repository", "tag", "config"), + [ + pytest.param("", "v1.0.0", CONFIG, id="missing-repository"), + pytest.param("tox-dev/fixture", "", CONFIG, id="missing-tag"), + pytest.param("tox-dev/fixture", "v1.0.0", "/tmp/hooks.yaml", id="absolute-config"), + pytest.param("tox-dev/fixture", "v1.0.0", "../hooks.yaml", id="traversing-config"), + ], +) +def test_tagged_hook_age_rejects_invalid_arguments(repository: str, tag: str, config: str) -> None: + with pytest.raises(ValueError, match="relative config path"): + check_tagged_hooks(repository, tag, config) + + +@pytest.mark.parametrize( + ("content", "error"), + [ + pytest.param("[]\n", "repository list", id="non-mapping"), + pytest.param("repos: null\n", "repository list", id="missing-list"), + pytest.param("repos:\n - 1\n", "needs a URL", id="non-mapping-repository"), + pytest.param("repos:\n - repo: 1\n", "needs a URL", id="invalid-url"), + pytest.param("repos:\n - repo: https://github.com/example/hook\n", "needs a revision", id="missing-rev"), + ], +) +def test_tagged_hook_age_rejects_malformed_configuration( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], content: str, error: str +) -> None: + server, environment = github_api + repository, _ = _repository(tmp_path) + _expect_release(server) + (repository / CONFIG).write_text(content) + + result = _run(repository, environment) + + assert result.returncode != 0 + assert error in result.stderr + + +def test_tagged_hook_age_accepts_unchanged_revision( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository, _ = _repository(tmp_path) + _git(repository, "tag", "-d", "v1.1.0") + _git(repository, "reset", "--hard", "HEAD^") + _git(repository, "commit", "--allow-empty", "-qm", "No hook changes") + _git(repository, "tag", "v1.1.0") + _expect_release(server) + + result = _run(repository, environment) + + assert result.returncode == 0, result.stderr + + +def test_tagged_hook_age_accepts_local_only_configuration( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository, _ = _repository(tmp_path) + _expect_release(server) + (repository / CONFIG).write_text("repos:\n - repo: local\n hooks: []\n - repo: meta\n hooks: []\n") + + result = _run(repository, environment) + + assert result.returncode == 0, result.stderr + + +def test_tagged_hook_age_rejects_invalid_tag_and_api_failure( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository, _ = _repository(tmp_path) + _expect_release(server) + + invalid = _run(repository, environment, "not-a-release") + + assert invalid.returncode != 0 + assert "cannot select prior release tag" in invalid.stderr + server.clear() + server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_data( + "server error", status=503 + ) + + unavailable = _run(repository, environment) + + assert unavailable.returncode != 0 + assert "GitHub API request failed" in unavailable.stderr + + +def test_tagged_hook_age_rejects_missing_git_repository(tmp_path: Path) -> None: + result = _run(tmp_path, {}) + + assert result.returncode != 0 + assert "git rev-parse" in result.stderr + + +def test_tagged_hook_age_requires_proof_for_first_release( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository, introduction = _repository(tmp_path) + server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_json([]) + for commit in (_git(repository, "rev-parse", "HEAD^"), introduction): + server.expect_request( + f"/api/v3/repos/tox-dev/fixture/commits/{commit}/pulls", query_string="per_page=100" + ).respond_with_json([]) + + result = _run(repository, environment) + + assert result.returncode != 0 + assert "seven-day GitHub observation" in result.stderr + server.check_assertions() + + +@pytest.mark.parametrize("case", ["unrelated-pull", "unchanged-file", "deleted-fork"], ids=str) +def test_tagged_hook_age_rejects_unproven_introducing_pull( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], case: str +) -> None: + server, environment = github_api + repository, introduction = _repository(tmp_path) + _expect_release(server) + pull = { + "number": 7, + "merge_commit_sha": "0" * 40 if case == "unrelated-pull" else introduction, + "merged_at": _timestamp(0), + "created_at": _timestamp(20), + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + "head": { + "repo": None if case == "deleted-fork" else {"full_name": "gaborbernat/fixture"}, + "ref": "feature", + "sha": "c" * 40, + }, + } + server.expect_request( + f"/api/v3/repos/tox-dev/fixture/commits/{introduction}/pulls", query_string="per_page=100" + ).respond_with_json([pull]) + if case != "unrelated-pull": + server.expect_request( + "/api/v3/repos/tox-dev/fixture/pulls/7/files", query_string="per_page=100" + ).respond_with_json([{"filename": "README.md" if case == "unchanged-file" else CONFIG}]) + + result = _run(repository, environment) + + assert result.returncode != 0 + assert "seven-day GitHub observation" in result.stderr + server.check_assertions() + + +@pytest.mark.parametrize("case", ["other-pull", "server-error"], ids=str) +def test_tagged_hook_age_rejects_untrusted_or_unavailable_run( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], case: str +) -> None: + server, environment = github_api + repository, introduction = _repository(tmp_path) + _expect_release(server) + _expect_pull(server, introduction, merged_days=0) + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs").respond_with_json( + { + "workflow_runs": [ + { + "event": "pull_request", + "head_repository": {"full_name": "gaborbernat/fixture"}, + "head_branch": "feature", + "head_sha": "d" * 40, + "created_at": _timestamp(10), + "pull_requests": [{"number": 8}] if case == "other-pull" else [], + } + ] + } + ) + if case == "server-error": + server.expect_request( + f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'d' * 40}" + ).respond_with_data("server error", status=503) + + result = _run(repository, environment) + + assert result.returncode != 0 + assert ("seven-day GitHub observation" if case == "other-pull" else "GitHub API request failed") in result.stderr + server.check_assertions() diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py index e0a39f7..d0e8922 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -79,6 +79,7 @@ def test_recheck_reruns_failed_job( "repo": {"full_name": head_repository}, }, "base": {"repo": {"full_name": "tox-dev/fixture"}}, + "created_at": (datetime.now(UTC) - timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ"), "updated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), } ] @@ -142,6 +143,82 @@ def test_recheck_reruns_failed_job( ) == reruns +@pytest.mark.parametrize( + ("head_repository", "opened_days", "reruns"), + [ + pytest.param("tox-dev/fixture", 20, 1, id="same-repository-unlinked"), + pytest.param("gaborbernat/fixture", 20, 1, id="fork-unlinked"), + pytest.param("gaborbernat/fixture", 5, 0, id="observation-predates-pr"), + ], +) +def test_recheck_observes_unlinked_exact_head( + github_api: tuple[HTTPServer, dict[str, str]], + action_scripts: Path, + tmp_path: Path, + head_repository: str, + opened_days: int, + reruns: int, +) -> None: + server, environment = github_api + observed_at = (datetime.now(UTC) - timedelta(days=8)).strftime("%Y-%m-%dT%H:%M:%SZ") + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + [ + { + "number": 7, + "head": {"sha": "a" * 40, "ref": "update", "repo": {"full_name": head_repository}}, + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + "created_at": (datetime.now(UTC) - timedelta(days=opened_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + ] + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( + [{"filename": ".pre-commit-config.yaml"}] + ) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", method="GET" + ).respond_with_json( + { + "workflow_runs": [ + { + "id": 42, + "event": "pull_request", + "head_repository": {"full_name": head_repository}, + "head_sha": "a" * 40, + "head_branch": "update", + "pull_requests": [], + "status": "completed", + "created_at": observed_at, + } + ] + } + ) + if reruns: + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( + {"jobs": [{"id": 99, "name": "hook-cooldown", "conclusion": "failure"}]} + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99", method="GET").respond_with_json( + {"id": 99, "run_id": 42, "name": "hook-cooldown", "status": "completed", "conclusion": "failure"} + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun", method="POST").respond_with_data( + status=201 + ) + + result = action( + tmp_path, + tmp_path, + action_scripts, + "recheck-hook-update-cooldown", + version="", + GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], + **environment, + ) + + assert result.returncode == 0, result.stderr + assert [request.path for request, _ in server.log].count( + "/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun" + ) == reruns + + def test_recheck_continues_after_failed_rerun( github_api: tuple[HTTPServer, dict[str, str]], action_scripts: Path, tmp_path: Path ) -> None: @@ -154,6 +231,7 @@ def test_recheck_continues_after_failed_rerun( "user": {"login": "release-app[bot]", "type": "Bot"}, "head": {"sha": str(number) * 40, "ref": f"update-{number}", "repo": {"full_name": "tox-dev/fixture"}}, "base": {"repo": {"full_name": "tox-dev/fixture"}}, + "created_at": (datetime.now(UTC) - timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ"), } for number in (7, 8) ] diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 6d04c66..24020cb 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -20,6 +20,7 @@ from tools.nab_cutoffs import ( remove_expired_cutoffs, remove_expired_release_creator_cutoffs, + validate_recent_cutoffs, validate_release_creator_cutoffs, ) @@ -142,6 +143,27 @@ def test_release_creator_cutoffs_require_review(cutoff: str, accepted: bool) -> validate_release_creator_cutoffs(project, datetime(2026, 10, 17, tzinfo=UTC)) +@pytest.mark.parametrize( + ("cutoff", "accepted"), + [ + pytest.param("2026-10-22T01:32:34Z", True, id="reviewed"), + pytest.param("2026-10-22T01:32:35Z", False, id="unreviewed"), + ], +) +def test_uv_resolver_cutoff_requires_review(cutoff: str, accepted: bool) -> None: + nab = f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.uv]\nuploaded-prior-to = "{cutoff}"\n' + release_creator = f'[tool.release-creator.locks.exclude-newer-package]\nuv = "{cutoff}"\n' + + if accepted: + validate_recent_cutoffs(nab, datetime(2026, 10, 17, tzinfo=UTC)) + validate_release_creator_cutoffs(release_creator, datetime(2026, 10, 17, tzinfo=UTC)) + else: + with pytest.raises(ValueError, match="unreviewed recent Nab cutoff"): + validate_recent_cutoffs(nab, datetime(2026, 10, 17, tzinfo=UTC)) + with pytest.raises(ValueError, match="unreviewed recent release-creator cutoff"): + validate_release_creator_cutoffs(release_creator, datetime(2026, 10, 17, tzinfo=UTC)) + + @pytest.mark.parametrize( ("project_text", "accepted"), [ diff --git a/tests/refresh_release_lock/test_provider.py b/tests/refresh_release_lock/test_provider.py index bec1e78..807d893 100644 --- a/tests/refresh_release_lock/test_provider.py +++ b/tests/refresh_release_lock/test_provider.py @@ -1,8 +1,14 @@ from __future__ import annotations +import os +import subprocess +import tomllib from pathlib import Path -from conftest import action +import pytest +import yaml +from conftest import action, run +from packaging.version import Version def test_refresh_rejects_unknown_lock_producer(tmp_path: Path, action_scripts: Path) -> None: @@ -28,3 +34,112 @@ def test_refresh_rejects_secondary_options_without_lock(tmp_path: Path, action_s assert result.returncode != 0 assert "secondary matrix inputs require additional-lock" in result.stderr + + +def test_refresh_rejects_native_uv_lock_for_nab(tmp_path: Path, action_scripts: Path) -> None: + (tmp_path / "pylock.toml").write_text('[tool.nab]\ncommand-line = ["nab", "lock"]\n') + + result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="", KEEP_UV_LOCK="true") + + assert result.returncode != 0 + assert "keep-uv-lock requires a release-creator lock" in result.stderr + + +@pytest.mark.parametrize("mode", ["check", "refresh"]) +def test_refresh_uses_the_locked_resolver(tmp_path: Path, action_scripts: Path, mode: str) -> None: + source = Path(__file__).resolve().parents[2] + steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] + resolver = next(step for step in steps if step.get("with", {}).get("groups") == "release-resolver") + assert resolver["if"] == "steps.producer.outputs.name == 'release-creator'" + assert resolver["with"]["lock-source"] == "shared" + assert resolver["with"]["venv"] == "${{ runner.temp }}/release-resolver" + packages = tomllib.loads((source / "pylock.toml").read_text())["packages"] + pinned = next(package for package in packages if package["name"] == "uv") + assert Version(pinned["version"]) >= Version("0.13.5") + project = tomllib.loads((source / "pyproject.toml").read_text()) + assert project["dependency-groups"]["release-resolver"] == [f"uv=={pinned['version']}"] + assert all(artifact["hashes"]["sha256"] for artifact in pinned["wheels"]) + + executable = tmp_path / "release-resolver/bin/uv" + executable.parent.mkdir(parents=True) + executable.write_text('#!/bin/sh\nprintf "%s\\n" "$0" >"$RUNNER_TEMP/selected-uv"\n') + executable.chmod(0o755) + resolve_step = next( + index for index, step in enumerate(steps) if step.get("name") == "Resolve all project groups and extras" + ) + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{resolve_step}.sh")], + cwd=tmp_path, + env=os.environ | {"MODE": mode, "KEEP_UV_LOCK": "false", "RUNNER_TEMP": str(tmp_path)}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "selected-uv").read_text().strip() == str(executable) + + +@pytest.mark.parametrize("keep_uv_lock", [False, True], ids=["pylock-only", "native-uv-lock"]) +@pytest.mark.parametrize("mode", ["check", "refresh"]) +def test_refresh_passes_native_lock_flag(tmp_path: Path, action_scripts: Path, mode: str, keep_uv_lock: bool) -> None: + source = Path(__file__).resolve().parents[2] + steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] + index = next( + index for index, step in enumerate(steps) if step.get("name") == "Resolve all project groups and extras" + ) + executable = tmp_path / "release-resolver/bin/uv" + executable.parent.mkdir(parents=True) + executable.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/argv"\n') + executable.chmod(0o755) + + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], + cwd=tmp_path, + env=os.environ | {"MODE": mode, "KEEP_UV_LOCK": str(keep_uv_lock).lower(), "RUNNER_TEMP": str(tmp_path)}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + arguments = (tmp_path / "argv").read_text().splitlines() + assert ("--keep-uv-lock" in arguments) is keep_uv_lock + assert ("--check" in arguments) is (mode == "check") + + +@pytest.mark.parametrize("mode", ["check", "refresh"]) +def test_refresh_includes_native_uv_lock_in_diff(tmp_path: Path, action_scripts: Path, mode: str) -> None: + source = Path(__file__).resolve().parents[2] + steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] + index = next(index for index, step in enumerate(steps) if step.get("id") == "lock") + (tmp_path / "pyproject.toml").write_text('[project]\nname = "example"\nversion = "1.0"\n') + (tmp_path / "pylock.toml").write_text('lock-version = "1.0"\n') + (tmp_path / "uv.lock").write_text("version = 1\n") + run(tmp_path, "git", "init", "-b", "main") + run(tmp_path, "git", "config", "user.name", "Test") + run(tmp_path, "git", "config", "user.email", "test@example.org") + run(tmp_path, "git", "add", ".") + run(tmp_path, "git", "commit", "-m", "Add locks") + (tmp_path / "uv.lock").write_text("version = 2\n") + + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], + cwd=tmp_path, + env=os.environ + | { + "MODE": mode, + "KEEP_UV_LOCK": "true", + "ADDITIONAL_LOCK": "", + "RUNNER_TEMP": str(tmp_path), + "GITHUB_OUTPUT": str(tmp_path / "output"), + }, + text=True, + capture_output=True, + check=False, + ) + + assert (result.returncode != 0) is (mode == "check"), result.stderr + if mode == "refresh": + assert "diff --git a/uv.lock b/uv.lock" in (tmp_path / "lock.patch").read_text() + assert (tmp_path / "output").read_text() == "changed=true\n" diff --git a/tests/refresh_release_lock/test_resolver_upgrade.py b/tests/refresh_release_lock/test_resolver_upgrade.py new file mode 100644 index 0000000..c1cc0c6 --- /dev/null +++ b/tests/refresh_release_lock/test_resolver_upgrade.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import os +import runpy +import subprocess +import sys +import tomllib +from pathlib import Path +from textwrap import dedent + +import pytest +import yaml + + +@pytest.mark.parametrize( + ("requirement", "versions", "mode", "include_group", "error"), + [ + pytest.param("uv>=0.13.5", ("0.13.5",), "widen", True, "exact uv pin", id="non-exact-start"), + pytest.param('uv==0.13.5", "other', ("0.13.5",), "widen", True, "one uv requirement", id="extra-requirement"), + pytest.param("uv==0.13.5", ("0.13.5",), "pin", True, "temporary uv range", id="pin-without-widen"), + pytest.param("uv>=0.13.5", ("0.13.5",), "pin", False, "omits release-resolver", id="missing-group"), + pytest.param("uv>=0.13.5", ("0.13.5", "0.13.6"), "pin", True, "one uv resolver", id="ambiguous-candidate"), + pytest.param("uv>=0.13.5", (), "pin", True, "one uv resolver", id="missing-candidate"), + ], +) +def test_resolver_upgrade_rejects_invalid_state( + tmp_path: Path, requirement: str, versions: tuple[str, ...], mode: str, include_group: bool, error: str +) -> None: + source = Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py" + project = tmp_path / "pyproject.toml" + project.write_text(f'[dependency-groups]\nrelease-resolver = ["{requirement}"]\n') + lock = tmp_path / "pylock.toml" + lock.write_text( + ('dependency-groups = ["release-resolver"]\n' if include_group else "dependency-groups = []\n") + + ("".join(_uv_package(version) for version in versions) if versions else "packages = []\n") + ) + + result = subprocess.run( + [sys.executable, str(source), mode, str(project), str(lock)], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert error in result.stderr + + +def test_resolver_upgrade_import_has_no_side_effect() -> None: + source = Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py" + + assert runpy.run_path(str(source), run_name="imported")["__all__"] == ["main"] + + +def test_weekly_resolver_upgrade_is_opt_in() -> None: + source = Path(__file__).resolve().parents[2] + caller = yaml.safe_load((source / ".github/workflows/update-tools.yaml").read_text()) + reusable = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text()) + action = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text()) + + assert caller["jobs"]["update"]["with"]["upgrade-resolver"] is True + refresh = next( + step for step in reusable["jobs"]["generate-lock"]["steps"] if step.get("uses") == "$/refresh-release-lock" + ) + assert refresh["with"]["upgrade-resolver"] == "${{ inputs.upgrade-resolver }}" + assert action["inputs"]["upgrade-resolver"]["default"] == "false" + + +def test_resolver_upgrade_uses_candidate_to_regenerate_lock(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] + names = [step.get("name", "") for step in steps] + old_index = names.index("Resolve all project groups and extras") + new_index = names.index("Resolve with the updated uv resolver") + assert names.index("Widen the shared uv pin") < old_index + assert old_index < names.index("Pin the updated uv resolver") < new_index + installer = steps[new_index - 1] + assert installer["uses"] == "$/install-locked-tools" + assert installer["with"]["groups"] == "release-resolver" + assert installer["with"].get("lock-source", "caller") == "caller" + + project = tmp_path / "pyproject.toml" + project.write_text('[dependency-groups]\nrelease-resolver = ["uv==0.13.5"]\n') + (tmp_path / "pylock.toml").write_text('dependency-groups = ["release-resolver"]\n' + _uv_package("0.13.5")) + (tmp_path / "candidate.toml").write_text( + 'lock-version = "old-output"\ndependency-groups = ["release-resolver"]\n' + _uv_package("0.13.6") + ) + (tmp_path / "final.toml").write_text( + 'lock-version = "new-output"\ndependency-groups = ["release-resolver"]\n' + _uv_package("0.13.6") + ) + _fake_uv(tmp_path / "release-resolver/bin/uv", "candidate.toml") + _fake_uv(tmp_path / "updated-release-resolver/bin/uv", "final.toml") + tool = source / "tools/refresh_uv_resolver.py" + for mode in ("widen", "pin"): + if mode == "pin": + _run_step(action_scripts, old_index, tmp_path) + result = subprocess.run( + [sys.executable, str(tool), mode, str(project), str(tmp_path / "pylock.toml")], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + _run_step(action_scripts, new_index, tmp_path) + + assert tomllib.loads(project.read_text())["dependency-groups"]["release-resolver"] == ["uv==0.13.6"] + assert tomllib.loads((tmp_path / "pylock.toml").read_text())["lock-version"] == "new-output" + assert (tmp_path / "checked-with-candidate").exists() + + +def _uv_package(version: str) -> str: + return f"[[packages]]\nname = 'uv'\nversion = '{version}'\nmarker = '\"release-resolver\" in dependency_groups'\n" + + +def _fake_uv(path: Path, lock: str) -> None: + path.parent.mkdir(parents=True) + path.write_text( + dedent( + f"""\ + #!{sys.executable} + import pathlib + import shutil + import sys + + root = pathlib.Path.cwd() + if "--check" in sys.argv: + assert (root / "pylock.toml").read_bytes() == (root / "{lock}").read_bytes() + (root / "checked-with-candidate").touch() + else: + shutil.copyfile(root / "{lock}", root / "pylock.toml") + """ + ) + ) + path.chmod(0o755) + + +def _run_step(action_scripts: Path, index: int, root: Path) -> None: + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], + cwd=root, + env=os.environ | {"MODE": "refresh", "KEEP_UV_LOCK": "false", "RUNNER_TEMP": str(root)}, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index d891593..6d22ebc 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -33,6 +33,17 @@ def test_release_hook_installation_has_cooldown(workflow: str, job: str, step_na assert step["env"]["UV_EXCLUDE_NEWER"] == "7 days" +def test_tagged_release_checks_the_committed_lock() -> None: + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) + check = workflow["jobs"]["check"] + assert next(step for step in check["steps"] if step.get("uses") == "$/refresh-release-lock")["with"] == { + "mode": "check" + } + assert "check" in workflow["jobs"]["publish"]["needs"] + assert check["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"} + assert next(step for step in check["steps"] if "checkout" in step.get("uses", ""))["with"]["fetch-depth"] == 0 + + 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() diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 6b4a61c..9069919 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -64,7 +64,7 @@ def test_upgrade_locks_only_selected_groups(lock_repository: Path, tmp_path: Pat assert installed.returncode == 0, installed.stderr resolved = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-5.sh", + refresh_script(upgrade_scripts, "Resolve selected Nab groups"), tmp_path, LOCK_GROUPS="release-check", MODE="refresh", @@ -94,7 +94,7 @@ def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path assert installed.returncode == 0, installed.stderr refreshed = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-5.sh", + refresh_script(upgrade_scripts, "Resolve selected Nab groups"), tmp_path, LOCK_GROUPS="release-check", MODE="refresh", @@ -138,7 +138,7 @@ def test_upgrade_preserves_deliberate_nab_hold(lock_repository: Path, tmp_path: VENV=str(tmp_path / "lock-tools"), ) assert installed.returncode == 0, installed.stderr - expired = execute(lock_repository, upgrade_scripts / "refresh-release-lock-4.sh", tmp_path) + expired = execute(lock_repository, refresh_script(upgrade_scripts, "Expire reviewed package cutoffs"), tmp_path) assert expired.returncode == 0, expired.stderr assert tomllib.loads(project.read_text())["tool"]["nab"]["package-rules"][0]["uploaded-prior-to"] == ( "2026-01-01T00:00:00Z" @@ -158,7 +158,7 @@ def test_upgrade_emits_patch_for_allowed_change( (lock_repository / changed_path).write_text((lock_repository / changed_path).read_text() + "\n") result = execute( lock_repository, - upgrade_scripts / "refresh-release-lock-8.sh", + refresh_script(upgrade_scripts, "lock"), tmp_path, MODE="refresh", GITHUB_OUTPUT=str(tmp_path / "output"), @@ -169,17 +169,18 @@ def test_upgrade_emits_patch_for_allowed_change( @pytest.mark.parametrize( - ("path", "artifact", "additional_lock", "accepted"), + ("path", "artifact", "additional_lock", "keep_uv_lock", "accepted"), [ - 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"), + pytest.param("pylock.toml", "lock", "", False, True, id="lock"), + pytest.param("pyproject.toml", "lock", "", False, True, id="nab-policy"), + pytest.param("pylock.zipapp.toml", "lock", "pylock.zipapp.toml", False, True, id="secondary-lock"), + pytest.param(".pre-commit-config.yaml", "hooks", "", False, True, id="hooks"), + pytest.param(".pre-commit-config.yaml", "lock", "", False, False, id="hook-in-lock-artifact"), + pytest.param("pylock.toml", "hooks", "", False, False, id="lock-in-hook-artifact"), + pytest.param("pyproject.toml", "hooks", "", False, False, id="policy-in-hook-artifact"), + pytest.param("uv.lock", "lock", "", False, False, id="uv-lock-disabled"), + pytest.param("uv.lock", "lock", "", True, True, id="uv-lock-enabled"), + pytest.param("other.txt", "lock", "", False, False, id="unrelated-file"), ], ) def test_upgrade_checks_each_artifact_path( @@ -189,6 +190,7 @@ def test_upgrade_checks_each_artifact_path( path: str, artifact: str, additional_lock: str, + keep_uv_lock: bool, accepted: bool, ) -> None: (lock_repository / path).write_text("before\n") @@ -205,6 +207,7 @@ def test_upgrade_checks_each_artifact_path( publish_scripts / "publish-check-artifact-patch-paths.sh", tmp_path, ADDITIONAL_LOCK=additional_lock, + KEEP_UV_LOCK=str(keep_uv_lock).lower(), LOCK_CHANGED=str(artifact == "lock").lower(), HOOKS_CHANGED=str(artifact == "hooks").lower(), ) @@ -390,9 +393,17 @@ def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) "MATRIX_PYTHON": "", "MATRIX_PLATFORMS": "", "MATRIX_IMPLEMENTATIONS": "", + "KEEP_UV_LOCK": "false", } | environment, text=True, capture_output=True, check=False, ) + + +def refresh_script(scripts: Path, name: str) -> Path: + source = Path(__file__).resolve().parents[2] + steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] + index = next(index for index, step in enumerate(steps) if step.get("name", step.get("id")) == name) + return scripts / f"refresh-release-lock-{index}.sh" diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 5818376..1389158 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -138,19 +138,28 @@ def test_validate_news_forwards_secondary_lock_inputs() -> None: assert secondary assert all(refresh["with"][name] == "${{ inputs." + name + " }}" for name in secondary) assert refresh["with"]["mode"] == "check" + assert refresh["with"]["keep-uv-lock"] == "${{ inputs.keep-uv-lock }}" @pytest.mark.parametrize( - ("additional_lock", "changed_path", "changed"), + ("additional_lock", "keep_uv_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"), - pytest.param("", "meta/pyproject.toml", "true", id="workspace-member"), + pytest.param("pylock.zipapp.toml", False, "pylock.zipapp.toml", "true", id="selected-secondary-lock"), + pytest.param("", False, "pylock.zipapp.toml", "false", id="unselected-secondary-lock"), + pytest.param("", False, "nab.toml", "true", id="nab-policy"), + pytest.param("", False, "meta/pyproject.toml", "true", id="workspace-member"), + pytest.param("", True, "uv.lock", "true", id="selected-native-uv-lock"), + pytest.param("", False, "uv.lock", "false", id="unselected-native-uv-lock"), ], ) def test_validate_news_detects_release_input_change( - repository: Path, tmp_path: Path, action_scripts: Path, additional_lock: str, changed_path: str, changed: str + repository: Path, + tmp_path: Path, + action_scripts: Path, + additional_lock: str, + keep_uv_lock: bool, + changed_path: str, + changed: str, ) -> None: (repository / "pyproject.toml").write_text( (repository / "pyproject.toml").read_text() + "\n[dependency-groups]\nrelease-news = []\n" @@ -159,6 +168,7 @@ def test_validate_news_detects_release_input_change( '[tool.nab]\ncommand-line = ["nab", "lock", "--groups", "release-news", "--output", "pylock.toml"]\n' ) (repository / "pylock.zipapp.toml").write_text("lock-version = '1.0'\n") + (repository / "uv.lock").write_text("version = 1\n") run(repository, "git", "add", ".") run(repository, "git", "commit", "-m", "Add release locks") run(repository, "git", "push", "origin", "main") @@ -180,6 +190,7 @@ def test_validate_news_detects_release_input_change( | { "BASE_REF": "main", "ADDITIONAL_LOCK": additional_lock, + "KEEP_UV_LOCK": str(keep_uv_lock).lower(), "GITHUB_OUTPUT": str(output), "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"), }, diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index 245bf9b..b84e4b7 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -52,9 +52,26 @@ 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"} + assert set(workflow["on"]["workflow_call"]["inputs"]) == { + "source", + "artifact-prefix", + "keep-uv-lock", + "additional-lock", + "additional-lock-groups", + "additional-lock-selection", + "additional-matrix-python", + "additional-matrix-platforms", + "additional-matrix-implementations", + } assert set(workflow["jobs"]) == {"source-check", "constraints", "build", "validate"} - assert workflow["jobs"]["build"]["needs"] == "constraints" + assert workflow["jobs"]["build"]["needs"] == ["source-check", "constraints"] + lock_check = next( + step for step in workflow["jobs"]["source-check"]["steps"] if step.get("uses") == "$/refresh-release-lock" + ) + assert lock_check["with"]["mode"] == "check" + for option in workflow["on"]["workflow_call"]["inputs"]: + if option not in {"source", "artifact-prefix"}: + assert lock_check["with"][option] == f"${{{{ inputs.{option} }}}}" assert not any(step.get("uses", "").startswith("$/install-") for step in workflow["jobs"]["build"]["steps"]) assert workflow["jobs"]["validate"]["needs"] == "build" assert not any(step.get("uses") == "$/install-locked-tools" for step in workflow["jobs"]["validate"]["steps"]) @@ -65,6 +82,47 @@ def test_validator_isolates_release_tools_from_build() -> None: assert "check-wheel-contents>=0.6.3" in validator["run"] +def test_tagged_source_has_history_and_read_access_for_hook_proof(release_workflow: dict[str, Any]) -> None: + source = release_workflow["jobs"]["source-check"] + assert source["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"} + assert next(step for step in source["steps"] if "checkout" in step.get("uses", ""))["with"]["fetch-depth"] == 0 + assert [step.get("uses") for step in source["steps"]].index("$/refresh-release-lock") < [ + step.get("uses") for step in source["steps"] + ].index("$/verify-frozen-hooks") + + +def test_tagged_source_rejects_stale_project_requirement(tmp_path: Path, release_workflow: dict[str, Any]) -> None: + source = Path(__file__).resolve().parents[2] + project = tmp_path / "pyproject.toml" + project.write_text( + '[project]\nname = "probe"\nversion = "1.0.0"\nrequires-python = ">=3.15"\ndependencies = []\n' + "[dependency-groups]\nrelease-check = []\n" + "[tool.release-creator.locks]\n" + ) + tool = source / ".release-tools/bin/release-creator" + locked = subprocess.run([str(tool), "locks"], cwd=tmp_path, text=True, capture_output=True, check=False) + assert locked.returncode == 0, locked.stderr + project.write_text(project.read_text().replace("dependencies = []", 'dependencies = ["prek>=999"]')) + selected = subprocess.run( + [sys.executable, str(source / "tools/check_lock_groups.py"), "pylock.toml", "release-check"], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + freshness = subprocess.run( + [str(tool), "locks", "--check"], cwd=tmp_path, text=True, capture_output=True, check=False + ) + + assert selected.returncode == 0, selected.stderr + assert freshness.returncode != 0 + assert "prek>=999" in freshness.stderr + assert any( + step.get("uses") == "$/refresh-release-lock" and step.get("with", {}).get("mode") == "check" + for step in release_workflow["jobs"]["source-check"]["steps"] + ) + + def test_validator_builds_reproducible_distributions_and_checks_them( build_repository: Path, build_constraints: Path, tmp_path: Path, release_workflow: dict[str, Any] ) -> None: diff --git a/tools/check_tagged_hook_age.py b/tools/check_tagged_hook_age.py new file mode 100644 index 0000000..00ef134 --- /dev/null +++ b/tools/check_tagged_hook_age.py @@ -0,0 +1,206 @@ +"""Require GitHub-observed age for hook revisions added since the last release.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import subprocess +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from yaml import YAMLError, safe_load + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repository") + parser.add_argument("tag") + parser.add_argument("--config", default=".pre-commit-config.yaml") + try: + args = parser.parse_args() + check_tagged_hooks(args.repository, args.tag, args.config) + except (OSError, RuntimeError, TypeError, ValueError, YAMLError, json.JSONDecodeError) as error: + parser.error(str(error)) + + +def check_tagged_hooks(repository: str, tag: str, config: str) -> None: + if not repository or not tag or Path(config).is_absolute() or ".." in Path(config).parts: + raise ValueError("tagged hook proof needs a repository, tag, and relative config path") + if _git("rev-parse", "--is-shallow-repository") != "false": + raise ValueError("tagged hook proof needs complete git history and tags") + head = _git("rev-parse", "HEAD") + previous = _previous_release(repository, head, tag) + current = _pairs(Path(config).read_text(encoding="utf-8")) + pending = current - (_pairs_at(previous, config) if previous else set()) + if not pending: + return + revision_range = f"{previous}..HEAD" if previous else "HEAD" + introductions: dict[tuple[str, str], str] = {} + for commit in _git("rev-list", "--first-parent", "--reverse", revision_range, "--", config).splitlines(): + parent = _git("rev-list", "--parents", "-n", "1", commit).split()[1:2] + introduced = (_pairs_at(commit, config) - (_pairs_at(parent[0], config) if parent else set())) & pending + introductions.update(dict.fromkeys(introduced, commit)) + for commit in set(introductions.values()): + pending -= _proven_pairs( + repository, config, commit, {pair for pair, change in introductions.items() if change == commit} + ) + if not pending: + return + raise ValueError( + f"hook revisions lack a seven-day GitHub observation: {', '.join(sorted(f'{repo}@{rev}' for repo, rev in pending))}" + ) + + +def _previous_release(repository: str, head: str, tag: str) -> str | None: + published = { + release["tag_name"] + for release in _api(f"repos/{repository}/releases?per_page=100") + if not release.get("draft") and release.get("published_at") and isinstance(release.get("tag_name"), str) + } + lineage = set(_git("rev-list", "--first-parent", head).splitlines()) + tags = _git("tag", "--list") + while previous := _previous_tag(tag, tags): + commit = _git_optional("rev-parse", "--verify", f"refs/tags/{previous}^{{commit}}") + if previous in published and commit in lineage and commit != head: + return previous + tag = previous + return None + + +def _previous_tag(tag: str, tags: str) -> str: + command = [sys.executable, "-m", "release_creator", "tags", tag, "--previous"] + if "/" in tag: + command.extend(("--package", tag.split("/", 1)[0])) + result = subprocess.run(command, input=tags, text=True, capture_output=True, check=False) + if result.returncode: + raise RuntimeError(f"cannot select prior release tag: {result.stderr.strip()}") + return result.stdout.strip() + + +def _pairs_at(revision: str, config: str) -> set[tuple[str, str]]: + content = _git_optional("show", f"{revision}:{config}") + return _pairs(content) if content is not None else set() + + +def _pairs(content: str) -> set[tuple[str, str]]: + root = safe_load(content) + if not isinstance(root, dict) or not isinstance(root.get("repos"), list): + raise TypeError("hook configuration needs a repository list") + pairs: set[tuple[str, str]] = set() + for item in root["repos"]: + if not isinstance(item, dict) or not isinstance(item.get("repo"), str): + raise TypeError("hook repository needs a URL") + if item["repo"] not in {"local", "meta"}: + if not isinstance(item.get("rev"), str): + raise TypeError("external hook repository needs a revision") + pairs.add((item["repo"], item["rev"])) + return pairs + + +def _proven_pairs(repository: str, config: str, commit: str, introduced: set[tuple[str, str]]) -> set[tuple[str, str]]: + proven: set[tuple[str, str]] = set() + for pull in _api(f"repos/{repository}/commits/{commit}/pulls?per_page=100"): + if ( + pull.get("merge_commit_sha") != commit + or not pull.get("merged_at") + or pull.get("base", {}).get("repo", {}).get("full_name") != repository + ): + continue + number = pull["number"] + if config not in {item["filename"] for item in _api(f"repos/{repository}/pulls/{number}/files?per_page=100")}: + continue + merged_at = datetime.fromisoformat(pull["merged_at"]) + if merged_at <= datetime.now(UTC) - timedelta(days=7): + proven |= introduced + continue + head = pull["head"] + if not isinstance(head.get("repo"), dict) or not isinstance(head["repo"].get("full_name"), str): + continue + head_repository = head["repo"]["full_name"] + encoded = _api_object(f"repos/{head_repository}/contents/{config}?ref={head['sha']}")["content"] + for pair in _pairs(base64.b64decode(encoded).decode("utf-8")) & introduced: + if _observed_pair(repository, config, pair, pull): + proven.add(pair) + return proven + + +def _observed_pair(repository: str, config: str, pair: tuple[str, str], pull: dict[str, Any]) -> bool: + head = pull["head"] + source = head["repo"]["full_name"] + opened = datetime.fromisoformat(pull["created_at"]) + cutoff = datetime.now(UTC) - timedelta(days=7) + for run in _api( + f"repos/{repository}/actions/runs", + event="pull_request", + branch=head["ref"], + created=f"<{cutoff.isoformat(timespec='seconds').replace('+00:00', 'Z')}", + per_page="100", + ): + if ( + run["event"] != "pull_request" + or not isinstance(run.get("head_repository"), dict) + or run["head_repository"].get("full_name") != source + or run["head_branch"] != head["ref"] + or datetime.fromisoformat(run["created_at"]) < opened + or datetime.fromisoformat(run["created_at"]) > cutoff + ): + continue + if not isinstance(run.get("pull_requests"), list) or ( + run["pull_requests"] and not any(item["number"] == pull["number"] for item in run["pull_requests"]) + ): + continue + try: + encoded = _api_object(f"repos/{source}/contents/{config}?ref={run['head_sha']}")["content"] + except RuntimeError as error: + if "HTTP 404" in str(error): + continue + raise + if pair in _pairs(base64.b64decode(encoded).decode("utf-8")): + return True + return False + + +def _api(path: str, **fields: str) -> list[dict[str, Any]]: + command = ["gh", "api", "--paginate", "--slurp", "-X", "GET", path] + for name, value in fields.items(): + command.extend(("-f", f"{name}={value}")) + result = subprocess.run(command, text=True, capture_output=True, check=False) + if result.returncode: + raise RuntimeError(f"GitHub API request failed for {path}: {result.stderr.strip()}") + return [ + item + for page in json.loads(result.stdout) + for item in (page if isinstance(page, list) else page.get("workflow_runs", page.get("jobs", []))) + ] + + +def _api_object(path: str) -> dict[str, Any]: + result = subprocess.run(["gh", "api", path], text=True, capture_output=True, check=False) + if result.returncode: + raise RuntimeError(f"GitHub API request failed for {path}: {result.stderr.strip()}") + return json.loads(result.stdout) + + +def _git(*arguments: str) -> str: + result = subprocess.run(["git", *arguments], text=True, capture_output=True, check=False) + if result.returncode: + raise RuntimeError(f"git {' '.join(arguments)} failed: {result.stderr.strip()}") + return result.stdout.strip() + + +def _git_optional(*arguments: str) -> str | None: + result = subprocess.run(["git", *arguments], text=True, capture_output=True, check=False) + return result.stdout.strip() if result.returncode == 0 else None + + +if __name__ == "__main__": + main() + + +__all__ = [ + "check_tagged_hooks", + "main", +] diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index b5ef527..60cfdbc 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -25,6 +25,7 @@ ("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), ("release-creator", "1.1.0"): datetime(2026, 10, 24, 9, 8, 40, tzinfo=UTC), + ("uv", "0.13.5"): datetime(2026, 10, 22, 1, 32, 34, tzinfo=UTC), ("uv-build", "0.13.4"): datetime(2026, 10, 21, tzinfo=UTC), } _REVIEWED_EXCEPTIONS: Final = { diff --git a/tools/refresh_uv_resolver.py b/tools/refresh_uv_resolver.py new file mode 100644 index 0000000..26555db --- /dev/null +++ b/tools/refresh_uv_resolver.py @@ -0,0 +1,49 @@ +"""Keep lock bytes stable when the shared uv resolver changes.""" + +from __future__ import annotations + +import argparse +import tomllib +from pathlib import Path + +import tomlkit +from tomlkit.exceptions import ParseError + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("widen", "pin")) + parser.add_argument("project", type=Path) + parser.add_argument("lock", type=Path) + args = parser.parse_args() + try: + project = tomlkit.parse(args.project.read_text()) + requirements = project["dependency-groups"]["release-resolver"] + if len(requirements) != 1: + raise ValueError("release-resolver must contain one uv requirement") + if args.mode == "widen": + if not str(requirements[0]).startswith("uv=="): + raise ValueError("release-resolver must have an exact uv pin") + requirements[0] = "uv>=0.13.5" + else: + if requirements[0] != "uv>=0.13.5": + raise ValueError("release-resolver must have the temporary uv range") + lock = tomllib.loads(args.lock.read_text()) + if "release-resolver" not in lock["dependency-groups"]: + raise ValueError("candidate lock omits release-resolver") + versions = {package["version"] for package in lock["packages"] if package["name"] == "uv"} + if len(versions) != 1: + raise ValueError("candidate lock must select one uv resolver version") + requirements[0] = f"uv=={versions.pop()}" + args.project.write_text(tomlkit.dumps(project)) + except (OSError, KeyError, TypeError, ValueError, ParseError, tomllib.TOMLDecodeError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] diff --git a/validate-news/action.yml b/validate-news/action.yml index decd30c..4624ea0 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -1,6 +1,9 @@ name: Validate changelog news description: Require a fragment for human changes and validate any fragment supplied by a bot inputs: + keep-uv-lock: + description: Check uv.lock alongside pylock.toml when both are committed + default: "false" additional-lock: description: Secondary pylock basename to check on a pull request default: "" @@ -44,9 +47,11 @@ runs: env: BASE_REF: ${{ github.base_ref }} ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | paths=(pyproject.toml ':(glob)**/pyproject.toml' pylock.toml nab.toml) if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi + if [[ "$KEEP_UV_LOCK" == true ]]; then paths+=(uv.lock); fi changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${paths[@]}") if [[ -z "$changed" ]]; then echo 'changed=false' >>"$GITHUB_OUTPUT" @@ -74,4 +79,5 @@ runs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + keep-uv-lock: ${{ inputs.keep-uv-lock }} mode: check diff --git a/verify-frozen-hooks/action.yml b/verify-frozen-hooks/action.yml index eb01ca0..efb1b36 100644 --- a/verify-frozen-hooks/action.yml +++ b/verify-frozen-hooks/action.yml @@ -1,5 +1,9 @@ name: Verify frozen hooks description: Check immutable revisions and the complete cooled Python dependency closure +inputs: + tag: + description: Explicit release tag when the workflow is dispatched from a branch + default: "" runs: using: composite steps: @@ -16,3 +20,15 @@ runs: - name: Check Python hook dependencies shell: bash run: uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" release-creator hooks --check + - name: Check tagged hook revision provenance + if: inputs.tag != '' || startsWith(github.ref, 'refs/tags/') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag || github.ref_name }} + run: | + set -euo pipefail + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_tagged_hook_age.py" \ + "$GITHUB_REPOSITORY" "$TAG"