Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 122 additions & 0 deletions .github/scripts/check_documented_inputs.py
Original file line number Diff line number Diff line change
@@ -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())
121 changes: 121 additions & 0 deletions .github/scripts/sync_readme_examples.py
Original file line number Diff line number Diff line change
@@ -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<path>[^"]+)"(?P<lines> lines=(?P<start>\d+)-(?P<end>\d+))?\n'
r"(?P<body>.*?)^```$",
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())
4 changes: 4 additions & 0 deletions .github/workflows/autofix.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
name: autofix.ci
on: [pull_request]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions: {}

jobs:
Expand Down
63 changes: 43 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 }}
Expand Down
Loading
Loading