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
69 changes: 46 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,24 @@ jobs:
contents: read
pull-requests: read
outputs:
# On merge_group — and on the `schedule` / `workflow_dispatch` full runs
# (#16467) — everything counts as changed: dorny/paths-filter resolves a
# diff only on `pull_request` and `push`, and each of these three events
# is a place a skipped job can never be the right answer (the queue build
# is the last validation before main; the hourly run IS the full battery).
# A skipped step's output is the empty string (falsy), so `|| 'true'`
# supplies their value without touching PR/push behavior.
docs: ${{ steps.changes.outputs.docs || 'true' }}
core: ${{ steps.changes.outputs.core || 'true' }}
# On the `schedule` / `workflow_dispatch` full runs (#16467) everything
# counts as changed: dorny/paths-filter is skipped there (no diff to
# resolve), a skipped step's output is the empty string (falsy), and
# `|| 'true'` supplies the value without touching PR/push behavior.
#
# On merge_group the step RUNS (the diff is the queue base_sha against
# the group head, see the step below), but only the three ui outputs
# read it. `docs`, `core` and `crosspkg` stay 'true' there through the
# `github.event_name != 'merge_group' &&` guard: the queue build is the
# last validation before main, and those jobs run on every entry, as
# they did before the step ran on this event. On any other event the
# guard is true and the expression reads exactly as `<output> ||
# 'true'` did.
docs: ${{ github.event_name != 'merge_group' && steps.changes.outputs.docs || 'true' }}
core: ${{ github.event_name != 'merge_group' && steps.changes.outputs.core || 'true' }}
# Gates the `test` job ONLY, ORed with `core` — see the `crosspkg`
# filter below and the `test` job's `if:` (#9829, #10015).
crosspkg: ${{ steps.changes.outputs.crosspkg || 'true' }}
crosspkg: ${{ github.event_name != 'merge_group' && steps.changes.outputs.crosspkg || 'true' }}
# The objectui tree under `ui/` (objectstack#25677). Three outputs, read
# together by the four `* UI` jobs at the bottom of this file:
# ui — a path under ui/, or a root file the ui jobs build
Expand All @@ -158,13 +164,21 @@ jobs:
# UI) and built (Build & E2E UI) against the new shape in
# the PR that moves it;
# ui_tree — `ui/package.json` exists in this checkout.
# On merge_group `ui` and `ui_consumers` come from the queue diff
# (objectstack#25867): an entry whose merged diff against
# `merge_group.base_sha` touches no ui path and none of the seven
# packages skips the four ui jobs, exactly as the same diff does on its
# PR head; an entry that touches one runs them. If the step fails (the
# base cannot be fetched, the diff errors), it is `continue-on-error`
# there, its outputs are empty and `|| 'true'` runs every ui job.
#
# `ui_tree` is what keeps the ui jobs SKIPPED, not failed, on a tree that
# does not carry ui/ yet: `ui` and `ui_consumers` fall back to 'true' on
# merge_group / schedule / dispatch (half 1 below), and on push to main a
# spec change sets `ui_consumers`, so neither path output alone can tell a
# job there is nothing to build. Its step runs on every event, so the
# `|| 'true'` fallback is reached only when this job died before it — and
# then THE FILTER CONTRACT's "run everything" is the intended reading.
# schedule / dispatch (half 1 below), and on push to main a spec change
# sets `ui_consumers`, so neither path output alone can tell a job there
# is nothing to build. Its step runs on every event, so the `|| 'true'`
# fallback is reached only when this job died before it — and then THE
# FILTER CONTRACT's "run everything" is the intended reading.
ui: ${{ steps.changes.outputs.ui || 'true' }}
ui_consumers: ${{ steps.changes.outputs.ui_consumers || 'true' }}
ui_tree: ${{ steps.ui-tree.outputs.present || 'true' }}
Expand Down Expand Up @@ -216,13 +230,15 @@ jobs:
echo 'ui/package.json is absent: every ui job skips on this run.'
fi

# ⭐ SPELLED AS AN ALLOW-LIST, not as `!= 'merge_group'` (#16467). This
# action reads a diff, and the only two events that hand it one it can
# resolve unaided are `pull_request` (base vs head) and `push`
# (`event.before` vs `after`). `merge_group` was the first event with
# neither, and the exclusion was written as its name; `schedule` and
# `workflow_dispatch` are the second and third, and under the old
# spelling they would have RUN the action with no resolvable base.
# ⭐ SPELLED AS AN ALLOW-LIST, never as a deny-list (#16467). This
# action reads a diff, and only three events hand it one it can resolve
# unaided: `pull_request` (base vs head), `push` (`event.before` vs
# `after`) and `merge_group` (the payload's `base_sha` vs `head_sha`; at
# the pinned v4.0.3, src/main.ts reads both when `base` / `ref` are
# unset, and src/git.ts fetches the base sha at depth 1 and diffs the two
# trees, `base..head`). `schedule` and `workflow_dispatch` hand it
# neither, and under a deny-list spelling they would RUN the action with
# no resolvable base.
#
# That failure direction is the dangerous one: every filter output would
# come back `false`, `|| 'true'` supplies nothing (the output exists and
Expand All @@ -231,9 +247,16 @@ jobs:
# of nothing at all. An allow-list makes each new event fail toward
# THE FILTER CONTRACT's half 1 instead: the step is skipped, its outputs
# are the empty string, `|| 'true'` fills them in, and everything runs.
#
# `merge_group` joined the list for the ui outputs alone (#25867; the
# three core-side outputs above ignore the step on that event). Its
# `continue-on-error` is the fail-open half there: a queue build whose
# base cannot be fetched reads as "run every ui job", never as a red
# `filter` job. On pull_request and push a failure stays red, as before.
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
if: github.event_name == 'pull_request' || github.event_name == 'push'
if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'merge_group'
continue-on-error: ${{ github.event_name == 'merge_group' }}
with:
filters: |
docs:
Expand Down
35 changes: 22 additions & 13 deletions scripts/pm/check-expected-skips.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,16 @@
*
* It does NOT answer the DIFF question — "should this ROSTERED job have run on
* THIS diff?" — and the boundary is deliberate. For ci.yml's `filter`-gated
* family the merge-queue build answers it: on `merge_group` every filter output
* widens to `'true'` and the family runs on the merged tree before anything
* reaches `main` (the `|| 'true'` half of ci.yml's filter contract, pinned by
* `--self-test`) — except the full-battery-only jobs (ci.yml's THE TWO SPEEDS),
* which skip on every PR head and queue build and are answered by `main`'s
* push and hourly runs instead. For the event- and label-conditional advisory jobs the reason
* family the merge-queue build answers it: on `merge_group` the `docs`, `core`
* and `crosspkg` outputs widen to `'true'` and those jobs run on the merged
* tree before anything reaches `main` (the `|| 'true'` half of ci.yml's filter
* contract, pinned by `--self-test`). Two exceptions. The four ui jobs: on
* `merge_group` their outputs are the same paths filter read against the
* queue's `base_sha`, so a queue build skips them exactly when the merged diff
* touches no ui path, and main's hourly run widens them. And the
* full-battery-only jobs (ci.yml's THE TWO SPEEDS), which skip on every PR
* head and queue build and are answered by `main`'s push and hourly runs
* instead. For the event- and label-conditional advisory jobs the reason
* column names the condition a reader checks by hand. Deriving expectedness
* live from the workflows' `paths` filters would answer the diff question, at
* the cost of a SECOND evaluator of the platform's own semantics — the
Expand Down Expand Up @@ -184,7 +188,9 @@ export const OWN_PROXY_REARM_GUARD = 'OS_EXPECTED_SKIPS_PROXY_REARMED';
*
* filter-output ci.yml's `filter` job said `false` for every named output —
* the job's `if:` spells `needs.filter.outputs.<o> != 'false'`
* and the filter widens each output with `|| 'true'` off PRs.
* and the filter widens each output with `|| 'true'` where
* its paths filter did not run (the ui outputs read it on
* merge_group too).
* event the job's `if:` excludes `github.event.action` values, so a
* `labeled` / `unlabeled` / `edited` run of the same head skips.
* label the job's `if:` reads a label literal — an opt-out (skip when
Expand Down Expand Up @@ -216,6 +222,8 @@ const UI_GATE = Object.freeze({ kind: 'filter-output', outputs: ['ui_tree', 'ui'
const UI_CONSUMERS_GATE = Object.freeze({ kind: 'filter-output', outputs: ['ui_tree', 'ui', 'ui_consumers'] });
const UI_TREE_REASON =
"gated on ci.yml's `filter` job `ui_tree` output: it skips on every event while `ui/package.json` is absent (before the objectui import), and is not a required context";
const UI_QUEUE_REASON =
"the merge-queue build reads the same paths filter against the queue's `base_sha`, so a queue entry whose merged diff touches none of those paths skips it too; main's hourly run widens it to 'true'";
const FULL_BATTERY_REASON =
"full battery only (ci.yml's THE TWO SPEEDS): its `if:` excludes `pull_request` and `merge_group`, so it skips on every PR head and every queue build and runs on main's push, hourly schedule and dispatch runs";

Expand Down Expand Up @@ -335,28 +343,28 @@ export const EXPECTED_SKIPS = Object.freeze([
workflow: 'ci.yml',
job: 'type-check-ui',
gate: UI_CONSUMERS_GATE,
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output)`,
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output); ${UI_QUEUE_REASON}`,
},
{
name: 'Test UI (${{ matrix.shard }}/16)',
workflow: 'ci.yml',
job: 'test-ui',
gate: UI_GATE,
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; the raw matrix template is the pre-expansion name`,
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; ${UI_QUEUE_REASON}; the raw matrix template is the pre-expansion name`,
},
{
name: 'Lint UI',
workflow: 'ci.yml',
job: 'lint-ui',
gate: UI_GATE,
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path`,
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; ${UI_QUEUE_REASON}`,
},
{
name: 'Build & E2E UI',
workflow: 'ci.yml',
job: 'build-e2e-ui',
gate: UI_CONSUMERS_GATE,
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output)`,
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output); ${UI_QUEUE_REASON}`,
},
]);

Expand All @@ -373,8 +381,9 @@ export const FAILED_CONCLUSIONS = Object.freeze(['failure', 'cancelled', 'timed_
* exists in its workflow, carries the row's name, carries an `if:`, and that
* `if:` spells the gate the row declares. For `filter-output` rows the
* `filter` job's output must carry the `|| 'true'` widening, because the
* reason column promises the queue build runs the job — a promise that is only
* true while that half of the filter contract stands.
* reason column promises the job runs whenever the filter could not answer —
* the queue build for the core-side outputs, the hourly run for every output —
* a promise that is only true while that half of the filter contract stands.
*
* @param {readonly object[]} roster
* @param {(file: string) => object|null} readWorkflow parsed YAML, or null when unreadable
Expand Down
3 changes: 2 additions & 1 deletion scripts/pm/dispatch-gates.self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7502,7 +7502,8 @@ export function selfTest({ tier = 'full' } = {}) {
'scripts/check-issue-citations.mjs:204 local-env',
'scripts/cli-build-prerequisite.mjs:111 inherited-population',
// 131 -> 133 (#22998): the header gained two lines naming the full-battery-only jobs.
'scripts/pm/check-expected-skips.mjs:133 self-test-reads',
// 133 -> 137 (#25867): the header gained four lines on the ui jobs' queue-diff outputs.
'scripts/pm/check-expected-skips.mjs:137 self-test-reads',
'scripts/pm/dispatch-gates.mjs:736 inherited-population',
].join(' · '),
censusRows.join(' · '),
Expand Down
Loading