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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Keeps the commit-pinned actions in this repository's workflows and in action.yml current. The
# templates brains get are not watched: a test fails until they pin the same commits, so a
# Dependabot update here has to be carried into src/km/templates/team/*.yml by hand.
# Dependabot update here has to be carried into src/km/templates/team/*.yml by hand. An action only a
# template uses (actions/create-github-app-token, TEMPLATE_ONLY in tests/team_smoke.sh) is watched by
# no one here: each brain's Dependabot moves it there; bump it in the template by hand.
version: 2
updates:
- package-ecosystem: github-actions
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
- run: bash tests/list_smoke.sh
- run: bash tests/skill_smoke.sh
- run: bash tests/hook_smoke.sh
- run: bash tests/mounted_pins_smoke.sh
- name: team_smoke.sh, with a Latin-1 locale for its file-name encoding test
run: sudo localedef -i en_US -f ISO-8859-1 en_US.ISO-8859-1 && bash tests/team_smoke.sh

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ brains. See README.md for the layout.
- Brains pin a km version and never carry km code, but their `schema.local.yaml`, docs and templates
from earlier versions stay: keep new versions working on them, and keep `km upgrade` able to move
an older brain forward.
- The one exception is the team template `bump-mounted-pins.py` (with its workflow), copied into a team brain because the job holding the app token must not install third-party code, the km package included. `km upgrade` replaces a copy whose hash is in `SHIPPED` (src/km/upgrade.py): a change to either template adds its new hash there and keeps the old ones (a test checks the current one is listed).
- A release bumps `src/km/__init__.py`, `.claude-plugin/plugin.json` and the README examples together
(a test checks it). Merging it to main is the release: CI tags `vX.Y.Z` once main is green. The
templates pin the tag of the km that wrote them.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ without installing: `uvx --from git+https://github.com/pasrom/km@<commit> km val

## What a brain gets

Brains carry no km code. `km init` writes `CONVENTIONS.md`, `CLAUDE.md`, `schema.local.yaml`,
Brains carry no km code (the one exception, the script of the mounted-pins workflow, is below). `km init` writes `CONVENTIONS.md`, `CLAUDE.md`, `schema.local.yaml`,
`inbox/`, a `.gitattributes` that keeps text files LF, and a pre-commit hook pinned to a km version. `km init --team` adds domain folders with
generated indexes, a README, and CI that installs km through this repository's GitHub Action:

Expand All @@ -108,6 +108,8 @@ generated indexes, a README, and CI that installs km through this repository's G

The team brain's ci workflow also runs on `workflow_dispatch`: a pull request that a workflow opens with `GITHUB_TOKEN` starts no `pull_request` run, so such a workflow (a pin bump, say) dispatches ci on its branch and the pull request gets its check. `km upgrade` adds the trigger to a km ci workflow that lacks it.

A team brain also gets `bump-mounted-pins`, a workflow that moves the pins of the mounted brains (submodules) by pull request, in place of Dependabot's submodule updates, which need an organisation-wide grant for private submodules and stop every submodule of a group on one refusal. On weekday mornings it walks each mount's tracked branch (its `branch =`, else the repository's default branch) back from the tip to the newest commit whose CI on that branch did not fail (runs of a side branch or a tag do not count; a commit without a run of its own, such as a bot's, inherits the state of the nearest older commit that has runs; a pin never moves backwards), sets the gitlinks without cloning, force-pushes `chore/bump-mounted-pins` with a lease and opens or updates the pull request, which lists short shas only, and dispatches ci on it. A brain without mounts stops after reading `.gitmodules`. It reads the mounts with a GitHub App (Contents and Actions: read) whose secrets `CI_APP_CLIENT_ID` and `CI_APP_PRIVATE_KEY` live in an environment `mounted-pins` restricted to `main`; the workflow's header says how to set it up, and `km doctor` notes a missing or unrestricted environment. Its script, `.github/scripts/bump-mounted-pins.py`, is the one piece of km code a brain carries: the job that holds the app token installs nothing, so it cannot run the km package. `km upgrade` adds both files when missing and replaces a copy that is still a version km shipped, unchanged but for the action pins its Dependabot moves (a pin moved past the template's version is kept), so later fixes reach the brain; a copy the brain edited is its own, left alone with a note.

The pre-commit hook works the same way (`repo: https://github.com/pasrom/km`,
`rev: <commit> # frozen: v1.9.0`, `id: km-validate`). Every pin names a release by its commit, so a
tag moved later cannot change what a brain runs; the version rides along as a comment, and
Expand Down Expand Up @@ -146,7 +148,7 @@ tests/ smoke tests
## Development

The smoke tests need Python 3.11+, PyYAML and git: `bash tests/gate_smoke.sh`, `index_smoke.sh`,
`team_smoke.sh`, `doctor_smoke.sh`, `sync_smoke.sh`, `list_smoke.sh`, `skill_smoke.sh` (the skill's core
`team_smoke.sh`, `doctor_smoke.sh`, `sync_smoke.sh`, `list_smoke.sh`, `mounted_pins_smoke.sh` (the mounted-pins workflow and script a team brain gets, against a stand-in GitHub API), `skill_smoke.sh` (the skill's core
keeps its size limits, its reference paths resolve, every `/km` verb has a dispatch row) and `hook_smoke.sh` (the plugin's edit guard;
the pre-push hook too where pre-commit is installed). CI runs them plus the GitHub Action on every pull request, and
`encoding_smoke.sh`, `doctor_smoke.sh`, `sync_smoke.sh` and `hook_smoke.sh` on Windows (team_smoke runs the encoding test on macOS under non-UTF-8 locales). A release bumps
Expand Down
4 changes: 2 additions & 2 deletions skills/km/reference/brains.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
checks every mount out at its pin and never moves one ahead of it. Pass its notes on: a mount
with changes of its own or ahead of its pin is left as it is (`brain reset <name>` puts it
back), never a silent skip. Newer content of a mount comes only with a moved pin, through a pull
request: the host's Dependabot PR, or the pin-bump PR below when the user asks for it
request: a team brain's bump-mounted-pins PR, or the pin-bump PR below when the user asks for it

### Shared resources (self-describing repos)

Expand All @@ -48,7 +48,7 @@ mount at a newer commit (or km sync reports one ahead of its pin that they want
step may move the checkout past the pin: it is the bump), `git add brains/<name>`, commit
`chore(brains): bump <name> to <short sha>`, push, `gh pr create`; then `git switch main` and
`km sync`, which puts the mount back at main's pin until the PR merges. A team brain gets the
same bump from Dependabot's submodule PRs.
same bump from its bump-mounted-pins workflow (`chore/bump-mounted-pins`).

## Brain management

Expand Down
36 changes: 32 additions & 4 deletions src/km/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,10 @@ def check_line_endings(root: Path) -> None:
"{ nodes { nameWithOwner parent { nameWithOwner } } } } }")


def check_github(origin: str, offline: bool) -> None:
def check_github(origin: str, offline: bool) -> str | None:
"""On a GitHub brain, `/km pr`, a contribution and the search of open pull requests run through
gh. Without push access to origin, a contribution goes through a fork."""
gh. Without push access to origin, a contribution goes through a fork. Returns the repository's
owner/name when gh can see it."""
repo = github_repo(origin)
if not repo:
return
Expand All @@ -400,7 +401,7 @@ def check_github(origin: str, offline: bool) -> None:
push, _, full = out.partition("\t") # full_name: the current name, after a rename or transfer
if push == "true":
report("ok", f"you can push branches to {full}")
return
return full
_, forks = gh("api", "--hostname", "github.com", "graphql", "-f", f"query={FORKS}", "--jq",
f'.data.viewer.repositories.nodes[] | select((.parent.nameWithOwner // "" | ascii_downcase) == '
f'"{full.lower()}") | .nameWithOwner')
Expand All @@ -409,6 +410,32 @@ def check_github(origin: str, offline: bool) -> None:
else:
report("note", f"you can read {full} but not push to it, and have no fork of it yet",
"a contribution creates one (`gh repo fork --remote=false`); nothing to do before then")
return full


def check_mounted_pins(root: Path, repo: str) -> None:
"""A brain with mounts and the bump-mounted-pins workflow needs the environment that holds the
workflow's app key, restricted to main (the workflow's header says how to set it up). One call;
a note, since only a maintainer can fix it."""
if not ((root / ".gitmodules").is_file() and (root / ".github/workflows/bump-mounted-pins.yml").is_file()):
return
env = f"repos/{repo}/environments/mounted-pins"
code, policy = gh("api", "--hostname", "github.com", env, "--jq",
'.deployment_branch_policy | if . == null then "open" elif .custom_branch_policies then "custom" else "protected" end')
fix = "Settings, Environments, mounted-pins: deployment branches, selected branches, `main` only"
if code != 0:
report("note", f"{repo} has no environment mounted-pins (or gh cannot see it): the mounted pins do not move",
"a maintainer sets it up as the top of .github/workflows/bump-mounted-pins.yml describes")
return
names = ""
if policy == "custom": # a second call only for a list of chosen branches
_, names = gh("api", "--hostname", "github.com", f"{env}/deployment-branch-policies", "--jq", "[.branch_policies[].name] | join(\" \")")
if names == "main":
report("ok", f"{repo} has the environment mounted-pins, restricted to main")
else:
what = {"open": "every branch", "protected": "every protected branch"}.get(policy, names or "no branch")
report("note", f"the environment mounted-pins of {repo} is open to {what}, not to main alone: "
f"a workflow pushed on such a branch can read the app key", fix)


def main() -> int:
Expand All @@ -428,7 +455,8 @@ def main() -> int:
check_submodules(root, origin, args.offline)
check_hook(root)
check_line_endings(root)
check_github(origin, args.offline)
if repo := check_github(origin, args.offline):
check_mounted_pins(root, repo)
check_km_version(root)
check_plugins(root)
check_uv()
Expand Down
6 changes: 4 additions & 2 deletions src/km/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

km init DIR --team --name NAME --desc TEXT --initials XX --folder DIR=PURPOSE [--folder ...]
[--rule TEXT ...] [--profile SLUG]
A team brain: also the root and folder indexes, README, .gitignore, the two workflows and a
Dependabot config. Refuses to run if any file it would write already exists.
A team brain: also the root and folder indexes, README, .gitignore, its workflows (ci,
staleness, main-guard, bump-mounted-pins with its script) and a Dependabot config. Refuses to run if any file it would write already exists.

Both render and check everything before writing anything, and refuse a release whose commit
cannot be looked up (not released yet, or no network). Files only: git add, the first
Expand Down Expand Up @@ -47,6 +47,8 @@
"team/staleness.yml": ".github/workflows/staleness.yml",
"team/main-guard.yml": ".github/workflows/main-guard.yml",
"team/dependabot.yml": ".github/dependabot.yml",
"team/bump-mounted-pins.yml": ".github/workflows/bump-mounted-pins.yml",
"team/bump-mounted-pins.py": ".github/scripts/bump-mounted-pins.py",
"pre-commit-config.template.yaml": ".pre-commit-config.yaml",
}
PLACEHOLDERS = ("<BRAIN>", "<TEAM>", "<MAINTAINER>", "<profile>", "<date>", "<FOLDER_ROWS>",
Expand Down
2 changes: 1 addition & 1 deletion src/km/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- makes the mounted brains match what the brain pins: `git submodule update --init` for a mount
that is missing or behind its pin; one with changes of its own, a branch checked out, or ahead
of or diverged from its pin is left alone and reported. A pin never moves here; that is the
host's automation (Dependabot) and a reviewed pull request.
host's automation (the bump-mounted-pins workflow) and a reviewed pull request.

A fork's parent that gh found is added once as the remote `upstream` (as `gh repo fork` does), so
the next run needs no gh and the fetched branch has a ref of its own.
Expand Down
Loading
Loading