diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f9f1e3c..9d79c41 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,13 @@ updates: labels: - dependencies - skip news + - package-ecosystem: github-actions + directory: /check-hook-update-cooldown + cooldown: + default-days: 7 + schedule: + interval: weekly + day: tuesday + labels: + - dependencies + - skip news diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 67cf977..b95d043 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -3,71 +3,135 @@ on: push: branches: [main] pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] workflow_dispatch: permissions: contents: read +concurrency: + group: check-${{ github.ref }}-${{ github.run_attempt > 1 && github.run_id || 'current' }} + cancel-in-progress: ${{ github.run_attempt == 1 }} jobs: check: runs-on: ubuntu-26.04 + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/prek + key: prek-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.pre-commit-config.yaml') }} - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: + version: latest-known python-version: "3.15" - enable-cache: false - - name: Install locked tools - run: | - uv venv --python 3.15 .venv - uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml - echo "$PWD/.venv/bin" >>"$GITHUB_PATH" + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml + - uses: $/install-locked-tools + with: + groups: release-check + venv: .release-tools + - name: Add locked tools to PATH + run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH" - name: Check repository formatting run: prek run --all-files - - name: Check Python tests - run: | - ruff format --check . - ruff check . --output-format=concise - ty check tests - - name: Measure Action shell coverage - run: | - sudo apt-get update - sudo apt-get install -y kcov - KCOV_BIN=kcov KCOV_OUTPUT="$RUNNER_TEMP/shell-coverage" pytest -q - kcov --merge "$RUNNER_TEMP/shell-coverage-merged" "$RUNNER_TEMP/shell-coverage"/* - python - <<'PY' - import json - import os - from pathlib import Path - - import yaml - - report = next(Path(os.environ["RUNNER_TEMP"], "shell-coverage-merged").rglob("codecov.json")) - coverage = json.loads(report.read_text())["coverage"] - expected = { - f"{path.parent.name}-{index}.sh" - for path in Path().glob("*/action.yml") - for index, _ in enumerate(yaml.safe_load(path.read_text())["runs"]["steps"]) - } - if set(coverage) != expected or any(not lines or not all(lines.values()) for lines in coverage.values()): - raise SystemExit(f"Action shell coverage is below 100%: {coverage}") - PY + shell: + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/prek + key: prek-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.pre-commit-config.yaml') }} + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml + - uses: $/install-locked-tools + with: + groups: release-shell + venv: .release-tools + - name: Add locked tools to PATH + run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH" - name: Check inline shell run: | set -euo pipefail + script_dir=./inline-shell-check + mkdir "$script_dir" + trap 'rm -rf "$script_dir"' EXIT for file in .github/workflows/*.yaml */action.yml; do yq -rj '[.. | objects | select(has("run")) | .run] | .[] | . + "\u0000"' "$file" \ >"$RUNNER_TEMP/run-blocks" index=0 while IFS= read -r -d '' script; do - echo "$file: run block $index" - printf '#!/usr/bin/env bash\n%s\n' "${script%$'\n'}" >"$RUNNER_TEMP/inline-shell.sh" - prek run shellcheck --files "$RUNNER_TEMP/inline-shell.sh" - prek run shfmt --files "$RUNNER_TEMP/inline-shell.sh" + script_file="$script_dir/run-${file//\//-}-$index.sh" + printf '#!/usr/bin/env bash\n%s\n' "${script%$'\n'}" >"$script_file" index=$((index + 1)) done <"$RUNNER_TEMP/run-blocks" done + prek run shellcheck --files "$script_dir"/*.sh + prek run shfmt --files "$script_dir"/*.sh + test: + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml + - uses: $/install-locked-tools + with: + groups: release-news release-shell + venv: .release-tools + - name: Add locked tools to PATH + run: echo "$PWD/.release-tools/bin" >>"$GITHUB_PATH" + - name: Install current test tools + run: | + uv venv --python 3.15 .venv + uv pip install --python .venv/bin/python --group test + - name: Run Action tests + run: .venv/bin/pytest -q -n auto + type: + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml + - name: Check Python types + run: uvx --with tox-uv tox run -e type changelog: if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 @@ -78,161 +142,65 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: + version: latest-known python-version: "3.15" - enable-cache: false - - name: Require release notes - env: - SKIP_NEWS: ${{ contains(github.event.pull_request.labels.*.name, 'skip news') }} - BOT_AUTHOR: ${{ github.event.pull_request.user.type == 'Bot' }} - run: | - set -euo pipefail - if git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD" -- 'changelog.d/*.md' | grep -q .; then - uv run --no-default-groups --group release towncrier check --compare-with "origin/${GITHUB_BASE_REF}" - elif [[ "$SKIP_NEWS" != true && "$BOT_AUTHOR" != true ]]; then - echo '::error::add a changelog fragment or apply the skip news label' - exit 1 - fi - release-dry-run: + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml + - uses: $/install-locked-tools + with: + groups: release-news + venv: ${{ runner.temp }}/release-tools + - name: Add locked tools to PATH + run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH" + - uses: $/validate-news + hook-cooldown: if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 permissions: + actions: read contents: read pull-requests: read + steps: + - uses: $/check-hook-update-cooldown + release-tools-preview: + if: github.event_name == 'pull_request' + runs-on: ubuntu-26.04 + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - ref: ${{ github.event.pull_request.head.sha }} - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: + version: latest-known python-version: "3.15" - enable-cache: false - - name: Install locked release tools - run: | - uv venv --python 3.15 .venv - uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml - echo "$PWD/.venv/bin" >>"$GITHUB_PATH" - - name: Rehearse a squash merge - id: fixture - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - git checkout -B release-validation "$BASE_SHA" - git merge --squash "$HEAD_SHA" - printf 'Validate the release pipeline on this pull request.\n' \ - >"changelog.d/+validation-${PR_NUMBER}.bugfix.md" - git add -A - git -c user.name=release-validation \ - -c user.email=release-validation@users.noreply.github.com \ - commit -m "Validate release pipeline (#${PR_NUMBER})" - git init --bare "$RUNNER_TEMP/release-origin.git" - git remote set-url origin "$RUNNER_TEMP/release-origin.git" - git push origin HEAD:main - echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - - name: Dirty the release checkout - run: touch untracked-release-file - - id: dirty_prepare - name: Reject a dirty release checkout - uses: $/prepare-towncrier - continue-on-error: true - with: - changelog: CHANGELOG.md - format: markdown - patch: ${{ runner.temp }}/release.patch - - name: Check dirty checkout rejection - env: - OUTCOME: ${{ steps.dirty_prepare.outcome }} - run: | - test "$OUTCOME" = failure - rm untracked-release-file - - id: prepare - uses: $/prepare-towncrier - with: - changelog: CHANGELOG.md - format: markdown - patch: ${{ runner.temp }}/release.patch - - name: Restore the source commit - run: git reset --hard HEAD - - id: stale_validate - name: Reject a stale release branch - uses: $/validate-release-patch - continue-on-error: true - with: - version: ${{ steps.prepare.outputs.version }} - patch: ${{ runner.temp }}/release.patch - changelog: CHANGELOG.md - fragments: changelog.d - expected-sha: "0000000000000000000000000000000000000000" - - name: Check stale branch rejection - env: - OUTCOME: ${{ steps.stale_validate.outcome }} - run: test "$OUTCOME" = failure - - uses: $/validate-release-patch - with: - version: ${{ steps.prepare.outputs.version }} - patch: ${{ runner.temp }}/release.patch - changelog: CHANGELOG.md - fragments: changelog.d - expected-sha: ${{ steps.fixture.outputs.sha }} - - id: invalid_tag - name: Reject an invalid release tag - uses: $/commit-and-tag - continue-on-error: true - with: - version: bad tag - token: ${{ github.token }} - app-slug: github-actions - message: invalid release - - name: Check invalid tag rejection - env: - OUTCOME: ${{ steps.invalid_tag.outcome }} - run: test "$OUTCOME" = failure - - uses: $/commit-and-tag - with: - version: ${{ steps.prepare.outputs.version }} - token: ${{ github.token }} - app-slug: github-actions - message: release ${{ steps.prepare.outputs.version }} - - name: Render tagged notes - env: - VERSION: ${{ steps.prepare.outputs.version }} - run: | - set -euo pipefail - test -n "$(git ls-remote origin "refs/tags/$VERSION")" - previous=$(git tag --list | release-creator tags "$VERSION" --previous) - args=() - if [[ -n "$previous" ]]; then - args+=(--previous "$previous") - fi - release-creator notes --changelog CHANGELOG.md --tag "$VERSION" \ - --repository "$GITHUB_REPOSITORY" "${args[@]}" \ - >"$RUNNER_TEMP/release-notes.md" - test -s "$RUNNER_TEMP/release-notes.md" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-dry-run - path: | - ${{ runner.temp }}/release.patch - ${{ runner.temp }}/release-notes.md - if-no-files-found: error + enable-cache: true + cache-dependency-glob: | + pylock.toml + pyproject.toml + - uses: $/refresh-release-lock + with: + lock-groups: >- + build lock-tools release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check + mode: check gate: - name: Required checks pass - if: always() - needs: [check, changelog, release-dry-run] + name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} + if: ${{ !cancelled() }} + needs: [check, shell, test, type, changelog, hook-cooldown, release-tools-preview] runs-on: ubuntu-26.04 steps: - env: NEEDS_JSON: ${{ toJSON(needs) }} + EVENT_NAME: ${{ github.event_name }} run: | jq -e ' all( to_entries[]; .value.result == "success" or - (.key == "changelog" and .value.result == "skipped") or - (.key == "release-dry-run" and .value.result == "skipped") + ($ENV.EVENT_NAME != "pull_request" and .value.result == "skipped" and + (.key == "changelog" or .key == "hook-cooldown" or .key == "release-tools-preview")) ) ' <<<"$NEEDS_JSON" diff --git a/.github/workflows/label-bot-news.yaml b/.github/workflows/label-bot-news.yaml new file mode 100644 index 0000000..e7f9521 --- /dev/null +++ b/.github/workflows/label-bot-news.yaml @@ -0,0 +1,28 @@ +name: Label bot news +on: + workflow_run: # zizmor: ignore[dangerous-triggers] Verifies source CI and PR metadata without checkout or artifacts. + workflows: [Check] + types: [completed] +permissions: {} +concurrency: + group: label-bot-news-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: true +jobs: + label: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-26.04 + permissions: + actions: read + issues: write + pull-requests: read + steps: + - id: pr + uses: $/resolve-pr-from-run + with: + run-id: ${{ github.event.workflow_run.id }} + workflow-path: .github/workflows/check.yaml + - if: steps.pr.outputs.bot-author == 'true' + uses: $/label-bot-news + with: + pull-request: ${{ steps.pr.outputs.number }} + bot-author: "true" diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index 2a991bf..88ba696 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -9,85 +9,13 @@ on: options: [auto, major, minor, patch] default: auto permissions: {} -concurrency: - group: prepare-release - cancel-in-progress: false jobs: - assemble: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-26.04 + prepare: + uses: $/.github/workflows/prepare-towncrier-release.yaml permissions: contents: read pull-requests: read - outputs: - version: ${{ steps.prepare.outputs.version }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - python-version: "3.15" - enable-cache: false - - name: Install locked release tools - run: | - uv venv --python 3.15 .venv - uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml - echo "$PWD/.venv/bin" >>"$GITHUB_PATH" - - id: prepare - uses: $/prepare-towncrier - with: - bump: ${{ inputs.bump }} - changelog: CHANGELOG.md - format: markdown - patch: ${{ runner.temp }}/release.patch - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-patch - path: ${{ runner.temp }}/release.patch - if-no-files-found: error - push: - needs: assemble - runs-on: ubuntu-26.04 - environment: release-auth - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-patch - path: ${{ runner.temp }}/release-patch - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - python-version: "3.15" - enable-cache: false - - name: Install locked release tools - run: | - uv venv --python 3.15 .venv - uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml - echo "$PWD/.venv/bin" >>"$GITHUB_PATH" - - uses: $/validate-release-patch - with: - version: ${{ needs.assemble.outputs.version }} - patch: ${{ runner.temp }}/release-patch/release.patch - changelog: CHANGELOG.md - fragments: changelog.d - - id: token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 - with: - client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-contents: write - - uses: $/commit-and-tag - with: - version: ${{ needs.assemble.outputs.version }} - token: ${{ steps.token.outputs.token }} - app-slug: ${{ steps.token.outputs.app-slug }} - message: release ${{ needs.assemble.outputs.version }} + with: + bump: ${{ inputs.bump }} + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml new file mode 100644 index 0000000..320b9a9 --- /dev/null +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -0,0 +1,161 @@ +name: Prepare Towncrier release +on: + workflow_call: + inputs: + bump: + description: Version selection mode + type: string + default: auto + tag-prefix: + description: Optional v prefix for the tag + type: string + default: "" + changelog-prefix: + description: Optional v prefix for the Towncrier changelog heading + type: string + default: "" + local-tool-source: + description: Install release-creator from the tagged checkout for its own release + type: boolean + default: false + annotated: + description: Create an annotated tag + type: boolean + default: false + release-auth-deployment: + description: Create a GitHub Deployment for the App-key job + type: boolean + default: true + secrets: + RELEASE_APP_PRIVATE_KEY: + description: Key held by the caller's release-auth environment + required: true +permissions: {} +concurrency: + group: prepare-release-${{ github.repository }} + cancel-in-progress: false +jobs: + assemble: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-26.04 + permissions: + contents: read + pull-requests: read + outputs: + version: ${{ steps.prepare.outputs.version }} + artifact-id: ${{ steps.patch.outputs.artifact-id }} + patch-sha: ${{ steps.digest.outputs.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - uses: $/install-locked-tools + with: + groups: release-prepare${{ inputs.local-tool-source && ' project-runtime' || '' }} + venv: .venv + local-tool-source: ${{ inputs.local-tool-source }} + - name: Add locked tools to PATH + run: echo "$PWD/.venv/bin" >>"$GITHUB_PATH" + - name: Install prek hooks + id: hooks + run: prek install-hooks + background: true + - name: Create a fresh validation worktree + run: git worktree add --detach "$RUNNER_TEMP/release-validation" "$GITHUB_SHA" + - name: Export local build constraints + if: inputs.local-tool-source + id: constraints + uses: $/export-build-constraints + - name: Build local release tool wheel + if: inputs.local-tool-source + env: + BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} + run: | + uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ + --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . + - name: Install local release tool wheel + if: inputs.local-tool-source + run: uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl + - id: prepare + name: Build changelog + uses: $/prepare-towncrier + with: + bump: ${{ inputs.bump }} + tag-prefix: ${{ inputs.tag-prefix }} + changelog-prefix: ${{ inputs.changelog-prefix }} + phase: build + - wait: hooks + - name: Finalize release patch + uses: $/prepare-towncrier + with: + version: ${{ steps.prepare.outputs.version }} + phase: finalize + - uses: $/validate-release-patch + with: + version: ${{ steps.prepare.outputs.version }} + patch: ${{ runner.temp }}/release.patch + working-directory: ${{ runner.temp }}/release-validation + - name: Record validated patch digest + id: digest + run: | + patch_sha=$(sha256sum "$RUNNER_TEMP/release.patch") + printf 'sha=%s\n' "${patch_sha%% *}" >>"$GITHUB_OUTPUT" + - id: patch + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-patch + path: ${{ runner.temp }}/release.patch + if-no-files-found: error + push: + needs: assemble + runs-on: ubuntu-26.04 + environment: + name: release-auth + deployment: ${{ inputs.release-auth-deployment }} + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.assemble.outputs.artifact-id }} + path: ${{ runner.temp }}/release-patch + - uses: $/validate-release-patch + with: + version: ${{ needs.assemble.outputs.version }} + patch: ${{ runner.temp }}/release-patch/release.patch + expected-patch-sha: ${{ needs.assemble.outputs.patch-sha }} + checks: "false" + - name: Require release App key + env: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + run: | + if [[ -z "$RELEASE_APP_PRIVATE_KEY" ]]; then + echo '::error::RELEASE_APP_PRIVATE_KEY is missing from release-auth' >&2 + exit 1 + fi + - id: token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + - uses: $/commit-and-tag + with: + version: ${{ needs.assemble.outputs.version }} + token: ${{ steps.token.outputs.token }} + app-slug: ${{ steps.token.outputs.app-slug }} + message: release ${{ needs.assemble.outputs.version }} + annotated: ${{ inputs.annotated }} diff --git a/.github/workflows/recheck-hook-cooldown.yaml b/.github/workflows/recheck-hook-cooldown.yaml new file mode 100644 index 0000000..4686f4a --- /dev/null +++ b/.github/workflows/recheck-hook-cooldown.yaml @@ -0,0 +1,18 @@ +name: Recheck hook cooldown +on: + schedule: + - cron: "17 7 * * *" + workflow_dispatch: +permissions: {} +concurrency: + group: recheck-hook-cooldown-${{ github.ref }} + cancel-in-progress: true +jobs: + recheck: + runs-on: ubuntu-26.04 + permissions: + actions: write + contents: read + pull-requests: read + steps: + - uses: $/recheck-hook-update-cooldown diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3c7a35d..6305178 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,43 +4,147 @@ on: tags: ["*"] permissions: contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - release: + check: runs-on: ubuntu-26.04 permissions: - contents: write + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: + version: latest-known python-version: "3.15" enable-cache: false - - name: Install locked release tools - run: | - uv venv --python 3.15 .venv - uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml - echo "$PWD/.venv/bin" >>"$GITHUB_PATH" + - uses: $/install-locked-tools + with: + groups: release-check + venv: .venv - name: Check tagged source + run: .venv/bin/prek run --all-files + - name: Check source remains clean + run: test -z "$(git status --porcelain)" + source: + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + previous: ${{ steps.previous.outputs.previous }} + commit: ${{ steps.tag.outputs.commit }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - uses: $/install-locked-tools + with: + groups: release-notes + venv: .venv + - name: Verify tagged source + id: tag env: TAG: ${{ github.ref_name }} run: | - git tag --list | release-creator tags "$TAG" - prek run --all-files - - name: Publish GitHub Release + set -euo pipefail + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + git merge-base --is-ancestor HEAD origin/main + echo "commit=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" + - id: previous + uses: $/select-previous-tag + with: + tag: ${{ github.ref_name }} + executable: .venv/bin/release-creator + contributors: + needs: source + runs-on: ubuntu-26.04 + permissions: + contents: write + outputs: + artifact-id: ${{ steps.artifact.outputs.artifact-id }} + steps: + - name: Fetch contributor metadata env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} + PREVIOUS: ${{ needs.source.outputs.previous }} run: | set -euo pipefail - previous=$(git tag --list | release-creator tags "$TAG" --previous) - args=() - if [[ -n "$previous" ]]; then - args+=(--previous "$previous") - fi - release-creator notes --changelog CHANGELOG.md --tag "$TAG" \ - --repository "$GITHUB_REPOSITORY" "${args[@]}" \ - >"$RUNNER_TEMP/release-notes.md" - gh release create "$TAG" --verify-tag --title "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md" + args=(-f "tag_name=$TAG") + if [[ -n "$PREVIOUS" ]]; then args+=(-f "previous_tag_name=$PREVIOUS"); fi + gh api -X POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" "${args[@]}" --jq .body \ + >"$RUNNER_TEMP/generated-notes.md" + - id: artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: generated-notes + path: ${{ runner.temp }}/generated-notes.md + if-no-files-found: error + notes: + needs: [source, contributors] + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + artifact-id: ${{ steps.artifact.outputs.artifact-id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - uses: $/install-locked-tools + with: + groups: release-notes + venv: .venv + - name: Add locked tools to PATH + run: echo "$PWD/.venv/bin" >>"$GITHUB_PATH" + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.contributors.outputs.artifact-id }} + path: ${{ runner.temp }} + - uses: $/render-release-notes + with: + tag: ${{ github.ref_name }} + previous: ${{ needs.source.outputs.previous }} + changelog: CHANGELOG.md + generated-notes: ${{ runner.temp }}/generated-notes.md + output: ${{ runner.temp }}/release-notes.md + - id: artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-notes + path: ${{ runner.temp }}/release-notes.md + if-no-files-found: error + publish: + needs: [check, source, notes] + runs-on: ubuntu-26.04 + environment: release + permissions: + contents: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.notes.outputs.artifact-id }} + path: ${{ runner.temp }} + - uses: $/publish-github-release + env: + GH_TOKEN: ${{ github.token }} + with: + tag: ${{ github.ref_name }} + commit: ${{ needs.source.outputs.commit }} + title: ${{ github.ref_name }} + notes-file: ${{ runner.temp }}/release-notes.md diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 5dd7065..49a6580 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -3,38 +3,17 @@ on: workflow_dispatch: schedule: - cron: "17 9 * * 2" -permissions: - contents: write - pull-requests: write - actions: write +permissions: {} jobs: update: - runs-on: ubuntu-26.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - python-version: "3.15" - enable-cache: false - - name: Refresh the tool lock - run: | - uv pip compile --upgrade --group release --group test \ - --python-version 3.15 --format pylock.toml \ - --output-file pylock.toml pyproject.toml - - id: update - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - token: ${{ github.token }} - branch: automation/update-release-tools - add-paths: pylock.toml - commit-message: "🔧 build: refresh release tool lock" - title: "🔧 build: refresh release tool lock" - body: "Refresh the locked tools used by release preparation and CI." - - name: Run checks on the update branch - if: contains(fromJSON('["created","updated"]'), steps.update.outputs.pull-request-operation) - env: - GH_TOKEN: ${{ github.token }} - BRANCH: ${{ steps.update.outputs.pull-request-branch }} - run: gh workflow run check.yaml --ref "$BRANCH" + uses: $/.github/workflows/upgrade-release-tools.yaml + permissions: + contents: read + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + with: + lock-groups: >- + build lock-tools release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check + 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 new file mode 100644 index 0000000..142ea12 --- /dev/null +++ b/.github/workflows/upgrade-release-tools.yaml @@ -0,0 +1,243 @@ +name: Upgrade release tools +on: + workflow_call: + inputs: + lock-groups: + description: Release dependency groups selected by the universal lock + required: true + type: string + additional-lock: + description: Optional secondary pylock basename for a separate build matrix + type: string + default: "" + additional-lock-groups: + description: Groups for the secondary lock; empty selects build requirements + type: string + default: "" + 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: "" + commit-message: + description: Commit message for the refreshed dependency files + type: string + default: "🔧 build(deps): refresh release tools" + pull-request-title: + description: Title for the dependency pull request + type: string + default: "🔧 build(deps): refresh release tools" + pull-request-body: + description: Body for the dependency pull request + type: string + default: |- + Refresh the project lock and exact pre-commit hook dependency pins. + secrets: + RELEASE_APP_PRIVATE_KEY: + description: Repo-only release App key + required: true +permissions: {} +jobs: + generate-lock: + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + changed: ${{ steps.lock.outputs.changed }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - id: lock + uses: $/refresh-release-lock + with: + lock-groups: ${{ inputs.lock-groups }} + additional-lock: ${{ inputs.additional-lock }} + additional-lock-groups: ${{ inputs.additional-lock-groups }} + additional-matrix-python: ${{ inputs.additional-matrix-python }} + additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} + additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.lock.outputs.changed == 'true' + with: + name: release-tools-lock + path: ${{ runner.temp }}/lock.patch + if-no-files-found: error + retention-days: 7 + generate-hooks: + runs-on: ubuntu-26.04 + env: + UV_EXCLUDE_NEWER: "" + UV_EXCLUDE_NEWER_PACKAGE: "" + permissions: + contents: read + outputs: + changed: ${{ steps.hooks.outputs.changed }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - id: hooks + uses: $/refresh-hook-pins + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.hooks.outputs.changed == 'true' + with: + name: release-tools-hooks + path: ${{ runner.temp }}/hooks.patch + if-no-files-found: error + retention-days: 7 + publish: + needs: [generate-lock, generate-hooks] + if: | + (needs.generate-lock.outputs.changed == 'true' || needs.generate-hooks.outputs.changed == 'true') && + github.ref == 'refs/heads/main' + runs-on: ubuntu-26.04 + environment: release-auth + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + if: needs.generate-lock.outputs.changed == 'true' + with: + name: release-tools-lock + path: ${{ runner.temp }}/release-tools-lock + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + if: needs.generate-hooks.outputs.changed == 'true' + with: + name: release-tools-hooks + path: ${{ runner.temp }}/release-tools-hooks + - name: Apply lock patch + if: needs.generate-lock.outputs.changed == 'true' + run: | + git apply --check "$RUNNER_TEMP/release-tools-lock/lock.patch" + git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch" + - name: Apply hook patch + if: needs.generate-hooks.outputs.changed == 'true' + run: | + git apply --check "$RUNNER_TEMP/release-tools-hooks/hooks.patch" + git apply --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" + - name: Check dependency patch paths + env: + ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + run: | + set -euo pipefail + if [[ -n "$ADDITIONAL_LOCK" && ("$ADDITIONAL_LOCK" != pylock.*.toml || "$ADDITIONAL_LOCK" == */*) ]]; then + echo '::error::invalid additional lock basename' >&2 + exit 1 + fi + git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/dependency-paths" + while IFS= read -r -d '' path; do + if [[ "$path" != pylock.toml && "$path" != pyproject.toml && + "$path" != .pre-commit-config.yaml && "$path" != "$ADDITIONAL_LOCK" ]]; then + echo "::error::unexpected dependency path: $path" >&2 + exit 1 + fi + done <"$RUNNER_TEMP/dependency-paths" + git diff --cached --check + - name: Check dependency patch file mode + run: | + set -euo pipefail + git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/dependency-modes" + while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do + read -ra fields <<<"$metadata" + if [[ "${fields[1]}" != 100644 ]]; then + echo "::error::unexpected dependency file mode in $path: ${fields[1]}" >&2 + exit 1 + fi + done <"$RUNNER_TEMP/dependency-modes" + - uses: $/verify-nab-cutoff-patch + - name: Commit refreshed tools + env: + COMMIT_MESSAGE: ${{ inputs.commit-message }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m "$COMMIT_MESSAGE" + - name: Check release App credentials + env: + RELEASE_APP_CLIENT_ID: ${{ vars.RELEASE_APP_CLIENT_ID }} + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + run: | + test -n "$RELEASE_APP_CLIENT_ID" + test -n "$RELEASE_APP_PRIVATE_KEY" + - id: repository + name: Select the current repository + env: + REPOSITORY: ${{ github.repository }} + run: echo "name=${REPOSITORY#*/}" >>"$GITHUB_OUTPUT" + - id: token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ steps.repository.outputs.name }} + permission-contents: write + permission-pull-requests: write + - name: Push dependency branch + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + gh auth setup-git --hostname github.com --force + git push --force-with-lease origin HEAD:refs/heads/automation/update-release-tools + - name: Write dependency pull request body + env: + PR_BODY: ${{ inputs.pull-request-body }} + run: printf '%s\n' "$PR_BODY" >"$RUNNER_TEMP/pull-request.md" + - name: Find dependency pull request + id: pr + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + set -euo pipefail + owner=${GITHUB_REPOSITORY%%/*} + gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open \ + -f head="$owner:automation/update-release-tools" -f base=main | + jq -r --arg repository "$GITHUB_REPOSITORY" ' + [.[] | select(.head.repo.full_name == $repository and + .head.ref == "automation/update-release-tools" and .base.ref == "main")] | + if length == 0 then "number=" + elif length == 1 then "number=\(.[0].number)" + else error("multiple dependency pull requests") end + ' >>"$GITHUB_OUTPUT" + - name: Update dependency pull request + if: steps.pr.outputs.number != '' + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + PR_TITLE: ${{ inputs.pull-request-title }} + run: | + gh pr edit "$PR_NUMBER" --title "$PR_TITLE" --body-file "$RUNNER_TEMP/pull-request.md" \ + --add-label dependencies --add-label 'skip news' + - name: Open dependency pull request + if: steps.pr.outputs.number == '' + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + PR_TITLE: ${{ inputs.pull-request-title }} + run: | + gh pr create --base main --head automation/update-release-tools --title "$PR_TITLE" \ + --body-file "$RUNNER_TEMP/pull-request.md" --label dependencies --label 'skip news' diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml new file mode 100644 index 0000000..fca00f1 --- /dev/null +++ b/.github/workflows/validate-python-release.yaml @@ -0,0 +1,110 @@ +name: Validate Python release +on: + workflow_call: + inputs: + changelog: + description: Tracked changelog path + required: true + type: string + artifact-base-url: + description: Ecosystem release URL without the version + required: true + type: string + artifact-label: + description: Link text for the ecosystem release + default: PyPI release + type: string + local-tool-source: + description: Install release-creator from this checkout for its own release + default: false + type: boolean + source: + description: Package source directory + default: . + type: string + artifact-prefix: + description: Prefix for distribution artifacts in a multi-package release + default: "" + type: string +permissions: + contents: read +jobs: + source-check: + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - uses: $/install-locked-tools + with: + groups: release-check + venv: ${{ runner.temp }}/release-tools + - name: Run tagged source checks + run: >- + "$RUNNER_TEMP/release-tools/bin/prek" run --all-files + - name: Check source remains clean + run: test -z "$(git status --porcelain)" + validate: + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - name: Verify the exact release tag + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + test "$GITHUB_REF" = "refs/tags/$TAG" + git check-ref-format "refs/tags/$TAG" + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + git merge-base --is-ancestor HEAD origin/main + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - uses: $/install-locked-tools + with: + groups: release-notes${{ inputs.local-tool-source && ' project-runtime' || '' }} + venv: ${{ runner.temp }}/release-tools + local-tool-source: ${{ inputs.local-tool-source }} + - name: Add locked tools to PATH + run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH" + - uses: $/build-python-distribution + with: + source: ${{ inputs.source }} + - name: Install the local release tool + if: inputs.local-tool-source + run: uv pip install --python "$RUNNER_TEMP/release-tools/bin/python" --no-deps dist/*.whl + - name: Render tagged-source notes + uses: $/render-release-notes + with: + tag: ${{ github.ref_name }} + changelog: ${{ inputs.changelog }} + artifact-url: ${{ inputs.artifact-base-url }}${{ github.ref_name }}/ + artifact-label: ${{ inputs.artifact-label }} + output: ${{ runner.temp }}/release-notes.md + - uses: $/validate-python-distribution + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.artifact-prefix }}python-package-distributions + path: dist/* + if-no-files-found: error + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.artifact-prefix }}release-validation-notes + path: ${{ runner.temp }}/release-notes.md + if-no-files-found: error diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aa516b0..0810075 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,10 +2,17 @@ default_language_version: python: python3.15 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 38bfb07c0c64c304f47eb52090f8288150c77730 # v0.17.1 + hooks: + - id: ruff-check + args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] + - id: ruff-format + types_or: [python, pyi] - repo: local hooks: - id: mdformat @@ -13,21 +20,21 @@ repos: entry: mdformat --wrap 120 language: python types: [markdown] - additional_dependencies: [mdformat-gfm>=1] + additional_dependencies: [mdformat-gfm==1.0.0] - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.14.1-1 + rev: 479be5958357ba5ab65ce47172117348516860e9 # v3.14.1-1 hooks: - id: shfmt - args: ["-i", "2"] + args: ["-i", "2", "-d"] - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.11.0.1 + rev: 745eface02aef23e168a8afb6b5737818efbea95 # v0.11.0.1 hooks: - id: shellcheck - repo: https://github.com/google/yamlfmt - rev: v0.21.0 + rev: 21ca5323a9c87ee37a434e0ca908efc0a89daa07 # v0.21.0 hooks: - id: yamlfmt - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.31.1 + rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # v1.31.1 hooks: - id: zizmor diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0ea3e..c8abb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,14 @@ ### Added - Support Towncrier fragment directories outside the repository root during version selection and PR linking. - (fragment-directory) + ([#2](https://github.com/tox-dev/github-workflows/pull/2)) by [@gaborbernat](https://github.com/gaborbernat). ### Documentation -- Put the Action setup and input reference in the repository README, with examples for each step. (readme) +- Put the Action setup and input reference in the repository README, with examples for each step + ([#2](https://github.com/tox-dev/github-workflows/pull/2)) by [@gaborbernat](https://github.com/gaborbernat). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48db84a..9871625 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,49 +1,52 @@ # Contributing Changes to a shared Action can affect every caller pinned to its next release. Describe the behavior you are changing -and exercise it through the PR release rehearsal on a GitHub runner. +and test its inputs and outputs before opening a pull request. ## Set up -Use Python 3.15 and the checked-in release-tool lock: +Use Python 3.15. Install the release tools from the lock and test tools from the current index: ```bash -uv venv --python 3.15 .venv -uv pip sync --preview-features pylock --python .venv/bin/python pylock.toml -export PATH="$PWD/.venv/bin:$PATH" +uv venv --allow-existing --python 3.15 .venv +uv venv --allow-existing --python 3.15 .venv/lock-tools +uv pip sync --preview-features pylock --python .venv/lock-tools/bin/python --group lock-tools \ + --only-binary :all: --require-hashes pylock.toml +.venv/lock-tools/bin/python tools/build_constraints.py .venv/build-constraints.txt pylock.toml +uv venv --allow-existing --python 3.15 .venv/release-tools +uv pip sync --preview-features pylock --preview-features build-dependency-hashes \ + --python .venv/release-tools/bin/python --group release-hooks \ + --build-constraints .venv/build-constraints.txt --require-build-hashes pylock.toml +uv pip install --python .venv/bin/python --group test +export PATH="$PWD/.venv/release-tools/bin:$PWD/.venv/bin:$PATH" ``` Run the checks before pushing: ```bash prek run --all-files -ruff format --check . -ruff check . -ty check tests -pytest -q +.venv/bin/tox run -e type +.venv/bin/pytest -q -n auto ``` `prek` formats YAML with `yamlfmt` and runs zizmor. Run it again after accepting formatter changes. CI extracts inline shell from the workflows and Actions, then runs ShellCheck and `shfmt` on each block. The tests extract the exact `run:` -blocks from each composite Action and execute them in temporary Git repositories. CI uses `kcov` to require 100% line -coverage of those blocks. The PR rehearsal also executes the composite Actions on a GitHub runner to check their wiring. +blocks from each composite Action and execute them in temporary Git repositories. ## Change an Action -Keep its `action.yml` inputs and step behavior in sync with the [README](README.md). The PR `release-dry-run` job -invokes all three composite Actions, checks rejected inputs, and uploads the patch and rendered GitHub notes. Inspect -its steps and artifacts when changing input mapping or release output. Add a runner failure case when changing input -validation. +Keep its `action.yml` inputs and step behavior in sync with the [README](README.md). Test rejected inputs and inspect +the patch and rendered notes when changing release output. Add a failure case when changing input validation. -The release preparation steps must work without a write token. Keep repository-specific build and publisher steps in the -caller. Do not add a secret to an Action input when a short-lived, repository-scoped token is sufficient. +The release preparation and Python build steps must work without a write token. Keep custom build and publisher steps in +the caller. Do not add a secret to an Action input when a short-lived, repository-scoped token is sufficient. ## Open a pull request -For a user-visible change, add a fragment such as `changelog.d/+explain-inputs.doc.md`. Use the category directories -listed in `pyproject.toml`; the filename needs no issue or PR number. A squash merge lets `release-creator` link the -fragment to the PR and credit its authors. Put each distinct change in its own fragment. Write the effect on users, -without contributor credits or bot-generated changes that have no user-facing effect. +For a user-visible change, add a fragment such as `changelog.d/+explain-inputs.doc.md`. Use the category suffixes listed +in `pyproject.toml`; the filename needs no issue or PR number. A squash merge lets `release-creator` link the fragment +to the PR and credit its authors. Put each distinct change in its own fragment. Write the effect on users, without +contributor credits or bot-generated changes that have no user-facing effect. For internal-only work, apply the `skip news` label. It may be applied after the PR opens. Bot PRs need no fragment unless they supply one; any supplied fragment is checked. PRs are squash-merged after the required checks pass. The diff --git a/README.md b/README.md index 3c8c76c..a460f71 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,445 @@ # GitHub workflows -Three composite Actions prepare a Towncrier release, verify its patch, and push an App-authored commit and tag. They run -as steps in the caller's workflow. Each repository keeps its dispatch form, build, publisher, GitHub environment, and -required check names. +Composite Actions check PR news, prepare a Towncrier release, verify its patch, push an App-authored commit and tag, +build a Python wheel and sdist, and render GitHub Release notes. They run as steps in the caller's workflow. Each +repository keeps its dispatch form, publisher, GitHub environment, and required check names. + +## Install locked tools + +`install-locked-tools` creates a virtual environment and installs selected groups from the caller's `pylock.toml` by +default. `lock-source: shared` selects this Action repository's `pylock.toml` for shared jobs such as hook maintenance +that do not need the caller's release tools. Nab's universal lock records the selected release groups for Linux, macOS, +and Windows. It resolves dependencies under the seven-day cutoff in `[tool.nab]`; test, type, and documentation +dependencies resolve fresh. The Action rejects missing groups and leaves tracked files unchanged. Source builds use +constraints generated from the lock's `build` group under the runner's temporary directory. + +- `venv` (required): directory for the virtual environment. +- `groups` (required): whitespace-separated release dependency groups. +- `lock-source`: `caller` (default) installs from the checkout's lock; `shared` installs from the pinned Action's lock. + Both paths use the pinned Action's lock-tool bootstrap. The Action checks that every selected group exists in the + selected lock and applies hashed build constraints. +- `local-tool-source`: set to `true` only when release-creator builds and installs its own wheel after this Action. The + caller's lock then needs the selected groups but does not need a release-creator package entry. + +There is no named output. The installed commands are in `venv/bin` or Windows `venv/Scripts`; add that directory to +`GITHUB_PATH` when later steps call them by name. Run `actions/checkout` and `astral-sh/setup-uv` with Python 3.15 +before this Action. + +`install-nab` is the shared bootstrap used by `install-locked-tools`, `export-build-constraints`, and +`refresh-release-lock`. It takes no inputs and has no outputs. It creates `$RUNNER_TEMP/lock-tools` and installs the +hash-checked `lock-tools` group from this repository's pinned `pylock.toml`; it does not install caller dependencies. + +## Check pull request news + +`validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the +configured directory against the configured categories, filename format, and nonempty content. A valid fragment runs +`towncrier check` against the PR base. A human PR without a fragment needs `skip news`; a bot PR without a fragment does +not. A bot's supplied fragment is checked and included in the normal release flow. The Action reads repository files and +Git history; it does not add labels or write to GitHub. + +Keep the caller's existing `changelog` job name and full checkout. Install Towncrier from the caller's release lock +before calling the Action: + +```yaml +changelog: + if: github.event_name == 'pull_request' + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@CHECKOUT_SHA + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@SETUP_UV_SHA + with: + version: latest-known + python-version: '3.15' + - uses: tox-dev/github-workflows/install-locked-tools@SHA + with: + groups: release-news + venv: ${{ runner.temp }}/release-tools + - name: Expose release tools + run: echo "$RUNNER_TEMP/release-tools/bin" >>"$GITHUB_PATH" + - uses: tox-dev/github-workflows/validate-news@SHA +``` + +The Action expects a pull request base ref and a Markdown or reStructuredText changelog. The caller should also run on +`labeled` and `unlabeled` events, so a label added after opening the PR updates the required check. + +`label-bot-news` adds `skip news` to a bot PR with no fragment and removes it when the bot adds one. It reads the PR +file list through GitHub's API and never checks out PR code. Keep it in a separate `workflow_run` caller so the +read-only PR workflow does not receive Issues write access. `resolve-pr-from-run` verifies that a completed run came +from that PR workflow, then looks up the current PR by its source owner, branch, repository, and exact head SHA. It +returns no PR when the branch has moved. This also handles fork PRs, whose workflow-run payload may omit the PR number. + +The [caller in this repository](.github/workflows/label-bot-news.yaml) shows the event and permissions. It runs after +the read-only `Check` workflow finishes, even if its tests fail. Set `workflows` to that caller's PR workflow name and +`workflow-path` to its file. The label action accepts `fragments` and `extension` for an RST repository, and `fragments` +accepts comma-separated directories when the caller also recognizes a legacy fragment path: + +```yaml +name: Label bot news +on: + workflow_run: + workflows: [Check] + types: [completed] +permissions: {} +jobs: + label: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-26.04 + permissions: + actions: read + issues: write + pull-requests: read + steps: + - id: pr + uses: tox-dev/github-workflows/resolve-pr-from-run@SHA + with: + run-id: ${{ github.event.workflow_run.id }} + workflow-path: .github/workflows/check.yaml + - if: steps.pr.outputs.bot-author == 'true' + uses: tox-dev/github-workflows/label-bot-news@SHA + with: + pull-request: ${{ steps.pr.outputs.number }} + bot-author: 'true' + fragments: docs/changelog + extension: rst +``` + +`resolve-pr-from-run` takes the source `run-id` and `workflow-path` in the caller repository. It outputs `number` and +`bot-author`; `number` is empty when the run's head is no longer the open PR head. It reads GitHub metadata and writes +no repository files. `label-bot-news` takes that verified number and bot flag. For `.changeset/.md`, pass +`fragments: .changeset` and `fragment-style: changeset`. `fragment-style` defaults to `towncrier`, which expects +`..md` or RST. It only changes the `skip news` label when the bot's fragment state and label disagree. +Label writes made with `GITHUB_TOKEN` do not trigger PR CI. The next contributor or bot update runs the news check +against the settled label state. + +## Refresh release tools and hooks + +Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only jobs refresh the universal +`pylock.toml` and the frozen pre-commit hooks in parallel. Nab reads the seven-day package cutoff and any reviewed +package exceptions from `[tool.nab]`. The hook updater applies the same cooldown to hook revisions and their +`additional_dependencies`. Each job uploads a patch. The writer accepts changes to `pylock.toml`, an explicitly named +secondary lock, and `.pre-commit-config.yaml`, plus removal of expired Nab cutoffs in `pyproject.toml`. It independently +checks those cutoff removals before minting the App token, then opens or updates one dependency PR from +`automation/update-release-tools` to `main`. A lock or hook update runs the normal PR checks before merge. + +The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this +repository, set `RELEASE_APP_CLIENT_ID` as a repository variable, and store `RELEASE_APP_PRIVATE_KEY` in the +`release-auth` environment. Restrict that environment to `main`. Create `dependencies` and `skip news` labels, and allow +the App to push the dependency branch. The caller supplies the secret by name; GitHub requires this mapping even when +the key exists only in the writer job's environment. + +```yaml +name: Upgrade release tools +on: + workflow_dispatch: + schedule: + - cron: 0 8 * * 2 +permissions: {} +jobs: + upgrade: + uses: >- + tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@SHA + permissions: + contents: read + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + with: + lock-groups: >- + build lock-tools release-notes release-news release-prepare + release-validate release-check release-attestations release-shell + release-hooks release-hook-check +``` + +The required `lock-groups` input lists the caller's release dependency groups, separated by spaces or newlines. Include +`build` and `lock-tools` where declared; exclude test, type, and documentation groups. This example lists this +repository's groups; a caller supplies its own. The project configures its Python and platform targets in `[tool.nab]`. +For a separately checked-in build matrix lock, also pass `additional-lock` with an existing `pylock.*.toml` basename, +`additional-matrix-python` with its Python range, and `additional-matrix-platforms` and +`additional-matrix-implementations` as space-separated Nab values. The secondary lock keeps its specified environment +matrix during refresh. `additional-lock-groups` selects dependency groups for it; when empty, Nab locks the project's +build requirements. These inputs have no effect when `additional-lock` is absent, and matrix inputs without that lock +fail. The writer accepts only the named secondary lock path. + +For a repository that already commits `pylock.build.toml`, the complete caller is: + +```yaml +name: Upgrade release tools +on: + workflow_dispatch: + schedule: + - cron: 0 8 * * 2 +permissions: {} +jobs: + upgrade: + uses: >- + tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@SHA + permissions: + contents: read + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + with: + lock-groups: build lock-tools release-news release-prepare release-check + additional-lock: pylock.build.toml + additional-matrix-python: '>=3.15,<3.16' + additional-matrix-platforms: linux_x86_64 macos_arm64 windows_amd64 + additional-matrix-implementations: cpython +``` + +The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The +workflow does not update `uv.lock` or a checked-in constraints file. Build constraints are generated from the lock's +`build` group at runtime. + +`refresh-release-lock` is the read-only half of the updater. It accepts the same secondary-lock inputs. In `refresh` +mode, it upgrades the selected release groups under Nab's configured cutoff, runs `prek`, and writes +`$RUNNER_TEMP/lock.patch` if either lock or an expired Nab cutoff changes. In `check` mode, it regenerates without +upgrading and fails if the checked-in files differ; the caller's normal formatting job runs `prek`. It rejects changes +outside the selected locks and expired cutoff policy. Its `changed` output is `true` only when the refresh produces a +patch. Neither mode pushes a branch or requests a write token. Check it on a PR in a job with Contents read permission: + +```yaml +release-tools-preview: + if: github.event_name == 'pull_request' + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@CHECKOUT_SHA + with: + persist-credentials: false + - uses: astral-sh/setup-uv@SETUP_UV_SHA + with: + version: latest-known + python-version: '3.15' + - uses: tox-dev/github-workflows/refresh-release-lock@SHA + with: + lock-groups: >- + build lock-tools release-notes release-news release-prepare + release-validate release-check release-attestations release-shell + release-hooks release-hook-check + mode: check +``` + +`refresh-hook-pins` is the matching read-only Action. It freezes eligible revisions with +`prek update --freeze --cooldown-days 7`. For selected Python hooks, it reads each hook repository's package metadata +and pins both the hook package dependencies and `additional_dependencies` under the seven-day cutoff. Non-Python hooks +keep their frozen revisions. It then runs the hooks. Its `changed` output is `true` only when `.pre-commit-config.yaml` +changes; the patch is `$RUNNER_TEMP/hooks.patch`. It fails when a hook changes another tracked file. The Action does not +write to GitHub. + +`check-hook-update-cooldown` checks proposed hook revisions on bot PRs. Put it in the caller's `pull_request` workflow +with `actions: read`, `contents: read`, and `pull-requests: read`, then include it in the required aggregate. It reads +its PR number and head SHA from the event, compares them with the live PR, and requires each proposed revision to have +appeared in a GitHub PR workflow run at least `minimum-days` earlier. The default is seven days. A force push does not +reset an unchanged revision's age or give a new revision the old one's age. Human PRs pass without installing hook +tools. + +```yaml +hook-cooldown: + if: github.event_name == 'pull_request' + runs-on: ubuntu-26.04 + permissions: + actions: read + contents: read + pull-requests: read + steps: + - uses: tox-dev/github-workflows/check-hook-update-cooldown@SHA +``` + +Run the PR workflow on `opened`, `reopened`, and `synchronize`; the cooldown check must succeed on PRs and may skip on +push. The Action has no output or write side effect. Schedule `recheck-hook-update-cooldown` daily with +`actions: write`, `contents: read`, and `pull-requests: read`. It reruns eligible failed cooldown jobs at the current +head. GitHub accepts reruns for 30 days. A failed rerun is retried on the next schedule while eligible. The +[caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. Set `workflow` or +`job-name` only when the caller uses different names. + +Run the hook preview in its own PR job so its patch check sees only the hook configuration: + +```yaml +hook-pins-preview: + if: github.event_name == 'pull_request' + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@CHECKOUT_SHA + with: + persist-credentials: false + - uses: astral-sh/setup-uv@SETUP_UV_SHA + with: + version: latest-known + python-version: '3.15' + - uses: tox-dev/github-workflows/refresh-hook-pins@SHA +``` ## Start with a caller -The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository is a complete caller. Its -`assemble` job creates a patch without a write credential. Its `push` job applies and checks that patch before minting a -repository-scoped App token. Copy those jobs, then set the changelog path, fragment directory, commit message, and tag -style. Replace this repository's relative `uses: $/…` references with a full release commit SHA. In the examples below, -`SHA` means that release commit and `TOKEN_ACTION_SHA` means the pinned token Action commit in the linked workflow. +The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable +Towncrier workflow. Its `assemble` job creates a patch, validates it in a fresh worktree without the App key, and +records the patch digest. Its `push` job checks the patch and digest again before minting a repository-scoped App token. +Set the tag style in the caller and the changelog path and fragment directory in committed Towncrier configuration. Pin +the reusable workflow to a release commit SHA; `SHA` in these examples means that commit. -Both jobs need a full checkout (`fetch-depth: 0`, `persist-credentials: false`) and `release-creator`, Towncrier, and -`prek` installed from the caller's checked-in `pylock.toml`. Preparation needs `contents: read` and -`pull-requests: read` to resolve PR authors. The `push` job uses the `release-auth` environment with -`RELEASE_APP_PRIVATE_KEY` and the repository variable `RELEASE_APP_CLIENT_ID`. Install the App on that repository only, -with Contents write and Metadata read. Give it the necessary branch and tag ruleset bypass. +Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. Both +jobs use `persist-credentials: false`. Assembly installs the caller's locked release tools and needs `contents: read` +and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while creating the validation worktree +and building the changelog, then waits for the hooks before formatting and validating the patch. The writer uses the +`release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that environment to +`main`. Install the App on this repository only, with Contents write and Metadata read, and grant the necessary branch +and tag ruleset bypass. ```text main + fragments │ ▼ -prepare-towncrier ── version + patch ──► upload artifact - │ - download artifact - │ - ▼ - validate-release-patch - │ - mint App token - │ - ▼ - commit-and-tag ──► main + tag +prepare-towncrier ──► validate in fresh worktree ──► upload patch + digest + │ + ▼ + verify patch ──► mint App token + │ + ▼ + commit-and-tag ──► main + tag ``` The tag starts the caller's publisher. Keep PyPI trusted publishing in that top-level workflow, in a separate job with -`id-token: write`: -[PyPI does not support trusted publishing inside reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github). -Run `prek` on the tagged source before publication. The [self-release workflow](.github/workflows/release.yaml) shows -the GitHub Release path; callers keep their ecosystem builds and uploads. +`id-token: write` because +[PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Run `prek` in a separate +read-only job alongside the tagged-source build, and make publishing depend on both. Repositories with the standard `uv` +wheel and sdist build can use `build-python-distribution` in the tag publisher. The +[self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem +uploads. The read-only build workflow checks the tagged source and renders the Release body. The tag caller alone has a +Contents write job that creates the GitHub Release. + +## Validate a Python release + +`validate-python-release.yaml` runs read-only source checks, notes rendering, a hash-constrained wheel and sdist build, +and distribution checks on a release tag. Before building, it requires the fully qualified tag ref to resolve to the +checked-out commit and that commit to be on `main`. The workflow uses `$/` for its own Actions, so a caller pinned to a +workflow commit runs those Actions from the same commit. + +Call the pinned workflow from the tag publisher's build job: + +```yaml +build: + uses: >- + tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA + permissions: + contents: read + with: + changelog: CHANGELOG.md + artifact-base-url: https://pypi.org/project/example/ +``` + +`changelog` is the rendered changelog path. `artifact-base-url` ends just before the version; the workflow appends +`1.2.3/` for tag `1.2.3`. Set it to the package's actual PyPI project path. `artifact-label` changes the link text from +`PyPI release`. reStructuredText `:issue:` references link to issues; `:pr:` and `:pull:` link to pull requests. + +The workflow uses Python 3.15 and `pylock.toml`. The caller declares `release-check` for `prek`, `release-notes` for +`release-creator`, `pkg-meta` for Twine and check-wheel-contents, and a `build` group for backend constraints. `source` +defaults to the repository root for `uv build`. `artifact-prefix` defaults to empty; set it when a tag builds more than +one distribution path so the jobs use distinct artifact names. Set `local-tool-source: true` for release-creator's own +release; the keyless job builds its tagged wheel and installs it with locked third-party dependencies. The installer +rejects an absent group before syncing. + +The workflow uploads `python-package-distributions` with the wheel and sdist, and `release-validation-notes` with the +body rendered from the tagged source. Callers download the distribution with pinned `download-artifact` +[v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), +whose default fails on an artifact digest mismatch. Its notes lack GitHub contributor metadata. The tag-only GitHub +Release job must fetch generated notes with Contents write, rerender with `render-release-notes`, and create the Release +after the ecosystem upload. PyPI OIDC stays in the caller's separate `release` environment job. + +The PyPI publisher downloads the complete validated distribution artifact. It can run alongside GitHub note rendering +once the build succeeds. Verify PyPI and its attestations before publishing the GitHub Release. + +## Prepare and tag with one reusable workflow + +`prepare-towncrier-release.yaml` runs two jobs. `assemble` selects a tag and writes a Towncrier patch without the App +key, applies it in a fresh worktree, runs `prek`, and records its digest. `push` checks the digest and patch paths, +reads the caller's `release-auth` environment, and uses a repository-scoped App token to push the release commit and tag +together. Only a dispatch from `main` can reach the writer. + +```yaml +jobs: + prepare: + if: github.ref == 'refs/heads/main' + uses: >- + tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA + permissions: + contents: read + pull-requests: read + with: + bump: ${{ inputs.bump }} + tag-prefix: v + annotated: true + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} +``` + +The caller supplies the `release-auth` environment secret by name; the App client ID comes from that environment's +`RELEASE_APP_CLIENT_ID` variable. The App token requests Contents write for this repository only. The caller grants its +GitHub token Contents and Pull requests read. Assembly installs `release-prepare`, which contains release-creator, +Towncrier, and prek. No job output crosses back to the caller. A successful run pushes a tag, which the caller's tag +workflow handles. + +`bump`, `tag-prefix`, and `changelog-prefix` have the same meanings as the `prepare-towncrier` inputs below. `annotated` +selects an annotated tag. `release-auth-deployment` defaults to `true`; set it to `false` if the current App-key job +uses `release-auth` without creating a GitHub Deployment. `local-tool-source: true` builds release-creator's wheel from +the checkout and installs it with locked third-party dependencies in assembly; use it for release-creator itself. The +caller can place repository-specific checks in a preceding job and make this job depend on it. One dispatch prepares one +tag; run separate dispatches for separate packages. The tag-triggered ecosystem publisher stays in the caller +repository. + +## Read Towncrier configuration + +`read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`. It requires +normalized changelog and fragment paths, a Markdown or RST changelog suffix, and explicit fragment categories. It reads +committed bytes so an untrusted release patch cannot change the allowed paths or categories. + +```yaml + - id: towncrier + uses: tox-dev/github-workflows/read-towncrier-config@SHA + - env: + CHANGELOG: ${{ steps.towncrier.outputs.changelog }} + run: printf '%s\n' "$CHANGELOG" +``` + +`working-directory` defaults to `.`; pass a different checkout when validating a patch in a linked worktree. Outputs are +`changelog`, `fragments`, and `format` (`markdown` or `rst`). The Action does not modify the checkout or GitHub state. ## Prepare Towncrier release -`prepare-towncrier` reads a clean checkout, chooses the next version, links named fragments to their squash PRs, runs -Towncrier and `prek`, and writes a binary Git patch. It does not commit or push. It fails if version selection, notes -rendering, formatting, or checks fail, or if preparation creates untracked files or no patch. +`prepare-towncrier` reads a clean checkout, chooses the next version, links fragments to their squash PRs, runs +Towncrier and the selected formatter hook through `prek`, then writes a binary Git patch at +`$RUNNER_TEMP/release.patch`. It reads the changelog path, fragment directory, categories, and Markdown or RST format +from committed Towncrier configuration through `read-towncrier-config`. It does not commit or push. It fails if version +selection, notes rendering, formatting, or checks fail, or if preparation creates untracked files or no patch. ```yaml - id: prepare uses: tox-dev/github-workflows/prepare-towncrier@SHA with: bump: ${{ inputs.bump }} - changelog: docs/changelog.md - fragments: docs/changelog.d - format: markdown - patch: ${{ runner.temp }}/release.patch ``` - `bump`: `auto` (default), `major`, `minor`, `patch`, or `calver`. Passed to `release-creator tags --next`; `auto` uses the pending fragments. The caller's dispatch form decides which choices to expose. +- `tag-prefix`: Empty by default. Use `v` when release tags are spelled `v1.2.3`; version comparison still includes + unprefixed numeric tags in the repository's history. This does not set the changelog heading prefix. +- `changelog-prefix`: Empty by default, so Towncrier writes `1.2.3` even if the tag is `v1.2.3`. Set `v` for a changelog + whose release headings use `v1.2.3`. - `version`: Explicit version for a caller with its own version selector. It takes precedence over `bump`; the Action still checks that it is newer than existing tags. -- `changelog` **(required)**: Tracked file Towncrier writes, such as `CHANGELOG.md` or `docs/changelog.rst`. This must - match `tool.towncrier.filename` in the caller's `pyproject.toml`. -- `fragments`: Fragment directory relative to the checkout root; default `changelog.d`. Set this to the caller's - `tool.towncrier.directory`. Version selection and PR linking both read this directory. -- `format`: `none` (default) or `markdown`. `markdown` runs the caller's `mdformat` prek hook on the assembled - changelog. Use `none` for RST or when that hook does not exist. The full `prek` suite runs in either case. -- `link-fragments`: `true` (default) resolves named fragments and authors through GitHub; `false` skips that step for a - caller whose fragments already contain release links and credits. -- `patch` **(required)**: Writable path to the Git patch, usually under `runner.temp`. Upload this exact file as an - artifact for the validation job. +- `phase`: `all` (default) runs the complete Action. The reusable workflow uses `build` to select the version, link + authors, and build the changelog while hooks install; it then uses `finalize` to format, render, and write the patch. + Pass the version selected by `build` to `finalize`. + +The committed `tool.towncrier` configuration must give a normalized `filename`, `directory`, and explicit fragment +categories. The filename must end in `.md` or `.rst`; `mdformat` or `docstrfmt` must be a configured `prek` hook for +that format. The Action writes the patch at `$RUNNER_TEMP/release.patch`; the reusable workflow uploads it by artifact +ID. **Output:** `version` is the selected tag string. Use `steps.prepare.outputs.version` as a job output and pass it to both later Actions. **Working-tree effect:** Towncrier changes the changelog and consumes fragments; the patch records @@ -79,40 +447,38 @@ those changes. The checkout must be clean before this Action starts. ## Validate release patch -`validate-release-patch` starts from a fresh checkout of the original branch commit. It rejects a branch that moved, -applies the uploaded patch, checks its changed paths and version heading, and runs `prek`. It leaves the patch staged -for the commit Action. It does not need a write token. +`validate-release-patch` runs in the caller's fresh checkout of the original branch commit. It rejects a `main` branch +that moved, applies the uploaded patch, checks its changed paths and file modes, then asks `release-creator` to verify +that every deleted file is a configured Towncrier fragment. It renders the requested version and runs `prek`. It leaves +the patch staged for the commit Action. Run it in a keyless validation job. The App-key job repeats the branch, digest, +path, and mode checks with `checks: "false"`, without installing release tools. ```yaml - uses: tox-dev/github-workflows/validate-release-patch@SHA with: version: ${{ needs.assemble.outputs.version }} patch: ${{ runner.temp }}/release-patch/release.patch - changelog: docs/changelog.md - fragments: docs/changelog.d ``` -- `version` **(required)**: Exact tag chosen by preparation. The changelog must contain a heading for it. +- `version` **(required)**: Exact tag chosen by preparation. `release-creator notes` must find one valid section for it. - `patch` **(required)**: Local path of the downloaded patch artifact, not an artifact name or URL. -- `changelog` **(required)**: Same tracked path passed to preparation. Only this file and files below `fragments` may - change in the patch. -- `fragments` **(required)**: Same directory passed to preparation. Towncrier may consume or rename its files. -- `heading-prefix`: Text before the version in its heading; default `## `. For `Version 1.2.3 (date)`, use `Version `. - The Action expects the version to be followed by ` (`. -- `branch`: Protected release branch; default `main`. -- `remote`: Git remote fetched for the freshness check; default `origin`. -- `expected-sha`: Commit that preparation checked out. By default the Action uses `GITHUB_SHA`; set this when - preparation used another commit. - -**Output:** No named output. **Working-tree effect:** Applies and stages the patch. A failed `prek` run, unexpected -path, or modification by `prek` fails the job before a write credential is minted. +- `working-directory`: Checkout where the patch is applied; default `.`. Assembly uses a fresh linked worktree here so + validation cannot pass because of files left by preparation. +- `expected-patch-sha`: SHA-256 digest of the patch from a separate keyless validation job. The Action rejects a + different downloaded patch before applying it. +- `checks`: `true` (default) runs `release-creator` and `prek`; `false` only applies and checks the validated patch. + +**Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path, +fragment directory, and categories from committed Towncrier configuration. Only the changelog and direct fragment +deletions may appear in the patch. A failed `prek` run, unexpected path or mode, or modification by `prek` fails the +keyless job. The key job accepts only the validated digest. ## Commit and tag release `commit-and-tag` accepts only a staged release patch in a clean working tree. It commits with the GitHub App bot identity, creates the tag, and atomically pushes the branch and tag to `origin`. A rejected push leaves neither remote ref updated. Mint the token after validation with `actions/create-github-app-token`, narrowed to this repository and -Contents write. +Contents write. Put `commit-and-tag` last in the writer job: its Git credential helper remains available to later steps. ```yaml - id: token @@ -135,20 +501,277 @@ Contents write. - `token` **(required)**: Short-lived installation token from the repository-only App. The Action never reads the key. - `app-slug` **(required)**: `app-slug` output from the token Action, used to resolve the bot's Git identity. - `message` **(required)**: Commit message. It is also the annotated tag message when `annotated` is `true`. -- `branch`: Target branch; default `main`. The checkout must still be at its validated commit. - `annotated`: `false` (default) creates a lightweight tag; `true` creates an annotated tag. Match the repository's existing release history. -**Output:** No named output. **Remote effect:** One new commit on `branch` and one tag at that commit. The App needs a +**Output:** No named output. **Remote effect:** One new commit on `main` and one tag at that commit. The App needs a branch and tag ruleset bypass. A stale branch, invalid tag, or ruleset rejection fails the push. -## Check a caller before switching +## Build Python distribution + +`build-python-distribution` builds a wheel and sdist with `uv`. The build job checks out the tagged source and sets up +`uv` first. Run `prek` in a separate read-only job alongside the build, and require both before publishing. The caller +verifies the tag and changelog section before this Action. + +```yaml + - uses: tox-dev/github-workflows/build-python-distribution@SHA + with: + source: . +``` + +- `source`: Source directory passed to `uv build`; default `.`. + +**Output:** No named output. **Working-tree effect:** Writes a wheel and sdist to `dist`. The checkout must be clean +before the Action starts. The Action exports hashed backend constraints from the lock's `build` group at runtime; +`uv build` rejects a missing hash. Use a separate build path for repositories whose build command differs from +`uv build --sdist --wheel`. + +## Validate Python distribution + +`validate-python-distribution` runs Twine and check-wheel-contents on the files the build job will upload. It installs +both tools from the caller's locked `pkg-meta` group. Run it after `build-python-distribution` and before +`upload-artifact`. + +```yaml + - uses: tox-dev/github-workflows/validate-python-distribution@SHA +``` + +The Action takes no inputs and checks `dist` on Python 3.15. **Output:** No named output. **Working-tree effect:** None. +An invalid sdist or wheel stops before the artifact upload. + +## Render GitHub Release notes + +`render-release-notes` checks the tag and its changelog section, finds the previous tag, and renders an unwrapped GitHub +Release body with `release-creator`. Call it on the actual tag. The publisher still fetches GitHub's generated notes and +creates the Release; this Action only writes a local Markdown file. + +```yaml + - uses: tox-dev/github-workflows/render-release-notes@SHA + with: + tag: ${{ github.ref_name }} + changelog: CHANGELOG.md + artifact-url: https://pypi.org/project/example/${{ github.ref_name }}/ + generated-notes: ${{ runner.temp }}/generated-notes.md + output: ${{ runner.temp }}/release-notes.md +``` + +- `tag` **(required)**: Release version, such as `1.2.3`. The checkout must contain its changelog section. +- `changelog` **(required)**: Tracked Markdown or reStructuredText changelog in the format that `release-creator notes` + parses, with exactly one section for the tag. +- `artifact-url`: Ecosystem release URL, such as a PyPI version page. Omit it for a GitHub-only project. +- `artifact-label`: Link text for `artifact-url`; default `PyPI release`. Use `Zipapp` or another product name for a + different artifact. +- `generated-notes`: Path to GitHub's generated-notes body. The tag publisher fetches it after PyPI publication. It + preserves New Contributors and fills any missing `by @handle` credit. Omit it when the release has no GitHub-generated + contributor metadata. +- `output` **(required)**: Writable path for the GitHub Release body. Pass it to `gh release create --notes-file` only + after the ecosystem publisher succeeds. + +The Action reads `GITHUB_REPOSITORY` for PR and comparison links. A repository with separate release namespaces, such as +toml-fmt, calls `release-creator notes --package` directly for its package changelog. + +**Output:** `previous` is the nearest earlier version tag, or empty for the first release. **Working-tree effect:** The +Action writes only `output`. It rejects an invalid tag, a missing heading, an empty body, or a missing comparison link +when an earlier tag exists. It neither fetches generated notes nor writes a GitHub Release, so a PR job needs only read +permissions. Fetching generated notes uses GitHub's release endpoint and needs Contents write in the tag publisher. + +## Select the previous release tag + +`select-previous-tag` finds the preceding numeric tag using release-creator's version rules. The caller must check out +the tag history and install release-creator first. It reads Git tags and writes one step output; it does not change the +checkout or GitHub state. + +```yaml + - id: previous + uses: tox-dev/github-workflows/select-previous-tag@SHA + with: + tag: ${{ github.ref_name }} +``` + +`tag` is required. Set `package` for a namespace such as `pyproject-fmt/2.30.3`; set `stable-only: "true"` when a stable +release must skip prerelease tags. `executable` defaults to `release-creator` on `PATH` and accepts a path to a locked +installation. The `previous` output is empty when this is the first release. Pass it as `previous_tag_name` to GitHub's +generated-notes endpoint only when nonempty. + +## Reconcile PyPI artifacts + +`reconcile-pypi-artifacts` compares the complete validated distribution directory with PyPI's Simple JSON API and +verifies a trusted-publisher attestation for each file. Upload the complete validated directory with the pinned PyPI +publisher and `skip-existing: true`. A rerun sends only files missing from PyPI. Run this Action in a read-only job +after the publisher. A different digest in an already published file, unexpected or yanked file, malformed response, or +invalid attestation fails. The GitHub Release writer must depend on this verification. + +```yaml + - uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA + with: + project: example + version: ${{ github.ref_name }} + dist: dist +``` + +`project`, `version`, and `dist` are required. The Action installs locked `release-notes` and `release-attestations` +groups on Python 3.15 and waits up to 120 seconds for PyPI's listing to propagate. Set `local-tool-source: "true"` only +when releasing release-creator itself; it installs the wheel already built in `dist`. The Action prints no credentials; +it checks the complete local file set and each published file's attestation against the caller repository. + +## Publish a GitHub Release + +`publish-github-release` verifies the tag and its rendered notes before writing a GitHub Release. Pass the exact notes +file and the tag's commit SHA. A retry resumes a matching draft, or succeeds without a write when the published Release +already matches. Run it in a Contents-write job that downloads the notes and optional assets; it needs no checkout or +tool installation. For a repository that must upload GitHub assets before PyPI, call it with `mode: stage`, verify +PyPI's exact artifact set, then call it with `mode: publish` and the same `assets-directory`. The draft stays private +until the second call succeeds. + +```yaml + - uses: tox-dev/github-workflows/publish-github-release@SHA + env: + GH_TOKEN: ${{ github.token }} + with: + tag: ${{ github.ref_name }} + commit: ${{ github.sha }} + title: ${{ github.ref_name }} + notes-file: ${{ runner.temp }}/release-notes.md +``` + +- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the commit + the existing tag must resolve to. `title` and the file bytes must match an existing Release exactly. +- `assets-directory`: Optional directory containing only the assets to attach. The Action compares each filename and + SHA256 digest with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. + After publication it verifies GitHub's release attestation for each local asset. Enable immutable releases in the + repository before using this option. +- `prerelease`: `true` or `false`; default `false`. It must match an existing Release. +- `mode`: `complete` (default) creates or resumes a draft and publishes it in one call. `stage` creates or resumes a + draft and uploads assets without publishing. `publish` requires an existing draft and publishes it after checking the + tag, notes, and complete asset set again. + +**Output:** No named output. **Remote effect:** Depending on `mode`, the Action creates or resumes a draft, uploads +missing assets, and publishes only after the complete asset set matches. A matching published Release causes no write. A +different body, title, tag commit, prerelease flag, or asset set fails without changing the existing Release. A failed +upload leaves the draft for a later retry. An asset in GitHub's incomplete `starter` state requires manual cleanup +before retrying. The Action retries reading the release by ID for up to ten seconds after creating the draft, uploading +assets, or publishing; a mismatch still fails when that window ends. + +## Reconcile a post-release pull request + +`publish-post-release-pr` consumes a candidate commit prepared without an App token. The preparation job records the +base commit, desired branch, and a tab-separated file of changed paths and Git blob SHA1 values, then creates a Git +bundle containing `refs/heads/release-candidate` and its full history. A thin bundle fails verification in the writer's +empty bare repository. Upload the bundle, path file, and body as one artifact. Pass the upload step's artifact ID to the +writer's download step, so a rerun cannot select an older artifact with the same name. The writer runs without checkout +under the caller's `release-auth` environment and needs Contents, Pull requests, and Issues write access for its App +token. Put `publish-post-release-pr` last in the writer job because its Git credential helper remains available to later +steps. + +```yaml + - uses: actions/download-artifact@DOWNLOAD_SHA + with: + artifact-ids: ${{ needs.prepare.outputs.artifact-id }} + path: prepared/ + - uses: tox-dev/github-workflows/publish-post-release-pr@SHA + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + with: + app-slug: ${{ steps.app-token.outputs.app-slug }} + base-commit: ${{ needs.prepare.outputs.base-commit }} + branch: post-release-${{ github.ref_name }} + title: Bump version to 2.4.8-dev + commit-message: Bump version to 2.4.8-dev after 2.4.7 + body-file: prepared/body.md + paths-file: prepared/paths.tsv + allowed-paths: gradle.properties + bundle: prepared/candidate.bundle + candidate-commit: ${{ needs.prepare.outputs.candidate-commit }} +``` + +The Action derives the bot identity from the writer's App slug and verifies the commit author and committer, exact +commit message, single-parent commit, changed path set against the writer's `allowed-paths`, regular file modes, and +blob identities before any write. + +Pass the keyless job's `base-commit`, the desired `branch` and `title`, its exact one-line `commit-message`, and the +exact PR body in `body-file`. `paths-file` contains one tab-separated path and desired Git blob SHA1 per line. The +Action targets `main` in `GITHUB_REPOSITORY` and applies `skip news` to the PR. + +A matching open branch and PR need no second push. If a matching branch has no PR, it creates one. A merged PR with a +deleted branch succeeds only when the current base still has the expected file bytes. A closed, unmerged PR fails before +the Action creates a replacement branch or PR; reopen the PR before retrying. A different branch, PR title, body, base, +path set, or merged file state fails without a force-push. When the prepared change has already merged and produced no +new commit, pass empty `bundle` and `candidate-commit` values with the same path manifest; the Action still checks the +merged PR and current base. The Action has no named output. + +## Sign a JetBrains plugin + +`sign-jetbrains-plugin` signs an unsigned plugin ZIP after a keyless Gradle `buildPlugin` job. Run it in a fresh job on +`ubuntu-26.04` with the signing environment, no checkout, and no Gradle invocation. Download the unsigned artifact and +pass the SHA256 emitted before that artifact was uploaded. The Action checks the file and its digest, downloads +JetBrains Marketplace ZIP Signer 0.1.43 from Maven Central, verifies its pinned SHA256, and uses the runner's Java 17 +installation. It accepts plain PEM or base64 credentials and an encrypted private key with its password. The private key +is mapped only into the sign step's environment. Its temporary file is removed when that step ends. + +```yaml +sign: + needs: build-unsigned + runs-on: ubuntu-26.04 + environment: release + permissions: {} + outputs: + archive: ${{ steps.signed.outputs.archive }} + sha256: ${{ steps.signed.outputs.sha256 }} + steps: + - uses: actions/download-artifact@DOWNLOAD_SHA + with: + name: unsigned-plugin + path: dist/ + - id: signed + uses: tox-dev/github-workflows/sign-jetbrains-plugin@SHA + with: + archive: dist/Plugin-1.2.3.zip + sha256: ${{ needs.build-unsigned.outputs.archive_sha }} + certificate-chain: ${{ secrets.CERTIFICATE_CHAIN }} + private-key: ${{ secrets.PRIVATE_KEY }} + private-key-password: ${{ secrets.PRIVATE_KEY_PASSWORD }} + - uses: actions/upload-artifact@UPLOAD_SHA + with: + name: plugin-distribution + path: ${{ steps.signed.outputs.archive }} + if-no-files-found: error +``` + +`archive` and `sha256` identify the unsigned ZIP. `certificate-chain` and `private-key` supply the signing identity; +`private-key-password` is needed only for an encrypted key. The outputs are `archive`, the signed ZIP path, and +`sha256`, the digest to check after download in the publisher and GitHub Release jobs. **Remote effect:** downloads the +pinned signer JAR. It makes no GitHub or Marketplace write. The caller uploads the signed ZIP as a GitHub Actions +artifact after the Action succeeds. Keep the Marketplace token in a different no-checkout job. + +## Publish a JetBrains plugin + +`publish-jetbrains-marketplace` uploads one signed ZIP through the +[Marketplace upload API](https://plugins.jetbrains.com/docs/marketplace/plugin-upload.html). Run it in a fresh job +without checkout or Gradle. Download the ZIP produced by the signing job and pass that job's SHA256 output; the Action +checks the downloaded bytes, ZIP integrity, and member names before the token-bearing upload step. + +```yaml + - uses: tox-dev/github-workflows/publish-jetbrains-marketplace@SHA + env: + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + with: + archive: dist/Fish Shell 2-0.1.11-signed.zip + sha256: ${{ needs.sign.outputs.sha256 }} + plugin-id: '29973' + channel: '' +``` + +`archive`, `sha256`, and numeric `plugin-id` are required. `channel` is empty for Stable; use a Marketplace channel name +for a prerelease. **Output:** None. **Remote effect:** One multipart upload to JetBrains Marketplace. The Action does +not check whether this version was published already; use the keyless release-creator `marketplace --mode stage` check +before the upload, and `--mode verify` afterward. A retry must compare the published ZIP's member digests before +skipping an upload because Marketplace can repackage the outer ZIP. + +## Test a caller -The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. The -[`release-dry-run` job](.github/workflows/check.yaml) invokes the composite Actions on a GitHub runner against a local -bare remote. It cannot prove that another repository's Towncrier template, release hooks, package build, or publisher -works. Run that caller's prepare and validation path at its PR head without pushing a GitHub ref or publishing an -artifact. Inspect the version, patch, changelog, notes, and built package before changing its live release workflow. +The [Actions test suite](tests) executes each inline `run:` block in temporary Git repositories. Test a caller's +Towncrier template, release hooks, package build, and publisher against representative source data before changing its +live release workflow. Inspect the version, patch, changelog, notes, and built package. This repository releases its own Actions with the same prepare and tag path. See [Contributing](CONTRIBUTING.md) for local checks and news fragments, and [Changelog](CHANGELOG.md) for released changes. diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml new file mode 100644 index 0000000..c2ec053 --- /dev/null +++ b/build-python-distribution/action.yml @@ -0,0 +1,44 @@ +name: Build Python distribution +description: Build a wheel and sdist from source checked in a separate job +inputs: + source: + description: Distribution source directory + default: . +runs: + using: composite + steps: + - name: Require clean tagged source + shell: bash + run: | + if [[ -n $(git status --porcelain) ]]; then + echo '::error::tagged source is dirty' >&2 + exit 1 + fi + - name: Require empty output directory + shell: bash + run: | + set -euo pipefail + if [[ -d dist && -n $(find dist -mindepth 1 -maxdepth 1 -print -quit) ]]; then + echo '::error::distribution output directory is not empty: dist' >&2 + exit 1 + fi + - name: Select build interpreter + shell: bash + run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/build-python" + - id: constraints + uses: $/export-build-constraints + - name: Build wheel and sdist + shell: bash + env: + SOURCE: ${{ inputs.source }} + BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} + run: | + set -euo pipefail + unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE + build_python=$(uv python find "$RUNNER_TEMP/build-python") + test -n "$build_python" + export SOURCE_DATE_EPOCH + SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) + uv build --python "$build_python" \ + --python-preference only-managed --sdist --wheel \ + --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes "$SOURCE" --out-dir dist diff --git a/changelog.d/+action-test-inputs.bugfix.md b/changelog.d/+action-test-inputs.bugfix.md new file mode 100644 index 0000000..c482286 --- /dev/null +++ b/changelog.d/+action-test-inputs.bugfix.md @@ -0,0 +1 @@ +Resolve Action inputs and step outputs by their declared names in the local test runner. diff --git a/changelog.d/+app-hook-cooldown.security.md b/changelog.d/+app-hook-cooldown.security.md new file mode 100644 index 0000000..4026e0f --- /dev/null +++ b/changelog.d/+app-hook-cooldown.security.md @@ -0,0 +1 @@ +Apply the seven-day hook revision check to App-authored dependency pull requests. diff --git a/changelog.d/+bot-news-label.feature.md b/changelog.d/+bot-news-label.feature.md new file mode 100644 index 0000000..22c3d17 --- /dev/null +++ b/changelog.d/+bot-news-label.feature.md @@ -0,0 +1 @@ +Label bot pull requests without news fragments from a no-checkout workflow, and remove the label when a bot adds news. diff --git a/changelog.d/+bot-workflow-run.feature.md b/changelog.d/+bot-workflow-run.feature.md new file mode 100644 index 0000000..ae558d8 --- /dev/null +++ b/changelog.d/+bot-workflow-run.feature.md @@ -0,0 +1 @@ +Label bot pull requests after read-only CI, including forks and failed checks. diff --git a/changelog.d/+caller-build-constraints.bugfix.md b/changelog.d/+caller-build-constraints.bugfix.md new file mode 100644 index 0000000..ca2f78d --- /dev/null +++ b/changelog.d/+caller-build-constraints.bugfix.md @@ -0,0 +1 @@ +Combine shared build pins with a caller's build group so source dependencies can install on Python 3.15. diff --git a/changelog.d/+cancelled-gate.bugfix.md b/changelog.d/+cancelled-gate.bugfix.md new file mode 100644 index 0000000..0c6d91b --- /dev/null +++ b/changelog.d/+cancelled-gate.bugfix.md @@ -0,0 +1 @@ +Keep the required check from reporting a failure after its run is cancelled. diff --git a/changelog.d/+changelog-prefix.bugfix.md b/changelog.d/+changelog-prefix.bugfix.md new file mode 100644 index 0000000..4ec6626 --- /dev/null +++ b/changelog.d/+changelog-prefix.bugfix.md @@ -0,0 +1 @@ +Let Towncrier callers preserve `v`-prefixed changelog headings independently of their release tag prefix. diff --git a/changelog.d/+check-runtime.performance.md b/changelog.d/+check-runtime.performance.md new file mode 100644 index 0000000..f4c7edd --- /dev/null +++ b/changelog.d/+check-runtime.performance.md @@ -0,0 +1 @@ +Cache pre-commit hooks and use shallow checkouts where release checks need no history. diff --git a/changelog.d/+empty-groups.bugfix.md b/changelog.d/+empty-groups.bugfix.md new file mode 100644 index 0000000..1e682d2 --- /dev/null +++ b/changelog.d/+empty-groups.bugfix.md @@ -0,0 +1 @@ +Report an empty dependency group selection before trying to install release tools. diff --git a/changelog.d/+fragment-validation.bugfix.md b/changelog.d/+fragment-validation.bugfix.md new file mode 100644 index 0000000..fc8fe1b --- /dev/null +++ b/changelog.d/+fragment-validation.bugfix.md @@ -0,0 +1 @@ +Reject fragment filenames that would publish without a pull request link or author credit. diff --git a/changelog.d/+github-release-retry.feature.md b/changelog.d/+github-release-retry.feature.md new file mode 100644 index 0000000..dc054af --- /dev/null +++ b/changelog.d/+github-release-retry.feature.md @@ -0,0 +1,2 @@ +Stage and resume a draft GitHub Release, then publish it after ecosystem verification. Tag reruns verify the exact notes +and assets before succeeding. diff --git a/changelog.d/+hook-recheck.bugfix.md b/changelog.d/+hook-recheck.bugfix.md new file mode 100644 index 0000000..b758a06 --- /dev/null +++ b/changelog.d/+hook-recheck.bugfix.md @@ -0,0 +1 @@ +Recheck mature bot hook updates each day and continue after a failed GitHub job rerun. diff --git a/changelog.d/+hook-run-filter.performance.md b/changelog.d/+hook-run-filter.performance.md new file mode 100644 index 0000000..f73a43f --- /dev/null +++ b/changelog.d/+hook-run-filter.performance.md @@ -0,0 +1 @@ +Limit hook cooldown history requests to workflow runs older than the required waiting period. diff --git a/changelog.d/+hook-update-cooldown.feature.md b/changelog.d/+hook-update-cooldown.feature.md new file mode 100644 index 0000000..c911e94 --- /dev/null +++ b/changelog.d/+hook-update-cooldown.feature.md @@ -0,0 +1 @@ +Check the age of the current `pre-commit.ci` pull request head before a required CI gate permits a hook update. diff --git a/changelog.d/+inline-shell-hidden.bugfix.md b/changelog.d/+inline-shell-hidden.bugfix.md new file mode 100644 index 0000000..b8a854d --- /dev/null +++ b/changelog.d/+inline-shell-hidden.bugfix.md @@ -0,0 +1 @@ +Check shell commands from workflows in `.github` with ShellCheck and shfmt. diff --git a/changelog.d/+jetbrains-marketplace.feature.md b/changelog.d/+jetbrains-marketplace.feature.md new file mode 100644 index 0000000..dbca85f --- /dev/null +++ b/changelog.d/+jetbrains-marketplace.feature.md @@ -0,0 +1 @@ +Add a credential-only JetBrains Marketplace upload Action for a verified signed ZIP. diff --git a/changelog.d/+jetbrains-signer.feature.md b/changelog.d/+jetbrains-signer.feature.md new file mode 100644 index 0000000..e38314f --- /dev/null +++ b/changelog.d/+jetbrains-signer.feature.md @@ -0,0 +1 @@ +Sign a JetBrains plugin ZIP in an isolated job after verifying the unsigned build artifact and pinned signing tool. diff --git a/changelog.d/+label-pagination.bugfix.md b/changelog.d/+label-pagination.bugfix.md new file mode 100644 index 0000000..41c7cda --- /dev/null +++ b/changelog.d/+label-pagination.bugfix.md @@ -0,0 +1 @@ +Read every label before changing the skip news label on a bot pull request. diff --git a/changelog.d/+license-metadata.packaging.md b/changelog.d/+license-metadata.packaging.md new file mode 100644 index 0000000..ac77c9e --- /dev/null +++ b/changelog.d/+license-metadata.packaging.md @@ -0,0 +1 @@ +Declare the MIT license and its file in project metadata. diff --git a/changelog.d/+linked-rst.bugfix.md b/changelog.d/+linked-rst.bugfix.md new file mode 100644 index 0000000..720697d --- /dev/null +++ b/changelog.d/+linked-rst.bugfix.md @@ -0,0 +1 @@ +Validate linked reStructuredText release headings when applying a release patch. diff --git a/changelog.d/+marketplace-error.bugfix.md b/changelog.d/+marketplace-error.bugfix.md new file mode 100644 index 0000000..02a8f11 --- /dev/null +++ b/changelog.d/+marketplace-error.bugfix.md @@ -0,0 +1 @@ +Show the JetBrains Marketplace response when an upload fails. diff --git a/changelog.d/+marketplace-form-path.bugfix.md b/changelog.d/+marketplace-form-path.bugfix.md new file mode 100644 index 0000000..2f8abd7 --- /dev/null +++ b/changelog.d/+marketplace-form-path.bugfix.md @@ -0,0 +1 @@ +Upload signed JetBrains plugins when the runner temporary path contains commas or semicolons. diff --git a/changelog.d/+news-checkout.bugfix.md b/changelog.d/+news-checkout.bugfix.md new file mode 100644 index 0000000..b5b7814 --- /dev/null +++ b/changelog.d/+news-checkout.bugfix.md @@ -0,0 +1 @@ +Fetch the PR base branch for changelog checks, which compare the PR head with `origin/main`. diff --git a/changelog.d/+news-validation.feature.md b/changelog.d/+news-validation.feature.md new file mode 100644 index 0000000..b173d63 --- /dev/null +++ b/changelog.d/+news-validation.feature.md @@ -0,0 +1,2 @@ +Validate pull request changelog fragments in a shared Action, with bot author handling that does not depend on label +timing. diff --git a/changelog.d/+package-notes.feature.md b/changelog.d/+package-notes.feature.md new file mode 100644 index 0000000..7e299c5 --- /dev/null +++ b/changelog.d/+package-notes.feature.md @@ -0,0 +1 @@ +Render GitHub Release notes for independently tagged packages in one repository. diff --git a/changelog.d/+parallel-checks.performance.md b/changelog.d/+parallel-checks.performance.md new file mode 100644 index 0000000..cf946d0 --- /dev/null +++ b/changelog.d/+parallel-checks.performance.md @@ -0,0 +1 @@ +Run formatting, inline shell checks, tests, and type checks in parallel. diff --git a/changelog.d/+patch-artifact-id.bugfix.md b/changelog.d/+patch-artifact-id.bugfix.md new file mode 100644 index 0000000..9d9419a --- /dev/null +++ b/changelog.d/+patch-artifact-id.bugfix.md @@ -0,0 +1 @@ +Download the release patch by its uploaded artifact ID, so a same-named artifact cannot replace it in a later job. diff --git a/changelog.d/+post-release-commit.security.md b/changelog.d/+post-release-commit.security.md new file mode 100644 index 0000000..f648414 --- /dev/null +++ b/changelog.d/+post-release-commit.security.md @@ -0,0 +1 @@ +Check the prepared commit's committer and message before opening a post-release pull request. diff --git a/changelog.d/+post-release-pr-retry.feature.md b/changelog.d/+post-release-pr-retry.feature.md new file mode 100644 index 0000000..8fff0d2 --- /dev/null +++ b/changelog.d/+post-release-pr-retry.feature.md @@ -0,0 +1,2 @@ +Create or resume an App-authored post-release pull request, verifying the branch and changed files before a retry +succeeds. diff --git a/changelog.d/+pypi-retry.feature.md b/changelog.d/+pypi-retry.feature.md new file mode 100644 index 0000000..496cc99 --- /dev/null +++ b/changelog.d/+pypi-retry.feature.md @@ -0,0 +1,2 @@ +Stage only PyPI files absent from an identical release, then verify the complete published artifact set before writing +the GitHub Release. diff --git a/changelog.d/+python-distribution.feature.md b/changelog.d/+python-distribution.feature.md new file mode 100644 index 0000000..98a0664 --- /dev/null +++ b/changelog.d/+python-distribution.feature.md @@ -0,0 +1,2 @@ +Build a Python wheel and sdist after a separate tagged-source check, using hashed build backend pins from the full +project lock. Check the exact wheel and sdist with Twine and check-wheel-contents before uploading them. diff --git a/changelog.d/+python-publisher.feature.md b/changelog.d/+python-publisher.feature.md new file mode 100644 index 0000000..9604b80 --- /dev/null +++ b/changelog.d/+python-publisher.feature.md @@ -0,0 +1 @@ +Offer a read-only tagged-source workflow for notes, wheel and sdist builds, and distribution checks before publishing. diff --git a/changelog.d/+release-docs.doc.md b/changelog.d/+release-docs.doc.md new file mode 100644 index 0000000..c9a7705 --- /dev/null +++ b/changelog.d/+release-docs.doc.md @@ -0,0 +1 @@ +Correct the release job order, checkout requirements, retry behavior, and hook rerun window in the Action guide. diff --git a/changelog.d/+release-notes.feature.md b/changelog.d/+release-notes.feature.md new file mode 100644 index 0000000..78a82b0 --- /dev/null +++ b/changelog.d/+release-notes.feature.md @@ -0,0 +1 @@ +Render GitHub Release notes from a tagged changelog in tag publishers. diff --git a/changelog.d/+release-patch-check.bugfix.md b/changelog.d/+release-patch-check.bugfix.md new file mode 100644 index 0000000..070a7de --- /dev/null +++ b/changelog.d/+release-patch-check.bugfix.md @@ -0,0 +1 @@ +Fail release patch validation when Git cannot list the changed paths, modes, or RST lines. diff --git a/changelog.d/+release-patch.security.md b/changelog.d/+release-patch.security.md new file mode 100644 index 0000000..d3ec904 --- /dev/null +++ b/changelog.d/+release-patch.security.md @@ -0,0 +1 @@ +Validate release patches without the App key, then recheck their digest, paths, and file modes before minting a token. diff --git a/changelog.d/+release-read-retry.bugfix.md b/changelog.d/+release-read-retry.bugfix.md new file mode 100644 index 0000000..c6fcdcc --- /dev/null +++ b/changelog.d/+release-read-retry.bugfix.md @@ -0,0 +1 @@ +Retry failed GitHub Release reads without repeating asset uploads or publication. diff --git a/changelog.d/+reproducible-builds.bugfix.md b/changelog.d/+reproducible-builds.bugfix.md new file mode 100644 index 0000000..fa9d64a --- /dev/null +++ b/changelog.d/+reproducible-builds.bugfix.md @@ -0,0 +1 @@ +Use the tagged commit time when building Python distributions so a retry produces the same bytes. diff --git a/changelog.d/+signer-guards.security.md b/changelog.d/+signer-guards.security.md new file mode 100644 index 0000000..add1cb1 --- /dev/null +++ b/changelog.d/+signer-guards.security.md @@ -0,0 +1 @@ +Reject an existing signed ZIP and mask decoded JetBrains signing key material. diff --git a/changelog.d/+source-tag.security.md b/changelog.d/+source-tag.security.md new file mode 100644 index 0000000..f86a575 --- /dev/null +++ b/changelog.d/+source-tag.security.md @@ -0,0 +1 @@ +Require a release tag to point to a commit on main before publishing. diff --git a/changelog.d/+tool-updater.feature.md b/changelog.d/+tool-updater.feature.md new file mode 100644 index 0000000..28c3862 --- /dev/null +++ b/changelog.d/+tool-updater.feature.md @@ -0,0 +1,2 @@ +Share the release-tool lock updater across repositories while keeping each caller's schedule and checks, and expose its +read-only generator for PR validation. diff --git a/changelog.d/+type-task.bugfix.md b/changelog.d/+type-task.bugfix.md new file mode 100644 index 0000000..12222a8 --- /dev/null +++ b/changelog.d/+type-task.bugfix.md @@ -0,0 +1 @@ +Run the type-check tox environment with current test dependencies without installing them twice. diff --git a/changelog.d/+v-prefix.feature.md b/changelog.d/+v-prefix.feature.md new file mode 100644 index 0000000..45b48c4 --- /dev/null +++ b/changelog.d/+v-prefix.feature.md @@ -0,0 +1 @@ +Let release callers calculate `v`-prefixed tags without typing a version. diff --git a/changelog.d/+weekly-hook-pins.feature.md b/changelog.d/+weekly-hook-pins.feature.md new file mode 100644 index 0000000..e46b816 --- /dev/null +++ b/changelog.d/+weekly-hook-pins.feature.md @@ -0,0 +1,3 @@ +Refresh exact pre-commit hook dependency pins in the weekly release-tool PR, with an opt-in to retain a project's +checked-in `uv.lock` alongside its selectable `pylock.toml`. Allow explicit package exceptions to the seven-day cutoff +when a declared minimum is newer than that cutoff. diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml new file mode 100644 index 0000000..bb5d7e3 --- /dev/null +++ b/check-hook-update-cooldown/action.yml @@ -0,0 +1,256 @@ +name: Check hook update cooldown +description: Require seven days before a pre-commit.ci update pull request can merge +inputs: + minimum-days: + description: Minimum age of a proposed hook revision + default: "7" +runs: + using: composite + steps: + - id: identity + name: Verify the pull request identity + shell: bash + env: + MINIMUM_DAYS: ${{ inputs.minimum-days }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + REPOSITORY=$GITHUB_REPOSITORY + PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH") + HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH") + case "$REPOSITORY" in + */*) ;; + *) exit 1 ;; + esac + for part in "${REPOSITORY%%/*}" "${REPOSITORY#*/}"; do + case "$part" in + '' | *[!A-Za-z0-9_.-]*) exit 1 ;; + esac + done + case "$PR_NUMBER" in + '' | 0* | *[!0-9]*) exit 1 ;; + esac + test "${#HEAD_SHA}" -eq 40 + case "$HEAD_SHA" in + *[!0-9a-f]*) exit 1 ;; + esac + case "$MINIMUM_DAYS" in + '' | 0* | *[!0-9]*) exit 1 ;; + esac + if ((MINIMUM_DAYS < 7)); then + echo '::error::hook cooldown must be at least seven days' >&2 + exit 1 + fi + test "$GITHUB_EVENT_NAME" = pull_request + jq -e --argjson number "$PR_NUMBER" --arg head "$HEAD_SHA" ' + .number == $number and .pull_request.head.sha == $head + ' "$GITHUB_EVENT_PATH" >/dev/null + gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" >"$RUNNER_TEMP/hook-update-pr.json" + jq -e --arg repository "$REPOSITORY" --arg head "$HEAD_SHA" ' + .state == "open" and .base.repo.full_name == $repository and + .head.sha == $head and + (.user.login | type == "string") + ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null + if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then + gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' \ + >"$RUNNER_TEMP/hook-update-files" + fi + if [[ -f "$RUNNER_TEMP/hook-update-files" ]] && + grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-update-files"; then + gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \ + -f head_sha="$HEAD_SHA" -f per_page=100 >"$RUNNER_TEMP/hook-update-runs.jsonl" + if jq -se --arg repository "$REPOSITORY" \ + --arg branch "$(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json")" \ + --arg head "$HEAD_SHA" --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" ' + [.[].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) + ) | .created_at | fromdateiso8601] as $observed | + ($observed | length) > 0 and ($observed | min) <= now - ($days * 86400) + ' "$RUNNER_TEMP/hook-update-runs.jsonl" >/dev/null; then + echo 'cooled=true' >>"$GITHUB_OUTPUT" + fi + echo 'bot=true' >>"$GITHUB_OUTPUT" + fi + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + if: steps.identity.outputs.bot == 'true' + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - uses: $/install-locked-tools + if: steps.identity.outputs.bot == 'true' + with: + groups: release-hook-check + lock-source: shared + venv: ${{ runner.temp }}/hook-tools + - name: Read the base and proposed hook configurations + if: steps.identity.outputs.bot == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + REPOSITORY=$GITHUB_REPOSITORY + HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH") + base_sha=$(jq -r .base.sha "$RUNNER_TEMP/hook-update-pr.json") + gh api "repos/$REPOSITORY/contents/.pre-commit-config.yaml?ref=$base_sha" --jq .content | + base64 --decode >"$RUNNER_TEMP/base-hooks.yaml" + gh api "repos/$REPOSITORY/contents/.pre-commit-config.yaml?ref=$HEAD_SHA" --jq .content | + base64 --decode >"$RUNNER_TEMP/proposed-hooks.yaml" + cp "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/eligible-hooks.yaml" + git init -q "$RUNNER_TEMP/hook-cooldown" + - name: Select eligible hook revisions + if: steps.identity.outputs.bot == 'true' + shell: bash + env: + MINIMUM_DAYS: ${{ inputs.minimum-days }} + run: | + uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update -C "$RUNNER_TEMP/hook-cooldown" \ + --config "$RUNNER_TEMP/eligible-hooks.yaml" \ + --cooldown-days "$MINIMUM_DAYS" --no-progress + - name: Compare proposed hook revisions + if: steps.identity.outputs.bot == 'true' + shell: bash + run: | + set -euo pipefail + for config in base proposed eligible; do + uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c \ + '[.repos[] | select(.repo != "local" and .repo != "meta") | {repo, rev}]' \ + "$RUNNER_TEMP/$config-hooks.yaml" >"$RUNNER_TEMP/$config-hooks.json" + done + jq -e -n \ + --slurpfile base "$RUNNER_TEMP/base-hooks.json" \ + --slurpfile proposed "$RUNNER_TEMP/proposed-hooks.json" \ + --slurpfile eligible "$RUNNER_TEMP/eligible-hooks.json" ' + $base[0] as $old | + $eligible[0] as $allowed | + $proposed[0] as $new | + if ($old | map(.repo)) != ($new | map(.repo)) or + ($old | map(.repo)) != ($allowed | map(.repo)) + then error("hook repository entries changed") + else [range(0; $new | length) as $index | + $new[$index] | + select(.rev != $old[$index].rev and .rev != $allowed[$index].rev) | + [.repo, .rev, ([$new[:$index][] | select(.repo == $new[$index].repo)] | length)]] end + ' >"$RUNNER_TEMP/older-hook-revisions.json" + jq -r '.[] | @tsv' "$RUNNER_TEMP/older-hook-revisions.json" >"$RUNNER_TEMP/older-hook-revisions" + - name: Find prior pull request heads + if: steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true' + shell: bash + env: + MINIMUM_DAYS: ${{ inputs.minimum-days }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + REPOSITORY=$GITHUB_REPOSITORY + PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH") + branch=$(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json") + cutoff=$(jq -nr --argjson days "$MINIMUM_DAYS" 'now - $days * 86400 | todateiso8601') + gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \ + -f branch="$branch" -f "created=<$cutoff" -f per_page=100 >"$RUNNER_TEMP/hook-branch-runs.jsonl" + jq -sr --arg repository "$REPOSITORY" --arg branch "$branch" \ + --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" ' + [.[].workflow_runs[] | select( + .event == "pull_request" and .head_repository.full_name == $repository and + .head_branch == $branch and any(.pull_requests[]; .number == $number) and + (.created_at | fromdateiso8601) <= now - ($days * 86400) + ) | .head_sha] | unique[] + ' "$RUNNER_TEMP/hook-branch-runs.jsonl" >"$RUNNER_TEMP/older-heads" + - name: Read prior hook revisions + if: steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + REPOSITORY=$GITHUB_REPOSITORY + : >"$RUNNER_TEMP/observed-revisions.jsonl" + while IFS= read -r head; do + if gh api "repos/$REPOSITORY/contents/.pre-commit-config.yaml?ref=$head" --jq .content \ + >"$RUNNER_TEMP/older-hooks.b64" 2>"$RUNNER_TEMP/older-hooks-error"; then + base64 --decode <"$RUNNER_TEMP/older-hooks.b64" >"$RUNNER_TEMP/older-hooks.yaml" + elif grep -Fq '(HTTP 404)' "$RUNNER_TEMP/older-hooks-error"; then + continue + else + cat "$RUNNER_TEMP/older-hooks-error" >&2 + exit 1 + fi + if ! uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c ' + [.repos[] | select(.repo != "local" and .repo != "meta") | {repo, rev}] + ' "$RUNNER_TEMP/older-hooks.yaml" >"$RUNNER_TEMP/older-revisions.json"; then + echo "::warning::skip malformed hook config at $head" >&2 + continue + fi + jq -c ' + . as $entries | to_entries[] | . as $entry | + {repo: .value.repo, rev: .value.rev, + occurrence: ([$entries[:$entry.key][] | select(.repo == $entry.value.repo)] | length)} + ' "$RUNNER_TEMP/older-revisions.json" >>"$RUNNER_TEMP/observed-revisions.jsonl" + done <"$RUNNER_TEMP/older-heads" + - name: Verify the first GitHub observation of each revision + if: steps.identity.outputs.bot == 'true' && steps.identity.outputs.cooled != 'true' + shell: bash + run: | + jq -n --slurpfile base "$RUNNER_TEMP/base-hooks.json" \ + --slurpfile proposed "$RUNNER_TEMP/proposed-hooks.json" ' + [$proposed[0] as $entries | $entries | to_entries[] | . as $entry | + select(.value.rev != $base[0][.key].rev) | + {repo: .value.repo, rev: .value.rev, + occurrence: ([$entries[:$entry.key][] | select(.repo == $entry.value.repo)] | length)}] + ' >"$RUNNER_TEMP/proposed-revisions.json" + if ! jq -se --slurpfile proposed "$RUNNER_TEMP/proposed-revisions.json" ' + . as $observed | + all($proposed[0][]; . as $revision | + any($observed[]; + .repo == $revision.repo and .occurrence == $revision.occurrence and .rev == $revision.rev)) + ' "$RUNNER_TEMP/observed-revisions.jsonl" >/dev/null; then + echo '::error::hook revision has not been observed on this pull request for seven days' >&2 + exit 1 + fi + - name: Verify older pinned revisions + if: steps.identity.outputs.bot == 'true' + shell: bash + env: + MINIMUM_DAYS: ${{ inputs.minimum-days }} + run: | + set -euo pipefail + while IFS=$'\t' read -r repository revision occurrence; do + if [[ ${#revision} -eq 40 ]]; then + case "$revision" in + *[!0-9a-f]*) exit 1 ;; + esac + tags=() + while read -r commit ref; do + if [[ "$commit" == "$revision" ]]; then + tag=${ref#refs/tags/} + tags+=("${tag%"^{}"}") + fi + done < <(git ls-remote --tags "$repository") + options=(--freeze) + else + tags=("$revision") + options=() + fi + accepted=false + for tag in "${tags[@]}"; do + cp "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/candidate-hooks.yaml" + uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update -C "$RUNNER_TEMP/hook-cooldown" \ + --config "$RUNNER_TEMP/candidate-hooks.yaml" --repo "$repository" \ + --repo-include-tag "$repository=$tag" --cooldown-days "$MINIMUM_DAYS" \ + "${options[@]}" --no-progress + selected_revisions=$(uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -r \ + --arg repository "$repository" \ + ".repos[] | select(.repo == \$repository) | .rev" "$RUNNER_TEMP/candidate-hooks.yaml") + mapfile -t selected <<<"$selected_revisions" + if [[ "${selected[$occurrence]:-}" == "$revision" ]]; then + accepted=true + break + fi + done + if [[ "$accepted" != true ]]; then + echo "::error::hook revision is not eligible: $repository $revision" >&2 + exit 1 + fi + done <"$RUNNER_TEMP/older-hook-revisions" diff --git a/commit-and-tag/action.yml b/commit-and-tag/action.yml index a211bd8..2c005ca 100644 --- a/commit-and-tag/action.yml +++ b/commit-and-tag/action.yml @@ -13,9 +13,6 @@ inputs: message: description: Release commit message required: true - branch: - description: Protected release branch - default: main annotated: description: Create an annotated tag default: "false" @@ -41,24 +38,41 @@ runs: echo "::error::invalid release tag: $VERSION" >&2 exit 1 fi - - name: Commit, tag, and push + - name: Set App commit identity shell: bash env: - VERSION: ${{ inputs.version }} GH_TOKEN: ${{ inputs.token }} APP_SLUG: ${{ inputs.app-slug }} - MESSAGE: ${{ inputs.message }} - BRANCH: ${{ inputs.branch }} - ANNOTATED: ${{ inputs.annotated }} run: | set -euo pipefail + if [[ -z "$GH_TOKEN" || -z "$APP_SLUG" ]]; then + echo '::error::repository App token and slug are required' >&2 + exit 1 + fi app_user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) git config user.name "${APP_SLUG}[bot]" git config user.email "${app_user_id}+${APP_SLUG}[bot]@users.noreply.github.com" - git commit -m "$MESSAGE" + - name: Commit release patch + shell: bash + env: + MESSAGE: ${{ inputs.message }} + run: git commit -m "$MESSAGE" + - name: Create release tag + shell: bash + env: + VERSION: ${{ inputs.version }} + MESSAGE: ${{ inputs.message }} + ANNOTATED: ${{ inputs.annotated }} + run: | case "$ANNOTATED" in true) git tag -a -m "$MESSAGE" -- "$VERSION" ;; false) git tag -- "$VERSION" ;; esac + - name: Push release commit and tag + shell: bash + env: + VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ inputs.token }} + run: | gh auth setup-git --hostname github.com --force - git push --atomic origin "HEAD:$BRANCH" "refs/tags/$VERSION" + git push --atomic origin HEAD:refs/heads/main "refs/tags/$VERSION" diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml new file mode 100644 index 0000000..9778354 --- /dev/null +++ b/export-build-constraints/action.yml @@ -0,0 +1,51 @@ +name: Export build constraints +description: Write hashed build dependencies selected from the caller's pylock +inputs: + lock: + description: pylock.toml merges shared and caller pins; another lock uses only its build pins + default: pylock.toml + target-platform: + description: Nab platform ID such as linux_aarch64; required with targets + default: "" + targets: + description: Space-separated pairs such as 3.15.0:cpython; selected package versions must match + default: "" +outputs: + path: + description: Path to the generated constraints for this lock selection + value: ${{ steps.constraints.outputs.path }} +runs: + using: composite + steps: + - uses: $/install-nab + - id: constraints + name: Export locked build requirements + shell: bash + env: + BUILD_LOCK: ${{ inputs.lock }} + TARGET_PLATFORM: ${{ inputs.target-platform }} + TARGETS: ${{ inputs.targets }} + run: | + set -euo pipefail + args=() + if [[ "$BUILD_LOCK" == pylock.toml ]]; then + args+=(--shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml") + locks=("$GITHUB_ACTION_PATH/../pylock.toml" pylock.toml) + else + locks=("$BUILD_LOCK") + fi + if [[ -n "$TARGET_PLATFORM" || -n "$TARGETS" ]]; then + args+=(--target-platform "$TARGET_PLATFORM" --targets "$TARGETS") + fi + digest=$( + sha256sum "${locks[@]}" + printf '%s\n' "$TARGET_PLATFORM" "$TARGETS" "${args[@]}" + ) + digest=$(sha256sum <<<"$digest") + output="$RUNNER_TEMP/build-constraints-${digest%% *}.txt" + if [[ ! -f "$output" ]]; then + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \ + "$output" "${locks[@]}" + fi + echo "path=$output" >>"$GITHUB_OUTPUT" diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml new file mode 100644 index 0000000..63def2f --- /dev/null +++ b/install-locked-tools/action.yml @@ -0,0 +1,80 @@ +name: Install locked release tools +description: Install selected pylock groups with hashed build dependencies +inputs: + groups: + description: Space-separated dependency groups to install + required: true + venv: + description: Virtual environment directory + required: true + lock-source: + description: Install groups from the caller or the shared Action lock + default: caller + local-tool-source: + description: The caller builds release-creator from its own checkout after installing locked third-party tools + default: "false" +runs: + using: composite + steps: + - uses: $/install-nab + - id: selection + name: Select locked groups and build constraints + shell: bash + env: + DEPENDENCY_GROUPS: ${{ inputs.groups }} + VENV: ${{ inputs.venv }} + LOCK_SOURCE: ${{ inputs.lock-source }} + LOCAL_TOOL_SOURCE: ${{ inputs.local-tool-source }} + run: | + set -euo pipefail + uv venv --allow-existing --python 3.15 "$VENV" + case "$LOCK_SOURCE" in + caller) + lock=pylock.toml + lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml") + ;; + shared) + lock="$GITHUB_ACTION_PATH/../pylock.toml" + lock_check=() + ;; + *) + echo "::error::unknown lock source: $LOCK_SOURCE" >&2 + exit 1 + ;; + esac + groups=() + while IFS= read -r line; do + read -ra selected <<<"$line" + groups+=("${selected[@]}") + done <<<"$DEPENDENCY_GROUPS" + if ((${#groups[@]} == 0)); then + echo '::error::at least one dependency group is required' >&2 + exit 1 + fi + source_check=() + if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" \ + "${lock_check[@]}" "${source_check[@]}" + printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" + printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" + - id: constraints + uses: $/export-build-constraints + with: + lock: ${{ steps.selection.outputs.lock }} + - name: Install selected groups + shell: bash + env: + VENV: ${{ inputs.venv }} + LOCK: ${{ steps.selection.outputs.lock }} + BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} + run: | + set -euo pipefail + unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE + groups=() + while IFS= read -r group; do + groups+=(--group "$group") + done <"$RUNNER_TEMP/selected-lock-groups" + uv pip sync --preview-features pylock --preview-features build-dependency-hashes --python "$VENV" \ + "${groups[@]}" --build-constraints "$BUILD_CONSTRAINTS" \ + --require-build-hashes --require-hashes "$LOCK" diff --git a/install-nab/action.yml b/install-nab/action.yml new file mode 100644 index 0000000..e2d0788 --- /dev/null +++ b/install-nab/action.yml @@ -0,0 +1,24 @@ +name: Install locked Nab +description: Install the lock reader from the shared pylock +runs: + using: composite + steps: + - name: Create lock-tool environment + shell: bash + run: | + if [[ ! -x "$RUNNER_TEMP/lock-tools/bin/python" ]]; then + uv venv --python 3.15 "$RUNNER_TEMP/lock-tools" + fi + - name: Install lock reader + shell: bash + run: | + set -euo pipefail + unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE + digest=$(sha256sum "$GITHUB_ACTION_PATH/../pylock.toml") + if [[ -f "$RUNNER_TEMP/lock-tools/.lock-digest" && + $(cat "$RUNNER_TEMP/lock-tools/.lock-digest") == "${digest%% *}" ]]; then + exit 0 + fi + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ + --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" + printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-tools/.lock-digest" diff --git a/label-bot-news/action.yml b/label-bot-news/action.yml new file mode 100644 index 0000000..d07adf2 --- /dev/null +++ b/label-bot-news/action.yml @@ -0,0 +1,118 @@ +name: Label bot pull request news +description: Keep skip news on bot PRs without a changelog fragment +inputs: + pull-request: + description: Verified pull request number from a workflow-run resolver, or current PR event when empty + default: "" + bot-author: + description: Whether the verified pull request author is a bot, or current PR author when empty + default: "" + fragments: + description: Comma-separated directories containing changelog fragments + default: changelog.d + extension: + description: Fragment extension without the dot, either md or rst + default: md + fragment-style: + description: Filename convention, towncrier or changeset + default: towncrier +runs: + using: composite + steps: + - name: Validate bot fragment format + if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' + shell: bash + env: + EXTENSION: ${{ inputs.extension }} + FRAGMENT_STYLE: ${{ inputs.fragment-style }} + run: | + if [[ "$EXTENSION" != md && "$EXTENSION" != rst ]]; then + echo '::error::fragment extension must be md or rst' >&2 + exit 1 + fi + if [[ "$FRAGMENT_STYLE" != towncrier && "$FRAGMENT_STYLE" != changeset ]]; then + echo '::error::fragment style must be towncrier or changeset' >&2 + exit 1 + fi + if [[ "$FRAGMENT_STYLE" == changeset && "$EXTENSION" != md ]]; then + echo '::error::changeset fragments must use md' >&2 + exit 1 + fi + - name: Read bot pull request files + if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }} + run: | + gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" \ + --jq '.[] | select(.status != "removed") | .filename' >"$RUNNER_TEMP/bot-pr-files" + - name: Find bot news fragments + if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' + shell: bash + env: + FRAGMENTS: ${{ inputs.fragments }} + EXTENSION: ${{ inputs.extension }} + FRAGMENT_STYLE: ${{ inputs.fragment-style }} + LC_ALL: C + run: | + has_fragment=false + mapfile -t files <"$RUNNER_TEMP/bot-pr-files" + read -ra directories <<<"${FRAGMENTS//,/ }" + for path in "${files[@]}"; do + for directory in "${directories[@]}"; do + if [[ "$FRAGMENT_STYLE" == changeset ]]; then + case "$path" in + "$directory"/*.md) + fragment=${path#"$directory"/} + if [[ "$fragment" != */* ]]; then has_fragment=true; fi + ;; + esac + else + case "$path" in + "$directory"/*.*."$EXTENSION") + fragment=${path#"$directory"/} + if [[ "$fragment" == */* ]]; then continue; fi + stem=${fragment%."$EXTENSION"} + counter=${stem##*.} + if [[ "$stem" == *.*.* && -n "$counter" && "$counter" != *[!0-9]* ]]; then + stem=${stem%.*} + fi + if [[ "$stem" != *.* ]]; then continue; fi + identifier=${stem%.*} + if [[ "$identifier" == +?* ]] || [[ -n "$identifier" && "$identifier" != *[!0-9]* ]]; then + has_fragment=true + fi + ;; + esac + fi + done + done + echo "$has_fragment" >"$RUNNER_TEMP/bot-has-fragment" + - name: Read bot pull request labels + if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }} + run: gh api --paginate "repos/$REPOSITORY/issues/$PR_NUMBER/labels" --jq '.[].name' >"$RUNNER_TEMP/bot-pr-labels" + - name: Set skip news for a bot PR + if: inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pull-request || github.event.pull_request.number }} + run: | + read -r has_fragment <"$RUNNER_TEMP/bot-has-fragment" + has_label=false + if grep -Fxq 'skip news' "$RUNNER_TEMP/bot-pr-labels"; then + has_label=true + fi + if [[ "$has_fragment" == false && "$has_label" == false ]]; then + gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --add-label 'skip news' >/dev/null + elif [[ "$has_fragment" == true && "$has_label" == true ]]; then + gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --remove-label 'skip news' >/dev/null + fi diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index a508bfb..b389f67 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -1,27 +1,21 @@ name: Prepare Towncrier release -description: Resolve a version, build the changelog, run prek, and write a release patch +description: Resolve a version, build and format the changelog, and write a release patch inputs: bump: description: Version bump passed to release-creator tags default: auto + tag-prefix: + description: Prefix for calculated release tags, either empty or v + default: "" + changelog-prefix: + description: Prefix for the Towncrier changelog version, either empty or v + default: "" version: description: Explicit version for repositories with custom version selection default: "" - changelog: - description: Changelog path - required: true - fragments: - description: Towncrier fragment directory, relative to the repository root - default: changelog.d - format: - description: Changelog format, either markdown or none - default: none - link-fragments: - description: Resolve named fragments and authors with release-creator - default: "true" - patch: - description: Path for the generated Git patch - required: true + phase: + description: Run the full preparation, changelog build, or finalization + default: all outputs: version: description: Resolved release version @@ -29,76 +23,126 @@ outputs: runs: using: composite steps: + - name: Require a valid preparation phase + shell: bash + env: + PHASE: ${{ inputs.phase }} + run: | + case "$PHASE" in + all | build | finalize) ;; + *) + echo '::error::phase must be all, build, or finalize' >&2 + exit 1 + ;; + esac + - name: Require a clean checkout + if: inputs.phase != 'finalize' + shell: bash + run: | + if [[ -n $(git status --porcelain) ]]; then + echo '::error::release preparation requires a clean checkout' >&2 + exit 1 + fi + - id: config + uses: $/read-towncrier-config - id: version name: Select release version + if: inputs.phase != 'finalize' shell: bash env: BUMP: ${{ inputs.bump }} + TAG_PREFIX: ${{ inputs.tag-prefix }} VERSION: ${{ inputs.version }} - FRAGMENTS: ${{ inputs.fragments }} + FRAGMENTS: ${{ steps.config.outputs.fragments }} run: | set -euo pipefail - if [[ -n $(git status --porcelain) ]]; then - echo '::error::release preparation requires a clean checkout' >&2 - exit 1 - fi if [[ -z "$VERSION" ]]; then - VERSION=$(git tag --list | release-creator tags --next "$BUMP" --directory "$FRAGMENTS") + args=(--next "$BUMP" --directory "$FRAGMENTS") + if [[ -n "$TAG_PREFIX" ]]; then + args+=(--prefix "$TAG_PREFIX") + fi + VERSION=$(git tag --list | release-creator tags "${args[@]}") + else + git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null fi git tag --list | release-creator tags "$VERSION" --require-newer printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" - name: Link fragments and authors + if: inputs.phase != 'finalize' shell: bash env: - LINK_FRAGMENTS: ${{ inputs.link-fragments }} - FRAGMENTS: ${{ inputs.fragments }} + FRAGMENTS: ${{ steps.config.outputs.fragments }} + REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [[ "$LINK_FRAGMENTS" != true && "$LINK_FRAGMENTS" != false ]]; then - echo '::error::link-fragments must be true or false' >&2 - exit 1 - fi - if [[ "$LINK_FRAGMENTS" == true ]]; then - release-creator fragments --directory "$FRAGMENTS" --repository "$GITHUB_REPOSITORY" --git --resolve-authors - fi - - name: Build and format changelog + release-creator fragments --directory "$FRAGMENTS" --repository "$REPOSITORY" + - name: Build changelog + if: inputs.phase != 'finalize' shell: bash env: VERSION: ${{ steps.version.outputs.version }} - CHANGELOG: ${{ inputs.changelog }} - FORMAT: ${{ inputs.format }} + CHANGELOG_PREFIX: ${{ inputs.changelog-prefix }} run: | set -euo pipefail - towncrier build --yes --version "$VERSION" - if [[ "$FORMAT" != markdown && "$FORMAT" != none ]]; then - echo '::error::format must be markdown or none' >&2 + case "$CHANGELOG_PREFIX" in + '' | v) ;; + *) + echo '::error::changelog-prefix must be empty or v' >&2 exit 1 - fi - if [[ "$FORMAT" == markdown ]]; then - if ! prek run mdformat --files "$CHANGELOG"; then - prek run mdformat --files "$CHANGELOG" - fi - fi - notes=$(release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$GITHUB_REPOSITORY") - if [[ "$notes" != *'### '* ]]; then - echo '::error::release preparation has no release notes' >&2 + ;; + esac + towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}" + - name: Format changelog + if: inputs.phase != 'build' + shell: bash + env: + CHANGELOG: ${{ steps.config.outputs.changelog }} + FORMAT: ${{ steps.config.outputs.format }} + run: | + set -euo pipefail + case "$FORMAT" in + markdown) formatter=mdformat ;; + rst) formatter=docstrfmt ;; + *) + echo '::error::unsupported changelog format' >&2 exit 1 + ;; + esac + if ! prek run "$formatter" --files "$CHANGELOG"; then + prek run "$formatter" --files "$CHANGELOG" fi - - name: Run checks and write patch + - name: Check rendered release notes + if: inputs.phase != 'build' shell: bash env: - PATCH: ${{ inputs.patch }} + VERSION: ${{ inputs.version || steps.version.outputs.version }} + CHANGELOG: ${{ steps.config.outputs.changelog }} + REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - prek run --all-files + release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$REPOSITORY" >/dev/null + - name: Check generated files + if: inputs.phase != 'build' + shell: bash + env: + CHANGELOG: ${{ steps.config.outputs.changelog }} + run: | + if [[ -f "$CHANGELOG" ]] && ! git ls-files --error-unmatch "$CHANGELOG" >/dev/null 2>&1; then + git add -N -- "$CHANGELOG" + fi if [[ -n $(git ls-files --others --exclude-standard) ]]; then echo '::error::release preparation created untracked files' >&2 exit 1 fi + - name: Write release patch + if: inputs.phase != 'build' + shell: bash + run: | + set -euo pipefail git diff --check - git diff HEAD --binary >"$PATCH" - if [[ ! -s "$PATCH" ]]; then + git diff HEAD --binary >"$RUNNER_TEMP/release.patch" + if [[ ! -s "$RUNNER_TEMP/release.patch" ]]; then echo '::error::release preparation produced no patch' >&2 exit 1 fi diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml new file mode 100644 index 0000000..352cf94 --- /dev/null +++ b/publish-github-release/action.yml @@ -0,0 +1,298 @@ +name: Publish GitHub Release +description: Resume a matching draft or verify an already published release +inputs: + tag: + description: Existing Git tag for the release + required: true + commit: + description: Commit expected at the tag + required: true + title: + description: Exact GitHub Release title + required: true + notes-file: + description: File containing the exact GitHub Release body + required: true + assets-directory: + description: Directory containing only the intended Release assets + default: "" + prerelease: + description: Whether the release is a prerelease + default: "false" + mode: + description: Stage a draft, publish an existing draft, or complete both phases + default: complete +runs: + using: composite + steps: + - name: Verify the tagged release request + shell: bash + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + COMMIT: ${{ inputs.commit }} + TITLE: ${{ inputs.title }} + NOTES_FILE: ${{ inputs.notes-file }} + ASSETS_DIRECTORY: ${{ inputs.assets-directory }} + PRERELEASE: ${{ inputs.prerelease }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + [[ -n "$GH_REPO" && -n "$TAG" && -n "$TITLE" && -s "$NOTES_FILE" ]] || exit 1 + test "$PRERELEASE" = true || test "$PRERELEASE" = false + case "$MODE" in + stage | publish | complete) ;; + *) + echo "::error::invalid release mode: $MODE" + exit 1 + ;; + esac + tag_commit=$(gh api "repos/$GH_REPO/commits/refs/tags/$TAG" --jq .sha) + test "$tag_commit" == "$COMMIT" + printf '[]\n' >"$RUNNER_TEMP/release-assets.json" + if [[ -n "$ASSETS_DIRECTORY" ]]; then + shopt -s nullglob dotglob + test -d "$ASSETS_DIRECTORY" + test -z "$(find "$ASSETS_DIRECTORY" -mindepth 1 -maxdepth 1 ! -type f -print -quit)" + : >"$RUNNER_TEMP/release-assets.tsv" + for asset in "$ASSETS_DIRECTORY"/*; do + test -f "$asset" + name=${asset##*/} + case "$name" in + .* | *#* | *[[:cntrl:]]*) + echo "::error::unsafe release asset name: $name" + exit 1 + ;; + esac + printf '%s\tsha256:%s\n' "$name" "$(sha256sum "$asset" | cut -d' ' -f1)" \ + >>"$RUNNER_TEMP/release-assets.tsv" + done + jq -R -s '[split("\n")[] | select(length > 0) | split("\t") | {name: .[0], digest: .[1]}]' \ + "$RUNNER_TEMP/release-assets.tsv" >"$RUNNER_TEMP/release-assets.json" + fi + - name: Inspect any existing release + shell: bash + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + NOTES_FILE: ${{ inputs.notes-file }} + PRERELEASE: ${{ inputs.prerelease }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + gh api --paginate "repos/$GH_REPO/releases?per_page=100" | + jq -s --arg tag "$TAG" '[.[][] | select(.tag_name == $tag)]' >"$RUNNER_TEMP/release-existing.json" + if jq -e 'length == 1' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + jq -er '.[0].id' "$RUNNER_TEMP/release-existing.json" >"$RUNNER_TEMP/release-id" + fi + jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' + length <= 1 and all(.[]; + . as $release | + .name == $title and .body == $body and .prerelease == $prerelease and + ((.assets | map(.name) | unique | length) == (.assets | length)) and + all(.assets[]; + .name as $name | .digest as $digest | + any($assets[0][]; .name == $name) and + (.state != "uploaded" and $release.draft or + any($assets[0][]; .name == $name and .digest == $digest))) and + (.draft or (.assets | length) == ($assets[0] | length)) + ) + ' "$RUNNER_TEMP/release-existing.json" + if [[ "$MODE" == publish ]]; then + jq -e 'length == 1' "$RUNNER_TEMP/release-existing.json" >/dev/null + fi + - name: Create a missing draft + shell: bash + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + COMMIT: ${{ inputs.commit }} + TITLE: ${{ inputs.title }} + NOTES_FILE: ${{ inputs.notes-file }} + PRERELEASE: ${{ inputs.prerelease }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + if [[ "$MODE" != publish ]] && jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + jq -n --arg tag "$TAG" --arg commit "$COMMIT" --arg title "$TITLE" \ + --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + '{tag_name: $tag, target_commitish: $commit, name: $title, body: $body, + draft: true, prerelease: $prerelease}' >"$RUNNER_TEMP/release-request.json" + gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \ + >"$RUNNER_TEMP/release-created.json" + jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id" + fi + - name: Upload missing draft assets + shell: bash + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + NOTES_FILE: ${{ inputs.notes-file }} + ASSETS_DIRECTORY: ${{ inputs.assets-directory }} + PRERELEASE: ${{ inputs.prerelease }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + if [[ "$MODE" != publish && -n "$ASSETS_DIRECTORY" ]]; then + shopt -s nullglob + release_id=$(<"$RUNNER_TEMP/release-id") + if jq -e 'length == 1 and .[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + while IFS= read -r asset_id; do + gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" >/dev/null + done < <(jq -r '.[0].assets[] | select(.state != "uploaded") | .id' "$RUNNER_TEMP/release-existing.json") + fi + for attempt in {1..6}; do + if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-current.json"; then + if ((attempt == 6)); then + echo '::error::draft release could not be read' >&2 + exit 1 + fi + sleep 2 + continue + fi + if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' + length == 1 and .[0].name == $title and .[0].body == $body and + .[0].prerelease == $prerelease and + all(.[0].assets[]; .state == "uploaded" and + (.name as $name | .digest as $digest | + any($assets[0][]; .name == $name and .digest == $digest))) + ' "$RUNNER_TEMP/release-current.json" >/dev/null; then + break + fi + if ((attempt == 6)); then + echo '::error::draft release did not become visible' >&2 + exit 1 + fi + sleep 2 + done + if jq -e '.[0].draft' "$RUNNER_TEMP/release-current.json" >/dev/null; then + for asset in "$ASSETS_DIRECTORY"/*; do + name=${asset##*/} + if ! jq -e --arg name "$name" 'any(.[0].assets[]; .name == $name)' \ + "$RUNNER_TEMP/release-current.json" >/dev/null; then + for attempt in {1..6}; do + if gh release upload "$TAG" "$asset" --repo "$GH_REPO"; then + break + fi + if gh api "repos/$GH_REPO/releases/$release_id" | jq -e --arg name "$name" \ + --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' + any(.assets[]; .name == $name and .state == "uploaded" and + (.digest as $digest | any($assets[0][]; .name == $name and .digest == $digest))) + ' >/dev/null; then + break + fi + if ((attempt == 6)); then + echo "::error::could not upload release asset: $name" >&2 + exit 1 + fi + sleep 2 + done + fi + done + fi + fi + - name: Verify the complete asset set + shell: bash + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + NOTES_FILE: ${{ inputs.notes-file }} + PRERELEASE: ${{ inputs.prerelease }} + run: | + set -euo pipefail + release_id=$(<"$RUNNER_TEMP/release-id") + for attempt in {1..6}; do + if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-complete.json"; then + if ((attempt == 6)); then + echo '::error::release could not be read after upload' >&2 + exit 1 + fi + sleep 2 + continue + fi + if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' + length == 1 and .[0].name == $title and .[0].body == $body and + .[0].prerelease == $prerelease and + ([.[0].assets[] | select(.state == "uploaded") | {name, digest}] | sort_by(.name)) == + ($assets[0] | sort_by(.name)) + ' "$RUNNER_TEMP/release-complete.json" >/dev/null; then + exit 0 + fi + if ((attempt < 6)); then sleep 2; fi + done + echo '::error::release metadata or assets did not match after publication' >&2 + exit 1 + - name: Publish the draft + shell: bash + env: + GH_REPO: ${{ github.repository }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + if [[ "$MODE" != stage ]] && jq -e '.[0].draft' "$RUNNER_TEMP/release-complete.json" >/dev/null; then + release_id=$(<"$RUNNER_TEMP/release-id") + gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false >/dev/null + fi + - name: Verify the final release state + shell: bash + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + NOTES_FILE: ${{ inputs.notes-file }} + PRERELEASE: ${{ inputs.prerelease }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + release_id=$(<"$RUNNER_TEMP/release-id") + for attempt in {1..6}; do + if ! gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-published.json"; then + if ((attempt == 6)); then + echo '::error::published release could not be read' >&2 + exit 1 + fi + sleep 2 + continue + fi + if jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + --arg mode "$MODE" \ + --slurpfile assets "$RUNNER_TEMP/release-assets.json" ' + length == 1 and ($mode == "stage" or .[0].draft == false) and .[0].name == $title and + .[0].body == $body and .[0].prerelease == $prerelease and + ([.[0].assets[] | select(.state == "uploaded") | {name, digest}] | sort_by(.name)) == + ($assets[0] | sort_by(.name)) + ' "$RUNNER_TEMP/release-published.json" >/dev/null; then + exit 0 + fi + if ((attempt < 6)); then sleep 2; fi + done + echo '::error::final release state did not match after publication' >&2 + exit 1 + - name: Verify published asset attestations + if: inputs.mode != 'stage' && inputs.assets-directory != '' + shell: bash + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + ASSETS_DIRECTORY: ${{ inputs.assets-directory }} + run: | + set -euo pipefail + shopt -s nullglob + for asset in "$ASSETS_DIRECTORY"/*; do + for attempt in {1..6}; do + if gh release verify-asset "$TAG" "$asset" --repo "$GH_REPO"; then + break + fi + if ((attempt == 6)); then + echo "::error::release asset attestation could not be verified: ${asset##*/}" >&2 + exit 1 + fi + sleep 2 + done + done diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml new file mode 100644 index 0000000..d836708 --- /dev/null +++ b/publish-jetbrains-marketplace/action.yml @@ -0,0 +1,56 @@ +name: Publish JetBrains Marketplace plugin +description: Upload one verified signed ZIP without checking out or building source +inputs: + archive: + description: Signed plugin ZIP from the validated build artifact + required: true + sha256: + description: SHA256 recorded by the signing job before artifact upload + required: true + plugin-id: + description: Numeric JetBrains Marketplace plugin ID + required: true + channel: + description: Marketplace channel; empty selects Stable + default: "" +runs: + using: composite + steps: + - name: Verify signed ZIP bytes + shell: bash + env: + ARCHIVE: ${{ inputs.archive }} + EXPECTED_SHA: ${{ inputs.sha256 }} + PLUGIN_ID: ${{ inputs.plugin-id }} + CHANNEL: ${{ inputs.channel }} + run: | + set -euo pipefail + [[ -f "$ARCHIVE" && ! -L "$ARCHIVE" ]] || exit 1 + case "$ARCHIVE" in *.zip) ;; *) exit 1 ;; esac + case "$PLUGIN_ID" in '' | *[!0-9]*) exit 1 ;; esac + case "$CHANNEL" in *[!a-zA-Z0-9._-]*) exit 1 ;; esac + test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" + unzip -tq "$ARCHIVE" >/dev/null + test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" + - name: Upload signed ZIP + shell: bash + env: + ARCHIVE: ${{ inputs.archive }} + EXPECTED_SHA: ${{ inputs.sha256 }} + PLUGIN_ID: ${{ inputs.plugin-id }} + CHANNEL: ${{ inputs.channel }} + run: | + set -euo pipefail + test -n "$PUBLISH_TOKEN" + upload_file=$(mktemp "$RUNNER_TEMP/marketplace-upload.XXXXXX.zip") + cp -- "$ARCHIVE" "$upload_file" + test "$(sha256sum "$upload_file" | cut -d' ' -f1)" = "$EXPECTED_SHA" + cd "$RUNNER_TEMP" + if ! curl --fail-with-body --max-time 120 --silent --show-error \ + --output "$RUNNER_TEMP/marketplace-response.json" \ + --config <(printf 'header = "Authorization: Bearer %s"\n' "$PUBLISH_TOKEN") \ + --form "pluginId=$PLUGIN_ID" --form "file=@${upload_file##*/};filename=plugin.zip" --form "channel=$CHANNEL" \ + https://plugins.jetbrains.com/api/updates/upload; then + cat "$RUNNER_TEMP/marketplace-response.json" >&2 + exit 1 + fi diff --git a/publish-post-release-pr/action.yml b/publish-post-release-pr/action.yml new file mode 100644 index 0000000..8c76361 --- /dev/null +++ b/publish-post-release-pr/action.yml @@ -0,0 +1,292 @@ +name: Publish a prepared pull request +description: Reconcile a prepared branch and pull request without checking out project code +inputs: + app-slug: + description: Slug of the GitHub App whose bot authors the prepared commit + required: true + base-commit: + description: Base commit used by the keyless preparation job + required: true + branch: + description: Stable branch name for the pull request + required: true + title: + description: Exact pull request title + required: true + body-file: + description: File containing the exact pull request body + required: true + paths-file: + description: Tab-separated changed path and desired Git blob SHA on each line + required: true + allowed-paths: + description: Newline-separated paths the writer permits the prepared commit to change + required: true + bundle: + description: Bundle containing refs/heads/release-candidate, empty when the change is already merged + default: "" + candidate-commit: + description: Candidate commit SHA in the bundle, empty when the change is already merged + default: "" + commit-message: + description: Exact subject of the prepared candidate commit + required: true +runs: + using: composite + steps: + - name: Validate the prepared pull request inputs + shell: bash + env: + APP_SLUG: ${{ inputs.app-slug }} + BASE: main + BASE_COMMIT: ${{ inputs.base-commit }} + BRANCH: ${{ inputs.branch }} + TITLE: ${{ inputs.title }} + BODY_FILE: ${{ inputs.body-file }} + PATHS_FILE: ${{ inputs.paths-file }} + BUNDLE: ${{ inputs.bundle }} + CANDIDATE_COMMIT: ${{ inputs.candidate-commit }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + run: | + set -euo pipefail + case "$APP_SLUG" in + '' | *[!A-Za-z0-9-]*) exit 1 ;; + esac + bot_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) + case "$bot_id" in + '' | 0* | *[!0-9]*) exit 1 ;; + esac + printf '%s\n' "$bot_id" >"$RUNNER_TEMP/post-release-bot-id" + git check-ref-format --branch "$BASE" + git check-ref-format --branch "$BRANCH" + test "$BASE" != "$BRANCH" + test "${#BASE_COMMIT}" -eq 40 + case "$BASE_COMMIT" in + *[!0-9a-f]*) exit 1 ;; + esac + test -n "$TITLE" + test -n "$COMMIT_MESSAGE" + [[ "$COMMIT_MESSAGE" != *$'\n'* ]] + test -s "$BODY_FILE" + test -s "$PATHS_FILE" + if [[ -n "$BUNDLE" ]]; then + test -f "$BUNDLE" + test "${#CANDIDATE_COMMIT}" -eq 40 + case "$CANDIDATE_COMMIT" in + *[!0-9a-f]*) exit 1 ;; + esac + else + test -z "$CANDIDATE_COMMIT" + fi + - name: Check the allowed path manifest + shell: bash + env: + PATHS_FILE: ${{ inputs.paths-file }} + ALLOWED_PATHS: ${{ inputs.allowed-paths }} + run: | + set -euo pipefail + trusted_paths=() + while IFS= read -r path || [[ -n "$path" ]]; do + case "$path" in + '' | /* | *[!A-Za-z0-9._/-]* | . | .. | ./* | ../* | */. | */.. | */./* | */../*) exit 1 ;; + esac + trusted_paths+=("$path") + done < <(printf '%s' "$ALLOWED_PATHS") + test "${#trusted_paths[@]}" -gt 0 + printf '%s\n' "${trusted_paths[@]}" | LC_ALL=C sort -u >"$RUNNER_TEMP/allowed-paths" + test "$(wc -l <"$RUNNER_TEMP/allowed-paths")" -eq "${#trusted_paths[@]}" + paths=() + while IFS=$'\t' read -r path blob; do + case "$path" in + '' | /* | *[!A-Za-z0-9._/-]* | . | .. | ./* | ../* | */. | */.. | */./* | */../*) exit 1 ;; + esac + grep -Fqx -- "$path" "$RUNNER_TEMP/allowed-paths" + test "${#blob}" -eq 40 + case "$blob" in + *[!0-9a-f]*) exit 1 ;; + esac + paths+=("$path") + done <"$PATHS_FILE" + test "${#paths[@]}" -gt 0 + test "$(printf '%s\n' "${paths[@]}" | LC_ALL=C sort -u | wc -l)" -eq "${#paths[@]}" + printf '%s\n' "${paths[@]}" | LC_ALL=C sort >"$RUNNER_TEMP/expected-paths" + - name: Verify the prepared commit + shell: bash + env: + APP_SLUG: ${{ inputs.app-slug }} + BASE_COMMIT: ${{ inputs.base-commit }} + BUNDLE: ${{ inputs.bundle }} + CANDIDATE_COMMIT: ${{ inputs.candidate-commit }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + PATHS_FILE: ${{ inputs.paths-file }} + run: | + set -euo pipefail + if [[ -n "$BUNDLE" ]]; then + BUNDLE=$(python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$BUNDLE") + git init --bare "$RUNNER_TEMP/post-release.git" >/dev/null + git -C "$RUNNER_TEMP/post-release.git" bundle verify "$BUNDLE" >/dev/null + git -C "$RUNNER_TEMP/post-release.git" fetch "$BUNDLE" \ + refs/heads/release-candidate:refs/heads/release-candidate >/dev/null + test "$(git -C "$RUNNER_TEMP/post-release.git" rev-parse refs/heads/release-candidate)" = "$CANDIDATE_COMMIT" + read -r commit parent extra < <( + git -C "$RUNNER_TEMP/post-release.git" rev-list --parents -n 1 "$CANDIDATE_COMMIT" + ) + test "$commit" = "$CANDIDATE_COMMIT" + test "$parent" = "$BASE_COMMIT" + test -z "${extra:-}" + author_name="${APP_SLUG}[bot]" + author_email="$(cat "$RUNNER_TEMP/post-release-bot-id")+${author_name}@users.noreply.github.com" + test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%an "$CANDIDATE_COMMIT")" = "$author_name" + test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%ae "$CANDIDATE_COMMIT")" = "$author_email" + test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%cn "$CANDIDATE_COMMIT")" = "$author_name" + test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%ce "$CANDIDATE_COMMIT")" = "$author_email" + test "$(git -C "$RUNNER_TEMP/post-release.git" show -s --format=%B "$CANDIDATE_COMMIT")" = "$COMMIT_MESSAGE" + git -C "$RUNNER_TEMP/post-release.git" rev-parse "$CANDIDATE_COMMIT^{tree}" >"$RUNNER_TEMP/candidate-tree" + git -C "$RUNNER_TEMP/post-release.git" diff --name-only --no-renames "$BASE_COMMIT" "$CANDIDATE_COMMIT" | + LC_ALL=C sort >"$RUNNER_TEMP/candidate-paths" + cmp -s "$RUNNER_TEMP/expected-paths" "$RUNNER_TEMP/candidate-paths" + while IFS=$'\t' read -r path blob; do + read -r mode object actual _ < <( + git -C "$RUNNER_TEMP/post-release.git" ls-tree "$CANDIDATE_COMMIT" -- "$path" + ) + test "$mode" = 100644 + test "$object" = blob + test "$actual" = "$blob" + done <"$PATHS_FILE" + fi + - name: Inspect the existing pull request + shell: bash + env: + BASE: main + BRANCH: ${{ inputs.branch }} + TITLE: ${{ inputs.title }} + BODY_FILE: ${{ inputs.body-file }} + run: | + set -euo pipefail + head_owner=${GITHUB_REPOSITORY%%/*} + gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=all -f "head=$head_owner:$BRANCH" -f per_page=100 \ + >"$RUNNER_TEMP/post-release-all-prs.json" + jq -e --arg repository "$GITHUB_REPOSITORY" --arg title "$TITLE" --arg base "$BASE" \ + --arg branch "$BRANCH" --rawfile body "$BODY_FILE" ' + [.[] | select(.head.repo.full_name == $repository and .head.ref == $branch)] as $existing | + if any($existing[]; .state == "closed" and .merged_at == null) then + error("post-release pull request was closed without merging; reopen it to retry") + elif any($existing[]; .state == "open" and + (.title != $title or .body != $body or .base.ref != $base)) then + error("open pull request metadata changed") + else [$existing[] | select( + .title == $title and .body == $body and .base.ref == $base and + (.state == "open" or .merged_at != null) + )] | if length <= 1 then . else error("multiple matching pull requests") end end + ' "$RUNNER_TEMP/post-release-all-prs.json" >"$RUNNER_TEMP/post-release-prs.json" + - name: Verify an already merged pull request + shell: bash + env: + BASE: main + PATHS_FILE: ${{ inputs.paths-file }} + run: | + set -euo pipefail + if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then + base_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha) + test "${#base_sha}" -eq 40 + case "$base_sha" in + *[!0-9a-f]*) exit 1 ;; + esac + pr_number=$(jq -r '.[0].number' "$RUNNER_TEMP/post-release-prs.json") + while IFS=$'\t' read -r path blob; do + read -r object actual < <(gh api "repos/$GITHUB_REPOSITORY/contents/$path?ref=$BASE" --jq '[.type, .sha] | @tsv') + test "$object" = file + test "$actual" = "$blob" + done <"$PATHS_FILE" + gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number/files" --paginate --jq '.[].filename' | + LC_ALL=C sort | cmp -s - "$RUNNER_TEMP/expected-paths" + printf '%s\n' "$pr_number" >"$RUNNER_TEMP/post-release-number" + fi + - name: Publish or verify the prepared branch + shell: bash + env: + APP_SLUG: ${{ inputs.app-slug }} + BASE: main + BASE_COMMIT: ${{ inputs.base-commit }} + BRANCH: ${{ inputs.branch }} + BUNDLE: ${{ inputs.bundle }} + CANDIDATE_COMMIT: ${{ inputs.candidate-commit }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + run: | + set -euo pipefail + if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") != true ]]; then + test -n "$BUNDLE" + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT" + pr_number=$(jq -r '.[0].number // empty' "$RUNNER_TEMP/post-release-prs.json") + if branch_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha \ + 2>"$RUNNER_TEMP/branch-error"); then + gh api "repos/$GITHUB_REPOSITORY/git/commits/$branch_sha" >"$RUNNER_TEMP/post-release-branch.json" + read -r parent_count branch_parent branch_tree branch_author branch_email < <( + jq -r '[(.parents | length), .parents[0].sha, .tree.sha, .author.name, .author.email] | @tsv' \ + "$RUNNER_TEMP/post-release-branch.json" + ) + test "$parent_count" = 1 + test "$branch_parent" = "$BASE_COMMIT" + test "$branch_tree" = "$(cat "$RUNNER_TEMP/candidate-tree")" + test "$branch_author" = "${APP_SLUG}[bot]" + test "$branch_email" = "$(cat "$RUNNER_TEMP/post-release-bot-id")+${APP_SLUG}[bot]@users.noreply.github.com" + jq -e --arg name "${APP_SLUG}[bot]" \ + --arg email "$(cat "$RUNNER_TEMP/post-release-bot-id")+${APP_SLUG}[bot]@users.noreply.github.com" \ + --arg message "$COMMIT_MESSAGE" \ + '.committer.name == $name and .committer.email == $email and .message == $message' \ + "$RUNNER_TEMP/post-release-branch.json" >/dev/null + elif grep -Fq '(HTTP 404)' "$RUNNER_TEMP/branch-error"; then + test -z "$pr_number" + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE" --jq .object.sha)" = "$BASE_COMMIT" + gh auth setup-git --hostname "${GH_HOST:-github.com}" --force + git -C "$RUNNER_TEMP/post-release.git" remote add origin \ + "https://${GH_HOST:-github.com}/$GITHUB_REPOSITORY.git" + git -C "$RUNNER_TEMP/post-release.git" push origin "refs/heads/release-candidate:refs/heads/$BRANCH" + branch_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha) + test "$branch_sha" = "$CANDIDATE_COMMIT" + else + cat "$RUNNER_TEMP/branch-error" >&2 + exit 1 + fi + printf '%s\n' "$branch_sha" >"$RUNNER_TEMP/post-release-branch-sha" + fi + - name: Create or verify the pull request + shell: bash + env: + BASE: main + BRANCH: ${{ inputs.branch }} + TITLE: ${{ inputs.title }} + BODY_FILE: ${{ inputs.body-file }} + run: | + set -euo pipefail + if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") != true ]]; then + pr_number=$(jq -r '.[0].number // empty' "$RUNNER_TEMP/post-release-prs.json") + branch_sha=$(cat "$RUNNER_TEMP/post-release-branch-sha") + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)" = "$branch_sha" + if [[ -n "$pr_number" ]]; then + test "$(jq -r '.[0].head.sha' "$RUNNER_TEMP/post-release-prs.json")" = "$branch_sha" + else + gh api -X POST "repos/$GITHUB_REPOSITORY/pulls" -f "head=$BRANCH" -f "base=$BASE" \ + -f "title=$TITLE" -F "body=@$BODY_FILE" >"$RUNNER_TEMP/post-release-created.json" + pr_number=$(jq -r .number "$RUNNER_TEMP/post-release-created.json") + case "$pr_number" in + '' | 0* | *[!0-9]*) exit 1 ;; + esac + test "$(jq -r .head.sha "$RUNNER_TEMP/post-release-created.json")" = "$branch_sha" + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" --jq .object.sha)" = "$branch_sha" + fi + printf '%s\n' "$pr_number" >"$RUNNER_TEMP/post-release-number" + fi + - name: Ensure the maintenance label + shell: bash + run: | + set -euo pipefail + pr_number=$(cat "$RUNNER_TEMP/post-release-number") + labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" --jq '.[].name') + if ! grep -Fxq 'skip news' <<<"$labels"; then + if [[ $(jq -r '.[0].merged_at != null' "$RUNNER_TEMP/post-release-prs.json") == true ]]; then + echo '::error::merged pull request lost its required label' >&2 + exit 1 + fi + gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$pr_number/labels" -f 'labels[]=skip news' >/dev/null + fi diff --git a/pylock.toml b/pylock.toml index a1c2365..198455d 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1,151 +1,2359 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile --group release --group test --python-version 3.15 --format pylock.toml --output-file pylock.toml pyproject.toml lock-version = "1.0" -created-by = "uv" +environments = [ + "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"x86_64\" and implementation_name == \"cpython\" and os_name == \"posix\" and platform_python_implementation == \"CPython\" and platform_system == \"Linux\"", + "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"aarch64\" and implementation_name == \"cpython\" and os_name == \"posix\" and platform_python_implementation == \"CPython\" and platform_system == \"Linux\"", + "python_version == \"3.15\" and sys_platform == \"darwin\" and platform_machine == \"arm64\" and implementation_name == \"cpython\" and os_name == \"posix\" and platform_python_implementation == \"CPython\" and platform_system == \"Darwin\"", + "python_version == \"3.15\" and sys_platform == \"win32\" and platform_machine == \"AMD64\" and implementation_name == \"cpython\" and os_name == \"nt\" and platform_python_implementation == \"CPython\" and platform_system == \"Windows\"", +] requires-python = ">=3.15" +dependency-groups = [ + "build", + "lock-tools", + "project-runtime", + "release-attestations", + "release-check", + "release-hook-check", + "release-hooks", + "release-news", + "release-notes", + "release-prepare", + "release-shell", + "release-validate", +] +default-groups = [ + "project-runtime", +] +created-by = "nab" + +[[packages]] +name = "annotated-types" +version = "0.8.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "annotated_types-0.8.0.tar.gz" +upload-time = 2026-07-23 20:16:13.995706+00:00 +url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz" +size = 15893 + +[packages.sdist.hashes] +sha256 = "13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" + +[[packages.wheels]] +name = "annotated_types-0.8.0-py3-none-any.whl" +upload-time = 2026-07-23 20:16:12.938651+00:00 +url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl" +size = 13427 + +[packages.wheels.hashes] +sha256 = "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" [[packages]] name = "argcomplete" version = "3.7.2" -sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", upload-time = 2026-08-06T04:53:21Z, size = 74473, hashes = { sha256 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", upload-time = 2026-08-06T04:53:20Z, size = 43294, hashes = { sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" } }] +marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "argcomplete-3.7.2.tar.gz" +upload-time = 2026-08-06 04:53:21.662042+00:00 +url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz" +size = 74473 + +[packages.sdist.hashes] +sha256 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9" + +[[packages.wheels]] +name = "argcomplete-3.7.2-py3-none-any.whl" +upload-time = 2026-08-06 04:53:20.246664+00:00 +url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl" +size = 43294 + +[packages.wheels.hashes] +sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" + +[[packages]] +name = "build" +version = "1.6.1" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "colorama" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "build-1.6.1.tar.gz" +upload-time = 2026-09-10 07:56:00.417084+00:00 +url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz" +size = 113427 + +[packages.sdist.hashes] +sha256 = "51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53" + +[[packages.wheels]] +name = "build-1.6.1-py3-none-any.whl" +upload-time = 2026-09-10 07:55:59.148398+00:00 +url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl" +size = 31179 + +[packages.wheels.hashes] +sha256 = "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7" + +[[packages]] +name = "certifi" +version = "2026.7.22" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "certifi-2026.7.22.tar.gz" +upload-time = 2026-07-22 03:35:12.644458+00:00 +url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz" +size = 138112 + +[packages.sdist.hashes] +sha256 = "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" + +[[packages.wheels]] +name = "certifi-2026.7.22-py3-none-any.whl" +upload-time = 2026-07-22 03:35:11.276376+00:00 +url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl" +size = 136983 + +[packages.wheels.hashes] +sha256 = "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" + +[[packages]] +name = "cffi" +version = "2.1.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "pycparser" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cffi-2.1.1.tar.gz" +upload-time = 2026-08-03 21:21:18.939988+00:00 +url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz" +size = 530807 + +[packages.sdist.hashes] +sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" + +[[packages.wheels]] +name = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-08-03 21:20:52.173342+00:00 +url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl" +size = 185002 + +[packages.wheels.hashes] +sha256 = "2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8" + +[[packages.wheels]] +name = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-08-03 21:20:53.462549+00:00 +url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +size = 222271 + +[packages.wheels.hashes] +sha256 = "507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6" + +[[packages.wheels]] +name = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-08-03 21:20:57.336082+00:00 +url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 221630 + +[packages.wheels.hashes] +sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" + +[[packages.wheels]] +name = "cffi-2.1.1-cp315-cp315-win_amd64.whl" +upload-time = 2026-08-03 21:21:16.108179+00:00 +url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl" +size = 187897 + +[packages.wheels.hashes] +sha256 = "e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960" + +[[packages]] +name = "charset-normalizer" +version = "3.5.2" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "charset_normalizer-3.5.2.tar.gz" +upload-time = 2026-09-30 04:39:23.398219+00:00 +url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz" +size = 172659 + +[packages.sdist.hashes] +sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl" +upload-time = 2026-09-30 04:37:28.329851+00:00 +url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl" +size = 367676 + +[packages.wheels.hashes] +sha256 = "87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +upload-time = 2026-09-30 04:37:30.167187+00:00 +url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +size = 248414 + +[packages.wheels.hashes] +sha256 = "9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 04:37:37.397915+00:00 +url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +size = 257336 + +[packages.wheels.hashes] +sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl" +upload-time = 2026-09-30 04:37:53.427053+00:00 +url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl" +size = 210858 + +[packages.wheels.hashes] +sha256 = "89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl" +upload-time = 2026-09-30 04:38:26.216534+00:00 +url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl" +size = 331815 + +[packages.wheels.hashes] +sha256 = "4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +upload-time = 2026-09-30 04:38:28.032254+00:00 +url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +size = 253276 + +[packages.wheels.hashes] +sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +upload-time = 2026-09-30 04:38:29.732000+00:00 +url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +size = 241239 + +[packages.wheels.hashes] +sha256 = "d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl" +upload-time = 2026-09-30 04:38:49.707190+00:00 +url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl" +size = 200092 + +[packages.wheels.hashes] +sha256 = "9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-py3-none-any.whl" +upload-time = 2026-09-30 04:39:21.828049+00:00 +url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl" +size = 68872 + +[packages.wheels.hashes] +sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" [[packages]] name = "click" version = "8.5.0" -sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", upload-time = 2026-08-26T13:33:14Z, size = 382235, hashes = { sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", upload-time = 2026-08-26T13:33:12Z, size = 125251, hashes = { sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" } }] +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "click-8.5.0.tar.gz" +upload-time = 2026-08-26 13:33:14.560531+00:00 +url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" +size = 382235 + +[packages.sdist.hashes] +sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" + +[[packages.wheels]] +name = "click-8.5.0-py3-none-any.whl" +upload-time = 2026-08-26 13:33:12.928043+00:00 +url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" +size = 125251 + +[packages.wheels.hashes] +sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" + +[[packages]] +name = "colorama" +version = "0.4.6" +marker = "\"lock-tools\" in dependency_groups and sys_platform == \"win32\"" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "colorama-0.4.6.tar.gz" +upload-time = 2022-10-25 02:36:22.414799+00:00 +url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz" +size = 27697 + +[packages.sdist.hashes] +sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" + +[[packages.wheels]] +name = "colorama-0.4.6-py2.py3-none-any.whl" +upload-time = 2022-10-25 02:36:20.889702+00:00 +url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl" +size = 25335 + +[packages.wheels.hashes] +sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + +[[packages]] +name = "cryptography" +version = "50.0.2" +marker = "\"release-attestations\" in dependency_groups" +requires-python = "!=3.9.0,!=3.9.1,>=3.9" +dependencies = [ + { name = "cffi" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cryptography-50.0.2.tar.gz" +upload-time = 2026-09-30 15:30:04.884601+00:00 +url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz" +size = 880623 + +[packages.sdist.hashes] +sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-09-30 14:43:44.339921+00:00 +url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl" +size = 3914904 + +[packages.wheels.hashes] +sha256 = "fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-09-30 14:43:47.113016+00:00 +url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +size = 4731146 + +[packages.wheels.hashes] +sha256 = "79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:43:49.010540+00:00 +url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4719841 + +[packages.wheels.hashes] +sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl" +upload-time = 2026-09-30 14:43:50.932452+00:00 +url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl" +size = 4738340 + +[packages.wheels.hashes] +sha256 = "f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:43:55.272150+00:00 +url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +size = 4753050 + +[packages.wheels.hashes] +sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl" +upload-time = 2026-09-30 14:43:59.541693+00:00 +url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl" +size = 4737859 + +[packages.wheels.hashes] +sha256 = "87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:44:04.545137+00:00 +url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +size = 4752576 + +[packages.wheels.hashes] +sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-win_amd64.whl" +upload-time = 2026-09-30 14:44:11.671809+00:00 +url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl" +size = 3824692 + +[packages.wheels.hashes] +sha256 = "7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl" +upload-time = 2026-09-30 14:44:41.807195+00:00 +url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl" +size = 4133708 + +[packages.wheels.hashes] +sha256 = "edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-09-30 14:44:43.693364+00:00 +url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +size = 4956267 + +[packages.wheels.hashes] +sha256 = "d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:44:45.769143+00:00 +url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4966465 + +[packages.wheels.hashes] +sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl" +upload-time = 2026-09-30 14:44:48.211032+00:00 +url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl" +size = 4959356 + +[packages.wheels.hashes] +sha256 = "e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:44:53.379605+00:00 +url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +size = 5001199 + +[packages.wheels.hashes] +sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl" +upload-time = 2026-09-30 14:44:59.639056+00:00 +url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl" +size = 4958822 + +[packages.wheels.hashes] +sha256 = "fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:45:05.009031+00:00 +url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +size = 5000859 + +[packages.wheels.hashes] +sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl" +upload-time = 2026-09-30 15:29:20.155896+00:00 +url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl" +size = 4111557 + +[packages.wheels.hashes] +sha256 = "c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-09-30 15:29:22.265815+00:00 +url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl" +size = 3943588 + +[packages.wheels.hashes] +sha256 = "0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-09-30 15:29:24.580797+00:00 +url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +size = 4756166 + +[packages.wheels.hashes] +sha256 = "c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 15:29:26.807001+00:00 +url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4749145 + +[packages.wheels.hashes] +sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl" +upload-time = 2026-09-30 15:29:28.588698+00:00 +url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl" +size = 4763638 + +[packages.wheels.hashes] +sha256 = "dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 15:29:32.605292+00:00 +url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +size = 4781387 + +[packages.wheels.hashes] +sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl" +upload-time = 2026-09-30 15:29:36.149639+00:00 +url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl" +size = 4764319 + +[packages.wheels.hashes] +sha256 = "0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 15:29:41.251208+00:00 +url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +size = 4780973 + +[packages.wheels.hashes] +sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-win_amd64.whl" +upload-time = 2026-09-30 15:29:46.782322+00:00 +url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl" +size = 3854095 + +[packages.wheels.hashes] +sha256 = "4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de" [[packages]] -name = "iniconfig" -version = "2.3.1" -sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", upload-time = 2026-10-06T22:48:38Z, size = 21209, hashes = { sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", upload-time = 2026-10-06T22:48:36Z, size = 7552, hashes = { sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" } }] +name = "cython" +version = "3.3.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cython-3.3.0.tar.gz" +upload-time = 2026-08-22 05:16:39.493040+00:00 +url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz" +size = 3727515 + +[packages.sdist.hashes] +sha256 = "eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8" + +[[packages.wheels]] +name = "cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-08-22 05:17:32.523944+00:00 +url = "https://files.pythonhosted.org/packages/ea/45/7f6988070013e16918e39b1b3dab9c5f2c8e404253a7fd10ee685bbd6902/cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl" +size = 3177681 + +[packages.wheels.hashes] +sha256 = "596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66" + +[[packages.wheels]] +name = "cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +upload-time = 2026-08-22 05:17:34.511844+00:00 +url = "https://files.pythonhosted.org/packages/6e/5d/afb6866ab10236bb208dff0f172ea4b397c9693c5250280c4d9d26057218/cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +size = 3328312 + +[packages.wheels.hashes] +sha256 = "4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d" + +[[packages.wheels]] +name = "cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-08-22 05:17:36.538454+00:00 +url = "https://files.pythonhosted.org/packages/c9/aa/4c0b6773ecf6bc1ec6cda7db8312a566611b330fb6dae87d740e44a47822/cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +size = 3454285 + +[packages.wheels.hashes] +sha256 = "b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616" + +[[packages.wheels]] +name = "cython-3.3.0-cp315-cp315-win_amd64.whl" +upload-time = 2026-08-22 05:17:38.650619+00:00 +url = "https://files.pythonhosted.org/packages/44/bb/3e2631122f96300723d6fc42b9cf65550bdcda570a6ad5c4e0226e2e787c/cython-3.3.0-cp315-cp315-win_amd64.whl" +size = 2924709 + +[packages.wheels.hashes] +sha256 = "b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef" + +[[packages.wheels]] +name = "cython-3.3.0-py3-none-any.whl" +upload-time = 2026-08-22 05:16:36.741632+00:00 +url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl" +size = 1349151 + +[packages.wheels.hashes] +sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1" + +[[packages]] +name = "dnspython" +version = "2.9.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "dnspython-2.9.0.tar.gz" +upload-time = 2026-10-09 00:07:24.352654+00:00 +url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz" +size = 423560 + +[packages.sdist.hashes] +sha256 = "b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1" + +[[packages.wheels]] +name = "dnspython-2.9.0-py3-none-any.whl" +upload-time = 2026-10-09 00:07:22.622887+00:00 +url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl" +size = 354822 + +[packages.wheels.hashes] +sha256 = "9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9" + +[[packages]] +name = "docutils" +version = "0.23" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "docutils-0.23.tar.gz" +upload-time = 2026-05-27 17:41:06.934109+00:00 +url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz" +size = 2303823 + +[packages.sdist.hashes] +sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" + +[[packages.wheels]] +name = "docutils-0.23-py3-none-any.whl" +upload-time = 2026-05-27 17:40:58.442528+00:00 +url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl" +size = 634701 + +[packages.wheels.hashes] +sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" + +[[packages]] +name = "email-validator" +version = "2.3.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "email_validator-2.3.0.tar.gz" +upload-time = 2025-08-26 13:09:06.831581+00:00 +url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz" +size = 51238 + +[packages.sdist.hashes] +sha256 = "9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" + +[[packages.wheels]] +name = "email_validator-2.3.0-py3-none-any.whl" +upload-time = 2025-08-26 13:09:05.858779+00:00 +url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl" +size = 35604 + +[packages.wheels.hashes] +sha256 = "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" + +[[packages]] +name = "id" +version = "1.6.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "id-1.6.1.tar.gz" +upload-time = 2026-02-04 16:19:41.260133+00:00 +url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz" +size = 18088 + +[packages.sdist.hashes] +sha256 = "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" + +[[packages.wheels]] +name = "id-1.6.1-py3-none-any.whl" +upload-time = 2026-02-04 16:19:40.051745+00:00 +url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl" +size = 14689 + +[packages.wheels.hashes] +sha256 = "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca" + +[[packages]] +name = "idna" +version = "3.20" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "idna-3.20.tar.gz" +upload-time = 2026-09-17 14:11:04.752193+00:00 +url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz" +size = 216463 + +[packages.sdist.hashes] +sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" + +[[packages.wheels]] +name = "idna-3.20-py3-none-any.whl" +upload-time = 2026-09-17 14:11:03.168153+00:00 +url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl" +size = 69583 + +[packages.wheels.hashes] +sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" + +[[packages]] +name = "installer" +version = "1.1.0" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "installer-1.1.0.tar.gz" +upload-time = 2026-10-12 19:35:52.851170+00:00 +url = "https://files.pythonhosted.org/packages/56/d3/2fab33d15004d41ae8e7b5d3e310510f7f34a7e1dd032a3e60d8b38d8fba/installer-1.1.0.tar.gz" +size = 481257 + +[packages.sdist.hashes] +sha256 = "5adc0c01ce62d809989e246987cd68944fe5df8af562d0358f65b2ab15cb5de7" + +[[packages.wheels]] +name = "installer-1.1.0-py3-none-any.whl" +upload-time = 2026-10-12 19:35:51.096942+00:00 +url = "https://files.pythonhosted.org/packages/d7/7a/63547a792a12148882205d79e07644091d5b7690f40c2bb9d41452a8bdc2/installer-1.1.0-py3-none-any.whl" +size = 464466 + +[packages.wheels.hashes] +sha256 = "f55f5728ff61d3dd54dbc5a397cf3f7307cb64b180c20b72afbebf01c9e2c8a0" [[packages]] name = "jinja2" version = "3.1.6" -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", upload-time = 2025-03-05T20:05:02Z, size = 245115, hashes = { sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", upload-time = 2025-03-05T20:05:00Z, size = 134899, hashes = { sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" } }] +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "markupsafe" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jinja2-3.1.6.tar.gz" +upload-time = 2025-03-05 20:05:02.478302+00:00 +url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" +size = 245115 + +[packages.sdist.hashes] +sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" + +[[packages.wheels]] +name = "jinja2-3.1.6-py3-none-any.whl" +upload-time = 2025-03-05 20:05:00.369721+00:00 +url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" +size = 134899 + +[packages.wheels.hashes] +sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" [[packages]] name = "markdown-it-py" version = "4.2.0" -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", upload-time = 2026-05-07T12:08:28Z, size = 82454, hashes = { sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", upload-time = 2026-05-07T12:08:27Z, size = 91687, hashes = { sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" } }] +marker = "\"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "mdurl" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "markdown_it_py-4.2.0.tar.gz" +upload-time = 2026-05-07 12:08:28.360612+00:00 +url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz" +size = 82454 + +[packages.sdist.hashes] +sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" + +[[packages.wheels]] +name = "markdown_it_py-4.2.0-py3-none-any.whl" +upload-time = 2026-05-07 12:08:27.182789+00:00 +url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" +size = 91687 + +[packages.wheels.hashes] +sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" version = "3.0.4" -sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", upload-time = 2026-10-02T23:07:22Z, size = 153777, hashes = { sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-10-02T23:06:22Z, size = 23111, hashes = { sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" } }] +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "markupsafe-3.0.4.tar.gz" +upload-time = 2026-10-02 23:07:22.290270+00:00 +url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" +size = 153777 + +[packages.sdist.hashes] +sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" + +[[packages.wheels]] +name = "markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-10-02 23:06:17.855823+00:00 +url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl" +size = 12075 + +[packages.wheels.hashes] +sha256 = "ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97" + +[[packages.wheels]] +name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +upload-time = 2026-10-02 23:06:18.963942+00:00 +url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +size = 24656 + +[packages.wheels.hashes] +sha256 = "4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b" + +[[packages.wheels]] +name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-10-02 23:06:22.524416+00:00 +url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +size = 23111 + +[packages.wheels.hashes] +sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" + +[[packages.wheels]] +name = "markupsafe-3.0.4-cp315-cp315-win_amd64.whl" +upload-time = 2026-10-02 23:06:33.258900+00:00 +url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl" +size = 14496 + +[packages.wheels.hashes] +sha256 = "d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214" [[packages]] name = "mdformat" version = "1.0.0" -sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", upload-time = 2025-10-16T12:05:03Z, size = 56953, hashes = { sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", upload-time = 2025-10-16T12:05:02Z, size = 53288, hashes = { sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" } }] +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdformat-1.0.0.tar.gz" +upload-time = 2025-10-16 12:05:03.695033+00:00 +url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" +size = 56953 + +[packages.sdist.hashes] +sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" + +[[packages.wheels]] +name = "mdformat-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 12:05:02.607432+00:00 +url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" +size = 53288 + +[packages.wheels.hashes] +sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" [[packages]] name = "mdformat-gfm" version = "1.0.0" -sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", upload-time = 2025-10-16T09:12:22Z, size = 10112, hashes = { sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", upload-time = 2025-10-16T09:12:21Z, size = 10970, hashes = { sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" } }] +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdit-py-plugins" }, + { name = "wcwidth" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdformat_gfm-1.0.0.tar.gz" +upload-time = 2025-10-16 09:12:22.402429+00:00 +url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" +size = 10112 + +[packages.sdist.hashes] +sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" + +[[packages.wheels]] +name = "mdformat_gfm-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 09:12:21.276157+00:00 +url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" +size = 10970 + +[packages.wheels.hashes] +sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" [[packages]] name = "mdit-py-plugins" version = "0.6.1" -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", upload-time = 2026-05-13T09:03:38Z, size = 56114, hashes = { sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", upload-time = 2026-05-13T09:03:37Z, size = 66663, hashes = { sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" } }] +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdit_py_plugins-0.6.1.tar.gz" +upload-time = 2026-05-13 09:03:38.910230+00:00 +url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" +size = 56114 + +[packages.sdist.hashes] +sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" + +[[packages.wheels]] +name = "mdit_py_plugins-0.6.1-py3-none-any.whl" +upload-time = 2026-05-13 09:03:37.760000+00:00 +url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" +size = 66663 + +[packages.wheels.hashes] +sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" [[packages]] name = "mdurl" version = "0.1.2" -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", upload-time = 2022-08-14T12:40:10Z, size = 8729, hashes = { sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", upload-time = 2022-08-14T12:40:09Z, size = 9979, hashes = { sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" } }] +marker = "\"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdurl-0.1.2.tar.gz" +upload-time = 2022-08-14 12:40:10.846281+00:00 +url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" +size = 8729 + +[packages.sdist.hashes] +sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + +[[packages.wheels]] +name = "mdurl-0.1.2-py3-none-any.whl" +upload-time = 2022-08-14 12:40:09.779676+00:00 +url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" +size = 9979 + +[packages.wheels.hashes] +sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" + +[[packages]] +name = "nab" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "nab-index" }, + { name = "nab-markersets" }, + { name = "nab-project" }, + { name = "nab-provider" }, + { name = "nab-resolver" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nab-0.0.22.tar.gz" +upload-time = 2026-10-18 05:45:08.513928+00:00 +url = "https://files.pythonhosted.org/packages/25/3d/e4c8f4b2890efbf2fa6eef82acf57ecc0ef39b0a1e6843d306d2053a107c/nab-0.0.22.tar.gz" +size = 286128 + +[packages.sdist.hashes] +sha256 = "a0da54ee96bf1eb1997581566620c250c8a3557139e9467ccaced2a12e9e71ac" + +[[packages.wheels]] +name = "nab-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:45:06.803974+00:00 +url = "https://files.pythonhosted.org/packages/56/4b/77baa0ddb0b7578b6475b2b578fa4579207b0f97e0b3b80480e01dcb3d6f/nab-0.0.22-py3-none-any.whl" +size = 137802 + +[packages.wheels.hashes] +sha256 = "f62fcf75b28786fbf58627f6d7eaa735daefbc7f736715403bc4357acb57e7bb" + +[[packages]] +name = "nab-index" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "nab-provider" }, + { name = "packaging" }, + { name = "truststore" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nab_index-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:43.857925+00:00 +url = "https://files.pythonhosted.org/packages/2a/83/8d2483239b7dc5ac8d9f758db1cc19d4129d0143d34c2e1ab8cd48ea71fb/nab_index-0.0.22.tar.gz" +size = 110621 + +[packages.sdist.hashes] +sha256 = "5900ea5ad298512379cfddff1a73a544590f0b04620ef58ff345f99a7c59688e" + +[[packages.wheels]] +name = "nab_index-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:42.188007+00:00 +url = "https://files.pythonhosted.org/packages/c0/58/c06ada29934a78479b989675829f26083972f38bf1e8a2503e01101cb970/nab_index-0.0.22-py3-none-any.whl" +size = 88373 + +[packages.wheels.hashes] +sha256 = "c89ffa607dfe4246dc59e9f5236d54413da3d310183ffa3298ba51a48e7c2ec7" + +[[packages]] +name = "nab-markersets" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nab_markersets-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:19.580614+00:00 +url = "https://files.pythonhosted.org/packages/fc/b7/3997893dfdada9998d4dc786c10445a1361e197b4aed54ddd866c84dd538/nab_markersets-0.0.22.tar.gz" +size = 109258 + +[packages.sdist.hashes] +sha256 = "38212664f517f75d798808ecfe902bfd11fef340042ab0203100c7e06e78eea5" + +[[packages.wheels]] +name = "nab_markersets-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:18.237004+00:00 +url = "https://files.pythonhosted.org/packages/5e/9e/9744adff3a66633a811f8253faaa8b91a447c99199edba16f2790e3647ea/nab_markersets-0.0.22-py3-none-any.whl" +size = 44360 + +[packages.wheels.hashes] +sha256 = "ad268dcece034495b2ea31a70250cd43cc4f6123039a548762c7ec8826e5d3bd" + +[[packages]] +name = "nab-project" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "build" }, + { name = "installer" }, + { name = "nab-index" }, + { name = "nab-markersets" }, + { name = "nab-provider" }, + { name = "nab-resolver" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nab_project-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:55.821489+00:00 +url = "https://files.pythonhosted.org/packages/65/84/f5197d4044ceda9c6ee3e7aefce154ab616fec632fa1d89cdfaacc665c07/nab_project-0.0.22.tar.gz" +size = 774409 + +[packages.sdist.hashes] +sha256 = "5f7a1dee5b618149267b2b6233c3a220e45cd6f97bd5ec7f3f48fa7570e9f053" + +[[packages.wheels]] +name = "nab_project-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:53.956127+00:00 +url = "https://files.pythonhosted.org/packages/de/c9/62affa4df6cabce48751307c4c3adfbaa8c54e402d464d015897a16a6dba/nab_project-0.0.22-py3-none-any.whl" +size = 148262 + +[packages.wheels.hashes] +sha256 = "cb09fa143105fd99a60609b3ff4830924b0d4eaf5abad48678467ec4b73e577f" + +[[packages]] +name = "nab-provider" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "nab-markersets" }, + { name = "nab-resolver" }, + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nab_provider-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:31.579358+00:00 +url = "https://files.pythonhosted.org/packages/94/0d/64fbadae562fefc00257c4f5ec988171d6e6b3ea4363f1da2e8886e80228/nab_provider-0.0.22.tar.gz" +size = 409367 + +[packages.sdist.hashes] +sha256 = "3f129ab7394bf5eb031152c3535ee74073e2a6b6ae081fb7f5a4d269fcf5f8a0" + +[[packages.wheels]] +name = "nab_provider-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:29.803721+00:00 +url = "https://files.pythonhosted.org/packages/30/67/32bda18d34f0f8ec098f8e4a0f792164ef1a4b2aa61d435d20f4645572af/nab_provider-0.0.22-py3-none-any.whl" +size = 339242 + +[packages.wheels.hashes] +sha256 = "1450c9dc58b23da1a7bde7fdb43a72b043327bd0e3ef20435254ffcb8b7eae6b" + +[[packages]] +name = "nab-resolver" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nab_resolver-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:08.017044+00:00 +url = "https://files.pythonhosted.org/packages/84/bc/dcbfe5163538b1cc8421797e3f8ff77d79321cbc9943af8065e2c8ae1c09/nab_resolver-0.0.22.tar.gz" +size = 167648 + +[packages.sdist.hashes] +sha256 = "e005b225213e032858cf4aef3850961aaa1d4ea521428da731f33175ebd3b05f" + +[[packages.wheels]] +name = "nab_resolver-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:06.445864+00:00 +url = "https://files.pythonhosted.org/packages/22/a8/6e30b25ae12e0c662dc9b181a7f32fa8cd22f26016f12dc1d10176f62bba/nab_resolver-0.0.22-py3-none-any.whl" +size = 58597 + +[packages.wheels.hashes] +sha256 = "6f77daa98279ef0eedb9e8f2248aee4c50e01b92b470b68886995e5056d79971" [[packages]] name = "packaging" version = "26.3" -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", upload-time = 2026-08-04T18:15:28Z, size = 313412, hashes = { sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", upload-time = 2026-08-04T18:15:27Z, size = 129956, hashes = { sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" } }] +marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "packaging-26.3.tar.gz" +upload-time = 2026-08-04 18:15:28.737071+00:00 +url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" +size = 313412 + +[packages.sdist.hashes] +sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" + +[[packages.wheels]] +name = "packaging-26.3-py3-none-any.whl" +upload-time = 2026-08-04 18:15:27.159957+00:00 +url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" +size = 129956 + +[packages.wheels.hashes] +sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" [[packages]] -name = "pluggy" -version = "1.7.0" -sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", upload-time = 2026-10-15T09:50:58Z, size = 123304, hashes = { sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", upload-time = 2026-10-15T09:50:56Z, size = 27082, hashes = { sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" } }] +name = "platformdirs" +version = "4.13.3" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "platformdirs-4.13.3.tar.gz" +upload-time = 2026-10-16 01:16:16.573321+00:00 +url = "https://files.pythonhosted.org/packages/da/91/4a03cfdb03314cfca262921797fff04ff4054bd86a160aed76eddf8aa12b/platformdirs-4.13.3.tar.gz" +size = 61559 + +[packages.sdist.hashes] +sha256 = "5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce" + +[[packages.wheels]] +name = "platformdirs-4.13.3-py3-none-any.whl" +upload-time = 2026-10-16 01:16:15.051988+00:00 +url = "https://files.pythonhosted.org/packages/45/b8/fd1af06b079af236f5423f7c1821264419cc8f6b4803f79353acbb8bfa53/platformdirs-4.13.3-py3-none-any.whl" +size = 32762 + +[packages.wheels.hashes] +sha256 = "f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4" [[packages]] name = "prek" -version = "0.5.6" -sdist = { url = "https://files.pythonhosted.org/packages/4f/96/c4062bcaebdabfa945d07c424fb8c05d53a72d4f3fdcf255b1188abbb150/prek-0.5.6.tar.gz", upload-time = 2026-10-19T05:46:52Z, size = 579962, hashes = { sha256 = "87a8c19f873b96be809b4368959ed908cfa06255dba15082d0fb91173e9293bc" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/e1/f7/04e08d2343ce1d54c5da879e2dfea7df20f3140b1b2023a937fac87fa545/prek-0.5.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-10-19T05:46:42Z, size = 6165929, hashes = { sha256 = "aabec01beeb372bcdc48f6b339021bc33c07edf477b337fc92dc5d3b01f99150" } }] +version = "0.5.5" +marker = "\"release-check\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "prek-0.5.5.tar.gz" +upload-time = 2026-10-05 06:08:59.165911+00:00 +url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz" +size = 570428 + +[packages.sdist.hashes] +sha256 = "16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a" + +[[packages.wheels]] +name = "prek-0.5.5-py3-none-macosx_11_0_arm64.whl" +upload-time = 2026-10-05 06:08:41.428822+00:00 +url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl" +size = 5548955 + +[packages.wheels.hashes] +sha256 = "2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e" + +[[packages.wheels]] +name = "prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" +upload-time = 2026-10-05 06:08:44.051128+00:00 +url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" +size = 5848228 + +[packages.wheels.hashes] +sha256 = "120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b" + +[[packages.wheels]] +name = "prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-05 06:08:48.645837+00:00 +url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 6269922 + +[packages.wheels.hashes] +sha256 = "73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850" + +[[packages.wheels]] +name = "prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl" +upload-time = 2026-10-05 06:08:50.808055+00:00 +url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl" +size = 5861518 + +[packages.wheels.hashes] +sha256 = "76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99" + +[[packages.wheels]] +name = "prek-0.5.5-py3-none-win_amd64.whl" +upload-time = 2026-10-05 06:08:55.056244+00:00 +url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl" +size = 5784759 + +[packages.wheels.hashes] +sha256 = "15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874" + +[[packages]] +name = "pyasn1" +version = "0.6.4" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyasn1-0.6.4.tar.gz" +upload-time = 2026-07-09 01:12:33.988388+00:00 +url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz" +size = 151262 + +[packages.sdist.hashes] +sha256 = "9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81" + +[[packages.wheels]] +name = "pyasn1-0.6.4-py3-none-any.whl" +upload-time = 2026-07-09 01:12:32.920042+00:00 +url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl" +size = 84410 + +[packages.wheels.hashes] +sha256 = "deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b" + +[[packages]] +name = "pycparser" +version = "3.11" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pycparser-3.11.tar.gz" +upload-time = 2026-10-09 12:56:59.539345+00:00 +url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz" +size = 113796 + +[packages.sdist.hashes] +sha256 = "d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc" + +[[packages.wheels]] +name = "pycparser-3.11-py3-none-any.whl" +upload-time = 2026-10-09 12:56:58.131173+00:00 +url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl" +size = 51178 + +[packages.wheels.hashes] +sha256 = "51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80" + +[[packages]] +name = "pydantic" +version = "2.14.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "annotated-types" }, + { name = "email-validator" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pydantic-2.14.1.tar.gz" +upload-time = 2026-10-11 18:37:55.396662+00:00 +url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz" +size = 900362 + +[packages.sdist.hashes] +sha256 = "94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26" + +[[packages.wheels]] +name = "pydantic-2.14.1-py3-none-any.whl" +upload-time = 2026-10-11 18:37:53.437059+00:00 +url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl" +size = 488106 + +[packages.wheels.hashes] +sha256 = "9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454" + +[[packages]] +name = "pydantic-core" +version = "2.50.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pydantic_core-2.50.1.tar.gz" +upload-time = 2026-10-11 18:35:44.820993+00:00 +url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz" +size = 513515 + +[packages.sdist.hashes] +sha256 = "e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09" + +[[packages.wheels]] +name = "pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-10-11 18:34:05.901276+00:00 +url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl" +size = 2024191 + +[packages.wheels.hashes] +sha256 = "84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c" + +[[packages.wheels]] +name = "pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +upload-time = 2026-10-11 18:34:07.757584+00:00 +url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +size = 2048252 + +[packages.wheels.hashes] +sha256 = "23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7" + +[[packages.wheels]] +name = "pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-11 18:34:15.560294+00:00 +url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 2159961 + +[packages.wheels.hashes] +sha256 = "7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa" + +[[packages.wheels]] +name = "pydantic_core-2.50.1-cp315-cp315-win_amd64.whl" +upload-time = 2026-10-11 18:34:29.508272+00:00 +url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl" +size = 2171681 + +[packages.wheels.hashes] +sha256 = "983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed" [[packages]] name = "pygments" version = "2.21.0" -sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", upload-time = 2026-08-17T08:02:48Z, size = 5005329, hashes = { sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", upload-time = 2026-08-17T08:02:44Z, size = 1250147, hashes = { sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" } }] +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pygments-2.21.0.tar.gz" +upload-time = 2026-08-17 08:02:48.824391+00:00 +url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" +size = 5005329 + +[packages.sdist.hashes] +sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" + +[[packages.wheels]] +name = "pygments-2.21.0-py3-none-any.whl" +upload-time = 2026-08-17 08:02:44.912148+00:00 +url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" +size = 1250147 + +[packages.wheels.hashes] +sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" + +[[packages]] +name = "pyjwt" +version = "2.15.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyjwt-2.15.1.tar.gz" +upload-time = 2026-09-28 18:40:42.598678+00:00 +url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz" +size = 121252 + +[packages.sdist.hashes] +sha256 = "4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8" + +[[packages.wheels]] +name = "pyjwt-2.15.1-py3-none-any.whl" +upload-time = 2026-09-28 18:40:41.429003+00:00 +url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl" +size = 33860 + +[packages.wheels.hashes] +sha256 = "42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193" [[packages]] -name = "pytest" -version = "9.1.1" -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", upload-time = 2026-06-19T10:58:32Z, size = 1636369, hashes = { sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", upload-time = 2026-06-19T10:58:31Z, size = 386536, hashes = { sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" } }] +name = "pyopenssl" +version = "26.4.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "cryptography" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyopenssl-26.4.0.tar.gz" +upload-time = 2026-08-01 19:50:50.512064+00:00 +url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz" +size = 182046 + +[packages.sdist.hashes] +sha256 = "28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7" + +[[packages.wheels]] +name = "pyopenssl-26.4.0-py3-none-any.whl" +upload-time = 2026-08-01 19:50:48.940195+00:00 +url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl" +size = 56026 + +[packages.wheels.hashes] +sha256 = "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c" + +[[packages]] +name = "pypi-attestations" +version = "0.0.30" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "cryptography" }, + { name = "packaging" }, + { name = "pyasn1" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "rfc3986" }, + { name = "sigstore" }, + { name = "sigstore-models" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pypi_attestations-0.0.30.tar.gz" +upload-time = 2026-07-28 14:08:35.011386+00:00 +url = "https://files.pythonhosted.org/packages/97/1e/eb8a0233cdcaf01acb18076ac8b8a057714177b3717baf26b78a11b8d929/pypi_attestations-0.0.30.tar.gz" +size = 139108 + +[packages.sdist.hashes] +sha256 = "14ff13c76bbef02a483c2b77532da02bfd746b5141b881f355e06b9807434423" + +[[packages.wheels]] +name = "pypi_attestations-0.0.30-py3-none-any.whl" +upload-time = 2026-07-28 14:08:33.453745+00:00 +url = "https://files.pythonhosted.org/packages/5f/24/e59078318b5e2bca59be5a21957d70e082e6eb4f478adffe373f3b74daf4/pypi_attestations-0.0.30-py3-none-any.whl" +size = 23015 + +[packages.wheels.hashes] +sha256 = "b3a9c53f6cb89e5e7b5b70e6cfca97cfc66008c1ed54087355e06e40071cef21" + +[[packages]] +name = "pyproject-hooks" +version = "1.3.3" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyproject_hooks-1.3.3.tar.gz" +upload-time = 2026-09-16 08:58:03.999217+00:00 +url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz" +size = 21013 + +[packages.sdist.hashes] +sha256 = "defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43" + +[[packages.wheels]] +name = "pyproject_hooks-1.3.3-py3-none-any.whl" +upload-time = 2026-09-16 08:58:02.960279+00:00 +url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl" +size = 10724 + +[packages.wheels.hashes] +sha256 = "5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b" [[packages]] name = "pyyaml" version = "6.0.3" -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", upload-time = 2025-09-25T21:33:16Z, size = 130960, hashes = { sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } } +marker = "\"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyyaml-6.0.3.tar.gz" +upload-time = 2025-09-25 21:33:16.546886+00:00 +url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" +size = 130960 + +[packages.sdist.hashes] +sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "0.4.1" -sdist = { url = "https://files.pythonhosted.org/packages/98/3e/22a3249fecc6ee0e166ef81227a4ba8cb5c83e0bddd5e57b081050ca2bcd/release_creator-0.4.1.tar.gz", upload-time = 2026-10-19T20:26:30Z, size = 63943, hashes = { sha256 = "80f064352f1c0546f12e27ef643fed35e91059b095def95cb5f5baa44357a0d2" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/7b/a2/acae8a1ef434bf427f9e0726f66ddf6ee4913704e4f31891a280a712a58d/release_creator-0.4.1-py3-none-any.whl", upload-time = 2026-10-19T20:26:29Z, size = 20115, hashes = { sha256 = "0328c9044719b5e011312628a2b2cb818288f862e6ac0933510778e412b5e569" } }] +version = "0.6.0" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.15" +dependencies = [ + { name = "docutils" }, + { name = "mdformat" }, + { name = "mdformat-gfm" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "towncrier" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "release_creator-0.6.0.tar.gz" +upload-time = 2026-10-23 02:44:39.588714+00:00 +url = "https://files.pythonhosted.org/packages/17/11/c0126b406a17370db72d8a08afa1a5823204e69c44938de796cc98df0d02/release_creator-0.6.0.tar.gz" +size = 135957 + +[packages.sdist.hashes] +sha256 = "58afd3cd0fc0b0e47ded23a735a1ba9189a7b0adad410628c90c96cf74457ed6" + +[[packages.wheels]] +name = "release_creator-0.6.0-py3-none-any.whl" +upload-time = 2026-10-23 02:44:37.874747+00:00 +url = "https://files.pythonhosted.org/packages/ef/91/ad1ce7d5c65892866893879fb0f407898f2d5c808bbd266ca0a82513a205/release_creator-0.6.0-py3-none-any.whl" +size = 49478 + +[packages.wheels.hashes] +sha256 = "4b9cd6187c87fcb7bf3aca5f58c67c90703e58875643af633661a0448756d45f" [[packages]] -name = "ruff" -version = "0.17.1" -sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", upload-time = 2026-10-15T15:23:18Z, size = 5323349, hashes = { sha256 = "5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-10-15T15:22:54Z, size = 10350811, hashes = { sha256 = "a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189" } }] +name = "requests" +version = "2.34.2" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "requests-2.34.2.tar.gz" +upload-time = 2026-05-14 19:25:27.735762+00:00 +url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz" +size = 142856 + +[packages.sdist.hashes] +sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" + +[[packages.wheels]] +name = "requests-2.34.2-py3-none-any.whl" +upload-time = 2026-05-14 19:25:26.443000+00:00 +url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl" +size = 73075 + +[packages.wheels.hashes] +sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" + +[[packages]] +name = "rfc3161-client" +version = "1.0.9" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "cryptography" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rfc3161_client-1.0.9.tar.gz" +upload-time = 2026-09-14 19:32:29.113898+00:00 +url = "https://files.pythonhosted.org/packages/56/53/e2f526fb6957023180c928448e2dd71b03b35038c315aed2d12d9a77c3af/rfc3161_client-1.0.9.tar.gz" +size = 112825 + +[packages.sdist.hashes] +sha256 = "66997415086d2c6e5d57a2d315157b32a9c3f4f1312877ca8eb55e2e0ddf8ce4" + +[[packages.wheels]] +name = "rfc3161_client-1.0.9-cp39-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-09-14 19:32:10.339509+00:00 +url = "https://files.pythonhosted.org/packages/88/cf/bcfb66eaeccdc076457451a6213360c840bcacd0e938e8507951be29373f/rfc3161_client-1.0.9-cp39-abi3-macosx_11_0_arm64.whl" +size = 2439706 + +[packages.wheels.hashes] +sha256 = "a7004edfbf1bb7ec7801978a39fa7a7fe385ca59673df90d7578079b6eba3b24" + +[[packages.wheels]] +name = "rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +upload-time = 2026-09-14 19:32:12.025939+00:00 +url = "https://files.pythonhosted.org/packages/8e/f3/4b69d47fd7f79238bf8db20902a30d28c908efc0d906fb57abcc89ac75e3/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +size = 2651455 + +[packages.wheels.hashes] +sha256 = "7b703b233dbee7228d117d5f1c118363c61d523fa6f018c9217c79e3c6684f86" + +[[packages.wheels]] +name = "rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-09-14 19:32:17.185308+00:00 +url = "https://files.pythonhosted.org/packages/11/ae/19139fee184916065705d660bad203b676dc40b449afff36f79818c59c4f/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 2415566 + +[packages.wheels.hashes] +sha256 = "fac3f440a507555e684dc5daba75e33dc08f0f45fdefa48448f19001233a6b21" + +[[packages.wheels]] +name = "rfc3161_client-1.0.9-cp39-abi3-win_amd64.whl" +upload-time = 2026-09-14 19:32:27.621426+00:00 +url = "https://files.pythonhosted.org/packages/d2/a1/b71d9f2071660a914e039061d3cdcad77b1cbccf7276bff073f95e8b4d1a/rfc3161_client-1.0.9-cp39-abi3-win_amd64.whl" +size = 2431351 + +[packages.wheels.hashes] +sha256 = "908f6a775da4bdce1d39f825a4431f389542696078ef3e4ed911a068bcaf1792" + +[[packages]] +name = "rfc3986" +version = "2.0.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rfc3986-2.0.0.tar.gz" +upload-time = 2022-01-10 00:52:30.832978+00:00 +url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz" +size = 49026 + +[packages.sdist.hashes] +sha256 = "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + +[[packages.wheels]] +name = "rfc3986-2.0.0-py2.py3-none-any.whl" +upload-time = 2022-01-10 00:52:29.594625+00:00 +url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl" +size = 31326 + +[packages.wheels.hashes] +sha256 = "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" + +[[packages]] +name = "rfc8785" +version = "0.1.4" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rfc8785-0.1.4.tar.gz" +upload-time = 2024-09-27 16:33:31.206853+00:00 +url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz" +size = 14321 + +[packages.sdist.hashes] +sha256 = "e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da" + +[[packages.wheels]] +name = "rfc8785-0.1.4-py3-none-any.whl" +upload-time = 2024-09-27 16:33:29.683421+00:00 +url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl" +size = 9240 + +[packages.wheels.hashes] +sha256 = "520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48" + +[[packages]] +name = "rich" +version = "15.0.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.9.0" +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rich-15.0.0.tar.gz" +upload-time = 2026-04-12 08:24:00.750018+00:00 +url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz" +size = 230680 + +[packages.sdist.hashes] +sha256 = "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" + +[[packages.wheels]] +name = "rich-15.0.0-py3-none-any.whl" +upload-time = 2026-04-12 08:24:02.830783+00:00 +url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl" +size = 310654 + +[packages.wheels.hashes] +sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" + +[[packages]] +name = "securesystemslib" +version = "1.5.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = "~=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "securesystemslib-1.5.1.tar.gz" +upload-time = 2026-09-04 06:55:32.066349+00:00 +url = "https://files.pythonhosted.org/packages/81/d4/f9323507144693d869f30f13694035438ab6642153b94e3057945cf0584f/securesystemslib-1.5.1.tar.gz" +size = 947087 + +[packages.sdist.hashes] +sha256 = "4b8d00abd93707ead10b69eb2b8582376a1364de3b0a71077de534c2ef4985e0" + +[[packages.wheels]] +name = "securesystemslib-1.5.1-py3-none-any.whl" +upload-time = 2026-09-04 06:55:30.393643+00:00 +url = "https://files.pythonhosted.org/packages/38/fc/c71335a5046d46bf3b761a8c7fa092b40a7721429a5613538f0a28ae80a6/securesystemslib-1.5.1-py3-none-any.whl" +size = 876390 + +[packages.wheels.hashes] +sha256 = "ada8bdf817da29ece4ba91654f6a162ce7cfbadbc3ae3f840f7313f9d22675de" + +[[packages]] +name = "setuptools" +version = "84.0.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "setuptools-84.0.0.tar.gz" +upload-time = 2026-08-08 18:27:58.365696+00:00 +url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz" +size = 1168449 + +[packages.sdist.hashes] +sha256 = "f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" + +[[packages.wheels]] +name = "setuptools-84.0.0-py3-none-any.whl" +upload-time = 2026-08-08 18:27:56.719708+00:00 +url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl" +size = 818216 + +[packages.wheels.hashes] +sha256 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" + +[[packages]] +name = "sigstore" +version = "4.5.0" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "cryptography" }, + { name = "id" }, + { name = "platformdirs" }, + { name = "pyasn1" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "pyopenssl" }, + { name = "requests" }, + { name = "rfc3161-client" }, + { name = "rfc8785" }, + { name = "rich" }, + { name = "sigstore-models" }, + { name = "sigstore-rekor-types" }, + { name = "tuf" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sigstore-4.5.0.tar.gz" +upload-time = 2026-07-28 07:34:01.717744+00:00 +url = "https://files.pythonhosted.org/packages/18/e0/279419065e2d7102413605b3456122adbbccbc42e010b499c7b882fc01f8/sigstore-4.5.0.tar.gz" +size = 90969 + +[packages.sdist.hashes] +sha256 = "020d3e07f622b2916bf453e66ff6ff0711e1fdc5ab69e8bd8902f71d9fcb316f" + +[[packages.wheels]] +name = "sigstore-4.5.0-py3-none-any.whl" +upload-time = 2026-07-28 07:34:00.211729+00:00 +url = "https://files.pythonhosted.org/packages/bf/7f/51dc313c06dd3ec6c5b308e334492cfdea0b02c9184a0da7b2db8c2a30a1/sigstore-4.5.0-py3-none-any.whl" +size = 111724 + +[packages.wheels.hashes] +sha256 = "f045b207f2e12605cf775ec38e89c5eda625d71ffa7830477db65e47ec2bc8b2" + +[[packages]] +name = "sigstore-models" +version = "0.0.6" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sigstore_models-0.0.6.tar.gz" +upload-time = 2025-11-26 19:18:12.610187+00:00 +url = "https://files.pythonhosted.org/packages/c6/ed/5c0ff809f90b19f4e971e17c1ed11f4df60082c6010b32a82054087e91e0/sigstore_models-0.0.6.tar.gz" +size = 7037 + +[packages.sdist.hashes] +sha256 = "c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849" + +[[packages.wheels]] +name = "sigstore_models-0.0.6-py3-none-any.whl" +upload-time = 2025-11-26 19:18:11.365736+00:00 +url = "https://files.pythonhosted.org/packages/12/dc/7a19864a7bc9f43121ab459e12768ab0080590e3e1b60a29b2f2eb01fb85/sigstore_models-0.0.6-py3-none-any.whl" +size = 13213 + +[packages.wheels.hashes] +sha256 = "5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e" + +[[packages]] +name = "sigstore-rekor-types" +version = "0.0.18" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "pydantic" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sigstore_rekor_types-0.0.18.tar.gz" +upload-time = 2024-11-22 13:59:54.009830+00:00 +url = "https://files.pythonhosted.org/packages/b4/54/102e772445c5e849b826fbdcd44eb9ad7b3d10fda17b08964658ec7027dc/sigstore_rekor_types-0.0.18.tar.gz" +size = 15687 + +[packages.sdist.hashes] +sha256 = "19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5" + +[[packages.wheels]] +name = "sigstore_rekor_types-0.0.18-py3-none-any.whl" +upload-time = 2024-11-22 13:59:52.684229+00:00 +url = "https://files.pythonhosted.org/packages/45/7c/f0b4e19fd424df4cc964f5d454e1d814fd2dc3b386342e6040441024318f/sigstore_rekor_types-0.0.18-py3-none-any.whl" +size = 20610 + +[packages.wheels.hashes] +sha256 = "b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78" + +[[packages]] +name = "tomli" +version = "2.5.0" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tomli-2.5.0.tar.gz" +upload-time = 2026-10-07 12:23:37.892554+00:00 +url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz" +size = 17662 + +[packages.sdist.hashes] +sha256 = "264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6" + +[[packages.wheels]] +name = "tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-10-07 12:23:15.215497+00:00 +url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl" +size = 161406 + +[packages.wheels.hashes] +sha256 = "9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276" + +[[packages.wheels]] +name = "tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +upload-time = 2026-10-07 12:23:16.471112+00:00 +url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +size = 273855 + +[packages.wheels.hashes] +sha256 = "752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52" + +[[packages.wheels]] +name = "tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-10-07 12:23:18.166140+00:00 +url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +size = 284910 + +[packages.wheels.hashes] +sha256 = "c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7" + +[[packages.wheels]] +name = "tomli-2.5.0-cp315-cp315-win_amd64.whl" +upload-time = 2026-10-07 12:23:23.098344+00:00 +url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl" +size = 114589 + +[packages.wheels.hashes] +sha256 = "d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5" + +[[packages.wheels]] +name = "tomli-2.5.0-py3-none-any.whl" +upload-time = 2026-10-07 12:23:36.875825+00:00 +url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl" +size = 14765 + +[packages.wheels.hashes] +sha256 = "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b" + +[[packages]] +name = "tomli-w" +version = "1.2.0" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tomli_w-1.2.0.tar.gz" +upload-time = 2025-01-15 12:07:24.262974+00:00 +url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz" +size = 7184 + +[packages.sdist.hashes] +sha256 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021" + +[[packages.wheels]] +name = "tomli_w-1.2.0-py3-none-any.whl" +upload-time = 2025-01-15 12:07:22.074224+00:00 +url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl" +size = 6675 + +[packages.wheels.hashes] +sha256 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" [[packages]] name = "tomlkit" -version = "0.15.2" -sdist = { url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz", upload-time = 2026-10-16T04:07:08Z, size = 190522, hashes = { sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl", upload-time = 2026-10-16T04:07:09Z, size = 52225, hashes = { sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" } }] +version = "0.15.1" +marker = "\"lock-tools\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tomlkit-0.15.1.tar.gz" +upload-time = 2026-07-17 01:48:04.562015+00:00 +url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz" +size = 180129 + +[packages.sdist.hashes] +sha256 = "e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97" + +[[packages.wheels]] +name = "tomlkit-0.15.1-py3-none-any.whl" +upload-time = 2026-07-17 01:48:05.728578+00:00 +url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl" +size = 49449 + +[packages.wheels.hashes] +sha256 = "177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304" [[packages]] name = "towncrier" version = "26.9.0" -sdist = { url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz", upload-time = 2026-09-04T12:57:25Z, size = 79338, hashes = { sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl", upload-time = 2026-09-04T12:57:23Z, size = 67030, hashes = { sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" } }] +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "towncrier-26.9.0.tar.gz" +upload-time = 2026-09-04 12:57:25.341701+00:00 +url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" +size = 79338 + +[packages.sdist.hashes] +sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" + +[[packages.wheels]] +name = "towncrier-26.9.0-py3-none-any.whl" +upload-time = 2026-09-04 12:57:23.986160+00:00 +url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" +size = 67030 + +[packages.wheels.hashes] +sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" [[packages]] -name = "ty" -version = "0.0.89" -sdist = { url = "https://files.pythonhosted.org/packages/67/ea/dfb9bdbc168c50a568809466596751afa0c7da60da03b8c12906d1decde4/ty-0.0.89.tar.gz", upload-time = 2026-10-16T10:35:05Z, size = 7978241, hashes = { sha256 = "bc1027b1ea95cca25c2a3a0b41433c809cf4b86a384d2b2434eba4bcd34548d4" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/3e/61/da22c714fd4f4dff642d60fb0d8bf8da1985d126f21cc89237f8bf707c92/ty-0.0.89-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-10-16T10:34:44Z, size = 14510395, hashes = { sha256 = "95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc" } }] +name = "truststore" +version = "0.10.5" +marker = "\"lock-tools\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "truststore-0.10.5.tar.gz" +upload-time = 2026-10-12 22:27:31.808128+00:00 +url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz" +size = 28091 + +[packages.sdist.hashes] +sha256 = "30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd" + +[[packages.wheels]] +name = "truststore-0.10.5-py3-none-any.whl" +upload-time = 2026-10-12 22:27:30.377491+00:00 +url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl" +size = 19017 + +[packages.wheels.hashes] +sha256 = "9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c" + +[[packages]] +name = "tuf" +version = "7.0.1" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "securesystemslib" }, + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tuf-7.0.1.tar.gz" +upload-time = 2026-09-02 06:19:52.071733+00:00 +url = "https://files.pythonhosted.org/packages/9c/dd/52e7390cbac308e6b1cfe6be9bc3a96fe26326d5893cc297a52bb72792a9/tuf-7.0.1.tar.gz" +size = 274148 + +[packages.sdist.hashes] +sha256 = "5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31" + +[[packages.wheels]] +name = "tuf-7.0.1-py3-none-any.whl" +upload-time = 2026-09-02 06:19:50.608554+00:00 +url = "https://files.pythonhosted.org/packages/a2/10/171e306e4a19ff980b9029ce5a523881e34bc6a2a52750fb1853f49f0fb9/tuf-7.0.1-py3-none-any.whl" +size = 56253 + +[packages.wheels.hashes] +sha256 = "d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c" + +[[packages]] +name = "typing-extensions" +version = "4.16.0" +marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "typing_extensions-4.16.0.tar.gz" +upload-time = 2026-07-02 08:40:05.920538+00:00 +url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz" +size = 113555 + +[packages.sdist.hashes] +sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" + +[[packages.wheels]] +name = "typing_extensions-4.16.0-py3-none-any.whl" +upload-time = 2026-07-02 08:40:04.659120+00:00 +url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl" +size = 45571 + +[packages.wheels.hashes] +sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" + +[[packages]] +name = "typing-inspection" +version = "0.4.4" +marker = "\"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "typing_inspection-0.4.4.tar.gz" +upload-time = 2026-08-12 12:37:25.997329+00:00 +url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz" +size = 76928 + +[packages.sdist.hashes] +sha256 = "547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47" + +[[packages.wheels]] +name = "typing_inspection-0.4.4-py3-none-any.whl" +upload-time = 2026-08-12 12:37:24.648475+00:00 +url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl" +size = 14750 + +[packages.wheels.hashes] +sha256 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" + +[[packages]] +name = "urllib3" +version = "2.8.0" +marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "urllib3-2.8.0.tar.gz" +upload-time = 2026-09-15 19:29:36.253420+00:00 +url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz" +size = 458972 + +[packages.sdist.hashes] +sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" + +[[packages.wheels]] +name = "urllib3-2.8.0-py3-none-any.whl" +upload-time = 2026-09-15 19:29:34.577402+00:00 +url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl" +size = 135717 + +[packages.wheels.hashes] +sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" [[packages]] name = "wcwidth" version = "0.9.2" -sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz", upload-time = 2026-10-05T00:24:05Z, size = 955039, hashes = { sha256 = "ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b" } } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", upload-time = 2026-10-05T00:23:40Z, size = 769763, hashes = { sha256 = "751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389" } }, - { url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl", upload-time = 2026-10-05T00:23:21Z, size = 301667, hashes = { sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e" } }, -] +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "wcwidth-0.9.2.tar.gz" +upload-time = 2026-10-05 00:24:05.521972+00:00 +url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz" +size = 955039 + +[packages.sdist.hashes] +sha256 = "ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b" + +[[packages.wheels]] +name = "wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-10-05 00:23:27.049292+00:00 +url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl" +size = 609960 + +[packages.wheels.hashes] +sha256 = "ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17" + +[[packages.wheels]] +name = "wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-10-05 00:23:32.448318+00:00 +url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +size = 771632 + +[packages.wheels.hashes] +sha256 = "674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724" + +[[packages.wheels]] +name = "wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-10-05 00:23:40.175985+00:00 +url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 769763 + +[packages.wheels.hashes] +sha256 = "751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389" + +[[packages.wheels]] +name = "wcwidth-0.9.2-cp310-abi3-win_amd64.whl" +upload-time = 2026-10-05 00:23:57.489330+00:00 +url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl" +size = 597827 + +[packages.wheels.hashes] +sha256 = "2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec" + +[[packages.wheels]] +name = "wcwidth-0.9.2-py3-none-any.whl" +upload-time = 2026-10-05 00:23:21.097514+00:00 +url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl" +size = 301667 + +[packages.wheels.hashes] +sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e" [[packages]] name = "xmltodict" version = "1.0.4" -sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", upload-time = 2026-02-22T02:21:22Z, size = 26124, hashes = { sha256 = "6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", upload-time = 2026-02-22T02:21:21Z, size = 13580, hashes = { sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a" } }] +marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "xmltodict-1.0.4.tar.gz" +upload-time = 2026-02-22 02:21:22.074263+00:00 +url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz" +size = 26124 + +[packages.sdist.hashes] +sha256 = "6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61" + +[[packages.wheels]] +name = "xmltodict-1.0.4-py3-none-any.whl" +upload-time = 2026-02-22 02:21:21.039836+00:00 +url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl" +size = 13580 + +[packages.wheels.hashes] +sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a" [[packages]] name = "yq" version = "4.4.5" -sdist = { url = "https://files.pythonhosted.org/packages/fd/be/ae37199d28a0a99eb79084f91da94f6e7e58550552361a2397c5032d5d37/yq-4.4.5.tar.gz", upload-time = 2026-10-11T02:20:56Z, size = 54535, hashes = { sha256 = "e17b21a927d04ae33f61a7284351b69b0a539bfb73b852a04a907b79bcd15de2" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/fd/38/cca2b04f284c788ded19a9401e3049fd1f82d0467c1d6bd361915a77c063/yq-4.4.5-py3-none-any.whl", upload-time = 2026-10-11T02:20:55Z, size = 30255, hashes = { sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" } }] +marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "argcomplete" }, + { name = "pyyaml" }, + { name = "tomlkit" }, + { name = "xmltodict" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "yq-4.4.5.tar.gz" +upload-time = 2026-10-11 02:20:56.748595+00:00 +url = "https://files.pythonhosted.org/packages/fd/be/ae37199d28a0a99eb79084f91da94f6e7e58550552361a2397c5032d5d37/yq-4.4.5.tar.gz" +size = 54535 + +[packages.sdist.hashes] +sha256 = "e17b21a927d04ae33f61a7284351b69b0a539bfb73b852a04a907b79bcd15de2" + +[[packages.wheels]] +name = "yq-4.4.5-py3-none-any.whl" +upload-time = 2026-10-11 02:20:55.272902+00:00 +url = "https://files.pythonhosted.org/packages/fd/38/cca2b04f284c788ded19a9401e3049fd1f82d0467c1d6bd361915a77c063/yq-4.4.5-py3-none-any.whl" +size = 30255 + +[packages.wheels.hashes] +sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" + +[tool.nab] +nab-version = "0.0.22" +created-at = 2026-10-23 02:46:01.225053+00:00 +command-line = [ + "nab", + "lock", + "--groups", + "build", + "lock-tools", + "release-attestations", + "release-check", + "release-hook-check", + "release-hooks", + "release-news", + "release-notes", + "release-prepare", + "release-shell", + "release-validate", + "--output", + "pylock.toml", + "--upgrade", +] +input-path = "pyproject.toml" +mode = "universal" +python-specifier = ">=3.15,<3.16" +platforms = [ + "linux_x86_64", + "linux_aarch64", + "macos_arm64", + "windows_amd64", +] diff --git a/pyproject.toml b/pyproject.toml index bc4f0d0..b435dec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,65 @@ version = "0.0.0" description = "Shared release actions for tox-dev repositories" requires-python = ">=3.15" dependencies = [] +license = "MIT" +license-files = [ "LICENSE" ] [dependency-groups] -release = [ "release-creator>=0.4.1", "towncrier>=26.9", "prek>=0.2", "yq>=4.4.5" ] -test = [ "pytest>=9", "pyyaml>=6", "ruff", "ty" ] +build = [ "Cython>=3.0", "setuptools>=80" ] +lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] +release-notes = [ "release-creator>=0.6.0" ] +release-news = [ "release-creator>=0.6.0", "towncrier>=26.9" ] +release-prepare = [ "release-creator>=0.6.0", "towncrier>=26.9", "prek>=0.2" ] +release-validate = [ "release-creator>=0.6.0", "prek>=0.2" ] +release-check = [ "prek>=0.2" ] +release-attestations = [ "pypi-attestations>=0.0.30" ] +release-shell = [ "prek>=0.2", "yq>=4.4.5" ] +release-hooks = [ "release-creator>=0.6.0", "prek>=0.2" ] +release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] +test = [ + "hatchling>=1.28", + "pytest>=9", + "pytest-httpserver", + "pytest-xdist>=3", + "pyyaml>=6", + "tomlkit>=0.13", + "tox>=4.64.7", + "tox-uv>=1.36.1", + "trustme", + "ty", +] + +[tool.nab] +mode = "universal" +uploaded-prior-to = "P7D" +decision-order = "stable" +base-group = "project-runtime" +default-groups = [] + +[tool.nab.matrix] +python = ">=3.15,<3.16" +platforms = [ "linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64" ] + +[tool.nab.packages.nab] +uploaded-prior-to = "2026-10-18T05:45:09Z" + +[tool.nab.packages.nab-index] +uploaded-prior-to = "2026-10-18T05:45:09Z" + +[tool.nab.packages.nab-markersets] +uploaded-prior-to = "2026-10-18T05:45:09Z" + +[tool.nab.packages.nab-project] +uploaded-prior-to = "2026-10-18T05:45:09Z" + +[tool.nab.packages.nab-provider] +uploaded-prior-to = "2026-10-18T05:45:09Z" + +[tool.nab.packages.nab-resolver] +uploaded-prior-to = "2026-10-18T05:45:09Z" + +[tool.nab.packages.release-creator] +uploaded-prior-to = "2026-10-23T02:44:40Z" [tool.ruff] line-length = 120 diff --git a/read-towncrier-config/action.yml b/read-towncrier-config/action.yml new file mode 100644 index 0000000..070e73d --- /dev/null +++ b/read-towncrier-config/action.yml @@ -0,0 +1,73 @@ +name: Read Towncrier configuration +description: Read release paths, format, and fragment categories from the committed Towncrier configuration +inputs: + working-directory: + description: Checkout containing the configuration + default: . +outputs: + changelog: + description: Changelog path relative to the repository + value: ${{ steps.config.outputs.changelog }} + fragments: + description: Fragment directory relative to the repository + value: ${{ steps.config.outputs.fragments }} + format: + description: Changelog formatter, markdown or rst + value: ${{ steps.config.outputs.format }} +runs: + using: composite + steps: + - name: Read committed Towncrier configuration + id: config + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + config_dir=$(mktemp -d "$RUNNER_TEMP/towncrier-config.XXXXXX") + if git cat-file -e HEAD:towncrier.toml 2>/dev/null; then + git show HEAD:towncrier.toml >"$config_dir/config.toml" + else + git show HEAD:pyproject.toml >"$config_dir/config.toml" + fi + python3 - "$config_dir/config.toml" "$GITHUB_OUTPUT" <<'PY' + import sys + import tomllib + from pathlib import Path, PurePosixPath + + document = tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + config = document.get("tool", {}).get("towncrier") + if not isinstance(config, dict): + raise SystemExit("Towncrier configuration is required") + types = config.get("type") + fragments = config.get("fragment") + if types is not None and fragments is not None: + raise SystemExit("Towncrier type and fragment tables cannot be combined") + for key in ("filename", "directory"): + value = config.get(key) + if not isinstance(value, str) or not value.isprintable() or not value or value != str(PurePosixPath(value)): + raise SystemExit("Towncrier paths must be normalized relative paths") + if PurePosixPath(value).is_absolute() or ".." in value.split("/"): + raise SystemExit("Towncrier paths must stay within the repository") + changelog = config["filename"] + if changelog.endswith(".md"): + format_name = "markdown" + elif changelog.endswith(".rst"): + format_name = "rst" + else: + raise SystemExit("Towncrier changelog must use Markdown or RST") + if types is not None: + if not isinstance(types, list) or not types or any(not isinstance(item, dict) for item in types): + raise SystemExit("Towncrier type categories are required") + categories = [category.get("directory") for category in types] + elif isinstance(fragments, dict) and fragments and all(isinstance(value, dict) for value in fragments.values()): + categories = list(fragments) + else: + raise SystemExit("Towncrier fragment categories are required") + if not all( + isinstance(category, str) and category.isprintable() and category and "/" not in category + for category in categories + ): + raise SystemExit("Towncrier fragment categories must be printable names") + with Path(sys.argv[2]).open("a", encoding="utf-8") as output: + output.write(f"changelog={changelog}\nfragments={config['directory']}\nformat={format_name}\n") + PY diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml new file mode 100644 index 0000000..ce1a2f0 --- /dev/null +++ b/recheck-hook-update-cooldown/action.yml @@ -0,0 +1,84 @@ +name: Recheck cooled hook updates +description: Recheck failed bot hook-update cooldown jobs each day +inputs: + workflow: + description: Workflow file containing the required hook-cooldown job + default: check.yaml + job-name: + description: Name of the required cooldown job to rerun + default: hook-cooldown + minimum-days: + description: Minimum age of an eligible hook revision + default: "7" +runs: + using: composite + steps: + - name: Find hook update pull requests + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh api --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" | + jq -c --arg repository "$GITHUB_REPOSITORY" ' + .[] | select(.user.type == "Bot" and + .head.repo.full_name == $repository and .base.repo.full_name == $repository) | + {number, head: .head.sha, branch: .head.ref} + ' >"$RUNNER_TEMP/hook-pulls.jsonl" + - name: Rerun the failed hook checks + shell: bash + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW: ${{ inputs.workflow }} + JOB_NAME: ${{ inputs.job-name }} + MINIMUM_DAYS: ${{ inputs.minimum-days }} + run: | + set -euo pipefail + case "$MINIMUM_DAYS" in + '' | 0* | *[!0-9]*) exit 1 ;; + esac + if ((MINIMUM_DAYS < 7)); then + echo '::error::hook cooldown must be at least seven days' >&2 + exit 1 + fi + while IFS= read -r pull; do + number=$(jq -r .number <<<"$pull") + gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$number/files" --jq '.[].filename' \ + >"$RUNNER_TEMP/hook-files" + if ! grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-files"; then + continue + fi + head=$(jq -r .head <<<"$pull") + branch=$(jq -r .branch <<<"$pull") + runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW/runs" \ + -f event=pull_request -f head_sha="$head" -f per_page=100 | + jq -sc --arg repository "$GITHUB_REPOSITORY" --arg branch "$branch" --arg head "$head" \ + --argjson number "$number" ' + [.[].workflow_runs[] | select( + .event == "pull_request" and .head_repository.full_name == $repository and + .head_branch == $branch and .head_sha == $head and + any(.pull_requests[]; .number == $number) + )]') + if ! jq -e --argjson minimum_days "$MINIMUM_DAYS" \ + '[.[].created_at | fromdateiso8601] | min <= now - $minimum_days * 86400' \ + <<<"$runs" >/dev/null; then + continue + fi + run=$(jq -c '[.[] | select(.status == "completed")] | sort_by(.created_at) | last // empty' <<<"$runs") + if [[ -z "$run" ]]; then + continue + fi + if ! jq -e '(.created_at | fromdateiso8601) >= now - 30 * 86400' <<<"$run" >/dev/null; then + continue + fi + job=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$(jq -r .id <<<"$run")/jobs" \ + -f filter=latest -f per_page=100 | + jq -sc --arg name "$JOB_NAME" \ + '[.[].jobs[] | select(.name == $name and .conclusion == "failure")] | first // empty') + if [[ -n "$job" ]]; then + if ! gh run rerun "$(jq -r .id <<<"$run")" --job "$(jq -r .id <<<"$job")" \ + --repo "$GITHUB_REPOSITORY"; then + echo "::warning::could not rerun hook cooldown for pull request #$number" >&2 + fi + fi + done <"$RUNNER_TEMP/hook-pulls.jsonl" diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml new file mode 100644 index 0000000..62404e8 --- /dev/null +++ b/reconcile-pypi-artifacts/action.yml @@ -0,0 +1,63 @@ +name: Verify PyPI artifacts +description: Compare every built file with PyPI and verify its trusted-publisher attestation +inputs: + project: + description: PyPI project name + required: true + version: + description: PyPI release version + required: true + dist: + description: Directory containing the complete expected distribution set + required: true + local-tool-source: + description: Install release-creator from the tagged checkout + default: "false" +runs: + using: composite + steps: + - name: Check artifact inputs + shell: bash + env: + PROJECT: ${{ inputs.project }} + VERSION: ${{ inputs.version }} + DIST: ${{ inputs.dist }} + run: | + [[ -n "$PROJECT" && -n "$VERSION" && -d "$DIST" ]] || exit 1 + - name: Install locked release tool + uses: $/install-locked-tools + with: + groups: release-notes${{ inputs.local-tool-source == 'true' && ' project-runtime' || '' }} + venv: ${{ runner.temp }}/pypi-reconcile + local-tool-source: ${{ inputs.local-tool-source }} + - name: Install tagged release tool + if: inputs.local-tool-source == 'true' + shell: bash + env: + DIST: ${{ inputs.dist }} + run: uv pip install --python "$RUNNER_TEMP/pypi-reconcile" --no-deps "$DIST"/release_creator-*.whl + - name: Compare published files + shell: bash + env: + PROJECT: ${{ inputs.project }} + VERSION: ${{ inputs.version }} + DIST: ${{ inputs.dist }} + run: | + uv run --no-project --python "$RUNNER_TEMP/pypi-reconcile" release-creator artifacts --project "$PROJECT" \ + --version "$VERSION" --dist "$DIST" --wait-seconds 120 >"$RUNNER_TEMP/pypi-file-urls" + - name: Install locked provenance verifier + uses: $/install-locked-tools + with: + groups: release-attestations + venv: ${{ runner.temp }}/pypi-attestations + lock-source: shared + - name: Verify trusted-publisher attestations + shell: bash + env: + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + while IFS= read -r url; do + uv run --no-project --python "$RUNNER_TEMP/pypi-attestations" pypi-attestations verify pypi \ + --repository "https://github.com/$REPOSITORY" "$url" + done <"$RUNNER_TEMP/pypi-file-urls" diff --git a/refresh-hook-pins/action.yml b/refresh-hook-pins/action.yml new file mode 100644 index 0000000..e19e5db --- /dev/null +++ b/refresh-hook-pins/action.yml @@ -0,0 +1,50 @@ +name: Refresh pre-commit hook pins +description: Update cooled frozen hook revisions and exact dependencies without GitHub writes +outputs: + changed: + description: Whether the hook configuration differs from the checkout + value: ${{ steps.patch.outputs.changed }} +runs: + using: composite + steps: + - uses: $/install-locked-tools + with: + groups: release-hooks + lock-source: shared + venv: ${{ runner.temp }}/hook-tools + - name: Refresh frozen hook revisions + shell: bash + run: | + uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update --config .pre-commit-config.yaml \ + --freeze --cooldown-days 7 --no-progress + - name: Refresh exact hook dependencies + shell: bash + run: | + uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks \ + --exclude-newer '7 days' --python-version 3.15 + - name: Run repository checks + shell: bash + run: >- + uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek run --config .pre-commit-config.yaml --all-files + - name: Check updated paths + shell: bash + run: | + set -euo pipefail + while IFS= read -r entry; do + path=${entry:3} + if [[ "$path" != .pre-commit-config.yaml ]]; then + echo "::error::hook update changed unexpected path: $path" >&2 + exit 1 + fi + done < <(git status --porcelain=v1 --untracked-files=all) + - id: patch + shell: bash + run: | + set -euo pipefail + if git diff --quiet -- .pre-commit-config.yaml; then + echo 'changed=false' >>"$GITHUB_OUTPUT" + exit 0 + fi + echo 'changed=true' >>"$GITHUB_OUTPUT" + git diff --binary -- .pre-commit-config.yaml >"$RUNNER_TEMP/hooks.patch" + test -s "$RUNNER_TEMP/hooks.patch" diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml new file mode 100644 index 0000000..f35f3ad --- /dev/null +++ b/refresh-release-lock/action.yml @@ -0,0 +1,171 @@ +name: Refresh a release-tool lock +description: Refresh or check the Nab lock for selected release groups +inputs: + lock-groups: + description: Space-separated dependency groups recorded in pylock.toml + required: true + additional-lock: + description: Optional secondary pylock basename for a separate build matrix + default: "" + additional-lock-groups: + description: Groups for the secondary lock; empty selects build requirements + default: "" + additional-matrix-python: + description: Python range for the secondary lock + default: "" + additional-matrix-platforms: + description: Space-separated Nab platform IDs for the secondary lock + default: "" + additional-matrix-implementations: + description: Space-separated Python implementations for the secondary lock + default: "" + mode: + description: Refresh the lock or check that the committed lock is current + default: refresh +outputs: + changed: + description: Whether the lock or expired package cutoffs differ from the checkout + value: ${{ steps.lock.outputs.changed }} +runs: + using: composite + steps: + - uses: $/install-nab + - name: Check recent package cutoffs + shell: bash + run: uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/check_recent_cutoffs.py" pyproject.toml + - name: Expire reviewed package cutoffs + if: inputs.mode == 'refresh' + shell: bash + run: | + set -euo pipefail + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/expire_cutoffs.py" pyproject.toml + cp pyproject.toml "$RUNNER_TEMP/project-after-expiry.toml" + - name: Resolve release groups + shell: bash + env: + LOCK_GROUPS: ${{ inputs.lock-groups }} + MODE: ${{ inputs.mode }} + ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + ADDITIONAL_GROUPS: ${{ inputs.additional-lock-groups }} + MATRIX_PYTHON: ${{ inputs.additional-matrix-python }} + MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }} + MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }} + run: | + set -euo pipefail + groups=() + while IFS= read -r line; do + read -ra selected <<<"$line" + groups+=("${selected[@]}") + done <<<"$LOCK_GROUPS" + if ((${#groups[@]} == 0)); then + echo '::error::at least one lock group is required' >&2 + exit 1 + fi + case "$MODE" in + check) + refresh=() + cp pylock.toml "$RUNNER_TEMP/pylock-before-check.toml" + ;; + refresh) refresh=(--upgrade) ;; + *) + echo "::error::invalid lock mode: $MODE" >&2 + exit 1 + ;; + esac + uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --groups "${groups[@]}" \ + --output pylock.toml "${refresh[@]}" + if [[ "$MODE" == check ]]; then + python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \ + "$RUNNER_TEMP/pylock-before-check.toml" pylock.toml + fi + if [[ -z "$ADDITIONAL_LOCK" ]]; then + if [[ -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS" ]]; then + echo '::error::secondary matrix inputs require additional-lock' >&2 + exit 1 + fi + exit 0 + fi + if [[ "$ADDITIONAL_LOCK" != pylock.*.toml || "$ADDITIONAL_LOCK" == */* || + ! -f "$ADDITIONAL_LOCK" || -z "$MATRIX_PYTHON" || + -z "$MATRIX_PLATFORMS" || -z "$MATRIX_IMPLEMENTATIONS" ]]; then + echo '::error::additional-lock needs an existing pylock.*.toml and a complete matrix' >&2 + exit 1 + fi + platforms=() + implementations=() + selected_groups=() + while IFS= read -r line; do + read -ra words <<<"$line" + platforms+=("${words[@]}") + done <<<"$MATRIX_PLATFORMS" + while IFS= read -r line; do + read -ra words <<<"$line" + implementations+=("${words[@]}") + done <<<"$MATRIX_IMPLEMENTATIONS" + while IFS= read -r line; do + read -ra words <<<"$line" + selected_groups+=("${words[@]}") + done <<<"$ADDITIONAL_GROUPS" + if ((${#platforms[@]} == 0 || ${#implementations[@]} == 0)); then + echo '::error::secondary matrix needs platforms and implementations' >&2 + exit 1 + fi + selection=(--build-requirements) + if ((${#selected_groups[@]})); then selection=(--groups "${selected_groups[@]}"); fi + cp "$ADDITIONAL_LOCK" "$RUNNER_TEMP/additional-lock-before.toml" + uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock pyproject.toml "${selection[@]}" \ + --project-matrix-python "$MATRIX_PYTHON" \ + --project-matrix-platforms "${platforms[@]}" \ + --project-matrix-implementations "${implementations[@]}" \ + --output "$ADDITIONAL_LOCK" "${refresh[@]}" + python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" --environments-only \ + "$RUNNER_TEMP/additional-lock-before.toml" "$ADDITIONAL_LOCK" + if [[ "$MODE" == check ]]; then + python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \ + "$RUNNER_TEMP/additional-lock-before.toml" "$ADDITIONAL_LOCK" + fi + - name: Install refreshed release checks + if: inputs.mode == 'refresh' + uses: $/install-locked-tools + with: + groups: release-check + venv: ${{ runner.temp }}/release-tools + - name: Run repository checks + if: inputs.mode == 'refresh' + shell: bash + run: uv run --no-project --python "$RUNNER_TEMP/release-tools" prek run --all-files + - name: Check refreshed paths + if: inputs.mode == 'refresh' + shell: bash + env: + ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + run: | + set -euo pipefail + cmp pyproject.toml "$RUNNER_TEMP/project-after-expiry.toml" + while IFS= read -r entry; do + path=${entry:3} + if [[ "$path" != pylock.toml && "$path" != pyproject.toml && "$path" != "${ADDITIONAL_LOCK:-}" ]]; then + echo "::error::prek changed file outside the lock and cutoff policy: $path" >&2 + exit 1 + fi + done < <(git status --porcelain=v1 --untracked-files=all) + - id: lock + shell: bash + env: + MODE: ${{ inputs.mode }} + ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + run: | + set -euo pipefail + paths=(pylock.toml pyproject.toml) + if [[ -n "${ADDITIONAL_LOCK:-}" ]]; then paths+=("$ADDITIONAL_LOCK"); fi + if [[ "$MODE" == check ]]; then + git diff --exit-code -- "${paths[@]}" + echo 'changed=false' >>"$GITHUB_OUTPUT" + elif git diff --quiet -- "${paths[@]}"; then + echo 'changed=false' >>"$GITHUB_OUTPUT" + else + echo 'changed=true' >>"$GITHUB_OUTPUT" + git diff --binary -- "${paths[@]}" >"$RUNNER_TEMP/lock.patch" + test -s "$RUNNER_TEMP/lock.patch" + fi diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml new file mode 100644 index 0000000..44fce5b --- /dev/null +++ b/render-release-notes/action.yml @@ -0,0 +1,71 @@ +name: Render release notes +description: Validate a tagged changelog and render its GitHub Release body +inputs: + tag: + description: Existing release tag + required: true + changelog: + description: Tracked changelog path + required: true + artifact-url: + description: Published ecosystem release URL + default: "" + artifact-label: + description: Link text for the published artifact + default: PyPI release + output: + description: Path for the rendered Markdown body + required: true + generated-notes: + description: Existing GitHub generated-notes file with contributor metadata + default: "" + previous: + description: Previous tag, or auto to calculate it from Git history + default: auto +outputs: + previous: + description: Previous version tag, or an empty string for the first release + value: ${{ steps.previous.outputs.previous }} +runs: + using: composite + steps: + - id: selected + if: inputs.previous == 'auto' + uses: $/select-previous-tag + with: + tag: ${{ inputs.tag }} + - id: previous + name: Select previous tag + shell: bash + env: + INPUT_PREVIOUS: ${{ inputs.previous }} + SELECTED_PREVIOUS: ${{ steps.selected.outputs.previous }} + run: | + if [[ "$INPUT_PREVIOUS" == auto ]]; then + echo "previous=$SELECTED_PREVIOUS" >>"$GITHUB_OUTPUT" + else + echo "previous=$INPUT_PREVIOUS" >>"$GITHUB_OUTPUT" + fi + - name: Render GitHub Release notes + shell: bash + env: + TAG: ${{ inputs.tag }} + CHANGELOG: ${{ inputs.changelog }} + ARTIFACT_URL: ${{ inputs.artifact-url }} + ARTIFACT_LABEL: ${{ inputs.artifact-label }} + OUTPUT: ${{ inputs.output }} + GENERATED_NOTES: ${{ inputs.generated-notes }} + PREVIOUS: ${{ steps.previous.outputs.previous }} + run: | + set -euo pipefail + args=(--changelog "$CHANGELOG" --tag "$TAG" --repository "$GITHUB_REPOSITORY") + if [[ -n "$PREVIOUS" ]]; then + args+=(--previous "$PREVIOUS") + fi + if [[ -n "$ARTIFACT_URL" ]]; then + args+=(--artifact-url "$ARTIFACT_URL" --artifact-label "$ARTIFACT_LABEL") + fi + if [[ -n "$GENERATED_NOTES" ]]; then + args+=(--generated-notes "$GENERATED_NOTES") + fi + release-creator notes "${args[@]}" >"$OUTPUT" diff --git a/resolve-pr-from-run/action.yml b/resolve-pr-from-run/action.yml new file mode 100644 index 0000000..69fa8b5 --- /dev/null +++ b/resolve-pr-from-run/action.yml @@ -0,0 +1,80 @@ +name: Resolve a pull request from its CI run +description: Verify a completed read-only PR run and find its current pull request +inputs: + run-id: + description: GitHub Actions run ID from the workflow_run event + required: true + workflow-path: + description: Repository-relative path of the read-only PR workflow + required: true +outputs: + number: + description: Current matching pull request number, or empty for a stale run + value: ${{ steps.resolve.outputs.number }} + bot-author: + description: Whether the matching pull request author is a bot + value: ${{ steps.resolve.outputs.bot-author }} +runs: + using: composite + steps: + - name: Verify the source run + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ inputs.run-id }} + WORKFLOW_PATH: ${{ inputs.workflow-path }} + run: | + set -euo pipefail + case "$RUN_ID" in + '' | *[!0-9]*) + echo '::error::run-id must be a decimal integer' >&2 + exit 1 + ;; + esac + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID" >"$RUNNER_TEMP/source-run.json" + if ! jq -e --arg repository "$GITHUB_REPOSITORY" --arg workflow "$WORKFLOW_PATH" --argjson run_id "$RUN_ID" ' + .id == $run_id and + .repository.full_name == $repository and + .event == "pull_request" and + .path == $workflow and + .status == "completed" and + (.head_repository.full_name | type == "string") and + (.head_repository.owner.login | type == "string") and + (.head_branch | type == "string") and + (.head_sha | length == 40) + ' "$RUNNER_TEMP/source-run.json" >/dev/null; then + echo '::error::source must be a completed pull_request run from the expected workflow' >&2 + exit 1 + fi + - id: resolve + name: Match the current pull request + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + head_owner=$(jq -r .head_repository.owner.login "$RUNNER_TEMP/source-run.json") + head_branch=$(jq -r .head_branch "$RUNNER_TEMP/source-run.json") + head_repository=$(jq -r .head_repository.full_name "$RUNNER_TEMP/source-run.json") + head_sha=$(jq -r .head_sha "$RUNNER_TEMP/source-run.json") + gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open -f "head=$head_owner:$head_branch" -f per_page=100 \ + >"$RUNNER_TEMP/source-pulls.json" + matches=$(jq --arg repository "$GITHUB_REPOSITORY" --arg head_repository "$head_repository" \ + --arg head_sha "$head_sha" ' + [.[] | select( + .state == "open" and + .head.sha == $head_sha and + .head.repo.full_name == $head_repository and + .base.repo.full_name == $repository + )] + ' "$RUNNER_TEMP/source-pulls.json") + count=$(jq 'length' <<<"$matches") + if [[ "$count" == 0 ]]; then + echo 'Source run no longer matches an open pull request' + exit 0 + fi + if [[ "$count" != 1 ]]; then + echo '::error::source run matches multiple pull requests' >&2 + exit 1 + fi + jq -r '"number=\(.[0].number)", "bot-author=\(.[0].user.type == "Bot")"' <<<"$matches" >>"$GITHUB_OUTPUT" diff --git a/select-previous-tag/action.yml b/select-previous-tag/action.yml new file mode 100644 index 0000000..e41b2cd --- /dev/null +++ b/select-previous-tag/action.yml @@ -0,0 +1,42 @@ +name: Select previous release tag +description: Select the preceding numeric tag in the release namespace +inputs: + tag: + description: Current release tag + required: true + package: + description: Package namespace in a multi-package repository + default: "" + executable: + description: Path to the installed release-creator executable + default: release-creator + stable-only: + description: Ignore prerelease tags when finding the predecessor + default: "false" +outputs: + previous: + description: Preceding tag, or empty for the first release + value: ${{ steps.select.outputs.previous }} +runs: + using: composite + steps: + - id: select + shell: bash + env: + TAG: ${{ inputs.tag }} + PACKAGE: ${{ inputs.package }} + EXECUTABLE: ${{ inputs.executable }} + STABLE_ONLY: ${{ inputs.stable-only }} + run: | + set -euo pipefail + target="$TAG" + if [[ -n "$PACKAGE" && "$TAG" != */* ]]; then target="$PACKAGE/$TAG"; fi + if ! git show-ref --verify --quiet "refs/tags/$target"; then + echo "::error::release tag not found: $target" >&2 + exit 1 + fi + args=(tags "$TAG" --previous) + if [[ -n "$PACKAGE" ]]; then args+=(--package "$PACKAGE"); fi + if [[ "$STABLE_ONLY" == true ]]; then args+=(--stable-only); fi + previous=$(git tag --list | "$EXECUTABLE" "${args[@]}") + printf 'previous=%s\n' "$previous" >>"$GITHUB_OUTPUT" diff --git a/sign-jetbrains-plugin/action.yml b/sign-jetbrains-plugin/action.yml new file mode 100644 index 0000000..8bb29ff --- /dev/null +++ b/sign-jetbrains-plugin/action.yml @@ -0,0 +1,114 @@ +name: Sign JetBrains plugin +description: Sign one verified plugin ZIP in a job without checkout or Gradle +inputs: + archive: + description: Unsigned plugin ZIP from the keyless build + required: true + sha256: + description: SHA256 recorded by the keyless build before artifact upload + required: true + certificate-chain: + description: JetBrains signing certificate chain in PEM or base64 form + required: true + private-key: + description: Signing private key in PEM or base64 form + required: true + private-key-password: + description: Password for an encrypted signing private key + required: false +outputs: + archive: + description: Path to the signed plugin ZIP + value: ${{ steps.digest.outputs.archive }} + sha256: + description: SHA256 of the signed plugin ZIP + value: ${{ steps.digest.outputs.sha256 }} +runs: + using: composite + steps: + - name: Verify unsigned plugin ZIP + id: unsigned + shell: bash + env: + ARCHIVE: ${{ inputs.archive }} + EXPECTED_SHA: ${{ inputs.sha256 }} + run: | + set -euo pipefail + [[ -f "$ARCHIVE" && ! -L "$ARCHIVE" ]] || exit 1 + case "$ARCHIVE" in *.zip) ;; *) exit 1 ;; esac + test "$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" = "$EXPECTED_SHA" + unzip -tq "$ARCHIVE" >/dev/null + test -z "$(zipinfo -1 "$ARCHIVE" | LC_ALL=C sort | uniq -d)" + echo "archive=${ARCHIVE%.zip}-signed.zip" >>"$GITHUB_OUTPUT" + - name: Download pinned Marketplace signer + shell: bash + env: + SIGNER_BASE_URL: https://repo.maven.apache.org/maven2/org/jetbrains/marketplace-zip-signer/0.1.43 + run: | + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + --output "$RUNNER_TEMP/marketplace-signer-cli.jar" \ + "$SIGNER_BASE_URL/marketplace-zip-signer-0.1.43-cli.jar" + - name: Verify Marketplace signer digest + shell: bash + env: + SIGNER_SHA256: 2958a0f42221d6062b50f22754e413ae5cc42f60c441467202c6700df2e22f44 + run: echo "$SIGNER_SHA256 $RUNNER_TEMP/marketplace-signer-cli.jar" | sha256sum --check + - name: Check Java 17 + shell: bash + run: test -x "$JAVA_HOME_17_X64/bin/java" + - name: Sign and verify plugin ZIP + shell: bash + env: + ARCHIVE: ${{ inputs.archive }} + SIGNED: ${{ steps.unsigned.outputs.archive }} + CERTIFICATE_CHAIN: ${{ inputs.certificate-chain }} + PRIVATE_KEY: ${{ inputs.private-key }} + PRIVATE_KEY_PASSWORD: ${{ inputs.private-key-password }} + run: | + set -euo pipefail + umask 077 + if [[ -e "$SIGNED" || -L "$SIGNED" ]]; then + echo '::error::signed archive already exists' >&2 + exit 1 + fi + trap 'rm -f "$RUNNER_TEMP"/signing-{key,key-decrypted,chain}.pem' EXIT + if [[ "$CERTIFICATE_CHAIN" == *'-----BEGIN CERTIFICATE-----'* ]]; then + printf '%s' "$CERTIFICATE_CHAIN" >"$RUNNER_TEMP/signing-chain.pem" + else + printf '%s' "$CERTIFICATE_CHAIN" | base64 --decode >"$RUNNER_TEMP/signing-chain.pem" + fi + if [[ "$PRIVATE_KEY" == *'-----BEGIN '* ]]; then + printf '%s' "$PRIVATE_KEY" >"$RUNNER_TEMP/signing-key.pem" + else + printf '%s' "$PRIVATE_KEY" | base64 --decode >"$RUNNER_TEMP/signing-key.pem" + fi + while IFS= read -r line; do + [[ -z "$line" ]] || echo "::add-mask::$line" + done <"$RUNNER_TEMP/signing-key.pem" + signing_key="$RUNNER_TEMP/signing-key.pem" + if [[ -n "$PRIVATE_KEY_PASSWORD" ]]; then + openssl pkey -in "$signing_key" -out "$RUNNER_TEMP/signing-key-decrypted.pem" \ + -passin env:PRIVATE_KEY_PASSWORD + while IFS= read -r line; do + [[ -z "$line" ]] || echo "::add-mask::$line" + done <"$RUNNER_TEMP/signing-key-decrypted.pem" + signing_key="$RUNNER_TEMP/signing-key-decrypted.pem" + fi + "$JAVA_HOME_17_X64/bin/java" -jar "$RUNNER_TEMP/marketplace-signer-cli.jar" sign \ + -in "$ARCHIVE" -out "$SIGNED" -cert-file "$RUNNER_TEMP/signing-chain.pem" \ + -key-file "$signing_key" + "$JAVA_HOME_17_X64/bin/java" -jar "$RUNNER_TEMP/marketplace-signer-cli.jar" verify \ + -in "$SIGNED" -cert "$RUNNER_TEMP/signing-chain.pem" + - name: Record signed plugin ZIP + id: digest + shell: bash + env: + SIGNED: ${{ steps.unsigned.outputs.archive }} + run: | + set -euo pipefail + [[ -f "$SIGNED" && ! -L "$SIGNED" ]] || exit 1 + unzip -tq "$SIGNED" >/dev/null + test -z "$(zipinfo -1 "$SIGNED" | LC_ALL=C sort | uniq -d)" + read -r digest _ < <(sha256sum "$SIGNED") + echo "archive=$SIGNED" >>"$GITHUB_OUTPUT" + echo "sha256=$digest" >>"$GITHUB_OUTPUT" diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py new file mode 100644 index 0000000..76322fd --- /dev/null +++ b/tests/build_python_distribution/test_build.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import json +import os +import shutil +import tomllib +from pathlib import Path + +import pytest +from conftest import action, run + + +@pytest.fixture +def build_repository(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + project = source_project / "pyproject.toml" + release_requirement = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())[ + "dependency-groups" + ]["release-notes"][0] + project.write_text( + project.read_text() + + f"\n[dependency-groups]\nrelease-notes = [{json.dumps(release_requirement)}]\n" + + '\n[tool.nab]\nbase-group = "project-runtime"\nbuild-group = "build"\n' + ) + run( + source_project, + str(nab_executable), + "lock", + "--groups", + "release-notes", + "--output", + "pylock.toml", + ) + run(source_project, "git", "add", ".") + run(source_project, "git", "commit", "-m", "Add package source") + run(source_project, "git", "push", "origin", "main") + return source_project + + +def test_build_python_distribution_creates_wheel_and_sdist( + build_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + result = action( + build_repository, + tmp_path, + action_scripts, + "build-python-distribution", + version="0.2.0", + SOURCE=".", + RUNNER_TEMP=str(tmp_path), + ) + assert result.returncode == 0, result.stderr + assert {path.name for path in (build_repository / "dist").iterdir() if path.suffix in {".whl", ".gz"}} == { + "fixture-0.2.0-py3-none-any.whl", + "fixture-0.2.0.tar.gz", + } + assert next(tmp_path.glob("build-constraints-*.txt")).read_text().count("hatchling==") == 1 + + +def test_build_python_distribution_repeats_exact_bytes( + build_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + outputs: list[dict[str, bytes]] = [] + for name in ("first", "second"): + temporary = tmp_path / name + temporary.mkdir() + result = action( + build_repository, + temporary, + action_scripts, + "build-python-distribution", + version="0.2.0", + SOURCE=".", + RUNNER_TEMP=str(temporary), + ) + assert result.returncode == 0, result.stderr + outputs.append({path.name: path.read_bytes() for path in (build_repository / "dist").iterdir()}) + shutil.rmtree(build_repository / "dist") + assert outputs[0] == outputs[1] + + +def test_build_python_distribution_rejects_dirty_source( + build_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + (build_repository / "pyproject.toml").write_text((build_repository / "pyproject.toml").read_text() + "\n") + result = action( + build_repository, + tmp_path, + action_scripts, + "build-python-distribution", + version="0.2.0", + SOURCE=".", + RUNNER_TEMP=str(tmp_path), + ) + assert "tagged source is dirty" in result.stderr + + +def test_build_python_distribution_rejects_existing_artifact( + build_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + (build_repository / ".gitignore").write_text("dist/\n") + run(build_repository, "git", "add", ".gitignore") + run(build_repository, "git", "commit", "-m", "Ignore build output") + output = build_repository / "dist" + output.mkdir() + (output / "old.whl").write_bytes(b"old") + result = action( + build_repository, + tmp_path, + action_scripts, + "build-python-distribution", + version="0.2.0", + SOURCE=".", + RUNNER_TEMP=str(tmp_path), + ) + assert result.returncode != 0 + assert "distribution output directory is not empty" in result.stderr + + +def test_build_python_distribution_rejects_backend_hash_mismatch( + build_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + lock = build_repository / "pylock.toml" + contents = lock.read_text() + package = next(package for package in tomllib.loads(contents)["packages"] if package["name"] == "hatchling") + for artifact in [package["sdist"], *package["wheels"]]: + contents = contents.replace(artifact["hashes"]["sha256"], "0" * 64, 1) + lock.write_text(contents) + run(build_repository, "git", "add", "pylock.toml") + run(build_repository, "git", "commit", "-m", "Corrupt backend hash") + result = action( + build_repository, + tmp_path, + action_scripts, + "build-python-distribution", + version="0.2.0", + SOURCE=".", + RUNNER_TEMP=str(tmp_path), + UV_CACHE_DIR=str(tmp_path / "uv-cache"), + ) + assert result.returncode != 0 + assert "Hash mismatch" in result.stderr + + +def test_build_python_distribution_does_not_run_hooks( + build_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + config = build_repository / ".pre-commit-config.yaml" + config.write_text(config.read_text().replace("entry: bash -c true", "entry: bash -c 'touch generated-file'")) + run(build_repository, "git", "add", ".") + run(build_repository, "git", "commit", "-m", "Create an untracked output") + result = action( + build_repository, + tmp_path, + action_scripts, + "build-python-distribution", + version="0.2.0", + SOURCE=".", + RUNNER_TEMP=str(tmp_path), + ) + assert result.returncode == 0, result.stderr + assert not (build_repository / "generated-file").exists() + + +def test_build_python_distribution_stops_when_interpreter_lookup_fails( + build_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + tools = tmp_path / "bin" + tools.mkdir() + uv = shutil.which("uv") + assert uv is not None + (tools / "uv").write_text( + '#!/usr/bin/env bash\nif [[ "$1" == python && "$2" == find ]]; then exit 19; fi\n' + 'if [[ "$1" == build ]]; then printf "build called\\n" >>"$RUNNER_TEMP/build-calls"; fi\n' + f'exec "{uv}" "$@"\n', + ) + (tools / "uv").chmod(0o755) + result = action( + build_repository, + tmp_path, + action_scripts, + "build-python-distribution", + version="0.2.0", + PATH=f"{tools}:{os.environ['PATH']}", + ) + assert result.returncode == 19, result.stderr + assert not (tmp_path / "build-calls").exists() diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py new file mode 100644 index 0000000..0c11af3 --- /dev/null +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import base64 +import json +import os +import subprocess +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +import yaml +from conftest import action +from pytest_httpserver import HTTPServer + + +def test_action_uses_scoped_job_token() -> None: + action = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text()) + assert [step["env"]["GH_TOKEN"] for step in action["runs"]["steps"] if "GH_TOKEN" in step.get("env", {})] == [ + "${{ github.token }}", + "${{ github.token }}", + "${{ github.token }}", + "${{ github.token }}", + ] + + +@pytest.mark.parametrize( + ("author", "revision", "frozen_base", "accepted"), + [ + pytest.param("gaborbernat", "v2", False, True, id="human"), + pytest.param("pre-commit-ci[bot]", "v1", False, True, id="cooled-revision-on-new-head"), + pytest.param("pre-commit-ci[bot]", "v0.5", False, True, id="older-cooled-revision"), + pytest.param("pre-commit-ci[bot]", "1.0", False, True, id="cooled-alias"), + pytest.param("pre-commit-ci[bot]", "sha-v0.5", True, True, id="older-frozen-revision"), + pytest.param("pre-commit-ci[bot]", "v1", True, True, id="frozen-base"), + pytest.param("pre-commit-ci[bot]", "v2", False, False, id="young-revision-on-old-pr"), + pytest.param("release-app[bot]", "v1", False, True, id="app-cooled-revision"), + pytest.param("release-app[bot]", "v2", False, False, id="app-young-revision"), + ], +) +def test_cooldown_checks_hook_revision( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + author: str, + revision: str, + frozen_base: bool, + accepted: bool, +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author=author) + if author.endswith("[bot]"): + base_revision = ( + subprocess.run( + ["git", "-C", str(repository), "rev-parse", "v0^{}"], + text=True, + capture_output=True, + check=True, + ).stdout.strip() + if frozen_base + else "v0" + ) + if revision == "sha-v0.5": + revision = subprocess.run( + ["git", "-C", str(repository), "rev-parse", "v0.5^{}"], + text=True, + capture_output=True, + check=True, + ).stdout.strip() + expect_config(server, "b" * 40, repository, base_revision) + expect_config(server, "a" * 40, repository, revision) + result = run_cooldown(action_scripts, tmp_path, environment) + assert (result.returncode == 0) is accepted, result.stderr + assert [request.path for request, _ in server.log] == [ + "/api/v3/repos/tox-dev/fixture/pulls/7", + *( + [ + "/api/v3/repos/tox-dev/fixture/pulls/7/files", + "/api/v3/repos/tox-dev/fixture/actions/runs", + "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", + "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", + ] + if author.endswith("[bot]") + else [] + ), + ] + + +def test_cooldown_skips_bot_without_hook_changes( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + expect_pull(server, author="dependabot[bot]", hook_update=False) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode == 0, result.stderr + assert [request.path for request, _ in server.log] == [ + "/api/v3/repos/tox-dev/fixture/pulls/7", + "/api/v3/repos/tox-dev/fixture/pulls/7/files", + ] + + +def test_cooldown_rejects_changed_head( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + expect_pull(server, author="pre-commit-ci[bot]", head="c" * 40) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode != 0 + assert len(server.log) == 1 + + +def test_cooldown_rejects_backdated_tag_on_recent_pull_request( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + expect_pull(server, author="pre-commit-ci[bot]", observed_days=0, updated_days=10) + repository = hook_repository(tmp_path) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v2") + expect_historical_runs(server, older_head=None) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode != 0 + assert "hook revision has not been observed" in result.stderr + + +@pytest.mark.parametrize( + ("historical_revision", "accepted"), + [pytest.param("v1", True, id="same-revision"), pytest.param("v0", False, id="new-revision")], +) +def test_cooldown_survives_force_push_without_accepting_new_revision( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + historical_revision: str, + accepted: bool, +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + expect_historical_runs(server, older_head="c" * 40) + expect_config(server, "c" * 40, repository, historical_revision) + result = run_cooldown(action_scripts, tmp_path, environment) + assert (result.returncode == 0) is accepted, result.stderr + branch_runs = [ + request for request, _ in server.log if request.path.endswith("/actions/runs") and "branch" in request.args + ] + assert len(branch_runs) == 1 + cutoff = datetime.fromisoformat(branch_runs[0].args["created"].removeprefix("<")) + assert timedelta(days=6, hours=23) < datetime.now(UTC) - cutoff < timedelta(days=7, hours=1) + if not accepted: + assert "hook revision has not been observed" in result.stderr + + +def test_cooldown_skips_unavailable_old_head( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + expect_historical_runs(server, older_head=("c" * 40, "d" * 40)) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", + method="GET", + query_string=f"ref={'c' * 40}", + ).respond_with_json({"message": "Not Found"}, status=404) + expect_config(server, "d" * 40, repository, "v1") + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode == 0, result.stderr + + +def test_cooldown_skips_malformed_old_head( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + expect_historical_runs(server, older_head=("c" * 40, "d" * 40)) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", + method="GET", + query_string=f"ref={'c' * 40}", + ).respond_with_json({"content": base64.b64encode(b"repos: [").decode()}) + expect_config(server, "d" * 40, repository, "v1") + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode == 0, result.stderr + + +def test_cooldown_matches_revision_after_repository_list_change( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + expect_historical_runs(server, older_head="c" * 40) + historical = ( + "repos:\n" + " - repo: https://example.invalid/another-hook\n rev: v2\n hooks: []\n" + f" - repo: {repository.as_uri()}\n rev: v1\n hooks:\n - id: sample\n" + ) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", + method="GET", + query_string=f"ref={'c' * 40}", + ).respond_with_json({"content": base64.b64encode(historical.encode()).decode()}) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode == 0, result.stderr + + +def test_cooldown_ignores_another_pull_request_history( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + expect_historical_runs(server, older_head="c" * 40, pull_request=8) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode != 0 + assert "hook revision has not been observed" in result.stderr + + +def test_cooldown_checks_each_duplicate_repository( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]") + expect_config(server, "b" * 40, repository, ("v0", "v0")) + expect_config(server, "a" * 40, repository, ("v2", "v0")) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode != 0 + assert "hook repository entries changed" not in result.stderr + assert "hook revision is not eligible" in result.stderr + + +def test_cooldown_does_not_use_another_duplicate_repository_observation( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) + expect_config(server, "b" * 40, repository, ("v0", "v0")) + expect_config(server, "a" * 40, repository, ("v1", "v0")) + expect_historical_runs(server, older_head="c" * 40) + expect_config(server, "c" * 40, repository, ("v0", "v1")) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode != 0 + assert "hook revision has not been observed" in result.stderr + + +def test_cooldown_rejects_other_event_before_api( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], +) -> None: + server, environment = github_api + result = run_cooldown( + action_scripts, + tmp_path, + environment, + event_name="workflow_dispatch", + ) + assert result.returncode != 0 + assert not server.log + + +def run_cooldown( + action_scripts: Path, + tmp_path: Path, + environment: dict[str, str], + *, + event_name: str = "pull_request", + event_head: str = "a" * 40, +) -> subprocess.CompletedProcess[str]: + path = tmp_path / "event.json" + path.write_text(json.dumps({"number": 7, "pull_request": {"head": {"sha": event_head}}})) + (tmp_path / "pylock.toml").write_text( + 'lock-version = "1.0"\ncreated-by = "test"\nrequires-python = ">=3.15"\npackages = []\n', + ) + return action( + tmp_path, + tmp_path, + action_scripts, + "check-hook-update-cooldown", + version="", + MINIMUM_DAYS="7", + GITHUB_EVENT_NAME=event_name, + GITHUB_EVENT_PATH=str(path), + GITHUB_OUTPUT=str(tmp_path / "outputs"), + **environment, + ) + + +def expect_pull( + server: HTTPServer, + *, + author: str, + head: str = "a" * 40, + observed_days: int = 10, + updated_days: int = 0, + hook_update: bool = True, +) -> None: + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json( + { + "state": "open", + "base": {"repo": {"full_name": "tox-dev/fixture"}, "sha": "b" * 40}, + "head": {"sha": head, "ref": "bot-update"}, + "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"}, + "updated_at": (datetime.now(UTC) - timedelta(days=updated_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + ) + if author.endswith("[bot]"): + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( + [{"filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}] + ) + if author.endswith("[bot]") and hook_update: + server.expect_request( + "/api/v3/repos/tox-dev/fixture/actions/runs", + method="GET", + query_string=f"event=pull_request&head_sha={head}&per_page=100", + ).respond_with_json( + { + "workflow_runs": [ + { + "event": "pull_request", + "head_repository": {"full_name": "tox-dev/fixture"}, + "head_branch": "bot-update", + "head_sha": head, + "pull_requests": [{"number": 7}], + "created_at": (datetime.now(UTC) - timedelta(days=observed_days)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + } + ] + } + ) + + +def expect_config(server: HTTPServer, sha: str, repository: Path, revision: str | tuple[str, ...]) -> None: + revisions = (revision,) if isinstance(revision, str) else revision + server.expect_request( + "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", method="GET", query_string=f"ref={sha}" + ).respond_with_json( + { + "content": base64.b64encode( + ( + "repos:\n" + + "".join( + f" - repo: {repository.as_uri()}\n rev: {item}\n hooks:\n - id: sample\n" + for item in revisions + ) + ).encode() + ).decode() + } + ) + + +def expect_historical_runs( + server: HTTPServer, *, older_head: str | tuple[str, ...] | None, pull_request: int = 7 +) -> None: + heads = () if older_head is None else (older_head,) if isinstance(older_head, str) else older_head + server.expect_request( + "/api/v3/repos/tox-dev/fixture/actions/runs", + method="GET", + ).respond_with_json( + { + "workflow_runs": [ + { + "event": "pull_request", + "head_repository": {"full_name": "tox-dev/fixture"}, + "head_branch": "bot-update", + "head_sha": head, + "pull_requests": [{"number": pull_request}], + "created_at": (datetime.now(UTC) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + for head in heads + ] + } + ) + + +def hook_repository(tmp_path: Path) -> Path: + repository = tmp_path / "hook-repo" + repository.mkdir() + subprocess.run(["git", "init", "-q", str(repository)], check=True) + subprocess.run(["git", "-C", str(repository), "config", "user.name", "Hook test"], check=True) + subprocess.run(["git", "-C", str(repository), "config", "user.email", "hooks@example.org"], check=True) + (repository / ".pre-commit-hooks.yaml").write_text( + "- id: sample\n name: sample\n entry: true\n language: system\n" + ) + subprocess.run(["git", "-C", str(repository), "add", "."], check=True) + subprocess.run(["git", "-C", str(repository), "commit", "-qm", "Add hook"], check=True) + for name, days in (("v0", 20), ("v0.5", 15), ("v1", 10), ("v2", 1)): + date = (datetime.now(UTC) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ") + (repository / "version").write_text(name) + subprocess.run(["git", "-C", str(repository), "add", "version"], check=True) + subprocess.run( + ["git", "-C", str(repository), "commit", "-qm", name], + env=os.environ | {"GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date}, + check=True, + ) + subprocess.run( + ["git", "-C", str(repository), "tag", "-a", name, "-m", name], + env=os.environ | {"GIT_COMMITTER_DATE": date}, + check=True, + ) + subprocess.run( + ["git", "-C", str(repository), "tag", "-a", "1.0", "v1^{}", "-m", "1.0"], + env=os.environ + | {"GIT_COMMITTER_DATE": (datetime.now(UTC) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%SZ")}, + check=True, + ) + return repository diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py new file mode 100644 index 0000000..983b7fe --- /dev/null +++ b/tests/check_hook_update_cooldown/test_workflow.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + + +def test_cooldown_job_is_read_only_and_required() -> None: + workflow = yaml.load( + (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(), + Loader=yaml.BaseLoader, + ) + job = workflow["jobs"]["hook-cooldown"] + assert job["if"] == "github.event_name == 'pull_request'" + assert job["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"} + assert job["steps"][0] == {"uses": "$/check-hook-update-cooldown"} + assert "hook-cooldown" in workflow["jobs"]["gate"]["needs"] + + +def test_cooldown_installs_only_required_hook_tools() -> None: + action = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text()) + assert next(step for step in action["runs"]["steps"] if step.get("uses") == "$/install-locked-tools") == { + "uses": "$/install-locked-tools", + "if": "steps.identity.outputs.bot == 'true'", + "with": {"groups": "release-hook-check", "lock-source": "shared", "venv": "${{ runner.temp }}/hook-tools"}, + } + + +@pytest.mark.parametrize( + ("event_name", "cooldown_result", "accepted"), + [ + pytest.param("pull_request", "success", True, id="cooled-pr"), + pytest.param("pull_request", "skipped", False, id="skipped-pr"), + pytest.param("pull_request", "failure", False, id="young-bot-pr"), + pytest.param("push", "skipped", True, id="push"), + ], +) +def test_required_gate_checks_cooldown_result(event_name: str, cooldown_result: str, accepted: bool) -> None: + workflow = yaml.load( + (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(), + Loader=yaml.BaseLoader, + ) + script = workflow["jobs"]["gate"]["steps"][0]["run"] + needs = { + name: { + "result": cooldown_result + if name == "hook-cooldown" + else "skipped" + if event_name == "push" and name in {"changelog", "hook-cooldown", "release-tools-preview"} + else "success" + } + for name in workflow["jobs"]["gate"]["needs"] + } + result = subprocess.run( + ["bash", "-c", script], + env=os.environ | {"NEEDS_JSON": json.dumps(needs), "EVENT_NAME": event_name}, + text=True, + capture_output=True, + check=False, + ) + assert (result.returncode == 0) is accepted, result.stderr diff --git a/tests/commit_and_tag/test_commit.py b/tests/commit_and_tag/test_commit.py index c52fc6c..e352271 100644 --- a/tests/commit_and_tag/test_commit.py +++ b/tests/commit_and_tag/test_commit.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -lazy from pathlib import Path +from pathlib import Path import pytest from conftest import action, run @@ -11,13 +11,9 @@ "annotated", [pytest.param("false", id="lightweight"), pytest.param("true", id="annotated")], ) -@pytest.mark.parametrize("branch", [pytest.param("main", id="main"), pytest.param("release", id="release-branch")]) def test_commit_and_tag_pushes_atomically( - repository: Path, tmp_path: Path, action_scripts: Path, annotated: str, branch: str + repository: Path, tmp_path: Path, action_scripts: Path, annotated: str ) -> None: - if branch != "main": - run(repository, "git", "checkout", "-b", branch) - run(repository, "git", "push", "origin", f"HEAD:{branch}") assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 run(repository, "git", "reset", "--hard", "HEAD") assert ( @@ -27,7 +23,6 @@ def test_commit_and_tag_pushes_atomically( action_scripts, "validate-release-patch", version="0.2.0", - BRANCH=branch, ).returncode == 0 ) @@ -41,15 +36,14 @@ def test_commit_and_tag_pushes_atomically( "commit-and-tag", version="0.2.0", APP_SLUG="release-test", - GH_TOKEN="test-token", # ruff: ignore[hardcoded-password-func-arg] # Local Git fixture never sends this token. + TOKEN="test-token", # ruff: ignore[hardcoded-password-func-arg] # Local Git fixture never sends this token. MESSAGE="release 0.2.0", ANNOTATED=annotated, - BRANCH=branch, PATH=f"{tmp_path}:{os.environ['PATH']}", ) assert result.returncode == 0, result.stderr assert run(repository, "git", "ls-remote", "origin", "refs/tags/0.2.0").stdout.strip() - assert run(repository, "git", "ls-remote", "origin", f"refs/heads/{branch}").stdout.strip() + assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout.strip() assert run(repository, "git", "cat-file", "-t", "refs/tags/0.2.0").stdout.strip() == ( "tag" if annotated == "true" else "commit" ) @@ -127,10 +121,33 @@ def test_commit_and_tag_keeps_remote_unchanged_on_rejection( "commit-and-tag", version="0.2.0", APP_SLUG="release-test", - GH_TOKEN="test-token", + TOKEN="test-token", MESSAGE="release 0.2.0", PATH=f"{tmp_path}:{os.environ['PATH']}", ) assert result.returncode != 0 assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == before assert not run(repository, "git", "ls-remote", "origin", "refs/tags/0.2.0").stdout + + +def test_commit_and_tag_rejects_missing_token_without_remote_change( + repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + run(repository, "git", "reset", "--hard", "HEAD") + assert action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0").returncode == 0 + before = run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout + result = action( + repository, + tmp_path, + action_scripts, + "commit-and-tag", + version="0.2.0", + APP_SLUG="release-test", + TOKEN="", + GH_TOKEN="ambient-test-token", # ruff: ignore[hardcoded-password-func-arg] # No token reaches GitHub. + ) + assert result.returncode != 0 + assert "repository App token and slug are required" in result.stderr + assert run(repository, "git", "ls-remote", "origin", "refs/heads/main").stdout == before + assert not run(repository, "git", "ls-remote", "origin", "refs/tags/0.2.0").stdout diff --git a/tests/conftest.py b/tests/conftest.py index 50d3fea..c21ef57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,44 @@ from __future__ import annotations +import json import os +import ssl import subprocess +import sys +from collections.abc import Iterator, Mapping from pathlib import Path -from uuid import uuid4 +from textwrap import dedent +from typing import Final import pytest +import trustme import yaml +from pytest_httpserver import HTTPServer +from werkzeug.wrappers import Request, Response + + +@pytest.fixture +def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]: + authority = trustme.CA() + certificate = authority.issue_cert("localhost") + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + certificate.configure_cert(context) + server = HTTPServer(host="localhost", ssl_context=context) + server.start() + bundle = tmp_path / "ca.pem" + authority.cert_pem.write_to_path(bundle) + if system_certs := ssl.get_default_verify_paths().cafile: + bundle.write_bytes(bundle.read_bytes() + Path(system_certs).read_bytes()) + yield ( + server, + { + "GH_HOST": f"localhost:{server.port}", + "GH_ENTERPRISE_TOKEN": "test-token", + "SSL_CERT_FILE": str(bundle), + "NO_PROXY": "localhost,127.0.0.1", + }, + ) + server.stop() @pytest.fixture(scope="session") @@ -17,17 +49,66 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "prepare-towncrier", "validate-release-patch", "commit-and-tag", + "build-python-distribution", + "validate-python-distribution", + "render-release-notes", + "select-previous-tag", + "validate-news", + "label-bot-news", + "resolve-pr-from-run", + "refresh-release-lock", + "install-locked-tools", + "install-nab", + "export-build-constraints", + "read-towncrier-config", + "refresh-hook-pins", + "publish-github-release", + "reconcile-pypi-artifacts", + "publish-jetbrains-marketplace", + "sign-jetbrains-plugin", + "check-hook-update-cooldown", + "recheck-hook-update-cooldown", + "publish-post-release-pr", ): action = yaml.safe_load((source / action_name / "action.yml").read_text()) for index, step in enumerate(action["runs"]["steps"]): + if "run" not in step: + continue path = scripts / f"{action_name}-{index}.sh" path.write_text(f"#!/usr/bin/env bash\n{step['run']}") path.chmod(0o755) return scripts +@pytest.fixture(scope="session") +def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path: + root = Path(__file__).resolve().parents[1] + environment = tmp_path_factory.mktemp("nab-tools") + subprocess.run(["uv", "venv", "--python", "3.15", str(environment)], check=True, capture_output=True) + subprocess.run( + [ + "uv", + "pip", + "sync", + "--preview-features", + "pylock", + "--python", + str(environment / "bin/python"), + "--group", + "lock-tools", + "--only-binary", + ":all:", + "--require-hashes", + str(root / "pylock.toml"), + ], + check=True, + capture_output=True, + ) + return environment / "bin/nab" + + @pytest.fixture -def repository(tmp_path: Path) -> Path: +def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], monkeypatch: pytest.MonkeyPatch) -> Path: remote = tmp_path / "remote.git" run(tmp_path, "git", "init", "--bare", str(remote)) worktree = tmp_path / "worktree" @@ -43,20 +124,79 @@ def repository(tmp_path: Path) -> Path: (worktree / ".pre-commit-config.yaml").write_text( "repos:\n - repo: local\n hooks:\n - id: release-check\n" " name: release-check\n entry: bash -c true\n" - " language: system\n pass_filenames: false\n", + " language: system\n pass_filenames: false\n" + " - id: mdformat\n name: mdformat\n entry: mdformat --wrap 120\n" + " language: system\n types: [markdown]\n", ) (worktree / "CHANGELOG.md").write_text("# Changelog\n\n\n") (worktree / "changelog.d").mkdir() run(worktree, "git", "add", ".") run(worktree, "git", "commit", "-m", "Initial files") run(worktree, "git", "tag", "0.1.0") - (worktree / "changelog.d/feature.feature.md").write_text("Add a new feature.\n") + (worktree / "changelog.d/+feature.feature.md").write_text("Add a new feature.\n") run(worktree, "git", "add", ".") - run(worktree, "git", "commit", "-m", "Add feature") + run(worktree, "git", "commit", "-m", "Add feature (#2)") run(worktree, "git", "push", "-u", "origin", "main", "--tags") + server, environment = github_api + for name, value in environment.items(): + monkeypatch.setenv(name, value) + + def associated_pulls(request: Request) -> Response: + query = request.get_json()["query"] + commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:]) + return Response( + json.dumps( + { + "data": { + "repository": { + f"c{index}": { + "associatedPullRequests": { + "nodes": [ + { + "number": int(environment.get("TEST_PULL_NUMBER", "2")), + "mergedAt": "2026-10-22T00:00:00Z", + "mergeCommit": {"oid": commit}, + "author": {"login": "gaborbernat", "__typename": "User"}, + } + ] + } + } + for index, commit in enumerate(commits) + } + } + } + ), + mimetype="application/json", + ) + + server.expect_request("/api/graphql", method="POST").respond_with_handler(associated_pulls) return worktree +@pytest.fixture +def source_project(repository: Path) -> Path: + project = repository / "pyproject.toml" + project.write_text( + project.read_text() + + dedent( + """ + [project] + name = "fixture" + version = "0.2.0" + requires-python = ">=3.15" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + """, + ), + ) + package = repository / "src/fixture" + package.mkdir(parents=True) + (package / "__init__.py").write_text('"""Fixture package."""\n') + return repository + + def action( repository: Path, tmp_path: Path, @@ -66,44 +206,118 @@ def action( version: str, **extra_env: str, ) -> subprocess.CompletedProcess[str]: - environment = ( - os.environ + action_config = yaml.safe_load((Path(__file__).resolve().parents[1] / name / "action.yml").read_text()) + head = run(repository, "git", "rev-parse", "HEAD").stdout.strip() if (repository / ".git").exists() else "0" * 40 + input_values = { + "VERSION": version, + "BUMP": "auto", + "TAG_PREFIX": "", + "CHANGELOG_PREFIX": "", + "CHANGELOG": "CHANGELOG.md", + "FRAGMENTS": "changelog.d", + "FORMAT": action_config.get("inputs", {}).get("format", {}).get("default", "none"), + "PATCH": str(tmp_path / "release.patch"), + "EXPECTED_SHA": head, + "EXPECTED_PATCH_SHA": "", + "CHECKS": "true", + "BRANCH": "main", + "REMOTE": "origin", + "ANNOTATED": "false", + "ARTIFACT_URL": "", + "ARTIFACT_LABEL": "PyPI release", + "LOCK": "pylock.toml", + "GENERATED_NOTES": "", + "MODE": "complete", + "RUN_ID": "42", + "WORKFLOW_PATH": ".github/workflows/check.yaml", + } | extra_env + input_values["REPOSITORY"] = extra_env.get("REPOSITORY", "tox-dev/fixture") + inputs = _inputs(action_config, input_values) + input_names = {input_name.replace("-", "_").upper() for input_name in action_config.get("inputs", {})} + environment: dict[str, str] = ( + _safe_environment() | { - "VERSION": version, - "BUMP": "auto", - "CHANGELOG": "CHANGELOG.md", - "FRAGMENTS": "changelog.d", - "FORMAT": "none", - "LINK_FRAGMENTS": "false", - "PATCH": str(tmp_path / "release.patch"), "GITHUB_OUTPUT": str(tmp_path / "output"), "GITHUB_REPOSITORY": "tox-dev/fixture", - "GITHUB_SHA": run(repository, "git", "rev-parse", "HEAD").stdout.strip(), - "EXPECTED_SHA": run(repository, "git", "rev-parse", "HEAD").stdout.strip(), - "HEADING_PREFIX": "## ", - "BRANCH": "main", - "REMOTE": "origin", - "ANNOTATED": "false", + "GITHUB_SHA": head, + "RUNNER_TEMP": str(tmp_path), + "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[1] / name), } - | extra_env + | {key: value for key, value in extra_env.items() if key not in input_names} ) - stdout = [] - stderr = [] - for script in sorted(action_scripts.glob(f"{name}-*.sh")): - command = ["bash", str(script)] - if coverage_binary := os.environ.get("KCOV_BIN"): - output = Path(os.environ["KCOV_OUTPUT"]) - output.mkdir(parents=True, exist_ok=True) - command = [ - coverage_binary, - f"--include-path={action_scripts}", - str(output / uuid4().hex), - str(script), - ] + if "PATH" not in extra_env: + environment["PATH"] = _tools_path(environment) + result, _ = _run_action(repository, action_scripts, name, environment, inputs) + return result + + +def _run_action( + repository: Path, + action_scripts: Path, + name: str, + environment: dict[str, str], + inputs: dict[str, str], +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + source = Path(__file__).resolve().parents[1] + action_config = yaml.safe_load((source / name / "action.yml").read_text()) + stdout: list[str] = [] + stderr: list[str] = [] + action_steps = action_config["runs"]["steps"] + step_outputs: dict[str, dict[str, str]] = {} + github_bash: Final[tuple[str, ...]] = ("bash", "--noprofile", "--norc", "-e", "-o", "pipefail") + command = list(github_bash) + for index, step in enumerate(action_steps): + if (condition := step.get("if")) is not None and not _condition(condition, environment, inputs, step_outputs): + continue + if (used := step.get("uses")) is not None: + if used.startswith("astral-sh/setup-uv@"): + continue + if not used.startswith("$/"): + msg = f"unsupported nested action: {used}" + raise ValueError(msg) + nested_name = used.removeprefix("$/") + nested_config = yaml.safe_load((source / nested_name / "action.yml").read_text()) + nested_inputs = { + input_name: _step_value( + str(step.get("with", {}).get(input_name, definition.get("default", ""))), + environment, + inputs, + step_outputs, + ) + for input_name, definition in nested_config.get("inputs", {}).items() + } + result, outputs = _run_action( + repository, + action_scripts, + nested_name, + environment + | { + "GITHUB_ACTION_PATH": str(source / nested_name), + "GITHUB_OUTPUT": str(Path(environment["RUNNER_TEMP"]) / f"nested-{name}-{index}-output"), + }, + nested_inputs, + ) + stdout.append(result.stdout) + stderr.append(result.stderr) + if result.returncode: + return subprocess.CompletedProcess(result.args, result.returncode, "".join(stdout), "".join(stderr)), {} + if step_id := step.get("id"): + step_outputs[step_id] = outputs + continue + if "run" not in step: + continue + script = action_scripts / f"{name}-{index}.sh" + command = [*github_bash, str(script)] + output_path = Path(environment["GITHUB_OUTPUT"]) + prior_output = output_path.read_text() if output_path.exists() else "" + step_environment: dict[str, str] = environment | { + variable: _step_value(str(value), environment, inputs, step_outputs) + for variable, value in (step.get("env") or {}).items() + } result = subprocess.run( command, - cwd=repository, - env=environment, + cwd=repository / _step_value(str(step.get("working-directory", ".")), environment, inputs, step_outputs), + env=step_environment, text=True, capture_output=True, check=False, @@ -111,14 +325,155 @@ def action( stdout.append(result.stdout) stderr.append(result.stderr) if result.returncode: - return subprocess.CompletedProcess(command, result.returncode, "".join(stdout), "".join(stderr)) - if name == "prepare-towncrier" and script.name == "prepare-towncrier-0.sh": - environment["VERSION"] = (tmp_path / "output").read_text().split("version=", 1)[1].splitlines()[0] - return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr)) + return subprocess.CompletedProcess(command, result.returncode, "".join(stdout), "".join(stderr)), {} + if step_id := step.get("id"): + current_output = output_path.read_text() if output_path.exists() else "" + assert current_output.startswith(prior_output) + step_outputs[step_id] = dict( + line.split("=", 1) for line in current_output[len(prior_output) :].splitlines() + ) + outputs: dict[str, str] = { + output_name: _step_value(definition["value"], environment, inputs, step_outputs) + for output_name, definition in action_config.get("outputs", {}).items() + } + return subprocess.CompletedProcess(command, 0, "".join(stdout), "".join(stderr)), outputs def run(directory: Path, *command: str) -> subprocess.CompletedProcess[str]: - return subprocess.run(command, cwd=directory, text=True, capture_output=True, check=True) + environment = _safe_environment() + result = subprocess.run( + command, + cwd=directory, + env=environment | {"PATH": _tools_path(environment)}, + text=True, + capture_output=True, + check=False, + ) + if result.returncode: + msg = f"{' '.join(command)} failed with exit code {result.returncode}:\n{result.stderr}" + raise AssertionError(msg) + return result + + +def _tools_path(environment: dict[str, str]) -> str: + return f"{Path(__file__).resolve().parents[1] / '.release-tools/bin'}:{Path(sys.executable).parent}:{environment['PATH']}" + + +def _condition( + expression: str, + environment: dict[str, str], + inputs: dict[str, str], + step_outputs: dict[str, dict[str, str]], +) -> bool: + if " || " in expression: + return any(_condition(part, environment, inputs, step_outputs) for part in expression.split(" || ")) + if " && " in expression: + return all(_condition(part, environment, inputs, step_outputs) for part in expression.split(" && ")) + if expression.startswith("endsWith(") and expression.endswith(")"): + arguments = expression.removeprefix("endsWith(").removesuffix(")").split(", ", 1) + return _expression(arguments[0], environment, inputs, step_outputs).endswith(arguments[1].strip("'")) + for operator in (" == ", " != "): + if operator in expression: + left, right = expression.split(operator, 1) + equal = _expression(left, environment, inputs, step_outputs) == right.strip("'") + return equal if operator == " == " else not equal + msg = f"unsupported GitHub step condition: {expression}" + raise ValueError(msg) + + +def _safe_environment() -> dict[str, str]: + return { + name: os.environ[name] + for name in ( + "HOME", + "LANG", + "PATH", + "UV_CACHE_DIR", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "GH_HOST", + "GH_ENTERPRISE_TOKEN", + ) + if name in os.environ + } + + +def _inputs( + configuration: Mapping[str, Mapping[str, Mapping[str, str | bool]]], environment: dict[str, str] +) -> dict[str, str]: + return { + name: environment.get(name.replace("-", "_").upper(), _input_default(definition.get("default", ""))) + for name, definition in configuration.get("inputs", {}).items() + } + + +def _input_default(value: str | bool) -> str: + return str(value).lower() if isinstance(value, bool) else value + + +def _step_value( + value: str, + environment: dict[str, str], + inputs: dict[str, str], + step_outputs: dict[str, dict[str, str]], +) -> str: + parts: list[str] = [] + remainder = value + while (start := remainder.find("${{")) != -1: + end = remainder.find("}}", start + 3) + if end == -1: + msg = f"unterminated GitHub expression: {value}" + raise ValueError(msg) + parts.append(remainder[:start]) + parts.append(_expression(remainder[start + 3 : end].strip(), environment, inputs, step_outputs)) + remainder = remainder[end + 2 :] + return "".join([*parts, remainder]) + + +def _expression( + expression: str, + environment: dict[str, str], + inputs: dict[str, str], + step_outputs: dict[str, dict[str, str]], +) -> str: + if " && " in expression and " || " in expression: + condition, alternatives = expression.split(" && ", 1) + true_value, false_value = alternatives.split(" || ", 1) + return (true_value if _condition(condition, environment, inputs, step_outputs) else false_value).strip("'") + if " == " in expression or " != " in expression: + return str(_condition(expression, environment, inputs, step_outputs)).lower() + if " || " in expression: + return next( + ( + value + for part in expression.split(" || ") + if (value := _expression(part, environment, inputs, step_outputs)) + ), + "", + ) + if expression.startswith("inputs."): + return inputs[expression.removeprefix("inputs.")] + if expression.startswith("steps."): + _, step_id, _, output_name = expression.split(".") + return step_outputs.get(step_id, {}).get(output_name, "") + if expression == "runner.temp": + return environment["RUNNER_TEMP"] + if expression == "github.repository": + return environment["GITHUB_REPOSITORY"] + if expression == "github.base_ref": + return environment.get("BASE_REF", "") + if expression == "github.event.pull_request.number": + return environment.get("GITHUB_EVENT_PULL_REQUEST_NUMBER", "") + if expression == "github.token": + return environment.get("GH_TOKEN", environment.get("GH_ENTERPRISE_TOKEN", "")) + if expression == "github.event.pull_request.user.type": + return "Bot" if environment.get("BOT_AUTHOR") == "true" else "User" + if expression == "contains(github.event.pull_request.labels.*.name, 'skip news')": + return environment.get("SKIP_NEWS", "false") + msg = f"unsupported GitHub step environment: {expression}" + raise ValueError(msg) __all__ = [ diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py new file mode 100644 index 0000000..e4e7a5e --- /dev/null +++ b/tests/export_build_constraints/test_export.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from textwrap import dedent + +import pytest +import tomlkit +from conftest import action + + +def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) + result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", version="") + assert result.returncode == 0, result.stderr + output = next(tmp_path.glob("build-constraints-*.txt")) + constraints = output.read_text() + assert "cython==" in constraints + assert "setuptools==" in constraints + assert "pytest==" not in constraints + assert (tmp_path / "output").read_text() == f"path={output}\n" + + +@pytest.mark.parametrize( + ("version", "accepted"), + [pytest.param("81.0.0", True, id="compatible"), pytest.param("79.0.0", False, id="incompatible")], +) +def test_export_build_constraints_prefers_compatible_caller_build_pin( + tmp_path: Path, version: str, accepted: bool +) -> None: + source = Path(__file__).resolve().parents[2] + shared = source / "pylock.toml" + caller = tmp_path / "pylock.toml" + document = tomlkit.parse(shared.read_text()) + for package in document["packages"]: + if package["name"] == "setuptools": + package["version"] = version + for wheel in package["wheels"]: + wheel["name"] = wheel["name"].replace("84.0.0", version) + wheel["url"] = wheel["url"].replace("84.0.0", version) + package["sdist"]["name"] = package["sdist"]["name"].replace("84.0.0", version) + package["sdist"]["url"] = package["sdist"]["url"].replace("84.0.0", version) + caller.write_text(tomlkit.dumps(document)) + output = tmp_path / "constraints.txt" + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + "--shared-policy", + str(source / "pyproject.toml"), + str(output), + str(shared), + str(caller), + ], + text=True, + capture_output=True, + check=False, + ) + assert (result.returncode == 0) == accepted + if accepted: + assert f"setuptools=={version} " in output.read_text() + else: + assert "violates setuptools>=80" in result.stderr + + +@pytest.mark.parametrize( + ("platform", "targets"), + [ + pytest.param("macos_arm64", "3.15.0:cpython 3.11.0:pypy", id="macos-pypy"), + pytest.param("windows_arm64", "3.15.0:cpython", id="windows-arm"), + ], +) +def test_export_build_constraints_selects_explicit_targets(tmp_path: Path, platform: str, targets: str) -> None: + source = Path(__file__).resolve().parents[2] + lock = tmp_path / "pylock.build.toml" + write_matrix_lock(lock) + output = tmp_path / "constraints.txt" + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + "--target-platform", + platform, + "--targets", + targets, + str(output), + str(lock), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert output.read_text() == f"example==1.0 --hash=sha256:{'0' * 64}\n" + + +def test_export_build_constraints_rejects_different_target_versions(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + lock = tmp_path / "pylock.build.toml" + write_matrix_lock(lock) + lock.write_text( + lock.read_text().replace('version = "1.0"', 'version = "1.0"\nmarker = \'implementation_name == "pypy"\'') + + dedent( + """\ + [[packages]] + name = "example" + version = "2.0" + marker = 'implementation_name == "cpython"' + index = "https://pypi.org/simple/" + [[packages.wheels]] + name = "example-2.0-py3-none-any.whl" + url = "https://files.pythonhosted.org/example-2.0-py3-none-any.whl" + hashes.sha256 = "1111111111111111111111111111111111111111111111111111111111111111" + """, + ), + ) + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + "--target-platform", + "macos_arm64", + "--targets", + "3.15.0:cpython 3.11.0:pypy", + str(tmp_path / "constraints.txt"), + str(lock), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "versions differ across requested targets" in result.stderr + + +@pytest.mark.parametrize( + ("version", "selected"), + [ + pytest.param("3.15.1", True, id="full-version-selected"), + pytest.param("3.15.0", False, id="full-version-excluded"), + ], +) +def test_export_build_constraints_uses_full_python_version(tmp_path: Path, version: str, selected: bool) -> None: + source = Path(__file__).resolve().parents[2] + lock = tmp_path / "pylock.build.toml" + write_matrix_lock(lock) + lock.write_text( + lock.read_text().replace( + 'index = "https://pypi.org/simple/"', + 'marker = \'python_full_version >= "3.15.1"\'\nindex = "https://pypi.org/simple/"', + ), + ) + output = tmp_path / "constraints.txt" + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + "--target-platform", + "macos_arm64", + "--targets", + f"{version}:cpython", + str(output), + str(lock), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert ("example==1.0" in output.read_text()) == selected + + +def write_matrix_lock(path: Path) -> None: + path.write_text( + dedent( + """\ + lock-version = "1.0" + created-by = "test" + requires-python = ">=3.11" + environments = [ + 'python_version == "3.11" and sys_platform == "darwin" and platform_machine == "arm64" and implementation_name == "pypy"', + 'python_version == "3.15" and sys_platform == "darwin" and platform_machine == "arm64" and implementation_name == "cpython"', + 'python_version == "3.15" and sys_platform == "win32" and platform_machine == "ARM64" and implementation_name == "cpython"', + ] + [[packages]] + name = "example" + version = "1.0" + index = "https://pypi.org/simple/" + [[packages.wheels]] + name = "example-1.0-py3-none-any.whl" + url = "https://files.pythonhosted.org/example-1.0-py3-none-any.whl" + hashes.sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + """, + ), + ) diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py new file mode 100644 index 0000000..3cc274b --- /dev/null +++ b/tests/install_locked_tools/test_install.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import shutil +import subprocess +import tomllib +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from conftest import action + + +@pytest.mark.parametrize( + ("groups", "module"), + [ + pytest.param("release-news", "yaml", id="release-news"), + pytest.param("release-check release-notes", "release_creator", id="multiple-groups"), + pytest.param("release-check\nrelease-notes", "release_creator", id="multiline-groups"), + ], +) +def test_install_locked_tools_installs_groups(tmp_path: Path, action_scripts: Path, groups: str, module: str) -> None: + source = Path(__file__).resolve().parents[2] + environment = tmp_path / "tools" + result = install( + source, + action_scripts, + tmp_path, + groups=groups, + venv=environment, + ) + assert result.returncode == 0, result.stderr + imported = subprocess.run( + [str(environment / "bin/python"), "-c", "import importlib, sys; importlib.import_module(sys.argv[1])", module], + text=True, + capture_output=True, + check=False, + ) + assert imported.returncode == 0, imported.stderr + + +@pytest.mark.parametrize( + ("group", "present", "absent"), + [ + pytest.param("release-check", "prek", "release-creator", id="source-check"), + pytest.param("release-news", "towncrier", "prek", id="pr-news"), + pytest.param("release-validate", "release-creator", "yq", id="patch-validation"), + pytest.param("release-shell", "yq", "release-creator", id="inline-shell"), + pytest.param("release-hooks", "release-creator", "yq", id="action-tests"), + pytest.param("release-hook-check", "yq", "towncrier", id="hook-cooldown"), + ], +) +def test_install_locked_tools_selects_job_tools( + tmp_path: Path, action_scripts: Path, group: str, present: str, absent: str +) -> None: + source = Path(__file__).resolve().parents[2] + result = install( + source, + action_scripts, + tmp_path, + groups=group, + venv=tmp_path / "tools", + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "tools/bin" / present).exists() + assert not (tmp_path / "tools/bin" / absent).exists() + + +def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + generated = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "packaging>=26.1", + "python", + str(source / "tools/build_constraints.py"), + str(tmp_path / "constraints.txt"), + str(source / "pylock.toml"), + ], + text=True, + capture_output=True, + check=False, + ) + assert generated.returncode == 0, generated.stderr + constraints = (tmp_path / "constraints.txt").read_text() + assert "cython==" in constraints + assert "setuptools==" in constraints + assert "--hash=sha256:" in constraints + + +def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + action_root = tmp_path / "install-nab" + action_root.mkdir() + lock = (source / "pylock.toml").read_text() + package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "nab") + (tmp_path / "pylock.toml").write_text(lock.replace(package["wheels"][0]["hashes"]["sha256"], "0" * 64, 1)) + result = action( + tmp_path, + tmp_path, + action_scripts, + "install-nab", + version="0.2.0", + GITHUB_ACTION_PATH=str(action_root), + UV_CACHE_DIR=str(tmp_path / "cache"), + ) + assert result.returncode != 0 + assert "Hash mismatch" in result.stderr + + +def test_install_locked_tools_rejects_empty_groups(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + result = install( + source, + action_scripts, + tmp_path, + groups="", + venv=tmp_path / "tools", + ) + assert result.returncode != 0 + assert "at least one dependency group is required" in result.stderr + + +def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + result = install( + source, + action_scripts, + tmp_path, + groups="relesae", + venv=tmp_path / "tools", + ) + assert result.returncode != 0 + assert "missing lock dependency groups: relesae" in result.stderr + + +@pytest.mark.parametrize("local_source", [False, True], ids=["locked-caller", "local-source"]) +def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, local_source: bool) -> None: + lock = tmp_path / "pylock.toml" + lock.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' + 'dependency-groups = ["release-prepare"]\ncreated-by = "test"\npackages = []\n' + ) + source = Path(__file__).resolve().parents[2] + command = [ + "python", + str(source / "tools/check_lock_groups.py"), + str(lock), + "release-prepare", + "--shared-lock", + str(source / "pylock.toml"), + ] + if local_source: + command.append("--local-tool-source") + result = subprocess.run(command, text=True, capture_output=True, check=False) + assert (result.returncode == 0) is local_source, result.stderr + + +def test_install_locked_tools_shared_group_ignores_missing_caller_group(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + lock = (source / "pylock.toml").read_text().replace(' "release-attestations",\n', "", 1) + (tmp_path / "pylock.toml").write_text(lock) + assert "release-attestations" not in tomllib.loads(lock)["dependency-groups"] + result = install( + tmp_path, + action_scripts, + tmp_path, + groups="release-attestations", + venv=tmp_path / "tools", + lock_source="shared", + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "tools/bin/pypi-attestations").is_file() + + +def test_install_locked_tools_rejects_unknown_lock_source(tmp_path: Path, action_scripts: Path) -> None: + result = install( + Path(__file__).resolve().parents[2], + action_scripts, + tmp_path, + groups="release-notes", + venv=tmp_path / "tools", + lock_source="unknown", + ) + assert result.returncode != 0 + assert "unknown lock source: unknown" in result.stderr + + +def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + for target in ("notes", "validators"): + result = install( + source, + action_scripts, + tmp_path, + groups="release-notes", + venv=tmp_path / target, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "validators/bin/release-creator").is_file() + + +def test_install_locked_tools_uses_managed_python(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + tools = tmp_path / "bin" + tools.mkdir() + for name in ("bash", "sha256sum", "uv"): + executable = shutil.which(name) + assert executable is not None + (tools / name).symlink_to(executable) + result = install( + source, + action_scripts, + tmp_path, + groups="release-check", + venv=tmp_path / "tools", + extra_env={"PATH": str(tools)}, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "tools/bin/prek").is_file() + + +def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + result = install( + source, + action_scripts, + tmp_path, + groups="release-notes", + venv=tmp_path / "tools", + ) + assert result.returncode == 0, result.stderr + imported = subprocess.run( + [str(tmp_path / "lock-tools/bin/python"), "-c", "import yaml"], + text=True, + capture_output=True, + check=False, + ) + assert "No module named 'yaml'" in imported.stderr + + +def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) + (tmp_path / "pylock.toml").write_text( + (tmp_path / "pylock.toml").read_text() + + """ +[[packages]] +name = "probe-only" +version = "1.0" +marker = '\"build\" in dependency_groups' +index = "https://pypi.org/simple/" +[[packages.wheels]] +name = "probe_only-1.0-py3-none-any.whl" +url = "https://files.pythonhosted.org/probe_only-1.0-py3-none-any.whl" +hashes.sha256 = """ + + f'"{"0" * 64}"\n', + ) + result = install( + tmp_path, + action_scripts, + tmp_path, + groups="release-notes", + venv=tmp_path / "tools", + ) + assert result.returncode == 0, result.stderr + assert "probe-only==1.0 " in next(tmp_path.glob("build-constraints-*.txt")).read_text() + + +def test_install_locked_tools_accepts_lock_without_build_group( + tmp_path: Path, action_scripts: Path, nab_executable: Path +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "tool-only"\nversion = "0.1.0"\nrequires-python = ">=3.15"\n' + '[dependency-groups]\nrelease-check = ["prek>=0.2"]\n' + '[tool.nab]\nbase-group = "project-runtime"\ndefault-groups = []\n', + ) + generated = subprocess.run( + [str(nab_executable), "lock", "--groups", "release-check", "--output", "pylock.toml"], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + assert generated.returncode == 0, generated.stderr + assert "build" not in tomllib.loads((tmp_path / "pylock.toml").read_text())["dependency-groups"] + result = install(tmp_path, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") + assert result.returncode == 0, result.stderr + assert "setuptools==" in next(tmp_path.glob("build-constraints-*.txt")).read_text() + + +def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + result = install( + source, + action_scripts, + tmp_path, + groups="release-notes", + venv=tmp_path / "tools", + extra_env={"UV_EXCLUDE_NEWER": "1970-01-01T00:00:00Z"}, + ) + assert result.returncode == 0, result.stderr + locked_version = next( + package["version"] + for package in tomllib.loads((source / "pylock.toml").read_text())["packages"] + if package["name"] == "release-creator" + ) + installed_version = subprocess.run( + [ + str(tmp_path / "tools/bin/python"), + "-c", + "from importlib.metadata import version; print(version('release-creator'))", + ], + text=True, + capture_output=True, + check=True, + ).stdout.strip() + assert installed_version == locked_version + + +def test_shared_lock_records_upload_times() -> None: + packages = tomllib.loads((Path(__file__).resolve().parents[2] / "pylock.toml").read_text())["packages"] + missing = { + package["name"] + for package in packages + if not any("upload-time" in artifact for artifact in [package.get("sdist", {}), *package.get("wheels", [])]) + } + assert not missing, f"packages without upload times: {sorted(missing)}" + + +def test_shared_lock_respects_seven_day_cutoff() -> None: + source = Path(__file__).resolve().parents[2] + policy = tomllib.loads((source / "pyproject.toml").read_text())["tool"]["nab"] + floors = { + name: datetime.fromisoformat(settings["uploaded-prior-to"]) + for name, settings in policy.get("packages", {}).items() + if "uploaded-prior-to" in settings + } + for rule in policy.get("package-rules", []): + floors.update(dict.fromkeys(rule["match"], datetime.fromisoformat(rule["uploaded-prior-to"]))) + lock = tomllib.loads((source / "pylock.toml").read_text()) + cutoff = datetime.now(UTC) - timedelta(days=7) + packages = lock["packages"] + younger = { + package["name"] + for package in packages + if ( + times := [ + artifact["upload-time"] + for artifact in [package.get("sdist", {}), *package.get("wheels", [])] + if "upload-time" in artifact + ] + ) + and max(times) > floors.get(package["name"], cutoff) + } + assert not younger, f"packages newer than the seven-day cutoff: {sorted(younger)}" + + +def install( + repository: Path, + action_scripts: Path, + tmp_path: Path, + *, + groups: str, + venv: Path, + lock_source: str = "caller", + extra_env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + return action( + repository, + tmp_path, + action_scripts, + "install-locked-tools", + version="", + GROUPS=groups, + VENV=str(venv), + LOCK_SOURCE=lock_source, + **(extra_env or {}), + ) diff --git a/tests/label_bot_news/test_label.py b/tests/label_bot_news/test_label.py new file mode 100644 index 0000000..0976e56 --- /dev/null +++ b/tests/label_bot_news/test_label.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import pytest +import yaml +from conftest import action +from pytest_httpserver import HTTPServer + + +@pytest.mark.parametrize( + ("files", "labels", "edit_option", "fragment_dirs", "extension", "fragment_style"), + [ + pytest.param([], [], "--add-label", "changelog.d", "md", "towncrier", id="add"), + pytest.param( + [{"filename": "changelog.d/+old.feature.md", "status": "removed"}], + [], + "--add-label", + "changelog.d", + "md", + "towncrier", + id="deleted-fragment-needs-label", + ), + pytest.param([], [{"name": "skip news"}], None, "changelog.d", "md", "towncrier", id="already-labeled"), + pytest.param( + [{"filename": "changelog.d/+fix.bugfix.md"}], + [{"name": "skip news"}], + "--remove-label", + "changelog.d", + "md", + "towncrier", + id="remove", + ), + pytest.param( + [{"filename": "changelog.d/+fix.bugfix.md"}], + [], + None, + "changelog.d", + "md", + "towncrier", + id="fragment-without-label", + ), + pytest.param( + [{"filename": "changelog.d/+fix.bugfix.2.md"}], + [{"name": "skip news"}], + "--remove-label", + "changelog.d", + "md", + "towncrier", + id="named-fragment-counter", + ), + pytest.param( + [{"filename": "changelog.d/123.bugfix.md"}], + [{"name": "skip news"}], + "--remove-label", + "changelog.d", + "md", + "towncrier", + id="numeric-fragment", + ), + pytest.param( + [{"filename": "changelog.d/123.bugfix.2.md"}], + [{"name": "skip news"}], + "--remove-label", + "changelog.d", + "md", + "towncrier", + id="numeric-fragment-counter", + ), + pytest.param( + [{"filename": "changelog.d/topic.feature.md"}], + [{"name": "skip news"}], + None, + "changelog.d", + "md", + "towncrier", + id="bare-name-not-a-fragment", + ), + pytest.param( + [{"filename": "changelog.d/123a.feature.md"}], + [{"name": "skip news"}], + None, + "changelog.d", + "md", + "towncrier", + id="mixed-number-not-a-fragment", + ), + pytest.param( + [{"filename": "changelog.d/+.feature.md"}], + [{"name": "skip news"}], + None, + "changelog.d", + "md", + "towncrier", + id="empty-named-stem-not-a-fragment", + ), + pytest.param( + [{"filename": "docs/changelog.d/+fix.bugfix.md"}], + [{"name": "skip news"}], + "--remove-label", + "changelog.d,docs/changelog.d", + "md", + "towncrier", + id="legacy-fragment-path", + ), + pytest.param( + [{"filename": "docs/changelog/+fix.bugfix.rst"}], + [{"name": "skip news"}], + "--remove-label", + "docs/changelog", + "rst", + "towncrier", + id="rst-fragment-removes-label", + ), + pytest.param([], [], "--add-label", ".changeset", "md", "changeset", id="changeset-add"), + pytest.param( + [{"filename": "changelog.d/nested/+fix.bugfix.md"}], + [{"name": "skip news"}], + None, + "changelog.d", + "md", + "towncrier", + id="nested-towncrier-is-not-a-fragment", + ), + pytest.param( + [{"filename": ".changeset/nested/fix-something.md"}], + [{"name": "skip news"}], + None, + ".changeset", + "md", + "changeset", + id="nested-changeset-is-not-a-fragment", + ), + pytest.param( + [{"filename": ".changeset/fix-something.md"}], + [{"name": "skip news"}], + "--remove-label", + ".changeset", + "md", + "changeset", + id="changeset-remove", + ), + pytest.param( + [{"filename": ".changeset/fix-something.md"}], + [], + None, + ".changeset", + "md", + "changeset", + id="changeset-fragment-without-label", + ), + ], +) +def test_label_bot_news_updates_label( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + files: list[dict[str, str]], + labels: list[dict[str, str]], + edit_option: str | None, + fragment_dirs: str, + extension: str, + fragment_style: str, +) -> None: + server, environment = github_api + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_request(f"{prefix}/pulls/1/files", method="GET").respond_with_json(files) + server.expect_request(f"{prefix}/issues/1/labels", method="GET").respond_with_json(labels) + real_gh = shutil.which("gh") + assert real_gh is not None + executable = tmp_path / "gh-shim" / "gh" + executable.parent.mkdir() + executable.write_text( + "#!/usr/bin/env bash\n" + 'if [[ $1 == pr && $2 == edit ]]; then printf "%s\\0" "$@" >>"$GH_EDIT_LOG"; exit 0; fi\n' + 'exec "$GH_REAL" "$@"\n', + ) + executable.chmod(0o755) + edit_log = tmp_path / "gh-edit-args" + result = action( + repository, + tmp_path, + action_scripts, + "label-bot-news", + version="0.2.0", + REPOSITORY="tox-dev/fixture", + GITHUB_EVENT_PULL_REQUEST_NUMBER="1", + BOT_AUTHOR="true", + FRAGMENTS=fragment_dirs, + EXTENSION=extension, + FRAGMENT_STYLE=fragment_style, + RUNNER_TEMP=str(tmp_path), + PATH=f"{executable.parent}:{os.environ['PATH']}", + GH_REAL=real_gh, + GH_EDIT_LOG=str(edit_log), + **environment, + ) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert (edit_log.read_bytes().split(b"\0")[:-1] if edit_log.exists() else []) == ( + [part.encode() for part in ("pr", "edit", "1", "--repo", "tox-dev/fixture", edit_option, "skip news")] + if edit_option is not None + else [] + ) + + +def test_label_bot_news_ignores_human( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "label-bot-news/action.yml").read_text())["runs"][ + "steps" + ] + assert all( + step.get("if") == "inputs.bot-author == 'true' || github.event.pull_request.user.type == 'Bot'" + for step in steps + ) + server, environment = github_api + result = action( + repository, + tmp_path, + action_scripts, + "label-bot-news", + version="0.2.0", + BOT_AUTHOR="false", + **environment, + ) + assert result.returncode == 0 + assert not server.log + + +def test_label_bot_news_rejects_extension( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + result = action( + repository, + tmp_path, + action_scripts, + "label-bot-news", + version="0.2.0", + BOT_AUTHOR="true", + EXTENSION="txt", + **environment, + ) + assert result.returncode != 0 + assert "fragment extension must be md or rst" in result.stderr + assert not server.log + + +@pytest.mark.parametrize( + ("style", "extension", "error"), + [ + pytest.param("unknown", "md", "fragment style must be towncrier or changeset", id="unknown-style"), + pytest.param("changeset", "rst", "changeset fragments must use md", id="rst-changeset"), + ], +) +def test_label_bot_news_rejects_invalid_style( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + style: str, + extension: str, + error: str, +) -> None: + server, environment = github_api + result = action( + repository, + tmp_path, + action_scripts, + "label-bot-news", + version="0.2.0", + BOT_AUTHOR="true", + FRAGMENT_STYLE=style, + EXTENSION=extension, + **environment, + ) + assert result.returncode != 0 + assert error in result.stderr + assert not server.log diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index d80ba3b..0a94266 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -1,11 +1,12 @@ from __future__ import annotations -import os from datetime import date -lazy from pathlib import Path +from pathlib import Path +from textwrap import dedent import pytest from conftest import action, run +from pytest_httpserver import HTTPServer def test_prepare_builds_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -15,35 +16,53 @@ def test_prepare_builds_patch(repository: Path, tmp_path: Path, action_scripts: assert "0.2.0" in (tmp_path / "release.patch").read_text() -def test_prepare_custom_changelog_and_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +def test_prepare_includes_new_changelog_in_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + run(repository, "git", "rm", "CHANGELOG.md") + run(repository, "git", "commit", "-m", "Remove old changelog") + run(repository, "git", "push", "origin", "main") + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") + assert result.returncode == 0, result.stderr + assert "new file mode 100644" in (tmp_path / "release.patch").read_text() + assert "+++ b/CHANGELOG.md" in (tmp_path / "release.patch").read_text() + + +def test_prepare_build_phase_defers_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") + assert result.returncode == 0, result.stderr + assert "0.2.0" in (repository / "CHANGELOG.md").read_text() + assert not (tmp_path / "release.patch").exists() + + +def test_prepare_finalize_phase_writes_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") + assert result.returncode == 0, result.stderr + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="finalize") + assert result.returncode == 0, result.stderr + assert "0.2.0" in (tmp_path / "release.patch").read_text() + + +def test_prepare_custom_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: project = repository / "pyproject.toml" project.write_text(project.read_text().replace("CHANGELOG.md", "HISTORY.md")) (repository / "CHANGELOG.md").rename(repository / "HISTORY.md") run(repository, "git", "add", "-A") run(repository, "git", "commit", "-m", "Use a custom changelog") run(repository, "git", "push", "origin", "main") - patch = tmp_path / "custom.patch" - result = action( - repository, - tmp_path, - action_scripts, - "prepare-towncrier", - version="0.2.0", - CHANGELOG="HISTORY.md", - PATCH=str(patch), - ) + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") assert result.returncode == 0, result.stderr assert "0.2.0" in (repository / "HISTORY.md").read_text() - assert "HISTORY.md" in patch.read_text() + assert "HISTORY.md" in (tmp_path / "release.patch").read_text() -def test_prepare_custom_fragment_directory(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +def test_prepare_custom_fragment_directory( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: project = repository / "pyproject.toml" project.write_text(project.read_text().replace('directory = "changelog.d"', 'directory = "docs/changelog.d"')) (repository / "docs").mkdir() (repository / "changelog.d").rename(repository / "docs/changelog.d") run(repository, "git", "add", "-A") - run(repository, "git", "commit", "-m", "Move release fragments") + run(repository, "git", "commit", "-m", "Move release fragments (#2)") run(repository, "git", "push", "origin", "main") result = action( repository, @@ -51,17 +70,32 @@ def test_prepare_custom_fragment_directory(repository: Path, tmp_path: Path, act action_scripts, "prepare-towncrier", version="", - FRAGMENTS="docs/changelog.d", - LINK_FRAGMENTS="true", + GITHUB_REPOSITORY="tox-dev/github-workflows", + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr assert "version=0.2.0" in (tmp_path / "output").read_text() - assert "Add a new feature." in (repository / "CHANGELOG.md").read_text() - assert "docs/changelog.d/feature.feature.md" in (tmp_path / "release.patch").read_text() + expected = ( + "Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) " + "by [@gaborbernat](https://github.com/gaborbernat)." + ) + assert expected in " ".join((repository / "CHANGELOG.md").read_text().split()) + assert "docs/changelog.d/+feature.feature.md" in (tmp_path / "release.patch").read_text() -def test_prepare_renders_github_notes(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 +def test_prepare_renders_github_notes( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + result = action( + repository, + tmp_path, + action_scripts, + "prepare-towncrier", + version="0.2.0", + GITHUB_REPOSITORY="tox-dev/github-workflows", + **_author_lookup(github_api, 2), + ) + assert result.returncode == 0, result.stderr result = run( repository, "release-creator", @@ -71,9 +105,21 @@ def test_prepare_renders_github_notes(repository: Path, tmp_path: Path, action_s "--tag", "0.2.0", "--repository", - "tox-dev/fixture", + "tox-dev/github-workflows", + "--previous", + "0.1.0", + ) + assert result.stdout == dedent( + """\ + + + ### Features + + - Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) by @gaborbernat. + + **Full Changelog**: https://github.com/tox-dev/github-workflows/compare/0.1.0...0.2.0 + """, ) - assert "### Features" in result.stdout def test_prepare_rejects_dirty_checkout(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -86,13 +132,8 @@ def test_prepare_rejects_dirty_checkout(repository: Path, tmp_path: Path, action @pytest.mark.parametrize( ("option", "value", "error"), [ - pytest.param("FORMAT", "rst", "format must be markdown or none", id="format"), - pytest.param( - "LINK_FRAGMENTS", - "maybe", - "link-fragments must be true or false", - id="fragment-linking", - ), + pytest.param("CHANGELOG_PREFIX", "x", "changelog-prefix must be empty or v", id="changelog-prefix"), + pytest.param("PHASE", "unknown", "phase must be all, build, or finalize", id="phase"), ], ) def test_prepare_rejects_invalid_option( @@ -133,6 +174,22 @@ def test_prepare_selects_version( assert f"version={expected}" in (tmp_path / "output").read_text() +def test_prepare_selects_prefixed_version(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="", TAG_PREFIX="v") + assert result.returncode == 0, result.stderr + assert "version=v0.2.0" in (tmp_path / "output").read_text() + assert "## 0.2.0 (" in (repository / "CHANGELOG.md").read_text() + + +def test_prepare_preserves_prefixed_changelog_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action( + repository, tmp_path, action_scripts, "prepare-towncrier", version="", TAG_PREFIX="v", CHANGELOG_PREFIX="v" + ) + assert result.returncode == 0, result.stderr + assert "version=v0.2.0" in (tmp_path / "output").read_text() + assert "## v0.2.0 (" in (repository / "CHANGELOG.md").read_text() + + @pytest.mark.parametrize("version", [pytest.param("0.1.0", id="existing"), pytest.param("0.0.1", id="older")]) def test_prepare_rejects_reused_version(repository: Path, tmp_path: Path, action_scripts: Path, version: str) -> None: result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version=version) @@ -141,23 +198,81 @@ def test_prepare_rejects_reused_version(repository: Path, tmp_path: Path, action assert not (tmp_path / "release.patch").exists() -def test_prepare_links_named_fragments(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +def test_prepare_links_named_fragments( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + result = action( + repository, + tmp_path, + action_scripts, + "prepare-towncrier", + version="0.2.0", + GITHUB_REPOSITORY="tox-dev/github-workflows", + **_author_lookup(github_api, 2), + ) + assert result.returncode == 0, result.stderr + expected = ( + "Add a new feature ([#2](https://github.com/tox-dev/github-workflows/pull/2)) " + "by [@gaborbernat](https://github.com/gaborbernat)." + ) + assert expected in " ".join((repository / "CHANGELOG.md").read_text().split()) + + +def test_prepare_links_numeric_fragment_to_merged_pull_request( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + run(repository, "git", "mv", "changelog.d/+feature.feature.md", "changelog.d/2.feature.md") + run(repository, "git", "commit", "-m", "Use an original PR fragment") + run(repository, "git", "push", "origin", "main") + result = action( + repository, + tmp_path, + action_scripts, + "prepare-towncrier", + version="0.2.0", + GITHUB_REPOSITORY="tox-dev/github-workflows", + **_author_lookup(github_api, 2), + ) + assert result.returncode == 0, result.stderr + assert "[#2](https://github.com/tox-dev/github-workflows/pull/2)" in (repository / "CHANGELOG.md").read_text() + + +def test_prepare_resolves_numeric_fragment_from_commit( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + run(repository, "git", "mv", "changelog.d/+feature.feature.md", "changelog.d/2.feature.md") + run(repository, "git", "commit", "-m", "Backfill an earlier fragment (#3)") + run(repository, "git", "push", "origin", "main") result = action( repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", - LINK_FRAGMENTS="true", + GITHUB_REPOSITORY="tox-dev/github-workflows", + **_author_lookup(github_api, 3), ) assert result.returncode == 0, result.stderr - assert "Add a new feature." in (repository / "CHANGELOG.md").read_text() + assert "[#3](https://github.com/tox-dev/github-workflows/pull/3)" in (repository / "CHANGELOG.md").read_text() + + +@pytest.mark.parametrize("version", [pytest.param("", id="automatic"), pytest.param("0.2.0", id="explicit")]) +def test_prepare_rejects_unlinked_fragment_name( + repository: Path, tmp_path: Path, action_scripts: Path, version: str +) -> None: + run(repository, "git", "mv", "changelog.d/+feature.feature.md", "changelog.d/feature.feature.md") + run(repository, "git", "commit", "-m", "Use a bare fragment name") + run(repository, "git", "push", "origin", "main") + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version=version) + assert result.returncode != 0 + assert "unsupported changelog fragment name" in result.stderr + assert not (tmp_path / "release.patch").exists() def test_prepare_rejects_untracked_output(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / ".pre-commit-config.yaml").write_text( - "repos:\n - repo: local\n hooks:\n - id: release-check\n" - " name: release-check\n entry: bash -c 'touch extra-output'\n" + "repos:\n - repo: local\n hooks:\n - id: mdformat\n" + " name: mdformat\n entry: bash -c 'touch extra-output'\n" " language: system\n pass_filenames: false\n", ) run(repository, "git", "add", ".") @@ -169,47 +284,92 @@ def test_prepare_rejects_untracked_output(repository: Path, tmp_path: Path, acti def test_prepare_rejects_empty_release(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "changelog.d/feature.feature.md").unlink() + (repository / "changelog.d/+feature.feature.md").unlink() run(repository, "git", "add", "-u") run(repository, "git", "commit", "-m", "Remove unused fragment") run(repository, "git", "push", "origin", "main") result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") assert result.returncode != 0 - assert "has no release notes" in result.stderr + assert "no changelog fragments to release" in result.stderr def test_prepare_rejects_empty_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / "CHANGELOG.md").write_text( - "# Changelog\n\n\n\n" - '\n\n## 0.2.0 (2026-10-19)\n\n### Added\n\n- Add a feature.\n', + built = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") + assert built.returncode == 0, built.stderr + run(repository, "git", "add", "-A") + run(repository, "git", "commit", "-m", "Store rendered notes") + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="finalize") + assert result.returncode != 0 + assert "produced no patch" in result.stderr + + +def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + (repository / "changelog.d/+feature.feature.md").write_text("Add " + "a long sentence " * 15 + ".\n") + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Add long changelog entry") + run(repository, "git", "push", "origin", "main") + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") + assert result.returncode == 0, result.stderr + changelog_lines = (repository / "CHANGELOG.md").read_text().splitlines() + assert max(map(len, changelog_lines)) <= 120 # ruff: ignore[magic-value-comparison] # Changelog width. + + +def test_prepare_formats_rst( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + project = repository / "pyproject.toml" + project.write_text( + project.read_text().replace("CHANGELOG.md", "CHANGELOG.rst").replace("template.jinja2", "template.rst.jinja2") + ) + (repository / "CHANGELOG.md").rename(repository / "CHANGELOG.rst") + (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n\n.. towncrier release notes start\n") + (repository / "changelog.d/+feature.feature.md").rename(repository / "changelog.d/+feature.feature.rst") + (repository / "changelog.d/+feature.feature.rst").write_text("Add a Before feature.\n") + (repository / ".pre-commit-config.yaml").write_text( + "repos:\n - repo: local\n hooks:\n - id: docstrfmt\n" + " name: docstrfmt\n entry: sed -i s/Before/After/\n" + " language: system\n files: CHANGELOG.rst\n" ) - run(repository, "git", "add", "CHANGELOG.md") - run(repository, "git", "commit", "-m", "Record existing notes") + run(repository, "git", "add", "-A") + run(repository, "git", "commit", "-m", "Set RST hook") run(repository, "git", "push", "origin", "main") - tools = tmp_path / "bin" - tools.mkdir() - (tools / "towncrier").symlink_to("/usr/bin/true") result = action( repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", - PATH=f"{tools}:{os.environ['PATH']}", + **_author_lookup(github_api, 2), ) - assert result.returncode != 0 - assert "produced no patch" in result.stderr + assert result.returncode == 0, result.stderr + assert "Add a After feature (:pr:`2`) by :user:`gaborbernat`." in (repository / "CHANGELOG.rst").read_text() -def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +def test_prepare_avoids_rst_conflict_marker( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + project = repository / "pyproject.toml" + project.write_text( + project.read_text() + .replace("CHANGELOG.md", "CHANGELOG.rst") + .replace("template.jinja2", "template.rst.jinja2") + .replace('name = "Added"', 'name = "Changes"') + .replace("title_format = false", 'underlines = ["*", "=", "-"]\ntitle_format = false') + ) + (repository / "CHANGELOG.md").rename(repository / "CHANGELOG.rst") + (repository / "CHANGELOG.rst").write_text( + "Changelog\n=========\n\n.. towncrier release notes start\n\n" + "0.1.0 (2026-01-01)\n******************\n\nChanges\n=======\n\n" + "- ``>>>>>>> historical branch`` remains quoted.\n" + ) + (repository / "changelog.d/+feature.feature.md").rename(repository / "changelog.d/+feature.feature.rst") (repository / ".pre-commit-config.yaml").write_text( - "repos:\n - repo: local\n hooks:\n - id: mdformat\n" - " name: mdformat\n entry: mdformat --wrap 120\n" - " language: system\n types: [markdown]\n", + "repos:\n - repo: local\n hooks:\n - id: docstrfmt\n" + " name: docstrfmt\n entry: bash -c true\n" + " language: system\n files: CHANGELOG.rst\n" ) - (repository / "changelog.d/feature.feature.md").write_text("Add " + "a long sentence " * 15 + ".\n") - run(repository, "git", "add", ".") - run(repository, "git", "commit", "-m", "Set markdown hook") + run(repository, "git", "add", "-A") + run(repository, "git", "commit", "-m", "Set RST category") run(repository, "git", "push", "origin", "main") result = action( repository, @@ -217,8 +377,22 @@ def test_prepare_formats_markdown(repository: Path, tmp_path: Path, action_scrip action_scripts, "prepare-towncrier", version="0.2.0", - FORMAT="markdown", + **_author_lookup(github_api, 2), ) assert result.returncode == 0, result.stderr - changelog_lines = (repository / "CHANGELOG.md").read_text().splitlines() - assert max(map(len, changelog_lines)) <= 120 # ruff: ignore[magic-value-comparison] # Changelog width. + run(repository, "git", "reset", "--hard", "HEAD") + result = action( + repository, + tmp_path, + action_scripts, + "validate-release-patch", + version="0.2.0", + ) + assert result.returncode == 0, result.stderr + assert "Changes\n=======" in (repository / "CHANGELOG.rst").read_text() + + +def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) -> dict[str, str]: + _, environment = github_api + environment["TEST_PULL_NUMBER"] = str(number) + return environment diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py new file mode 100644 index 0000000..1822fa4 --- /dev/null +++ b/tests/publish_github_release/test_publish.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from hashlib import sha256 +from pathlib import Path + +import pytest +from conftest import action, run +from pytest_httpserver import HTTPServer + + +@pytest.fixture +def release_cli(tmp_path: Path) -> dict[str, str]: + real_gh = shutil.which("gh") + assert real_gh is not None + executable = tmp_path / "bin" / "gh" + executable.parent.mkdir() + executable.write_text( + "#!/usr/bin/env bash\n" + "if [[ $1 == release ]]; then\n" + ' printf "%s\\0" "$@" >>"$GH_RELEASE_LOG"\n' + ' printf "\\n" >>"$GH_RELEASE_LOG"\n' + " if [[ $2 == upload && ${GH_UPLOAD_FAILURE:-false} == true ]]; then exit 1; fi\n" + " if [[ $2 == upload && ${GH_UPLOAD_FAILURE:-false} == once && ! -f $GH_FAILURE_MARKER ]]; then\n" + ' touch "$GH_FAILURE_MARKER"; exit 1\n' + " fi\n" + " if [[ $2 == verify-asset && ${GH_VERIFY_FAILURE:-false} == true ]]; then exit 1; fi\n" + " exit 0\n" + "fi\n" + 'exec "$GH_REAL" "$@"\n', + ) + executable.chmod(0o755) + (executable.parent / "sleep").write_text("#!/usr/bin/env bash\nexit 0\n") + (executable.parent / "sleep").chmod(0o755) + return { + "PATH": f"{executable.parent}:{os.environ['PATH']}", + "GH_REAL": real_gh, + "GH_RELEASE_LOG": str(tmp_path / "release-cli-arguments"), + "GH_FAILURE_MARKER": str(tmp_path / "release-cli-failed-once"), + } + + +def _release_calls(tmp_path: Path) -> list[tuple[str, ...]]: + path = tmp_path / "release-cli-arguments" + return ( + [tuple(part.decode() for part in line.split(b"\0")[:-1]) for line in path.read_bytes().splitlines()] + if path.exists() + else [] + ) + + +def _notes_and_commit(repository: Path, tmp_path: Path) -> tuple[Path, str]: + notes = tmp_path / "notes.md" + notes.write_text("Release notes.\n") + return notes, run(repository, "git", "rev-parse", "0.1.0").stdout.strip() + + +def _release( + notes: Path, *, draft: bool, assets: list[dict[str, str]] | None = None +) -> dict[str, str | int | bool | list[dict[str, str]]]: + return { + "id": 42, + "tag_name": "0.1.0", + "name": "0.1.0", + "body": notes.read_text(), + "draft": draft, + "prerelease": False, + "assets": assets or [], + } + + +def _request( + repository: Path, + tmp_path: Path, + action_scripts: Path, + notes: Path, + commit: str, + environment: dict[str, str], + *, + assets: Path | None = None, + mode: str = "complete", +) -> subprocess.CompletedProcess[str]: + return action( + repository, + tmp_path, + action_scripts, + "publish-github-release", + version="0.1.0", + TAG="0.1.0", + COMMIT=commit, + TITLE="0.1.0", + NOTES_FILE=str(notes), + ASSETS_DIRECTORY=str(assets) if assets is not None else "", + PRERELEASE="false", + MODE=mode, + **environment, + ) + + +@pytest.mark.parametrize("with_asset", [pytest.param(False, id="notes-only"), pytest.param(True, id="with-asset")]) +def test_existing_published_release_is_verified_without_writes( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], + with_asset: bool, +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + if with_asset: + assets.mkdir() + (assets / "fixture.zip").write_bytes(b"release archive") + release = _release( + notes, + draft=False, + assets=( + [{"name": "fixture.zip", "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"}] + if with_asset + else [] + ), + ) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [release] + ) + for _ in range(2 + int(with_asset)): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) + result = _request( + repository, + tmp_path, + action_scripts, + notes, + commit, + environment | release_cli, + assets=assets if with_asset else None, + ) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET"] * (4 + int(with_asset)) + assert _release_calls(tmp_path) == ( + [("release", "verify-asset", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] + if with_asset + else [] + ) + + +@pytest.mark.parametrize( + "changed_field", + [ + pytest.param("name", id="title"), + pytest.param("body", id="notes"), + pytest.param("prerelease", id="prerelease"), + pytest.param("assets", id="assets"), + ], +) +def test_existing_release_rejects_changed_content( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], + changed_field: str, +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + release = _release(notes, draft=False) + release[changed_field] = { + "name": "Wrong title", + "body": "Wrong notes.\n", + "prerelease": True, + "assets": [{"name": "extra.zip", "digest": "sha256:deadbeef", "state": "uploaded"}], + }[changed_field] + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [release] + ) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) + assert result.returncode != 0 + assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert not _release_calls(tmp_path) + + +def test_tag_endpoint_must_return_expected_commit( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json( + {"sha": "0" * 40} + ) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) + assert result.returncode != 0 + assert [request.method for request, _ in server.log] == ["GET"] + + +def test_missing_tag_rejects_same_name_branch( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + run(repository, "git", "branch", "0.1.0", commit) + server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json( + {"message": "Not Found"}, status=404 + ) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) + assert result.returncode != 0 + assert [request.method for request, _ in server.log] == ["GET"] + + +@pytest.mark.parametrize( + "asset_name", + [ + pytest.param(".hidden.zip", id="hidden"), + pytest.param("unsafe#label.zip", id="label-delimiter"), + pytest.param("unsafe\nname.zip", id="newline"), + ], +) +def test_unsafe_asset_names_are_rejected( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], + asset_name: str, +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + (assets / asset_name).write_bytes(b"release archive") + server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET").respond_with_json( + {"sha": commit} + ) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets) + assert result.returncode != 0 + assert "unsafe release asset name" in result.stdout + assert [request.method for request, _ in server.log] == ["GET"] + + +def test_publish_requires_an_existing_draft( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="publish") + assert result.returncode != 0 + assert [request.method for request, _ in server.log] == ["GET", "GET"] + + +@pytest.mark.parametrize("mode", [pytest.param("stage", id="draft"), pytest.param("complete", id="published")]) +def test_missing_release_is_created_then_optionally_published( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], + mode: str, +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + draft = _release(notes, draft=True) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) + server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + if mode == "complete": + server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( + draft | {"draft": False} + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( + draft | {"draft": mode == "stage"} + ) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode=mode) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == [ + "GET", + "GET", + "POST", + "GET", + *(["PATCH"] if mode == "complete" else []), + "GET", + ] + assert not _release_calls(tmp_path) + request = next(request for request, _ in server.log if request.method == "POST") + assert request.get_json() == { + "tag_name": "0.1.0", + "target_commitish": commit, + "name": "0.1.0", + "body": notes.read_text(), + "draft": True, + "prerelease": False, + } + + +@pytest.mark.parametrize("mode", [pytest.param("stage", id="draft"), pytest.param("complete", id="published")]) +@pytest.mark.parametrize( + "asset_name", + [ + pytest.param("fixture.zip", id="plain"), + pytest.param("fixture+linux.zip", id="plus"), + pytest.param("Fixture signed.zip", id="space"), + ], +) +def test_draft_release_uploads_missing_asset( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], + mode: str, + asset_name: str, +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + asset_path = assets / asset_name + asset_path.write_bytes(b"release archive") + asset = {"name": asset_name, "digest": f"sha256:{sha256(b'release archive').hexdigest()}", "state": "uploaded"} + draft = _release(notes, draft=True) + complete = _release(notes, draft=True, assets=[asset]) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [draft] + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) + if mode == "complete": + server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( + complete | {"draft": False} + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json( + complete | {"draft": mode == "stage"} + ) + result = _request( + repository, + tmp_path, + action_scripts, + notes, + commit, + environment | release_cli, + assets=assets, + mode=mode, + ) + assert result.returncode == 0, result.stderr + assert _release_calls(tmp_path) == [ + ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), + *( + [("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] + if mode == "complete" + else [] + ), + ] + assert [request.method for request, _ in server.log] == [ + "GET", + "GET", + "GET", + "GET", + *(["PATCH"] if mode == "complete" else []), + "GET", + ] + + +def test_draft_release_replaces_incomplete_asset( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + asset_path = assets / "fixture.zip" + asset_path.write_bytes(b"release archive") + incomplete = _release( + notes, + draft=True, + assets=[{"id": "21", "name": asset_path.name, "digest": "", "state": "starter"}], + ) + uploaded = _release( + notes, + draft=True, + assets=[ + { + "name": asset_path.name, + "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}", + "state": "uploaded", + } + ], + ) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [incomplete] + ) + server.expect_ordered_request(f"{prefix}/releases/assets/21", method="DELETE").respond_with_json({}) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(incomplete | {"assets": []}) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(uploaded) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(uploaded) + result = _request( + repository, + tmp_path, + action_scripts, + notes, + commit, + environment | release_cli, + assets=assets, + mode="stage", + ) + assert result.returncode == 0, result.stderr + assert ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture") in _release_calls(tmp_path) + assert [request.method for request, _ in server.log] == ["GET", "GET", "DELETE", "GET", "GET", "GET"] + + +def test_failed_asset_upload_leaves_draft_unpublished( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + (assets / "fixture.zip").write_bytes(b"release archive") + draft = _release(notes, draft=True) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [draft] + ) + for _ in range(7): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + result = _request( + repository, + tmp_path, + action_scripts, + notes, + commit, + environment | release_cli | {"GH_UPLOAD_FAILURE": "true"}, + assets=assets, + ) + assert result.returncode != 0 + assert [request.method for request, _ in server.log] == ["GET"] * 9 + assert ( + _release_calls(tmp_path) + == [("release", "upload", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] * 6 + ) + + +def test_upload_failure_recovers_when_asset_is_present( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + asset_path = assets / "fixture.zip" + asset_path.write_bytes(b"release archive") + asset = { + "name": asset_path.name, + "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}", + "state": "uploaded", + } + draft = _release(notes, draft=True) + complete = _release(notes, draft=True, assets=[asset]) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [draft] + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) + server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json( + complete | {"draft": False} + ) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete | {"draft": False}) + result = _request( + repository, + tmp_path, + action_scripts, + notes, + commit, + environment | release_cli | {"GH_UPLOAD_FAILURE": "once"}, + assets=assets, + ) + assert result.returncode == 0, result.stderr + assert _release_calls(tmp_path) == [ + ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), + ("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), + ] + + +def test_failed_asset_attestation_is_reported( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + assets = tmp_path / "assets" + assets.mkdir() + asset_path = assets / "fixture.zip" + asset_path.write_bytes(b"release archive") + release = _release( + notes, + draft=False, + assets=[ + { + "name": asset_path.name, + "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}", + "state": "uploaded", + } + ], + ) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [release] + ) + for _ in range(3): + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) + result = _request( + repository, + tmp_path, + action_scripts, + notes, + commit, + environment | release_cli | {"GH_VERIFY_FAILURE": "true"}, + assets=assets, + ) + assert result.returncode != 0 + assert "release asset attestation could not be verified" in result.stderr + assert ( + _release_calls(tmp_path) + == [("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] * 6 + ) + + +def test_duplicate_releases_are_rejected( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit}) + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( + [_release(notes, draft=True), _release(notes, draft=True)] + ) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) + assert result.returncode != 0 + assert [request.method for request, _ in server.log] == ["GET", "GET"] + + +def test_invalid_mode_rejects_before_api_access( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="unknown") + assert result.returncode != 0 + assert "invalid release mode" in result.stdout + assert not server.log diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py new file mode 100644 index 0000000..e7a443a --- /dev/null +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import shutil +from hashlib import sha256 +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +import pytest +from conftest import action +from pytest_httpserver import HTTPServer + + +@pytest.mark.parametrize( + "changed", + [ + pytest.param("digest", id="digest"), + pytest.param("plugin-id", id="plugin-id"), + pytest.param("channel", id="channel"), + pytest.param("extension", id="extension"), + pytest.param("corrupt", id="corrupt"), + pytest.param("duplicate", id="duplicate"), + pytest.param("symlink", id="symlink"), + ], +) +def test_invalid_archive_or_input_fails_before_upload( + signed_archive: Path, tmp_path: Path, marketplace_scripts: Path, httpserver: HTTPServer, changed: str +) -> None: + settings = { + "ARCHIVE": str(signed_archive), + "SHA256": sha256(signed_archive.read_bytes()).hexdigest(), + "PLUGIN_ID": "29973", + "CHANNEL": "beta", + } + if changed == "digest": + settings["SHA256"] = "0" * 64 + elif changed == "plugin-id": + settings["PLUGIN_ID"] = "fish" + elif changed == "channel": + settings["CHANNEL"] = "beta release" + elif changed == "extension": + wrong_extension = tmp_path / "plugin.tar" + wrong_extension.write_bytes(signed_archive.read_bytes()) + settings["ARCHIVE"] = str(wrong_extension) + elif changed == "corrupt": + signed_archive.write_bytes(b"not a zip") + settings["SHA256"] = sha256(signed_archive.read_bytes()).hexdigest() + elif changed == "duplicate": + with ZipFile(signed_archive, "a") as content: + content.writestr("Fish Shell/lib/plugin.jar", b"duplicate") + settings["SHA256"] = sha256(signed_archive.read_bytes()).hexdigest() + else: + link = tmp_path / "linked.zip" + link.symlink_to(signed_archive) + settings["ARCHIVE"] = str(link) + result = action( + tmp_path, + tmp_path, + marketplace_scripts, + "publish-jetbrains-marketplace", + version="", + PUBLISH_TOKEN="fixture-token", + **settings, + ) + assert result.returncode != 0 + assert not httpserver.log + + +@pytest.mark.parametrize("status", [pytest.param(200, id="accepted"), pytest.param(403, id="denied")]) +@pytest.mark.parametrize( + "archive_name", + ["Fish Shell 2-0.1.11-signed.zip", "Fish Shell, 2; 0.1.11-signed.zip"], + ids=["plain-path", "multipart-delimiters"], +) +@pytest.mark.parametrize("temporary_name", ["runner-temp", "runner,; temp"], ids=["plain-temp", "multipart-temp"]) +def test_multipart_upload_uses_exact_archive_and_channel( + signed_archive: Path, + tmp_path: Path, + marketplace_scripts: Path, + httpserver: HTTPServer, + status: int, + archive_name: str, + temporary_name: str, +) -> None: + signed_archive = signed_archive.rename(tmp_path / archive_name) + runner_temp = tmp_path / temporary_name + runner_temp.mkdir() + httpserver.expect_request("/api/updates/upload", method="POST").respond_with_data( + "upload denied" if status == 403 else "{}", status=status + ) + result = action( + tmp_path, + runner_temp, + marketplace_scripts, + "publish-jetbrains-marketplace", + version="", + ARCHIVE=str(signed_archive), + SHA256=sha256(signed_archive.read_bytes()).hexdigest(), + PLUGIN_ID="29973", + CHANNEL="beta", + PUBLISH_TOKEN="fixture-token", + RUNNER_TEMP=str(runner_temp), + ) + assert (result.returncode == 0) is (status == 200) + if status == 403: + assert "upload denied" in result.stderr + request = httpserver.log[0][0] + assert request.headers["Authorization"] == "Bearer fixture-token" + assert request.form["pluginId"] == "29973" + assert request.form["channel"] == "beta" + assert request.files["file"].filename == "plugin.zip" + assert request.files["file"].read() == signed_archive.read_bytes() + + +def test_missing_token_fails_without_request( + signed_archive: Path, tmp_path: Path, marketplace_scripts: Path, httpserver: HTTPServer +) -> None: + result = action( + tmp_path, + tmp_path, + marketplace_scripts, + "publish-jetbrains-marketplace", + version="", + ARCHIVE=str(signed_archive), + SHA256=sha256(signed_archive.read_bytes()).hexdigest(), + PLUGIN_ID="29973", + CHANNEL="", + PUBLISH_TOKEN="", + RUNNER_TEMP=str(tmp_path), + ) + assert result.returncode != 0 + assert not httpserver.log + + +@pytest.fixture +def signed_archive(tmp_path: Path) -> Path: + archive = tmp_path / "Fish Shell 2-0.1.11-signed.zip" + with ZipFile(archive, "w", ZIP_DEFLATED) as content: + content.writestr("Fish Shell/lib/plugin.jar", b"signed plugin") + return archive + + +@pytest.fixture +def marketplace_scripts(action_scripts: Path, tmp_path: Path, httpserver: HTTPServer) -> Path: + scripts = tmp_path / "scripts" + shutil.copytree(action_scripts, scripts) + upload = scripts / "publish-jetbrains-marketplace-1.sh" + upload.write_text( + upload.read_text().replace( + "https://plugins.jetbrains.com/api/updates/upload", httpserver.url_for("/api/updates/upload") + ) + ) + return scripts diff --git a/tests/publish_post_release_pr/test_post_release_pr.py b/tests/publish_post_release_pr/test_post_release_pr.py new file mode 100644 index 0000000..8ddaf19 --- /dev/null +++ b/tests/publish_post_release_pr/test_post_release_pr.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest +import yaml +from conftest import action +from pytest_httpserver import HTTPServer + + +def test_action_maps_writer_app_slug() -> None: + action = yaml.safe_load((Path(__file__).resolve().parents[2] / "publish-post-release-pr/action.yml").read_text()) + assert action["inputs"]["app-slug"]["required"] + assert action["inputs"]["allowed-paths"]["required"] + assert action["runs"]["steps"][0]["env"]["APP_SLUG"] == "${{ inputs.app-slug }}" + assert action["runs"]["steps"][1]["env"]["ALLOWED_PATHS"] == "${{ inputs.allowed-paths }}" + assert "outputs" not in action + assert {"repository", "base", "label"}.isdisjoint(action["inputs"]) + + +def run_publish(action_scripts: Path, *, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + settings = { + name: value + for name, value in env.items() + if name not in {"BASE", "LABEL"} and (name not in os.environ or os.environ[name] != value) + } + return action( + cwd, + Path(env["RUNNER_TEMP"]), + action_scripts, + "publish-post-release-pr", + version="", + **settings, + ) + + +def expect_bot(server: HTTPServer) -> None: + server.expect_request("/api/v3/users/fixture-app[bot]", method="GET").respond_with_json({"id": 123}) + + +def publish( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + *, + main_blob: str, + expected_blob: str, + additional_blob: str | None = None, +) -> tuple[subprocess.CompletedProcess[str], HTTPServer]: + server, environment = github_api + expect_bot(server) + body = tmp_path / "body.md" + body.write_text("Set the next development version.\n") + paths = tmp_path / "paths.tsv" + paths.write_text(f"gradle.properties\t{expected_blob}\n") + if additional_blob is not None: + with paths.open("a") as stream: + stream.write(f"other.txt\t{additional_blob}\n") + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + [ + { + "number": 7, + "state": "closed", + "merged_at": "2026-10-20T00:00:00Z", + "title": "Bump version to 2.4.8-dev", + "body": body.read_text(), + "base": {"ref": "main"}, + "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7"}, + } + ] + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/heads/main", method="GET").respond_with_json( + {"object": {"sha": "a" * 40}} + ) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/contents/gradle.properties", query_string="ref=main", method="GET" + ).respond_with_json({"type": "file", "sha": main_blob}) + if additional_blob is not None and main_blob == expected_blob: + server.expect_request( + "/api/v3/repos/tox-dev/fixture/contents/other.txt", query_string="ref=main", method="GET" + ).respond_with_json({"type": "file", "sha": additional_blob}) + if main_blob == expected_blob: + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( + [{"filename": "gradle.properties"}, *([{"filename": "other.txt"}] if additional_blob is not None else [])] + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/issues/7/labels", method="GET").respond_with_json( + [{"name": "skip news"}] + ) + result = run_publish( + action_scripts, + cwd=tmp_path, + env=os.environ + | environment + | { + "GITHUB_REPOSITORY": "tox-dev/fixture", + "APP_SLUG": "fixture-app", + "BASE": "main", + "BASE_COMMIT": "a" * 40, + "BRANCH": "post-release-v2.4.7", + "TITLE": "Bump version to 2.4.8-dev", + "COMMIT_MESSAGE": "Bump version", + "BODY_FILE": str(body), + "PATHS_FILE": str(paths), + "ALLOWED_PATHS": "gradle.properties\nother.txt\n" if additional_blob is not None else "gradle.properties", + "BUNDLE": "", + "CANDIDATE_COMMIT": "", + "LABEL": "skip news", + "RUNNER_TEMP": str(tmp_path), + }, + ) + return result, server + + +def test_merged_branch_deleted_and_main_matches_is_complete( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + result, server = publish(tmp_path, action_scripts, github_api, main_blob="b" * 40, expected_blob="b" * 40) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET"] * 6 + + +def test_multiline_allowed_paths_accepts_trailing_newline( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + result, server = publish( + tmp_path, action_scripts, github_api, main_blob="b" * 40, expected_blob="b" * 40, additional_blob="c" * 40 + ) + assert result.returncode == 0, result.stderr + assert [request.method for request, _ in server.log] == ["GET"] * 7 + + +def test_merged_branch_deleted_and_later_main_change_fails( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + result, server = publish(tmp_path, action_scripts, github_api, main_blob="c" * 40, expected_blob="b" * 40) + assert result.returncode != 0 + assert [request.method for request, _ in server.log] == ["GET"] * 4 + + +def test_open_pull_request_metadata_change_fails( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + expect_bot(server) + body = tmp_path / "body.md" + body.write_text("Set the next development version.\n") + paths = tmp_path / "paths.tsv" + paths.write_text(f"gradle.properties\t{'b' * 40}\n") + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + [ + { + "number": 7, + "state": "open", + "merged_at": None, + "title": "Different title", + "body": body.read_text(), + "base": {"ref": "main"}, + "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7"}, + } + ] + ) + result = run_publish( + action_scripts, + cwd=tmp_path, + env=os.environ + | environment + | { + "GITHUB_REPOSITORY": "tox-dev/fixture", + "APP_SLUG": "fixture-app", + "BASE": "main", + "BASE_COMMIT": "a" * 40, + "BRANCH": "post-release-v2.4.7", + "TITLE": "Bump version to 2.4.8-dev", + "COMMIT_MESSAGE": "Bump version", + "BODY_FILE": str(body), + "PATHS_FILE": str(paths), + "ALLOWED_PATHS": "gradle.properties", + "BUNDLE": "", + "CANDIDATE_COMMIT": "", + "RUNNER_TEMP": str(tmp_path), + }, + ) + assert result.returncode != 0 + assert "open pull request metadata changed" in result.stderr + + +def test_closed_unmerged_pr_blocks_retry_before_write( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + expect_bot(server) + bundle, base, commit, blob = candidate(tmp_path) + body = tmp_path / "body.md" + body.write_text("Set the next development version.\n") + paths = tmp_path / "paths.tsv" + paths.write_text(f"gradle.properties\t{blob}\n") + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + [ + { + "number": 7, + "state": "closed", + "merged_at": None, + "title": "Bump version to 2.4.8-dev", + "body": body.read_text(), + "base": {"ref": "main"}, + "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7"}, + } + ] + ) + result = run_publish( + action_scripts, + cwd=tmp_path, + env=os.environ + | environment + | { + "GITHUB_REPOSITORY": "tox-dev/fixture", + "APP_SLUG": "fixture-app", + "BASE": "main", + "BASE_COMMIT": base, + "BRANCH": "post-release-v2.4.7", + "TITLE": "Bump version to 2.4.8-dev", + "COMMIT_MESSAGE": "Bump version", + "BODY_FILE": str(body), + "PATHS_FILE": str(paths), + "ALLOWED_PATHS": "gradle.properties", + "BUNDLE": str(bundle.relative_to(tmp_path)), + "CANDIDATE_COMMIT": commit, + "LABEL": "skip news", + "RUNNER_TEMP": str(tmp_path), + }, + ) + assert result.returncode != 0 + assert "post-release pull request was closed without merging; reopen it to retry" in result.stderr + assert [request.method for request, _ in server.log] == ["GET", "GET"] + + +def run_git(directory: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(directory), *args], capture_output=True, text=True, check=True + ).stdout.strip() + + +def candidate( + tmp_path: Path, + *, + extra_path: bool = False, + wrong_author: bool = False, + wrong_committer: bool = False, + wrong_message: bool = False, +) -> tuple[Path, str, str, str]: + repository = tmp_path / "source" + repository.mkdir() + run_git(repository, "init", "-q", "-b", "main") + run_git(repository, "config", "user.name", "fixture-app[bot]") + run_git(repository, "config", "user.email", "123+fixture-app[bot]@users.noreply.github.com") + (repository / "gradle.properties").write_text("pluginVersion=2.4.7\n") + run_git(repository, "add", "gradle.properties") + run_git(repository, "commit", "-qm", "Base") + base = run_git(repository, "rev-parse", "HEAD") + run_git(repository, "checkout", "-qb", "release-candidate") + (repository / "gradle.properties").write_text("pluginVersion=2.4.8-dev\n") + run_git(repository, "add", "gradle.properties") + if extra_path: + (repository / "other.txt").write_text("unexpected\n") + run_git(repository, "add", "other.txt") + if wrong_author: + run_git(repository, "config", "user.name", "Different author") + subprocess.run( + ["git", "-C", str(repository), "commit", "-qm", "Different message" if wrong_message else "Bump version"], + env=os.environ | ({"GIT_COMMITTER_NAME": "Different committer"} if wrong_committer else {}), + check=True, + ) + commit = run_git(repository, "rev-parse", "HEAD") + blob = run_git(repository, "rev-parse", "HEAD:gradle.properties") + bundle = tmp_path / "candidate.bundle" + run_git(repository, "bundle", "create", str(bundle), "refs/heads/release-candidate") + return bundle, base, commit, blob + + +@pytest.mark.parametrize( + "branch_tamper", + [ + pytest.param("none", id="exact-branch"), + pytest.param("tree", id="divergent-tree"), + pytest.param("author", id="divergent-author"), + pytest.param("committer", id="divergent-committer"), + pytest.param("message", id="divergent-message"), + pytest.param("base", id="moved-base"), + pytest.param("parent", id="merge-commit"), + ], +) +def test_open_branch_must_match_prepared_candidate( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + branch_tamper: str, +) -> None: + server, environment = github_api + expect_bot(server) + bundle, base, commit, blob = candidate(tmp_path) + body = tmp_path / "body.md" + body.write_text("Set the next development version.\n") + paths = tmp_path / "paths.tsv" + paths.write_text(f"gradle.properties\t{blob}\n") + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + [ + { + "number": 7, + "state": "open", + "merged_at": None, + "title": "Bump version to 2.4.8-dev", + "body": body.read_text(), + "base": {"ref": "main"}, + "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "post-release-v2.4.7", "sha": commit}, + } + ] + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/git/ref/heads/main", method="GET").respond_with_json( + {"object": {"sha": "c" * 40 if branch_tamper == "base" else base}} + ) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/git/ref/heads/post-release-v2.4.7", method="GET" + ).respond_with_json({"object": {"sha": commit}}) + server.expect_request(f"/api/v3/repos/tox-dev/fixture/git/commits/{commit}", method="GET").respond_with_json( + { + "parents": [{"sha": base}] + ([{"sha": "d" * 40}] if branch_tamper == "parent" else []), + "author": { + "name": "Other" if branch_tamper == "author" else "fixture-app[bot]", + "email": "123+fixture-app[bot]@users.noreply.github.com", + }, + "committer": { + "name": "Other" if branch_tamper == "committer" else "fixture-app[bot]", + "email": "123+fixture-app[bot]@users.noreply.github.com", + }, + "message": "Other" if branch_tamper == "message" else "Bump version", + "tree": { + "sha": "b" * 40 if branch_tamper == "tree" else run_git(tmp_path / "source", "rev-parse", "HEAD^{tree}") + }, + } + ) + if branch_tamper == "none": + server.expect_request("/api/v3/repos/tox-dev/fixture/issues/7/labels", method="GET").respond_with_json( + [{"name": "skip news"}] + ) + result = run_publish( + action_scripts, + cwd=tmp_path, + env=os.environ + | environment + | { + "GITHUB_REPOSITORY": "tox-dev/fixture", + "APP_SLUG": "fixture-app", + "BASE": "main", + "BASE_COMMIT": base, + "BRANCH": "post-release-v2.4.7", + "TITLE": "Bump version to 2.4.8-dev", + "COMMIT_MESSAGE": "Bump version", + "BODY_FILE": str(body), + "PATHS_FILE": str(paths), + "ALLOWED_PATHS": "gradle.properties", + "BUNDLE": str(bundle.relative_to(tmp_path)), + "CANDIDATE_COMMIT": commit, + "LABEL": "skip news", + "RUNNER_TEMP": str(tmp_path), + }, + ) + assert (result.returncode == 0) is (branch_tamper == "none"), result.stderr + assert all(request.method == "GET" for request, _ in server.log) + + +@pytest.mark.parametrize( + "matching_head", [pytest.param(True, id="prepared-head"), pytest.param(False, id="changed-head")] +) +def test_absent_branch_pushes_prepared_commit_and_opens_pr( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], matching_head: bool +) -> None: + server, environment = github_api + server.expect_ordered_request("/api/v3/users/fixture-app[bot]", method="GET").respond_with_json({"id": 123}) + bundle, base, commit, blob = candidate(tmp_path) + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True) + git_config = tmp_path / "gitconfig" + subprocess.run( + [ + "git", + "config", + "--file", + str(git_config), + f"url.{remote}.insteadOf", + f"https://{environment['GH_HOST']}/tox-dev/fixture.git", + ], + check=True, + ) + body = tmp_path / "body.md" + body.write_text("Set the next development version.\n") + paths = tmp_path / "paths.tsv" + paths.write_text(f"gradle.properties\t{blob}\n") + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/pulls", method="GET").respond_with_json([]) + server.expect_ordered_request(f"{prefix}/git/ref/heads/main", method="GET").respond_with_json( + {"object": {"sha": base}} + ) + server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( + {"message": "Not Found"}, status=404 + ) + server.expect_ordered_request(f"{prefix}/git/ref/heads/main", method="GET").respond_with_json( + {"object": {"sha": base}} + ) + server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( + {"object": {"sha": commit}} + ) + server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( + {"object": {"sha": commit}} + ) + server.expect_ordered_request(f"{prefix}/pulls", method="POST").respond_with_json( + {"number": 7, "head": {"sha": commit if matching_head else "b" * 40}}, status=201 + ) + if matching_head: + server.expect_ordered_request(f"{prefix}/git/ref/heads/post-release-v2.4.7", method="GET").respond_with_json( + {"object": {"sha": commit}} + ) + server.expect_ordered_request(f"{prefix}/issues/7/labels", method="GET").respond_with_json([]) + server.expect_ordered_request(f"{prefix}/issues/7/labels", method="POST").respond_with_json( + [{"name": "skip news"}], status=200 + ) + result = run_publish( + action_scripts, + cwd=tmp_path, + env=os.environ + | environment + | { + "GITHUB_REPOSITORY": "tox-dev/fixture", + "APP_SLUG": "fixture-app", + "BASE": "main", + "BASE_COMMIT": base, + "BRANCH": "post-release-v2.4.7", + "TITLE": "Bump version to 2.4.8-dev", + "COMMIT_MESSAGE": "Bump version", + "BODY_FILE": str(body), + "PATHS_FILE": str(paths), + "ALLOWED_PATHS": "gradle.properties", + "BUNDLE": str(bundle), + "CANDIDATE_COMMIT": commit, + "LABEL": "skip news", + "RUNNER_TEMP": str(tmp_path), + "GIT_CONFIG_GLOBAL": str(git_config), + }, + ) + assert (result.returncode == 0) is matching_head, result.stderr + assert run_git(remote, "rev-parse", "refs/heads/post-release-v2.4.7") == commit + + +@pytest.mark.parametrize( + "changed_input", + [ + pytest.param("commit", id="stale-bundle"), + pytest.param("blob", id="changed-path-manifest"), + pytest.param("extra", id="extra-path"), + pytest.param("author", id="wrong-author"), + pytest.param("committer", id="wrong-committer"), + pytest.param("message", id="wrong-message"), + pytest.param("unauthorized", id="unauthorized-path"), + ], +) +def test_prepared_candidate_identity_is_checked_before_api_write( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + changed_input: str, +) -> None: + server, environment = github_api + expect_bot(server) + bundle, base, commit, blob = candidate( + tmp_path, + extra_path=changed_input in {"extra", "unauthorized"}, + wrong_author=changed_input == "author", + wrong_committer=changed_input == "committer", + wrong_message=changed_input == "message", + ) + body = tmp_path / "body.md" + body.write_text("Set the next development version.\n") + paths = tmp_path / "paths.tsv" + paths.write_text(f"gradle.properties\t{'c' * 40 if changed_input == 'blob' else blob}\n") + if changed_input == "unauthorized": + with paths.open("a") as stream: + stream.write(f"other.txt\t{run_git(tmp_path / 'source', 'rev-parse', 'HEAD:other.txt')}\n") + result = run_publish( + action_scripts, + cwd=tmp_path, + env=os.environ + | environment + | { + "GITHUB_REPOSITORY": "tox-dev/fixture", + "APP_SLUG": "fixture-app", + "BASE": "main", + "BASE_COMMIT": base, + "BRANCH": "post-release-v2.4.7", + "TITLE": "Bump version to 2.4.8-dev", + "BODY_FILE": str(body), + "PATHS_FILE": str(paths), + "ALLOWED_PATHS": "gradle.properties", + "BUNDLE": str(bundle), + "CANDIDATE_COMMIT": "d" * 40 if changed_input == "commit" else commit, + "COMMIT_MESSAGE": "Bump version", + "LABEL": "skip news", + "RUNNER_TEMP": str(tmp_path), + }, + ) + assert result.returncode != 0 + assert [request.method for request, _ in server.log] == ["GET"] diff --git a/tests/read_towncrier_config/test_config.py b/tests/read_towncrier_config/test_config.py new file mode 100644 index 0000000..f7db56d --- /dev/null +++ b/tests/read_towncrier_config/test_config.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest +from conftest import action, run + + +def test_reader_uses_configured_type_array(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="") + assert result.returncode == 0, result.stderr + outputs = dict(line.split("=", 1) for line in (tmp_path / "output").read_text().splitlines()) + assert outputs == { + "changelog": "CHANGELOG.md", + "fragments": "changelog.d", + "format": "markdown", + } + + +def test_reader_prefers_towncrier_fragment_tables(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + (repository / "towncrier.toml").write_text( + dedent( + """\ + [tool.towncrier] + directory = "docs/news" + filename = "docs/NEWS.rst" + + [tool.towncrier.fragment.feature] + name = "Added" + + [tool.towncrier.fragment.bugfix] + name = "Fixed" + """ + ) + ) + run(repository, "git", "add", "towncrier.toml") + run(repository, "git", "commit", "-m", "Configure Towncrier in a separate file") + result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="") + assert result.returncode == 0, result.stderr + outputs = dict(line.split("=", 1) for line in (tmp_path / "output").read_text().splitlines()) + assert outputs == { + "changelog": "docs/NEWS.rst", + "fragments": "docs/news", + "format": "rst", + } + + +@pytest.mark.parametrize("path", [pytest.param("../NEWS.md", id="parent"), pytest.param("/NEWS.md", id="absolute")]) +def test_reader_rejects_unsafe_changelog_path( + repository: Path, tmp_path: Path, action_scripts: Path, path: str +) -> None: + project = repository / "pyproject.toml" + project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', f'filename = "{path}"')) + run(repository, "git", "add", "pyproject.toml") + run(repository, "git", "commit", "-m", "Set changelog path") + result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="") + assert result.returncode != 0 + assert "Towncrier paths must stay within the repository" in result.stderr + + +def test_reader_rejects_mixed_fragment_styles(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + project = repository / "pyproject.toml" + project.write_text(project.read_text() + '\n[tool.towncrier.fragment.bugfix]\nname = "Fixed"\n') + run(repository, "git", "add", "pyproject.toml") + run(repository, "git", "commit", "-m", "Mix Towncrier fragment styles") + result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="") + assert result.returncode != 0 + assert "type and fragment tables cannot be combined" in result.stderr + + +def test_reader_rejects_non_table_fragment(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + (repository / "towncrier.toml").write_text( + '[tool.towncrier]\ndirectory = "changelog.d"\nfilename = "CHANGELOG.md"\nfragment = { feature = "Added" }\n' + ) + run(repository, "git", "add", "towncrier.toml") + run(repository, "git", "commit", "-m", "Set an invalid fragment definition") + result = action(repository, tmp_path, action_scripts, "read-towncrier-config", version="") + assert result.returncode != 0 + assert "Towncrier fragment categories are required" in result.stderr diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py new file mode 100644 index 0000000..af4ef0e --- /dev/null +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +import yaml +from conftest import action +from pytest_httpserver import HTTPServer + + +def test_recheck_schedule_has_only_required_permissions() -> None: + workflow = yaml.load( + (Path(__file__).resolve().parents[2] / ".github/workflows/recheck-hook-cooldown.yaml").read_text(), + Loader=yaml.BaseLoader, + ) + assert workflow["jobs"]["recheck"] == { + "runs-on": "ubuntu-26.04", + "permissions": {"actions": "write", "contents": "read", "pull-requests": "read"}, + "steps": [{"uses": "$/recheck-hook-update-cooldown"}], + } + + +@pytest.mark.parametrize( + ("observed_age", "job_age", "job_conclusion", "job_name", "reruns"), + [ + pytest.param(8, 8, "failure", "hook-cooldown", 1, id="matured-failure"), + pytest.param(8, 8, "failure", "Hook update cooldown", 1, id="named-job"), + pytest.param(6, 6, "failure", "hook-cooldown", 0, id="recent-pull"), + pytest.param(8, 0, "failure", "hook-cooldown", 1, id="failed-rerun"), + pytest.param(31, 31, "failure", "hook-cooldown", 0, id="expired-run"), + pytest.param(8, 8, "success", "hook-cooldown", 0, id="successful-job"), + ], +) +@pytest.mark.parametrize( + "bot_login", + [pytest.param("pre-commit-ci[bot]", id="pre-commit-ci"), pytest.param("release-app[bot]", id="app")], +) +def test_recheck_reruns_failed_job( + github_api: tuple[HTTPServer, dict[str, str]], + action_scripts: Path, + tmp_path: Path, + observed_age: int, + job_age: int, + job_conclusion: str, + job_name: str, + reruns: int, + bot_login: str, +) -> None: + server, environment = github_api + observed_at = (datetime.now(UTC) - timedelta(days=observed_age)).strftime("%Y-%m-%dT%H:%M:%SZ") + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + [ + { + "number": 7, + "user": {"login": bot_login, "type": "Bot"}, + "head": { + "sha": "a" * 40, + "ref": "pre-commit-ci-update-config", + "repo": {"full_name": "tox-dev/fixture"}, + }, + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + "updated_at": datetime.now(UTC).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": "tox-dev/fixture"}, + "head_sha": "a" * 40, + "head_branch": "pre-commit-ci-update-config", + "pull_requests": [{"number": 7}], + "status": "completed", + "created_at": observed_at, + } + ] + } + ) + if 7 <= observed_age < 30: + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( + { + "jobs": [ + {"id": 98, "name": "Other failed job", "conclusion": "failure", "completed_at": observed_at}, + { + "id": 99, + "name": job_name, + "conclusion": job_conclusion, + "completed_at": (datetime.now(UTC) - timedelta(days=job_age)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + ] + } + ) + if reruns: + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99", method="GET").respond_with_json( + {"id": 99, "run_id": 42, "name": job_name, "status": "completed", "conclusion": "failure"} + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun", method="POST").respond_with_data( + status=201 + ) + result = action( + tmp_path, + tmp_path, + action_scripts, + "recheck-hook-update-cooldown", + version="", + GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], + JOB_NAME=job_name, + **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: + server, environment = github_api + observed_at = (datetime.now(UTC) - timedelta(days=8)).strftime("%Y-%m-%dT%H:%M:%SZ") + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + [ + { + "number": number, + "user": {"login": "release-app[bot]", "type": "Bot"}, + "head": {"sha": str(number) * 40, "ref": f"update-{number}", "repo": {"full_name": "tox-dev/fixture"}}, + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + } + for number in (7, 8) + ] + ) + for number in (7, 8): + server.expect_request(f"/api/v3/repos/tox-dev/fixture/pulls/{number}/files", method="GET").respond_with_json( + [{"filename": ".pre-commit-config.yaml"}] + ) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", + method="GET", + query_string=f"event=pull_request&head_sha={str(number) * 40}&per_page=100", + ).respond_with_json( + { + "workflow_runs": [ + { + "id": number + 40, + "event": "pull_request", + "head_repository": {"full_name": "tox-dev/fixture"}, + "head_sha": str(number) * 40, + "head_branch": f"update-{number}", + "pull_requests": [{"number": number}], + "status": "completed", + "created_at": observed_at, + } + ] + } + ) + job = number + 90 + server.expect_request( + f"/api/v3/repos/tox-dev/fixture/actions/runs/{number + 40}/jobs", method="GET" + ).respond_with_json({"jobs": [{"id": job, "name": "hook-cooldown", "conclusion": "failure"}]}) + server.expect_request(f"/api/v3/repos/tox-dev/fixture/actions/jobs/{job}", method="GET").respond_with_json( + {"id": job, "run_id": number + 40, "name": "hook-cooldown", "status": "completed"} + ) + server.expect_request( + f"/api/v3/repos/tox-dev/fixture/actions/jobs/{job}/rerun", method="POST" + ).respond_with_data(status=500 if number == 7 else 201) + 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 "could not rerun hook cooldown for pull request #7" in result.stderr + assert [request.path for request, _ in server.log if request.method == "POST"] == [ + "/api/v3/repos/tox-dev/fixture/actions/jobs/97/rerun", + "/api/v3/repos/tox-dev/fixture/actions/jobs/98/rerun", + ] diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py new file mode 100644 index 0000000..6f640b6 --- /dev/null +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from textwrap import dedent + +import pytest +from conftest import action +from pytest_httpserver import HTTPServer + + +@pytest.mark.parametrize( + ("project", "version", "distribution_exists"), + [ + pytest.param("", "1.0", True, id="missing-project"), + pytest.param("fixture", "", True, id="missing-version"), + pytest.param("fixture", "1.0", False, id="missing-distribution"), + ], +) +def test_reconcile_rejects_invalid_inputs( + tmp_path: Path, + action_scripts: Path, + project: str, + version: str, + distribution_exists: bool, +) -> None: + distribution = tmp_path / "dist" + if distribution_exists: + distribution.mkdir() + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "reconcile-pypi-artifacts", + version=version, + PROJECT=project, + DIST=str(distribution), + ) + assert result.returncode != 0 + + +@pytest.mark.parametrize( + ("remote_extra", "wrong_digest", "expected_error"), + [ + pytest.param(False, False, "Provenance for file", id="matching-files-require-attestation"), + pytest.param(True, False, "PyPI has unexpected files", id="unexpected-remote-file"), + pytest.param(False, True, "PyPI has different SHA256 digests", id="digest-mismatch"), + ], +) +def test_reconcile_checks_complete_release_before_attestations( + tmp_path: Path, + action_scripts: Path, + httpserver: HTTPServer, + remote_extra: bool, + wrong_digest: bool, + expected_error: str, +) -> None: + distribution = tmp_path / "dist" + distribution.mkdir() + wheel_name = "fixture-1.0-py3-none-any.whl" + wheel = distribution / wheel_name + wheel.write_bytes(b"wheel contents") + url = f"https://files.pythonhosted.org/packages/a/{wheel_name}" + sdist_name = "fixture-1.0.tar.gz" + sdist_url = f"https://files.pythonhosted.org/packages/b/{sdist_name}" + if not remote_extra and not wrong_digest: + (distribution / sdist_name).write_bytes(b"sdist contents") + files = [ + { + "filename": wheel_name, + "url": url, + "hashes": {"sha256": "0" * 64 if wrong_digest else hashlib.sha256(wheel.read_bytes()).hexdigest()}, + "yanked": False, + }, + ] + if not wrong_digest: + files.append( + { + "filename": sdist_name, + "url": sdist_url, + "hashes": { + "sha256": "1" * 64 + if remote_extra + else hashlib.sha256((distribution / sdist_name).read_bytes()).hexdigest() + }, + "yanked": False, + }, + ) + httpserver.expect_request("/simple/fixture/").respond_with_json({"meta": {"api-version": "1.0"}, "files": files}) + if not remote_extra and not wrong_digest: + httpserver.expect_request(f"/packages/a/{wheel_name}").respond_with_data(wheel.read_bytes()) + httpserver.expect_request(f"/integrity/fixture/1.0/{wheel_name}/provenance").respond_with_json( + {"message": "missing"}, status=404 + ) + python_path = tmp_path / "pythonpath" + python_path.mkdir() + (python_path / "sitecustomize.py").write_text( + dedent( + """ + import os + from urllib.parse import urlsplit + import urllib.request + + base = os.environ["TEST_PYPI_SERVER"] + original_open = urllib.request.urlopen + + def route_open(request, *args, **kwargs): + parsed = urlsplit(request.full_url) + if parsed.hostname == "pypi.org": + request = urllib.request.Request(f"{base}{parsed.path}", headers=dict(request.header_items())) + return original_open(request, *args, **kwargs) + + urllib.request.urlopen = route_open + + try: + import requests + except ImportError: + pass + else: + original_request = requests.Session.request + + def route_request(self, method, url, *args, **kwargs): + parsed = urlsplit(url) + if parsed.hostname in {"pypi.org", "files.pythonhosted.org"}: + url = f"{base}{parsed.path}" + return original_request(self, method, url, *args, **kwargs) + + requests.Session.request = route_request + """ + ), + ) + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "reconcile-pypi-artifacts", + version="1.0", + PROJECT="fixture", + DIST=str(distribution), + PYTHONPATH=str(python_path), + TEST_PYPI_SERVER=httpserver.url_for("").rstrip("/"), + NO_PROXY="localhost,127.0.0.1", + ) + assert result.returncode != 0 + assert expected_error in result.stderr + if not remote_extra and not wrong_digest: + assert (tmp_path / "pypi-file-urls").read_text() == f"{url}\n{sdist_url}\n" diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py new file mode 100644 index 0000000..cd42887 --- /dev/null +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + + +@pytest.fixture +def cutoff_project(tmp_path: Path) -> Path: + project = tmp_path / "pyproject.toml" + project.write_text( + '[project]\nname = "example"\nversion = "0.1"\n' + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' + '[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n' + '[[tool.nab.package-rules]]\nmatch = ["old-rule"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' + '[[tool.nab.package-rules]]\nmatch = ["new-rule"]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', + ) + subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run(["git", "add", "pyproject.toml"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.name=Release test", "-c", "user.email=release@example.org", "commit", "-m", "Add policy"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + return project + + +def test_expire_cutoffs_removes_only_expired_rules(cutoff_project: Path) -> None: + source = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + nab = tomllib.loads(cutoff_project.read_text())["tool"]["nab"] + assert set(nab["packages"]) == {"new"} + assert [rule["match"] for rule in nab["package-rules"]] == [["new-rule"]] + + +def test_expire_cutoffs_removes_empty_package_table(cutoff_project: Path) -> None: + cutoff_project.write_text( + cutoff_project.read_text().replace('[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', "") + ) + source = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "packages" not in tomllib.loads(cutoff_project.read_text())["tool"]["nab"] + + +def test_expire_cutoffs_accepts_toml_datetime(cutoff_project: Path) -> None: + cutoff_project.write_text(cutoff_project.read_text().replace('"2026-01-01T00:00:00Z"', "2026-01-01T00:00:00Z")) + source = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "old" not in tomllib.loads(cutoff_project.read_text())["tool"]["nab"]["packages"] + + +def test_verify_cutoff_patch_accepts_expired_only(cutoff_project: Path) -> None: + source = Path(__file__).resolve().parents[2] / "tools" + subprocess.run([sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], check=True) + result = subprocess.run( + [sys.executable, str(source / "verify_cutoff_patch.py")], + cwd=cutoff_project.parent, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_verify_cutoff_patch_accepts_subset_expired(cutoff_project: Path) -> None: + cutoff_project.write_text( + cutoff_project.read_text().replace('[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', "") + ) + source = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(source / "verify_cutoff_patch.py")], + cwd=cutoff_project.parent, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_verify_cutoff_patch_rejects_other_metadata(cutoff_project: Path) -> None: + source = Path(__file__).resolve().parents[2] / "tools" + subprocess.run([sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], check=True) + cutoff_project.write_text(cutoff_project.read_text().replace('name = "example"', 'name = "intruder"')) + result = subprocess.run( + [sys.executable, str(source / "verify_cutoff_patch.py")], + cwd=cutoff_project.parent, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "changes beyond expired Nab cutoffs" in result.stderr + + +def test_verify_cutoff_patch_rejects_early_floor_removal(cutoff_project: Path) -> None: + source = Path(__file__).resolve().parents[2] / "tools" + text = cutoff_project.read_text() + cutoff_project.write_text(text.replace('[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', "")) + result = subprocess.run( + [sys.executable, str(source / "verify_cutoff_patch.py")], + cwd=cutoff_project.parent, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "changes beyond expired Nab cutoffs" in result.stderr + + +def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" + committed = tmp_path / "committed.toml" + generated = tmp_path / "generated.toml" + committed.write_text('packages = ["safe"]\n[tool.nab]\ncommand-line = ["--upgrade"]\n') + generated.write_text('packages = ["changed"]\n[tool.nab]\ncommand-line = []\n') + result = subprocess.run( + [sys.executable, str(source), str(committed), str(generated)], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "regenerated Nab lock differs" in result.stderr + + +@pytest.mark.parametrize( + ("package", "timestamp", "accepted"), + [ + pytest.param("nab", "2026-10-19T00:00:00Z", True, id="reviewed-nab"), + pytest.param("nab", "2026-10-20T00:00:00Z", False, id="extended-nab"), + pytest.param("release-creator", "2026-10-23T02:44:40Z", True, id="reviewed-release-creator"), + pytest.param("release-creator", "2026-10-23T02:44:41Z", False, id="extended-release-creator"), + pytest.param("unreviewed", "2026-10-22T00:00:00Z", False, id="new-package"), + ], +) +def test_recent_cutoff_requires_review(package: str, timestamp: str, accepted: bool) -> None: + project = ( + f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.{package}]\nuploaded-prior-to = "{timestamp}"\n' + ) + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "from datetime import UTC, datetime; from nab_cutoffs import validate_recent_cutoffs; " + "import sys; validate_recent_cutoffs(sys.stdin.read(), datetime(2026, 10, 16, tzinfo=UTC))" + ), + ], + cwd=Path(__file__).resolve().parents[2] / "tools", + input=project, + capture_output=True, + text=True, + check=False, + ) + assert (result.returncode == 0) == accepted + if not accepted: + assert "unreviewed recent Nab cutoff" in result.stderr diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py new file mode 100644 index 0000000..cc8247c --- /dev/null +++ b/tests/release_workflow/test_graph.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import yaml + + +def test_prepare_workflow_calls_reusable_preparation() -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-release.yaml").read_text() + ) + assert list(workflow["jobs"]) == ["prepare"] + assert workflow["jobs"]["prepare"]["uses"] == "$/.github/workflows/prepare-towncrier-release.yaml" + + +def test_prepare_jobs_transfer_the_uploaded_patch() -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() + ) + jobs = workflow["jobs"] + assert list(jobs) == ["assemble", "push"] + assert jobs["assemble"]["outputs"]["artifact-id"] == "${{ steps.patch.outputs.artifact-id }}" + assert jobs["assemble"]["outputs"]["patch-sha"] == "${{ steps.digest.outputs.sha }}" + assert ( + next(step for step in jobs["assemble"]["steps"] if step.get("id") == "patch")["with"]["name"] == "release-patch" + ) + assert ( + next(step for step in jobs["push"]["steps"] if "download-artifact" in step.get("uses", ""))["with"][ + "artifact-ids" + ] + == "${{ needs.assemble.outputs.artifact-id }}" + ) + + +def test_prepare_validates_patch_in_fresh_worktree() -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() + ) + steps = workflow["jobs"]["assemble"]["steps"] + worktree = next(step for step in steps if step.get("name") == "Create a fresh validation worktree") + validation = next(step for step in steps if step.get("uses") == "$/validate-release-patch") + assert "git worktree add --detach" in worktree["run"] + assert validation["with"]["working-directory"] == "${{ runner.temp }}/release-validation" + assert ( + steps.index(worktree) + < steps.index(validation) + < next(index for index, step in enumerate(steps) if step.get("id") == "patch") + ) + + +def test_prepare_builds_local_tool_once_with_locked_backends() -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() + ) + steps = workflow["jobs"]["assemble"]["steps"] + builds = [step for step in steps if step.get("name") == "Build local release tool wheel"] + assert len(builds) == 1 + assert builds[0]["env"]["BUILD_CONSTRAINTS"] == "${{ steps.constraints.outputs.path }}" + assert '--build-constraints "$BUILD_CONSTRAINTS"' in builds[0]["run"] + assert "--require-build-hashes" in builds[0]["run"] + assert any( + step.get("name") == "Install local release tool wheel" and '"$RUNNER_TEMP/local-tool-dist/"*.whl' in step["run"] + for step in steps + ) + + +def test_prepare_installs_hooks_while_building_changelog() -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() + ) + steps = workflow["jobs"]["assemble"]["steps"] + hooks = next(step for step in steps if step.get("id") == "hooks") + build = next(step for step in steps if step.get("id") == "prepare") + wait = next(step for step in steps if step.get("wait") == "hooks") + finalize = next(step for step in steps if step.get("name") == "Finalize release patch") + assert hooks["background"] is True + assert hooks["run"] == "prek install-hooks" + assert [build["with"]["phase"], finalize["with"]["phase"]] == ["build", "finalize"] + assert steps.index(hooks) < steps.index(build) < steps.index(wait) < steps.index(finalize) + + +def test_changelog_checkout_fetches_the_base_branch() -> None: + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text()) + checkout = next(step for step in workflow["jobs"]["changelog"]["steps"] if "checkout" in step.get("uses", "")) + assert checkout["with"]["fetch-depth"] == 0 + + +def test_release_writer_has_only_notes_and_reconciler() -> None: + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) + publish = workflow["jobs"]["publish"] + assert publish["permissions"] == {"contents": "write"} + assert publish["needs"] == ["check", "source", "notes"] + assert [step["uses"] for step in publish["steps"]] == [ + "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333", + "$/publish-github-release", + ] + assert publish["steps"][-1]["with"]["commit"] == "${{ needs.source.outputs.commit }}" + assert publish["steps"][0]["with"]["artifact-ids"] == "${{ needs.notes.outputs.artifact-id }}" + + +def test_release_notes_run_without_write_access() -> None: + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) + notes = workflow["jobs"]["notes"] + assert notes["permissions"] == {"contents": "read"} + assert notes["steps"][0]["with"]["fetch-depth"] == 0 + assert "$/render-release-notes" in [step.get("uses") for step in notes["steps"]] + assert notes["outputs"]["artifact-id"] == "${{ steps.artifact.outputs.artifact-id }}" + assert ( + next(step for step in notes["steps"] if "download-artifact" in step.get("uses", ""))["with"]["artifact-ids"] + == "${{ needs.contributors.outputs.artifact-id }}" + ) + + +def test_release_contributors_job_has_no_checkout() -> None: + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) + contributors = workflow["jobs"]["contributors"] + assert contributors["permissions"] == {"contents": "write"} + assert not any("checkout" in step.get("uses", "") for step in contributors["steps"]) + assert not any("setup-uv" in step.get("uses", "") for step in contributors["steps"]) + + +def test_release_contributors_job_uploads_generated_notes() -> None: + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) + contributors = workflow["jobs"]["contributors"] + assert contributors["steps"][0]["name"] == "Fetch contributor metadata" + assert "releases/generate-notes" in contributors["steps"][0]["run"] + assert contributors["steps"][1]["with"]["path"] == "${{ runner.temp }}/generated-notes.md" + assert contributors["outputs"]["artifact-id"] == "${{ steps.artifact.outputs.artifact-id }}" + + +def test_shfmt_hook_rejects_unformatted_inline_shell(tmp_path: Path) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + script = tmp_path / "inline-shell-check.sh" + script.write_text("#!/usr/bin/env bash\nif true\nthen\necho x\nfi\n") + executable = shutil.which("prek") + assert executable is not None + result = subprocess.run( + [ + executable, + "run", + "shfmt", + "--config", + str(Path(__file__).resolve().parents[2] / ".pre-commit-config.yaml"), + "--files", + str(script), + ], + cwd=tmp_path, + env={name: os.environ[name] for name in ("HOME", "PATH")}, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "diff" in result.stdout + + +def test_action_manifests_have_no_empty_environment_mapping() -> None: + root = Path(__file__).resolve().parents[2] + for path in root.glob("*/action.yml"): + action = yaml.safe_load(path.read_text()) + assert all("env" not in step or isinstance(step["env"], dict) for step in action["runs"]["steps"]), path diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py new file mode 100644 index 0000000..05fe274 --- /dev/null +++ b/tests/render_release_notes/test_notes.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest +from conftest import action, run + + +@pytest.fixture +def tagged_repository(repository: Path) -> Path: + (repository / "CHANGELOG.md").write_text( + dedent( + """\ + # Changelog + + + + + + ## 0.2.0 (2026-10-20) + + + + ### Added + + - Add a new feature ([#2](https://github.com/tox-dev/fixture/pull/2)) by + [@gaborbernat](https://github.com/gaborbernat). + """, + ), + ) + run(repository, "git", "add", "CHANGELOG.md") + run(repository, "git", "commit", "-m", "Release 0.2.0") + run(repository, "git", "tag", "0.2.0") + return repository + + +def test_render_release_notes_uses_tagged_changelog( + tagged_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + result = action( + tagged_repository, + tmp_path, + action_scripts, + "render-release-notes", + version="0.2.0", + TAG="0.2.0", + OUTPUT=str(tmp_path / "notes.md"), + ARTIFACT_URL="https://pypi.org/project/fixture/0.2.0/", + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "notes.md").read_text() == dedent( + """\ + + + ### Features + + - Add a new feature ([#2](https://github.com/tox-dev/fixture/pull/2)) by @gaborbernat. + + [PyPI release](https://pypi.org/project/fixture/0.2.0/) + + **Full Changelog**: https://github.com/tox-dev/fixture/compare/0.1.0...0.2.0 + """, + ) + assert "previous=0.1.0" in (tmp_path / "output").read_text() + + +def test_render_release_notes_uses_generated_contributors( + tagged_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + generated = tmp_path / "generated.md" + generated.write_text( + "* Add a new feature by @gaborbernat in https://github.com/tox-dev/fixture/pull/2\n" + "\n## New Contributors\n* @gaborbernat made their first contribution in " + "https://github.com/tox-dev/fixture/pull/2\n", + ) + result = action( + tagged_repository, + tmp_path, + action_scripts, + "render-release-notes", + version="0.2.0", + TAG="0.2.0", + OUTPUT=str(tmp_path / "notes.md"), + GENERATED_NOTES=str(generated), + ) + assert result.returncode == 0, result.stderr + assert "### New Contributors\n\n- @gaborbernat" in (tmp_path / "notes.md").read_text() + + +def test_render_release_notes_accepts_first_release( + tagged_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + run(tagged_repository, "git", "tag", "-d", "0.1.0") + result = action( + tagged_repository, + tmp_path, + action_scripts, + "render-release-notes", + version="0.2.0", + TAG="0.2.0", + OUTPUT=str(tmp_path / "notes.md"), + ) + assert result.returncode == 0, result.stderr + assert "### Features" in (tmp_path / "notes.md").read_text() + assert "compare/" not in (tmp_path / "notes.md").read_text() + + +@pytest.mark.parametrize( + ("role", "path"), + [pytest.param("pr", "pull", id="pull-request"), pytest.param("issue", "issues", id="issue")], +) +def test_render_release_notes_converts_rst_reference( + tagged_repository: Path, tmp_path: Path, action_scripts: Path, role: str, path: str +) -> None: + (tagged_repository / "CHANGELOG.rst").write_text( + f"0.2.0 (2026-10-20)\n==================\n\nFixed\n-----\n\n- Correct a bug (:{role}:`4`).\n", + ) + result = action( + tagged_repository, + tmp_path, + action_scripts, + "render-release-notes", + version="0.2.0", + TAG="0.2.0", + CHANGELOG="CHANGELOG.rst", + OUTPUT=str(tmp_path / "notes.md"), + ) + assert result.returncode == 0, result.stderr + assert f"Correct a bug ([#4](https://github.com/tox-dev/fixture/{path}/4))" in (tmp_path / "notes.md").read_text() + + +def test_render_release_notes_custom_artifact_label( + tagged_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + result = action( + tagged_repository, + tmp_path, + action_scripts, + "render-release-notes", + version="0.2.0", + TAG="0.2.0", + OUTPUT=str(tmp_path / "notes.md"), + ARTIFACT_URL="https://github.com/tox-dev/fixture/releases/download/0.2.0/fixture.pyz", + ARTIFACT_LABEL="Zipapp", + ) + assert result.returncode == 0, result.stderr + assert ( + "[Zipapp](https://github.com/tox-dev/fixture/releases/download/0.2.0/fixture.pyz)" + in (tmp_path / "notes.md").read_text() + ) + + +@pytest.mark.parametrize( + ("tag", "remove_heading", "error"), + [ + pytest.param("invalid tag", False, "release tag not found", id="invalid-tag"), + pytest.param("0.2.0", True, "expected one changelog section", id="missing-heading"), + ], +) +def test_render_release_notes_rejects_invalid_source( + tagged_repository: Path, + tmp_path: Path, + action_scripts: Path, + tag: str, + remove_heading: bool, + error: str, +) -> None: + if remove_heading: + changelog = tagged_repository / "CHANGELOG.md" + changelog.write_text(changelog.read_text().replace("## 0.2.0 (", "0.2.0 (")) + result = action( + tagged_repository, + tmp_path, + action_scripts, + "render-release-notes", + version=tag, + TAG=tag, + OUTPUT=str(tmp_path / "notes.md"), + ) + assert result.returncode != 0 + assert error in result.stderr diff --git a/tests/resolve_pr_from_run/test_resolve.py b/tests/resolve_pr_from_run/test_resolve.py new file mode 100644 index 0000000..1a3a7d5 --- /dev/null +++ b/tests/resolve_pr_from_run/test_resolve.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import action +from pytest_httpserver import HTTPServer + + +@pytest.mark.parametrize( + ("head_repository", "head_owner", "branch", "author_type", "conclusion"), + [ + pytest.param("tox-dev/fixture", "tox-dev", "dependabot/uv", "Bot", "success", id="same-repo-bot"), + pytest.param("external/fixture", "external", "fix/news", "Bot", "failure", id="fork-bot-failed-ci"), + pytest.param("external/fixture", "external", "fix/news", "User", "success", id="human"), + ], +) +def test_resolve_pr_from_run_matches_current_head( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + head_repository: str, + head_owner: str, + branch: str, + author_type: str, + conclusion: str, +) -> None: + server, environment = github_api + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json( + { + "id": 42, + "repository": {"full_name": "tox-dev/fixture"}, + "event": "pull_request", + "path": ".github/workflows/check.yaml", + "status": "completed", + "conclusion": conclusion, + "head_repository": {"full_name": head_repository, "owner": {"login": head_owner}}, + "head_branch": branch, + "head_sha": "a" * 40, + "pull_requests": [], + }, + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json( + [ + { + "number": 7, + "state": "open", + "user": {"type": author_type}, + "head": {"sha": "a" * 40, "repo": {"full_name": head_repository}}, + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + }, + ], + ) + result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment) + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == f"number=7\nbot-author={str(author_type == 'Bot').lower()}\n" + assert server.log[1][0].args["head"] == f"{head_owner}:{branch}" + + +@pytest.mark.parametrize( + ("head_sha", "head_repository", "response"), + [ + pytest.param("b" * 40, "external/fixture", "stale-head", id="stale-head"), + pytest.param("a" * 40, "other/fixture", "wrong-fork", id="wrong-fork"), + pytest.param("a" * 40, "external/fixture", "missing", id="no-pr"), + ], +) +def test_resolve_pr_from_run_skips_unmatched_pr( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + head_sha: str, + head_repository: str, + response: str, +) -> None: + server, environment = github_api + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json( + { + "id": 42, + "repository": {"full_name": "tox-dev/fixture"}, + "event": "pull_request", + "path": ".github/workflows/check.yaml", + "status": "completed", + "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}}, + "head_branch": "fix/news", + "head_sha": "a" * 40, + }, + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json( + [] + if response == "missing" + else [ + { + "number": 7, + "state": "open", + "user": {"type": "Bot"}, + "head": {"sha": head_sha, "repo": {"full_name": head_repository}}, + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + }, + ], + ) + result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment) + assert result.returncode == 0, result.stderr + assert not (tmp_path / "output").exists() + + +def test_resolve_pr_from_run_rejects_ambiguous_pr( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json( + { + "id": 42, + "repository": {"full_name": "tox-dev/fixture"}, + "event": "pull_request", + "path": ".github/workflows/check.yaml", + "status": "completed", + "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}}, + "head_branch": "fix/news", + "head_sha": "a" * 40, + }, + ) + candidate = { + "number": 7, + "state": "open", + "user": {"type": "Bot"}, + "head": {"sha": "a" * 40, "repo": {"full_name": "external/fixture"}}, + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + } + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls").respond_with_json( + [candidate, candidate | {"number": 8}], + ) + result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment) + assert result.returncode != 0 + assert "source run matches multiple pull requests" in result.stderr + + +@pytest.mark.parametrize( + ("field", "value"), + [ + pytest.param("event", "pull_request_target", id="privileged-event"), + pytest.param("path", ".github/workflows/release.yaml", id="wrong-workflow"), + pytest.param("status", "in_progress", id="unfinished-run"), + pytest.param("repository", {"full_name": "other/fixture"}, id="wrong-repository"), + pytest.param("id", 43, id="wrong-run-id"), + ], +) +def test_resolve_pr_from_run_rejects_untrusted_source( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + field: str, + value: _JsonValue, +) -> None: + server, environment = github_api + source: dict[str, _JsonValue] = { + "id": 42, + "repository": {"full_name": "tox-dev/fixture"}, + "event": "pull_request", + "path": ".github/workflows/check.yaml", + "status": "completed", + "head_repository": {"full_name": "external/fixture", "owner": {"login": "external"}}, + "head_branch": "fix/news", + "head_sha": "a" * 40, + } + source[field] = value + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42").respond_with_json(source) + result = action(repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", **environment) + assert result.returncode != 0 + assert len(server.log) == 1 + + +def test_resolve_pr_from_run_rejects_non_numeric_id( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + result = action( + repository, tmp_path, action_scripts, "resolve-pr-from-run", version="0.2.0", RUN_ID="../42", **environment + ) + assert result.returncode != 0 + assert "run-id must be a decimal integer" in result.stderr + assert not server.log + + +type _JsonValue = str | int | dict[str, _JsonValue] diff --git a/tests/select_previous_tag/test_select.py b/tests/select_previous_tag/test_select.py new file mode 100644 index 0000000..57c2ddc --- /dev/null +++ b/tests/select_previous_tag/test_select.py @@ -0,0 +1,74 @@ +from pathlib import Path + +import pytest +from conftest import action, run + + +def test_select_previous_tag_uses_version_order(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + for tag in ("0.2.0", "0.10.0", "1.0.0"): + run(repository, "git", "tag", tag) + result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="1.0.0", TAG="1.0.0") + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == "previous=0.10.0\n" + + +def test_select_previous_tag_accepts_first_release(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="0.1.0", TAG="0.1.0") + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == "previous=\n" + + +@pytest.mark.parametrize( + "target", + [ + pytest.param("1.1.0", id="bare-version"), + pytest.param("pyproject-fmt/1.1.0", id="qualified-tag"), + ], +) +def test_select_previous_tag_scopes_package( + repository: Path, tmp_path: Path, action_scripts: Path, target: str +) -> None: + for tag in ("pyproject-fmt/1.0.0", "pyproject-fmt/1.1.0", "tox-toml-fmt/1.0.5"): + run(repository, "git", "tag", tag) + result = action( + repository, + tmp_path, + action_scripts, + "select-previous-tag", + version=target, + TAG=target, + PACKAGE="pyproject-fmt", + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == "previous=pyproject-fmt/1.0.0\n" + + +@pytest.mark.parametrize( + ("stable_only", "previous"), + [ + pytest.param("false", "v1.1.0rc1", id="include-prerelease"), + pytest.param("true", "v1.0.0", id="stable-only"), + ], +) +def test_select_previous_tag_filters_prereleases( + repository: Path, tmp_path: Path, action_scripts: Path, stable_only: str, previous: str +) -> None: + for tag in ("v1.0.0", "v1.1.0rc1", "v1.1.0"): + run(repository, "git", "tag", tag) + result = action( + repository, + tmp_path, + action_scripts, + "select-previous-tag", + version="v1.1.0", + TAG="v1.1.0", + STABLE_ONLY=stable_only, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == f"previous={previous}\n" + + +def test_select_previous_tag_requires_current_tag(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "select-previous-tag", version="0.2.0", TAG="0.2.0") + assert result.returncode != 0 + assert not (tmp_path / "output").exists() diff --git a/tests/sign_jetbrains_plugin/test_sign.py b/tests/sign_jetbrains_plugin/test_sign.py new file mode 100644 index 0000000..5d3aeef --- /dev/null +++ b/tests/sign_jetbrains_plugin/test_sign.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import base64 +import hashlib +import shutil +import subprocess +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +import pytest +from conftest import action + + +@pytest.mark.parametrize( + "credential_format", + [ + pytest.param("pem", id="pem"), + pytest.param("base64", id="base64"), + pytest.param("encrypted", id="encrypted"), + ], +) +def test_signs_and_verifies_exact_archive( + action_scripts: Path, + certificate: tuple[Path, Path], + unsigned_archive: Path, + tmp_path: Path, + credential_format: str, +) -> None: + private_key, chain = certificate + password = "" + if credential_format == "encrypted": + encrypted_key = tmp_path / "encrypted.pem" + subprocess.run( + [ + "openssl", + "pkcs8", + "-topk8", + "-v2", + "aes-256-cbc", + "-in", + str(private_key), + "-out", + str(encrypted_key), + "-passout", + "pass:fixture-pass", + ], + check=True, + capture_output=True, + text=True, + ) + private_key = encrypted_key + password = "fixture-pass" + key_text = private_key.read_text() + chain_text = chain.read_text() + if credential_format == "base64": + key_text = base64.b64encode(key_text.encode()).decode() + chain_text = base64.b64encode(chain_text.encode()).decode() + java = shutil.which("java") + assert java is not None + result = action( + tmp_path, + tmp_path, + action_scripts, + "sign-jetbrains-plugin", + version="", + ARCHIVE=str(unsigned_archive), + SHA256=hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(), + JAVA_HOME_17_X64=str(Path(java).resolve().parents[1]), + CERTIFICATE_CHAIN=chain_text, + PRIVATE_KEY=key_text, + PRIVATE_KEY_PASSWORD=password, + ) + assert result.returncode == 0, result.stderr + assert not (tmp_path / "signing-key.pem").exists() + assert not (tmp_path / "signing-key-decrypted.pem").exists() + assert not (tmp_path / "signing-chain.pem").exists() + signed = unsigned_archive.with_name("Plugin 2-1.0-signed.zip") + assert dict(line.split("=", 1) for line in (tmp_path / "output").read_text().splitlines()) == { + "archive": str(signed), + "sha256": hashlib.sha256(signed.read_bytes()).hexdigest(), + } + with ZipFile(unsigned_archive) as unsigned, ZipFile(signed) as signed_zip: + assert {name: unsigned.read(name) for name in unsigned.namelist()} == { + name: signed_zip.read(name) for name in signed_zip.namelist() + } + + +@pytest.mark.parametrize( + "invalid", + [ + pytest.param("digest", id="wrong-digest"), + pytest.param("extension", id="wrong-extension"), + pytest.param("corrupt", id="corrupt-zip"), + pytest.param("duplicate", id="duplicate-member"), + pytest.param("symlink", id="symlink"), + ], +) +def test_rejects_invalid_unsigned_archive( + action_scripts: Path, + unsigned_archive: Path, + tmp_path: Path, + invalid: str, +) -> None: + archive = unsigned_archive + if invalid == "extension": + archive = tmp_path / "plugin.tar" + archive.write_bytes(unsigned_archive.read_bytes()) + elif invalid == "corrupt": + archive.write_bytes(b"not a ZIP") + elif invalid == "duplicate": + with ZipFile(archive, "a") as content: + content.writestr("Plugin/lib/Plugin.jar", b"changed") + elif invalid == "symlink": + archive = tmp_path / "linked.zip" + archive.symlink_to(unsigned_archive) + assert ( + action( + tmp_path, + tmp_path, + action_scripts, + "sign-jetbrains-plugin", + version="", + ARCHIVE=str(archive), + SHA256="0" * 64 if invalid == "digest" else hashlib.sha256(archive.read_bytes()).hexdigest(), + ).returncode + != 0 + ) + + +def test_rejects_missing_java(action_scripts: Path, unsigned_archive: Path, tmp_path: Path) -> None: + assert ( + action( + tmp_path, + tmp_path, + action_scripts, + "sign-jetbrains-plugin", + version="", + ARCHIVE=str(unsigned_archive), + SHA256=hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(), + JAVA_HOME_17_X64=str(tmp_path / "missing"), + ).returncode + != 0 + ) + + +@pytest.mark.parametrize("existing", [pytest.param("file", id="file"), pytest.param("symlink", id="symlink")]) +def test_rejects_existing_signed_archive( + action_scripts: Path, unsigned_archive: Path, tmp_path: Path, existing: str +) -> None: + signed = tmp_path / "Plugin 2-1.0-signed.zip" + if existing == "symlink": + signed.symlink_to(unsigned_archive) + else: + signed.write_bytes(b"existing") + result = action( + tmp_path, + tmp_path, + action_scripts, + "sign-jetbrains-plugin", + version="", + ARCHIVE=str(unsigned_archive), + SHA256=hashlib.sha256(unsigned_archive.read_bytes()).hexdigest(), + ) + assert result.returncode != 0 + assert "signed archive already exists" in result.stderr + + +@pytest.fixture +def unsigned_archive(tmp_path: Path) -> Path: + archive = tmp_path / "Plugin 2-1.0.zip" + with ZipFile(archive, "w", ZIP_DEFLATED) as content: + content.writestr("Plugin/lib/Plugin.jar", b"plugin payload") + return archive + + +@pytest.fixture +def certificate(tmp_path: Path) -> tuple[Path, Path]: + private_key = tmp_path / "key.pem" + chain = tmp_path / "chain.pem" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + str(private_key), + "-out", + str(chain), + "-subj", + "/CN=JetBrains test", + "-days", + "1", + ], + check=True, + capture_output=True, + text=True, + ) + return private_key, chain diff --git a/tests/upgrade_release_tools/test_hooks.py b/tests/upgrade_release_tools/test_hooks.py new file mode 100644 index 0000000..a18df25 --- /dev/null +++ b/tests/upgrade_release_tools/test_hooks.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest +import yaml +from conftest import action, run + + +@pytest.fixture +def hook_repository(tmp_path: Path) -> Path: + repository = tmp_path / "repository" + repository.mkdir() + (repository / "pylock.toml").write_text( + 'lock-version = "1.0"\ncreated-by = "test"\nrequires-python = ">=3.15"\npackages = []\n', + ) + (repository / ".pre-commit-config.yaml").write_text( + "repos:\n - repo: local\n hooks:\n - id: test\n name: test\n" + " entry: python -c 'pass'\n language: python\n" + " additional_dependencies: [mdformat-gfm==0.1.0]\n" + ) + run(repository, "git", "init", "-b", "main") + run(repository, "git", "add", ".") + run(repository, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "Initial") + return repository + + +def test_hook_refresh_resolves_exact_pin(hook_repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action( + hook_repository, + tmp_path, + action_scripts, + "refresh-hook-pins", + version="", + ) + assert result.returncode == 0, result.stderr + config = yaml.safe_load((hook_repository / ".pre-commit-config.yaml").read_text()) + dependencies = config["repos"][0]["hooks"][0]["additional_dependencies"] + assert dependencies[0] == "mdformat-gfm==1.0.0" + assert any(item.startswith("markdown-it-py==") for item in dependencies) + assert all("==" in item for item in dependencies) + + +def test_hook_refresh_freezes_only_cooled_revisions( + hook_repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + remote = tmp_path / "remote" + remote.mkdir() + run(remote, "git", "init", "-b", "main") + (remote / ".pre-commit-hooks.yaml").write_text( + "- id: fixture\n name: fixture\n entry: true\n language: system\n" + ) + run(remote, "git", "add", ".") + subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "Old hook"], + cwd=remote, + env=os.environ | {"GIT_AUTHOR_DATE": "2020-01-01T00:00:00Z", "GIT_COMMITTER_DATE": "2020-01-01T00:00:00Z"}, + check=True, + capture_output=True, + ) + run(remote, "git", "tag", "v1.0.0") + old_commit = run(remote, "git", "rev-parse", "HEAD").stdout.strip() + (remote / "version.txt").write_text("new\n") + run(remote, "git", "add", ".") + run(remote, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-m", "New hook") + run(remote, "git", "tag", "v2.0.0") + (hook_repository / ".pre-commit-config.yaml").write_text( + f"repos:\n - repo: {remote.as_uri()}\n rev: v1.0.0\n hooks:\n - id: fixture\n" + ) + result = action( + hook_repository, + tmp_path, + action_scripts, + "refresh-hook-pins", + version="", + ) + assert result.returncode == 0, result.stderr + assert yaml.safe_load((hook_repository / ".pre-commit-config.yaml").read_text())["repos"][0]["rev"] == old_commit + + +@pytest.mark.parametrize( + ("path", "accepted"), + [ + pytest.param(None, True, id="clean"), + pytest.param(".pre-commit-config.yaml", True, id="hook-config"), + pytest.param("other.txt", False, id="other-file"), + ], +) +def test_hook_refresh_checks_paths( + hook_repository: Path, tmp_path: Path, action_scripts: Path, path: str | None, accepted: bool +) -> None: + if path is not None: + if path == ".pre-commit-config.yaml": + (hook_repository / path).write_text((hook_repository / path).read_text() + "# updated\n") + else: + (hook_repository / path).write_text("updated\n") + result = action(hook_repository, tmp_path, action_scripts, "refresh-hook-pins", version="") + assert (result.returncode == 0) == accepted + assert ("unexpected path" in result.stderr) == (not accepted) + + +@pytest.mark.parametrize("changed", [pytest.param(False, id="unchanged"), pytest.param(True, id="changed")]) +def test_hook_refresh_emits_patch(hook_repository: Path, tmp_path: Path, action_scripts: Path, changed: bool) -> None: + if not changed: + (hook_repository / ".pre-commit-config.yaml").write_text( + "repos:\n - repo: local\n hooks:\n - id: test\n name: test\n" + " entry: python -c 'pass'\n language: system\n" + ) + run(hook_repository, "git", "add", ".pre-commit-config.yaml") + run( + hook_repository, + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.org", + "commit", + "-m", + "Empty hooks", + ) + result = action(hook_repository, tmp_path, action_scripts, "refresh-hook-pins", version="") + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == f"changed={str(changed).lower()}\n" + assert (tmp_path / "hooks.patch").exists() == changed + + +def test_hook_refresh_uses_relative_cutoff() -> None: + action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-hook-pins/action.yml").read_text()) + refresh = next(step for step in action["runs"]["steps"] if step.get("name") == "Refresh exact hook dependencies") + assert "--exclude-newer '7 days'" in refresh["run"] diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py new file mode 100644 index 0000000..dcff75e --- /dev/null +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +import os +import subprocess +import tomllib +from pathlib import Path + +import pytest +import yaml +from conftest import action, run +from pytest_httpserver import HTTPServer + + +@pytest.fixture(scope="session") +def upgrade_scripts(action_scripts: Path) -> Path: + return action_scripts + + +@pytest.fixture(scope="session") +def publish_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: + source = Path(__file__).resolve().parents[2] + scripts = tmp_path_factory.mktemp("publish-steps") + workflow = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text()) + for step in workflow["jobs"]["publish"]["steps"]: + if "run" in step: + name = step.get("id", step.get("name", "step")).replace(" ", "-").lower() + (scripts / f"publish-{name}.sh").write_text(step["run"]) + return scripts + + +def test_upgrade_pull_request_uses_repository_app() -> None: + workflow = yaml.load( + (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text(), + Loader=yaml.BaseLoader, + ) + publish = workflow["jobs"]["publish"] + assert workflow["on"]["workflow_call"]["inputs"]["lock-groups"]["required"] == "true" + assert workflow["jobs"]["generate-lock"]["steps"][2]["with"]["lock-groups"] == "${{ inputs.lock-groups }}" + assert publish["environment"] == "release-auth" + assert publish["permissions"] == {"contents": "read"} + token = next(step for step in publish["steps"] if step.get("id") == "token") + assert token["with"]["repositories"] == "${{ steps.repository.outputs.name }}" + assert token["with"]["permission-contents"] == "write" + assert token["with"]["permission-pull-requests"] == "write" + steps = publish["steps"] + assert next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-nab-cutoff-patch") < next( + index for index, step in enumerate(steps) if step.get("id") == "token" + ) + for step in publish["steps"]: + if step.get("name") in { + "Push dependency branch", + "Find dependency pull request", + "Update dependency pull request", + "Open dependency pull request", + }: + assert step["env"]["GH_TOKEN"] == "${{ steps.token.outputs.token }}" + + +@pytest.fixture +def lock_repository(tmp_path: Path) -> Path: + repository = tmp_path / "repository" + repository.mkdir() + (repository / "pyproject.toml").write_text( + '[project]\nname = "example"\nversion = "0.1.0"\nrequires-python = ">=3.15"\n' + "[dependency-groups]\nrelease = []\ntest = []\n" + '[tool.nab]\nmode = "universal"\nuploaded-prior-to = "P7D"\n' + 'decision-order = "stable"\nbase-group = "project-runtime"\ndefault-groups = []\n' + '[tool.nab.matrix]\npython = ">=3.15,<3.16"\n' + 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n', + ) + run(repository, "git", "init", "-b", "main") + run(repository, "git", "config", "user.name", "Release test") + run(repository, "git", "config", "user.email", "release@example.org") + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Initial files") + return repository + + +def test_upgrade_locks_only_selected_groups(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: + project = lock_repository / "pyproject.toml" + project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')) + installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") + assert installed.returncode == 0, installed.stderr + resolved = execute( + lock_repository, + upgrade_scripts / "refresh-release-lock-3.sh", + tmp_path, + LOCK_GROUPS="release-check", + MODE="refresh", + ) + assert resolved.returncode == 0, resolved.stderr + lock = tomllib.loads((lock_repository / "pylock.toml").read_text()) + assert lock["dependency-groups"] == ["project-runtime", "release-check"] + assert "test" not in {package["name"] for package in lock["packages"]} + + +def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: + project = lock_repository / "pyproject.toml" + project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')) + installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") + assert installed.returncode == 0, installed.stderr + refreshed = execute( + lock_repository, + upgrade_scripts / "refresh-release-lock-3.sh", + tmp_path, + LOCK_GROUPS="release-check", + MODE="refresh", + ) + assert refreshed.returncode == 0, refreshed.stderr + expected = (lock_repository / "pylock.toml").read_bytes() + checked = execute( + lock_repository, + upgrade_scripts / "refresh-release-lock-3.sh", + tmp_path, + LOCK_GROUPS="release-check", + MODE="check", + ) + assert checked.returncode == 0, checked.stderr + assert (lock_repository / "pylock.toml").read_bytes() == expected + + +@pytest.mark.parametrize( + ("groups", "selection"), + [ + pytest.param("", "--build-requirements", id="build-only"), + pytest.param("release-check", "--groups", id="selected-group"), + ], +) +def test_upgrade_preserves_secondary_lock_matrix( + lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, groups: str, selection: str +) -> None: + project = lock_repository / "pyproject.toml" + project.write_text( + project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]') + + '[build-system]\nrequires = ["setuptools>=80"]\nbuild-backend = "setuptools.build_meta"\n', + ) + installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") + assert installed.returncode == 0, installed.stderr + primary = execute( + lock_repository, + upgrade_scripts / "refresh-release-lock-3.sh", + tmp_path, + LOCK_GROUPS="release-check", + MODE="refresh", + ) + assert primary.returncode == 0, primary.stderr + additional = lock_repository / "pylock.build.toml" + command = [ + "uv", + "run", + "--no-project", + "--python", + str(tmp_path / "lock-tools"), + "nab", + "lock", + "pyproject.toml", + selection, + ] + if groups: + command.append(groups) + command.extend( + [ + "--project-matrix-python", + ">=3.15,<3.16", + "--project-matrix-platforms", + "linux_x86_64", + "macos_arm64", + "--project-matrix-implementations", + "cpython", + "--output", + str(additional), + ], + ) + initial = subprocess.run(command, cwd=lock_repository, text=True, capture_output=True, check=False) + assert initial.returncode == 0, initial.stderr + expected = additional.read_bytes() + environment = { + "LOCK_GROUPS": "release-check", + "MODE": "check", + "ADDITIONAL_LOCK": additional.name, + "ADDITIONAL_GROUPS": groups, + "MATRIX_PYTHON": ">=3.15,<3.16", + "MATRIX_PLATFORMS": "linux_x86_64 macos_arm64", + "MATRIX_IMPLEMENTATIONS": "cpython", + } + checked = execute(lock_repository, upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, **environment) + assert checked.returncode == 0, checked.stderr + assert additional.read_bytes() == expected + + +def test_upgrade_rejects_secondary_matrix_change(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: + project = lock_repository / "pyproject.toml" + project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')) + installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") + assert installed.returncode == 0, installed.stderr + initial = execute( + lock_repository, + upgrade_scripts / "refresh-release-lock-3.sh", + tmp_path, + LOCK_GROUPS="release-check", + MODE="refresh", + ) + assert initial.returncode == 0, initial.stderr + (lock_repository / "pylock.build.toml").write_bytes((lock_repository / "pylock.toml").read_bytes()) + result = execute( + lock_repository, + upgrade_scripts / "refresh-release-lock-3.sh", + tmp_path, + LOCK_GROUPS="release-check", + MODE="refresh", + ADDITIONAL_LOCK="pylock.build.toml", + ADDITIONAL_GROUPS="release-check", + MATRIX_PYTHON=">=3.15,<3.16", + MATRIX_PLATFORMS="linux_x86_64", + MATRIX_IMPLEMENTATIONS="cpython", + ) + assert result.returncode != 0 + assert "changed its target environments" in result.stderr + + +def test_upgrade_expires_old_nab_floor(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: + project = lock_repository / "pyproject.toml" + project.write_text( + project.read_text() + + '[[tool.nab.package-rules]]\nmatch = ["nab"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', + ) + installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="") + assert installed.returncode == 0, installed.stderr + expired = execute(lock_repository, upgrade_scripts / "refresh-release-lock-2.sh", tmp_path) + assert expired.returncode == 0, expired.stderr + assert "package-rules" not in tomllib.loads(project.read_text())["tool"]["nab"] + + +@pytest.mark.parametrize( + "changed_path", + [pytest.param("pylock.toml", id="lock"), pytest.param("pyproject.toml", id="expired-floor")], +) +def test_upgrade_emits_patch_for_allowed_change( + lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, changed_path: str +) -> None: + (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n') + run(lock_repository, "git", "add", ".") + run(lock_repository, "git", "commit", "-m", "Add lock") + (lock_repository / changed_path).write_text((lock_repository / changed_path).read_text() + "\n") + result = execute( + lock_repository, + upgrade_scripts / "refresh-release-lock-7.sh", + tmp_path, + MODE="refresh", + GITHUB_OUTPUT=str(tmp_path / "output"), + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == "changed=true\n" + assert f"diff --git a/{changed_path} b/{changed_path}" in (tmp_path / "lock.patch").read_text() + + +def test_upgrade_check_mode_rejects_lock_drift(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: + (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n') + run(lock_repository, "git", "add", ".") + run(lock_repository, "git", "commit", "-m", "Add lock") + (lock_repository / "pylock.toml").write_text('lock-version = "1.1"\n') + result = execute( + lock_repository, + upgrade_scripts / "refresh-release-lock-7.sh", + tmp_path, + MODE="check", + GITHUB_OUTPUT=str(tmp_path / "output"), + ) + assert result.returncode != 0 + + +@pytest.mark.parametrize( + "path", + [pytest.param("other.txt", id="unrelated-file"), pytest.param("uv.lock", id="uv-lock")], +) +def test_upgrade_rejects_unrelated_change( + lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, path: str +) -> None: + (tmp_path / "project-after-expiry.toml").write_bytes((lock_repository / "pyproject.toml").read_bytes()) + (lock_repository / path).write_text("changed\n") + result = execute(lock_repository, upgrade_scripts / "refresh-release-lock-6.sh", tmp_path) + assert result.returncode != 0 + assert "outside the lock and cutoff policy" in result.stderr + + +@pytest.mark.parametrize( + ("path", "accepted"), + [ + pytest.param("pylock.toml", True, id="lock"), + pytest.param("pyproject.toml", True, id="nab-policy"), + pytest.param(".pre-commit-config.yaml", True, id="hooks"), + pytest.param("uv.lock", False, id="uv-lock"), + pytest.param("other.txt", False, id="unrelated-file"), + ], +) +def test_upgrade_rejects_unrelated_patch( + lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, accepted: bool +) -> None: + (lock_repository / path).write_text("before\n") + run(lock_repository, "git", "add", ".") + run(lock_repository, "git", "commit", "-m", "Add file") + (lock_repository / path).write_text("after\n") + patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout + run(lock_repository, "git", "restore", path) + patch_dir = tmp_path / "release-tools-lock" + patch_dir.mkdir() + (patch_dir / "lock.patch").write_text(patch) + applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) + assert applied.returncode == 0, applied.stderr + result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-paths.sh", tmp_path) + assert (result.returncode == 0) == accepted + assert ("unexpected dependency path" in result.stderr) == (not accepted) + + +def test_upgrade_allows_only_named_secondary_lock(lock_repository: Path, tmp_path: Path, publish_scripts: Path) -> None: + for name in ("pylock.build.toml", "pylock.pgo.toml"): + (lock_repository / name).write_text("before\n") + run(lock_repository, "git", "add", ".") + run(lock_repository, "git", "commit", "-m", "Add secondary locks") + (lock_repository / "pylock.build.toml").write_text("after\n") + patch = run(lock_repository, "git", "diff", "--binary", "--", "pylock.build.toml").stdout + run(lock_repository, "git", "restore", "pylock.build.toml") + patch_dir = tmp_path / "release-tools-lock" + patch_dir.mkdir() + (patch_dir / "lock.patch").write_text(patch) + applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) + assert applied.returncode == 0, applied.stderr + accepted = execute( + lock_repository, + publish_scripts / "publish-check-dependency-patch-paths.sh", + tmp_path, + ADDITIONAL_LOCK="pylock.build.toml", + ) + assert accepted.returncode == 0, accepted.stderr + rejected = execute( + lock_repository, + publish_scripts / "publish-check-dependency-patch-paths.sh", + tmp_path, + ADDITIONAL_LOCK="pylock.pgo.toml", + ) + assert rejected.returncode != 0 + assert "unexpected dependency path: pylock.build.toml" in rejected.stderr + + +@pytest.mark.parametrize( + ("path", "unsafe_mode"), + [ + pytest.param("pylock.toml", "executable", id="executable-lock"), + pytest.param(".pre-commit-config.yaml", "symlink", id="symlink-hook-config"), + ], +) +def test_upgrade_rejects_unsafe_file_mode( + lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, unsafe_mode: str +) -> None: + target = lock_repository / path + target.write_text("before\n") + run(lock_repository, "git", "add", path) + run(lock_repository, "git", "commit", "-m", "Add lock") + if unsafe_mode == "executable": + target.chmod(0o755) + else: + target.unlink() + target.symlink_to("other.txt") + patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout + run(lock_repository, "git", "restore", path) + patch_dir = tmp_path / ("release-tools-lock" if path == "pylock.toml" else "release-tools-hooks") + patch_dir.mkdir() + (patch_dir / ("lock.patch" if path == "pylock.toml" else "hooks.patch")).write_text(patch) + applied = execute( + lock_repository, + publish_scripts / ("publish-apply-lock-patch.sh" if path == "pylock.toml" else "publish-apply-hook-patch.sh"), + tmp_path, + ) + assert applied.returncode == 0, applied.stderr + result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-file-mode.sh", tmp_path) + assert result.returncode != 0 + assert "unexpected dependency file mode" in result.stderr + + +def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: Path, publish_scripts: Path) -> None: + lock = lock_repository / "pylock.toml" + config = lock_repository / ".pre-commit-config.yaml" + lock.write_text('lock-version = "1.0"\n') + config.write_text("repos: []\n") + run(lock_repository, "git", "add", ".") + run(lock_repository, "git", "commit", "-m", "Add dependency files") + lock.write_text('lock-version = "1.1"\n') + lock_patch = run(lock_repository, "git", "diff", "--binary", "--", "pylock.toml").stdout + config.write_text("repos: [local]\n") + hooks_patch = run(lock_repository, "git", "diff", "--binary", "--", ".pre-commit-config.yaml").stdout + run(lock_repository, "git", "restore", ".") + lock_artifact = tmp_path / "release-tools-lock" + lock_artifact.mkdir() + (lock_artifact / "lock.patch").write_text(lock_patch) + hooks_artifact = tmp_path / "release-tools-hooks" + hooks_artifact.mkdir() + (hooks_artifact / "hooks.patch").write_text(hooks_patch) + + applied_lock = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) + applied_hooks = execute(lock_repository, publish_scripts / "publish-apply-hook-patch.sh", tmp_path) + checked = execute( + lock_repository, + publish_scripts / "publish-check-dependency-patch-paths.sh", + tmp_path, + ) + + assert (applied_lock.returncode, applied_hooks.returncode, checked.returncode) == (0, 0, 0) + assert run(lock_repository, "git", "diff", "--cached", "--name-only").stdout.splitlines() == [ + ".pre-commit-config.yaml", + "pylock.toml", + ] + + +def test_upgrade_preserves_requested_pull_request_body( + lock_repository: Path, tmp_path: Path, publish_scripts: Path +) -> None: + result = execute( + lock_repository, + publish_scripts / "publish-write-dependency-pull-request-body.sh", + tmp_path, + PR_BODY="Refresh release tools.", + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "pull-request.md").read_text() == "Refresh release tools.\n" + + +def test_upgrade_finds_only_own_dependency_pull_request( + lock_repository: Path, + tmp_path: Path, + publish_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], +) -> None: + server, environment = github_api + server.expect_request( + "/api/v3/repos/tox-dev/fixture/pulls", + method="GET", + query_string="base=main&head=tox-dev%3Aautomation%2Fupdate-release-tools&state=open", + ).respond_with_json( + [ + { + "number": 17, + "head": {"repo": {"full_name": "attacker/fork"}, "ref": "automation/update-release-tools"}, + "base": {"ref": "main"}, + }, + { + "number": 18, + "head": {"repo": {"full_name": "tox-dev/fixture"}, "ref": "automation/update-release-tools"}, + "base": {"ref": "main"}, + }, + ] + ) + result = execute( + lock_repository, + publish_scripts / "publish-pr.sh", + tmp_path, + **environment, + GITHUB_REPOSITORY="tox-dev/fixture", + GITHUB_OUTPUT=str(tmp_path / "output"), + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == "number=18\n" + + +def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(script)], + cwd=repository, + env=os.environ + | { + "RUNNER_TEMP": str(tmp_path), + "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"), + "ADDITIONAL_LOCK": "", + "ADDITIONAL_GROUPS": "", + "MATRIX_PYTHON": "", + "MATRIX_PLATFORMS": "", + "MATRIX_IMPLEMENTATIONS": "", + } + | environment, + text=True, + capture_output=True, + check=False, + ) diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py new file mode 100644 index 0000000..3a67a5a --- /dev/null +++ b/tests/validate_news/test_news.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import action, run + + +@pytest.mark.parametrize( + ("bot", "skip", "accepted"), + [ + pytest.param("false", "false", False, id="human-needs-fragment"), + pytest.param("true", "false", True, id="bot-needs-no-fragment"), + pytest.param("false", "true", True, id="human-can-skip-news"), + ], +) +def test_validate_news_without_fragment( + repository: Path, tmp_path: Path, action_scripts: Path, bot: str, skip: str, accepted: bool +) -> None: + result = action( + repository, + tmp_path, + action_scripts, + "validate-news", + version="0.2.0", + BASE_REF="main", + BOT_AUTHOR=bot, + SKIP_NEWS=skip, + ) + assert (result.returncode == 0) == accepted + if not accepted: + assert "add a changelog fragment" in result.stderr + + +@pytest.mark.parametrize( + "name", + [ + pytest.param("+addition.feature.md", id="named"), + pytest.param("+addition.feature.1.md", id="named-multiple"), + pytest.param("123.feature.2.md", id="numbered-multiple"), + ], +) +def test_validate_news_checks_bot_fragment(repository: Path, tmp_path: Path, action_scripts: Path, name: str) -> None: + (repository / "changelog.d" / name).write_text("Add another feature.\n") + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Add news") + result = action( + repository, + tmp_path, + action_scripts, + "validate-news", + version="0.2.0", + BASE_REF="main", + BOT_AUTHOR="true", + SKIP_NEWS="true", + ) + assert result.returncode == 0, result.stderr + + +def test_validate_news_reads_rst_categories(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + project = repository / "pyproject.toml" + project.write_text( + project.read_text() + .replace('directory = "changelog.d"', 'directory = "docs/changelog"') + .replace('filename = "CHANGELOG.md"', 'filename = "CHANGELOG.rst"') + .replace('directory = "feature", name = "Added"', 'directory = "fix", name = "Fixed"') + ) + (repository / "docs").mkdir() + (repository / "docs/changelog").mkdir() + (repository / "docs/changelog/+repair.fix.rst").write_text("Repair a user-facing bug.\n") + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Add RST news") + result = action( + repository, + tmp_path, + action_scripts, + "validate-news", + version="0.2.0", + BASE_REF="main", + BOT_AUTHOR="false", + SKIP_NEWS="false", + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("name", "content", "error"), + [ + pytest.param("+addition.unknown.md", "Add a feature.\n", "unsupported changelog fragment", id="category"), + pytest.param("addition.feature.md", "Add a feature.\n", "unsupported changelog fragment", id="name"), + pytest.param("+addition.feature.x.md", "Add a feature.\n", "unsupported changelog fragment", id="ordinal"), + pytest.param("+addition.feature.md", " \n", "empty changelog fragment", id="empty"), + ], +) +def test_validate_news_rejects_bad_fragment( + repository: Path, tmp_path: Path, action_scripts: Path, name: str, content: str, error: str +) -> None: + (repository / "changelog.d" / name).write_text(content) + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Add news") + result = action( + repository, + tmp_path, + action_scripts, + "validate-news", + version="0.2.0", + BASE_REF="main", + BOT_AUTHOR="true", + SKIP_NEWS="false", + ) + assert result.returncode != 0 + assert error in result.stderr + + +def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action( + repository, + tmp_path, + action_scripts, + "validate-news", + version="0.2.0", + BASE_REF="", + BOT_AUTHOR="true", + SKIP_NEWS="false", + ) + assert result.returncode != 0 + assert "news validation needs a PR base" in result.stderr diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py new file mode 100644 index 0000000..48edea9 --- /dev/null +++ b/tests/validate_python_distribution/test_distribution.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from zipfile import ZipFile + +import pytest +from conftest import action, run + + +@pytest.fixture +def distribution(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + project = source_project / "pyproject.toml" + project.write_text( + project.read_text() + '\n[dependency-groups]\npkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"]\n', + ) + run( + source_project, + str(nab_executable), + "lock", + "--python", + "3.15", + "--groups", + "pkg-meta", + ) + output = source_project / "dist" + run( + source_project, + "uv", + "build", + "--no-build-isolation", + "--python", + sys.executable, + "--sdist", + "--wheel", + "--out-dir", + str(output), + ) + return source_project, output + + +def test_validate_python_distribution_accepts_built_files( + distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path +) -> None: + repository = distribution[0] + result = action( + repository, + tmp_path, + action_scripts, + "validate-python-distribution", + version="0.2.0", + ) + assert result.returncode == 0, result.stderr + assert "PASSED" in result.stdout + + +def test_validate_python_distribution_rejects_invalid_sdist( + distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path +) -> None: + repository, output = distribution + next(output.glob("*.tar.gz")).write_bytes(b"invalid sdist") + result = action( + repository, + tmp_path, + action_scripts, + "validate-python-distribution", + version="0.2.0", + ) + assert result.returncode != 0 + + +def test_validate_python_distribution_rejects_bad_wheel_contents( + distribution: tuple[Path, Path], tmp_path: Path, action_scripts: Path +) -> None: + repository, output = distribution + wheel = next(output.glob("*.whl")) + with ZipFile(wheel) as archive: + contents = {name: archive.read(name) for name in archive.namelist()} + record = next(name for name in contents if name.endswith(".dist-info/RECORD")) + contents["fixture/compiled.pyc"] = b"invalid bytecode" + contents[record] += b"fixture/compiled.pyc,,\n" + with ZipFile(wheel, "w") as archive: + for name, content in contents.items(): + archive.writestr(name, content) + result = action( + repository, + tmp_path, + action_scripts, + "validate-python-distribution", + version="0.2.0", + ) + assert result.returncode != 0 + assert "W001" in result.stdout diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py new file mode 100644 index 0000000..a88f794 --- /dev/null +++ b/tests/validate_python_release/test_tagged.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest +import yaml +from conftest import run + + +def test_validator_uses_shared_distribution_action() -> None: + workflow = yaml.load( + (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text(), + Loader=yaml.BaseLoader, + ) + assert any(step.get("uses") == "$/validate-python-distribution" for step in workflow["jobs"]["validate"]["steps"]) + assert set(workflow["on"]["workflow_call"]["inputs"]) == { + "changelog", + "artifact-base-url", + "artifact-label", + "local-tool-source", + "source", + "artifact-prefix", + } + assert set(workflow["jobs"]) == {"source-check", "validate"} + + +@pytest.mark.parametrize( + ("tag_kind", "event_ref", "accepted"), + [ + pytest.param("lightweight", "refs/tags/0.2.0", True, id="lightweight-tag"), + pytest.param("annotated", "refs/tags/0.2.0", True, id="annotated-tag"), + pytest.param("branch", "refs/tags/0.2.0", False, id="same-name-branch"), + pytest.param("lightweight", "refs/heads/main", False, id="branch-event"), + pytest.param("off-main", "refs/tags/0.2.0", False, id="tagged-off-main-commit"), + ], +) +def test_validator_requires_exact_tag(repository: Path, tag_kind: str, event_ref: str, accepted: bool) -> None: + if tag_kind == "branch": + run(repository, "git", "branch", "0.2.0") + elif tag_kind == "off-main": + run(repository, "git", "switch", "-c", "release-candidate") + run(repository, "git", "commit", "--allow-empty", "-m", "Unmerged change") + run(repository, "git", "tag", "0.2.0") + elif tag_kind == "annotated": + run(repository, "git", "tag", "-a", "-m", "Release", "0.2.0") + else: + run(repository, "git", "tag", "0.2.0") + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text() + ) + verify = next( + step for step in workflow["jobs"]["validate"]["steps"] if step.get("name") == "Verify the exact release tag" + ) + result = subprocess.run( + ["bash", "-c", verify["run"]], + cwd=repository, + env=os.environ | {"GITHUB_REF": event_ref, "TAG": "0.2.0"}, + text=True, + capture_output=True, + check=False, + ) + assert (result.returncode == 0) is accepted, result.stderr diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index 3c27abf..8fdcf40 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -1,8 +1,11 @@ from __future__ import annotations +from hashlib import sha256 +from pathlib import Path from textwrap import dedent -lazy from pathlib import Path +import pytest +import yaml from conftest import action, run @@ -15,38 +18,114 @@ def test_validate_applies_patch(repository: Path, tmp_path: Path, action_scripts assert run(repository, "git", "diff", "--cached", "--name-only").stdout.strip() -def test_validate_custom_remote_branch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +def test_validate_applies_patch_in_fresh_worktree(repository: Path, tmp_path: Path, action_scripts: Path) -> None: assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 - run(repository, "git", "reset", "--hard", "HEAD") - run(repository, "git", "remote", "add", "release-remote", str(tmp_path / "remote.git")) - run(repository, "git", "push", "release-remote", "HEAD:release") - result = action( - repository, - tmp_path, - action_scripts, - "validate-release-patch", - version="0.2.0", - BRANCH="release", - REMOTE="release-remote", - EXPECTED_SHA="", + source_staged = run(repository, "git", "diff", "--cached", "--name-only").stdout + validation = tmp_path / "validation" + run(repository, "git", "worktree", "add", "--detach", str(validation), "HEAD") + result = action(validation, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") + assert result.returncode == 0, result.stderr + assert run(validation, "git", "diff", "--cached", "--name-only").stdout.strip() + assert run(repository, "git", "diff", "--cached", "--name-only").stdout == source_staged + + +def test_validate_steps_honor_working_directory() -> None: + config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-release-patch/action.yml").read_text()) + assert all( + step.get("working-directory", step.get("with", {}).get("working-directory")) + == "${{ inputs.working-directory }}" + for step in config["runs"]["steps"] + ) + + +def track_rst_changelog(repository: Path) -> None: + (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") + project = repository / "pyproject.toml" + project.write_text(project.read_text().replace('filename = "CHANGELOG.md"', 'filename = "CHANGELOG.rst"')) + run(repository, "git", "add", "CHANGELOG.rst", "pyproject.toml") + run(repository, "git", "commit", "-m", "Track RST changelog") + run(repository, "git", "push", "origin", "main") + + +@pytest.mark.parametrize( + "heading", + [ + pytest.param("0.2.0 (2026-10-20)", id="plain"), + pytest.param("`0.2.0 `_ - 2026-10-20", id="linked"), + ], +) +def test_validate_rst_heading(repository: Path, tmp_path: Path, action_scripts: Path, heading: str) -> None: + changelog = repository / "CHANGELOG.rst" + track_rst_changelog(repository) + changelog.write_text( + dedent("""\ + Changelog + ========= + + {heading} + {underline} + + Changes + ======= + + - Add a feature. + """).format(heading=heading, underline="=" * len(heading)), ) + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode == 0, result.stderr - assert run(repository, "git", "diff", "--cached", "--name-only").stdout.strip() -def test_validate_rejects_wrong_heading_prefix(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 +@pytest.mark.parametrize( + "marker", + [ + pytest.param("<<<<<<< HEAD", id="ours"), + pytest.param("||||||| base", id="base"), + pytest.param(">>>>>>> branch", id="theirs"), + ], +) +def test_validate_rejects_rst_conflict_marker( + repository: Path, tmp_path: Path, action_scripts: Path, marker: str +) -> None: + changelog = repository / "CHANGELOG.rst" + track_rst_changelog(repository) + changelog.write_text(f"Changelog\n=========\n\n0.2.0\n=====\n\n{marker}\n") + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) run(repository, "git", "reset", "--hard", "HEAD") - result = action( - repository, - tmp_path, - action_scripts, - "validate-release-patch", - version="0.2.0", - HEADING_PREFIX="### ", - ) + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") + assert result.returncode != 0 + assert "release changelog contains conflict markers" in result.stderr + + +@pytest.mark.parametrize("tracked", [pytest.param(True, id="tracked"), pytest.param(False, id="first-release")]) +def test_validate_rejects_staged_rst_conflict_marker( + repository: Path, tmp_path: Path, action_scripts: Path, tracked: bool +) -> None: + track_rst_changelog(repository) + changelog = repository / "CHANGELOG.rst" + if not tracked: + run(repository, "git", "rm", "CHANGELOG.rst") + run(repository, "git", "commit", "-m", "Remove RST changelog") + run(repository, "git", "push", "origin", "main") + changelog.write_text("Changelog\n=========\n\n<<<<<<< HEAD\n") + if not tracked: + run(repository, "git", "add", "-N", "CHANGELOG.rst") + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 - assert "has no section" in result.stderr + assert "conflict markers" in result.stderr + + +def test_validate_rejects_unchanged_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + fragment = repository / "changelog.d/+feature.feature.md" + fragment.unlink() + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") + assert result.returncode != 0 + assert "release patch does not change CHANGELOG.md" in result.stderr def test_validate_rejects_stale_branch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -58,7 +137,7 @@ def test_validate_rejects_stale_branch(repository: Path, tmp_path: Path, action_ action_scripts, "validate-release-patch", version="0.2.0", - EXPECTED_SHA="0000000000000000000000000000000000000000", + GITHUB_SHA="0000000000000000000000000000000000000000", ) assert result.returncode != 0 assert "changed during release preparation" in result.stderr @@ -87,6 +166,54 @@ def test_validate_rejects_noop_patch(repository: Path, tmp_path: Path, action_sc assert "release patch contains no changes" in result.stderr +def test_validate_rejects_changed_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + run(repository, "git", "reset", "--hard", "HEAD") + result = action( + repository, + tmp_path, + action_scripts, + "validate-release-patch", + version="0.2.0", + EXPECTED_PATCH_SHA="0" * 64, + ) + assert result.returncode != 0 + assert "release patch differs from the validated artifact" in result.stderr + + +def test_validate_requires_digest_when_checks_skipped(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + run(repository, "git", "reset", "--hard", "HEAD") + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", CHECKS="false") + assert result.returncode != 0 + assert "skipping release checks requires a validated patch digest" in result.stderr + + +def test_validate_rejects_unknown_check_mode(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + run(repository, "git", "reset", "--hard", "HEAD") + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0", CHECKS="sometimes") + assert result.returncode != 0 + assert "checks must be true or false" in result.stderr + + +def test_validate_key_job_uses_only_checked_patch(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + run(repository, "git", "reset", "--hard", "HEAD") + result = action( + repository, + tmp_path, + action_scripts, + "validate-release-patch", + version="0.2.0", + CHECKS="false", + EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), + PATH="/usr/bin:/bin", + ) + assert result.returncode == 0, result.stderr + assert run(repository, "git", "diff", "--cached", "--name-only").stdout.strip() + + def test_validate_rejects_unexpected_path(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "pyproject.toml").write_text((repository / "pyproject.toml").read_text() + "\n# changed\n") (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "--binary").stdout) @@ -96,13 +223,162 @@ def test_validate_rejects_unexpected_path(repository: Path, tmp_path: Path, acti assert "unexpected release path" in result.stderr +@pytest.mark.parametrize( + "fragment", + [ + pytest.param("evil.py", id="wrong-extension"), + pytest.param("nested/7.feature.md", id="nested"), + pytest.param("evil.feature.md", id="unsupported-stem"), + pytest.param("7.bugfix.md", id="unconfigured-category"), + pytest.param("7.feature.rst", id="wrong-format"), + pytest.param("7.feature.md", id="added-valid-fragment"), + ], +) +def test_validate_rejects_unsupported_fragment_path( + repository: Path, tmp_path: Path, action_scripts: Path, fragment: str +) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + path = repository / "changelog.d" / fragment + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("Unexpected release fragment.\n") + run(repository, "git", "add", str(path.relative_to(repository))) + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + run(repository, "git", "clean", "-fd") + result = action( + repository, + tmp_path, + action_scripts, + "validate-release-patch", + version="0.2.0", + CHECKS="false", + EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), + ) + assert result.returncode != 0 + assert "unexpected release path status" in result.stderr + + +def test_validate_rejects_renamed_fragment_source(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + source = repository / "changelog.d/template.jinja" + source.write_text("Add a feature.\n") + run(repository, "git", "add", str(source.relative_to(repository))) + run(repository, "git", "commit", "-m", "Track release template") + run(repository, "git", "push", "origin", "main") + run(repository, "git", "mv", str(source.relative_to(repository)), "changelog.d/1.feature.md") + (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-22)\n\n- Add a feature.\n") + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action( + repository, + tmp_path, + action_scripts, + "validate-release-patch", + version="0.2.0", + CHECKS="false", + EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), + ) + assert result.returncode != 0 + assert "unexpected release path status" in result.stderr + + +@pytest.mark.parametrize( + ("before", "after", "error"), + [ + pytest.param("[tool.towncrier]", "[tool.other]", "Towncrier configuration is required", id="missing-config"), + pytest.param('directory = "changelog.d"\n', "", "normalized relative paths", id="missing-directory"), + pytest.param('filename = "CHANGELOG.md"\n', "", "normalized relative paths", id="missing-filename"), + pytest.param( + 'type = [{ directory = "feature", name = "Added", showcontent = true }]\n', + "", + "Towncrier fragment categories are required", + id="missing-categories", + ), + ], +) +def test_validate_requires_explicit_towncrier_config( + repository: Path, tmp_path: Path, action_scripts: Path, before: str, after: str, error: str +) -> None: + project = repository / "pyproject.toml" + project.write_text(project.read_text().replace(before, after)) + run(repository, "git", "add", "pyproject.toml") + run(repository, "git", "commit", "-m", "Remove release configuration") + run(repository, "git", "push", "origin", "main") + (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-22)\n\n- Add a feature.\n") + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action( + repository, + tmp_path, + action_scripts, + "validate-release-patch", + version="0.2.0", + CHECKS="false", + EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), + ) + assert result.returncode != 0 + assert error in result.stderr + + +@pytest.mark.parametrize( + "file_mode", + [pytest.param("executable", id="executable"), pytest.param("symlink", id="symlink")], +) +def test_validate_rejects_unsafe_file_mode( + repository: Path, tmp_path: Path, action_scripts: Path, file_mode: str +) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + if file_mode == "executable": + (repository / "CHANGELOG.md").chmod(0o755) + else: + (repository / "CHANGELOG.md").unlink() + (repository / "CHANGELOG.md").symlink_to("pyproject.toml") + run(repository, "git", "add", "CHANGELOG.md") + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") + assert result.returncode != 0 + assert "unexpected release file mode" in result.stderr + + +def test_validate_rejects_nonregular_deleted_fragment(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + fragment = repository / "changelog.d/7.feature.md" + fragment.symlink_to("../CHANGELOG.md") + run(repository, "git", "add", "changelog.d/7.feature.md") + run(repository, "git", "commit", "-m", "Track linked fragment") + run(repository, "git", "push", "origin", "main") + (repository / "CHANGELOG.md").write_text("# Changelog\n\n## 0.2.0 (2026-10-21)\n\n- Add a feature.\n") + fragment.unlink() + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action( + repository, + tmp_path, + action_scripts, + "validate-release-patch", + version="0.2.0", + CHECKS="false", + EXPECTED_PATCH_SHA=sha256((tmp_path / "release.patch").read_bytes()).hexdigest(), + ) + assert result.returncode != 0 + assert "unexpected previous release file mode" in result.stderr + + +def test_validate_rejects_removed_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + (repository / "CHANGELOG.md").unlink() + (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "HEAD", "--binary").stdout) + run(repository, "git", "reset", "--hard", "HEAD") + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") + assert result.returncode != 0 + assert "unexpected release file mode in CHANGELOG.md" in result.stderr + + def test_validate_rejects_missing_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None: (repository / "CHANGELOG.md").write_text("# Changelog\n\nOther notes.\n") (tmp_path / "release.patch").write_text(run(repository, "git", "diff", "--binary").stdout) run(repository, "git", "restore", ".") result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 - assert "has no section" in result.stderr + assert "has no valid section" in result.stderr def test_validate_rejects_embedded_heading(repository: Path, tmp_path: Path, action_scripts: Path) -> None: @@ -111,15 +387,16 @@ def test_validate_rejects_embedded_heading(repository: Path, tmp_path: Path, act run(repository, "git", "restore", ".") result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") assert result.returncode != 0 - assert "has no section" in result.stderr + assert "has no valid section" in result.stderr def test_validate_rejects_prek_changes(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / ".pre-commit-config.yaml").write_text( - "repos:\n - repo: local\n hooks:\n - id: release-check\n" - " name: release-check\n" - " entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then printf changed >> CHANGELOG.md; fi'\n" - " language: system\n pass_filenames: false\n", + config = repository / ".pre-commit-config.yaml" + config.write_text( + config.read_text().replace( + "entry: bash -c true", + "entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then printf changed >> CHANGELOG.md; fi'", + ), ) run(repository, "git", "add", ".") run(repository, "git", "commit", "-m", "Check release tree") @@ -139,11 +416,12 @@ def test_validate_rejects_prek_changes(repository: Path, tmp_path: Path, action_ def test_validate_rejects_untracked_prek_output(repository: Path, tmp_path: Path, action_scripts: Path) -> None: - (repository / ".pre-commit-config.yaml").write_text( - "repos:\n - repo: local\n hooks:\n - id: release-check\n" - " name: release-check\n" - " entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then touch extra-output; fi'\n" - " language: system\n pass_filenames: false\n", + config = repository / ".pre-commit-config.yaml" + config.write_text( + config.read_text().replace( + "entry: bash -c true", + "entry: bash -c 'if [[ $VALIDATE_REHEARSAL == 1 ]]; then touch extra-output; fi'", + ), ) run(repository, "git", "add", ".") run(repository, "git", "commit", "-m", "Check release tree") diff --git a/tools/build_constraints.py b/tools/build_constraints.py new file mode 100644 index 0000000..046a4ae --- /dev/null +++ b/tools/build_constraints.py @@ -0,0 +1,160 @@ +"""Export hashed build constraints from selected PEP 751 lock targets.""" + +from __future__ import annotations + +import argparse +import tomllib +from pathlib import Path + +from packaging.markers import Environment, default_environment +from packaging.pylock import Pylock, PylockSelectError, PylockValidationError +from packaging.requirements import Requirement +from packaging.tags import Tag +from packaging.utils import canonicalize_name, parse_wheel_filename +from packaging.version import Version + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("locks", nargs="+", type=Path) + parser.add_argument("--shared-policy", type=Path) + parser.add_argument("--target-platform") + parser.add_argument("--targets") + args = parser.parse_args() + if bool(args.target_platform) != bool(args.targets): + parser.error("target-platform and targets must be supplied together") + if len(args.locks) > 2: + parser.error("at most one shared and one caller lock are supported") + if args.shared_policy and len(args.locks) != 2: + parser.error("shared-policy requires a shared and caller lock") + try: + locks = [] + for path in args.locks: + with path.open("rb") as stream: + locks.append(Pylock.from_dict(tomllib.load(stream))) + requirements = _shared_requirements(args.shared_policy) if args.shared_policy else {} + targets = _target_environments(args.target_platform, args.targets) if args.targets else [None] + selected_by_target = [_selected_build(locks, environment, requirements) for environment in targets] + versions = [{name: version for name, (version, _) in selected.items()} for selected in selected_by_target] + if any(other != versions[0] for other in versions[1:]): + parser.error("build package versions differ across requested targets") + selected = selected_by_target[0] + for other in selected_by_target[1:]: + for name, (_, hashes) in other.items(): + selected[name][1].update(hashes) + args.output.write_text( + "".join( + f"{name}=={version} {' '.join(f'--hash=sha256:{digest}' for digest in sorted(hashes))}\n" + for name, (version, hashes) in sorted(selected.items()) + ), + ) + except ( + OSError, + ValueError, + KeyError, + PylockValidationError, + PylockSelectError, + ) as error: + parser.error(str(error)) + + +def _shared_requirements(path: Path) -> dict[str, Requirement]: + with path.open("rb") as stream: + project = tomllib.load(stream) + requirements = (Requirement(value) for value in project["dependency-groups"]["build"]) + return {canonicalize_name(requirement.name): requirement for requirement in requirements} + + +def _target_environments(platform: str, values: str) -> list[Environment]: + platforms = { + "linux_x86_64": ("linux", "Linux", "x86_64", "posix"), + "linux_aarch64": ("linux", "Linux", "aarch64", "posix"), + "macos_x86_64": ("darwin", "Darwin", "x86_64", "posix"), + "macos_arm64": ("darwin", "Darwin", "arm64", "posix"), + "windows_amd64": ("win32", "Windows", "AMD64", "nt"), + "windows_arm64": ("win32", "Windows", "ARM64", "nt"), + } + if platform not in platforms: + raise ValueError(f"unknown target platform: {platform}") + environments = [] + for value in values.replace(",", " ").split(): + version_text, separator, implementation = value.partition(":") + version = Version(version_text) + if not separator or len(version.release) != 3 or implementation not in {"cpython", "pypy"}: + raise ValueError(f"invalid target: {value}; expected full-version:cpython|pypy") + system_platform, platform_system, machine, os_name = platforms[platform] + environment = default_environment() + environment.update( + python_version=f"{version.major}.{version.minor}", + python_full_version=str(version), + implementation_version=str(version), + implementation_name=implementation, + platform_python_implementation="CPython" if implementation == "cpython" else "PyPy", + sys_platform=system_platform, + platform_system=platform_system, + platform_machine=machine, + os_name=os_name, + platform_release="", + platform_version="", + ) + environments.append(environment) + if not environments: + raise ValueError("at least one target is required") + return environments + + +def _selected_build( + locks: list[Pylock], environment: Environment | None, requirements: dict[str, Requirement] +) -> dict[str, tuple[Version, set[str]]]: + selected: dict[str, tuple[Version, set[str]]] = {} + for index, lock in enumerate(locks): + if "build" not in (lock.dependency_groups or ()): + if len(locks) == 2: + continue + if lock.dependency_groups: + raise ValueError("custom lock has dependency groups but no build group") + groups: set[str] = set() + else: + groups = {"build"} + # Artifact choice is left to the installer; constraints carry every locked hash. + tags: list[Tag] | None = None + if environment is not None: + tags = sorted( + { + tag + for package in lock.packages + for wheel in package.wheels or () + for tag in parse_wheel_filename(wheel.filename)[3] + }, + key=str, + ) + for package, _ in lock.select(environment=environment, tags=tags, dependency_groups=groups): + if package.version is None or package.index is None: + raise ValueError(f"build dependency {package.name} must be an index version") + name = canonicalize_name(package.name) + hashes = { + artifact.hashes["sha256"] + for artifact in [*(package.wheels or ()), *((package.sdist,) if package.sdist else ())] + if "sha256" in artifact.hashes + } + if not hashes: + raise ValueError(f"build dependency {package.name} has no SHA256 hash") + if (existing := selected.get(name)) is not None and existing[0] != package.version: + if index != 1 or not (requirement := requirements.get(name)): + raise ValueError(f"conflicting build dependency versions for {name}") + if package.version not in requirement.specifier: + raise ValueError(f"caller build dependency {name}=={package.version} violates {requirement}") + elif existing is not None: + hashes.update(existing[1]) + selected[name] = (package.version, hashes) + return selected + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py new file mode 100644 index 0000000..f6341f4 --- /dev/null +++ b/tools/check_lock_groups.py @@ -0,0 +1,50 @@ +"""Reject dependency groups absent from a PEP 751 lock.""" + +from __future__ import annotations + +import argparse +import tomllib +from pathlib import Path + +from packaging.pylock import Pylock, PylockValidationError +from packaging.version import Version + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("lock", type=Path) + parser.add_argument("groups", nargs="+") + parser.add_argument("--shared-lock", type=Path) + parser.add_argument("--local-tool-source", action="store_true") + args = parser.parse_args() + try: + with args.lock.open("rb") as stream: + lock = Pylock.from_dict(tomllib.load(stream)) + if missing := set(args.groups) - set(lock.dependency_groups or ()): + parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") + if args.shared_lock: + with args.shared_lock.open("rb") as stream: + shared = Pylock.from_dict(tomllib.load(stream)) + required = next(package.version for package in shared.packages if package.name == "release-creator") + installed = next((package.version for package in lock.packages if package.name == "release-creator"), None) + needs_release_creator = bool( + set(args.groups) + & {"release-news", "release-notes", "release-prepare", "release-validate", "release-hooks"} + ) + if ( + needs_release_creator + and not args.local_tool_source + and (installed is None or Version(str(installed)) < Version(str(required))) + ): + parser.error(f"relock: release-creator {required} required") + except (OSError, PylockValidationError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] diff --git a/tools/check_nab_lock.py b/tools/check_nab_lock.py new file mode 100644 index 0000000..cadca68 --- /dev/null +++ b/tools/check_nab_lock.py @@ -0,0 +1,39 @@ +"""Compare a regenerated Nab lock while ignoring the command used to regenerate it.""" + +from __future__ import annotations + +import argparse +import tomllib +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--environments-only", action="store_true") + parser.add_argument("committed", type=Path) + parser.add_argument("regenerated", type=Path) + args = parser.parse_args() + try: + before = args.committed.read_bytes() + old = tomllib.loads(before.decode()) + new = tomllib.loads(args.regenerated.read_text()) + if args.environments_only: + if old.get("environments") != new.get("environments"): + parser.error("regenerated Nab lock changed its target environments") + return + old["tool"]["nab"].pop("command-line", None) + new["tool"]["nab"].pop("command-line", None) + if old != new: + parser.error("regenerated Nab lock differs from the committed lock") + args.regenerated.write_bytes(before) + except (OSError, UnicodeDecodeError, KeyError, tomllib.TOMLDecodeError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] diff --git a/tools/check_recent_cutoffs.py b/tools/check_recent_cutoffs.py new file mode 100644 index 0000000..a1b6cec --- /dev/null +++ b/tools/check_recent_cutoffs.py @@ -0,0 +1,28 @@ +"""Reject recent Nab cutoffs without an explicit reviewed exception.""" + +from __future__ import annotations + +import argparse +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from nab_cutoffs import validate_recent_cutoffs + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("project", type=Path) + args = parser.parse_args() + try: + validate_recent_cutoffs(args.project.read_text(), datetime.now(UTC) - timedelta(days=7)) + except (OSError, KeyError, ValueError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] diff --git a/tools/expire_cutoffs.py b/tools/expire_cutoffs.py new file mode 100644 index 0000000..7b26a44 --- /dev/null +++ b/tools/expire_cutoffs.py @@ -0,0 +1,32 @@ +"""Remove reviewed Nab cutoffs once the global seven-day window covers them.""" + +from __future__ import annotations + +import argparse +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from nab_cutoffs import remove_expired_cutoffs +from tomlkit.exceptions import ParseError + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("project", type=Path) + args = parser.parse_args() + try: + source = args.project.read_text() + updated = remove_expired_cutoffs(source, datetime.now(UTC) - timedelta(days=7)) + if source != updated: + args.project.write_text(updated) + except (OSError, KeyError, ValueError, ParseError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py new file mode 100644 index 0000000..d65df7f --- /dev/null +++ b/tools/nab_cutoffs.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import tomllib +from datetime import UTC, datetime + +_REVIEWED_EXCEPTIONS = { + **dict.fromkeys( + ("nab", "nab-index", "nab-markersets", "nab-project", "nab-provider", "nab-resolver"), + datetime(2026, 10, 19, tzinfo=UTC), + ), + "release-creator": datetime(2026, 10, 23, 2, 44, 40, tzinfo=UTC), +} + + +def validate_recent_cutoffs(project: str, threshold: datetime) -> None: + nab = tomllib.loads(project)["tool"]["nab"] + candidates = [ + (name, settings["uploaded-prior-to"]) + for name, settings in nab.get("packages", {}).items() + if "uploaded-prior-to" in settings + ] + candidates.extend( + (name, rule["uploaded-prior-to"]) + for rule in nab.get("package-rules", []) + if "uploaded-prior-to" in rule + for name in rule["match"] + ) + for name, value in candidates: + if isinstance(value, str) and value.startswith("P"): + continue + timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value + if timestamp.tzinfo is None: + raise ValueError(f"Nab package cutoff has no timezone: {value}") + if timestamp > threshold and timestamp > _REVIEWED_EXCEPTIONS.get(name, datetime.min.replace(tzinfo=UTC)): + raise ValueError(f"unreviewed recent Nab cutoff for {name}: {timestamp.isoformat()}") + + +def remove_expired_cutoffs(project: str, threshold: datetime) -> str: + import tomlkit + + names, positions = expired_cutoffs(project, threshold) + if not names and not positions: + return project + document = tomlkit.parse(project) + remove_cutoffs(document["tool"]["nab"], names, positions) + return tomlkit.dumps(document) + + +def remove_cutoffs(nab: dict, names: set[str], positions: set[int]) -> None: + for name in names: + settings = nab["packages"][name] + del settings["uploaded-prior-to"] + if not settings: + del nab["packages"][name] + if "packages" in nab and not nab["packages"]: + del nab["packages"] + for index in sorted(positions, reverse=True): + rule = nab["package-rules"][index] + del rule["uploaded-prior-to"] + if set(rule) == {"match"}: + del nab["package-rules"][index] + if "package-rules" in nab and not nab["package-rules"]: + del nab["package-rules"] + + +def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[int]]: + nab = tomllib.loads(project)["tool"]["nab"] + packages = { + name + for name, settings in nab.get("packages", {}).items() + if (value := settings.get("uploaded-prior-to")) is not None and _expired(value, threshold) + } + rules = { + index + for index, rule in enumerate(nab.get("package-rules", [])) + if (value := rule.get("uploaded-prior-to")) is not None and _expired(value, threshold) + } + return packages, rules + + +def _expired(value: str | datetime, threshold: datetime) -> bool: + if isinstance(value, str) and value.startswith("P"): + return False + timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value + if timestamp.tzinfo is None: + raise ValueError(f"Nab package cutoff has no timezone: {value}") + return timestamp <= threshold + + +__all__ = [ + "expired_cutoffs", + "remove_cutoffs", + "remove_expired_cutoffs", + "validate_recent_cutoffs", +] diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py new file mode 100644 index 0000000..57c57d5 --- /dev/null +++ b/tools/verify_cutoff_patch.py @@ -0,0 +1,49 @@ +"""Allow only expired Nab upload cutoffs to change in pyproject.toml.""" + +from __future__ import annotations + +import argparse +import subprocess +import tomllib +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from nab_cutoffs import expired_cutoffs, remove_cutoffs + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("project", nargs="?", type=Path, default=Path("pyproject.toml")) + args = parser.parse_args() + try: + baseline = subprocess.run( + ["git", "show", f"HEAD:{args.project.as_posix()}"], + check=True, + capture_output=True, + text=True, + ).stdout + original = tomllib.loads(baseline) + actual = tomllib.loads(args.project.read_text()) + names, positions = expired_cutoffs(baseline, datetime.now(UTC) - timedelta(days=7)) + before = original["tool"]["nab"] + after = actual["tool"]["nab"] + removed_names = { + name for name in names if after.get("packages", {}).get(name, {}).get("uploaded-prior-to") is None + } + removed_positions = { + index for index in positions if before["package-rules"][index] not in after.get("package-rules", []) + } + remove_cutoffs(before, removed_names, removed_positions) + if original != actual: + parser.error("pyproject.toml has changes beyond expired Nab cutoffs") + except (OSError, KeyError, ValueError, subprocess.CalledProcessError, tomllib.TOMLDecodeError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] diff --git a/tox.toml b/tox.toml new file mode 100644 index 0000000..acea0f7 --- /dev/null +++ b/tox.toml @@ -0,0 +1,9 @@ +requires = ["tox>=4.64.7", "tox-uv>=1.36.1"] +env_list = ["type"] + +[env.type] +description = "type-check Action tests" +base_python = "3.15" +skip_install = true +dependency_groups = ["test"] +commands = [["ty", "check", "--python-platform", "all", "--error", "all", "--output-format", "concise", "tests"]] diff --git a/validate-news/action.yml b/validate-news/action.yml new file mode 100644 index 0000000..69758b6 --- /dev/null +++ b/validate-news/action.yml @@ -0,0 +1,30 @@ +name: Validate changelog news +description: Require a fragment for human changes and validate any fragment supplied by a bot +runs: + using: composite + steps: + - name: Count changed fragments + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count" + - name: Check changed fragments with Towncrier + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: | + read -r fragments <"$RUNNER_TEMP/news-fragment-count" + if ((fragments)); then + towncrier check --compare-with "origin/$BASE_REF" + fi + - name: Require news for human changes + shell: bash + env: + SKIP_NEWS: ${{ contains(github.event.pull_request.labels.*.name, 'skip news') }} + BOT_AUTHOR: ${{ github.event.pull_request.user.type == 'Bot' }} + run: | + read -r fragments <"$RUNNER_TEMP/news-fragment-count" + if ((! fragments)) && [[ "$SKIP_NEWS" != true && "$BOT_AUTHOR" != true ]]; then + echo '::error::add a changelog fragment or apply the skip news label' >&2 + exit 1 + fi diff --git a/validate-python-distribution/action.yml b/validate-python-distribution/action.yml new file mode 100644 index 0000000..2fc7b19 --- /dev/null +++ b/validate-python-distribution/action.yml @@ -0,0 +1,16 @@ +name: Validate Python distribution +description: Check the exact wheel and sdist before uploading them +runs: + using: composite + steps: + - name: Install locked package validators + uses: $/install-locked-tools + with: + groups: pkg-meta + venv: ${{ runner.temp }}/package-validators + - name: Check distribution metadata + shell: bash + run: uv run --no-project --python "$RUNNER_TEMP/package-validators" twine check dist/* + - name: Check wheel contents + shell: bash + run: uv run --no-project --python "$RUNNER_TEMP/package-validators" check-wheel-contents --no-config dist diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 0bd6a46..f048ed1 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -1,5 +1,5 @@ name: Validate release patch -description: Apply the prepared patch to the original main commit and rerun prek +description: Check and apply a prepared release patch, with optional release-tool validation inputs: version: description: Release version @@ -7,81 +7,176 @@ inputs: patch: description: Path to the downloaded patch required: true - changelog: - description: Changelog path - required: true - fragments: - description: Fragment directory - required: true - heading-prefix: - description: Literal prefix of the version heading - default: "## " - branch: - description: Protected release branch - default: main - remote: - description: Git remote containing the release branch - default: origin - expected-sha: - description: Exact branch commit used for release preparation + working-directory: + description: Checkout to validate and apply the patch in + default: . + expected-patch-sha: + description: SHA-256 digest from a separate keyless validation job default: "" + checks: + description: Run release-creator and prek after applying the patch + default: "true" runs: using: composite steps: - - name: Verify release source + - name: Require a clean release checkout shell: bash - env: - BRANCH: ${{ inputs.branch }} - REMOTE: ${{ inputs.remote }} - EXPECTED_SHA: ${{ inputs.expected-sha }} + working-directory: ${{ inputs.working-directory }} run: | - set -euo pipefail if [[ -n $(git status --porcelain) ]]; then echo '::error::release validation requires a clean checkout' >&2 exit 1 fi - git fetch "$REMOTE" "$BRANCH" - if [[ $(git rev-parse "$REMOTE/$BRANCH") != "${EXPECTED_SHA:-$GITHUB_SHA}" ]]; then - echo "::error::$BRANCH changed during release preparation; dispatch again" >&2 + - name: Verify release source + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + git fetch origin main + if [[ $(git rev-parse origin/main) != "$GITHUB_SHA" ]]; then + echo '::error::main changed during release preparation; dispatch again' >&2 exit 1 fi - - name: Apply and inspect release patch + - name: Apply release patch shell: bash + working-directory: ${{ inputs.working-directory }} env: - VERSION: ${{ inputs.version }} PATCH: ${{ inputs.patch }} - CHANGELOG: ${{ inputs.changelog }} - FRAGMENTS: ${{ inputs.fragments }} - HEADING_PREFIX: ${{ inputs.heading-prefix }} + EXPECTED_PATCH_SHA: ${{ inputs.expected-patch-sha }} + CHECKS: ${{ inputs.checks }} run: | set -euo pipefail + if [[ "$CHECKS" != true && "$CHECKS" != false ]]; then + echo '::error::checks must be true or false' >&2 + exit 1 + fi + if [[ "$CHECKS" == false && -z "$EXPECTED_PATCH_SHA" ]]; then + echo '::error::skipping release checks requires a validated patch digest' >&2 + exit 1 + fi + read -r patch_sha _ < <(sha256sum "$PATCH") + if [[ -n "$EXPECTED_PATCH_SHA" && "$patch_sha" != "$EXPECTED_PATCH_SHA" ]]; then + echo '::error::release patch differs from the validated artifact' >&2 + exit 1 + fi git apply --check "$PATCH" git apply --index "$PATCH" if git diff --cached --quiet; then echo '::error::release patch contains no changes' >&2 exit 1 fi - mapfile -d '' -t paths < <(git diff --cached --name-only -z) - for path in "${paths[@]}"; do - if [[ "$path" != "$CHANGELOG" && "$path" != "$FRAGMENTS/"* ]]; then + - id: config + uses: $/read-towncrier-config + with: + working-directory: ${{ inputs.working-directory }} + - name: Check release patch paths + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + CHANGELOG: ${{ steps.config.outputs.changelog }} + FRAGMENTS: ${{ steps.config.outputs.fragments }} + run: | + set -euo pipefail + git diff --cached --name-status --no-renames -z >"$RUNNER_TEMP/release-paths" + while IFS= read -r -d '' status && IFS= read -r -d '' path; do + if [[ "$path" == "$CHANGELOG" ]]; then + continue + fi + if [[ "$path" != "$FRAGMENTS/"* ]]; then echo "::error::unexpected release path: $path" >&2 exit 1 fi - done + if [[ "$status" != D ]]; then + echo "::error::unexpected release path status: $status $path" >&2 + exit 1 + fi + done <"$RUNNER_TEMP/release-paths" - heading_found=false - mapfile -t changelog_lines <"$CHANGELOG" - for line in "${changelog_lines[@]}"; do - if [[ "$line" == "$HEADING_PREFIX$VERSION ("* ]]; then - heading_found=true + if git diff --cached --quiet -- "$CHANGELOG"; then + echo "::error::release patch does not change $CHANGELOG" >&2 + exit 1 + fi + - name: Check consumed fragments + if: inputs.checks == 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: release-creator validate-fragment-deletions + - name: Check release patch file modes + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + FRAGMENTS: ${{ steps.config.outputs.fragments }} + run: | + set -euo pipefail + git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes" + while IFS= read -r -d '' metadata && IFS= read -r -d '' path; do + read -ra fields <<<"$metadata" + case "${fields[4]}" in + A) + old_mode=000000 + new_mode=100644 + ;; + M) + old_mode=100644 + new_mode=100644 + ;; + D) + old_mode=100644 + new_mode=000000 + if [[ "$path" != "$FRAGMENTS/"* ]]; then + echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2 + exit 1 + fi + ;; + *) + echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2 + exit 1 + ;; + esac + if [[ "${fields[0]#\:}" != "$old_mode" ]]; then + echo "::error::unexpected previous release file mode in $path: ${fields[0]#\:}" >&2 + exit 1 fi - done - if [[ "$heading_found" != true ]]; then - echo "::error::$CHANGELOG has no section for $VERSION" >&2 + if [[ "${fields[1]}" != "$new_mode" ]]; then + echo "::error::unexpected release file mode in $path: ${fields[1]}" >&2 + exit 1 + fi + done <"$RUNNER_TEMP/release-modes" + - name: Check RST conflict markers + if: steps.config.outputs.format == 'rst' + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + CHANGELOG: ${{ steps.config.outputs.changelog }} + run: | + set -euo pipefail + git diff --cached --unified=0 -- "$CHANGELOG" >"$RUNNER_TEMP/release-rst-diff" + while IFS= read -r line; do + case "$line" in + +'<<<<<<<'* | +'|||||||'* | +'>>>>>>>'*) + echo '::error::release changelog contains conflict markers' >&2 + exit 1 + ;; + esac + done <"$RUNNER_TEMP/release-rst-diff" + printf '%s conflict-marker-size=10\n' "$CHANGELOG" >>"$(git rev-parse --git-path info/attributes)" + - name: Check rendered release section + if: inputs.checks == 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + VERSION: ${{ inputs.version }} + CHANGELOG: ${{ steps.config.outputs.changelog }} + run: | + if ! release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$GITHUB_REPOSITORY" \ + >/dev/null; then + echo "::error::$CHANGELOG has no valid section for $VERSION" >&2 exit 1 fi - name: Run prek on release patch + if: inputs.checks == 'true' shell: bash + working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail git diff --cached --check diff --git a/verify-nab-cutoff-patch/action.yml b/verify-nab-cutoff-patch/action.yml new file mode 100644 index 0000000..e4d4fbf --- /dev/null +++ b/verify-nab-cutoff-patch/action.yml @@ -0,0 +1,8 @@ +name: Verify Nab cutoff patch +description: Reject pyproject changes other than expired Nab package cutoffs +runs: + using: composite + steps: + - name: Verify pyproject policy + shell: bash + run: python3 "$GITHUB_ACTION_PATH/../tools/verify_cutoff_patch.py"