Skip to content

CI: provision EC2 runners through shared workflows, with retry and backoff - #760

Merged
harrism merged 4 commits into
openvdb:mainfrom
harrism:mjh/ci-runner-reusable-workflows
Sep 3, 2026
Merged

CI: provision EC2 runners through shared workflows, with retry and backoff#760
harrism merged 4 commits into
openvdb:mainfrom
harrism:mjh/ci-runner-reusable-workflows

Conversation

@harrism

@harrism harrism commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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) 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 CI: retry EC2 runner start with backoff on capacity failures #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 CI: let the runner token be forwarded to a local reusable workflow #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

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.

harrism and others added 2 commits September 3, 2026 02:06
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>
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
harrism marked this pull request as ready for review September 3, 2026 03:33
@harrism
harrism requested a review from a team as a code owner September 3, 2026 03:33
harrism and others added 2 commits September 3, 2026 03:34
…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>
@harrism
harrism merged commit 5421bff into openvdb:main Sep 3, 2026
40 checks passed
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