Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/build-wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 25 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions cuprum/unittests/test_benchmark_gate_ci_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}"
)

Expand Down
12 changes: 6 additions & 6 deletions docs/ci-cache-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/behaviour/test_ci_runner_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
15 changes: 11 additions & 4 deletions tests/features/ci_runner_selection.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
18 changes: 14 additions & 4 deletions tests/helpers/ci_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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),)


Expand Down
50 changes: 41 additions & 9 deletions tests/helpers/ci_placement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +89,15 @@
r"\s*&&\s*'(?P<fork_arm>[^']+)'"
r"\s*\|\|\s*'(?P<owned_arm>[^']+)'\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<fork_ref>" + re.escape(FORK_FIELD) + r")"
r"\s*\|\|\s*(?P<author_ref>" + re.escape(AUTHOR_FIELD) + r")"
r"\s*==\s*'dependabot\[bot\]'\s*\)"
r"\s*&&\s*'(?P<fork_arm>[^']+)'"
r"\s*\|\|\s*'(?P<owned_arm>[^']+)'\s*\Z"
)
_MATRIX_EXPRESSION = re.compile(r"\A\s*matrix\.(?P<key>[\w-]+)\s*\Z")


Expand Down Expand Up @@ -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")}),
Comment thread
leynos marked this conversation as resolved.
)
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.

Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers/ci_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions tests/test_ci_compiler_cache_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import pytest

from tests.helpers.ci_runners import (
AUTHOR_FIELD,
CACHE_RESTORE,
CREDENTIALS_ACTION,
CREDENTIALS_STEP,
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading