Skip to content

CI: let the runner token be forwarded to a local reusable workflow - #759

Merged
harrism merged 1 commit into
openvdb:mainfrom
harrism:mjh/ci-runner-token-policy
Sep 3, 2026
Merged

CI: let the runner token be forwarded to a local reusable workflow#759
harrism merged 1 commit into
openvdb:mainfrom
harrism:mjh/ci-runner-token-policy

Conversation

@harrism

@harrism harrism commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Amends the EC2 runner-token policy (added in #672) so the token may be forwarded, by name, to a local reusable workflow — and rejects secrets: inherit, which currently slips through.

This unblocks #760, which removes ~400 lines of duplicated runner YAML. It is worth landing on its own, because it makes the policy strictly tighter than it is today regardless of what follows.

Why the current rule is both too strict and not strict enough

Rule 2 requires every mention of the token to be literally github-token: ${{ secrets.EC2_RUNNER_TOKEN }}, which forbids a secrets: block outright. The rule's stated purpose is to keep the token's reachable surface enumerable by grep, so a PR cannot quietly move it somewhere it can escape and have that land on main (the two-stage, time-of-merge attack described in #672). Measured against that purpose:

  • The letter is not strict enough. secrets: inherit never spells the token's name, so the textual scan is blind to the forwarding entirely. Forward to a remote reusable workflow (uses: other-org/repo/.github/workflows/x.yml@main) and the token leaves this repository with the check reporting OK. Verified against main: that workflow passes today.
  • The spirit is slightly too strict. For a local callee, the callee is itself a file in .github/workflows/, so this same scan covers it and rules 2–4 apply to it directly; under pull_request_target it is base-controlled like every other workflow; and a reusable-workflow caller job has no steps: and no workspace, so rule 4's rationale ("no untrusted code beside the privileged context") has nothing to bite on.

Put bluntly: the checker currently passes blanket inherit and fails the explicit single-secret mapping — it rejects the more careful of the two.

What changed

The rule becomes structural rather than textual:

  • Allow EC2_RUNNER_TOKEN: ${{ secrets.EC2_RUNNER_TOKEN }} inside a job's secrets: block, only when that job's uses: is ./.github/workflows/<name>.yml and that file exists in this directory (i.e. only when the callee is covered by this same scan).
  • Require the forwarded name to be EC2_RUNNER_TOKEN itself, so the token keeps one name across caller and callee and a single git grep EC2_RUNNER_TOKEN still enumerates every file that touches it.
  • Allow the matching on.workflow_call.secrets declaration on the callee.
  • New rule 6: reject secrets: inherit anywhere in a workflow.
  • Check every remaining mention structurally, so shapes that merely look like a forward — a workflow-level env:, a job-level with: — are still rejected.

Net effect is strictly tighter than before: it closes the inherit hole and the remote-callee hole, and opens only the one shape this scan can actually verify.

Testing

  • check_runner_token_policy.py: OK on all 17 workflow files.
  • Policy unit tests: 26 passed (10 new) — local forward allowed, remote forward rejected, missing callee rejected, renamed forward rejected, secrets: inherit rejected (local and remote), workflow-level env: rejected, callee declaration allowed.
  • actionlint 1.7.7 and zizmor 1.26.1 --persona=regular: clean.

Reviewer note on CI signal

workflow-security.yml deliberately runs the policy script and its tests from the trusted base checkout, overlaying only the PR's .github/workflows as data. This PR touches only .github/scripts/, so the gate here exercises the old script against the old workflows and will go green without testing any of this. That is the design working as intended (a PR must not be able to edit the check that guards it) — the new code is exercised by the push: main run after merge, and by the 26 tests above locally.

@swahtz — this is your rule from #672, so flagging you directly.

🤖 Generated with Claude Code

The runner-token policy required every mention of EC2_RUNNER_TOKEN to be
literally `github-token: ${{ secrets.EC2_RUNNER_TOKEN }}`, which forbade a
`secrets:` block outright. That made every EC2 start/stop job inline the
machulav action, and it is the reason the same nine-line block is copy-pasted
across tests.yml, cu130.yml, cu132.yml, publish.yml and nightly-publish.yml.

The prohibition was too blunt in one direction and not blunt enough in the
other. The rule's purpose (openvdb#672) is to keep the token's reachable surface
enumerable by grep, so that a PR cannot quietly move it somewhere it can escape
and have that land on main. But:

  * `secrets: inherit` never spells the token's name, so it slipped through the
    textual scan entirely -- including a forward to a *remote* reusable
    workflow, which takes the token out of the repo with the check reporting OK;
  * forwarding to a *local* reusable workflow is safe by the rule's own
    reasoning. The callee is a file in .github/workflows/, so this same scan
    covers it and rules 2-4 apply to it directly; under pull_request_target it
    is base-controlled like every other workflow; and a caller job has no steps
    and no workspace, so rule 4 ("no untrusted code beside the token") has
    nothing to bite on.

So the checker rejected the careful form and accepted the blanket one. Make the
rule structural instead of textual:

  * allow `EC2_RUNNER_TOKEN: ${{ secrets.EC2_RUNNER_TOKEN }}` in a job's
    `secrets:` block, but only when that job's `uses:` is
    `./.github/workflows/<name>.yml` and that file exists here;
  * require the forwarded name to be the token's own, so a single
    `git grep EC2_RUNNER_TOKEN` still enumerates every file that touches it;
  * allow the matching `on.workflow_call.secrets` declaration on the callee;
  * reject `secrets: inherit` anywhere (new rule 6);
  * check every remaining mention structurally, so shapes that merely look like
    a forward -- a workflow-level `env:`, a job-level `with:` -- are still
    rejected.

Net effect is strictly tighter than before: it closes the `inherit` hole and
the remote-callee hole, and opens only the one shape this scan can actually
verify.

Signed-off-by: Mark Harris <mharris@nvidia.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
@harrism
harrism merged commit f785e02 into openvdb:main Sep 3, 2026
40 checks passed
@harrism
harrism deleted the mjh/ci-runner-token-policy branch September 3, 2026 03:32
harrism added a commit that referenced this pull request Sep 3, 2026
…ckoff (#760)

Replaces #758. CI keeps failing because us-east-2 has no capacity for
the requested instance type at the moment the runner is requested.
`machulav/ec2-github-runner` sweeps the availability zones in
`availability-zones-config` within one attempt, but it never retries the
sweep, so a transient region-wide shortage fails the whole run. Retrying
twice, 90s and then 180s later, turns most of those into a slow pass
instead of a red build.

#758 inlined that retry ladder at every call site. That is ~30 lines of
near-identical YAML in nine places that can drift independently, which
is why this replaces it: the ladder goes in a reusable workflow instead.

## What changed

Two new local reusable workflows hold the retry policy, the pinned
action SHA, the AWS OIDC step and the teardown:

* `.github/workflows/start-ec2-runner.yml`
* `.github/workflows/stop-ec2-runner.yml`

`tests.yml`, `cu130.yml`, `cu132.yml`, `publish.yml` and
`nightly-publish.yml` now call them — nine start jobs and nine stop
jobs. **401 lines of duplicated YAML removed, 171 added.**

The reusable workflows absorb both call shapes the repo already had:

* `runner-label` / `aws-resource-tags` for the matrix fan-outs in
`publish.yml` and `nightly-publish.yml`, which need a predictable label
because they find the instance again by tag at teardown.
* `stagger-seconds`, replacing the hand-rolled `RANDOM % 16` sleep those
same matrix jobs used to spread out runner registrations.

`stop-ec2-runner.yml` takes the instance id directly when the caller has
one from a start job's output, and otherwise resolves it from the
`RunnerLabel` tag — the lookup the publish workflows had to do inline,
because a matrix job has no single output to read.

## Two details worth a reviewer's eye

* `aws-resource-tags` defaults to `'[]'`, not `''`. The pinned action
does a bare `JSON.parse()` on that input
([`src/config.js`](https://github.com/machulav/ec2-github-runner/blob/343a1b2ae682e681c3cec9a235d882da17ff04ef/src/config.js#L39))
with no fallback, so an empty string throws and would break every caller
that does not set tags. An empty `label`, by contrast, is explicitly
fine — the action generates a unique one.
* Retry semantics are unchanged from #758: only one attempt can succeed,
since each is skipped unless every earlier one failed, so at most one of
the three step outputs is non-empty and `||` picks it.

## Testing

* `check_runner_token_policy.py` (with #759 applied): OK on all 17
workflow files.
* Policy unit tests: 26 passed.
* `actionlint` 1.7.7: clean. `zizmor` 1.26.1 `--persona=regular`: no
findings.
* Parsed every reusable-workflow caller job to confirm none carries a
key incompatible with `uses:` (`runs-on`, `steps`, `env`, `container`,
…).

Because CI runs on `pull_request_target` from the base branch, this
change cannot exercise itself on its own PR — the first real run of the
new provisioning path is the one after merge.

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

## Update: retry-path fix folded in

#759 has merged, so the Workflow Security gate now passes here.

While confirming the ladder had not yet fired in production (it hasn't —
every start since the merge succeeded on attempt 1), I audited the
never-executed path and found a defect, now fixed in `c90f2d7`:

The action publishes its outputs as soon as the instance launches,
**before** waiting for the runner to register. So a "no capacity"
failure leaves outputs empty, but a *registration* failure leaves them
populated. Selecting outputs with `a1 || a2 || a3` takes the first
**non-empty** value rather than the **successful** one — which in that
second case hands back a dead label (dependent job queues until timeout
instead of failing fast) and the wrong instance id (the retry's instance
leaks).

Fixed by selecting on `outcome`, and by stopping the instance a failed
attempt left running before retrying. Verified against all five outcome
combinations. Same fix for frc is openvdb/fvdb-reality-capture#330.

Not a regression from this refactor — the inline version in #758 had the
same `||` chain. The refactor is why the fix is one file rather than
nine.

---------

Signed-off-by: Mark Harris <mharris@nvidia.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants