CI: provision EC2 runners through shared workflows, with retry and backoff - #760
Merged
Merged
Conversation
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>
…ckoff
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.
Rather than paste that retry ladder into all nine start jobs, put it in a
reusable workflow. `start-ec2-runner.yml` and `stop-ec2-runner.yml` now hold the
retry policy, the pinned action SHA, the AWS OIDC step and the teardown, and the
five workflows call them. The retry ladder exists once and cannot drift between
tests.yml, cu130.yml, cu132.yml, publish.yml and nightly-publish.yml -- which is
also why the previous inline version of this change was not worth landing.
The reusable workflows absorb the two 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. `aws-resource-tags` defaults to '[]'
rather than '': the action runs a bare JSON.parse() on it, which throws on
an empty string.
* `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.
Net: 401 lines of duplicated YAML removed, 171 added, plus the two shared
workflows. Forwarding the token this way is what the accompanying policy change
permits; the checker, actionlint and zizmor all pass on the result.
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>
matthewdcong
approved these changes
Sep 3, 2026
harrism
added a commit
that referenced
this pull request
Sep 3, 2026
) ## 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](https://claude.com/claude-code) Signed-off-by: Mark Harris <mharris@nvidia.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
harrism
added a commit
to openvdb/fvdb-reality-capture
that referenced
this pull request
Sep 3, 2026
…ckoff (#329) ## Summary Replaces #327. 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 (#326), 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. #327 inlined that retry ladder at every call site — ~30 lines of near-identical YAML in six places that can drift independently. This replaces it: the ladder goes in a reusable workflow instead. ## What changed Two new local reusable workflows hold the retry policy, the action version, the AWS OIDC step, the IAM role ARN and the teardown: * `.github/workflows/start-ec2-runner.yml` * `.github/workflows/stop-ec2-runner.yml` `tests.yml`, `nightly.yml`, `publish.yml` and `gsplat-l4-tests.yml` now call them — six start jobs and six stop jobs. **226 lines of duplicated YAML removed, 84 added.** Two things that were duplicated and are now single-sourced: * The IAM role ARN and `us-east-2` were repeated verbatim at all twelve call sites. They are now defaults on the reusable workflows, so a call site only names them if it differs. * The token forwards explicitly as `RUNNER_TOKEN`. That deliberately preserves the two secrets already in use — `EC2_RUNNERS_ACTION` for tests/nightly/gsplat, `GH_PERSONAL_ACCESS_TOKEN` for publish — at their existing call sites rather than quietly consolidating them. Worth a follow-up decision, but not smuggled into this PR. ## Testing * `actionlint` 1.7.7: clean. * Parsed every reusable-workflow caller job to confirm none carries a key incompatible with `uses:` (`runs-on`, `steps`, `env`, `container`, …), and that no dangling `steps.*` references remain. The equivalent change for fvdb-core is openvdb/fvdb-core#760. Because these workflows only run on the real EC2 fleet, the first genuine exercise of the new provisioning path is the run after merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Mark Harris <mharris@nvidia.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
harrism
marked this pull request as ready for review
September 3, 2026 03:33
…ble-workflows Signed-off-by: Mark Harris <mharris@nvidia.com>
The retry ladder handled only one of the two ways a start attempt can fail.
machulav/ec2-github-runner publishes its outputs as soon as the instance
launches, BEFORE it waits for the runner to register:
const result = await aws.startEc2Instance(label, ...);
setOutput(label, ec2InstanceId, region); // <-- here
await aws.waitForInstanceRunning(ec2InstanceId, region);
await gh.waitForRunnerRegistered(label, pollCallback); // 5-min timeout
So a "no capacity" failure throws before setOutput and leaves the outputs empty,
but a registration failure -- timeout, bad AMI, userdata error -- fails with the
outputs already populated. The ladder picked outputs with
${{ steps.a1.outputs.label || steps.a2.outputs.label || ... }}
which selects the first NON-EMPTY value, not the successful one. In the second
failure class that hands back the dead attempt's label, so the dependent job
queues against a runner that never appears instead of failing fast, and the
instance the retry actually provisioned is never stopped, because teardown was
given the wrong instance id.
Fix both halves:
* Select on the step outcome instead of on emptiness, in an explicit step
that also produces the terminal error when no attempt succeeded. Keying on
`outcome` is what makes "the attempt that worked" and "the first attempt
with an output" the same thing again.
* Stop the instance a failed attempt left running before retrying, rather
than leaking it. This is needed even with correct selection: nothing else
knows those instances exist, since only the winner reaches the stop job.
Verified the selection logic against all five combinations, including the case
that motivated this: attempt 1 launches but never registers, attempt 2 succeeds
-- previously resolved to attempt 1's dead label, now resolves to attempt 2.
Not a regression from the reusable-workflow refactor; the inline version had the
same `||` chain. The refactor is why the fix is one file instead of nine.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-runnersweeps the availability zones inavailability-zones-configwithin 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.ymltests.yml,cu130.yml,cu132.yml,publish.ymlandnightly-publish.ymlnow 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-tagsfor the matrix fan-outs inpublish.ymlandnightly-publish.yml, which need a predictable label because they find the instance again by tag at teardown.stagger-seconds, replacing the hand-rolledRANDOM % 16sleep those same matrix jobs used to spread out runner registrations.stop-ec2-runner.ymltakes the instance id directly when the caller has one from a start job's output, and otherwise resolves it from theRunnerLabeltag — 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-tagsdefaults to'[]', not''. The pinned action does a bareJSON.parse()on that input (src/config.js) with no fallback, so an empty string throws and would break every caller that does not set tags. An emptylabel, by contrast, is explicitly fine — the action generates a unique one.||picks it.Testing
check_runner_token_policy.py(with CI: let the runner token be forwarded to a local reusable workflow #759 applied): OK on all 17 workflow files.actionlint1.7.7: clean.zizmor1.26.1--persona=regular: no findings.uses:(runs-on,steps,env,container, …).Because CI runs on
pull_request_targetfrom 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
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 || a3takes 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.