Skip to content

feat(team): a workflow that moves the mounted pins by pull request - #45

Merged
pasrom merged 1 commit into
mainfrom
feat/bump-mounted-pins
Nov 7, 2026
Merged

pasrom merged 1 commit into
mainfrom
feat/bump-mounted-pins

Conversation

@pasrom

@pasrom pasrom commented Nov 7, 2026 •

Copy link
Copy Markdown
Owner

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 gitsubmodule updates, 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 (environment mounted-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 with GITHUB_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 with git 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 are ignore = 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-pushes chore/bump-mounted-pins with a lease on the head resolve saw (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-slim pinned by digest with defaults.run.shell: bash (a container job runs sh otherwise); every checkout keeps no credentials; GITHUB_TOKEN reaches git only through GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0 in the environment of the calls that need it, its base64 form masked; multi-line outputs use a random heredoc delimiter. runs-on is ubuntu-latest unless the repository variable MOUNTED_PINS_RUNNER names 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 .gitmodules is "nothing to do" instead of a traceback; a relative submodule URL ../name.git is the sibling repository under the brain's owner, ../../owner/name.git that 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)

  1. A GitHub App with Contents: read and Actions: read, installed on the brain's owner for the mounted repositories; a private key.
  2. Environment mounted-pins, deployment branches main only. Create it before the first mount: GitHub creates an environment a workflow names on its first run, without that restriction.
  3. In it, the secrets CI_APP_CLIENT_ID and CI_APP_PRIVATE_KEY.
  4. "Allow GitHub Actions to create and approve pull requests".
  5. In main's ruleset or branch protection, "Require approval of the most recent reviewable push", so a commit pushed onto the bump branch needs someone else's review.

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 --team writes both files always. A fresh brain has no mounts yet (init writes files into an empty repository, km brain add comes later), so "only with submodules" would mean never; the workflow is inert until a mount and the setup exist.
  • km upgrade adds each file a team brain lacks. The list now comes from init.TEAM instead of a second copy of the paths. Adding the workflow to a brain that already has .gitmodules prints a note to create the environment restricted to main before the first run, since GitHub would otherwise create it open to every branch.
  • km upgrade replaces the copied workflow and script while they are still a version km shipped: SHIPPED in upgrade.py keeps 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 masks uses: 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.yml and main-guard.yml are still only added when missing.
  • km's CLAUDE.md names this template as the one exception to "brains carry no km code", and why: the job holding the app token must not install third-party code, km included.
  • km doctor (one gh api call, a second only for a list of chosen branches, and only where the brain has .gitmodules and the workflow): a note when the environment is missing or open to more than main (every branch, every protected branch, or chosen branches other than main alone). check_github now returns the repository it found, and main calls the new check after it.
  • sync.py's docstring and the skill's reference/brains.md no longer name Dependabot as what moves pins. That is a change under skills/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 km subcommand 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-action pasrom/km/<path>@<sha> without any install, which keeps the brain free of copied code and moves with the km pin, but needs pins.py to recognise sub-action pins and still runs km code next to the token; (b) a km subcommand in the token job, accepting the install. Later km fixes reach unchanged copies through km upgrade (see above).

Tests

  • tests/mounted_pins_check.py (in mounted_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=none sees a moved ignore = all gitlink.
  • The workflow's shape: which job holds the token and the environment, the token's owner and permissions, read-only defaults, the actions each job may run, no pip, no credentials in checkouts, lease push, token not in a URL.
  • The resolve step itself, extracted from the workflow and run with bash -eo pipefail against 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.
  • The resolve step, additionally: a failing mount while a pull request is open pushes nothing and fails; with none open, the others are still proposed and the run fails.
  • Third round, in 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-repos deduplicates; 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-moved holds 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 2 fetch. In team_smoke.sh: carry_pins keeps a newer brain pin and takes a newer template pin.
  • Mutation checks for the third round, each failing the suite: no branch filter on runs, default branch assumed 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.
  • Mutation checks for the follow-up changes, each failing the suite: upgrade's replace branch, its note, a missing current hash in SHIPPED, the pin masking, the masked-equal skip, the environment note, the widened api() 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 .gitmodules guard, the no-workflows rule; in the workflow --ignore-submodules=none, the seen guard, shell: bash.
  • team_smoke.sh: init writes both files, upgrade adds them, a personal brain gets none; SHIPPED lists 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.py additionally: a mount without a branch line asks for the default branch; ../../owner/name resolves; a non-JSON answer and a dropped connection are an ApiError; nothing is carried over from the bump branch.
  • doctor_smoke.sh: missing environment, open to every branch, restricted, no mounts no call, and check_github returning the repository.
  • actions/create-github-app-token is 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's dependabot.yml comment says to bump it by hand (each brain's Dependabot moves it in the brain). actionlint is clean on the new workflow.

Reviews

/simplify (four angles, two reviewers), applied: upgrade's list of files to add comes from init.TEAM; the doctor check runs from main after check_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's dependabot.yml; the "no copied scripts" test excludes exactly the one script. Not applied: the repeated setup steps (git install, checkout, trust) and the authgit helper in two jobs (jobs share no shell, and a composite action would be more copied code); running mounts without 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 failed update-index without --add and the traceback stopped every mount (fixed in the script and in publish, tested); an empty .gitmodules crashed --list-repos (tested); only ApiError was caught per mount (now also KeyError, TypeError, CalledProcessError, tested); doctor called any branch policy "restricted" (now only main alone 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: one shipped_hash() helper used by upgrade.py and 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 empty pass branch with a walrus folded into one condition; a gitlink(ref, path) helper in the script instead of two copies of the ls-tree parsing; below derived from pin_in_window in one line. Not applied: one registry for OLD_COPIES and SHIPPED (the OLD_COPIES path triggers the release lookup and hashes without comments, which would overwrite a brain's own notes in the script; dependabot.yml and main-guard.yml updates are out of scope); a dummy job so km's Dependabot watches create-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 duplicated authgit (as before); the remaining minor nits.

/code-review, applied: --keep-from could 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-from is removed entirely (see resolve above, tested); km upgrade would 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() let JSONDecodeError and a dropped read through as a traceback that stopped every mount (tested); a mount without a branch line asked for main instead of the default branch (tested); brains.md still named Dependabot; ../../owner/name URLs; the docstring's usage lines now call python3 (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 of pins.USES for the masking (it does not cover the trailing version comment Dependabot rewrites; a quoted uses: 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 upgrade keeps an action pin the brain's Dependabot moved past the template (by the version comment) and takes the template's otherwise; --list-repos deduplicates; branch names are URL-encoded. Not applied: gating resolve on a repository variable so that the environment is never created open before setup, because whether a job skipped by if: still creates the environment it names could not be verified here without an experiment repository; km upgrade prints the setup note instead.

/code-review on 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_pins took 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; the odd stand-in again tests a missing workflow_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 in reference/brains.md (the brains row). brains-list-remove on 296fcf0: score 1.00, 3 of 3 runs, $0.91.

An independent second review of the fixes (d0d57f2 to 296fcf0) found them clean.

@pasrom
pasrom changed the base branch from feat/ci-workflow-dispatch to main November 7, 2026 20:38
@pasrom
pasrom force-pushed the feat/bump-mounted-pins branch 2 times, most recently from adfd5a9 to d0d57f2 Compare November 7, 2026 21:09
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
pasrom force-pushed the feat/bump-mounted-pins branch from d0d57f2 to 296fcf0 Compare November 7, 2026 21:36
@pasrom
pasrom merged commit 0100308 into main Nov 7, 2026
4 checks passed
@pasrom
pasrom deleted the feat/bump-mounted-pins branch November 7, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant