You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Run only reviewed main-branch workflows on the production runner #437
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.
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:37 and :147. Any pushed workflow can add another job with runs-on: prod.docs.plus.
The production deploy's workflow_dispatch arm has no branch check: prod.docs.plus.yml:429-433. Its environment: production (:434) is the only gate.
observability.docs.plus.yml:13 accepts workflow_dispatch from any branch. It has no environment: 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:33 lists repos/${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.plus runner. 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/main and docs-plus/docs.plus/.github/workflows/observability.docs.plus.yml@refs/heads/main.
Move the stage.docs.plus runner to a second group, with stage.docs.plus.yml@refs/heads/dev.
Re-register the runners in their groups if a move is not offered. Then update runner-watchdog.yml:33 if the runners no longer list under the repo.
production environment (maintainer). Set "Deployment branches" to main only.
Workflow guards. These make a dispatch from another branch skip cleanly, not wait for a runner forever.
prod.docs.plus.yml:433: add github.ref == 'refs/heads/main' && inside the workflow_dispatch arm of the deploy job's if.
observability.docs.plus.yml:47 and :149: add github.ref == 'refs/heads/main' to both jobs' if. Inside workflow_call, github.ref is the caller's ref, so the push-to-main path still runs.
Do not add environment: production to the observability jobs. The comment at :48-49 explains 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 deploy job of prod.docs.plus.yml, copy scripts/traefik/ to ${DEPLOY_STATE_DIR}/traefik/ just before up -d traefik redis (:646-647). Add TRAEFIK_CONFIG_DIR=/opt/projects/prod.docs.plus/.deploy/traefik to the production host .env, so every compose call with --env-file agrees.
The mount change recreates Traefik once. Deploy it in a quiet window.
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.plus on a non-main branch never starts on the production runner. The job only echoes text.
Dispatching CI/CD Production or CI/CD Observability from a non-main branch skips every job on the production runner.
gh api repos/docs-plus/docs.plus/environments/production shows a deployment branch policy that allows only main.
Checking out another branch in the runner workspace does not change the files Traefik reads.
A normal push to main still 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/.
changed the title [-][Security] Branch-write access runs code on the production server without review[/-][+]Run only reviewed main-branch workflows on the production runner[/+]on Oct 6, 2026
Code half is live (73e5bd972): production jobs skip on a dispatch from any branch but main, and Traefik reads its config from the deploy copy under .deploy/traefik.
Left before closing (maintainer, GitHub settings): set the production environment's deployment branches to main only, and, if the org plan offers Selected workflows, a runner group that accepts only prod.docs.plus.yml and observability.docs.plus.yml from refs/heads/main. Also confirm the mounts with docker inspect traefik --format "{{json .Mounts}}".
Summary
The production runner
prod.docs.plusholds the production.envand the Docker socket. Any workflow that names it can run there. That includes a branch pushed by anyone with write access. Theproductionenvironment 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..github/workflows/,docker-compose.prod.ymlWhere
.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.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.docker-compose.prod.yml:67-68. The file provider reloads on change:scripts/traefik/traefik.yml:85-87..github/workflows/runner-watchdog.yml:33listsrepos/${GITHUB_REPOSITORY}/actions/runners..github/workflows/stage.docs.plus.yml:194(runs-on: stage.docs.plus).Fix plan
prod.docs.plusrunner. Allow only this repository, with "Allow public repositories" on.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.stage.docs.plusrunner to a second group, withstage.docs.plus.yml@refs/heads/dev.runner-watchdog.yml:33if the runners no longer list under the repo.productionenvironment (maintainer). Set "Deployment branches" tomainonly.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.environment: productionto the observability jobs. The comment at:48-49explains why it stays off.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.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.Out of scope
Acceptance criteria
runs-on: prod.docs.pluson a non-mainbranch never starts on the production runner. The job only echoes text.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.mainstill deploys the app, observability and Uptime Kuma.Verify
gh api repos/docs-plus/docs.plus/environments/production.runs-on: prod.docs.plus,run: echo ok. Confirm it never gets a runner, then cancel it and delete the branch.docker inspect traefik --format '{{json .Mounts}}'. Both config sources must be under/opt/projects/prod.docs.plus/.deploy/traefik/.maindeploy run to the end.Related
Generated by Claude Code