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
35 changes: 25 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
'
12 changes: 12 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
9 changes: 2 additions & 7 deletions .github/workflows/quickstart.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
50 changes: 43 additions & 7 deletions docs/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
<https://app.snyk.io/account> → 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=<id>` to the two commands only when the
token must report under a specific organization.

Expand All @@ -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

Expand Down
103 changes: 91 additions & 12 deletions scripts/ci.mjs
Original file line number Diff line number Diff line change
@@ -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`);
}
Loading
Loading