Repository navigation
feat(team): a workflow that moves the mounted pins by pull request - #45
Merged
Merged
Conversation
pasrom
force-pushed
the
feat/bump-mounted-pins
branch
2 times, most recently
from
November 7, 2026 21:09
adfd5a9 to
d0d57f2
Compare
Dependabot's submodule updates need an organisation-wide grant for private submodules and stall every submodule of a group on one 403. A team brain now gets bump-mounted-pins instead: on weekday mornings it moves each mount's gitlink to the newest commit of its tracked branch whose CI on that branch did not fail (runs of side branches and tags do not count; a commit without runs inherits the state of the nearest older commit with runs; never backwards), commits it on chore/bump-mounted-pins, force-pushes with a lease and opens or updates the pull request, with short shas only, then dispatches ci on it. A mount without a branch line follows its default branch. A mount that cannot be resolved stays at main's pin and fails the run; nothing is carried over from the bump branch, so a pin pushed there by hand is never committed under the workflow's name. While a pull request is open and its commit moves a mount that failed (against the commit's parent, not against main now), the run pushes nothing, so a passing failure does not force-push that move away; a mount that fails for good and that the branch does not move holds nothing back. The bump branch counts as current only while its head is one commit on main that changes gitlinks and nothing else; anything pushed onto it is regenerated away. Three jobs: mounts reads .gitmodules without any token, so a brain without mounts stops there and no environment is created; resolve holds a read-only GitHub App token for the mounted repositories of the brain's owner, behind the environment mounted-pins, and installs nothing beyond git; publish alone can write. Until the app's secrets are set, resolve warns and stops instead of failing every morning. runs-on is ubuntu-latest unless the variable MOUNTED_PINS_RUNNER names a runner. The script is a template copied into the brain (.github/scripts/bump-mounted-pins.py), the one piece of km code a brain carries: the selection needs the app token, and the job holding it must not install the km package. km init --team writes both files always (a fresh brain has no mounts yet; the workflow is inert then), and km doctor notes a missing environment, or one open to every branch, where the brain has mounts and the workflow. km upgrade adds both files when missing, and replaces a copy whose hash, taken with its action pins masked (the brain's Dependabot moves those), is one of the versions km shipped (SHIPPED in upgrade.py, which keeps every earlier hash; a test checks the current templates are listed), so later fixes reach the brain, keeping an action pin whose version the old copy's Dependabot moved past the template's; a copy the brain edited is left with a note. Adding the workflow to a brain that already has mounts says to restrict the environment before the first run, since GitHub would otherwise create it open to every branch. Brains carry no km code otherwise; CLAUDE.md names this template as the exception and why.
pasrom
force-pushed
the
feat/bump-mounted-pins
branch
from
November 7, 2026 21:36
d0d57f2 to
296fcf0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A team brain reads its mounted brains (submodules) at the commit it pins, and a pin moves only through a reviewed pull request. Until now that pull request came from Dependabot's
gitsubmoduleupdates, which need an organisation-wide grant to read private submodules and stall every submodule of a group when one of them answers 403. This adds a workflow of km's own that does the same job without either problem. It is a generic port of an implementation that has run in a private team brain, with its reviewed behaviours kept.Was stacked on #43, which is now merged; this branch is rebased onto main and carries only its own commit.
What a team brain gets
.github/workflows/bump-mounted-pins.yml, weekday mornings (06:25 UTC, off the hour) and by hand, three jobs:mounts(no token, no environment): lists the mounted repositories of the brain's owner (github.repository_owner) from.gitmodules. None: the run ends here, so a brain without mounts never touches the environment below and never fails.resolve(environmentmounted-pins,contents: read,pull-requests: read): a short-lived GitHub App token (Contents and Actions: read) for those repositories. For each submodule it walks its tracked branch (branch =,.counts as main, else the mounted repository's default branch) back from the tip and takes the newest commit whose CI on that branch did not fail: per workflow the newest run of a push to that branch counts (runs of a side branch or a tag do not; branch names are URL-encoded; the default branch's name is looked up only when a run is), a failed, cancelled or running one refuses the commit; a commit without runs (a bot's, pushed withGITHUB_TOKEN) inherits the state of the nearest older commit with runs, or of the current pin, and is not taken below a refused commit; a pin never moves to a commit that is not ahead of it. Gitlinks are set withgit update-index --cacheinfo, nothing is cloned. One mount that cannot be resolved does not hold the others back; it stays at main's pin, the body names it and the run fails. Nothing is carried over from the bump branch: anyone who can push to that branch could otherwise plant a pin that the workflow would re-commit under its own name. Without a carry-over, a passing failure (a 502, say) while the pull request is open would force-push the branch without that mount's proposed move and the next run would put it back; so while a pull request is open, a run pushes nothing when the branch's commit moves a mount that failed (--hold-if-moved, compared with that commit's parent, the main it was built on, fetched with--depth 2; comparing with main now would freeze the pull request after a manual bump on main). A mount that fails for good (a private mount of another owner, say) and that the branch does not move holds nothing back. With no pull request open, the other mounts are still proposed; the run fails either way. The result is compared with the bump branch using--ignore-submodules=none(mounts areignore = all, which hides gitlink changes from a plain diff): a pull request, open or closed, whose head already proposes exactly these pins means nothing to do, but only while that head is what publish pushes: one commit on main changing gitlinks only (checked through the compare API; a missing file list does not pass). A branch someone pushed more onto is regenerated; a branch whose pull request never opened is pushed again so it opens. If the app's secrets are not set yet, the job warns and stops.publish(contents,pull-requests,actions: write, no secrets): commits, force-pusheschore/bump-mounted-pinswith a lease on the headresolvesaw (empty: the branch must not exist), opens or updates the pull request, and dispatches ci on the branch (feat(team): run a team brain's ci workflow on workflow_dispatch too #43). The pull request lists each moved mount with its old and new short sha only: not every reader of the brain reads the mounts.All jobs run
python:3.12-slimpinned by digest withdefaults.run.shell: bash(a container job runsshotherwise); every checkout keeps no credentials;GITHUB_TOKENreaches git only throughGIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0in the environment of the calls that need it, its base64 form masked; multi-line outputs use a random heredoc delimiter.runs-onisubuntu-latestunless the repository variableMOUNTED_PINS_RUNNERnames another label, so a brain changes it without editing the file..github/scripts/bump-mounted-pins.py: the selection, the pull request calls and the dispatch, standard library only. Generic changes against the original: no owner or runner names; a missing or empty.gitmodulesis "nothing to do" instead of a traceback; a relative submodule URL../name.gitis the sibling repository under the brain's owner,../../owner/name.gitthat owner's; a mount not pinned yet gets its first pin (update-index --add); a mount whose API answer is malformed (not JSON included), whose connection drops mid-read, or whose git call fails is reported like a refused one instead of stopping the others.Setup (in the workflow's header, and the README)
mounted-pins, deployment branchesmainonly. Create it before the first mount: GitHub creates an environment a workflow names on its first run, without that restriction.CI_APP_CLIENT_IDandCI_APP_PRIVATE_KEY.A mount of another owner is outside the token's scope: a public one still resolves, a private one is reported and fails the run.
km
km init --teamwrites both files always. A fresh brain has no mounts yet (init writes files into an empty repository,km brain addcomes later), so "only with submodules" would mean never; the workflow is inert until a mount and the setup exist.km upgradeadds each file a team brain lacks. The list now comes frominit.TEAMinstead of a second copy of the paths. Adding the workflow to a brain that already has.gitmodulesprints a note to create the environment restricted tomainbefore the first run, since GitHub would otherwise create it open to every branch.km upgradereplaces the copied workflow and script while they are still a version km shipped:SHIPPEDinupgrade.pykeeps a hash of every shipped version of each template (a test fails when the current template is not listed, so a change adds its hash and keeps the old ones). The hash masksuses:refs and their version comments, so a copy whose action pins the brain's Dependabot moved still counts as km's; when the replacement happens, an action pin whose version comment the old copy moved past the template's is kept, otherwise the template's pin is taken (it may be newer, and the new template may need it). A copy that differs from the current template only in those pins is left as it is. A copy the brain edited is left with a note to compare it with the template.dependabot.ymlandmain-guard.ymlare still only added when missing.km doctor(onegh apicall, a second only for a list of chosen branches, and only where the brain has.gitmodulesand the workflow): a note when the environment is missing or open to more thanmain(every branch, every protected branch, or chosen branches other thanmainalone).check_githubnow returns the repository it found, andmaincalls the new check after it.sync.py's docstring and the skill'sreference/brains.mdno longer name Dependabot as what moves pins. That is a change underskills/km/, so the plugin evals should run before merge; they were not run here (they cost money; a doc wording change only).Where the script lives (a decision for you)
km's rule is that brains carry no km code. The selection needs the app token, and the original keeps that token out of any job that installs third-party code. Running it as a
kmsubcommand would put the token in a job that installs km through the action (PyYAML from PyPI included), so the script is a template copied into the brain, the one exception, said so in the README. Alternatives, not done: (a) a stdlib-only km module run from a sub-actionpasrom/km/<path>@<sha>without any install, which keeps the brain free of copied code and moves with the km pin, but needspins.pyto recognise sub-action pins and still runs km code next to the token; (b) akmsubcommand in the token job, accepting the install. Later km fixes reach unchanged copies throughkm upgrade(see above).Tests
tests/mounted_pins_check.py(inmounted_pins_smoke.sh, new in CI): 21 cases of the commit selection against a stand-in GitHub API (green tip; bot tip on a green commit, on the pin, on a failed commit, two deep; inner commit below a refused head; an older green below it; a candidate not ahead of the pin; newest run per workflow both ways; cancelled; running; skipped and neutral; one failed workflow of two; pin at the tip; pin beyond the window with and without workflows, and inheriting an older run; unpinned; an empty branch), plus the resolve step in a real git repository: no.gitmodules, slugs (relative, ssh, other host),--list-repos, a failing mount (stays at main's pin), short shas only in body and message, the problems listed, and that only--ignore-submodules=nonesees a movedignore = allgitlink.bash -eo pipefailagainst a local origin and a stand-in script: no branch; an open pull request already proposing these pins (nothing pushed); an open one with older pins; a failing mount with and without an open pull request; a branch whose pull request never opened (pushed again); a pull request closed on these pins (not proposed again); nothing to move. It needs bash 4 (mapfile): it runs in CI and was run locally in the workflow's own image; on macOS's bash 3.2 it is skipped with a note.mounted_pins_check.py: runs of a side branch and of a tag push do not count; a slash branch is quoted and its runs count; the default branch's runs count;--list-reposdeduplicates; the bump branch head counts as current only as one gitlinks-only commit on main (other file, extra commit, missing file list each regenerate);--hold-if-movedholds for a failing mount the branch moves and not for one main moved by hand since. In the resolve step (bash 4, the workflow's image): hold only while open, and the--depth 2fetch. Inteam_smoke.sh:carry_pinskeeps a newer brain pin and takes a newer template pin.main, no dedupe, no quoting, no gitlinks-only check, an empty file list passing, hold compared with main now, no hold, hold passed always or never, a depth-1 fetch, carry always, carry never.SHIPPED, the pin masking, the masked-equal skip, the environment note, the widenedapi()except, the default-branch rule, the../../slug, the push-nothing-while-open guard (run in the workflow's own image, since the step needs bash 4). Earlier mutation checks: the selection's inherit rule, the refused flag, the ahead check, newest-run-per-workflow, the.gitmodulesguard, the no-workflows rule; in the workflow--ignore-submodules=none, theseenguard,shell: bash.team_smoke.sh: init writes both files, upgrade adds them, a personal brain gets none;SHIPPEDlists the current templates; the hash ignores an action pin and its comment but not the action; an earlier shipped version (a stand-in registered for the run) with Dependabot-moved pins is replaced; the current version with moved pins is left untouched and silent; an edited copy is left with a note; adding the workflow to a brain with mounts prints the environment note.mounted_pins_check.pyadditionally: a mount without a branch line asks for the default branch;../../owner/nameresolves; a non-JSON answer and a dropped connection are anApiError; nothing is carried over from the bump branch.doctor_smoke.sh: missing environment, open to every branch, restricted, no mounts no call, andcheck_githubreturning the repository.actions/create-github-app-tokenis used by no workflow of km's own, so km's Dependabot does not watch it: the template-pins test lists it as template-only, and km'sdependabot.ymlcomment says to bump it by hand (each brain's Dependabot moves it in the brain).actionlintis clean on the new workflow.Reviews
/simplify(four angles, two reviewers), applied: upgrade's list of files to add comes frominit.TEAM; the doctor check runs frommainaftercheck_github, which now returns the repository, and its tests call it directly; the owner read once outside the loop; test helpers trimmed; the template-only action documented in km'sdependabot.yml; the "no copied scripts" test excludes exactly the one script. Not applied: the repeated setup steps (git install, checkout, trust) and theauthgithelper in two jobs (jobs share no shell, and a composite action would be more copied code); runningmountswithout the container (a brain's own runner may lack python); an early exit for a mount whose CI never runs on push (about 100 calls a day for such a mount, a narrow case)./code-review, applied: a mount not pinned yet failedupdate-indexwithout--addand the traceback stopped every mount (fixed in the script and in publish, tested); an empty.gitmodulescrashed--list-repos(tested); onlyApiErrorwas caught per mount (now alsoKeyError,TypeError,CalledProcessError, tested); doctor called any branch policy "restricted" (now onlymainalone is ok). Each new test was checked to fail without its fix. Documented, not handled: a brain whose mounts all belong to other owners (mounts finds none and stops); submodule paths with spaces; the image digest has no update path (Dependabot moves actions, not a job's container), said in the header. Not handled: if the dispatch or the body update fails after the pull request opened, the next run sees the same pins and does not retry them (the original's behaviour; it fails that run visibly). The rule that brains carry no km code: see the decision above.Follow-up round (after the rebase onto main)
/simplify(four angles), applied: oneshipped_hash()helper used byupgrade.pyand the tests instead of an inline hash in three places; the hash masks action pins, since the brain's Dependabot is the declared way to move them and a byte-exact hash would freeze every copy after its first Dependabot merge; the emptypassbranch with a walrus folded into one condition; agitlink(ref, path)helper in the script instead of two copies of thels-treeparsing;belowderived frompin_in_windowin one line. Not applied: one registry forOLD_COPIESandSHIPPED(theOLD_COPIESpath triggers the release lookup and hashes without comments, which would overwrite a brain's own notes in the script;dependabot.ymlandmain-guard.ymlupdates are out of scope); a dummy job so km's Dependabot watchescreate-github-app-token(the template-only exemption stays documented); one runs listing per mount instead of one per candidate commit (changes which runs count, typical runs make one or two calls); the container setup repeated in three jobs and the duplicatedauthgit(as before); the remaining minor nits./code-review, applied:--keep-fromcould move a pin backwards after a manual bump on main; a separate security review then found that the carry-over let anyone who can push to the bump branch get an arbitrary pin re-committed under the workflow's name, so--keep-fromis removed entirely (see resolve above, tested);km upgradewould put back older action pins on every run (a copy that differs only in pins is now left alone, tested); upgrade adding the workflow to a brain that has mounts said nothing about the environment GitHub then creates open (note, tested);api()letJSONDecodeErrorand a dropped read through as a traceback that stopped every mount (tested); a mount without a branch line asked formaininstead of the default branch (tested);brains.mdstill named Dependabot;../../owner/nameURLs; the docstring's usage lines now callpython3(the copy is not executable). Declined: an open bump PR is not updated or closed when main moves past it by hand and nothing is newer (the next newer commit regenerates it; closing PRs is a policy choice for a later change); reuse ofpins.USESfor the masking (it does not cover the trailing version comment Dependabot rewrites; a quoteduses:is masked by the current pattern, tested); the duplicated setup steps (as above).Third round (an independent review at d0d57f2)
Applied: runs are filtered by the tracked branch (a feature-branch or tag run counted as green); a failing mount freezes the open pull request only when the branch's commit moves that mount, not on any failure; a branch head someone pushed more onto is regenerated instead of kept under the bot's pull request, and the setup notes list "Require approval of the most recent reviewable push";
km upgradekeeps an action pin the brain's Dependabot moved past the template (by the version comment) and takes the template's otherwise;--list-reposdeduplicates; branch names are URL-encoded. Not applied: gatingresolveon a repository variable so that the environment is never created open before setup, because whether a job skipped byif:still creates the environment it names could not be verified here without an experiment repository;km upgradeprints the setup note instead./code-reviewon that round's delta, applied: the hold compared the branch with main now, which a manual bump on main would turn into a permanent freeze (now against the commit's parent);carry_pinstook every old pin, which could revert a newer pin a new template needs (now only a pin whose version is newer); an empty or missing compare file list passed the gitlinks-only check; the hold rule moved from a shell loop into the script (--hold-if-moved), where the Python suite covers it; the default branch's name is looked up only when a run is; theoddstand-in again tests a missingworkflow_runs; a test restores the environment and the API it patches. Declined: "two commits with the same subject" (the branch has one commit; the reviewed range spanned the old and the new head).Plugin evals
The only change under
skills/km/is one sentence inreference/brains.md(the brains row).brains-list-removeon 296fcf0: score 1.00, 3 of 3 runs, $0.91.An independent second review of the fixes (d0d57f2 to 296fcf0) found them clean.