@decohere/ci-tools 0.0.1-dev.20261105.103 → 0.0.1-dev.20261105.116
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 +2 -4
- package/src/release/promote-latest.mjs +7 -0
- package/src/release/restamp-build.mjs +87 -0
- package/tests/github/publish-workflow.test.mjs +68 -0
- package/src/github/create-or-update-pr.mjs +0 -143
- package/src/github/smoke-create-launcher.mjs +0 -85
- package/src/release/capture-version.mjs +0 -27
- package/src/release/npm-links.mjs +0 -32
- package/src/release/push-prerelease-commit.mjs +0 -85
- package/src/release/sync-dependencies.mjs +0 -26
- package/tests/github/smoke-create-launcher.test.mjs +0 -132
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decohere/ci-tools",
|
|
3
|
-
"version": "0.0.1-dev.20261105.
|
|
3
|
+
"version": "0.0.1-dev.20261105.116",
|
|
4
4
|
"description": "CI utility scripts for badge generation, coverage reporting, pages artifact assembly, and GitHub API tooling",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -41,11 +41,9 @@
|
|
|
41
41
|
"./changeset/check-status": "./src/changeset/check-status.mjs",
|
|
42
42
|
"./github/create-issue": "./src/github/create-issue.mjs",
|
|
43
43
|
"./github/ensure-metadata": "./src/github/ensure-metadata.mjs",
|
|
44
|
-
"./github/create-or-update-pr": "./src/github/create-or-update-pr.mjs",
|
|
45
44
|
"./github/publish-verify": "./src/github/publish-verify.mjs",
|
|
46
45
|
"./github/first-publish-check": "./src/github/first-publish-check.mjs",
|
|
47
|
-
"./github/npm-bootstrap": "./src/github/npm-bootstrap.mjs"
|
|
48
|
-
"./github/smoke-create-launcher": "./src/github/smoke-create-launcher.mjs"
|
|
46
|
+
"./github/npm-bootstrap": "./src/github/npm-bootstrap.mjs"
|
|
49
47
|
},
|
|
50
48
|
"scripts": {
|
|
51
49
|
"test": "vitest run"
|
|
@@ -43,6 +43,13 @@ export function promoteLatest(options) {
|
|
|
43
43
|
}
|
|
44
44
|
log(`Promoting ${version} to latest on ${packages.length} packages`);
|
|
45
45
|
|
|
46
|
+
// Once a stable version is out, latest belongs to releases: see "Stable release" in docs/ci.
|
|
47
|
+
const current = /^latest: (.+)$/m.exec(npm("dist-tag", "ls", LAUNCH_PACKAGE).stdout)?.[1];
|
|
48
|
+
if (current != null && !current.includes("-") && version.includes("-")) {
|
|
49
|
+
log(`latest is the stable ${current}, and ${version} is a dev version. Nothing moved.`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
const tag = run("git", ["tag", "--list", `v${version}`]).stdout.trim();
|
|
47
54
|
if (tag !== `v${version}`) {
|
|
48
55
|
log(`${version} has no v${version} tag, so it didn't fully ship. Nothing moved.`);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gives a tested build its stable version by changing only `package.json` files, so what ships is
|
|
4
|
+
* the code that was tested. Refuses if the old version appears anywhere else, because then the
|
|
5
|
+
* version couldn't be changed in those files alone. Checks everything before writing anything.
|
|
6
|
+
*
|
|
7
|
+
* node restamp-build.mjs <build dir> <tested version> <stable version>
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join, relative, resolve } from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
const STABLE = /^\d+\.\d+\.\d+$/;
|
|
14
|
+
const DEPENDENCY_KEYS = [
|
|
15
|
+
"dependencies",
|
|
16
|
+
"devDependencies",
|
|
17
|
+
"peerDependencies",
|
|
18
|
+
"optionalDependencies",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function listFiles(dir) {
|
|
22
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
23
|
+
const path = join(dir, entry.name);
|
|
24
|
+
return entry.isDirectory() ? listFiles(path) : [path];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ dir: string, from: string, to: string }} options
|
|
30
|
+
* @returns {{ packages: string[] }}
|
|
31
|
+
*/
|
|
32
|
+
export function restampBuild({ dir, from, to }) {
|
|
33
|
+
if (!STABLE.test(to)) {
|
|
34
|
+
throw new Error(`${to} isn't a stable version: expected major.minor.patch`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const files = listFiles(dir);
|
|
38
|
+
const stamped = [];
|
|
39
|
+
const elsewhere = [];
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
const text = readFileSync(file, "utf8");
|
|
42
|
+
if (file.endsWith("/package.json")) {
|
|
43
|
+
const manifest = JSON.parse(text);
|
|
44
|
+
if (manifest.version !== from) {
|
|
45
|
+
throw new Error(`${manifest.name} is at ${manifest.version}, not ${from}`);
|
|
46
|
+
}
|
|
47
|
+
manifest.version = to;
|
|
48
|
+
for (const key of DEPENDENCY_KEYS) {
|
|
49
|
+
for (const name of Object.keys(manifest[key] ?? {})) {
|
|
50
|
+
if (name.startsWith("@decohere/") && manifest[key][name] === from) {
|
|
51
|
+
manifest[key][name] = to;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const output = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
56
|
+
if (output.includes(from)) elsewhere.push(relative(dir, file));
|
|
57
|
+
stamped.push({ file, name: manifest.name, output });
|
|
58
|
+
} else if (text.includes(from)) {
|
|
59
|
+
elsewhere.push(relative(dir, file));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (stamped.length === 0) {
|
|
64
|
+
throw new Error(`No packages in ${dir}`);
|
|
65
|
+
}
|
|
66
|
+
if (elsewhere.length > 0) {
|
|
67
|
+
throw new Error(`${from} also appears in: ${elsewhere.join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const { file, output } of stamped) {
|
|
71
|
+
writeFileSync(file, output);
|
|
72
|
+
}
|
|
73
|
+
return { packages: stamped.map((item) => item.name).sort() };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
process.argv[1] != null &&
|
|
78
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
79
|
+
) {
|
|
80
|
+
const [dir, from, to] = process.argv.slice(2);
|
|
81
|
+
if (!dir || !from || !to) {
|
|
82
|
+
console.error("Usage: restamp-build.mjs <build dir> <tested version> <stable version>");
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const { packages } = restampBuild({ dir, from, to });
|
|
86
|
+
console.log(`${packages.length} packages now at ${to}`);
|
|
87
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const fs = await vi.importActual("node:fs");
|
|
5
|
+
const PUBLISH_YML = fileURLToPath(
|
|
6
|
+
new URL("../../../../.github/workflows/publish.yml", import.meta.url),
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
describe("publish.yml", () => {
|
|
10
|
+
const yml = fs.readFileSync(PUBLISH_YML, "utf8");
|
|
11
|
+
|
|
12
|
+
it("defaults a stable release to a dry run", () => {
|
|
13
|
+
const dryRun = yml.slice(yml.indexOf("dry_run:"), yml.indexOf("\nconcurrency:"));
|
|
14
|
+
|
|
15
|
+
expect(dryRun).toMatch(/default: true/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("stops a stable release before publishing on a dry run, or if its checks fail", () => {
|
|
19
|
+
const verify = yml.slice(yml.indexOf("\n verify:"), yml.indexOf("\n npm:"));
|
|
20
|
+
|
|
21
|
+
expect(verify).toContain("inputs.dry_run != true");
|
|
22
|
+
expect(verify).toContain("needs.release-build.result == 'success'");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("ships a stable release from the tested build, without rebuilding it", () => {
|
|
26
|
+
const build = yml.slice(yml.indexOf("\n release-build:"), yml.indexOf("\n verify:"));
|
|
27
|
+
|
|
28
|
+
expect(build).toContain("forge-release-beta-");
|
|
29
|
+
expect(build).toContain("restamp-build.mjs");
|
|
30
|
+
expect(build).not.toContain("forge --log-level info build");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("only tags a version once npm and the images have shipped", () => {
|
|
34
|
+
const tag = yml.slice(yml.indexOf("\n tag:"));
|
|
35
|
+
const needs = /needs: \[(.+)\]/.exec(tag)?.[1].split(", ");
|
|
36
|
+
|
|
37
|
+
expect(needs).toContain("npm");
|
|
38
|
+
expect(needs).toContain("check-images");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("checks the images with no login, as a stranger would", () => {
|
|
42
|
+
const job = yml.slice(yml.indexOf("\n check-images:"), yml.indexOf("\n tag:"));
|
|
43
|
+
|
|
44
|
+
expect(job).toContain("permissions: {}");
|
|
45
|
+
expect(job).toContain("docker manifest inspect");
|
|
46
|
+
expect(job).not.toContain("docker login");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("reads npm without a token: the packages are public and publishing is OIDC", () => {
|
|
50
|
+
const workflows = fileURLToPath(new URL("../../../../.github/", import.meta.url));
|
|
51
|
+
const files = fs
|
|
52
|
+
.readdirSync(workflows, { recursive: true })
|
|
53
|
+
.filter((file) => /\.ya?ml$/.test(file));
|
|
54
|
+
expect(files.length).toBeGreaterThan(0);
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
expect(fs.readFileSync(`${workflows}${file}`, "utf8"), file).not.toMatch(
|
|
57
|
+
/NODE_AUTH_TOKEN|_authToken/,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("grants the tag job actions:read so publish-prepare can download CI artifacts", () => {
|
|
63
|
+
const tag = yml.slice(yml.indexOf("\n tag:"));
|
|
64
|
+
const perms = tag.slice(tag.indexOf("permissions:"), tag.indexOf("steps:"));
|
|
65
|
+
expect(perms).toMatch(/contents:\s*write/);
|
|
66
|
+
expect(perms).toMatch(/actions:\s*read/);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
|
|
4
|
-
function getArg(name, fallback) {
|
|
5
|
-
const equals = process.argv.find((arg) => arg.startsWith(`--${name}=`));
|
|
6
|
-
if (equals) return equals.slice(name.length + 3);
|
|
7
|
-
const index = process.argv.indexOf(`--${name}`);
|
|
8
|
-
if (index >= 0) return process.argv[index + 1];
|
|
9
|
-
return fallback;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function hasFlag(name) {
|
|
13
|
-
return process.argv.includes(`--${name}`);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function run(command) {
|
|
17
|
-
return execSync(command, { encoding: "utf8" }).trim();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function parseRepoFromRemoteUrl(url) {
|
|
21
|
-
const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)(?:\.git)?$/);
|
|
22
|
-
if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
23
|
-
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)(?:\.git)?$/);
|
|
24
|
-
if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
25
|
-
throw new Error(`Unable to parse GitHub owner/repo from remote URL: ${url}`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function request(apiBaseUrl, token, method, path, body) {
|
|
29
|
-
const response = await fetch(`${apiBaseUrl}${path}`, {
|
|
30
|
-
method,
|
|
31
|
-
headers: {
|
|
32
|
-
Authorization: `Bearer ${token}`,
|
|
33
|
-
Accept: "application/vnd.github+json",
|
|
34
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
35
|
-
"Content-Type": "application/json",
|
|
36
|
-
},
|
|
37
|
-
body: body == null ? undefined : JSON.stringify(body),
|
|
38
|
-
});
|
|
39
|
-
if (!response.ok) {
|
|
40
|
-
const message = await response.text();
|
|
41
|
-
throw new Error(`GitHub API ${method} ${path} failed (${response.status}): ${message}`);
|
|
42
|
-
}
|
|
43
|
-
return response.status === 204 ? undefined : response.json();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (hasFlag("help")) {
|
|
47
|
-
console.log(`Usage: node create-or-update-pr.mjs [options]
|
|
48
|
-
|
|
49
|
-
Required:
|
|
50
|
-
--title <title> PR title
|
|
51
|
-
--body <markdown> PR body markdown
|
|
52
|
-
or
|
|
53
|
-
--body-file <path> Read PR body markdown from file
|
|
54
|
-
|
|
55
|
-
Optional:
|
|
56
|
-
--base <branch> Base branch (default: main)
|
|
57
|
-
--head <branch> Head branch (default: current git branch)
|
|
58
|
-
--draft Create as draft
|
|
59
|
-
--labels <a,b,c> Apply labels
|
|
60
|
-
--reviewers <u1,u2> Request reviewers
|
|
61
|
-
--dry-run Print intended operation without calling API
|
|
62
|
-
--help Show this help
|
|
63
|
-
|
|
64
|
-
Auth: Set GITHUB_TOKEN or GH_TOKEN
|
|
65
|
-
`);
|
|
66
|
-
process.exit(0);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
70
|
-
if (token == null || token.length === 0) throw new Error("Missing GITHUB_TOKEN or GH_TOKEN");
|
|
71
|
-
|
|
72
|
-
const apiBaseUrl = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
73
|
-
const base = getArg("base", "main");
|
|
74
|
-
const head = getArg("head", run("git rev-parse --abbrev-ref HEAD"));
|
|
75
|
-
const title = getArg("title");
|
|
76
|
-
const bodyFile = getArg("body-file");
|
|
77
|
-
const body = getArg("body", bodyFile ? readFileSync(bodyFile, "utf8") : undefined);
|
|
78
|
-
const draft = hasFlag("draft");
|
|
79
|
-
const dryRun = hasFlag("dry-run");
|
|
80
|
-
const labels = (getArg("labels") ?? "")
|
|
81
|
-
.split(",")
|
|
82
|
-
.map((v) => v.trim())
|
|
83
|
-
.filter(Boolean);
|
|
84
|
-
const reviewers = (getArg("reviewers") ?? "")
|
|
85
|
-
.split(",")
|
|
86
|
-
.map((v) => v.trim())
|
|
87
|
-
.filter(Boolean);
|
|
88
|
-
|
|
89
|
-
if (!title) throw new Error("Missing required argument: --title");
|
|
90
|
-
if (!body) throw new Error("Missing required argument: --body or --body-file");
|
|
91
|
-
|
|
92
|
-
const remoteUrl = run("git config --get remote.origin.url");
|
|
93
|
-
const parsedRepo = parseRepoFromRemoteUrl(remoteUrl);
|
|
94
|
-
const owner = getArg("owner", parsedRepo.owner);
|
|
95
|
-
const repo = getArg("repo", parsedRepo.repo);
|
|
96
|
-
|
|
97
|
-
const findPath = `/repos/${owner}/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}`;
|
|
98
|
-
|
|
99
|
-
if (dryRun) {
|
|
100
|
-
console.log(
|
|
101
|
-
JSON.stringify({ owner, repo, base, head, title, draft, labels, reviewers }, null, 2),
|
|
102
|
-
);
|
|
103
|
-
process.exit(0);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const existing = await request(apiBaseUrl, token, "GET", findPath);
|
|
107
|
-
const pr = existing[0]
|
|
108
|
-
? await request(
|
|
109
|
-
apiBaseUrl,
|
|
110
|
-
token,
|
|
111
|
-
"PATCH",
|
|
112
|
-
`/repos/${owner}/${repo}/pulls/${existing[0].number}`,
|
|
113
|
-
{ title, body, base },
|
|
114
|
-
)
|
|
115
|
-
: await request(apiBaseUrl, token, "POST", `/repos/${owner}/${repo}/pulls`, {
|
|
116
|
-
title,
|
|
117
|
-
body,
|
|
118
|
-
base,
|
|
119
|
-
head,
|
|
120
|
-
draft,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
if (labels.length > 0) {
|
|
124
|
-
await request(
|
|
125
|
-
apiBaseUrl,
|
|
126
|
-
token,
|
|
127
|
-
"POST",
|
|
128
|
-
`/repos/${owner}/${repo}/issues/${pr.number}/labels`,
|
|
129
|
-
{ labels },
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (reviewers.length > 0) {
|
|
134
|
-
await request(
|
|
135
|
-
apiBaseUrl,
|
|
136
|
-
token,
|
|
137
|
-
"POST",
|
|
138
|
-
`/repos/${owner}/${repo}/pulls/${pr.number}/requested_reviewers`,
|
|
139
|
-
{ reviewers },
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
console.log(`${existing[0] ? "Updated" : "Created"} PR #${pr.number}: ${pr.html_url}`);
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
6
|
-
import { pathToFileURL } from "node:url";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Run `npx @decohere/create@version --help` from an empty temp directory.
|
|
10
|
-
* Must not use the repo cwd — after `@decohere/cli` is pinned, pnpm installs
|
|
11
|
-
* `@decohere/create` into the workspace tree and npm's npx misses the bin.
|
|
12
|
-
* No token: the packages are public, so this installs the launcher the way a user does.
|
|
13
|
-
* Anonymous reads go through npm's cache, which can lag minutes behind a publish, so it
|
|
14
|
-
* first waits until npm serves the version (`attempts` tries, `intervalMs` apart).
|
|
15
|
-
*
|
|
16
|
-
* @param {{
|
|
17
|
-
* createVersion: string,
|
|
18
|
-
* attempts?: number,
|
|
19
|
-
* intervalMs?: number,
|
|
20
|
-
* sleep?: (ms: number) => void,
|
|
21
|
-
* mkdtempSync?: typeof mkdtempSync,
|
|
22
|
-
* writeFileSync?: typeof writeFileSync,
|
|
23
|
-
* spawnSync?: typeof spawnSync,
|
|
24
|
-
* tmpdir?: () => string,
|
|
25
|
-
* stdio?: import("node:child_process").StdioOptions,
|
|
26
|
-
* }} options
|
|
27
|
-
*/
|
|
28
|
-
export function runCreateLauncherSmoke(options) {
|
|
29
|
-
const version = options.createVersion?.trim() ?? "";
|
|
30
|
-
if (!version) {
|
|
31
|
-
throw new Error("CREATE_VERSION is required");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const mkdtemp = options.mkdtempSync ?? mkdtempSync;
|
|
35
|
-
const writeFile = options.writeFileSync ?? writeFileSync;
|
|
36
|
-
const spawn = options.spawnSync ?? spawnSync;
|
|
37
|
-
const tmp = options.tmpdir ?? tmpdir;
|
|
38
|
-
const sleep = options.sleep ?? sleepSync;
|
|
39
|
-
const attempts = options.attempts ?? 20;
|
|
40
|
-
const intervalMs = options.intervalMs ?? 15_000;
|
|
41
|
-
|
|
42
|
-
const workDir = mkdtemp(join(tmp(), "decohere-create-smoke-"));
|
|
43
|
-
const npmrc = join(workDir, ".npmrc");
|
|
44
|
-
writeFile(npmrc, "@decohere:registry=https://registry.npmjs.org/\n");
|
|
45
|
-
|
|
46
|
-
const env = { ...process.env, NPM_CONFIG_USERCONFIG: npmrc };
|
|
47
|
-
const spec = `@decohere/create@${version}`;
|
|
48
|
-
|
|
49
|
-
for (let attempt = 1; ; attempt++) {
|
|
50
|
-
const view = spawn("npm", ["view", spec, "version"], { cwd: workDir, env, stdio: "pipe" });
|
|
51
|
-
if (view.status === 0) break;
|
|
52
|
-
if (attempt >= attempts) {
|
|
53
|
-
console.error(`${spec} is still not served by npm after ${attempts} tries`);
|
|
54
|
-
return { workDir, status: 1 };
|
|
55
|
-
}
|
|
56
|
-
console.log(`${spec} not served by npm yet (try ${attempt} of ${attempts}); waiting`);
|
|
57
|
-
sleep(intervalMs);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const result = spawn("npx", ["--yes", spec, "--help"], {
|
|
61
|
-
cwd: workDir,
|
|
62
|
-
env,
|
|
63
|
-
stdio: options.stdio ?? "inherit",
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
workDir,
|
|
68
|
-
status: result.status ?? 1,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** @param {number} ms */
|
|
73
|
-
function sleepSync(ms) {
|
|
74
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const isCli =
|
|
78
|
-
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
79
|
-
|
|
80
|
-
if (isCli) {
|
|
81
|
-
const { status } = runCreateLauncherSmoke({
|
|
82
|
-
createVersion: process.env.CREATE_VERSION ?? "",
|
|
83
|
-
});
|
|
84
|
-
process.exit(status);
|
|
85
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Capture final version metadata from package.json
|
|
4
|
-
* Outputs version and is_prerelease flags for GitHub Actions
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
const packageJson = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"));
|
|
12
|
-
const version = packageJson.version;
|
|
13
|
-
|
|
14
|
-
if (!version) {
|
|
15
|
-
console.error("No version found in package.json");
|
|
16
|
-
process.exit(1);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
console.log(`version=${version}`);
|
|
20
|
-
|
|
21
|
-
// Check if prerelease (has hyphen in version)
|
|
22
|
-
const isPrerelease = version.includes("-");
|
|
23
|
-
console.log(`is_prerelease=${isPrerelease}`);
|
|
24
|
-
} catch (e) {
|
|
25
|
-
console.error(`Failed to capture version: ${e.message}`);
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Generate npm package links for all @decohere packages
|
|
4
|
-
* Outputs markdown formatted links for GitHub release notes
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { listProjectDirs } from "../lib/workspace-packages.mjs";
|
|
10
|
-
|
|
11
|
-
const VERSION = process.argv[2] || process.env.VERSION;
|
|
12
|
-
|
|
13
|
-
if (!VERSION) {
|
|
14
|
-
console.error("VERSION not provided as argument or env var");
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
let links = "## Published Packages\n\n";
|
|
19
|
-
|
|
20
|
-
try {
|
|
21
|
-
for (const projectDir of listProjectDirs(process.cwd())) {
|
|
22
|
-
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
|
|
23
|
-
if (pkg.name?.startsWith("@decohere/")) {
|
|
24
|
-
links += `- [\`${pkg.name}@${VERSION}\`](https://www.npmjs.com/package/${pkg.name}/v/${VERSION})\n`;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
console.log(links);
|
|
29
|
-
} catch (e) {
|
|
30
|
-
console.error(`Failed to generate npm links: ${e.message}`);
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Publish prerelease version commit via PR
|
|
4
|
-
* Validates working tree, pushes prerelease commit to a release branch,
|
|
5
|
-
* and creates/updates a PR targeting the protected base branch.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { execSync } from "node:child_process";
|
|
9
|
-
|
|
10
|
-
function run(command) {
|
|
11
|
-
return execSync(command, { encoding: "utf8" }).trim();
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function runInherit(command, env) {
|
|
15
|
-
execSync(command, { stdio: "inherit", env });
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
// Check if working tree is dirty
|
|
20
|
-
const status = run("git status --porcelain");
|
|
21
|
-
if (status.trim().length > 0) {
|
|
22
|
-
console.error(
|
|
23
|
-
"::error::Working tree is dirty after version/dependency sync; refusing to publish with uncommitted changes.",
|
|
24
|
-
);
|
|
25
|
-
console.log(status);
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Compare HEAD with origin and create a PR branch targeting an explicit base branch.
|
|
30
|
-
const head = run("git rev-parse HEAD");
|
|
31
|
-
const sourceBranch = process.env.GITHUB_REF_NAME || run("git rev-parse --abbrev-ref HEAD");
|
|
32
|
-
const channel = process.env.RELEASE_CHANNEL ?? "beta";
|
|
33
|
-
const baseBranch = process.env.RELEASE_BASE_BRANCH || sourceBranch;
|
|
34
|
-
const releasePrBranch = `release/${channel}-${sourceBranch}-to-${baseBranch}`;
|
|
35
|
-
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const origin = run(`git rev-parse origin/${sourceBranch}`);
|
|
39
|
-
|
|
40
|
-
if (head !== origin) {
|
|
41
|
-
console.log(
|
|
42
|
-
`Publishing release commit via PR branch origin/${releasePrBranch} (base: origin/${baseBranch})`,
|
|
43
|
-
);
|
|
44
|
-
runInherit(`git push origin HEAD:${releasePrBranch} --force`, process.env);
|
|
45
|
-
|
|
46
|
-
if (token == null || token.length === 0) {
|
|
47
|
-
throw new Error("Missing GITHUB_TOKEN/GH_TOKEN for PR upsert");
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const version = process.env.RELEASE_VERSION ?? "(unknown)";
|
|
51
|
-
const ownerRepo = run("git config --get remote.origin.url")
|
|
52
|
-
.replace(/^.*github\.com[:/]/, "")
|
|
53
|
-
.replace(/\.git$/, "");
|
|
54
|
-
|
|
55
|
-
runInherit(
|
|
56
|
-
[
|
|
57
|
-
"node projects/ci-tools/src/github/create-or-update-pr.mjs",
|
|
58
|
-
`--title "chore(release): ${channel} ${version}"`,
|
|
59
|
-
`--base "${baseBranch}"`,
|
|
60
|
-
`--head "${releasePrBranch}"`,
|
|
61
|
-
`--body "Automated ${channel} version bump and dependency sync.\\n\\n- Channel: ${channel}\\n- Version: ${version}\\n- Source branch: ${sourceBranch}\\n- Base branch: ${baseBranch}\\n- Source commit: ${head}\\n\\nGenerated by pull-request workflow."`,
|
|
62
|
-
`--owner "${ownerRepo.split("/")[0]}"`,
|
|
63
|
-
`--repo "${ownerRepo.split("/")[1]}"`,
|
|
64
|
-
].join(" "),
|
|
65
|
-
{ ...process.env, GITHUB_TOKEN: token },
|
|
66
|
-
);
|
|
67
|
-
} else {
|
|
68
|
-
console.log("No prerelease version commit to push.");
|
|
69
|
-
}
|
|
70
|
-
} catch (err) {
|
|
71
|
-
const message = err?.message ?? "";
|
|
72
|
-
if (
|
|
73
|
-
message.includes(`origin/${sourceBranch}`) &&
|
|
74
|
-
message.includes("Needed a single revision")
|
|
75
|
-
) {
|
|
76
|
-
console.log(`Remote branch origin/${sourceBranch} not found, skipping push.`);
|
|
77
|
-
} else {
|
|
78
|
-
console.error("::error::Failed to publish release commit via PR branch.");
|
|
79
|
-
throw err;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
} catch (e) {
|
|
83
|
-
console.error(`Push failed: ${e.message}`);
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Sync cross-library dependency versions
|
|
4
|
-
* Conditionally runs sync with or without commit based on DRY_RUN flag
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { execSync } from "node:child_process";
|
|
8
|
-
|
|
9
|
-
const DRY_RUN = process.env.DRY_RUN === "true";
|
|
10
|
-
const DECOHERE_CONFIG = process.env.DECOHERE_CONFIG || ".decohere.js";
|
|
11
|
-
|
|
12
|
-
const baseCmd = `pnpm forge --log-level info --config-file "${DECOHERE_CONFIG}" dependencies --configuration release sync`;
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
if (DRY_RUN) {
|
|
16
|
-
console.log(`[DRY RUN] ${baseCmd}`);
|
|
17
|
-
execSync(baseCmd, { stdio: "inherit" });
|
|
18
|
-
} else {
|
|
19
|
-
const commitCmd = `${baseCmd} --commit --message "chore: version packages and sync cross-library dependencies [skip ci]"`;
|
|
20
|
-
console.log(`Syncing with commit...`);
|
|
21
|
-
execSync(commitCmd, { stdio: "inherit" });
|
|
22
|
-
}
|
|
23
|
-
} catch (e) {
|
|
24
|
-
console.error(`Dependency sync failed: ${e.message}`);
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
-
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { runCreateLauncherSmoke } from "../../src/github/smoke-create-launcher.mjs";
|
|
4
|
-
|
|
5
|
-
const fs = await vi.importActual("node:fs");
|
|
6
|
-
const PUBLISH_YML = fileURLToPath(
|
|
7
|
-
new URL("../../../../.github/workflows/publish.yml", import.meta.url),
|
|
8
|
-
);
|
|
9
|
-
|
|
10
|
-
describe("runCreateLauncherSmoke", () => {
|
|
11
|
-
it("execs npx from a temp directory, not the repo root", () => {
|
|
12
|
-
/** @type {{ cmd: string, args: string[], opts: { cwd?: string } }[]} */
|
|
13
|
-
const calls = [];
|
|
14
|
-
const written = [];
|
|
15
|
-
|
|
16
|
-
const result = runCreateLauncherSmoke({
|
|
17
|
-
createVersion: "0.0.1-dev.80",
|
|
18
|
-
tmpdir: () => "/tmp",
|
|
19
|
-
mkdtempSync: (prefix) => `${prefix}abc`,
|
|
20
|
-
writeFileSync: (path, body) => {
|
|
21
|
-
written.push({ path, body });
|
|
22
|
-
},
|
|
23
|
-
spawnSync: (cmd, args, opts) => {
|
|
24
|
-
calls.push({ cmd, args, opts });
|
|
25
|
-
return { status: 0 };
|
|
26
|
-
},
|
|
27
|
-
stdio: "pipe",
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
expect(result.workDir).toBe("/tmp/decohere-create-smoke-abc");
|
|
31
|
-
expect(result.workDir).not.toBe(process.cwd());
|
|
32
|
-
const npx = calls.at(-1);
|
|
33
|
-
expect(npx.cmd).toBe("npx");
|
|
34
|
-
expect(npx.args).toEqual(["--yes", "@decohere/create@0.0.1-dev.80", "--help"]);
|
|
35
|
-
expect(npx.opts.cwd).toBe("/tmp/decohere-create-smoke-abc");
|
|
36
|
-
expect(npx.opts.cwd).not.toBe(process.cwd());
|
|
37
|
-
expect(written).toEqual([
|
|
38
|
-
{
|
|
39
|
-
path: "/tmp/decohere-create-smoke-abc/.npmrc",
|
|
40
|
-
body: "@decohere:registry=https://registry.npmjs.org/\n",
|
|
41
|
-
},
|
|
42
|
-
]);
|
|
43
|
-
// The packages are public: the smoke installs them the way a user does, with no token.
|
|
44
|
-
expect(npx.opts.env.NODE_AUTH_TOKEN).toBeUndefined();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// npm's public cache can lag minutes behind a publish: the first token-free Publish ran npx
|
|
48
|
-
// 90 seconds after publishing and got ETARGET for a version the registry already had.
|
|
49
|
-
it("waits until npm serves the new version before running npx", () => {
|
|
50
|
-
/** @type {{ cmd: string, args: string[] }[]} */
|
|
51
|
-
const calls = [];
|
|
52
|
-
const sleeps = [];
|
|
53
|
-
let views = 0;
|
|
54
|
-
|
|
55
|
-
const result = runCreateLauncherSmoke({
|
|
56
|
-
createVersion: "0.0.1-dev.127",
|
|
57
|
-
tmpdir: () => "/tmp",
|
|
58
|
-
mkdtempSync: (prefix) => `${prefix}abc`,
|
|
59
|
-
writeFileSync: () => {},
|
|
60
|
-
spawnSync: (cmd, args) => {
|
|
61
|
-
calls.push({ cmd, args });
|
|
62
|
-
if (cmd === "npm") return { status: ++views < 3 ? 1 : 0 };
|
|
63
|
-
return { status: 0 };
|
|
64
|
-
},
|
|
65
|
-
sleep: (ms) => sleeps.push(ms),
|
|
66
|
-
stdio: "pipe",
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
expect(result.status).toBe(0);
|
|
70
|
-
expect(calls.map((call) => call.cmd)).toEqual(["npm", "npm", "npm", "npx"]);
|
|
71
|
-
expect(calls[0].args).toEqual(["view", "@decohere/create@0.0.1-dev.127", "version"]);
|
|
72
|
-
expect(sleeps).toHaveLength(2);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("fails without running npx when npm never serves the version", () => {
|
|
76
|
-
/** @type {string[]} */
|
|
77
|
-
const commands = [];
|
|
78
|
-
|
|
79
|
-
const result = runCreateLauncherSmoke({
|
|
80
|
-
createVersion: "0.0.1-dev.127",
|
|
81
|
-
tmpdir: () => "/tmp",
|
|
82
|
-
mkdtempSync: (prefix) => `${prefix}abc`,
|
|
83
|
-
writeFileSync: () => {},
|
|
84
|
-
spawnSync: (cmd) => {
|
|
85
|
-
commands.push(cmd);
|
|
86
|
-
return { status: 1 };
|
|
87
|
-
},
|
|
88
|
-
sleep: () => {},
|
|
89
|
-
attempts: 3,
|
|
90
|
-
stdio: "pipe",
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
expect(result.status).toBe(1);
|
|
94
|
-
expect(commands).toEqual(["npm", "npm", "npm"]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("fails closed without a version", () => {
|
|
98
|
-
expect(() => runCreateLauncherSmoke({ createVersion: "" })).toThrow(
|
|
99
|
-
"CREATE_VERSION is required",
|
|
100
|
-
);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("publish.yml create smoke + tag", () => {
|
|
105
|
-
const yml = fs.readFileSync(PUBLISH_YML, "utf8");
|
|
106
|
-
|
|
107
|
-
it("wires create smoke through the isolated launcher", () => {
|
|
108
|
-
expect(yml).toContain("node projects/ci-tools/src/github/smoke-create-launcher.mjs");
|
|
109
|
-
expect(yml).toContain("CREATE_VERSION:");
|
|
110
|
-
expect(yml).not.toContain("npx --yes");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("reads npm without a token: the packages are public and publishing is OIDC", () => {
|
|
114
|
-
const workflows = fileURLToPath(new URL("../../../../.github/", import.meta.url));
|
|
115
|
-
const files = fs
|
|
116
|
-
.readdirSync(workflows, { recursive: true })
|
|
117
|
-
.filter((file) => /\.ya?ml$/.test(file));
|
|
118
|
-
expect(files.length).toBeGreaterThan(0);
|
|
119
|
-
for (const file of files) {
|
|
120
|
-
expect(fs.readFileSync(`${workflows}${file}`, "utf8"), file).not.toMatch(
|
|
121
|
-
/NODE_AUTH_TOKEN|_authToken/,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("grants the tag job actions:read so publish-prepare can download CI artifacts", () => {
|
|
127
|
-
const tag = yml.slice(yml.indexOf("\n tag:"));
|
|
128
|
-
const perms = tag.slice(tag.indexOf("permissions:"), tag.indexOf("steps:"));
|
|
129
|
-
expect(perms).toMatch(/contents:\s*write/);
|
|
130
|
-
expect(perms).toMatch(/actions:\s*read/);
|
|
131
|
-
});
|
|
132
|
-
});
|