Skip to content
Open
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
116 changes: 95 additions & 21 deletions .github/workflows/test-eql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ on:
# surface removed in 3.0.0. All four were missing from all three copies of
# this list, so a push to main touching only docs started no run of this
# workflow at all — `pull_request` was unaffected, because it applies no
# `paths:` filter and `docs-static` is deliberately not relevance-gated.
# Derived rather than remembered now: the second half of
# `paths:` filter and `docs-static` was never gated on this list. (It is
# gated on the `changes` job's wider `eql:` filter, which selects the whole
# subtree.) Derived rather than remembered now: the second half of
# `scripts/__tests__/eql-workflow-filters.test.mjs` walks each `mise run`
# out to the paths the task names and fails on any this list does not select.
paths:
Expand Down Expand Up @@ -149,13 +150,18 @@ concurrency:

jobs:
# Runs on EVERY event and MUST always succeed (never skipped, never failed) —
# downstream heavy jobs `needs: [changes]`, and a skipped/failed `changes`
# every gated job `needs: [changes]`, and a skipped/failed `changes`
# would either skip the merge-queue matrix or deadlock `ci-required`.
#
# Two outputs. `relevant` gates the heavy jobs. `eql` gates the three cheap
# ones (`docs-static`, `doc-anchors`, `known-failures`), which read more of
# the subtree than `relevant` selects. See the filter step below.
changes:
name: "Detect relevant changes"
runs-on: blacksmith-16vcpu-ubuntu-2204
outputs:
relevant: ${{ steps.r.outputs.relevant }}
eql: ${{ steps.r.outputs.eql }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
Expand Down Expand Up @@ -186,11 +192,37 @@ jobs:
# reading as coverage).
#
# The four documentation entries are inputs to `docs-static` and
# `doc-anchors`, neither of which is relevance-gated, so this copy
# does not strictly need them. It carries them because the three
# copies are held EQUAL — see the note above the `push` list, and the
# reasoning on `bench-eql.yml`'s own filter for why equal beats
# minimal.
# `doc-anchors`, which are gated on `eql:` rather than on this list,
# so this copy does not strictly need them. It lists them because the
# three copies are kept EQUAL — see the note above the `push` list,
# and the reasoning on `bench-eql.yml`'s own filter for why equal
# beats minimal.
#
# `eql:` is a SECOND question, not a fourth copy of the list, and the
# parity check reads `relevant:` alone. It gates `docs-static`,
# `doc-anchors` and `known-failures`, which read more of the subtree
# than `relevant:` selects: `test:public_identifiers` runs `git grep`
# over every tracked file under `packages/eql/`, and
# `test:doc-anchors` reads every tracked `*.md` file there
# (`AGENTS.md`, `CHANGELOG.md`, `DEVELOPMENT.md`, …). So it takes the
# whole subtree, plus what the three jobs read OUTSIDE it:
#
# * `mise.toml` and the task files its `[task_config].includes`
# names. mise reads config from every parent directory, so each
# job installs the root's tools (go, golangci-lint, wasm-pack)
# and loads the root's tasks. Four of those task files are under
# the crate globs below; `stack-guest-abi`'s is not.
# * `.cargo/config.toml`, `Cargo.toml` and the five stack-* crates.
# `docs:validate:source` depends on `build`, which runs
# `cargo run -p eql-codegen`. cargo reads config from every parent
# directory, and to resolve the EQL workspace it loads the crates
# `eql-bindings` and `tests/encryption` reach by path, plus the
# root manifest those crates inherit from.
#
# None of the three jobs runs `require-cs-secrets`, so it is absent.
# `eql-workflow-filters.test.mjs` fails if this list stops selecting
# any input named above; `eql-suite-ci.test.mjs` fails if it selects
# any other path outside the subtree.
filters: |
relevant:
- ".github/workflows/test-eql.yml"
Expand All @@ -213,12 +245,24 @@ jobs:
- "packages/stack-auth/**"
- "packages/stack-profile/**"
- "Cargo.toml"
eql:
- ".github/workflows/test-eql.yml"
- "packages/eql/**"
- "mise.toml"
- "packages/stack-guest-abi/tasks.toml"
- ".cargo/config.toml"
- "Cargo.toml"
- "packages/stack-encrypt/**"
- "packages/stack-encrypt-derive/**"
- "packages/stack-kms/**"
- "packages/stack-auth/**"
- "packages/stack-profile/**"

# Explicit default (not `|| 'true'`, which trips GitHub's inconsistent
# treatment of the string 'false'). push/schedule/merge_group/dispatch
# never run the filter above — it needs a base ref that only a pull
# request has — so they take the default, and `push` is already narrowed
# by its own `on:`-level `paths:`.
# request has — so both outputs take the default, and `push` is already
# narrowed by its own `on:`-level `paths:`.
#
# The event arrives through `env:`, not through a `${{ }}` interpolated
# into the body. That is what lets
Expand All @@ -229,12 +273,15 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
FILTER_RELEVANT: ${{ steps.f.outputs.relevant }}
FILTER_EQL: ${{ steps.f.outputs.eql }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
echo "relevant=$FILTER_RELEVANT" >> "$GITHUB_OUTPUT"
echo "eql=$FILTER_EQL" >> "$GITHUB_OUTPUT"
else
echo "relevant=true" >> "$GITHUB_OUTPUT"
echo "eql=true" >> "$GITHUB_OUTPUT"
fi

# Pure bash; no checkout/toolchain. Derives the PG-version + shard fan-out
Expand Down Expand Up @@ -833,12 +880,19 @@ jobs:
mise run --output prefix test:splinter --postgres "${POSTGRES_VERSION}"

# Source-only SQL documentation validation (coverage + required Doxygen tags).
# Deliberately NOT relevance-gated: it runs on EVERY pull_request — including
# docs-only PRs that skip the heavy jobs — so documentation is always
# validated. DB-free and creds-free (the psql-backed syntax check stays in the
# per-version `validate` job).
# Gated on `eql`, not `relevant`: it runs on every pull_request that touches
# the EQL subtree or a file it reads outside it, and skips the rest. The
# `relevant` list leaves out files this job reads — `test:public_identifiers`
# greps every tracked file under `packages/eql/`. DB-free and creds-free (the
# psql-backed syntax check stays in the per-version `validate` job).
docs-static:
name: "SQL doc validation"
needs: [changes]
# Exclusion, not an allowlist — see the note on `build-archive`. No fork
# clause: this job has no credentials.
if: >-
github.event_name != 'pull_request'
|| needs.changes.outputs.eql == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2204
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
Expand Down Expand Up @@ -875,9 +929,21 @@ jobs:
# this is the half that stops a suppression outliving a closed issue.
#
# Credential-free and DB-free — it only reads the registry and asks GitHub for
# issue state, so it runs on every PR rather than hiding behind the e2e job.
# issue state, so it runs on its own rather than behind the e2e job.
#
# Gated on `eql`. The second step asks GitHub whether each issue is open NOW,
# so ungated, closing an issue would fail the next run of every pull request,
# including one that touches nothing in EQL. It still runs on every PR that
# touches EQL, and on every push and schedule run, where a closed issue
# belongs.
known-failures:
name: "known-failure markers"
needs: [changes]
# Exclusion, not an allowlist — see the note on `build-archive`. No fork
# clause: this job has no credentials.
if: >-
github.event_name != 'pull_request'
|| needs.changes.outputs.eql == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2204
permissions:
contents: read
Expand Down Expand Up @@ -910,12 +976,19 @@ jobs:
run: |
mise run test:known-failures

# Markdown anchor links. DB-free, credential-free and fast, and deliberately
# NOT relevance-gated: its inputs are the docs themselves, so gating it on the
# `relevant` filter (src/**, crates/**) would skip it on exactly the docs-only
# PRs it exists to check.
# Markdown anchor links. DB-free, credential-free and fast. Gated on `eql`,
# not `relevant`: it reads every tracked `*.md` file in the subtree, and the
# `relevant` list leaves out several of them (`AGENTS.md`, `CHANGELOG.md`,
# `DEVELOPMENT.md`, …), so gating it there would skip it on exactly the
# docs-only PRs it exists to check.
doc-anchors:
name: "doc anchor links"
needs: [changes]
# Exclusion, not an allowlist — see the note on `build-archive`. No fork
# clause: this job has no credentials.
if: >-
github.event_name != 'pull_request'
|| needs.changes.outputs.eql == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2204
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
Expand Down Expand Up @@ -992,8 +1065,9 @@ jobs:
# protection never references an event-dependent leaf name (which would
# deadlock). Passes iff every needed job is success or skipped. Treating
# skipped as pass is intentional: heavy jobs are legitimately skipped on
# docs-only PRs, and a genuine failure is still caught because the FAILING
# source job is itself in `needs` and reports failure.
# docs-only PRs, the three `eql`-gated jobs on PRs that touch no EQL input,
# and a genuine failure is still caught because the FAILING source job is
# itself in `needs` and reports failure.
ci-required:
name: "ci-required"
needs: [changes, setup, build-archive, test, validate, schema, rust-crates,
Expand Down
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,13 @@ monorepo, which is where the silent failures are.
`docs-static`'s `mise run test:docs_v3_grep` scanned every one of them: a
push to main touching only documentation started no EQL workflow at all.
**`pull_request` was never affected** — it applies no `paths:` filter, and
`docs-static` and `doc-anchors` are deliberately not relevance-gated. The
`docs-static` and `doc-anchors` never read `relevant:`. They and
`known-failures` are gated on the same step's second key, `eql:`, which is
**not a fourth copy** and is deliberately wider: the whole subtree (the jobs
`git grep` every tracked file and read every tracked `*.md` there), plus the
root `mise.toml` and its task includes, `.cargo/config.toml`, and the crates
cargo loads to resolve the EQL workspace. The parity check reads `relevant:`
alone; `eql:` has its own checks in the same file. The
derivation reads paths that are WRITTEN DOWN; it cannot see `postgres:up`
picking up `tests/docker-compose.yml` from its working directory, or the glob
pathspec in `tasks/test/doc-anchors.sh` (`git ls-files '*.md'`, i.e. every
Expand Down
4 changes: 2 additions & 2 deletions packages/eql/tasks/docs/validate/source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
#
# This is the DB-free subset of `docs:validate`: coverage + required-tags read
# the `--!` doxygen comments out of src/**/*.sql and need no Postgres. It exists
# so CI can validate documentation on EVERY PR (including docs-only PRs that skip
# the heavy, relevance-gated jobs) without standing up a database. The
# so CI can validate documentation on every PR that touches EQL (including PRs
# that skip the heavy jobs) without standing up a database. The
# `documented-sql` syntax check (which needs psql) stays in the per-Postgres
# `validate` job.

Expand Down
73 changes: 46 additions & 27 deletions scripts/__tests__/eql-matrix-triggers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,27 @@ const MATRIX_SOURCE = (() => {
return null
})()

/** The relevance flag every gated job's `if:` reads. */
const RELEVANCE_SOURCE = (() => {
/**
* Every flag a job `if:` reads, de-duplicated. More than one: the heavy jobs
* read `relevant` and the cheap ones read `eql`, and a wrong default on either
* silently skips its jobs on push and schedule.
*/
const GATE_SOURCES = (() => {
const found = new Map()
for (const job of Object.values(wf?.jobs ?? {})) {
const ref = needsOutputRef(job?.if)
if (ref) return ref
for (const match of String(job?.if ?? '').matchAll(
/needs\.([A-Za-z0-9_-]+)\.outputs\.([A-Za-z0-9_-]+)/g,
)) {
found.set(`${match[1]}.${match[2]}`, { job: match[1], output: match[2] })
}
}
return null
return [...found.values()]
})()

/** A minimum, not an equality: a third flag is checked the day it lands. */
const EXPECTED_GATE_FLAGS = ['eql', 'relevant']

const MATRIX_STEP = MATRIX_SOURCE ? stepProducing(MATRIX_SOURCE) : null
const RELEVANCE_STEP = RELEVANCE_SOURCE ? stepProducing(RELEVANCE_SOURCE) : null

/**
* The step `env:` key wired to `${{ github.event_name }}`.
Expand Down Expand Up @@ -281,32 +291,41 @@ describe('the EQL matrix is reachable by a trigger that fires', () => {
})
})

describe('the relevance flag opens on every non-PR event', () => {
it('follows the workflow to the step that computes it', () => {
describe('every gate flag opens on every non-PR event', () => {
it('finds the flags the job conditions read', () => {
expect(
RELEVANCE_SOURCE,
`No job \`if:\` in ${WORKFLOW} reads a \`needs.<job>.outputs.<name>\` relevance flag, so this suite has nothing to evaluate.`,
).toBeTruthy()
expect(RELEVANCE_STEP).toBeTruthy()
expect(
eventEnvKey(RELEVANCE_STEP?.step),
`The "${RELEVANCE_SOURCE?.job}" job's \`${RELEVANCE_STEP?.stepId}\` step must read the event from its \`env:\` rather than inlining \`\${{ github.event_name }}\`, for the reason given on the matrix step.`,
).toBeTruthy()
GATE_SOURCES.map((source) => source.output),
`The job \`if:\`s in ${WORKFLOW} no longer read every flag this file expects, so a default below goes unchecked. Found: ${GATE_SOURCES.map((s) => `needs.${s.job}.outputs.${s.output}`).join(', ') || '(none)'}`,
).toEqual(expect.arrayContaining(EXPECTED_GATE_FLAGS))
})

for (const event of CANDIDATE_EVENTS.filter((e) => e !== 'pull_request')) {
it(`defaults to relevant on ${event}`, () => {
// The path filter only runs on `pull_request` — it needs a base ref — so
// on every other event the flag is a hardcoded default. Get that default
// wrong and every gated job skips on the new trigger while the run still
// reports success, which is the same silent-skip class as the trigger bug
// this file exists for.
const outputs = runStep(RELEVANCE_STEP, event)
for (const source of GATE_SOURCES) {
const step = stepProducing(source)

it(`follows ${source.output} to the step that computes it`, () => {
expect(
outputs[RELEVANCE_SOURCE.output],
`The relevance step wrote ${JSON.stringify(outputs)} on a ${event} event. Every heavy job is gated on this being 'true', and the filter that would compute it does not run outside \`pull_request\`.`,
).toBe('true')
step,
`\`jobs.${source.job}.outputs.${source.output}\` does not resolve to a step \`id:\` in that job.`,
).toBeTruthy()
expect(
eventEnvKey(step?.step),
`The "${source.job}" job's \`${step?.stepId}\` step must read the event from its \`env:\` rather than inlining \`\${{ github.event_name }}\`, for the reason given on the matrix step.`,
).toBeTruthy()
})

for (const event of CANDIDATE_EVENTS.filter((e) => e !== 'pull_request')) {
it(`defaults ${source.output} to true on ${event}`, () => {
// The path filter only runs on `pull_request` — it needs a base ref —
// so on every other event the flag is a hardcoded default. Get it wrong
// and every job gated on it skips on that trigger while the run still
// reports success: the silent-skip class this file exists for.
const outputs = runStep(step, event)
expect(
outputs[source.output],
`The step wrote ${JSON.stringify(outputs)} on a ${event} event. The jobs gated on \`${source.output}\` run only when it is 'true', and the filter that would compute it does not run outside \`pull_request\`.`,
).toBe('true')
})
}
}
})

Expand Down
Loading
Loading