diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index c7ba9a324..3d24fa06e 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -166,8 +166,12 @@ jobs: # and on a fork's pull request the hosted arm is, because a fork cannot # obtain an Ubicloud runner. Keep the continuation at this indent; a # more-indented one would put a newline inside the expression. + # 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 + ${{ (github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') && 'ubuntu-latest' || 'ubicloud-standard-2' }} timeout-minutes: 20 needs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2045a6a47..a4bd0e619 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,8 +69,12 @@ jobs: # scalar keeps its line break and puts a newline inside the expression. # GitHub evaluates the broken value regardless, so a green run is not # evidence; `test_placement_expressions_parse_to_one_line` is. + # 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 + ${{ (github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') && 'ubuntu-latest' || 'ubicloud-standard-2' }} # Compile-bound on two toolchains plus a Windows cross-target check, # measured at 198 to 292 s over six runs on four GitHub-hosted vCPU. @@ -479,7 +483,8 @@ jobs: # GitHub evaluates the broken value regardless, so a green run is not # evidence; `test_placement_expressions_parse_to_one_line` is. 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' }} timeout-minutes: 45 env: @@ -762,7 +767,8 @@ jobs: # GitHub evaluates the broken value regardless, so a green run is not # evidence; `test_placement_expressions_parse_to_one_line` is. 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' }} timeout-minutes: 45 steps: @@ -1060,7 +1066,8 @@ jobs: # GitHub evaluates the broken value regardless, so a green run is not # evidence; `test_placement_expressions_parse_to_one_line` is. 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' }} # Above the 3,300 s cargo watchdog set below plus the work either side # of the coverage step, which reached 43 s on run 34071469378. Sixty @@ -1180,10 +1187,12 @@ jobs: # line's indentation, so the value is the single reference it # reads as, not two joined by a space. # - # A fork's pull request runs this job on the GitHub-hosted fork arm, - # where there is no proxy and the action fails closed, so the step - # runs on the owned arm only. - if: ${{ !github.event.pull_request.head.repo.fork }} + # A fork's or Dependabot's pull request runs this job on the + # GitHub-hosted fork arm, where there is no proxy and the action fails + # closed, so the step runs on the owned arm only. + if: >- + ${{ !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') }} uses: "leynos/shared-actions/.github/actions/\ export-ubicloud-cache-credentials@dbe2e22ceaf498d85512679ccded38be9dbe7777" @@ -1197,7 +1206,10 @@ jobs: # The fork arm has no proxy and no credentials, so it takes the # directory backend and compiles cold, as every fork arm does. The # arms are read by position, like the runner's. - backend: ${{ github.event.pull_request.head.repo.fork && 'local' || 'gha' }} + backend: >- + ${{ (github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]') + && 'local' || 'gha' }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -1356,7 +1368,8 @@ jobs: # more-indented continuation in a folded scalar keeps its line break and # puts a newline inside the expression. 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' }} timeout-minutes: 10 permissions: @@ -1661,7 +1674,8 @@ jobs: # GitHub evaluates the broken value regardless, so a green run is not # evidence; `test_placement_expressions_parse_to_one_line` is. 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' }} timeout-minutes: 60 needs: diff --git a/cuprum/unittests/test_benchmark_gate_ci_contract.py b/cuprum/unittests/test_benchmark_gate_ci_contract.py index 4acb5dacb..39b4c7721 100644 --- a/cuprum/unittests/test_benchmark_gate_ci_contract.py +++ b/cuprum/unittests/test_benchmark_gate_ci_contract.py @@ -156,11 +156,12 @@ def test_the_changes_job_runs_on_the_lane_it_gates( minutes cost none. That saved seconds and cost a pull request 46 minutes on 2026-09-23 (run 35904789287), when the hosted queue held it while every Ubicloud job started. A fork's pull request takes the hosted arm, because - a fork cannot obtain an Ubicloud runner. + a fork cannot obtain an Ubicloud runner, and so does Dependabot's. """ runner = " ".join(str(job(workflow_data, CHANGES_JOB).get("runs-on")).split()) expected = ( - "${{ 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' }}" ) diff --git a/docs/ci-cache-ownership.md b/docs/ci-cache-ownership.md index 53535fccc..25774e220 100644 --- a/docs/ci-cache-ownership.md +++ b/docs/ci-cache-ownership.md @@ -175,12 +175,12 @@ from the Ubicloud console on 2026-09-03, and for a GitHub-hosted lane it still holds. The Ubicloud lanes other than coverage archive their directories through the key families above; moving any of them to the proxy is a separate change. -A fork's pull request runs `coverage` on the GitHub-hosted fork arm, where -there is no proxy and the credentials action fails closed. That arm skips the -credentials step and passes `backend: local`, so it compiles cold into a -directory nothing saves, as every fork arm already does. Both halves are read -by position from the same fork expression the runner uses, and a contract -asserts each. +A fork's or Dependabot's pull request runs `coverage` on the GitHub-hosted fork +arm, where there is no proxy and the credentials action fails closed. That arm +skips the credentials step and passes `backend: local`, so it compiles cold +into a directory nothing saves, as every fork arm already does. Both halves are +read by position from the same fork-or-Dependabot condition the runner uses, +and a contract asserts each. The interpreter matrix has no 3.13 leg. The coverage job runs that interpreter's suite and `extension-tests` runs its typechecker, so a leg would diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 0166334a5..5f882a57c 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -181,14 +181,25 @@ change. ### The fork arm A pull request from a fork cannot obtain an Ubicloud runner, so every -fork-reachable lane selects its runner by event: +fork-reachable lane selects its runner by event. A pull request Dependabot +opened takes the same hosted arm: hosted minutes cost nothing on a public +repository, and Dependabot opens many pull requests, which therefore build +cold. The expression tests 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' }} ``` +The `coverage` lane's credentials-step guard and its sccache `backend` use the +same condition, so a Dependabot pull request on the hosted arm skips the +credentials action (which fails closed without a proxy) and compiles cold into +a directory nothing saves. + Keep the continuation at the same indent. A continuation indented one level deeper keeps its line break inside the folded scalar and puts a newline in the middle of the expression; GitHub evaluates the broken value anyway, so a green diff --git a/tests/behaviour/test_ci_runner_selection.py b/tests/behaviour/test_ci_runner_selection.py index e5a06c7fb..a565b9f0e 100644 --- a/tests/behaviour/test_ci_runner_selection.py +++ b/tests/behaviour/test_ci_runner_selection.py @@ -21,6 +21,7 @@ from pytest_bdd import given, parsers, scenario, then, when from tests.helpers.ci_events import ( + DEPENDABOT_PULL_REQUEST, FORK_PULL_REQUEST, OWNED_PULL_REQUEST, PUSH_TO_MAIN, @@ -58,6 +59,7 @@ PULL_REQUEST_ONLY_LANE: typ.Final = ("ci.yml", "coverage") EVENTS: typ.Final = { "fork": FORK_PULL_REQUEST, + "dependabot": DEPENDABOT_PULL_REQUEST, "owned": OWNED_PULL_REQUEST, "push": PUSH_TO_MAIN, } @@ -97,6 +99,14 @@ def _fork_pull_request(selection: dict[str, object]) -> None: ) +@when("a pull request is opened by Dependabot from a branch of this repository") +def _dependabot_pull_request(selection: dict[str, object]) -> None: + """Resolve the schedule for a pull request Dependabot opened.""" + selection["scheduled"] = schedule( + typ.cast("str", selection["workflow"]), DEPENDABOT_PULL_REQUEST + ) + + @when("a commit is pushed to the default branch") def _push_to_main(selection: dict[str, object]) -> None: """Resolve the schedule for a push to the default branch.""" @@ -276,6 +286,11 @@ def test_a_fork_pull_request_is_schedulable() -> None: """A fork's pull request never waits on a runner it cannot be given.""" +@scenario(FEATURE, "Dependabot's pull request selects only hosted runners") +def test_a_dependabot_pull_request_takes_the_hosted_arm() -> None: + """Run the Dependabot pull-request scenario.""" + + @scenario(FEATURE, "a push to the default branch takes the owned arm") def test_a_push_takes_the_owned_arm() -> None: """With no pull-request payload the fork field is absent, so falsy.""" diff --git a/tests/features/ci_runner_selection.feature b/tests/features/ci_runner_selection.feature index 144112910..c6ee683aa 100644 --- a/tests/features/ci_runner_selection.feature +++ b/tests/features/ci_runner_selection.feature @@ -20,6 +20,12 @@ Feature: CI runner selection by event Then no lane selects a paid runner And every fork-reachable lane selects the hosted fallback + Scenario: Dependabot's pull request selects only hosted runners + Given the continuous integration workflow + When a pull request is opened by Dependabot from a branch of this repository + Then no lane selects a paid runner + And every fork-reachable lane selects the hosted fallback + Scenario: a push to the default branch takes the owned arm Given the continuous integration workflow When a commit is pushed to the default branch @@ -47,7 +53,8 @@ Feature: CI runner selection by event Then every selected label is a concrete runner name Examples: - | event | - | fork | - | owned | - | push | + | event | + | fork | + | dependabot | + | owned | + | push | diff --git a/tests/helpers/ci_events.py b/tests/helpers/ci_events.py index 2d35a809e..858af8f5e 100644 --- a/tests/helpers/ci_events.py +++ b/tests/helpers/ci_events.py @@ -38,6 +38,15 @@ "fork": False, "ref": "refs/pull/1/merge", } +#: A pull request Dependabot opened from a branch of this repository. The fork +#: field is false and the author login names the bot, which is what takes the +#: hosted arm. +DEPENDABOT_PULL_REQUEST: typ.Final = { + "event_name": "pull_request", + "fork": False, + "author": "dependabot[bot]", + "ref": "refs/pull/1/merge", +} #: A push to the default branch. There is no pull-request payload, so the fork #: field is absent, which GitHub evaluates as falsy. PUSH_TO_MAIN: typ.Final = { @@ -135,10 +144,11 @@ def _select( return tuple(sorted(placed.labels)) if placed.kind == "literal": return (typ.cast("str", placed.owned),) - # The fork fallback. GitHub evaluates the condition against the event - # payload: absent on a push, false on an owned pull request, true on a - # fork's. Only a truthy value takes the hosted arm. - takes_fork_arm = bool(event.get("fork")) + # The hosted arm. GitHub evaluates the condition against the event + # payload: the fork field is absent on a push, false on an owned pull + # request and true on a fork's, and the author login is that of the pull + # request's opener. A fork, or the Dependabot author, takes the hosted arm. + takes_fork_arm = bool(event.get("fork")) or event.get("author") == "dependabot[bot]" return (typ.cast("str", placed.fork if takes_fork_arm else placed.owned),) diff --git a/tests/helpers/ci_placement.py b/tests/helpers/ci_placement.py index c7ad20568..f3cde1dbb 100644 --- a/tests/helpers/ci_placement.py +++ b/tests/helpers/ci_placement.py @@ -47,6 +47,12 @@ #: a conditional accepts it. FORK_FIELD = "github.event.pull_request.head.repo.fork" +#: The pull request's author. Dependabot's pull requests take the fork arm too, +#: because hosted minutes cost nothing on a public repository. The author is +#: read, not ``github.actor``: the actor is whoever triggered the run, so a +#: person pushing to a Dependabot branch would flip the runner. +AUTHOR_FIELD = "github.event.pull_request.user.login" + #: GitHub-hosted labels this repository is permitted to use. Named rather than #: derived from a vendor prefix: a ``ubicloud-`` prefix filter would silently #: exempt a second paid provider's labels from the registry question, which is @@ -83,6 +89,15 @@ r"\s*&&\s*'(?P[^']+)'" r"\s*\|\|\s*'(?P[^']+)'\s*\Z" ) +#: The estate's hosted-arm expression: the fork test or the Dependabot author +#: test, read by position like the fork-only form. The author literal is exact. +_HOSTED_EXPRESSION = re.compile( + r"\A\s*\(\s*(?P" + re.escape(FORK_FIELD) + r")" + r"\s*\|\|\s*(?P" + re.escape(AUTHOR_FIELD) + r")" + r"\s*==\s*'dependabot\[bot\]'\s*\)" + r"\s*&&\s*'(?P[^']+)'" + r"\s*\|\|\s*'(?P[^']+)'\s*\Z" +) _MATRIX_EXPRESSION = re.compile(r"\A\s*matrix\.(?P[\w-]+)\s*\Z") @@ -232,6 +247,29 @@ def _literal_labels( return frozenset(labels) +def _conditional_placement(body: str) -> Placement | None: + """Read a fork-or-Dependabot or a fork-only conditional, else ``None``.""" + hosted = _HOSTED_EXPRESSION.match(body) + if hosted is not None: + return Placement( + "fork", + hosted.group("owned_arm"), + hosted.group("fork_arm"), + frozenset({hosted.group("owned_arm"), hosted.group("fork_arm")}), + frozenset({hosted.group("fork_ref"), hosted.group("author_ref")}), + ) + fork = _FORKEXPRESSION.match(body) + if fork is None: + return None + return Placement( + "fork", + fork.group("owned_arm"), + fork.group("fork_arm"), + frozenset({fork.group("owned_arm"), fork.group("fork_arm")}), + frozenset({fork.group("condition")}), + ) + + def placement(workflow_name: str, job_name: str) -> Placement: """Model one job's runner selection, refusing any shape it cannot read. @@ -291,15 +329,9 @@ def placement(workflow_name: str, job_name: str) -> Placement: ) return Placement("literal", text, None, frozenset({text}), frozenset()) body = match.group("body") - fork = _FORKEXPRESSION.match(body) - if fork is not None: - return Placement( - "fork", - fork.group("owned_arm"), - fork.group("fork_arm"), - frozenset({fork.group("owned_arm"), fork.group("fork_arm")}), - frozenset({fork.group("condition")}), - ) + conditional = _conditional_placement(body) + if conditional is not None: + return conditional matrix = _MATRIX_EXPRESSION.match(body) if matrix is not None: key = matrix.group("key") diff --git a/tests/helpers/ci_runners.py b/tests/helpers/ci_runners.py index d10fe305f..45262337d 100644 --- a/tests/helpers/ci_runners.py +++ b/tests/helpers/ci_runners.py @@ -15,6 +15,7 @@ from tests.helpers.ci_job_rules import ceiling, never_runs, references from tests.helpers.ci_placement import ( + AUTHOR_FIELD, FORK_FIELD, FROZEN_HOSTED_LABELS, Placement, @@ -54,6 +55,7 @@ # workflows are meant to contain, `ci_workflows` reads what they do contain. # fmt: off __all__ = ( + "AUTHOR_FIELD", "CACHE_ACTION_PIN", "CACHE_PLAIN", "CACHE_RESTORE", diff --git a/tests/test_ci_compiler_cache_backend.py b/tests/test_ci_compiler_cache_backend.py index 609f201d2..b08778f24 100644 --- a/tests/test_ci_compiler_cache_backend.py +++ b/tests/test_ci_compiler_cache_backend.py @@ -24,6 +24,7 @@ import pytest from tests.helpers.ci_runners import ( + AUTHOR_FIELD, CACHE_RESTORE, CREDENTIALS_ACTION, CREDENTIALS_STEP, @@ -55,11 +56,12 @@ #: fork's pull request runs on the GitHub-hosted fork arm, which has no proxy, #: so that arm takes the directory backend; the arms are read by position, as #: the runner's are, so swapping them fails. -FORK_ARM_BACKEND: typ.Final = f"${{{{ {FORK_FIELD} && 'local' || 'gha' }}}}" +HOSTED_CONDITION: typ.Final = f"({FORK_FIELD} || {AUTHOR_FIELD} == 'dependabot[bot]')" +FORK_ARM_BACKEND: typ.Final = f"${{{{ {HOSTED_CONDITION} && 'local' || 'gha' }}}}" #: The guard that keeps the credentials step off the fork arm, where the action #: fails closed because there is no proxy to export. -OWNED_ARM_ONLY: typ.Final = f"${{{{ !{FORK_FIELD} }}}}" +OWNED_ARM_ONLY: typ.Final = f"${{{{ !{HOSTED_CONDITION} }}}}" def _normalized(value: object) -> str: diff --git a/tests/test_ci_harness_boundary.py b/tests/test_ci_harness_boundary.py index c280cbfad..414c0c066 100644 --- a/tests/test_ci_harness_boundary.py +++ b/tests/test_ci_harness_boundary.py @@ -50,9 +50,10 @@ def test_projection_preserves_the_detector_and_admission_contract( # its expression can select; an unmapped label makes act skip the job and # exit zero. declared = str(job(projected, "changes")["runs-on"]) - selectable = ( - set(re.findall(r"'([^']+)'", declared)) if "${{" in declared else {declared} - ) + # The author test compares against the literal `dependabot[bot]`, an + # operand and not a runner label, so it is not one of the selectable arms. + quoted = set(re.findall(r"'([^']+)'", declared)) - {"dependabot[bot]"} + selectable = quoted if "${{" in declared else {declared} assert selectable, "the changes job must name the labels it can select" assert selectable <= set(MAPPED_RUNNER_LABELS), ( f"act must map every label changes can select; unmapped: " diff --git a/tests/test_ci_placement_reader.py b/tests/test_ci_placement_reader.py index b44e87028..1ac19c96b 100644 --- a/tests/test_ci_placement_reader.py +++ b/tests/test_ci_placement_reader.py @@ -22,6 +22,10 @@ OWNED = "ubicloud-standard-2" HOSTED = "ubuntu-latest" FORK_EXPRESSION = f"${{{{ {reader.FORK_FIELD} && '{HOSTED}' || '{OWNED}' }}}}" +HOSTED_EXPRESSION = ( + f"${{{{ ({reader.FORK_FIELD} || {reader.AUTHOR_FIELD} == 'dependabot[bot]') " + f"&& '{HOSTED}' || '{OWNED}' }}}}" +) def _declare(monkeypatch: pytest.MonkeyPatch, declared: dict[str, object]) -> None: @@ -101,6 +105,44 @@ def test_a_sibling_field_is_reported_as_a_different_condition( ) +def test_the_hosted_expression_reads_the_fork_and_author_tests( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Read the combined fork and Dependabot condition by position.""" + _declare(monkeypatch, {"runs-on": HOSTED_EXPRESSION, "steps": []}) + placed = reader.placement("w.yml", "j") + assert placed.kind == "fork", f"expected a fork placement, got {placed.kind}" + assert placed.fork == HOSTED, f"hosted arm was {placed.fork!r}" + assert placed.owned == OWNED, f"owned arm was {placed.owned!r}" + assert placed.references == frozenset({reader.FORK_FIELD, reader.AUTHOR_FIELD}), ( + f"references were {sorted(placed.references)}" + ) + + +@pytest.mark.parametrize( + "expression", + [ + HOSTED_EXPRESSION.replace(reader.AUTHOR_FIELD, "github.actor"), + HOSTED_EXPRESSION + .replace(reader.FORK_FIELD, "TMP") + .replace(reader.AUTHOR_FIELD, reader.FORK_FIELD) + .replace("TMP", reader.AUTHOR_FIELD), + ], + ids=["actor-for-author", "fields-swapped"], +) +def test_only_the_exact_hosted_condition_is_modelled( + monkeypatch: pytest.MonkeyPatch, expression: str +) -> None: + """Refuse `github.actor` and a swapped pair of fields. + + The roles of the two references matter: with the fields swapped every + pull request has a truthy author login and would take the hosted arm. + """ + _declare(monkeypatch, {"runs-on": expression, "steps": []}) + with pytest.raises(AssertionError, match="cannot model"): + reader.placement("w.yml", "j") + + def test_a_hyphenated_condition_is_read(monkeypatch: pytest.MonkeyPatch) -> None: """Read a condition whose property path carries a hyphen. diff --git a/tests/test_ci_runner_placement.py b/tests/test_ci_runner_placement.py index 82c76bae7..498e8afe9 100644 --- a/tests/test_ci_runner_placement.py +++ b/tests/test_ci_runner_placement.py @@ -17,6 +17,7 @@ import pytest from tests.helpers.ci_runners import ( + AUTHOR_FIELD, CONTINUE_ON_ERROR_JOBS, EXPERIMENTAL_LEG_KEY, FORK_FIELD, @@ -80,8 +81,9 @@ def test_fork_reachable_lanes_fall_back_by_position( f"{workflow_name}:{job_name} must select its runner with the fork " f"fallback expression, got a {placed.kind} placement" ) - assert placed.references == frozenset({FORK_FIELD}), ( - f"{workflow_name}:{job_name} must branch on {FORK_FIELD}, " + assert placed.references == frozenset({FORK_FIELD, AUTHOR_FIELD}), ( + f"{workflow_name}:{job_name} must branch on {FORK_FIELD} and " + f"{AUTHOR_FIELD}, " f"got {sorted(placed.references)}" ) assert placed.fork == GITHUB_LABEL, (