From 1573f4191bedfd6639751a51b95ae6958723e9cb Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sat, 17 Oct 2026 18:43:44 +0200 Subject: [PATCH] Gate selective CI with one required result check --- .github/workflows/ci.yml | 65 +++++++++++++++++-- scripts/check-ci-results.mjs | 57 +++++++++++++++++ tests/js/ci_gate.spec.ts | 121 +++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 scripts/check-ci-results.mjs create mode 100644 tests/js/ci_gate.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60f4fc73..47cf6f39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,14 +11,10 @@ on: - "README.md" - ".github/workflows/release.yml" - ".github/workflows/official-site.yml" + # Required checks must be reported for every PR, including docs-only changes. pull_request: branches: - "main" - paths-ignore: - - "docs/**" - - "README.md" - - ".github/workflows/release.yml" - - ".github/workflows/official-site.yml" merge_group: concurrency: @@ -32,7 +28,31 @@ env: REGISTRY_IMAGE: lovasoa/sqlpage jobs: + ci_selection: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + # Only a confirmed excluded-files-only PR may skip the core jobs. + full: ${{ github.event_name != 'pull_request' || steps.paths.outputs.full != 'false' }} + steps: + - id: paths + name: Select test jobs + # The PR files API is limited to 3000 files; larger PRs select core jobs. + if: github.event_name == 'pull_request' && github.event.pull_request.changed_files <= 3000 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + with: + predicate-quantifier: every + filters: | + full: + - '!docs/**' + - '!README.md' + - '!.github/workflows/release.yml' + - '!.github/workflows/official-site.yml' + compile_and_lint: + needs: ci_selection + if: needs.ci_selection.outputs.full == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -66,6 +86,8 @@ jobs: path: "target/debug/sqlpage" published_crate: + needs: ci_selection + if: needs.ci_selection.outputs.full == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -150,6 +172,8 @@ jobs: MALLOC_PERTURB_: 10 windows_test: + needs: ci_selection + if: needs.ci_selection.outputs.full == 'true' runs-on: windows-latest steps: - uses: actions/checkout@v7 @@ -214,6 +238,8 @@ jobs: retention-days: 30 docker_build: + needs: ci_selection + if: needs.ci_selection.outputs.full == 'true' name: docker_build (${{ matrix.platform }}, ${{ matrix.variant }}) runs-on: ubuntu-latest strategy: @@ -335,6 +361,8 @@ jobs: if-no-files-found: error retention-days: 1 hurl_examples: + needs: ci_selection + if: needs.ci_selection.outputs.full == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -397,7 +425,7 @@ jobs: hurl: name: hurl (${{ matrix.example }}) runs-on: ubuntu-latest - if: ${{ needs.hurl_examples.outputs.examples != '[]' }} + if: ${{ needs.hurl_examples.result == 'success' && needs.hurl_examples.outputs.examples != '[]' }} timeout-minutes: 15 needs: - docker_build @@ -437,6 +465,31 @@ jobs: - name: Run example Hurl test run: scripts/test-examples-hurl.sh "${{ matrix.example }}" + # Require this single check in branch protection instead of individual jobs. + ci_gate: + name: CI gate + if: always() + needs: + - ci_selection + - compile_and_lint + - published_crate + - test + - windows_test + - playwright + - docker_build + - hurl_examples + - hurl + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - name: Check selected job results + run: node scripts/check-ci-results.mjs + env: + NEEDS_JSON: ${{ toJSON(needs) }} + docker_push: runs-on: ubuntu-latest if: github.event_name == 'push' diff --git a/scripts/check-ci-results.mjs b/scripts/check-ci-results.mjs new file mode 100644 index 00000000..5ef2e79f --- /dev/null +++ b/scripts/check-ci-results.mjs @@ -0,0 +1,57 @@ +// This always-run gate accepts only skips justified by successful selection. +function fail(message) { + console.error(message); + process.exit(1); +} + +let jobs; +try { + jobs = JSON.parse(process.env.NEEDS_JSON ?? ""); +} catch { + fail("Missing or invalid CI job results."); +} +if (!jobs || typeof jobs !== "object" || Array.isArray(jobs)) + fail("CI job results must be an object."); + +if (jobs.ci_selection?.result !== "success") + fail("CI change detection did not succeed."); +const full = jobs.ci_selection.outputs?.full; +if (full !== "true" && full !== "false") + fail("CI change detection returned an ambiguous selection."); + +for (const [name, job] of Object.entries(jobs)) { + if (!["success", "skipped"].includes(job?.result)) + fail(`${name} did not pass: ${job?.result ?? "missing result"}.`); +} + +if (full === "true") { + for (const name of [ + "compile_and_lint", + "published_crate", + "test", + "windows_test", + "playwright", + "docker_build", + "hurl_examples", + ]) { + if (jobs[name]?.result !== "success") + fail(`Required job ${name} did not run successfully.`); + } + + let examples; + try { + examples = JSON.parse(jobs.hurl_examples.outputs?.examples ?? ""); + } catch { + fail("Hurl selection returned invalid JSON."); + } + if ( + !Array.isArray(examples) || + !examples.every((item) => typeof item === "string" && item.length > 0) + ) + fail("Hurl selection must be an array of example names."); + const allowed = examples.length > 0 ? ["success"] : ["success", "skipped"]; + if (!allowed.includes(jobs.hurl?.result)) + fail("Selected Hurl tests did not run successfully."); +} + +console.log("All selected CI jobs passed."); diff --git a/tests/js/ci_gate.spec.ts b/tests/js/ci_gate.spec.ts new file mode 100644 index 00000000..d79ef482 --- /dev/null +++ b/tests/js/ci_gate.spec.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +type Job = { result: string; outputs?: Record }; +const root = fileURLToPath(new URL("../../", import.meta.url)); +const coreJobs = [ + "compile_and_lint", + "published_crate", + "test", + "windows_test", + "playwright", + "docker_build", + "hurl_examples", +]; + +function results(full = true, examples: string[] = []) { + const jobs: Record = Object.fromEntries( + coreJobs.map((name) => [name, { result: full ? "success" : "skipped" }]), + ); + jobs.ci_selection = { result: "success", outputs: { full: String(full) } }; + if (full) + jobs.hurl_examples = { + result: "success", + outputs: { examples: JSON.stringify(examples) }, + }; + jobs.hurl = { result: full && examples.length > 0 ? "success" : "skipped" }; + return jobs; +} + +function check(jobs: unknown) { + const run = spawnSync(process.execPath, ["scripts/check-ci-results.mjs"], { + cwd: root, + env: { ...process.env, NEEDS_JSON: JSON.stringify(jobs) }, + encoding: "utf8", + timeout: 5_000, + }); + assert.ifError(run.error); + assert.equal(run.signal, null, run.stderr); + return run.status; +} + +test("CI gate accepts intentionally skipped suites for a docs-only PR", () => { + assert.equal(check(results(false)), 0); +}); + +test("CI gate accepts successful suites and an empty Hurl selection", () => { + assert.equal(check(results()), 0); +}); + +test("CI gate accepts the selected Hurl examples after they pass", () => { + assert.equal(check(results(true, ["examples/single sign on"])), 0); +}); + +for (const name of [...coreJobs, "ci_selection", "hurl"]) { + for (const result of ["failure", "cancelled"]) { + test(`CI gate blocks ${name} ${result}`, () => { + const jobs = results(true, ["examples/single sign on"]); + jobs[name] = { ...jobs[name], result }; + assert.notEqual(check(jobs), 0); + }); + } +} + +for (const name of coreJobs) { + test(`CI gate blocks an unexpected ${name} skip`, () => { + const jobs = results(); + jobs[name] = { ...jobs[name], result: "skipped" }; + assert.notEqual(check(jobs), 0); + }); +} + +test("CI gate blocks skipped Hurl tests when examples were selected", () => { + const jobs = results(true, ["examples/single sign on"]); + jobs.hurl = { result: "skipped" }; + assert.notEqual(check(jobs), 0); +}); + +test("CI gate blocks missing results from a required job", () => { + const jobs = results(); + delete jobs.test; + assert.notEqual(check(jobs), 0); +}); + +for (const full of [undefined, "", "unknown"]) { + test(`CI gate blocks an ambiguous selection: ${String(full)}`, () => { + const jobs = results(false); + jobs.ci_selection = { + result: "success", + outputs: full === undefined ? {} : { full }, + }; + assert.notEqual(check(jobs), 0); + }); +} + +test("CI gate blocks a skipped change detector", () => { + const jobs = results(false); + jobs.ci_selection = { result: "skipped" }; + assert.notEqual(check(jobs), 0); +}); + +for (const examples of ["", "invalid-json", "null", "{}", '[""]', "[true]"]) { + test(`CI gate blocks an invalid Hurl selection: ${examples}`, () => { + const jobs = results(); + jobs.hurl_examples = { result: "success", outputs: { examples } }; + assert.notEqual(check(jobs), 0); + }); +} + +test("CI gate rejects failed jobs even on a docs-only selection", () => { + const jobs = results(false); + jobs.test = { result: "failure" }; + assert.notEqual(check(jobs), 0); +}); + +for (const jobs of [null, {}, [], { ci_selection: { result: "success" } }]) { + test(`CI gate blocks malformed or incomplete result data: ${JSON.stringify(jobs)}`, () => { + assert.notEqual(check(jobs), 0); + }); +}