Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
57 changes: 57 additions & 0 deletions scripts/check-ci-results.mjs
Original file line number Diff line number Diff line change
@@ -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.");
121 changes: 121 additions & 0 deletions tests/js/ci_gate.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };
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<string, Job> = 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);
});
}
Loading