@bensandee/tooling 0.34.0 → 0.36.0

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.
Files changed (2) hide show
  1. package/dist/bin.mjs +262 -453
  2. package/package.json +1 -1
package/dist/bin.mjs CHANGED
@@ -10,7 +10,7 @@ import JSON5 from "json5";
10
10
  import { parse } from "jsonc-parser";
11
11
  import { z } from "zod";
12
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
- import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify } from "yaml";
13
+ import { isMap, isScalar, parse as parse$1, parseDocument, stringify, visit } from "yaml";
14
14
  import picomatch from "picomatch";
15
15
  import { tmpdir } from "node:os";
16
16
  //#region src/types.ts
@@ -956,17 +956,82 @@ function runDockerPublish(executor, config) {
956
956
  //#endregion
957
957
  //#region src/utils/yaml-merge.ts
958
958
  const IGNORE_PATTERN = "@bensandee/tooling:ignore";
959
+ const CUSTOM_START = "# @tooling:custom";
960
+ const CUSTOM_END = "# @tooling:endcustom";
959
961
  const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
962
+ /** Extract custom blocks from a workflow file, recording their anchor lines. */
963
+ function extractCustomBlocks(content) {
964
+ const lines = content.split("\n");
965
+ const blocks = [];
966
+ let i = 0;
967
+ while (i < lines.length) if (lines[i].trim() === CUSTOM_START) {
968
+ let anchor = "";
969
+ for (let j = i - 1; j >= 0; j--) if (lines[j].trim() !== "") {
970
+ anchor = lines[j].trim();
971
+ break;
972
+ }
973
+ const blockLines = [lines[i]];
974
+ i++;
975
+ while (i < lines.length && lines[i].trim() !== CUSTOM_END) {
976
+ blockLines.push(lines[i]);
977
+ i++;
978
+ }
979
+ if (i < lines.length) {
980
+ blockLines.push(lines[i]);
981
+ i++;
982
+ }
983
+ blocks.push({
984
+ anchor,
985
+ lines: blockLines
986
+ });
987
+ } else i++;
988
+ return blocks;
989
+ }
990
+ /** Remove custom blocks (markers + content) from a workflow string. */
991
+ function stripCustomBlocks(content) {
992
+ const lines = content.split("\n");
993
+ const result = [];
994
+ let inCustom = false;
995
+ for (const line of lines) {
996
+ if (line.trim() === CUSTOM_START) {
997
+ inCustom = true;
998
+ continue;
999
+ }
1000
+ if (inCustom && line.trim() === CUSTOM_END) {
1001
+ inCustom = false;
1002
+ continue;
1003
+ }
1004
+ if (!inCustom) result.push(line);
1005
+ }
1006
+ return result.join("\n");
1007
+ }
1008
+ /** Insert previously extracted custom blocks into freshly generated content, anchored by preceding line. */
1009
+ function insertCustomBlocks(generated, blocks) {
1010
+ if (blocks.length === 0) return generated;
1011
+ const lines = generated.split("\n");
1012
+ const insertions = [];
1013
+ for (const block of blocks) {
1014
+ let insertAfter = -1;
1015
+ for (let i = 0; i < lines.length; i++) if (lines[i].trim() === block.anchor) {
1016
+ insertAfter = i;
1017
+ break;
1018
+ }
1019
+ if (insertAfter < 0) insertAfter = lines.length - 1;
1020
+ insertions.push({
1021
+ afterIndex: insertAfter,
1022
+ block
1023
+ });
1024
+ }
1025
+ insertions.sort((a, b) => b.afterIndex - a.afterIndex);
1026
+ for (const { afterIndex, block } of insertions) lines.splice(afterIndex + 1, 0, ...block.lines);
1027
+ return lines.join("\n");
1028
+ }
960
1029
  /** Prepend the Forgejo schema comment if it's not already present. No-op for GitHub. */
961
1030
  function ensureSchemaComment(content, ci) {
962
1031
  if (ci !== "forgejo") return content;
963
1032
  if (content.includes("yaml-language-server")) return content;
964
1033
  return FORGEJO_SCHEMA_COMMENT + content;
965
1034
  }
966
- /** Migrate content from old tooling binary name to new. */
967
- function migrateToolingBinary(content) {
968
- return content.replaceAll("pnpm exec tooling ", "pnpm exec bst ");
969
- }
970
1035
  /** Check if a YAML file has an opt-out comment in the first 10 lines. */
971
1036
  function isToolingIgnored(content) {
972
1037
  return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
@@ -1011,14 +1076,42 @@ function mergeLefthookCommands(existing, requiredCommands) {
1011
1076
  };
1012
1077
  }
1013
1078
  }
1014
- /** Extract only the mergeable steps (those with a match) as RequiredSteps. */
1015
- function toRequiredSteps(steps) {
1016
- return steps.filter((s) => s.match !== void 0).map((s) => ({
1017
- match: s.match,
1018
- step: s.step
1019
- }));
1079
+ /**
1080
+ * Normalize a workflow YAML string for comparison purposes.
1081
+ * Parses the YAML DOM to strip action versions from `uses:` values and remove all comments,
1082
+ * then re-serializes so that pinned hashes, older tags, or formatting differences
1083
+ * don't cause unnecessary overwrites.
1084
+ */
1085
+ function normalizeWorkflow(content) {
1086
+ const doc = parseDocument(stripCustomBlocks(content).replaceAll(/node\s+packages\/tooling-cli\/dist\/bin\.mjs/g, "pnpm exec bst"));
1087
+ doc.comment = null;
1088
+ doc.commentBefore = null;
1089
+ visit(doc, {
1090
+ Node(_key, node) {
1091
+ if ("comment" in node) node.comment = null;
1092
+ if ("commentBefore" in node) node.commentBefore = null;
1093
+ },
1094
+ Pair(_key, node) {
1095
+ if (isScalar(node.key) && node.key.value === "uses" && isScalar(node.value) && typeof node.value.value === "string") node.value.value = node.value.value.replace(/@.*$/, "");
1096
+ }
1097
+ });
1098
+ return doc.toString({
1099
+ lineWidth: 0,
1100
+ nullStr: ""
1101
+ });
1102
+ }
1103
+ const DEV_BINARY_PATTERN = /node\s+packages\/tooling-cli\/dist\/bin\.mjs/;
1104
+ /**
1105
+ * If the existing file uses the dev binary path (`node packages/tooling-cli/dist/bin.mjs`),
1106
+ * substitute it back into the generated content so we don't overwrite it with `pnpm exec bst`.
1107
+ */
1108
+ function preserveDevBinaryPath(generated, existing) {
1109
+ if (!existing) return generated;
1110
+ const match = DEV_BINARY_PATTERN.exec(existing);
1111
+ if (!match) return generated;
1112
+ return generated.replaceAll("pnpm exec bst", match[0]);
1020
1113
  }
1021
- /** Build a complete workflow YAML string from structured options. Single source of truth for both new files and merge steps. */
1114
+ /** Build a complete workflow YAML string from structured options. Single source of truth for workflow files. */
1022
1115
  function buildWorkflowYaml(options) {
1023
1116
  const doc = { name: options.name };
1024
1117
  if (options.enableEmailNotifications) doc["enable-email-notifications"] = true;
@@ -1032,132 +1125,7 @@ function buildWorkflowYaml(options) {
1032
1125
  return ensureSchemaComment(stringify(doc, {
1033
1126
  lineWidth: 0,
1034
1127
  nullStr: ""
1035
- }), options.ci);
1036
- }
1037
- /**
1038
- * Ensure required steps exist in a workflow job's steps array.
1039
- * Only adds missing steps at the end — never modifies existing ones.
1040
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1041
- */
1042
- function mergeWorkflowSteps(existing, jobName, requiredSteps) {
1043
- if (isToolingIgnored(existing)) return {
1044
- content: existing,
1045
- changed: false
1046
- };
1047
- try {
1048
- const doc = parseDocument(existing);
1049
- const steps = doc.getIn([
1050
- "jobs",
1051
- jobName,
1052
- "steps"
1053
- ]);
1054
- if (!isSeq(steps)) return {
1055
- content: existing,
1056
- changed: false
1057
- };
1058
- let changed = false;
1059
- for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
1060
- if (!isMap(item)) return false;
1061
- if (match.run) {
1062
- const run = item.get("run");
1063
- return typeof run === "string" && run.includes(match.run);
1064
- }
1065
- if (match.uses) {
1066
- const uses = item.get("uses");
1067
- return typeof uses === "string" && uses.startsWith(match.uses);
1068
- }
1069
- return false;
1070
- })) {
1071
- steps.add(doc.createNode(step));
1072
- changed = true;
1073
- }
1074
- return {
1075
- content: changed ? doc.toString() : existing,
1076
- changed
1077
- };
1078
- } catch {
1079
- return {
1080
- content: existing,
1081
- changed: false
1082
- };
1083
- }
1084
- }
1085
- /**
1086
- * Add a job to an existing workflow YAML if it doesn't already exist.
1087
- * Returns unchanged content if the job already exists, the file has an opt-out comment,
1088
- * or the document can't be parsed.
1089
- */
1090
- /**
1091
- * Ensure a `concurrency` block exists at the workflow top level.
1092
- * Adds it if missing — never modifies an existing one.
1093
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1094
- */
1095
- /**
1096
- * Ensure `on.push` has `tags-ignore: ["**"]` so tag pushes don't trigger CI.
1097
- * Only adds the filter when `on.push` exists and `tags-ignore` is absent.
1098
- */
1099
- function ensureWorkflowTagsIgnore(existing) {
1100
- if (isToolingIgnored(existing)) return {
1101
- content: existing,
1102
- changed: false
1103
- };
1104
- try {
1105
- const doc = parseDocument(existing);
1106
- const on = doc.get("on");
1107
- if (!isMap(on)) return {
1108
- content: existing,
1109
- changed: false
1110
- };
1111
- const push = on.get("push");
1112
- if (!isMap(push)) return {
1113
- content: existing,
1114
- changed: false
1115
- };
1116
- if (push.has("tags-ignore")) return {
1117
- content: existing,
1118
- changed: false
1119
- };
1120
- push.set("tags-ignore", ["**"]);
1121
- return {
1122
- content: doc.toString(),
1123
- changed: true
1124
- };
1125
- } catch {
1126
- return {
1127
- content: existing,
1128
- changed: false
1129
- };
1130
- }
1131
- }
1132
- function ensureWorkflowConcurrency(existing, concurrency) {
1133
- if (isToolingIgnored(existing)) return {
1134
- content: existing,
1135
- changed: false
1136
- };
1137
- try {
1138
- const doc = parseDocument(existing);
1139
- if (doc.has("concurrency")) return {
1140
- content: existing,
1141
- changed: false
1142
- };
1143
- doc.set("concurrency", concurrency);
1144
- const contents = doc.contents;
1145
- if (isMap(contents)) {
1146
- const items = contents.items;
1147
- const nameIdx = items.findIndex((p) => isScalar(p.key) && p.key.value === "name");
1148
- const concPair = items.pop();
1149
- if (concPair) items.splice(nameIdx + 1, 0, concPair);
1150
- }
1151
- return {
1152
- content: doc.toString(),
1153
- changed: true
1154
- };
1155
- } catch {
1156
- return {
1157
- content: existing,
1158
- changed: false
1159
- };
1160
- }
1128
+ }).replace(/^(?=\S)/gm, (match, offset) => offset === 0 ? match : `\n${match}`), options.ci);
1161
1129
  }
1162
1130
  //#endregion
1163
1131
  //#region src/generators/ci-utils.ts
@@ -1177,38 +1145,24 @@ function computeNodeVersionYaml(ctx) {
1177
1145
  //#region src/generators/publish-ci.ts
1178
1146
  function publishSteps(nodeVersionYaml) {
1179
1147
  return [
1180
- {
1181
- match: { uses: "actions/checkout" },
1182
- step: { uses: "actions/checkout@v6" }
1183
- },
1184
- {
1185
- match: { uses: "pnpm/action-setup" },
1186
- step: { uses: "pnpm/action-setup@v5" }
1187
- },
1188
- {
1189
- match: { uses: "actions/setup-node" },
1190
- step: {
1191
- uses: "actions/setup-node@v6",
1192
- with: nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" }
1193
- }
1194
- },
1195
- {
1196
- match: { run: "pnpm install" },
1197
- step: { run: "pnpm install --frozen-lockfile" }
1198
- },
1199
- {
1200
- match: { run: "docker:publish" },
1201
- step: {
1202
- name: "Publish Docker images",
1203
- env: {
1204
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1205
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
1206
- DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1207
- DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD")
1208
- },
1209
- run: "pnpm exec bst docker:publish"
1210
- }
1211
- }
1148
+ { step: { uses: "actions/checkout@v6" } },
1149
+ { step: { uses: "pnpm/action-setup@v5" } },
1150
+ { step: {
1151
+ uses: "actions/setup-node@v6",
1152
+ with: nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" }
1153
+ } },
1154
+ { step: { run: "pnpm install --frozen-lockfile" } },
1155
+ { step: {
1156
+ name: "Publish Docker images",
1157
+ env: {
1158
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1159
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
1160
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1161
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
1162
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
1163
+ },
1164
+ run: "pnpm exec bst docker:publish"
1165
+ } }
1212
1166
  ];
1213
1167
  }
1214
1168
  const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
@@ -1257,59 +1211,26 @@ async function generateDeployCi(ctx) {
1257
1211
  jobName: "publish",
1258
1212
  steps
1259
1213
  });
1260
- if (ctx.exists(workflowPath)) {
1261
- const raw = ctx.read(workflowPath);
1262
- if (raw) {
1263
- const existing = migrateToolingBinary(raw);
1264
- if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) {
1265
- if (existing !== raw) {
1266
- ctx.write(workflowPath, ensureSchemaComment(existing, ctx.config.ci));
1267
- return {
1268
- filePath: workflowPath,
1269
- action: "updated",
1270
- description: "Migrated tooling binary name in publish workflow"
1271
- };
1272
- }
1273
- return {
1274
- filePath: workflowPath,
1275
- action: "skipped",
1276
- description: "Publish workflow already up to date"
1277
- };
1278
- }
1279
- const merged = mergeWorkflowSteps(existing, "publish", toRequiredSteps(steps));
1280
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
1281
- if (!merged.changed) {
1282
- if (withComment !== raw) {
1283
- ctx.write(workflowPath, withComment);
1284
- return {
1285
- filePath: workflowPath,
1286
- action: "updated",
1287
- description: existing !== raw ? "Migrated tooling binary name in publish workflow" : "Added schema comment to publish workflow"
1288
- };
1289
- }
1290
- return {
1291
- filePath: workflowPath,
1292
- action: "skipped",
1293
- description: "Existing publish workflow preserved"
1294
- };
1295
- }
1296
- ctx.write(workflowPath, withComment);
1297
- return {
1298
- filePath: workflowPath,
1299
- action: "updated",
1300
- description: "Added missing steps to publish workflow"
1301
- };
1302
- }
1303
- return {
1214
+ const alreadyExists = ctx.exists(workflowPath);
1215
+ const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
1216
+ if (existing) {
1217
+ if (isToolingIgnored(existing)) return {
1218
+ filePath: workflowPath,
1219
+ action: "skipped",
1220
+ description: "Publish workflow has ignore comment"
1221
+ };
1222
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
1304
1223
  filePath: workflowPath,
1305
1224
  action: "skipped",
1306
1225
  description: "Publish workflow already up to date"
1307
1226
  };
1308
1227
  }
1309
- ctx.write(workflowPath, content);
1228
+ const withDevPath = preserveDevBinaryPath(content, existing);
1229
+ const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
1230
+ ctx.write(workflowPath, final);
1310
1231
  return {
1311
1232
  filePath: workflowPath,
1312
- action: "created",
1233
+ action: alreadyExists ? "updated" : "created",
1313
1234
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions publish workflow`
1314
1235
  };
1315
1236
  }
@@ -1577,7 +1498,7 @@ function getAddedDevDepNames(config) {
1577
1498
  const deps = { ...ROOT_DEV_DEPS };
1578
1499
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
1579
1500
  deps["@bensandee/config"] = "0.9.1";
1580
- deps["@bensandee/tooling"] = "0.34.0";
1501
+ deps["@bensandee/tooling"] = "0.36.0";
1581
1502
  if (config.formatter === "oxfmt") deps["oxfmt"] = {
1582
1503
  "@changesets/cli": "2.30.0",
1583
1504
  "@release-it/bumper": "7.0.5",
@@ -1632,7 +1553,7 @@ async function generatePackageJson(ctx) {
1632
1553
  const devDeps = { ...ROOT_DEV_DEPS };
1633
1554
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1634
1555
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1635
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.34.0";
1556
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.36.0";
1636
1557
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1637
1558
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1638
1559
  "@changesets/cli": "2.30.0",
@@ -1680,10 +1601,6 @@ async function generatePackageJson(ctx) {
1680
1601
  changes.push("set type: \"module\"");
1681
1602
  }
1682
1603
  const existingScripts = pkg.scripts ?? {};
1683
- for (const [key, value] of Object.entries(existingScripts)) if (typeof value === "string" && value.includes("pnpm exec tooling ")) {
1684
- existingScripts[key] = migrateToolingBinary(value);
1685
- changes.push(`migrated script: ${key}`);
1686
- }
1687
1604
  for (const [key, value] of Object.entries(allScripts)) if (!(key in existingScripts)) {
1688
1605
  existingScripts[key] = value;
1689
1606
  changes.push(`added script: ${key}`);
@@ -2170,41 +2087,58 @@ async function generateGitignore(ctx) {
2170
2087
  }
2171
2088
  //#endregion
2172
2089
  //#region src/generators/ci.ts
2090
+ /** Build the release step for the check job (changesets strategy). */
2091
+ function changesetsReleaseStep(ci, publishesNpm) {
2092
+ if (ci === "github") return { step: {
2093
+ uses: "changesets/action@v1",
2094
+ if: "github.ref == 'refs/heads/main'",
2095
+ with: {
2096
+ publish: "pnpm changeset publish",
2097
+ version: "pnpm changeset version"
2098
+ },
2099
+ env: {
2100
+ GITHUB_TOKEN: actionsExpr("github.token"),
2101
+ ...publishesNpm && { NPM_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2102
+ }
2103
+ } };
2104
+ return { step: {
2105
+ name: "Release",
2106
+ if: "github.ref == 'refs/heads/main'",
2107
+ env: {
2108
+ FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2109
+ FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2110
+ RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
2111
+ ...publishesNpm && { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") },
2112
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2113
+ },
2114
+ run: "pnpm exec bst release:changesets"
2115
+ } };
2116
+ }
2173
2117
  const CI_CONCURRENCY = {
2174
2118
  group: `ci-${actionsExpr("github.ref")}`,
2175
2119
  "cancel-in-progress": actionsExpr("github.ref != 'refs/heads/main'")
2176
2120
  };
2177
- function checkSteps(nodeVersionYaml) {
2121
+ function checkSteps(nodeVersionYaml, publishesNpm) {
2122
+ const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2178
2123
  return [
2179
- {
2180
- match: { uses: "actions/checkout" },
2181
- step: { uses: "actions/checkout@v6" }
2182
- },
2183
- {
2184
- match: { uses: "pnpm/action-setup" },
2185
- step: { uses: "pnpm/action-setup@v5" }
2186
- },
2187
- {
2188
- match: { uses: "actions/setup-node" },
2189
- step: {
2190
- uses: "actions/setup-node@v6",
2191
- with: {
2192
- ...nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" },
2193
- cache: "pnpm"
2194
- }
2195
- }
2196
- },
2197
- {
2198
- match: { run: "pnpm install" },
2199
- step: { run: "pnpm install --frozen-lockfile" }
2200
- },
2201
- {
2202
- match: { run: "check" },
2203
- step: {
2204
- name: "Run all checks",
2205
- run: "pnpm ci:check"
2124
+ { step: {
2125
+ uses: "actions/checkout@v6",
2126
+ with: { "fetch-depth": 0 }
2127
+ } },
2128
+ { step: { uses: "pnpm/action-setup@v5" } },
2129
+ { step: {
2130
+ uses: "actions/setup-node@v6",
2131
+ with: {
2132
+ ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2133
+ cache: "pnpm",
2134
+ ...publishesNpm && { "registry-url": actionsExpr("vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org'") }
2206
2135
  }
2207
- }
2136
+ } },
2137
+ { step: { run: "pnpm install --frozen-lockfile" } },
2138
+ { step: {
2139
+ name: "Run all checks",
2140
+ run: "pnpm ci:check"
2141
+ } }
2208
2142
  ];
2209
2143
  }
2210
2144
  /** Resolve the CI workflow filename based on release strategy. */
@@ -2220,7 +2154,9 @@ async function generateCi(ctx) {
2220
2154
  const isGitHub = ctx.config.ci === "github";
2221
2155
  const isForgejo = !isGitHub;
2222
2156
  const isChangesets = ctx.config.releaseStrategy === "changesets";
2223
- const steps = checkSteps(computeNodeVersionYaml(ctx));
2157
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
2158
+ const publishesNpm = ctx.config.publishNpm === true;
2159
+ const steps = [...checkSteps(nodeVersionYaml, isChangesets && publishesNpm), ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm)] : []];
2224
2160
  const content = buildWorkflowYaml({
2225
2161
  ci: ctx.config.ci,
2226
2162
  name: "CI",
@@ -2237,42 +2173,26 @@ async function generateCi(ctx) {
2237
2173
  steps
2238
2174
  });
2239
2175
  const filePath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
2240
- if (ctx.exists(filePath)) {
2241
- const existing = ctx.read(filePath);
2242
- if (existing) {
2243
- let result = mergeWorkflowSteps(existing, "check", toRequiredSteps(steps));
2244
- const withTagsIgnore = ensureWorkflowTagsIgnore(result.content);
2245
- result = {
2246
- content: withTagsIgnore.content,
2247
- changed: result.changed || withTagsIgnore.changed
2248
- };
2249
- if (isChangesets) {
2250
- const withConcurrency = ensureWorkflowConcurrency(result.content, CI_CONCURRENCY);
2251
- result = {
2252
- content: withConcurrency.content,
2253
- changed: result.changed || withConcurrency.changed
2254
- };
2255
- }
2256
- const withComment = ensureSchemaComment(result.content, isGitHub ? "github" : "forgejo");
2257
- if (result.changed || withComment !== result.content) {
2258
- ctx.write(filePath, withComment);
2259
- return {
2260
- filePath,
2261
- action: "updated",
2262
- description: "Added missing steps to CI workflow"
2263
- };
2264
- }
2265
- }
2266
- return {
2176
+ const alreadyExists = ctx.exists(filePath);
2177
+ const existing = alreadyExists ? ctx.read(filePath) : void 0;
2178
+ if (existing) {
2179
+ if (isToolingIgnored(existing)) return {
2180
+ filePath,
2181
+ action: "skipped",
2182
+ description: "CI workflow has ignore comment"
2183
+ };
2184
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
2267
2185
  filePath,
2268
2186
  action: "skipped",
2269
2187
  description: "CI workflow already up to date"
2270
2188
  };
2271
2189
  }
2272
- ctx.write(filePath, content);
2190
+ const withDevPath = preserveDevBinaryPath(content, existing);
2191
+ const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
2192
+ ctx.write(filePath, final);
2273
2193
  return {
2274
2194
  filePath,
2275
- action: "created",
2195
+ action: alreadyExists ? "updated" : "created",
2276
2196
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions CI workflow`
2277
2197
  };
2278
2198
  }
@@ -2749,47 +2669,33 @@ async function generateChangesets(ctx) {
2749
2669
  function commonSteps(nodeVersionYaml, publishesNpm) {
2750
2670
  const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2751
2671
  return [
2752
- {
2753
- match: { uses: "actions/checkout" },
2754
- step: {
2755
- uses: "actions/checkout@v6",
2756
- with: { "fetch-depth": 0 }
2757
- }
2758
- },
2759
- {
2760
- match: { uses: "pnpm/action-setup" },
2761
- step: { uses: "pnpm/action-setup@v5" }
2762
- },
2763
- {
2764
- match: { uses: "actions/setup-node" },
2765
- step: {
2766
- uses: "actions/setup-node@v6",
2767
- with: {
2768
- ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2769
- cache: "pnpm",
2770
- ...publishesNpm && { "registry-url": "https://registry.npmjs.org" }
2771
- }
2672
+ { step: {
2673
+ uses: "actions/checkout@v6",
2674
+ with: { "fetch-depth": 0 }
2675
+ } },
2676
+ { step: { uses: "pnpm/action-setup@v5" } },
2677
+ { step: {
2678
+ uses: "actions/setup-node@v6",
2679
+ with: {
2680
+ ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2681
+ cache: "pnpm",
2682
+ ...publishesNpm && { "registry-url": actionsExpr("vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org'") }
2772
2683
  }
2773
- },
2774
- {
2775
- match: { run: "pnpm install" },
2776
- step: { run: "pnpm install --frozen-lockfile" }
2777
- }
2684
+ } },
2685
+ { step: { run: "pnpm install --frozen-lockfile" } }
2778
2686
  ];
2779
2687
  }
2780
2688
  function releaseItSteps(ci, nodeVersionYaml, publishesNpm) {
2781
2689
  const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
2782
2690
  const npmEnv = publishesNpm ? { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") } : {};
2783
- return [...commonSteps(nodeVersionYaml, publishesNpm), {
2784
- match: { run: "release-it" },
2785
- step: {
2786
- run: "pnpm release-it --ci",
2787
- env: {
2788
- ...tokenEnv,
2789
- ...npmEnv
2790
- }
2691
+ return [...commonSteps(nodeVersionYaml, publishesNpm), { step: {
2692
+ run: "pnpm release-it --ci",
2693
+ env: {
2694
+ ...tokenEnv,
2695
+ ...npmEnv,
2696
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2791
2697
  }
2792
- }];
2698
+ } }];
2793
2699
  }
2794
2700
  /** Build the workflow_dispatch trigger with optional inputs for the simple strategy. */
2795
2701
  function simpleWorkflowDispatchTrigger() {
@@ -2827,70 +2733,36 @@ function simpleReleaseCommand() {
2827
2733
  ].join("\n");
2828
2734
  }
2829
2735
  function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker) {
2830
- const releaseStep = {
2831
- match: { run: "release:simple" },
2832
- step: {
2833
- name: "Release",
2834
- env: ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
2736
+ const releaseStep = { step: {
2737
+ name: "Release",
2738
+ env: {
2739
+ ...ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
2835
2740
  FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2836
2741
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2837
2742
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
2838
2743
  },
2839
- run: simpleReleaseCommand()
2840
- }
2841
- };
2842
- const dockerStep = {
2843
- match: { run: "docker:publish" },
2844
- step: {
2845
- name: "Publish Docker images",
2846
- if: "success()",
2847
- env: {
2848
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
2849
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
2850
- DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
2851
- DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD")
2852
- },
2853
- run: "pnpm exec bst docker:publish"
2854
- }
2855
- };
2744
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2745
+ },
2746
+ run: simpleReleaseCommand()
2747
+ } };
2748
+ const dockerStep = { step: {
2749
+ name: "Publish Docker images",
2750
+ if: "success()",
2751
+ env: {
2752
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
2753
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
2754
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
2755
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
2756
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2757
+ },
2758
+ run: "pnpm exec bst docker:publish"
2759
+ } };
2856
2760
  return [
2857
2761
  ...commonSteps(nodeVersionYaml, publishesNpm),
2858
2762
  releaseStep,
2859
2763
  ...hasDocker ? [dockerStep] : []
2860
2764
  ];
2861
2765
  }
2862
- /** Build the required release step for the check job (changesets). */
2863
- function changesetsReleaseStep(ci, publishesNpm) {
2864
- if (ci === "github") return {
2865
- match: { uses: "changesets/action" },
2866
- step: {
2867
- uses: "changesets/action@v1",
2868
- if: "github.ref == 'refs/heads/main'",
2869
- with: {
2870
- publish: "pnpm changeset publish",
2871
- version: "pnpm changeset version"
2872
- },
2873
- env: {
2874
- GITHUB_TOKEN: actionsExpr("github.token"),
2875
- ...publishesNpm && { NPM_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2876
- }
2877
- }
2878
- };
2879
- return {
2880
- match: { run: "release:changesets" },
2881
- step: {
2882
- name: "Release",
2883
- if: "github.ref == 'refs/heads/main'",
2884
- env: {
2885
- FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2886
- FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2887
- RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
2888
- ...publishesNpm && { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2889
- },
2890
- run: "pnpm exec bst release:changesets"
2891
- }
2892
- };
2893
- }
2894
2766
  function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker) {
2895
2767
  switch (strategy) {
2896
2768
  case "release-it": return releaseItSteps(ci, nodeVersionYaml, publishesNpm);
@@ -2898,40 +2770,6 @@ function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker) {
2898
2770
  default: return null;
2899
2771
  }
2900
2772
  }
2901
- function generateChangesetsReleaseCi(ctx, publishesNpm) {
2902
- const ciPath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
2903
- const raw = ctx.read(ciPath);
2904
- if (!raw) return {
2905
- filePath: ciPath,
2906
- action: "skipped",
2907
- description: "CI workflow not found — run check generator first"
2908
- };
2909
- const existing = migrateToolingBinary(raw);
2910
- const merged = mergeWorkflowSteps(existing, "check", [changesetsReleaseStep(ctx.config.ci, publishesNpm)]);
2911
- if (!merged.changed) {
2912
- if (existing !== raw) {
2913
- const withComment = ensureSchemaComment(existing, ctx.config.ci);
2914
- ctx.write(ciPath, withComment);
2915
- return {
2916
- filePath: ciPath,
2917
- action: "updated",
2918
- description: "Migrated tooling binary name in CI workflow"
2919
- };
2920
- }
2921
- return {
2922
- filePath: ciPath,
2923
- action: "skipped",
2924
- description: "Release step in CI workflow already up to date"
2925
- };
2926
- }
2927
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2928
- ctx.write(ciPath, withComment);
2929
- return {
2930
- filePath: ciPath,
2931
- action: "updated",
2932
- description: "Added release step to CI workflow"
2933
- };
2934
- }
2935
2773
  async function generateReleaseCi(ctx) {
2936
2774
  const filePath = "release-ci";
2937
2775
  if (ctx.config.releaseStrategy === "none" || ctx.config.ci === "none") return {
@@ -2940,7 +2778,11 @@ async function generateReleaseCi(ctx) {
2940
2778
  description: "Release CI workflow not applicable"
2941
2779
  };
2942
2780
  const publishesNpm = ctx.config.publishNpm === true;
2943
- if (ctx.config.releaseStrategy === "changesets") return generateChangesetsReleaseCi(ctx, publishesNpm);
2781
+ if (ctx.config.releaseStrategy === "changesets") return {
2782
+ filePath,
2783
+ action: "skipped",
2784
+ description: "Release step included in CI workflow"
2785
+ };
2944
2786
  const isGitHub = ctx.config.ci === "github";
2945
2787
  const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
2946
2788
  const nodeVersionYaml = computeNodeVersionYaml(ctx);
@@ -2960,59 +2802,26 @@ async function generateReleaseCi(ctx) {
2960
2802
  jobName: "release",
2961
2803
  steps
2962
2804
  });
2963
- if (ctx.exists(workflowPath)) {
2964
- const raw = ctx.read(workflowPath);
2965
- if (raw) {
2966
- const existing = migrateToolingBinary(raw);
2967
- if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) {
2968
- if (existing !== raw) {
2969
- ctx.write(workflowPath, ensureSchemaComment(existing, ctx.config.ci));
2970
- return {
2971
- filePath: workflowPath,
2972
- action: "updated",
2973
- description: "Migrated tooling binary name in release workflow"
2974
- };
2975
- }
2976
- return {
2977
- filePath: workflowPath,
2978
- action: "skipped",
2979
- description: "Release workflow already up to date"
2980
- };
2981
- }
2982
- const merged = mergeWorkflowSteps(existing, "release", toRequiredSteps(steps));
2983
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2984
- if (!merged.changed) {
2985
- if (withComment !== raw) {
2986
- ctx.write(workflowPath, withComment);
2987
- return {
2988
- filePath: workflowPath,
2989
- action: "updated",
2990
- description: existing !== raw ? "Migrated tooling binary name in release workflow" : "Added schema comment to release workflow"
2991
- };
2992
- }
2993
- return {
2994
- filePath: workflowPath,
2995
- action: "skipped",
2996
- description: "Existing release workflow preserved"
2997
- };
2998
- }
2999
- ctx.write(workflowPath, withComment);
3000
- return {
3001
- filePath: workflowPath,
3002
- action: "updated",
3003
- description: "Added missing steps to release workflow"
3004
- };
3005
- }
3006
- return {
2805
+ const alreadyExists = ctx.exists(workflowPath);
2806
+ const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
2807
+ if (existing) {
2808
+ if (isToolingIgnored(existing)) return {
2809
+ filePath: workflowPath,
2810
+ action: "skipped",
2811
+ description: "Release workflow has ignore comment"
2812
+ };
2813
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
3007
2814
  filePath: workflowPath,
3008
2815
  action: "skipped",
3009
2816
  description: "Release workflow already up to date"
3010
2817
  };
3011
2818
  }
3012
- ctx.write(workflowPath, content);
2819
+ const withDevPath = preserveDevBinaryPath(content, existing);
2820
+ const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
2821
+ ctx.write(workflowPath, final);
3013
2822
  return {
3014
2823
  filePath: workflowPath,
3015
- action: "created",
2824
+ action: alreadyExists ? "updated" : "created",
3016
2825
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions release workflow`
3017
2826
  };
3018
2827
  }
@@ -3308,7 +3117,7 @@ function generateMigratePrompt(results, config, detected) {
3308
3117
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3309
3118
  sections.push("# Migration Prompt");
3310
3119
  sections.push("");
3311
- sections.push(`_Generated by \`@bensandee/tooling@0.34.0 repo:sync\` on ${timestamp}_`);
3120
+ sections.push(`_Generated by \`@bensandee/tooling@0.36.0 repo:sync\` on ${timestamp}_`);
3312
3121
  sections.push("");
3313
3122
  sections.push("The following prompt was generated by `@bensandee/tooling repo:sync`. Paste it into Claude Code or another AI assistant to finish migrating this repository.");
3314
3123
  sections.push("");
@@ -5181,7 +4990,7 @@ const dockerCheckCommand = defineCommand({
5181
4990
  const main = defineCommand({
5182
4991
  meta: {
5183
4992
  name: "bst",
5184
- version: "0.34.0",
4993
+ version: "0.36.0",
5185
4994
  description: "Bootstrap and maintain standardized TypeScript project tooling"
5186
4995
  },
5187
4996
  subCommands: {
@@ -5197,7 +5006,7 @@ const main = defineCommand({
5197
5006
  "docker:check": dockerCheckCommand
5198
5007
  }
5199
5008
  });
5200
- console.log(`@bensandee/tooling v0.34.0`);
5009
+ console.log(`@bensandee/tooling v0.36.0`);
5201
5010
  async function run() {
5202
5011
  await runMain(main);
5203
5012
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "bst": "./dist/bin.mjs"