* test(#3833): failing-first suite for the PR mergeability preflight * chore(#3833): gate every PR compute lane behind a mergeability preflight * fix(#3833): assert the preflight gate on parsed yaml and guard status-function if * fix(#3833): run stub-backed cli tests in-process to avoid a spawnsync deadlock * fix(#3833): fail the preflight open when its script is absent at the base sha --------- Co-authored-by: sim <sim@local>
This commit is contained in:
13
.github/workflows/branch-naming.yml
vendored
13
.github/workflows/branch-naming.yml
vendored
@@ -11,7 +11,20 @@ concurrency:
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
# #3833: no lane runs while the PR has a merge conflict. The workflow-level
|
||||
# `permissions: {}` above is why the calling job below names both scopes
|
||||
# explicitly — a calling job's permissions REPLACE the workflow default
|
||||
# rather than merging with it, so an implicit inherit would hand the
|
||||
# preflight a token with nothing on it.
|
||||
preflight:
|
||||
name: PR mergeability
|
||||
uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
check-branch:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
steps:
|
||||
|
||||
9
.github/workflows/changeset-required.yml
vendored
9
.github/workflows/changeset-required.yml
vendored
@@ -13,7 +13,16 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# #3833: no lane runs while the PR has a merge conflict.
|
||||
preflight:
|
||||
name: PR mergeability
|
||||
uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
changeset-lint:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
@@ -20,7 +20,16 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# #3833: no lane runs while the PR has a merge conflict.
|
||||
preflight:
|
||||
name: PR mergeability
|
||||
uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
default-flip-documentation:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
10
.github/workflows/docs-required.yml
vendored
10
.github/workflows/docs-required.yml
vendored
@@ -13,7 +13,17 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# #3833: this lane is `npm ci` plus every registered docs-guard test (~210s).
|
||||
# None of it means anything against a tree that cannot merge.
|
||||
preflight:
|
||||
name: PR mergeability
|
||||
uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
docs-lint:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
# This job supplies the ALREADY-REQUIRED `docs-lint` context
|
||||
# (.github/rulesets/main-protection.json) and runs `npm ci` plus every
|
||||
|
||||
18
.github/workflows/install-smoke.yml
vendored
18
.github/workflows/install-smoke.yml
vendored
@@ -55,7 +55,24 @@ jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 1: tarball install (existing canonical path)
|
||||
# ---------------------------------------------------------------------------
|
||||
# #3833: no compute lane runs while the PR has a merge conflict.
|
||||
#
|
||||
# This workflow is ALSO a workflow_call target (release.yml calls it for the
|
||||
# rc and finalize actions). Two consequences the design depends on:
|
||||
# 1. On those runs the event is workflow_dispatch, so the script no-ops in
|
||||
# ~15s with zero API calls and both smoke jobs run exactly as before.
|
||||
# 2. A nested reusable workflow may only DOWNGRADE its caller's token, so
|
||||
# release.yml's two calling jobs grant `pull-requests: read` — without
|
||||
# it the release run fails to start on a permissions escalation.
|
||||
preflight:
|
||||
name: PR mergeability
|
||||
uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
smoke:
|
||||
needs: preflight
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
@@ -200,6 +217,7 @@ jobs:
|
||||
# Job 2: unpacked-dir install
|
||||
# ---------------------------------------------------------------------------
|
||||
smoke-unpacked:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
|
||||
47
.github/workflows/mutation.yml
vendored
47
.github/workflows/mutation.yml
vendored
@@ -36,8 +36,17 @@ jobs:
|
||||
# ── Job 1: detect ─────────────────────────────────────────────────────────
|
||||
# Computes which covered modules changed and emits a matrix for the mutate job.
|
||||
# Intentionally does NOT run npm ci — it only needs git + Node builtins.
|
||||
# #3833: no compute lane runs while the PR has a merge conflict.
|
||||
preflight:
|
||||
name: PR mergeability
|
||||
uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
detect:
|
||||
name: Detect changed covered modules
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_work: ${{ steps.matrix.outputs.has_work }}
|
||||
@@ -127,16 +136,24 @@ jobs:
|
||||
# stryker.config.mjs (which reads process.env.MUTATION_BREAK for the
|
||||
# break threshold). See ADR-456 / issue #1187.
|
||||
# Note: Stryker 9.x has no --break CLI flag; the threshold is config-only.
|
||||
# Every matrix value is bound here and referenced as a shell variable
|
||||
# below. CONTRIBUTING.md's final rule forbids `${{ }}` inside a `run:`
|
||||
# block outright — binding via `env:` is the sanctioned form. Fixed as
|
||||
# part of #3833 rather than left in place for the next reader, per the
|
||||
# repo's no-defer rule.
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
MUTATION_TEST_CMD: node --test ${{ matrix.tests }}
|
||||
MUTATION_BREAK: ${{ matrix.minScore }}
|
||||
MODULE_NAME: ${{ matrix.name }}
|
||||
MUTATE_GLOB: ${{ matrix.mutate }}
|
||||
MODULE_TESTS: ${{ matrix.tests }}
|
||||
run: |
|
||||
echo "Module: ${{ matrix.name }}"
|
||||
echo "Mutate: ${{ matrix.mutate }}"
|
||||
echo "Tests: ${{ matrix.tests }}"
|
||||
echo "MinScore: ${{ matrix.minScore }}"
|
||||
npx stryker run --incremental --mutate "${{ matrix.mutate }}"
|
||||
echo "Module: ${MODULE_NAME}"
|
||||
echo "Mutate: ${MUTATE_GLOB}"
|
||||
echo "Tests: ${MODULE_TESTS}"
|
||||
echo "MinScore: ${MUTATION_BREAK}"
|
||||
npx stryker run --incremental --mutate "${MUTATE_GLOB}"
|
||||
|
||||
- name: Upload mutation report — ${{ matrix.name }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -157,7 +174,7 @@ jobs:
|
||||
# change. The per-module shards report as "Stryker (<module>)".
|
||||
mutation-gate:
|
||||
name: Stryker mutation score (changed files only)
|
||||
needs: [detect, mutate]
|
||||
needs: [detect, mutate, preflight]
|
||||
# has_work=false is rendered by SKIPPING this job entirely (job-level `if:`), not by an
|
||||
# early `exit 0` inside the step — a step that runs and exits 0 always reports `success`,
|
||||
# indistinguishable in the UI from "evaluated and passed". A job-level-skipped job reports
|
||||
@@ -167,13 +184,25 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate gate
|
||||
# Values arrive through `env:`, never interpolated into the script body —
|
||||
# CONTRIBUTING.md's final rule. They were inline `${{ }}` until #3833
|
||||
# touched this job; fixed here rather than left for the next reader.
|
||||
env:
|
||||
PREFLIGHT: ${{ needs.preflight.result }}
|
||||
DETECT: ${{ needs.detect.result }}
|
||||
MUTATE: ${{ needs.mutate.result }}
|
||||
run: |
|
||||
DETECT="${{ needs.detect.result }}"
|
||||
MUTATE="${{ needs.mutate.result }}"
|
||||
|
||||
echo "preflight result : ${PREFLIGHT}"
|
||||
echo "detect result : ${DETECT}"
|
||||
echo "mutate result : ${MUTATE}"
|
||||
|
||||
# #3833: report the real cause first. Without this arm a conflicted PR
|
||||
# skips `detect` and the gate blames scope detection for a conflict.
|
||||
if [ "${PREFLIGHT}" != "success" ]; then
|
||||
echo "FAIL: PR mergeability preflight did not pass (${PREFLIGHT}) — resolve the merge conflict and push again"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${DETECT}" != "success" ]; then
|
||||
echo "FAIL: detect job did not succeed (${DETECT})"
|
||||
exit 1
|
||||
|
||||
131
.github/workflows/pr-mergeable-preflight.yml
vendored
Normal file
131
.github/workflows/pr-mergeable-preflight.yml
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
name: PR Mergeability Preflight
|
||||
|
||||
# Reusable gate (#3833). A pull request with a merge conflict must run NO CI
|
||||
# until the conflict is resolved.
|
||||
#
|
||||
# Before this existed, the only conflict detector was scripts/ci-rebase-check.cjs,
|
||||
# which runs INSIDE each job after checkout — so a conflicted PR provisioned the
|
||||
# whole 12-cell test matrix, both install-smoke jobs, the Stryker shards and the
|
||||
# docs-guard lane, and every one of them failed with the same annotation. The
|
||||
# lanes with no rebase check at all ran to completion against a tree that cannot
|
||||
# merge, producing verdicts about a state that will never exist.
|
||||
#
|
||||
# ci-rebase-check.cjs is deliberately UNCHANGED and remains the authoritative
|
||||
# in-job backstop, including the #2472 base-sha pin. This workflow is an
|
||||
# early-exit optimization layered above it, never a replacement: every path
|
||||
# except an explicit `mergeable: false` fails OPEN.
|
||||
#
|
||||
# Callers add one job:
|
||||
#
|
||||
# preflight:
|
||||
# uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
# permissions:
|
||||
# contents: read
|
||||
# pull-requests: read
|
||||
#
|
||||
# and `needs: preflight` on each compute job. A calling job's `permissions`
|
||||
# REPLACES the workflow default rather than merging with it, so both scopes must
|
||||
# be named — branch-naming.yml's workflow default is `{}`, which would otherwise
|
||||
# starve the API read.
|
||||
#
|
||||
# This job deliberately applies NO label. #3833 originally proposed adding and
|
||||
# removing `needs-review: merge-conflict` here; that was dropped during
|
||||
# implementation for two reasons. Eight caller workflows each invoke this
|
||||
# preflight, so eight jobs would race to add-or-remove one label on every PR
|
||||
# event — label thrash and API contention. And it would force
|
||||
# `pull-requests: write` into eight lanes that today hold `contents: read`
|
||||
# (and one that holds `permissions: {}`), widening the token everywhere to
|
||||
# write a signal the red check already carries. `needs-review: merge-conflict`
|
||||
# remains a maintainer triage label applied during PR sweeps.
|
||||
#
|
||||
# The job is NOT guarded with `if: github.event_name == 'pull_request'`. A
|
||||
# SKIPPED dependency skips its dependents exactly as a failed one does, so that
|
||||
# guard would skip the entire pipeline on every push to `next` and on every
|
||||
# release.yml run that calls install-smoke.yml. Instead the job always runs and
|
||||
# scripts/ci-pr-mergeability.cjs no-ops (zero API calls, exit 0) whenever the
|
||||
# event is not `pull_request`.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
mergeable:
|
||||
description: >-
|
||||
'false' only when GitHub reported an explicit merge conflict.
|
||||
'true' for a clean PR, for an indeterminate read, and for any
|
||||
non-pull_request event — the gate is fail-open by design.
|
||||
value: ${{ jobs.mergeability.outputs.mergeable }}
|
||||
verdict:
|
||||
description: >-
|
||||
One of MERGEABLE | CONFLICTED | INDETERMINATE | SKIPPED_NOT_A_PR.
|
||||
value: ${{ jobs.mergeability.outputs.verdict }}
|
||||
|
||||
# Deliberately NO `concurrency:` block. A group name matching a caller's, with
|
||||
# cancel-in-progress on both sides, cancels the running CALLER.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
mergeability:
|
||||
name: mergeability
|
||||
runs-on: ubuntu-latest
|
||||
# The whole job is one sparse checkout plus at most five bounded API reads.
|
||||
# The script's own backoff across the full budget stays under 60s.
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
mergeable: ${{ steps.check.outputs.mergeable }}
|
||||
verdict: ${{ steps.check.outputs.verdict }}
|
||||
steps:
|
||||
- name: Check out the mergeability script from the base branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Pinned to the BASE sha, never the default ref, for two reasons.
|
||||
#
|
||||
# 1. Correctness — and this is the trap the whole design turns on.
|
||||
# For a pull_request event GitHub sets the default ref to
|
||||
# refs/pull/<n>/merge, which exists only "for open, MERGEABLE pull
|
||||
# requests". On a conflicted PR that ref is stale or absent, so a
|
||||
# default checkout fails on precisely the pull requests this job
|
||||
# exists to diagnose.
|
||||
#
|
||||
# 2. Security — the script that decides the gate then comes from a
|
||||
# trusted commit. No PR-supplied code is fetched or executed here,
|
||||
# which matches the base-context posture pr-template-format.yml
|
||||
# already uses ("Check out policy from base branch").
|
||||
#
|
||||
# Empty on a non-pull_request event, where actions/checkout falls back
|
||||
# to the triggering ref. That is fine: the script no-ops before it
|
||||
# reads anything.
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
fetch-depth: 1
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve pull request mergeability
|
||||
id: check
|
||||
env:
|
||||
# Every value arrives through env. CONTRIBUTING.md forbids `${{ }}`
|
||||
# inside a `run:` block.
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# Bootstrap arm. The checkout above is of the BASE sha, so this step
|
||||
# runs the script as it exists on the base branch — which is the whole
|
||||
# point (trusted code, and a usable ref on a conflicted PR), but it
|
||||
# means the script is absent on the one pull request that INTRODUCES
|
||||
# it, and on any PR branched from a base predating it. Absent is not
|
||||
# "conflicted": it is one more thing we cannot determine, so it takes
|
||||
# the same fail-open path as an unknown `mergeable`, reusing the
|
||||
# INDETERMINATE atom with its exact documented meaning. Self-healing —
|
||||
# once the script is on the base branch this arm never fires again.
|
||||
if [ ! -f scripts/ci-pr-mergeability.cjs ]; then
|
||||
echo "::warning::scripts/ci-pr-mergeability.cjs is not present at the base sha; skipping the mergeability preflight (fail-open). Expected on the PR that introduces it, or on a branch whose base predates it. scripts/ci-rebase-check.cjs remains the in-job backstop."
|
||||
{
|
||||
echo "verdict=INDETERMINATE"
|
||||
echo "mergeable=true"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
node scripts/ci-pr-mergeability.cjs
|
||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -340,6 +340,12 @@ jobs:
|
||||
if: inputs.action == 'rc'
|
||||
permissions:
|
||||
contents: read
|
||||
# #3833: install-smoke.yml now calls the mergeability preflight, which
|
||||
# requests pull-requests: read. A nested reusable workflow may only
|
||||
# DOWNGRADE its caller's token, so without this the whole release run
|
||||
# fails to start on a permissions escalation. On a release the preflight
|
||||
# itself no-ops (the event is not pull_request) and never uses the scope.
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/install-smoke.yml
|
||||
with:
|
||||
ref: ${{ needs.validate-version.outputs.branch }}
|
||||
@@ -528,6 +534,9 @@ jobs:
|
||||
if: inputs.action == 'finalize'
|
||||
permissions:
|
||||
contents: read
|
||||
# #3833: see install-smoke-rc — the nested preflight requests this scope,
|
||||
# and a nested reusable workflow can only downgrade, never elevate.
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/install-smoke.yml
|
||||
with:
|
||||
ref: ${{ needs.validate-version.outputs.branch }}
|
||||
|
||||
10
.github/workflows/security-scan.yml
vendored
10
.github/workflows/security-scan.yml
vendored
@@ -26,7 +26,17 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# #3833: no compute lane runs while the PR has a merge conflict. No-ops on
|
||||
# push and workflow_dispatch, so this dependency is unconditional.
|
||||
preflight:
|
||||
name: PR mergeability
|
||||
uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
security:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
# 30m (was 10m): the base64 scan is O(changed-files); a very large diff
|
||||
# (e.g. the #604 repo-wide rename, ~800 changed files) can exceed 10m. The
|
||||
|
||||
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -21,6 +21,21 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# #3833: a conflicted PR must provision no compute runner. The gate fails OPEN
|
||||
# on everything except an explicit `mergeable: false`, and it no-ops (exit 0,
|
||||
# zero API calls) on push and workflow_dispatch — so this job is safe to make
|
||||
# an unconditional dependency of the lanes below.
|
||||
#
|
||||
# `changes` is deliberately NOT gated: it is a 2-minute scope classifier that
|
||||
# runs in parallel with this job, and serializing it behind the preflight
|
||||
# would add latency to every healthy PR for no saving.
|
||||
preflight:
|
||||
name: PR mergeability
|
||||
uses: ./.github/workflows/pr-mergeable-preflight.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
changes:
|
||||
name: Detect test scope
|
||||
runs-on: ubuntu-latest
|
||||
@@ -85,6 +100,7 @@ jobs:
|
||||
# Static lint: test-suite and PR-check contracts.
|
||||
# Runs once, not per matrix lane.
|
||||
lint-tests:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -113,7 +129,7 @@ jobs:
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.os }}, ${{ matrix.node-version }}${{ matrix.shard && format(', shard {0}', matrix.shard) || '' }})
|
||||
needs: changes
|
||||
needs: [changes, preflight]
|
||||
if: needs.changes.outputs.product_changed == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
# #2952: the `scope: full` lane is sharded three ways (see the matrix
|
||||
@@ -335,7 +351,7 @@ jobs:
|
||||
|
||||
test-inert:
|
||||
name: test (inert CI)
|
||||
needs: changes
|
||||
needs: [changes, preflight]
|
||||
if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.product_changed != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -389,7 +405,7 @@ jobs:
|
||||
|
||||
test-full:
|
||||
name: full test (${{ matrix.os }}, ${{ matrix.node-version }}, shard ${{ matrix.shard }}/3)
|
||||
needs: changes
|
||||
needs: [changes, preflight]
|
||||
if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.full_matrix == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
@@ -554,7 +570,7 @@ jobs:
|
||||
# include/exclude globs changes, only where they are evaluated.
|
||||
coverage-gate:
|
||||
name: Coverage gate (merged shards)
|
||||
needs: [changes, test]
|
||||
needs: [changes, test, preflight]
|
||||
if: needs.changes.outputs.product_changed == 'true' && needs.test.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -627,7 +643,7 @@ jobs:
|
||||
# to walk on a docs-only change.
|
||||
qa-loop-walk:
|
||||
name: QA loop walk (smell ratchet)
|
||||
needs: changes
|
||||
needs: [changes, preflight]
|
||||
if: needs.changes.outputs.product_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -686,6 +702,7 @@ jobs:
|
||||
required-tests:
|
||||
name: Required tests
|
||||
needs:
|
||||
- preflight
|
||||
- changes
|
||||
- lint-tests
|
||||
- test
|
||||
@@ -699,6 +716,7 @@ jobs:
|
||||
steps:
|
||||
- name: Summarize required test gate
|
||||
env:
|
||||
PREFLIGHT_RESULT: ${{ needs.preflight.result }}
|
||||
CODE_CHANGED: ${{ needs.changes.outputs.code_changed }}
|
||||
PRODUCT_CHANGED: ${{ needs.changes.outputs.product_changed }}
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
@@ -710,6 +728,7 @@ jobs:
|
||||
QA_LOOP_WALK_RESULT: ${{ needs.qa-loop-walk.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "preflight=$PREFLIGHT_RESULT"
|
||||
echo "code_changed=$CODE_CHANGED"
|
||||
echo "product_changed=$PRODUCT_CHANGED"
|
||||
echo "changes=$CHANGES_RESULT"
|
||||
@@ -720,6 +739,18 @@ jobs:
|
||||
echo "coverage-gate=$COVERAGE_GATE_RESULT"
|
||||
echo "qa-loop-walk=$QA_LOOP_WALK_RESULT"
|
||||
|
||||
# #3833: the preflight arm comes FIRST and reports its own cause.
|
||||
# Without it a conflicted PR skips `changes` too, and this gate would
|
||||
# blame scope detection for a merge conflict. It also matters that
|
||||
# this job stays `if: always()` and exits NON-ZERO here: a required
|
||||
# status check that merely goes absent does not block a merge (see
|
||||
# the mutation-gate note in mutation.yml, #3319), so the required
|
||||
# `Required tests` context has to actually report red.
|
||||
if [ "$PREFLIGHT_RESULT" != "success" ]; then
|
||||
echo "::error::PR mergeability preflight did not pass ($PREFLIGHT_RESULT) — resolve the merge conflict and push again; no test lane runs until then"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$CHANGES_RESULT" != "success" ]; then
|
||||
echo "::error::test scope detection did not pass"
|
||||
exit 1
|
||||
|
||||
@@ -1252,6 +1252,23 @@ does not recognize `git stash`, `git rm --cached`, `git restore --staged`, or
|
||||
`git update-index --add`, any of which can also move `.planning/` content into a state a later
|
||||
commit picks up.
|
||||
|
||||
### A conflicted PR runs no CI
|
||||
|
||||
Every `pull_request` compute lane waits on one shared gate, `PR mergeability`.
|
||||
If GitHub reports your PR as having a merge conflict, **nothing runs** — no test
|
||||
matrix, no install smoke, no mutation shards, no docs or changeset lint — until
|
||||
you resolve it. The check annotates the base branch and the fix:
|
||||
|
||||
```bash
|
||||
git fetch origin && git rebase origin/next && git push --force-with-lease
|
||||
```
|
||||
|
||||
The gate fails **open**: if GitHub cannot tell us whether the PR is mergeable,
|
||||
the pipeline runs exactly as it did before, and the per-job
|
||||
`scripts/ci-rebase-check.cjs` still catches the conflict. Full reference,
|
||||
including which lanes are deliberately *not* gated, is in
|
||||
[docs/TESTING-SUITES.md → The mergeability preflight](docs/TESTING-SUITES.md#the-mergeability-preflight).
|
||||
|
||||
### CI Test Quality Checks
|
||||
|
||||
The following checks run on every PR in addition to the test suite:
|
||||
|
||||
@@ -350,6 +350,99 @@ Reported paths are labelled `CREATED`, `MODIFIED`, `DELETED`, or `UNVERIFIED`.
|
||||
`UNVERIFIED` means a scan bound was hit and the path could not be attested
|
||||
either way — it is never the same as clean.
|
||||
|
||||
## The mergeability preflight
|
||||
|
||||
Before any of the matrix below is provisioned, every `pull_request` compute lane
|
||||
waits on one shared gate: **`PR mergeability`**, the reusable workflow
|
||||
`.github/workflows/pr-mergeable-preflight.yml`. **A pull request with a merge
|
||||
conflict runs no CI at all until the conflict is resolved** (#3833).
|
||||
|
||||
### Reference
|
||||
|
||||
| Verdict | When | Job result | Effect on the pipeline |
|
||||
|---|---|---|---|
|
||||
| `MERGEABLE` | GitHub reported `mergeable: true` | success | everything runs as normal |
|
||||
| `CONFLICTED` | GitHub reported `mergeable: false` | **failure** | every gated job is skipped; `Required tests` and `Stryker mutation score` report **red** |
|
||||
| `INDETERMINATE` | mergeability still unknown after the retry budget, or the API read failed | success **(fails open)** | everything runs as normal; a `::warning::` is emitted |
|
||||
| `SKIPPED_NOT_A_PR` | the event is not `pull_request` (push, `workflow_dispatch`, a `release.yml` call into `install-smoke.yml`) | success | everything runs as normal; **zero** API calls |
|
||||
| `INDETERMINATE` (bootstrap) | `scripts/ci-pr-mergeability.cjs` is absent at the base sha | success **(fails open)** | everything runs as normal; a `::warning::` names the cause |
|
||||
|
||||
The bootstrap row is a consequence of the checkout being pinned to the base sha:
|
||||
the preflight runs the script **as it exists on the base branch**, so the script
|
||||
is absent on the pull request that introduces it, and on any branch whose base
|
||||
predates it. Absent is not "conflicted" — it is one more thing the gate cannot
|
||||
determine, so it takes the same fail-open path. The arm is self-healing and
|
||||
never fires again once the script is on the base branch; a test pins it in place
|
||||
so a future reader does not mistake it for dead code.
|
||||
|
||||
The verdict comes from `scripts/ci-pr-mergeability.cjs`, which polls
|
||||
`GET /repos/{owner}/{repo}/pulls/{number}` until `mergeable` is non-null.
|
||||
GitHub computes mergeability in an **asynchronous background job** and returns
|
||||
`null` while it is in progress, so a single cold read is never authoritative —
|
||||
the poll is required, not defensive.
|
||||
|
||||
Two properties are load-bearing and easy to break:
|
||||
|
||||
- **Only `mergeable === true` / `=== false` decides the verdict.** `null` is
|
||||
falsy, so a truthiness test (`if (!mergeable)`) would classify every cold read
|
||||
as a conflict and red every PR in the repo.
|
||||
- **`mergeable_state` never decides the verdict.** Its `blocked` value means
|
||||
"required checks have not passed", which is true *while this very job is
|
||||
running* — gating on it self-deadlocks the pipeline. It is read for the
|
||||
failure message only.
|
||||
|
||||
Gated: `test.yml` (`lint-tests`, `test`, `test-inert`, `test-full`,
|
||||
`coverage-gate`, `qa-loop-walk`, `required-tests`), `install-smoke.yml`,
|
||||
`mutation.yml`, `security-scan.yml`, `docs-required.yml`,
|
||||
`changeset-required.yml`, `default-flip-documentation.yml`, `branch-naming.yml`.
|
||||
|
||||
**Deliberately not gated:** the `pull_request_target` policy and security lanes —
|
||||
auto-close-unsolicited-PRs, close-draft-PRs, the target/title/template
|
||||
validators, issue-link, and unauthorized-approval dismissal. Gating those would
|
||||
let a conflicted drive-by PR evade auto-close, so they keep running on a
|
||||
conflicted PR and a test asserts they are never wired to the preflight.
|
||||
|
||||
### What it deliberately does not do
|
||||
|
||||
**It applies no label.** The gate does not add or remove `needs-review: merge-conflict`.
|
||||
Eight caller workflows each invoke the preflight, so eight jobs would race to
|
||||
add-or-remove one label on every PR event, and it would force
|
||||
`pull-requests: write` into eight lanes that today hold `contents: read`.
|
||||
`needs-review: merge-conflict` stays a maintainer triage label applied during PR
|
||||
sweeps; the red check and its annotation are the machine signal.
|
||||
|
||||
**It changes no branch protection.** `.github/rulesets/main-protection.json` is
|
||||
untouched and needs no new required context — GitHub natively refuses to merge a
|
||||
pull request with conflicts, so the ruleset already blocks it. (Adding the check
|
||||
as *required* before the workflow exists on the base branch would deadlock every
|
||||
open PR until it landed.)
|
||||
|
||||
### What this does not replace
|
||||
|
||||
`scripts/ci-rebase-check.cjs` is unchanged and still runs inside each matrix job,
|
||||
including its #2472 base-sha pin. The preflight is an early-exit optimization on
|
||||
GitHub's asynchronously-computed view; the in-job merge remains authoritative and
|
||||
covers the case where the base advances mid-run. **The gate is never a safety
|
||||
property** — every path except an explicit `mergeable: false` fails open, so an
|
||||
API outage simply restores the pre-#3833 behavior.
|
||||
|
||||
### If your PR shows a red `PR mergeability` check
|
||||
|
||||
The annotation names the base branch and the remedy. There is one step:
|
||||
|
||||
```bash
|
||||
git fetch origin && git rebase origin/next && git push --force-with-lease
|
||||
```
|
||||
|
||||
Resolve the conflicts the rebase reports, then push. The next `synchronize`
|
||||
event re-runs the preflight and the full pipeline comes back. (Because the
|
||||
sequence is a single command, this has no separate how-to page — see
|
||||
[CONTRIBUTING.md → Where Do I Open My PR?](../CONTRIBUTING.md#where-do-i-open-my-pr-branching-model)
|
||||
for the branching model that makes the rebase necessary in the first place.)
|
||||
|
||||
Note the interaction with the sha-bound pass marker: a rebase changes your HEAD
|
||||
sha, which invalidates any prior remote-runner verification. Rebase *last*.
|
||||
|
||||
## CI matrix
|
||||
|
||||
The `Tests` workflow runs every PR through a scoped gate generated by
|
||||
|
||||
262
scripts/ci-pr-mergeability.cjs
Normal file
262
scripts/ci-pr-mergeability.cjs
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ci-pr-mergeability.cjs — #3833 PR mergeability preflight.
|
||||
//
|
||||
// Risk asymmetry drives this design: a false positive (a clean PR wrongly
|
||||
// classified CONFLICTED) skips every gated compute lane in all eight gated
|
||||
// workflows on EVERY PR — a repo-wide outage. A false negative (a real
|
||||
// conflict missed) is today's behavior; the per-job scripts/ci-rebase-check.cjs
|
||||
// backstop still catches it there. So the weight is entirely on the
|
||||
// false-positive surface: the single predicate `mergeable === false`, and
|
||||
// every falsy-but-not-false value around it.
|
||||
//
|
||||
// GitHub's `mergeable` field on GET /repos/:owner/:repo/pulls/:number is a
|
||||
// documented tri-state: `true`, `false`, or `null` while a background
|
||||
// computation is still running. `null` is falsy — `if (!mergeable)` would
|
||||
// misclassify "still computing" as "conflicted" and would have red every PR
|
||||
// in the repo the moment this job started running. Only strict `=== false`
|
||||
// counts as a conflict.
|
||||
//
|
||||
// `mergeable_state` is read only for the human-facing annotation, never for
|
||||
// the verdict: its `blocked` value means "required checks have not passed",
|
||||
// which is true for every PR while THIS preflight job itself is one of the
|
||||
// required checks still running — classifying on it would self-deadlock the
|
||||
// pipeline (the gate can never go green because it is itself ungreen).
|
||||
//
|
||||
// Because `mergeable` starts null and GitHub computes it asynchronously, this
|
||||
// polls with linear backoff up to a small budget, then fails OPEN
|
||||
// (INDETERMINATE => exit 0) rather than blocking the world on an API that
|
||||
// hasn't finished thinking.
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
|
||||
const VERDICT = Object.freeze({
|
||||
MERGEABLE: 'MERGEABLE',
|
||||
CONFLICTED: 'CONFLICTED',
|
||||
INDETERMINATE: 'INDETERMINATE',
|
||||
SKIPPED_NOT_A_PR: 'SKIPPED_NOT_A_PR',
|
||||
});
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const BASE_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Pure, total classifier: payload -> VERDICT atom. Never throws.
|
||||
* Deliberately reads ONLY `mergeable`, and only with strict equality — see
|
||||
* the header comment for why `mergeable_state` and truthiness are excluded.
|
||||
*/
|
||||
function classifyMergeability(payload) {
|
||||
if (payload === null || typeof payload !== 'object') return VERDICT.INDETERMINATE;
|
||||
if (payload.mergeable === true) return VERDICT.MERGEABLE;
|
||||
if (payload.mergeable === false) return VERDICT.CONFLICTED;
|
||||
return VERDICT.INDETERMINATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear backoff: attempt 1..N-1 gives 1x..(N-1)x baseMs. Non-positive/NaN
|
||||
* attempts clamp to one base interval rather than producing 0 or NaN delays.
|
||||
*/
|
||||
function nextDelayMs(attempt, { baseMs = BASE_DELAY_MS } = {}) {
|
||||
const n = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 1;
|
||||
return baseMs * n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency-injected poll loop. Resolves as soon as GitHub reports a
|
||||
* definite mergeable/conflicted state, or gives up after maxAttempts and
|
||||
* reports INDETERMINATE (fail-open).
|
||||
*
|
||||
* @returns {Promise<{verdict:string, attempts:number, payload:*, reason:string}>}
|
||||
*/
|
||||
async function resolveMergeability({
|
||||
fetchPr,
|
||||
sleep,
|
||||
eventName,
|
||||
prNumber,
|
||||
maxAttempts = MAX_ATTEMPTS,
|
||||
baseDelayMs = BASE_DELAY_MS,
|
||||
} = {}) {
|
||||
if (eventName !== 'pull_request') {
|
||||
return { verdict: VERDICT.SKIPPED_NOT_A_PR, attempts: 0, payload: null, reason: 'not-a-pull-request' };
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
return { verdict: VERDICT.INDETERMINATE, attempts: 0, payload: null, reason: 'no-pr-number' };
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
let lastPayload = null;
|
||||
let lastError = null;
|
||||
|
||||
for (let i = 1; i <= maxAttempts; i++) {
|
||||
attempts = i;
|
||||
let payload;
|
||||
try {
|
||||
payload = await fetchPr(prNumber);
|
||||
lastPayload = payload;
|
||||
lastError = null;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
payload = null;
|
||||
}
|
||||
|
||||
const verdict = classifyMergeability(payload);
|
||||
if (verdict === VERDICT.MERGEABLE || verdict === VERDICT.CONFLICTED) {
|
||||
return { verdict, attempts, payload, reason: 'resolved' };
|
||||
}
|
||||
|
||||
if (i < maxAttempts) {
|
||||
await sleep(nextDelayMs(i, { baseMs: baseDelayMs }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
verdict: VERDICT.INDETERMINATE,
|
||||
attempts,
|
||||
payload: lastPayload,
|
||||
reason: lastError ? 'fetch-failed' : 'mergeability-not-computed',
|
||||
};
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
'Usage:',
|
||||
' node scripts/ci-pr-mergeability.cjs',
|
||||
'',
|
||||
'CI-only preflight: polls the GitHub API for a PR\'s mergeable state and',
|
||||
'fails the job (exit 1) only on a definite conflict. Everything else',
|
||||
'(unknown, unreadable, not a PR) fails open (exit 0).',
|
||||
'',
|
||||
'Environment variables read:',
|
||||
' GITHUB_EVENT_NAME workflow trigger event; only "pull_request" is polled',
|
||||
' GITHUB_REPOSITORY owner/repo',
|
||||
' GITHUB_TOKEN optional bearer token for the API read',
|
||||
' GITHUB_BASE_REF PR base branch name (default: next)',
|
||||
' GITHUB_API_URL GitHub API base URL (default: https://api.github.com)',
|
||||
' PR_NUMBER the pull request number',
|
||||
' GITHUB_OUTPUT path to append verdict=/mergeable= step outputs to',
|
||||
' GITHUB_STEP_SUMMARY path to append a human-readable summary to',
|
||||
' GSD_PR_MERGEABILITY_BASE_DELAY_MS optional numeric override of the base backoff, for tests',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
for (const arg of argv) {
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(`${usage()}\n`);
|
||||
throw new ExitError(0);
|
||||
}
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeOutput(lines) {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (typeof outputPath !== 'string' || outputPath === '') return;
|
||||
try {
|
||||
const fs = require('node:fs');
|
||||
fs.appendFileSync(outputPath, `${lines.join('\n')}\n`);
|
||||
} catch (err) {
|
||||
// A failure while REPORTING the verdict must never invert the gate.
|
||||
process.stderr.write(`::warning::failed to write GITHUB_OUTPUT: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSummary(text) {
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (typeof summaryPath !== 'string' || summaryPath === '') return;
|
||||
try {
|
||||
const fs = require('node:fs');
|
||||
fs.appendFileSync(summaryPath, `${text}\n`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`::warning::failed to write GITHUB_STEP_SUMMARY: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// `argv` defaults to real CLI argv but is a parameter so tests can call
|
||||
// main() in-process (e.g. main([])) without inheriting the test runner's own
|
||||
// argv, which would otherwise trip parseArgs's "unknown argument" branch.
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
parseArgs(argv);
|
||||
|
||||
const eventName = process.env.GITHUB_EVENT_NAME;
|
||||
const repo = process.env.GITHUB_REPOSITORY || '';
|
||||
const token = process.env.GITHUB_TOKEN || '';
|
||||
const baseBranch = process.env.GITHUB_BASE_REF || 'next';
|
||||
const apiBase = process.env.GITHUB_API_URL || 'https://api.github.com';
|
||||
const prNumberRaw = process.env.PR_NUMBER;
|
||||
const prNumber = Number.parseInt(prNumberRaw, 10);
|
||||
|
||||
const overrideDelay = Number(process.env.GSD_PR_MERGEABILITY_BASE_DELAY_MS);
|
||||
const baseDelayMs = Number.isFinite(overrideDelay) && overrideDelay >= 0 ? overrideDelay : BASE_DELAY_MS;
|
||||
|
||||
const fetchPr = async (number) => {
|
||||
const url = `${apiBase}/repos/${repo}/pulls/${number}`;
|
||||
const headers = {
|
||||
accept: 'application/vnd.github+json',
|
||||
'x-github-api-version': '2022-11-28',
|
||||
'user-agent': 'gsd-core-ci-pr-mergeability',
|
||||
};
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const response = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API responded ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const result = await resolveMergeability({
|
||||
fetchPr,
|
||||
sleep,
|
||||
eventName,
|
||||
prNumber: Number.isNaN(prNumber) ? NaN : prNumber,
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
baseDelayMs,
|
||||
});
|
||||
|
||||
const mergeable = result.verdict === VERDICT.CONFLICTED ? 'false' : 'true';
|
||||
writeOutput([`verdict=${result.verdict}`, `mergeable=${mergeable}`]);
|
||||
writeSummary(`PR mergeability preflight: ${result.verdict}`);
|
||||
|
||||
if (result.verdict === VERDICT.CONFLICTED) {
|
||||
process.stderr.write(
|
||||
`::error::This pull request cannot cleanly merge ${baseBranch}. Rebase onto current ${baseBranch} and push again; no CI will run until the conflict is resolved.\n`,
|
||||
);
|
||||
const state = result.payload && typeof result.payload === 'object' ? result.payload.mergeable_state : undefined;
|
||||
if (state) {
|
||||
process.stderr.write(`::error::mergeable_state: ${state}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (result.verdict === VERDICT.INDETERMINATE) {
|
||||
process.stderr.write(
|
||||
'::warning::GitHub did not report mergeability within the polling budget; proceeding (fail-open). scripts/ci-rebase-check.cjs remains the in-job backstop.\n',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.stdout.write(`PR mergeability preflight: ${result.verdict}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runMain(main);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VERDICT,
|
||||
MAX_ATTEMPTS,
|
||||
BASE_DELAY_MS,
|
||||
classifyMergeability,
|
||||
nextDelayMs,
|
||||
resolveMergeability,
|
||||
main,
|
||||
};
|
||||
@@ -39,6 +39,10 @@ const PROTECTED_WORKFLOWS = new Set([
|
||||
'mutation.yml',
|
||||
'security-scan.yml',
|
||||
'release.yml',
|
||||
// #3833: the reusable gate every `pull_request` compute lane depends on —
|
||||
// marking it inert would let a change to the gate itself ship without ever
|
||||
// running the full matrix it is responsible for enforcing.
|
||||
'pr-mergeable-preflight.yml',
|
||||
]);
|
||||
for (const wf of PROTECTED_WORKFLOWS) {
|
||||
if (INERT_WORKFLOWS.has(wf)) {
|
||||
@@ -341,19 +345,48 @@ function missingRuleTestFiles(rules) {
|
||||
return [...referenced].filter(f => !existsSync(path.join(__dirname, '..', f))).sort();
|
||||
}
|
||||
|
||||
// Fail loudly at module load, mirroring the PROTECTED_WORKFLOWS check above —
|
||||
// this fires on EVERY invocation of the CLI (including the real `changes` job
|
||||
// in .github/workflows/test.yml), not only when a test suite happens to run.
|
||||
{
|
||||
const missing = missingRuleTestFiles(RULES);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`ci-test-scope: RULES reference test file(s) that do not exist on disk ` +
|
||||
`(silent coverage hole — see #2758):\n ${missing.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Every PROTECTED_WORKFLOWS member that does not exist on disk.
|
||||
* The name list and the real filenames are two surfaces over one fact
|
||||
* (#3833): without this, renaming or deleting a gating workflow silently
|
||||
* un-protects it and CI stays green while the protection is gone. Same
|
||||
* shape and rationale as missingRuleTestFiles() above.
|
||||
*/
|
||||
function missingProtectedWorkflows(names = PROTECTED_WORKFLOWS) {
|
||||
return [...names]
|
||||
.filter(name => !existsSync(path.join(__dirname, '..', '.github', 'workflows', name)))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared module-load assertion for the two "this list names something that
|
||||
* does not exist on disk" guards above. Both are silent-coverage-hole guards:
|
||||
* a name that no longer resolves stops selecting/protecting anything while CI
|
||||
* stays green, so both must fail loudly at load rather than at test time.
|
||||
*/
|
||||
function assertNoneMissing(missing, summary, issueRef) {
|
||||
if (missing.length === 0) return;
|
||||
throw new Error(`ci-test-scope: ${summary} (${issueRef}):\n ${missing.join('\n ')}`);
|
||||
}
|
||||
|
||||
// Fail loudly at module load — this fires on EVERY invocation of the CLI
|
||||
// (including the real `changes` job in .github/workflows/test.yml), not only
|
||||
// when a test suite happens to run.
|
||||
assertNoneMissing(
|
||||
missingRuleTestFiles(RULES),
|
||||
'RULES reference test file(s) that do not exist on disk (silent coverage hole)',
|
||||
'see #2758',
|
||||
);
|
||||
|
||||
// A PROTECTED_WORKFLOWS entry naming a file that does not exist means the
|
||||
// workflow was renamed or deleted without updating this list, silently
|
||||
// un-protecting it while CI stays green.
|
||||
assertNoneMissing(
|
||||
missingProtectedWorkflows(PROTECTED_WORKFLOWS),
|
||||
'PROTECTED_WORKFLOWS reference workflow file(s) that do not exist on disk (silent protection hole)',
|
||||
'see #3833',
|
||||
);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
'Usage:',
|
||||
@@ -590,4 +623,4 @@ if (require.main === module) {
|
||||
runMain(main);
|
||||
}
|
||||
|
||||
module.exports = { RULES, missingRuleTestFiles };
|
||||
module.exports = { RULES, missingRuleTestFiles, PROTECTED_WORKFLOWS, INERT_WORKFLOWS, missingProtectedWorkflows };
|
||||
|
||||
955
tests/ci-pr-mergeability.test.cjs
Normal file
955
tests/ci-pr-mergeability.test.cjs
Normal file
@@ -0,0 +1,955 @@
|
||||
'use strict';
|
||||
|
||||
// #3833 — PR mergeability preflight.
|
||||
//
|
||||
// Risk asymmetry drives this suite (see .gsd/phase/.../50-test-matrix.md):
|
||||
// false positive (clean PR called CONFLICTED) => every compute lane in all
|
||||
// eight gated workflows skips, on every PR. Repo-wide outage.
|
||||
// false negative (conflict missed) => today's behavior; the per-job
|
||||
// scripts/ci-rebase-check.cjs backstop still catches it.
|
||||
// So the weight is on the false-positive surface: the single predicate
|
||||
// `mergeable === false`, and every falsy-but-not-false value around it.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const yaml = require('js-yaml');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'ci-pr-mergeability.cjs');
|
||||
const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
|
||||
const PREFLIGHT_WORKFLOW = 'pr-mergeable-preflight.yml';
|
||||
const PREFLIGHT_USES = `./.github/workflows/${PREFLIGHT_WORKFLOW}`;
|
||||
|
||||
const {
|
||||
VERDICT,
|
||||
MAX_ATTEMPTS,
|
||||
classifyMergeability,
|
||||
nextDelayMs,
|
||||
resolveMergeability,
|
||||
main,
|
||||
} = require('../scripts/ci-pr-mergeability.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes. The seams are parameters, not module patches — no global state, so
|
||||
// every case is order-independent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A fetchPr fake that replays a script of responses; a value that is an Error is thrown. */
|
||||
function scriptedFetch(responses) {
|
||||
const calls = [];
|
||||
const fn = async (prNumber) => {
|
||||
const index = calls.length;
|
||||
calls.push(prNumber);
|
||||
const next = index < responses.length ? responses[index] : responses[responses.length - 1];
|
||||
if (next instanceof Error) throw next;
|
||||
return next;
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** A sleep fake that records the delays it was asked for and never actually waits. */
|
||||
function recordingSleep() {
|
||||
const delays = [];
|
||||
const fn = async (ms) => { delays.push(ms); };
|
||||
fn.delays = delays;
|
||||
return fn;
|
||||
}
|
||||
|
||||
const PR = { number: 4242 };
|
||||
|
||||
function resolveWith(overrides) {
|
||||
return resolveMergeability({
|
||||
eventName: 'pull_request',
|
||||
prNumber: PR.number,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A. classifyMergeability — pure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ci-pr-mergeability: classifyMergeability', () => {
|
||||
test('classifies mergeable true as MERGEABLE', () => {
|
||||
assert.equal(classifyMergeability({ mergeable: true }), VERDICT.MERGEABLE);
|
||||
});
|
||||
|
||||
test('classifies mergeable false as CONFLICTED', () => {
|
||||
assert.equal(classifyMergeability({ mergeable: false }), VERDICT.CONFLICTED);
|
||||
});
|
||||
|
||||
test('classifies mergeable null as INDETERMINATE, never CONFLICTED', () => {
|
||||
// GitHub's documented "background computation is in progress" sentinel.
|
||||
assert.equal(classifyMergeability({ mergeable: null }), VERDICT.INDETERMINATE);
|
||||
});
|
||||
|
||||
test('classifies mergeable undefined as INDETERMINATE', () => {
|
||||
assert.equal(classifyMergeability({ mergeable: undefined }), VERDICT.INDETERMINATE);
|
||||
});
|
||||
|
||||
test('classifies an absent mergeable key as INDETERMINATE', () => {
|
||||
assert.equal(classifyMergeability({ id: 1 }), VERDICT.INDETERMINATE);
|
||||
});
|
||||
|
||||
test('never treats a falsy non-false mergeable as CONFLICTED', () => {
|
||||
// This is the row that forecloses `if (!mergeable) return CONFLICTED`.
|
||||
for (const value of [0, -0, '', NaN, null, undefined]) {
|
||||
assert.equal(
|
||||
classifyMergeability({ mergeable: value }),
|
||||
VERDICT.INDETERMINATE,
|
||||
`falsy value ${String(value)} must not be a conflict`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('never treats a truthy non-true mergeable as MERGEABLE', () => {
|
||||
for (const value of [1, 'true', 'false', {}, [], 'yes']) {
|
||||
assert.equal(
|
||||
classifyMergeability({ mergeable: value }),
|
||||
VERDICT.INDETERMINATE,
|
||||
`truthy value ${JSON.stringify(value)} must not be a clean verdict`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeable_state does not override a null mergeable', () => {
|
||||
assert.equal(
|
||||
classifyMergeability({ mergeable: null, mergeable_state: 'dirty' }),
|
||||
VERDICT.INDETERMINATE,
|
||||
);
|
||||
});
|
||||
|
||||
test('blocked mergeable_state is not a conflict', () => {
|
||||
// `blocked` means "required checks have not passed" — always true while
|
||||
// this very job is running. Classifying on it self-deadlocks the pipeline.
|
||||
assert.equal(
|
||||
classifyMergeability({ mergeable: true, mergeable_state: 'blocked' }),
|
||||
VERDICT.MERGEABLE,
|
||||
);
|
||||
});
|
||||
|
||||
test('only mergeable decides the verdict, across every mergeable_state', () => {
|
||||
for (const state of ['behind', 'unstable', 'draft', 'unknown', 'clean', 'dirty']) {
|
||||
assert.equal(
|
||||
classifyMergeability({ mergeable: true, mergeable_state: state }),
|
||||
VERDICT.MERGEABLE,
|
||||
`mergeable_state=${state} must not change a clean verdict`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('degrades a non-object payload to INDETERMINATE without throwing', () => {
|
||||
for (const value of [null, undefined, 0, 'str', true, 42]) {
|
||||
assert.equal(classifyMergeability(value), VERDICT.INDETERMINATE);
|
||||
}
|
||||
});
|
||||
|
||||
test('VERDICT is frozen and its atom set is locked', () => {
|
||||
assert.ok(Object.isFrozen(VERDICT));
|
||||
assert.deepEqual(
|
||||
Object.keys(VERDICT).sort(),
|
||||
['CONFLICTED', 'INDETERMINATE', 'MERGEABLE', 'SKIPPED_NOT_A_PR'],
|
||||
);
|
||||
});
|
||||
|
||||
test('CONFLICTED iff mergeable === false (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.anything(), (value) => {
|
||||
const verdict = classifyMergeability({ mergeable: value });
|
||||
return (verdict === VERDICT.CONFLICTED) === (value === false);
|
||||
}),
|
||||
{ seed: 3833, numRuns: 500, verbose: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B. nextDelayMs — pure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ci-pr-mergeability: nextDelayMs', () => {
|
||||
test('first retry waits one base interval', () => {
|
||||
assert.equal(nextDelayMs(1, { baseMs: 1000 }), 1000);
|
||||
});
|
||||
|
||||
test('backoff is strictly increasing and finite across the budget', () => {
|
||||
let previous = 0;
|
||||
for (let attempt = 1; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const delay = nextDelayMs(attempt, { baseMs: 1000 });
|
||||
assert.ok(Number.isFinite(delay), `delay for attempt ${attempt} must be finite`);
|
||||
assert.ok(delay > previous, `delay must increase: ${delay} !> ${previous}`);
|
||||
previous = delay;
|
||||
}
|
||||
});
|
||||
|
||||
test('clamps a non-positive attempt to one base interval', () => {
|
||||
for (const attempt of [0, -1, -99, NaN, undefined]) {
|
||||
assert.equal(nextDelayMs(attempt, { baseMs: 1000 }), 1000);
|
||||
}
|
||||
});
|
||||
|
||||
test('total backoff across the full budget stays within the job budget', () => {
|
||||
let total = 0;
|
||||
for (let attempt = 1; attempt < MAX_ATTEMPTS; attempt++) total += nextDelayMs(attempt);
|
||||
// The preflight job is capped at 3 minutes; the sleeps alone must not
|
||||
// approach it, or a slow API turns the optimization into a stall.
|
||||
assert.ok(total < 60_000, `total backoff ${total}ms must stay under 60s`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C. resolveMergeability — dependency-injected poll loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ci-pr-mergeability: resolveMergeability', () => {
|
||||
test('resolves on the first read without sleeping', async () => {
|
||||
const fetchPr = scriptedFetch([{ mergeable: true }]);
|
||||
const sleep = recordingSleep();
|
||||
const result = await resolveWith({ fetchPr, sleep });
|
||||
assert.equal(result.verdict, VERDICT.MERGEABLE);
|
||||
assert.equal(fetchPr.calls.length, 1);
|
||||
assert.equal(sleep.delays.length, 0);
|
||||
});
|
||||
|
||||
test('polls past a cold null read', async () => {
|
||||
const fetchPr = scriptedFetch([{ mergeable: null }, { mergeable: true }]);
|
||||
const sleep = recordingSleep();
|
||||
const result = await resolveWith({ fetchPr, sleep });
|
||||
assert.equal(result.verdict, VERDICT.MERGEABLE);
|
||||
assert.equal(fetchPr.calls.length, 2);
|
||||
assert.equal(sleep.delays.length, 1);
|
||||
});
|
||||
|
||||
test('stops polling the moment a conflict is known', async () => {
|
||||
const fetchPr = scriptedFetch([{ mergeable: false }]);
|
||||
const sleep = recordingSleep();
|
||||
const result = await resolveWith({ fetchPr, sleep });
|
||||
assert.equal(result.verdict, VERDICT.CONFLICTED);
|
||||
assert.equal(fetchPr.calls.length, 1);
|
||||
assert.equal(sleep.delays.length, 0);
|
||||
});
|
||||
|
||||
test('a late false is still a conflict', async () => {
|
||||
const fetchPr = scriptedFetch([
|
||||
{ mergeable: null }, { mergeable: null }, { mergeable: false },
|
||||
]);
|
||||
const result = await resolveWith({ fetchPr, sleep: recordingSleep() });
|
||||
assert.equal(result.verdict, VERDICT.CONFLICTED);
|
||||
assert.equal(fetchPr.calls.length, 3);
|
||||
});
|
||||
|
||||
test('resolves at limit-1 attempts', async () => {
|
||||
const responses = Array.from({ length: MAX_ATTEMPTS - 2 }, () => ({ mergeable: null }));
|
||||
responses.push({ mergeable: true });
|
||||
const fetchPr = scriptedFetch(responses);
|
||||
const result = await resolveWith({ fetchPr, sleep: recordingSleep() });
|
||||
assert.equal(result.verdict, VERDICT.MERGEABLE);
|
||||
assert.equal(fetchPr.calls.length, MAX_ATTEMPTS - 1);
|
||||
});
|
||||
|
||||
test('resolves on the final permitted attempt', async () => {
|
||||
const responses = Array.from({ length: MAX_ATTEMPTS - 1 }, () => ({ mergeable: null }));
|
||||
responses.push({ mergeable: true });
|
||||
const fetchPr = scriptedFetch(responses);
|
||||
const result = await resolveWith({ fetchPr, sleep: recordingSleep() });
|
||||
assert.equal(result.verdict, VERDICT.MERGEABLE);
|
||||
assert.equal(fetchPr.calls.length, MAX_ATTEMPTS);
|
||||
});
|
||||
|
||||
test('stops at the budget and never makes a sixth call', async () => {
|
||||
// The fake would answer null forever; the budget is what must stop it.
|
||||
const fetchPr = scriptedFetch([{ mergeable: null }]);
|
||||
const sleep = recordingSleep();
|
||||
const result = await resolveWith({ fetchPr, sleep });
|
||||
assert.equal(result.verdict, VERDICT.INDETERMINATE);
|
||||
assert.equal(fetchPr.calls.length, MAX_ATTEMPTS);
|
||||
assert.equal(sleep.delays.length, MAX_ATTEMPTS - 1);
|
||||
});
|
||||
|
||||
test('honours a budget of one', async () => {
|
||||
const fetchPr = scriptedFetch([{ mergeable: null }]);
|
||||
const sleep = recordingSleep();
|
||||
const result = await resolveWith({ fetchPr, sleep, maxAttempts: 1 });
|
||||
assert.equal(result.verdict, VERDICT.INDETERMINATE);
|
||||
assert.equal(fetchPr.calls.length, 1);
|
||||
assert.equal(sleep.delays.length, 0);
|
||||
});
|
||||
|
||||
test('a non-positive budget makes no API call', async () => {
|
||||
for (const maxAttempts of [0, -1]) {
|
||||
const fetchPr = scriptedFetch([{ mergeable: false }]);
|
||||
const result = await resolveWith({ fetchPr, sleep: recordingSleep(), maxAttempts });
|
||||
assert.equal(result.verdict, VERDICT.INDETERMINATE);
|
||||
assert.equal(fetchPr.calls.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a throwing fetch degrades to INDETERMINATE', async () => {
|
||||
const fetchPr = scriptedFetch([new Error('ECONNRESET')]);
|
||||
const result = await resolveWith({ fetchPr, sleep: recordingSleep() });
|
||||
assert.equal(result.verdict, VERDICT.INDETERMINATE);
|
||||
assert.equal(fetchPr.calls.length, MAX_ATTEMPTS);
|
||||
});
|
||||
|
||||
test('recovers from a transient fetch error', async () => {
|
||||
const fetchPr = scriptedFetch([new Error('503'), { mergeable: true }]);
|
||||
const result = await resolveWith({ fetchPr, sleep: recordingSleep() });
|
||||
assert.equal(result.verdict, VERDICT.MERGEABLE);
|
||||
assert.equal(fetchPr.calls.length, 2);
|
||||
});
|
||||
|
||||
test('a conflict after a transient error is still reported', async () => {
|
||||
const fetchPr = scriptedFetch([new Error('503'), { mergeable: false }]);
|
||||
const result = await resolveWith({ fetchPr, sleep: recordingSleep() });
|
||||
assert.equal(result.verdict, VERDICT.CONFLICTED);
|
||||
});
|
||||
|
||||
test('makes no API call on a push event', async () => {
|
||||
const fetchPr = scriptedFetch([{ mergeable: false }]);
|
||||
const sleep = recordingSleep();
|
||||
const result = await resolveMergeability({
|
||||
eventName: 'push', prNumber: PR.number, fetchPr, sleep,
|
||||
});
|
||||
assert.equal(result.verdict, VERDICT.SKIPPED_NOT_A_PR);
|
||||
assert.equal(fetchPr.calls.length, 0);
|
||||
assert.equal(sleep.delays.length, 0);
|
||||
});
|
||||
|
||||
test('makes no API call on workflow_dispatch', async () => {
|
||||
const fetchPr = scriptedFetch([{ mergeable: false }]);
|
||||
const result = await resolveMergeability({
|
||||
eventName: 'workflow_dispatch', prNumber: PR.number, fetchPr, sleep: recordingSleep(),
|
||||
});
|
||||
assert.equal(result.verdict, VERDICT.SKIPPED_NOT_A_PR);
|
||||
assert.equal(fetchPr.calls.length, 0);
|
||||
});
|
||||
|
||||
test('an unknown event name makes no API call', async () => {
|
||||
for (const eventName of ['', undefined, 'schedule', 'release']) {
|
||||
const fetchPr = scriptedFetch([{ mergeable: false }]);
|
||||
const result = await resolveMergeability({
|
||||
eventName, prNumber: PR.number, fetchPr, sleep: recordingSleep(),
|
||||
});
|
||||
assert.equal(result.verdict, VERDICT.SKIPPED_NOT_A_PR);
|
||||
assert.equal(fetchPr.calls.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a missing PR number resolves INDETERMINATE without calling', async () => {
|
||||
for (const prNumber of [undefined, null, 0, -1, NaN, '12']) {
|
||||
const fetchPr = scriptedFetch([{ mergeable: true }]);
|
||||
const result = await resolveMergeability({
|
||||
eventName: 'pull_request', prNumber, fetchPr, sleep: recordingSleep(),
|
||||
});
|
||||
assert.equal(result.verdict, VERDICT.INDETERMINATE);
|
||||
assert.equal(fetchPr.calls.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('successive resolutions do not share state', async () => {
|
||||
const first = scriptedFetch([{ mergeable: null }]);
|
||||
await resolveWith({ fetchPr: first, sleep: recordingSleep() });
|
||||
assert.equal(first.calls.length, MAX_ATTEMPTS);
|
||||
|
||||
const second = scriptedFetch([{ mergeable: true }]);
|
||||
const result = await resolveWith({ fetchPr: second, sleep: recordingSleep() });
|
||||
assert.equal(result.verdict, VERDICT.MERGEABLE);
|
||||
assert.equal(second.calls.length, 1, 'the second run must start from a fresh budget');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D. main() — integration through the process seam against a real local API.
|
||||
//
|
||||
// The CLI's real fetch path is exercised by pointing GITHUB_API_URL at a
|
||||
// throwaway localhost server, so no production test-mode branch exists and
|
||||
// nothing reaches api.github.com.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SENTINEL_TOKEN = 'ghs_sentinel_must_never_be_echoed_3833';
|
||||
|
||||
/** Start a one-shot API stub. `handler(requestCount)` returns { status, body }. */
|
||||
async function startApiStub(handler) {
|
||||
let requestCount = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
const { status, body } = handler(requestCount++);
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
get requestCount() { return requestCount; },
|
||||
};
|
||||
}
|
||||
|
||||
function runCli(env, { outputPath } = {}) {
|
||||
return runNode([SCRIPT], {
|
||||
cwd: ROOT,
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_REPOSITORY: 'open-gsd/gsd-core',
|
||||
GITHUB_TOKEN: SENTINEL_TOKEN,
|
||||
GITHUB_BASE_REF: 'next',
|
||||
PR_NUMBER: String(PR.number),
|
||||
GSD_PR_MERGEABILITY_BASE_DELAY_MS: '1',
|
||||
...(outputPath ? { GITHUB_OUTPUT: outputPath } : {}),
|
||||
...env,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// `runNode` (tests/helpers/process-seam.cjs) is spawnSync: it blocks this
|
||||
// process's event loop for the whole child lifetime. `startApiStub` is an
|
||||
// http.createServer living in THIS process, so while spawnSync blocks, the
|
||||
// stub can never accept the child's connection — the child's fetch hangs on
|
||||
// every attempt until its own AbortSignal.timeout fires, five attempts in a
|
||||
// row, blowing past PROBE_TIMEOUT_MS. The observed symptom was exitCode:null
|
||||
// with empty stdout/stderr (caught by the remote runner, not locally, since
|
||||
// the deadlock's exact timing depends on scheduler contention). Any test that
|
||||
// needs the stub must instead call main() in-process, which keeps this
|
||||
// process's event loop live so the stub can actually answer.
|
||||
/**
|
||||
* Invoke main() in-process with a controlled environment, capturing writes.
|
||||
* See the comment above for why this exists instead of routing through the
|
||||
* runNode/spawnSync seam whenever an in-process HTTP stub is involved.
|
||||
*/
|
||||
async function callMain(t, env, { outputPath } = {}) {
|
||||
const overrides = {
|
||||
GITHUB_REPOSITORY: 'open-gsd/gsd-core',
|
||||
GITHUB_TOKEN: SENTINEL_TOKEN,
|
||||
GITHUB_BASE_REF: 'next',
|
||||
PR_NUMBER: String(PR.number),
|
||||
GSD_PR_MERGEABILITY_BASE_DELAY_MS: '1',
|
||||
...(outputPath ? { GITHUB_OUTPUT: outputPath } : {}),
|
||||
...env,
|
||||
};
|
||||
const saved = new Map();
|
||||
for (const key of Object.keys(overrides)) saved.set(key, process.env[key]);
|
||||
Object.assign(process.env, overrides);
|
||||
t.after(() => {
|
||||
for (const [key, value] of saved) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
t.mock.method(process.stdout, 'write', (chunk) => { stdout.push(String(chunk)); return true; });
|
||||
t.mock.method(process.stderr, 'write', (chunk) => { stderr.push(String(chunk)); return true; });
|
||||
|
||||
const code = await main([]);
|
||||
return { code, stdout: stdout.join(''), stderr: stderr.join('') };
|
||||
}
|
||||
|
||||
function readOutputs(outputPath) {
|
||||
const raw = fs.readFileSync(outputPath, 'utf8');
|
||||
const outputs = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const index = line.indexOf('=');
|
||||
if (index > 0) outputs[line.slice(0, index)] = line.slice(index + 1);
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
// Split by what each case actually needs: stub-backed cases call main()
|
||||
// in-process via callMain() (spawnSync would deadlock against the
|
||||
// in-process HTTP stub — see the comment above callMain()); the remaining
|
||||
// argv/exit-code-as-a-process cases stay on the runCli()/runNode seam since
|
||||
// they assert real process argv parsing and exit-code plumbing.
|
||||
describe('ci-pr-mergeability: CLI', () => {
|
||||
test('exits 0 and writes a skip verdict on a push event', (t) => {
|
||||
const dir = createTempDir('mergeability-skip-');
|
||||
t.after(() => cleanup(dir));
|
||||
const outputPath = path.join(dir, 'gh-output');
|
||||
|
||||
const result = runCli({ GITHUB_EVENT_NAME: 'push' }, { outputPath });
|
||||
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
const outputs = readOutputs(outputPath);
|
||||
assert.equal(outputs.verdict, VERDICT.SKIPPED_NOT_A_PR);
|
||||
assert.equal(outputs.mergeable, 'true');
|
||||
});
|
||||
|
||||
test('exits 1 and annotates the conflict', async (t) => {
|
||||
const dir = createTempDir('mergeability-conflict-');
|
||||
const api = await startApiStub(() => ({ status: 200, body: { mergeable: false, mergeable_state: 'dirty' } }));
|
||||
t.after(async () => { await api.close(); cleanup(dir); });
|
||||
const outputPath = path.join(dir, 'gh-output');
|
||||
|
||||
const result = await callMain(
|
||||
t,
|
||||
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
||||
{ outputPath },
|
||||
);
|
||||
|
||||
assert.equal(result.code, 1, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const combined = `${result.stdout}${result.stderr}`;
|
||||
assert.ok(combined.includes('::error::'), 'must emit a workflow error annotation');
|
||||
assert.ok(combined.includes('next'), 'the annotation must name the base branch');
|
||||
assert.equal(readOutputs(outputPath).verdict, VERDICT.CONFLICTED);
|
||||
});
|
||||
|
||||
test('exits 0 on a mergeable PR', async (t) => {
|
||||
const dir = createTempDir('mergeability-clean-');
|
||||
const api = await startApiStub(() => ({ status: 200, body: { mergeable: true, mergeable_state: 'clean' } }));
|
||||
t.after(async () => { await api.close(); cleanup(dir); });
|
||||
const outputPath = path.join(dir, 'gh-output');
|
||||
|
||||
const result = await callMain(
|
||||
t,
|
||||
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
||||
{ outputPath },
|
||||
);
|
||||
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
const outputs = readOutputs(outputPath);
|
||||
assert.equal(outputs.verdict, VERDICT.MERGEABLE);
|
||||
assert.equal(outputs.mergeable, 'true');
|
||||
});
|
||||
|
||||
test('fails open with a warning when mergeability is unknown', async (t) => {
|
||||
const dir = createTempDir('mergeability-unknown-');
|
||||
const api = await startApiStub(() => ({ status: 200, body: { mergeable: null } }));
|
||||
t.after(async () => { await api.close(); cleanup(dir); });
|
||||
const outputPath = path.join(dir, 'gh-output');
|
||||
|
||||
const result = await callMain(
|
||||
t,
|
||||
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
||||
{ outputPath },
|
||||
);
|
||||
|
||||
assert.equal(result.code, 0, `an unknown mergeability must not block: ${result.stderr}`);
|
||||
const combined = `${result.stdout}${result.stderr}`;
|
||||
assert.ok(combined.includes('::warning::'), 'must warn');
|
||||
assert.ok(!combined.includes('::error::'), 'must not emit an error annotation');
|
||||
assert.equal(readOutputs(outputPath).verdict, VERDICT.INDETERMINATE);
|
||||
assert.equal(api.requestCount, MAX_ATTEMPTS, 'must exhaust exactly the budget');
|
||||
});
|
||||
|
||||
test('fails open when the API rejects the read', async (t) => {
|
||||
const dir = createTempDir('mergeability-403-');
|
||||
const api = await startApiStub(() => ({ status: 403, body: { message: 'Resource not accessible' } }));
|
||||
t.after(async () => { await api.close(); cleanup(dir); });
|
||||
const outputPath = path.join(dir, 'gh-output');
|
||||
|
||||
const result = await callMain(
|
||||
t,
|
||||
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
||||
{ outputPath },
|
||||
);
|
||||
|
||||
assert.equal(result.code, 0, 'an unreadable PR is not an unmergeable PR');
|
||||
assert.equal(readOutputs(outputPath).verdict, VERDICT.INDETERMINATE);
|
||||
});
|
||||
|
||||
test('fails open when the API body is not a JSON object', async (t) => {
|
||||
const dir = createTempDir('mergeability-badjson-');
|
||||
const api = await startApiStub(() => ({ status: 200, body: 'not json at all' }));
|
||||
t.after(async () => { await api.close(); cleanup(dir); });
|
||||
const outputPath = path.join(dir, 'gh-output');
|
||||
|
||||
const result = await callMain(
|
||||
t,
|
||||
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
||||
{ outputPath },
|
||||
);
|
||||
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(readOutputs(outputPath).verdict, VERDICT.INDETERMINATE);
|
||||
});
|
||||
|
||||
test('works when GITHUB_OUTPUT is not set', (t) => {
|
||||
const dir = createTempDir('mergeability-nooutput-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const result = runCli({ GITHUB_EVENT_NAME: 'push', GITHUB_OUTPUT: '' });
|
||||
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
});
|
||||
|
||||
test('an unwritable GITHUB_OUTPUT does not change the exit code', async (t) => {
|
||||
// A failure while REPORTING the verdict must never invert the gate.
|
||||
// Injected by pointing at a path whose parent does not exist — no mode-bit
|
||||
// tricks, which root Docker/CI silently bypasses.
|
||||
const dir = createTempDir('mergeability-badout-');
|
||||
const api = await startApiStub(() => ({ status: 200, body: { mergeable: false } }));
|
||||
t.after(async () => { await api.close(); cleanup(dir); });
|
||||
const outputPath = path.join(dir, 'no-such-dir', 'gh-output');
|
||||
|
||||
const result = await callMain(
|
||||
t,
|
||||
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
||||
{ outputPath },
|
||||
);
|
||||
|
||||
assert.equal(result.code, 1, 'a conflict must still exit 1 when the output write fails');
|
||||
});
|
||||
|
||||
test('never echoes the token', async (t) => {
|
||||
const dir = createTempDir('mergeability-token-');
|
||||
const api = await startApiStub(() => ({ status: 500, body: { message: 'boom' } }));
|
||||
t.after(async () => { await api.close(); cleanup(dir); });
|
||||
|
||||
const result = await callMain(
|
||||
t,
|
||||
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
||||
{ outputPath: path.join(dir, 'gh-output') },
|
||||
);
|
||||
|
||||
assert.ok(!`${result.stdout}${result.stderr}`.includes(SENTINEL_TOKEN));
|
||||
});
|
||||
|
||||
test('reports a failure without a stack trace', async (t) => {
|
||||
const dir = createTempDir('mergeability-nostack-');
|
||||
const api = await startApiStub(() => ({ status: 200, body: { mergeable: false } }));
|
||||
t.after(async () => { await api.close(); cleanup(dir); });
|
||||
|
||||
const result = await callMain(
|
||||
t,
|
||||
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
||||
{ outputPath: path.join(dir, 'gh-output') },
|
||||
);
|
||||
|
||||
assert.ok(!`${result.stdout}${result.stderr}`.includes(' at '), 'no raw stack frames');
|
||||
});
|
||||
|
||||
test('prints usage', () => {
|
||||
const result = runNode([SCRIPT, '--help'], { cwd: ROOT, timeoutMs: PROBE_TIMEOUT_MS });
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.ok(/Usage/i.test(result.stdout));
|
||||
});
|
||||
|
||||
test('rejects an unknown argument', () => {
|
||||
const result = runNode([SCRIPT, '--nope'], { cwd: ROOT, timeoutMs: PROBE_TIMEOUT_MS });
|
||||
assert.notEqual(result.exitCode, 0);
|
||||
assert.ok(`${result.stdout}${result.stderr}`.includes('--nope'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E. Workflow wiring — structural assertions on the parsed YAML object graph.
|
||||
//
|
||||
// CONTRIBUTING.md:842 bans raw-text matching on test outputs regardless of
|
||||
// whether the text came from a source file — broader than local/no-source-grep's
|
||||
// .cjs/.js/.ts scope. Every assertion below reads the parsed object graph
|
||||
// (yaml.load), not the raw file text, EXCEPT the single absence check further
|
||||
// down ('security and policy lanes are never gated on mergeability'): a
|
||||
// structural read cannot see a comment or an unreachable key, so that one
|
||||
// test deliberately keeps a raw scan, justified inline at its call site.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** workflow file -> job ids that must be gated on the preflight. */
|
||||
const GATED = Object.freeze({
|
||||
'test.yml': ['lint-tests', 'test', 'test-inert', 'test-full', 'coverage-gate', 'qa-loop-walk', 'required-tests'],
|
||||
'install-smoke.yml': ['smoke', 'smoke-unpacked'],
|
||||
'mutation.yml': ['detect', 'mutation-gate'],
|
||||
'security-scan.yml': ['security'],
|
||||
'docs-required.yml': ['docs-lint'],
|
||||
'changeset-required.yml': ['changeset-lint'],
|
||||
'default-flip-documentation.yml': ['default-flip-documentation'],
|
||||
'branch-naming.yml': ['check-branch'],
|
||||
});
|
||||
|
||||
/** Lanes that must keep running on a conflicted PR — security and policy. */
|
||||
const NEVER_GATED = Object.freeze([
|
||||
'auto-close-unsolicited-prs.yml',
|
||||
'close-draft-prs.yml',
|
||||
'pr-target-validator.yml',
|
||||
'pr-title-validator.yml',
|
||||
'pr-template-format.yml',
|
||||
'require-issue-link.yml',
|
||||
'dismiss-unauthorized-pr-approvals.yml',
|
||||
]);
|
||||
|
||||
function loadWorkflow(name) {
|
||||
return yaml.load(fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf8'));
|
||||
}
|
||||
|
||||
function rawWorkflow(name) {
|
||||
return fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf8');
|
||||
}
|
||||
|
||||
function needsOf(job) {
|
||||
const needs = job && job.needs;
|
||||
if (!needs) return [];
|
||||
return Array.isArray(needs) ? needs : [needs];
|
||||
}
|
||||
|
||||
function preflightJobIds(doc) {
|
||||
return Object.entries(doc.jobs || {})
|
||||
.filter(([, job]) => job && job.uses === PREFLIGHT_USES)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
describe('ci-pr-mergeability: workflow wiring', () => {
|
||||
test('the preflight workflow exists and is call-only', () => {
|
||||
const doc = loadWorkflow(PREFLIGHT_WORKFLOW);
|
||||
// `on: workflow_call` parses to the key `true` under YAML 1.1 unless quoted;
|
||||
// accept either shape rather than pinning the parser's quirk.
|
||||
const triggers = doc.on || doc[true];
|
||||
assert.deepEqual(Object.keys(triggers), ['workflow_call']);
|
||||
assert.equal(doc.concurrency, undefined, 'a matching concurrency group would cancel the caller');
|
||||
});
|
||||
|
||||
function findCheckoutStep() {
|
||||
const doc = loadWorkflow(PREFLIGHT_WORKFLOW);
|
||||
const job = doc.jobs.mergeability;
|
||||
const step = (job.steps || []).find(
|
||||
(s) => typeof s.uses === 'string' && s.uses.startsWith('actions/checkout@'),
|
||||
);
|
||||
assert.ok(step, 'the mergeability job must have an actions/checkout step');
|
||||
return step;
|
||||
}
|
||||
|
||||
test('the preflight checks out the base sha, never the merge ref', () => {
|
||||
const step = findCheckoutStep();
|
||||
const ref = step.with && step.with.ref;
|
||||
assert.equal(typeof ref, 'string');
|
||||
// For a CONFLICTED PR refs/pull/N/merge is stale or absent, so a default
|
||||
// checkout fails on exactly the PRs this job exists to diagnose; pinning
|
||||
// the base sha also means no PR-supplied code is fetched or executed.
|
||||
assert.ok(
|
||||
ref.includes('github.event.pull_request.base.sha'),
|
||||
'the checkout must pin ref: to pull_request.base.sha',
|
||||
);
|
||||
});
|
||||
|
||||
test('the preflight checkout is shallow, sparse, and credential-free', () => {
|
||||
const step = findCheckoutStep();
|
||||
const { with: withArgs } = step;
|
||||
assert.equal(withArgs['fetch-depth'], 1, 'fetch-depth must be the number 1');
|
||||
// The point is it checks out only what it runs.
|
||||
assert.equal(typeof withArgs['sparse-checkout'], 'string');
|
||||
assert.equal(withArgs['sparse-checkout'].trim(), 'scripts');
|
||||
assert.equal(withArgs['persist-credentials'], false, 'must not persist credentials');
|
||||
});
|
||||
|
||||
test('the preflight fails open when the script is absent at the base sha', () => {
|
||||
// The checkout above pins ref: to pull_request.base.sha, so `node
|
||||
// scripts/ci-pr-mergeability.cjs` runs the copy that exists on the BASE
|
||||
// branch, not on the PR. On the very PR that introduces this script, the
|
||||
// base has no such file; the same is true for any PR whose base predates
|
||||
// it. An absent script is not evidence of a conflict — it is undetermined
|
||||
// — so the run step must take the same fail-open path as an unknown
|
||||
// `mergeable`, not fail the job. That arm only ever fires while the base
|
||||
// lacks the script, so once the fix lands on the base it goes silent and
|
||||
// looks, to a future reader, like dead code safe to delete. This test
|
||||
// exists to keep it from being deleted.
|
||||
const doc = loadWorkflow(PREFLIGHT_WORKFLOW);
|
||||
const step = (doc.jobs.mergeability.steps || []).find((s) => s.id === 'check');
|
||||
assert.ok(step, 'the mergeability job must have a step with id: check');
|
||||
const { run } = step;
|
||||
assert.equal(typeof run, 'string', 'the check step must have a run: script');
|
||||
|
||||
assert.match(
|
||||
run,
|
||||
/if\s+\[\s+!\s+-f\s+scripts\/ci-pr-mergeability\.cjs\s+\]/,
|
||||
'the script must guard on the absence of scripts/ci-pr-mergeability.cjs before invoking it',
|
||||
);
|
||||
assert.ok(
|
||||
run.includes('::warning::'),
|
||||
'an absent script at the base sha must warn, not error',
|
||||
);
|
||||
assert.ok(
|
||||
!run.includes('::error::'),
|
||||
'an absent script at the base sha must not emit an error annotation',
|
||||
);
|
||||
assert.ok(
|
||||
run.includes('verdict=INDETERMINATE'),
|
||||
'the bootstrap arm must report verdict=INDETERMINATE, the same as any other unresolved read',
|
||||
);
|
||||
assert.ok(
|
||||
run.includes('mergeable=true'),
|
||||
'the bootstrap arm must fail open with mergeable=true so downstream gates are not blocked',
|
||||
);
|
||||
assert.ok(
|
||||
run.includes('exit 0'),
|
||||
'the bootstrap arm must exit 0 so the job itself does not go red on a missing script',
|
||||
);
|
||||
assert.ok(
|
||||
run.includes('node scripts/ci-pr-mergeability.cjs'),
|
||||
'the normal path must still invoke the real script once it exists at the base sha',
|
||||
);
|
||||
assert.ok(
|
||||
!run.includes('${{'),
|
||||
'CONTRIBUTING.md: run: blocks must not interpolate a GitHub expression',
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, jobIds] of Object.entries(GATED)) {
|
||||
test(`${name} calls the preflight`, () => {
|
||||
const ids = preflightJobIds(loadWorkflow(name));
|
||||
assert.equal(ids.length, 1, `${name} must have exactly one preflight caller job, found ${ids.length}`);
|
||||
});
|
||||
|
||||
test(`${name}: every gated compute job needs the preflight`, () => {
|
||||
const doc = loadWorkflow(name);
|
||||
const [callerId] = preflightJobIds(doc);
|
||||
for (const jobId of jobIds) {
|
||||
const job = (doc.jobs || {})[jobId];
|
||||
assert.ok(job, `${name}: job "${jobId}" not found — the gate list has drifted from the workflow`);
|
||||
assert.ok(
|
||||
needsOf(job).includes(callerId),
|
||||
`${name}: job "${jobId}" must declare needs: ${callerId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${name}: the preflight caller grants the scopes the preflight needs`, () => {
|
||||
// A calling job's `permissions` REPLACES the workflow default; it does
|
||||
// not merge. branch-naming.yml's workflow default is `{}`, so an implicit
|
||||
// inherit would starve the API read.
|
||||
const doc = loadWorkflow(name);
|
||||
const [callerId] = preflightJobIds(doc);
|
||||
const permissions = (doc.jobs || {})[callerId].permissions;
|
||||
assert.equal(permissions && permissions['contents'], 'read', `${name}: caller needs contents: read`);
|
||||
assert.equal(permissions && permissions['pull-requests'], 'read', `${name}: caller needs pull-requests: read`);
|
||||
});
|
||||
}
|
||||
|
||||
test('release.yml grants install-smoke the scopes its nested preflight requests', () => {
|
||||
// release.yml -> install-smoke.yml -> pr-mergeable-preflight.yml is a
|
||||
// nested reusable-workflow chain. A nested job may only DOWNGRADE the
|
||||
// caller's permissions, so a request the caller never granted fails the
|
||||
// whole release run before a single step executes.
|
||||
const doc = loadWorkflow('release.yml');
|
||||
const callers = Object.entries(doc.jobs || {})
|
||||
.filter(([, job]) => job && job.uses === './.github/workflows/install-smoke.yml');
|
||||
assert.ok(callers.length >= 2, 'expected release.yml to call install-smoke.yml');
|
||||
for (const [id, job] of callers) {
|
||||
assert.equal(job.permissions && job.permissions['pull-requests'], 'read',
|
||||
`release.yml job "${id}" must grant pull-requests: read for the nested preflight`);
|
||||
}
|
||||
});
|
||||
|
||||
test('security and policy lanes are never gated on mergeability', () => {
|
||||
// Deliberate raw scan, not a structural read: this asserts ABSENCE
|
||||
// anywhere in the file, and the parsed object graph cannot see a
|
||||
// commented-out or otherwise unreachable reference — exactly what this
|
||||
// test must still catch.
|
||||
for (const name of NEVER_GATED) {
|
||||
const raw = rawWorkflow(name);
|
||||
assert.ok(
|
||||
!raw.includes(PREFLIGHT_WORKFLOW),
|
||||
`${name} must keep running on a conflicted PR — a gated auto-close lets a drive-by PR evade it`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no run block added by this change interpolates a GitHub expression', () => {
|
||||
// CONTRIBUTING.md, final line: no ${{ }} in `run:` blocks — bind via env:.
|
||||
for (const name of [PREFLIGHT_WORKFLOW, 'mutation.yml']) {
|
||||
const doc = loadWorkflow(name);
|
||||
for (const [jobId, job] of Object.entries(doc.jobs || {})) {
|
||||
for (const step of (job.steps || [])) {
|
||||
if (typeof step.run !== 'string') continue;
|
||||
assert.ok(
|
||||
!step.run.includes('${{'),
|
||||
`${name}: jobs.${jobId} has a run block interpolating an expression; bind it via env:`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('required-tests reports red rather than absent when the preflight fails', () => {
|
||||
const doc = loadWorkflow('test.yml');
|
||||
const job = doc.jobs['required-tests'];
|
||||
assert.equal(job.if, 'always()', 'required-tests must still evaluate when upstream jobs skip');
|
||||
const script = (job.steps || []).map((s) => s.run || '').join('\n');
|
||||
assert.ok(
|
||||
/PREFLIGHT_RESULT/.test(script),
|
||||
'required-tests must have an explicit preflight arm so the required context goes red, not absent',
|
||||
);
|
||||
});
|
||||
|
||||
test('a gated job whose if() overrides the implicit success() must check the preflight result itself', () => {
|
||||
// GitHub Actions applies an implicit success() to a job-level `if:` unless
|
||||
// that `if:` already contains a status-check function: "By default, GitHub
|
||||
// Actions applies a success() check to steps unless another status function
|
||||
// is specified" and "you must explicitly include failure() to override the
|
||||
// default success() check that is otherwise applied automatically."
|
||||
//
|
||||
// So for a job with a plain `if:` (no status function), `needs: preflight`
|
||||
// alone correctly skips it when the preflight fails — nothing extra is
|
||||
// required. But the moment a job's `if:` contains always(), failure(), or
|
||||
// cancelled(), that implicit success() is gone and `needs:` stops gating
|
||||
// it. Such a job runs even on a conflicted PR and must therefore check the
|
||||
// preflight result itself.
|
||||
//
|
||||
// Today exactly two gated jobs are in that category and both already
|
||||
// handle it: test.yml's required-tests (if: always(), with a
|
||||
// PREFLIGHT_RESULT arm) and mutation.yml's mutation-gate (if: always() &&
|
||||
// ..., with a PREFLIGHT arm). This test exists to catch a FUTURE edit that
|
||||
// adds always() to, say, `test` or `coverage-gate` — which would silently
|
||||
// make the whole gate inert on the most expensive lanes while every
|
||||
// existing assertion kept passing.
|
||||
const STATUS_FUNCTION = /always\(\)|failure\(\)|cancelled\(\)/;
|
||||
const violations = [];
|
||||
let inspected = 0;
|
||||
for (const [name, jobIds] of Object.entries(GATED)) {
|
||||
const doc = loadWorkflow(name);
|
||||
for (const jobId of jobIds) {
|
||||
const job = (doc.jobs || {})[jobId];
|
||||
const condition = typeof job.if === 'string' ? job.if : '';
|
||||
if (!STATUS_FUNCTION.test(condition)) continue;
|
||||
inspected++;
|
||||
const checksPreflightInIf = /needs\.preflight\.result/.test(condition);
|
||||
const stepsText = JSON.stringify(job.steps || []);
|
||||
const checksPreflightInSteps = /PREFLIGHT/.test(stepsText);
|
||||
if (!checksPreflightInIf && !checksPreflightInSteps) {
|
||||
violations.push(`${name}:${jobId} (if: ${condition})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
inspected > 0,
|
||||
'expected at least one gated job with a status-function if(); a vacuous pass here means the invariant is no longer being checked',
|
||||
);
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
`these jobs override the implicit success() with a status function in if:, so \`needs: preflight\` no longer gates them — each must test needs.preflight.result (or read PREFLIGHT_RESULT in a step) itself: ${violations.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F. ci-test-scope ripple — the preflight workflow must never be marked inert.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ci-pr-mergeability: ci-test-scope protection', () => {
|
||||
const scope = require('../scripts/ci-test-scope.cjs');
|
||||
|
||||
test('the preflight workflow is protected from being marked inert', () => {
|
||||
assert.ok(
|
||||
scope.PROTECTED_WORKFLOWS.has(PREFLIGHT_WORKFLOW),
|
||||
'a workflow that gates every compute lane must never be demoted to the inert lane',
|
||||
);
|
||||
});
|
||||
|
||||
test('every protected workflow name refers to a real file', () => {
|
||||
// Parity assertion: the literal name list and the real filenames are two
|
||||
// surfaces over one fact. Without this, a rename silently un-protects a
|
||||
// gating workflow and CI stays green.
|
||||
const missing = [...scope.PROTECTED_WORKFLOWS]
|
||||
.filter((name) => !fs.existsSync(path.join(WORKFLOWS_DIR, name)))
|
||||
.sort();
|
||||
assert.deepEqual(missing, [], `PROTECTED_WORKFLOWS names files that do not exist: ${missing.join(', ')}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user