@bensandee/tooling 0.86.1 → 0.87.1
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/README.md +3 -3
- package/dist/bin.mjs +822 -528
- package/package.json +3 -3
- package/tooling.schema.json +16 -1
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.
|
|
272
|
+
version: "0.87.1"
|
|
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
|
|
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." }),
|
|
@@ -3036,57 +3050,6 @@ function loadRegistryHostOverride(cwd) {
|
|
|
3036
3050
|
}
|
|
3037
3051
|
}
|
|
3038
3052
|
//#endregion
|
|
3039
|
-
//#region src/utils/generated-header.ts
|
|
3040
|
-
/**
|
|
3041
|
-
* Leading comment headers for fully generated files.
|
|
3042
|
-
*
|
|
3043
|
-
* These mark a file as managed by `@bensandee/tooling` so a reader knows that
|
|
3044
|
-
* `bst repo:sync` may overwrite it, and points at the supported ways to keep
|
|
3045
|
-
* custom content. Only used for files that are largely (re)generated wholesale
|
|
3046
|
-
* -- not for files that are merged into (package.json, tsconfig.json, etc.).
|
|
3047
|
-
*/
|
|
3048
|
-
/**
|
|
3049
|
-
* Stable substring present in every managed-file header. Used to detect whether an
|
|
3050
|
-
* existing file already carries the *current* header -- a comment-only difference is
|
|
3051
|
-
* invisible to `normalizeWorkflow()` (it strips comments before comparing), so generators
|
|
3052
|
-
* check for this marker to know a one-time header upgrade is still needed.
|
|
3053
|
-
*
|
|
3054
|
-
* The trailing `--` is load-bearing: it is what made the 2026-07-27 ASCII fix reach files
|
|
3055
|
-
* that were already synced. Widen this marker whenever the header's leading text changes,
|
|
3056
|
-
* or the change lands in the template and never in anyone's repo.
|
|
3057
|
-
*/
|
|
3058
|
-
const MANAGED_HEADER_MARKER = "Managed by @bensandee/tooling --";
|
|
3059
|
-
/** Render a comment block: each line is prefixed with `comment`; empty lines stay bare. */
|
|
3060
|
-
function managedFileHeader(comment, lines) {
|
|
3061
|
-
return lines.map((line) => line === "" ? comment : `${comment} ${line}`).join("\n") + "\n";
|
|
3062
|
-
}
|
|
3063
|
-
/**
|
|
3064
|
-
* Header for generated CI workflow files (`ci.yml`, `release.yml`, `publish.yml`).
|
|
3065
|
-
*
|
|
3066
|
-
* The literal `@bensandee/tooling:ignore` directive is kept mid-line on purpose so
|
|
3067
|
-
* `isToolingIgnored()` (which looks for the directive at the start of a comment) does
|
|
3068
|
-
* not mistake this header for an opt-out.
|
|
3069
|
-
*/
|
|
3070
|
-
const WORKFLOW_MANAGED_HEADER = managedFileHeader("#", [
|
|
3071
|
-
"Managed by @bensandee/tooling -- `bst repo:sync` regenerates this workflow,",
|
|
3072
|
-
"so direct edits are overwritten on the next sync.",
|
|
3073
|
-
"To preserve changes, wrap them between `# @tooling:custom` and",
|
|
3074
|
-
"`# @tooling:endcustom` markers, or stop managing this file by making",
|
|
3075
|
-
"`# @bensandee/tooling:ignore` the first line."
|
|
3076
|
-
]);
|
|
3077
|
-
/** Header for the generated `lefthook.yml` pre-commit config. */
|
|
3078
|
-
const LEFTHOOK_MANAGED_HEADER = managedFileHeader("#", [
|
|
3079
|
-
"Managed by @bensandee/tooling -- `bst repo:sync` keeps the `lint` and",
|
|
3080
|
-
"`format` pre-commit commands current. Other commands you add are preserved.",
|
|
3081
|
-
"To stop managing this file, make `# @bensandee/tooling:ignore` the first line."
|
|
3082
|
-
]);
|
|
3083
|
-
/**
|
|
3084
|
-
* Header for a generated agent guide (`.agent/guides/*.md`). One line, because guide ownership
|
|
3085
|
-
* is decided by the marker on the first line alone. It spells the ignore directive out in words
|
|
3086
|
-
* rather than as the literal comment, which would close this one early.
|
|
3087
|
-
*/
|
|
3088
|
-
const GUIDE_MANAGED_HEADER = "<!-- Managed by @bensandee/tooling -- `bst repo:sync` rewrites this guide and archives local edits. To keep your own version, make the first line an HTML comment reading @bensandee/tooling:ignore. -->\n";
|
|
3089
|
-
//#endregion
|
|
3090
3053
|
//#region src/utils/debug.ts
|
|
3091
3054
|
const verboseLogger = createLogger({ level: "trace" });
|
|
3092
3055
|
/** Check if verbose/debug mode is enabled via environment variables. */
|
|
@@ -4077,6 +4040,77 @@ function tracedRunners(logger) {
|
|
|
4077
4040
|
function fileErrorCode(error) {
|
|
4078
4041
|
if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
|
|
4079
4042
|
}
|
|
4043
|
+
function listWorkspacePackages(cwd) {
|
|
4044
|
+
const packagesDir = path.join(cwd, "packages");
|
|
4045
|
+
const packages = [];
|
|
4046
|
+
try {
|
|
4047
|
+
for (const entry of readdirSync(packagesDir)) {
|
|
4048
|
+
const pkgPath = path.join(packagesDir, entry, "package.json");
|
|
4049
|
+
try {
|
|
4050
|
+
const pkg = parsePackageJson(readFileSync(pkgPath, "utf-8"));
|
|
4051
|
+
if (pkg?.name && pkg.version) packages.push({
|
|
4052
|
+
name: pkg.name,
|
|
4053
|
+
version: pkg.version,
|
|
4054
|
+
dir: entry
|
|
4055
|
+
});
|
|
4056
|
+
} catch (_error) {}
|
|
4057
|
+
}
|
|
4058
|
+
} catch (_error) {}
|
|
4059
|
+
return packages;
|
|
4060
|
+
}
|
|
4061
|
+
function readRegularFile(filePath, maxBytes) {
|
|
4062
|
+
let fd;
|
|
4063
|
+
try {
|
|
4064
|
+
fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
4065
|
+
} catch (error) {
|
|
4066
|
+
const code = fileErrorCode(error);
|
|
4067
|
+
if (code === "ENOENT") return { kind: "absent" };
|
|
4068
|
+
if (code === "ELOOP") return {
|
|
4069
|
+
kind: "refused",
|
|
4070
|
+
reason: "symlink"
|
|
4071
|
+
};
|
|
4072
|
+
return {
|
|
4073
|
+
kind: "refused",
|
|
4074
|
+
reason: "unreadable"
|
|
4075
|
+
};
|
|
4076
|
+
}
|
|
4077
|
+
try {
|
|
4078
|
+
const stat = fstatSync(fd);
|
|
4079
|
+
if (!stat.isFile()) return {
|
|
4080
|
+
kind: "refused",
|
|
4081
|
+
reason: "not-regular"
|
|
4082
|
+
};
|
|
4083
|
+
if (stat.size > maxBytes) return {
|
|
4084
|
+
kind: "refused",
|
|
4085
|
+
reason: "too-large"
|
|
4086
|
+
};
|
|
4087
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
4088
|
+
let bytesRead;
|
|
4089
|
+
try {
|
|
4090
|
+
bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
|
4091
|
+
} catch {
|
|
4092
|
+
return {
|
|
4093
|
+
kind: "refused",
|
|
4094
|
+
reason: "unreadable"
|
|
4095
|
+
};
|
|
4096
|
+
}
|
|
4097
|
+
if (bytesRead > maxBytes) return {
|
|
4098
|
+
kind: "refused",
|
|
4099
|
+
reason: "too-large"
|
|
4100
|
+
};
|
|
4101
|
+
return {
|
|
4102
|
+
kind: "ok",
|
|
4103
|
+
content: buffer.toString("utf8", 0, bytesRead)
|
|
4104
|
+
};
|
|
4105
|
+
} catch {
|
|
4106
|
+
return {
|
|
4107
|
+
kind: "refused",
|
|
4108
|
+
reason: "unreadable"
|
|
4109
|
+
};
|
|
4110
|
+
} finally {
|
|
4111
|
+
closeSync(fd);
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4080
4114
|
/**
|
|
4081
4115
|
* Create an executor that runs real commands, fetches, and reads the filesystem. Subprocess and
|
|
4082
4116
|
* network calls are traced through `logger` (default: the {@link rootLogger}); see
|
|
@@ -4093,24 +4127,7 @@ function createRealExecutor$1(logger = rootLogger) {
|
|
|
4093
4127
|
return [];
|
|
4094
4128
|
}
|
|
4095
4129
|
},
|
|
4096
|
-
listWorkspacePackages
|
|
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
|
-
},
|
|
4130
|
+
listWorkspacePackages,
|
|
4114
4131
|
listPackageDirs(cwd) {
|
|
4115
4132
|
const packagesDir = path.join(cwd, "packages");
|
|
4116
4133
|
try {
|
|
@@ -4140,59 +4157,7 @@ function createRealExecutor$1(logger = rootLogger) {
|
|
|
4140
4157
|
return null;
|
|
4141
4158
|
}
|
|
4142
4159
|
},
|
|
4143
|
-
readRegularFile
|
|
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
|
-
},
|
|
4160
|
+
readRegularFile,
|
|
4196
4161
|
writeFile(filePath, content) {
|
|
4197
4162
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
4198
4163
|
writeFileSync(filePath, content);
|
|
@@ -5536,6 +5501,24 @@ function readPublishedImageVersion(executor, config, ref) {
|
|
|
5536
5501
|
debug(config, `Unexpected manifest shape for ${ref}; no version label read`);
|
|
5537
5502
|
}
|
|
5538
5503
|
/**
|
|
5504
|
+
* Name every check-only image this run is about to publish or has just left out -- one line
|
|
5505
|
+
* per image with no explicit `publish` setting, since an explicit value either way is the
|
|
5506
|
+
* repo having answered the question already. Under `warn` the image still goes out and the
|
|
5507
|
+
* line is the notice that a later tooling release stops doing that; under `enforce` it did
|
|
5508
|
+
* not go out and the line says so. A dry run takes both lines verbatim: it is the run a
|
|
5509
|
+
* release rehearses, so a rehearsed phase has to read like the real one.
|
|
5510
|
+
*/
|
|
5511
|
+
function reportCheckOnlyImages(packages, phase) {
|
|
5512
|
+
for (const pkg of packages) {
|
|
5513
|
+
if (pkg.checkOnlyIn === void 0 || pkg.docker.publish !== void 0) continue;
|
|
5514
|
+
if (phase === "warn") {
|
|
5515
|
+
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.`);
|
|
5516
|
+
continue;
|
|
5517
|
+
}
|
|
5518
|
+
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.`);
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
/**
|
|
5539
5522
|
* Run the full Docker publish pipeline:
|
|
5540
5523
|
* 1. Build all images via runDockerBuild
|
|
5541
5524
|
* 2. Login to registry
|
|
@@ -5555,24 +5538,6 @@ function readPublishedImageVersion(executor, config, ref) {
|
|
|
5555
5538
|
* overlapping runs that both read a ref before either writes can still finish in the wrong order --
|
|
5556
5539
|
* seconds wide, where an unconditional push was open for a whole run.
|
|
5557
5540
|
*/
|
|
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
5541
|
function runDockerPublish(executor, config) {
|
|
5577
5542
|
const checkOnlyPhase = config.checkOnlyPhase ?? "warn";
|
|
5578
5543
|
const { packages } = runDockerBuild(executor, {
|
|
@@ -5668,6 +5633,136 @@ function contextAsDockerReader(ctx) {
|
|
|
5668
5633
|
};
|
|
5669
5634
|
}
|
|
5670
5635
|
//#endregion
|
|
5636
|
+
//#region src/release/publish/publish-config-lint.ts
|
|
5637
|
+
const FIX = `add "publishConfig": { "registry": "<your registry URL>" } to its package.json`;
|
|
5638
|
+
/**
|
|
5639
|
+
* Whether a value can serve as a publish destination: an absolute http(s) URL.
|
|
5640
|
+
* Presence alone is not the test -- a relative or empty registry is as silently
|
|
5641
|
+
* wrong as an absent one, and npm resolves neither to the place the author meant.
|
|
5642
|
+
*/
|
|
5643
|
+
function isUsableRegistry(value) {
|
|
5644
|
+
if (typeof value !== "string" || value.trim() === "") return false;
|
|
5645
|
+
try {
|
|
5646
|
+
const url = new URL(value);
|
|
5647
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
5648
|
+
} catch {
|
|
5649
|
+
return false;
|
|
5650
|
+
}
|
|
5651
|
+
}
|
|
5652
|
+
function isRecord(value) {
|
|
5653
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5654
|
+
}
|
|
5655
|
+
/**
|
|
5656
|
+
* Require every package this repo would publish to declare where it publishes.
|
|
5657
|
+
*
|
|
5658
|
+
* npm and pnpm use one `registry` key for reads and writes alike, so a project
|
|
5659
|
+
* `.npmrc` aimed at an install cache silently repoints `pnpm publish` too. A
|
|
5660
|
+
* package that never declares its destination inherits one, and an inherited
|
|
5661
|
+
* destination can be changed by a file that never mentions publishing.
|
|
5662
|
+
*
|
|
5663
|
+
* Reports rather than repairs: the correct registry is not derivable here, and
|
|
5664
|
+
* writing a guess would redirect a working release exactly the way this gate
|
|
5665
|
+
* exists to prevent.
|
|
5666
|
+
*/
|
|
5667
|
+
function lintPublishConfig(targetDir, structure) {
|
|
5668
|
+
const issues = [];
|
|
5669
|
+
for (const pkg of getPublishablePackages(targetDir, structure)) {
|
|
5670
|
+
const manifest = readPackageJson(pkg.dir);
|
|
5671
|
+
if (!manifest) continue;
|
|
5672
|
+
const file = path.relative(targetDir, path.join(pkg.dir, "package.json"));
|
|
5673
|
+
const pc = manifest.publishConfig;
|
|
5674
|
+
const registry = isRecord(pc) ? pc["registry"] : void 0;
|
|
5675
|
+
if (registry === void 0) {
|
|
5676
|
+
issues.push({
|
|
5677
|
+
kind: "missing",
|
|
5678
|
+
package: pkg.name,
|
|
5679
|
+
file,
|
|
5680
|
+
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}.`
|
|
5681
|
+
});
|
|
5682
|
+
continue;
|
|
5683
|
+
}
|
|
5684
|
+
if (!isUsableRegistry(registry)) issues.push({
|
|
5685
|
+
kind: "malformed",
|
|
5686
|
+
package: pkg.name,
|
|
5687
|
+
file,
|
|
5688
|
+
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}.`
|
|
5689
|
+
});
|
|
5690
|
+
}
|
|
5691
|
+
return issues;
|
|
5692
|
+
}
|
|
5693
|
+
/**
|
|
5694
|
+
* The registries this repo's publishable packages actually declare, distinct and in
|
|
5695
|
+
* first-seen order.
|
|
5696
|
+
*
|
|
5697
|
+
* Reuses the same "absolute http(s) URL" narrowing as {@link lintPublishConfig} rather
|
|
5698
|
+
* than re-deriving it -- a registry this misses here would fail that gate first, so
|
|
5699
|
+
* `repo:sync` never reaches a generator with an undeclared or malformed registry. A
|
|
5700
|
+
* caller that invokes a generator directly (or a repo whose publishable set is empty,
|
|
5701
|
+
* e.g. every package private) still gets a safe empty result.
|
|
5702
|
+
*/
|
|
5703
|
+
function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
|
|
5704
|
+
const registries = [];
|
|
5705
|
+
for (const pkg of getPublishablePackages(targetDir, structure, rootPackageJson)) {
|
|
5706
|
+
const manifest = structure === "single" ? rootPackageJson ?? readPackageJson(pkg.dir) : readPackageJson(pkg.dir);
|
|
5707
|
+
if (!manifest) continue;
|
|
5708
|
+
const pc = manifest.publishConfig;
|
|
5709
|
+
const registry = isRecord(pc) ? pc["registry"] : void 0;
|
|
5710
|
+
if (typeof registry === "string" && isUsableRegistry(registry) && !registries.includes(registry)) registries.push(registry);
|
|
5711
|
+
}
|
|
5712
|
+
return registries;
|
|
5713
|
+
}
|
|
5714
|
+
//#endregion
|
|
5715
|
+
//#region src/utils/generated-header.ts
|
|
5716
|
+
/**
|
|
5717
|
+
* Leading comment headers for fully generated files.
|
|
5718
|
+
*
|
|
5719
|
+
* These mark a file as managed by `@bensandee/tooling` so a reader knows that
|
|
5720
|
+
* `bst repo:sync` may overwrite it, and points at the supported ways to keep
|
|
5721
|
+
* custom content. Only used for files that are largely (re)generated wholesale
|
|
5722
|
+
* -- not for files that are merged into (package.json, tsconfig.json, etc.).
|
|
5723
|
+
*/
|
|
5724
|
+
/**
|
|
5725
|
+
* Stable substring present in every managed-file header. Used to detect whether an
|
|
5726
|
+
* existing file already carries the *current* header -- a comment-only difference is
|
|
5727
|
+
* invisible to `normalizeWorkflow()` (it strips comments before comparing), so generators
|
|
5728
|
+
* check for this marker to know a one-time header upgrade is still needed.
|
|
5729
|
+
*
|
|
5730
|
+
* The trailing `--` is load-bearing: it is what made the 2026-07-27 ASCII fix reach files
|
|
5731
|
+
* that were already synced. Widen this marker whenever the header's leading text changes,
|
|
5732
|
+
* or the change lands in the template and never in anyone's repo.
|
|
5733
|
+
*/
|
|
5734
|
+
const MANAGED_HEADER_MARKER = "Managed by @bensandee/tooling --";
|
|
5735
|
+
/** Render a comment block: each line is prefixed with `comment`; empty lines stay bare. */
|
|
5736
|
+
function managedFileHeader(comment, lines) {
|
|
5737
|
+
return lines.map((line) => line === "" ? comment : `${comment} ${line}`).join("\n") + "\n";
|
|
5738
|
+
}
|
|
5739
|
+
/**
|
|
5740
|
+
* Header for generated CI workflow files (`ci.yml`, `release.yml`).
|
|
5741
|
+
*
|
|
5742
|
+
* The literal `@bensandee/tooling:ignore` directive is kept mid-line on purpose so
|
|
5743
|
+
* `isToolingIgnored()` (which looks for the directive at the start of a comment) does
|
|
5744
|
+
* not mistake this header for an opt-out.
|
|
5745
|
+
*/
|
|
5746
|
+
const WORKFLOW_MANAGED_HEADER = managedFileHeader("#", [
|
|
5747
|
+
"Managed by @bensandee/tooling -- `bst repo:sync` regenerates this workflow,",
|
|
5748
|
+
"so direct edits are overwritten on the next sync.",
|
|
5749
|
+
"To preserve changes, wrap them between `# @tooling:custom` and",
|
|
5750
|
+
"`# @tooling:endcustom` markers, or stop managing this file by making",
|
|
5751
|
+
"`# @bensandee/tooling:ignore` the first line."
|
|
5752
|
+
]);
|
|
5753
|
+
/** Header for the generated `lefthook.yml` pre-commit config. */
|
|
5754
|
+
const LEFTHOOK_MANAGED_HEADER = managedFileHeader("#", [
|
|
5755
|
+
"Managed by @bensandee/tooling -- `bst repo:sync` keeps the `lint` and",
|
|
5756
|
+
"`format` pre-commit commands current. Other commands you add are preserved.",
|
|
5757
|
+
"To stop managing this file, make `# @bensandee/tooling:ignore` the first line."
|
|
5758
|
+
]);
|
|
5759
|
+
/**
|
|
5760
|
+
* Header for a generated agent guide (`.agent/guides/*.md`). One line, because guide ownership
|
|
5761
|
+
* is decided by the marker on the first line alone. It spells the ignore directive out in words
|
|
5762
|
+
* rather than as the literal comment, which would close this one early.
|
|
5763
|
+
*/
|
|
5764
|
+
const GUIDE_MANAGED_HEADER = "<!-- Managed by @bensandee/tooling -- `bst repo:sync` rewrites this guide and archives local edits. To keep your own version, make the first line an HTML comment reading @bensandee/tooling:ignore. -->\n";
|
|
5765
|
+
//#endregion
|
|
5671
5766
|
//#region src/utils/yaml-merge.ts
|
|
5672
5767
|
const IGNORE_PATTERN = "@bensandee/tooling:ignore";
|
|
5673
5768
|
const CUSTOM_START = "# @tooling:custom";
|
|
@@ -5930,129 +6025,50 @@ function extractPinnedActions(existing) {
|
|
|
5930
6025
|
* generator's step objects cannot carry. Preserving them would leave every repo on the commit it
|
|
5931
6026
|
* first received.
|
|
5932
6027
|
*/
|
|
5933
|
-
function preservePinnedActions(generated, existing, owned = []) {
|
|
5934
|
-
let result = generated;
|
|
5935
|
-
for (const { name, comment } of owned) {
|
|
5936
|
-
const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@(\S+)(?:[ \t]+#[^\n]*)?$`, "gm");
|
|
5937
|
-
result = result.replace(pattern, `$1${name}@$2 # ${comment}`);
|
|
5938
|
-
}
|
|
5939
|
-
if (!existing) return result;
|
|
5940
|
-
const pins = extractPinnedActions(existing);
|
|
5941
|
-
if (pins.size === 0) return result;
|
|
5942
|
-
for (const [name, tail] of pins) {
|
|
5943
|
-
if (owned.some((action) => action.name === name)) continue;
|
|
5944
|
-
const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@\S+(?:[ \t]+#[^\n]*)?$`, "gm");
|
|
5945
|
-
result = result.replace(pattern, `$1${name}@${tail}`);
|
|
5946
|
-
}
|
|
5947
|
-
return result;
|
|
5948
|
-
}
|
|
5949
|
-
/** Build a complete workflow YAML string from structured options. Single source of truth for workflow files. */
|
|
5950
|
-
function buildWorkflowYaml(options) {
|
|
5951
|
-
const doc = { name: options.name };
|
|
5952
|
-
if (options.enableEmailNotifications) doc["enable-email-notifications"] = true;
|
|
5953
|
-
doc["on"] = options.on;
|
|
5954
|
-
if (options.permissions) doc["permissions"] = options.permissions;
|
|
5955
|
-
if (options.concurrency) doc["concurrency"] = options.concurrency;
|
|
5956
|
-
const jobs = { [options.jobName]: {
|
|
5957
|
-
...options.jobIf && { if: options.jobIf },
|
|
5958
|
-
"runs-on": options.runsOn ?? "ubuntu-latest",
|
|
5959
|
-
...options.jobEnv && { env: options.jobEnv },
|
|
5960
|
-
...options.jobOutputs && { outputs: options.jobOutputs },
|
|
5961
|
-
steps: options.steps.map((s) => s.step)
|
|
5962
|
-
} };
|
|
5963
|
-
for (const [name, def] of Object.entries(options.extraJobs ?? {})) jobs[name] = {
|
|
5964
|
-
...def.needs && { needs: def.needs },
|
|
5965
|
-
...def.if && { if: def.if },
|
|
5966
|
-
...def["continue-on-error"] && { "continue-on-error": true },
|
|
5967
|
-
"runs-on": def.runsOn ?? "ubuntu-latest",
|
|
5968
|
-
...def.env && { env: def.env },
|
|
5969
|
-
steps: def.steps.map((s) => s.step)
|
|
5970
|
-
};
|
|
5971
|
-
doc["jobs"] = jobs;
|
|
5972
|
-
const readable = stringify(doc, {
|
|
5973
|
-
lineWidth: 0,
|
|
5974
|
-
nullStr: ""
|
|
5975
|
-
}).replace(/^(?=\S)/gm, (match, offset) => offset === 0 ? match : `\n${match}`);
|
|
5976
|
-
return ensureSchemaComment(WORKFLOW_MANAGED_HEADER + "\n" + readable, options.ci);
|
|
5977
|
-
}
|
|
5978
|
-
//#endregion
|
|
5979
|
-
//#region src/release/publish/publish-config-lint.ts
|
|
5980
|
-
const FIX = `add "publishConfig": { "registry": "<your registry URL>" } to its package.json`;
|
|
5981
|
-
/**
|
|
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.
|
|
5985
|
-
*/
|
|
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;
|
|
5993
|
-
}
|
|
5994
|
-
}
|
|
5995
|
-
function isRecord(value) {
|
|
5996
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5997
|
-
}
|
|
5998
|
-
/**
|
|
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.
|
|
6005
|
-
*
|
|
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.
|
|
6009
|
-
*/
|
|
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
|
-
});
|
|
6033
|
-
}
|
|
6034
|
-
return issues;
|
|
6035
|
-
}
|
|
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);
|
|
6028
|
+
function preservePinnedActions(generated, existing, owned = []) {
|
|
6029
|
+
let result = generated;
|
|
6030
|
+
for (const { name, comment } of owned) {
|
|
6031
|
+
const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@(\S+)(?:[ \t]+#[^\n]*)?$`, "gm");
|
|
6032
|
+
result = result.replace(pattern, `$1${name}@$2 # ${comment}`);
|
|
6054
6033
|
}
|
|
6055
|
-
return
|
|
6034
|
+
if (!existing) return result;
|
|
6035
|
+
const pins = extractPinnedActions(existing);
|
|
6036
|
+
if (pins.size === 0) return result;
|
|
6037
|
+
for (const [name, tail] of pins) {
|
|
6038
|
+
if (owned.some((action) => action.name === name)) continue;
|
|
6039
|
+
const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@\S+(?:[ \t]+#[^\n]*)?$`, "gm");
|
|
6040
|
+
result = result.replace(pattern, `$1${name}@${tail}`);
|
|
6041
|
+
}
|
|
6042
|
+
return result;
|
|
6043
|
+
}
|
|
6044
|
+
/** Build a complete workflow YAML string from structured options. Single source of truth for workflow files. */
|
|
6045
|
+
function buildWorkflowYaml(options) {
|
|
6046
|
+
const doc = { name: options.name };
|
|
6047
|
+
if (options.enableEmailNotifications) doc["enable-email-notifications"] = true;
|
|
6048
|
+
doc["on"] = options.on;
|
|
6049
|
+
if (options.permissions) doc["permissions"] = options.permissions;
|
|
6050
|
+
if (options.concurrency) doc["concurrency"] = options.concurrency;
|
|
6051
|
+
const jobs = { [options.jobName]: {
|
|
6052
|
+
...options.jobIf && { if: options.jobIf },
|
|
6053
|
+
"runs-on": options.runsOn ?? "ubuntu-latest",
|
|
6054
|
+
...options.jobEnv && { env: options.jobEnv },
|
|
6055
|
+
...options.jobOutputs && { outputs: options.jobOutputs },
|
|
6056
|
+
steps: options.steps.map((s) => s.step)
|
|
6057
|
+
} };
|
|
6058
|
+
for (const [name, def] of Object.entries(options.extraJobs ?? {})) jobs[name] = {
|
|
6059
|
+
...def.needs && { needs: def.needs },
|
|
6060
|
+
...def.if && { if: def.if },
|
|
6061
|
+
...def["continue-on-error"] && { "continue-on-error": true },
|
|
6062
|
+
"runs-on": def.runsOn ?? "ubuntu-latest",
|
|
6063
|
+
...def.env && { env: def.env },
|
|
6064
|
+
steps: def.steps.map((s) => s.step)
|
|
6065
|
+
};
|
|
6066
|
+
doc["jobs"] = jobs;
|
|
6067
|
+
const readable = stringify(doc, {
|
|
6068
|
+
lineWidth: 0,
|
|
6069
|
+
nullStr: ""
|
|
6070
|
+
}).replace(/^(?=\S)/gm, (match, offset) => offset === 0 ? match : `\n${match}`);
|
|
6071
|
+
return ensureSchemaComment(WORKFLOW_MANAGED_HEADER + "\n" + readable, options.ci);
|
|
6056
6072
|
}
|
|
6057
6073
|
//#endregion
|
|
6058
6074
|
//#region src/generators/mise-pin.ts
|
|
@@ -6068,8 +6084,8 @@ function declaredPublishRegistries(targetDir, structure, rootPackageJson) {
|
|
|
6068
6084
|
* shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
|
|
6069
6085
|
* digest (`customManagers` entries scoped to this file).
|
|
6070
6086
|
*/
|
|
6071
|
-
const MISE_VERSION = "2026.10.
|
|
6072
|
-
const MISE_LINUX_X64_SHA256 = "
|
|
6087
|
+
const MISE_VERSION = "2026.10.3";
|
|
6088
|
+
const MISE_LINUX_X64_SHA256 = "8d48bc510b7d844fad0bc7156c0855c2a1e7230c51e498a7f6b63b021f8b57c5";
|
|
6073
6089
|
/** Where generated Forgejo workflows fetch the shared mise setup action: a composite action in
|
|
6074
6090
|
* `bensandee/common`, on the forge every fleet repo lives on. GitHub cannot reach it; the
|
|
6075
6091
|
* generators keep inline steps there. */
|
|
@@ -6077,7 +6093,7 @@ const SETUP_MISE_ACTION_URL = "https://code.orangebikelabs.com/bensandee/common/
|
|
|
6077
6093
|
/** The tag in `bensandee/common` that marks the current release of the action. Generated `uses:`
|
|
6078
6094
|
* lines carry it as their comment, and Renovate moves the SHA below to wherever it points. */
|
|
6079
6095
|
const SETUP_MISE_ACTION_TAG = "setup-mise@latest";
|
|
6080
|
-
const SETUP_MISE_ACTION_SHA = "
|
|
6096
|
+
const SETUP_MISE_ACTION_SHA = "db99e4e9ee46bfeaca2146e7ae55cc700b4c23bf";
|
|
6081
6097
|
//#endregion
|
|
6082
6098
|
//#region src/generators/ci-utils.ts
|
|
6083
6099
|
/** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
|
|
@@ -6125,30 +6141,6 @@ const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
|
|
|
6125
6141
|
/** `if:` condition referencing the job-level `IS_MAIN` env var (see {@link IS_MAIN_JOB_ENV}). */
|
|
6126
6142
|
const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
|
|
6127
6143
|
/**
|
|
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
6144
|
* npm's own registry-key rule for an `.npmrc` auth line: strip the scheme, keep
|
|
6153
6145
|
* host/port/path, and end with exactly one slash. `actions/setup-node` derives the
|
|
6154
6146
|
* same key from a registry URL; this mirrors that so a hand-written line matches
|
|
@@ -6160,26 +6152,38 @@ function npmrcAuthKey(registryUrl) {
|
|
|
6160
6152
|
}
|
|
6161
6153
|
const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
|
|
6162
6154
|
/**
|
|
6163
|
-
* Build
|
|
6164
|
-
*
|
|
6165
|
-
*
|
|
6166
|
-
* gets
|
|
6167
|
-
*
|
|
6168
|
-
*
|
|
6155
|
+
* Build both halves of a job's npm auth wiring together, so one value decides
|
|
6156
|
+
* them: the auth lines the publishing step writes, and the step-level
|
|
6157
|
+
* `NODE_AUTH_TOKEN` it carries. A job that gets none of the declared registries
|
|
6158
|
+
* gets neither -- including a `publishes: true` job whose publishable set is
|
|
6159
|
+
* empty (every package private), since there is then no npmrc line for a real
|
|
6160
|
+
* secret to pair with.
|
|
6161
|
+
*
|
|
6162
|
+
* The lines go into a temp userconfig exported only inside the publishing
|
|
6163
|
+
* step's shell, never `~/.npmrc` or `$GITHUB_ENV`: pnpm 11 warns
|
|
6164
|
+
* `Failed to replace env in config` on every call that reads the placeholder
|
|
6165
|
+
* while the token is unset or empty, so a line in a shared file makes install, `ci:check`,
|
|
6166
|
+
* and every step after the publish warn. An empty job-level default does not
|
|
6167
|
+
* silence it, and `${NODE_AUTH_TOKEN:-}` is rejected as an empty fallback and
|
|
6168
|
+
* warns in every case. The `-n` guard keeps a run without the secret quiet; the
|
|
6169
|
+
* single quotes keep the token itself off disk, since npm expands it at read time.
|
|
6169
6170
|
*/
|
|
6170
6171
|
function npmAuthWiring(ctx, publishes) {
|
|
6171
6172
|
const registries = publishes ? declaredPublishRegistries(ctx.targetDir, ctx.config.structure, ctx.packageJson) : [];
|
|
6172
6173
|
if (registries.length === 0) return {
|
|
6173
|
-
|
|
6174
|
-
jobEnv: {},
|
|
6174
|
+
publishRun: (command) => command,
|
|
6175
6175
|
tokenEnv: {}
|
|
6176
6176
|
};
|
|
6177
|
+
const setup = [
|
|
6178
|
+
"if [ -n \"$NODE_AUTH_TOKEN\" ]; then",
|
|
6179
|
+
" NPM_CONFIG_USERCONFIG=\"$(mktemp)\"",
|
|
6180
|
+
" export NPM_CONFIG_USERCONFIG",
|
|
6181
|
+
" if [ -f ~/.npmrc ]; then cat ~/.npmrc > \"$NPM_CONFIG_USERCONFIG\"; fi",
|
|
6182
|
+
...registries.map((registry) => ` printf '\\n%s\\n' '${npmrcAuthKey(registry)}:_authToken=${NODE_AUTH_TOKEN_VAR}' >> "$NPM_CONFIG_USERCONFIG"`),
|
|
6183
|
+
"fi"
|
|
6184
|
+
];
|
|
6177
6185
|
return {
|
|
6178
|
-
|
|
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,
|
|
6186
|
+
publishRun: (command) => [...setup, command].join("\n"),
|
|
6183
6187
|
tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
|
|
6184
6188
|
};
|
|
6185
6189
|
}
|
|
@@ -6304,6 +6308,37 @@ function dockerPublishEnv(host) {
|
|
|
6304
6308
|
RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
|
|
6305
6309
|
};
|
|
6306
6310
|
}
|
|
6311
|
+
/** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
|
|
6312
|
+
const ARCHIVE_DIR$1 = ".tooling-archived";
|
|
6313
|
+
/**
|
|
6314
|
+
* Retire a workflow file tooling used to generate and no longer writes.
|
|
6315
|
+
*
|
|
6316
|
+
* Returns `undefined` when there is no file at `workflowPath` -- nothing to report. A file
|
|
6317
|
+
* tooling does not manage (it carries the ignore comment, or no managed header) is left in
|
|
6318
|
+
* place with a `skipped` result naming it, so a plain `repo:sync` tells the human why the
|
|
6319
|
+
* leftover is still there. Any other file is removed: `ctx.remove()` archives before deleting
|
|
6320
|
+
* (see createContext), so no manual copy is needed.
|
|
6321
|
+
*
|
|
6322
|
+
* The result is `removed`, not `archived`: `repo:sync --check` counts only the former as drift,
|
|
6323
|
+
* and the agent's workflow sync sweep acts only on drift the check reports.
|
|
6324
|
+
*/
|
|
6325
|
+
function retireManagedWorkflow(ctx, workflowPath, reason) {
|
|
6326
|
+
const existing = ctx.read(workflowPath);
|
|
6327
|
+
if (existing === void 0) return void 0;
|
|
6328
|
+
if (isToolingIgnored(existing) || !existing.includes("Managed by @bensandee/tooling --")) return {
|
|
6329
|
+
filePath: workflowPath,
|
|
6330
|
+
action: "skipped",
|
|
6331
|
+
description: `${workflowPath} is not managed by tooling, so it was left in place`
|
|
6332
|
+
};
|
|
6333
|
+
ctx.remove(workflowPath);
|
|
6334
|
+
let description = `Moved to ${ARCHIVE_DIR$1}/${workflowPath} (${reason})`;
|
|
6335
|
+
if (extractCustomBlocks(existing).length > 0) description += "; its # @tooling:custom blocks are kept only in the archive";
|
|
6336
|
+
return {
|
|
6337
|
+
filePath: workflowPath,
|
|
6338
|
+
action: "removed",
|
|
6339
|
+
description
|
|
6340
|
+
};
|
|
6341
|
+
}
|
|
6307
6342
|
//#endregion
|
|
6308
6343
|
//#region src/generators/publish-ci.ts
|
|
6309
6344
|
const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
|
|
@@ -6342,18 +6377,6 @@ function resolveGenTimeHost(ctx) {
|
|
|
6342
6377
|
if (!parsed) return null;
|
|
6343
6378
|
return registryHostFromHostname(parsed.hostname);
|
|
6344
6379
|
}
|
|
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
6380
|
const PostReleaseHookSchema = z.object({
|
|
6358
6381
|
postReleaseHook: z.string().optional(),
|
|
6359
6382
|
postReleaseHookSecrets: z.array(z.string()).optional()
|
|
@@ -6472,63 +6495,24 @@ function hasVersionStamp(ctx) {
|
|
|
6472
6495
|
return false;
|
|
6473
6496
|
}
|
|
6474
6497
|
}
|
|
6475
|
-
/**
|
|
6476
|
-
|
|
6498
|
+
/**
|
|
6499
|
+
* Archive the manual `publish.yml` this generator used to write; it writes nothing. Docker
|
|
6500
|
+
* images publish from the release workflow, and the manual workflow was a thin wrapper around
|
|
6501
|
+
* `bst docker:publish` that nobody ran -- run that command instead. Only a file carrying the
|
|
6502
|
+
* managed header is removed: a hand-written `publish.yml`, or one opted out with the ignore
|
|
6503
|
+
* comment, belongs to the repo.
|
|
6504
|
+
*/
|
|
6477
6505
|
async function generateDeployCi(ctx) {
|
|
6478
6506
|
const filePath = "deploy-ci";
|
|
6479
|
-
if (
|
|
6507
|
+
if (ctx.config.ci === "none") return {
|
|
6480
6508
|
filePath,
|
|
6481
6509
|
action: "skipped",
|
|
6482
|
-
description: "
|
|
6510
|
+
description: "Publish workflow not applicable"
|
|
6483
6511
|
};
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
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);
|
|
6528
|
-
return {
|
|
6529
|
-
filePath: workflowPath,
|
|
6530
|
-
action: alreadyExists ? "updated" : "created",
|
|
6531
|
-
description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions publish workflow`
|
|
6512
|
+
return retireManagedWorkflow(ctx, ctx.config.ci === "github" ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml", "run `bst docker:publish` to publish by hand") ?? {
|
|
6513
|
+
filePath,
|
|
6514
|
+
action: "skipped",
|
|
6515
|
+
description: "No publish workflow to retire"
|
|
6532
6516
|
};
|
|
6533
6517
|
}
|
|
6534
6518
|
//#endregion
|
|
@@ -6758,7 +6742,8 @@ function diagnoseCheckScriptForwardingAt(targetDir) {
|
|
|
6758
6742
|
* Single source of truth for the supported Node floor: `engines.node` (`>=<floor>`) is the
|
|
6759
6743
|
* honest minimum this package declares. `mise.toml`'s `tools.node` (`generators/mise.ts`) is the
|
|
6760
6744
|
* exact pin every consumer -- the agent's containers, generated CI, a developer's shell -- now
|
|
6761
|
-
* installs and runs, seeded from
|
|
6745
|
+
* installs and runs, seeded on first generation from the highest of this floor, `engines.node`,
|
|
6746
|
+
* and the `devEngines.runtime` range removed below.
|
|
6762
6747
|
*
|
|
6763
6748
|
* `devEngines.runtime` used to carry a caret form of this floor for `actions/setup-node` and
|
|
6764
6749
|
* pnpm's runtime-download check to read; both are retired by the shared "Install mise" workflow
|
|
@@ -6923,7 +6908,7 @@ function perPackageDevDeps(config) {
|
|
|
6923
6908
|
deps["typescript"] = {
|
|
6924
6909
|
"@changesets/cli": "3.0.3",
|
|
6925
6910
|
"@release-it/bumper": "8.0.1",
|
|
6926
|
-
"@types/node": "24.
|
|
6911
|
+
"@types/node": "24.19.1",
|
|
6927
6912
|
"@vitest/coverage-v8": "5.0.3",
|
|
6928
6913
|
"commit-and-tag-version": "13.2.1",
|
|
6929
6914
|
"knip": "6.39.0",
|
|
@@ -6937,12 +6922,12 @@ function perPackageDevDeps(config) {
|
|
|
6937
6922
|
"tsdown": "0.23.0",
|
|
6938
6923
|
"typescript": "6.0.3",
|
|
6939
6924
|
"vitest": "5.0.3",
|
|
6940
|
-
"pnpm": "11.
|
|
6925
|
+
"pnpm": "11.28.3"
|
|
6941
6926
|
}["typescript"];
|
|
6942
6927
|
deps["@types/node"] = {
|
|
6943
6928
|
"@changesets/cli": "3.0.3",
|
|
6944
6929
|
"@release-it/bumper": "8.0.1",
|
|
6945
|
-
"@types/node": "24.
|
|
6930
|
+
"@types/node": "24.19.1",
|
|
6946
6931
|
"@vitest/coverage-v8": "5.0.3",
|
|
6947
6932
|
"commit-and-tag-version": "13.2.1",
|
|
6948
6933
|
"knip": "6.39.0",
|
|
@@ -6956,13 +6941,13 @@ function perPackageDevDeps(config) {
|
|
|
6956
6941
|
"tsdown": "0.23.0",
|
|
6957
6942
|
"typescript": "6.0.3",
|
|
6958
6943
|
"vitest": "5.0.3",
|
|
6959
|
-
"pnpm": "11.
|
|
6944
|
+
"pnpm": "11.28.3"
|
|
6960
6945
|
}["@types/node"];
|
|
6961
6946
|
}
|
|
6962
6947
|
if (config.builder === "tsdown") deps["tsdown"] = {
|
|
6963
6948
|
"@changesets/cli": "3.0.3",
|
|
6964
6949
|
"@release-it/bumper": "8.0.1",
|
|
6965
|
-
"@types/node": "24.
|
|
6950
|
+
"@types/node": "24.19.1",
|
|
6966
6951
|
"@vitest/coverage-v8": "5.0.3",
|
|
6967
6952
|
"commit-and-tag-version": "13.2.1",
|
|
6968
6953
|
"knip": "6.39.0",
|
|
@@ -6976,7 +6961,7 @@ function perPackageDevDeps(config) {
|
|
|
6976
6961
|
"tsdown": "0.23.0",
|
|
6977
6962
|
"typescript": "6.0.3",
|
|
6978
6963
|
"vitest": "5.0.3",
|
|
6979
|
-
"pnpm": "11.
|
|
6964
|
+
"pnpm": "11.28.3"
|
|
6980
6965
|
}["tsdown"];
|
|
6981
6966
|
return deps;
|
|
6982
6967
|
}
|
|
@@ -6993,7 +6978,7 @@ function vitestDevDeps() {
|
|
|
6993
6978
|
vitest: {
|
|
6994
6979
|
"@changesets/cli": "3.0.3",
|
|
6995
6980
|
"@release-it/bumper": "8.0.1",
|
|
6996
|
-
"@types/node": "24.
|
|
6981
|
+
"@types/node": "24.19.1",
|
|
6997
6982
|
"@vitest/coverage-v8": "5.0.3",
|
|
6998
6983
|
"commit-and-tag-version": "13.2.1",
|
|
6999
6984
|
"knip": "6.39.0",
|
|
@@ -7007,12 +6992,12 @@ function vitestDevDeps() {
|
|
|
7007
6992
|
"tsdown": "0.23.0",
|
|
7008
6993
|
"typescript": "6.0.3",
|
|
7009
6994
|
"vitest": "5.0.3",
|
|
7010
|
-
"pnpm": "11.
|
|
6995
|
+
"pnpm": "11.28.3"
|
|
7011
6996
|
}["vitest"],
|
|
7012
6997
|
"@vitest/coverage-v8": {
|
|
7013
6998
|
"@changesets/cli": "3.0.3",
|
|
7014
6999
|
"@release-it/bumper": "8.0.1",
|
|
7015
|
-
"@types/node": "24.
|
|
7000
|
+
"@types/node": "24.19.1",
|
|
7016
7001
|
"@vitest/coverage-v8": "5.0.3",
|
|
7017
7002
|
"commit-and-tag-version": "13.2.1",
|
|
7018
7003
|
"knip": "6.39.0",
|
|
@@ -7026,7 +7011,7 @@ function vitestDevDeps() {
|
|
|
7026
7011
|
"tsdown": "0.23.0",
|
|
7027
7012
|
"typescript": "6.0.3",
|
|
7028
7013
|
"vitest": "5.0.3",
|
|
7029
|
-
"pnpm": "11.
|
|
7014
|
+
"pnpm": "11.28.3"
|
|
7030
7015
|
}["@vitest/coverage-v8"]
|
|
7031
7016
|
};
|
|
7032
7017
|
}
|
|
@@ -7040,7 +7025,7 @@ function rootDevDeps(config) {
|
|
|
7040
7025
|
const deps = { lefthook: {
|
|
7041
7026
|
"@changesets/cli": "3.0.3",
|
|
7042
7027
|
"@release-it/bumper": "8.0.1",
|
|
7043
|
-
"@types/node": "24.
|
|
7028
|
+
"@types/node": "24.19.1",
|
|
7044
7029
|
"@vitest/coverage-v8": "5.0.3",
|
|
7045
7030
|
"commit-and-tag-version": "13.2.1",
|
|
7046
7031
|
"knip": "6.39.0",
|
|
@@ -7054,13 +7039,13 @@ function rootDevDeps(config) {
|
|
|
7054
7039
|
"tsdown": "0.23.0",
|
|
7055
7040
|
"typescript": "6.0.3",
|
|
7056
7041
|
"vitest": "5.0.3",
|
|
7057
|
-
"pnpm": "11.
|
|
7042
|
+
"pnpm": "11.28.3"
|
|
7058
7043
|
}["lefthook"] };
|
|
7059
7044
|
if (config.stack !== "none") {
|
|
7060
7045
|
deps["knip"] = {
|
|
7061
7046
|
"@changesets/cli": "3.0.3",
|
|
7062
7047
|
"@release-it/bumper": "8.0.1",
|
|
7063
|
-
"@types/node": "24.
|
|
7048
|
+
"@types/node": "24.19.1",
|
|
7064
7049
|
"@vitest/coverage-v8": "5.0.3",
|
|
7065
7050
|
"commit-and-tag-version": "13.2.1",
|
|
7066
7051
|
"knip": "6.39.0",
|
|
@@ -7074,12 +7059,12 @@ function rootDevDeps(config) {
|
|
|
7074
7059
|
"tsdown": "0.23.0",
|
|
7075
7060
|
"typescript": "6.0.3",
|
|
7076
7061
|
"vitest": "5.0.3",
|
|
7077
|
-
"pnpm": "11.
|
|
7062
|
+
"pnpm": "11.28.3"
|
|
7078
7063
|
}["knip"];
|
|
7079
7064
|
deps["oxlint"] = {
|
|
7080
7065
|
"@changesets/cli": "3.0.3",
|
|
7081
7066
|
"@release-it/bumper": "8.0.1",
|
|
7082
|
-
"@types/node": "24.
|
|
7067
|
+
"@types/node": "24.19.1",
|
|
7083
7068
|
"@vitest/coverage-v8": "5.0.3",
|
|
7084
7069
|
"commit-and-tag-version": "13.2.1",
|
|
7085
7070
|
"knip": "6.39.0",
|
|
@@ -7093,14 +7078,14 @@ function rootDevDeps(config) {
|
|
|
7093
7078
|
"tsdown": "0.23.0",
|
|
7094
7079
|
"typescript": "6.0.3",
|
|
7095
7080
|
"vitest": "5.0.3",
|
|
7096
|
-
"pnpm": "11.
|
|
7081
|
+
"pnpm": "11.28.3"
|
|
7097
7082
|
}["oxlint"];
|
|
7098
7083
|
Object.assign(deps, vitestDevDeps());
|
|
7099
7084
|
}
|
|
7100
7085
|
if (config.stack === "typescript") deps["oxlint-tsgolint"] = {
|
|
7101
7086
|
"@changesets/cli": "3.0.3",
|
|
7102
7087
|
"@release-it/bumper": "8.0.1",
|
|
7103
|
-
"@types/node": "24.
|
|
7088
|
+
"@types/node": "24.19.1",
|
|
7104
7089
|
"@vitest/coverage-v8": "5.0.3",
|
|
7105
7090
|
"commit-and-tag-version": "13.2.1",
|
|
7106
7091
|
"knip": "6.39.0",
|
|
@@ -7114,7 +7099,7 @@ function rootDevDeps(config) {
|
|
|
7114
7099
|
"tsdown": "0.23.0",
|
|
7115
7100
|
"typescript": "6.0.3",
|
|
7116
7101
|
"vitest": "5.0.3",
|
|
7117
|
-
"pnpm": "11.
|
|
7102
|
+
"pnpm": "11.28.3"
|
|
7118
7103
|
}["oxlint-tsgolint"];
|
|
7119
7104
|
return deps;
|
|
7120
7105
|
}
|
|
@@ -7145,7 +7130,7 @@ function addReleaseDeps(deps, config) {
|
|
|
7145
7130
|
deps["release-it"] = {
|
|
7146
7131
|
"@changesets/cli": "3.0.3",
|
|
7147
7132
|
"@release-it/bumper": "8.0.1",
|
|
7148
|
-
"@types/node": "24.
|
|
7133
|
+
"@types/node": "24.19.1",
|
|
7149
7134
|
"@vitest/coverage-v8": "5.0.3",
|
|
7150
7135
|
"commit-and-tag-version": "13.2.1",
|
|
7151
7136
|
"knip": "6.39.0",
|
|
@@ -7159,12 +7144,12 @@ function addReleaseDeps(deps, config) {
|
|
|
7159
7144
|
"tsdown": "0.23.0",
|
|
7160
7145
|
"typescript": "6.0.3",
|
|
7161
7146
|
"vitest": "5.0.3",
|
|
7162
|
-
"pnpm": "11.
|
|
7147
|
+
"pnpm": "11.28.3"
|
|
7163
7148
|
}["release-it"];
|
|
7164
7149
|
if (config.structure === "monorepo") deps["@release-it/bumper"] = {
|
|
7165
7150
|
"@changesets/cli": "3.0.3",
|
|
7166
7151
|
"@release-it/bumper": "8.0.1",
|
|
7167
|
-
"@types/node": "24.
|
|
7152
|
+
"@types/node": "24.19.1",
|
|
7168
7153
|
"@vitest/coverage-v8": "5.0.3",
|
|
7169
7154
|
"commit-and-tag-version": "13.2.1",
|
|
7170
7155
|
"knip": "6.39.0",
|
|
@@ -7178,14 +7163,14 @@ function addReleaseDeps(deps, config) {
|
|
|
7178
7163
|
"tsdown": "0.23.0",
|
|
7179
7164
|
"typescript": "6.0.3",
|
|
7180
7165
|
"vitest": "5.0.3",
|
|
7181
|
-
"pnpm": "11.
|
|
7166
|
+
"pnpm": "11.28.3"
|
|
7182
7167
|
}["@release-it/bumper"];
|
|
7183
7168
|
break;
|
|
7184
7169
|
case "simple":
|
|
7185
7170
|
deps["commit-and-tag-version"] = {
|
|
7186
7171
|
"@changesets/cli": "3.0.3",
|
|
7187
7172
|
"@release-it/bumper": "8.0.1",
|
|
7188
|
-
"@types/node": "24.
|
|
7173
|
+
"@types/node": "24.19.1",
|
|
7189
7174
|
"@vitest/coverage-v8": "5.0.3",
|
|
7190
7175
|
"commit-and-tag-version": "13.2.1",
|
|
7191
7176
|
"knip": "6.39.0",
|
|
@@ -7199,13 +7184,13 @@ function addReleaseDeps(deps, config) {
|
|
|
7199
7184
|
"tsdown": "0.23.0",
|
|
7200
7185
|
"typescript": "6.0.3",
|
|
7201
7186
|
"vitest": "5.0.3",
|
|
7202
|
-
"pnpm": "11.
|
|
7187
|
+
"pnpm": "11.28.3"
|
|
7203
7188
|
}["commit-and-tag-version"];
|
|
7204
7189
|
break;
|
|
7205
7190
|
case "changesets": deps["@changesets/cli"] = {
|
|
7206
7191
|
"@changesets/cli": "3.0.3",
|
|
7207
7192
|
"@release-it/bumper": "8.0.1",
|
|
7208
|
-
"@types/node": "24.
|
|
7193
|
+
"@types/node": "24.19.1",
|
|
7209
7194
|
"@vitest/coverage-v8": "5.0.3",
|
|
7210
7195
|
"commit-and-tag-version": "13.2.1",
|
|
7211
7196
|
"knip": "6.39.0",
|
|
@@ -7219,7 +7204,7 @@ function addReleaseDeps(deps, config) {
|
|
|
7219
7204
|
"tsdown": "0.23.0",
|
|
7220
7205
|
"typescript": "6.0.3",
|
|
7221
7206
|
"vitest": "5.0.3",
|
|
7222
|
-
"pnpm": "11.
|
|
7207
|
+
"pnpm": "11.28.3"
|
|
7223
7208
|
}["@changesets/cli"];
|
|
7224
7209
|
}
|
|
7225
7210
|
}
|
|
@@ -7413,7 +7398,7 @@ function applyMetadataDefaults(pkg, ctx, changes) {
|
|
|
7413
7398
|
const pinnedPnpm = {
|
|
7414
7399
|
"@changesets/cli": "3.0.3",
|
|
7415
7400
|
"@release-it/bumper": "8.0.1",
|
|
7416
|
-
"@types/node": "24.
|
|
7401
|
+
"@types/node": "24.19.1",
|
|
7417
7402
|
"@vitest/coverage-v8": "5.0.3",
|
|
7418
7403
|
"commit-and-tag-version": "13.2.1",
|
|
7419
7404
|
"knip": "6.39.0",
|
|
@@ -7427,7 +7412,7 @@ function applyMetadataDefaults(pkg, ctx, changes) {
|
|
|
7427
7412
|
"tsdown": "0.23.0",
|
|
7428
7413
|
"typescript": "6.0.3",
|
|
7429
7414
|
"vitest": "5.0.3",
|
|
7430
|
-
"pnpm": "11.
|
|
7415
|
+
"pnpm": "11.28.3"
|
|
7431
7416
|
}["pnpm"];
|
|
7432
7417
|
const desiredPm = pinnedPnpm ? `pnpm@${pinnedPnpm}` : void 0;
|
|
7433
7418
|
if (desiredPm) {
|
|
@@ -7468,12 +7453,12 @@ function buildManagedDevDeps(ctx) {
|
|
|
7468
7453
|
const devDeps = { ...rootDevDeps(ctx.config) };
|
|
7469
7454
|
if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
|
|
7470
7455
|
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.
|
|
7472
|
-
if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.
|
|
7456
|
+
if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.87.1";
|
|
7457
|
+
if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.16.0";
|
|
7473
7458
|
if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
|
|
7474
7459
|
"@changesets/cli": "3.0.3",
|
|
7475
7460
|
"@release-it/bumper": "8.0.1",
|
|
7476
|
-
"@types/node": "24.
|
|
7461
|
+
"@types/node": "24.19.1",
|
|
7477
7462
|
"@vitest/coverage-v8": "5.0.3",
|
|
7478
7463
|
"commit-and-tag-version": "13.2.1",
|
|
7479
7464
|
"knip": "6.39.0",
|
|
@@ -7487,12 +7472,12 @@ function buildManagedDevDeps(ctx) {
|
|
|
7487
7472
|
"tsdown": "0.23.0",
|
|
7488
7473
|
"typescript": "6.0.3",
|
|
7489
7474
|
"vitest": "5.0.3",
|
|
7490
|
-
"pnpm": "11.
|
|
7475
|
+
"pnpm": "11.28.3"
|
|
7491
7476
|
}["oxfmt"];
|
|
7492
7477
|
if (ctx.config.formatter === "prettier") devDeps["prettier"] = {
|
|
7493
7478
|
"@changesets/cli": "3.0.3",
|
|
7494
7479
|
"@release-it/bumper": "8.0.1",
|
|
7495
|
-
"@types/node": "24.
|
|
7480
|
+
"@types/node": "24.19.1",
|
|
7496
7481
|
"@vitest/coverage-v8": "5.0.3",
|
|
7497
7482
|
"commit-and-tag-version": "13.2.1",
|
|
7498
7483
|
"knip": "6.39.0",
|
|
@@ -7506,7 +7491,7 @@ function buildManagedDevDeps(ctx) {
|
|
|
7506
7491
|
"tsdown": "0.23.0",
|
|
7507
7492
|
"typescript": "6.0.3",
|
|
7508
7493
|
"vitest": "5.0.3",
|
|
7509
|
-
"pnpm": "11.
|
|
7494
|
+
"pnpm": "11.28.3"
|
|
7510
7495
|
}["prettier"];
|
|
7511
7496
|
addReleaseDeps(devDeps, ctx.config);
|
|
7512
7497
|
return devDeps;
|
|
@@ -7562,7 +7547,7 @@ async function generatePackageJson(ctx) {
|
|
|
7562
7547
|
packageManager: `pnpm@${{
|
|
7563
7548
|
"@changesets/cli": "3.0.3",
|
|
7564
7549
|
"@release-it/bumper": "8.0.1",
|
|
7565
|
-
"@types/node": "24.
|
|
7550
|
+
"@types/node": "24.19.1",
|
|
7566
7551
|
"@vitest/coverage-v8": "5.0.3",
|
|
7567
7552
|
"commit-and-tag-version": "13.2.1",
|
|
7568
7553
|
"knip": "6.39.0",
|
|
@@ -7576,7 +7561,7 @@ async function generatePackageJson(ctx) {
|
|
|
7576
7561
|
"tsdown": "0.23.0",
|
|
7577
7562
|
"typescript": "6.0.3",
|
|
7578
7563
|
"vitest": "5.0.3",
|
|
7579
|
-
"pnpm": "11.
|
|
7564
|
+
"pnpm": "11.28.3"
|
|
7580
7565
|
}["pnpm"]}`
|
|
7581
7566
|
};
|
|
7582
7567
|
ctx.write(filePath, JSON.stringify(pkg, null, 2) + "\n");
|
|
@@ -8394,6 +8379,17 @@ const CONFIG_WITH_LINT_RULES = `import recommended from "@bensandee/config/oxlin
|
|
|
8394
8379
|
import bensandee from "@bensandee/eslint-plugin/recommended";
|
|
8395
8380
|
import { defineConfig } from "oxlint";
|
|
8396
8381
|
|
|
8382
|
+
export default defineConfig({
|
|
8383
|
+
extends: [recommended],
|
|
8384
|
+
jsPlugins: bensandee.jsPlugins ?? [],
|
|
8385
|
+
rules: bensandee.rules ?? {},
|
|
8386
|
+
ignorePatterns: [${IGNORE_PATTERNS_SPREAD}],
|
|
8387
|
+
});
|
|
8388
|
+
`;
|
|
8389
|
+
const LEGACY_CONFIG_WITH_LINT_RULES = `import recommended from "@bensandee/config/oxlint/recommended";
|
|
8390
|
+
import bensandee from "@bensandee/eslint-plugin/recommended";
|
|
8391
|
+
import { defineConfig } from "oxlint";
|
|
8392
|
+
|
|
8397
8393
|
// Report phase: every plugin rule runs and reports, none blocks. The severities are derived
|
|
8398
8394
|
// from the plugin's own rule names rather than listed, so a rule the plugin adds arrives the
|
|
8399
8395
|
// day it publishes -- enumerating them is how the plugin came to be installed and never
|
|
@@ -8426,7 +8422,11 @@ function addIgnorePatterns(content) {
|
|
|
8426
8422
|
return content.slice(0, insertPos) + ` ignorePatterns: [${IGNORE_PATTERNS_SPREAD}],\n` + content.slice(insertPos);
|
|
8427
8423
|
}
|
|
8428
8424
|
const PLUGIN_IMPORT = "import bensandee from \"@bensandee/eslint-plugin/recommended\";\n";
|
|
8429
|
-
const
|
|
8425
|
+
const TIER_SPREAD = " ...bensandee.rules,\n";
|
|
8426
|
+
const LEGACY_REPORT_SPREAD = " ...Object.fromEntries(Object.keys(bensandee.rules ?? {}).map((name) => [name, \"warn\"])),\n";
|
|
8427
|
+
/** The text every report-phase severity map carries, whatever its shape -- in either quote
|
|
8428
|
+
* style, since the merged spread went through the repo's own formatter. */
|
|
8429
|
+
const REPORT_PHASE_MARKER = /\[name, (["'])warn\1\]/;
|
|
8430
8430
|
const RULES_KEY = /^ {2}"?rules"?: \{/m;
|
|
8431
8431
|
const RULES_KEY_INLINE = /^ {2}"?rules"?\s*[,:]/m;
|
|
8432
8432
|
const JS_PLUGINS_KEY = /^ {2}"?jsPlugins"?: \[/m;
|
|
@@ -8473,28 +8473,29 @@ function addBensandeePlugin(content) {
|
|
|
8473
8473
|
const rulesKey = RULES_KEY.exec(rulesRegion);
|
|
8474
8474
|
if (rulesKey) {
|
|
8475
8475
|
const at = insertPos + rulesKey.index + rulesKey[0].length;
|
|
8476
|
-
return withPlugins.slice(0, at) + `\n${
|
|
8476
|
+
return withPlugins.slice(0, at) + `\n${TIER_SPREAD}` + withPlugins.slice(at);
|
|
8477
8477
|
}
|
|
8478
8478
|
if (RULES_KEY_INLINE.test(rulesRegion)) return UNRECOGNIZED_SHAPE;
|
|
8479
|
-
return withPlugins.slice(0, afterJsPlugins) + ` rules: {\n${
|
|
8480
|
-
}
|
|
8481
|
-
/** The exact
|
|
8482
|
-
* scratch -- checked before
|
|
8483
|
-
*
|
|
8484
|
-
* },` shell behind. */
|
|
8485
|
-
const
|
|
8479
|
+
return withPlugins.slice(0, afterJsPlugins) + ` rules: {\n${TIER_SPREAD} },\n` + withPlugins.slice(afterJsPlugins);
|
|
8480
|
+
}
|
|
8481
|
+
/** The exact literals `addBensandeePlugin` splices in when it creates a `rules` key from
|
|
8482
|
+
* scratch, current and legacy -- checked before the merged-in-place spreads below, since each
|
|
8483
|
+
* spread is a substring of its block and would otherwise match first and leave the empty `rules:
|
|
8484
|
+
* { },` shell behind. */
|
|
8485
|
+
const CREATED_RULES_BLOCKS = [` rules: {\n${TIER_SPREAD} },\n`, ` rules: {\n${LEGACY_REPORT_SPREAD} },\n`];
|
|
8486
|
+
const MERGED_RULES_SPREADS = [`\n${TIER_SPREAD}`, `\n${LEGACY_REPORT_SPREAD}`];
|
|
8486
8487
|
const CREATED_JS_PLUGINS_LINE = " jsPlugins: bensandee.jsPlugins ?? [],\n";
|
|
8487
8488
|
const MERGED_JS_PLUGINS_SPREAD = /\.\.\.\(bensandee\.jsPlugins \?\? \[\]\),\s+/;
|
|
8488
8489
|
/**
|
|
8489
8490
|
* The exact inverse of {@link addBensandeePlugin}: strips the import line and whichever of the
|
|
8490
|
-
* two `jsPlugins` and two `rules` shapes it spliced in,
|
|
8491
|
-
* than re-deriving positions. Returns `undefined` when
|
|
8492
|
-
* does not name the plugin -- keeps a re-sync a no-op),
|
|
8493
|
-
* does but none of the expected literals are present --
|
|
8494
|
-
* this tool never injected, or (for the merged
|
|
8495
|
-
* wider than {@link MERGED_JS_PLUGINS_SPREAD}
|
|
8496
|
-
* `addBensandeePlugin` itself follows, rather than
|
|
8497
|
-
* corrupt the file.
|
|
8491
|
+
* two `jsPlugins` and two `rules` shapes it spliced in, the legacy `rules` literals included, by
|
|
8492
|
+
* removing those same literals rather than re-deriving positions. Returns `undefined` when
|
|
8493
|
+
* there is nothing to remove (the content does not name the plugin -- keeps a re-sync a no-op),
|
|
8494
|
+
* and {@link UNRECOGNIZED_SHAPE} when it does but none of the expected literals are present --
|
|
8495
|
+
* a hand-edit since injection, a shape this tool never injected, or (for the merged
|
|
8496
|
+
* `jsPlugins` spread only) a formatter reflow wider than {@link MERGED_JS_PLUGINS_SPREAD}
|
|
8497
|
+
* tolerates -- declined whole, the same rule `addBensandeePlugin` itself follows, rather than
|
|
8498
|
+
* guessing at a partial removal that could corrupt the file.
|
|
8498
8499
|
*/
|
|
8499
8500
|
function removeBensandeePlugin(content) {
|
|
8500
8501
|
if (!content.includes("@bensandee/eslint-plugin")) return void 0;
|
|
@@ -8503,10 +8504,17 @@ function removeBensandeePlugin(content) {
|
|
|
8503
8504
|
if (result.includes(CREATED_JS_PLUGINS_LINE)) result = result.replace(CREATED_JS_PLUGINS_LINE, "");
|
|
8504
8505
|
else if (MERGED_JS_PLUGINS_SPREAD.test(result)) result = result.replace(MERGED_JS_PLUGINS_SPREAD, "");
|
|
8505
8506
|
else return UNRECOGNIZED_SHAPE;
|
|
8506
|
-
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
|
|
8507
|
+
const rulesLiteral = CREATED_RULES_BLOCKS.find((block) => result.includes(block)) ?? MERGED_RULES_SPREADS.find((spread) => result.includes(spread));
|
|
8508
|
+
if (rulesLiteral === void 0) return UNRECOGNIZED_SHAPE;
|
|
8509
|
+
return result.replace(rulesLiteral, "");
|
|
8510
|
+
}
|
|
8511
|
+
/**
|
|
8512
|
+
* Swap the report-phase spread an earlier release merged in for {@link TIER_SPREAD}. Only the
|
|
8513
|
+
* byte-exact literal is replaced: a map the repo has reflowed or edited is left for the caller
|
|
8514
|
+
* to report, since rewriting a shape this tool did not write could drop the repo's own rules.
|
|
8515
|
+
*/
|
|
8516
|
+
function migrateReportSpread(content) {
|
|
8517
|
+
return content.replace(`\n${LEGACY_REPORT_SPREAD}`, `\n${TIER_SPREAD}`);
|
|
8510
8518
|
}
|
|
8511
8519
|
async function generateOxlint(ctx) {
|
|
8512
8520
|
const filePath = "oxlint.config.ts";
|
|
@@ -8524,7 +8532,7 @@ async function generateOxlint(ctx) {
|
|
|
8524
8532
|
action: "skipped",
|
|
8525
8533
|
description: "Already configured"
|
|
8526
8534
|
};
|
|
8527
|
-
if (
|
|
8535
|
+
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
8536
|
ctx.write(filePath, content);
|
|
8529
8537
|
return {
|
|
8530
8538
|
filePath,
|
|
@@ -8537,8 +8545,14 @@ async function generateOxlint(ctx) {
|
|
|
8537
8545
|
let pluginResult;
|
|
8538
8546
|
let withPlugin;
|
|
8539
8547
|
if (ctx.config.useEslintPlugin) {
|
|
8540
|
-
|
|
8541
|
-
|
|
8548
|
+
const migrated = migrateReportSpread(withIgnores);
|
|
8549
|
+
pluginResult = addBensandeePlugin(migrated);
|
|
8550
|
+
withPlugin = typeof pluginResult === "string" ? pluginResult : migrated;
|
|
8551
|
+
if (pluginResult === void 0 && REPORT_PHASE_MARKER.test(migrated)) return {
|
|
8552
|
+
filePath,
|
|
8553
|
+
action: "failed",
|
|
8554
|
+
description: "Plugin severities not migrated: unrecognized config shape -- replace the all-\"warn\" rule map with \"...bensandee.rules,\""
|
|
8555
|
+
};
|
|
8542
8556
|
} else {
|
|
8543
8557
|
pluginResult = removeBensandeePlugin(withIgnores);
|
|
8544
8558
|
withPlugin = typeof pluginResult === "string" ? pluginResult : withIgnores;
|
|
@@ -8821,20 +8835,30 @@ function toolsRange(source) {
|
|
|
8821
8835
|
function pnpmVersionFromPackageManager(packageManager) {
|
|
8822
8836
|
return /^pnpm@(\S+)$/.exec(packageManager ?? "")?.[1];
|
|
8823
8837
|
}
|
|
8824
|
-
/** The
|
|
8825
|
-
*
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8838
|
+
/** The `version` range of the `node` entry in `devEngines.runtime`, which may be one entry or an
|
|
8839
|
+
* array of them. */
|
|
8840
|
+
function devEnginesNodeVersion(devEngines) {
|
|
8841
|
+
const runtime = devEngines?.runtime;
|
|
8842
|
+
if (!runtime) return void 0;
|
|
8843
|
+
return (Array.isArray(runtime) ? runtime : [runtime]).find((entry) => entry.name === "node")?.version;
|
|
8844
|
+
}
|
|
8845
|
+
/** The exact pin to seed `tools.node` with: the highest floor among `devEngines.runtime`'s node
|
|
8846
|
+
* range, `engines.node`, and {@link NODE_ENGINES_FLOOR}. Renovate's shared preset never raises
|
|
8847
|
+
* `engines.node`, so on its own it can seed a pin below the Node the repo actually runs;
|
|
8848
|
+
* `devEngines.runtime` is the range Renovate does raise, which is why it is read here even though
|
|
8849
|
+
* `generatePackageJson` removes it. An unparseable range is ignored. */
|
|
8850
|
+
function nodePinFor(enginesNode, devEnginesNode) {
|
|
8851
|
+
let pin = minVersionTuple(NODE_ENGINES_FLOOR);
|
|
8852
|
+
for (const range of [enginesNode, devEnginesNode]) {
|
|
8853
|
+
const tuple = range ? minVersionTuple(range) : void 0;
|
|
8854
|
+
if (tuple && (!pin || compareTuples(tuple, pin) > 0)) pin = tuple;
|
|
8855
|
+
}
|
|
8856
|
+
return pin ? pin.join(".") : NODE_ENGINES_FLOOR;
|
|
8833
8857
|
}
|
|
8834
8858
|
/** The two owned lines, in order, for a file created from scratch (or a `[tools]` table added to
|
|
8835
8859
|
* one that had none). */
|
|
8836
|
-
function ownedLines(pnpmVersion,
|
|
8837
|
-
const lines = [`[tools]`, `node = "${
|
|
8860
|
+
function ownedLines(pnpmVersion, nodePin) {
|
|
8861
|
+
const lines = [`[tools]`, `node = "${nodePin}"`];
|
|
8838
8862
|
if (pnpmVersion) lines.push(`pnpm = "${pnpmVersion}"`);
|
|
8839
8863
|
return lines;
|
|
8840
8864
|
}
|
|
@@ -8878,19 +8902,19 @@ function readPackageJsonFacts(ctx) {
|
|
|
8878
8902
|
const pkg = pkgRaw ? parsePackageJson(pkgRaw) : void 0;
|
|
8879
8903
|
return {
|
|
8880
8904
|
enginesNode: pkg?.engines?.["node"],
|
|
8905
|
+
devEngines: pkg?.devEngines,
|
|
8881
8906
|
pnpmVersion: pnpmVersionFromPackageManager(pkg?.packageManager)
|
|
8882
8907
|
};
|
|
8883
8908
|
}
|
|
8884
8909
|
/** Edit an existing `[tools]` body in place: add `node` only when absent, rewrite `pnpm` only on
|
|
8885
8910
|
* drift. Returns the new body and the change descriptions, or `undefined` for the body when
|
|
8886
8911
|
* nothing changed. */
|
|
8887
|
-
function editToolsBody(body, existingNode, existingPnpm, pnpmVersion,
|
|
8912
|
+
function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, nodePin) {
|
|
8888
8913
|
let newBody = body;
|
|
8889
8914
|
const changes = [];
|
|
8890
8915
|
if (existingNode === void 0) {
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
changes.push(`added tools.node = "${pin}"`);
|
|
8916
|
+
newBody = appendLine(newBody, `node = "${nodePin}"`);
|
|
8917
|
+
changes.push(`added tools.node = "${nodePin}"`);
|
|
8894
8918
|
}
|
|
8895
8919
|
if (pnpmVersion && existingPnpm !== pnpmVersion) {
|
|
8896
8920
|
if (existingPnpm === void 0) {
|
|
@@ -8908,12 +8932,18 @@ function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, enginesNod
|
|
|
8908
8932
|
changes
|
|
8909
8933
|
};
|
|
8910
8934
|
}
|
|
8911
|
-
|
|
8935
|
+
/**
|
|
8936
|
+
* `priorDevEngines` is `package.json`'s `devEngines` as it stood before this sync: the pipeline
|
|
8937
|
+
* reads it before `generatePackageJson` removes `devEngines.runtime`, because by the time this
|
|
8938
|
+
* generator re-reads `package.json` the field is gone. Without it, the file read now is used.
|
|
8939
|
+
*/
|
|
8940
|
+
async function generateMise(ctx, priorDevEngines) {
|
|
8912
8941
|
const filePath = "mise.toml";
|
|
8913
|
-
const { enginesNode, pnpmVersion } = readPackageJsonFacts(ctx);
|
|
8942
|
+
const { enginesNode, devEngines, pnpmVersion } = readPackageJsonFacts(ctx);
|
|
8943
|
+
const nodePin = nodePinFor(enginesNode, devEnginesNodeVersion(priorDevEngines ?? devEngines));
|
|
8914
8944
|
const existing = ctx.read(filePath);
|
|
8915
8945
|
if (!existing) {
|
|
8916
|
-
ctx.write(filePath, ownedLines(pnpmVersion,
|
|
8946
|
+
ctx.write(filePath, ownedLines(pnpmVersion, nodePin).join("\n") + "\n");
|
|
8917
8947
|
return {
|
|
8918
8948
|
filePath,
|
|
8919
8949
|
action: "created",
|
|
@@ -8952,11 +8982,11 @@ async function generateMise(ctx) {
|
|
|
8952
8982
|
action: "failed",
|
|
8953
8983
|
description: `mise.toml: tools.* is declared with dotted keys; repo:sync only manages a [tools] table`
|
|
8954
8984
|
};
|
|
8955
|
-
text = ownedLines(pnpmVersion,
|
|
8985
|
+
text = ownedLines(pnpmVersion, nodePin).join("\n") + "\n\n" + existing;
|
|
8956
8986
|
changes = ["added [tools] table"];
|
|
8957
8987
|
} else {
|
|
8958
8988
|
const body = existing.slice(range.start, range.end);
|
|
8959
|
-
const edited = editToolsBody(body, existingNode, existingPnpm, pnpmVersion,
|
|
8989
|
+
const edited = editToolsBody(body, existingNode, existingPnpm, pnpmVersion, nodePin);
|
|
8960
8990
|
changes = edited.changes;
|
|
8961
8991
|
text = edited.body === body ? existing : existing.slice(0, range.start) + edited.body + existing.slice(range.end);
|
|
8962
8992
|
}
|
|
@@ -9813,7 +9843,7 @@ function postReleaseHookJob(opts) {
|
|
|
9813
9843
|
needs: opts.needsJob,
|
|
9814
9844
|
if: opts.publishCondition,
|
|
9815
9845
|
"continue-on-error": true,
|
|
9816
|
-
steps: [...commonSteps(opts.ci
|
|
9846
|
+
steps: [...commonSteps(opts.ci), { step: {
|
|
9817
9847
|
name: "Post-release hook",
|
|
9818
9848
|
env: {
|
|
9819
9849
|
...hookSecretEnv(opts.secrets),
|
|
@@ -9852,21 +9882,20 @@ function deployDispatchStep(opts) {
|
|
|
9852
9882
|
} };
|
|
9853
9883
|
}
|
|
9854
9884
|
/** Common setup steps shared by release-it and simple strategies. */
|
|
9855
|
-
function commonSteps(ci
|
|
9885
|
+
function commonSteps(ci) {
|
|
9856
9886
|
return [
|
|
9857
9887
|
{ step: {
|
|
9858
9888
|
uses: "actions/checkout@v6",
|
|
9859
9889
|
with: { "fetch-depth": 0 }
|
|
9860
9890
|
} },
|
|
9861
9891
|
...miseSetupSteps(ci),
|
|
9862
|
-
...authSteps,
|
|
9863
9892
|
{ step: { run: "pnpm install --frozen-lockfile" } }
|
|
9864
9893
|
];
|
|
9865
9894
|
}
|
|
9866
9895
|
function releaseItSteps(ci, npmAuth) {
|
|
9867
9896
|
const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
|
|
9868
|
-
return [...commonSteps(ci
|
|
9869
|
-
run: "pnpm release-it --ci",
|
|
9897
|
+
return [...commonSteps(ci), { step: {
|
|
9898
|
+
run: npmAuth.publishRun("pnpm release-it --ci"),
|
|
9870
9899
|
env: {
|
|
9871
9900
|
...tokenEnv,
|
|
9872
9901
|
...npmAuth.tokenEnv,
|
|
@@ -9917,7 +9946,7 @@ function simpleReleaseEnv() {
|
|
|
9917
9946
|
};
|
|
9918
9947
|
}
|
|
9919
9948
|
function simpleReleaseSteps(ctx) {
|
|
9920
|
-
const { ci,
|
|
9949
|
+
const { ci, hasDocker, hasAssets, host } = ctx;
|
|
9921
9950
|
const releaseEnv = connectionEnv(ci);
|
|
9922
9951
|
const pushedIf = "steps.release.outputs.pushed == 'true'";
|
|
9923
9952
|
const releaseStep = { step: {
|
|
@@ -9943,7 +9972,7 @@ function simpleReleaseSteps(ctx) {
|
|
|
9943
9972
|
run: "pnpm exec bst docker:publish"
|
|
9944
9973
|
} };
|
|
9945
9974
|
return [
|
|
9946
|
-
...commonSteps(ci
|
|
9975
|
+
...commonSteps(ci),
|
|
9947
9976
|
releaseStep,
|
|
9948
9977
|
...hasAssets ? [assetsStep] : [],
|
|
9949
9978
|
...hasDocker ? [dockerStep] : []
|
|
@@ -9956,21 +9985,36 @@ function buildSteps(strategy, ctx) {
|
|
|
9956
9985
|
default: return null;
|
|
9957
9986
|
}
|
|
9958
9987
|
}
|
|
9988
|
+
/**
|
|
9989
|
+
* Whether this release strategy gets its own `release.yml`.
|
|
9990
|
+
*
|
|
9991
|
+
* `changesets` releases from the CI workflow (`generateCi` writes that step inline) and `none`
|
|
9992
|
+
* releases nothing. Exported for `generateStaleCiWorkflows`, which asks the same question to
|
|
9993
|
+
* decide whether a `release.yml` on disk is still current -- the two must not answer it
|
|
9994
|
+
* differently or the retirer deletes a file this generator has just written.
|
|
9995
|
+
*/
|
|
9996
|
+
function hasReleaseWorkflow(strategy) {
|
|
9997
|
+
return strategy === "simple" || strategy === "release-it";
|
|
9998
|
+
}
|
|
9999
|
+
/** Where this platform's release workflow lives. */
|
|
10000
|
+
function releaseWorkflowPath(ci) {
|
|
10001
|
+
return ci === "github" ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
|
|
10002
|
+
}
|
|
9959
10003
|
async function generateReleaseCi(ctx) {
|
|
9960
10004
|
const filePath = "release-ci";
|
|
9961
|
-
|
|
10005
|
+
const isGitHub = ctx.config.ci === "github";
|
|
10006
|
+
if (ctx.config.ci === "none" || ctx.config.releaseStrategy === "none") return {
|
|
9962
10007
|
filePath,
|
|
9963
10008
|
action: "skipped",
|
|
9964
10009
|
description: "Release CI workflow not applicable"
|
|
9965
10010
|
};
|
|
9966
|
-
|
|
9967
|
-
if (ctx.config.releaseStrategy === "changesets") return {
|
|
10011
|
+
if (!hasReleaseWorkflow(ctx.config.releaseStrategy)) return {
|
|
9968
10012
|
filePath,
|
|
9969
10013
|
action: "skipped",
|
|
9970
10014
|
description: "Release step included in CI workflow"
|
|
9971
10015
|
};
|
|
9972
|
-
const
|
|
9973
|
-
const workflowPath =
|
|
10016
|
+
const publishesNpm = ctx.config.publishNpm === true;
|
|
10017
|
+
const workflowPath = releaseWorkflowPath(ctx.config.ci);
|
|
9974
10018
|
const hasDocker = hasDockerPackages(ctx) && ctx.config.publishDocker;
|
|
9975
10019
|
const hasAssets = hasReleaseAssets(ctx) || hasVersionStamp(ctx);
|
|
9976
10020
|
const host = resolveGenTimeHost(ctx);
|
|
@@ -10026,7 +10070,6 @@ async function generateReleaseCi(ctx) {
|
|
|
10026
10070
|
on,
|
|
10027
10071
|
...isGitHub && { permissions: { contents: "write" } },
|
|
10028
10072
|
jobName: "release",
|
|
10029
|
-
...Object.keys(npmAuth.jobEnv).length > 0 && { jobEnv: npmAuth.jobEnv },
|
|
10030
10073
|
...jobOutputs && { jobOutputs },
|
|
10031
10074
|
steps,
|
|
10032
10075
|
...extraJobs && { extraJobs }
|
|
@@ -10099,7 +10142,7 @@ function healthSteps(includeHistory) {
|
|
|
10099
10142
|
} }];
|
|
10100
10143
|
}
|
|
10101
10144
|
/** Build the release step for the check job (changesets strategy). */
|
|
10102
|
-
function changesetsReleaseStep(ci, publishesNpm,
|
|
10145
|
+
function changesetsReleaseStep(ci, publishesNpm, npmAuth) {
|
|
10103
10146
|
if (ci === "github") {
|
|
10104
10147
|
const changesetsEnv = {
|
|
10105
10148
|
GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
|
|
@@ -10123,10 +10166,10 @@ function changesetsReleaseStep(ci, publishesNpm, tokenEnv) {
|
|
|
10123
10166
|
FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
|
|
10124
10167
|
FORGEJO_REPOSITORY: actionsExpr("github.repository"),
|
|
10125
10168
|
RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
|
|
10126
|
-
...tokenEnv,
|
|
10169
|
+
...npmAuth.tokenEnv,
|
|
10127
10170
|
RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
|
|
10128
10171
|
},
|
|
10129
|
-
run: "pnpm exec bst release:changesets"
|
|
10172
|
+
run: npmAuth.publishRun("pnpm exec bst release:changesets")
|
|
10130
10173
|
} };
|
|
10131
10174
|
}
|
|
10132
10175
|
/**
|
|
@@ -10159,7 +10202,7 @@ function ciConcurrency(ci) {
|
|
|
10159
10202
|
"cancel-in-progress": actionsExpr(IS_NOT_MAIN_BRANCH)
|
|
10160
10203
|
};
|
|
10161
10204
|
}
|
|
10162
|
-
function checkSteps(ci,
|
|
10205
|
+
function checkSteps(ci, coverageEnabled, emitDockerSweepStep) {
|
|
10163
10206
|
const checkCommand = coverageEnabled ? "pnpm ci:check" : "pnpm ci:check --no-coverage";
|
|
10164
10207
|
return [
|
|
10165
10208
|
{ step: {
|
|
@@ -10167,7 +10210,6 @@ function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
|
|
|
10167
10210
|
with: { "fetch-depth": 0 }
|
|
10168
10211
|
} },
|
|
10169
10212
|
...miseSetupSteps(ci),
|
|
10170
|
-
...authSteps,
|
|
10171
10213
|
{ step: { run: "pnpm install --frozen-lockfile" } },
|
|
10172
10214
|
...emitDockerSweepStep ? [{ step: {
|
|
10173
10215
|
name: "Sweep leaked Docker resources",
|
|
@@ -10179,7 +10221,13 @@ function checkSteps(ci, authSteps, coverageEnabled, emitDockerSweepStep) {
|
|
|
10179
10221
|
} }
|
|
10180
10222
|
];
|
|
10181
10223
|
}
|
|
10182
|
-
/**
|
|
10224
|
+
/**
|
|
10225
|
+
* Resolve the CI workflow filename based on release strategy.
|
|
10226
|
+
*
|
|
10227
|
+
* Exported for `generateStaleCiWorkflows`, which derives the file the current strategy keeps
|
|
10228
|
+
* from this one function rather than a second copy of the mapping -- if the two could drift
|
|
10229
|
+
* apart, the retirer would delete a file this generator has just written.
|
|
10230
|
+
*/
|
|
10183
10231
|
function ciWorkflowPath(ci, releaseStrategy) {
|
|
10184
10232
|
return `${ci === "github" ? ".github/workflows" : ".forgejo/workflows"}/${releaseStrategy === "changesets" ? "ci.yml" : "check.yml"}`;
|
|
10185
10233
|
}
|
|
@@ -10220,10 +10268,10 @@ async function generateCi(ctx) {
|
|
|
10220
10268
|
}
|
|
10221
10269
|
const npmAuth = npmAuthWiring(ctx, isChangesets && publishesNpm && !isGitHub);
|
|
10222
10270
|
const steps = [
|
|
10223
|
-
...checkSteps(ctx.config.ci,
|
|
10271
|
+
...checkSteps(ctx.config.ci, coverage.enabled, hasDockerPackages(ctx)),
|
|
10224
10272
|
...emitCoverageSteps ? coverageHistorySteps() : [],
|
|
10225
10273
|
...healthSteps(emitHealthHistory),
|
|
10226
|
-
...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth
|
|
10274
|
+
...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth)] : [],
|
|
10227
10275
|
...isChangesets && isGitHub ? [githubChangesetsDeriveTagStep] : [],
|
|
10228
10276
|
...hasAssets ? [{ step: {
|
|
10229
10277
|
name: "Upload Release Assets",
|
|
@@ -10261,10 +10309,7 @@ async function generateCi(ctx) {
|
|
|
10261
10309
|
secrets: hookSecrets
|
|
10262
10310
|
}) };
|
|
10263
10311
|
}
|
|
10264
|
-
const checkJobEnv = {
|
|
10265
|
-
...emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {},
|
|
10266
|
-
...npmAuth.jobEnv
|
|
10267
|
-
};
|
|
10312
|
+
const checkJobEnv = emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {};
|
|
10268
10313
|
const checksSlice = parseToolingConfigSlice(ctx.read(".tooling.json"), z.object({ checks: ChecksConfigSchema.optional() }), { block: "checks" });
|
|
10269
10314
|
const skipOnVersionPr = isChangesets && checksSlice?.checks?.runOnVersionPr !== true;
|
|
10270
10315
|
const content = buildWorkflowYaml({
|
|
@@ -11181,7 +11226,7 @@ function buildConfig$1(config) {
|
|
|
11181
11226
|
].join("\n");
|
|
11182
11227
|
}
|
|
11183
11228
|
/** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
|
|
11184
|
-
const ARCHIVE_DIR
|
|
11229
|
+
const ARCHIVE_DIR = ".tooling-archived";
|
|
11185
11230
|
/** Common client-side git hooks that husky may have configured. */
|
|
11186
11231
|
const HUSKY_HOOK_NAMES = [
|
|
11187
11232
|
"pre-commit",
|
|
@@ -11215,7 +11260,7 @@ function archiveHuskyHooks(ctx, results) {
|
|
|
11215
11260
|
results.push({
|
|
11216
11261
|
filePath: huskyPath,
|
|
11217
11262
|
action: "archived",
|
|
11218
|
-
description: `Moved to ${ARCHIVE_DIR
|
|
11263
|
+
description: `Moved to ${ARCHIVE_DIR}/${huskyPath}`
|
|
11219
11264
|
});
|
|
11220
11265
|
found = true;
|
|
11221
11266
|
}
|
|
@@ -11230,7 +11275,7 @@ function archiveLintStagedConfigs(ctx, results) {
|
|
|
11230
11275
|
results.push({
|
|
11231
11276
|
filePath: lsPath,
|
|
11232
11277
|
action: "archived",
|
|
11233
|
-
description: `Moved to ${ARCHIVE_DIR
|
|
11278
|
+
description: `Moved to ${ARCHIVE_DIR}/${lsPath}`
|
|
11234
11279
|
});
|
|
11235
11280
|
found = true;
|
|
11236
11281
|
}
|
|
@@ -11438,12 +11483,17 @@ async function generateVscodeSettings(ctx) {
|
|
|
11438
11483
|
}
|
|
11439
11484
|
//#endregion
|
|
11440
11485
|
//#region src/generators/coverage-ci.ts
|
|
11441
|
-
/** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
|
|
11442
|
-
const ARCHIVE_DIR = ".tooling-archived";
|
|
11443
11486
|
/**
|
|
11444
|
-
* Cleanup-only generator: coverage steps now run inline in
|
|
11445
|
-
*
|
|
11446
|
-
*
|
|
11487
|
+
* Cleanup-only generator: coverage steps now run inline in the CI workflow (see generateCi), so
|
|
11488
|
+
* any legacy `coverage.yml` left over from an older sync is removed.
|
|
11489
|
+
*
|
|
11490
|
+
* The result is `removed`, not `archived`: `repo:sync --check` counts only the former as drift,
|
|
11491
|
+
* and the agent's workflow sync sweep acts only on drift the check reports. An `archived` result
|
|
11492
|
+
* let a plain `repo:sync` delete a file while `--check` called the repo up to date.
|
|
11493
|
+
*
|
|
11494
|
+
* The ignore comment is the only opt-out here, and there is deliberately no managed-header guard:
|
|
11495
|
+
* this file predates the header, so no legacy `coverage.yml` in a synced repo carries one. Requiring
|
|
11496
|
+
* it would leave every file this generator exists to remove exactly where it is.
|
|
11447
11497
|
*/
|
|
11448
11498
|
async function generateCoverageCi(ctx) {
|
|
11449
11499
|
const filePath = "coverage-ci";
|
|
@@ -11453,19 +11503,23 @@ async function generateCoverageCi(ctx) {
|
|
|
11453
11503
|
description: "CI workflow not requested"
|
|
11454
11504
|
};
|
|
11455
11505
|
const workflowPath = ctx.config.ci === "github" ? ".github/workflows/coverage.yml" : ".forgejo/workflows/coverage.yml";
|
|
11456
|
-
|
|
11457
|
-
|
|
11458
|
-
return {
|
|
11459
|
-
filePath: workflowPath,
|
|
11460
|
-
action: "archived",
|
|
11461
|
-
description: `Moved to ${ARCHIVE_DIR}/${workflowPath} (coverage now runs inline in ci.yml)`
|
|
11462
|
-
};
|
|
11463
|
-
}
|
|
11464
|
-
return {
|
|
11506
|
+
const existing = ctx.read(workflowPath);
|
|
11507
|
+
if (existing === void 0) return {
|
|
11465
11508
|
filePath,
|
|
11466
11509
|
action: "skipped",
|
|
11467
11510
|
description: "Coverage runs inline in ci.yml"
|
|
11468
11511
|
};
|
|
11512
|
+
if (isToolingIgnored(existing)) return {
|
|
11513
|
+
filePath: workflowPath,
|
|
11514
|
+
action: "skipped",
|
|
11515
|
+
description: "coverage.yml has ignore comment"
|
|
11516
|
+
};
|
|
11517
|
+
ctx.remove(workflowPath);
|
|
11518
|
+
return {
|
|
11519
|
+
filePath: workflowPath,
|
|
11520
|
+
action: "removed",
|
|
11521
|
+
description: `Moved to ${ARCHIVE_DIR$1}/${workflowPath} (coverage now runs inline in ci.yml)`
|
|
11522
|
+
};
|
|
11469
11523
|
}
|
|
11470
11524
|
//#endregion
|
|
11471
11525
|
//#region src/docs-check/blocks.ts
|
|
@@ -12381,7 +12435,7 @@ function webUiGuideBody() {
|
|
|
12381
12435
|
const mwg = `pnpm dlx modern-web-guidance@${{
|
|
12382
12436
|
"@changesets/cli": "3.0.3",
|
|
12383
12437
|
"@release-it/bumper": "8.0.1",
|
|
12384
|
-
"@types/node": "24.
|
|
12438
|
+
"@types/node": "24.19.1",
|
|
12385
12439
|
"@vitest/coverage-v8": "5.0.3",
|
|
12386
12440
|
"commit-and-tag-version": "13.2.1",
|
|
12387
12441
|
"knip": "6.39.0",
|
|
@@ -12395,7 +12449,7 @@ function webUiGuideBody() {
|
|
|
12395
12449
|
"tsdown": "0.23.0",
|
|
12396
12450
|
"typescript": "6.0.3",
|
|
12397
12451
|
"vitest": "5.0.3",
|
|
12398
|
-
"pnpm": "11.
|
|
12452
|
+
"pnpm": "11.28.3"
|
|
12399
12453
|
}["modern-web-guidance"]}`;
|
|
12400
12454
|
return [
|
|
12401
12455
|
"# Web UI",
|
|
@@ -12946,12 +13000,74 @@ async function generateVersionrc(ctx) {
|
|
|
12946
13000
|
};
|
|
12947
13001
|
}
|
|
12948
13002
|
//#endregion
|
|
13003
|
+
//#region src/generators/stale-ci-workflows.ts
|
|
13004
|
+
/**
|
|
13005
|
+
* Cleanup-only generator: retire the strategy-dependent CI workflow that the repo's current
|
|
13006
|
+
* `releaseStrategy` no longer writes.
|
|
13007
|
+
*
|
|
13008
|
+
* `generateCi` names its file after the release strategy (`ci.yml` for changesets, `check.yml`
|
|
13009
|
+
* for every other strategy), and `generateReleaseCi` writes `release.yml` for two of the four.
|
|
13010
|
+
* Changing `.tooling.json#releaseStrategy` therefore leaves the previous file on disk, where it
|
|
13011
|
+
* keeps running the check pipeline or a release job beside the new one. Nothing else removes it:
|
|
13012
|
+
* no generator names a file the current strategy does not use, so no generator reports it.
|
|
13013
|
+
*
|
|
13014
|
+
* The kept set comes from the two generators' own functions -- `ciWorkflowPath` for the CI file
|
|
13015
|
+
* and `hasReleaseWorkflow` for `release.yml` -- never from a table copied here. That is the whole
|
|
13016
|
+
* safety argument: this retirer cannot delete a file the generator has just written, because both
|
|
13017
|
+
* read one mapping.
|
|
13018
|
+
*
|
|
13019
|
+
* `ci: "none"` retires nothing. A repo that switched CI off is not claiming these files are
|
|
13020
|
+
* stale, and emptying a workflow directory a human chose to manage by hand is not this
|
|
13021
|
+
* generator's call.
|
|
13022
|
+
*/
|
|
13023
|
+
async function generateStaleCiWorkflows(ctx) {
|
|
13024
|
+
const ci = ctx.config.ci;
|
|
13025
|
+
if (ci === "none") return [];
|
|
13026
|
+
const strategy = ctx.config.releaseStrategy;
|
|
13027
|
+
const keptCiPath = ciWorkflowPath(ci, strategy);
|
|
13028
|
+
const candidates = [
|
|
13029
|
+
ciWorkflowPath(ci, "changesets"),
|
|
13030
|
+
ciWorkflowPath(ci, "none"),
|
|
13031
|
+
releaseWorkflowPath(ci)
|
|
13032
|
+
];
|
|
13033
|
+
const kept = /* @__PURE__ */ new Set([keptCiPath]);
|
|
13034
|
+
if (hasReleaseWorkflow(strategy)) kept.add(releaseWorkflowPath(ci));
|
|
13035
|
+
const results = [];
|
|
13036
|
+
for (const candidate of candidates) {
|
|
13037
|
+
if (kept.has(candidate)) continue;
|
|
13038
|
+
const result = retireManagedWorkflow(ctx, candidate, retireReason(strategy, candidate, keptCiPath, ci));
|
|
13039
|
+
if (result !== void 0) results.push(result);
|
|
13040
|
+
}
|
|
13041
|
+
return results;
|
|
13042
|
+
}
|
|
13043
|
+
/** Last path segment. `node:path` has no opinion about workflow separators, and these are
|
|
13044
|
+
* always POSIX paths, so this stays here rather than pulling in a platform-aware helper. */
|
|
13045
|
+
function baseName(workflowPath) {
|
|
13046
|
+
return workflowPath.slice(workflowPath.lastIndexOf("/") + 1);
|
|
13047
|
+
}
|
|
13048
|
+
/**
|
|
13049
|
+
* Why this file is stale, naming the strategy that made it so, so the sync sweep's pull request
|
|
13050
|
+
* and the `--check` output explain the deletion without the reader opening a generator.
|
|
13051
|
+
*
|
|
13052
|
+
* Every branch reads the kept path, never a strategy literal: a changesets repo runs its release
|
|
13053
|
+
* step inside `ci.yml`, so a retired `release.yml` there says the release moved there, and
|
|
13054
|
+
* anywhere else the strategy has no release workflow at all.
|
|
13055
|
+
*/
|
|
13056
|
+
function retireReason(strategy, retiredPath, keptCiPath, ci) {
|
|
13057
|
+
const keptCiBaseName = baseName(keptCiPath);
|
|
13058
|
+
const prefix = `releaseStrategy "${strategy}"`;
|
|
13059
|
+
if (retiredPath !== releaseWorkflowPath(ci)) return `${prefix} runs checks in ${keptCiBaseName}`;
|
|
13060
|
+
if (keptCiPath === ciWorkflowPath(ci, "changesets")) return `${prefix} releases from ${keptCiBaseName}`;
|
|
13061
|
+
return `${prefix} has no release workflow`;
|
|
13062
|
+
}
|
|
13063
|
+
//#endregion
|
|
12949
13064
|
//#region src/generators/pipeline.ts
|
|
12950
13065
|
/** Run all generators sequentially and return their results. */
|
|
12951
13066
|
async function runGenerators(ctx) {
|
|
12952
13067
|
const results = [];
|
|
13068
|
+
const priorDevEngines = ctx.packageJson?.devEngines;
|
|
12953
13069
|
results.push(await generatePackageJson(ctx));
|
|
12954
|
-
results.push(await generateMise(ctx));
|
|
13070
|
+
results.push(await generateMise(ctx, priorDevEngines));
|
|
12955
13071
|
results.push(...await generateWorkspaceEngines(ctx));
|
|
12956
13072
|
results.push(await generatePnpmWorkspace(ctx));
|
|
12957
13073
|
results.push(...await generateTsconfig(ctx));
|
|
@@ -12970,6 +13086,7 @@ async function runGenerators(ctx) {
|
|
|
12970
13086
|
results.push(await generateVersionrc(ctx));
|
|
12971
13087
|
results.push(await generateChangesets(ctx));
|
|
12972
13088
|
results.push(await generateReleaseCi(ctx));
|
|
13089
|
+
results.push(...await generateStaleCiWorkflows(ctx));
|
|
12973
13090
|
results.push(await generateDeployCi(ctx));
|
|
12974
13091
|
results.push(await generateCoverageCi(ctx));
|
|
12975
13092
|
results.push(...await generateClaudeMd(ctx));
|
|
@@ -13002,7 +13119,7 @@ function reportFailures(failed, command) {
|
|
|
13002
13119
|
function generateMigratePrompt(results, config, detected) {
|
|
13003
13120
|
const sections = [];
|
|
13004
13121
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
13005
|
-
sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.
|
|
13122
|
+
sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.1 repo:sync\` on ${timestamp}_\n`);
|
|
13006
13123
|
const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
|
|
13007
13124
|
if (changed.length > 0) {
|
|
13008
13125
|
const items = changed.map((r) => {
|
|
@@ -13988,7 +14105,9 @@ function createFileReader() {
|
|
|
13988
14105
|
* (`commands/sync-gates.ts`) -- see that gate's doc comment for why an absent mise warns rather
|
|
13989
14106
|
* than fails -- plus, when `publishDocker` is on, one more per image only the check overlay
|
|
13990
14107
|
* 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
|
|
14108
|
+
* publishing it, counted rather than failed for the same reason the other two are -- plus one
|
|
14109
|
+
* more per workflow file whose custom block installs packages with apt (`runWorkflowAptGate`,
|
|
14110
|
+
* same file), which the agent's containers cannot run.
|
|
13992
14111
|
*/
|
|
13993
14112
|
const CHECKS_MARKER = "::bst-checks::";
|
|
13994
14113
|
const WARN_LINE = new RegExp(`^${CHECKS_MARKER} warn (\\d+)\\s*$`);
|
|
@@ -15560,6 +15679,113 @@ const runChecksCommand = defineCommand({
|
|
|
15560
15679
|
}
|
|
15561
15680
|
});
|
|
15562
15681
|
//#endregion
|
|
15682
|
+
//#region src/utils/workflow-paths.ts
|
|
15683
|
+
/**
|
|
15684
|
+
* The single definition of "workflow dir", shared by `repo:sync --check`'s advisory
|
|
15685
|
+
* classification and the workflow sync sweep's path allowlist -- so the two halves cannot
|
|
15686
|
+
* disagree about what counts as workflow output. Each prefix carries its trailing slash so a
|
|
15687
|
+
* file named e.g. `.github/workflowsX` never matches.
|
|
15688
|
+
*/
|
|
15689
|
+
const WORKFLOW_DIR_PREFIXES = [".forgejo/workflows/", ".github/workflows/"];
|
|
15690
|
+
/** True when `filePath` (repo-relative) lives under one of the workflow directories. */
|
|
15691
|
+
function isWorkflowDirPath(filePath) {
|
|
15692
|
+
return WORKFLOW_DIR_PREFIXES.some((prefix) => filePath.startsWith(prefix));
|
|
15693
|
+
}
|
|
15694
|
+
//#endregion
|
|
15695
|
+
//#region src/commands/workflow-apt-steps.ts
|
|
15696
|
+
/** `apt-get install`, `apt install`, and either with options before the verb (`apt-get -y
|
|
15697
|
+
* install`). */
|
|
15698
|
+
const APT_INSTALL = /\bapt(?:-get)?(?:\s+-\S+)*\s+install\b/g;
|
|
15699
|
+
const COMMAND_END = /&&|\|\||;|\|/;
|
|
15700
|
+
/** Every readable `.yml`/`.yaml` file in the two workflow directories, as repo-relative paths. */
|
|
15701
|
+
function workflowFiles(targetDir) {
|
|
15702
|
+
const files = [];
|
|
15703
|
+
for (const prefix of WORKFLOW_DIR_PREFIXES) {
|
|
15704
|
+
let names;
|
|
15705
|
+
try {
|
|
15706
|
+
names = readdirSync(path.join(targetDir, prefix));
|
|
15707
|
+
} catch {
|
|
15708
|
+
continue;
|
|
15709
|
+
}
|
|
15710
|
+
for (const name of names) {
|
|
15711
|
+
if (!/\.ya?ml$/.test(name)) continue;
|
|
15712
|
+
const relative = `${prefix}${name}`;
|
|
15713
|
+
if (isReadableFile$1(path.join(targetDir, relative))) files.push(relative);
|
|
15714
|
+
}
|
|
15715
|
+
}
|
|
15716
|
+
return files;
|
|
15717
|
+
}
|
|
15718
|
+
/** False for a directory, a dangling symlink, or anything `stat` refuses: an advisory never fails
|
|
15719
|
+
* the run it advises, so a file it cannot look at is skipped, not thrown on. */
|
|
15720
|
+
function isReadableFile$1(file) {
|
|
15721
|
+
try {
|
|
15722
|
+
return statSync(file).isFile();
|
|
15723
|
+
} catch {
|
|
15724
|
+
return false;
|
|
15725
|
+
}
|
|
15726
|
+
}
|
|
15727
|
+
/** A file's text, or null when it cannot be read -- skipped for the same reason. */
|
|
15728
|
+
function readText(file) {
|
|
15729
|
+
try {
|
|
15730
|
+
return readFileSync(file, "utf8");
|
|
15731
|
+
} catch {
|
|
15732
|
+
return null;
|
|
15733
|
+
}
|
|
15734
|
+
}
|
|
15735
|
+
/** A custom block's content lines, with the markers and every other comment line dropped (a
|
|
15736
|
+
* commented-out apt step has been removed) and `\`-continued lines joined. */
|
|
15737
|
+
function logicalLines(blockLines) {
|
|
15738
|
+
const lines = [];
|
|
15739
|
+
let pending = "";
|
|
15740
|
+
for (const line of blockLines) {
|
|
15741
|
+
if (line.trim().startsWith("#")) continue;
|
|
15742
|
+
const trimmed = line.trimEnd();
|
|
15743
|
+
if (trimmed.endsWith("\\")) {
|
|
15744
|
+
pending += `${trimmed.slice(0, -1)} `;
|
|
15745
|
+
continue;
|
|
15746
|
+
}
|
|
15747
|
+
lines.push(pending + line);
|
|
15748
|
+
pending = "";
|
|
15749
|
+
}
|
|
15750
|
+
if (pending !== "") lines.push(pending);
|
|
15751
|
+
return lines;
|
|
15752
|
+
}
|
|
15753
|
+
/** The packages one line installs, or none when it runs no apt install. */
|
|
15754
|
+
function packagesIn(line) {
|
|
15755
|
+
const packages = [];
|
|
15756
|
+
for (const match of line.matchAll(APT_INSTALL)) {
|
|
15757
|
+
const command = line.slice(match.index + match[0].length).split(COMMAND_END)[0] ?? "";
|
|
15758
|
+
for (const token of command.split(/\s+/)) {
|
|
15759
|
+
if (token === "" || token.startsWith("-")) continue;
|
|
15760
|
+
packages.push(token);
|
|
15761
|
+
}
|
|
15762
|
+
}
|
|
15763
|
+
return packages;
|
|
15764
|
+
}
|
|
15765
|
+
/** True when `line` runs an apt install at all, packages or not. */
|
|
15766
|
+
function installsWithApt(line) {
|
|
15767
|
+
return line.search(APT_INSTALL) !== -1;
|
|
15768
|
+
}
|
|
15769
|
+
function findWorkflowAptSteps(targetDir) {
|
|
15770
|
+
const steps = [];
|
|
15771
|
+
for (const file of workflowFiles(targetDir)) {
|
|
15772
|
+
const content = readText(path.join(targetDir, file));
|
|
15773
|
+
if (content === null || isToolingIgnored(content)) continue;
|
|
15774
|
+
let matched = false;
|
|
15775
|
+
const packages = /* @__PURE__ */ new Set();
|
|
15776
|
+
for (const block of extractCustomBlocks(content)) for (const line of logicalLines(block.lines)) {
|
|
15777
|
+
if (!installsWithApt(line)) continue;
|
|
15778
|
+
matched = true;
|
|
15779
|
+
for (const name of packagesIn(line)) packages.add(name);
|
|
15780
|
+
}
|
|
15781
|
+
if (matched) steps.push({
|
|
15782
|
+
file,
|
|
15783
|
+
packages: [...packages]
|
|
15784
|
+
});
|
|
15785
|
+
}
|
|
15786
|
+
return steps.toSorted((a, b) => a.file.localeCompare(b.file));
|
|
15787
|
+
}
|
|
15788
|
+
//#endregion
|
|
15563
15789
|
//#region src/commands/sync-gates.ts
|
|
15564
15790
|
/**
|
|
15565
15791
|
* The pre-generator gates and config diagnostics every `repo:sync` mode runs -- split out of
|
|
@@ -15619,6 +15845,23 @@ function runCheckOnlyImageGate(config, targetDir) {
|
|
|
15619
15845
|
return images;
|
|
15620
15846
|
}
|
|
15621
15847
|
/**
|
|
15848
|
+
* Warn, once per workflow file, when a `@tooling:custom` block installs packages with apt. CI's
|
|
15849
|
+
* job runs as root and can do that; the agent's containers run without root and cannot, so the
|
|
15850
|
+
* package is present in CI and missing in the agent's measurement of the same tree. Advisory like
|
|
15851
|
+
* {@link runCheckOnlyImageGate}, and for the same reason it returns the entries: `repo:sync
|
|
15852
|
+
* --check` folds their count into the `::bst-checks:: warn` marker. It never fails a run and never
|
|
15853
|
+
* edits the block -- not every Debian package has a conda equivalent under the same name, so moving
|
|
15854
|
+
* it is a human's call.
|
|
15855
|
+
*/
|
|
15856
|
+
function runWorkflowAptGate(targetDir) {
|
|
15857
|
+
const steps = findWorkflowAptSteps(targetDir);
|
|
15858
|
+
for (const step of steps) {
|
|
15859
|
+
const packages = step.packages.length > 0 ? step.packages.join(", ") : "packages";
|
|
15860
|
+
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.`);
|
|
15861
|
+
}
|
|
15862
|
+
return steps;
|
|
15863
|
+
}
|
|
15864
|
+
/**
|
|
15622
15865
|
* Require a declared publish destination before any generator or apply step runs.
|
|
15623
15866
|
* Returns the number of offenders (0 = clean). Each is emitted via `log.error` so
|
|
15624
15867
|
* the user sees the full message and the fix.
|
|
@@ -15906,19 +16149,6 @@ function runToolchainGate(targetDir) {
|
|
|
15906
16149
|
};
|
|
15907
16150
|
}
|
|
15908
16151
|
//#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
16152
|
//#region src/commands/repo-sync.ts
|
|
15923
16153
|
const rawLog$1 = (msg) => console.log(msg);
|
|
15924
16154
|
const syncCommand = defineCommand({
|
|
@@ -15958,7 +16188,7 @@ const syncCommand = defineCommand({
|
|
|
15958
16188
|
},
|
|
15959
16189
|
json: {
|
|
15960
16190
|
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."
|
|
16191
|
+
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
16192
|
}
|
|
15963
16193
|
},
|
|
15964
16194
|
async run({ args }) {
|
|
@@ -16035,6 +16265,7 @@ function gateFailure(toolchainWarned = false) {
|
|
|
16035
16265
|
failed: [],
|
|
16036
16266
|
toolchain: { warned: toolchainWarned },
|
|
16037
16267
|
checkOnlyImages: [],
|
|
16268
|
+
aptSteps: [],
|
|
16038
16269
|
warnCount: 0,
|
|
16039
16270
|
exitCode: 1
|
|
16040
16271
|
};
|
|
@@ -16045,8 +16276,8 @@ function gateFailure(toolchainWarned = false) {
|
|
|
16045
16276
|
* withheld -- the same number rides in `warnCount`, and printing both would put a second thing on
|
|
16046
16277
|
* the stream beside the payload.
|
|
16047
16278
|
*/
|
|
16048
|
-
function upToDateReport(toolchain, checkOnlyImages) {
|
|
16049
|
-
const warnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length;
|
|
16279
|
+
function upToDateReport(toolchain, checkOnlyImages, aptSteps) {
|
|
16280
|
+
const warnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length + aptSteps.length;
|
|
16050
16281
|
if (warnCount > 0) {
|
|
16051
16282
|
if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
|
|
16052
16283
|
} else log$1.success("Repository is up to date.");
|
|
@@ -16056,6 +16287,7 @@ function upToDateReport(toolchain, checkOnlyImages) {
|
|
|
16056
16287
|
failed: [],
|
|
16057
16288
|
toolchain,
|
|
16058
16289
|
checkOnlyImages,
|
|
16290
|
+
aptSteps,
|
|
16059
16291
|
warnCount,
|
|
16060
16292
|
exitCode: 0
|
|
16061
16293
|
};
|
|
@@ -16091,28 +16323,30 @@ async function runCheckReport(targetDir) {
|
|
|
16091
16323
|
toolchain: { warned: toolchain.warned }
|
|
16092
16324
|
};
|
|
16093
16325
|
const checkOnlyImages = runCheckOnlyImageGate(config, targetDir);
|
|
16326
|
+
const aptSteps = runWorkflowAptGate(targetDir);
|
|
16094
16327
|
const { ctx, pendingWrites } = createDryRunContext(config);
|
|
16095
16328
|
logDetectionSummary(ctx);
|
|
16096
16329
|
const results = await runGenerators(ctx);
|
|
16097
16330
|
const failed = failedResults(results);
|
|
16098
16331
|
reportFailures(failed, "repo:sync");
|
|
16099
16332
|
const actionable = actionableResults(results, pendingWrites, targetDir);
|
|
16100
|
-
const
|
|
16333
|
+
const advisoryWarnCount = (toolchain.warned ? 1 : 0) + checkOnlyImages.length + aptSteps.length;
|
|
16101
16334
|
const toolchainState = { warned: toolchain.warned };
|
|
16102
|
-
if (actionable.length === 0 && failed.length === 0) return upToDateReport(toolchainState, checkOnlyImages);
|
|
16335
|
+
if (actionable.length === 0 && failed.length === 0) return upToDateReport(toolchainState, checkOnlyImages, aptSteps);
|
|
16103
16336
|
if (actionable.length === 0) return {
|
|
16104
16337
|
status: "failed",
|
|
16105
16338
|
actionable: [],
|
|
16106
16339
|
failed,
|
|
16107
16340
|
toolchain: toolchainState,
|
|
16108
16341
|
checkOnlyImages: [],
|
|
16342
|
+
aptSteps: [],
|
|
16109
16343
|
warnCount: 0,
|
|
16110
16344
|
exitCode: 1
|
|
16111
16345
|
};
|
|
16112
16346
|
log$1.warn(`${actionable.length} file(s) would be changed by repo:sync`);
|
|
16113
16347
|
for (const r of actionable) logActionableChange(r, targetDir, pendingWrites);
|
|
16114
16348
|
if (failed.length === 0 && actionable.every((r) => isWorkflowDirPath(r.filePath))) {
|
|
16115
|
-
const warnCount = actionable.length +
|
|
16349
|
+
const warnCount = actionable.length + advisoryWarnCount;
|
|
16116
16350
|
if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
|
|
16117
16351
|
return {
|
|
16118
16352
|
status: "workflow-drift",
|
|
@@ -16120,6 +16354,7 @@ async function runCheckReport(targetDir) {
|
|
|
16120
16354
|
failed: [],
|
|
16121
16355
|
toolchain: toolchainState,
|
|
16122
16356
|
checkOnlyImages,
|
|
16357
|
+
aptSteps,
|
|
16123
16358
|
warnCount,
|
|
16124
16359
|
exitCode: 0
|
|
16125
16360
|
};
|
|
@@ -16130,6 +16365,7 @@ async function runCheckReport(targetDir) {
|
|
|
16130
16365
|
failed,
|
|
16131
16366
|
toolchain: toolchainState,
|
|
16132
16367
|
checkOnlyImages: [],
|
|
16368
|
+
aptSteps: [],
|
|
16133
16369
|
warnCount: 0,
|
|
16134
16370
|
exitCode: 1
|
|
16135
16371
|
};
|
|
@@ -16324,6 +16560,9 @@ const PullListItemSchema = z.object({
|
|
|
16324
16560
|
updated_at: z.string().nullish(),
|
|
16325
16561
|
labels: z.array(z.object({ name: z.string() })).nullish()
|
|
16326
16562
|
});
|
|
16563
|
+
function updatedBefore(since) {
|
|
16564
|
+
return (pr) => pr.updatedAt !== null && Date.parse(pr.updatedAt) < since;
|
|
16565
|
+
}
|
|
16327
16566
|
/**
|
|
16328
16567
|
* Every pull request -- open, merged, and closed-unmerged -- by default, via `state=all`; a caller
|
|
16329
16568
|
* may narrow the read with {@link ListPullsOptions.state}. Paged to an empty page or to
|
|
@@ -16351,7 +16590,9 @@ async function listPulls(executor, conn, options = {}) {
|
|
|
16351
16590
|
limit: String(PULLS_PAGE_SIZE),
|
|
16352
16591
|
page: String(page)
|
|
16353
16592
|
});
|
|
16354
|
-
|
|
16593
|
+
const sort = options.updatedSince === void 0 ? options.sort : "recentupdate";
|
|
16594
|
+
if (sort !== void 0) params.set("sort", sort);
|
|
16595
|
+
for (const id of options.labelIds ?? []) params.append("labels", String(id));
|
|
16355
16596
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?${params.toString()}`;
|
|
16356
16597
|
const res = await executor.fetch(url, {
|
|
16357
16598
|
headers,
|
|
@@ -16380,6 +16621,7 @@ async function listPulls(executor, conn, options = {}) {
|
|
|
16380
16621
|
pulls.push(...items);
|
|
16381
16622
|
if (items.length === 0) break;
|
|
16382
16623
|
if (options.stopWhen !== void 0 && items.some(options.stopWhen)) break;
|
|
16624
|
+
if (options.updatedSince !== void 0 && items.some(updatedBefore(options.updatedSince))) break;
|
|
16383
16625
|
if (options.limit !== void 0 && pulls.length >= options.limit) break;
|
|
16384
16626
|
if (page === PULLS_MAX_PAGES) {
|
|
16385
16627
|
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 +21379,7 @@ function createRealLintTypesExecutor() {
|
|
|
21137
21379
|
}
|
|
21138
21380
|
//#endregion
|
|
21139
21381
|
//#region src/lint-types/phase.ts
|
|
21140
|
-
const LINT_TYPES_PHASE = "
|
|
21382
|
+
const LINT_TYPES_PHASE = "enforce";
|
|
21141
21383
|
//#endregion
|
|
21142
21384
|
//#region src/lint-types/report.ts
|
|
21143
21385
|
/** oxlint spells a rule code `plugin(rule)` (e.g. `typescript(no-base-to-string)`); the escape
|
|
@@ -21158,8 +21400,8 @@ function formatFindingLine(diagnostic) {
|
|
|
21158
21400
|
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
21401
|
}
|
|
21160
21402
|
/**
|
|
21161
|
-
*
|
|
21162
|
-
* worse than naming none.
|
|
21403
|
+
* Prints only when the phase is pinned to "report". Names no date: inventing one the fleet
|
|
21404
|
+
* cannot meet is worse than naming none.
|
|
21163
21405
|
*/
|
|
21164
21406
|
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
21407
|
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 +21605,7 @@ function runLintTypes(executor, targetDir, extraArgs = []) {
|
|
|
21363
21605
|
const lintTypesCommand = defineCommand({
|
|
21364
21606
|
meta: {
|
|
21365
21607
|
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.
|
|
21608
|
+
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
21609
|
},
|
|
21368
21610
|
args: {
|
|
21369
21611
|
dir: {
|
|
@@ -21966,12 +22208,16 @@ const PROBE_TIMEOUT_SECONDS = "5";
|
|
|
21966
22208
|
* Pinned by digest, not a tag: a tag-pinned probe image would re-pull (and
|
|
21967
22209
|
* could silently change) on every check run, reintroducing this exact
|
|
21968
22210
|
* issue's silent-timeout symptom on the very shared-agent CI runners it
|
|
21969
|
-
* targets, since registry egress is often restricted there.
|
|
21970
|
-
*
|
|
21971
|
-
*
|
|
21972
|
-
*
|
|
22211
|
+
* targets, since registry egress is often restricted there. The digest is
|
|
22212
|
+
* the multi-arch manifest list's, so `docker run` still resolves the correct
|
|
22213
|
+
* platform automatically.
|
|
22214
|
+
*
|
|
22215
|
+
* The tag rides along only for Renovate: Docker resolves a `tag@digest`
|
|
22216
|
+
* reference by the digest alone, and the root `renovate.json` has a custom
|
|
22217
|
+
* manager that reads the tag as the current version and moves tag and digest
|
|
22218
|
+
* together.
|
|
21973
22219
|
*/
|
|
21974
|
-
const PROBE_IMAGE = "curlimages/curl@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
|
|
22220
|
+
const PROBE_IMAGE = "curlimages/curl:8.21.0@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
|
|
21975
22221
|
/** Look up the health status of a specific service from container info. */
|
|
21976
22222
|
function getContainerHealth(containers, serviceName) {
|
|
21977
22223
|
return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
|
|
@@ -32247,6 +32493,11 @@ function taskLabelNumber(text) {
|
|
|
32247
32493
|
* statement speaks for every future slice, the progress note only for this one. `**Progress:**`
|
|
32248
32494
|
* and `**Blocked:**` alone declare nothing.
|
|
32249
32495
|
*
|
|
32496
|
+
* A box's **title** is the first sentence of its paragraph, not of its line: markdown joins a
|
|
32497
|
+
* wrapped box into one paragraph, and a title cut at the wrap read as a fragment on the control
|
|
32498
|
+
* panel. `**` markers are dropped from it. The title is the identity the agent's box diff pairs
|
|
32499
|
+
* on, so it must stay a pure function of the box's own text.
|
|
32500
|
+
*
|
|
32250
32501
|
* A box is the **retire task** when its title starts with optional `**` or `__`, then the
|
|
32251
32502
|
* literal `Retire this plan`, word-bounded (not followed by a letter or digit). That
|
|
32252
32503
|
* classification carries no judgement about whether the task is done; `isOutstanding` reads a
|
|
@@ -32274,6 +32525,49 @@ function noteKind(label) {
|
|
|
32274
32525
|
if (label === "Blocked") return "blocked";
|
|
32275
32526
|
return "declined";
|
|
32276
32527
|
}
|
|
32528
|
+
/** A line that ends a box's paragraph: another list item, or a `**Progress:**`-shaped note. */
|
|
32529
|
+
const PARAGRAPH_BREAK_REGEX = /^[ \t]*(?:[-*+][ \t]|[0-9]+[.)][ \t]|\*\*(?:Progress|Blocked|Declined):\*\*)/;
|
|
32530
|
+
/** A word whose trailing period abbreviates rather than ends a sentence. */
|
|
32531
|
+
const ABBREVIATION_REGEX = /(?:^|[^A-Za-z])(?:e\.g|i\.e|etc|vs|cf)$/i;
|
|
32532
|
+
/**
|
|
32533
|
+
* The box line's text joined with the lines that continue its paragraph, one space apart. Markdown
|
|
32534
|
+
* renders a wrapped box as one paragraph, so a line break says nothing about where the title ends.
|
|
32535
|
+
* The paragraph stops at a blank line, a heading, a fenced line, another list item, or a note.
|
|
32536
|
+
*/
|
|
32537
|
+
function boxParagraph(lines, fenced, start, end, first) {
|
|
32538
|
+
const parts = [first];
|
|
32539
|
+
for (let index = start + 1; index < end; index++) {
|
|
32540
|
+
const line = lines[index] ?? "";
|
|
32541
|
+
if (fenced[index] === true || line.trim() === "") break;
|
|
32542
|
+
if (HEADING_REGEX.test(line) || PARAGRAPH_BREAK_REGEX.test(line)) break;
|
|
32543
|
+
parts.push(line.trim());
|
|
32544
|
+
}
|
|
32545
|
+
return parts.join(" ").trim();
|
|
32546
|
+
}
|
|
32547
|
+
/**
|
|
32548
|
+
* The paragraph's first sentence: up to the first `.`, `!` or `?` outside an inline code span that
|
|
32549
|
+
* is followed by whitespace or the end, with any closing `*`, `_`, `)` or quote kept on it so a
|
|
32550
|
+
* bold span ending in a period stays whole. A common abbreviation (`e.g.`) does not end it. A
|
|
32551
|
+
* paragraph with no sentence end is returned whole.
|
|
32552
|
+
*/
|
|
32553
|
+
function firstSentence$1(paragraph) {
|
|
32554
|
+
let inCode = false;
|
|
32555
|
+
for (let index = 0; index < paragraph.length; index++) {
|
|
32556
|
+
const char = paragraph[index];
|
|
32557
|
+
if (char === "`") inCode = !inCode;
|
|
32558
|
+
if (inCode || char !== "." && char !== "!" && char !== "?") continue;
|
|
32559
|
+
let end = index + 1;
|
|
32560
|
+
while (end < paragraph.length && /[*_)"']/.test(paragraph[end] ?? "")) end++;
|
|
32561
|
+
if (end < paragraph.length && !/\s/.test(paragraph[end] ?? "")) continue;
|
|
32562
|
+
if (char === "." && ABBREVIATION_REGEX.test(paragraph.slice(0, index))) continue;
|
|
32563
|
+
return paragraph.slice(0, end);
|
|
32564
|
+
}
|
|
32565
|
+
return paragraph;
|
|
32566
|
+
}
|
|
32567
|
+
/** `**` markers outside inline code spans removed: the title is shown as plain text. */
|
|
32568
|
+
function stripStrong(text) {
|
|
32569
|
+
return text.split(/(`[^`]*`)/).map((part) => part.startsWith("`") ? part : part.replaceAll("**", "")).join("").trim();
|
|
32570
|
+
}
|
|
32277
32571
|
/**
|
|
32278
32572
|
* The checkbox marker stripped, then a `**`/`__` span wrapping the whole title unwrapped (only
|
|
32279
32573
|
* when the closing marker matches the opening one, so a title merely starting or ending with the
|
|
@@ -32310,7 +32604,7 @@ function parsePlanBoxes(planText) {
|
|
|
32310
32604
|
break;
|
|
32311
32605
|
}
|
|
32312
32606
|
}
|
|
32313
|
-
const title = unwrapEmphasis(box.rawTitle);
|
|
32607
|
+
const title = stripStrong(unwrapEmphasis(firstSentence$1(boxParagraph(lines, fenced, box.index, blockEnd, box.rawTitle))));
|
|
32314
32608
|
const retireTask = RETIRE_TASK_REGEX.test(title);
|
|
32315
32609
|
if (box.checked) return {
|
|
32316
32610
|
line: box.index,
|
|
@@ -32843,7 +33137,7 @@ setForgejoLogger(log$1);
|
|
|
32843
33137
|
* `local <sha>[-dirty]` otherwise -- so an artifact built from the working tree
|
|
32844
33138
|
* can't pass itself off as the published release whose version it carries.
|
|
32845
33139
|
*/
|
|
32846
|
-
const displayVersion = "0.
|
|
33140
|
+
const displayVersion = "0.87.1";
|
|
32847
33141
|
/**
|
|
32848
33142
|
* The registered `bst` subcommand map, exported so both the JSON entry path below (resolving a
|
|
32849
33143
|
* command name before citty parses anything) and the coverage test (enumerating every
|