diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc45d28c..8c17eb4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,13 @@ name: CI on: pull_request: workflow_call: + outputs: + run_quickstart: + description: Whether main changed a quickstart consumer + value: ${{ jobs.changes.outputs.run_quickstart }} workflow_dispatch: + schedule: + - cron: '41 5 * * 1' permissions: contents: read @@ -19,7 +25,9 @@ jobs: outputs: has_packages: ${{ steps.detect.outputs.has_packages }} matrix: ${{ steps.detect.outputs.matrix }} - base: ${{ steps.base.outputs.sha }} + run_insights: ${{ steps.detect.outputs.run_insights }} + run_tooling: ${{ steps.detect.outputs.run_tooling }} + run_quickstart: ${{ steps.detect.outputs.run_quickstart }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -39,13 +47,19 @@ jobs: else echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" fi + # Keep this guard unconditional and dependency-free, not coupled to npm ci. + - name: Validate release intent + env: + BASE: ${{ steps.base.outputs.sha }} + run: node scripts/release.mjs check "$BASE" "$GITHUB_SHA" - id: detect env: - BASE: ${{ github.event_name != 'workflow_dispatch' && steps.base.outputs.sha || '' }} + BASE: ${{ !contains(fromJSON('["workflow_dispatch", "schedule"]'), github.event_name) && steps.base.outputs.sha || '' }} run: node scripts/ci.mjs "$BASE" "$GITHUB_SHA" tooling: needs: changes + if: needs.changes.outputs.run_tooling == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -65,10 +79,6 @@ jobs: run: npm run test:tooling - name: Audit tooling dependencies run: npm audit --audit-level=high - - name: Validate release intent - env: - BASE: ${{ needs.changes.outputs.base }} - run: npm run release:check -- "$BASE" "$GITHUB_SHA" packages: needs: changes @@ -141,6 +151,8 @@ jobs: retention-days: 30 insights: + needs: changes + if: needs.changes.outputs.run_insights == 'true' name: Herdr Insights runs-on: ubuntu-latest timeout-minutes: 10 @@ -176,13 +188,16 @@ jobs: run: | node -e ' const jobs = JSON.parse(process.env.RESULTS); - for (const name of ["changes", "tooling", "insights", "secrets"]) { + for (const name of ["changes", "secrets"]) { if (jobs[name].result !== "success") process.exit(1); } const expected = { - packages: jobs.changes.outputs.has_packages === "true" + packages: jobs.changes.outputs.has_packages, + tooling: jobs.changes.outputs.run_tooling, + insights: jobs.changes.outputs.run_insights }; - for (const [name, required] of Object.entries(expected)) { - if (jobs[name].result !== (required ? "success" : "skipped")) process.exit(1); + for (const [name, selected] of Object.entries(expected)) { + if (!["true", "false"].includes(selected)) process.exit(1); + if (jobs[name].result !== (selected === "true" ? "success" : "skipped")) process.exit(1); } ' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2f81697b..731c8a40 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,6 +31,18 @@ jobs: - id: plan run: node scripts/release.mjs check "$(git rev-parse HEAD^1)" "$(git rev-parse HEAD)" + # Wait for publication when applicable, but skip expensive downloads for + # Herdr/docs-only pushes. Scheduled/manual Quickstart CI remains independent. + quickstart: + needs: [validate, eligible, publish] + if: >- + always() && !cancelled() && + needs.validate.result == 'success' && + needs.eligible.result == 'success' && + (needs.publish.result == 'success' || needs.publish.result == 'skipped') && + needs.validate.outputs.run_quickstart == 'true' + uses: ./.github/workflows/quickstart.yml + publish: needs: eligible if: needs.eligible.outputs.release == 'true' diff --git a/.github/workflows/quickstart.yml b/.github/workflows/quickstart.yml index 912138a6..d7b35246 100644 --- a/.github/workflows/quickstart.yml +++ b/.github/workflows/quickstart.yml @@ -1,31 +1,26 @@ name: Quickstart CI on: + workflow_call: workflow_dispatch: schedule: - cron: "23 7 * * *" - workflow_run: - workflows: ["Main CI and releases"] - types: [completed] - branches: [main] permissions: contents: read concurrency: - group: quickstart-${{ github.event_name }}-${{ github.event.workflow_run.id || github.sha }} + group: quickstart-${{ github.event_name }}-${{ github.sha }} cancel-in-progress: false jobs: quickstart: name: Package downloads and root install - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 83ff6305..810ccab5 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -6,10 +6,33 @@ Short-lived PR branches merge into `main`. There is no `develop` branch or long-lived release branch. Ordinary merges **never publish to npm**. GitHub Actions runs `.github/workflows/ci.yml` on every PR, including fork PRs -without secrets. `publish.yml` calls that same reusable CI on every push to `main`. It detects affected packages; -shared tooling/workflow/release-plan changes validate all packages. Root -documentation-only changes still run tooling and secret checks. The stable -required status is **CI passed**. +without secrets. `publish.yml` calls that same reusable CI on every push to `main`. +`scripts/ci.mjs` selects checks by ownership rather than treating every root-level +change as shared Pi code. The stable required status is **CI passed**; it requires +success for selected jobs and accepts skips only for explicitly unselected jobs. + +| Changed paths | Selected checks | +| --- | --- | +| A Pi package, including its tests | That package | +| `herdr-insights/`, `tests/herdr-insights/`, `docs/herdr-insights-development.md` | Herdr | +| `scripts/herdr-insights/` | Herdr plus tooling lint/tests | +| Shared package runner/build/release scripts, root lockfile, ESLint config | All Pi packages plus tooling | +| Root `package.json`, CI routing code/tests, workflow definitions | All packages, Herdr, and tooling | +| `.release/plan.json` | Packages named by the plan (old plan when deleted) | +| Other tooling tests, dependency/security/token/hook scripts | Tooling | +| Ordinary root documentation and GitHub metadata | No package suites | +| Unclassified paths | Full validation, with paths reported in logs and the job summary | + +Release intent validation runs unconditionally in the dependency-free `changes` +job. Secret scanning remains unconditional. Herdr retains its Node 22.15.0, +22, and 24 matrix, but no longer runs on unrelated Pi changes. Package-owned +Markdown still selects its package: distribution tests can consume documentation. + +Manual CI dispatch and the weekly full sweep validate every package, Herdr, and +tooling. PR detection compares the merge checkout with its first parent; main +pushes compare the event's before SHA with its head, including multi-commit pushes. +Moves are treated as deletion plus addition so both owners receive checks. +Tests stay in their existing package directories. Each affected package runs: @@ -224,8 +247,8 @@ pulling a Docker image for every matrix leg. Bump that version deliberately in Snyk requires the repository secret `SNYK_TOKEN`. Generate one at → Auth Token and set it under Settings → Secrets and variables → Actions. Fork pull requests cannot read -repository secrets, so the Snyk steps are skipped there; the tooling and secret -lanes still run. `publish.yml` passes `secrets: inherit` to `ci.yml` so the +repository secrets, so the Snyk steps are skipped there; selected checks and the +unconditional secret lane still run. `publish.yml` passes `secrets: inherit` to `ci.yml` so the scan also gates `main`. Add `--org=` to the two commands only when the token must report under a specific organization. @@ -235,7 +258,20 @@ Snyk's free tier covers public repositories without a monthly test limit. ## Quickstart CI -`quickstart.yml` validates the README's live quickstart after the main CI/release workflow completes successfully, on a daily schedule, and on manual dispatch. It installs the pinned Pi CLI into an isolated temporary agent directory, runs `pi install .` from a fresh checkout without `node_modules`, verifies Pi starts far enough to print help, waits through npm's post-publish processing window, and downloads every publishable package from npm at the exact version in its manifest. The post-release trigger plus registry polling avoids checking a new version before the publish job has made it downloadable. +`quickstart.yml` validates the README's live quickstart on a daily schedule and +manual dispatch. Main also calls it as a reusable workflow when the ownership +map selects quickstart: Pi package changes, release plans, quickstart scripts, +shared package tooling, or broad/full validation. Herdr-only and ordinary +documentation pushes skip downloads and installation. + +The main caller waits for successful validation and release eligibility, and for +publication to succeed or be intentionally skipped. It uses the same commit SHA, +not a later main checkout. It installs the pinned Pi CLI into an isolated temporary +agent directory, runs `pi install .` from a fresh checkout without `node_modules`, +verifies Pi starts far enough to print help, waits through npm's post-publish +processing window, and downloads every publishable package from npm at the exact +version in its manifest. Waiting for publication plus registry polling avoids +checking a new version before the publish job has made it downloadable. ## Dependency and Actions updates diff --git a/scripts/ci.mjs b/scripts/ci.mjs index e7c2795d..16eeaaad 100644 --- a/scripts/ci.mjs +++ b/scripts/ci.mjs @@ -1,27 +1,106 @@ import { appendFileSync } from "node:fs"; import { packages } from "./packages.mjs"; import { git, isMain } from "./lib.mjs"; +import { planPath, validatePlan } from "./release.mjs"; -export function affectedPackages(files) { - const names = Object.keys(packages); - const shared = files.some(file => - !names.some(name => file.startsWith(name + "/")) && - !/^(?:(?:README\.md|LICENSE|llms\.txt)$|(?:docs|reports)\/)/.test(file)); - return shared ? names : names.filter(name => files.some(file => file.startsWith(name + "/"))); +const packageNames = Object.keys(packages); +const packageTooling = new Set([ + "package-lock.json", "eslint.config.mjs", "scripts/packages.mjs", + "scripts/lib.mjs", "scripts/test-package.mjs", "scripts/package.mjs", + "scripts/artifact-smoke.mjs", "scripts/release.mjs", +]); + +// Ownership, not directory depth, determines which checks consume a change. +// Unknown paths conservatively select full validation, never no checks. +export function affectedChecks(files, { releasePackages } = {}) { + const selected = new Set(); + let insights = false, tooling = false, quickstart = false; + const unknown = []; + const allPackages = () => { packageNames.forEach(name => selected.add(name)); }; + const all = () => { allPackages(); insights = tooling = quickstart = true; }; + for (const file of files) { + const owner = packageNames.find(name => file.startsWith(name + "/")); + if (owner) { + selected.add(owner); + quickstart = true; + } else if (/^(herdr-insights|tests\/herdr-insights|scripts\/herdr-insights)\//.test(file) + || file === "docs/herdr-insights-development.md") { + insights = true; + // These scripts are also linted by the tooling job. + if (file.startsWith("scripts/")) tooling = true; + } else if (file === "package.json" || file === "scripts/ci.mjs" + || file === "scripts/tests/ci.test.mjs" || file.startsWith(".github/workflows/")) { + all(); + } else if (packageTooling.has(file)) { + allPackages(); + tooling = quickstart = true; + } else if (file === planPath) { + for (const name of releasePackages) { + if (!Object.hasOwn(packages, name)) throw new Error(`Unknown release package: ${name}`); + selected.add(name); + } + quickstart = true; + } else if (file === "scripts/quickstart-ci.mjs" || file === "scripts/tests/quickstart-ci.test.mjs") { + tooling = quickstart = true; + } else if (file.startsWith("scripts/tests/") || [ + "scripts/dependency-check.mjs", "scripts/tokens.mjs", "scripts/install-gitleaks.sh", + "scripts/secret-scan.sh", "scripts/install-hooks.sh", ".gitleaks.toml", ".githooks/pre-commit", + ].includes(file)) { + tooling = true; + } else if (/^(?:(?:README\.md|LICENSE|llms\.txt)$|(?:docs|reports)\/)/.test(file) + || [".github/CODEOWNERS", ".github/dependabot.yml", ".github/pull_request_template.md"].includes(file) + || file.startsWith(".github/ISSUE_TEMPLATE/")) { + // Documentation and repository metadata have no package consumers. + } else { + unknown.push(file); + all(); + } + } + return { packages: packageNames.filter(name => selected.has(name)), insights, tooling, quickstart, unknown }; } -// One combined leg per package: a single job and a single check name keep the -// required-status list and the release gate simple. +// One combined leg per package keeps check names and the release gate simple. export function ciMatrix(paths) { return paths.map(name => ({ package: name })); } +export function detectChecks(base, head, gitCommand = git) { + if (!/^[a-f0-9]{40}$/.test(head ?? "")) throw new Error("Expected a full head SHA"); + if (!base || /^0+$/.test(base)) { + return { packages: packageNames, insights: true, tooling: true, quickstart: true, unknown: [] }; + } + if (!/^[a-f0-9]{40}$/.test(base)) throw new Error("Expected a full base SHA"); + // --no-renames reports both sides of moves, including moves across owners. + const files = gitCommand("diff", "--name-only", "-z", "--no-renames", base, head).split("\0").filter(Boolean); + let releasePackages; + if (files.includes(planPath)) { + // A deleted plan can be an exact release cancellation: use its old owners. + const ref = gitCommand("ls-tree", "--name-only", head, "--", planPath) ? head : base; + const plan = validatePlan(JSON.parse(gitCommand("show", `${ref}:${planPath}`))); + releasePackages = plan.packages.map(item => item.path); + } + return affectedChecks(files, { releasePackages }); +} + +export function checkOutputs(checks) { + return Object.entries({ + has_packages: checks.packages.length > 0, + matrix: JSON.stringify({ include: ciMatrix(checks.packages) }), + run_insights: checks.insights, + run_tooling: checks.tooling, + run_quickstart: checks.quickstart, + }).map(([key, value]) => `${key}=${value}\n`).join(""); +} + if (isMain(import.meta.url)) { const [base, head] = process.argv.slice(2); - if (!/^[a-f0-9]{40}$/.test(head ?? "")) throw new Error("Expected a full head SHA"); - const paths = !base || /^0+$/.test(base) ? Object.keys(packages) - : affectedPackages(git("diff", "--name-only", "--no-renames", base, head).split("\n").filter(Boolean)); - const output = `has_packages=${paths.length > 0}\nmatrix=${JSON.stringify({ include: ciMatrix(paths) })}\n`; + const checks = detectChecks(base, head); + const output = checkOutputs(checks); process.stdout.write(output); + if (checks.unknown.length) console.warn(`Full validation for unclassified paths: ${JSON.stringify(checks.unknown)}`); if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, output); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, + `## Selected checks\n\nPackages: ${checks.packages.join(", ") || "none"}\n\n` + + `Herdr: ${checks.insights}; tooling: ${checks.tooling}; post-main quickstart: ${checks.quickstart}\n\n` + + `Unclassified paths (full validation): ${JSON.stringify(checks.unknown)}\n`); } diff --git a/scripts/tests/ci.test.mjs b/scripts/tests/ci.test.mjs index a8db36b6..dd450bf3 100644 --- a/scripts/tests/ci.test.mjs +++ b/scripts/tests/ci.test.mjs @@ -1,21 +1,155 @@ import assert from "node:assert/strict"; -import { join } from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import test from "node:test"; -import { affectedPackages, ciMatrix } from "../ci.mjs"; -import { root } from "../lib.mjs"; +import { affectedChecks, checkOutputs, ciMatrix, detectChecks } from "../ci.mjs"; +import { isolatedEnvironment, root } from "../lib.mjs"; import { packages } from "../packages.mjs"; import { validateContents } from "../package.mjs"; import { selectTests, testFiles } from "../test-package.mjs"; test("package changes select only their packages; shared changes select all", () => { - assert.deepEqual(affectedPackages(["pi-ask/src/index.ts"]), ["pi-ask"]); - assert.deepEqual(affectedPackages(["pi-slate/package.json", "pi-ask/tests/example.test.ts"]), ["pi-ask", "pi-slate"]); - assert.deepEqual(affectedPackages(["README.md", "docs/releases.md"]), []); - for (const file of ["package-lock.json", ".github/workflows/ci.yml", "scripts/packages.mjs", ".gitleaks.toml", ".release/plan.json"]) { - assert.deepEqual(affectedPackages([file]), Object.keys(packages)); + assert.deepEqual(affectedChecks(["pi-ask/src/index.ts"]).packages, ["pi-ask"]); + assert.deepEqual(affectedChecks(["pi-slate/package.json", "pi-ask/tests/example.test.ts"]).packages, ["pi-ask", "pi-slate"]); + assert.deepEqual(affectedChecks(["README.md", "docs/releases.md"]).packages, []); + for (const file of ["package-lock.json", ".github/workflows/ci.yml", "scripts/packages.mjs"]) { + assert.deepEqual(affectedChecks([file]).packages, Object.keys(packages)); } }); +const noChecks = { packages: [], insights: false, tooling: false, quickstart: false, unknown: [] }; +const allChecks = { packages: Object.keys(packages), insights: true, tooling: true, quickstart: true, unknown: [] }; +const read = path => readFileSync(new URL(path, import.meta.url), "utf8"); + +test("Herdr-only changes skip Pi packages, tooling, and quickstart", () => { + const paths = JSON.parse(read("./fixtures/herdr-only-paths.json")); + assert.deepEqual(affectedChecks(paths), { ...noChecks, insights: true }); + for (const path of paths) assert.deepEqual(affectedChecks([path]), { ...noChecks, insights: true }); +}); + +test("ownership covers test-only, mixed, tooling, and documentation changes", () => { + for (const [paths, expected] of [ + [["pi-ask/tests/example.test.ts"], { packages: ["pi-ask"], quickstart: true }], + [["pi-subagents/test/example.test.ts"], { packages: ["pi-subagents"], quickstart: true }], + [["tests/herdr-insights/example.test.mjs", "pi-slate/themes/example.json"], + { packages: ["pi-slate"], insights: true, quickstart: true }], + [["scripts/herdr-insights/check.mjs"], { insights: true, tooling: true }], + [["docs/herdr-insights-development.md"], { insights: true }], + [["README.md", "docs/ci-cd.md", ".github/CODEOWNERS"], {}], + [["scripts/tests/release.test.mjs", ".gitleaks.toml"], { tooling: true }], + [["scripts/quickstart-ci.mjs"], { tooling: true, quickstart: true }], + [["package-lock.json", "eslint.config.mjs", "scripts/test-package.mjs"], + { packages: Object.keys(packages), tooling: true, quickstart: true }], + [["package.json"], allChecks], + [["scripts/ci.mjs"], allChecks], + [[".github/workflows/ci.yml"], allChecks], + [["new-shared-code/index.ts"], { ...allChecks, unknown: ["new-shared-code/index.ts"] }], + [["herdr-insights-other/file.js"], { ...allChecks, unknown: ["herdr-insights-other/file.js"] }], + ]) assert.deepEqual(affectedChecks(paths), { ...noChecks, ...expected }, paths.join(", ")); +}); + +test("release plans select their packages rather than every Pi package", () => { + assert.deepEqual(affectedChecks([".release/plan.json"], { releasePackages: ["pi-ask"] }), + { ...noChecks, packages: ["pi-ask"], quickstart: true }); + assert.throws(() => affectedChecks([".release/plan.json"], { releasePackages: ["unknown"] }), /Unknown release package/); +}); + +test("manual/full runs and outputs are explicit, including empty matrices", () => { + const head = "a".repeat(40); + assert.deepEqual(detectChecks("", head), allChecks); + assert.deepEqual(detectChecks("0".repeat(40), head), allChecks); + assert.throws(() => detectChecks("", "HEAD"), /head SHA/); + assert.throws(() => detectChecks("HEAD^", head), /base SHA/); + assert.equal(checkOutputs(noChecks), 'has_packages=false\nmatrix={"include":[]}\nrun_insights=false\nrun_tooling=false\nrun_quickstart=false\n'); +}); + +test("Git detection covers multiple commits, deleted tests, cross-owner moves, and release cancellation", t => { + const directory = mkdtempSync(join(tmpdir(), "ci-routing-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const repo = join(directory, "repo"); + mkdirSync(repo); + const env = isolatedEnvironment(join(directory, "env")); + const git = (...args) => execFileSync("git", args, { cwd: repo, env, encoding: "utf8" }).trim(); + const put = (path, content = "example\n") => { + mkdirSync(dirname(join(repo, path)), { recursive: true }); + writeFileSync(join(repo, path), content); + }; + const commit = () => { git("add", "."); git("commit", "-qm", "fixture"); return git("rev-parse", "HEAD"); }; + git("init", "-q"); + put("tests/herdr-insights/deleted.test.mjs"); + const base = commit(); + rmSync(join(repo, "tests/herdr-insights/deleted.test.mjs")); + const deleted = commit(); + assert.deepEqual(detectChecks(base, deleted, git), { ...noChecks, insights: true }); + put("pi-ask/tests/moved.test.ts"); + const beforeMove = commit(); + mkdirSync(join(repo, "pi-slate/tests"), { recursive: true }); + renameSync(join(repo, "pi-ask/tests/moved.test.ts"), join(repo, "pi-slate/tests/moved.test.ts")); + const moved = commit(); + assert.deepEqual(detectChecks(beforeMove, moved, git), + { ...noChecks, packages: ["pi-ask", "pi-slate"], quickstart: true }); + assert.deepEqual(detectChecks(base, moved, git), + { ...noChecks, packages: ["pi-slate"], insights: true, quickstart: true }); + put(".release/plan.json", JSON.stringify({ schema: 1, sourceSha: moved, notes: "fixture", + packages: [{ path: "pi-ask", name: packages["pi-ask"].name, bump: "patch", from: "1.0.0", to: "1.0.1" }] })); + const release = commit(); + const expected = { ...noChecks, packages: ["pi-ask"], quickstart: true }; + assert.deepEqual(detectChecks(moved, release, git), expected); + rmSync(join(repo, ".release/plan.json")); + assert.deepEqual(detectChecks(release, commit(), git), expected); + put(".release/plan.json", "{}"); + assert.throws(() => detectChecks(release, commit(), git)); +}); + +test("CI gate accepts only successful selected jobs and intentional skips", () => { + const workflow = read("../../.github/workflows/ci.yml"); + const gate = workflow.match(/node -e '([\s\S]*?)'/)[1]; + const jobs = { + changes: { result: "success", outputs: { has_packages: "false", run_insights: "true", run_tooling: "false" } }, + secrets: { result: "success" }, packages: { result: "skipped" }, + insights: { result: "success" }, tooling: { result: "skipped" }, + }; + const runGate = needs => spawnSync(process.execPath, ["-e", gate], { + env: { ...process.env, RESULTS: JSON.stringify(needs) }, + }).status; + assert.equal(runGate(jobs), 0); + for (const name of Object.keys(jobs)) { + for (const result of ["failure", "cancelled"]) { + assert.notEqual(runGate({ ...jobs, [name]: { ...jobs[name], result } }), 0); + } + } + assert.notEqual(runGate({ ...jobs, insights: { result: "skipped" } }), 0); + assert.notEqual(runGate({ ...jobs, packages: { result: "success" } }), 0); + for (const value of [undefined, "", "unexpected"]) { + const invalid = structuredClone(jobs); + invalid.changes.outputs.has_packages = value; + assert.notEqual(runGate(invalid), 0); + } + const full = structuredClone(jobs); + full.changes.outputs = { has_packages: "true", run_insights: "true", run_tooling: "true" }; + full.packages.result = full.tooling.result = "success"; + assert.equal(runGate(full), 0); + const docs = structuredClone(jobs); + docs.changes.outputs.run_insights = "false"; + docs.insights.result = "skipped"; + assert.equal(runGate(docs), 0); +}); + +test("workflow wires routing, full sweeps, and unconditional dependency-free release validation", () => { + const workflow = read("../../.github/workflows/ci.yml"); + assert.match(workflow, /schedule:/); + assert.match(workflow, /\["workflow_dispatch", "schedule"\]/); + for (const [job, output] of [["tooling", "run_tooling"], ["insights", "run_insights"], ["packages", "has_packages"]]) { + assert.match(workflow, new RegExp(` ${job}:\\n needs: changes\\n if: needs.changes.outputs.${output} == 'true'`)); + } + const changes = workflow.split(" changes:")[1].split("\n tooling:")[0]; + assert.match(changes, /node scripts\/release.mjs check/); + assert.doesNotMatch(changes, /run: npm ci/); + assert.match(workflow, / {2}secrets:\n {4}uses: .\/\.github\/workflows\/secret-scan.yml/); +}); + test("the matrix is exactly one combined leg per affected package", () => { assert.deepEqual(ciMatrix(["pi-ask"]), [{ package: "pi-ask" }]); assert.deepEqual(ciMatrix(["pi-ask", "pi-web-search"]), [{ package: "pi-ask" }, { package: "pi-web-search" }]); diff --git a/scripts/tests/fixtures/herdr-only-paths.json b/scripts/tests/fixtures/herdr-only-paths.json new file mode 100644 index 00000000..04917006 --- /dev/null +++ b/scripts/tests/fixtures/herdr-only-paths.json @@ -0,0 +1,15 @@ +[ + "herdr-insights/README.md", + "herdr-insights/RECIPE.md", + "herdr-insights/mods/insights/analytics.js", + "herdr-insights/mods/insights/core/adapters.mjs", + "herdr-insights/mods/insights/core/antigravity.mjs", + "herdr-insights/mods/insights/core/config.mjs", + "herdr-insights/mods/insights/core/scan.mjs", + "tests/herdr-insights/analytics.test.mjs", + "tests/herdr-insights/collector-adapters.test.mjs", + "tests/herdr-insights/collector-antigravity.test.mjs", + "tests/herdr-insights/collector-config.test.mjs", + "tests/herdr-insights/fixtures/antigravity-generation.hex", + "tests/herdr-insights/fixtures/antigravity-generation.md" +] diff --git a/scripts/tests/quickstart-ci.test.mjs b/scripts/tests/quickstart-ci.test.mjs index 3318f5fd..c9142c25 100644 --- a/scripts/tests/quickstart-ci.test.mjs +++ b/scripts/tests/quickstart-ci.test.mjs @@ -19,12 +19,38 @@ test("quickstart smoke uses the pinned Pi CLI and the dedicated workflow is rele assert.equal(piCliVersion(), manifest.devDependencies["@earendil-works/pi-coding-agent"]); const workflow = readFileSync(new URL("../../.github/workflows/quickstart.yml", import.meta.url), "utf8"); - assert.match(workflow, /workflow_run:/); - assert.match(workflow, /workflows: \["Main CI and releases"\]/); + assert.match(workflow, /workflow_call:/); + assert.doesNotMatch(workflow, /workflow_run:/); + assert.doesNotMatch(workflow, /\bref:/); assert.match(workflow, /schedule:/); assert.match(workflow, /node scripts\/quickstart-ci\.mjs/); }); +test("post-main quickstart runs only for selected changes after successful validation/publication", () => { + const workflow = readFileSync(new URL("../../.github/workflows/publish.yml", import.meta.url), "utf8"); + const job = workflow.split(" quickstart:\n")[1].split("\n publish:")[0]; + assert.match(job, /needs: \[validate, eligible, publish\]/); + assert.match(job, /uses: .\/\.github\/workflows\/quickstart.yml/); + const condition = job.match(/if: >-\n([\s\S]*?)\n {4}uses:/)[1].trim(); + const selected = new Function("needs", "always", "cancelled", `return (${condition});`); + const needs = { + validate: { result: "success", outputs: { run_quickstart: "true" } }, + eligible: { result: "success" }, publish: { result: "skipped" }, + }; + const run = (jobs, cancelled = false) => selected(jobs, () => true, () => cancelled); + assert.equal(run(needs), true); + assert.equal(run({ ...needs, publish: { result: "success" } }), true); + assert.equal(run(needs, true), false); + for (const name of Object.keys(needs)) { + for (const result of ["failure", "cancelled"]) { + assert.equal(run({ ...needs, [name]: { ...needs[name], result } }), false); + } + } + for (const value of ["false", "", undefined]) { + assert.equal(run({ ...needs, validate: { result: "success", outputs: { run_quickstart: value } } }), false); + } +}); + test("quickstart waits through npm's post-publish processing delay", () => { let calls = 0; const version = waitForRegistryPackage("demo@1.2.3", {