@bensandee/tooling 0.86.0 → 0.87.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.
package/dist/bin.mjs CHANGED
@@ -269,7 +269,7 @@ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
269
269
  */
270
270
  const envFileLoad = loadEnvFileForBin(process.argv.slice(2), {
271
271
  name: "bst",
272
- version: "0.86.0"
272
+ version: "0.87.0"
273
273
  });
274
274
  //#endregion
275
275
  //#region ../cli-kit/src/logger.ts
@@ -1132,7 +1132,7 @@ const AsciiConfigSchema = z.strictObject({
1132
1132
  allow: z.array(z.string()).optional().meta({ description: "Codepoints (as U+XXXX) to drop from the effective deny-list" }),
1133
1133
  treatAsProse: z.array(z.string()).optional().meta({ description: "Path globs (matched the same way as exclude) whose files --fix classifies as prose wholesale regardless of extension, flattening rather than flagging. The opt-back-in for an extension with no dedicated lexical-context classifier where blind flattening is known safe. No built-in defaults." })
1134
1134
  });
1135
- const LintConfigSchema = z.strictObject({ strictTypeAware: z.boolean().optional().meta({ description: "Make `bst lint:types` (the type-aware oxlint gate, backed by oxlint-tsgolint) exit non-zero on any finding while the tooling is still in its report phase (default false; the enforce phase, a later tooling release, exits non-zero unconditionally). A missing oxlint-tsgolint binary is also a hard failure rather than a notice while this is set: arming the gate is what turns a platform gap into a build break, deliberately, because the repo that armed it is the one that asked for it." }) });
1135
+ const LintConfigSchema = z.strictObject({ strictTypeAware: z.boolean().optional().meta({ description: "Make a missing oxlint-tsgolint binary a hard failure for `bst lint:types` (the type-aware oxlint gate) rather than a notice (default false): arming it is what turns a platform gap into a build break, deliberately, because the repo that armed it is the one that asked for it. Findings block in every repo regardless of this setting." }) });
1136
1136
  const ChecksConfigSchema = z.strictObject({
1137
1137
  add: z.array(z.string()).optional().meta({ description: "Extra package.json scripts to run as first-class check steps, appended to the built-in list (e.g. ['lint:php']). An added step with no matching script is a hard failure." }),
1138
1138
  skip: z.array(z.string()).optional().meta({ description: "Check steps to skip, unioned with the built-in CI defaults (never replacing them). Supports glob patterns." }),
@@ -2310,6 +2310,18 @@ const LintSweepConfigSchema = z.strictObject({
2310
2310
  minWarnings: z.number().int().nonnegative().optional().meta({ description: "How many oxlint warnings the default branch must carry before the sweep opens a pull request (default 5). Below the floor the warn marker on checks:run is the whole response." }),
2311
2311
  cooldownDays: z.number().int().nonnegative().optional().meta({ description: "Days after a lint-sweep pull request merged or closed before the sweep may open another (default 7): a merged sweep whose declined warnings survive must not re-file them the next morning." })
2312
2312
  });
2313
+ /**
2314
+ * `.tooling.json#agent.egress`: the extra egress hosts this repo's agent containers may reach.
2315
+ *
2316
+ * Only the browse lane (GET and HEAD) is repo-declarable. The passthrough allows every method,
2317
+ * so it stays agent-owned -- see the non-goals of the per-repo egress spec.
2318
+ *
2319
+ * The list is validated as a list of non-empty strings here, not against the pattern grammar:
2320
+ * that grammar lives in the agent package and the dependency edge runs agent -> tooling, never
2321
+ * back. The agent checks every pattern when it reads the key and refuses the run with the bad
2322
+ * pattern named, so `repo:sync` deliberately accepts a malformed pattern.
2323
+ */
2324
+ const AgentEgressConfigSchema = z.strictObject({ browseHosts: z.array(z.string().min(1)).optional().meta({ description: "Host patterns this repo's agent containers may fetch from, on top of the hosts every run needs: an exact host (`fastdl.mongodb.org`) or a wildcard over its leftmost label (`*.example.com`). No ports. These join the browse lane, which serves GET and HEAD only." }) });
2313
2325
  const AgentConfigSchema = z.strictObject({
2314
2326
  autonomy: z.boolean().optional().meta({ description: "Opt into interior autonomy (default false). When a phase's latest rationale reports readiness \"ready\" with an empty openQuestions[] and the review phase is clean, the agent applies the existing plan -> implement -> review -> park-at-mergeable advance itself instead of parking for a human. Never applies to the design/spec gate or the final merge from the mergeable terminal -- those always wait on a human. Also arms the trunk-red detector: when this repo's default branch combined status settles red, the agent files a tracking issue naming the failing checks and labels it agent/spec, starting the fix through the ordinary spec/plan/implement/review pipeline. It also closes that issue, with a comment, if the default branch recovers on its own before a human acts on it. Also arms the world-changed retrigger sweep: a pull request labelled agent-flags/autopilot that is parked at a -stuck or -failed terminal is re-queued when the fact its park recorded stops being true (the default branch went green, or moved past a conflicted base)." }),
2315
2327
  preRunRebase: z.boolean().optional().meta({ description: "Merge the current default branch into the work branch before the implement (execute) phase's container starts (default true). A clean merge or an already-up-to-date branch runs the container as is; a conflicting merge is aborted, recorded on the run's status comment, and the run proceeds on the un-synced branch -- it never fails the run. Two consecutive conflicting attempts hand the unit to agent/repair before any container runs; a timeout or a failed merge never counts toward that pair. Set false to skip the attempt entirely for this repo: no fetch, no merge, no abort, and the branch is worked exactly as cloned." }),
@@ -2322,6 +2334,7 @@ const AgentConfigSchema = z.strictObject({
2322
2334
  "low"
2323
2335
  ])).optional().meta({ description: "Map this repo's own issue-label names onto the agent's four priority levels, e.g. {\"P1\": \"critical\", \"P4\": \"low\"}. The map UNIONS with the built-in priority/<level> names rather than replacing them, and the agent does not create aliased labels -- the repo owns names the agent did not choose. Priority affects claim order only: it never changes a run's model, effort, or any budget." }),
2324
2336
  triageAnalysis: z.boolean().optional().meta({ description: "Whether the agent may route its own agent-note/human-triage-needed issues through the agent/analyze stage (default: off). Unlike agent.backlog, which defaults on, this defaults off: a backlog run batches every pending finding into one container, while triage routing spends one container per issue -- arming it is the reversible direction, not disarming it." }),
2337
+ egress: AgentEgressConfigSchema.optional().meta({ description: "Extra egress hosts this repo's agent containers may reach. `browseHosts` joins the agent's browse lane (GET and HEAD only) on top of the hosts every run needs; the passthrough lane stays agent-owned. Each entry is a host pattern -- an exact host or a *.host wildcard, never a port. Read from the repo's DEFAULT BRANCH, never off a pull request's own tree, so a run can never widen its own egress: the declaration takes effect on the first container started after a human merges it. An entry whose pattern the agent cannot parse refuses the run rather than being dropped. Changing this block holds the pull request from automerge." }),
2325
2338
  findingHolds: FindingHoldsConfigSchema.optional().meta({ description: "Which of a pull request's linked open findings withhold its unattended merge. `rules` is ordered and the first `when` that matches decides; no match falls through to `default`; an absent block is { rules: [], default: true }, so every open linked finding holds by default. Read off the repo's DEFAULT BRANCH, never off the pull request's own tree -- a pull request cannot edit the rule deciding whether it is held. In a `mentions` entry a /.../flags pattern is a regular expression, a literal:<text> pattern is the substring <text> (how to name a path such as /src/utils), and anything else is a case-insensitive substring." })
2326
2339
  });
2327
2340
  /**
@@ -2345,7 +2358,8 @@ const AgentConfigSchema = z.strictObject({
2345
2358
  const TolerantAgentConfigSchema = z.object({
2346
2359
  ...AgentConfigSchema.shape,
2347
2360
  lintSweep: tolerantBlock(LintSweepConfigSchema).optional(),
2348
- findingHolds: z.unknown().optional()
2361
+ findingHolds: z.unknown().optional(),
2362
+ egress: z.unknown().optional()
2349
2363
  });
2350
2364
  const BranchPruneConfigSchema = z.strictObject({
2351
2365
  mergedDays: z.number().int().nonnegative().optional().meta({ description: "Days after a work branch's PR merged before bst branch:prune deletes it (default 7). Zero is valid on purpose (an explicit 'delete everything merged already'), matching the --merged-days CLI flag's contract." }),
@@ -3061,7 +3075,7 @@ function managedFileHeader(comment, lines) {
3061
3075
  return lines.map((line) => line === "" ? comment : `${comment} ${line}`).join("\n") + "\n";
3062
3076
  }
3063
3077
  /**
3064
- * Header for generated CI workflow files (`ci.yml`, `release.yml`, `publish.yml`).
3078
+ * Header for generated CI workflow files (`ci.yml`, `release.yml`).
3065
3079
  *
3066
3080
  * The literal `@bensandee/tooling:ignore` directive is kept mid-line on purpose so
3067
3081
  * `isToolingIgnored()` (which looks for the directive at the start of a comment) does
@@ -3242,21 +3256,33 @@ function parseContainerPortSegment(segment) {
3242
3256
  const parsed = Number.parseInt(segment.trim(), 10);
3243
3257
  return Number.isNaN(parsed) ? void 0 : parsed;
3244
3258
  }
3259
+ /**
3260
+ * Split a port mapping on the `:` separators outside `${...}` substitutions, whose
3261
+ * defaults may contain `:` themselves (`${PORT:-8080}`, `${BIND_IP:-0.0.0.0}`).
3262
+ * Returns `undefined` for an unclosed substitution.
3263
+ */
3264
+ function splitPortSegments(mapping) {
3265
+ const segments = [];
3266
+ let depth = 0;
3267
+ let start = 0;
3268
+ for (let i = 0; i < mapping.length; i++) if (mapping.startsWith("${", i)) {
3269
+ depth++;
3270
+ i++;
3271
+ } else if (mapping[i] === "}" && depth > 0) depth--;
3272
+ else if (mapping[i] === ":" && depth === 0) {
3273
+ segments.push(mapping.slice(start, i));
3274
+ start = i + 1;
3275
+ }
3276
+ if (depth !== 0) return void 0;
3277
+ segments.push(mapping.slice(start));
3278
+ return segments;
3279
+ }
3245
3280
  /** Parse a single port mapping string and extract both the host and container ports. */
3246
3281
  function parsePortString(port) {
3247
3282
  const withoutProtocol = port.split("/")[0];
3248
3283
  if (!withoutProtocol) return void 0;
3249
- if (withoutProtocol.startsWith("${")) {
3250
- const close = withoutProtocol.indexOf("}");
3251
- if (close === -1) return void 0;
3252
- const hostPort = parseHostPortSegment(withoutProtocol.slice(0, close + 1));
3253
- const rest = withoutProtocol.slice(close + 1);
3254
- return {
3255
- hostPort,
3256
- containerPort: rest.startsWith(":") ? parseContainerPortSegment(rest.slice(1)) : void 0
3257
- };
3258
- }
3259
- const parts = withoutProtocol.split(":");
3284
+ const parts = splitPortSegments(withoutProtocol);
3285
+ if (!parts) return void 0;
3260
3286
  if (parts.length === 2) return {
3261
3287
  hostPort: parts[0] ? parseHostPortSegment(parts[0]) : void 0,
3262
3288
  containerPort: parts[1] ? parseContainerPortSegment(parts[1]) : void 0
@@ -4065,6 +4091,77 @@ function tracedRunners(logger) {
4065
4091
  function fileErrorCode(error) {
4066
4092
  if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
4067
4093
  }
4094
+ function listWorkspacePackages(cwd) {
4095
+ const packagesDir = path.join(cwd, "packages");
4096
+ const packages = [];
4097
+ try {
4098
+ for (const entry of readdirSync(packagesDir)) {
4099
+ const pkgPath = path.join(packagesDir, entry, "package.json");
4100
+ try {
4101
+ const pkg = parsePackageJson(readFileSync(pkgPath, "utf-8"));
4102
+ if (pkg?.name && pkg.version) packages.push({
4103
+ name: pkg.name,
4104
+ version: pkg.version,
4105
+ dir: entry
4106
+ });
4107
+ } catch (_error) {}
4108
+ }
4109
+ } catch (_error) {}
4110
+ return packages;
4111
+ }
4112
+ function readRegularFile(filePath, maxBytes) {
4113
+ let fd;
4114
+ try {
4115
+ fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
4116
+ } catch (error) {
4117
+ const code = fileErrorCode(error);
4118
+ if (code === "ENOENT") return { kind: "absent" };
4119
+ if (code === "ELOOP") return {
4120
+ kind: "refused",
4121
+ reason: "symlink"
4122
+ };
4123
+ return {
4124
+ kind: "refused",
4125
+ reason: "unreadable"
4126
+ };
4127
+ }
4128
+ try {
4129
+ const stat = fstatSync(fd);
4130
+ if (!stat.isFile()) return {
4131
+ kind: "refused",
4132
+ reason: "not-regular"
4133
+ };
4134
+ if (stat.size > maxBytes) return {
4135
+ kind: "refused",
4136
+ reason: "too-large"
4137
+ };
4138
+ const buffer = Buffer.alloc(maxBytes + 1);
4139
+ let bytesRead;
4140
+ try {
4141
+ bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
4142
+ } catch {
4143
+ return {
4144
+ kind: "refused",
4145
+ reason: "unreadable"
4146
+ };
4147
+ }
4148
+ if (bytesRead > maxBytes) return {
4149
+ kind: "refused",
4150
+ reason: "too-large"
4151
+ };
4152
+ return {
4153
+ kind: "ok",
4154
+ content: buffer.toString("utf8", 0, bytesRead)
4155
+ };
4156
+ } catch {
4157
+ return {
4158
+ kind: "refused",
4159
+ reason: "unreadable"
4160
+ };
4161
+ } finally {
4162
+ closeSync(fd);
4163
+ }
4164
+ }
4068
4165
  /**
4069
4166
  * Create an executor that runs real commands, fetches, and reads the filesystem. Subprocess and
4070
4167
  * network calls are traced through `logger` (default: the {@link rootLogger}); see
@@ -4081,24 +4178,7 @@ function createRealExecutor$1(logger = rootLogger) {
4081
4178
  return [];
4082
4179
  }
4083
4180
  },
4084
- listWorkspacePackages(cwd) {
4085
- const packagesDir = path.join(cwd, "packages");
4086
- const packages = [];
4087
- try {
4088
- for (const entry of readdirSync(packagesDir)) {
4089
- const pkgPath = path.join(packagesDir, entry, "package.json");
4090
- try {
4091
- const pkg = parsePackageJson(readFileSync(pkgPath, "utf-8"));
4092
- if (pkg?.name && pkg.version) packages.push({
4093
- name: pkg.name,
4094
- version: pkg.version,
4095
- dir: entry
4096
- });
4097
- } catch (_error) {}
4098
- }
4099
- } catch (_error) {}
4100
- return packages;
4101
- },
4181
+ listWorkspacePackages,
4102
4182
  listPackageDirs(cwd) {
4103
4183
  const packagesDir = path.join(cwd, "packages");
4104
4184
  try {
@@ -4128,59 +4208,7 @@ function createRealExecutor$1(logger = rootLogger) {
4128
4208
  return null;
4129
4209
  }
4130
4210
  },
4131
- readRegularFile(filePath, maxBytes) {
4132
- let fd;
4133
- try {
4134
- fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
4135
- } catch (error) {
4136
- const code = fileErrorCode(error);
4137
- if (code === "ENOENT") return { kind: "absent" };
4138
- if (code === "ELOOP") return {
4139
- kind: "refused",
4140
- reason: "symlink"
4141
- };
4142
- return {
4143
- kind: "refused",
4144
- reason: "unreadable"
4145
- };
4146
- }
4147
- try {
4148
- const stat = fstatSync(fd);
4149
- if (!stat.isFile()) return {
4150
- kind: "refused",
4151
- reason: "not-regular"
4152
- };
4153
- if (stat.size > maxBytes) return {
4154
- kind: "refused",
4155
- reason: "too-large"
4156
- };
4157
- const buffer = Buffer.alloc(maxBytes + 1);
4158
- let bytesRead;
4159
- try {
4160
- bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
4161
- } catch {
4162
- return {
4163
- kind: "refused",
4164
- reason: "unreadable"
4165
- };
4166
- }
4167
- if (bytesRead > maxBytes) return {
4168
- kind: "refused",
4169
- reason: "too-large"
4170
- };
4171
- return {
4172
- kind: "ok",
4173
- content: buffer.toString("utf8", 0, bytesRead)
4174
- };
4175
- } catch {
4176
- return {
4177
- kind: "refused",
4178
- reason: "unreadable"
4179
- };
4180
- } finally {
4181
- closeSync(fd);
4182
- }
4183
- },
4211
+ readRegularFile,
4184
4212
  writeFile(filePath, content) {
4185
4213
  mkdirSync(path.dirname(filePath), { recursive: true });
4186
4214
  writeFileSync(filePath, content);
@@ -5524,6 +5552,24 @@ function readPublishedImageVersion(executor, config, ref) {
5524
5552
  debug(config, `Unexpected manifest shape for ${ref}; no version label read`);
5525
5553
  }
5526
5554
  /**
5555
+ * Name every check-only image this run is about to publish or has just left out -- one line
5556
+ * per image with no explicit `publish` setting, since an explicit value either way is the
5557
+ * repo having answered the question already. Under `warn` the image still goes out and the
5558
+ * line is the notice that a later tooling release stops doing that; under `enforce` it did
5559
+ * not go out and the line says so. A dry run takes both lines verbatim: it is the run a
5560
+ * release rehearses, so a rehearsed phase has to read like the real one.
5561
+ */
5562
+ function reportCheckOnlyImages(packages, phase) {
5563
+ for (const pkg of packages) {
5564
+ if (pkg.checkOnlyIn === void 0 || pkg.docker.publish !== void 0) continue;
5565
+ if (phase === "warn") {
5566
+ log$1.warn(`Image "${pkg.imageName}" is declared only in ${pkg.checkOnlyIn}, the check overlay, so a later tooling release stops publishing it by default. Keep it: set publish to true for "${pkg.imageName}" under .tooling.json#docker. Settle it: set publish to false there, or move the service into a base compose file.`);
5567
+ continue;
5568
+ }
5569
+ log$1.info(`Skipped "${pkg.imageName}": it is declared only in ${pkg.checkOnlyIn}, the check overlay, so it was not pushed. Set publish to true for "${pkg.imageName}" under .tooling.json#docker to publish it anyway.`);
5570
+ }
5571
+ }
5572
+ /**
5527
5573
  * Run the full Docker publish pipeline:
5528
5574
  * 1. Build all images via runDockerBuild
5529
5575
  * 2. Login to registry
@@ -5543,24 +5589,6 @@ function readPublishedImageVersion(executor, config, ref) {
5543
5589
  * overlapping runs that both read a ref before either writes can still finish in the wrong order --
5544
5590
  * seconds wide, where an unconditional push was open for a whole run.
5545
5591
  */
5546
- /**
5547
- * Name every check-only image this run is about to publish or has just left out -- one line
5548
- * per image with no explicit `publish` setting, since an explicit value either way is the
5549
- * repo having answered the question already. Under `warn` the image still goes out and the
5550
- * line is the notice that a later tooling release stops doing that; under `enforce` it did
5551
- * not go out and the line says so. A dry run takes both lines verbatim: it is the run a
5552
- * release rehearses, so a rehearsed phase has to read like the real one.
5553
- */
5554
- function reportCheckOnlyImages(packages, phase) {
5555
- for (const pkg of packages) {
5556
- if (pkg.checkOnlyIn === void 0 || pkg.docker.publish !== void 0) continue;
5557
- if (phase === "warn") {
5558
- log$1.warn(`Image "${pkg.imageName}" is declared only in ${pkg.checkOnlyIn}, the check overlay, so a later tooling release stops publishing it by default. Keep it: set publish to true for "${pkg.imageName}" under .tooling.json#docker. Settle it: set publish to false there, or move the service into a base compose file.`);
5559
- continue;
5560
- }
5561
- log$1.info(`Skipped "${pkg.imageName}": it is declared only in ${pkg.checkOnlyIn}, the check overlay, so it was not pushed. Set publish to true for "${pkg.imageName}" under .tooling.json#docker to publish it anyway.`);
5562
- }
5563
- }
5564
5592
  function runDockerPublish(executor, config) {
5565
5593
  const checkOnlyPhase = config.checkOnlyPhase ?? "warn";
5566
5594
  const { packages } = runDockerBuild(executor, {
@@ -5964,433 +5992,92 @@ function buildWorkflowYaml(options) {
5964
5992
  return ensureSchemaComment(WORKFLOW_MANAGED_HEADER + "\n" + readable, options.ci);
5965
5993
  }
5966
5994
  //#endregion
5967
- //#region src/release/publish/publish-config-lint.ts
5968
- const FIX = `add "publishConfig": { "registry": "<your registry URL>" } to its package.json`;
5995
+ //#region src/generators/publish-ci.ts
5996
+ const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
5969
5997
  /**
5970
- * Whether a value can serve as a publish destination: an absolute http(s) URL.
5971
- * Presence alone is not the test -- a relative or empty registry is as silently
5972
- * wrong as an absent one, and npm resolves neither to the place the author meant.
5998
+ * Resolve the docker-registry host at gen time so the workflow generator can
5999
+ * pick registry-specific secret names. Honors `.tooling.json#registryHost`
6000
+ * first, then git remote `origin` (the operational host, not a mirror-pointed
6001
+ * package.json), then derives from `package.json#repository` using the same
6002
+ * parser as the runtime (`parseGitUrl` handles https, git+https, and ssh shapes).
6003
+ * Returns null if neither source yields a host -- in that case the generator
6004
+ * falls back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` secrets and
6005
+ * lets the runtime resolve the host from `DOCKER_REGISTRY_HOST` (still emitted
6006
+ * into the step).
5973
6007
  */
5974
- function isUsableRegistry(value) {
5975
- if (typeof value !== "string" || value.trim() === "") return false;
5976
- try {
5977
- const url = new URL(value);
5978
- return url.protocol === "https:" || url.protocol === "http:";
5979
- } catch {
5980
- return false;
6008
+ function resolveGenTimeHost(ctx) {
6009
+ const toolingRaw = ctx.read(".tooling.json");
6010
+ if (toolingRaw) try {
6011
+ const parsed = RegistryHostSchema.safeParse(JSON.parse(toolingRaw));
6012
+ if (parsed.success && parsed.data.registryHost && parsed.data.registryHost.length > 0) return parsed.data.registryHost;
6013
+ } catch (error) {
6014
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
5981
6015
  }
6016
+ const originUrl = detectGitRemoteUrl(ctx.targetDir);
6017
+ if (originUrl !== null) {
6018
+ const parsedOrigin = parseGitUrl(originUrl);
6019
+ if (parsedOrigin) return registryHostFromHostname(parsedOrigin.hostname);
6020
+ }
6021
+ const raw = ctx.read("package.json");
6022
+ if (!raw) return null;
6023
+ const repo = parsePackageJson(raw)?.repository;
6024
+ let urlStr = null;
6025
+ if (typeof repo === "string") urlStr = repo;
6026
+ else if (repo && typeof repo === "object") urlStr = repo.url;
6027
+ if (typeof urlStr !== "string") return null;
6028
+ const parsed = parseGitUrl(urlStr);
6029
+ if (!parsed) return null;
6030
+ return registryHostFromHostname(parsed.hostname);
5982
6031
  }
5983
- function isRecord(value) {
5984
- return typeof value === "object" && value !== null && !Array.isArray(value);
5985
- }
6032
+ const PostReleaseHookSchema = z.object({
6033
+ postReleaseHook: z.string().optional(),
6034
+ postReleaseHookSecrets: z.array(z.string()).optional()
6035
+ });
6036
+ const ReleaseAssetsSchema = z.object({ releaseAssets: z.array(ReleaseAssetSchema).optional() });
6037
+ const VersionStampConfigSchema = z.object({ versionStamp: z.array(VersionStampSchema).optional() });
6038
+ const DeploySchema = z.object({ deploy: DeployConfigSchema.optional() });
5986
6039
  /**
5987
- * Require every package this repo would publish to declare where it publishes.
5988
- *
5989
- * npm and pnpm use one `registry` key for reads and writes alike, so a project
5990
- * `.npmrc` aimed at an install cache silently repoints `pnpm publish` too. A
5991
- * package that never declares its destination inherits one, and an inherited
5992
- * destination can be changed by a file that never mentions publishing.
6040
+ * Read the `deploy` block from .tooling.json, if configured.
5993
6041
  *
5994
- * Reports rather than repairs: the correct registry is not derivable here, and
5995
- * writing a guess would redirect a working release exactly the way this gate
5996
- * exists to prevent.
6042
+ * The full file is already validated against ToolingConfigSchema at sync time,
6043
+ * so an invalid `deploy` block fails earlier -- undefined here means "not set".
5997
6044
  */
5998
- function lintPublishConfig(targetDir, structure) {
5999
- const issues = [];
6000
- for (const pkg of getPublishablePackages(targetDir, structure)) {
6001
- const manifest = readPackageJson(pkg.dir);
6002
- if (!manifest) continue;
6003
- const file = path.relative(targetDir, path.join(pkg.dir, "package.json"));
6004
- const pc = manifest.publishConfig;
6005
- const registry = isRecord(pc) ? pc["registry"] : void 0;
6006
- if (registry === void 0) {
6007
- issues.push({
6008
- kind: "missing",
6009
- package: pkg.name,
6010
- file,
6011
- message: `${pkg.name} is published from this repo but declares no publishConfig.registry, so its release destination is inherited -- a project .npmrc can repoint it silently. In ${file}, ${FIX}.`
6012
- });
6013
- continue;
6014
- }
6015
- if (!isUsableRegistry(registry)) issues.push({
6016
- kind: "malformed",
6017
- package: pkg.name,
6018
- file,
6019
- message: `${pkg.name} declares publishConfig.registry as ${JSON.stringify(registry)}, which is not an absolute http(s) URL and cannot be published to. In ${file}, ${FIX}.`
6020
- });
6045
+ function getDeployConfig(ctx) {
6046
+ const raw = ctx.read(".tooling.json");
6047
+ if (!raw) return void 0;
6048
+ try {
6049
+ const result = DeploySchema.safeParse(JSON.parse(raw));
6050
+ if (!result.success) return void 0;
6051
+ return result.data.deploy;
6052
+ } catch (error) {
6053
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6054
+ return;
6021
6055
  }
6022
- return issues;
6023
6056
  }
6024
- /**
6025
- * The registries this repo's publishable packages actually declare, distinct and in
6026
- * first-seen order.
6027
- *
6028
- * Reuses the same "absolute http(s) URL" narrowing as {@link lintPublishConfig} rather
6029
- * than re-deriving it -- a registry this misses here would fail that gate first, so
6030
- * `repo:sync` never reaches a generator with an undeclared or malformed registry. A
6031
- * caller that invokes a generator directly (or a repo whose publishable set is empty,
6032
- * e.g. every package private) still gets a safe empty result.
6033
- */
6034
- function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
6035
- const registries = [];
6036
- for (const pkg of getPublishablePackages(targetDir, structure, rootPackageJson)) {
6037
- const manifest = structure === "single" ? rootPackageJson ?? readPackageJson(pkg.dir) : readPackageJson(pkg.dir);
6038
- if (!manifest) continue;
6039
- const pc = manifest.publishConfig;
6040
- const registry = isRecord(pc) ? pc["registry"] : void 0;
6041
- if (typeof registry === "string" && isUsableRegistry(registry) && !registries.includes(registry)) registries.push(registry);
6042
- }
6043
- return registries;
6044
- }
6045
- //#endregion
6046
- //#region src/generators/mise-pin.ts
6047
- /**
6048
- * The mise version and release-asset digest the fleet's CI and the agent runner base image agree
6049
- * to run. This is the one place that agreement is recorded; `packages/agent/test/runner-base-image-dockerfile.test.ts`
6050
- * (task 8 of the mise-toolchain-provisioning plan) reads both constants back through
6051
- * `readRepoFile` and asserts the base image's own `ARG MISE_VERSION` and `mise.sha256` manifest
6052
- * agree with them, so the two packages cannot drift without a failing test on either side.
6053
- *
6054
- * Each constant sits under its own `// renovate:` marker comment, in the three-line grammar
6055
- * `packages/agent/docker/agent-goose-image/goose.sha256` uses for its own digest line, so the same
6056
- * shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
6057
- * digest (`customManagers` entries scoped to this file).
6058
- */
6059
- const MISE_VERSION = "2026.10.2";
6060
- const MISE_LINUX_X64_SHA256 = "8f5f6660336f572830e33cd9b378d3131e529a0d4c4f0c553776be90a1ba302a";
6061
- /** Where generated Forgejo workflows fetch the shared mise setup action: a composite action in
6062
- * `bensandee/common`, on the forge every fleet repo lives on. GitHub cannot reach it; the
6063
- * generators keep inline steps there. */
6064
- const SETUP_MISE_ACTION_URL = "https://code.orangebikelabs.com/bensandee/common/actions/setup-mise";
6065
- /** The tag in `bensandee/common` that marks the current release of the action. Generated `uses:`
6066
- * lines carry it as their comment, and Renovate moves the SHA below to wherever it points. */
6067
- const SETUP_MISE_ACTION_TAG = "setup-mise@latest";
6068
- const SETUP_MISE_ACTION_SHA = "13f60e0f19ad57b5616f94c9bc18bf214bbe91fe";
6069
- //#endregion
6070
- //#region src/generators/ci-utils.ts
6071
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
6072
- function actionsExpr(expr) {
6073
- return `\${{ ${expr} }}`;
6074
- }
6075
- const MAIN_BRANCH_REF = "refs/heads/main";
6076
- /** `if:` condition that gates a step to push events on the default branch. */
6077
- const IS_MAIN_BRANCH = `github.ref == '${MAIN_BRANCH_REF}'`;
6078
- /**
6079
- * Negated form for concurrency `cancel-in-progress` and similar uses.
6080
- *
6081
- * Main is exempt from cancellation on purpose, and the exemption stays: a main run publishes and
6082
- * pushes tags, and cancelling one mid-flight can leave a partial publish -- some packages on the
6083
- * registry, no matching tags, and no run left alive to finish. A superseded feature-branch run
6084
- * wastes minutes; a half-published release costs a manual unwind.
6085
- *
6086
- * So several main runs can be in flight at once, each versioning from its own triggering commit.
6087
- * Overlap safety rests on one invariant: **main advances solely via commits that trigger a CI
6088
- * run.** The `[skip ci]` coverage, health, and egress records go to `bst/*` orphan branches and
6089
- * never to main; a `[skip ci]` commit pushed straight to main would leave a superseded run skipping
6090
- * and its changesets stranded until the next push, which is why the supersession guard skips rather
6091
- * than fails. Three guards carry the overlap itself: supersession plus a `--force-with-lease`
6092
- * version push (`release/publish/version.ts`), the `headBumpedVersion` publish guard plus reading
6093
- * remote tags and releases before acting (`commands/release-changesets.ts`,
6094
- * `release/publish/publish.ts`), and floating git and Docker tags that only move forward
6095
- * (`release/publish/floating-tags.ts`, `release/docker.ts`). Such a run may also version pending
6096
- * changesets after it publishes -- the stale version-PR merge -- under the same supersession guard
6097
- * and push lease as any other version run. A later commit publishes a bump only when its tag is
6098
- * missing and every main run since the package's last release tag has settled
6099
- * (`release/publish/unpublished-bump.ts`), so no run that could still publish it is in flight; a
6100
- * manual re-run of one of those runs is the overlap left. On Forgejo the concurrency block is
6101
- * only a PR-run cancellation mechanism -- it holds a main run in its group and, in Forgejo 16,
6102
- * intermittently fails to release it, which is why main runs get a group of their own there. GitHub's shape stays shared
6103
- * because its queue works: one run, one pending, a newer arrival replacing the pending, so
6104
- * `changesets/action@v1` never needs a guard of its own. Any new side effect in a main run must be
6105
- * idempotent under overlap or be listed here with the guard that makes it so.
6106
- */
6107
- const IS_NOT_MAIN_BRANCH = `github.ref != '${MAIN_BRANCH_REF}'`;
6108
- /**
6109
- * Job-level `env:` block that evaluates the default-branch check once, so steps
6110
- * can gate on `env.IS_MAIN` instead of repeating the `github.ref` expression.
6111
- */
6112
- const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
6113
- /** `if:` condition referencing the job-level `IS_MAIN` env var (see {@link IS_MAIN_JOB_ENV}). */
6114
- const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
6115
- /**
6116
- * Job-level default for the npm auth token, declared as the empty string.
6117
- *
6118
- * This repo's own `Configure npm auth` step (see {@link npmAuthWiring}) appends
6119
- * `//<registry>/:_authToken=${NODE_AUTH_TOKEN}` lines to `~/.npmrc` for each
6120
- * declared `publishConfig.registry`. Only the publishing step defines that
6121
- * variable, so every earlier pnpm invocation -- install, and each nested pnpm
6122
- * inside `ci:check` -- hits an undefined var and prints
6123
- * `[WARN] Failed to replace env in config: ${NODE_AUTH_TOKEN}`. Declaring it
6124
- * empty at job level makes the interpolation succeed and the warning vanish; an
6125
- * empty token is not sent as credentials, so anonymous registry reads are
6126
- * unaffected.
6127
- *
6128
- * Deliberately empty rather than the real secret: the publishing step overrides
6129
- * it with `secrets.NPM_TOKEN` via step-level `env`, which keeps the token out of
6130
- * `pnpm install` (and any dependency's postinstall script) the way it is today.
6131
- * The empty default also has to survive steps *after* the publish step
6132
- * (`release:assets`, `docker:publish`, deploy dispatch): those shell out to pnpm
6133
- * again without the secret in scope, so without this default they would warn too.
6134
- *
6135
- * Emit this in lockstep with the `Configure npm auth` step -- both come from the
6136
- * same {@link npmAuthWiring} call, so a job that gets one gets the other.
6137
- */
6138
- const NPM_AUTH_JOB_ENV = { NODE_AUTH_TOKEN: "" };
6139
- /**
6140
- * npm's own registry-key rule for an `.npmrc` auth line: strip the scheme, keep
6141
- * host/port/path, and end with exactly one slash. `actions/setup-node` derives the
6142
- * same key from a registry URL; this mirrors that so a hand-written line matches
6143
- * what npm looks up at publish time -- npm-registry-fetch walks the request path
6144
- * upward through ancestor keys until one matches.
6145
- */
6146
- function npmrcAuthKey(registryUrl) {
6147
- return `${registryUrl.replace(/^https?:/i, "").replace(/\/+$/, "")}/`;
6148
- }
6149
- const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
6150
- /**
6151
- * Build every half of a job's npm auth wiring together, so one value decides all
6152
- * three: the `Configure npm auth` step, the job-level `NODE_AUTH_TOKEN` default,
6153
- * and the step-level `NODE_AUTH_TOKEN` the publish step should carry. A job that
6154
- * gets none of the declared registries gets none of the three -- including a
6155
- * `publishes: true` job whose publishable set is empty (every package private),
6156
- * since there is then no npmrc line for a real secret to pair with.
6157
- */
6158
- function npmAuthWiring(ctx, publishes) {
6159
- const registries = publishes ? declaredPublishRegistries(ctx.targetDir, ctx.config.structure, ctx.packageJson) : [];
6160
- if (registries.length === 0) return {
6161
- steps: [],
6162
- jobEnv: {},
6163
- tokenEnv: {}
6164
- };
6165
- return {
6166
- steps: [{ step: {
6167
- name: "Configure npm auth",
6168
- run: registries.map((registry) => `printf '\\n%s\\n' '${npmrcAuthKey(registry)}:_authToken=${NODE_AUTH_TOKEN_VAR}' >> ~/.npmrc`).join("\n")
6169
- } }],
6170
- jobEnv: NPM_AUTH_JOB_ENV,
6171
- tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
6172
- };
6173
- }
6174
- const MISE_RELEASE_URL = `https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64`;
6175
- /**
6176
- * The node script `inlineMiseSteps` pipes `mise env --json` into, run under the mise it just
6177
- * installed. Reads the env map off stdin (mise exec passes it straight through) and exports every
6178
- * key except `PATH` into `$GITHUB_ENV` in heredoc form -- the form that survives any value,
6179
- * including one containing `$` or quotes -- then appends mise's shim directory and the
6180
- * `~/.local/bin` install location to `$GITHUB_PATH` so every later step sees node, pnpm, and any
6181
- * other declared tool on PATH with no prefix.
6182
- */
6183
- const MISE_ENV_EXPORT_SCRIPT = [
6184
- "const fs = require(\"fs\");",
6185
- "const env = JSON.parse(fs.readFileSync(0, \"utf8\"));",
6186
- "let out = \"\";",
6187
- "for (const [key, value] of Object.entries(env)) {",
6188
- " if (key === \"PATH\") continue;",
6189
- " out += key + \"<<BST_MISE_ENV_EOF\\n\" + value + \"\\nBST_MISE_ENV_EOF\\n\";",
6190
- "}",
6191
- "fs.appendFileSync(process.env.GITHUB_ENV, out);",
6192
- "fs.appendFileSync(",
6193
- " process.env.GITHUB_PATH,",
6194
- " process.env.HOME + \"/.local/share/mise/shims\\n\" + process.env.HOME + \"/.local/bin\\n\",",
6195
- ");"
6196
- ].join("\n");
6197
- /** A fresh object per call: one shared object in two jobs would serialize as a YAML alias. */
6198
- function pnpmStoreCacheStep() {
6199
- return { step: {
6200
- name: "Cache pnpm store",
6201
- uses: "actions/cache@v4",
6202
- with: {
6203
- path: "~/.local/share/pnpm/store",
6204
- key: `pnpm-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('pnpm-lock.yaml')")}`
6205
- }
6206
- } };
6207
- }
6208
- /** Actions whose ref the generator pins itself, so `preservePinnedActions` must not keep a
6209
- * repo's older ref for them. */
6210
- const GENERATOR_OWNED_ACTIONS = [{
6211
- name: SETUP_MISE_ACTION_URL,
6212
- comment: SETUP_MISE_ACTION_TAG
6213
- }];
6214
- /**
6215
- * The steps every workflow generator uses in place of `pnpm/action-setup` + `actions/setup-node`:
6216
- * install the pinned `mise`, `mise install` the repo's `mise.toml` toolchain, export its env into
6217
- * the rest of the job, and cache the pnpm store. `MISE_VERSION`/`MISE_LINUX_X64_SHA256`
6218
- * (`mise-pin.ts`) are the same pin the agent runner's base image installs, checked by
6219
- * `runner-base-image-dockerfile.test.ts`.
6220
- *
6221
- * On Forgejo the mise half is the shared composite action in `bensandee/common`, pinned by commit.
6222
- * GitHub resolves actions only from github.com, so it keeps the same steps inline.
6223
- */
6224
- function miseSetupSteps(ci) {
6225
- if (ci !== "forgejo") return inlineMiseSteps();
6226
- return [{ step: {
6227
- name: "Set up mise",
6228
- uses: `${SETUP_MISE_ACTION_URL}@${SETUP_MISE_ACTION_SHA}`,
6229
- with: {
6230
- version: MISE_VERSION,
6231
- sha256: MISE_LINUX_X64_SHA256
6232
- }
6233
- } }, pnpmStoreCacheStep()];
6234
- }
6235
- /**
6236
- * The inline form of the shared action, for GitHub. Uses `mise env --json` piped into the
6237
- * just-installed mise's own `node -e`, not `mise env --shell bash`: the JSON form is
6238
- * shell-independent and the interpreter reading it is guaranteed to exist (mise just installed
6239
- * it), so this needs no assumption about which shell the runner's `run:` steps use.
6240
- */
6241
- function inlineMiseSteps() {
6242
- const installScript = [
6243
- "curl -fsSL -o \"$RUNNER_TEMP/mise\" " + MISE_RELEASE_URL,
6244
- `echo "${MISE_LINUX_X64_SHA256} $RUNNER_TEMP/mise" | sha256sum -c`,
6245
- "mkdir -p \"$HOME/.local/bin\" && install -m 0755 \"$RUNNER_TEMP/mise\" \"$HOME/.local/bin/mise\"",
6246
- "export MISE_TRUSTED_CONFIG_PATHS=\"$GITHUB_WORKSPACE\"",
6247
- "export MISE_YES=1",
6248
- "\"$HOME/.local/bin/mise\" install",
6249
- `"$HOME/.local/bin/mise" env --json | "$HOME/.local/bin/mise" exec -- node -e '${MISE_ENV_EXPORT_SCRIPT}'`
6250
- ].join("\n");
6251
- return [
6252
- { step: {
6253
- name: "Cache mise tools",
6254
- uses: "actions/cache@v4",
6255
- with: {
6256
- path: "~/.local/share/mise",
6257
- key: `mise-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('mise.toml')")}`
6258
- }
6259
- } },
6260
- pnpmStoreCacheStep(),
6261
- { step: {
6262
- name: "Install mise",
6263
- env: { GITHUB_TOKEN: "" },
6264
- run: installScript
6265
- } }
6266
- ];
6267
- }
6268
- /**
6269
- * Build the docker publish env block.
6270
- *
6271
- * The LHS env-var names (`DOCKER_USERNAME` / `DOCKER_PASSWORD`) match what
6272
- * publish-docker.ts reads at runtime. The RHS secret references are
6273
- * registry-specific (see {@link dockerSecretNames}) so downstream repos can
6274
- * stop relying on the user/org-level `DOCKER_USERNAME` / `DOCKER_PASSWORD`
6275
- * secrets -- which the Forgejo act fork auto-injects into every `container:`
6276
- * image pull regardless of the target registry.
6277
- *
6278
- * When `host` is `null` (couldn't be derived at gen time), the secret references
6279
- * fall back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` and the
6280
- * workflow keeps `DOCKER_REGISTRY_HOST` / `DOCKER_REGISTRY_NAMESPACE` so the CLI
6281
- * resolves the registry at runtime.
6282
- */
6283
- function dockerPublishEnv(host) {
6284
- const { username, password } = dockerSecretNames(host);
6285
- return {
6286
- ...host === null ? {
6287
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
6288
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
6289
- } : {},
6290
- DOCKER_USERNAME: actionsExpr(`secrets.${username}`),
6291
- DOCKER_PASSWORD: actionsExpr(`secrets.${password}`),
6292
- RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
6293
- };
6294
- }
6295
- //#endregion
6296
- //#region src/generators/publish-ci.ts
6297
- const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
6298
- /**
6299
- * Resolve the docker-registry host at gen time so the workflow generator can
6300
- * pick registry-specific secret names. Honors `.tooling.json#registryHost`
6301
- * first, then git remote `origin` (the operational host, not a mirror-pointed
6302
- * package.json), then derives from `package.json#repository` using the same
6303
- * parser as the runtime (`parseGitUrl` handles https, git+https, and ssh shapes).
6304
- * Returns null if neither source yields a host -- in that case the generator
6305
- * falls back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` secrets and
6306
- * lets the runtime resolve the host from `DOCKER_REGISTRY_HOST` (still emitted
6307
- * into the step).
6308
- */
6309
- function resolveGenTimeHost(ctx) {
6310
- const toolingRaw = ctx.read(".tooling.json");
6311
- if (toolingRaw) try {
6312
- const parsed = RegistryHostSchema.safeParse(JSON.parse(toolingRaw));
6313
- if (parsed.success && parsed.data.registryHost && parsed.data.registryHost.length > 0) return parsed.data.registryHost;
6314
- } catch (error) {
6315
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6316
- }
6317
- const originUrl = detectGitRemoteUrl(ctx.targetDir);
6318
- if (originUrl !== null) {
6319
- const parsedOrigin = parseGitUrl(originUrl);
6320
- if (parsedOrigin) return registryHostFromHostname(parsedOrigin.hostname);
6321
- }
6322
- const raw = ctx.read("package.json");
6323
- if (!raw) return null;
6324
- const repo = parsePackageJson(raw)?.repository;
6325
- let urlStr = null;
6326
- if (typeof repo === "string") urlStr = repo;
6327
- else if (repo && typeof repo === "object") urlStr = repo.url;
6328
- if (typeof urlStr !== "string") return null;
6329
- const parsed = parseGitUrl(urlStr);
6330
- if (!parsed) return null;
6331
- return registryHostFromHostname(parsed.hostname);
6332
- }
6333
- function publishSteps(ci, host) {
6334
- return [
6335
- { step: { uses: "actions/checkout@v6" } },
6336
- ...miseSetupSteps(ci),
6337
- { step: { run: "pnpm install --frozen-lockfile" } },
6338
- { step: {
6339
- name: "Publish Docker images",
6340
- env: dockerPublishEnv(host),
6341
- run: "pnpm exec bst docker:publish"
6342
- } }
6343
- ];
6344
- }
6345
- const PostReleaseHookSchema = z.object({
6346
- postReleaseHook: z.string().optional(),
6347
- postReleaseHookSecrets: z.array(z.string()).optional()
6348
- });
6349
- const ReleaseAssetsSchema = z.object({ releaseAssets: z.array(ReleaseAssetSchema).optional() });
6350
- const VersionStampConfigSchema = z.object({ versionStamp: z.array(VersionStampSchema).optional() });
6351
- const DeploySchema = z.object({ deploy: DeployConfigSchema.optional() });
6352
- /**
6353
- * Read the `deploy` block from .tooling.json, if configured.
6354
- *
6355
- * The full file is already validated against ToolingConfigSchema at sync time,
6356
- * so an invalid `deploy` block fails earlier -- undefined here means "not set".
6357
- */
6358
- function getDeployConfig(ctx) {
6359
- const raw = ctx.read(".tooling.json");
6360
- if (!raw) return void 0;
6361
- try {
6362
- const result = DeploySchema.safeParse(JSON.parse(raw));
6363
- if (!result.success) return void 0;
6364
- return result.data.deploy;
6365
- } catch (error) {
6366
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6367
- return;
6368
- }
6369
- }
6370
- /** Read the postReleaseHook command override from .tooling.json, if configured. */
6371
- function getPostReleaseHookCommand(ctx) {
6372
- const raw = ctx.read(".tooling.json");
6373
- if (!raw) return void 0;
6374
- try {
6375
- const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6376
- if (!result.success) return void 0;
6377
- return result.data.postReleaseHook;
6378
- } catch (error) {
6379
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6380
- return;
6381
- }
6382
- }
6383
- /** Read the postReleaseHookSecrets array from .tooling.json, if configured. */
6384
- function getPostReleaseHookSecrets(ctx) {
6385
- const raw = ctx.read(".tooling.json");
6386
- if (!raw) return void 0;
6387
- try {
6388
- const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6389
- if (!result.success) return void 0;
6390
- return result.data.postReleaseHookSecrets;
6391
- } catch (error) {
6392
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6393
- return;
6057
+ /** Read the postReleaseHook command override from .tooling.json, if configured. */
6058
+ function getPostReleaseHookCommand(ctx) {
6059
+ const raw = ctx.read(".tooling.json");
6060
+ if (!raw) return void 0;
6061
+ try {
6062
+ const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6063
+ if (!result.success) return void 0;
6064
+ return result.data.postReleaseHook;
6065
+ } catch (error) {
6066
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6067
+ return;
6068
+ }
6069
+ }
6070
+ /** Read the postReleaseHookSecrets array from .tooling.json, if configured. */
6071
+ function getPostReleaseHookSecrets(ctx) {
6072
+ const raw = ctx.read(".tooling.json");
6073
+ if (!raw) return void 0;
6074
+ try {
6075
+ const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6076
+ if (!result.success) return void 0;
6077
+ return result.data.postReleaseHookSecrets;
6078
+ } catch (error) {
6079
+ log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6080
+ return;
6394
6081
  }
6395
6082
  }
6396
6083
  /**
@@ -6462,61 +6149,37 @@ function hasVersionStamp(ctx) {
6462
6149
  }
6463
6150
  /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
6464
6151
  const ARCHIVE_DIR$2 = ".tooling-archived";
6152
+ /**
6153
+ * Archive the manual `publish.yml` this generator used to write; it writes nothing. Docker
6154
+ * images publish from the release workflow, and the manual workflow was a thin wrapper around
6155
+ * `bst docker:publish` that nobody ran -- run that command instead. Only a file carrying the
6156
+ * managed header is removed: a hand-written `publish.yml`, or one opted out with the ignore
6157
+ * comment, belongs to the repo.
6158
+ */
6465
6159
  async function generateDeployCi(ctx) {
6466
6160
  const filePath = "deploy-ci";
6467
- if (!ctx.config.publishDocker || ctx.config.ci === "none") return {
6161
+ if (ctx.config.ci === "none") return {
6468
6162
  filePath,
6469
6163
  action: "skipped",
6470
- description: "Deploy CI workflow not applicable"
6164
+ description: "Publish workflow not applicable"
6471
6165
  };
6472
- const isGitHub = ctx.config.ci === "github";
6473
- const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
6474
- if (ctx.config.releaseStrategy === "simple") {
6475
- if (ctx.read(workflowPath) !== void 0) {
6476
- ctx.remove(workflowPath);
6477
- return {
6478
- filePath: workflowPath,
6479
- action: "archived",
6480
- description: `Moved to ${ARCHIVE_DIR$2}/${workflowPath} (docker publish runs in release.yml for simple strategy)`
6481
- };
6482
- }
6483
- return {
6484
- filePath,
6485
- action: "skipped",
6486
- description: "Publish workflow not generated for simple release strategy"
6487
- };
6488
- }
6489
- const host = resolveGenTimeHost(ctx);
6490
- const steps = publishSteps(ctx.config.ci, host);
6491
- const content = buildWorkflowYaml({
6492
- ci: ctx.config.ci,
6493
- name: "Publish",
6494
- on: { workflow_dispatch: null },
6495
- jobName: "publish",
6496
- steps
6497
- });
6498
- const alreadyExists = ctx.exists(workflowPath);
6499
- const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
6500
- const withDevPath = applyWorkspaceToolingBuild(content, ctx.packageJson);
6501
- if (existing) {
6502
- if (isToolingIgnored(existing)) return {
6503
- filePath: workflowPath,
6504
- action: "skipped",
6505
- description: "Publish workflow has ignore comment"
6506
- };
6507
- if (existing.includes("Managed by @bensandee/tooling --") && normalizeWorkflow(existing) === normalizeWorkflow(withDevPath)) return {
6508
- filePath: workflowPath,
6509
- action: "skipped",
6510
- description: "Publish workflow already up to date"
6511
- };
6512
- }
6513
- const withPinnedActions = preservePinnedActions(withDevPath, existing, GENERATOR_OWNED_ACTIONS);
6514
- const final = existing ? insertCustomBlocks(withPinnedActions, extractCustomBlocks(existing)) : withPinnedActions;
6515
- ctx.write(workflowPath, final);
6166
+ const workflowPath = ctx.config.ci === "github" ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
6167
+ const existing = ctx.read(workflowPath);
6168
+ if (existing === void 0) return {
6169
+ filePath,
6170
+ action: "skipped",
6171
+ description: "No publish workflow to retire"
6172
+ };
6173
+ if (isToolingIgnored(existing) || !existing.includes("Managed by @bensandee/tooling --")) return {
6174
+ filePath: workflowPath,
6175
+ action: "skipped",
6176
+ description: "Publish workflow is not managed by tooling"
6177
+ };
6178
+ ctx.remove(workflowPath);
6516
6179
  return {
6517
6180
  filePath: workflowPath,
6518
- action: alreadyExists ? "updated" : "created",
6519
- description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions publish workflow`
6181
+ action: "removed",
6182
+ description: `Moved to ${ARCHIVE_DIR$2}/${workflowPath} (run \`bst docker:publish\` to publish by hand)`
6520
6183
  };
6521
6184
  }
6522
6185
  //#endregion
@@ -6911,60 +6574,60 @@ function perPackageDevDeps(config) {
6911
6574
  deps["typescript"] = {
6912
6575
  "@changesets/cli": "3.0.3",
6913
6576
  "@release-it/bumper": "8.0.1",
6914
- "@types/node": "24.13.5",
6915
- "@vitest/coverage-v8": "5.0.1",
6577
+ "@types/node": "24.19.1",
6578
+ "@vitest/coverage-v8": "5.0.3",
6916
6579
  "commit-and-tag-version": "13.2.1",
6917
- "knip": "6.38.0",
6918
- "lefthook": "2.1.14",
6580
+ "knip": "6.39.0",
6581
+ "lefthook": "2.1.16",
6919
6582
  "modern-web-guidance": "0.0.191",
6920
6583
  "oxfmt": "0.71.0",
6921
- "oxlint": "1.85.0",
6584
+ "oxlint": "1.86.0",
6922
6585
  "oxlint-tsgolint": "7.0.2003",
6923
6586
  "prettier": "3.9.9",
6924
6587
  "release-it": "21.1.0",
6925
6588
  "tsdown": "0.23.0",
6926
6589
  "typescript": "6.0.3",
6927
- "vitest": "5.0.1",
6928
- "pnpm": "11.27.1"
6590
+ "vitest": "5.0.3",
6591
+ "pnpm": "11.28.3"
6929
6592
  }["typescript"];
6930
6593
  deps["@types/node"] = {
6931
6594
  "@changesets/cli": "3.0.3",
6932
6595
  "@release-it/bumper": "8.0.1",
6933
- "@types/node": "24.13.5",
6934
- "@vitest/coverage-v8": "5.0.1",
6596
+ "@types/node": "24.19.1",
6597
+ "@vitest/coverage-v8": "5.0.3",
6935
6598
  "commit-and-tag-version": "13.2.1",
6936
- "knip": "6.38.0",
6937
- "lefthook": "2.1.14",
6599
+ "knip": "6.39.0",
6600
+ "lefthook": "2.1.16",
6938
6601
  "modern-web-guidance": "0.0.191",
6939
6602
  "oxfmt": "0.71.0",
6940
- "oxlint": "1.85.0",
6603
+ "oxlint": "1.86.0",
6941
6604
  "oxlint-tsgolint": "7.0.2003",
6942
6605
  "prettier": "3.9.9",
6943
6606
  "release-it": "21.1.0",
6944
6607
  "tsdown": "0.23.0",
6945
6608
  "typescript": "6.0.3",
6946
- "vitest": "5.0.1",
6947
- "pnpm": "11.27.1"
6609
+ "vitest": "5.0.3",
6610
+ "pnpm": "11.28.3"
6948
6611
  }["@types/node"];
6949
6612
  }
6950
6613
  if (config.builder === "tsdown") deps["tsdown"] = {
6951
6614
  "@changesets/cli": "3.0.3",
6952
6615
  "@release-it/bumper": "8.0.1",
6953
- "@types/node": "24.13.5",
6954
- "@vitest/coverage-v8": "5.0.1",
6616
+ "@types/node": "24.19.1",
6617
+ "@vitest/coverage-v8": "5.0.3",
6955
6618
  "commit-and-tag-version": "13.2.1",
6956
- "knip": "6.38.0",
6957
- "lefthook": "2.1.14",
6619
+ "knip": "6.39.0",
6620
+ "lefthook": "2.1.16",
6958
6621
  "modern-web-guidance": "0.0.191",
6959
6622
  "oxfmt": "0.71.0",
6960
- "oxlint": "1.85.0",
6623
+ "oxlint": "1.86.0",
6961
6624
  "oxlint-tsgolint": "7.0.2003",
6962
6625
  "prettier": "3.9.9",
6963
6626
  "release-it": "21.1.0",
6964
6627
  "tsdown": "0.23.0",
6965
6628
  "typescript": "6.0.3",
6966
- "vitest": "5.0.1",
6967
- "pnpm": "11.27.1"
6629
+ "vitest": "5.0.3",
6630
+ "pnpm": "11.28.3"
6968
6631
  }["tsdown"];
6969
6632
  return deps;
6970
6633
  }
@@ -6981,40 +6644,40 @@ function vitestDevDeps() {
6981
6644
  vitest: {
6982
6645
  "@changesets/cli": "3.0.3",
6983
6646
  "@release-it/bumper": "8.0.1",
6984
- "@types/node": "24.13.5",
6985
- "@vitest/coverage-v8": "5.0.1",
6647
+ "@types/node": "24.19.1",
6648
+ "@vitest/coverage-v8": "5.0.3",
6986
6649
  "commit-and-tag-version": "13.2.1",
6987
- "knip": "6.38.0",
6988
- "lefthook": "2.1.14",
6650
+ "knip": "6.39.0",
6651
+ "lefthook": "2.1.16",
6989
6652
  "modern-web-guidance": "0.0.191",
6990
6653
  "oxfmt": "0.71.0",
6991
- "oxlint": "1.85.0",
6654
+ "oxlint": "1.86.0",
6992
6655
  "oxlint-tsgolint": "7.0.2003",
6993
6656
  "prettier": "3.9.9",
6994
6657
  "release-it": "21.1.0",
6995
6658
  "tsdown": "0.23.0",
6996
6659
  "typescript": "6.0.3",
6997
- "vitest": "5.0.1",
6998
- "pnpm": "11.27.1"
6660
+ "vitest": "5.0.3",
6661
+ "pnpm": "11.28.3"
6999
6662
  }["vitest"],
7000
6663
  "@vitest/coverage-v8": {
7001
6664
  "@changesets/cli": "3.0.3",
7002
6665
  "@release-it/bumper": "8.0.1",
7003
- "@types/node": "24.13.5",
7004
- "@vitest/coverage-v8": "5.0.1",
6666
+ "@types/node": "24.19.1",
6667
+ "@vitest/coverage-v8": "5.0.3",
7005
6668
  "commit-and-tag-version": "13.2.1",
7006
- "knip": "6.38.0",
7007
- "lefthook": "2.1.14",
6669
+ "knip": "6.39.0",
6670
+ "lefthook": "2.1.16",
7008
6671
  "modern-web-guidance": "0.0.191",
7009
6672
  "oxfmt": "0.71.0",
7010
- "oxlint": "1.85.0",
6673
+ "oxlint": "1.86.0",
7011
6674
  "oxlint-tsgolint": "7.0.2003",
7012
6675
  "prettier": "3.9.9",
7013
6676
  "release-it": "21.1.0",
7014
6677
  "tsdown": "0.23.0",
7015
6678
  "typescript": "6.0.3",
7016
- "vitest": "5.0.1",
7017
- "pnpm": "11.27.1"
6679
+ "vitest": "5.0.3",
6680
+ "pnpm": "11.28.3"
7018
6681
  }["@vitest/coverage-v8"]
7019
6682
  };
7020
6683
  }
@@ -7028,81 +6691,81 @@ function rootDevDeps(config) {
7028
6691
  const deps = { lefthook: {
7029
6692
  "@changesets/cli": "3.0.3",
7030
6693
  "@release-it/bumper": "8.0.1",
7031
- "@types/node": "24.13.5",
7032
- "@vitest/coverage-v8": "5.0.1",
6694
+ "@types/node": "24.19.1",
6695
+ "@vitest/coverage-v8": "5.0.3",
7033
6696
  "commit-and-tag-version": "13.2.1",
7034
- "knip": "6.38.0",
7035
- "lefthook": "2.1.14",
6697
+ "knip": "6.39.0",
6698
+ "lefthook": "2.1.16",
7036
6699
  "modern-web-guidance": "0.0.191",
7037
6700
  "oxfmt": "0.71.0",
7038
- "oxlint": "1.85.0",
6701
+ "oxlint": "1.86.0",
7039
6702
  "oxlint-tsgolint": "7.0.2003",
7040
6703
  "prettier": "3.9.9",
7041
6704
  "release-it": "21.1.0",
7042
6705
  "tsdown": "0.23.0",
7043
6706
  "typescript": "6.0.3",
7044
- "vitest": "5.0.1",
7045
- "pnpm": "11.27.1"
6707
+ "vitest": "5.0.3",
6708
+ "pnpm": "11.28.3"
7046
6709
  }["lefthook"] };
7047
6710
  if (config.stack !== "none") {
7048
6711
  deps["knip"] = {
7049
6712
  "@changesets/cli": "3.0.3",
7050
6713
  "@release-it/bumper": "8.0.1",
7051
- "@types/node": "24.13.5",
7052
- "@vitest/coverage-v8": "5.0.1",
6714
+ "@types/node": "24.19.1",
6715
+ "@vitest/coverage-v8": "5.0.3",
7053
6716
  "commit-and-tag-version": "13.2.1",
7054
- "knip": "6.38.0",
7055
- "lefthook": "2.1.14",
6717
+ "knip": "6.39.0",
6718
+ "lefthook": "2.1.16",
7056
6719
  "modern-web-guidance": "0.0.191",
7057
6720
  "oxfmt": "0.71.0",
7058
- "oxlint": "1.85.0",
6721
+ "oxlint": "1.86.0",
7059
6722
  "oxlint-tsgolint": "7.0.2003",
7060
6723
  "prettier": "3.9.9",
7061
6724
  "release-it": "21.1.0",
7062
6725
  "tsdown": "0.23.0",
7063
6726
  "typescript": "6.0.3",
7064
- "vitest": "5.0.1",
7065
- "pnpm": "11.27.1"
6727
+ "vitest": "5.0.3",
6728
+ "pnpm": "11.28.3"
7066
6729
  }["knip"];
7067
6730
  deps["oxlint"] = {
7068
6731
  "@changesets/cli": "3.0.3",
7069
6732
  "@release-it/bumper": "8.0.1",
7070
- "@types/node": "24.13.5",
7071
- "@vitest/coverage-v8": "5.0.1",
6733
+ "@types/node": "24.19.1",
6734
+ "@vitest/coverage-v8": "5.0.3",
7072
6735
  "commit-and-tag-version": "13.2.1",
7073
- "knip": "6.38.0",
7074
- "lefthook": "2.1.14",
6736
+ "knip": "6.39.0",
6737
+ "lefthook": "2.1.16",
7075
6738
  "modern-web-guidance": "0.0.191",
7076
6739
  "oxfmt": "0.71.0",
7077
- "oxlint": "1.85.0",
6740
+ "oxlint": "1.86.0",
7078
6741
  "oxlint-tsgolint": "7.0.2003",
7079
6742
  "prettier": "3.9.9",
7080
6743
  "release-it": "21.1.0",
7081
6744
  "tsdown": "0.23.0",
7082
6745
  "typescript": "6.0.3",
7083
- "vitest": "5.0.1",
7084
- "pnpm": "11.27.1"
6746
+ "vitest": "5.0.3",
6747
+ "pnpm": "11.28.3"
7085
6748
  }["oxlint"];
7086
6749
  Object.assign(deps, vitestDevDeps());
7087
6750
  }
7088
6751
  if (config.stack === "typescript") deps["oxlint-tsgolint"] = {
7089
6752
  "@changesets/cli": "3.0.3",
7090
6753
  "@release-it/bumper": "8.0.1",
7091
- "@types/node": "24.13.5",
7092
- "@vitest/coverage-v8": "5.0.1",
6754
+ "@types/node": "24.19.1",
6755
+ "@vitest/coverage-v8": "5.0.3",
7093
6756
  "commit-and-tag-version": "13.2.1",
7094
- "knip": "6.38.0",
7095
- "lefthook": "2.1.14",
6757
+ "knip": "6.39.0",
6758
+ "lefthook": "2.1.16",
7096
6759
  "modern-web-guidance": "0.0.191",
7097
6760
  "oxfmt": "0.71.0",
7098
- "oxlint": "1.85.0",
6761
+ "oxlint": "1.86.0",
7099
6762
  "oxlint-tsgolint": "7.0.2003",
7100
6763
  "prettier": "3.9.9",
7101
6764
  "release-it": "21.1.0",
7102
6765
  "tsdown": "0.23.0",
7103
6766
  "typescript": "6.0.3",
7104
- "vitest": "5.0.1",
7105
- "pnpm": "11.27.1"
6767
+ "vitest": "5.0.3",
6768
+ "pnpm": "11.28.3"
7106
6769
  }["oxlint-tsgolint"];
7107
6770
  return deps;
7108
6771
  }
@@ -7133,81 +6796,81 @@ function addReleaseDeps(deps, config) {
7133
6796
  deps["release-it"] = {
7134
6797
  "@changesets/cli": "3.0.3",
7135
6798
  "@release-it/bumper": "8.0.1",
7136
- "@types/node": "24.13.5",
7137
- "@vitest/coverage-v8": "5.0.1",
6799
+ "@types/node": "24.19.1",
6800
+ "@vitest/coverage-v8": "5.0.3",
7138
6801
  "commit-and-tag-version": "13.2.1",
7139
- "knip": "6.38.0",
7140
- "lefthook": "2.1.14",
6802
+ "knip": "6.39.0",
6803
+ "lefthook": "2.1.16",
7141
6804
  "modern-web-guidance": "0.0.191",
7142
6805
  "oxfmt": "0.71.0",
7143
- "oxlint": "1.85.0",
6806
+ "oxlint": "1.86.0",
7144
6807
  "oxlint-tsgolint": "7.0.2003",
7145
6808
  "prettier": "3.9.9",
7146
6809
  "release-it": "21.1.0",
7147
6810
  "tsdown": "0.23.0",
7148
6811
  "typescript": "6.0.3",
7149
- "vitest": "5.0.1",
7150
- "pnpm": "11.27.1"
6812
+ "vitest": "5.0.3",
6813
+ "pnpm": "11.28.3"
7151
6814
  }["release-it"];
7152
6815
  if (config.structure === "monorepo") deps["@release-it/bumper"] = {
7153
6816
  "@changesets/cli": "3.0.3",
7154
6817
  "@release-it/bumper": "8.0.1",
7155
- "@types/node": "24.13.5",
7156
- "@vitest/coverage-v8": "5.0.1",
6818
+ "@types/node": "24.19.1",
6819
+ "@vitest/coverage-v8": "5.0.3",
7157
6820
  "commit-and-tag-version": "13.2.1",
7158
- "knip": "6.38.0",
7159
- "lefthook": "2.1.14",
6821
+ "knip": "6.39.0",
6822
+ "lefthook": "2.1.16",
7160
6823
  "modern-web-guidance": "0.0.191",
7161
6824
  "oxfmt": "0.71.0",
7162
- "oxlint": "1.85.0",
6825
+ "oxlint": "1.86.0",
7163
6826
  "oxlint-tsgolint": "7.0.2003",
7164
6827
  "prettier": "3.9.9",
7165
6828
  "release-it": "21.1.0",
7166
6829
  "tsdown": "0.23.0",
7167
6830
  "typescript": "6.0.3",
7168
- "vitest": "5.0.1",
7169
- "pnpm": "11.27.1"
6831
+ "vitest": "5.0.3",
6832
+ "pnpm": "11.28.3"
7170
6833
  }["@release-it/bumper"];
7171
6834
  break;
7172
6835
  case "simple":
7173
6836
  deps["commit-and-tag-version"] = {
7174
6837
  "@changesets/cli": "3.0.3",
7175
6838
  "@release-it/bumper": "8.0.1",
7176
- "@types/node": "24.13.5",
7177
- "@vitest/coverage-v8": "5.0.1",
6839
+ "@types/node": "24.19.1",
6840
+ "@vitest/coverage-v8": "5.0.3",
7178
6841
  "commit-and-tag-version": "13.2.1",
7179
- "knip": "6.38.0",
7180
- "lefthook": "2.1.14",
6842
+ "knip": "6.39.0",
6843
+ "lefthook": "2.1.16",
7181
6844
  "modern-web-guidance": "0.0.191",
7182
6845
  "oxfmt": "0.71.0",
7183
- "oxlint": "1.85.0",
6846
+ "oxlint": "1.86.0",
7184
6847
  "oxlint-tsgolint": "7.0.2003",
7185
6848
  "prettier": "3.9.9",
7186
6849
  "release-it": "21.1.0",
7187
6850
  "tsdown": "0.23.0",
7188
6851
  "typescript": "6.0.3",
7189
- "vitest": "5.0.1",
7190
- "pnpm": "11.27.1"
6852
+ "vitest": "5.0.3",
6853
+ "pnpm": "11.28.3"
7191
6854
  }["commit-and-tag-version"];
7192
6855
  break;
7193
6856
  case "changesets": deps["@changesets/cli"] = {
7194
6857
  "@changesets/cli": "3.0.3",
7195
6858
  "@release-it/bumper": "8.0.1",
7196
- "@types/node": "24.13.5",
7197
- "@vitest/coverage-v8": "5.0.1",
6859
+ "@types/node": "24.19.1",
6860
+ "@vitest/coverage-v8": "5.0.3",
7198
6861
  "commit-and-tag-version": "13.2.1",
7199
- "knip": "6.38.0",
7200
- "lefthook": "2.1.14",
6862
+ "knip": "6.39.0",
6863
+ "lefthook": "2.1.16",
7201
6864
  "modern-web-guidance": "0.0.191",
7202
6865
  "oxfmt": "0.71.0",
7203
- "oxlint": "1.85.0",
6866
+ "oxlint": "1.86.0",
7204
6867
  "oxlint-tsgolint": "7.0.2003",
7205
6868
  "prettier": "3.9.9",
7206
6869
  "release-it": "21.1.0",
7207
6870
  "tsdown": "0.23.0",
7208
6871
  "typescript": "6.0.3",
7209
- "vitest": "5.0.1",
7210
- "pnpm": "11.27.1"
6872
+ "vitest": "5.0.3",
6873
+ "pnpm": "11.28.3"
7211
6874
  }["@changesets/cli"];
7212
6875
  }
7213
6876
  }
@@ -7401,21 +7064,21 @@ function applyMetadataDefaults(pkg, ctx, changes) {
7401
7064
  const pinnedPnpm = {
7402
7065
  "@changesets/cli": "3.0.3",
7403
7066
  "@release-it/bumper": "8.0.1",
7404
- "@types/node": "24.13.5",
7405
- "@vitest/coverage-v8": "5.0.1",
7067
+ "@types/node": "24.19.1",
7068
+ "@vitest/coverage-v8": "5.0.3",
7406
7069
  "commit-and-tag-version": "13.2.1",
7407
- "knip": "6.38.0",
7408
- "lefthook": "2.1.14",
7070
+ "knip": "6.39.0",
7071
+ "lefthook": "2.1.16",
7409
7072
  "modern-web-guidance": "0.0.191",
7410
7073
  "oxfmt": "0.71.0",
7411
- "oxlint": "1.85.0",
7074
+ "oxlint": "1.86.0",
7412
7075
  "oxlint-tsgolint": "7.0.2003",
7413
7076
  "prettier": "3.9.9",
7414
7077
  "release-it": "21.1.0",
7415
7078
  "tsdown": "0.23.0",
7416
7079
  "typescript": "6.0.3",
7417
- "vitest": "5.0.1",
7418
- "pnpm": "11.27.1"
7080
+ "vitest": "5.0.3",
7081
+ "pnpm": "11.28.3"
7419
7082
  }["pnpm"];
7420
7083
  const desiredPm = pinnedPnpm ? `pnpm@${pinnedPnpm}` : void 0;
7421
7084
  if (desiredPm) {
@@ -7456,45 +7119,45 @@ function buildManagedDevDeps(ctx) {
7456
7119
  const devDeps = { ...rootDevDeps(ctx.config) };
7457
7120
  if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
7458
7121
  if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.14.5";
7459
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.86.0";
7460
- if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.15.2";
7122
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.87.0";
7123
+ if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.16.0";
7461
7124
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
7462
7125
  "@changesets/cli": "3.0.3",
7463
7126
  "@release-it/bumper": "8.0.1",
7464
- "@types/node": "24.13.5",
7465
- "@vitest/coverage-v8": "5.0.1",
7127
+ "@types/node": "24.19.1",
7128
+ "@vitest/coverage-v8": "5.0.3",
7466
7129
  "commit-and-tag-version": "13.2.1",
7467
- "knip": "6.38.0",
7468
- "lefthook": "2.1.14",
7130
+ "knip": "6.39.0",
7131
+ "lefthook": "2.1.16",
7469
7132
  "modern-web-guidance": "0.0.191",
7470
7133
  "oxfmt": "0.71.0",
7471
- "oxlint": "1.85.0",
7134
+ "oxlint": "1.86.0",
7472
7135
  "oxlint-tsgolint": "7.0.2003",
7473
7136
  "prettier": "3.9.9",
7474
7137
  "release-it": "21.1.0",
7475
7138
  "tsdown": "0.23.0",
7476
7139
  "typescript": "6.0.3",
7477
- "vitest": "5.0.1",
7478
- "pnpm": "11.27.1"
7140
+ "vitest": "5.0.3",
7141
+ "pnpm": "11.28.3"
7479
7142
  }["oxfmt"];
7480
7143
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = {
7481
7144
  "@changesets/cli": "3.0.3",
7482
7145
  "@release-it/bumper": "8.0.1",
7483
- "@types/node": "24.13.5",
7484
- "@vitest/coverage-v8": "5.0.1",
7146
+ "@types/node": "24.19.1",
7147
+ "@vitest/coverage-v8": "5.0.3",
7485
7148
  "commit-and-tag-version": "13.2.1",
7486
- "knip": "6.38.0",
7487
- "lefthook": "2.1.14",
7149
+ "knip": "6.39.0",
7150
+ "lefthook": "2.1.16",
7488
7151
  "modern-web-guidance": "0.0.191",
7489
7152
  "oxfmt": "0.71.0",
7490
- "oxlint": "1.85.0",
7153
+ "oxlint": "1.86.0",
7491
7154
  "oxlint-tsgolint": "7.0.2003",
7492
7155
  "prettier": "3.9.9",
7493
7156
  "release-it": "21.1.0",
7494
7157
  "tsdown": "0.23.0",
7495
7158
  "typescript": "6.0.3",
7496
- "vitest": "5.0.1",
7497
- "pnpm": "11.27.1"
7159
+ "vitest": "5.0.3",
7160
+ "pnpm": "11.28.3"
7498
7161
  }["prettier"];
7499
7162
  addReleaseDeps(devDeps, ctx.config);
7500
7163
  return devDeps;
@@ -7550,21 +7213,21 @@ async function generatePackageJson(ctx) {
7550
7213
  packageManager: `pnpm@${{
7551
7214
  "@changesets/cli": "3.0.3",
7552
7215
  "@release-it/bumper": "8.0.1",
7553
- "@types/node": "24.13.5",
7554
- "@vitest/coverage-v8": "5.0.1",
7216
+ "@types/node": "24.19.1",
7217
+ "@vitest/coverage-v8": "5.0.3",
7555
7218
  "commit-and-tag-version": "13.2.1",
7556
- "knip": "6.38.0",
7557
- "lefthook": "2.1.14",
7219
+ "knip": "6.39.0",
7220
+ "lefthook": "2.1.16",
7558
7221
  "modern-web-guidance": "0.0.191",
7559
7222
  "oxfmt": "0.71.0",
7560
- "oxlint": "1.85.0",
7223
+ "oxlint": "1.86.0",
7561
7224
  "oxlint-tsgolint": "7.0.2003",
7562
7225
  "prettier": "3.9.9",
7563
7226
  "release-it": "21.1.0",
7564
7227
  "tsdown": "0.23.0",
7565
7228
  "typescript": "6.0.3",
7566
- "vitest": "5.0.1",
7567
- "pnpm": "11.27.1"
7229
+ "vitest": "5.0.3",
7230
+ "pnpm": "11.28.3"
7568
7231
  }["pnpm"]}`
7569
7232
  };
7570
7233
  ctx.write(filePath, JSON.stringify(pkg, null, 2) + "\n");
@@ -8382,6 +8045,17 @@ const CONFIG_WITH_LINT_RULES = `import recommended from "@bensandee/config/oxlin
8382
8045
  import bensandee from "@bensandee/eslint-plugin/recommended";
8383
8046
  import { defineConfig } from "oxlint";
8384
8047
 
8048
+ export default defineConfig({
8049
+ extends: [recommended],
8050
+ jsPlugins: bensandee.jsPlugins ?? [],
8051
+ rules: bensandee.rules ?? {},
8052
+ ignorePatterns: [${IGNORE_PATTERNS_SPREAD}],
8053
+ });
8054
+ `;
8055
+ const LEGACY_CONFIG_WITH_LINT_RULES = `import recommended from "@bensandee/config/oxlint/recommended";
8056
+ import bensandee from "@bensandee/eslint-plugin/recommended";
8057
+ import { defineConfig } from "oxlint";
8058
+
8385
8059
  // Report phase: every plugin rule runs and reports, none blocks. The severities are derived
8386
8060
  // from the plugin's own rule names rather than listed, so a rule the plugin adds arrives the
8387
8061
  // day it publishes -- enumerating them is how the plugin came to be installed and never
@@ -8414,7 +8088,11 @@ function addIgnorePatterns(content) {
8414
8088
  return content.slice(0, insertPos) + ` ignorePatterns: [${IGNORE_PATTERNS_SPREAD}],\n` + content.slice(insertPos);
8415
8089
  }
8416
8090
  const PLUGIN_IMPORT = "import bensandee from \"@bensandee/eslint-plugin/recommended\";\n";
8417
- const REPORT_SPREAD = " ...Object.fromEntries(Object.keys(bensandee.rules ?? {}).map((name) => [name, \"warn\"])),\n";
8091
+ const TIER_SPREAD = " ...bensandee.rules,\n";
8092
+ const LEGACY_REPORT_SPREAD = " ...Object.fromEntries(Object.keys(bensandee.rules ?? {}).map((name) => [name, \"warn\"])),\n";
8093
+ /** The text every report-phase severity map carries, whatever its shape -- in either quote
8094
+ * style, since the merged spread went through the repo's own formatter. */
8095
+ const REPORT_PHASE_MARKER = /\[name, (["'])warn\1\]/;
8418
8096
  const RULES_KEY = /^ {2}"?rules"?: \{/m;
8419
8097
  const RULES_KEY_INLINE = /^ {2}"?rules"?\s*[,:]/m;
8420
8098
  const JS_PLUGINS_KEY = /^ {2}"?jsPlugins"?: \[/m;
@@ -8461,28 +8139,29 @@ function addBensandeePlugin(content) {
8461
8139
  const rulesKey = RULES_KEY.exec(rulesRegion);
8462
8140
  if (rulesKey) {
8463
8141
  const at = insertPos + rulesKey.index + rulesKey[0].length;
8464
- return withPlugins.slice(0, at) + `\n${REPORT_SPREAD}` + withPlugins.slice(at);
8142
+ return withPlugins.slice(0, at) + `\n${TIER_SPREAD}` + withPlugins.slice(at);
8465
8143
  }
8466
8144
  if (RULES_KEY_INLINE.test(rulesRegion)) return UNRECOGNIZED_SHAPE;
8467
- return withPlugins.slice(0, afterJsPlugins) + ` rules: {\n${REPORT_SPREAD} },\n` + withPlugins.slice(afterJsPlugins);
8468
- }
8469
- /** The exact literal `addBensandeePlugin` splices in when it creates a `rules` key from
8470
- * scratch -- checked before {@link REPORT_SPREAD}'s own merged-in-place form below, since that
8471
- * one is a substring of this one and would otherwise match first and leave the empty `rules: {
8472
- * },` shell behind. */
8473
- const CREATED_RULES_BLOCK = ` rules: {\n${REPORT_SPREAD} },\n`;
8145
+ return withPlugins.slice(0, afterJsPlugins) + ` rules: {\n${TIER_SPREAD} },\n` + withPlugins.slice(afterJsPlugins);
8146
+ }
8147
+ /** The exact literals `addBensandeePlugin` splices in when it creates a `rules` key from
8148
+ * scratch, current and legacy -- checked before the merged-in-place spreads below, since each
8149
+ * spread is a substring of its block and would otherwise match first and leave the empty `rules:
8150
+ * { },` shell behind. */
8151
+ const CREATED_RULES_BLOCKS = [` rules: {\n${TIER_SPREAD} },\n`, ` rules: {\n${LEGACY_REPORT_SPREAD} },\n`];
8152
+ const MERGED_RULES_SPREADS = [`\n${TIER_SPREAD}`, `\n${LEGACY_REPORT_SPREAD}`];
8474
8153
  const CREATED_JS_PLUGINS_LINE = " jsPlugins: bensandee.jsPlugins ?? [],\n";
8475
8154
  const MERGED_JS_PLUGINS_SPREAD = /\.\.\.\(bensandee\.jsPlugins \?\? \[\]\),\s+/;
8476
8155
  /**
8477
8156
  * The exact inverse of {@link addBensandeePlugin}: strips the import line and whichever of the
8478
- * two `jsPlugins` and two `rules` shapes it spliced in, by removing those same literals rather
8479
- * than re-deriving positions. Returns `undefined` when there is nothing to remove (the content
8480
- * does not name the plugin -- keeps a re-sync a no-op), and {@link UNRECOGNIZED_SHAPE} when it
8481
- * does but none of the expected literals are present -- a hand-edit since injection, a shape
8482
- * this tool never injected, or (for the merged `jsPlugins` spread only) a formatter reflow
8483
- * wider than {@link MERGED_JS_PLUGINS_SPREAD} tolerates -- declined whole, the same rule
8484
- * `addBensandeePlugin` itself follows, rather than guessing at a partial removal that could
8485
- * corrupt the file.
8157
+ * two `jsPlugins` and two `rules` shapes it spliced in, the legacy `rules` literals included, by
8158
+ * removing those same literals rather than re-deriving positions. Returns `undefined` when
8159
+ * there is nothing to remove (the content does not name the plugin -- keeps a re-sync a no-op),
8160
+ * and {@link UNRECOGNIZED_SHAPE} when it does but none of the expected literals are present --
8161
+ * a hand-edit since injection, a shape this tool never injected, or (for the merged
8162
+ * `jsPlugins` spread only) a formatter reflow wider than {@link MERGED_JS_PLUGINS_SPREAD}
8163
+ * tolerates -- declined whole, the same rule `addBensandeePlugin` itself follows, rather than
8164
+ * guessing at a partial removal that could corrupt the file.
8486
8165
  */
8487
8166
  function removeBensandeePlugin(content) {
8488
8167
  if (!content.includes("@bensandee/eslint-plugin")) return void 0;
@@ -8491,10 +8170,17 @@ function removeBensandeePlugin(content) {
8491
8170
  if (result.includes(CREATED_JS_PLUGINS_LINE)) result = result.replace(CREATED_JS_PLUGINS_LINE, "");
8492
8171
  else if (MERGED_JS_PLUGINS_SPREAD.test(result)) result = result.replace(MERGED_JS_PLUGINS_SPREAD, "");
8493
8172
  else return UNRECOGNIZED_SHAPE;
8494
- if (result.includes(CREATED_RULES_BLOCK)) result = result.replace(CREATED_RULES_BLOCK, "");
8495
- else if (result.includes(`\n${REPORT_SPREAD}`)) result = result.replace(`\n${REPORT_SPREAD}`, "");
8496
- else return UNRECOGNIZED_SHAPE;
8497
- return result;
8173
+ const rulesLiteral = CREATED_RULES_BLOCKS.find((block) => result.includes(block)) ?? MERGED_RULES_SPREADS.find((spread) => result.includes(spread));
8174
+ if (rulesLiteral === void 0) return UNRECOGNIZED_SHAPE;
8175
+ return result.replace(rulesLiteral, "");
8176
+ }
8177
+ /**
8178
+ * Swap the report-phase spread an earlier release merged in for {@link TIER_SPREAD}. Only the
8179
+ * byte-exact literal is replaced: a map the repo has reflowed or edited is left for the caller
8180
+ * to report, since rewriting a shape this tool did not write could drop the repo's own rules.
8181
+ */
8182
+ function migrateReportSpread(content) {
8183
+ return content.replace(`\n${LEGACY_REPORT_SPREAD}`, `\n${TIER_SPREAD}`);
8498
8184
  }
8499
8185
  async function generateOxlint(ctx) {
8500
8186
  const filePath = "oxlint.config.ts";
@@ -8512,7 +8198,7 @@ async function generateOxlint(ctx) {
8512
8198
  action: "skipped",
8513
8199
  description: "Already configured"
8514
8200
  };
8515
- if (isGeneratedTemplate(ctx, filePath, ctx.config.useEslintPlugin ? CONFIG_PRESET_ONLY : CONFIG_WITH_LINT_RULES)) {
8201
+ if ((ctx.config.useEslintPlugin ? [CONFIG_PRESET_ONLY, LEGACY_CONFIG_WITH_LINT_RULES] : [CONFIG_WITH_LINT_RULES, LEGACY_CONFIG_WITH_LINT_RULES]).some((template) => isGeneratedTemplate(ctx, filePath, template))) {
8516
8202
  ctx.write(filePath, content);
8517
8203
  return {
8518
8204
  filePath,
@@ -8525,8 +8211,14 @@ async function generateOxlint(ctx) {
8525
8211
  let pluginResult;
8526
8212
  let withPlugin;
8527
8213
  if (ctx.config.useEslintPlugin) {
8528
- pluginResult = addBensandeePlugin(withIgnores);
8529
- withPlugin = typeof pluginResult === "string" ? pluginResult : withIgnores;
8214
+ const migrated = migrateReportSpread(withIgnores);
8215
+ pluginResult = addBensandeePlugin(migrated);
8216
+ withPlugin = typeof pluginResult === "string" ? pluginResult : migrated;
8217
+ if (pluginResult === void 0 && REPORT_PHASE_MARKER.test(migrated)) return {
8218
+ filePath,
8219
+ action: "failed",
8220
+ description: "Plugin severities not migrated: unrecognized config shape -- replace the all-\"warn\" rule map with \"...bensandee.rules,\""
8221
+ };
8530
8222
  } else {
8531
8223
  pluginResult = removeBensandeePlugin(withIgnores);
8532
8224
  withPlugin = typeof pluginResult === "string" ? pluginResult : withIgnores;
@@ -9664,89 +9356,406 @@ async function generateGitignore(ctx) {
9664
9356
  ...OPTIONAL_ENTRIES,
9665
9357
  ...conditionalEntries(ctx)
9666
9358
  ];
9667
- const existing = ctx.read(filePath);
9668
- if (existing) {
9669
- const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
9670
- const missing = allEntries.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9671
- if (missing.length === 0) return {
9672
- filePath,
9673
- action: "skipped",
9674
- description: "Already has all standard entries"
9675
- };
9676
- const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9677
- const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
9678
- ctx.write(filePath, updated);
9679
- if (missingRequired.length === 0) return {
9680
- filePath,
9681
- action: "skipped",
9682
- description: "Only optional entries missing"
9683
- };
9684
- return {
9685
- filePath,
9686
- action: "updated",
9687
- description: `Appended ${String(missing.length)} missing entries`
9688
- };
9689
- }
9690
- ctx.write(filePath, allEntries.join("\n") + "\n");
9359
+ const existing = ctx.read(filePath);
9360
+ if (existing) {
9361
+ const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
9362
+ const missing = allEntries.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9363
+ if (missing.length === 0) return {
9364
+ filePath,
9365
+ action: "skipped",
9366
+ description: "Already has all standard entries"
9367
+ };
9368
+ const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9369
+ const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
9370
+ ctx.write(filePath, updated);
9371
+ if (missingRequired.length === 0) return {
9372
+ filePath,
9373
+ action: "skipped",
9374
+ description: "Only optional entries missing"
9375
+ };
9376
+ return {
9377
+ filePath,
9378
+ action: "updated",
9379
+ description: `Appended ${String(missing.length)} missing entries`
9380
+ };
9381
+ }
9382
+ ctx.write(filePath, allEntries.join("\n") + "\n");
9383
+ return {
9384
+ filePath,
9385
+ action: "created",
9386
+ description: "Generated .gitignore"
9387
+ };
9388
+ }
9389
+ //#endregion
9390
+ //#region src/generators/gitattributes.ts
9391
+ const MANAGED_HEADER = ["# Managed by @bensandee/tooling: LF working tree on every platform, overriding", "# core.autocrlf/core.eol at checkout. Rules below this line win over it."].join("\n");
9392
+ /** Placed only when prepending above a consumer's existing content -- explains, in the file
9393
+ * itself, why the rule goes first rather than last (the code has no other way to show it). */
9394
+ const PREPEND_REASON = ["# Prepended above your existing rules: .gitattributes is last-match-wins, so a rule", "# placed below would override any binary/-text marks you already have."].join("\n");
9395
+ const RULE_LINE = "* text=auto eol=lf";
9396
+ /**
9397
+ * True when `content` already carries a rule equivalent to `* text=auto eol=lf`: a
9398
+ * non-comment line whose first whitespace-separated token is exactly `*` and whose
9399
+ * remaining tokens include both `text=auto` and `eol=lf`, in either order. Token-based so
9400
+ * reordered or extra-whitespace variants still count, but a narrower pattern (`*.ts ...`)
9401
+ * or a commented-out line does not.
9402
+ */
9403
+ function hasLfRule(content) {
9404
+ return content.split("\n").some((line) => {
9405
+ const trimmed = line.trim();
9406
+ if (trimmed.startsWith("#")) return false;
9407
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
9408
+ if (tokens[0] !== "*") return false;
9409
+ const rest = new Set(tokens.slice(1));
9410
+ return rest.has("text=auto") && rest.has("eol=lf");
9411
+ });
9412
+ }
9413
+ /**
9414
+ * Scaffold `.gitattributes` with `* text=auto eol=lf`, the rule that makes the working tree
9415
+ * LF regardless of a developer's global `core.autocrlf`/`core.eol`. No managed-file header
9416
+ * from `utils/generated-header.ts` -- this file is merged into like `.gitignore`, not
9417
+ * wholesale-regenerated. The rule is required, not optional: a missing rule is
9418
+ * created/updated and fails `repo:sync --check` like any other drift.
9419
+ */
9420
+ async function generateGitattributes(ctx) {
9421
+ const filePath = ".gitattributes";
9422
+ const existing = ctx.read(filePath);
9423
+ if (existing === void 0 || existing.trim() === "") {
9424
+ ctx.write(filePath, `${MANAGED_HEADER}\n${RULE_LINE}\n`);
9425
+ return {
9426
+ filePath,
9427
+ action: existing === void 0 ? "created" : "updated",
9428
+ description: "Generated .gitattributes"
9429
+ };
9430
+ }
9431
+ if (hasLfRule(existing)) return {
9432
+ filePath,
9433
+ action: "skipped",
9434
+ description: "Already has the LF rule"
9435
+ };
9436
+ const updated = `${MANAGED_HEADER}\n${PREPEND_REASON}\n${RULE_LINE}\n\n${existing}`;
9437
+ ctx.write(filePath, updated);
9438
+ return {
9439
+ filePath,
9440
+ action: "updated",
9441
+ description: "Prepended the LF rule"
9442
+ };
9443
+ }
9444
+ //#endregion
9445
+ //#region src/release/publish/publish-config-lint.ts
9446
+ const FIX = `add "publishConfig": { "registry": "<your registry URL>" } to its package.json`;
9447
+ /**
9448
+ * Whether a value can serve as a publish destination: an absolute http(s) URL.
9449
+ * Presence alone is not the test -- a relative or empty registry is as silently
9450
+ * wrong as an absent one, and npm resolves neither to the place the author meant.
9451
+ */
9452
+ function isUsableRegistry(value) {
9453
+ if (typeof value !== "string" || value.trim() === "") return false;
9454
+ try {
9455
+ const url = new URL(value);
9456
+ return url.protocol === "https:" || url.protocol === "http:";
9457
+ } catch {
9458
+ return false;
9459
+ }
9460
+ }
9461
+ function isRecord(value) {
9462
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9463
+ }
9464
+ /**
9465
+ * Require every package this repo would publish to declare where it publishes.
9466
+ *
9467
+ * npm and pnpm use one `registry` key for reads and writes alike, so a project
9468
+ * `.npmrc` aimed at an install cache silently repoints `pnpm publish` too. A
9469
+ * package that never declares its destination inherits one, and an inherited
9470
+ * destination can be changed by a file that never mentions publishing.
9471
+ *
9472
+ * Reports rather than repairs: the correct registry is not derivable here, and
9473
+ * writing a guess would redirect a working release exactly the way this gate
9474
+ * exists to prevent.
9475
+ */
9476
+ function lintPublishConfig(targetDir, structure) {
9477
+ const issues = [];
9478
+ for (const pkg of getPublishablePackages(targetDir, structure)) {
9479
+ const manifest = readPackageJson(pkg.dir);
9480
+ if (!manifest) continue;
9481
+ const file = path.relative(targetDir, path.join(pkg.dir, "package.json"));
9482
+ const pc = manifest.publishConfig;
9483
+ const registry = isRecord(pc) ? pc["registry"] : void 0;
9484
+ if (registry === void 0) {
9485
+ issues.push({
9486
+ kind: "missing",
9487
+ package: pkg.name,
9488
+ file,
9489
+ message: `${pkg.name} is published from this repo but declares no publishConfig.registry, so its release destination is inherited -- a project .npmrc can repoint it silently. In ${file}, ${FIX}.`
9490
+ });
9491
+ continue;
9492
+ }
9493
+ if (!isUsableRegistry(registry)) issues.push({
9494
+ kind: "malformed",
9495
+ package: pkg.name,
9496
+ file,
9497
+ message: `${pkg.name} declares publishConfig.registry as ${JSON.stringify(registry)}, which is not an absolute http(s) URL and cannot be published to. In ${file}, ${FIX}.`
9498
+ });
9499
+ }
9500
+ return issues;
9501
+ }
9502
+ /**
9503
+ * The registries this repo's publishable packages actually declare, distinct and in
9504
+ * first-seen order.
9505
+ *
9506
+ * Reuses the same "absolute http(s) URL" narrowing as {@link lintPublishConfig} rather
9507
+ * than re-deriving it -- a registry this misses here would fail that gate first, so
9508
+ * `repo:sync` never reaches a generator with an undeclared or malformed registry. A
9509
+ * caller that invokes a generator directly (or a repo whose publishable set is empty,
9510
+ * e.g. every package private) still gets a safe empty result.
9511
+ */
9512
+ function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
9513
+ const registries = [];
9514
+ for (const pkg of getPublishablePackages(targetDir, structure, rootPackageJson)) {
9515
+ const manifest = structure === "single" ? rootPackageJson ?? readPackageJson(pkg.dir) : readPackageJson(pkg.dir);
9516
+ if (!manifest) continue;
9517
+ const pc = manifest.publishConfig;
9518
+ const registry = isRecord(pc) ? pc["registry"] : void 0;
9519
+ if (typeof registry === "string" && isUsableRegistry(registry) && !registries.includes(registry)) registries.push(registry);
9520
+ }
9521
+ return registries;
9522
+ }
9523
+ //#endregion
9524
+ //#region src/generators/mise-pin.ts
9525
+ /**
9526
+ * The mise version and release-asset digest the fleet's CI and the agent runner base image agree
9527
+ * to run. This is the one place that agreement is recorded; `packages/agent/test/runner-base-image-dockerfile.test.ts`
9528
+ * (task 8 of the mise-toolchain-provisioning plan) reads both constants back through
9529
+ * `readRepoFile` and asserts the base image's own `ARG MISE_VERSION` and `mise.sha256` manifest
9530
+ * agree with them, so the two packages cannot drift without a failing test on either side.
9531
+ *
9532
+ * Each constant sits under its own `// renovate:` marker comment, in the three-line grammar
9533
+ * `packages/agent/docker/agent-goose-image/goose.sha256` uses for its own digest line, so the same
9534
+ * shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
9535
+ * digest (`customManagers` entries scoped to this file).
9536
+ */
9537
+ const MISE_VERSION = "2026.10.2";
9538
+ const MISE_LINUX_X64_SHA256 = "8f5f6660336f572830e33cd9b378d3131e529a0d4c4f0c553776be90a1ba302a";
9539
+ /** Where generated Forgejo workflows fetch the shared mise setup action: a composite action in
9540
+ * `bensandee/common`, on the forge every fleet repo lives on. GitHub cannot reach it; the
9541
+ * generators keep inline steps there. */
9542
+ const SETUP_MISE_ACTION_URL = "https://code.orangebikelabs.com/bensandee/common/actions/setup-mise";
9543
+ /** The tag in `bensandee/common` that marks the current release of the action. Generated `uses:`
9544
+ * lines carry it as their comment, and Renovate moves the SHA below to wherever it points. */
9545
+ const SETUP_MISE_ACTION_TAG = "setup-mise@latest";
9546
+ const SETUP_MISE_ACTION_SHA = "db99e4e9ee46bfeaca2146e7ae55cc700b4c23bf";
9547
+ //#endregion
9548
+ //#region src/generators/ci-utils.ts
9549
+ /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
9550
+ function actionsExpr(expr) {
9551
+ return `\${{ ${expr} }}`;
9552
+ }
9553
+ const MAIN_BRANCH_REF = "refs/heads/main";
9554
+ /** `if:` condition that gates a step to push events on the default branch. */
9555
+ const IS_MAIN_BRANCH = `github.ref == '${MAIN_BRANCH_REF}'`;
9556
+ /**
9557
+ * Negated form for concurrency `cancel-in-progress` and similar uses.
9558
+ *
9559
+ * Main is exempt from cancellation on purpose, and the exemption stays: a main run publishes and
9560
+ * pushes tags, and cancelling one mid-flight can leave a partial publish -- some packages on the
9561
+ * registry, no matching tags, and no run left alive to finish. A superseded feature-branch run
9562
+ * wastes minutes; a half-published release costs a manual unwind.
9563
+ *
9564
+ * So several main runs can be in flight at once, each versioning from its own triggering commit.
9565
+ * Overlap safety rests on one invariant: **main advances solely via commits that trigger a CI
9566
+ * run.** The `[skip ci]` coverage, health, and egress records go to `bst/*` orphan branches and
9567
+ * never to main; a `[skip ci]` commit pushed straight to main would leave a superseded run skipping
9568
+ * and its changesets stranded until the next push, which is why the supersession guard skips rather
9569
+ * than fails. Three guards carry the overlap itself: supersession plus a `--force-with-lease`
9570
+ * version push (`release/publish/version.ts`), the `headBumpedVersion` publish guard plus reading
9571
+ * remote tags and releases before acting (`commands/release-changesets.ts`,
9572
+ * `release/publish/publish.ts`), and floating git and Docker tags that only move forward
9573
+ * (`release/publish/floating-tags.ts`, `release/docker.ts`). Such a run may also version pending
9574
+ * changesets after it publishes -- the stale version-PR merge -- under the same supersession guard
9575
+ * and push lease as any other version run. A later commit publishes a bump only when its tag is
9576
+ * missing and every main run since the package's last release tag has settled
9577
+ * (`release/publish/unpublished-bump.ts`), so no run that could still publish it is in flight; a
9578
+ * manual re-run of one of those runs is the overlap left. On Forgejo the concurrency block is
9579
+ * only a PR-run cancellation mechanism -- it holds a main run in its group and, in Forgejo 16,
9580
+ * intermittently fails to release it, which is why main runs get a group of their own there. GitHub's shape stays shared
9581
+ * because its queue works: one run, one pending, a newer arrival replacing the pending, so
9582
+ * `changesets/action@v1` never needs a guard of its own. Any new side effect in a main run must be
9583
+ * idempotent under overlap or be listed here with the guard that makes it so.
9584
+ */
9585
+ const IS_NOT_MAIN_BRANCH = `github.ref != '${MAIN_BRANCH_REF}'`;
9586
+ /**
9587
+ * Job-level `env:` block that evaluates the default-branch check once, so steps
9588
+ * can gate on `env.IS_MAIN` instead of repeating the `github.ref` expression.
9589
+ */
9590
+ const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
9591
+ /** `if:` condition referencing the job-level `IS_MAIN` env var (see {@link IS_MAIN_JOB_ENV}). */
9592
+ const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
9593
+ /**
9594
+ * npm's own registry-key rule for an `.npmrc` auth line: strip the scheme, keep
9595
+ * host/port/path, and end with exactly one slash. `actions/setup-node` derives the
9596
+ * same key from a registry URL; this mirrors that so a hand-written line matches
9597
+ * what npm looks up at publish time -- npm-registry-fetch walks the request path
9598
+ * upward through ancestor keys until one matches.
9599
+ */
9600
+ function npmrcAuthKey(registryUrl) {
9601
+ return `${registryUrl.replace(/^https?:/i, "").replace(/\/+$/, "")}/`;
9602
+ }
9603
+ const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
9604
+ /**
9605
+ * Build both halves of a job's npm auth wiring together, so one value decides
9606
+ * them: the auth lines the publishing step writes, and the step-level
9607
+ * `NODE_AUTH_TOKEN` it carries. A job that gets none of the declared registries
9608
+ * gets neither -- including a `publishes: true` job whose publishable set is
9609
+ * empty (every package private), since there is then no npmrc line for a real
9610
+ * secret to pair with.
9611
+ *
9612
+ * The lines go into a temp userconfig exported only inside the publishing
9613
+ * step's shell, never `~/.npmrc` or `$GITHUB_ENV`: pnpm 11 warns
9614
+ * `Failed to replace env in config` on every call that reads the placeholder
9615
+ * while the token is unset or empty, so a line in a shared file makes install, `ci:check`,
9616
+ * and every step after the publish warn. An empty job-level default does not
9617
+ * silence it, and `${NODE_AUTH_TOKEN:-}` is rejected as an empty fallback and
9618
+ * warns in every case. The `-n` guard keeps a run without the secret quiet; the
9619
+ * single quotes keep the token itself off disk, since npm expands it at read time.
9620
+ */
9621
+ function npmAuthWiring(ctx, publishes) {
9622
+ const registries = publishes ? declaredPublishRegistries(ctx.targetDir, ctx.config.structure, ctx.packageJson) : [];
9623
+ if (registries.length === 0) return {
9624
+ publishRun: (command) => command,
9625
+ tokenEnv: {}
9626
+ };
9627
+ const setup = [
9628
+ "if [ -n \"$NODE_AUTH_TOKEN\" ]; then",
9629
+ " NPM_CONFIG_USERCONFIG=\"$(mktemp)\"",
9630
+ " export NPM_CONFIG_USERCONFIG",
9631
+ " if [ -f ~/.npmrc ]; then cat ~/.npmrc > \"$NPM_CONFIG_USERCONFIG\"; fi",
9632
+ ...registries.map((registry) => ` printf '\\n%s\\n' '${npmrcAuthKey(registry)}:_authToken=${NODE_AUTH_TOKEN_VAR}' >> "$NPM_CONFIG_USERCONFIG"`),
9633
+ "fi"
9634
+ ];
9691
9635
  return {
9692
- filePath,
9693
- action: "created",
9694
- description: "Generated .gitignore"
9636
+ publishRun: (command) => [...setup, command].join("\n"),
9637
+ tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
9695
9638
  };
9696
9639
  }
9697
- //#endregion
9698
- //#region src/generators/gitattributes.ts
9699
- const MANAGED_HEADER = ["# Managed by @bensandee/tooling: LF working tree on every platform, overriding", "# core.autocrlf/core.eol at checkout. Rules below this line win over it."].join("\n");
9700
- /** Placed only when prepending above a consumer's existing content -- explains, in the file
9701
- * itself, why the rule goes first rather than last (the code has no other way to show it). */
9702
- const PREPEND_REASON = ["# Prepended above your existing rules: .gitattributes is last-match-wins, so a rule", "# placed below would override any binary/-text marks you already have."].join("\n");
9703
- const RULE_LINE = "* text=auto eol=lf";
9640
+ const MISE_RELEASE_URL = `https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64`;
9704
9641
  /**
9705
- * True when `content` already carries a rule equivalent to `* text=auto eol=lf`: a
9706
- * non-comment line whose first whitespace-separated token is exactly `*` and whose
9707
- * remaining tokens include both `text=auto` and `eol=lf`, in either order. Token-based so
9708
- * reordered or extra-whitespace variants still count, but a narrower pattern (`*.ts ...`)
9709
- * or a commented-out line does not.
9642
+ * The node script `inlineMiseSteps` pipes `mise env --json` into, run under the mise it just
9643
+ * installed. Reads the env map off stdin (mise exec passes it straight through) and exports every
9644
+ * key except `PATH` into `$GITHUB_ENV` in heredoc form -- the form that survives any value,
9645
+ * including one containing `$` or quotes -- then appends mise's shim directory and the
9646
+ * `~/.local/bin` install location to `$GITHUB_PATH` so every later step sees node, pnpm, and any
9647
+ * other declared tool on PATH with no prefix.
9710
9648
  */
9711
- function hasLfRule(content) {
9712
- return content.split("\n").some((line) => {
9713
- const trimmed = line.trim();
9714
- if (trimmed.startsWith("#")) return false;
9715
- const tokens = trimmed.split(/\s+/).filter(Boolean);
9716
- if (tokens[0] !== "*") return false;
9717
- const rest = new Set(tokens.slice(1));
9718
- return rest.has("text=auto") && rest.has("eol=lf");
9719
- });
9649
+ const MISE_ENV_EXPORT_SCRIPT = [
9650
+ "const fs = require(\"fs\");",
9651
+ "const env = JSON.parse(fs.readFileSync(0, \"utf8\"));",
9652
+ "let out = \"\";",
9653
+ "for (const [key, value] of Object.entries(env)) {",
9654
+ " if (key === \"PATH\") continue;",
9655
+ " out += key + \"<<BST_MISE_ENV_EOF\\n\" + value + \"\\nBST_MISE_ENV_EOF\\n\";",
9656
+ "}",
9657
+ "fs.appendFileSync(process.env.GITHUB_ENV, out);",
9658
+ "fs.appendFileSync(",
9659
+ " process.env.GITHUB_PATH,",
9660
+ " process.env.HOME + \"/.local/share/mise/shims\\n\" + process.env.HOME + \"/.local/bin\\n\",",
9661
+ ");"
9662
+ ].join("\n");
9663
+ /** A fresh object per call: one shared object in two jobs would serialize as a YAML alias. */
9664
+ function pnpmStoreCacheStep() {
9665
+ return { step: {
9666
+ name: "Cache pnpm store",
9667
+ uses: "actions/cache@v4",
9668
+ with: {
9669
+ path: "~/.local/share/pnpm/store",
9670
+ key: `pnpm-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('pnpm-lock.yaml')")}`
9671
+ }
9672
+ } };
9720
9673
  }
9674
+ /** Actions whose ref the generator pins itself, so `preservePinnedActions` must not keep a
9675
+ * repo's older ref for them. */
9676
+ const GENERATOR_OWNED_ACTIONS = [{
9677
+ name: SETUP_MISE_ACTION_URL,
9678
+ comment: SETUP_MISE_ACTION_TAG
9679
+ }];
9721
9680
  /**
9722
- * Scaffold `.gitattributes` with `* text=auto eol=lf`, the rule that makes the working tree
9723
- * LF regardless of a developer's global `core.autocrlf`/`core.eol`. No managed-file header
9724
- * from `utils/generated-header.ts` -- this file is merged into like `.gitignore`, not
9725
- * wholesale-regenerated. The rule is required, not optional: a missing rule is
9726
- * created/updated and fails `repo:sync --check` like any other drift.
9681
+ * The steps every workflow generator uses in place of `pnpm/action-setup` + `actions/setup-node`:
9682
+ * install the pinned `mise`, `mise install` the repo's `mise.toml` toolchain, export its env into
9683
+ * the rest of the job, and cache the pnpm store. `MISE_VERSION`/`MISE_LINUX_X64_SHA256`
9684
+ * (`mise-pin.ts`) are the same pin the agent runner's base image installs, checked by
9685
+ * `runner-base-image-dockerfile.test.ts`.
9686
+ *
9687
+ * On Forgejo the mise half is the shared composite action in `bensandee/common`, pinned by commit.
9688
+ * GitHub resolves actions only from github.com, so it keeps the same steps inline.
9727
9689
  */
9728
- async function generateGitattributes(ctx) {
9729
- const filePath = ".gitattributes";
9730
- const existing = ctx.read(filePath);
9731
- if (existing === void 0 || existing.trim() === "") {
9732
- ctx.write(filePath, `${MANAGED_HEADER}\n${RULE_LINE}\n`);
9733
- return {
9734
- filePath,
9735
- action: existing === void 0 ? "created" : "updated",
9736
- description: "Generated .gitattributes"
9737
- };
9738
- }
9739
- if (hasLfRule(existing)) return {
9740
- filePath,
9741
- action: "skipped",
9742
- description: "Already has the LF rule"
9743
- };
9744
- const updated = `${MANAGED_HEADER}\n${PREPEND_REASON}\n${RULE_LINE}\n\n${existing}`;
9745
- ctx.write(filePath, updated);
9690
+ function miseSetupSteps(ci) {
9691
+ if (ci !== "forgejo") return inlineMiseSteps();
9692
+ return [{ step: {
9693
+ name: "Set up mise",
9694
+ uses: `${SETUP_MISE_ACTION_URL}@${SETUP_MISE_ACTION_SHA}`,
9695
+ with: {
9696
+ version: MISE_VERSION,
9697
+ sha256: MISE_LINUX_X64_SHA256
9698
+ }
9699
+ } }, pnpmStoreCacheStep()];
9700
+ }
9701
+ /**
9702
+ * The inline form of the shared action, for GitHub. Uses `mise env --json` piped into the
9703
+ * just-installed mise's own `node -e`, not `mise env --shell bash`: the JSON form is
9704
+ * shell-independent and the interpreter reading it is guaranteed to exist (mise just installed
9705
+ * it), so this needs no assumption about which shell the runner's `run:` steps use.
9706
+ */
9707
+ function inlineMiseSteps() {
9708
+ const installScript = [
9709
+ "curl -fsSL -o \"$RUNNER_TEMP/mise\" " + MISE_RELEASE_URL,
9710
+ `echo "${MISE_LINUX_X64_SHA256} $RUNNER_TEMP/mise" | sha256sum -c`,
9711
+ "mkdir -p \"$HOME/.local/bin\" && install -m 0755 \"$RUNNER_TEMP/mise\" \"$HOME/.local/bin/mise\"",
9712
+ "export MISE_TRUSTED_CONFIG_PATHS=\"$GITHUB_WORKSPACE\"",
9713
+ "export MISE_YES=1",
9714
+ "\"$HOME/.local/bin/mise\" install",
9715
+ `"$HOME/.local/bin/mise" env --json | "$HOME/.local/bin/mise" exec -- node -e '${MISE_ENV_EXPORT_SCRIPT}'`
9716
+ ].join("\n");
9717
+ return [
9718
+ { step: {
9719
+ name: "Cache mise tools",
9720
+ uses: "actions/cache@v4",
9721
+ with: {
9722
+ path: "~/.local/share/mise",
9723
+ key: `mise-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('mise.toml')")}`
9724
+ }
9725
+ } },
9726
+ pnpmStoreCacheStep(),
9727
+ { step: {
9728
+ name: "Install mise",
9729
+ env: { GITHUB_TOKEN: "" },
9730
+ run: installScript
9731
+ } }
9732
+ ];
9733
+ }
9734
+ /**
9735
+ * Build the docker publish env block.
9736
+ *
9737
+ * The LHS env-var names (`DOCKER_USERNAME` / `DOCKER_PASSWORD`) match what
9738
+ * publish-docker.ts reads at runtime. The RHS secret references are
9739
+ * registry-specific (see {@link dockerSecretNames}) so downstream repos can
9740
+ * stop relying on the user/org-level `DOCKER_USERNAME` / `DOCKER_PASSWORD`
9741
+ * secrets -- which the Forgejo act fork auto-injects into every `container:`
9742
+ * image pull regardless of the target registry.
9743
+ *
9744
+ * When `host` is `null` (couldn't be derived at gen time), the secret references
9745
+ * fall back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` and the
9746
+ * workflow keeps `DOCKER_REGISTRY_HOST` / `DOCKER_REGISTRY_NAMESPACE` so the CLI
9747
+ * resolves the registry at runtime.
9748
+ */
9749
+ function dockerPublishEnv(host) {
9750
+ const { username, password } = dockerSecretNames(host);
9746
9751
  return {
9747
- filePath,
9748
- action: "updated",
9749
- description: "Prepended the LF rule"
9752
+ ...host === null ? {
9753
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
9754
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
9755
+ } : {},
9756
+ DOCKER_USERNAME: actionsExpr(`secrets.${username}`),
9757
+ DOCKER_PASSWORD: actionsExpr(`secrets.${password}`),
9758
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
9750
9759
  };
9751
9760
  }
9752
9761
  //#endregion
@@ -9801,7 +9810,7 @@ function postReleaseHookJob(opts) {
9801
9810
  needs: opts.needsJob,
9802
9811
  if: opts.publishCondition,
9803
9812
  "continue-on-error": true,
9804
- steps: [...commonSteps(opts.ci, []), { step: {
9813
+ steps: [...commonSteps(opts.ci), { step: {
9805
9814
  name: "Post-release hook",
9806
9815
  env: {
9807
9816
  ...hookSecretEnv(opts.secrets),
@@ -9840,21 +9849,20 @@ function deployDispatchStep(opts) {
9840
9849
  } };
9841
9850
  }
9842
9851
  /** Common setup steps shared by release-it and simple strategies. */
9843
- function commonSteps(ci, authSteps) {
9852
+ function commonSteps(ci) {
9844
9853
  return [
9845
9854
  { step: {
9846
9855
  uses: "actions/checkout@v6",
9847
9856
  with: { "fetch-depth": 0 }
9848
9857
  } },
9849
9858
  ...miseSetupSteps(ci),
9850
- ...authSteps,
9851
9859
  { step: { run: "pnpm install --frozen-lockfile" } }
9852
9860
  ];
9853
9861
  }
9854
9862
  function releaseItSteps(ci, npmAuth) {
9855
9863
  const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
9856
- return [...commonSteps(ci, npmAuth.steps), { step: {
9857
- run: "pnpm release-it --ci",
9864
+ return [...commonSteps(ci), { step: {
9865
+ run: npmAuth.publishRun("pnpm release-it --ci"),
9858
9866
  env: {
9859
9867
  ...tokenEnv,
9860
9868
  ...npmAuth.tokenEnv,
@@ -9905,7 +9913,7 @@ function simpleReleaseEnv() {
9905
9913
  };
9906
9914
  }
9907
9915
  function simpleReleaseSteps(ctx) {
9908
- const { ci, npmAuth, hasDocker, hasAssets, host } = ctx;
9916
+ const { ci, hasDocker, hasAssets, host } = ctx;
9909
9917
  const releaseEnv = connectionEnv(ci);
9910
9918
  const pushedIf = "steps.release.outputs.pushed == 'true'";
9911
9919
  const releaseStep = { step: {
@@ -9931,7 +9939,7 @@ function simpleReleaseSteps(ctx) {
9931
9939
  run: "pnpm exec bst docker:publish"
9932
9940
  } };
9933
9941
  return [
9934
- ...commonSteps(ci, npmAuth.steps),
9942
+ ...commonSteps(ci),
9935
9943
  releaseStep,
9936
9944
  ...hasAssets ? [assetsStep] : [],
9937
9945
  ...hasDocker ? [dockerStep] : []
@@ -10014,7 +10022,6 @@ async function generateReleaseCi(ctx) {
10014
10022
  on,
10015
10023
  ...isGitHub && { permissions: { contents: "write" } },
10016
10024
  jobName: "release",
10017
- ...Object.keys(npmAuth.jobEnv).length > 0 && { jobEnv: npmAuth.jobEnv },
10018
10025
  ...jobOutputs && { jobOutputs },
10019
10026
  steps,
10020
10027
  ...extraJobs && { extraJobs }
@@ -10087,7 +10094,7 @@ function healthSteps(includeHistory) {
10087
10094
  } }];
10088
10095
  }
10089
10096
  /** Build the release step for the check job (changesets strategy). */
10090
- function changesetsReleaseStep(ci, publishesNpm, tokenEnv) {
10097
+ function changesetsReleaseStep(ci, publishesNpm, npmAuth) {
10091
10098
  if (ci === "github") {
10092
10099
  const changesetsEnv = {
10093
10100
  GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
@@ -10111,10 +10118,10 @@ function changesetsReleaseStep(ci, publishesNpm, tokenEnv) {
10111
10118
  FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
10112
10119
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
10113
10120
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
10114
- ...tokenEnv,
10121
+ ...npmAuth.tokenEnv,
10115
10122
  RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
10116
10123
  },
10117
- run: "pnpm exec bst release:changesets"
10124
+ run: npmAuth.publishRun("pnpm exec bst release:changesets")
10118
10125
  } };
10119
10126
  }
10120
10127
  /**
@@ -10147,7 +10154,7 @@ function ciConcurrency(ci) {
10147
10154
  "cancel-in-progress": actionsExpr(IS_NOT_MAIN_BRANCH)
10148
10155
  };
10149
10156
  }
10150
- function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
10157
+ function checkSteps(ci, coverageEnabled, emitDockerSweepStep) {
10151
10158
  const checkCommand = coverageEnabled ? "pnpm ci:check" : "pnpm ci:check --no-coverage";
10152
10159
  return [
10153
10160
  { step: {
@@ -10155,7 +10162,6 @@ function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
10155
10162
  with: { "fetch-depth": 0 }
10156
10163
  } },
10157
10164
  ...miseSetupSteps(ci),
10158
- ...authSteps,
10159
10165
  { step: { run: "pnpm install --frozen-lockfile" } },
10160
10166
  ...emitDockerSweepStep ? [{ step: {
10161
10167
  name: "Sweep leaked Docker resources",
@@ -10208,10 +10214,10 @@ async function generateCi(ctx) {
10208
10214
  }
10209
10215
  const npmAuth = npmAuthWiring(ctx, isChangesets && publishesNpm && !isGitHub);
10210
10216
  const steps = [
10211
- ...checkSteps(ctx.config.ci, npmAuth.steps, coverage.enabled, hasDockerPackages(ctx)),
10217
+ ...checkSteps(ctx.config.ci, coverage.enabled, hasDockerPackages(ctx)),
10212
10218
  ...emitCoverageSteps ? coverageHistorySteps() : [],
10213
10219
  ...healthSteps(emitHealthHistory),
10214
- ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth.tokenEnv)] : [],
10220
+ ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth)] : [],
10215
10221
  ...isChangesets && isGitHub ? [githubChangesetsDeriveTagStep] : [],
10216
10222
  ...hasAssets ? [{ step: {
10217
10223
  name: "Upload Release Assets",
@@ -10249,10 +10255,7 @@ async function generateCi(ctx) {
10249
10255
  secrets: hookSecrets
10250
10256
  }) };
10251
10257
  }
10252
- const checkJobEnv = {
10253
- ...emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {},
10254
- ...npmAuth.jobEnv
10255
- };
10258
+ const checkJobEnv = emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {};
10256
10259
  const checksSlice = parseToolingConfigSlice(ctx.read(".tooling.json"), z.object({ checks: ChecksConfigSchema.optional() }), { block: "checks" });
10257
10260
  const skipOnVersionPr = isChangesets && checksSlice?.checks?.runOnVersionPr !== true;
10258
10261
  const content = buildWorkflowYaml({
@@ -12369,21 +12372,21 @@ function webUiGuideBody() {
12369
12372
  const mwg = `pnpm dlx modern-web-guidance@${{
12370
12373
  "@changesets/cli": "3.0.3",
12371
12374
  "@release-it/bumper": "8.0.1",
12372
- "@types/node": "24.13.5",
12373
- "@vitest/coverage-v8": "5.0.1",
12375
+ "@types/node": "24.19.1",
12376
+ "@vitest/coverage-v8": "5.0.3",
12374
12377
  "commit-and-tag-version": "13.2.1",
12375
- "knip": "6.38.0",
12376
- "lefthook": "2.1.14",
12378
+ "knip": "6.39.0",
12379
+ "lefthook": "2.1.16",
12377
12380
  "modern-web-guidance": "0.0.191",
12378
12381
  "oxfmt": "0.71.0",
12379
- "oxlint": "1.85.0",
12382
+ "oxlint": "1.86.0",
12380
12383
  "oxlint-tsgolint": "7.0.2003",
12381
12384
  "prettier": "3.9.9",
12382
12385
  "release-it": "21.1.0",
12383
12386
  "tsdown": "0.23.0",
12384
12387
  "typescript": "6.0.3",
12385
- "vitest": "5.0.1",
12386
- "pnpm": "11.27.1"
12388
+ "vitest": "5.0.3",
12389
+ "pnpm": "11.28.3"
12387
12390
  }["modern-web-guidance"]}`;
12388
12391
  return [
12389
12392
  "# Web UI",
@@ -12990,7 +12993,7 @@ function reportFailures(failed, command) {
12990
12993
  function generateMigratePrompt(results, config, detected) {
12991
12994
  const sections = [];
12992
12995
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
12993
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.86.0 repo:sync\` on ${timestamp}_\n`);
12996
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.0 repo:sync\` on ${timestamp}_\n`);
12994
12997
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
12995
12998
  if (changed.length > 0) {
12996
12999
  const items = changed.map((r) => {
@@ -13976,7 +13979,9 @@ function createFileReader() {
13976
13979
  * (`commands/sync-gates.ts`) -- see that gate's doc comment for why an absent mise warns rather
13977
13980
  * than fails -- plus, when `publishDocker` is on, one more per image only the check overlay
13978
13981
  * declares (`runCheckOnlyImageGate`, same file): an advisory that a later tooling release stops
13979
- * publishing it, counted rather than failed for the same reason the other two are.
13982
+ * publishing it, counted rather than failed for the same reason the other two are -- plus one
13983
+ * more per workflow file whose custom block installs packages with apt (`runWorkflowAptGate`,
13984
+ * same file), which the agent's containers cannot run.
13980
13985
  */
13981
13986
  const CHECKS_MARKER = "::bst-checks::";
13982
13987
  const WARN_LINE = new RegExp(`^${CHECKS_MARKER} warn (\\d+)\\s*$`);
@@ -15548,6 +15553,113 @@ const runChecksCommand = defineCommand({
15548
15553
  }
15549
15554
  });
15550
15555
  //#endregion
15556
+ //#region src/utils/workflow-paths.ts
15557
+ /**
15558
+ * The single definition of "workflow dir", shared by `repo:sync --check`'s advisory
15559
+ * classification and the workflow sync sweep's path allowlist -- so the two halves cannot
15560
+ * disagree about what counts as workflow output. Each prefix carries its trailing slash so a
15561
+ * file named e.g. `.github/workflowsX` never matches.
15562
+ */
15563
+ const WORKFLOW_DIR_PREFIXES = [".forgejo/workflows/", ".github/workflows/"];
15564
+ /** True when `filePath` (repo-relative) lives under one of the workflow directories. */
15565
+ function isWorkflowDirPath(filePath) {
15566
+ return WORKFLOW_DIR_PREFIXES.some((prefix) => filePath.startsWith(prefix));
15567
+ }
15568
+ //#endregion
15569
+ //#region src/commands/workflow-apt-steps.ts
15570
+ /** `apt-get install`, `apt install`, and either with options before the verb (`apt-get -y
15571
+ * install`). */
15572
+ const APT_INSTALL = /\bapt(?:-get)?(?:\s+-\S+)*\s+install\b/g;
15573
+ const COMMAND_END = /&&|\|\||;|\|/;
15574
+ /** Every readable `.yml`/`.yaml` file in the two workflow directories, as repo-relative paths. */
15575
+ function workflowFiles(targetDir) {
15576
+ const files = [];
15577
+ for (const prefix of WORKFLOW_DIR_PREFIXES) {
15578
+ let names;
15579
+ try {
15580
+ names = readdirSync(path.join(targetDir, prefix));
15581
+ } catch {
15582
+ continue;
15583
+ }
15584
+ for (const name of names) {
15585
+ if (!/\.ya?ml$/.test(name)) continue;
15586
+ const relative = `${prefix}${name}`;
15587
+ if (isReadableFile$1(path.join(targetDir, relative))) files.push(relative);
15588
+ }
15589
+ }
15590
+ return files;
15591
+ }
15592
+ /** False for a directory, a dangling symlink, or anything `stat` refuses: an advisory never fails
15593
+ * the run it advises, so a file it cannot look at is skipped, not thrown on. */
15594
+ function isReadableFile$1(file) {
15595
+ try {
15596
+ return statSync(file).isFile();
15597
+ } catch {
15598
+ return false;
15599
+ }
15600
+ }
15601
+ /** A file's text, or null when it cannot be read -- skipped for the same reason. */
15602
+ function readText(file) {
15603
+ try {
15604
+ return readFileSync(file, "utf8");
15605
+ } catch {
15606
+ return null;
15607
+ }
15608
+ }
15609
+ /** A custom block's content lines, with the markers and every other comment line dropped (a
15610
+ * commented-out apt step has been removed) and `\`-continued lines joined. */
15611
+ function logicalLines(blockLines) {
15612
+ const lines = [];
15613
+ let pending = "";
15614
+ for (const line of blockLines) {
15615
+ if (line.trim().startsWith("#")) continue;
15616
+ const trimmed = line.trimEnd();
15617
+ if (trimmed.endsWith("\\")) {
15618
+ pending += `${trimmed.slice(0, -1)} `;
15619
+ continue;
15620
+ }
15621
+ lines.push(pending + line);
15622
+ pending = "";
15623
+ }
15624
+ if (pending !== "") lines.push(pending);
15625
+ return lines;
15626
+ }
15627
+ /** The packages one line installs, or none when it runs no apt install. */
15628
+ function packagesIn(line) {
15629
+ const packages = [];
15630
+ for (const match of line.matchAll(APT_INSTALL)) {
15631
+ const command = line.slice(match.index + match[0].length).split(COMMAND_END)[0] ?? "";
15632
+ for (const token of command.split(/\s+/)) {
15633
+ if (token === "" || token.startsWith("-")) continue;
15634
+ packages.push(token);
15635
+ }
15636
+ }
15637
+ return packages;
15638
+ }
15639
+ /** True when `line` runs an apt install at all, packages or not. */
15640
+ function installsWithApt(line) {
15641
+ return line.search(APT_INSTALL) !== -1;
15642
+ }
15643
+ function findWorkflowAptSteps(targetDir) {
15644
+ const steps = [];
15645
+ for (const file of workflowFiles(targetDir)) {
15646
+ const content = readText(path.join(targetDir, file));
15647
+ if (content === null || isToolingIgnored(content)) continue;
15648
+ let matched = false;
15649
+ const packages = /* @__PURE__ */ new Set();
15650
+ for (const block of extractCustomBlocks(content)) for (const line of logicalLines(block.lines)) {
15651
+ if (!installsWithApt(line)) continue;
15652
+ matched = true;
15653
+ for (const name of packagesIn(line)) packages.add(name);
15654
+ }
15655
+ if (matched) steps.push({
15656
+ file,
15657
+ packages: [...packages]
15658
+ });
15659
+ }
15660
+ return steps.toSorted((a, b) => a.file.localeCompare(b.file));
15661
+ }
15662
+ //#endregion
15551
15663
  //#region src/commands/sync-gates.ts
15552
15664
  /**
15553
15665
  * The pre-generator gates and config diagnostics every `repo:sync` mode runs -- split out of
@@ -15607,6 +15719,23 @@ function runCheckOnlyImageGate(config, targetDir) {
15607
15719
  return images;
15608
15720
  }
15609
15721
  /**
15722
+ * Warn, once per workflow file, when a `@tooling:custom` block installs packages with apt. CI's
15723
+ * job runs as root and can do that; the agent's containers run without root and cannot, so the
15724
+ * package is present in CI and missing in the agent's measurement of the same tree. Advisory like
15725
+ * {@link runCheckOnlyImageGate}, and for the same reason it returns the entries: `repo:sync
15726
+ * --check` folds their count into the `::bst-checks:: warn` marker. It never fails a run and never
15727
+ * edits the block -- not every Debian package has a conda equivalent under the same name, so moving
15728
+ * it is a human's call.
15729
+ */
15730
+ function runWorkflowAptGate(targetDir) {
15731
+ const steps = findWorkflowAptSteps(targetDir);
15732
+ for (const step of steps) {
15733
+ const packages = step.packages.length > 0 ? step.packages.join(", ") : "packages";
15734
+ log$1.warn(`${step.file} installs ${packages} with apt in a custom block. The agent's containers cannot run that step, so its runs lack what CI has. Declare each tool in mise.toml as conda:<name> (both CI and the agent install from it), then remove the apt step.`);
15735
+ }
15736
+ return steps;
15737
+ }
15738
+ /**
15610
15739
  * Require a declared publish destination before any generator or apply step runs.
15611
15740
  * Returns the number of offenders (0 = clean). Each is emitted via `log.error` so
15612
15741
  * the user sees the full message and the fix.
@@ -15894,19 +16023,6 @@ function runToolchainGate(targetDir) {
15894
16023
  };
15895
16024
  }
15896
16025
  //#endregion
15897
- //#region src/utils/workflow-paths.ts
15898
- /**
15899
- * The single definition of "workflow dir", shared by `repo:sync --check`'s advisory
15900
- * classification and the workflow sync sweep's path allowlist -- so the two halves cannot
15901
- * disagree about what counts as workflow output. Each prefix carries its trailing slash so a
15902
- * file named e.g. `.github/workflowsX` never matches.
15903
- */
15904
- const WORKFLOW_DIR_PREFIXES = [".forgejo/workflows/", ".github/workflows/"];
15905
- /** True when `filePath` (repo-relative) lives under one of the workflow directories. */
15906
- function isWorkflowDirPath(filePath) {
15907
- return WORKFLOW_DIR_PREFIXES.some((prefix) => filePath.startsWith(prefix));
15908
- }
15909
- //#endregion
15910
16026
  //#region src/commands/repo-sync.ts
15911
16027
  const rawLog$1 = (msg) => console.log(msg);
15912
16028
  const syncCommand = defineCommand({
@@ -15946,7 +16062,7 @@ const syncCommand = defineCommand({
15946
16062
  },
15947
16063
  json: {
15948
16064
  type: "boolean",
15949
- description: "Emit one JSON document: status (up-to-date | drift | workflow-drift | failed), actionable, failed, toolchain, checkOnlyImages, warnCount, exitCode. --check only: the write mode prompts and inherits child streams."
16065
+ description: "Emit one JSON document: status (up-to-date | drift | workflow-drift | failed), actionable, failed, toolchain, checkOnlyImages, aptSteps, warnCount, exitCode. --check only: the write mode prompts and inherits child streams."
15950
16066
  }
15951
16067
  },
15952
16068
  async run({ args }) {
@@ -16023,6 +16139,7 @@ function gateFailure(toolchainWarned = false) {
16023
16139
  failed: [],
16024
16140
  toolchain: { warned: toolchainWarned },
16025
16141
  checkOnlyImages: [],
16142
+ aptSteps: [],
16026
16143
  warnCount: 0,
16027
16144
  exitCode: 1
16028
16145
  };
@@ -16033,8 +16150,8 @@ function gateFailure(toolchainWarned = false) {
16033
16150
  * withheld -- the same number rides in `warnCount`, and printing both would put a second thing on
16034
16151
  * the stream beside the payload.
16035
16152
  */
16036
- function upToDateReport(toolchain, checkOnlyImages) {
16037
- const warnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length;
16153
+ function upToDateReport(toolchain, checkOnlyImages, aptSteps) {
16154
+ const warnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length + aptSteps.length;
16038
16155
  if (warnCount > 0) {
16039
16156
  if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
16040
16157
  } else log$1.success("Repository is up to date.");
@@ -16044,6 +16161,7 @@ function upToDateReport(toolchain, checkOnlyImages) {
16044
16161
  failed: [],
16045
16162
  toolchain,
16046
16163
  checkOnlyImages,
16164
+ aptSteps,
16047
16165
  warnCount,
16048
16166
  exitCode: 0
16049
16167
  };
@@ -16079,28 +16197,30 @@ async function runCheckReport(targetDir) {
16079
16197
  toolchain: { warned: toolchain.warned }
16080
16198
  };
16081
16199
  const checkOnlyImages = runCheckOnlyImageGate(config, targetDir);
16200
+ const aptSteps = runWorkflowAptGate(targetDir);
16082
16201
  const { ctx, pendingWrites } = createDryRunContext(config);
16083
16202
  logDetectionSummary(ctx);
16084
16203
  const results = await runGenerators(ctx);
16085
16204
  const failed = failedResults(results);
16086
16205
  reportFailures(failed, "repo:sync");
16087
16206
  const actionable = actionableResults(results, pendingWrites, targetDir);
16088
- const toolchainWarnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length;
16207
+ const advisoryWarnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length + aptSteps.length;
16089
16208
  const toolchainState = { warned: toolchain.warned };
16090
- if (actionable.length === 0 && failed.length === 0) return upToDateReport(toolchainState, checkOnlyImages);
16209
+ if (actionable.length === 0 && failed.length === 0) return upToDateReport(toolchainState, checkOnlyImages, aptSteps);
16091
16210
  if (actionable.length === 0) return {
16092
16211
  status: "failed",
16093
16212
  actionable: [],
16094
16213
  failed,
16095
16214
  toolchain: toolchainState,
16096
16215
  checkOnlyImages: [],
16216
+ aptSteps: [],
16097
16217
  warnCount: 0,
16098
16218
  exitCode: 1
16099
16219
  };
16100
16220
  log$1.warn(`${actionable.length} file(s) would be changed by repo:sync`);
16101
16221
  for (const r of actionable) logActionableChange(r, targetDir, pendingWrites);
16102
16222
  if (failed.length === 0 && actionable.every((r) => isWorkflowDirPath(r.filePath))) {
16103
- const warnCount = actionable.length + toolchainWarnCount;
16223
+ const warnCount = actionable.length + advisoryWarnCount;
16104
16224
  if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
16105
16225
  return {
16106
16226
  status: "workflow-drift",
@@ -16108,6 +16228,7 @@ async function runCheckReport(targetDir) {
16108
16228
  failed: [],
16109
16229
  toolchain: toolchainState,
16110
16230
  checkOnlyImages,
16231
+ aptSteps,
16111
16232
  warnCount,
16112
16233
  exitCode: 0
16113
16234
  };
@@ -16118,6 +16239,7 @@ async function runCheckReport(targetDir) {
16118
16239
  failed,
16119
16240
  toolchain: toolchainState,
16120
16241
  checkOnlyImages: [],
16242
+ aptSteps: [],
16121
16243
  warnCount: 0,
16122
16244
  exitCode: 1
16123
16245
  };
@@ -16312,6 +16434,9 @@ const PullListItemSchema = z.object({
16312
16434
  updated_at: z.string().nullish(),
16313
16435
  labels: z.array(z.object({ name: z.string() })).nullish()
16314
16436
  });
16437
+ function updatedBefore(since) {
16438
+ return (pr) => pr.updatedAt !== null && Date.parse(pr.updatedAt) < since;
16439
+ }
16315
16440
  /**
16316
16441
  * Every pull request -- open, merged, and closed-unmerged -- by default, via `state=all`; a caller
16317
16442
  * may narrow the read with {@link ListPullsOptions.state}. Paged to an empty page or to
@@ -16339,7 +16464,9 @@ async function listPulls(executor, conn, options = {}) {
16339
16464
  limit: String(PULLS_PAGE_SIZE),
16340
16465
  page: String(page)
16341
16466
  });
16342
- if (options.sort !== void 0) params.set("sort", options.sort);
16467
+ const sort = options.updatedSince === void 0 ? options.sort : "recentupdate";
16468
+ if (sort !== void 0) params.set("sort", sort);
16469
+ for (const id of options.labelIds ?? []) params.append("labels", String(id));
16343
16470
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?${params.toString()}`;
16344
16471
  const res = await executor.fetch(url, {
16345
16472
  headers,
@@ -16368,6 +16495,7 @@ async function listPulls(executor, conn, options = {}) {
16368
16495
  pulls.push(...items);
16369
16496
  if (items.length === 0) break;
16370
16497
  if (options.stopWhen !== void 0 && items.some(options.stopWhen)) break;
16498
+ if (options.updatedSince !== void 0 && items.some(updatedBefore(options.updatedSince))) break;
16371
16499
  if (options.limit !== void 0 && pulls.length >= options.limit) break;
16372
16500
  if (page === PULLS_MAX_PAGES) {
16373
16501
  log.warn(`PR listing on ${conn.repository} hit the ${String(PULLS_MAX_PAGES)}-page cap without an empty page; results may be truncated`);
@@ -21125,7 +21253,7 @@ function createRealLintTypesExecutor() {
21125
21253
  }
21126
21254
  //#endregion
21127
21255
  //#region src/lint-types/phase.ts
21128
- const LINT_TYPES_PHASE = "report";
21256
+ const LINT_TYPES_PHASE = "enforce";
21129
21257
  //#endregion
21130
21258
  //#region src/lint-types/report.ts
21131
21259
  /** oxlint spells a rule code `plugin(rule)` (e.g. `typescript(no-base-to-string)`); the escape
@@ -21146,8 +21274,8 @@ function formatFindingLine(diagnostic) {
21146
21274
  return `${span?.line !== void 0 && span.column !== void 0 ? `${diagnostic.filename}:${String(span.line)}:${String(span.column)}` : diagnostic.filename} ${formatRuleCode(diagnostic.code)} ${diagnostic.message}`;
21147
21275
  }
21148
21276
  /**
21149
- * Names no date: the enforce release has none, and inventing one the fleet cannot meet is
21150
- * worse than naming none.
21277
+ * Prints only when the phase is pinned to "report". Names no date: inventing one the fleet
21278
+ * cannot meet is worse than naming none.
21151
21279
  */
21152
21280
  const CONSEQUENCE_LINE = "These will block once a future release of the tooling flips lint:types from the report phase to the enforce phase.";
21153
21281
  const ESCAPE_HATCHES = [" - Turn a rule off in this repo's own oxlint.config.ts (e.g. \"typescript/<rule>\": \"off\") -- plain `pnpm lint` ignores type-aware rules entirely, so this never weakens it.", " - Skip the step entirely with .tooling.json#checks.skip: [\"lint:types\"]."];
@@ -21351,7 +21479,7 @@ function runLintTypes(executor, targetDir, extraArgs = []) {
21351
21479
  const lintTypesCommand = defineCommand({
21352
21480
  meta: {
21353
21481
  name: "lint:types",
21354
- description: "Run oxlint --type-aware (backed by oxlint-tsgolint) and report every type-aware finding, selected by subtracting a plain oxlint run. Report phase: prints findings, states they will block once a future tooling release flips to the enforce phase, exits 0 unless .tooling.json#lint.strictTypeAware is set. When findings do not block, the last stdout line is the ::bst-checks:: warn marker."
21482
+ description: "Run oxlint --type-aware (backed by oxlint-tsgolint) and report every type-aware finding, selected by subtracting a plain oxlint run. Enforce phase: prints findings and exits 1 when there are any. .tooling.json#lint.strictTypeAware makes a missing oxlint-tsgolint binary a failure instead of a notice."
21355
21483
  },
21356
21484
  args: {
21357
21485
  dir: {
@@ -32831,7 +32959,7 @@ setForgejoLogger(log$1);
32831
32959
  * `local <sha>[-dirty]` otherwise -- so an artifact built from the working tree
32832
32960
  * can't pass itself off as the published release whose version it carries.
32833
32961
  */
32834
- const displayVersion = "0.86.0";
32962
+ const displayVersion = "0.87.0";
32835
32963
  /**
32836
32964
  * The registered `bst` subcommand map, exported so both the JSON entry path below (resolving a
32837
32965
  * command name before citty parses anything) and the coverage test (enumerating every