Skip to content

add OD_CI_CONTROL_FALLBACK escape hatch for the control runner profile - #6290

Open
dpeterson01 wants to merge 2 commits into
nexu-io:mainfrom
dpeterson01:ci/control-runner-hosted-fallback
Open

add OD_CI_CONTROL_FALLBACK escape hatch for the control runner profile#6290
dpeterson01 wants to merge 2 commits into
nexu-io:mainfrom
dpeterson01:ci/control-runner-hosted-fallback

Conversation

@dpeterson01

@dpeterson01 dpeterson01 commented Jul 30, 2026

Copy link
Copy Markdown

Refs #6289.

Why

When the self-hosted control pool (od-persistent-ci) is unavailable, the control-profile required checks (Detect validation scopes, Static gate, Validate workspace, Runtime summary) stall or cancel and wedge approved PRs (#6076, #6079). Today the only lever is OD_CI_RUNNER_MODE, but its non-default modes also move general_medium and the hot paths, so there's no clean way to move just control to hosted.

What users will see

No product or end-user change. This adds a maintainer-facing CI control: when the self-hosted control runner pool is unavailable, setting the OD_CI_CONTROL_FALLBACK repo variable routes the control-profile CI jobs to GitHub-hosted runners so PRs stop wedging on cancelled required checks. With the variable unset, CI behaves exactly as it does today.

What it changes

A dedicated repo variable, OD_CI_CONTROL_FALLBACK. When a maintainer sets it truthy (1/true/yes/on/hosted), control jobs run on GitHub-hosted runners. It's off by default and independent of OD_CI_RUNNER_MODE, so it touches only the control profile. The resolved decision output now records control_fallback so the choice is visible in logs.

Decision and rationale: least-privilege, no elevated token permissions

I considered automatic detection (have the resolve job query runner health and self-heal), but listing self-hosted runners requires GET /repos/{owner}/{repo}/actions/runners, which needs the administration permission. This workflow's token only has actions: read. Broadening the CI token would mean a standing privilege escalation on every run and a larger attack surface, so instead this uses a maintainer-flippable repo variable that needs no token permissions at all. If you'd later prefer automatic detection and are fine granting administration: read, that's a clean follow-up. I kept this change minimal.

Surface area

  • UI — new page / dialog / panel / menu item / setting / empty state in apps/web or apps/desktop (including Electron menu bar)
  • Keyboard shortcut — new or changed
  • CLI / env var — new od subcommand or flag, new tools-dev / tools-pack flag, or new OD_* env var
  • API / contract — new /api/* endpoint, new SSE event, or changed shape in packages/contracts
  • Extension point — new entry under skills/, design-systems/, design-templates/, or craft/, or change to the skills protocol
  • i18n keys — added new translation keys (see TRANSLATIONS.md for the locale workflow)
  • New top-level dependency — adding any new entry to the root package.json (dependencies or devDependencies)
  • Default behavior change — changes what existing users experience without opting in (default model, default setting, file/SQLite schema, auto-network on startup, auto-install)
  • None — internal refactor, docs, tests, or translation update only

The new box is OD_CI_CONTROL_FALLBACK, a CI/Actions repo variable read by .github/scripts/runners.py.

Bug fix verification

Ran runners.py locally across the relevant cases:

  • unset: control stays self-hosted (unchanged default)
  • OD_CI_CONTROL_FALLBACK=1: control becomes ubuntu-24.04
  • =hosted with OD_CI_RUNNER_MODE=performance: only control flips; general_medium and the hot paths keep their mode values; decision.control_fallback is true
  • empty string: unchanged

Validation

Verified runners.py across the four cases above, and ci.yml parses as valid YAML. There's no test harness for .github/scripts today; I'm happy to add one if you have a preferred runner.

Route control-profile CI jobs to GitHub-hosted runners when a maintainer sets
the OD_CI_CONTROL_FALLBACK repo variable, so an unavailable self-hosted control
pool no longer wedges required checks. Off by default (variable unset), and
independent of OD_CI_RUNNER_MODE so it only touches the control profile. Reads a
repo variable via the job env, so it needs no elevated workflow-token
permissions.
@lefarcen

Copy link
Copy Markdown
Contributor

Thanks @dpeterson01 — the least-privilege split here is nicely scoped, and the rationale in the description makes the intent easy to follow.

Before pool review, could you add a short What users will see section and convert Surface area to the template checklist format with the applicable box(es) ticked? Right now the CI details are present, but those two template fields are still effectively incomplete.

@lefarcen
lefarcen requested a review from nettee July 30, 2026 19:40
@lefarcen lefarcen added size/XS PR changes <20 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/bugfix Bug fix labels Jul 30, 2026
@dpeterson01

Copy link
Copy Markdown
Author

Both addressed: added a What users will see section, and switched Surface area to the template checklist (ticked CLI / env var for the new OD_CI_CONTROL_FALLBACK variable). Thanks for the review.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one blocking regression in the runner contract/test boundary; details inline.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

"decision": {
"schema_version": 1,
"mode": mode,
"control_fallback": control_fallback,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding decision.control_fallback changes the runners.py output schema, but the owning workflow contract test still asserts exact equality on that object in e2e/tests/packaged-smoke-workflow.test.ts:1221, :1250, and :1261. On this PR head, python3 .github/scripts/runners.py now emits decision={"schema_version":1,"mode":"default","control_fallback":false}, which no longer matches the existing { schema_version: 1, mode: "default" } expectation, so this PR introduces a concrete regression in the current runner-profile test suite and still leaves the new fallback path unverified. Please update that test section to include the new field in the expected decision payload and add a truthy OD_CI_CONTROL_FALLBACK case that proves only runs_on.control flips to ubuntu-24.04 while the other runner tiers remain mode-driven.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Address review on nexu-io#6290: the decision payload now includes control_fallback, so
update the exact-equality expectations in the runner-profile contract test and
add a truthy OD_CI_CONTROL_FALLBACK case proving only runs_on.control flips to
GitHub-hosted while the other tiers stay mode-driven.
@dpeterson01

Copy link
Copy Markdown
Author

Good catch, thanks. Fixed in 1c05c3e9a: the runner-profile contract test now expects control_fallback in the decision payload, and I added a truthy OD_CI_CONTROL_FALLBACK case asserting only runs_on.control flips to ubuntu-24.04 while the other tiers stay mode-driven (including a performance-mode sub-case). Ran both locally: green.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dpeterson01 I re-checked the updated head and the earlier runner-contract gap is closed: the workflow now passes OD_CI_CONTROL_FALLBACK, runners.py records decision.control_fallback, and the packaged workflow contract test expectations now cover both the new decision field and the hosted-control fallback path. I also spot-checked python3 .github/scripts/runners.py with and without OD_CI_CONTROL_FALLBACK=1, and the outputs match the intended control-only override. I couldn't run e2e/tests/packaged-smoke-workflow.test.ts in this prepared worktree because node_modules/vitest are not installed here. Nice follow-up on a narrowly scoped CI escape hatch.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/XS PR changes <20 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants