@decohere/ci-tools 0.0.1-dev.20261105.110 → 0.0.1-dev.20261105.119

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/ci-tools",
3
- "version": "0.0.1-dev.20261105.110",
3
+ "version": "0.0.1-dev.20261105.119",
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,7 +41,6 @@
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
46
  "./github/npm-bootstrap": "./src/github/npm-bootstrap.mjs"
@@ -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
+ }
@@ -9,6 +9,27 @@ const PUBLISH_YML = fileURLToPath(
9
9
  describe("publish.yml", () => {
10
10
  const yml = fs.readFileSync(PUBLISH_YML, "utf8");
11
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
+
12
33
  it("only tags a version once npm and the images have shipped", () => {
13
34
  const tag = yml.slice(yml.indexOf("\n tag:"));
14
35
  const needs = /needs: \[(.+)\]/.exec(tag)?.[1].split(", ");
@@ -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,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
- }