From 41fde63206634aa2cb6bc0bd4779d27114a62e37 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 14:38:14 +0200 Subject: [PATCH 01/14] fix(ci): stop requesting contents: write when only posting comments The post_comment activity never touches the Contents API: it reads the workflow run and its artifacts (actions: read), lists the PR (pull-requests: read) and creates or edits an issue comment (pull-requests: write). Getting the repository only needs the metadata scope, which is always granted. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/coverage-comment.yml | 2 +- tests/end_to_end/repo/.github/workflows/coverage-comment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml index 5f35022b..54af17f5 100644 --- a/.github/workflows/coverage-comment.yml +++ b/.github/workflows/coverage-comment.yml @@ -16,7 +16,7 @@ jobs: permissions: actions: read pull-requests: write - contents: write + contents: read steps: - name: Post comment uses: py-cov-action/python-coverage-comment-action@main # zizmor: ignore[unpinned-uses] Dogfooding diff --git a/tests/end_to_end/repo/.github/workflows/coverage-comment.yml b/tests/end_to_end/repo/.github/workflows/coverage-comment.yml index caa27e0e..a19afa9f 100644 --- a/tests/end_to_end/repo/.github/workflows/coverage-comment.yml +++ b/tests/end_to_end/repo/.github/workflows/coverage-comment.yml @@ -16,7 +16,7 @@ jobs: permissions: actions: read pull-requests: write - contents: write + contents: read steps: - name: Post comment uses: py-cov-action/python-coverage-comment-action@__ACTION_REF__ # zizmor: ignore[unpinned-uses] From ce3be58f09ec6e11fafc294a09625b3f172b357f Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 14:38:30 +0200 Subject: [PATCH 02/14] ci: move the e2e tokens into a dedicated environment The e2e PATs were repository secrets, so every job in every workflow could read them -- including the pytest run of any collaborator's pull request. Scoping them to an `e2e` environment means only the jobs that declare it can reach them, and it allows a deployment branch rule to restrict them to main. The workflows that need them all run on the default branch's ref (workflow_run, pull_request_target, issue_comment), so such a rule doesn't affect them. ci.yml runs on refs/pull/N/merge and cannot use the environment, so it no longer receives the tokens at all; the e2e tests skip without them, and external runs go through the /e2e approval flow. This only takes effect once the secrets are actually moved to the environment in the repository settings; until then the repository secrets still resolve. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/e2e-delete-repo.yml | 1 + .github/workflows/e2e-external-phase-2.yml | 1 + .github/workflows/e2e-private-link-in-pr.yml | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8904cb..d9be326f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,8 @@ jobs: run: uv run pytest 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 }} + # The e2e tests skip without these; they only run through the /e2e + # approval flow, which holds the tokens in the `e2e` environment. COVERAGE_COMMENT_E2E_ACTION_REF: ${{ github.sha }} COVERAGE_COMMENT_E2E_REPOSITORY_OWNER: ${{ github.repository_owner }} COVERAGE_COMMENT_E2E_REPO_SUFFIX: ${{ github.event.number }} diff --git a/.github/workflows/e2e-delete-repo.yml b/.github/workflows/e2e-delete-repo.yml index cfe3bc61..1d4f3d62 100644 --- a/.github/workflows/e2e-delete-repo.yml +++ b/.github/workflows/e2e-delete-repo.yml @@ -10,6 +10,7 @@ permissions: {} jobs: test: 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 diff --git a/.github/workflows/e2e-external-phase-2.yml b/.github/workflows/e2e-external-phase-2.yml index 2a1fda90..77f0aaaf 100644 --- a/.github/workflows/e2e-external-phase-2.yml +++ b/.github/workflows/e2e-external-phase-2.yml @@ -16,6 +16,7 @@ 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 diff --git a/.github/workflows/e2e-private-link-in-pr.yml b/.github/workflows/e2e-private-link-in-pr.yml index 89035cd2..95a88587 100644 --- a/.github/workflows/e2e-private-link-in-pr.yml +++ b/.github/workflows/e2e-private-link-in-pr.yml @@ -13,6 +13,7 @@ jobs: github.event.issue.pull_request && contains(github.event.comment.body, '/invite') runs-on: ubuntu-latest + environment: e2e strategy: matrix: collaborator: From 038231c5e8f6df2091c668d00bb41f4a8f42cebc Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 14:38:52 +0200 Subject: [PATCH 03/14] ci: stop publishing the base image to Docker Hub Nothing consumes ewjoachim/python-coverage-comment-action-base:v7. The runtime Dockerfile moved to ghcr.io in 90c8df8, released in v3.31; every version that still pulls from Docker Hub pins :v6 or older, and those tags keep existing. The push was write-only, so drop it along with DOCKER_PASSWORD. While here, fix the stale pointer in Dockerfile.build: the base image version lives in ci.yml, not in a release.yml that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 +-------- Dockerfile.build | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9be326f..3e7899d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: path: python-coverage-comment-action.txt 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 @@ -90,12 +90,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 +123,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/Dockerfile.build b/Dockerfile.build index ae1f556d..b3163d65 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -1,6 +1,6 @@ # If you change anything here, bump the version in: # - Dockerfile -# - .github/workflows/release.yml +# - .github/workflows/ci.yml (the image tags of the push-to-registry job) FROM python:3.14-slim From 39ff776c65153d19032d748277a81c8e428bd21a Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 14:39:08 +0200 Subject: [PATCH 04/14] ci: document permissions, name jobs, and add concurrency groups Three of zizmor's pedantic/auditor audits, all on the repo's own workflows: - undocumented-permissions: each requested permission now says what it's for. Note zizmor only accepts a trailing comment, not one on the line above. - anonymous-definition: name the two jobs that were just `test:`. - concurrency-limits: without a group, concurrent runs race on shared state -- two runs can each decide no coverage comment exists yet and both post one. On workflow_run, github.ref is always the default branch, so those group on the triggering run's head branch instead; the cleanup and invite workflows group per PR and never cancel, since they must run to completion. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/autofix.yml | 4 ++++ .github/workflows/ci.yml | 10 +++++----- .github/workflows/coverage-comment.yml | 10 ++++++++-- .github/workflows/e2e-delete-repo.yml | 6 ++++++ .github/workflows/e2e-external-phase-1.yml | 5 +++++ .github/workflows/e2e-external-phase-2.yml | 8 ++++---- .github/workflows/e2e-private-link-in-pr.yml | 7 ++++++- .github/workflows/e2e-public-link-in-pr.yml | 7 ++++++- .github/workflows/manual-release.yml | 2 +- tests/end_to_end/repo/.github/workflows/ci.yml | 4 ++-- .../repo/.github/workflows/coverage-comment.yml | 8 ++++++-- 11 files changed, 53 insertions(+), 18 deletions(-) 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 3e7899d6..2a7adf5a 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 @@ -78,9 +78,9 @@ jobs: needs: [lint, test] 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 diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml index 54af17f5..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,8 +20,8 @@ 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 + 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 diff --git a/.github/workflows/e2e-delete-repo.yml b/.github/workflows/e2e-delete-repo.yml index 1d4f3d62..d77068f2 100644 --- a/.github/workflows/e2e-delete-repo.yml +++ b/.github/workflows/e2e-delete-repo.yml @@ -5,10 +5,16 @@ 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: 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 77f0aaaf..e8782aa1 100644 --- a/.github/workflows/e2e-external-phase-2.yml +++ b/.github/workflows/e2e-external-phase-2.yml @@ -18,10 +18,10 @@ jobs: 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 diff --git a/.github/workflows/e2e-private-link-in-pr.yml b/.github/workflows/e2e-private-link-in-pr.yml index 95a88587..5eb0f5af 100644 --- a/.github/workflows/e2e-private-link-in-pr.yml +++ b/.github/workflows/e2e-private-link-in-pr.yml @@ -4,6 +4,11 @@ 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: @@ -46,7 +51,7 @@ jobs: && contains(github.event.comment.body, '/invite') runs-on: ubuntu-latest permissions: - pull-requests: write + pull-requests: write # Post the comment linking to the private e2e repo steps: - run: | gh pr comment ${LINK} --body-file - < Date: Sun, 9 Aug 2026 14:39:53 +0200 Subject: [PATCH 05/14] docs: extract the workflow examples into docs/examples/ The README examples had drifted: they recommended persist-credentials: true, carried permission comments zizmor doesn't accept, and had no top-level permissions or concurrency. Nothing linted them, because nothing could -- they only existed inside Markdown fences. They're now real workflow files that zizmor lints, pinact pins and renovate keeps current, copied into the README by a pre-commit hook. The fence carries the source path, and GitHub drops everything after the language, so it stays invisible when rendered. `lines=` shows a slice, for snippets that would be noise as a whole workflow. Renovate doesn't look outside .github/ on its own, hence the config addition; without it the new pins would silently rot. The examples themselves now use persist-credentials: false, a top-level permissions: {}, a concurrency group, and real pinned SHAs. Co-Authored-By: Claude Opus 5 (1M context) --- .github/renovate.json5 | 6 + .github/scripts/sync_readme_examples.py | 121 ++++++++++ .pre-commit-config.yaml | 9 +- README.md | 218 ++++++++++-------- docs/examples/activity/any-push.yml | 36 +++ docs/examples/activity/default-branch.yml | 36 +++ docs/examples/basic-usage/ci.yml | 46 ++++ docs/examples/basic-usage/coverage.yml | 37 +++ docs/examples/enforce-coverage/ci.yml | 39 ++++ docs/examples/matrix/ci.yml | 94 ++++++++ docs/examples/merge-queue/ci.yml | 33 +++ docs/examples/monorepo/ci.yml | 56 +++++ docs/examples/monorepo/coverage.yml | 42 ++++ docs/examples/no-external-contributors/ci.yml | 34 +++ 14 files changed, 711 insertions(+), 96 deletions(-) create mode 100644 .github/scripts/sync_readme_examples.py create mode 100644 docs/examples/activity/any-push.yml create mode 100644 docs/examples/activity/default-branch.yml create mode 100644 docs/examples/basic-usage/ci.yml create mode 100644 docs/examples/basic-usage/coverage.yml create mode 100644 docs/examples/enforce-coverage/ci.yml create mode 100644 docs/examples/matrix/ci.yml create mode 100644 docs/examples/merge-queue/ci.yml create mode 100644 docs/examples/monorepo/ci.yml create mode 100644 docs/examples/monorepo/coverage.yml create mode 100644 docs/examples/no-external-contributors/ci.yml 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/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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67e7a177..95975e24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,11 +27,18 @@ repos: require_serial: true types_or: [python, pyi] + - id: sync-readme-examples + name: sync README examples + entry: uv run python .github/scripts/sync_readme_examples.py + language: system + files: ^(README\.md|docs/examples/.*)$ + pass_filenames: false + - id: zizmor name: zizmor entry: uv run zizmor --fix=all types: [yaml] - files: (\.github/(workflows/.*|dependabot.ya?ml))|(action\.ya?ml)$ + files: (\.github/(workflows/.*|dependabot.ya?ml))|(action\.ya?ml)$|(docs/examples/.*\.ya?ml$) language: system require_serial: true args: diff --git a/README.md b/README.md index ba172b5e..2b6babd7 100644 --- a/README.md +++ b/README.md @@ -75,18 +75,24 @@ Combining this with [Github's Expressions] (https://docs.github.com/en/actions/reference/workflows-and-actions/expressions) you can build out the the custom handling needed. For example: -```yaml +```yaml title="docs/examples/activity/any-push.yml" lines=31-36 - name: Coverage comment id: coverage_comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: GITHUB_TOKEN: ${{ github.token }} - activity: "${{ github.event_name == 'push' && 'save_coverage_data_files' || 'process_pr' }}" + ACTIVITY: "${{ github.event_name == 'push' && 'save_coverage_data_files' || 'process_pr' }}" +``` - # or +Or, to only save the coverage data when pushing to the default branch: +```yaml title="docs/examples/activity/default-branch.yml" lines=31-36 + - name: Coverage comment + id: coverage_comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: - activity: "${{ (github.event_name == 'push' && github.ref_name == 'main') && 'save_coverage_data_files' || 'process_pr' }}" + GITHUB_TOKEN: ${{ github.token }} + ACTIVITY: "${{ (github.event_name == 'push' && github.ref_name == 'main') && 'save_coverage_data_files' || 'process_pr' }}" ``` ## Usage @@ -132,7 +138,7 @@ repository is explicitly configured to send write tokens to workflows from pull requests. In other words, these settings do not grant write access to untrusted code. -```yaml +```yaml title="docs/examples/basic-usage/ci.yml" # .github/workflows/ci.yml name: CI @@ -142,31 +148,37 @@ on: branches: - "main" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + jobs: test: name: Run tests & display coverage runs-on: ubuntu-latest permissions: - # Allows the action to publish new comments directly on trusted PRs. - # Forked pull_request runs are downgraded to read-only by GitHub. - pull-requests: write - # Allows updating the python-coverage-comment-action branch and editing - # existing comments when direct publication is allowed. - contents: write + # Forked pull_request runs are downgraded to read-only by GitHub, so + # these do not grant write access to untrusted code. + pull-requests: write # Publish/update the coverage comment on trusted PRs + contents: write # Push the coverage data to the data branch steps: - - uses: actions/checkout@sha1 # vx.y.z + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install everything, run the tests, produce the .coverage file run: make test # This is the part where you put your own test command - name: Coverage comment id: coverage_comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: GITHUB_TOKEN: ${{ github.token }} - name: Store Pull Request comment to be posted - uses: actions/upload-artifact@sha1 # vx.y.z + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' with: # If you use a different name, update COMMENT_ARTIFACT_NAME accordingly @@ -175,7 +187,7 @@ jobs: path: python-coverage-comment-action.txt ``` -```yaml +```yaml title="docs/examples/basic-usage/coverage.yml" # .github/workflows/coverage.yml name: Post coverage comment @@ -185,27 +197,28 @@ on: # zizmor: ignore[dangerous-triggers] We're using workflow_run to post a cov types: - completed +concurrency: + # Group by the PR's branch, so that runs for different PRs don't cancel + # each other. `github.ref` is always the default branch on `workflow_run`. + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +permissions: {} + jobs: test: name: Run tests & display coverage runs-on: ubuntu-latest if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' permissions: - # Gives the action the necessary permissions for publishing new - # comments in pull requests. - pull-requests: write - # Gives the action the necessary permissions for editing existing - # comments (to avoid publishing multiple comments in the same PR) - contents: write - # Gives the action the necessary permissions for looking up the - # workflow that launched this workflow, and download the related - # artifact that contains the comment to be published - actions: read + pull-requests: write # Post the comment, and edit it on later runs + actions: read # Download the comment artifact from the triggering CI run + contents: read steps: # DO NOT run actions/checkout here, for security reasons # For details, refer to https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ - name: Post comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} @@ -220,7 +233,7 @@ If you don't expect external contributors, you don't need all the shenanigans with the artifacts and the 2nd workflow. This is likely to be the most straightforward way to configure it for private repositories. It might look like this: -```yaml +```yaml title="docs/examples/no-external-contributors/ci.yml" # .github/workflows/ci.yml name: CI @@ -230,31 +243,29 @@ on: branches: - "main" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + jobs: test: name: Run tests & display coverage runs-on: ubuntu-latest permissions: - # Gives the action the necessary permissions for publishing new - # comments in pull requests. - pull-requests: write - # Gives the action the necessary permissions for pushing data to the - # python-coverage-comment-action branch, and for editing existing - # comments (to avoid publishing multiple comments in the same PR) - contents: write + pull-requests: write # Publish the coverage comment, and edit it on later runs + contents: write # Push the coverage data to the data branch steps: - - uses: actions/checkout@sha1 # vx.y.z + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # This is optional since by default it's to true. The git - # operations in python-coverage-comment-action utilize the token - # stored by actions/checkout. - persist-credentials: true + persist-credentials: false - name: Install everything, run the tests, produce the .coverage file run: make test # This is the part where you put your own test command - name: Coverage comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: GITHUB_TOKEN: ${{ github.token }} ``` @@ -267,7 +278,7 @@ You will need to ensure the action is run only _after_ all the actual merge chec For instance -```yaml +```yaml title="docs/examples/merge-queue/ci.yml" # .github/workflows/ci.yml name: CI @@ -275,31 +286,30 @@ on: pull_request: merge_group: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Never cancel a merge_group run: that would dequeue the pull request. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: {} + jobs: test: name: Run tests & display coverage runs-on: ubuntu-latest permissions: - # Gives the action the necessary permissions for publishing new - # comments in pull requests. - pull-requests: write - # Gives the action the necessary permissions for pushing data to the - # python-coverage-comment-action branch, and for editing existing - # comments (to avoid publishing multiple comments in the same PR) - contents: write + pull-requests: write # Publish the coverage comment, and edit it on later runs + contents: write # Push the coverage data to the data branch steps: - - uses: actions/checkout@sha1 # vx.y.z + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # This is optional since by default it's to true. The git - # operations in python-coverage-comment-action utilize the token - # stored by actions/checkout. - persist-credentials: true + persist-credentials: false - name: Install everything, run the tests, produce the .coverage file run: make test # This is the part where you put your own test command - name: Coverage comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: GITHUB_TOKEN: ${{ github.token }} ``` @@ -310,7 +320,7 @@ In case you have a job matrix and you want the report to be on the global coverage, you can configure your `ci.yml` like this (`coverage.yml` remains the same) -```yaml +```yaml title="docs/examples/matrix/ci.yml" name: CI on: @@ -321,6 +331,12 @@ on: tags: - "*" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + jobs: build: strategy: @@ -333,18 +349,17 @@ jobs: name: "Python ${{ matrix.python_version }}" runs-on: ubuntu-latest + permissions: + contents: read # Checkout the repository steps: - - uses: actions/checkout@sha1 # vx.y.z + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # This is optional since by default it's to true. The git - # operations in python-coverage-comment-action utilize the token - # stored by actions/checkout. - persist-credentials: true + persist-credentials: false - name: Set up Python id: setup-python - uses: actions/setup-python@sha1 # vx.y.z + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python_version }} @@ -358,7 +373,7 @@ jobs: # this prefix is not used. - name: Store coverage file - uses: actions/upload-artifact@sha1 # vx.y.z + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-${{ matrix.python_version }} path: .coverage.${{ matrix.python_version }} @@ -374,17 +389,14 @@ jobs: runs-on: ubuntu-latest needs: build permissions: - pull-requests: write - contents: write + pull-requests: write # Publish the coverage comment, and edit it on later runs + contents: write # Push the coverage data to the data branch steps: - - uses: actions/checkout@sha1 # vx.y.z + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # This is optional since by default it's to true. The git - # operations in python-coverage-comment-action utilize the token - # stored by actions/checkout. - persist-credentials: true + persist-credentials: false - - uses: actions/download-artifact@sha1 # vx.y.z + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 id: download with: pattern: coverage-* @@ -392,13 +404,13 @@ jobs: - name: Coverage comment id: coverage_comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MERGE_COVERAGE_FILES: true - name: Store Pull Request comment to be posted - uses: actions/upload-artifact@sha1 # vx.y.z + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' with: name: python-coverage-comment-action @@ -443,16 +455,16 @@ All the following outputs are only available when running in PR mode. Usage may look like this -```yaml -- name: Coverage comment - id: coverage_comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z - with: - GITHUB_TOKEN: ${{ github.token }} +```yaml title="docs/examples/enforce-coverage/ci.yml" lines=31-39 + - name: Coverage comment + id: coverage_comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ github.token }} -- name: Enforce coverage - if: ${{ steps.coverage_comment.outputs.new_percent_covered < steps.coverage_comment.outputs.reference_percent_covered }} - run: echo "Coverage decreased." && exit 1 + - name: Enforce coverage + if: ${{ steps.coverage_comment.outputs.new_percent_covered < steps.coverage_comment.outputs.reference_percent_covered }} + run: echo "Coverage decreased." && exit 1 ``` ### All options @@ -624,7 +636,7 @@ still use the same step for storing all files as artifacts. You'll end up with a different comment for each launch. Feel free to use the `COMMENT_TEMPLATE` if you want each comment to clearly state what it relates to. -```yaml +```yaml title="docs/examples/monorepo/ci.yml" # .github/workflows/ci.yml name: CI @@ -634,15 +646,23 @@ on: branches: - "main" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + jobs: test: name: Run tests & display coverage runs-on: ubuntu-latest permissions: - pull-requests: write - contents: write + pull-requests: write # Publish the coverage comment, and edit it on later runs + contents: write # Push the coverage data to the data branch steps: - - uses: actions/checkout@sha1 # vx.y.z + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Test project 1 run: make -C project_1 test @@ -652,7 +672,7 @@ jobs: - name: Coverage comment (project 1) id: coverage_comment_1 - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: COVERAGE_PATH: project_1 SUBPROJECT_ID: project-1 @@ -660,14 +680,14 @@ jobs: - name: Coverage comment (project 2) id: coverage_comment_2 - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: COVERAGE_PATH: project_2/src SUBPROJECT_ID: project-2 GITHUB_TOKEN: ${{ github.token }} - name: Store Pull Request comment to be posted - uses: actions/upload-artifact@sha1 # vx.y.z + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: steps.coverage_comment_1.outputs.COMMENT_FILE_WRITTEN == 'true' || steps.coverage_comment_2.outputs.COMMENT_FILE_WRITTEN == 'true' with: name: python-coverage-comment-action @@ -675,7 +695,7 @@ jobs: path: python-coverage-comment-action*.txt ``` -```yaml +```yaml title="docs/examples/monorepo/coverage.yml" # .github/workflows/coverage.yml name: Post coverage comment @@ -685,18 +705,26 @@ on: # zizmor: ignore[dangerous-triggers] We're using workflow_run to post a cov types: - completed +concurrency: + # Group by the PR's branch, so that runs for different PRs don't cancel + # each other. `github.ref` is always the default branch on `workflow_run`. + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +permissions: {} + jobs: test: name: Run tests & display coverage runs-on: ubuntu-latest if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' permissions: - pull-requests: write - contents: write - actions: read + pull-requests: write # Post the comment, and edit it on later runs + actions: read # Download the comment artifact from the triggering CI run + contents: read steps: - name: Post comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} @@ -704,7 +732,7 @@ jobs: COVERAGE_PATH: project_1 - name: Post comment - uses: py-cov-action/python-coverage-comment-action@sha1 # vx.y.z + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/docs/examples/activity/any-push.yml b/docs/examples/activity/any-push.yml new file mode 100644 index 00000000..e8406fc0 --- /dev/null +++ b/docs/examples/activity/any-push.yml @@ -0,0 +1,36 @@ +# .github/workflows/ci.yml +name: CI + +on: + pull_request: + push: + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + permissions: + pull-requests: write # Publish/update the coverage comment on the PR + contents: write # Push the coverage data to the data branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install everything, run the tests, produce the .coverage file + run: make test # This is the part where you put your own test command + + - name: Coverage comment + id: coverage_comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ github.token }} + ACTIVITY: "${{ github.event_name == 'push' && 'save_coverage_data_files' || 'process_pr' }}" diff --git a/docs/examples/activity/default-branch.yml b/docs/examples/activity/default-branch.yml new file mode 100644 index 00000000..52c16078 --- /dev/null +++ b/docs/examples/activity/default-branch.yml @@ -0,0 +1,36 @@ +# .github/workflows/ci.yml +name: CI + +on: + pull_request: + push: + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + permissions: + pull-requests: write # Publish/update the coverage comment on the PR + contents: write # Push the coverage data to the data branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install everything, run the tests, produce the .coverage file + run: make test # This is the part where you put your own test command + + - name: Coverage comment + id: coverage_comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ github.token }} + ACTIVITY: "${{ (github.event_name == 'push' && github.ref_name == 'main') && 'save_coverage_data_files' || 'process_pr' }}" diff --git a/docs/examples/basic-usage/ci.yml b/docs/examples/basic-usage/ci.yml new file mode 100644 index 00000000..b8d7e5e0 --- /dev/null +++ b/docs/examples/basic-usage/ci.yml @@ -0,0 +1,46 @@ +# .github/workflows/ci.yml +name: CI + +on: + pull_request: + push: + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + permissions: + # Forked pull_request runs are downgraded to read-only by GitHub, so + # these do not grant write access to untrusted code. + pull-requests: write # Publish/update the coverage comment on trusted PRs + contents: write # Push the coverage data to the data branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install everything, run the tests, produce the .coverage file + run: make test # This is the part where you put your own test command + + - name: Coverage comment + id: coverage_comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ github.token }} + + - name: Store Pull Request comment to be posted + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' + with: + # If you use a different name, update COMMENT_ARTIFACT_NAME accordingly + name: python-coverage-comment-action + # If you use a different name, update COMMENT_FILENAME accordingly + path: python-coverage-comment-action.txt diff --git a/docs/examples/basic-usage/coverage.yml b/docs/examples/basic-usage/coverage.yml new file mode 100644 index 00000000..02d5b578 --- /dev/null +++ b/docs/examples/basic-usage/coverage.yml @@ -0,0 +1,37 @@ +# .github/workflows/coverage.yml +name: Post coverage comment + +on: # zizmor: ignore[dangerous-triggers] We're using workflow_run to post a coverage comment on external PRs. This is safe because we don't checkout the external code or interact with the external code in any way but extracting an artifact containing the comment to post, and post it. + workflow_run: + workflows: ["CI"] + types: + - completed + +concurrency: + # Group by the PR's branch, so that runs for different PRs don't cancel + # each other. `github.ref` is always the default branch on `workflow_run`. + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + permissions: + pull-requests: write # Post the comment, and edit it on later runs + actions: read # Download the comment artifact from the triggering CI run + contents: read + steps: + # DO NOT run actions/checkout here, for security reasons + # For details, refer to https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + - name: Post comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} + # Update those if you changed the default values: + # COMMENT_ARTIFACT_NAME: python-coverage-comment-action + # COMMENT_FILENAME: python-coverage-comment-action.txt diff --git a/docs/examples/enforce-coverage/ci.yml b/docs/examples/enforce-coverage/ci.yml new file mode 100644 index 00000000..193895d6 --- /dev/null +++ b/docs/examples/enforce-coverage/ci.yml @@ -0,0 +1,39 @@ +# .github/workflows/ci.yml +name: CI + +on: + pull_request: + push: + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + permissions: + pull-requests: write # Publish/update the coverage comment on the PR + contents: write # Push the coverage data to the data branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install everything, run the tests, produce the .coverage file + run: make test # This is the part where you put your own test command + + - name: Coverage comment + id: coverage_comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ github.token }} + + - name: Enforce coverage + if: ${{ steps.coverage_comment.outputs.new_percent_covered < steps.coverage_comment.outputs.reference_percent_covered }} + run: echo "Coverage decreased." && exit 1 diff --git a/docs/examples/matrix/ci.yml b/docs/examples/matrix/ci.yml new file mode 100644 index 00000000..d77f01ad --- /dev/null +++ b/docs/examples/matrix/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + pull_request: + push: + branches: + - "master" + tags: + - "*" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + build: + strategy: + matrix: + include: + - python_version: "3.7" + - python_version: "3.8" + - python_version: "3.9" + - python_version: "3.10" + + name: "Python ${{ matrix.python_version }}" + runs-on: ubuntu-latest + permissions: + contents: read # Checkout the repository + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + id: setup-python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python_version }} + + - name: Install everything, run the tests, produce a .coverage.xxx file + run: make test # This is the part where you put your own test command + env: + COVERAGE_FILE: ".coverage.${{ matrix.python_version }}" + # The file name prefix must be ".coverage." for "coverage combine" + # enabled by "MERGE_COVERAGE_FILES: true" to work. A "subprocess" + # error with the message "No data to combine" will be triggered if + # this prefix is not used. + + - name: Store coverage file + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ matrix.python_version }} + path: .coverage.${{ matrix.python_version }} + # By default hidden files/folders (i.e. starting with .) are ignored. + # You may prefer (for security reasons) not setting this and instead + # set COVERAGE_FILE above to not start with a `.`, but you cannot + # use "MERGE_COVERAGE_FILES: true" later on and need to manually + # combine the coverage file using "pipx run coverage combine" + include-hidden-files: true + + coverage: + name: Coverage + runs-on: ubuntu-latest + needs: build + permissions: + pull-requests: write # Publish the coverage comment, and edit it on later runs + contents: write # Push the coverage data to the data branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + id: download + with: + pattern: coverage-* + merge-multiple: true + + - name: Coverage comment + id: coverage_comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MERGE_COVERAGE_FILES: true + + - name: Store Pull Request comment to be posted + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' + with: + name: python-coverage-comment-action + path: python-coverage-comment-action.txt diff --git a/docs/examples/merge-queue/ci.yml b/docs/examples/merge-queue/ci.yml new file mode 100644 index 00000000..99a4efc1 --- /dev/null +++ b/docs/examples/merge-queue/ci.yml @@ -0,0 +1,33 @@ +# .github/workflows/ci.yml +name: CI + +on: + pull_request: + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Never cancel a merge_group run: that would dequeue the pull request. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + permissions: + pull-requests: write # Publish the coverage comment, and edit it on later runs + contents: write # Push the coverage data to the data branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install everything, run the tests, produce the .coverage file + run: make test # This is the part where you put your own test command + + - name: Coverage comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ github.token }} diff --git a/docs/examples/monorepo/ci.yml b/docs/examples/monorepo/ci.yml new file mode 100644 index 00000000..ab5003c5 --- /dev/null +++ b/docs/examples/monorepo/ci.yml @@ -0,0 +1,56 @@ +# .github/workflows/ci.yml +name: CI + +on: + pull_request: + push: + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + permissions: + pull-requests: write # Publish the coverage comment, and edit it on later runs + contents: write # Push the coverage data to the data branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Test project 1 + run: make -C project_1 test + + - name: Test project 2 + run: make -C project_2 test + + - name: Coverage comment (project 1) + id: coverage_comment_1 + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + COVERAGE_PATH: project_1 + SUBPROJECT_ID: project-1 + GITHUB_TOKEN: ${{ github.token }} + + - name: Coverage comment (project 2) + id: coverage_comment_2 + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + COVERAGE_PATH: project_2/src + SUBPROJECT_ID: project-2 + GITHUB_TOKEN: ${{ github.token }} + + - name: Store Pull Request comment to be posted + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.coverage_comment_1.outputs.COMMENT_FILE_WRITTEN == 'true' || steps.coverage_comment_2.outputs.COMMENT_FILE_WRITTEN == 'true' + with: + name: python-coverage-comment-action + # Note the star + path: python-coverage-comment-action*.txt diff --git a/docs/examples/monorepo/coverage.yml b/docs/examples/monorepo/coverage.yml new file mode 100644 index 00000000..743374b5 --- /dev/null +++ b/docs/examples/monorepo/coverage.yml @@ -0,0 +1,42 @@ +# .github/workflows/coverage.yml +name: Post coverage comment + +on: # zizmor: ignore[dangerous-triggers] We're using workflow_run to post a coverage comment on external PRs. This is safe because we don't checkout the external code or interact with the external code in any way but extracting an artifact containing the comment to post, and post it. + workflow_run: + workflows: ["CI"] + types: + - completed + +concurrency: + # Group by the PR's branch, so that runs for different PRs don't cancel + # each other. `github.ref` is always the default branch on `workflow_run`. + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + permissions: + pull-requests: write # Post the comment, and edit it on later runs + actions: read # Download the comment artifact from the triggering CI run + contents: read + steps: + - name: Post comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} + SUBPROJECT_ID: project-1 + COVERAGE_PATH: project_1 + + - name: Post comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} + SUBPROJECT_ID: project-2 + COVERAGE_PATH: project_2/src diff --git a/docs/examples/no-external-contributors/ci.yml b/docs/examples/no-external-contributors/ci.yml new file mode 100644 index 00000000..75cd00e7 --- /dev/null +++ b/docs/examples/no-external-contributors/ci.yml @@ -0,0 +1,34 @@ +# .github/workflows/ci.yml +name: CI + +on: + pull_request: + push: + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + permissions: + pull-requests: write # Publish the coverage comment, and edit it on later runs + contents: write # Push the coverage data to the data branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install everything, run the tests, produce the .coverage file + run: make test # This is the part where you put your own test command + + - name: Coverage comment + uses: py-cov-action/python-coverage-comment-action@5d8df5979747514c914e1c5a12335a7cf9a2745f # v4.1 + with: + GITHUB_TOKEN: ${{ github.token }} From afa862b2f4e32938a0db3c5066d7fc51c3a95dbf Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 14:40:05 +0200 Subject: [PATCH 06/14] docs: explain when persist-credentials: false became safe The action stopped relying on the credentials actions/checkout writes into .git/config in 12255bc, released in v4.1: it passes the GITHUB_TOKEN it is given straight to git via http.extraheader for every network operation. Say so explicitly, and say which version, so that readers still pinned to a v3.x release don't follow the new examples and break their coverage push. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 2b6babd7..1d2e4f16 100644 --- a/README.md +++ b/README.md @@ -751,6 +751,26 @@ Using standard tools like [Zizmor](https://docs.zizmor.sh/) or given commit sha, and use a comment to indicate the corresponding exact version. This is format is understood and followed by dependabot/renovate. +## Persisted credentials + +Starting with **v4.1**, the action hands the `GITHUB_TOKEN` you pass it directly to +`git` for every network operation (fetching and pushing the coverage data branch). +It never reads the credentials that `actions/checkout` writes to `.git/config`, so +you can — and should — check out with `persist-credentials: false`. + +This keeps a write-scoped token out of `.git/config` on the runner, where any later +step in the job would be able to read it. It's what +[Zizmor](https://docs.zizmor.sh/) reports as +[`artipacked`](https://docs.zizmor.sh/audits/#artipacked). + +This only concerns *persisted* credentials. The job that stores the coverage data +still needs `contents: write`, because that's the permission carried by the +`GITHUB_TOKEN` the action uses. + +If you're pinned to a release older than v4.1 (that is, any `v3.x` release), the +action still relies on the credentials stored by `actions/checkout`. Keep +`persist-credentials: true` until you upgrade. + ## Note on the state of this action This action is tested with 100% coverage. That said, coverage isn't all, and From 3baf05edf7dd4c0dc71448fe2ee4fd463f5fa8cb Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 14:40:15 +0200 Subject: [PATCH 07/14] chore: run zizmor with the auditor persona Now that the repository is clean at that level. It's worth the step up: artipacked -- an explicit persist-credentials: true, the thing that started all this -- is only reported at the auditor persona. The default persona only catches a checkout with no persist-credentials at all. Co-Authored-By: Claude Opus 5 (1M context) --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95975e24..7687ce73 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,3 +43,4 @@ repos: require_serial: true args: - "--no-progress" # https://github.com/zizmorcore/zizmor/issues/582 + - "--persona=auditor" From 15c7094af8e13254c6cbef078133b0db929533c7 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 14:51:14 +0200 Subject: [PATCH 08/14] ci: quote shell variables in workflow run blocks shellcheck's SC2086. The values already come in through env: rather than being interpolated by Actions, so this is the second half of the same rule -- read them back as "$VAR" so a value with whitespace or a glob character can't be split or expanded. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/e2e-delete-repo.yml | 6 +++--- .github/workflows/e2e-external-phase-2.yml | 2 +- .github/workflows/e2e-private-link-in-pr.yml | 4 ++-- .github/workflows/e2e-public-link-in-pr.yml | 2 +- .github/workflows/manual-release.yml | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/e2e-delete-repo.yml b/.github/workflows/e2e-delete-repo.yml index d77068f2..2a4a0a8a 100644 --- a/.github/workflows/e2e-delete-repo.yml +++ b/.github/workflows/e2e-delete-repo.yml @@ -19,17 +19,17 @@ jobs: 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-2.yml b/.github/workflows/e2e-external-phase-2.yml index e8782aa1..dd02e842 100644 --- a/.github/workflows/e2e-external-phase-2.yml +++ b/.github/workflows/e2e-external-phase-2.yml @@ -120,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-link-in-pr.yml index 5eb0f5af..232e3068 100644 --- a/.github/workflows/e2e-private-link-in-pr.yml +++ b/.github/workflows/e2e-private-link-in-pr.yml @@ -36,7 +36,7 @@ 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 }} @@ -54,7 +54,7 @@ jobs: pull-requests: write # Post the comment linking to the private e2e repo steps: - run: | - gh pr comment ${LINK} --body-file - < Date: Sun, 9 Aug 2026 14:51:14 +0200 Subject: [PATCH 09/14] chore: lint workflows with actionlint Complements zizmor, which only looks at security: actionlint validates expressions and contexts, so a typo like github.event_nam or a reference to an undefined step output is caught. That matters for the examples, which document 20+ steps.coverage_comment.outputs.* names. shellcheck-py comes along because actionlint's shell linting is a no-op when shellcheck isn't on PATH -- silently, with no warning that the check was skipped. Two limits worth knowing. actionlint checks `with:` input names against a bundled database keyed by tag, so it never fires on a SHA-pinned `uses:` -- which is all of them here. And it reads every file it's given as a workflow, so action.yml is excluded from the hook. Co-Authored-By: Claude Opus 5 (1M context) --- .pre-commit-config.yaml | 10 ++++++++++ pyproject.toml | 2 ++ uv.lock | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7687ce73..59afad62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,16 @@ repos: files: ^(README\.md|docs/examples/.*)$ pass_filenames: false + - id: actionlint + name: actionlint + entry: uv run actionlint + types: [yaml] + # Unlike zizmor, actionlint reads every file it's given as a workflow, + # so action.yml is not in this list. + files: (\.github/workflows/.*\.ya?ml$)|(docs/examples/.*\.ya?ml$) + language: system + require_serial: true + - id: zizmor name: zizmor entry: uv run zizmor --fix=all diff --git a/pyproject.toml b/pyproject.toml index 070d0370..5b3467c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dev = [ "zizmor", "pytest-subprocess", "pytest-httpx", + "actionlint-py>=1.7.12.24", + "shellcheck-py>=0.11.0.1", ] [tool.hatch.build.targets.sdist] diff --git a/uv.lock b/uv.lock index 1b83f38e..f3ccaba3 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,12 @@ resolution-markers = [ "python_full_version < '3.15'", ] +[[package]] +name = "actionlint-py" +version = "1.7.12.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/0b/3f29683dfbe94208fb5c3806806a6ef419972892e25c3c4f95198f68c978/actionlint_py-1.7.12.24.tar.gz", hash = "sha256:7571b0724fde79b2572b98b2b53792c470249d4db29951b57fc49b9cd3eaf11e", size = 12071, upload-time = "2026-03-31T06:21:35.015Z" } + [[package]] name = "anyio" version = "4.14.2" @@ -141,6 +147,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "actionlint-py" }, { name = "basedpyright" }, { name = "mypy" }, { name = "pytest" }, @@ -148,6 +155,7 @@ dev = [ { name = "pytest-httpx" }, { name = "pytest-subprocess" }, { name = "ruff" }, + { name = "shellcheck-py" }, { name = "tenacity" }, { name = "zizmor" }, ] @@ -162,6 +170,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "actionlint-py", specifier = ">=1.7.12.24" }, { name = "basedpyright" }, { name = "mypy" }, { name = "pytest" }, @@ -169,6 +178,7 @@ dev = [ { name = "pytest-httpx" }, { name = "pytest-subprocess" }, { name = "ruff" }, + { name = "shellcheck-py", specifier = ">=0.11.0.1" }, { name = "tenacity" }, { name = "zizmor" }, ] @@ -537,6 +547,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] +[[package]] +name = "shellcheck-py" +version = "0.11.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/455b097417b3df3d330eff029c72c32f08b25739e3010acb30ad06d268ef/shellcheck_py-0.11.0.1.tar.gz", hash = "sha256:5c620c88901e8f1d3be5934b31ea99e3310065e1245253741eafd0a275c8c9cc", size = 3139, upload-time = "2025-08-09T17:53:42.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/27/d75b03e5458cefdb6d3b674566cd20476c3e4d3fe6cc9d68b7e3b854b296/shellcheck_py-0.11.0.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b6a3fee28efda2e16e38d6e6d59faf7224300256456639727370d404730849e8", size = 6774472, upload-time = "2025-08-09T17:53:34.573Z" }, + { url = "https://files.pythonhosted.org/packages/61/ac/2a84c37171c0cf5a10ea4b0a27d43eb0a1d29bd98b49c2c5ffe17ad24bbe/shellcheck_py-0.11.0.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6b88d0a244c82ed07e06a53e444da841f69330ca59ae15d4a66c391655dae7a0", size = 11381835, upload-time = "2025-08-09T17:53:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/96/55/250e0e3367613a5c22bd82e33b16b889287d81ab0f7dda67e6514a4cccf4/shellcheck_py-0.11.0.1-py2.py3-none-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b274df81de5b000ff78db433e7328b87e52e3c38481c60f8e488c3095beef05", size = 3800600, upload-time = "2025-08-09T17:53:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/15/5b/bb14c0a7474463b1aa3c09e866cb172dffc66ed2993b7ea8f1db581e86ee/shellcheck_py-0.11.0.1-py2.py3-none-win_amd64.whl", hash = "sha256:784156289ecb17e91c692cd783ab5152333309588cabb10032a047331c63e759", size = 8027541, upload-time = "2025-08-09T17:53:40.889Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" From f7bf75ba09cd4a2d5d48214ad40182c20bfbbe43 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 15:22:19 +0200 Subject: [PATCH 10/14] docs: document USE_GH_PAGES_HTML_URL Added in 717fea2 and released in v3.36, but never listed in "All options", so it has been undiscoverable for close to a year. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 1d2e4f16..9cb47cf7 100644 --- a/README.md +++ b/README.md @@ -512,6 +512,12 @@ Usage may look like this # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-notice-message ANNOTATION_TYPE: warning + # If true, link the HTML coverage report to your GitHub Pages site, which + # assumes you serve COVERAGE_DATA_BRANCH from there. If false, the link + # goes through htmlpreview.github.io instead (or, on GitHub Enterprise, + # straight to the file in the repository). + USE_GH_PAGES_HTML_URL: false + # Name of the artifact in which the body of the comment to post on the PR is stored. # You typically don't have to change this unless you're already using this name for something else. COMMENT_ARTIFACT_NAME: python-coverage-comment-action From a5a7a0920a50ffe26c62376d537343595605242d Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 15:22:19 +0200 Subject: [PATCH 11/14] chore: check that action.yml and the docs agree on the inputs USE_GH_PAGES_HTML_URL went undocumented for nearly a year, and nothing was in a position to notice. GitHub Actions only logs an "Unexpected input(s)" warning for a name that doesn't exist, actionlint's input database is keyed by tag so it never fires on a SHA-pinned `uses:`, and zizmor ignores inputs. So compare both directions: inputs used in docs/examples/ must exist in action.yml, and every input in action.yml must appear in "All options". The script locates things by structure -- a heading, a key -- and a checker that silently finds nothing reads as a pass, which is worse than having no checker. Each lookup therefore asserts it found something and fails loudly if the shape of action.yml or the README moves underneath it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/check_documented_inputs.py | 122 +++++++++++++++++++++ .pre-commit-config.yaml | 7 ++ pyproject.toml | 1 + uv.lock | 28 +++++ 4 files changed, 158 insertions(+) create mode 100644 .github/scripts/check_documented_inputs.py 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59afad62..a0e59efb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,13 @@ repos: files: ^(README\.md|docs/examples/.*)$ pass_filenames: false + - id: check-documented-inputs + name: check documented inputs + entry: uv run python .github/scripts/check_documented_inputs.py + language: system + files: ^(README\.md|action\.ya?ml|docs/examples/.*)$ + pass_filenames: false + - id: actionlint name: actionlint entry: uv run actionlint diff --git a/pyproject.toml b/pyproject.toml index 5b3467c1..47636de1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "pytest-httpx", "actionlint-py>=1.7.12.24", "shellcheck-py>=0.11.0.1", + "pyyaml>=6.0.3", ] [tool.hatch.build.targets.sdist] diff --git a/uv.lock b/uv.lock index f3ccaba3..10514531 100644 --- a/uv.lock +++ b/uv.lock @@ -154,6 +154,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-httpx" }, { name = "pytest-subprocess" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "shellcheck-py" }, { name = "tenacity" }, @@ -177,6 +178,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-httpx" }, { name = "pytest-subprocess" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "ruff" }, { name = "shellcheck-py", specifier = ">=0.11.0.1" }, { name = "tenacity" }, @@ -522,6 +524,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/4f/ebe38bf128380f6a8a9b0fbbbe24cbf83915bb2f934717be65cadf55b6fa/pytest_subprocess-1.6.0-py3-none-any.whl", hash = "sha256:00037100f30429c8546adc81f357fddb5213eb036fe3bfb47b7b6befc965e5b2", size = 23803, upload-time = "2026-05-10T08:22:52.52Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.15.21" From c64623c135f2556ddd3336cb059d4be42ff7da95 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 15:33:44 +0200 Subject: [PATCH 12/14] ci: keep the e2e tokens available to ci.yml Correction to ce3be58, which took the tokens away from ci.yml on the grounds that member pull requests could fall back to the /e2e approval flow. They can't: e2e-external-phase-1 gates on author_association != 'MEMBER', because that flow exists precisely for the PRs ci.yml doesn't cover. So that commit left our own PRs with no end-to-end run at all, and GitHub doesn't allow approving your own PR, so no condition tweak fixes it either. Declare the environment on the job instead and give it no protection rules. The tokens stay reachable from refs/pull/N/merge, which a deployment branch rule would refuse, and the scoping still holds: a job can only read them by naming the environment, which is a visible line in the diff. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a7adf5a..8129dfe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: test: name: Run tests & display coverage runs-on: ubuntu-latest + # Holds the e2e tokens. No protection rules: this job runs on + # refs/pull/N/merge, which a deployment branch rule would refuse. + environment: e2e permissions: 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 @@ -49,8 +52,8 @@ jobs: run: uv run pytest env: PY_COLORS: 1 - # The e2e tests skip without these; they only run through the /e2e - # approval flow, which holds the tokens in the `e2e` environment. + 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 }} From 1c0176781257defd21ad023d7cf52f756699b97b Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 9 Aug 2026 16:03:30 +0200 Subject: [PATCH 13/14] ci: run the end-to-end tests in their own job Three things fall out of the split. The e2e tokens stop being in scope for the unit tests, the coverage comment and the artifact upload, which is a tighter grant than putting the environment on the whole job. The `e2e` deployment stops appearing on fork pull requests, where the suite only skips for want of secrets, and where a deployment implied work that never happened. And an end-to-end failure is now distinguishable from a unit test failure. The end-to-end tests import nothing from coverage_comment, so ignoring them in the main job leaves the coverage figure unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 47 +++++++++++++++---- ...-link-in-pr.yml => e2e-private-invite.yml} | 0 .github/workflows/e2e-public-link-in-pr.yml | 33 ------------- 3 files changed, 37 insertions(+), 43 deletions(-) rename .github/workflows/{e2e-private-link-in-pr.yml => e2e-private-invite.yml} (100%) delete mode 100644 .github/workflows/e2e-public-link-in-pr.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8129dfe8..a832fb98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,6 @@ jobs: test: name: Run tests & display coverage runs-on: ubuntu-latest - # Holds the e2e tokens. No protection rules: this job runs on - # refs/pull/N/merge, which a deployment branch rule would refuse. - environment: e2e permissions: 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 @@ -49,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 @@ -72,13 +66,46 @@ 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 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 # Push the base image to ghcr.io diff --git a/.github/workflows/e2e-private-link-in-pr.yml b/.github/workflows/e2e-private-invite.yml similarity index 100% rename from .github/workflows/e2e-private-link-in-pr.yml rename to .github/workflows/e2e-private-invite.yml diff --git a/.github/workflows/e2e-public-link-in-pr.yml b/.github/workflows/e2e-public-link-in-pr.yml deleted file mode 100644 index 4c919b18..00000000 --- a/.github/workflows/e2e-public-link-in-pr.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Post link to public end-to-end test repository - -on: # zizmor: ignore[dangerous-triggers] We're using pull_request_target to post a static comment on PRs. We're not checking out anything. We don't interact with anything from the PR except html_url (provided by github) and pull_request.number (an integer). This is safe. - pull_request_target: - types: - - opened - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: false - -permissions: {} - -jobs: - test: - name: Post link to public e2e repo - runs-on: ubuntu-latest - permissions: - pull-requests: write # Post the comment linking to the public e2e repo - steps: - - run: | - gh pr comment "${LINK}" --body-file - < Date: Sun, 9 Aug 2026 16:03:30 +0200 Subject: [PATCH 14/14] ci: link the e2e repositories from the deployment Now that the job declares an environment, GitHub shows a deployment on the pull request, and it can carry the link that two workflows existed to post. The URL is the owner's repository list filtered by pull request number rather than one repository: a deployment gets a single URL, and this covers both the public and the private repo, while listing nothing instead of 404ing when a run created neither. So e2e-public-link-in-pr.yml goes, taking a pull_request_target trigger with it, and the private workflow keeps only the half a URL can't replace -- the invitation itself -- and is renamed to match. The admin commands it used to advertise move to CONTRIBUTING. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/e2e-private-invite.yml | 21 +-------------------- CONTRIBUTING.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/.github/workflows/e2e-private-invite.yml b/.github/workflows/e2e-private-invite.yml index 232e3068..3047861d 100644 --- a/.github/workflows/e2e-private-invite.yml +++ b/.github/workflows/e2e-private-invite.yml @@ -1,4 +1,4 @@ -name: Post link to private end-to-end test repository +name: Invite to the private end-to-end test repository on: issue_comment: @@ -43,22 +43,3 @@ jobs: 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 # Post the comment linking to the private e2e repo - steps: - - run: | - gh pr comment "${LINK}" --body-file - <