diff --git a/.github/workflows/benchmark-regressions.yml b/.github/workflows/benchmark-regressions.yml index c4008d2e..c15054b5 100644 --- a/.github/workflows/benchmark-regressions.yml +++ b/.github/workflows/benchmark-regressions.yml @@ -28,9 +28,14 @@ jobs: # label from the event is what lets one job be both without the # scheduled run reaching a paid runner. # A pull request from a fork cannot obtain an Ubicloud runner, so it - # falls back to GitHub's hosted pool. Estate rule, 2026-09-15. + # falls back to GitHub's hosted pool. Estate rule, 2026-09-15. So does a + # Dependabot pull request, which hosted runners serve at no charge on a + # public repository; the author is tested, not the actor. runs-on: >- - ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && 'ubicloud-standard-2' || 'ubuntu-latest' }} + ${{ github.event_name == 'pull_request' + && !github.event.pull_request.head.repo.fork + && github.event.pull_request.user.login != 'dependabot[bot]' + && 'ubicloud-standard-2' || 'ubuntu-latest' }} # Thirty-three seconds of work on GitHub's four cores. The ceiling is a # runaway guard, not a budget, and is set well clear of a cold cargo # build of chutoro-test-support on two. @@ -70,9 +75,14 @@ jobs: # Paid on the feedback path only, for the same reason as # benchmark-policy above: the weekly run of this matrix blocks nobody. # A pull request from a fork cannot obtain an Ubicloud runner, so it - # falls back to GitHub's hosted pool. Estate rule, 2026-09-15. + # falls back to GitHub's hosted pool. Estate rule, 2026-09-15. So does a + # Dependabot pull request, which hosted runners serve at no charge on a + # public repository; the author is tested, not the actor. runs-on: >- - ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && 'ubicloud-standard-2' || 'ubuntu-latest' }} + ${{ github.event_name == 'pull_request' + && !github.event.pull_request.head.repo.fork + && github.event.pull_request.user.login != 'dependabot[bot]' + && 'ubicloud-standard-2' || 'ubuntu-latest' }} timeout-minutes: 30 strategy: fail-fast: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caf9f484..9c3a2329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,13 @@ jobs: build-test: # A pull request from a fork cannot obtain an Ubicloud runner, so it # falls back to GitHub's hosted pool. Estate rule, 2026-09-15. + # Dependabot pull requests run on the hosted pool too (free on a public + # repository). The author is tested, not the actor, which changes when + # someone else pushes to the branch. runs-on: >- - ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubicloud-standard-4' }} + ${{ (github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') + && 'ubuntu-latest' || 'ubicloud-standard-4' }} # Above the 4,200 s cargo watchdog on the coverage step plus the 900 s # of work either side of it, and fifteen minutes above that sum rather # than equal to it. A ceiling sitting on its requirement cancels the @@ -224,7 +229,9 @@ jobs: # A pull request from a fork cannot obtain an Ubicloud runner, so it # falls back to GitHub's hosted pool. Estate rule, 2026-09-15. runs-on: >- - ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubicloud-standard-2' }} + ${{ (github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') + && 'ubuntu-latest' || 'ubicloud-standard-2' }} timeout-minutes: 30 permissions: contents: read diff --git a/.github/workflows/kani-pr.yml b/.github/workflows/kani-pr.yml index 92d6dab2..19de61e4 100644 --- a/.github/workflows/kani-pr.yml +++ b/.github/workflows/kani-pr.yml @@ -44,8 +44,13 @@ jobs: # that moves the critical path. # A pull request from a fork cannot obtain an Ubicloud runner, so it # falls back to GitHub's hosted pool. Estate rule, 2026-09-15. + # Dependabot pull requests run on the hosted pool too (free on a public + # repository). The author is tested, not the actor, which changes when + # someone else pushes to the branch. runs-on: >- - ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubicloud-standard-2' }} + ${{ (github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') + && 'ubuntu-latest' || 'ubicloud-standard-2' }} timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/.github/workflows/property-tests.yml b/.github/workflows/property-tests.yml index db8ce64b..0bf6c74d 100644 --- a/.github/workflows/property-tests.yml +++ b/.github/workflows/property-tests.yml @@ -32,8 +32,13 @@ jobs: # recorded in the developers guide. # A pull request from a fork cannot obtain an Ubicloud runner, so it # falls back to GitHub's hosted pool. Estate rule, 2026-09-15. + # Dependabot pull requests run on the hosted pool too (free on a public + # repository). The author is tested, not the actor, which changes when + # someone else pushes to the branch. runs-on: >- - ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubicloud-standard-2' }} + ${{ (github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') + && 'ubuntu-latest' || 'ubicloud-standard-2' }} # Keep this above the 600-second HNSW idempotency nextest timeout so setup # and the earlier property tests do not consume the whole job budget. timeout-minutes: 20 diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 0cf4c524..90222feb 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -27,22 +27,29 @@ No paid lane names its label outright, because a pull request from a fork cannot obtain an Ubicloud runner at all. Without a fallback such a job is not slow, it is unassignable: it sits forever and the pull request never reports. So every paid lane tests for the fork and falls back to GitHub's pool for that -case alone: +case. It falls back for a pull request Dependabot opened too: hosted minutes +cost nothing on a public repository, and Dependabot opens many pull requests, +which therefore build cold on the hosted pool. The test reads the pull +request's author, not `github.actor`, because the actor is whoever triggered +the run, so a person pushing to a Dependabot branch would flip the runner: ```yaml runs-on: >- - ${{ github.event.pull_request.head.repo.fork + ${{ (github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') && 'ubuntu-latest' || 'ubicloud-standard-2' }} ``` Two of the paid jobs also serve a weekly cron, and neither pays for it, so they carry both conditions at once. Written positively, the paid label is the one -taken when the lane is a pull request and its head is not a fork: +taken when the lane is a pull request, its head is not a fork and its author is +not Dependabot: ```yaml runs-on: >- ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork + && github.event.pull_request.user.login != 'dependabot[bot]' && 'ubicloud-standard-2' || 'ubuntu-latest' }} ``` @@ -77,9 +84,10 @@ test on a sibling field and report it as the guard it is not. That narrowness is contracted rather than asserted. The mutations run against it swap `head.repo.fork` for `head.repo.private` in an otherwise identical -expression, and drop the fork guard from a lane that keeps the event guard. -Both are rejected. A future selector on some other condition gets its own entry -in the guard table rather than a looser pattern here. +expression, drop the fork guard from a lane that keeps the event guard, and +drop the Dependabot author test or swap it for `github.actor`. Both are +rejected. A future selector on some other condition gets its own entry in the +guard table rather than a looser pattern here. It ran on `ubicloud-standard-8` until the shape was measured. On eight cores the whole "Run property suite" step, compilation and 250 cases together, took @@ -215,21 +223,21 @@ shows no figure there. Table: Every workflow job, the runner it uses, and what it does. -| Workflow | Job | Runner | Purpose | -| --------------------------- | ---------------------------- | --------------------------------------------------------------------------- | --------------------------------------------- | -| `benchmark-regressions.yml` | `benchmark-policy` | `ubicloud-standard-2` on a non-fork pull request, `ubuntu-latest` otherwise | Resolve the benchmark mode and matrix | -| `benchmark-regressions.yml` | `benchmark-smoke` | `ubicloud-standard-2` on a non-fork pull request, `ubuntu-latest` otherwise | Criterion discovery smoke check | -| `benchmark-regressions.yml` | `benchmark-baseline-compare` | `ubuntu-latest` | Compare against the previous commit | -| `ci.yml` | `build-test` | `ubicloud-standard-4`, `ubuntu-latest` for a fork | Format, lint, spelling, contracts, coverage | -| `ci.yml` | `verus-proofs` | `ubicloud-standard-2`, `ubuntu-latest` for a fork | Verus edge-harvest proofs | -| `coverage-main.yml` | `coverage-upload` | `ubicloud-standard-4` | Upload trunk coverage and advance the ratchet | -| `dependabot-automerge.yml` | `automerge` | callee-selected | Reusable workflow, API-bound | -| `kani-pr.yml` | `kani` | `ubicloud-standard-2`, `ubuntu-latest` for a fork | Kani practical harnesses | -| `mutation-testing.yml` | `mutation` | callee-selected | Reusable workflow, scheduled | -| `nightly-kani.yml` | `kani-full` | `ubuntu-latest` | Full Kani harness suite | -| `nightly-portable-simd.yml` | `nightly-portable-simd` | `ubuntu-latest` | Nightly portable-SIMD backend | -| `property-tests.yml` | `property-tests-pr` | `ubicloud-standard-2`, `ubuntu-latest` for a fork | Pull-request property suites | -| `property-tests.yml` | `property-tests-weekly` | `ubuntu-latest` | Weekly deep property suites | +| Workflow | Job | Runner | Purpose | +| --------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------- | +| `benchmark-regressions.yml` | `benchmark-policy` | `ubicloud-standard-2` on a non-fork, non-Dependabot pull request, `ubuntu-latest` otherwise | Resolve the benchmark mode and matrix | +| `benchmark-regressions.yml` | `benchmark-smoke` | `ubicloud-standard-2` on a non-fork, non-Dependabot pull request, `ubuntu-latest` otherwise | Criterion discovery smoke check | +| `benchmark-regressions.yml` | `benchmark-baseline-compare` | `ubuntu-latest` | Compare against the previous commit | +| `ci.yml` | `build-test` | `ubicloud-standard-4`, `ubuntu-latest` for a fork or Dependabot | Format, lint, spelling, contracts, coverage | +| `ci.yml` | `verus-proofs` | `ubicloud-standard-2`, `ubuntu-latest` for a fork or Dependabot | Verus edge-harvest proofs | +| `coverage-main.yml` | `coverage-upload` | `ubicloud-standard-4` | Upload trunk coverage and advance the ratchet | +| `dependabot-automerge.yml` | `automerge` | callee-selected | Reusable workflow, API-bound | +| `kani-pr.yml` | `kani` | `ubicloud-standard-2`, `ubuntu-latest` for a fork or Dependabot | Kani practical harnesses | +| `mutation-testing.yml` | `mutation` | callee-selected | Reusable workflow, scheduled | +| `nightly-kani.yml` | `kani-full` | `ubuntu-latest` | Full Kani harness suite | +| `nightly-portable-simd.yml` | `nightly-portable-simd` | `ubuntu-latest` | Nightly portable-SIMD backend | +| `property-tests.yml` | `property-tests-pr` | `ubicloud-standard-2`, `ubuntu-latest` for a fork or Dependabot | Pull-request property suites | +| `property-tests.yml` | `property-tests-weekly` | `ubuntu-latest` | Weekly deep property suites | ### Tool installers diff --git a/tests/workflow_contracts/runner_placement_test.py b/tests/workflow_contracts/runner_placement_test.py index 90b5a745..376dd379 100644 --- a/tests/workflow_contracts/runner_placement_test.py +++ b/tests/workflow_contracts/runner_placement_test.py @@ -27,6 +27,7 @@ from workflow_support import ( ACTIONLINT_CONFIG, GITHUB_HOSTED_LABELS, + NON_DEPENDABOT_GUARD, NON_FORK_GUARD, PULL_REQUEST_EVENT_GUARD, conditional_runner, @@ -90,7 +91,8 @@ def _paid_labels(job_definition: dict[str, typ.Any]) -> list[str]: [] >>> _paid_labels( ... { - ... "runs-on": "${{ github.event.pull_request.head.repo.fork" + ... "runs-on": "${{ (github.event.pull_request.head.repo.fork" + ... " || github.event.pull_request.user.login == 'dependabot[bot]')" ... " && 'ubuntu-latest' || 'ubicloud-standard-2' }}" ... } ... ) @@ -176,7 +178,7 @@ def test_paid_pull_request_lanes_fall_back_for_forks(workflow_name: str) -> None Without the fallback the lane does not run slowly, it does not run: the job sits unassignable and the pull request never reports. So every paid lane reachable from a ``pull_request`` event has to name the fork test - and fall back to GitHub's pool, and the fallback label is checked too, + and the Dependabot author test, and fall back to GitHub's pool, and the fallback label is checked too, because falling back to another paid label would fix nothing. """ workflow = load_workflow(workflow_name) @@ -195,6 +197,10 @@ def test_paid_pull_request_lanes_fall_back_for_forks(workflow_name: str) -> None f"{workflow_name}:{job_name} chooses a runner without the " f"{NON_FORK_GUARD} guard; found {sorted(selected.guards)}" ) + assert NON_DEPENDABOT_GUARD in selected.guards, ( + f"{workflow_name}:{job_name} chooses a runner without the " + f"{NON_DEPENDABOT_GUARD} guard; found {sorted(selected.guards)}" + ) assert selected.otherwise in GITHUB_HOSTED_LABELS, ( f"{workflow_name}:{job_name} falls back to {selected.otherwise}, " "which a fork cannot obtain either" diff --git a/tests/workflow_contracts/workflow_support.py b/tests/workflow_contracts/workflow_support.py index b8054932..175c7e29 100644 --- a/tests/workflow_contracts/workflow_support.py +++ b/tests/workflow_contracts/workflow_support.py @@ -200,6 +200,11 @@ def runner_labels(job_definition: dict[str, typ.Any]) -> list[str]: #: cannot obtain one at all, so the lane would not start without it. NON_FORK_GUARD = "non-fork head" +#: The guard that keeps a Dependabot pull request off a paid runner. Hosted +#: minutes cost nothing on a public repository, and Dependabot opens many +#: pull requests. +NON_DEPENDABOT_GUARD = "non-Dependabot author" + #: The whole of a conditional ``runs-on``: one condition, then the label #: taken when it holds and the label taken when it does not. Parsing the #: outer shape and the condition separately is what lets the contracts ask @@ -213,12 +218,22 @@ def runner_labels(job_definition: dict[str, typ.Any]) -> list[str]: #: The fork test, written the one way the estate writes it. _FORK_TEST = "github.event.pull_request.head.repo.fork" +#: The Dependabot test: the pull request's author, not ``github.actor``. The +#: actor is whoever triggered the run, so a person pushing to a Dependabot +#: branch would flip the runner. +_DEPENDABOT_TEST = "github.event.pull_request.user.login == 'dependabot[bot]'" +_NOT_DEPENDABOT_TEST = "github.event.pull_request.user.login != 'dependabot[bot]'" + +#: The condition a lane that only ever runs on pull requests carries. +_HOSTED_TEST = f"({_FORK_TEST} || {_DEPENDABOT_TEST})" + #: Each condition atom the contracts recognize, and the guard it supplies. #: Exact strings, not patterns: a looser match would accept a condition #: that tests a sibling field and report it as the guard it is not. _GUARD_ATOMS: typ.Final[dict[str, str]] = { "github.event_name == 'pull_request'": PULL_REQUEST_EVENT_GUARD, f"!{_FORK_TEST}": NON_FORK_GUARD, + _NOT_DEPENDABOT_TEST: NON_DEPENDABOT_GUARD, } @@ -239,14 +254,34 @@ def _parse_guards(condition: str) -> frozenset[str] | None: return frozenset(typ.cast("set[str]", guards)) +def _runner_from_arms( + condition: str, when_true: str, when_false: str +) -> ConditionalRunner | None: + """Return the runner a recognized condition and its two arms describe. + + The fork-or-Dependabot condition puts the hosted label first, so the paid + label is the falsy arm; the positive event conditions put it first. + """ + if condition == _HOSTED_TEST: + return ConditionalRunner( + when_false, + when_true, + frozenset({NON_FORK_GUARD, NON_DEPENDABOT_GUARD}), + ) + guards = _parse_guards(condition) + if guards is None: + return None + return ConditionalRunner(when_true, when_false, guards) + + def conditional_runner( job_definition: dict[str, typ.Any], ) -> ConditionalRunner | None: """Return the labels and guards of a conditional ``runs-on``. Two shapes are recognized, and only two. A lane that runs only on pull - requests names the fork test first, because the fork is the case that - must fall back; a lane that also serves a schedule names the positive + requests names the fork and Dependabot tests first, because those are the + cases that must fall back; a lane that also serves a schedule names the positive conditions first and joins them with ``&&``. Both are normalized here to the label a non-fork pull request takes, the label everything else takes, and the guards the condition actually supplies, so a contract @@ -271,28 +306,32 @@ def conditional_runner( Examples -------- - A lane that only ever runs on a pull request guards the fork alone: + A lane that only ever runs on a pull request guards the fork and the + Dependabot author: - >>> conditional_runner( + >>> both = conditional_runner( ... { - ... "runs-on": "${{ github.event.pull_request.head.repo.fork" + ... "runs-on": "${{ (github.event.pull_request.head.repo.fork" + ... " || github.event.pull_request.user.login == 'dependabot[bot]')" ... " && 'ubuntu-latest' || 'ubicloud-standard-2' }}" ... } ... ) - ConditionalRunner(paid='ubicloud-standard-2', otherwise='ubuntu-latest', guards=frozenset({'non-fork head'})) + >>> both.paid, sorted(both.guards) + ('ubicloud-standard-2', ['non-Dependabot author', 'non-fork head']) A lane that also serves a cron guards both, and the guards are what a contract reads: - >>> both = conditional_runner( + >>> cron = conditional_runner( ... { ... "runs-on": "${{ github.event_name == 'pull_request'" ... " && !github.event.pull_request.head.repo.fork" + ... " && github.event.pull_request.user.login != 'dependabot[bot]'" ... " && 'ubicloud-standard-2' || 'ubuntu-latest' }}" ... } ... ) - >>> both.paid, sorted(both.guards) - ('ubicloud-standard-2', ['non-fork head', 'pull-request event']) + >>> cron.paid, sorted(cron.guards) + ('ubicloud-standard-2', ['non-Dependabot author', 'non-fork head', 'pull-request event']) A plain label, or a condition on some other field, is not one of these: @@ -305,9 +344,10 @@ def conditional_runner( because a docstring holding the escape would end this example's line: >>> broken = ( - ... "${{ github.event.pull_request.head.repo.fork" + ... "${{ (github.event.pull_request.head.repo.fork" ... + chr(10) - ... + "&& 'ubuntu-latest' || 'ubicloud-standard-2' }}" + ... + "|| github.event.pull_request.user.login == 'dependabot[bot]')" + ... + " && 'ubuntu-latest' || 'ubicloud-standard-2' }}" ... ) >>> conditional_runner({"runs-on": broken}) is None True @@ -324,15 +364,9 @@ def conditional_runner( match = CONDITIONAL_RUNS_ON.match(runs_on.strip()) if match is None: return None - condition = match.group("condition") - when_true = match.group("when_true") - when_false = match.group("when_false") - if condition == _FORK_TEST: - return ConditionalRunner(when_false, when_true, frozenset({NON_FORK_GUARD})) - guards = _parse_guards(condition) - if guards is None: - return None - return ConditionalRunner(when_true, when_false, guards) + return _runner_from_arms( + match.group("condition"), match.group("when_true"), match.group("when_false") + ) def is_reusable_call(job_definition: dict[str, typ.Any]) -> bool: