diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 3d52bb29..4f1ba82e 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -6,6 +6,12 @@ enabled: true, automerge: true, }, + // The workflow examples in the README live in docs/examples/ as real + // workflows, so that zizmor can lint them. Renovate doesn't look outside + // .github/ on its own, and the pins would silently rot. + "github-actions": { + managerFilePatterns: ["/^docs/examples/.*\\.ya?ml$/"], + }, packageRules: [ { groupName: "all dependencies", diff --git a/.github/scripts/check_documented_inputs.py b/.github/scripts/check_documented_inputs.py new file mode 100644 index 00000000..496c245d --- /dev/null +++ b/.github/scripts/check_documented_inputs.py @@ -0,0 +1,122 @@ +"""Check that action.yml and the documentation agree on the action's inputs. + +Two directions, because they rot differently: + +1. Every input used in a docs/examples/ workflow must exist in action.yml. A + typo here is invisible otherwise: GitHub Actions only logs an "Unexpected + input(s)" warning, actionlint's input database is keyed by tag so it never + fires on a SHA-pinned `uses:`, and zizmor doesn't look at inputs at all. + +2. Every input in action.yml must appear in the README's "All options" block, + which is meant to be exhaustive. USE_GH_PAGES_HTML_URL shipped in v3.36 and + went undocumented for the best part of a year for want of this check. + +Everything this reads is located by structure (a heading, a key), and a +checker that silently finds nothing is worse than no checker -- it reads as a +pass. So each lookup asserts it found something, and the script fails loudly +if the shape of action.yml or the README changes underneath it. +""" + +from __future__ import annotations + +import pathlib +import re +import sys +from typing import Any + +import yaml + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +ACTION = ROOT / "action.yml" +README = ROOT / "README.md" +EXAMPLES = ROOT / "docs" / "examples" + +ACTION_REPO = "py-cov-action/python-coverage-comment-action" +OPTIONS_HEADING = "### All options" + + +class CheckFailed(Exception): + pass + + +def declared_inputs() -> set[str]: + inputs = yaml.safe_load(ACTION.read_text()).get("inputs") + if not inputs: + raise CheckFailed(f"no inputs found in {ACTION.name}") + return set(inputs) + + +def steps(workflow: dict[str, Any]): + for job in (workflow.get("jobs") or {}).values(): + yield from job.get("steps") or [] + + +def used_inputs() -> dict[str, set[str]]: + """Inputs passed to this action, per example file.""" + used: dict[str, set[str]] = {} + for path in sorted(EXAMPLES.rglob("*.yml")): + workflow = yaml.safe_load(path.read_text()) + for step in steps(workflow): + if not str(step.get("uses", "")).startswith(f"{ACTION_REPO}@"): + continue + keys = set(step.get("with") or {}) + if keys: + used.setdefault(str(path.relative_to(ROOT)), set()).update(keys) + if not used: + raise CheckFailed(f"no {ACTION_REPO} step with inputs found under {EXAMPLES}") + return used + + +def documented_inputs() -> set[str]: + readme = README.read_text() + _, _, after = readme.partition(f"\n{OPTIONS_HEADING}\n") + if not after: + raise CheckFailed(f"heading {OPTIONS_HEADING!r} not found in README.md") + block = re.search(r"^```yaml.*?\n(.*?)^```$", after, re.DOTALL | re.MULTILINE) + if not block: + raise CheckFailed(f"no yaml block under {OPTIONS_HEADING!r}") + documented = { + key for step in yaml.safe_load(block[1]) for key in (step.get("with") or {}) + } + if not documented: + raise CheckFailed(f"no inputs listed under {OPTIONS_HEADING!r}") + return documented + + +def main() -> int: + try: + declared = declared_inputs() + used = used_inputs() + documented = documented_inputs() + except CheckFailed as exc: + print(f"error: {exc}", file=sys.stderr) + print("(the check could not read what it expected; fix it)", file=sys.stderr) + return 1 + + failed = False + for path, keys in used.items(): + if unknown := sorted(keys - declared): + failed = True + print( + f"{path}: not an input of the action: {', '.join(unknown)}", + file=sys.stderr, + ) + + if missing := sorted(declared - documented): + failed = True + print( + f"README.md: {OPTIONS_HEADING!r} is missing: {', '.join(missing)}", + file=sys.stderr, + ) + if extra := sorted(documented - declared): + failed = True + print( + f"README.md: {OPTIONS_HEADING!r} documents unknown inputs: {', '.join(extra)}", + file=sys.stderr, + ) + + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/sync_readme_examples.py b/.github/scripts/sync_readme_examples.py new file mode 100644 index 00000000..fb2bca08 --- /dev/null +++ b/.github/scripts/sync_readme_examples.py @@ -0,0 +1,121 @@ +"""Sync the workflow examples in docs/examples/ into the README. + +The files under docs/examples/ are the source of truth: they're real workflows, +so zizmor lints them and renovate keeps their `uses:` pins current. The README +only holds a copy, marked up as: + + ```yaml title="docs/examples/basic-usage/ci.yml" + +GitHub renders that fence exactly like a plain ```yaml one -- everything after +the language is dropped -- so the marker is invisible in the rendered README. + +Add `lines=` to show only part of a file, for snippets that would be noise as a +whole workflow: + + ```yaml title="docs/examples/enforce-coverage/ci.yml" lines=24-31 + +Line numbers do drift when the example is edited. The sync rewrites the README +in the same commit, so drift shows up as a README diff rather than silently; +on top of that a slice must start on a `- ` step, which catches a range that +has slid into the middle of a mapping. + +Run with --check to fail instead of rewriting (the pre-commit hook rewrites, +which lets autofix.ci push the result). +""" + +from __future__ import annotations + +import argparse +import difflib +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +README = ROOT / "README.md" +EXAMPLES = ROOT / "docs" / "examples" + +BLOCK = re.compile( + r'^```yaml title="(?P[^"]+)"(?P lines=(?P\d+)-(?P\d+))?\n' + r"(?P.*?)^```$", + re.DOTALL | re.MULTILINE, +) + + +def slice_lines(path: str, text: str, start: int, end: int) -> str: + lines = text.splitlines(keepends=True) + if not 1 <= start <= end <= len(lines): + raise SystemExit( + f"{path} has {len(lines)} lines, but the README asks for {start}-{end}" + ) + excerpt = lines[start - 1 : end] + first = next((line for line in excerpt if line.strip()), "") + if not first.lstrip().startswith("- "): + raise SystemExit( + f"{path} lines {start}-{end} start mid-step ({first.strip()!r}); " + f"the range has probably drifted" + ) + return "".join(excerpt) + + +def sync(readme: str) -> tuple[str, list[str]]: + seen: list[str] = [] + + def replace(match: re.Match[str]) -> str: + path = match["path"] + source = ROOT / path + if not source.is_file(): + raise SystemExit(f"README references {path}, which does not exist") + seen.append(path) + text = source.read_text() + if match["lines"]: + text = slice_lines(path, text, int(match["start"]), int(match["end"])) + return f'```yaml title="{path}"{match["lines"] or ""}\n{text}```' + + return BLOCK.sub(replace, readme), seen + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", action="store_true", help="fail instead of rewriting" + ) + args = parser.parse_args() + + original = README.read_text() + updated, seen = sync(original) + + # Every example must be shown somewhere, otherwise it silently rots. + orphans = sorted( + str(path.relative_to(ROOT)) + for path in EXAMPLES.rglob("*.yml") + if str(path.relative_to(ROOT)) not in seen + ) + if orphans: + print("Not referenced by README.md: " + ", ".join(orphans), file=sys.stderr) + return 1 + + if updated == original: + return 0 + + if args.check: + diff = difflib.unified_diff( + original.splitlines(keepends=True), + updated.splitlines(keepends=True), + fromfile="README.md", + tofile="README.md (synced)", + ) + sys.stderr.writelines(diff) + print( + "\nREADME.md is out of sync; run .github/scripts/sync_readme_examples.py", + file=sys.stderr, + ) + return 1 + + README.write_text(updated) + print("Updated README.md") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 1b38ca9b..4550d31c 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -1,6 +1,10 @@ name: autofix.ci on: [pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: {} jobs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8904cb..a832fb98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,8 @@ jobs: name: Run tests & display coverage runs-on: ubuntu-latest permissions: - pull-requests: write - contents: write + pull-requests: write # Post the coverage comment on the PR, and edit it on later runs + contents: write # Push the coverage data to the python-coverage-comment-action-data branch steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -46,14 +46,11 @@ jobs: run: uv sync - name: Run tests - run: uv run pytest + # The end-to-end suite runs in its own job: it needs the e2e tokens, + # which have no business being in scope for the rest of this one. + run: uv run pytest --ignore=tests/end_to_end env: PY_COLORS: 1 - COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_1: ${{ secrets.COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_1 }} - COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_2: ${{ secrets.COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_2 }} - COVERAGE_COMMENT_E2E_ACTION_REF: ${{ github.sha }} - COVERAGE_COMMENT_E2E_REPOSITORY_OWNER: ${{ github.repository_owner }} - COVERAGE_COMMENT_E2E_REPO_SUFFIX: ${{ github.event.number }} - name: Coverage comment id: coverage_comment @@ -69,18 +66,51 @@ jobs: name: python-coverage-comment-action path: python-coverage-comment-action.txt + e2e: + name: Run end-to-end tests + runs-on: ubuntu-latest + # A fork's pull request gets no secrets, so the suite would only skip. + # Approved external contributions run through the e2e-external-* workflows. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + environment: + name: e2e + url: https://github.com/mihcaojwe?tab=repositories&q=end-to-end-${{ github.event.number }} + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Install deps + run: uv sync + + - name: Run end-to-end tests + run: uv run pytest tests/end_to_end + env: + PY_COLORS: 1 + COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_1: ${{ secrets.COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_1 }} + COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_2: ${{ secrets.COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_2 }} + COVERAGE_COMMENT_E2E_ACTION_REF: ${{ github.sha }} + COVERAGE_COMMENT_E2E_REPOSITORY_OWNER: ${{ github.repository_owner }} + COVERAGE_COMMENT_E2E_REPO_SUFFIX: ${{ github.event.number }} + push-to-registry: - name: Push Docker image to Docker Hub + name: Push Docker image to ghcr.io if: github.event_name == 'push' && github.ref == 'refs/heads/main' concurrency: group: release runs-on: ubuntu-latest - needs: [lint, test] + needs: [lint, test, e2e] permissions: contents: read - packages: write - attestations: write - id-token: write + packages: write # Push the base image to ghcr.io + attestations: write # Attach a build provenance attestation to the pushed image + id-token: write # Mint the OIDC token the attestation is signed with steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -90,12 +120,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Log in to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ewjoachim - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 @@ -129,7 +153,6 @@ jobs: org.opencontainers.image.description='Publish coverage report as PR comment, and create a coverage badge & dashboard to display on the Readme for Python projects, all inside GitHub without third party servers' org.opencontainers.image.licenses='MIT' tags: | - ewjoachim/python-coverage-comment-action-base:v7 ghcr.io/py-cov-action/python-coverage-comment-action-base:v7 ${{ steps.docker_meta.outputs.tags }} ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml index 5f35022b..ba8f119d 100644 --- a/.github/workflows/coverage-comment.yml +++ b/.github/workflows/coverage-comment.yml @@ -6,6 +6,12 @@ on: # zizmor: ignore[dangerous-triggers] We're using workflow_run to post a cove types: - completed +concurrency: + # Group by the PR's branch: `github.ref` is always the default branch here, + # so grouping on it would make unrelated PRs cancel each other. + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + permissions: {} jobs: @@ -14,9 +20,9 @@ jobs: runs-on: ubuntu-latest if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' permissions: - actions: read - pull-requests: write - contents: write + actions: read # Download the comment artifact from the triggering CI run + pull-requests: write # Post the coverage comment on the PR, and edit it on later runs + contents: read steps: - name: Post comment uses: py-cov-action/python-coverage-comment-action@main # zizmor: ignore[unpinned-uses] Dogfooding diff --git a/.github/workflows/e2e-delete-repo.yml b/.github/workflows/e2e-delete-repo.yml index cfe3bc61..2a4a0a8a 100644 --- a/.github/workflows/e2e-delete-repo.yml +++ b/.github/workflows/e2e-delete-repo.yml @@ -5,24 +5,31 @@ on: # zizmor: ignore[dangerous-triggers] We're using pull_request_target to clea types: - closed +concurrency: + # Deletions must run to completion, so never cancel one in flight. + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + permissions: {} jobs: test: + name: Delete the e2e test repos runs-on: ubuntu-latest + environment: e2e steps: - run: | - gh repo delete --yes https://github.com/mihcaojwe/python-coverage-comment-action-end-to-end-${NUMBER}-public || true + gh repo delete --yes "https://github.com/mihcaojwe/python-coverage-comment-action-end-to-end-${NUMBER}-public" || true env: NUMBER: ${{ github.event.pull_request.number }} GITHUB_TOKEN: ${{ secrets.COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_1 }} - run: | - gh repo delete --yes https://github.com/mihcaojwe2/python-coverage-comment-action-end-to-end-${NUMBER}-public || true + gh repo delete --yes "https://github.com/mihcaojwe2/python-coverage-comment-action-end-to-end-${NUMBER}-public" || true env: NUMBER: ${{ github.event.pull_request.number }} GITHUB_TOKEN: ${{ secrets.COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_2 }} - run: | - gh repo delete --yes https://github.com/mihcaojwe/python-coverage-comment-action-end-to-end-${NUMBER}-private || true + gh repo delete --yes "https://github.com/mihcaojwe/python-coverage-comment-action-end-to-end-${NUMBER}-private" || true env: NUMBER: ${{ github.event.pull_request.number }} GITHUB_TOKEN: ${{ secrets.COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_1 }} diff --git a/.github/workflows/e2e-external-phase-1.yml b/.github/workflows/e2e-external-phase-1.yml index dc298649..30fb87a0 100644 --- a/.github/workflows/e2e-external-phase-1.yml +++ b/.github/workflows/e2e-external-phase-1.yml @@ -4,6 +4,11 @@ on: pull_request_review: types: [submitted] +concurrency: + # On a re-approval, only the latest run's artifact matters to phase 2. + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: {} jobs: diff --git a/.github/workflows/e2e-external-phase-2.yml b/.github/workflows/e2e-external-phase-2.yml index 2a1fda90..dd02e842 100644 --- a/.github/workflows/e2e-external-phase-2.yml +++ b/.github/workflows/e2e-external-phase-2.yml @@ -16,11 +16,12 @@ jobs: name: End-to-end tests runs-on: ubuntu-latest if: github.event.workflow_run.conclusion == 'success' + environment: e2e permissions: - actions: read - pull-requests: write - contents: write - checks: write + actions: read # Download the pr_number artifact produced by phase 1 + pull-requests: write # Read the PR's reviews, to find the approved commit + contents: write # Checkout the reviewed commit + checks: write # Create and update the "End-to-end tests (external PR)" check run steps: - name: Extract PR number from artifact id: extract_pr_number @@ -119,7 +120,7 @@ jobs: gh api "repos/py-cov-action/python-coverage-comment-action/check-runs/${CHECK_RUN_ID}" -X PATCH - -F conclusion=${JOB_STATUS} + -F conclusion="${JOB_STATUS}" -F status=completed env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/e2e-private-link-in-pr.yml b/.github/workflows/e2e-private-invite.yml similarity index 54% rename from .github/workflows/e2e-private-link-in-pr.yml rename to .github/workflows/e2e-private-invite.yml index 89035cd2..3047861d 100644 --- a/.github/workflows/e2e-private-link-in-pr.yml +++ b/.github/workflows/e2e-private-invite.yml @@ -1,9 +1,14 @@ -name: Post link to private end-to-end test repository +name: Invite to the private end-to-end test repository on: issue_comment: types: [created] +concurrency: + # Invitations must run to completion, so never cancel one in flight. + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: false + permissions: {} jobs: @@ -13,6 +18,7 @@ jobs: github.event.issue.pull_request && contains(github.event.comment.body, '/invite') runs-on: ubuntu-latest + environment: e2e strategy: matrix: collaborator: @@ -30,29 +36,10 @@ jobs: steps: - name: Invite @${{ matrix.collaborator.LOGIN }} to the e2e private repo - run: gh api --method PUT /repos/mihcaojwe/python-coverage-comment-action-end-to-end-${NUMBER}-private/collaborators/${LOGIN} -f permission=${PERMISSION} + run: gh api --method PUT "/repos/mihcaojwe/python-coverage-comment-action-end-to-end-${NUMBER}-private/collaborators/${LOGIN}" -f "permission=${PERMISSION}" if: ${{ matrix.collaborator.ENABLED == true }} env: LOGIN: ${{ matrix.collaborator.LOGIN }} NUMBER: ${{ github.event.issue.number }} PERMISSION: ${{ matrix.collaborator.PERMISSION }} GITHUB_TOKEN: ${{ secrets.COVERAGE_COMMENT_E2E_GITHUB_TOKEN_USER_1 }} - - comment: - name: Add comment with link to e2e repos - if: | - github.event.issue.pull_request - && contains(github.event.comment.body, '/invite') - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - run: | - gh pr comment ${LINK} --body-file - <