From bb5133075043a5ec3658cdbe2e06af307fbcf6a8 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Thu, 1 Oct 2026 11:39:42 +0200 Subject: [PATCH 1/4] Keep Pipelex's own workflows out of projects made from the template Every job in cla.yml, guard-branches.yml, version-check.yml, changelog-check.yml and github-release.yml now runs only in Pipelex/pipelex-starter-python, so a repository created with "Use this template" inherits them inert instead of failing every pull request on the CLA app's missing secrets or asking its contributors to sign Pipelex's CLA. The guard keeps the template's required checks posting. The bootstrap script now deletes those workflows and the release skill (MAINTAINER_ONLY_PATHS), and a test holds that list against the guards on disk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UcXtZsHrK9JVCxL2BMhUvU --- .claude/skills/bootstrap/SKILL.md | 9 +- .claude/skills/bootstrap/scripts/bootstrap.py | 52 ++++++- .claude/skills/release/SKILL.md | 4 +- .github/workflows/changelog-check.yml | 5 +- .github/workflows/cla.yml | 4 + .github/workflows/github-release.yml | 4 + .github/workflows/guard-branches.yml | 9 +- .github/workflows/version-check.yml | 4 + AGENTS.md | 1 + CHANGELOG.md | 6 + README.md | 3 +- tests/unit/test_bootstrap_script.py | 132 ++++++++++++++++++ 12 files changed, 219 insertions(+), 14 deletions(-) diff --git a/.claude/skills/bootstrap/SKILL.md b/.claude/skills/bootstrap/SKILL.md index a179e15..1484763 100644 --- a/.claude/skills/bootstrap/SKILL.md +++ b/.claude/skills/bootstrap/SKILL.md @@ -65,7 +65,7 @@ python .claude/skills/bootstrap/scripts/bootstrap.py \ Pass `--clean` because the user opted to strip the template-only scaffolding (the README "Use this template / Next steps" block; the bootstrap skill itself is removed separately in Step 6). Omit it only if the user changed their mind and wants the template block kept. -The dry run prints the package-directory rename and the list of files that would be edited. Present that summary and **get explicit confirmation** before the real run. Only pass `--dist` if the user wants a distribution name that isn't just the package with dashes. +The dry run prints the package-directory rename, the list of files that would be edited and the template's own maintenance files that would be removed. Present that summary and **get explicit confirmation** before the real run. Only pass `--dist` if the user wants a distribution name that isn't just the package with dashes. ## Step 4 — Run the replacement @@ -75,8 +75,9 @@ Re-run the exact same command **without** `--dry-run`. The script: - fills in description, and (if given) author, repo URL - applies the license choice in all three places: the `LICENSE` body, `license = "..."` in `pyproject.toml`, and the README license line - strips the README template block +- removes the template's own maintenance, listed in the script's `MAINTAINER_ONLY_PATHS`: the CLA assistant (`cla.yml`), the branch-flow guard (`guard-branches.yml`), the release checks (`version-check.yml`, `changelog-check.yml`), the GitHub Release job (`github-release.yml`) and the `release` skill. They encode Pipelex's contributor agreement and release discipline, not the user's; each of their jobs is guarded to `Pipelex/pipelex-starter-python`, so they were already inert in the new repository, and removing them keeps them out of it altogether. `lint-check.yml`, `tests-check.yml` and `package-check.yml` stay: they are the project's own CI. The removal is a plain delete, unstaged like the edits, and it happens after the survivor check, so an aborted run has removed nothing. -It deliberately does **not** run the lock file, run the checks, commit, or touch `.github/`, `.venv/`, `uv.lock`, or the existing `release` skill. +It deliberately does **not** run the lock file, run the checks, commit, or touch `.venv/`, `uv.lock` or the workflows it keeps. **Heads-up — file state changed on disk.** The script rewrites `pyproject.toml`, `README.md`, and `LICENSE` (and `--clean` shifts README line numbers). If you find you need a manual `Edit` afterward, **re-read the file first** and re-derive any line numbers — a pre-run `grep` result is stale, and an `Edit` against an unread/old version will fail with "modified since read." In practice the script is meant to cover every placeholder so manual edits shouldn't be needed; if you reach for one, it's worth checking whether the script should handle that case instead. @@ -109,7 +110,7 @@ Use a plain `rm` (not `git rm`) so the deletion stays unstaged, like the other c Finally, give the user a short summary: - the three name forms that were applied, and the license that was set -- that the package directory was renamed +- that the package directory was renamed, and that the template's own CLA, branch-flow and release workflows and its `release` skill were removed - that `uv.lock` was regenerated and `make agent-check` / `make agent-test` pass - that **nothing is committed**; the renames are staged (`R`) and the content edits are unstaged (`M`) — they should review with `git status` and `git diff`, then commit (a single `git add -A && git commit` captures everything) - a nudge to skim the new `README.md` and write real project content, and to update `CLAUDE.md` if the project's specifics have changed @@ -120,5 +121,5 @@ Finally, give the user a short summary: - **Always dry-run before the real run** and get confirmation. This edits a brand-new repo and renames a directory; the preview is cheap insurance. - **Regenerate `uv.lock`.** Renaming the distribution name makes the lock stale; `make li` (or `uv lock`) is what keeps `package-check.yml` green. Don't skip it. - **Don't stop on a red check.** A failing `make agent-check` / `make agent-test` here means CI will fail too — fix the root cause and re-run. -- **Don't touch the `release` skill or `.github/` workflows** — they're generic to the template and not placeholders. +- **Don't edit the workflows the script keeps** (`lint-check.yml`, `tests-check.yml`, `package-check.yml`) — they're the project's own CI and hold no placeholder. Everything else under `.github/workflows/`, and the `release` skill, is the template's own and the script removes it. - If any step fails or the user wants to abort, stop immediately and leave the tree in a state they can inspect — don't push forward through errors. diff --git a/.claude/skills/bootstrap/scripts/bootstrap.py b/.claude/skills/bootstrap/scripts/bootstrap.py index fdd4ae2..9e0f802 100644 --- a/.claude/skills/bootstrap/scripts/bootstrap.py +++ b/.claude/skills/bootstrap/scripts/bootstrap.py @@ -16,9 +16,11 @@ what makes it testable, and it hard-fails if any placeholder token survives the substitution. -The script only transforms files. It does NOT touch git, run `uv lock`, run the -checks, or remove the bootstrap skill — the SKILL.md orchestrates those so each -step stays reviewable and the script stays a pure, idempotent transform. +Besides transforming files, it deletes the template's own maintenance files +(MAINTAINER_ONLY_PATHS), which no project made from the template has a use for. +It does NOT commit, run `uv lock`, run the checks, or remove the bootstrap skill +— the SKILL.md orchestrates those so each step stays reviewable and the script +stays an idempotent transform. """ from __future__ import annotations @@ -45,6 +47,23 @@ # it tests. See gather_target_files(). BOOTSTRAP_TEST_FILE = Path("tests") / "unit" / "test_bootstrap_script.py" +# The template's own maintenance, which run() deletes: the CLA assistant, the branch-flow +# guard, the release version and changelog checks, the GitHub Release job, and the `release` +# skill that drives them. They encode Pipelex's contributor agreement and release discipline, +# not a project's. Every job in those workflows is also guarded to the template's own +# repository, so a project made from the template without running the bootstrap inherits them +# inert. The workflows a project does want — lint, tests and the lock check — are not listed. +# tests/unit/test_bootstrap_script.py holds this list against the guards in the workflows on +# disk, so the two cannot drift apart. +MAINTAINER_ONLY_PATHS = ( + Path(".github") / "workflows" / "changelog-check.yml", + Path(".github") / "workflows" / "cla.yml", + Path(".github") / "workflows" / "github-release.yml", + Path(".github") / "workflows" / "guard-branches.yml", + Path(".github") / "workflows" / "version-check.yml", + Path(".claude") / "skills" / "release", +) + PACKAGE_RE = re.compile(r"^[a-z][a-z0-9_]*$") # PEP 503/508 distribution name: alphanumerics separated by single '.', '-' or # '_', starting and ending with an alphanumeric. uv/packaging reject anything @@ -446,6 +465,22 @@ def move(root: Path, src: Path, dst: Path, opts: "Options") -> None: print(f" renamed {rel_src} -> {rel_dst}") +def remove(root: Path, rel: Path, opts: "Options") -> bool: + """Delete a file or directory with a plain delete, so the deletion stays unstaged like the edits.""" + path = root / rel + if not path.exists(): + return False + if opts.dry_run: + print(f" remove {rel}") + return True + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + print(f" removed {rel}") + return True + + # --------------------------------------------------------------------------- # # Orchestration # --------------------------------------------------------------------------- # @@ -624,7 +659,16 @@ def run(root: Path, names: Names, opts: Options) -> None: if changed == 0: print(" (no content changes)") print() - print(f"Done. {changed} file(s) {'would be ' if opts.dry_run else ''}edited.") + + # 3. The template's own maintenance files. After the edits, so a run the survivor + # check aborts has deleted nothing; and idempotent, since a re-run finds them gone. + print("Removals:") + removed = sum(remove(root, rel, opts) for rel in MAINTAINER_ONLY_PATHS) + if removed == 0: + print(" (nothing to remove)") + print() + would_be = "would be " if opts.dry_run else "" + print(f"Done. {changed} file(s) {would_be}edited, {removed} path(s) {would_be}removed.") if not opts.dry_run: print("\nNext: regenerate the lock file (uv.lock pins the project name) and run the checks:") print(" make li && make agent-check && make agent-test") diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 3124763..3e40f33 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -24,7 +24,7 @@ The procedure is the workspace release play, [`docs/workspace/releasing.md`](../ Two things about that job are worth knowing before the merge: -- **It is unguarded.** Nothing checks whether the Release or the tag already exists, so a push to `main` carrying no version bump fails at `gh release create`, and re-running a run whose Release was already created fails the same way. Once a version has shipped, the way forward is a new version, not a re-run. +- **It is not idempotent.** Nothing checks whether the Release or the tag already exists, so a push to `main` carrying no version bump fails at `gh release create`, and re-running a run whose Release was already created fails the same way. Once a version has shipped, the way forward is a new version, not a re-run. - **A missing changelog entry does not fail it.** When no `## [vX.Y.Z] - ` heading matches the version, the extraction step prints a warning, sets the notes empty and exits 0, so the Release ships carrying the bare line `Release vX.Y.Z`. `changelog-check.yml` on the pull request is the only thing standing between a forgotten entry and that outcome. The landing verifies the publish — the run, the Release, the tag: @@ -55,6 +55,8 @@ Run in the worktree, in this order, before the commit: ## CI on the release pull request +The template's own workflows — `guard-branches.yml`, `version-check.yml`, `changelog-check.yml`, `cla.yml` and the `github-release.yml` above — guard every job with `if: github.repository == 'Pipelex/pipelex-starter-python' && …`, so a repository made with "Use this template" inherits them inert, and the bootstrap deletes them together with this skill (its `MAINTAINER_ONLY_PATHS`). A job added to one of them carries the same guard, and `tests/unit/test_bootstrap_script.py` fails until it does. Here the guard always holds, so it changes nothing about what follows; the required checks they post keep posting. + - `guard-branches.yml` — the `gate-main` job asserts the head branch into `main` matches `^release/v[0-9]+\.[0-9]+\.[0-9]+$` exactly. Its `protect-workflows` job additionally refuses a change under `.github/workflows/` from an author whose association is `CONTRIBUTOR`. - `version-check.yml` — the `pyproject.toml` version equals the version in the branch name. That is all it compares: it never checks the number against what `main` already carries, so a re-used or lower version passes here and fails after the merge, at `gh release create`. A head that is not a release branch does not slip past either — the `exit 0` in its first step ends that step alone, and the comparison that follows then fails on an empty branch version. - `changelog-check.yml` — `CHANGELOG.md` carries a `## [vX.Y.Z] - ` heading for the version in the branch name. It asserts nothing about `[Unreleased]`; leaving none behind is the play's rule, not CI's. diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 7684a94..a69bc61 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -1,3 +1,6 @@ +# The template's own maintenance, not a project's: every job runs only in +# Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits +# this file inert, and /bootstrap deletes it. name: Changelog Version Check on: @@ -9,7 +12,7 @@ on: jobs: check-changelog: runs-on: ubuntu-latest - if: startsWith(github.head_ref, 'release/v') # Only run on release branches + if: github.repository == 'Pipelex/pipelex-starter-python' && startsWith(github.head_ref, 'release/v') # Only run on the template's release branches steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 787ef8a..3d12d3e 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -1,3 +1,6 @@ +# The template's own maintenance, not a project's: every job runs only in +# Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits +# this file inert, and /bootstrap deletes it. name: "CLA Assistant bot" on: issue_comment: @@ -13,6 +16,7 @@ permissions: jobs: CLAAssistant: + if: github.repository == 'Pipelex/pipelex-starter-python' runs-on: ubuntu-latest steps: - name: Get GitHub App token diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index b91d756..ce74611 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -1,3 +1,6 @@ +# The template's own maintenance, not a project's: every job runs only in +# Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits +# this file inert, and /bootstrap deletes it. name: Create release on: @@ -8,6 +11,7 @@ on: jobs: github-release: name: Create GitHub Release + if: github.repository == 'Pipelex/pipelex-starter-python' runs-on: ubuntu-latest permissions: contents: write # IMPORTANT: mandatory for making GitHub Releases diff --git a/.github/workflows/guard-branches.yml b/.github/workflows/guard-branches.yml index 0cab356..289d4a2 100644 --- a/.github/workflows/guard-branches.yml +++ b/.github/workflows/guard-branches.yml @@ -1,3 +1,6 @@ +# The template's own maintenance, not a project's: every job runs only in +# Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits +# this file inert, and /bootstrap deletes it. name: Guard branch flow on: pull_request_target: @@ -8,7 +11,7 @@ jobs: # 1) Only release/vX.Y.Z → main # ─────────────────────────────────────────────────────────────── gate-main: - if: github.event.pull_request.base.ref == 'main' + if: github.repository == 'Pipelex/pipelex-starter-python' && github.event.pull_request.base.ref == 'main' runs-on: ubuntu-latest steps: - name: Verify source branch is a Release @@ -25,7 +28,7 @@ jobs: # 2) Only work-branches → release/vX.Y.Z # ─────────────────────────────────────────────────────────────── gate-release: - if: startsWith(github.event.pull_request.base.ref, 'release/v') || github.event.pull_request.base.ref == 'dev' + if: github.repository == 'Pipelex/pipelex-starter-python' && (startsWith(github.event.pull_request.base.ref, 'release/v') || github.event.pull_request.base.ref == 'dev') runs-on: ubuntu-latest steps: - name: Verify source branch uses allowed prefix @@ -47,7 +50,7 @@ jobs: protect-workflows: runs-on: ubuntu-latest # only block non-maintainers - if: github.event.pull_request.author_association == 'CONTRIBUTOR' + if: github.repository == 'Pipelex/pipelex-starter-python' && github.event.pull_request.author_association == 'CONTRIBUTOR' steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index ebcccc1..0dd1c53 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -1,3 +1,6 @@ +# The template's own maintenance, not a project's: every job runs only in +# Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits +# this file inert, and /bootstrap deletes it. name: Version check on: @@ -6,6 +9,7 @@ on: - main jobs: version-check: + if: github.repository == 'Pipelex/pipelex-starter-python' runs-on: ubuntu-latest steps: - name: Get branch info diff --git a/AGENTS.md b/AGENTS.md index 5599aa2..c416e86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ The full project guide for AI coding agents is [`CLAUDE.md`](CLAUDE.md) — read - **The project-name placeholder is an ordinary English word, so never write that word in ordinary prose.** `/bootstrap` substitutes every occurrence of the template's stand-in name and aborts the whole run if any survives, so a sentence using that word in its generic sense fails a user's bootstrap. The plural aborts too, and deliberately: no substitution rule matches it, so the survivor check is what stands between a stray plural and a half-renamed project. Say "the package", "the project" or "the CLI" instead. In `pyproject.toml` the bar is higher still: a bare occurrence there is refused rather than guessed at, since the file puts both name forms in bare-string positions, so name a path (`widget/methods`) or a module (`widget.cli`) and never the token alone. - **Do not move or delete the `# add-method:imports` and `# add-method:commands` anchors** in the three mode files. `make add-method` inserts at them, the prose after each token is free to reword, and `tests/unit/test_add_method.py` reads the real files so a lost anchor fails the suite. - **Never commit a planning note or campaign document to this template.** It is consumed through "Use this template", so anything it tracks is copied into somebody's new project. The template's own campaign documents live in the workspace wip repository (`wip//` at the workspace root), and the workspace guard refuses a write into a `wip/` here. +- **A workflow is either the project's CI or the template's own maintenance, and the template's own is guarded.** Everything under `.github/workflows/` is copied into every project made with "Use this template". A workflow a project wants — lint, tests, the lock check — runs anywhere. One that is the template's own — Pipelex's CLA, branch flow and release discipline — guards every job with `if: github.repository == 'Pipelex/pipelex-starter-python'`, joined to any other condition by `&&` and never escaped by an `||`, and is listed in `MAINTAINER_ONLY_PATHS` in the bootstrap script, which `/bootstrap` deletes. An unguarded CLA job fails every pull request of a project that lacks its secrets, or asks the project's contributors to sign Pipelex's CLA. `tests/unit/test_bootstrap_script.py` fails when the guards and the list disagree, but it cannot tell that a new workflow carrying neither is the template's own, so that call is yours. A method that lives **on the platform** (a catalog id) or **in a published package** (an address) is wired in with `make add-method METHOD=` rather than by hand — see [`docs/add-method.md`](docs/add-method.md). A method whose bundle you have on disk goes under `widget/methods//` and through `make codegen` instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f00bd..df07312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- **A project made from the template no longer runs Pipelex's own workflows**: the CLA assistant, the branch-flow guard, the release version and changelog checks and the GitHub Release job run only in `Pipelex/pipelex-starter-python`, so a repository created with "Use this template" no longer fails every pull request for want of the CLA app's secrets or asks its contributors to sign Pipelex's CLA. `/bootstrap` now deletes those workflows and the template's `release` skill, and keeps `lint-check.yml`, `tests-check.yml` and `package-check.yml` as the project's CI; a project made earlier deletes the same files by hand. + ## [v0.2.0] - 2026-09-27 ### Changed diff --git a/README.md b/README.md index 2ae86ce..d06f40c 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,14 @@ Each prints its result as JSON on stdout, and a short **cost report** (per-call This is a template repository — don't clone it directly. Click the green **Use this template** button at the top-right of the GitHub page to create your own repo, then clone that. -**Make it yours.** The fastest path is the bundled `/bootstrap` skill: open your new repo in [Claude Code](https://claude.com/claude-code) and run `/bootstrap`. It renames the placeholder (`widget` → your project name) everywhere — the package directory, `pyproject.toml`, the CLI command, imports, README, and LICENSE — then regenerates the lock file and runs the checks. Just answer its prompts (project name, description, license). +**Make it yours.** The fastest path is the bundled `/bootstrap` skill: open your new repo in [Claude Code](https://claude.com/claude-code) and run `/bootstrap`. It renames the placeholder (`widget` → your project name) everywhere — the package directory, `pyproject.toml`, the CLI command, imports, README, and LICENSE — removes the template's own CLA and release workflows, then regenerates the lock file and runs the checks. Just answer its prompts (project name, description, license). Prefer to do it by hand? The manual equivalent: 1. In `pyproject.toml`, replace `widget` with your project name — dashes in `[project] name` and the `[project.scripts]` command, underscores in `[tool.setuptools] packages`, `[tool.mypy] packages`, and `[tool.pyright] include`. 2. Rename the `widget/` directory to your package name (underscores). 3. Update the imports across `widget/` and `tests/` to match. 4. Rewrite this README with your own project details. +5. Delete the template's own maintenance: `cla.yml`, `guard-branches.yml`, `version-check.yml`, `changelog-check.yml` and `github-release.yml` under `.github/workflows/`, and the `.claude/skills/release/` skill. Their jobs run only in `Pipelex/pipelex-starter-python`, so they do nothing in your repository, but they are Pipelex's contributor agreement and release discipline rather than yours. Keep `lint-check.yml`, `tests-check.yml` and `package-check.yml`: they are your project's CI. ## Prerequisites diff --git a/tests/unit/test_bootstrap_script.py b/tests/unit/test_bootstrap_script.py index c80513a..ca0b53c 100644 --- a/tests/unit/test_bootstrap_script.py +++ b/tests/unit/test_bootstrap_script.py @@ -1,6 +1,8 @@ from __future__ import annotations +import dataclasses import importlib.util +import re import sys from importlib.machinery import SourceFileLoader from pathlib import Path @@ -10,6 +12,9 @@ ROOT = Path(__file__).resolve().parents[2] BOOTSTRAP_SCRIPT = ROOT / ".claude" / "skills" / "bootstrap" / "scripts" / "bootstrap.py" +WORKFLOWS_DIR = Path(".github") / "workflows" +# The condition that keeps a maintainer-only job from running in a project made from the template. +TEMPLATE_REPOSITORY_GUARD = "github.repository == 'Pipelex/pipelex-starter-python'" def load_bootstrap() -> Any: @@ -23,6 +28,52 @@ def load_bootstrap() -> Any: return module +def job_conditions(workflow: Path) -> dict[str, str | None]: + """Each job of a workflow, mapped to its job-level `if:` with any trailing comment dropped. + + Read from the layout every workflow here shares — job keys two spaces in under `jobs:`, their + fields four — rather than parsed, which keeps a YAML library out of the dependencies for one + test. A job whose `if:` sits anywhere else reads as unguarded, which fails loudly below.""" + jobs: dict[str, str | None] = {} + in_jobs = False + current = "" + for line in workflow.read_text(encoding="utf-8").splitlines(): + if line.startswith("jobs:"): + in_jobs = True + elif in_jobs and line and not line[0].isspace() and not line.startswith("#"): + in_jobs = False + elif in_jobs and (job := re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)): + current = job.group(1) + jobs[current] = None + elif in_jobs and current and (condition := re.match(r"^ if:\s*(.+)$", line)): + jobs[current] = re.sub(r"\s+#.*$", "", condition.group(1)).strip() + return jobs + + +def is_guarded_to_the_template(condition: str | None) -> bool: + """True when the condition can only hold in the template's own repository. + + That is the guard alone, or the guard joined by `&&` to a remainder with no top-level `||`: + `&&` binds tighter, so `guard && a || b` runs `b` anywhere.""" + if condition is None: + return False + if condition == TEMPLATE_REPOSITORY_GUARD: + return True + prefix = f"{TEMPLATE_REPOSITORY_GUARD} && " + if not condition.startswith(prefix): + return False + remainder = condition[len(prefix) :] + depth = 0 + for index, char in enumerate(remainder): + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + elif depth == 0 and remainder.startswith("||", index): + return False + return True + + def write_template(root: Path, *, extra_pyproject: str = "") -> None: (root / "widget").mkdir() (root / "widget" / "blocking").mkdir() @@ -344,3 +395,84 @@ def test_survivor_check_still_rejects_unhandled_template_tokens(tmp_path: Path) bootstrap.run(tmp_path, names, opts) assert 'custom = "run widget somewhere"' in str(exc_info.value) + + +def test_run_removes_the_templates_own_maintenance_files(tmp_path: Path) -> None: + # The CLA assistant, the branch-flow guard, the release checks and the release skill are the + # template's own. In a project made from it, the CLA job either fails every pull request for + # want of the CLA app's secrets or asks the project's contributors to sign Pipelex's CLA. The + # workflows a project does want stay, and the deletion is left unstaged like the edits. + bootstrap = load_bootstrap() + write_template(tmp_path) + for rel in bootstrap.MAINTAINER_ONLY_PATHS: + if rel.suffix: + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text("name: maintainer-only\n", encoding="utf-8") + else: + (tmp_path / rel).mkdir(parents=True) + (tmp_path / rel / "SKILL.md").write_text("---\nname: release\n---\n", encoding="utf-8") + kept = tmp_path / WORKFLOWS_DIR / "lint-check.yml" + kept.write_text("name: Lint check\n", encoding="utf-8") + + names = bootstrap.Names(dist="invoice-extractor", package="invoice_extractor", title="Invoice Extractor") + opts = bootstrap.Options( + description="Extract invoice fields", + author_name=None, + author_email=None, + repo_url=None, + lic=bootstrap.License(kind="mit", spdx="MIT", holder=None, year=2026), + clean=False, + dry_run=True, + use_git=False, + ) + + bootstrap.run(tmp_path, names, opts) + assert all((tmp_path / rel).exists() for rel in bootstrap.MAINTAINER_ONLY_PATHS) + + real_opts = dataclasses.replace(opts, dry_run=False) + bootstrap.run(tmp_path, names, real_opts) + assert not any((tmp_path / rel).exists() for rel in bootstrap.MAINTAINER_ONLY_PATHS) + assert kept.read_text(encoding="utf-8") == "name: Lint check\n" + + # A re-run finds nothing left to remove rather than failing on what is gone. + bootstrap.run(tmp_path, names, real_opts) + + +def test_the_workflows_the_bootstrap_removes_are_exactly_the_guarded_ones() -> None: + # Both halves of the fix have to agree. A workflow the bootstrap removes but that is not + # guarded runs in every project whose owner has not bootstrapped yet, which is how the + # CLA assistant reached projects made from the template. A guarded workflow the bootstrap + # keeps is dead weight in the project. A job that is guarded by half — one job of a + # workflow and not its siblings, or a guard an `||` escapes — is the first case again. + bootstrap = load_bootstrap() + listed = {rel.name for rel in bootstrap.MAINTAINER_ONLY_PATHS if rel.parent == WORKFLOWS_DIR} + + guarded: set[str] = set() + for workflow in sorted((ROOT / WORKFLOWS_DIR).glob("*.y*ml")): + conditions = job_conditions(workflow) + assert conditions, f"{workflow.name}: no job found under `jobs:`" + verdicts = {job: is_guarded_to_the_template(condition) for job, condition in conditions.items()} + if any(verdicts.values()) or workflow.name in listed: + unguarded = sorted(job for job, verdict in verdicts.items() if not verdict) + assert not unguarded, f"{workflow.name}: jobs not guarded with `if: {TEMPLATE_REPOSITORY_GUARD} && …`: {unguarded}" + guarded.add(workflow.name) + + assert guarded == {name for name in listed if (ROOT / WORKFLOWS_DIR / name).exists()}, ( + "every guarded workflow belongs in MAINTAINER_ONLY_PATHS, and every workflow listed there must be guarded" + ) + + +@pytest.mark.parametrize( + ("condition", "expected"), + [ + (TEMPLATE_REPOSITORY_GUARD, True), + (f"{TEMPLATE_REPOSITORY_GUARD} && github.event.pull_request.base.ref == 'main'", True), + (f"{TEMPLATE_REPOSITORY_GUARD} && (startsWith(github.head_ref, 'release/v') || github.head_ref == 'dev')", True), + (f"{TEMPLATE_REPOSITORY_GUARD} && startsWith(github.head_ref, 'release/v') || github.head_ref == 'dev'", False), + (f"github.head_ref == 'dev' && {TEMPLATE_REPOSITORY_GUARD}", False), + ("github.event.pull_request.base.ref == 'main'", False), + (None, False), + ], +) +def test_the_guard_reading_refuses_a_guard_an_or_escapes(condition: str | None, expected: bool) -> None: + assert is_guarded_to_the_template(condition) is expected From 3d04861b932693001a82b3cb6b11a63d343c02cf Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Thu, 1 Oct 2026 11:48:13 +0200 Subject: [PATCH 2/4] Make protect-workflows read the pull request it guards Under pull_request_target the default checkout is the default branch, so the job diffed two base branches and never saw a pull request's own changes; and its author_association == 'CONTRIBUTOR' condition skipped first-time authors. It now trusts only authors with write access, resolved from their effective repository permission as pipelex-sdk-python and mthds-python already do, and reads the pull request's files from the API instead of a local diff, so nothing of the head is checked out and the list is exactly the pull request's changes against its merge base. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UcXtZsHrK9JVCxL2BMhUvU --- .claude/skills/release/SKILL.md | 2 +- .github/workflows/guard-branches.yml | 67 +++++++++++++++++++++++----- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 3e40f33..7b19881 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -57,7 +57,7 @@ Run in the worktree, in this order, before the commit: The template's own workflows — `guard-branches.yml`, `version-check.yml`, `changelog-check.yml`, `cla.yml` and the `github-release.yml` above — guard every job with `if: github.repository == 'Pipelex/pipelex-starter-python' && …`, so a repository made with "Use this template" inherits them inert, and the bootstrap deletes them together with this skill (its `MAINTAINER_ONLY_PATHS`). A job added to one of them carries the same guard, and `tests/unit/test_bootstrap_script.py` fails until it does. Here the guard always holds, so it changes nothing about what follows; the required checks they post keep posting. -- `guard-branches.yml` — the `gate-main` job asserts the head branch into `main` matches `^release/v[0-9]+\.[0-9]+\.[0-9]+$` exactly. Its `protect-workflows` job additionally refuses a change under `.github/workflows/` from an author whose association is `CONTRIBUTOR`. +- `guard-branches.yml` — the `gate-main` job asserts the head branch into `main` matches `^release/v[0-9]+\.[0-9]+\.[0-9]+$` exactly. Its `protect-workflows` job additionally refuses a change under `.github/workflows/` from an author without write access to the repository, reading the author's effective permission and the pull request's own file list from the API. - `version-check.yml` — the `pyproject.toml` version equals the version in the branch name. That is all it compares: it never checks the number against what `main` already carries, so a re-used or lower version passes here and fails after the merge, at `gh release create`. A head that is not a release branch does not slip past either — the `exit 0` in its first step ends that step alone, and the comparison that follows then fails on an empty branch version. - `changelog-check.yml` — `CHANGELOG.md` carries a `## [vX.Y.Z] - ` heading for the version in the branch name. It asserts nothing about `[Unreleased]`; leaving none behind is the play's rule, not CI's. - `lint-check.yml` — `merge-check-ruff-format`, `merge-check-ruff-lint` (which pulls in `check-unused-imports`), `merge-check-pyright` and `merge-check-mypy` on each Python version in the matrix; the aggregator job `Lint (all versions)` is the single required status. diff --git a/.github/workflows/guard-branches.yml b/.github/workflows/guard-branches.yml index 289d4a2..ea0ac7c 100644 --- a/.github/workflows/guard-branches.yml +++ b/.github/workflows/guard-branches.yml @@ -48,19 +48,62 @@ jobs: # 3) Prevent forks from editing your workflows # ─────────────────────────────────────────────────────────────── protect-workflows: + if: github.repository == 'Pipelex/pipelex-starter-python' runs-on: ubuntu-latest - # only block non-maintainers - if: github.repository == 'Pipelex/pipelex-starter-python' && github.event.pull_request.author_association == 'CONTRIBUTOR' + # Least privilege: reading the author's permission and the pull request's files needs read only. + permissions: + contents: read + pull-requests: read steps: - - name: Checkout code - uses: actions/checkout@v4 + # Trust hinges on the author's effective repository permission, not author_association: + # the association is a social label that misses first-time authors entirely, and an org + # member can hold read-only access. Only write, maintain and admin are trusted. + - name: Resolve author repository permission + id: perm + uses: actions/github-script@v7 with: - fetch-depth: 0 + script: | + const username = context.payload.pull_request.user.login; + let data = { permission: 'none', role_name: 'none' }; + try { + ({ data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + })); + } catch (error) { + // A 404 means the author is not a resolvable collaborator: untrusted, so the + // workflow check runs. Anything else is rethrown, so an API failure fails closed. + if (error.status !== 404) throw error; + } + // The legacy `permission` field folds maintain into "write" and triage into "read". + const trusted = data.permission === 'admin' || data.permission === 'write'; + core.info(`Author ${username}: permission=${data.permission} role=${data.role_name} trusted=${trusted}`); + core.setOutput('trusted', trusted ? 'true' : 'false'); + + # The pull request's own files, read from the API rather than from a checkout: under + # pull_request_target the default checkout is the default branch, not the pull request, + # so a local diff never sees what it changes. The API lists exactly the pull request's + # changes against its merge base, and nothing of its head is checked out or executed. - name: Detect workflow changes - run: | - git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1 - CHANGED=$(git diff --name-only FETCH_HEAD HEAD | grep -E '^\.github/workflows/.*\.ya?ml$' || true) - if [ -n "$CHANGED" ]; then - echo "::error::External contributors may not modify workflow files: $CHANGED" - exit 1 - fi + if: steps.perm.outputs.trusted != 'true' + uses: actions/github-script@v7 + with: + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }); + // The API stops listing at 3000 files, so a list that long may hide a workflow change. + if (files.length >= 3000) { + core.setFailed(`The pull request changes ${files.length} or more files, too many to check for workflow changes.`); + return; + } + const workflow = /^\.github\/workflows\/.*\.ya?ml$/; + const changed = [...new Set(files.flatMap((file) => [file.filename, file.previous_filename]))] + .filter((name) => name && workflow.test(name)); + if (changed.length > 0) { + core.setFailed(`External contributors may not modify workflow files: ${changed.join(', ')}`); + } From e357df89aec37e9ba061f370da3ffaffb5581b01 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Thu, 1 Oct 2026 11:48:13 +0200 Subject: [PATCH 3/4] Keep the template-maintenance rule out of bootstrapped projects AGENTS.md and CLAUDE.md survive /bootstrap, so the rule on guarding the template's own workflows, which names the bootstrap script and its test, pointed at deleted files in every derived project. The rule now lives in the header of each maintainer-only workflow and in the release skill, both of which bootstrap deletes, and CLAUDE.md says the bootstrap and release skills go with the bootstrap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UcXtZsHrK9JVCxL2BMhUvU --- .claude/skills/release/SKILL.md | 2 +- .github/workflows/changelog-check.yml | 4 +++- .github/workflows/cla.yml | 4 +++- .github/workflows/github-release.yml | 4 +++- .github/workflows/guard-branches.yml | 4 +++- .github/workflows/version-check.yml | 4 +++- AGENTS.md | 1 - CLAUDE.md | 2 +- 8 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7b19881..c8dfa14 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -55,7 +55,7 @@ Run in the worktree, in this order, before the commit: ## CI on the release pull request -The template's own workflows — `guard-branches.yml`, `version-check.yml`, `changelog-check.yml`, `cla.yml` and the `github-release.yml` above — guard every job with `if: github.repository == 'Pipelex/pipelex-starter-python' && …`, so a repository made with "Use this template" inherits them inert, and the bootstrap deletes them together with this skill (its `MAINTAINER_ONLY_PATHS`). A job added to one of them carries the same guard, and `tests/unit/test_bootstrap_script.py` fails until it does. Here the guard always holds, so it changes nothing about what follows; the required checks they post keep posting. +The template's own workflows — `guard-branches.yml`, `version-check.yml`, `changelog-check.yml`, `cla.yml` and the `github-release.yml` above — guard every job with `if: github.repository == 'Pipelex/pipelex-starter-python' && …`, so a repository made with "Use this template" inherits them inert, and the bootstrap deletes them together with this skill (its `MAINTAINER_ONLY_PATHS`). A job added to one of them carries the same guard, and so does every job of a new workflow written for the template's own maintenance, which also joins `MAINTAINER_ONLY_PATHS`: `tests/unit/test_bootstrap_script.py` fails when the guards and the list disagree, but it cannot tell that a new workflow carrying neither is the template's own, so that call is the author's. Here the guard always holds, so it changes nothing about what follows; the required checks they post keep posting. - `guard-branches.yml` — the `gate-main` job asserts the head branch into `main` matches `^release/v[0-9]+\.[0-9]+\.[0-9]+$` exactly. Its `protect-workflows` job additionally refuses a change under `.github/workflows/` from an author without write access to the repository, reading the author's effective permission and the pull request's own file list from the API. - `version-check.yml` — the `pyproject.toml` version equals the version in the branch name. That is all it compares: it never checks the number against what `main` already carries, so a re-used or lower version passes here and fails after the merge, at `gh release create`. A head that is not a release branch does not slip past either — the `exit 0` in its first step ends that step alone, and the comparison that follows then fails on an empty branch version. diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index a69bc61..e500ae8 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -1,6 +1,8 @@ # The template's own maintenance, not a project's: every job runs only in # Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits -# this file inert, and /bootstrap deletes it. +# this file inert, and /bootstrap deletes it. A workflow added for the template's own +# maintenance carries the same guard on every job and joins MAINTAINER_ONLY_PATHS in the +# bootstrap script; tests/unit/test_bootstrap_script.py holds the two together. name: Changelog Version Check on: diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 3d12d3e..ebcd2f5 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -1,6 +1,8 @@ # The template's own maintenance, not a project's: every job runs only in # Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits -# this file inert, and /bootstrap deletes it. +# this file inert, and /bootstrap deletes it. A workflow added for the template's own +# maintenance carries the same guard on every job and joins MAINTAINER_ONLY_PATHS in the +# bootstrap script; tests/unit/test_bootstrap_script.py holds the two together. name: "CLA Assistant bot" on: issue_comment: diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index ce74611..5cb1c6d 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -1,6 +1,8 @@ # The template's own maintenance, not a project's: every job runs only in # Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits -# this file inert, and /bootstrap deletes it. +# this file inert, and /bootstrap deletes it. A workflow added for the template's own +# maintenance carries the same guard on every job and joins MAINTAINER_ONLY_PATHS in the +# bootstrap script; tests/unit/test_bootstrap_script.py holds the two together. name: Create release on: diff --git a/.github/workflows/guard-branches.yml b/.github/workflows/guard-branches.yml index ea0ac7c..322f32a 100644 --- a/.github/workflows/guard-branches.yml +++ b/.github/workflows/guard-branches.yml @@ -1,6 +1,8 @@ # The template's own maintenance, not a project's: every job runs only in # Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits -# this file inert, and /bootstrap deletes it. +# this file inert, and /bootstrap deletes it. A workflow added for the template's own +# maintenance carries the same guard on every job and joins MAINTAINER_ONLY_PATHS in the +# bootstrap script; tests/unit/test_bootstrap_script.py holds the two together. name: Guard branch flow on: pull_request_target: diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 0dd1c53..521a50c 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -1,6 +1,8 @@ # The template's own maintenance, not a project's: every job runs only in # Pipelex/pipelex-starter-python, so a repository made with "Use this template" inherits -# this file inert, and /bootstrap deletes it. +# this file inert, and /bootstrap deletes it. A workflow added for the template's own +# maintenance carries the same guard on every job and joins MAINTAINER_ONLY_PATHS in the +# bootstrap script; tests/unit/test_bootstrap_script.py holds the two together. name: Version check on: diff --git a/AGENTS.md b/AGENTS.md index c416e86..5599aa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,6 @@ The full project guide for AI coding agents is [`CLAUDE.md`](CLAUDE.md) — read - **The project-name placeholder is an ordinary English word, so never write that word in ordinary prose.** `/bootstrap` substitutes every occurrence of the template's stand-in name and aborts the whole run if any survives, so a sentence using that word in its generic sense fails a user's bootstrap. The plural aborts too, and deliberately: no substitution rule matches it, so the survivor check is what stands between a stray plural and a half-renamed project. Say "the package", "the project" or "the CLI" instead. In `pyproject.toml` the bar is higher still: a bare occurrence there is refused rather than guessed at, since the file puts both name forms in bare-string positions, so name a path (`widget/methods`) or a module (`widget.cli`) and never the token alone. - **Do not move or delete the `# add-method:imports` and `# add-method:commands` anchors** in the three mode files. `make add-method` inserts at them, the prose after each token is free to reword, and `tests/unit/test_add_method.py` reads the real files so a lost anchor fails the suite. - **Never commit a planning note or campaign document to this template.** It is consumed through "Use this template", so anything it tracks is copied into somebody's new project. The template's own campaign documents live in the workspace wip repository (`wip//` at the workspace root), and the workspace guard refuses a write into a `wip/` here. -- **A workflow is either the project's CI or the template's own maintenance, and the template's own is guarded.** Everything under `.github/workflows/` is copied into every project made with "Use this template". A workflow a project wants — lint, tests, the lock check — runs anywhere. One that is the template's own — Pipelex's CLA, branch flow and release discipline — guards every job with `if: github.repository == 'Pipelex/pipelex-starter-python'`, joined to any other condition by `&&` and never escaped by an `||`, and is listed in `MAINTAINER_ONLY_PATHS` in the bootstrap script, which `/bootstrap` deletes. An unguarded CLA job fails every pull request of a project that lacks its secrets, or asks the project's contributors to sign Pipelex's CLA. `tests/unit/test_bootstrap_script.py` fails when the guards and the list disagree, but it cannot tell that a new workflow carrying neither is the template's own, so that call is yours. A method that lives **on the platform** (a catalog id) or **in a published package** (an address) is wired in with `make add-method METHOD=` rather than by hand — see [`docs/add-method.md`](docs/add-method.md). A method whose bundle you have on disk goes under `widget/methods//` and through `make codegen` instead. diff --git a/CLAUDE.md b/CLAUDE.md index 8900647..df56221 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ This starter calls the **hosted Pipelex API** via the `pipelex-sdk` package (`Pi ## Project Structure - Package: `widget/` (Python 3.11+, target 3.11) — root `cli.py` + one sub-package per execution mode (`blocking/`, `attended/`, `detached/`) + the shared modules (`inputs.py`, `outputs.py`, `errors.py`, `usage.py`, `manifest.py`). Packages are discovered, not listed: `[tool.setuptools.packages.find]` takes everything under `widget/`, and each `codegen.lock` ships through a package-data glob — so a new mode sub-package or a scaffolded method needs no edit to `pyproject.toml`. -- Agent-facing files: `CLAUDE.md` (this file) and `AGENTS.md` (the damage-causing rules, for any agent), plus the `bootstrap` and `release` skills under `.claude/skills/`. `AGENTS.md` also carries this repo's decision **not** to ship an `adopt-in-an-existing-project.md`, and what to read instead. +- Agent-facing files: `CLAUDE.md` (this file) and `AGENTS.md` (the damage-causing rules, for any agent), plus the template's own `bootstrap` and `release` skills under `.claude/skills/`, which `/bootstrap` deletes from a project made from the template, together with the template's own CLA, branch-flow and release workflows. `AGENTS.md` also carries this repo's decision **not** to ship an `adopt-in-an-existing-project.md`, and what to read instead. - Tests: `tests/` (unit = offline per-mode CLI tests patching each mode's public lifecycle helper, plus mode-symmetry / error-mapping / generated-client tests; integration = offline boot/bundle checks + API `validate`; e2e = full run via the API, one execution mode per demo so all three get end-to-end coverage) - Dependency manager: uv (>=0.7.2) - Pipelex dependency: `pipelex-sdk` package from PyPI (the API client — see pyproject.toml). The `pipelex` runtime is **not** a dependency. From eb94c1f1b7278a04e5a73a953800c084364be963 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Thu, 1 Oct 2026 13:02:49 +0200 Subject: [PATCH 4/4] Make the guard test read a commented job key, and drop it from hand-renamed projects The job reader matched only a bare job key, so a key carrying a trailing comment was skipped: the job vanished from the check, or took the `if:` of the job above it, and the guard test passed with an unguarded maintainer-only job. It now reads the comment and fails on any other line two spaces in under `jobs:` that is not a job key. The README's manual checklist now deletes the bootstrap skill and its test, as the skill does once it has run, so a project renamed by hand does not keep a test that holds its own workflows to the template's layout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UcXtZsHrK9JVCxL2BMhUvU --- README.md | 1 + tests/unit/test_bootstrap_script.py | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d06f40c..0c138c8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Prefer to do it by hand? The manual equivalent: 3. Update the imports across `widget/` and `tests/` to match. 4. Rewrite this README with your own project details. 5. Delete the template's own maintenance: `cla.yml`, `guard-branches.yml`, `version-check.yml`, `changelog-check.yml` and `github-release.yml` under `.github/workflows/`, and the `.claude/skills/release/` skill. Their jobs run only in `Pipelex/pipelex-starter-python`, so they do nothing in your repository, but they are Pipelex's contributor agreement and release discipline rather than yours. Keep `lint-check.yml`, `tests-check.yml` and `package-check.yml`: they are your project's CI. +6. Delete the bootstrap skill, `.claude/skills/bootstrap/`, and its test, `tests/unit/test_bootstrap_script.py`, as the skill does once it has run. The steps above replace it, and the test holds the workflows to the template's own layout, which yours need not follow. ## Prerequisites diff --git a/tests/unit/test_bootstrap_script.py b/tests/unit/test_bootstrap_script.py index ca0b53c..ffcede3 100644 --- a/tests/unit/test_bootstrap_script.py +++ b/tests/unit/test_bootstrap_script.py @@ -33,7 +33,9 @@ def job_conditions(workflow: Path) -> dict[str, str | None]: Read from the layout every workflow here shares — job keys two spaces in under `jobs:`, their fields four — rather than parsed, which keeps a YAML library out of the dependencies for one - test. A job whose `if:` sits anywhere else reads as unguarded, which fails loudly below.""" + test. A job whose `if:` sits anywhere else reads as unguarded, which fails loudly below, and a + line two spaces in that is neither a job key nor a comment fails here: skipped, it would hide + a job or hand it the `if:` of the job above.""" jobs: dict[str, str | None] = {} in_jobs = False current = "" @@ -42,9 +44,11 @@ def job_conditions(workflow: Path) -> dict[str, str | None]: in_jobs = True elif in_jobs and line and not line[0].isspace() and not line.startswith("#"): in_jobs = False - elif in_jobs and (job := re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)): + elif in_jobs and (job := re.match(r"^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$", line)): current = job.group(1) jobs[current] = None + elif in_jobs and re.match(r"^ [^\s#]", line): + pytest.fail(f"{workflow.name}: cannot read {line.strip()!r} under `jobs:` as a job key") elif in_jobs and current and (condition := re.match(r"^ if:\s*(.+)$", line)): jobs[current] = re.sub(r"\s+#.*$", "", condition.group(1)).strip() return jobs @@ -476,3 +480,22 @@ def test_the_workflows_the_bootstrap_removes_are_exactly_the_guarded_ones() -> N ) def test_the_guard_reading_refuses_a_guard_an_or_escapes(condition: str | None, expected: bool) -> None: assert is_guarded_to_the_template(condition) is expected + + +def test_the_job_reading_sees_a_commented_key_and_refuses_an_unreadable_one(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text( + f"""jobs: + # ── a banner between jobs ── + first: + if: {TEMPLATE_REPOSITORY_GUARD} + notify: # a comment on the key + runs-on: ubuntu-latest +""", + encoding="utf-8", + ) + assert job_conditions(workflow) == {"first": TEMPLATE_REPOSITORY_GUARD, "notify": None} + + workflow.write_text('jobs:\n "quoted":\n runs-on: ubuntu-latest\n', encoding="utf-8") + with pytest.raises(pytest.fail.Exception, match="cannot read"): + job_conditions(workflow)