ci: cascade quality gate, centralize change detection, prune scope-parity matrix

Add a `paths-changes` job to ci.yml that runs `dorny/paths-filter@v3`
ONCE per CI run and exposes three outputs to the four reusable
sub-workflows via `with:`:

  - web (e2e gate)
  - shared-resolution (forces full parity matrix)
  - changed-languages (JSON array of language slugs to run parity for)

Tier the four sub-workflows so cheap quality checks gate the expensive
tier. `quality` failure now auto-skips `tests`, `e2e`, and
`scope-parity` via `needs: quality` + `if: needs.quality.result ==
'success'`. The `ci-status` aggregator surfaces the originating failure
first so cascade-skipped downstream results don't produce misleading
secondary errors. The aggregator now also includes `paths-changes` in
its `needs:` so a transient filter API failure surfaces with the
correct error, not a fake "Quality jobs failed".

`shared-resolution` is the OR of six independent signals — any change
that could regress any language's parity forces the full matrix:

  - shared-ingestion   (gitnexus/src/core/ingestion/** minus languages/)
  - shared-types       (gitnexus-shared/**)
  - shared-parser-infra (gitnexus/src/core/tree-sitter/**)
  - shared-test-harness (resolver helpers + gitnexus/test/helpers/**)
  - shared-pipeline    (ci.yml, ci-quality.yml, ci-scope-parity.yml,
                        setup-gitnexus action, discovery script)
  - shared-config      (package.json, package-lock.json, vitest.config,
                        tsconfig)

`predicate-quantifier: 'every'` is required for the shared-ingestion
negation to actually exclude (dorny/paths-filter#184); the other
filters use default OR semantics. Both filter steps carry
`continue-on-error: true` so a transient REST API blip falls back to
the conservative full-battery posture rather than blocking the PR.

In `ci-scope-parity.yml`, the discover job intersects MIGRATED_LANGUAGES
with `changed-languages` using strict equality (`IN(.slug; $changed[])`,
not `inside()` which would substring-match `c` against `cpp`). When
`shared-resolution-changed=true` the full matrix runs. The discover
step validates both its own script output and the caller-supplied
`changed-languages` input as JSON arrays before passing them through
`jq --argjson`, so malformed input fails loud at the discover step
instead of producing `fromJSON('')` matrix crashes downstream. An
empty filtered matrix is handled by `if: needs.discover.outputs.languages != '[]'`
on the parity job; the outer workflow conclusion stays `success`.

`ci-e2e.yml` drops its in-workflow `check-changes` job and consumes the
centralized `web-changed` input.

`save-pr-meta` and `ci-status` switch from `if: always()` to
`if: ${{ !cancelled() }}` so user-initiated workflow cancellation
actually cancels them (actions/runner#491).

Dead backward-compat hyphenated artifact copies in `save-pr-meta`
removed — verified the `ci-report.yml` on main reads only
underscore-named files.

Conservative `'true'` defaults on every input for non-PR callers
(release-candidate.yml, publish.yml via workflow_call): the full
battery still runs on every push to main and every release.

Adds `GITHUB_STEP_SUMMARY` lines in both `paths-changes` and the
scope-parity `discover` step so reviewers and agents can see why a
tier was pruned vs. ran without scraping logs.
This commit is contained in:
Gergo Magyar 2026-05-14 17:27:16 +01:00
parent 911a2ee1e6
commit 8674b2b453
3 changed files with 472 additions and 57 deletions

View file

@ -2,30 +2,30 @@ name: E2E Tests
on:
workflow_call:
inputs:
web-changed:
description: >-
Whether files under `gitnexus-web/**` (or shared types in
`gitnexus-shared/**`) changed on this PR. Computed by the
centralised `paths-changes` job in `ci.yml` and passed in
via `with:`. When `'false'`, the e2e job skips. For non-PR
callers (release-candidate.yml) `paths-changes` emits `'true'`
conservatively so the full battery still runs.
required: false
type: string
default: 'true'
permissions:
contents: read
jobs:
check-changes:
name: Check web module changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
web_changed: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
id: filter
with:
filters: |
web:
- 'gitnexus-web/**'
e2e:
name: e2e (chromium)
needs: check-changes
if: needs.check-changes.result == 'success' && needs.check-changes.outputs.web_changed == 'true'
# Centralised change detection: ci.yml computes `web` ONCE and passes
# it here via `inputs.web-changed`. The legacy in-workflow `check-changes`
# job (which ran `dorny/paths-filter` on its own) is removed — the same
# detection now feeds every sub-workflow from a single source.
if: inputs.web-changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:

View file

@ -21,12 +21,45 @@ name: Scope Resolution Parity
# discovers it, runs parity, and the language's default production path
# flips to registry-primary in the same change.
#
# When the set is empty (e.g. mid-Ring-3 for every language), the parity
# matrix is skipped and the workflow reports success — no-op until a
# language is explicitly claimed migrated.
# ── Path-aware matrix pruning ─────────────────────────────────────────
# `ci.yml` computes change detection ONCE per run and passes:
# - `shared-resolution-changed` — when 'true', shared ingestion code
# changed and ALL migrated languages must run (any could regress).
# - `changed-languages` — JSON array of language slugs whose source or
# resolver-test files changed (e.g. `["python","go"]`).
#
# The discover job intersects MIGRATED_LANGUAGES with `changed-languages`
# unless `shared-resolution-changed=true`, in which case the full matrix
# runs. On non-PR callers (release-candidate.yml) both inputs default to
# the full set, so the full matrix runs there too.
#
# When the filtered matrix is empty, the matrix job is skipped via the
# `if: needs.discover.outputs.languages != '[]'` guard. The outer workflow
# conclusion stays `success` so the `ci-status` aggregator in ci.yml
# passes the scope-parity gate.
on:
workflow_call:
inputs:
shared-resolution-changed:
description: >-
When `'true'`, shared ingestion/resolution code changed and the
full migrated-languages matrix must run. Conservatively defaults
to `'true'` so any caller that omits the input gets the full
battery.
required: false
type: string
default: 'true'
changed-languages:
description: >-
JSON array of migrated language slugs whose source or resolver
test files changed on this PR (e.g. `["python","go"]`). Used to
prune the parity matrix when `shared-resolution-changed` is
`'false'`. Defaults to the full set so omitting the input runs
everything.
required: false
type: string
default: '["python","csharp","typescript","go","c","cpp","php"]'
permissions:
contents: read
@ -38,32 +71,85 @@ jobs:
timeout-minutes: 5
outputs:
languages: ${{ steps.read.outputs.languages }}
has-any: ${{ steps.read.outputs.has-any }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus
- name: Extract MIGRATED_LANGUAGES from registry-primary-flag.ts
- name: Extract MIGRATED_LANGUAGES and filter by changed paths
id: read
shell: bash
working-directory: gitnexus
env:
SHARED_CHANGED: ${{ inputs.shared-resolution-changed }}
CHANGED_LANGS: ${{ inputs.changed-languages }}
run: |
set -euo pipefail
# `tsx` evaluates the TS source directly (no build step), imports
# the exported `Set`, and emits a GH-Actions-friendly JSON matrix.
LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts)
COUNT=$(printf '%s' "$LANGS" | jq 'length')
HAS_ANY="false"
if [[ "$COUNT" -gt 0 ]]; then HAS_ANY="true"; fi
# the exported `Set`, and emits a GH-Actions-friendly JSON matrix
# array: [{slug, envvar}, ...].
ALL_LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts)
# Validate the discovery script's output is a well-formed JSON
# array. A crash or schema regression here would otherwise
# propagate to `fromJSON('')` at matrix expansion time, where
# the failure surface is much harder to read.
if ! printf '%s' "$ALL_LANGS" | jq -e 'type == "array"' >/dev/null; then
echo "::error::ci-list-migrated-languages.ts produced non-array output: $ALL_LANGS"
exit 1
fi
ALL_COUNT=$(printf '%s' "$ALL_LANGS" | jq 'length')
echo "MIGRATED_LANGUAGES: $ALL_LANGS ($ALL_COUNT entries)"
# Shared-resolution change forces the full matrix — any change
# to shared ingestion code could regress any language's parity.
if [ "${SHARED_CHANGED:-true}" = "true" ]; then
LANGS="$ALL_LANGS"
REASON="shared-resolution changed → running full matrix"
else
# Validate the caller-supplied CHANGED_LANGS is a JSON array
# of strings. Malformed input would otherwise crash `jq
# --argjson` with a hard-to-diagnose parse error inside the
# filter pipeline.
if ! printf '%s' "${CHANGED_LANGS:-[]}" | jq -e 'type == "array" and all(type == "string")' >/dev/null; then
echo "::error::changed-languages input is not a JSON array of strings: ${CHANGED_LANGS:-<unset>}"
exit 1
fi
# Intersect MIGRATED_LANGUAGES with the changed-languages list.
# `IN(.slug; $changed[])` is strict equality membership — using
# `inside()` here would substring-match (`"c"` would match
# `"cpp"` because the `c` string is a prefix), which would
# over-trigger the matrix. The empty-list case is handled by
# the parity job's `if: != '[]'` guard.
LANGS=$(jq -c --argjson changed "${CHANGED_LANGS:-[]}" \
'[.[] | select(IN(.slug; $changed[]))]' <<< "$ALL_LANGS")
REASON="pruned by changed-languages: $CHANGED_LANGS"
fi
echo "languages=$LANGS" >> "$GITHUB_OUTPUT"
echo "has-any=$HAS_ANY" >> "$GITHUB_OUTPUT"
echo "Discovered $COUNT migrated language(s): $LANGS"
echo "Parity matrix will run: $HAS_ANY"
COUNT=$(printf '%s' "$LANGS" | jq 'length')
echo "Parity matrix entries: $COUNT ($REASON)"
# Job-summary line so reviewers and agents can see which
# languages actually ran without scraping logs.
{
echo "### Scope-parity matrix"
echo ""
echo "- **Entries:** $COUNT of $ALL_COUNT migrated languages"
echo "- **Reason:** $REASON"
if [ "$COUNT" -gt 0 ]; then
SLUGS=$(printf '%s' "$LANGS" | jq -r 'map(.slug) | join(", ")')
echo "- **Slugs:** $SLUGS"
fi
} >> "$GITHUB_STEP_SUMMARY"
parity:
name: ${{ matrix.lang.slug }} parity
needs: discover
if: needs.discover.outputs.has-any == 'true'
# Empty-matrix guard. When the filter produced no entries, the matrix
# job MUST skip — `fromJSON('[]')` in `strategy.matrix` would otherwise
# crash with "Matrix vector does not contain any values"
# (community#27096). Comparing the JSON string against `'[]'` is the
# idiomatic, documented pattern for this guard.
if: needs.discover.outputs.languages != '[]'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:

View file

@ -25,43 +25,350 @@ concurrency:
# ── Reusable workflow orchestration ─────────────────────────────────
# Each concern lives in its own workflow file for maintainability:
# ci-quality.yml — typecheck (tsc --noEmit)
# ci-quality.yml — typecheck (tsc --noEmit), lint, format, workflow-convention
# ci-tests.yml — unit + integration tests with coverage + cross-platform
# ci-e2e.yml — E2E tests (only when gitnexus-web/ changes)
# ci-scope-parity.yml — RFC #909 Ring 3 parity gate: legacy DAG + registry-primary
# both pass, per migrated language in the JSON registry
# both pass, per migrated language. Matrix is filtered to
# only the languages whose source files changed.
#
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
#
# ── Cascading execution model ───────────────────────────────────────
# Tier 1 (fast fail-fast): paths-changes → quality
# Tier 2 (gated on quality): tests, e2e, scope-parity
# Quality failure short-circuits the expensive tier via `needs: quality` —
# the downstream jobs auto-skip with `result == 'skipped'`. The `ci-status`
# aggregator reports the quality failure first so the cascade reason is
# unambiguous.
#
# ── Path-based change detection ─────────────────────────────────────
# The `paths-changes` job runs `dorny/paths-filter` ONCE per CI run and
# exposes a stable output set the reusable sub-workflows consume via
# `with:`. Two filter steps run because `predicate-quantifier: every` is
# required to make the `shared-resolution` negation work, but the other
# filters need the default `some` (OR) semantics.
#
# For non-`pull_request` triggers (workflow_call from release-candidate),
# there is no PR base to diff against. The job emits conservative `'true'`
# defaults for every output so the release-candidate run keeps exercising
# the full battery.
jobs:
# ── Centralised change detection ────────────────────────────────
# outputs.web — gitnexus-web/ or shared types changed
# outputs.shared-resolution — shared ingestion code (NOT under languages/) changed
# outputs.changed-languages — JSON array of migrated language slugs whose
# source or resolver-test files changed
paths-changes:
name: Detect changed paths
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
# dorny/paths-filter reads PR file lists via the REST API on
# pull_request events. Required even though the default GITHUB_TOKEN
# gets this scope on fork PRs automatically — explicit grant is
# robust to future job-level permission tightening.
pull-requests: read
outputs:
web: ${{ steps.compute.outputs.web }}
shared-resolution: ${{ steps.compute.outputs.shared-resolution }}
changed-languages: ${{ steps.compute.outputs.changed-languages }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Step 1 — shared-ingestion detection (negation-only).
# `predicate-quantifier: 'every'` is required for the negation
# (`!gitnexus/src/core/ingestion/languages/**`) to actually exclude
# — the default `'some'` quantifier silently ignores negation
# entries (dorny/paths-filter#184). This step is intentionally
# narrow: it is the ONLY filter that uses negation. Every other
# shared signal goes through Step 2's default-OR semantics where
# we can stack many independent patterns cleanly.
#
# `continue-on-error: true` ensures a transient GitHub REST API blip
# doesn't take down the whole CI run with a misleading "Quality jobs
# failed" message. The compute step below detects empty outputs and
# falls back to conservative `'true'` defaults (full battery) — the
# same posture as non-PR callers like release-candidate.yml.
- name: Detect shared-ingestion changes (negation step)
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
id: shared-filter
continue-on-error: true
with:
predicate-quantifier: 'every'
filters: |
shared-ingestion:
- 'gitnexus/src/core/ingestion/**'
- '!gitnexus/src/core/ingestion/languages/**'
# Step 2 — web, per-language, and the rest of the shared surface.
# Default `some` (OR) quantifier — a file matching ANY pattern in
# the filter triggers the filter.
#
# The per-language filters include both ingestion source AND
# resolver integration test files so a test-only edit (e.g.,
# python.test.ts) still triggers Python parity. The action's
# built-in `changes` output is the JSON array of matched filter
# names — we strip the non-language filter names (web, shared-*)
# in the compute step to produce the language matrix input.
#
# The shared-* filters are OR'd together in the compute step to
# derive the `shared-resolution` output. Anything that can regress
# the parity matrix for ANY language — shared ingestion code,
# shared types, parser infrastructure, the test harness, the CI
# pipeline itself, or dependency/config — forces the full matrix.
# Per-language pruning only applies when every shared-* filter is
# false AND only per-language files changed.
- name: Detect web, shared, and per-language changes
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
id: filter
continue-on-error: true
with:
filters: |
web:
- 'gitnexus-web/**'
- 'gitnexus-shared/**'
shared-types:
- 'gitnexus-shared/**'
shared-parser-infra:
- 'gitnexus/src/core/tree-sitter/**'
shared-test-harness:
- 'gitnexus/test/integration/resolvers/helpers.ts'
- 'gitnexus/test/helpers/**'
shared-pipeline:
- '.github/workflows/ci.yml'
- '.github/workflows/ci-quality.yml'
- '.github/workflows/ci-scope-parity.yml'
- '.github/actions/setup-gitnexus/**'
- 'gitnexus/scripts/ci-list-migrated-languages.ts'
shared-config:
- 'gitnexus/package.json'
- 'gitnexus/package-lock.json'
- 'gitnexus/vitest.config.ts'
- 'gitnexus/tsconfig.json'
- 'gitnexus-shared/package.json'
- 'gitnexus-shared/package-lock.json'
python:
- 'gitnexus/src/core/ingestion/languages/python/**'
- 'gitnexus/src/core/ingestion/languages/python.ts'
- 'gitnexus/test/integration/resolvers/python.test.ts'
csharp:
- 'gitnexus/src/core/ingestion/languages/csharp/**'
- 'gitnexus/src/core/ingestion/languages/csharp.ts'
- 'gitnexus/test/integration/resolvers/csharp.test.ts'
typescript:
- 'gitnexus/src/core/ingestion/languages/typescript/**'
- 'gitnexus/src/core/ingestion/languages/typescript.ts'
- 'gitnexus/test/integration/resolvers/typescript*.test.ts'
go:
- 'gitnexus/src/core/ingestion/languages/go/**'
- 'gitnexus/src/core/ingestion/languages/go.ts'
- 'gitnexus/test/integration/resolvers/go.test.ts'
c:
- 'gitnexus/src/core/ingestion/languages/c/**'
- 'gitnexus/src/core/ingestion/languages/c-cpp.ts'
- 'gitnexus/test/integration/resolvers/c.test.ts'
cpp:
- 'gitnexus/src/core/ingestion/languages/cpp/**'
- 'gitnexus/src/core/ingestion/languages/c-cpp.ts'
- 'gitnexus/test/integration/resolvers/cpp.test.ts'
php:
- 'gitnexus/src/core/ingestion/languages/php/**'
- 'gitnexus/src/core/ingestion/languages/php.ts'
- 'gitnexus/test/integration/resolvers/php.test.ts'
# Step 3 — derive job outputs from the two filter steps OR (for
# non-PR events) emit conservative defaults. release-candidate.yml
# calls ci.yml via `workflow_call`; without PR base/head context
# dorny/paths-filter has no commit range, so we short-circuit it
# for any non-PR event and run the full validation battery.
#
# `shared-resolution` is the OR of every "any-language-could-
# regress" signal:
# - shared-ingestion (Step 1, predicate-quantifier=every)
# - shared-types (gitnexus-shared/**)
# - shared-parser-infra (tree-sitter loader/safe-parse)
# - shared-test-harness (resolver helpers + test/helpers/**)
# - shared-pipeline (ci.yml, ci-quality.yml, ci-scope-parity.yml,
# setup-gitnexus, discovery script)
# - shared-config (gitnexus package manifests, vitest config,
# tsconfig, gitnexus-shared manifests)
# When any of these fire, the full migrated-languages matrix runs.
# Per-language pruning only applies when EVERY shared-* filter is
# false and only per-language files changed.
#
# Conservative-default fallback: when a filter step's `continue-on-
# error` fires (transient GitHub REST API blip), its outputs are
# unset. We detect step.outcome != 'success' and short-circuit to
# the full-battery posture — same as a non-PR caller. The
# alternative (`:-false` per-var fallback) would mask the filter
# failure as "nothing changed" and silently skip every downstream
# gate. Better to over-run on a flake than under-run.
- name: Compute outputs
id: compute
shell: bash
env:
IS_PR: ${{ github.event_name == 'pull_request' }}
WEB: ${{ steps.filter.outputs.web }}
# Shared signals OR'd into the final `shared-resolution` output.
SHARED_INGESTION: ${{ steps.shared-filter.outputs.shared-ingestion }}
SHARED_TYPES: ${{ steps.filter.outputs.shared-types }}
SHARED_PARSER_INFRA: ${{ steps.filter.outputs.shared-parser-infra }}
SHARED_TEST_HARNESS: ${{ steps.filter.outputs.shared-test-harness }}
SHARED_PIPELINE: ${{ steps.filter.outputs.shared-pipeline }}
SHARED_CONFIG: ${{ steps.filter.outputs.shared-config }}
# `changes` is dorny/paths-filter's built-in output: a JSON
# string-array listing every filter name that matched. We strip
# non-language filter names to get the per-language matrix input.
CHANGES: ${{ steps.filter.outputs.changes }}
# Step outcomes feed the fallback: if a filter step failed,
# treat shared-resolution as `true` and run the full matrix.
SHARED_FILTER_OUTCOME: ${{ steps.shared-filter.outcome }}
FILTER_OUTCOME: ${{ steps.filter.outcome }}
FULL_LANG_LIST: '["python","csharp","typescript","go","c","cpp","php"]'
# Non-language filter names — anything in this list is stripped
# from `changes` when computing `changed-languages`. Keep this
# set in sync with the filter definitions in Step 2 above.
NON_LANG_FILTERS: '["web","shared-types","shared-parser-infra","shared-test-harness","shared-pipeline","shared-config"]'
run: |
set -euo pipefail
if [ "$IS_PR" = "true" ]; then
# Filter-step failure → conservative full-battery posture.
if [ "$SHARED_FILTER_OUTCOME" != "success" ] || [ "$FILTER_OUTCOME" != "success" ]; then
echo "::warning::dorny/paths-filter failed (shared=$SHARED_FILTER_OUTCOME, filter=$FILTER_OUTCOME) — falling back to full battery"
{
echo "web=true"
echo "shared-resolution=true"
echo "changed-languages=$FULL_LANG_LIST"
} >> "$GITHUB_OUTPUT"
{
echo "### paths-changes — filter-step failure"
echo ""
echo "shared=\`$SHARED_FILTER_OUTCOME\`, filter=\`$FILTER_OUTCOME\` → running full battery"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# OR the six shared signals together into the final
# `shared-resolution` flag. Any 'true' wins.
SHARED="false"
for v in "$SHARED_INGESTION" "$SHARED_TYPES" "$SHARED_PARSER_INFRA" \
"$SHARED_TEST_HARNESS" "$SHARED_PIPELINE" "$SHARED_CONFIG"; do
if [ "$v" = "true" ]; then SHARED="true"; fi
done
# Filter the `changes` array down to language slugs only by
# removing non-language filter names. `jq -c` keeps the output
# compact so it round-trips cleanly through $GITHUB_OUTPUT and
# downstream `fromJSON()`.
CHANGED_LANGS=$(jq -c --argjson nonlang "$NON_LANG_FILTERS" \
'[.[] | select(IN(.; $nonlang[]) | not)]' <<< "${CHANGES:-[]}")
{
echo "web=${WEB:-true}"
echo "shared-resolution=$SHARED"
echo "changed-languages=$CHANGED_LANGS"
} >> "$GITHUB_OUTPUT"
{
echo "### paths-changes (pull_request)"
echo ""
echo "| Output | Value |"
echo "|---|---|"
echo "| web | ${WEB:-true} |"
echo "| **shared-resolution** | **$SHARED** |"
echo "| changed-languages | \`$CHANGED_LANGS\` |"
echo ""
echo "Shared signal sources:"
echo ""
echo "| Signal | Matched? |"
echo "|---|---|"
echo "| shared-ingestion (ingestion/ minus languages/) | ${SHARED_INGESTION:-false} |"
echo "| shared-types (gitnexus-shared/) | ${SHARED_TYPES:-false} |"
echo "| shared-parser-infra (tree-sitter/) | ${SHARED_PARSER_INFRA:-false} |"
echo "| shared-test-harness (test helpers) | ${SHARED_TEST_HARNESS:-false} |"
echo "| shared-pipeline (ci.yml + setup-gitnexus + discovery script) | ${SHARED_PIPELINE:-false} |"
echo "| shared-config (package.json, vitest/tsconfig) | ${SHARED_CONFIG:-false} |"
} >> "$GITHUB_STEP_SUMMARY"
else
# Conservative defaults for workflow_call / push / dispatch:
# run the full battery. release-candidate validates the
# entire codebase on every push to main regardless of diff.
{
echo "web=true"
echo "shared-resolution=true"
echo "changed-languages=$FULL_LANG_LIST"
} >> "$GITHUB_OUTPUT"
{
echo "### paths-changes (non-PR — full matrix)"
echo "Event: \`$GITHUB_EVENT_NAME\`"
} >> "$GITHUB_STEP_SUMMARY"
fi
# ── Tier 1: cheap quality gates ─────────────────────────────────
# Format, lint, typecheck (gitnexus + web), and workflow-convention.
# Median runtime under 5 minutes — fails fast on the most common
# contributor mistakes (missing format, lint errors, type errors).
quality:
needs: paths-changes
uses: ./.github/workflows/ci-quality.yml
permissions:
contents: read
# ── Tier 2: expensive validation, gated on quality success ──────
# `needs: quality` makes these auto-skip when quality fails — saves
# ~25 minutes of Vitest matrix + ~5-15 minutes of scope-parity matrix
# for the most common failure mode (a contributor PR that misses
# format/lint/typecheck locally).
tests:
needs: [paths-changes, quality]
if: needs.quality.result == 'success'
uses: ./.github/workflows/ci-tests.yml
permissions:
contents: read
# E2E is conditionally gated on `gitnexus-web/**` changes via the
# `web` input. When unchanged, the inner workflow's `e2e` job skips
# and the outer reusable-workflow conclusion is `success` (a skipped
# matrix with no parity entries returns success). The `ci-status`
# aggregator accepts `success` OR `skipped` for the e2e gate.
e2e:
needs: [paths-changes, quality]
if: needs.quality.result == 'success'
uses: ./.github/workflows/ci-e2e.yml
permissions:
contents: read
with:
web-changed: ${{ needs.paths-changes.outputs.web }}
# Scope-parity consumes shared-resolution + changed-languages. The
# discovery job inside the workflow uses changed-languages to filter
# MIGRATED_LANGUAGES; when shared-resolution=true the full matrix runs.
scope-parity:
needs: [paths-changes, quality]
if: needs.quality.result == 'success'
uses: ./.github/workflows/ci-scope-parity.yml
permissions:
contents: read
with:
shared-resolution-changed: ${{ needs.paths-changes.outputs.shared-resolution }}
changed-languages: ${{ needs.paths-changes.outputs.changed-languages }}
# ── Save PR metadata for the reporting workflow ─────────────────
# The ci-report.yml workflow (triggered by workflow_run) needs the
# PR number and job results to post a comment. We save them as an
# artifact because workflow_run context doesn't reliably carry PR
# info for fork PRs.
#
# `!cancelled()` instead of `always()` so a user-initiated workflow
# cancellation actually cancels this job too. `always()` is documented
# to make a job uncancellable (actions/runner#491), which masks
# cancel-button presses.
save-pr-meta:
name: Save PR Metadata
if: always() && github.event_name == 'pull_request'
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
needs: [quality, tests, e2e, scope-parity]
runs-on: ubuntu-latest
timeout-minutes: 5
@ -81,16 +388,6 @@ jobs:
echo "$TESTS" > pr-meta/tests_result
echo "$E2E" > pr-meta/e2e_result
echo "$SCOPE_PARITY" > pr-meta/scope_parity_result
# TODO(post-merge): remove backward-compat copies once ci-report.yml
# on main reads underscore names.
# Backward-compat: ci-report.yml on main still reads hyphenated
# names. workflow_run always executes from the default branch, so
# the main-branch reader won't find the underscore variants until
# this PR is merged. Write both until then.
cp pr-meta/pr_number pr-meta/pr-number
cp pr-meta/quality_result pr-meta/quality-result
cp pr-meta/tests_result pr-meta/tests-result
cp pr-meta/e2e_result pr-meta/e2e-result
- name: Upload PR metadata
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -100,42 +397,74 @@ jobs:
retention-days: 1
# ── Unified CI gate ──────────────────────────────────────────────
# Single required check for branch protection.
# Single required check for branch protection. This is THE check the
# rest of the workflow rolls up into — see emmer.dev's "Skippable
# GitHub Status Checks Aren't Really Required" for the rationale.
# The originating failure is surfaced FIRST so cascade-skipped
# downstream results don't produce a misleading secondary error.
#
# `!cancelled()` instead of `always()` for the same reason as
# save-pr-meta above — a cancel button press should cancel this too.
ci-status:
name: CI Gate
needs: [quality, tests, e2e, scope-parity]
if: always()
needs: [paths-changes, quality, tests, e2e, scope-parity]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check all jobs passed
shell: bash
env:
PATHS_CHANGES: ${{ needs.paths-changes.result }}
QUALITY: ${{ needs.quality.result }}
TESTS: ${{ needs.tests.result }}
E2E: ${{ needs.e2e.result }}
SCOPE_PARITY: ${{ needs.scope-parity.result }}
run: |
echo "Paths-changes: $PATHS_CHANGES"
echo "Quality: $QUALITY"
echo "Tests: $TESTS"
echo "E2E: $E2E"
echo "Scope parity: $SCOPE_PARITY"
if [[ "$QUALITY" != "success" ]] ||
[[ "$TESTS" != "success" ]]; then
echo "::error::Quality or test jobs failed"
# Walk the cascade top-down so the originating failure is the
# one we report. Filter steps have `continue-on-error: true`,
# so a transient REST API failure inside `paths-changes` does
# NOT take down the job — the compute step falls back to a
# full-battery posture and `PATHS_CHANGES=success`. The only
# way we see `PATHS_CHANGES != 'success'` is a genuine job-
# level failure (runner crash, timeout, dispatch failure).
if [[ "$PATHS_CHANGES" != "success" ]]; then
echo "::error::Path-change detection failed — downstream tiers were skipped via cascading needs"
exit 1
fi
# Quality is the first cheap gate. Its failure auto-skips
# tests/e2e/scope-parity via `needs: quality`. Those `skipped`
# results are an expected cascade, not a separate bug, so we
# check quality before the dependent jobs.
if [[ "$QUALITY" != "success" ]]; then
echo "::error::Quality jobs failed — downstream tiers were skipped via cascading needs"
exit 1
fi
# Tests should succeed when quality succeeded. `skipped` here
# only happens when an upstream `needs` failed, which the two
# checks above already handled — so any non-success at this
# point is a real test failure.
if [[ "$TESTS" != "success" ]]; then
echo "::error::Test jobs failed"
exit 1
fi
if [[ "$E2E" != "success" && "$E2E" != "skipped" ]]; then
echo "::error::E2E job failed"
exit 1
fi
# scope-parity is a reusable workflow. With an empty migrated-
# languages list, its parity matrix is skipped and the outer
# workflow still reports `success`. If any entry's legacy-DAG or
# registry-primary run fails, the workflow reports `failure`.
# Accept only `success`; `skipped` would mean the entire
# discover job was skipped too (upstream failure), which should
# still block.
# scope-parity is a reusable workflow. When changed-languages
# is empty AND shared-resolution didn't change, the parity
# matrix is pruned to zero but `discover` itself runs and
# succeeds, so the outer workflow conclusion is `success` —
# which this gate accepts. The only ways we'd see `skipped`
# here are upstream `needs` failures already caught above.
# If any matrix entry's legacy-DAG or registry-primary run
# fails, the outer workflow conclusion is `failure`.
if [[ "$SCOPE_PARITY" != "success" ]]; then
echo "::error::Scope-resolution parity gate failed (RFC #909 Ring 3)"
exit 1