Skip to content

feat(selector): route same-repository Dependabot runs to the fleet - #103

Merged
kyle-sexton merged 1 commit into
mainfrom
feat/route-dependabot-to-fleet
Jul 15, 2026
Merged

feat(selector): route same-repository Dependabot runs to the fleet#103
kyle-sexton merged 1 commit into
mainfrom
feat/route-dependabot-to-fleet

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Why

Dependabot-triggered CI is the dominant remaining hosted spend (est. 1,500–3,000 min/mo) and draws down the 3,000 included-minute reserve the org keeps for must-be-hosted jobs and fleet-down fallback. The org owner explicitly approved retiring the selector's "Dependabot must never receive the observer credential" guard (named approval, this session) so same-repo bump PRs route to the ephemeral one-job fleet like any push.

What

  • select-runner.cjs: drop the isDependabot hosted-only preflight term + the input; rationale comment updated (fork/public guards untouched)
  • select-runner.yml: token-mint step no longer excludes dependabot[bot] / secret_source == 'Dependabot'; IS_DEPENDABOT env removed; embedded bundle regenerated (render-select-runner-workflow.cjs --check passes)
  • select-runner.test.cjs: Dependabot hosted-route cases and static-guard terms removed (98/98 pass)
  • README: selector routing + zizmor eligibility wording updated; documents the Dependabot-store mirror requirement

Security boundary after this change

  • Fork PRs and public repos: hosted-only, unchanged
  • Observer key: still confined to the selector job (lanes receive a label string only); on Dependabot events it resolves from the org Dependabot secrets store — until CI_RUNNER_OBSERVER_PRIVATE_KEY is mirrored there, Dependabot runs simply fall back hosted (missing-secret), so rollout is fail-safe
  • Dependency-bump code executes in ephemeral one-job Docker workers, no docker socket, resource-capped

Activation

Org owner mirrors the observer key: gh secret set CI_RUNNER_OBSERVER_PRIVATE_KEY --org melodic-software --app dependabot --visibility selected --repos "claude-code-plugins,dotfiles,github-iac,medley,provisioning,standards" < <observer-key.pem>

Follow-up

A dependabot-routing case in local-runner-canary would give this a proof-harness; none existed for the old behavior either.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JLfjHY8PY6ozhgZPiuHKik

Dependabot-triggered CI was a reviewed hosted exception; the org owner
approved retiring it so dependency-bump lanes run in ephemeral one-job
workers instead of drawing the hosted included-minute reserve. Fork and
public guards are untouched, the observer key stays confined to the
selector job, and Dependabot events source it from the Dependabot
secrets store, which the org now mirrors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLfjHY8PY6ozhgZPiuHKik
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 55s —— View job


PR Review

  • Gather context
  • Read changed files
  • Analyze security implications
  • Post review findings

Summary

This PR removes the isDependabot hosted-only guard from the runner selector so that same-repository Dependabot PRs route to the ephemeral fleet like any other private same-repo pull_request. The change is logically correct, the embedded bundle stays in sync with the standalone script, and the fail-safe activation model (fall back to missing-secret/hosted until the org mirrors the key to the Dependabot secrets store) is sound.

A few items worth discussing:


Defense-in-depth: secret_source guard removed from token-mint if

File: .github/workflows/select-runner.yml L83–99

The previous if condition included two explicit Dependabot exclusions:

github.actor != 'dependabot[bot]'
github.secret_source != 'Dependabot'

Both are removed. The intent is correct—same-repo Dependabot runs should now be eligible to mint the observer token (once the key is mirrored to the Dependabot store). However, secret_source == 'Dependabot' is set by GitHub on any event where Dependabot is the triggering actor, including push events from Dependabot branches. In practice this is fine today because:

  • push from Dependabot branches only occurs for same-repo bumps (private repo, repositoryPrivate == true)
  • The if condition still requires github.event.repository.private == true
  • No mechanism currently gives Dependabot schedule or workflow_dispatch a different secret_source

So no actionable risk here, but it's worth a conscious acknowledgement that the secret_source check was the last context-level signal distinguishing "Dependabot secrets in scope" from "Actions secrets in scope." Its removal is a deliberate architectural choice, not an oversight.


Static guard test alignment

File: .github/scripts/select-runner.test.cjs L931–941

The test that validates required guards on the token-mint step correctly drops the two Dependabot assertions. The remaining verified guards (private == true, fork-PR repo match, event allowlist) are still present in the workflow and covered. No issue — just confirming the test update is in lockstep with the if condition change.


No new positive Dependabot routing test (acknowledged gap)

Since isDependabot is no longer an input field, a same-repo Dependabot pull_request is now indistinguishable from any other same-repo PR at the selector layer. That means the existing pull_request + isForkPullRequest: false tests implicitly cover the new Dependabot path — no new unit test is strictly needed for correctness. The PR body calls out a canary-level proof-harness as a follow-up, which is the right place for it.


Bundle / script consistency: confirmed

The embedded bundle in select-runner.yml L685–689 matches the standalone select-runner.cjs L547–550: isDependabot / IS_DEPENDABOT removed from both.


Minor: README link semantics shift

File: README.md L177–184

The [dependabot-secrets] link was previously used to justify why Dependabot routes hosted. It now anchors the requirement to mirror the key to the Dependabot store. The shift is correct in context, but if the link target is a GitHub doc on the Dependabot secret boundary (rather than the secrets store API), a reader following the link expecting setup guidance may need to look further. Low impact, fine as-is.


Verdict

Approved from a correctness and security standpoint. The fork-PR and public-repo guards are untouched. The observer key is still confined to the selector job; lanes only receive a label string. The fail-safe rollout path (missing-secret → hosted until the org mirrors the key) is correctly implemented. No blocking issues.

@kyle-sexton
kyle-sexton merged commit 3931f91 into main Jul 15, 2026
33 checks passed
@kyle-sexton
kyle-sexton deleted the feat/route-dependabot-to-fleet branch July 15, 2026 06:47
kyle-sexton added a commit to melodic-software/standards that referenced this pull request Jul 15, 2026
Step 2 of the selector revision lockstep requires a review note alongside
each new policy.json allowlist entry; the initial commit added the
3931f91 allowlist entry and test pin but not its README note, flagged by
chatgpt-codex-connector[bot] (P2). Source facts from merged
melodic-software/ci-workflows#103.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kyle-sexton added a commit to melodic-software/standards that referenced this pull request Jul 15, 2026
…117)

## Why

melodic-software/ci-workflows#103 (merged) retires the selector's
Dependabot hosted-only guard — owner-approved — so same-repo bump PRs
route to the ephemeral fleet instead of drawing the hosted
included-minute reserve. Consumers can only adopt the new selector once
its exact reference is reviewed in the owner-scoped allowlist.

## What

- `policy.json`: add `select-runner.yml@3931f91` to the melodic-software
owner-scoped selector allowlist (staged-set pattern; prior revisions
remain until consumers roll forward)
- `runner-policy.test.mjs`: pin the new reviewed reference;
positive-audit loop covers it

## Rollout

Dependabot's weekly `github-actions` group bumps move consumer pins to
the new SHA; each repo starts routing dependabot CI to the fleet as its
bump merges. Activation additionally requires the org Dependabot-store
mirror of the observer key (fail-safe until then: dependabot runs keep
falling back hosted via `missing-secret`).

## Verification

`node --test components/runner-policy/runner-policy.test.mjs`: 112 pass
/ 0 fail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01JLfjHY8PY6ozhgZPiuHKik

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 22, 2026
…224)

Closes #217

## Related

- #209 / #212 (github-script port pattern this issue's fix depends on
for consumers)
- medley#1628 report Theme 1 (the exit-127 defect class that motivates
never routing a gh-CLI-dependent job self-hosted without porting it
first)
- medley#1632 (comment-review-gate.yml github-script port -- the
reviewed no-checkout consumer this change enables)

## Summary

- Adds `admits-comment-events` (workflow_call input, default `false`) to
`select-runner.yml`. When a caller sets it `true`, `issue_comment`,
`pull_request_review`, and `pull_request_review_comment` jobs become
eligible for local (self-hosted) routing, subject to every existing
policy/private-repo/fork gate.
- Fixes a second, independent event enumeration in the "Mint read-only
observer token" step (`prefer-self-hosted` policy path) that had its own
hardcoded event list and was otherwise unreachable for comment/review
events even with the flag set.

## Why (and the design this landed on)

Org GitHub Actions spending is capped at $0. The governed policy is
`self-hosted-only`, so private-repo required checks already run on the
fleet -- but comment/review-event jobs were categorically excluded from
local routing and fell back to GitHub-hosted runners, which now fail at
startup on a billing-limit error whenever free minutes are exhausted
(observed on medley's `comment-review-gate.yml`).

**Original-rationale finding:** `LOCAL_EVENT_ALLOWLIST` (git blame/log
through #92, #103, #123, #135) is a plain default-deny allowlist --
"only explicitly reviewed caller event classes may route locally" -- not
a comment-specific security ban. Comment/review events were simply never
reviewed, not deliberately blocked.

**Checkout-bearing consumer found, scoped out:** Auditing fleet
consumers surfaced medley's `claude-assistant.yml`, which triggers on
`issue_comment`/`pull_request_review_comment`, checks out the
repository, and runs Claude with `contents: write` and commit signing
via the same selector. Under the current `self-hosted-only` policy that
job is dormant (comment events route hosted, so its `route ==
'self-hosted'` gate never passes). A blanket "admit these event classes"
change -- the originally scoped approach -- would have made that dormant
job self-hosted-eligible as a side effect: comment-triggered checkout +
code execution + write perms on the fleet, without a security review of
that specific exposure.

Landed on a **per-caller opt-in** instead: `admits-comment-events`
defaults `false`, so `claude-assistant.yml` is untouched by this PR and
stays exactly as dormant as it is today. Its dormant assist job would
only ever go live by **its own deliberate future opt-in** -- a separate
decision requiring its own security review of checkout+write-on-fleet
exposure, not a side effect of this change. Recording this explicitly
here per the review discussion; will also leave a comment on #217 for
anyone who revisits `claude-assistant.yml`'s routing later.

`comment-review-gate.yml` (medley#1632) is the reviewed consumer this
opt-in is for: verified pure `gh api`/now `github-script` calls, zero
checkout anywhere in that workflow.

## Consumer follow-up (not in this PR)

medley pins `select-runner.yml` by full commit SHA (currently
`90f1c54935203fa31b5b3d1f41531228be2c2b7f # v0.6.1`). Once this merges
and cuts a tag, medley's normal repin process picks up the new SHA; only
*after* that repin can `comment-review-gate.yml` add
`admits-comment-events: true` to its `select-runner` call (the
pinned-SHA reusable doesn't know the input yet, so adding it any earlier
would be rejected as an unexpected input).

## Verification

- `node --test .github/scripts/select-runner.test.cjs` -- 112/112
passing, including new coverage: `admits-comment-events` true/false x
the three comment/review events x
`self-hosted-only`/`prefer-self-hosted`, plus a regression test proving
the flag does not widen an unrelated blocked event class
(`workflow_run`)
- `node --test .github/scripts/*.test.cjs` -- 247/247 passing repo-wide
- `actionlint .github/workflows/select-runner.yml` -- clean
- `node .github/scripts/render-select-runner-workflow.cjs --check` --
generated block in sync with `select-runner.cjs`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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