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 f6ca16f..c8dfa14 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -14,7 +14,7 @@ description: > # Releasing pipelex-starter-python -The procedure is the workspace release play, [`docs/releasing.md`](../../../../docs/releasing.md) at the workspace root — read it first, then run it with what follows. The repo key is `pipelex-starter-python`, the base is `dev`, and the pull request targets `main`: `guard-branches.yml`'s `gate-main` job refuses any head branch but `release/vX.Y.Z` into `main`, so there is no other way in. The release worktree is `_pipelex-starter-python--release`, made with `wt add pipelex-starter-python release --branch release/vX.Y.Z`. The repo declares neither `.worktree.toml` nor `.worktreeinclude`, so `wt` resolves the base from `origin/dev` and provisions with the Makefile's `install` target, which is what creates the `.venv` every gate below runs out of. +The procedure is the workspace release play, [`docs/workspace/releasing.md`](../../../../docs/workspace/releasing.md) at the workspace root — read it first, then run it with what follows. The repo key is `pipelex-starter-python`, the base is `dev`, and the pull request targets `main`: `guard-branches.yml`'s `gate-main` job refuses any head branch but `release/vX.Y.Z` into `main`, so there is no other way in. The release worktree is `_pipelex-starter-python--release`, made with `wt add pipelex-starter-python release --branch release/vX.Y.Z`. The repo declares neither `.worktree.toml` nor `.worktreeinclude`, so `wt` resolves the base from `origin/dev` and provisions with the Makefile's `install` target, which is what creates the `.venv` every gate below runs out of. ## What ships @@ -24,7 +24,7 @@ The procedure is the workspace release play, [`docs/releasing.md`](../../../../d 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,7 +55,9 @@ Run in the worktree, in this order, before the commit: ## CI on the release pull request -- `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`. +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. - `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/changelog-check.yml b/.github/workflows/changelog-check.yml index 7684a94..e500ae8 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -1,3 +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. 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: @@ -9,7 +14,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..ebcd2f5 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -1,3 +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. 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: @@ -13,6 +18,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..5cb1c6d 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -1,3 +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. 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: @@ -8,6 +13,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..322f32a 100644 --- a/.github/workflows/guard-branches.yml +++ b/.github/workflows/guard-branches.yml @@ -1,3 +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. 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: @@ -8,7 +13,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 +30,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 @@ -45,19 +50,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.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(', ')}`); + } diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index ebcccc1..521a50c 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -1,3 +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. 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: @@ -6,6 +11,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/.gitignore b/.gitignore index d4019c7..d038a50 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,3 @@ results/ downloads/ temp/ - -# Internal planning notes and campaign documents — never shipped to template -# consumers (v0.11.0 already removed them once before open-sourcing). They live -# in the workspace wip repository instead; anything left here stays local. -/wip/ diff --git a/AGENTS.md b/AGENTS.md index f7897f6..5599aa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ The full project guide for AI coding agents is [`CLAUDE.md`](CLAUDE.md) — read - **No hand-written output shapes.** Narrow a run's `main_stuff` with the generated model (`ExtractedEntities.model_validate(main_stuff)`), and read a plural output through `widget/outputs.py`'s `list_items` rather than declaring which of its two wire shapes arrived. - **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. -- **`wip/` is gitignored here, so a campaign document written into it is never committed.** This repository is consumed through "Use this template", and anything it tracks is copied into somebody's new project — internal planning notes included. The workspace convention puts a repo-scoped campaign's documents in that repo's own `wip//`; this repository is the exception, and its durable home is the workspace wip repository (`wip//` at the workspace root) instead. Use the local `wip/` for scratch you are willing to lose. +- **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 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..22c263a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v0.2.1] - 2026-10-01 + +### 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/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. diff --git a/README.md b/README.md index 2ae86ce..0c138c8 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,15 @@ 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. +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/pyproject.toml b/pyproject.toml index a50f2fb..7f598dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "widget" -version = "0.2.0" +version = "0.2.1" description = "Replace this with your project description" # authors = [{ name = "Your Name", email = "your.email@example.com" }] license = "MIT" diff --git a/tests/unit/test_bootstrap_script.py b/tests/unit/test_bootstrap_script.py index c80513a..ffcede3 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,56 @@ 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, 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 = "" + 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 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 + + +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 +399,103 @@ 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 + + +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) diff --git a/uv.lock b/uv.lock index f932114..590c662 100644 --- a/uv.lock +++ b/uv.lock @@ -629,7 +629,7 @@ wheels = [ [[package]] name = "widget" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "mthds" },