Repository navigation
Run only reviewed main-branch workflows on the production runner #437
Copy link
Copy link
Open
Labels
DevOpsSecuritySecurity, access control, and data exposureSecurity, access control, and data exposure
Milestone
Description
Summary
The production runner prod.docs.plus holds the production .env and the Docker socket. Any workflow that names it can run there. That includes a branch pushed by anyone with write access. The production environment reviewer does not stop this, because the job's YAML comes from that branch. Separately, Traefik reads its routing config from the runner's working copy, so any checkout on that runner changes live routing.
- Severity: Medium
- Area: GitHub Actions settings,
.github/workflows/,docker-compose.prod.yml - Source: security review of 2026-10-06, finding M11
Where
- Jobs on the production runner:
.github/workflows/prod.docs.plus.yml:420(deploy) and:968(uptime-kuma-deploy),.github/workflows/observability.docs.plus.yml:37and:147. Any pushed workflow can add another job withruns-on: prod.docs.plus. - The production deploy's
workflow_dispatcharm has no branch check:prod.docs.plus.yml:429-433. Itsenvironment: production(:434) is the only gate. observability.docs.plus.yml:13acceptsworkflow_dispatchfrom any branch. It has noenvironment:on purpose (:48-49), so nothing reviews it.- Traefik config is bind-mounted from the runner workspace:
docker-compose.prod.yml:67-68. The file provider reloads on change:scripts/traefik/traefik.yml:85-87. - Runners are registered to the repo, not to an org runner group:
.github/workflows/runner-watchdog.yml:33listsrepos/${GITHUB_REPOSITORY}/actions/runners. - Staging uses the same pattern:
.github/workflows/stage.docs.plus.yml:194(runs-on: stage.docs.plus).
Fix plan
- Runner groups (maintainer, org settings).
- First confirm that the org plan offers "Selected workflows" on runner groups. If it does not, stop and report back before step 2.
- Create an org runner group for the
prod.docs.plusrunner. Allow only this repository, with "Allow public repositories" on. - Set "Selected workflows" to
docs-plus/docs.plus/.github/workflows/prod.docs.plus.yml@refs/heads/mainanddocs-plus/docs.plus/.github/workflows/observability.docs.plus.yml@refs/heads/main. - Move the
stage.docs.plusrunner to a second group, withstage.docs.plus.yml@refs/heads/main. Nodevbranch exists, so staging runs only through a manualworkflow_dispatch. A dispatch frommainruns asrefs/heads/main. - Re-register the runners in their groups if a move is not offered. Then update
runner-watchdog.yml:33if the runners no longer list under the repo.
productionenvironment (maintainer). Set "Deployment branches" tomainonly.- Workflow guards. These make a dispatch from another branch skip cleanly, not wait for a runner forever.
prod.docs.plus.yml:433: addgithub.ref == 'refs/heads/main' &&inside theworkflow_dispatcharm of thedeployjob'sif.observability.docs.plus.yml:47and:149: addgithub.ref == 'refs/heads/main'to both jobs'if. Insideworkflow_call,github.refis the caller's ref, so the push-to-main path still runs.- Do not add
environment: productionto the observability jobs. The comment at:48-49explains why it stays off.
- Traefik config path. The observability job checks out code on the production runner with no reviewer. Today that checkout also rewrites live routing.
- In
docker-compose.prod.yml:67-68, change both mount sources to${TRAEFIK_CONFIG_DIR:-./scripts/traefik}/.... Staging uses the same compose file, so it keeps the default. - In the
deployjob ofprod.docs.plus.yml, copyscripts/traefik/to${DEPLOY_STATE_DIR}/traefik/just beforeup -d traefik redis(:646-647). AddTRAEFIK_CONFIG_DIR=/opt/projects/prod.docs.plus/.deploy/traefikto the production host.env, so every compose call with--env-fileagrees. - The mount change recreates Traefik once. Deploy it in a quiet window.
- In
Out of scope
- Fork pull requests on the production runner (finding C2). Require approval before fork pull requests run workflows #440 owns that setting.
- The staging Traefik mount. Staging keeps the workspace path until someone asks for the same change there.
- Alloy and cAdvisor reach the Docker socket (review finding L17).
Acceptance criteria
- A test job with
runs-on: prod.docs.pluson a non-mainbranch never starts on the production runner. The job only echoes text. - Dispatching
CI/CD ProductionorCI/CD Observabilityfrom a non-mainbranch skips every job on the production runner. -
gh api repos/docs-plus/docs.plus/environments/productionshows a deployment branch policy that allows onlymain. - Checking out another branch in the runner workspace does not change the files Traefik reads.
- A normal push to
mainstill deploys the app, observability and Uptime Kuma.
Verify
- Read the branch policy with
gh api repos/docs-plus/docs.plus/environments/production. - Push a throwaway branch with a one-step workflow:
runs-on: prod.docs.plus,run: echo ok. Confirm it never gets a runner, then cancel it and delete the branch. - On the production host, run
docker inspect traefik --format '{{json .Mounts}}'. Both config sources must be under/opt/projects/prod.docs.plus/.deploy/traefik/. - Watch the next
maindeploy run to the end.
Related
- Fork pull request approval (C2): Require approval before fork pull requests run workflows #440
- Require a password on production Redis #438 production Redis has no password
Generated by Claude Code
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
DevOpsSecuritySecurity, access control, and data exposureSecurity, access control, and data exposure
Activity
[-][Security] Branch-write access runs code on the production server without review[/-][+]Run only reviewed main-branch workflows on the production runner[/+]fix(ops): run prod jobs only from main and narrow monitoring access
docs(repo): record the 9 Oct security and email batch in the changelo…
HMarzban commentedon Oct 9, 2026
Code half is live (
73e5bd972): production jobs skip on a dispatch from any branch butmain, and Traefik reads its config from the deploy copy under.deploy/traefik.Left before closing (maintainer, GitHub settings): set the
productionenvironment's deployment branches tomainonly, and, if the org plan offers Selected workflows, a runner group that accepts onlyprod.docs.plus.ymlandobservability.docs.plus.ymlfromrefs/heads/main. Also confirm the mounts withdocker inspect traefik --format "{{json .Mounts}}".HMarzban commentedon Oct 14, 2026
Correction, 2026-10-14
Fix plan step 1 named
stage.docs.plus.yml@refs/heads/devfor the staging runner group. Nodevbranch exists on origin, so staging runs only through a manualworkflow_dispatch, and a dispatch frommainruns asrefs/heads/main. The step now namesstage.docs.plus.yml@refs/heads/main. Nothing else in the body changed.