@bensandee/tooling 0.86.1 → 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.1"
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
@@ -4077,6 +4091,77 @@ function tracedRunners(logger) {
4077
4091
  function fileErrorCode(error) {
4078
4092
  if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
4079
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
+ }
4080
4165
  /**
4081
4166
  * Create an executor that runs real commands, fetches, and reads the filesystem. Subprocess and
4082
4167
  * network calls are traced through `logger` (default: the {@link rootLogger}); see
@@ -4093,24 +4178,7 @@ function createRealExecutor$1(logger = rootLogger) {
4093
4178
  return [];
4094
4179
  }
4095
4180
  },
4096
- listWorkspacePackages(cwd) {
4097
- const packagesDir = path.join(cwd, "packages");
4098
- const packages = [];
4099
- try {
4100
- for (const entry of readdirSync(packagesDir)) {
4101
- const pkgPath = path.join(packagesDir, entry, "package.json");
4102
- try {
4103
- const pkg = parsePackageJson(readFileSync(pkgPath, "utf-8"));
4104
- if (pkg?.name && pkg.version) packages.push({
4105
- name: pkg.name,
4106
- version: pkg.version,
4107
- dir: entry
4108
- });
4109
- } catch (_error) {}
4110
- }
4111
- } catch (_error) {}
4112
- return packages;
4113
- },
4181
+ listWorkspacePackages,
4114
4182
  listPackageDirs(cwd) {
4115
4183
  const packagesDir = path.join(cwd, "packages");
4116
4184
  try {
@@ -4140,59 +4208,7 @@ function createRealExecutor$1(logger = rootLogger) {
4140
4208
  return null;
4141
4209
  }
4142
4210
  },
4143
- readRegularFile(filePath, maxBytes) {
4144
- let fd;
4145
- try {
4146
- fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
4147
- } catch (error) {
4148
- const code = fileErrorCode(error);
4149
- if (code === "ENOENT") return { kind: "absent" };
4150
- if (code === "ELOOP") return {
4151
- kind: "refused",
4152
- reason: "symlink"
4153
- };
4154
- return {
4155
- kind: "refused",
4156
- reason: "unreadable"
4157
- };
4158
- }
4159
- try {
4160
- const stat = fstatSync(fd);
4161
- if (!stat.isFile()) return {
4162
- kind: "refused",
4163
- reason: "not-regular"
4164
- };
4165
- if (stat.size > maxBytes) return {
4166
- kind: "refused",
4167
- reason: "too-large"
4168
- };
4169
- const buffer = Buffer.alloc(maxBytes + 1);
4170
- let bytesRead;
4171
- try {
4172
- bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
4173
- } catch {
4174
- return {
4175
- kind: "refused",
4176
- reason: "unreadable"
4177
- };
4178
- }
4179
- if (bytesRead > maxBytes) return {
4180
- kind: "refused",
4181
- reason: "too-large"
4182
- };
4183
- return {
4184
- kind: "ok",
4185
- content: buffer.toString("utf8", 0, bytesRead)
4186
- };
4187
- } catch {
4188
- return {
4189
- kind: "refused",
4190
- reason: "unreadable"
4191
- };
4192
- } finally {
4193
- closeSync(fd);
4194
- }
4195
- },
4211
+ readRegularFile,
4196
4212
  writeFile(filePath, content) {
4197
4213
  mkdirSync(path.dirname(filePath), { recursive: true });
4198
4214
  writeFileSync(filePath, content);
@@ -5536,6 +5552,24 @@ function readPublishedImageVersion(executor, config, ref) {
5536
5552
  debug(config, `Unexpected manifest shape for ${ref}; no version label read`);
5537
5553
  }
5538
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
+ /**
5539
5573
  * Run the full Docker publish pipeline:
5540
5574
  * 1. Build all images via runDockerBuild
5541
5575
  * 2. Login to registry
@@ -5555,24 +5589,6 @@ function readPublishedImageVersion(executor, config, ref) {
5555
5589
  * overlapping runs that both read a ref before either writes can still finish in the wrong order --
5556
5590
  * seconds wide, where an unconditional push was open for a whole run.
5557
5591
  */
5558
- /**
5559
- * Name every check-only image this run is about to publish or has just left out -- one line
5560
- * per image with no explicit `publish` setting, since an explicit value either way is the
5561
- * repo having answered the question already. Under `warn` the image still goes out and the
5562
- * line is the notice that a later tooling release stops doing that; under `enforce` it did
5563
- * not go out and the line says so. A dry run takes both lines verbatim: it is the run a
5564
- * release rehearses, so a rehearsed phase has to read like the real one.
5565
- */
5566
- function reportCheckOnlyImages(packages, phase) {
5567
- for (const pkg of packages) {
5568
- if (pkg.checkOnlyIn === void 0 || pkg.docker.publish !== void 0) continue;
5569
- if (phase === "warn") {
5570
- 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.`);
5571
- continue;
5572
- }
5573
- 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.`);
5574
- }
5575
- }
5576
5592
  function runDockerPublish(executor, config) {
5577
5593
  const checkOnlyPhase = config.checkOnlyPhase ?? "warn";
5578
5594
  const { packages } = runDockerBuild(executor, {
@@ -5976,433 +5992,92 @@ function buildWorkflowYaml(options) {
5976
5992
  return ensureSchemaComment(WORKFLOW_MANAGED_HEADER + "\n" + readable, options.ci);
5977
5993
  }
5978
5994
  //#endregion
5979
- //#region src/release/publish/publish-config-lint.ts
5980
- 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() });
5981
5997
  /**
5982
- * Whether a value can serve as a publish destination: an absolute http(s) URL.
5983
- * Presence alone is not the test -- a relative or empty registry is as silently
5984
- * 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).
5985
6007
  */
5986
- function isUsableRegistry(value) {
5987
- if (typeof value !== "string" || value.trim() === "") return false;
5988
- try {
5989
- const url = new URL(value);
5990
- return url.protocol === "https:" || url.protocol === "http:";
5991
- } catch {
5992
- 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}`);
5993
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);
5994
6031
  }
5995
- function isRecord(value) {
5996
- return typeof value === "object" && value !== null && !Array.isArray(value);
5997
- }
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() });
5998
6039
  /**
5999
- * Require every package this repo would publish to declare where it publishes.
6000
- *
6001
- * npm and pnpm use one `registry` key for reads and writes alike, so a project
6002
- * `.npmrc` aimed at an install cache silently repoints `pnpm publish` too. A
6003
- * package that never declares its destination inherits one, and an inherited
6004
- * destination can be changed by a file that never mentions publishing.
6040
+ * Read the `deploy` block from .tooling.json, if configured.
6005
6041
  *
6006
- * Reports rather than repairs: the correct registry is not derivable here, and
6007
- * writing a guess would redirect a working release exactly the way this gate
6008
- * 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".
6009
6044
  */
6010
- function lintPublishConfig(targetDir, structure) {
6011
- const issues = [];
6012
- for (const pkg of getPublishablePackages(targetDir, structure)) {
6013
- const manifest = readPackageJson(pkg.dir);
6014
- if (!manifest) continue;
6015
- const file = path.relative(targetDir, path.join(pkg.dir, "package.json"));
6016
- const pc = manifest.publishConfig;
6017
- const registry = isRecord(pc) ? pc["registry"] : void 0;
6018
- if (registry === void 0) {
6019
- issues.push({
6020
- kind: "missing",
6021
- package: pkg.name,
6022
- file,
6023
- 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}.`
6024
- });
6025
- continue;
6026
- }
6027
- if (!isUsableRegistry(registry)) issues.push({
6028
- kind: "malformed",
6029
- package: pkg.name,
6030
- file,
6031
- 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}.`
6032
- });
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;
6033
6055
  }
6034
- return issues;
6035
6056
  }
6036
- /**
6037
- * The registries this repo's publishable packages actually declare, distinct and in
6038
- * first-seen order.
6039
- *
6040
- * Reuses the same "absolute http(s) URL" narrowing as {@link lintPublishConfig} rather
6041
- * than re-deriving it -- a registry this misses here would fail that gate first, so
6042
- * `repo:sync` never reaches a generator with an undeclared or malformed registry. A
6043
- * caller that invokes a generator directly (or a repo whose publishable set is empty,
6044
- * e.g. every package private) still gets a safe empty result.
6045
- */
6046
- function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
6047
- const registries = [];
6048
- for (const pkg of getPublishablePackages(targetDir, structure, rootPackageJson)) {
6049
- const manifest = structure === "single" ? rootPackageJson ?? readPackageJson(pkg.dir) : readPackageJson(pkg.dir);
6050
- if (!manifest) continue;
6051
- const pc = manifest.publishConfig;
6052
- const registry = isRecord(pc) ? pc["registry"] : void 0;
6053
- if (typeof registry === "string" && isUsableRegistry(registry) && !registries.includes(registry)) registries.push(registry);
6054
- }
6055
- return registries;
6056
- }
6057
- //#endregion
6058
- //#region src/generators/mise-pin.ts
6059
- /**
6060
- * The mise version and release-asset digest the fleet's CI and the agent runner base image agree
6061
- * to run. This is the one place that agreement is recorded; `packages/agent/test/runner-base-image-dockerfile.test.ts`
6062
- * (task 8 of the mise-toolchain-provisioning plan) reads both constants back through
6063
- * `readRepoFile` and asserts the base image's own `ARG MISE_VERSION` and `mise.sha256` manifest
6064
- * agree with them, so the two packages cannot drift without a failing test on either side.
6065
- *
6066
- * Each constant sits under its own `// renovate:` marker comment, in the three-line grammar
6067
- * `packages/agent/docker/agent-goose-image/goose.sha256` uses for its own digest line, so the same
6068
- * shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
6069
- * digest (`customManagers` entries scoped to this file).
6070
- */
6071
- const MISE_VERSION = "2026.10.2";
6072
- const MISE_LINUX_X64_SHA256 = "8f5f6660336f572830e33cd9b378d3131e529a0d4c4f0c553776be90a1ba302a";
6073
- /** Where generated Forgejo workflows fetch the shared mise setup action: a composite action in
6074
- * `bensandee/common`, on the forge every fleet repo lives on. GitHub cannot reach it; the
6075
- * generators keep inline steps there. */
6076
- const SETUP_MISE_ACTION_URL = "https://code.orangebikelabs.com/bensandee/common/actions/setup-mise";
6077
- /** The tag in `bensandee/common` that marks the current release of the action. Generated `uses:`
6078
- * lines carry it as their comment, and Renovate moves the SHA below to wherever it points. */
6079
- const SETUP_MISE_ACTION_TAG = "setup-mise@latest";
6080
- const SETUP_MISE_ACTION_SHA = "13f60e0f19ad57b5616f94c9bc18bf214bbe91fe";
6081
- //#endregion
6082
- //#region src/generators/ci-utils.ts
6083
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
6084
- function actionsExpr(expr) {
6085
- return `\${{ ${expr} }}`;
6086
- }
6087
- const MAIN_BRANCH_REF = "refs/heads/main";
6088
- /** `if:` condition that gates a step to push events on the default branch. */
6089
- const IS_MAIN_BRANCH = `github.ref == '${MAIN_BRANCH_REF}'`;
6090
- /**
6091
- * Negated form for concurrency `cancel-in-progress` and similar uses.
6092
- *
6093
- * Main is exempt from cancellation on purpose, and the exemption stays: a main run publishes and
6094
- * pushes tags, and cancelling one mid-flight can leave a partial publish -- some packages on the
6095
- * registry, no matching tags, and no run left alive to finish. A superseded feature-branch run
6096
- * wastes minutes; a half-published release costs a manual unwind.
6097
- *
6098
- * So several main runs can be in flight at once, each versioning from its own triggering commit.
6099
- * Overlap safety rests on one invariant: **main advances solely via commits that trigger a CI
6100
- * run.** The `[skip ci]` coverage, health, and egress records go to `bst/*` orphan branches and
6101
- * never to main; a `[skip ci]` commit pushed straight to main would leave a superseded run skipping
6102
- * and its changesets stranded until the next push, which is why the supersession guard skips rather
6103
- * than fails. Three guards carry the overlap itself: supersession plus a `--force-with-lease`
6104
- * version push (`release/publish/version.ts`), the `headBumpedVersion` publish guard plus reading
6105
- * remote tags and releases before acting (`commands/release-changesets.ts`,
6106
- * `release/publish/publish.ts`), and floating git and Docker tags that only move forward
6107
- * (`release/publish/floating-tags.ts`, `release/docker.ts`). Such a run may also version pending
6108
- * changesets after it publishes -- the stale version-PR merge -- under the same supersession guard
6109
- * and push lease as any other version run. A later commit publishes a bump only when its tag is
6110
- * missing and every main run since the package's last release tag has settled
6111
- * (`release/publish/unpublished-bump.ts`), so no run that could still publish it is in flight; a
6112
- * manual re-run of one of those runs is the overlap left. On Forgejo the concurrency block is
6113
- * only a PR-run cancellation mechanism -- it holds a main run in its group and, in Forgejo 16,
6114
- * intermittently fails to release it, which is why main runs get a group of their own there. GitHub's shape stays shared
6115
- * because its queue works: one run, one pending, a newer arrival replacing the pending, so
6116
- * `changesets/action@v1` never needs a guard of its own. Any new side effect in a main run must be
6117
- * idempotent under overlap or be listed here with the guard that makes it so.
6118
- */
6119
- const IS_NOT_MAIN_BRANCH = `github.ref != '${MAIN_BRANCH_REF}'`;
6120
- /**
6121
- * Job-level `env:` block that evaluates the default-branch check once, so steps
6122
- * can gate on `env.IS_MAIN` instead of repeating the `github.ref` expression.
6123
- */
6124
- const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
6125
- /** `if:` condition referencing the job-level `IS_MAIN` env var (see {@link IS_MAIN_JOB_ENV}). */
6126
- const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
6127
- /**
6128
- * Job-level default for the npm auth token, declared as the empty string.
6129
- *
6130
- * This repo's own `Configure npm auth` step (see {@link npmAuthWiring}) appends
6131
- * `//<registry>/:_authToken=${NODE_AUTH_TOKEN}` lines to `~/.npmrc` for each
6132
- * declared `publishConfig.registry`. Only the publishing step defines that
6133
- * variable, so every earlier pnpm invocation -- install, and each nested pnpm
6134
- * inside `ci:check` -- hits an undefined var and prints
6135
- * `[WARN] Failed to replace env in config: ${NODE_AUTH_TOKEN}`. Declaring it
6136
- * empty at job level makes the interpolation succeed and the warning vanish; an
6137
- * empty token is not sent as credentials, so anonymous registry reads are
6138
- * unaffected.
6139
- *
6140
- * Deliberately empty rather than the real secret: the publishing step overrides
6141
- * it with `secrets.NPM_TOKEN` via step-level `env`, which keeps the token out of
6142
- * `pnpm install` (and any dependency's postinstall script) the way it is today.
6143
- * The empty default also has to survive steps *after* the publish step
6144
- * (`release:assets`, `docker:publish`, deploy dispatch): those shell out to pnpm
6145
- * again without the secret in scope, so without this default they would warn too.
6146
- *
6147
- * Emit this in lockstep with the `Configure npm auth` step -- both come from the
6148
- * same {@link npmAuthWiring} call, so a job that gets one gets the other.
6149
- */
6150
- const NPM_AUTH_JOB_ENV = { NODE_AUTH_TOKEN: "" };
6151
- /**
6152
- * npm's own registry-key rule for an `.npmrc` auth line: strip the scheme, keep
6153
- * host/port/path, and end with exactly one slash. `actions/setup-node` derives the
6154
- * same key from a registry URL; this mirrors that so a hand-written line matches
6155
- * what npm looks up at publish time -- npm-registry-fetch walks the request path
6156
- * upward through ancestor keys until one matches.
6157
- */
6158
- function npmrcAuthKey(registryUrl) {
6159
- return `${registryUrl.replace(/^https?:/i, "").replace(/\/+$/, "")}/`;
6160
- }
6161
- const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
6162
- /**
6163
- * Build every half of a job's npm auth wiring together, so one value decides all
6164
- * three: the `Configure npm auth` step, the job-level `NODE_AUTH_TOKEN` default,
6165
- * and the step-level `NODE_AUTH_TOKEN` the publish step should carry. A job that
6166
- * gets none of the declared registries gets none of the three -- including a
6167
- * `publishes: true` job whose publishable set is empty (every package private),
6168
- * since there is then no npmrc line for a real secret to pair with.
6169
- */
6170
- function npmAuthWiring(ctx, publishes) {
6171
- const registries = publishes ? declaredPublishRegistries(ctx.targetDir, ctx.config.structure, ctx.packageJson) : [];
6172
- if (registries.length === 0) return {
6173
- steps: [],
6174
- jobEnv: {},
6175
- tokenEnv: {}
6176
- };
6177
- return {
6178
- steps: [{ step: {
6179
- name: "Configure npm auth",
6180
- run: registries.map((registry) => `printf '\\n%s\\n' '${npmrcAuthKey(registry)}:_authToken=${NODE_AUTH_TOKEN_VAR}' >> ~/.npmrc`).join("\n")
6181
- } }],
6182
- jobEnv: NPM_AUTH_JOB_ENV,
6183
- tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
6184
- };
6185
- }
6186
- const MISE_RELEASE_URL = `https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64`;
6187
- /**
6188
- * The node script `inlineMiseSteps` pipes `mise env --json` into, run under the mise it just
6189
- * installed. Reads the env map off stdin (mise exec passes it straight through) and exports every
6190
- * key except `PATH` into `$GITHUB_ENV` in heredoc form -- the form that survives any value,
6191
- * including one containing `$` or quotes -- then appends mise's shim directory and the
6192
- * `~/.local/bin` install location to `$GITHUB_PATH` so every later step sees node, pnpm, and any
6193
- * other declared tool on PATH with no prefix.
6194
- */
6195
- const MISE_ENV_EXPORT_SCRIPT = [
6196
- "const fs = require(\"fs\");",
6197
- "const env = JSON.parse(fs.readFileSync(0, \"utf8\"));",
6198
- "let out = \"\";",
6199
- "for (const [key, value] of Object.entries(env)) {",
6200
- " if (key === \"PATH\") continue;",
6201
- " out += key + \"<<BST_MISE_ENV_EOF\\n\" + value + \"\\nBST_MISE_ENV_EOF\\n\";",
6202
- "}",
6203
- "fs.appendFileSync(process.env.GITHUB_ENV, out);",
6204
- "fs.appendFileSync(",
6205
- " process.env.GITHUB_PATH,",
6206
- " process.env.HOME + \"/.local/share/mise/shims\\n\" + process.env.HOME + \"/.local/bin\\n\",",
6207
- ");"
6208
- ].join("\n");
6209
- /** A fresh object per call: one shared object in two jobs would serialize as a YAML alias. */
6210
- function pnpmStoreCacheStep() {
6211
- return { step: {
6212
- name: "Cache pnpm store",
6213
- uses: "actions/cache@v4",
6214
- with: {
6215
- path: "~/.local/share/pnpm/store",
6216
- key: `pnpm-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('pnpm-lock.yaml')")}`
6217
- }
6218
- } };
6219
- }
6220
- /** Actions whose ref the generator pins itself, so `preservePinnedActions` must not keep a
6221
- * repo's older ref for them. */
6222
- const GENERATOR_OWNED_ACTIONS = [{
6223
- name: SETUP_MISE_ACTION_URL,
6224
- comment: SETUP_MISE_ACTION_TAG
6225
- }];
6226
- /**
6227
- * The steps every workflow generator uses in place of `pnpm/action-setup` + `actions/setup-node`:
6228
- * install the pinned `mise`, `mise install` the repo's `mise.toml` toolchain, export its env into
6229
- * the rest of the job, and cache the pnpm store. `MISE_VERSION`/`MISE_LINUX_X64_SHA256`
6230
- * (`mise-pin.ts`) are the same pin the agent runner's base image installs, checked by
6231
- * `runner-base-image-dockerfile.test.ts`.
6232
- *
6233
- * On Forgejo the mise half is the shared composite action in `bensandee/common`, pinned by commit.
6234
- * GitHub resolves actions only from github.com, so it keeps the same steps inline.
6235
- */
6236
- function miseSetupSteps(ci) {
6237
- if (ci !== "forgejo") return inlineMiseSteps();
6238
- return [{ step: {
6239
- name: "Set up mise",
6240
- uses: `${SETUP_MISE_ACTION_URL}@${SETUP_MISE_ACTION_SHA}`,
6241
- with: {
6242
- version: MISE_VERSION,
6243
- sha256: MISE_LINUX_X64_SHA256
6244
- }
6245
- } }, pnpmStoreCacheStep()];
6246
- }
6247
- /**
6248
- * The inline form of the shared action, for GitHub. Uses `mise env --json` piped into the
6249
- * just-installed mise's own `node -e`, not `mise env --shell bash`: the JSON form is
6250
- * shell-independent and the interpreter reading it is guaranteed to exist (mise just installed
6251
- * it), so this needs no assumption about which shell the runner's `run:` steps use.
6252
- */
6253
- function inlineMiseSteps() {
6254
- const installScript = [
6255
- "curl -fsSL -o \"$RUNNER_TEMP/mise\" " + MISE_RELEASE_URL,
6256
- `echo "${MISE_LINUX_X64_SHA256} $RUNNER_TEMP/mise" | sha256sum -c`,
6257
- "mkdir -p \"$HOME/.local/bin\" && install -m 0755 \"$RUNNER_TEMP/mise\" \"$HOME/.local/bin/mise\"",
6258
- "export MISE_TRUSTED_CONFIG_PATHS=\"$GITHUB_WORKSPACE\"",
6259
- "export MISE_YES=1",
6260
- "\"$HOME/.local/bin/mise\" install",
6261
- `"$HOME/.local/bin/mise" env --json | "$HOME/.local/bin/mise" exec -- node -e '${MISE_ENV_EXPORT_SCRIPT}'`
6262
- ].join("\n");
6263
- return [
6264
- { step: {
6265
- name: "Cache mise tools",
6266
- uses: "actions/cache@v4",
6267
- with: {
6268
- path: "~/.local/share/mise",
6269
- key: `mise-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('mise.toml')")}`
6270
- }
6271
- } },
6272
- pnpmStoreCacheStep(),
6273
- { step: {
6274
- name: "Install mise",
6275
- env: { GITHUB_TOKEN: "" },
6276
- run: installScript
6277
- } }
6278
- ];
6279
- }
6280
- /**
6281
- * Build the docker publish env block.
6282
- *
6283
- * The LHS env-var names (`DOCKER_USERNAME` / `DOCKER_PASSWORD`) match what
6284
- * publish-docker.ts reads at runtime. The RHS secret references are
6285
- * registry-specific (see {@link dockerSecretNames}) so downstream repos can
6286
- * stop relying on the user/org-level `DOCKER_USERNAME` / `DOCKER_PASSWORD`
6287
- * secrets -- which the Forgejo act fork auto-injects into every `container:`
6288
- * image pull regardless of the target registry.
6289
- *
6290
- * When `host` is `null` (couldn't be derived at gen time), the secret references
6291
- * fall back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` and the
6292
- * workflow keeps `DOCKER_REGISTRY_HOST` / `DOCKER_REGISTRY_NAMESPACE` so the CLI
6293
- * resolves the registry at runtime.
6294
- */
6295
- function dockerPublishEnv(host) {
6296
- const { username, password } = dockerSecretNames(host);
6297
- return {
6298
- ...host === null ? {
6299
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
6300
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
6301
- } : {},
6302
- DOCKER_USERNAME: actionsExpr(`secrets.${username}`),
6303
- DOCKER_PASSWORD: actionsExpr(`secrets.${password}`),
6304
- RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
6305
- };
6306
- }
6307
- //#endregion
6308
- //#region src/generators/publish-ci.ts
6309
- const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
6310
- /**
6311
- * Resolve the docker-registry host at gen time so the workflow generator can
6312
- * pick registry-specific secret names. Honors `.tooling.json#registryHost`
6313
- * first, then git remote `origin` (the operational host, not a mirror-pointed
6314
- * package.json), then derives from `package.json#repository` using the same
6315
- * parser as the runtime (`parseGitUrl` handles https, git+https, and ssh shapes).
6316
- * Returns null if neither source yields a host -- in that case the generator
6317
- * falls back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` secrets and
6318
- * lets the runtime resolve the host from `DOCKER_REGISTRY_HOST` (still emitted
6319
- * into the step).
6320
- */
6321
- function resolveGenTimeHost(ctx) {
6322
- const toolingRaw = ctx.read(".tooling.json");
6323
- if (toolingRaw) try {
6324
- const parsed = RegistryHostSchema.safeParse(JSON.parse(toolingRaw));
6325
- if (parsed.success && parsed.data.registryHost && parsed.data.registryHost.length > 0) return parsed.data.registryHost;
6326
- } catch (error) {
6327
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6328
- }
6329
- const originUrl = detectGitRemoteUrl(ctx.targetDir);
6330
- if (originUrl !== null) {
6331
- const parsedOrigin = parseGitUrl(originUrl);
6332
- if (parsedOrigin) return registryHostFromHostname(parsedOrigin.hostname);
6333
- }
6334
- const raw = ctx.read("package.json");
6335
- if (!raw) return null;
6336
- const repo = parsePackageJson(raw)?.repository;
6337
- let urlStr = null;
6338
- if (typeof repo === "string") urlStr = repo;
6339
- else if (repo && typeof repo === "object") urlStr = repo.url;
6340
- if (typeof urlStr !== "string") return null;
6341
- const parsed = parseGitUrl(urlStr);
6342
- if (!parsed) return null;
6343
- return registryHostFromHostname(parsed.hostname);
6344
- }
6345
- function publishSteps(ci, host) {
6346
- return [
6347
- { step: { uses: "actions/checkout@v6" } },
6348
- ...miseSetupSteps(ci),
6349
- { step: { run: "pnpm install --frozen-lockfile" } },
6350
- { step: {
6351
- name: "Publish Docker images",
6352
- env: dockerPublishEnv(host),
6353
- run: "pnpm exec bst docker:publish"
6354
- } }
6355
- ];
6356
- }
6357
- const PostReleaseHookSchema = z.object({
6358
- postReleaseHook: z.string().optional(),
6359
- postReleaseHookSecrets: z.array(z.string()).optional()
6360
- });
6361
- const ReleaseAssetsSchema = z.object({ releaseAssets: z.array(ReleaseAssetSchema).optional() });
6362
- const VersionStampConfigSchema = z.object({ versionStamp: z.array(VersionStampSchema).optional() });
6363
- const DeploySchema = z.object({ deploy: DeployConfigSchema.optional() });
6364
- /**
6365
- * Read the `deploy` block from .tooling.json, if configured.
6366
- *
6367
- * The full file is already validated against ToolingConfigSchema at sync time,
6368
- * so an invalid `deploy` block fails earlier -- undefined here means "not set".
6369
- */
6370
- function getDeployConfig(ctx) {
6371
- const raw = ctx.read(".tooling.json");
6372
- if (!raw) return void 0;
6373
- try {
6374
- const result = DeploySchema.safeParse(JSON.parse(raw));
6375
- if (!result.success) return void 0;
6376
- return result.data.deploy;
6377
- } catch (error) {
6378
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6379
- return;
6380
- }
6381
- }
6382
- /** Read the postReleaseHook command override from .tooling.json, if configured. */
6383
- function getPostReleaseHookCommand(ctx) {
6384
- const raw = ctx.read(".tooling.json");
6385
- if (!raw) return void 0;
6386
- try {
6387
- const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6388
- if (!result.success) return void 0;
6389
- return result.data.postReleaseHook;
6390
- } catch (error) {
6391
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6392
- return;
6393
- }
6394
- }
6395
- /** Read the postReleaseHookSecrets array from .tooling.json, if configured. */
6396
- function getPostReleaseHookSecrets(ctx) {
6397
- const raw = ctx.read(".tooling.json");
6398
- if (!raw) return void 0;
6399
- try {
6400
- const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
6401
- if (!result.success) return void 0;
6402
- return result.data.postReleaseHookSecrets;
6403
- } catch (error) {
6404
- log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
6405
- 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;
6406
6081
  }
6407
6082
  }
6408
6083
  /**
@@ -6474,61 +6149,37 @@ function hasVersionStamp(ctx) {
6474
6149
  }
6475
6150
  /** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
6476
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
+ */
6477
6159
  async function generateDeployCi(ctx) {
6478
6160
  const filePath = "deploy-ci";
6479
- if (!ctx.config.publishDocker || ctx.config.ci === "none") return {
6161
+ if (ctx.config.ci === "none") return {
6480
6162
  filePath,
6481
6163
  action: "skipped",
6482
- description: "Deploy CI workflow not applicable"
6164
+ description: "Publish workflow not applicable"
6483
6165
  };
6484
- const isGitHub = ctx.config.ci === "github";
6485
- const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
6486
- if (ctx.config.releaseStrategy === "simple") {
6487
- if (ctx.read(workflowPath) !== void 0) {
6488
- ctx.remove(workflowPath);
6489
- return {
6490
- filePath: workflowPath,
6491
- action: "archived",
6492
- description: `Moved to ${ARCHIVE_DIR$2}/${workflowPath} (docker publish runs in release.yml for simple strategy)`
6493
- };
6494
- }
6495
- return {
6496
- filePath,
6497
- action: "skipped",
6498
- description: "Publish workflow not generated for simple release strategy"
6499
- };
6500
- }
6501
- const host = resolveGenTimeHost(ctx);
6502
- const steps = publishSteps(ctx.config.ci, host);
6503
- const content = buildWorkflowYaml({
6504
- ci: ctx.config.ci,
6505
- name: "Publish",
6506
- on: { workflow_dispatch: null },
6507
- jobName: "publish",
6508
- steps
6509
- });
6510
- const alreadyExists = ctx.exists(workflowPath);
6511
- const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
6512
- const withDevPath = applyWorkspaceToolingBuild(content, ctx.packageJson);
6513
- if (existing) {
6514
- if (isToolingIgnored(existing)) return {
6515
- filePath: workflowPath,
6516
- action: "skipped",
6517
- description: "Publish workflow has ignore comment"
6518
- };
6519
- if (existing.includes("Managed by @bensandee/tooling --") && normalizeWorkflow(existing) === normalizeWorkflow(withDevPath)) return {
6520
- filePath: workflowPath,
6521
- action: "skipped",
6522
- description: "Publish workflow already up to date"
6523
- };
6524
- }
6525
- const withPinnedActions = preservePinnedActions(withDevPath, existing, GENERATOR_OWNED_ACTIONS);
6526
- const final = existing ? insertCustomBlocks(withPinnedActions, extractCustomBlocks(existing)) : withPinnedActions;
6527
- 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);
6528
6179
  return {
6529
6180
  filePath: workflowPath,
6530
- action: alreadyExists ? "updated" : "created",
6531
- 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)`
6532
6183
  };
6533
6184
  }
6534
6185
  //#endregion
@@ -6923,7 +6574,7 @@ function perPackageDevDeps(config) {
6923
6574
  deps["typescript"] = {
6924
6575
  "@changesets/cli": "3.0.3",
6925
6576
  "@release-it/bumper": "8.0.1",
6926
- "@types/node": "24.13.5",
6577
+ "@types/node": "24.19.1",
6927
6578
  "@vitest/coverage-v8": "5.0.3",
6928
6579
  "commit-and-tag-version": "13.2.1",
6929
6580
  "knip": "6.39.0",
@@ -6937,12 +6588,12 @@ function perPackageDevDeps(config) {
6937
6588
  "tsdown": "0.23.0",
6938
6589
  "typescript": "6.0.3",
6939
6590
  "vitest": "5.0.3",
6940
- "pnpm": "11.27.1"
6591
+ "pnpm": "11.28.3"
6941
6592
  }["typescript"];
6942
6593
  deps["@types/node"] = {
6943
6594
  "@changesets/cli": "3.0.3",
6944
6595
  "@release-it/bumper": "8.0.1",
6945
- "@types/node": "24.13.5",
6596
+ "@types/node": "24.19.1",
6946
6597
  "@vitest/coverage-v8": "5.0.3",
6947
6598
  "commit-and-tag-version": "13.2.1",
6948
6599
  "knip": "6.39.0",
@@ -6956,13 +6607,13 @@ function perPackageDevDeps(config) {
6956
6607
  "tsdown": "0.23.0",
6957
6608
  "typescript": "6.0.3",
6958
6609
  "vitest": "5.0.3",
6959
- "pnpm": "11.27.1"
6610
+ "pnpm": "11.28.3"
6960
6611
  }["@types/node"];
6961
6612
  }
6962
6613
  if (config.builder === "tsdown") deps["tsdown"] = {
6963
6614
  "@changesets/cli": "3.0.3",
6964
6615
  "@release-it/bumper": "8.0.1",
6965
- "@types/node": "24.13.5",
6616
+ "@types/node": "24.19.1",
6966
6617
  "@vitest/coverage-v8": "5.0.3",
6967
6618
  "commit-and-tag-version": "13.2.1",
6968
6619
  "knip": "6.39.0",
@@ -6976,7 +6627,7 @@ function perPackageDevDeps(config) {
6976
6627
  "tsdown": "0.23.0",
6977
6628
  "typescript": "6.0.3",
6978
6629
  "vitest": "5.0.3",
6979
- "pnpm": "11.27.1"
6630
+ "pnpm": "11.28.3"
6980
6631
  }["tsdown"];
6981
6632
  return deps;
6982
6633
  }
@@ -6993,7 +6644,7 @@ function vitestDevDeps() {
6993
6644
  vitest: {
6994
6645
  "@changesets/cli": "3.0.3",
6995
6646
  "@release-it/bumper": "8.0.1",
6996
- "@types/node": "24.13.5",
6647
+ "@types/node": "24.19.1",
6997
6648
  "@vitest/coverage-v8": "5.0.3",
6998
6649
  "commit-and-tag-version": "13.2.1",
6999
6650
  "knip": "6.39.0",
@@ -7007,12 +6658,12 @@ function vitestDevDeps() {
7007
6658
  "tsdown": "0.23.0",
7008
6659
  "typescript": "6.0.3",
7009
6660
  "vitest": "5.0.3",
7010
- "pnpm": "11.27.1"
6661
+ "pnpm": "11.28.3"
7011
6662
  }["vitest"],
7012
6663
  "@vitest/coverage-v8": {
7013
6664
  "@changesets/cli": "3.0.3",
7014
6665
  "@release-it/bumper": "8.0.1",
7015
- "@types/node": "24.13.5",
6666
+ "@types/node": "24.19.1",
7016
6667
  "@vitest/coverage-v8": "5.0.3",
7017
6668
  "commit-and-tag-version": "13.2.1",
7018
6669
  "knip": "6.39.0",
@@ -7026,7 +6677,7 @@ function vitestDevDeps() {
7026
6677
  "tsdown": "0.23.0",
7027
6678
  "typescript": "6.0.3",
7028
6679
  "vitest": "5.0.3",
7029
- "pnpm": "11.27.1"
6680
+ "pnpm": "11.28.3"
7030
6681
  }["@vitest/coverage-v8"]
7031
6682
  };
7032
6683
  }
@@ -7040,7 +6691,7 @@ function rootDevDeps(config) {
7040
6691
  const deps = { lefthook: {
7041
6692
  "@changesets/cli": "3.0.3",
7042
6693
  "@release-it/bumper": "8.0.1",
7043
- "@types/node": "24.13.5",
6694
+ "@types/node": "24.19.1",
7044
6695
  "@vitest/coverage-v8": "5.0.3",
7045
6696
  "commit-and-tag-version": "13.2.1",
7046
6697
  "knip": "6.39.0",
@@ -7054,13 +6705,13 @@ function rootDevDeps(config) {
7054
6705
  "tsdown": "0.23.0",
7055
6706
  "typescript": "6.0.3",
7056
6707
  "vitest": "5.0.3",
7057
- "pnpm": "11.27.1"
6708
+ "pnpm": "11.28.3"
7058
6709
  }["lefthook"] };
7059
6710
  if (config.stack !== "none") {
7060
6711
  deps["knip"] = {
7061
6712
  "@changesets/cli": "3.0.3",
7062
6713
  "@release-it/bumper": "8.0.1",
7063
- "@types/node": "24.13.5",
6714
+ "@types/node": "24.19.1",
7064
6715
  "@vitest/coverage-v8": "5.0.3",
7065
6716
  "commit-and-tag-version": "13.2.1",
7066
6717
  "knip": "6.39.0",
@@ -7074,12 +6725,12 @@ function rootDevDeps(config) {
7074
6725
  "tsdown": "0.23.0",
7075
6726
  "typescript": "6.0.3",
7076
6727
  "vitest": "5.0.3",
7077
- "pnpm": "11.27.1"
6728
+ "pnpm": "11.28.3"
7078
6729
  }["knip"];
7079
6730
  deps["oxlint"] = {
7080
6731
  "@changesets/cli": "3.0.3",
7081
6732
  "@release-it/bumper": "8.0.1",
7082
- "@types/node": "24.13.5",
6733
+ "@types/node": "24.19.1",
7083
6734
  "@vitest/coverage-v8": "5.0.3",
7084
6735
  "commit-and-tag-version": "13.2.1",
7085
6736
  "knip": "6.39.0",
@@ -7093,14 +6744,14 @@ function rootDevDeps(config) {
7093
6744
  "tsdown": "0.23.0",
7094
6745
  "typescript": "6.0.3",
7095
6746
  "vitest": "5.0.3",
7096
- "pnpm": "11.27.1"
6747
+ "pnpm": "11.28.3"
7097
6748
  }["oxlint"];
7098
6749
  Object.assign(deps, vitestDevDeps());
7099
6750
  }
7100
6751
  if (config.stack === "typescript") deps["oxlint-tsgolint"] = {
7101
6752
  "@changesets/cli": "3.0.3",
7102
6753
  "@release-it/bumper": "8.0.1",
7103
- "@types/node": "24.13.5",
6754
+ "@types/node": "24.19.1",
7104
6755
  "@vitest/coverage-v8": "5.0.3",
7105
6756
  "commit-and-tag-version": "13.2.1",
7106
6757
  "knip": "6.39.0",
@@ -7114,7 +6765,7 @@ function rootDevDeps(config) {
7114
6765
  "tsdown": "0.23.0",
7115
6766
  "typescript": "6.0.3",
7116
6767
  "vitest": "5.0.3",
7117
- "pnpm": "11.27.1"
6768
+ "pnpm": "11.28.3"
7118
6769
  }["oxlint-tsgolint"];
7119
6770
  return deps;
7120
6771
  }
@@ -7145,7 +6796,7 @@ function addReleaseDeps(deps, config) {
7145
6796
  deps["release-it"] = {
7146
6797
  "@changesets/cli": "3.0.3",
7147
6798
  "@release-it/bumper": "8.0.1",
7148
- "@types/node": "24.13.5",
6799
+ "@types/node": "24.19.1",
7149
6800
  "@vitest/coverage-v8": "5.0.3",
7150
6801
  "commit-and-tag-version": "13.2.1",
7151
6802
  "knip": "6.39.0",
@@ -7159,12 +6810,12 @@ function addReleaseDeps(deps, config) {
7159
6810
  "tsdown": "0.23.0",
7160
6811
  "typescript": "6.0.3",
7161
6812
  "vitest": "5.0.3",
7162
- "pnpm": "11.27.1"
6813
+ "pnpm": "11.28.3"
7163
6814
  }["release-it"];
7164
6815
  if (config.structure === "monorepo") deps["@release-it/bumper"] = {
7165
6816
  "@changesets/cli": "3.0.3",
7166
6817
  "@release-it/bumper": "8.0.1",
7167
- "@types/node": "24.13.5",
6818
+ "@types/node": "24.19.1",
7168
6819
  "@vitest/coverage-v8": "5.0.3",
7169
6820
  "commit-and-tag-version": "13.2.1",
7170
6821
  "knip": "6.39.0",
@@ -7178,14 +6829,14 @@ function addReleaseDeps(deps, config) {
7178
6829
  "tsdown": "0.23.0",
7179
6830
  "typescript": "6.0.3",
7180
6831
  "vitest": "5.0.3",
7181
- "pnpm": "11.27.1"
6832
+ "pnpm": "11.28.3"
7182
6833
  }["@release-it/bumper"];
7183
6834
  break;
7184
6835
  case "simple":
7185
6836
  deps["commit-and-tag-version"] = {
7186
6837
  "@changesets/cli": "3.0.3",
7187
6838
  "@release-it/bumper": "8.0.1",
7188
- "@types/node": "24.13.5",
6839
+ "@types/node": "24.19.1",
7189
6840
  "@vitest/coverage-v8": "5.0.3",
7190
6841
  "commit-and-tag-version": "13.2.1",
7191
6842
  "knip": "6.39.0",
@@ -7199,13 +6850,13 @@ function addReleaseDeps(deps, config) {
7199
6850
  "tsdown": "0.23.0",
7200
6851
  "typescript": "6.0.3",
7201
6852
  "vitest": "5.0.3",
7202
- "pnpm": "11.27.1"
6853
+ "pnpm": "11.28.3"
7203
6854
  }["commit-and-tag-version"];
7204
6855
  break;
7205
6856
  case "changesets": deps["@changesets/cli"] = {
7206
6857
  "@changesets/cli": "3.0.3",
7207
6858
  "@release-it/bumper": "8.0.1",
7208
- "@types/node": "24.13.5",
6859
+ "@types/node": "24.19.1",
7209
6860
  "@vitest/coverage-v8": "5.0.3",
7210
6861
  "commit-and-tag-version": "13.2.1",
7211
6862
  "knip": "6.39.0",
@@ -7219,7 +6870,7 @@ function addReleaseDeps(deps, config) {
7219
6870
  "tsdown": "0.23.0",
7220
6871
  "typescript": "6.0.3",
7221
6872
  "vitest": "5.0.3",
7222
- "pnpm": "11.27.1"
6873
+ "pnpm": "11.28.3"
7223
6874
  }["@changesets/cli"];
7224
6875
  }
7225
6876
  }
@@ -7413,7 +7064,7 @@ function applyMetadataDefaults(pkg, ctx, changes) {
7413
7064
  const pinnedPnpm = {
7414
7065
  "@changesets/cli": "3.0.3",
7415
7066
  "@release-it/bumper": "8.0.1",
7416
- "@types/node": "24.13.5",
7067
+ "@types/node": "24.19.1",
7417
7068
  "@vitest/coverage-v8": "5.0.3",
7418
7069
  "commit-and-tag-version": "13.2.1",
7419
7070
  "knip": "6.39.0",
@@ -7427,7 +7078,7 @@ function applyMetadataDefaults(pkg, ctx, changes) {
7427
7078
  "tsdown": "0.23.0",
7428
7079
  "typescript": "6.0.3",
7429
7080
  "vitest": "5.0.3",
7430
- "pnpm": "11.27.1"
7081
+ "pnpm": "11.28.3"
7431
7082
  }["pnpm"];
7432
7083
  const desiredPm = pinnedPnpm ? `pnpm@${pinnedPnpm}` : void 0;
7433
7084
  if (desiredPm) {
@@ -7468,12 +7119,12 @@ function buildManagedDevDeps(ctx) {
7468
7119
  const devDeps = { ...rootDevDeps(ctx.config) };
7469
7120
  if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
7470
7121
  if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.14.5";
7471
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.86.1";
7472
- 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";
7473
7124
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
7474
7125
  "@changesets/cli": "3.0.3",
7475
7126
  "@release-it/bumper": "8.0.1",
7476
- "@types/node": "24.13.5",
7127
+ "@types/node": "24.19.1",
7477
7128
  "@vitest/coverage-v8": "5.0.3",
7478
7129
  "commit-and-tag-version": "13.2.1",
7479
7130
  "knip": "6.39.0",
@@ -7487,12 +7138,12 @@ function buildManagedDevDeps(ctx) {
7487
7138
  "tsdown": "0.23.0",
7488
7139
  "typescript": "6.0.3",
7489
7140
  "vitest": "5.0.3",
7490
- "pnpm": "11.27.1"
7141
+ "pnpm": "11.28.3"
7491
7142
  }["oxfmt"];
7492
7143
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = {
7493
7144
  "@changesets/cli": "3.0.3",
7494
7145
  "@release-it/bumper": "8.0.1",
7495
- "@types/node": "24.13.5",
7146
+ "@types/node": "24.19.1",
7496
7147
  "@vitest/coverage-v8": "5.0.3",
7497
7148
  "commit-and-tag-version": "13.2.1",
7498
7149
  "knip": "6.39.0",
@@ -7506,7 +7157,7 @@ function buildManagedDevDeps(ctx) {
7506
7157
  "tsdown": "0.23.0",
7507
7158
  "typescript": "6.0.3",
7508
7159
  "vitest": "5.0.3",
7509
- "pnpm": "11.27.1"
7160
+ "pnpm": "11.28.3"
7510
7161
  }["prettier"];
7511
7162
  addReleaseDeps(devDeps, ctx.config);
7512
7163
  return devDeps;
@@ -7562,7 +7213,7 @@ async function generatePackageJson(ctx) {
7562
7213
  packageManager: `pnpm@${{
7563
7214
  "@changesets/cli": "3.0.3",
7564
7215
  "@release-it/bumper": "8.0.1",
7565
- "@types/node": "24.13.5",
7216
+ "@types/node": "24.19.1",
7566
7217
  "@vitest/coverage-v8": "5.0.3",
7567
7218
  "commit-and-tag-version": "13.2.1",
7568
7219
  "knip": "6.39.0",
@@ -7576,7 +7227,7 @@ async function generatePackageJson(ctx) {
7576
7227
  "tsdown": "0.23.0",
7577
7228
  "typescript": "6.0.3",
7578
7229
  "vitest": "5.0.3",
7579
- "pnpm": "11.27.1"
7230
+ "pnpm": "11.28.3"
7580
7231
  }["pnpm"]}`
7581
7232
  };
7582
7233
  ctx.write(filePath, JSON.stringify(pkg, null, 2) + "\n");
@@ -8394,6 +8045,17 @@ const CONFIG_WITH_LINT_RULES = `import recommended from "@bensandee/config/oxlin
8394
8045
  import bensandee from "@bensandee/eslint-plugin/recommended";
8395
8046
  import { defineConfig } from "oxlint";
8396
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
+
8397
8059
  // Report phase: every plugin rule runs and reports, none blocks. The severities are derived
8398
8060
  // from the plugin's own rule names rather than listed, so a rule the plugin adds arrives the
8399
8061
  // day it publishes -- enumerating them is how the plugin came to be installed and never
@@ -8426,7 +8088,11 @@ function addIgnorePatterns(content) {
8426
8088
  return content.slice(0, insertPos) + ` ignorePatterns: [${IGNORE_PATTERNS_SPREAD}],\n` + content.slice(insertPos);
8427
8089
  }
8428
8090
  const PLUGIN_IMPORT = "import bensandee from \"@bensandee/eslint-plugin/recommended\";\n";
8429
- 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\]/;
8430
8096
  const RULES_KEY = /^ {2}"?rules"?: \{/m;
8431
8097
  const RULES_KEY_INLINE = /^ {2}"?rules"?\s*[,:]/m;
8432
8098
  const JS_PLUGINS_KEY = /^ {2}"?jsPlugins"?: \[/m;
@@ -8473,28 +8139,29 @@ function addBensandeePlugin(content) {
8473
8139
  const rulesKey = RULES_KEY.exec(rulesRegion);
8474
8140
  if (rulesKey) {
8475
8141
  const at = insertPos + rulesKey.index + rulesKey[0].length;
8476
- return withPlugins.slice(0, at) + `\n${REPORT_SPREAD}` + withPlugins.slice(at);
8142
+ return withPlugins.slice(0, at) + `\n${TIER_SPREAD}` + withPlugins.slice(at);
8477
8143
  }
8478
8144
  if (RULES_KEY_INLINE.test(rulesRegion)) return UNRECOGNIZED_SHAPE;
8479
- return withPlugins.slice(0, afterJsPlugins) + ` rules: {\n${REPORT_SPREAD} },\n` + withPlugins.slice(afterJsPlugins);
8480
- }
8481
- /** The exact literal `addBensandeePlugin` splices in when it creates a `rules` key from
8482
- * scratch -- checked before {@link REPORT_SPREAD}'s own merged-in-place form below, since that
8483
- * one is a substring of this one and would otherwise match first and leave the empty `rules: {
8484
- * },` shell behind. */
8485
- 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}`];
8486
8153
  const CREATED_JS_PLUGINS_LINE = " jsPlugins: bensandee.jsPlugins ?? [],\n";
8487
8154
  const MERGED_JS_PLUGINS_SPREAD = /\.\.\.\(bensandee\.jsPlugins \?\? \[\]\),\s+/;
8488
8155
  /**
8489
8156
  * The exact inverse of {@link addBensandeePlugin}: strips the import line and whichever of the
8490
- * two `jsPlugins` and two `rules` shapes it spliced in, by removing those same literals rather
8491
- * than re-deriving positions. Returns `undefined` when there is nothing to remove (the content
8492
- * does not name the plugin -- keeps a re-sync a no-op), and {@link UNRECOGNIZED_SHAPE} when it
8493
- * does but none of the expected literals are present -- a hand-edit since injection, a shape
8494
- * this tool never injected, or (for the merged `jsPlugins` spread only) a formatter reflow
8495
- * wider than {@link MERGED_JS_PLUGINS_SPREAD} tolerates -- declined whole, the same rule
8496
- * `addBensandeePlugin` itself follows, rather than guessing at a partial removal that could
8497
- * 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.
8498
8165
  */
8499
8166
  function removeBensandeePlugin(content) {
8500
8167
  if (!content.includes("@bensandee/eslint-plugin")) return void 0;
@@ -8503,10 +8170,17 @@ function removeBensandeePlugin(content) {
8503
8170
  if (result.includes(CREATED_JS_PLUGINS_LINE)) result = result.replace(CREATED_JS_PLUGINS_LINE, "");
8504
8171
  else if (MERGED_JS_PLUGINS_SPREAD.test(result)) result = result.replace(MERGED_JS_PLUGINS_SPREAD, "");
8505
8172
  else return UNRECOGNIZED_SHAPE;
8506
- if (result.includes(CREATED_RULES_BLOCK)) result = result.replace(CREATED_RULES_BLOCK, "");
8507
- else if (result.includes(`\n${REPORT_SPREAD}`)) result = result.replace(`\n${REPORT_SPREAD}`, "");
8508
- else return UNRECOGNIZED_SHAPE;
8509
- 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}`);
8510
8184
  }
8511
8185
  async function generateOxlint(ctx) {
8512
8186
  const filePath = "oxlint.config.ts";
@@ -8524,7 +8198,7 @@ async function generateOxlint(ctx) {
8524
8198
  action: "skipped",
8525
8199
  description: "Already configured"
8526
8200
  };
8527
- 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))) {
8528
8202
  ctx.write(filePath, content);
8529
8203
  return {
8530
8204
  filePath,
@@ -8537,8 +8211,14 @@ async function generateOxlint(ctx) {
8537
8211
  let pluginResult;
8538
8212
  let withPlugin;
8539
8213
  if (ctx.config.useEslintPlugin) {
8540
- pluginResult = addBensandeePlugin(withIgnores);
8541
- 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
+ };
8542
8222
  } else {
8543
8223
  pluginResult = removeBensandeePlugin(withIgnores);
8544
8224
  withPlugin = typeof pluginResult === "string" ? pluginResult : withIgnores;
@@ -9676,89 +9356,406 @@ async function generateGitignore(ctx) {
9676
9356
  ...OPTIONAL_ENTRIES,
9677
9357
  ...conditionalEntries(ctx)
9678
9358
  ];
9679
- const existing = ctx.read(filePath);
9680
- if (existing) {
9681
- const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
9682
- const missing = allEntries.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9683
- if (missing.length === 0) return {
9684
- filePath,
9685
- action: "skipped",
9686
- description: "Already has all standard entries"
9687
- };
9688
- const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
9689
- const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
9690
- ctx.write(filePath, updated);
9691
- if (missingRequired.length === 0) return {
9692
- filePath,
9693
- action: "skipped",
9694
- description: "Only optional entries missing"
9695
- };
9696
- return {
9697
- filePath,
9698
- action: "updated",
9699
- description: `Appended ${String(missing.length)} missing entries`
9700
- };
9701
- }
9702
- 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
+ ];
9703
9635
  return {
9704
- filePath,
9705
- action: "created",
9706
- description: "Generated .gitignore"
9636
+ publishRun: (command) => [...setup, command].join("\n"),
9637
+ tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
9707
9638
  };
9708
9639
  }
9709
- //#endregion
9710
- //#region src/generators/gitattributes.ts
9711
- 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");
9712
- /** Placed only when prepending above a consumer's existing content -- explains, in the file
9713
- * itself, why the rule goes first rather than last (the code has no other way to show it). */
9714
- 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");
9715
- 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`;
9716
9641
  /**
9717
- * True when `content` already carries a rule equivalent to `* text=auto eol=lf`: a
9718
- * non-comment line whose first whitespace-separated token is exactly `*` and whose
9719
- * remaining tokens include both `text=auto` and `eol=lf`, in either order. Token-based so
9720
- * reordered or extra-whitespace variants still count, but a narrower pattern (`*.ts ...`)
9721
- * 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.
9722
9648
  */
9723
- function hasLfRule(content) {
9724
- return content.split("\n").some((line) => {
9725
- const trimmed = line.trim();
9726
- if (trimmed.startsWith("#")) return false;
9727
- const tokens = trimmed.split(/\s+/).filter(Boolean);
9728
- if (tokens[0] !== "*") return false;
9729
- const rest = new Set(tokens.slice(1));
9730
- return rest.has("text=auto") && rest.has("eol=lf");
9731
- });
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
+ } };
9732
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
+ }];
9733
9680
  /**
9734
- * Scaffold `.gitattributes` with `* text=auto eol=lf`, the rule that makes the working tree
9735
- * LF regardless of a developer's global `core.autocrlf`/`core.eol`. No managed-file header
9736
- * from `utils/generated-header.ts` -- this file is merged into like `.gitignore`, not
9737
- * wholesale-regenerated. The rule is required, not optional: a missing rule is
9738
- * 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.
9739
9689
  */
9740
- async function generateGitattributes(ctx) {
9741
- const filePath = ".gitattributes";
9742
- const existing = ctx.read(filePath);
9743
- if (existing === void 0 || existing.trim() === "") {
9744
- ctx.write(filePath, `${MANAGED_HEADER}\n${RULE_LINE}\n`);
9745
- return {
9746
- filePath,
9747
- action: existing === void 0 ? "created" : "updated",
9748
- description: "Generated .gitattributes"
9749
- };
9750
- }
9751
- if (hasLfRule(existing)) return {
9752
- filePath,
9753
- action: "skipped",
9754
- description: "Already has the LF rule"
9755
- };
9756
- const updated = `${MANAGED_HEADER}\n${PREPEND_REASON}\n${RULE_LINE}\n\n${existing}`;
9757
- 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);
9758
9751
  return {
9759
- filePath,
9760
- action: "updated",
9761
- 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'")
9762
9759
  };
9763
9760
  }
9764
9761
  //#endregion
@@ -9813,7 +9810,7 @@ function postReleaseHookJob(opts) {
9813
9810
  needs: opts.needsJob,
9814
9811
  if: opts.publishCondition,
9815
9812
  "continue-on-error": true,
9816
- steps: [...commonSteps(opts.ci, []), { step: {
9813
+ steps: [...commonSteps(opts.ci), { step: {
9817
9814
  name: "Post-release hook",
9818
9815
  env: {
9819
9816
  ...hookSecretEnv(opts.secrets),
@@ -9852,21 +9849,20 @@ function deployDispatchStep(opts) {
9852
9849
  } };
9853
9850
  }
9854
9851
  /** Common setup steps shared by release-it and simple strategies. */
9855
- function commonSteps(ci, authSteps) {
9852
+ function commonSteps(ci) {
9856
9853
  return [
9857
9854
  { step: {
9858
9855
  uses: "actions/checkout@v6",
9859
9856
  with: { "fetch-depth": 0 }
9860
9857
  } },
9861
9858
  ...miseSetupSteps(ci),
9862
- ...authSteps,
9863
9859
  { step: { run: "pnpm install --frozen-lockfile" } }
9864
9860
  ];
9865
9861
  }
9866
9862
  function releaseItSteps(ci, npmAuth) {
9867
9863
  const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
9868
- return [...commonSteps(ci, npmAuth.steps), { step: {
9869
- run: "pnpm release-it --ci",
9864
+ return [...commonSteps(ci), { step: {
9865
+ run: npmAuth.publishRun("pnpm release-it --ci"),
9870
9866
  env: {
9871
9867
  ...tokenEnv,
9872
9868
  ...npmAuth.tokenEnv,
@@ -9917,7 +9913,7 @@ function simpleReleaseEnv() {
9917
9913
  };
9918
9914
  }
9919
9915
  function simpleReleaseSteps(ctx) {
9920
- const { ci, npmAuth, hasDocker, hasAssets, host } = ctx;
9916
+ const { ci, hasDocker, hasAssets, host } = ctx;
9921
9917
  const releaseEnv = connectionEnv(ci);
9922
9918
  const pushedIf = "steps.release.outputs.pushed == 'true'";
9923
9919
  const releaseStep = { step: {
@@ -9943,7 +9939,7 @@ function simpleReleaseSteps(ctx) {
9943
9939
  run: "pnpm exec bst docker:publish"
9944
9940
  } };
9945
9941
  return [
9946
- ...commonSteps(ci, npmAuth.steps),
9942
+ ...commonSteps(ci),
9947
9943
  releaseStep,
9948
9944
  ...hasAssets ? [assetsStep] : [],
9949
9945
  ...hasDocker ? [dockerStep] : []
@@ -10026,7 +10022,6 @@ async function generateReleaseCi(ctx) {
10026
10022
  on,
10027
10023
  ...isGitHub && { permissions: { contents: "write" } },
10028
10024
  jobName: "release",
10029
- ...Object.keys(npmAuth.jobEnv).length > 0 && { jobEnv: npmAuth.jobEnv },
10030
10025
  ...jobOutputs && { jobOutputs },
10031
10026
  steps,
10032
10027
  ...extraJobs && { extraJobs }
@@ -10099,7 +10094,7 @@ function healthSteps(includeHistory) {
10099
10094
  } }];
10100
10095
  }
10101
10096
  /** Build the release step for the check job (changesets strategy). */
10102
- function changesetsReleaseStep(ci, publishesNpm, tokenEnv) {
10097
+ function changesetsReleaseStep(ci, publishesNpm, npmAuth) {
10103
10098
  if (ci === "github") {
10104
10099
  const changesetsEnv = {
10105
10100
  GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
@@ -10123,10 +10118,10 @@ function changesetsReleaseStep(ci, publishesNpm, tokenEnv) {
10123
10118
  FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
10124
10119
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
10125
10120
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
10126
- ...tokenEnv,
10121
+ ...npmAuth.tokenEnv,
10127
10122
  RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
10128
10123
  },
10129
- run: "pnpm exec bst release:changesets"
10124
+ run: npmAuth.publishRun("pnpm exec bst release:changesets")
10130
10125
  } };
10131
10126
  }
10132
10127
  /**
@@ -10159,7 +10154,7 @@ function ciConcurrency(ci) {
10159
10154
  "cancel-in-progress": actionsExpr(IS_NOT_MAIN_BRANCH)
10160
10155
  };
10161
10156
  }
10162
- function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
10157
+ function checkSteps(ci, coverageEnabled, emitDockerSweepStep) {
10163
10158
  const checkCommand = coverageEnabled ? "pnpm ci:check" : "pnpm ci:check --no-coverage";
10164
10159
  return [
10165
10160
  { step: {
@@ -10167,7 +10162,6 @@ function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
10167
10162
  with: { "fetch-depth": 0 }
10168
10163
  } },
10169
10164
  ...miseSetupSteps(ci),
10170
- ...authSteps,
10171
10165
  { step: { run: "pnpm install --frozen-lockfile" } },
10172
10166
  ...emitDockerSweepStep ? [{ step: {
10173
10167
  name: "Sweep leaked Docker resources",
@@ -10220,10 +10214,10 @@ async function generateCi(ctx) {
10220
10214
  }
10221
10215
  const npmAuth = npmAuthWiring(ctx, isChangesets && publishesNpm && !isGitHub);
10222
10216
  const steps = [
10223
- ...checkSteps(ctx.config.ci, npmAuth.steps, coverage.enabled, hasDockerPackages(ctx)),
10217
+ ...checkSteps(ctx.config.ci, coverage.enabled, hasDockerPackages(ctx)),
10224
10218
  ...emitCoverageSteps ? coverageHistorySteps() : [],
10225
10219
  ...healthSteps(emitHealthHistory),
10226
- ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth.tokenEnv)] : [],
10220
+ ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth)] : [],
10227
10221
  ...isChangesets && isGitHub ? [githubChangesetsDeriveTagStep] : [],
10228
10222
  ...hasAssets ? [{ step: {
10229
10223
  name: "Upload Release Assets",
@@ -10261,10 +10255,7 @@ async function generateCi(ctx) {
10261
10255
  secrets: hookSecrets
10262
10256
  }) };
10263
10257
  }
10264
- const checkJobEnv = {
10265
- ...emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {},
10266
- ...npmAuth.jobEnv
10267
- };
10258
+ const checkJobEnv = emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {};
10268
10259
  const checksSlice = parseToolingConfigSlice(ctx.read(".tooling.json"), z.object({ checks: ChecksConfigSchema.optional() }), { block: "checks" });
10269
10260
  const skipOnVersionPr = isChangesets && checksSlice?.checks?.runOnVersionPr !== true;
10270
10261
  const content = buildWorkflowYaml({
@@ -12381,7 +12372,7 @@ function webUiGuideBody() {
12381
12372
  const mwg = `pnpm dlx modern-web-guidance@${{
12382
12373
  "@changesets/cli": "3.0.3",
12383
12374
  "@release-it/bumper": "8.0.1",
12384
- "@types/node": "24.13.5",
12375
+ "@types/node": "24.19.1",
12385
12376
  "@vitest/coverage-v8": "5.0.3",
12386
12377
  "commit-and-tag-version": "13.2.1",
12387
12378
  "knip": "6.39.0",
@@ -12395,7 +12386,7 @@ function webUiGuideBody() {
12395
12386
  "tsdown": "0.23.0",
12396
12387
  "typescript": "6.0.3",
12397
12388
  "vitest": "5.0.3",
12398
- "pnpm": "11.27.1"
12389
+ "pnpm": "11.28.3"
12399
12390
  }["modern-web-guidance"]}`;
12400
12391
  return [
12401
12392
  "# Web UI",
@@ -13002,7 +12993,7 @@ function reportFailures(failed, command) {
13002
12993
  function generateMigratePrompt(results, config, detected) {
13003
12994
  const sections = [];
13004
12995
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
13005
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.86.1 repo:sync\` on ${timestamp}_\n`);
12996
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.0 repo:sync\` on ${timestamp}_\n`);
13006
12997
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
13007
12998
  if (changed.length > 0) {
13008
12999
  const items = changed.map((r) => {
@@ -13988,7 +13979,9 @@ function createFileReader() {
13988
13979
  * (`commands/sync-gates.ts`) -- see that gate's doc comment for why an absent mise warns rather
13989
13980
  * than fails -- plus, when `publishDocker` is on, one more per image only the check overlay
13990
13981
  * declares (`runCheckOnlyImageGate`, same file): an advisory that a later tooling release stops
13991
- * 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.
13992
13985
  */
13993
13986
  const CHECKS_MARKER = "::bst-checks::";
13994
13987
  const WARN_LINE = new RegExp(`^${CHECKS_MARKER} warn (\\d+)\\s*$`);
@@ -15560,6 +15553,113 @@ const runChecksCommand = defineCommand({
15560
15553
  }
15561
15554
  });
15562
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
15563
15663
  //#region src/commands/sync-gates.ts
15564
15664
  /**
15565
15665
  * The pre-generator gates and config diagnostics every `repo:sync` mode runs -- split out of
@@ -15619,6 +15719,23 @@ function runCheckOnlyImageGate(config, targetDir) {
15619
15719
  return images;
15620
15720
  }
15621
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
+ /**
15622
15739
  * Require a declared publish destination before any generator or apply step runs.
15623
15740
  * Returns the number of offenders (0 = clean). Each is emitted via `log.error` so
15624
15741
  * the user sees the full message and the fix.
@@ -15906,19 +16023,6 @@ function runToolchainGate(targetDir) {
15906
16023
  };
15907
16024
  }
15908
16025
  //#endregion
15909
- //#region src/utils/workflow-paths.ts
15910
- /**
15911
- * The single definition of "workflow dir", shared by `repo:sync --check`'s advisory
15912
- * classification and the workflow sync sweep's path allowlist -- so the two halves cannot
15913
- * disagree about what counts as workflow output. Each prefix carries its trailing slash so a
15914
- * file named e.g. `.github/workflowsX` never matches.
15915
- */
15916
- const WORKFLOW_DIR_PREFIXES = [".forgejo/workflows/", ".github/workflows/"];
15917
- /** True when `filePath` (repo-relative) lives under one of the workflow directories. */
15918
- function isWorkflowDirPath(filePath) {
15919
- return WORKFLOW_DIR_PREFIXES.some((prefix) => filePath.startsWith(prefix));
15920
- }
15921
- //#endregion
15922
16026
  //#region src/commands/repo-sync.ts
15923
16027
  const rawLog$1 = (msg) => console.log(msg);
15924
16028
  const syncCommand = defineCommand({
@@ -15958,7 +16062,7 @@ const syncCommand = defineCommand({
15958
16062
  },
15959
16063
  json: {
15960
16064
  type: "boolean",
15961
- 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."
15962
16066
  }
15963
16067
  },
15964
16068
  async run({ args }) {
@@ -16035,6 +16139,7 @@ function gateFailure(toolchainWarned = false) {
16035
16139
  failed: [],
16036
16140
  toolchain: { warned: toolchainWarned },
16037
16141
  checkOnlyImages: [],
16142
+ aptSteps: [],
16038
16143
  warnCount: 0,
16039
16144
  exitCode: 1
16040
16145
  };
@@ -16045,8 +16150,8 @@ function gateFailure(toolchainWarned = false) {
16045
16150
  * withheld -- the same number rides in `warnCount`, and printing both would put a second thing on
16046
16151
  * the stream beside the payload.
16047
16152
  */
16048
- function upToDateReport(toolchain, checkOnlyImages) {
16049
- 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;
16050
16155
  if (warnCount > 0) {
16051
16156
  if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
16052
16157
  } else log$1.success("Repository is up to date.");
@@ -16056,6 +16161,7 @@ function upToDateReport(toolchain, checkOnlyImages) {
16056
16161
  failed: [],
16057
16162
  toolchain,
16058
16163
  checkOnlyImages,
16164
+ aptSteps,
16059
16165
  warnCount,
16060
16166
  exitCode: 0
16061
16167
  };
@@ -16091,28 +16197,30 @@ async function runCheckReport(targetDir) {
16091
16197
  toolchain: { warned: toolchain.warned }
16092
16198
  };
16093
16199
  const checkOnlyImages = runCheckOnlyImageGate(config, targetDir);
16200
+ const aptSteps = runWorkflowAptGate(targetDir);
16094
16201
  const { ctx, pendingWrites } = createDryRunContext(config);
16095
16202
  logDetectionSummary(ctx);
16096
16203
  const results = await runGenerators(ctx);
16097
16204
  const failed = failedResults(results);
16098
16205
  reportFailures(failed, "repo:sync");
16099
16206
  const actionable = actionableResults(results, pendingWrites, targetDir);
16100
- const toolchainWarnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length;
16207
+ const advisoryWarnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length + aptSteps.length;
16101
16208
  const toolchainState = { warned: toolchain.warned };
16102
- if (actionable.length === 0 && failed.length === 0) return upToDateReport(toolchainState, checkOnlyImages);
16209
+ if (actionable.length === 0 && failed.length === 0) return upToDateReport(toolchainState, checkOnlyImages, aptSteps);
16103
16210
  if (actionable.length === 0) return {
16104
16211
  status: "failed",
16105
16212
  actionable: [],
16106
16213
  failed,
16107
16214
  toolchain: toolchainState,
16108
16215
  checkOnlyImages: [],
16216
+ aptSteps: [],
16109
16217
  warnCount: 0,
16110
16218
  exitCode: 1
16111
16219
  };
16112
16220
  log$1.warn(`${actionable.length} file(s) would be changed by repo:sync`);
16113
16221
  for (const r of actionable) logActionableChange(r, targetDir, pendingWrites);
16114
16222
  if (failed.length === 0 && actionable.every((r) => isWorkflowDirPath(r.filePath))) {
16115
- const warnCount = actionable.length + toolchainWarnCount;
16223
+ const warnCount = actionable.length + advisoryWarnCount;
16116
16224
  if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
16117
16225
  return {
16118
16226
  status: "workflow-drift",
@@ -16120,6 +16228,7 @@ async function runCheckReport(targetDir) {
16120
16228
  failed: [],
16121
16229
  toolchain: toolchainState,
16122
16230
  checkOnlyImages,
16231
+ aptSteps,
16123
16232
  warnCount,
16124
16233
  exitCode: 0
16125
16234
  };
@@ -16130,6 +16239,7 @@ async function runCheckReport(targetDir) {
16130
16239
  failed,
16131
16240
  toolchain: toolchainState,
16132
16241
  checkOnlyImages: [],
16242
+ aptSteps: [],
16133
16243
  warnCount: 0,
16134
16244
  exitCode: 1
16135
16245
  };
@@ -16324,6 +16434,9 @@ const PullListItemSchema = z.object({
16324
16434
  updated_at: z.string().nullish(),
16325
16435
  labels: z.array(z.object({ name: z.string() })).nullish()
16326
16436
  });
16437
+ function updatedBefore(since) {
16438
+ return (pr) => pr.updatedAt !== null && Date.parse(pr.updatedAt) < since;
16439
+ }
16327
16440
  /**
16328
16441
  * Every pull request -- open, merged, and closed-unmerged -- by default, via `state=all`; a caller
16329
16442
  * may narrow the read with {@link ListPullsOptions.state}. Paged to an empty page or to
@@ -16351,7 +16464,9 @@ async function listPulls(executor, conn, options = {}) {
16351
16464
  limit: String(PULLS_PAGE_SIZE),
16352
16465
  page: String(page)
16353
16466
  });
16354
- 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));
16355
16470
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?${params.toString()}`;
16356
16471
  const res = await executor.fetch(url, {
16357
16472
  headers,
@@ -16380,6 +16495,7 @@ async function listPulls(executor, conn, options = {}) {
16380
16495
  pulls.push(...items);
16381
16496
  if (items.length === 0) break;
16382
16497
  if (options.stopWhen !== void 0 && items.some(options.stopWhen)) break;
16498
+ if (options.updatedSince !== void 0 && items.some(updatedBefore(options.updatedSince))) break;
16383
16499
  if (options.limit !== void 0 && pulls.length >= options.limit) break;
16384
16500
  if (page === PULLS_MAX_PAGES) {
16385
16501
  log.warn(`PR listing on ${conn.repository} hit the ${String(PULLS_MAX_PAGES)}-page cap without an empty page; results may be truncated`);
@@ -21137,7 +21253,7 @@ function createRealLintTypesExecutor() {
21137
21253
  }
21138
21254
  //#endregion
21139
21255
  //#region src/lint-types/phase.ts
21140
- const LINT_TYPES_PHASE = "report";
21256
+ const LINT_TYPES_PHASE = "enforce";
21141
21257
  //#endregion
21142
21258
  //#region src/lint-types/report.ts
21143
21259
  /** oxlint spells a rule code `plugin(rule)` (e.g. `typescript(no-base-to-string)`); the escape
@@ -21158,8 +21274,8 @@ function formatFindingLine(diagnostic) {
21158
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}`;
21159
21275
  }
21160
21276
  /**
21161
- * Names no date: the enforce release has none, and inventing one the fleet cannot meet is
21162
- * 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.
21163
21279
  */
21164
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.";
21165
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\"]."];
@@ -21363,7 +21479,7 @@ function runLintTypes(executor, targetDir, extraArgs = []) {
21363
21479
  const lintTypesCommand = defineCommand({
21364
21480
  meta: {
21365
21481
  name: "lint:types",
21366
- 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."
21367
21483
  },
21368
21484
  args: {
21369
21485
  dir: {
@@ -32843,7 +32959,7 @@ setForgejoLogger(log$1);
32843
32959
  * `local <sha>[-dirty]` otherwise -- so an artifact built from the working tree
32844
32960
  * can't pass itself off as the published release whose version it carries.
32845
32961
  */
32846
- const displayVersion = "0.86.1";
32962
+ const displayVersion = "0.87.0";
32847
32963
  /**
32848
32964
  * The registered `bst` subcommand map, exported so both the JSON entry path below (resolving a
32849
32965
  * command name before citty parses anything) and the coverage test (enumerating every