@bensandee/tooling 0.34.0 → 0.35.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 +187 -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
@@ -963,10 +963,6 @@ function ensureSchemaComment(content, ci) {
963
963
  if (content.includes("yaml-language-server")) return content;
964
964
  return FORGEJO_SCHEMA_COMMENT + content;
965
965
  }
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
966
  /** Check if a YAML file has an opt-out comment in the first 10 lines. */
971
967
  function isToolingIgnored(content) {
972
968
  return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
@@ -1011,14 +1007,42 @@ function mergeLefthookCommands(existing, requiredCommands) {
1011
1007
  };
1012
1008
  }
1013
1009
  }
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
- }));
1010
+ /**
1011
+ * Normalize a workflow YAML string for comparison purposes.
1012
+ * Parses the YAML DOM to strip action versions from `uses:` values and remove all comments,
1013
+ * then re-serializes so that pinned hashes, older tags, or formatting differences
1014
+ * don't cause unnecessary overwrites.
1015
+ */
1016
+ function normalizeWorkflow(content) {
1017
+ const doc = parseDocument(content.replaceAll(/node\s+packages\/tooling-cli\/dist\/bin\.mjs/g, "pnpm exec bst"));
1018
+ doc.comment = null;
1019
+ doc.commentBefore = null;
1020
+ visit(doc, {
1021
+ Node(_key, node) {
1022
+ if ("comment" in node) node.comment = null;
1023
+ if ("commentBefore" in node) node.commentBefore = null;
1024
+ },
1025
+ Pair(_key, node) {
1026
+ if (isScalar(node.key) && node.key.value === "uses" && isScalar(node.value) && typeof node.value.value === "string") node.value.value = node.value.value.replace(/@.*$/, "");
1027
+ }
1028
+ });
1029
+ return doc.toString({
1030
+ lineWidth: 0,
1031
+ nullStr: ""
1032
+ });
1033
+ }
1034
+ const DEV_BINARY_PATTERN = /node\s+packages\/tooling-cli\/dist\/bin\.mjs/;
1035
+ /**
1036
+ * If the existing file uses the dev binary path (`node packages/tooling-cli/dist/bin.mjs`),
1037
+ * substitute it back into the generated content so we don't overwrite it with `pnpm exec bst`.
1038
+ */
1039
+ function preserveDevBinaryPath(generated, existing) {
1040
+ if (!existing) return generated;
1041
+ const match = DEV_BINARY_PATTERN.exec(existing);
1042
+ if (!match) return generated;
1043
+ return generated.replaceAll("pnpm exec bst", match[0]);
1020
1044
  }
1021
- /** Build a complete workflow YAML string from structured options. Single source of truth for both new files and merge steps. */
1045
+ /** Build a complete workflow YAML string from structured options. Single source of truth for workflow files. */
1022
1046
  function buildWorkflowYaml(options) {
1023
1047
  const doc = { name: options.name };
1024
1048
  if (options.enableEmailNotifications) doc["enable-email-notifications"] = true;
@@ -1032,132 +1056,7 @@ function buildWorkflowYaml(options) {
1032
1056
  return ensureSchemaComment(stringify(doc, {
1033
1057
  lineWidth: 0,
1034
1058
  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
- }
1059
+ }).replace(/^(?=\S)/gm, (match, offset) => offset === 0 ? match : `\n${match}`), options.ci);
1161
1060
  }
1162
1061
  //#endregion
1163
1062
  //#region src/generators/ci-utils.ts
@@ -1177,38 +1076,24 @@ function computeNodeVersionYaml(ctx) {
1177
1076
  //#region src/generators/publish-ci.ts
1178
1077
  function publishSteps(nodeVersionYaml) {
1179
1078
  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
- }
1079
+ { step: { uses: "actions/checkout@v6" } },
1080
+ { step: { uses: "pnpm/action-setup@v5" } },
1081
+ { step: {
1082
+ uses: "actions/setup-node@v6",
1083
+ with: nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" }
1084
+ } },
1085
+ { step: { run: "pnpm install --frozen-lockfile" } },
1086
+ { step: {
1087
+ name: "Publish Docker images",
1088
+ env: {
1089
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1090
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
1091
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1092
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
1093
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
1094
+ },
1095
+ run: "pnpm exec bst docker:publish"
1096
+ } }
1212
1097
  ];
1213
1098
  }
1214
1099
  const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
@@ -1257,59 +1142,24 @@ async function generateDeployCi(ctx) {
1257
1142
  jobName: "publish",
1258
1143
  steps
1259
1144
  });
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 {
1145
+ const alreadyExists = ctx.exists(workflowPath);
1146
+ const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
1147
+ if (existing) {
1148
+ if (isToolingIgnored(existing)) return {
1149
+ filePath: workflowPath,
1150
+ action: "skipped",
1151
+ description: "Publish workflow has ignore comment"
1152
+ };
1153
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
1304
1154
  filePath: workflowPath,
1305
1155
  action: "skipped",
1306
1156
  description: "Publish workflow already up to date"
1307
1157
  };
1308
1158
  }
1309
- ctx.write(workflowPath, content);
1159
+ ctx.write(workflowPath, preserveDevBinaryPath(content, existing));
1310
1160
  return {
1311
1161
  filePath: workflowPath,
1312
- action: "created",
1162
+ action: alreadyExists ? "updated" : "created",
1313
1163
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions publish workflow`
1314
1164
  };
1315
1165
  }
@@ -1577,7 +1427,7 @@ function getAddedDevDepNames(config) {
1577
1427
  const deps = { ...ROOT_DEV_DEPS };
1578
1428
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
1579
1429
  deps["@bensandee/config"] = "0.9.1";
1580
- deps["@bensandee/tooling"] = "0.34.0";
1430
+ deps["@bensandee/tooling"] = "0.35.0";
1581
1431
  if (config.formatter === "oxfmt") deps["oxfmt"] = {
1582
1432
  "@changesets/cli": "2.30.0",
1583
1433
  "@release-it/bumper": "7.0.5",
@@ -1632,7 +1482,7 @@ async function generatePackageJson(ctx) {
1632
1482
  const devDeps = { ...ROOT_DEV_DEPS };
1633
1483
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1634
1484
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1635
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.34.0";
1485
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.35.0";
1636
1486
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1637
1487
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1638
1488
  "@changesets/cli": "2.30.0",
@@ -1680,10 +1530,6 @@ async function generatePackageJson(ctx) {
1680
1530
  changes.push("set type: \"module\"");
1681
1531
  }
1682
1532
  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
1533
  for (const [key, value] of Object.entries(allScripts)) if (!(key in existingScripts)) {
1688
1534
  existingScripts[key] = value;
1689
1535
  changes.push(`added script: ${key}`);
@@ -2170,41 +2016,58 @@ async function generateGitignore(ctx) {
2170
2016
  }
2171
2017
  //#endregion
2172
2018
  //#region src/generators/ci.ts
2019
+ /** Build the release step for the check job (changesets strategy). */
2020
+ function changesetsReleaseStep(ci, publishesNpm) {
2021
+ if (ci === "github") return { step: {
2022
+ uses: "changesets/action@v1",
2023
+ if: "github.ref == 'refs/heads/main'",
2024
+ with: {
2025
+ publish: "pnpm changeset publish",
2026
+ version: "pnpm changeset version"
2027
+ },
2028
+ env: {
2029
+ GITHUB_TOKEN: actionsExpr("github.token"),
2030
+ ...publishesNpm && { NPM_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2031
+ }
2032
+ } };
2033
+ return { step: {
2034
+ name: "Release",
2035
+ if: "github.ref == 'refs/heads/main'",
2036
+ env: {
2037
+ FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2038
+ FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2039
+ RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
2040
+ ...publishesNpm && { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") },
2041
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2042
+ },
2043
+ run: "pnpm exec bst release:changesets"
2044
+ } };
2045
+ }
2173
2046
  const CI_CONCURRENCY = {
2174
2047
  group: `ci-${actionsExpr("github.ref")}`,
2175
2048
  "cancel-in-progress": actionsExpr("github.ref != 'refs/heads/main'")
2176
2049
  };
2177
- function checkSteps(nodeVersionYaml) {
2050
+ function checkSteps(nodeVersionYaml, publishesNpm) {
2051
+ const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2178
2052
  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"
2053
+ { step: {
2054
+ uses: "actions/checkout@v6",
2055
+ with: { "fetch-depth": 0 }
2056
+ } },
2057
+ { step: { uses: "pnpm/action-setup@v5" } },
2058
+ { step: {
2059
+ uses: "actions/setup-node@v6",
2060
+ with: {
2061
+ ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2062
+ cache: "pnpm",
2063
+ ...publishesNpm && { "registry-url": actionsExpr("vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org'") }
2206
2064
  }
2207
- }
2065
+ } },
2066
+ { step: { run: "pnpm install --frozen-lockfile" } },
2067
+ { step: {
2068
+ name: "Run all checks",
2069
+ run: "pnpm ci:check"
2070
+ } }
2208
2071
  ];
2209
2072
  }
2210
2073
  /** Resolve the CI workflow filename based on release strategy. */
@@ -2220,7 +2083,9 @@ async function generateCi(ctx) {
2220
2083
  const isGitHub = ctx.config.ci === "github";
2221
2084
  const isForgejo = !isGitHub;
2222
2085
  const isChangesets = ctx.config.releaseStrategy === "changesets";
2223
- const steps = checkSteps(computeNodeVersionYaml(ctx));
2086
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
2087
+ const publishesNpm = ctx.config.publishNpm === true;
2088
+ const steps = [...checkSteps(nodeVersionYaml, isChangesets && publishesNpm), ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm)] : []];
2224
2089
  const content = buildWorkflowYaml({
2225
2090
  ci: ctx.config.ci,
2226
2091
  name: "CI",
@@ -2237,42 +2102,24 @@ async function generateCi(ctx) {
2237
2102
  steps
2238
2103
  });
2239
2104
  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 {
2105
+ const alreadyExists = ctx.exists(filePath);
2106
+ const existing = alreadyExists ? ctx.read(filePath) : void 0;
2107
+ if (existing) {
2108
+ if (isToolingIgnored(existing)) return {
2109
+ filePath,
2110
+ action: "skipped",
2111
+ description: "CI workflow has ignore comment"
2112
+ };
2113
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
2267
2114
  filePath,
2268
2115
  action: "skipped",
2269
2116
  description: "CI workflow already up to date"
2270
2117
  };
2271
2118
  }
2272
- ctx.write(filePath, content);
2119
+ ctx.write(filePath, preserveDevBinaryPath(content, existing));
2273
2120
  return {
2274
2121
  filePath,
2275
- action: "created",
2122
+ action: alreadyExists ? "updated" : "created",
2276
2123
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions CI workflow`
2277
2124
  };
2278
2125
  }
@@ -2749,47 +2596,33 @@ async function generateChangesets(ctx) {
2749
2596
  function commonSteps(nodeVersionYaml, publishesNpm) {
2750
2597
  const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2751
2598
  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
- }
2599
+ { step: {
2600
+ uses: "actions/checkout@v6",
2601
+ with: { "fetch-depth": 0 }
2602
+ } },
2603
+ { step: { uses: "pnpm/action-setup@v5" } },
2604
+ { step: {
2605
+ uses: "actions/setup-node@v6",
2606
+ with: {
2607
+ ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2608
+ cache: "pnpm",
2609
+ ...publishesNpm && { "registry-url": actionsExpr("vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org'") }
2772
2610
  }
2773
- },
2774
- {
2775
- match: { run: "pnpm install" },
2776
- step: { run: "pnpm install --frozen-lockfile" }
2777
- }
2611
+ } },
2612
+ { step: { run: "pnpm install --frozen-lockfile" } }
2778
2613
  ];
2779
2614
  }
2780
2615
  function releaseItSteps(ci, nodeVersionYaml, publishesNpm) {
2781
2616
  const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
2782
2617
  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
- }
2618
+ return [...commonSteps(nodeVersionYaml, publishesNpm), { step: {
2619
+ run: "pnpm release-it --ci",
2620
+ env: {
2621
+ ...tokenEnv,
2622
+ ...npmEnv,
2623
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2791
2624
  }
2792
- }];
2625
+ } }];
2793
2626
  }
2794
2627
  /** Build the workflow_dispatch trigger with optional inputs for the simple strategy. */
2795
2628
  function simpleWorkflowDispatchTrigger() {
@@ -2827,70 +2660,36 @@ function simpleReleaseCommand() {
2827
2660
  ].join("\n");
2828
2661
  }
2829
2662
  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") } : {
2663
+ const releaseStep = { step: {
2664
+ name: "Release",
2665
+ env: {
2666
+ ...ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
2835
2667
  FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2836
2668
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2837
2669
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
2838
2670
  },
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
- };
2671
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2672
+ },
2673
+ run: simpleReleaseCommand()
2674
+ } };
2675
+ const dockerStep = { step: {
2676
+ name: "Publish Docker images",
2677
+ if: "success()",
2678
+ env: {
2679
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
2680
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
2681
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
2682
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
2683
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2684
+ },
2685
+ run: "pnpm exec bst docker:publish"
2686
+ } };
2856
2687
  return [
2857
2688
  ...commonSteps(nodeVersionYaml, publishesNpm),
2858
2689
  releaseStep,
2859
2690
  ...hasDocker ? [dockerStep] : []
2860
2691
  ];
2861
2692
  }
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
2693
  function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker) {
2895
2694
  switch (strategy) {
2896
2695
  case "release-it": return releaseItSteps(ci, nodeVersionYaml, publishesNpm);
@@ -2898,40 +2697,6 @@ function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker) {
2898
2697
  default: return null;
2899
2698
  }
2900
2699
  }
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
2700
  async function generateReleaseCi(ctx) {
2936
2701
  const filePath = "release-ci";
2937
2702
  if (ctx.config.releaseStrategy === "none" || ctx.config.ci === "none") return {
@@ -2940,7 +2705,11 @@ async function generateReleaseCi(ctx) {
2940
2705
  description: "Release CI workflow not applicable"
2941
2706
  };
2942
2707
  const publishesNpm = ctx.config.publishNpm === true;
2943
- if (ctx.config.releaseStrategy === "changesets") return generateChangesetsReleaseCi(ctx, publishesNpm);
2708
+ if (ctx.config.releaseStrategy === "changesets") return {
2709
+ filePath,
2710
+ action: "skipped",
2711
+ description: "Release step included in CI workflow"
2712
+ };
2944
2713
  const isGitHub = ctx.config.ci === "github";
2945
2714
  const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
2946
2715
  const nodeVersionYaml = computeNodeVersionYaml(ctx);
@@ -2960,59 +2729,24 @@ async function generateReleaseCi(ctx) {
2960
2729
  jobName: "release",
2961
2730
  steps
2962
2731
  });
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 {
2732
+ const alreadyExists = ctx.exists(workflowPath);
2733
+ const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
2734
+ if (existing) {
2735
+ if (isToolingIgnored(existing)) return {
2736
+ filePath: workflowPath,
2737
+ action: "skipped",
2738
+ description: "Release workflow has ignore comment"
2739
+ };
2740
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
3007
2741
  filePath: workflowPath,
3008
2742
  action: "skipped",
3009
2743
  description: "Release workflow already up to date"
3010
2744
  };
3011
2745
  }
3012
- ctx.write(workflowPath, content);
2746
+ ctx.write(workflowPath, preserveDevBinaryPath(content, existing));
3013
2747
  return {
3014
2748
  filePath: workflowPath,
3015
- action: "created",
2749
+ action: alreadyExists ? "updated" : "created",
3016
2750
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions release workflow`
3017
2751
  };
3018
2752
  }
@@ -3308,7 +3042,7 @@ function generateMigratePrompt(results, config, detected) {
3308
3042
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3309
3043
  sections.push("# Migration Prompt");
3310
3044
  sections.push("");
3311
- sections.push(`_Generated by \`@bensandee/tooling@0.34.0 repo:sync\` on ${timestamp}_`);
3045
+ sections.push(`_Generated by \`@bensandee/tooling@0.35.0 repo:sync\` on ${timestamp}_`);
3312
3046
  sections.push("");
3313
3047
  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
3048
  sections.push("");
@@ -5181,7 +4915,7 @@ const dockerCheckCommand = defineCommand({
5181
4915
  const main = defineCommand({
5182
4916
  meta: {
5183
4917
  name: "bst",
5184
- version: "0.34.0",
4918
+ version: "0.35.0",
5185
4919
  description: "Bootstrap and maintain standardized TypeScript project tooling"
5186
4920
  },
5187
4921
  subCommands: {
@@ -5197,7 +4931,7 @@ const main = defineCommand({
5197
4931
  "docker:check": dockerCheckCommand
5198
4932
  }
5199
4933
  });
5200
- console.log(`@bensandee/tooling v0.34.0`);
4934
+ console.log(`@bensandee/tooling v0.35.0`);
5201
4935
  async function run() {
5202
4936
  await runMain(main);
5203
4937
  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.35.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "bst": "./dist/bin.mjs"