ci-sdk 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -13
- package/dist/cli.js +16 -2
- package/dist/lockfile.js +83 -0
- package/dist/run.js +257 -0
- package/dist/test.js +30 -19
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -2,31 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
Insanely fast CI. No migration. By [Diffling](https://diffling.com).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
ci-sdk makes the CI you already have skip work your change can't affect: packages whose inputs didn't change, tests your change can't reach, and jobs that already passed on the same code. No pipeline to rewrite, no config, any runner.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
On [React Router](https://github.com/remix-run/react-router) (10 packages), whole CI jobs (setup, install, build, typecheck, lint, unit tests) ran **40% faster on pushes and 27% faster on pull requests**, with the same results, replaying real commits on GitHub's standard runners. Gains are largest in monorepos with many packages and commits that touch a few of them; repos whose jobs take only seconds gain little.
|
|
8
|
+
|
|
9
|
+
Needs Node 22.18 or newer. Works with pnpm and npm workspaces (yarn and bun run through their own `run`).
|
|
10
|
+
|
|
11
|
+
## run
|
|
12
|
+
|
|
13
|
+
Run a package script in every workspace package, skipping packages whose inputs haven't changed since they last passed:
|
|
8
14
|
|
|
9
15
|
```bash
|
|
10
|
-
npx ci-sdk
|
|
16
|
+
npx ci-sdk run build
|
|
17
|
+
npx ci-sdk run typecheck
|
|
11
18
|
```
|
|
12
19
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
20
|
+
- A package reruns when its own files, a workspace package it depends on, or its dependencies in `pnpm-lock.yaml` change. Docs, changelogs, licenses and CI config don't count.
|
|
21
|
+
- Outputs are whatever the script creates outside git. On a cache hit they are restored and the script's log is replayed. No config.
|
|
22
|
+
- Packages run dependencies first, up to 4 at a time. Failures are never cached, and packages that depend on a failure don't run.
|
|
23
|
+
- The cache lives in `node_modules/.cache/ci-sdk`. In CI, entries the run didn't use are pruned, so a saved cache stays small.
|
|
24
|
+
- `--filter './packages/*'` limits it to some folders. `--no-cache` runs everything and refreshes the cache.
|
|
25
|
+
- Environment variables are not part of a package's inputs. If a script's output depends on one, run it with `--no-cache`.
|
|
18
26
|
|
|
19
27
|
## test
|
|
20
28
|
|
|
21
|
-
Run only the tests your changes can reach
|
|
29
|
+
Run only the tests your changes can reach:
|
|
22
30
|
|
|
23
31
|
```bash
|
|
24
|
-
npx ci-sdk test
|
|
32
|
+
npx ci-sdk test # uncommitted and untracked changes
|
|
33
|
+
npx ci-sdk test --since origin/main # plus commits on your branch
|
|
34
|
+
npx ci-sdk test --since HEAD~1 # in CI: the commit being tested
|
|
25
35
|
```
|
|
26
36
|
|
|
27
|
-
- Looks at uncommitted and untracked changes. Add `--since origin/main` to include commits on your branch.
|
|
28
|
-
- `--list` prints the affected test files instead of running them.
|
|
29
37
|
- Follows imports through barrel files, so a change in a shared package only runs the tests that use it.
|
|
38
|
+
- Runs each package's own test script with the affected files. Packages without one run through the root's script, such as `jest` or `vitest run`. `--script test-unit` picks a different script.
|
|
30
39
|
- If a change can affect everything, such as a root config, it runs the full suite.
|
|
40
|
+
- It never reports success for tests it couldn't run: if no script can take the affected files, it fails and names them.
|
|
41
|
+
- `--list` prints the affected test files instead of running them.
|
|
42
|
+
|
|
43
|
+
## GitHub Action
|
|
31
44
|
|
|
32
|
-
|
|
45
|
+
Restores tool caches (Turborepo, Next.js, ESLint, `node_modules/.cache`, which includes ci-sdk's own) and tells you when this exact code already passed this job:
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v6
|
|
50
|
+
with:
|
|
51
|
+
fetch-depth: 2 # lets `ci-sdk test --since HEAD~1` see the commit's changes
|
|
52
|
+
- uses: pnpm/action-setup@v6
|
|
53
|
+
- uses: actions/setup-node@v7
|
|
54
|
+
with:
|
|
55
|
+
node-version: 24
|
|
56
|
+
- id: ci-sdk
|
|
57
|
+
uses: jaymalve/ci-sdk@v0.0.3
|
|
58
|
+
- if: steps.ci-sdk.outputs.already-passed != 'true'
|
|
59
|
+
run: |
|
|
60
|
+
pnpm install --frozen-lockfile
|
|
61
|
+
npx ci-sdk run build
|
|
62
|
+
npx ci-sdk run typecheck
|
|
63
|
+
npx ci-sdk test --since HEAD~1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Add `ci-sdk` to your devDependencies to pin its version; `npx` then uses the installed copy instead of downloading it.
|
|
67
|
+
- `already-passed` is `true` when this job already passed on the same code (same git tree, job and matrix). Skip the rest of the job when it is.
|
|
68
|
+
- Each run saves a fresh cache entry and restores the latest one. On pull requests, that includes the base branch's.
|
|
69
|
+
- On runners that serve the GitHub Actions cache API from their own storage, such as Blacksmith and Depot, the Action uses that automatically.
|
|
70
|
+
|
|
71
|
+
## scan
|
|
72
|
+
|
|
73
|
+
See where your GitHub Actions minutes and dollars go, and how much ci-sdk would save. Run it in your repo:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx ci-sdk scan
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- Looks at the last 30 days of workflow runs (`--days`, `--max-runs` to change).
|
|
80
|
+
- Estimates savings from your repo's own history: repeated runs, tests a change could not reach (via an oxc import graph), and slow installs.
|
|
81
|
+
- `--markdown` for a PR or Slack, `--json` for tooling.
|
|
82
|
+
- Needs a GitHub token with **Actions: read**. Uses `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`.
|
|
83
|
+
- Runs locally. Your code and CI data never leave your machine.
|
package/dist/cli.js
CHANGED
|
@@ -2,17 +2,25 @@
|
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { getToken, repoFromGit } from './github.js';
|
|
4
4
|
import { json, markdown, text } from './report.js';
|
|
5
|
+
import { runCached } from './run.js';
|
|
5
6
|
import { scan } from './scan.js';
|
|
6
7
|
import { testAffected } from './test.js';
|
|
7
8
|
const HELP = `Usage: ci-sdk <command> [options]
|
|
8
9
|
|
|
9
10
|
Commands:
|
|
11
|
+
run Run a package script in every workspace package, skipping packages whose inputs already passed
|
|
10
12
|
test Run only the tests your changes can reach
|
|
11
13
|
scan Show where your GitHub Actions minutes and dollars go, and what ci-sdk would save
|
|
12
14
|
|
|
15
|
+
run options: ci-sdk run <script>
|
|
16
|
+
--filter <glob> Only packages in matching folders, e.g. './packages/*' (repeatable)
|
|
17
|
+
--concurrency <n> Packages at a time (default: up to 4)
|
|
18
|
+
--no-cache Run everything and refresh the cache
|
|
19
|
+
|
|
13
20
|
test options:
|
|
14
21
|
--since <ref> Also include changes committed since <ref>, e.g. origin/main (default: uncommitted only)
|
|
15
22
|
--list Print the affected test files instead of running them
|
|
23
|
+
--script <name> Script that runs tests and takes file paths (default: test), e.g. test-unit
|
|
16
24
|
|
|
17
25
|
scan options:
|
|
18
26
|
--repo <owner/name> Repo to scan (default: this checkout's origin)
|
|
@@ -35,17 +43,23 @@ async function main() {
|
|
|
35
43
|
markdown: { type: 'boolean' },
|
|
36
44
|
since: { type: 'string' },
|
|
37
45
|
list: { type: 'boolean' },
|
|
46
|
+
script: { type: 'string' },
|
|
47
|
+
filter: { type: 'string', multiple: true },
|
|
48
|
+
concurrency: { type: 'string' },
|
|
49
|
+
'no-cache': { type: 'boolean' },
|
|
38
50
|
help: { type: 'boolean', short: 'h' },
|
|
39
51
|
},
|
|
40
52
|
});
|
|
41
53
|
const command = positionals[0];
|
|
42
|
-
if (values.help ||
|
|
54
|
+
if (values.help || !['scan', 'test', 'run'].includes(command) || (command === 'run' && !positionals[1])) {
|
|
43
55
|
console.log(HELP);
|
|
44
56
|
process.exit(values.help ? 0 : 1);
|
|
45
57
|
}
|
|
46
58
|
const log = (m) => process.stderr.write(`ci-sdk: ${m}\n`);
|
|
59
|
+
if (command === 'run')
|
|
60
|
+
process.exit(await runCached({ cwd: process.cwd(), script: positionals[1], filter: values.filter, concurrency: values.concurrency ? Number(values.concurrency) : undefined, noCache: values['no-cache'], log }));
|
|
47
61
|
if (command === 'test')
|
|
48
|
-
process.exit(await testAffected({ cwd: process.cwd(), since: values.since, list: values.list, log }));
|
|
62
|
+
process.exit(await testAffected({ cwd: process.cwd(), since: values.since, list: values.list, script: values.script, log }));
|
|
49
63
|
const root = process.cwd();
|
|
50
64
|
const repo = values.repo ?? repoFromGit(root);
|
|
51
65
|
if (!repo)
|
package/dist/lockfile.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const sha = (parts) => {
|
|
3
|
+
const h = createHash('sha256');
|
|
4
|
+
for (const p of parts)
|
|
5
|
+
h.update(p).update('\0');
|
|
6
|
+
return h.digest('hex');
|
|
7
|
+
};
|
|
8
|
+
const ENTRY = /^(\s*)('[^']*'|[^\s:'][^:]*):(?:\s+(.*))?$/;
|
|
9
|
+
const DEP_GROUPS = new Set(['dependencies', 'devDependencies', 'optionalDependencies']);
|
|
10
|
+
/**
|
|
11
|
+
* Per-package fingerprints from a pnpm v9 lockfile, so a lockfile change reruns only packages whose dependency tree changed.
|
|
12
|
+
* Reads just the block layout pnpm writes; returns undefined for anything else, and callers then hash the whole file.
|
|
13
|
+
*/
|
|
14
|
+
export function pnpmLockKeys(text) {
|
|
15
|
+
const docs = text.split(/^---$/m);
|
|
16
|
+
const mainAt = docs.findLastIndex((d) => /^importers:/m.test(d));
|
|
17
|
+
if (mainAt < 0 || !/^lockfileVersion: '9\.\d+'/m.test(docs[mainAt]))
|
|
18
|
+
return undefined;
|
|
19
|
+
const other = docs.filter((_, i) => i !== mainAt);
|
|
20
|
+
// Top-level sections of the main document.
|
|
21
|
+
const sections = new Map();
|
|
22
|
+
let cur = '';
|
|
23
|
+
for (const line of docs[mainAt].split('\n')) {
|
|
24
|
+
const m = /^([^\s#][^:]*):/.exec(line);
|
|
25
|
+
if (m)
|
|
26
|
+
sections.set((cur = m[1]), []);
|
|
27
|
+
else
|
|
28
|
+
sections.get(cur)?.push(line);
|
|
29
|
+
}
|
|
30
|
+
for (const [name, lines] of sections)
|
|
31
|
+
if (!['importers', 'packages', 'snapshots'].includes(name))
|
|
32
|
+
other.push(`${name}\n${lines.join('\n')}`);
|
|
33
|
+
// importers: 2 spaces importer, 4 group, 6 dependency name, 8 specifier/version.
|
|
34
|
+
const direct = new Map();
|
|
35
|
+
let imp = '';
|
|
36
|
+
let group = '';
|
|
37
|
+
let dep = '';
|
|
38
|
+
for (const line of sections.get('importers') ?? []) {
|
|
39
|
+
const m = ENTRY.exec(line);
|
|
40
|
+
if (!m)
|
|
41
|
+
continue;
|
|
42
|
+
const [indent, key, value] = [m[1].length, m[2].replace(/^'(.*)'$/, '$1'), m[3]?.trim()];
|
|
43
|
+
if (indent === 2)
|
|
44
|
+
direct.set((imp = key), []);
|
|
45
|
+
else if (indent === 4)
|
|
46
|
+
group = key;
|
|
47
|
+
else if (indent === 6)
|
|
48
|
+
dep = key;
|
|
49
|
+
else if (indent === 8 && key === 'version' && DEP_GROUPS.has(group) && value)
|
|
50
|
+
direct.get(imp).push(node(dep, value));
|
|
51
|
+
}
|
|
52
|
+
// snapshots: 2 spaces id, 4 group, 6 `name: version`.
|
|
53
|
+
const children = new Map();
|
|
54
|
+
let id = '';
|
|
55
|
+
for (const line of sections.get('snapshots') ?? []) {
|
|
56
|
+
const m = ENTRY.exec(line);
|
|
57
|
+
if (!m)
|
|
58
|
+
continue;
|
|
59
|
+
const [indent, key, value] = [m[1].length, m[2].replace(/^'(.*)'$/, '$1'), m[3]?.trim()];
|
|
60
|
+
if (indent === 2)
|
|
61
|
+
children.set((id = key), []);
|
|
62
|
+
else if (indent === 4)
|
|
63
|
+
group = key;
|
|
64
|
+
else if (indent === 6 && DEP_GROUPS.has(group) && value)
|
|
65
|
+
children.get(id).push(node(key, value));
|
|
66
|
+
}
|
|
67
|
+
const byImporter = new Map();
|
|
68
|
+
for (const [importer, roots] of direct) {
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const stack = roots.filter((r) => !r.includes('@link:'));
|
|
71
|
+
while (stack.length) {
|
|
72
|
+
const n = stack.pop();
|
|
73
|
+
if (seen.has(n))
|
|
74
|
+
continue;
|
|
75
|
+
seen.add(n);
|
|
76
|
+
stack.push(...(children.get(n) ?? []));
|
|
77
|
+
}
|
|
78
|
+
byImporter.set(importer, sha([...seen].sort()));
|
|
79
|
+
}
|
|
80
|
+
return { shared: sha(other), byImporter };
|
|
81
|
+
}
|
|
82
|
+
// A dependency's snapshot id: `name@version`, or the version itself for an alias (`string-width: string-width@4.2.3`).
|
|
83
|
+
const node = (name, version) => (/^(@[^/]+\/)?[^@/(]+@/.test(version) ? version : `${name}@${version}`);
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { availableParallelism } from 'node:os';
|
|
5
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
6
|
+
import { glob } from 'tinyglobby';
|
|
7
|
+
import { pnpmLockKeys } from './lockfile.js';
|
|
8
|
+
import { packageManager, readJson, repoRoot, runScript } from './test.js';
|
|
9
|
+
// Bump when the key recipe changes, so old entries can never hit.
|
|
10
|
+
const VERSION = '2';
|
|
11
|
+
const gitZ = (root, args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).split('\0').filter(Boolean);
|
|
12
|
+
/** Workspace packages from pnpm-workspace.yaml or package.json `workspaces`; a repo without either is one package. */
|
|
13
|
+
export async function workspace(root, scriptName) {
|
|
14
|
+
const rootPkg = readJson(join(root, 'package.json')) ?? {};
|
|
15
|
+
let patterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : (rootPkg.workspaces?.packages ?? []);
|
|
16
|
+
const yaml = existsSync(join(root, 'pnpm-workspace.yaml')) ? readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8') : '';
|
|
17
|
+
// shortcut: reads only the `packages:` list of pnpm-workspace.yaml; use a YAML parser if repos put it in flow style.
|
|
18
|
+
const block = yaml.match(/^packages:\s*\n((?:\s+-.*\n?|\s*#.*\n?|\s*\n)*)/m)?.[1] ?? '';
|
|
19
|
+
patterns = [...patterns, ...[...block.matchAll(/^\s+-\s*['"]?([^'"#\n]+?)['"]?\s*$/gm)].map((m) => m[1])];
|
|
20
|
+
const manifests = patterns.length
|
|
21
|
+
? await glob(patterns.filter((p) => !p.startsWith('!')).map((p) => `${p.replace(/\/$/, '')}/package.json`), { cwd: root, ignore: ['**/node_modules/**', ...patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1))] })
|
|
22
|
+
: [];
|
|
23
|
+
const dirs = manifests.length ? manifests.map((m) => m.slice(0, -'package.json'.length)) : [''];
|
|
24
|
+
const pkgs = dirs.map((dir) => ({ dir, json: readJson(join(root, dir, 'package.json')) ?? {} }));
|
|
25
|
+
const names = new Set(pkgs.map((p) => p.json.name).filter(Boolean));
|
|
26
|
+
return pkgs.map(({ dir, json }) => ({
|
|
27
|
+
dir,
|
|
28
|
+
name: json.name ?? (dir || '(root)'),
|
|
29
|
+
script: json.scripts?.[scriptName],
|
|
30
|
+
deps: Object.keys({ ...json.dependencies, ...json.devDependencies, ...json.peerDependencies, ...json.optionalDependencies }).filter((d) => names.has(d)),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
const sha = (...parts) => {
|
|
34
|
+
const h = createHash('sha256');
|
|
35
|
+
for (const p of parts)
|
|
36
|
+
h.update(p).update('\0');
|
|
37
|
+
return h.digest('hex');
|
|
38
|
+
};
|
|
39
|
+
// Files outside every package that no package script reads: docs, changelogs, licenses, CI and editor config.
|
|
40
|
+
// Without this, a CHANGELOG or docs edit reruns everything. A package's own README or LICENSE still counts, since it is
|
|
41
|
+
// inside the package. shortcut: a build that copies a root README/LICENSE into its output will not rerun when that file changes.
|
|
42
|
+
const READ_BY_NO_SCRIPT = /(^|\/)(LICEN[CS]E[^/]*|[^/]+\.mdx?)$|^\.(github|changeset|vscode|idea)\//i;
|
|
43
|
+
// Release notes no build reads, even inside a package (other markdown there counts: docs packages build from it).
|
|
44
|
+
const RELEASE_NOTES = /(^|\/)(CHANGELOG[^/]*\.mdx?|\.changeset\/.+|\.changes\/.+)$/i;
|
|
45
|
+
/**
|
|
46
|
+
* One key per package: its own files, every file outside all packages (root configs, scripts), its dependency tree from
|
|
47
|
+
* the lockfile, and the keys of the workspace packages it depends on. Conservative on purpose: a root config change reruns everything.
|
|
48
|
+
*/
|
|
49
|
+
export function inputKeys(root, pkgs, salt) {
|
|
50
|
+
const files = gitZ(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
51
|
+
const deleted = new Set(gitZ(root, ['ls-files', '-z', '--deleted']));
|
|
52
|
+
const present = files.filter((f) => !deleted.has(f));
|
|
53
|
+
// git hash-object fails on symlinks to folders and on submodule folders, so hash those by their link target or kind.
|
|
54
|
+
const hashes = new Map();
|
|
55
|
+
const regular = [];
|
|
56
|
+
for (const f of present) {
|
|
57
|
+
const s = lstatSync(join(root, f), { throwIfNoEntry: false });
|
|
58
|
+
if (s?.isFile())
|
|
59
|
+
regular.push(f);
|
|
60
|
+
else
|
|
61
|
+
hashes.set(f, s?.isSymbolicLink() ? `link:${readlinkSync(join(root, f))}` : s ? 'dir' : 'missing');
|
|
62
|
+
}
|
|
63
|
+
if (regular.length) {
|
|
64
|
+
const out = execFileSync('git', ['hash-object', '--stdin-paths'], { cwd: root, input: regular.join('\n'), encoding: 'utf8', maxBuffer: 1 << 28 }).trim().split('\n');
|
|
65
|
+
regular.forEach((f, i) => hashes.set(f, out[i]));
|
|
66
|
+
}
|
|
67
|
+
// Deepest folder first, so a nested package owns its files.
|
|
68
|
+
const owners = pkgs.filter((p) => p.dir).sort((a, b) => b.dir.length - a.dir.length);
|
|
69
|
+
const own = new Map(pkgs.map((p) => [p.dir, []]));
|
|
70
|
+
const outside = [];
|
|
71
|
+
// pnpm: key each package on its own dependency tree instead of the whole lockfile, so a lockfile change reruns only
|
|
72
|
+
// packages whose dependencies changed. Root dependencies count for every package, since package scripts use them.
|
|
73
|
+
const lock = present.includes('pnpm-lock.yaml') ? pnpmLockKeys(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8')) : undefined;
|
|
74
|
+
const treeOf = (dir) => [lock?.byImporter.get('.') ?? '', lock?.byImporter.get(dir.replace(/\/$/, '') || '.') ?? ''];
|
|
75
|
+
present.forEach((f) => {
|
|
76
|
+
if (RELEASE_NOTES.test(f) || (lock && f === 'pnpm-lock.yaml'))
|
|
77
|
+
return;
|
|
78
|
+
const entry = `${f}:${hashes.get(f)}`;
|
|
79
|
+
const owner = owners.find((p) => f.startsWith(p.dir));
|
|
80
|
+
if (owner)
|
|
81
|
+
own.get(owner.dir).push(entry);
|
|
82
|
+
else if (!READ_BY_NO_SCRIPT.test(f))
|
|
83
|
+
outside.push(entry);
|
|
84
|
+
});
|
|
85
|
+
if (own.has(''))
|
|
86
|
+
own.get('').push(...outside);
|
|
87
|
+
const shared = sha(...outside, lock?.shared ?? '');
|
|
88
|
+
const byName = new Map(pkgs.map((p) => [p.name, p]));
|
|
89
|
+
const keys = new Map();
|
|
90
|
+
const keyOf = (p, seen) => {
|
|
91
|
+
if (keys.has(p.dir))
|
|
92
|
+
return keys.get(p.dir);
|
|
93
|
+
if (seen.has(p.dir))
|
|
94
|
+
return 'cycle'; // dependency cycles: each member still hashes its own files
|
|
95
|
+
seen.add(p.dir);
|
|
96
|
+
const key = sha(VERSION, salt, shared, ...treeOf(p.dir), ...own.get(p.dir), ...p.deps.sort().map((d) => keyOf(byName.get(d), seen)));
|
|
97
|
+
keys.set(p.dir, key);
|
|
98
|
+
return key;
|
|
99
|
+
};
|
|
100
|
+
for (const p of pkgs)
|
|
101
|
+
keyOf(p, new Set());
|
|
102
|
+
return keys;
|
|
103
|
+
}
|
|
104
|
+
/** Files under `dir` that git does not track (outputs live there), with size and mtime; node_modules excluded. */
|
|
105
|
+
function untracked(root, dir) {
|
|
106
|
+
const files = gitZ(root, ['ls-files', '-z', '--others', '--', dir || '.', ':(exclude,glob)**/node_modules/**']);
|
|
107
|
+
const stamp = new Map();
|
|
108
|
+
for (const f of files) {
|
|
109
|
+
try {
|
|
110
|
+
const s = statSync(join(root, f));
|
|
111
|
+
stamp.set(f, `${s.size}:${s.mtimeMs}`);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// vanished while listing
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return stamp;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Runs the script with sh the way npm and pnpm do, skipping a package-manager launch (~0.35s per package, which adds up
|
|
121
|
+
* along a dependency chain). Falls back to `<pm> run` where that isn't equivalent: yarn (PnP), bun, Windows, pre/post scripts.
|
|
122
|
+
*/
|
|
123
|
+
export function scriptCommand(pm, root, dir, script) {
|
|
124
|
+
const json = readJson(join(root, dir, 'package.json')) ?? {};
|
|
125
|
+
const body = json.scripts?.[script];
|
|
126
|
+
if (!body || (pm !== 'npm' && pm !== 'pnpm') || process.platform === 'win32' || json.scripts?.[`pre${script}`] || json.scripts?.[`post${script}`])
|
|
127
|
+
return [...runScript(pm, script, []), process.env];
|
|
128
|
+
const bins = [];
|
|
129
|
+
for (let d = join(root, dir);; d = dirname(d)) {
|
|
130
|
+
bins.push(join(d, 'node_modules/.bin'));
|
|
131
|
+
if (d === root || d === dirname(d))
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
const env = {
|
|
135
|
+
...process.env,
|
|
136
|
+
PATH: [...bins, process.env.PATH].join(delimiter),
|
|
137
|
+
npm_lifecycle_event: script,
|
|
138
|
+
npm_lifecycle_script: body,
|
|
139
|
+
npm_package_name: json.name,
|
|
140
|
+
npm_package_version: json.version,
|
|
141
|
+
npm_package_json: join(root, dir, 'package.json'),
|
|
142
|
+
// Tools such as wireit pick the package manager to spawn from this prefix.
|
|
143
|
+
npm_config_user_agent: process.env.npm_config_user_agent?.startsWith(`${pm}/`) ? process.env.npm_config_user_agent : `${pm}/unknown node/${process.version} ${process.platform} ${process.arch}`,
|
|
144
|
+
INIT_CWD: process.env.INIT_CWD ?? process.cwd(),
|
|
145
|
+
};
|
|
146
|
+
return ['sh', ['-c', body], env];
|
|
147
|
+
}
|
|
148
|
+
/** Runs a package script in every workspace package (dependencies first), skipping packages whose inputs already passed. */
|
|
149
|
+
export async function runCached(o) {
|
|
150
|
+
const root = repoRoot(o.cwd);
|
|
151
|
+
const pm = packageManager(root);
|
|
152
|
+
const all = await workspace(root, o.script);
|
|
153
|
+
const allowed = o.filter?.length ? new Set((await glob(o.filter.map((f) => `${f.replace(/\/$/, '')}/package.json`), { cwd: root, ignore: ['**/node_modules/**'] })).map((m) => m.slice(0, -'package.json'.length))) : undefined;
|
|
154
|
+
const todo = all.filter((p) => p.script && (!allowed || allowed.has(p.dir)));
|
|
155
|
+
if (!todo.length) {
|
|
156
|
+
o.log(`No package has a "${o.script}" script.`);
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
const node = process.version;
|
|
160
|
+
const keys = inputKeys(root, all, [o.script, node, process.platform, process.arch].join('|'));
|
|
161
|
+
// One folder per script, so pruning after `run build` never touches the entries of `run test`.
|
|
162
|
+
const cache = join(o.cacheDir ?? join(root, 'node_modules/.cache/ci-sdk'), encodeURIComponent(o.script));
|
|
163
|
+
const used = new Set();
|
|
164
|
+
const byName = new Map(all.map((p) => [p.name, p]));
|
|
165
|
+
const waitingOn = new Map(todo.map((p) => [p.dir, new Set(p.deps.map((d) => byName.get(d).dir).filter((d) => todo.some((t) => t.dir === d && d !== p.dir)))]));
|
|
166
|
+
const done = new Set();
|
|
167
|
+
const failed = [];
|
|
168
|
+
const failedDirs = new Set();
|
|
169
|
+
let hits = 0;
|
|
170
|
+
const started = Date.now();
|
|
171
|
+
const runOne = async (p) => {
|
|
172
|
+
const key = sha(keys.get(p.dir), p.script);
|
|
173
|
+
const dir = join(cache, key.slice(0, 2), key);
|
|
174
|
+
const meta = join(dir, 'meta.json');
|
|
175
|
+
used.add(join(key.slice(0, 2), key));
|
|
176
|
+
if (!o.noCache && existsSync(meta)) {
|
|
177
|
+
const entry = JSON.parse(readFileSync(meta, 'utf8'));
|
|
178
|
+
for (const f of entry.outputs) {
|
|
179
|
+
mkdirSync(dirname(join(root, f)), { recursive: true });
|
|
180
|
+
cpSync(join(dir, 'out', f), join(root, f));
|
|
181
|
+
}
|
|
182
|
+
hits++;
|
|
183
|
+
o.log(`${p.name}: cache hit, ${entry.outputs.length} outputs restored (saved ${(entry.ms / 1000).toFixed(1)}s)`);
|
|
184
|
+
process.stdout.write(entry.log);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const before = untracked(root, p.dir);
|
|
188
|
+
const t = Date.now();
|
|
189
|
+
const [cmd, args, env] = scriptCommand(pm, root, p.dir, o.script);
|
|
190
|
+
let log = '';
|
|
191
|
+
const code = await new Promise((resolve) => {
|
|
192
|
+
const child = spawn(cmd, args, { cwd: join(root, p.dir), env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
193
|
+
child.stdout.on('data', (d) => (log += d));
|
|
194
|
+
child.stderr.on('data', (d) => (log += d));
|
|
195
|
+
child.on('close', (c) => resolve(c ?? 1));
|
|
196
|
+
});
|
|
197
|
+
const ms = Date.now() - t;
|
|
198
|
+
o.log(`${p.name}: ran in ${(ms / 1000).toFixed(1)}s${code ? `, failed with exit code ${code}` : ''}`);
|
|
199
|
+
process.stdout.write(log);
|
|
200
|
+
if (code) {
|
|
201
|
+
failed.push(p.name);
|
|
202
|
+
failedDirs.add(p.dir);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// Outputs are whatever the script created or changed outside git, inferred with no config.
|
|
206
|
+
const outputs = [...untracked(root, p.dir)].filter(([f, s]) => before.get(f) !== s).map(([f]) => f);
|
|
207
|
+
const tmp = `${dir}.tmp-${process.pid}`;
|
|
208
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
209
|
+
for (const f of outputs) {
|
|
210
|
+
mkdirSync(dirname(join(tmp, 'out', f)), { recursive: true });
|
|
211
|
+
cpSync(join(root, f), join(tmp, 'out', f));
|
|
212
|
+
}
|
|
213
|
+
mkdirSync(tmp, { recursive: true });
|
|
214
|
+
writeFileSync(join(tmp, 'meta.json'), JSON.stringify({ log, outputs, ms }));
|
|
215
|
+
rmSync(dir, { recursive: true, force: true });
|
|
216
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
217
|
+
renameSync(tmp, dir); // atomic: a half-written entry is never read
|
|
218
|
+
};
|
|
219
|
+
// Dependencies first, up to `concurrency` packages at a time, like `pnpm -r`.
|
|
220
|
+
const limit = o.concurrency ?? Math.max(1, Math.min(4, availableParallelism()));
|
|
221
|
+
const running = new Map();
|
|
222
|
+
while (done.size < todo.length) {
|
|
223
|
+
const blocked = (p) => [...waitingOn.get(p.dir)].some((d) => !done.has(d));
|
|
224
|
+
for (const p of todo) {
|
|
225
|
+
if (running.size >= limit)
|
|
226
|
+
break;
|
|
227
|
+
if (done.has(p.dir) || running.has(p.dir) || blocked(p))
|
|
228
|
+
continue;
|
|
229
|
+
if ([...waitingOn.get(p.dir)].some((d) => failedDirs.has(d))) {
|
|
230
|
+
o.log(`${p.name}: skipped, a dependency failed`);
|
|
231
|
+
failed.push(p.name);
|
|
232
|
+
failedDirs.add(p.dir);
|
|
233
|
+
done.add(p.dir);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
running.set(p.dir, runOne(p).then(() => void done.add(p.dir), () => void (failed.push(p.name), failedDirs.add(p.dir), done.add(p.dir))));
|
|
237
|
+
}
|
|
238
|
+
if (!running.size) {
|
|
239
|
+
// A dependency cycle: run the rest in order rather than hang.
|
|
240
|
+
for (const p of todo.filter((p) => !done.has(p.dir)))
|
|
241
|
+
waitingOn.set(p.dir, new Set());
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
await Promise.race(running.values());
|
|
245
|
+
for (const d of [...running.keys()])
|
|
246
|
+
if (done.has(d))
|
|
247
|
+
running.delete(d);
|
|
248
|
+
}
|
|
249
|
+
if (o.prune ?? Boolean(process.env.CI)) {
|
|
250
|
+
for (const shard of existsSync(cache) ? readdirSync(cache) : [])
|
|
251
|
+
for (const entry of readdirSync(join(cache, shard)))
|
|
252
|
+
if (!used.has(join(shard, entry)))
|
|
253
|
+
rmSync(join(cache, shard, entry), { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
o.log(`${o.script}: ${todo.length} packages, ${hits} from cache, ${failed.length} failed, ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
|
256
|
+
return failed.length ? 1 : 0;
|
|
257
|
+
}
|
package/dist/test.js
CHANGED
|
@@ -10,10 +10,10 @@ export function changedFiles(root, since = 'HEAD') {
|
|
|
10
10
|
const committed = since === 'HEAD' ? [] : lines(git(root, ['diff', '--name-only', `${since}...HEAD`]));
|
|
11
11
|
return [...new Set([...committed, ...lines(git(root, ['diff', '--name-only', 'HEAD'])), ...lines(git(root, ['ls-files', '--others', '--exclude-standard']))])];
|
|
12
12
|
}
|
|
13
|
-
const packageManager = (root) => existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(root, 'yarn.lock')) ? 'yarn' : existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb')) ? 'bun' : 'npm';
|
|
13
|
+
export const packageManager = (root) => existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(root, 'yarn.lock')) ? 'yarn' : existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb')) ? 'bun' : 'npm';
|
|
14
14
|
// How each package manager appends extra arguments to a package script.
|
|
15
|
-
const runScript = (pm, script, args) => (pm === 'npm' ? ['npm', ['run', script, '--', ...args]] : [pm, ['run', script, ...args]]);
|
|
16
|
-
const readJson = (file) => {
|
|
15
|
+
export const runScript = (pm, script, args) => (pm === 'npm' ? ['npm', ['run', script, '--', ...args]] : [pm, ['run', script, ...args]]);
|
|
16
|
+
export const readJson = (file) => {
|
|
17
17
|
try {
|
|
18
18
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
19
19
|
}
|
|
@@ -21,20 +21,24 @@ const readJson = (file) => {
|
|
|
21
21
|
return undefined;
|
|
22
22
|
}
|
|
23
23
|
};
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// A script that fans out to every package (turbo, nx, lerna, pnpm -r) can't be narrowed to files.
|
|
25
|
+
const FANS_OUT = /\b(turbo|nx|lerna)\b|\s-r\b|--recursive|--workspaces?\b/;
|
|
26
|
+
// File paths appended to a chained script reach only its last command.
|
|
27
|
+
const CHAINED = /&&|\|\||;/;
|
|
28
|
+
const takesFiles = (script) => Boolean(script) && !FANS_OUT.test(script) && !CHAINED.test(script);
|
|
29
|
+
/** Affected test files grouped by the workspace package that owns them; each group runs through its own script, else the root's. */
|
|
30
|
+
export function plan(root, graph, tests, script = 'test') {
|
|
26
31
|
const byDir = new Map();
|
|
27
32
|
for (const t of tests) {
|
|
28
33
|
const dir = graph.packages.find((p) => t.startsWith(p)) ?? '';
|
|
29
34
|
(byDir.get(dir) ?? byDir.set(dir, []).get(dir)).push(t.slice(dir.length));
|
|
30
35
|
}
|
|
36
|
+
const rootTakesFiles = takesFiles(readJson(join(root, 'package.json'))?.scripts?.[script]);
|
|
31
37
|
return [...byDir]
|
|
32
38
|
.map(([dir, files]) => {
|
|
33
39
|
const pkg = readJson(join(root, dir, 'package.json'));
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const fansOut = !dir && /\b(turbo|nx|lerna)\b|\s-r\b|--recursive|--workspaces?\b/.test(script);
|
|
37
|
-
return { dir, name: pkg?.name ?? (dir || '(root)'), files: files.sort(), hasTestScript: Boolean(script) && !fansOut };
|
|
40
|
+
const via = dir && takesFiles(pkg?.scripts?.[script]) ? 'package' : rootTakesFiles ? 'root' : undefined;
|
|
41
|
+
return { dir, name: pkg?.name ?? (dir || '(root)'), files: files.sort(), via };
|
|
38
42
|
})
|
|
39
43
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
40
44
|
}
|
|
@@ -71,9 +75,10 @@ export async function testAffected(o) {
|
|
|
71
75
|
console.log(t);
|
|
72
76
|
return 0;
|
|
73
77
|
}
|
|
74
|
-
return exec(runScript(pm, 'test', []), root);
|
|
78
|
+
return exec(runScript(pm, o.script ?? 'test', []), root);
|
|
75
79
|
}
|
|
76
|
-
const
|
|
80
|
+
const script = o.script ?? 'test';
|
|
81
|
+
const runs = plan(root, graph, affected, script);
|
|
77
82
|
o.log(`${changed.length} changed files → ${affected.size} of ${graph.tests.size} test files in ${runs.length} packages`);
|
|
78
83
|
if (o.list) {
|
|
79
84
|
for (const t of [...affected].sort())
|
|
@@ -87,15 +92,21 @@ export async function testAffected(o) {
|
|
|
87
92
|
o.log('Building dependencies failed; not running tests.');
|
|
88
93
|
return 1;
|
|
89
94
|
}
|
|
95
|
+
// Never report success for tests that did not run.
|
|
96
|
+
const stranded = runs.filter((r) => !r.via);
|
|
97
|
+
if (stranded.length) {
|
|
98
|
+
for (const r of stranded)
|
|
99
|
+
o.log(`${r.name}: no "${script}" script here or at the root that accepts file paths, so ${r.files.length} affected test files can't run: ${r.files.slice(0, 3).join(', ')}`);
|
|
100
|
+
o.log(`Not running a partial suite. Pass --script <name> for a script that takes test files, or run the full suite.`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
90
103
|
let failed = 0;
|
|
91
|
-
for (const r of runs)
|
|
92
|
-
if (
|
|
93
|
-
o.log(`${r.name} has no test script that runs single files; skipped ${r.files.length} test files: ${r.files.slice(0, 3).join(', ')}`);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
if (exec(runScript(pm, 'test', r.files), join(root, r.dir)) !== 0)
|
|
104
|
+
for (const r of runs.filter((r) => r.via === 'package'))
|
|
105
|
+
if (exec(runScript(pm, script, r.files), join(root, r.dir)) !== 0)
|
|
97
106
|
failed++;
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
const viaRoot = runs.filter((r) => r.via === 'root').flatMap((r) => r.files.map((f) => r.dir + f));
|
|
108
|
+
if (viaRoot.length && exec(runScript(pm, script, viaRoot), root) !== 0)
|
|
109
|
+
failed++;
|
|
110
|
+
o.log(failed ? 'Some affected tests failed.' : `All affected tests passed (${affected.size} of ${graph.tests.size} test files).`);
|
|
100
111
|
return failed ? 1 : 0;
|
|
101
112
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ci-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Insanely fast CI. No migration. By Diffling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"homepage": "https://ci-sdk.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jaymalve/ci-sdk.git"
|
|
11
|
+
},
|
|
8
12
|
"bin": {
|
|
9
13
|
"ci-sdk": "dist/cli.js"
|
|
10
14
|
},
|