setup-git-repo 1.0.1 → 1.0.2
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/package.json +3 -2
- package/template/.github/scripts/list-test-packages.mjs +127 -0
- package/template/.github/scripts/pin-workspace-deps.mjs +109 -0
- package/template/.github/scripts/restore-pinned-deps.mjs +126 -0
- package/template/.github/scripts/workspace-build-order.mjs +127 -0
- package/template/.github/workflows/deploy-test-reports.yml +7 -0
- package/template/.github/workflows/npm-publish.yml +10 -68
- package/template/.github/workflows/tests.yml +7 -24
- package/template/docs/WORKFLOWS.md +55 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "setup-git-repo",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "One command to set up a GitHub repo: Turborepo, CI workflows, README badges, and the docs for each",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"homepage": "https://github.com/OpenSourceAGI/dev-tools-starter-agent/tree/master/packages/setup-git-repo",
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@vitest/coverage-v8": "^4.1.0",
|
|
46
|
-
"vitest": "^4.1.0"
|
|
46
|
+
"vitest": "^4.1.0",
|
|
47
|
+
"yaml": "^2.9.0"
|
|
47
48
|
}
|
|
48
49
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Emit the test matrix for `.github/workflows/tests.yml`.
|
|
4
|
+
*
|
|
5
|
+
* This was an inline `node --input-type=module -e '...'` in the workflow. Moved
|
|
6
|
+
* out for two reasons: an inline heredoc cannot be unit tested, and the inline
|
|
7
|
+
* version hardcoded `["packages", "apps"]` as the places to look. A repo whose
|
|
8
|
+
* workspaces are `libs/*` got an empty matrix and no error — the failure mode
|
|
9
|
+
* of the hand-maintained matrix this was meant to replace.
|
|
10
|
+
*
|
|
11
|
+
* The workspace globs from the root package.json are the source of truth now.
|
|
12
|
+
*
|
|
13
|
+
* node .github/scripts/list-test-packages.mjs [rootDir]
|
|
14
|
+
*
|
|
15
|
+
* Writes `packages=<json>` and `any=<bool>` to $GITHUB_OUTPUT (or stdout when
|
|
16
|
+
* that is unset, which is what the tests read).
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
|
+
import { basename, join } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
/** The script a package must define to be picked up, best first. */
|
|
23
|
+
const TEST_SCRIPTS = ["test:ci", "test:coverage", "test"];
|
|
24
|
+
|
|
25
|
+
/** Read and parse a JSON file, treating any failure as absent. */
|
|
26
|
+
function readJson(file) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Expand the root package.json `workspaces` field into directories.
|
|
36
|
+
*
|
|
37
|
+
* Only a trailing `/*` is expanded — anything cleverer would need a glob
|
|
38
|
+
* dependency in a script that runs before `install`. Both the array and the
|
|
39
|
+
* `{ packages: [] }` object forms are accepted, because npm and bun both do.
|
|
40
|
+
*
|
|
41
|
+
* Falls back to `packages` and `apps` so a repo with no workspaces field
|
|
42
|
+
* behaves as it did before this script existed.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} [root]
|
|
45
|
+
* @returns {string[]} directories relative to the repo root
|
|
46
|
+
*/
|
|
47
|
+
export function workspaceDirectories(root = ".") {
|
|
48
|
+
const manifest = readJson(join(root, "package.json"));
|
|
49
|
+
const globs = Array.isArray(manifest?.workspaces)
|
|
50
|
+
? manifest.workspaces
|
|
51
|
+
: (manifest?.workspaces?.packages ?? ["packages/*", "apps/*"]);
|
|
52
|
+
|
|
53
|
+
const directories = [];
|
|
54
|
+
|
|
55
|
+
for (const glob of globs) {
|
|
56
|
+
if (!glob.endsWith("/*")) {
|
|
57
|
+
if (existsSync(join(root, glob))) directories.push(glob);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const parent = glob.slice(0, -2);
|
|
62
|
+
if (!existsSync(join(root, parent))) continue;
|
|
63
|
+
|
|
64
|
+
for (const entry of readdirSync(join(root, parent), { withFileTypes: true })) {
|
|
65
|
+
if (entry.isDirectory()) directories.push(`${parent}/${entry.name}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return directories.sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every workspace package that declares a test script.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} [root]
|
|
76
|
+
* @returns {{ name: string, dir: string, flag: string, script: string }[]}
|
|
77
|
+
*/
|
|
78
|
+
export function testPackages(root = ".") {
|
|
79
|
+
const found = [];
|
|
80
|
+
|
|
81
|
+
for (const dir of workspaceDirectories(root)) {
|
|
82
|
+
const pkg = readJson(join(root, dir, "package.json"));
|
|
83
|
+
if (!pkg) continue;
|
|
84
|
+
|
|
85
|
+
const script = TEST_SCRIPTS.find((candidate) => pkg.scripts?.[candidate]);
|
|
86
|
+
if (!script) continue;
|
|
87
|
+
|
|
88
|
+
found.push({
|
|
89
|
+
name: pkg.name ?? basename(dir),
|
|
90
|
+
dir,
|
|
91
|
+
// The Codecov flag is the directory name, which is what someone reading
|
|
92
|
+
// the Codecov dashboard is looking for.
|
|
93
|
+
flag: basename(dir),
|
|
94
|
+
script,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {ReturnType<typeof testPackages>} packages
|
|
103
|
+
* @returns {string} the $GITHUB_OUTPUT lines
|
|
104
|
+
*/
|
|
105
|
+
export function formatOutput(packages) {
|
|
106
|
+
// `any` exists because GitHub fails a job whose matrix is empty, which would
|
|
107
|
+
// make a repo with no suites look broken rather than untested.
|
|
108
|
+
return `packages=${JSON.stringify(packages)}\nany=${packages.length > 0}\n`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
112
|
+
const packages = testPackages(process.argv[2]);
|
|
113
|
+
|
|
114
|
+
console.log(
|
|
115
|
+
packages.length > 0
|
|
116
|
+
? packages.map((p) => `${p.dir} (${p.script})`).join("\n")
|
|
117
|
+
: "No workspace package defines a test script.",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const output = formatOutput(packages);
|
|
121
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
122
|
+
const { appendFileSync } = await import("node:fs");
|
|
123
|
+
appendFileSync(process.env.GITHUB_OUTPUT, output);
|
|
124
|
+
} else {
|
|
125
|
+
process.stdout.write(output);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Rewrite `workspace:*` ranges in the current directory's package.json to real
|
|
4
|
+
* semver ranges, immediately before `npm publish` packs it.
|
|
5
|
+
*
|
|
6
|
+
* npm keeps the literal `workspace:*` protocol in the published tarball, and no
|
|
7
|
+
* consumer outside the monorepo can resolve it — installing such a package dies
|
|
8
|
+
* with EUNSUPPORTEDPROTOCOL. Every workspace has to substitute real ranges at
|
|
9
|
+
* pack time; this is that substitution, moved out of the inline `node -e` it
|
|
10
|
+
* used to be so it can be tested.
|
|
11
|
+
*
|
|
12
|
+
* A dependency on a local package that is private (and so never published) is
|
|
13
|
+
* dropped rather than pinned: pinning it produces a tarball that cannot install
|
|
14
|
+
* at all.
|
|
15
|
+
*
|
|
16
|
+
* The edit is left in the working tree on purpose — `restore-pinned-deps.mjs`
|
|
17
|
+
* takes it back out after the publish, keeping only any version bump.
|
|
18
|
+
*
|
|
19
|
+
* node .github/scripts/pin-workspace-deps.mjs [packageDir] [siblingsRoot...]
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
|
|
25
|
+
const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map local package name -> { version, private } across the given roots.
|
|
29
|
+
*
|
|
30
|
+
* @param {string[]} roots
|
|
31
|
+
* @returns {Map<string, { version: string, private: boolean }>}
|
|
32
|
+
*/
|
|
33
|
+
export function readSiblings(roots) {
|
|
34
|
+
const siblings = new Map();
|
|
35
|
+
|
|
36
|
+
for (const root of roots) {
|
|
37
|
+
if (!existsSync(root)) continue;
|
|
38
|
+
|
|
39
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
40
|
+
if (!entry.isDirectory()) continue;
|
|
41
|
+
|
|
42
|
+
const manifest = join(root, entry.name, "package.json");
|
|
43
|
+
if (!existsSync(manifest)) continue;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
47
|
+
if (pkg.name && pkg.version) {
|
|
48
|
+
siblings.set(pkg.name, { version: pkg.version, private: Boolean(pkg.private) });
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// A sibling with an unreadable manifest cannot be pinned against.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return siblings;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Substitute every `workspace:` range in `pkg`, in place.
|
|
61
|
+
*
|
|
62
|
+
* @param {Record<string, any>} pkg parsed package.json, mutated
|
|
63
|
+
* @param {Map<string, { version: string, private: boolean }>} siblings
|
|
64
|
+
* @returns {string[]} log lines describing what changed
|
|
65
|
+
*/
|
|
66
|
+
export function pinWorkspaceDeps(pkg, siblings) {
|
|
67
|
+
const changes = [];
|
|
68
|
+
|
|
69
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
70
|
+
const deps = pkg[field];
|
|
71
|
+
if (!deps) continue;
|
|
72
|
+
|
|
73
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
74
|
+
if (typeof range !== "string" || !range.startsWith("workspace:")) continue;
|
|
75
|
+
|
|
76
|
+
const sibling = siblings.get(name);
|
|
77
|
+
|
|
78
|
+
if (!sibling || sibling.private) {
|
|
79
|
+
delete deps[name];
|
|
80
|
+
changes.push(`Removed unpublishable workspace dependency: ${name}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// `workspace:1.2.3` and `workspace:^1.2.3` already carry the range the
|
|
85
|
+
// author chose; only the `*`, `~` and `^` shorthands need the local
|
|
86
|
+
// version substituted in.
|
|
87
|
+
const suffix = range.slice("workspace:".length);
|
|
88
|
+
deps[name] = /^[~^]?\d/.test(suffix) ? suffix : `^${sibling.version}`;
|
|
89
|
+
changes.push(`Pinned workspace dependency ${name} -> ${deps[name]}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return changes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
97
|
+
const [packageDir = ".", ...rootArgs] = process.argv.slice(2);
|
|
98
|
+
// Default roots are the publish workflow's: siblings live one level up from
|
|
99
|
+
// the package being published.
|
|
100
|
+
const roots = rootArgs.length > 0 ? rootArgs : [join(packageDir, "..")];
|
|
101
|
+
|
|
102
|
+
const manifestPath = join(packageDir, "package.json");
|
|
103
|
+
const pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
104
|
+
|
|
105
|
+
const changes = pinWorkspaceDeps(pkg, readSiblings(roots));
|
|
106
|
+
for (const change of changes) console.log(change);
|
|
107
|
+
|
|
108
|
+
if (changes.length > 0) writeFileSync(manifestPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
109
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Undo `pin-workspace-deps.mjs` across the workspace, keeping only the version
|
|
4
|
+
* bumps the publish step made.
|
|
5
|
+
*
|
|
6
|
+
* After a publish run each published package.json holds two kinds of edit: a
|
|
7
|
+
* `version` bump, which must be committed so the next run does not re-derive
|
|
8
|
+
* it, and the `workspace:*` -> `^x.y.z` pinning, which must not — committing
|
|
9
|
+
* that would freeze siblings at whatever version they happened to have and
|
|
10
|
+
* break local development.
|
|
11
|
+
*
|
|
12
|
+
* Restoring each file from `git show HEAD:` and patching only the version back
|
|
13
|
+
* in keeps the committed diff to one line per bumped package, and keeps each
|
|
14
|
+
* file's original formatting.
|
|
15
|
+
*
|
|
16
|
+
* This replaces an inline `node -e` that matched the literal string
|
|
17
|
+
* `"version": "<old>"`. A package.json without the space after the colon — or
|
|
18
|
+
* with tab indentation — silently kept its pinned dependencies and lost its
|
|
19
|
+
* bump, while logging that it had kept it. The rewrite is now verified: a patch
|
|
20
|
+
* that matches nothing is an error, not a silent no-op.
|
|
21
|
+
*
|
|
22
|
+
* node .github/scripts/restore-pinned-deps.mjs [packagesRoot...]
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { pathToFileURL } from "node:url";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Put `toVersion` back into the committed file text, leaving all else as it was
|
|
31
|
+
* committed.
|
|
32
|
+
*
|
|
33
|
+
* A targeted replace rather than parse-and-reserialize: reserializing would
|
|
34
|
+
* reformat files that use different indentation or key order, turning a
|
|
35
|
+
* one-line version bump into a whole-file diff.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} original the committed file text
|
|
38
|
+
* @param {string} fromVersion version in the committed text
|
|
39
|
+
* @param {string} toVersion version to restore
|
|
40
|
+
* @returns {string}
|
|
41
|
+
* @throws if the version field could not be found
|
|
42
|
+
*/
|
|
43
|
+
export function patchVersion(original, fromVersion, toVersion) {
|
|
44
|
+
if (fromVersion === toVersion) return original;
|
|
45
|
+
|
|
46
|
+
const pattern = new RegExp(`("version"\\s*:\\s*)"${fromVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`);
|
|
47
|
+
const patched = original.replace(pattern, `$1"${toVersion}"`);
|
|
48
|
+
|
|
49
|
+
if (patched === original) {
|
|
50
|
+
throw new Error(`could not find "version": "${fromVersion}" to replace with "${toVersion}"`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return patched;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One package whose version field cannot be rewritten must not abort the loop
|
|
58
|
+
* and leave every later package still carrying its pinned dependencies, so a
|
|
59
|
+
* failure is collected and reported rather than thrown.
|
|
60
|
+
*
|
|
61
|
+
* @param {string[]} roots
|
|
62
|
+
* @param {(file: string) => string} [showHead] injection point for tests
|
|
63
|
+
* @returns {{ messages: string[], failures: string[] }}
|
|
64
|
+
*/
|
|
65
|
+
export function restore(roots, showHead = defaultShowHead) {
|
|
66
|
+
const messages = [];
|
|
67
|
+
const failures = [];
|
|
68
|
+
|
|
69
|
+
for (const root of roots) {
|
|
70
|
+
if (!existsSync(root)) continue;
|
|
71
|
+
|
|
72
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
73
|
+
if (!entry.isDirectory()) continue;
|
|
74
|
+
|
|
75
|
+
const file = join(root, entry.name, "package.json");
|
|
76
|
+
if (!existsSync(file)) continue;
|
|
77
|
+
|
|
78
|
+
let committed;
|
|
79
|
+
try {
|
|
80
|
+
committed = showHead(file);
|
|
81
|
+
} catch {
|
|
82
|
+
// An untracked package has no committed text to restore to, and its
|
|
83
|
+
// whole manifest is new — leave it exactly as it is.
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const current = JSON.parse(readFileSync(file, "utf8"));
|
|
88
|
+
const original = JSON.parse(committed);
|
|
89
|
+
|
|
90
|
+
let restored;
|
|
91
|
+
try {
|
|
92
|
+
restored = patchVersion(committed, original.version, current.version);
|
|
93
|
+
} catch {
|
|
94
|
+
// The package was published under the new version; if the repo keeps
|
|
95
|
+
// the old one, the next run republishes the same content forever.
|
|
96
|
+
failures.push(
|
|
97
|
+
`::error title=Version bump lost::Could not rewrite the version field in ${file}. ` +
|
|
98
|
+
`${current.name} was published as ${current.version} but the repo still says ${original.version}.`,
|
|
99
|
+
);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (original.version !== current.version) {
|
|
104
|
+
messages.push(`Keeping version bump for ${current.name} -> ${current.version}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
writeFileSync(file, restored);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { messages, failures };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function defaultShowHead(file) {
|
|
115
|
+
return execFileSync("git", ["show", `HEAD:${file}`], { encoding: "utf8" });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
119
|
+
const roots = process.argv.slice(2);
|
|
120
|
+
const { messages, failures } = restore(roots.length > 0 ? roots : ["packages", "apps"]);
|
|
121
|
+
|
|
122
|
+
for (const message of messages) console.log(message);
|
|
123
|
+
for (const failure of failures) console.log(failure);
|
|
124
|
+
|
|
125
|
+
if (failures.length > 0) process.exitCode = 1;
|
|
126
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Order the workspace's package directories so every package comes after each
|
|
4
|
+
* sibling it depends on.
|
|
5
|
+
*
|
|
6
|
+
* The publish workflow used to walk `packages/*/ apps/*/`, i.e. shell-glob
|
|
7
|
+
* (alphabetical) order, which has nothing to do with the dependency graph. Bun
|
|
8
|
+
* and pnpm link workspace siblings into `node_modules` as symlinks, so a
|
|
9
|
+
* sibling that has not been built yet has no `dist/`, and the `exports` ->
|
|
10
|
+
* `types` entries in its package.json point at files that do not exist. The
|
|
11
|
+
* package that sorts earlier then fails its declaration build with:
|
|
12
|
+
*
|
|
13
|
+
* Cannot find module '<sibling>' or its corresponding type declarations.
|
|
14
|
+
*
|
|
15
|
+
* Dependency order also means a sibling's `workspace:*` pin is rewritten to the
|
|
16
|
+
* version that sibling just published, rather than the one it had before its
|
|
17
|
+
* own bump.
|
|
18
|
+
*
|
|
19
|
+
* node .github/scripts/workspace-build-order.mjs [rootDir...]
|
|
20
|
+
*
|
|
21
|
+
* Prints one directory per line with a trailing slash, e.g. `packages/core/`.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
|
|
27
|
+
const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read every `<root>/<dir>/package.json` across the given roots.
|
|
31
|
+
*
|
|
32
|
+
* @param {string[]} roots
|
|
33
|
+
* @returns {{ dir: string, pkg: Record<string, any> }[]}
|
|
34
|
+
*/
|
|
35
|
+
export function readWorkspacePackages(roots) {
|
|
36
|
+
const packages = [];
|
|
37
|
+
|
|
38
|
+
for (const root of roots) {
|
|
39
|
+
if (!existsSync(root)) continue;
|
|
40
|
+
|
|
41
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
42
|
+
if (!entry.isDirectory()) continue;
|
|
43
|
+
|
|
44
|
+
const manifest = join(root, entry.name, "package.json");
|
|
45
|
+
if (!existsSync(manifest)) continue;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
49
|
+
if (pkg.name) packages.push({ dir: `${root}/${entry.name}`, pkg });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`Skipping ${manifest}: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return packages;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Map each package directory to the sibling directories it depends on.
|
|
61
|
+
*
|
|
62
|
+
* Edges are keyed by package *name*, not by the version range: a workspace
|
|
63
|
+
* sibling is linked whenever the name matches, so `"core": "^1.2.3"` resolves
|
|
64
|
+
* to the local copy exactly as `"workspace:*"` does and needs building just the
|
|
65
|
+
* same.
|
|
66
|
+
*
|
|
67
|
+
* @param {ReturnType<typeof readWorkspacePackages>} packages
|
|
68
|
+
* @returns {Map<string, Set<string>>}
|
|
69
|
+
*/
|
|
70
|
+
export function localDependencyGraph(packages) {
|
|
71
|
+
const dirByName = new Map(packages.map(({ dir, pkg }) => [pkg.name, dir]));
|
|
72
|
+
const graph = new Map();
|
|
73
|
+
|
|
74
|
+
for (const { dir, pkg } of packages) {
|
|
75
|
+
const local = new Set();
|
|
76
|
+
|
|
77
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
78
|
+
for (const name of Object.keys(pkg[field] ?? {})) {
|
|
79
|
+
const dependency = dirByName.get(name);
|
|
80
|
+
if (dependency && dependency !== dir) local.add(dependency);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
graph.set(dir, local);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return graph;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Topologically sort the workspace, alphabetically within each ready set so the
|
|
92
|
+
* order is stable across runs.
|
|
93
|
+
*
|
|
94
|
+
* @param {string[]} [roots]
|
|
95
|
+
* @returns {string[]} directories with a trailing slash
|
|
96
|
+
*/
|
|
97
|
+
export function workspaceBuildOrder(roots = ["packages", "apps"]) {
|
|
98
|
+
const packages = readWorkspacePackages(roots);
|
|
99
|
+
const dependencies = localDependencyGraph(packages);
|
|
100
|
+
|
|
101
|
+
const pending = packages.map(({ dir }) => dir).sort();
|
|
102
|
+
const built = new Set();
|
|
103
|
+
const order = [];
|
|
104
|
+
|
|
105
|
+
while (pending.length > 0) {
|
|
106
|
+
const ready = pending.findIndex((dir) => [...dependencies.get(dir)].every((dep) => built.has(dep)));
|
|
107
|
+
|
|
108
|
+
// A dependency cycle leaves nothing ready. No order can be right, so take
|
|
109
|
+
// the alphabetically first entry and keep going: everything still gets
|
|
110
|
+
// built and published, and the cycle is reported on stderr where it lands
|
|
111
|
+
// in the job log.
|
|
112
|
+
if (ready === -1) {
|
|
113
|
+
console.error(`Dependency cycle involving ${pending[0]} — falling back to alphabetical order for it`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const [dir] = pending.splice(ready === -1 ? 0 : ready, 1);
|
|
117
|
+
built.add(dir);
|
|
118
|
+
order.push(`${dir}/`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return order;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
125
|
+
const roots = process.argv.slice(2);
|
|
126
|
+
console.log(workspaceBuildOrder(roots.length > 0 ? roots : undefined).join("\n"));
|
|
127
|
+
}
|
|
@@ -7,6 +7,13 @@ on:
|
|
|
7
7
|
branches: [{{DEFAULT_BRANCH}}]
|
|
8
8
|
workflow_dispatch:
|
|
9
9
|
|
|
10
|
+
# Two pushes in quick succession would otherwise race `wrangler deploy`, and the
|
|
11
|
+
# report that wins is whichever upload finishes last, not whichever commit is
|
|
12
|
+
# newer. Cancel the older run instead.
|
|
13
|
+
concurrency:
|
|
14
|
+
group: test-reports
|
|
15
|
+
cancel-in-progress: true
|
|
16
|
+
|
|
10
17
|
jobs:
|
|
11
18
|
deploy:
|
|
12
19
|
name: Generate & Deploy Test Reports
|
|
@@ -166,7 +166,14 @@ jobs:
|
|
|
166
166
|
unattempted_packages=""
|
|
167
167
|
auth_broken=""
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
# Dependency order, not shell-glob (alphabetical) order: a sibling
|
|
170
|
+
# that has not been built yet has no dist/, so a package that sorts
|
|
171
|
+
# earlier fails its declaration build on every sibling it imports.
|
|
172
|
+
build_order=$(node "$GITHUB_WORKSPACE/.github/scripts/workspace-build-order.mjs" packages apps)
|
|
173
|
+
echo "📋 Build order:"
|
|
174
|
+
echo "$build_order" | sed 's/^/ /'
|
|
175
|
+
|
|
176
|
+
for dir in $build_order; do
|
|
170
177
|
pkg="${dir}package.json"
|
|
171
178
|
[ -f "$pkg" ] || continue
|
|
172
179
|
|
|
@@ -201,37 +208,7 @@ jobs:
|
|
|
201
208
|
# semver range taken from the referenced local package; drop
|
|
202
209
|
# local packages that are not published. Only the version field
|
|
203
210
|
# of this edit is committed back, in the final step.
|
|
204
|
-
node -
|
|
205
|
-
const fs = require('fs');
|
|
206
|
-
const path = require('path');
|
|
207
|
-
const localVersions = {};
|
|
208
|
-
for (const root of ['../../packages', '../../apps']) {
|
|
209
|
-
if (!fs.existsSync(root)) continue;
|
|
210
|
-
for (const d of fs.readdirSync(root)) {
|
|
211
|
-
const sib = path.join(root, d, 'package.json');
|
|
212
|
-
if (!fs.existsSync(sib)) continue;
|
|
213
|
-
try {
|
|
214
|
-
const sp = JSON.parse(fs.readFileSync(sib, 'utf8'));
|
|
215
|
-
if (sp.name && sp.version && !sp.private) localVersions[sp.name] = sp.version;
|
|
216
|
-
} catch {}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const p = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
220
|
-
for (const s of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
|
221
|
-
if (!p[s]) continue;
|
|
222
|
-
for (const [k, v] of Object.entries(p[s])) {
|
|
223
|
-
if (typeof v !== 'string' || !v.startsWith('workspace:')) continue;
|
|
224
|
-
if (localVersions[k]) {
|
|
225
|
-
p[s][k] = '^' + localVersions[k];
|
|
226
|
-
console.log('Pinned workspace dependency', k, '->', p[s][k]);
|
|
227
|
-
} else {
|
|
228
|
-
delete p[s][k];
|
|
229
|
-
console.log('Removed unresolved workspace dependency:', k);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
fs.writeFileSync('package.json', JSON.stringify(p, null, 2));
|
|
234
|
-
"
|
|
211
|
+
node "$GITHUB_WORKSPACE/.github/scripts/pin-workspace-deps.mjs" . ../../packages ../../apps
|
|
235
212
|
|
|
236
213
|
version=$(node -e "const p=require('./package.json'); console.log(p.version)")
|
|
237
214
|
latest=$(npm view "$name" version 2>/dev/null || echo "")
|
|
@@ -351,42 +328,7 @@ jobs:
|
|
|
351
328
|
# Version bumps made during publish must land back in the repo so the
|
|
352
329
|
# next run sees them. Restore the workspace:* pinning done for the
|
|
353
330
|
# tarballs first — only the "version" field should be committed.
|
|
354
|
-
node -
|
|
355
|
-
const fs = require('fs');
|
|
356
|
-
const cp = require('child_process');
|
|
357
|
-
for (const root of ['packages', 'apps']) {
|
|
358
|
-
if (!fs.existsSync(root)) continue;
|
|
359
|
-
for (const d of fs.readdirSync(root)) {
|
|
360
|
-
const f = root + '/' + d + '/package.json';
|
|
361
|
-
if (!fs.existsSync(f)) continue;
|
|
362
|
-
const now = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
363
|
-
let raw;
|
|
364
|
-
try {
|
|
365
|
-
raw = cp.execSync('git show HEAD:' + f, { encoding: 'utf8' });
|
|
366
|
-
} catch { continue; }
|
|
367
|
-
const orig = JSON.parse(raw);
|
|
368
|
-
if (orig.version !== now.version) {
|
|
369
|
-
// Patch only the version string so the file keeps whatever
|
|
370
|
-
// formatting it had. Matching on a literal '\"version\": \"x\"'
|
|
371
|
-
// would miss a package.json written without the space after
|
|
372
|
-
// the colon, and silently drop the bump while claiming to
|
|
373
|
-
// keep it — so match the field, then assert it changed.
|
|
374
|
-
const before = raw;
|
|
375
|
-
raw = raw.replace(
|
|
376
|
-
/(\"version\"\s*:\s*\")[^\"]*(\")/,
|
|
377
|
-
(m, open_, close) => open_ + now.version + close
|
|
378
|
-
);
|
|
379
|
-
if (raw === before) {
|
|
380
|
-
console.log('::error title=Version bump lost::Could not rewrite the version field in ' + f + '. ' + now.name + ' was published as ' + now.version + ' but the repo still says ' + orig.version + '.');
|
|
381
|
-
process.exitCode = 1;
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
console.log('Keeping version bump for', now.name, '->', now.version);
|
|
385
|
-
}
|
|
386
|
-
fs.writeFileSync(f, raw);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
"
|
|
331
|
+
node .github/scripts/restore-pinned-deps.mjs packages apps
|
|
390
332
|
git add packages/*/package.json apps/*/package.json 2>/dev/null || true
|
|
391
333
|
git diff --staged --quiet && echo "No version changes to commit" && exit 0
|
|
392
334
|
# [skip ci] so the bump commit does not retrigger this workflow.
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
# per test and comments the failing ones on the pull request.
|
|
4
4
|
#
|
|
5
5
|
# The matrix is discovered at run time rather than hand-maintained: adding a
|
|
6
|
-
# package with a `test:ci` script is all it takes to get it tested here.
|
|
7
|
-
#
|
|
6
|
+
# package with a `test:ci` script is all it takes to get it tested here. The
|
|
7
|
+
# packages searched come from the root package.json `workspaces` globs, so a
|
|
8
|
+
# repo laid out as `libs/*` is covered without editing this file. That script
|
|
9
|
+
# must write, relative to the package directory:
|
|
8
10
|
#
|
|
9
11
|
# junit.xml - test results, the input Test Analytics ingests
|
|
10
12
|
# coverage/lcov.info - coverage, uploaded when the runner can produce it
|
|
@@ -36,27 +38,8 @@ jobs:
|
|
|
36
38
|
- uses: actions/checkout@v4
|
|
37
39
|
|
|
38
40
|
- id: list
|
|
39
|
-
name: Find packages with a test
|
|
40
|
-
run:
|
|
41
|
-
node --input-type=module -e '
|
|
42
|
-
import { readdirSync, readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
43
|
-
const out = [];
|
|
44
|
-
for (const root of ["packages", "apps"]) {
|
|
45
|
-
if (!existsSync(root)) continue;
|
|
46
|
-
for (const dir of readdirSync(root)) {
|
|
47
|
-
const file = `${root}/${dir}/package.json`;
|
|
48
|
-
if (!existsSync(file)) continue;
|
|
49
|
-
let pkg;
|
|
50
|
-
try { pkg = JSON.parse(readFileSync(file, "utf8")); } catch { continue; }
|
|
51
|
-
if (!pkg.scripts?.["test:ci"]) continue;
|
|
52
|
-
out.push({ name: pkg.name ?? dir, dir: `${root}/${dir}` });
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
out.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
56
|
-
appendFileSync(process.env.GITHUB_OUTPUT, `packages=${JSON.stringify(out)}\n`);
|
|
57
|
-
appendFileSync(process.env.GITHUB_OUTPUT, `any=${out.length > 0}\n`);
|
|
58
|
-
console.log(out.length ? out.map((p) => p.dir).join("\n") : "No package defines a test:ci script.");
|
|
59
|
-
'
|
|
41
|
+
name: Find packages with a test script
|
|
42
|
+
run: node .github/scripts/list-test-packages.mjs
|
|
60
43
|
|
|
61
44
|
test:
|
|
62
45
|
needs: discover
|
|
@@ -85,7 +68,7 @@ jobs:
|
|
|
85
68
|
# `turbo run` builds this package's workspace dependencies first, so a
|
|
86
69
|
# package that imports a sibling through its built `dist` output finds it.
|
|
87
70
|
- name: Run tests
|
|
88
|
-
run: bunx turbo run
|
|
71
|
+
run: bunx turbo run ${{ matrix.package.script }} --filter=./${{ matrix.package.dir }}
|
|
89
72
|
|
|
90
73
|
# `!cancelled()` so a red suite still reports: without it the upload is
|
|
91
74
|
# skipped on exactly the runs whose results matter most.
|
|
@@ -16,7 +16,7 @@ the part that costs hours if you meet them cold.
|
|
|
16
16
|
|
|
17
17
|
## tests.yml
|
|
18
18
|
|
|
19
|
-
Discovers every workspace package with a
|
|
19
|
+
Discovers every workspace package with a test script, runs each as its own
|
|
20
20
|
matrix job, and uploads results to Codecov Test Analytics and coverage to
|
|
21
21
|
Codecov.
|
|
22
22
|
|
|
@@ -26,6 +26,12 @@ workflow — the package silently has no CI. A `discover` job emits the list as
|
|
|
26
26
|
JSON; the `test` job consumes it through `fromJSON`. GitHub cannot compute a
|
|
27
27
|
matrix inside the job that uses it, which is why it is two jobs.
|
|
28
28
|
|
|
29
|
+
**Where it looks.** `.github/scripts/list-test-packages.mjs` expands the root
|
|
30
|
+
package.json `workspaces` globs, so a repo laid out as `libs/*` is covered with
|
|
31
|
+
no edit here. It prefers `test:ci`, falling back to `test:coverage` and then
|
|
32
|
+
`test`, and the job runs whichever it found — a package with only a plain `test`
|
|
33
|
+
script is tested rather than skipped.
|
|
34
|
+
|
|
29
35
|
**What your package must produce.** Relative to the package directory:
|
|
30
36
|
|
|
31
37
|
```
|
|
@@ -56,7 +62,7 @@ For Vitest:
|
|
|
56
62
|
|
|
57
63
|
| Symptom | Cause → fix |
|
|
58
64
|
| --- | --- |
|
|
59
|
-
| A package you added never appears as a job | It has no `test:ci` script, or it lives outside
|
|
65
|
+
| A package you added never appears as a job | It has no `test:ci`, `test:coverage` or `test` script, or it lives outside the root package.json `workspaces` globs |
|
|
60
66
|
| `Error: Unable to process file command 'output'` in `discover` | A package name contains a newline or the JSON exceeded the 1MB output cap |
|
|
61
67
|
| Codecov shows the run but no coverage | The runner wrote no `lcov.info` — `--coverage` missing, or a provider that writes nothing on your Node version |
|
|
62
68
|
| Coverage drops to 0% for an untouched package | `carryforward` is off for that flag in `codecov.yml` |
|
|
@@ -93,6 +99,14 @@ provenance statement into the public sigstore transparency log.
|
|
|
93
99
|
|
|
94
100
|
**Details that matter:**
|
|
95
101
|
|
|
102
|
+
- Packages are walked in dependency order, from
|
|
103
|
+
`.github/scripts/workspace-build-order.mjs`, not in shell-glob (alphabetical)
|
|
104
|
+
order. Workspace siblings are symlinked into `node_modules`, so one that has
|
|
105
|
+
not been built yet has no `dist/` and its package.json `types` entries point
|
|
106
|
+
at files that do not exist — the package that sorts earlier fails its
|
|
107
|
+
declaration build with `Cannot find module '<sibling>'`. Dependency order also
|
|
108
|
+
means a sibling's `workspace:*` pin is rewritten to the version that sibling
|
|
109
|
+
just published, not the one it had before its own bump.
|
|
96
110
|
- `set +e` in the publish loop. GitHub runs `run:` steps with `bash -e -o
|
|
97
111
|
pipefail`, so *not* writing `set -e` does not disable errexit — the first
|
|
98
112
|
failing package would kill the step and leave every later package unevaluated.
|
|
@@ -100,21 +114,27 @@ provenance statement into the public sigstore transparency log.
|
|
|
100
114
|
- Exit 43 means "npm rejected the credential". The loop stops attempting further
|
|
101
115
|
packages: they would all fail the same way, after another build and another
|
|
102
116
|
provenance signature each.
|
|
103
|
-
- `workspace:*` dependencies are rewritten to real semver ranges before packing
|
|
104
|
-
npm keeps the literal protocol in
|
|
105
|
-
|
|
117
|
+
- `workspace:*` dependencies are rewritten to real semver ranges before packing
|
|
118
|
+
(`.github/scripts/pin-workspace-deps.mjs`). npm keeps the literal protocol in
|
|
119
|
+
the tarball, and consumers cannot resolve it. A dependency on a *private*
|
|
120
|
+
sibling is dropped rather than pinned — pinning it produces a tarball that
|
|
121
|
+
cannot install at all. An explicit `workspace:^1.0.0` keeps the range its
|
|
122
|
+
author chose. Only the `version` field of that edit is committed back.
|
|
106
123
|
- A local version *behind* the registry is synced forward first, otherwise the
|
|
107
124
|
publish fails with "Cannot implicitly apply the latest tag".
|
|
108
125
|
- `E409 cannot publish over previously staged version` is retried at the next
|
|
109
126
|
free version, up to five times. `latest` lags versions an interrupted publish
|
|
110
127
|
reserved; `.github/scripts/next-free-version.mjs` reads the full version list
|
|
111
128
|
*and* the release timeline, which is where those numbers appear.
|
|
112
|
-
- The final step rewrites only the
|
|
113
|
-
`package.json`, by regex rather than by
|
|
114
|
-
the file keeps its own formatting. It
|
|
115
|
-
than a literal `"version": "x"`, and
|
|
116
|
-
nothing — a package.json written
|
|
117
|
-
|
|
129
|
+
- The final step (`.github/scripts/restore-pinned-deps.mjs`) rewrites only the
|
|
130
|
+
`version` field back into each `package.json`, by regex rather than by
|
|
131
|
+
re-serializing the parsed object, so the file keeps its own formatting. It
|
|
132
|
+
matches `"version"\s*:\s*"..."` rather than a literal `"version": "x"`, and
|
|
133
|
+
reports a failure if the replace found nothing — a package.json written
|
|
134
|
+
without the space after the colon would otherwise silently lose the bump while
|
|
135
|
+
the log claimed to keep it. One unrewritable file is reported and the step
|
|
136
|
+
fails at the end, rather than aborting the loop and leaving every later
|
|
137
|
+
package still carrying its pinned dependencies.
|
|
118
138
|
- The bump commit ends in `[skip ci]` so it does not retrigger the workflow.
|
|
119
139
|
- A no-op `husky` is put on PATH. Some published dependencies ship
|
|
120
140
|
`prepare: "husky install"`; when npm reconciles bun's linked `node_modules` it
|
|
@@ -203,3 +223,27 @@ it fetches them into an empty workspace rather than paying for a full checkout.
|
|
|
203
223
|
- **`main` instead of `master`.** Every `branches:` filter in this template is
|
|
204
224
|
written by `setup-git-repo` from your repo's actual default branch. If you
|
|
205
225
|
rename the branch later, grep the workflows for the old name.
|
|
226
|
+
|
|
227
|
+
## .github/scripts/
|
|
228
|
+
|
|
229
|
+
The workflows' logic lives here rather than in inline `node -e` heredocs, so it
|
|
230
|
+
can be unit tested — these run in *your* CI with nobody watching, and a bug in
|
|
231
|
+
one surfaces as a failed workflow rather than a failed test. They are covered by
|
|
232
|
+
`packages/setup-git-repo/test/template-scripts.test.mjs` in the repo this
|
|
233
|
+
template comes from.
|
|
234
|
+
|
|
235
|
+
| Script | Called by | Does |
|
|
236
|
+
| --- | --- | --- |
|
|
237
|
+
| `list-test-packages.mjs` | `tests.yml` | Emits the test matrix from the root `workspaces` globs |
|
|
238
|
+
| `workspace-build-order.mjs` | `npm-publish.yml` | Topologically sorts the workspace so a package is built after its siblings |
|
|
239
|
+
| `pin-workspace-deps.mjs` | `npm-publish.yml` | Rewrites `workspace:*` to real ranges at pack time |
|
|
240
|
+
| `next-free-version.mjs` | `npm-publish.yml` | Picks a version the registry has not already spent |
|
|
241
|
+
| `restore-pinned-deps.mjs` | `npm-publish.yml` | Undoes the pinning, keeping only the version bumps |
|
|
242
|
+
|
|
243
|
+
Each takes its roots as arguments and prints to stdout, so you can run any of
|
|
244
|
+
them locally against your own repo to see what CI will see:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
node .github/scripts/list-test-packages.mjs
|
|
248
|
+
node .github/scripts/workspace-build-order.mjs packages apps
|
|
249
|
+
```
|