@bensandee/tooling 0.87.0 → 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/dist/bin.mjs +2026 -1848
- package/package.json +1 -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.87.
|
|
272
|
+
version: "0.87.1"
|
|
273
273
|
});
|
|
274
274
|
//#endregion
|
|
275
275
|
//#region ../cli-kit/src/logger.ts
|
|
@@ -3050,57 +3050,6 @@ function loadRegistryHostOverride(cwd) {
|
|
|
3050
3050
|
}
|
|
3051
3051
|
}
|
|
3052
3052
|
//#endregion
|
|
3053
|
-
//#region src/utils/generated-header.ts
|
|
3054
|
-
/**
|
|
3055
|
-
* Leading comment headers for fully generated files.
|
|
3056
|
-
*
|
|
3057
|
-
* These mark a file as managed by `@bensandee/tooling` so a reader knows that
|
|
3058
|
-
* `bst repo:sync` may overwrite it, and points at the supported ways to keep
|
|
3059
|
-
* custom content. Only used for files that are largely (re)generated wholesale
|
|
3060
|
-
* -- not for files that are merged into (package.json, tsconfig.json, etc.).
|
|
3061
|
-
*/
|
|
3062
|
-
/**
|
|
3063
|
-
* Stable substring present in every managed-file header. Used to detect whether an
|
|
3064
|
-
* existing file already carries the *current* header -- a comment-only difference is
|
|
3065
|
-
* invisible to `normalizeWorkflow()` (it strips comments before comparing), so generators
|
|
3066
|
-
* check for this marker to know a one-time header upgrade is still needed.
|
|
3067
|
-
*
|
|
3068
|
-
* The trailing `--` is load-bearing: it is what made the 2026-07-27 ASCII fix reach files
|
|
3069
|
-
* that were already synced. Widen this marker whenever the header's leading text changes,
|
|
3070
|
-
* or the change lands in the template and never in anyone's repo.
|
|
3071
|
-
*/
|
|
3072
|
-
const MANAGED_HEADER_MARKER = "Managed by @bensandee/tooling --";
|
|
3073
|
-
/** Render a comment block: each line is prefixed with `comment`; empty lines stay bare. */
|
|
3074
|
-
function managedFileHeader(comment, lines) {
|
|
3075
|
-
return lines.map((line) => line === "" ? comment : `${comment} ${line}`).join("\n") + "\n";
|
|
3076
|
-
}
|
|
3077
|
-
/**
|
|
3078
|
-
* Header for generated CI workflow files (`ci.yml`, `release.yml`).
|
|
3079
|
-
*
|
|
3080
|
-
* The literal `@bensandee/tooling:ignore` directive is kept mid-line on purpose so
|
|
3081
|
-
* `isToolingIgnored()` (which looks for the directive at the start of a comment) does
|
|
3082
|
-
* not mistake this header for an opt-out.
|
|
3083
|
-
*/
|
|
3084
|
-
const WORKFLOW_MANAGED_HEADER = managedFileHeader("#", [
|
|
3085
|
-
"Managed by @bensandee/tooling -- `bst repo:sync` regenerates this workflow,",
|
|
3086
|
-
"so direct edits are overwritten on the next sync.",
|
|
3087
|
-
"To preserve changes, wrap them between `# @tooling:custom` and",
|
|
3088
|
-
"`# @tooling:endcustom` markers, or stop managing this file by making",
|
|
3089
|
-
"`# @bensandee/tooling:ignore` the first line."
|
|
3090
|
-
]);
|
|
3091
|
-
/** Header for the generated `lefthook.yml` pre-commit config. */
|
|
3092
|
-
const LEFTHOOK_MANAGED_HEADER = managedFileHeader("#", [
|
|
3093
|
-
"Managed by @bensandee/tooling -- `bst repo:sync` keeps the `lint` and",
|
|
3094
|
-
"`format` pre-commit commands current. Other commands you add are preserved.",
|
|
3095
|
-
"To stop managing this file, make `# @bensandee/tooling:ignore` the first line."
|
|
3096
|
-
]);
|
|
3097
|
-
/**
|
|
3098
|
-
* Header for a generated agent guide (`.agent/guides/*.md`). One line, because guide ownership
|
|
3099
|
-
* is decided by the marker on the first line alone. It spells the ignore directive out in words
|
|
3100
|
-
* rather than as the literal comment, which would close this one early.
|
|
3101
|
-
*/
|
|
3102
|
-
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";
|
|
3103
|
-
//#endregion
|
|
3104
3053
|
//#region src/utils/debug.ts
|
|
3105
3054
|
const verboseLogger = createLogger({ level: "trace" });
|
|
3106
3055
|
/** Check if verbose/debug mode is enabled via environment variables. */
|
|
@@ -5684,6 +5633,136 @@ function contextAsDockerReader(ctx) {
|
|
|
5684
5633
|
};
|
|
5685
5634
|
}
|
|
5686
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
|
|
5687
5766
|
//#region src/utils/yaml-merge.ts
|
|
5688
5767
|
const IGNORE_PATTERN = "@bensandee/tooling:ignore";
|
|
5689
5768
|
const CUSTOM_START = "# @tooling:custom";
|
|
@@ -5992,535 +6071,790 @@ function buildWorkflowYaml(options) {
|
|
|
5992
6071
|
return ensureSchemaComment(WORKFLOW_MANAGED_HEADER + "\n" + readable, options.ci);
|
|
5993
6072
|
}
|
|
5994
6073
|
//#endregion
|
|
5995
|
-
//#region src/generators/
|
|
5996
|
-
const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
|
|
6074
|
+
//#region src/generators/mise-pin.ts
|
|
5997
6075
|
/**
|
|
5998
|
-
*
|
|
5999
|
-
*
|
|
6000
|
-
*
|
|
6001
|
-
*
|
|
6002
|
-
*
|
|
6003
|
-
*
|
|
6004
|
-
*
|
|
6005
|
-
*
|
|
6006
|
-
*
|
|
6076
|
+
* The mise version and release-asset digest the fleet's CI and the agent runner base image agree
|
|
6077
|
+
* to run. This is the one place that agreement is recorded; `packages/agent/test/runner-base-image-dockerfile.test.ts`
|
|
6078
|
+
* (task 8 of the mise-toolchain-provisioning plan) reads both constants back through
|
|
6079
|
+
* `readRepoFile` and asserts the base image's own `ARG MISE_VERSION` and `mise.sha256` manifest
|
|
6080
|
+
* agree with them, so the two packages cannot drift without a failing test on either side.
|
|
6081
|
+
*
|
|
6082
|
+
* Each constant sits under its own `// renovate:` marker comment, in the three-line grammar
|
|
6083
|
+
* `packages/agent/docker/agent-goose-image/goose.sha256` uses for its own digest line, so the same
|
|
6084
|
+
* shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
|
|
6085
|
+
* digest (`customManagers` entries scoped to this file).
|
|
6007
6086
|
*/
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
const repo = parsePackageJson(raw)?.repository;
|
|
6024
|
-
let urlStr = null;
|
|
6025
|
-
if (typeof repo === "string") urlStr = repo;
|
|
6026
|
-
else if (repo && typeof repo === "object") urlStr = repo.url;
|
|
6027
|
-
if (typeof urlStr !== "string") return null;
|
|
6028
|
-
const parsed = parseGitUrl(urlStr);
|
|
6029
|
-
if (!parsed) return null;
|
|
6030
|
-
return registryHostFromHostname(parsed.hostname);
|
|
6087
|
+
const MISE_VERSION = "2026.10.3";
|
|
6088
|
+
const MISE_LINUX_X64_SHA256 = "8d48bc510b7d844fad0bc7156c0855c2a1e7230c51e498a7f6b63b021f8b57c5";
|
|
6089
|
+
/** Where generated Forgejo workflows fetch the shared mise setup action: a composite action in
|
|
6090
|
+
* `bensandee/common`, on the forge every fleet repo lives on. GitHub cannot reach it; the
|
|
6091
|
+
* generators keep inline steps there. */
|
|
6092
|
+
const SETUP_MISE_ACTION_URL = "https://code.orangebikelabs.com/bensandee/common/actions/setup-mise";
|
|
6093
|
+
/** The tag in `bensandee/common` that marks the current release of the action. Generated `uses:`
|
|
6094
|
+
* lines carry it as their comment, and Renovate moves the SHA below to wherever it points. */
|
|
6095
|
+
const SETUP_MISE_ACTION_TAG = "setup-mise@latest";
|
|
6096
|
+
const SETUP_MISE_ACTION_SHA = "db99e4e9ee46bfeaca2146e7ae55cc700b4c23bf";
|
|
6097
|
+
//#endregion
|
|
6098
|
+
//#region src/generators/ci-utils.ts
|
|
6099
|
+
/** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
|
|
6100
|
+
function actionsExpr(expr) {
|
|
6101
|
+
return `\${{ ${expr} }}`;
|
|
6031
6102
|
}
|
|
6032
|
-
const
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
});
|
|
6036
|
-
const ReleaseAssetsSchema = z.object({ releaseAssets: z.array(ReleaseAssetSchema).optional() });
|
|
6037
|
-
const VersionStampConfigSchema = z.object({ versionStamp: z.array(VersionStampSchema).optional() });
|
|
6038
|
-
const DeploySchema = z.object({ deploy: DeployConfigSchema.optional() });
|
|
6103
|
+
const MAIN_BRANCH_REF = "refs/heads/main";
|
|
6104
|
+
/** `if:` condition that gates a step to push events on the default branch. */
|
|
6105
|
+
const IS_MAIN_BRANCH = `github.ref == '${MAIN_BRANCH_REF}'`;
|
|
6039
6106
|
/**
|
|
6040
|
-
*
|
|
6107
|
+
* Negated form for concurrency `cancel-in-progress` and similar uses.
|
|
6041
6108
|
*
|
|
6042
|
-
*
|
|
6043
|
-
*
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
}
|
|
6070
|
-
/** Read the postReleaseHookSecrets array from .tooling.json, if configured. */
|
|
6071
|
-
function getPostReleaseHookSecrets(ctx) {
|
|
6072
|
-
const raw = ctx.read(".tooling.json");
|
|
6073
|
-
if (!raw) return void 0;
|
|
6074
|
-
try {
|
|
6075
|
-
const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
|
|
6076
|
-
if (!result.success) return void 0;
|
|
6077
|
-
return result.data.postReleaseHookSecrets;
|
|
6078
|
-
} catch (error) {
|
|
6079
|
-
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6080
|
-
return;
|
|
6081
|
-
}
|
|
6082
|
-
}
|
|
6109
|
+
* Main is exempt from cancellation on purpose, and the exemption stays: a main run publishes and
|
|
6110
|
+
* pushes tags, and cancelling one mid-flight can leave a partial publish -- some packages on the
|
|
6111
|
+
* registry, no matching tags, and no run left alive to finish. A superseded feature-branch run
|
|
6112
|
+
* wastes minutes; a half-published release costs a manual unwind.
|
|
6113
|
+
*
|
|
6114
|
+
* So several main runs can be in flight at once, each versioning from its own triggering commit.
|
|
6115
|
+
* Overlap safety rests on one invariant: **main advances solely via commits that trigger a CI
|
|
6116
|
+
* run.** The `[skip ci]` coverage, health, and egress records go to `bst/*` orphan branches and
|
|
6117
|
+
* never to main; a `[skip ci]` commit pushed straight to main would leave a superseded run skipping
|
|
6118
|
+
* and its changesets stranded until the next push, which is why the supersession guard skips rather
|
|
6119
|
+
* than fails. Three guards carry the overlap itself: supersession plus a `--force-with-lease`
|
|
6120
|
+
* version push (`release/publish/version.ts`), the `headBumpedVersion` publish guard plus reading
|
|
6121
|
+
* remote tags and releases before acting (`commands/release-changesets.ts`,
|
|
6122
|
+
* `release/publish/publish.ts`), and floating git and Docker tags that only move forward
|
|
6123
|
+
* (`release/publish/floating-tags.ts`, `release/docker.ts`). Such a run may also version pending
|
|
6124
|
+
* changesets after it publishes -- the stale version-PR merge -- under the same supersession guard
|
|
6125
|
+
* and push lease as any other version run. A later commit publishes a bump only when its tag is
|
|
6126
|
+
* missing and every main run since the package's last release tag has settled
|
|
6127
|
+
* (`release/publish/unpublished-bump.ts`), so no run that could still publish it is in flight; a
|
|
6128
|
+
* manual re-run of one of those runs is the overlap left. On Forgejo the concurrency block is
|
|
6129
|
+
* only a PR-run cancellation mechanism -- it holds a main run in its group and, in Forgejo 16,
|
|
6130
|
+
* intermittently fails to release it, which is why main runs get a group of their own there. GitHub's shape stays shared
|
|
6131
|
+
* because its queue works: one run, one pending, a newer arrival replacing the pending, so
|
|
6132
|
+
* `changesets/action@v1` never needs a guard of its own. Any new side effect in a main run must be
|
|
6133
|
+
* idempotent under overlap or be listed here with the guard that makes it so.
|
|
6134
|
+
*/
|
|
6135
|
+
const IS_NOT_MAIN_BRANCH = `github.ref != '${MAIN_BRANCH_REF}'`;
|
|
6083
6136
|
/**
|
|
6084
|
-
*
|
|
6085
|
-
*
|
|
6086
|
-
* Routed through the same chokepoint every other Docker consumer uses, so a
|
|
6087
|
-
* compose-only image (no convention Dockerfile, no override) still gates the
|
|
6088
|
-
* scripts and workflow steps below instead of silently dropping them.
|
|
6137
|
+
* Job-level `env:` block that evaluates the default-branch check once, so steps
|
|
6138
|
+
* can gate on `env.IS_MAIN` instead of repeating the `github.ref` expression.
|
|
6089
6139
|
*/
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
/** Check whether any Docker packages exist by convention or .tooling.json config. */
|
|
6094
|
-
function hasDockerPackages(ctx) {
|
|
6095
|
-
return getDockerPackageNames(ctx).length > 0;
|
|
6096
|
-
}
|
|
6097
|
-
/** Paths (relative to repo root) of packages whose package.json has a `build:release-assets` script. */
|
|
6098
|
-
function detectReleaseAssetsConventionDirs(ctx) {
|
|
6099
|
-
const dirs = [];
|
|
6100
|
-
if (ctx.config.structure === "monorepo") {
|
|
6101
|
-
const packages = getMonorepoPackages(ctx.targetDir);
|
|
6102
|
-
for (const pkg of packages) {
|
|
6103
|
-
const dirName = pkg.name.split("/").pop() ?? pkg.name;
|
|
6104
|
-
const pkgRaw = ctx.read(`packages/${dirName}/package.json`);
|
|
6105
|
-
if (!pkgRaw) continue;
|
|
6106
|
-
if (parsePackageJson(pkgRaw)?.scripts?.["build:release-assets"]) dirs.push(`packages/${dirName}`);
|
|
6107
|
-
}
|
|
6108
|
-
} else {
|
|
6109
|
-
const pkgRaw = ctx.read("package.json");
|
|
6110
|
-
if (pkgRaw) {
|
|
6111
|
-
if (parsePackageJson(pkgRaw)?.scripts?.["build:release-assets"]) dirs.push(".");
|
|
6112
|
-
}
|
|
6113
|
-
}
|
|
6114
|
-
return dirs;
|
|
6115
|
-
}
|
|
6116
|
-
/** Check whether release assets are configured by convention or in .tooling.json. */
|
|
6117
|
-
function hasReleaseAssets(ctx) {
|
|
6118
|
-
if (detectReleaseAssetsConventionDirs(ctx).length > 0) return true;
|
|
6119
|
-
const raw = ctx.read(".tooling.json");
|
|
6120
|
-
if (!raw) return false;
|
|
6121
|
-
try {
|
|
6122
|
-
const result = ReleaseAssetsSchema.safeParse(JSON.parse(raw));
|
|
6123
|
-
if (!result.success) return false;
|
|
6124
|
-
return !!result.data.releaseAssets && result.data.releaseAssets.length > 0;
|
|
6125
|
-
} catch (error) {
|
|
6126
|
-
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6127
|
-
return false;
|
|
6128
|
-
}
|
|
6129
|
-
}
|
|
6140
|
+
const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
|
|
6141
|
+
/** `if:` condition referencing the job-level `IS_MAIN` env var (see {@link IS_MAIN_JOB_ENV}). */
|
|
6142
|
+
const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
|
|
6130
6143
|
/**
|
|
6131
|
-
*
|
|
6132
|
-
*
|
|
6133
|
-
*
|
|
6134
|
-
*
|
|
6135
|
-
*
|
|
6136
|
-
* a stamp-only repo produces no such directory.
|
|
6144
|
+
* npm's own registry-key rule for an `.npmrc` auth line: strip the scheme, keep
|
|
6145
|
+
* host/port/path, and end with exactly one slash. `actions/setup-node` derives the
|
|
6146
|
+
* same key from a registry URL; this mirrors that so a hand-written line matches
|
|
6147
|
+
* what npm looks up at publish time -- npm-registry-fetch walks the request path
|
|
6148
|
+
* upward through ancestor keys until one matches.
|
|
6137
6149
|
*/
|
|
6138
|
-
function
|
|
6139
|
-
|
|
6140
|
-
if (!raw) return false;
|
|
6141
|
-
try {
|
|
6142
|
-
const result = VersionStampConfigSchema.safeParse(JSON.parse(raw));
|
|
6143
|
-
if (!result.success) return false;
|
|
6144
|
-
return !!result.data.versionStamp && result.data.versionStamp.length > 0;
|
|
6145
|
-
} catch (error) {
|
|
6146
|
-
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6147
|
-
return false;
|
|
6148
|
-
}
|
|
6150
|
+
function npmrcAuthKey(registryUrl) {
|
|
6151
|
+
return `${registryUrl.replace(/^https?:/i, "").replace(/\/+$/, "")}/`;
|
|
6149
6152
|
}
|
|
6150
|
-
|
|
6151
|
-
const ARCHIVE_DIR$2 = ".tooling-archived";
|
|
6153
|
+
const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
|
|
6152
6154
|
/**
|
|
6153
|
-
*
|
|
6154
|
-
*
|
|
6155
|
-
* `
|
|
6156
|
-
*
|
|
6157
|
-
*
|
|
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.
|
|
6158
6170
|
*/
|
|
6159
|
-
|
|
6160
|
-
const
|
|
6161
|
-
if (
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
description: "Publish workflow not applicable"
|
|
6165
|
-
};
|
|
6166
|
-
const workflowPath = ctx.config.ci === "github" ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
|
|
6167
|
-
const existing = ctx.read(workflowPath);
|
|
6168
|
-
if (existing === void 0) return {
|
|
6169
|
-
filePath,
|
|
6170
|
-
action: "skipped",
|
|
6171
|
-
description: "No publish workflow to retire"
|
|
6172
|
-
};
|
|
6173
|
-
if (isToolingIgnored(existing) || !existing.includes("Managed by @bensandee/tooling --")) return {
|
|
6174
|
-
filePath: workflowPath,
|
|
6175
|
-
action: "skipped",
|
|
6176
|
-
description: "Publish workflow is not managed by tooling"
|
|
6177
|
-
};
|
|
6178
|
-
ctx.remove(workflowPath);
|
|
6179
|
-
return {
|
|
6180
|
-
filePath: workflowPath,
|
|
6181
|
-
action: "removed",
|
|
6182
|
-
description: `Moved to ${ARCHIVE_DIR$2}/${workflowPath} (run \`bst docker:publish\` to publish by hand)`
|
|
6171
|
+
function npmAuthWiring(ctx, publishes) {
|
|
6172
|
+
const registries = publishes ? declaredPublishRegistries(ctx.targetDir, ctx.config.structure, ctx.packageJson) : [];
|
|
6173
|
+
if (registries.length === 0) return {
|
|
6174
|
+
publishRun: (command) => command,
|
|
6175
|
+
tokenEnv: {}
|
|
6183
6176
|
};
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
".mjs",
|
|
6193
|
-
".cjs"
|
|
6194
|
-
];
|
|
6195
|
-
const DEFAULT_DIRECTORY_FILE_LIMIT = 20;
|
|
6196
|
-
const DEFAULT_FAN_LIMIT = 10;
|
|
6197
|
-
const DEFAULT_MODULE_SIZE_LIMIT = 200;
|
|
6198
|
-
/** The extensions dependency-cruiser should glob for a given stack. */
|
|
6199
|
-
function defaultExtensions(stack) {
|
|
6200
|
-
if (stack === "typescript") return TS_EXTENSIONS;
|
|
6201
|
-
if (stack === "javascript") return JS_EXTENSIONS;
|
|
6202
|
-
return [];
|
|
6203
|
-
}
|
|
6204
|
-
/** Tolerant of a key `health` does not declare, shared by both entry points below:
|
|
6205
|
-
* `resolveHealthConfig` (the CLI) and `resolveHealthConfigFromContext` (the generator). The
|
|
6206
|
-
* generator's own caller runs behind `repo:sync`'s strict root read, which has already
|
|
6207
|
-
* rejected the document, so leniency here is unreachable there rather than a semantic change
|
|
6208
|
-
* for that path. */
|
|
6209
|
-
const ToolingShallowSchema$2 = z.object({ health: tolerantBlock(HealthConfigSchema).optional() });
|
|
6210
|
-
function defaultSrc(isMonorepo) {
|
|
6211
|
-
return isMonorepo ? ["packages/*/src"] : ["src"];
|
|
6212
|
-
}
|
|
6213
|
-
function resolveFromOverrides(overrides, isMonorepo, stack) {
|
|
6214
|
-
const o = overrides ?? {};
|
|
6215
|
-
const enabled = o.enabled === true;
|
|
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
|
+
];
|
|
6216
6185
|
return {
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
exclude: o.exclude ?? [],
|
|
6220
|
-
extensions: o.extensions ?? defaultExtensions(stack),
|
|
6221
|
-
directoryFileLimit: o.directoryFileLimit ?? DEFAULT_DIRECTORY_FILE_LIMIT,
|
|
6222
|
-
fanInLimit: o.fanInLimit ?? DEFAULT_FAN_LIMIT,
|
|
6223
|
-
fanOutLimit: o.fanOutLimit ?? DEFAULT_FAN_LIMIT,
|
|
6224
|
-
moduleSizeLimit: o.moduleSizeLimit ?? DEFAULT_MODULE_SIZE_LIMIT,
|
|
6225
|
-
crossPackage: o.crossPackage ?? true,
|
|
6226
|
-
historyEnabled: enabled && o.history !== "none",
|
|
6227
|
-
historyBranch: o.historyBranch ?? DEFAULT_HEALTH_HISTORY_BRANCH,
|
|
6228
|
-
tolerance: o.tolerance ?? 0,
|
|
6229
|
-
graphMetricsEnabled: stack !== "none",
|
|
6230
|
-
exempt: o.exempt ?? {}
|
|
6186
|
+
publishRun: (command) => [...setup, command].join("\n"),
|
|
6187
|
+
tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
|
|
6231
6188
|
};
|
|
6232
6189
|
}
|
|
6233
|
-
|
|
6234
|
-
return resolveFromOverrides(parseToolingConfigSlice(reader.read(".tooling.json"), ToolingShallowSchema$2, { block: "health" })?.health, opts.isMonorepo, reader.config.stack);
|
|
6235
|
-
}
|
|
6190
|
+
const MISE_RELEASE_URL = `https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64`;
|
|
6236
6191
|
/**
|
|
6237
|
-
* The
|
|
6238
|
-
*
|
|
6239
|
-
*
|
|
6240
|
-
*
|
|
6241
|
-
*
|
|
6192
|
+
* The node script `inlineMiseSteps` pipes `mise env --json` into, run under the mise it just
|
|
6193
|
+
* installed. Reads the env map off stdin (mise exec passes it straight through) and exports every
|
|
6194
|
+
* key except `PATH` into `$GITHUB_ENV` in heredoc form -- the form that survives any value,
|
|
6195
|
+
* including one containing `$` or quotes -- then appends mise's shim directory and the
|
|
6196
|
+
* `~/.local/bin` install location to `$GITHUB_PATH` so every later step sees node, pnpm, and any
|
|
6197
|
+
* other declared tool on PATH with no prefix.
|
|
6242
6198
|
*/
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
}
|
|
6266
|
-
|
|
6267
|
-
//#region src/utils/script-forwarding.ts
|
|
6268
|
-
/**
|
|
6269
|
-
* pnpm appends args forwarded via `pnpm <script> <args>` to the END of the
|
|
6270
|
-
* resolved script string. A forwarded flag therefore only reaches its intended
|
|
6271
|
-
* command when that command is the LAST command in the script. The generated
|
|
6272
|
-
* CI workflow depends on this two-hop forwarding:
|
|
6273
|
-
*
|
|
6274
|
-
* pnpm ci:check --with-coverage
|
|
6275
|
-
* -> pnpm check --skip 'docker:*' --with-coverage
|
|
6276
|
-
* -> bst checks:run --skip 'docker:*' --with-coverage
|
|
6277
|
-
*
|
|
6278
|
-
* If `check` or `ci:check` ends in a different command (e.g. an `&&` chain),
|
|
6279
|
-
* the forwarded flags silently land on the wrong command -- tests run without
|
|
6280
|
-
* coverage and `--skip` becomes a no-op. This module detects that shape so
|
|
6281
|
-
* callers can surface a precise diagnosis instead of a confusing downstream
|
|
6282
|
-
* failure.
|
|
6283
|
-
*/
|
|
6284
|
-
/** A script's commands, split on `&&`, `||`, `|`, and `;`, each trimmed. */
|
|
6285
|
-
function commandSegments(script) {
|
|
6286
|
-
return script.split(/&&|\|\|?|;/).map((segment) => segment.trim());
|
|
6287
|
-
}
|
|
6288
|
-
/** The final command of a script, after splitting on `&&`, `||`, `|`, and `;`. */
|
|
6289
|
-
function lastCommandSegment(script) {
|
|
6290
|
-
return commandSegments(script).at(-1) ?? "";
|
|
6199
|
+
const MISE_ENV_EXPORT_SCRIPT = [
|
|
6200
|
+
"const fs = require(\"fs\");",
|
|
6201
|
+
"const env = JSON.parse(fs.readFileSync(0, \"utf8\"));",
|
|
6202
|
+
"let out = \"\";",
|
|
6203
|
+
"for (const [key, value] of Object.entries(env)) {",
|
|
6204
|
+
" if (key === \"PATH\") continue;",
|
|
6205
|
+
" out += key + \"<<BST_MISE_ENV_EOF\\n\" + value + \"\\nBST_MISE_ENV_EOF\\n\";",
|
|
6206
|
+
"}",
|
|
6207
|
+
"fs.appendFileSync(process.env.GITHUB_ENV, out);",
|
|
6208
|
+
"fs.appendFileSync(",
|
|
6209
|
+
" process.env.GITHUB_PATH,",
|
|
6210
|
+
" process.env.HOME + \"/.local/share/mise/shims\\n\" + process.env.HOME + \"/.local/bin\\n\",",
|
|
6211
|
+
");"
|
|
6212
|
+
].join("\n");
|
|
6213
|
+
/** A fresh object per call: one shared object in two jobs would serialize as a YAML alias. */
|
|
6214
|
+
function pnpmStoreCacheStep() {
|
|
6215
|
+
return { step: {
|
|
6216
|
+
name: "Cache pnpm store",
|
|
6217
|
+
uses: "actions/cache@v4",
|
|
6218
|
+
with: {
|
|
6219
|
+
path: "~/.local/share/pnpm/store",
|
|
6220
|
+
key: `pnpm-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('pnpm-lock.yaml')")}`
|
|
6221
|
+
}
|
|
6222
|
+
} };
|
|
6291
6223
|
}
|
|
6224
|
+
/** Actions whose ref the generator pins itself, so `preservePinnedActions` must not keep a
|
|
6225
|
+
* repo's older ref for them. */
|
|
6226
|
+
const GENERATOR_OWNED_ACTIONS = [{
|
|
6227
|
+
name: SETUP_MISE_ACTION_URL,
|
|
6228
|
+
comment: SETUP_MISE_ACTION_TAG
|
|
6229
|
+
}];
|
|
6292
6230
|
/**
|
|
6293
|
-
*
|
|
6294
|
-
*
|
|
6295
|
-
*
|
|
6231
|
+
* The steps every workflow generator uses in place of `pnpm/action-setup` + `actions/setup-node`:
|
|
6232
|
+
* install the pinned `mise`, `mise install` the repo's `mise.toml` toolchain, export its env into
|
|
6233
|
+
* the rest of the job, and cache the pnpm store. `MISE_VERSION`/`MISE_LINUX_X64_SHA256`
|
|
6234
|
+
* (`mise-pin.ts`) are the same pin the agent runner's base image installs, checked by
|
|
6235
|
+
* `runner-base-image-dockerfile.test.ts`.
|
|
6296
6236
|
*
|
|
6297
|
-
*
|
|
6298
|
-
*
|
|
6299
|
-
* spelling for the tooling repo's own `check` script, which invoked
|
|
6300
|
-
* `node packages/tooling-cli/dist/bin.mjs` directly; that repo now depends on itself as
|
|
6301
|
-
* `workspace:*` and runs `pnpm exec bst checks:run`, so the exception has no caller left.
|
|
6302
|
-
*/
|
|
6303
|
-
function isCheckScriptForwardingSafe(script) {
|
|
6304
|
-
return /(?:^|[\s/])bst\s+checks:run(?=\s|$)/.test(lastCommandSegment(script));
|
|
6305
|
-
}
|
|
6306
|
-
/**
|
|
6307
|
-
* True when args forwarded to a `ci:check` script would reach `pnpm check`
|
|
6308
|
-
* -- its last command must invoke `pnpm check` (or `pnpm run check`).
|
|
6237
|
+
* On Forgejo the mise half is the shared composite action in `bensandee/common`, pinned by commit.
|
|
6238
|
+
* GitHub resolves actions only from github.com, so it keeps the same steps inline.
|
|
6309
6239
|
*/
|
|
6310
|
-
function
|
|
6311
|
-
|
|
6240
|
+
function miseSetupSteps(ci) {
|
|
6241
|
+
if (ci !== "forgejo") return inlineMiseSteps();
|
|
6242
|
+
return [{ step: {
|
|
6243
|
+
name: "Set up mise",
|
|
6244
|
+
uses: `${SETUP_MISE_ACTION_URL}@${SETUP_MISE_ACTION_SHA}`,
|
|
6245
|
+
with: {
|
|
6246
|
+
version: MISE_VERSION,
|
|
6247
|
+
sha256: MISE_LINUX_X64_SHA256
|
|
6248
|
+
}
|
|
6249
|
+
} }, pnpmStoreCacheStep()];
|
|
6312
6250
|
}
|
|
6313
6251
|
/**
|
|
6314
|
-
*
|
|
6315
|
-
*
|
|
6316
|
-
*
|
|
6252
|
+
* The inline form of the shared action, for GitHub. Uses `mise env --json` piped into the
|
|
6253
|
+
* just-installed mise's own `node -e`, not `mise env --shell bash`: the JSON form is
|
|
6254
|
+
* shell-independent and the interpreter reading it is guaranteed to exist (mise just installed
|
|
6255
|
+
* it), so this needs no assumption about which shell the runner's `run:` steps use.
|
|
6317
6256
|
*/
|
|
6318
|
-
function
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6257
|
+
function inlineMiseSteps() {
|
|
6258
|
+
const installScript = [
|
|
6259
|
+
"curl -fsSL -o \"$RUNNER_TEMP/mise\" " + MISE_RELEASE_URL,
|
|
6260
|
+
`echo "${MISE_LINUX_X64_SHA256} $RUNNER_TEMP/mise" | sha256sum -c`,
|
|
6261
|
+
"mkdir -p \"$HOME/.local/bin\" && install -m 0755 \"$RUNNER_TEMP/mise\" \"$HOME/.local/bin/mise\"",
|
|
6262
|
+
"export MISE_TRUSTED_CONFIG_PATHS=\"$GITHUB_WORKSPACE\"",
|
|
6263
|
+
"export MISE_YES=1",
|
|
6264
|
+
"\"$HOME/.local/bin/mise\" install",
|
|
6265
|
+
`"$HOME/.local/bin/mise" env --json | "$HOME/.local/bin/mise" exec -- node -e '${MISE_ENV_EXPORT_SCRIPT}'`
|
|
6266
|
+
].join("\n");
|
|
6267
|
+
return [
|
|
6268
|
+
{ step: {
|
|
6269
|
+
name: "Cache mise tools",
|
|
6270
|
+
uses: "actions/cache@v4",
|
|
6271
|
+
with: {
|
|
6272
|
+
path: "~/.local/share/mise",
|
|
6273
|
+
key: `mise-${actionsExpr("runner.os")}-${actionsExpr("hashFiles('mise.toml')")}`
|
|
6274
|
+
}
|
|
6275
|
+
} },
|
|
6276
|
+
pnpmStoreCacheStep(),
|
|
6277
|
+
{ step: {
|
|
6278
|
+
name: "Install mise",
|
|
6279
|
+
env: { GITHUB_TOKEN: "" },
|
|
6280
|
+
run: installScript
|
|
6281
|
+
} }
|
|
6282
|
+
];
|
|
6327
6283
|
}
|
|
6328
6284
|
/**
|
|
6329
|
-
*
|
|
6330
|
-
* force-converge. They are add-if-absent: once a value exists, sync never looks at
|
|
6331
|
-
* it again -- because force-converging `build` would stomp a Vite project's
|
|
6332
|
-
* `vite build`, which is the entire point of the `builder` axis. The cost of that
|
|
6333
|
-
* freedom is that these scripts can drift into nonsense and nothing notices, so we
|
|
6334
|
-
* diagnose the one drift shape that fails *silently* rather than loudly.
|
|
6335
|
-
*/
|
|
6336
|
-
const TOOLCHAIN_SCRIPTS = [
|
|
6337
|
-
"build",
|
|
6338
|
-
"typecheck",
|
|
6339
|
-
"lint",
|
|
6340
|
-
"test",
|
|
6341
|
-
"knip",
|
|
6342
|
-
"format"
|
|
6343
|
-
];
|
|
6344
|
-
/**
|
|
6345
|
-
* True when a pnpm invocation asks for recursion (`-r`) and then pins itself to the
|
|
6346
|
-
* workspace root (`-w`) -- `pnpm -r -w --parallel exec tsc --noEmit`.
|
|
6285
|
+
* Build the docker publish env block.
|
|
6347
6286
|
*
|
|
6348
|
-
*
|
|
6349
|
-
*
|
|
6350
|
-
*
|
|
6351
|
-
*
|
|
6352
|
-
*
|
|
6287
|
+
* The LHS env-var names (`DOCKER_USERNAME` / `DOCKER_PASSWORD`) match what
|
|
6288
|
+
* publish-docker.ts reads at runtime. The RHS secret references are
|
|
6289
|
+
* registry-specific (see {@link dockerSecretNames}) so downstream repos can
|
|
6290
|
+
* stop relying on the user/org-level `DOCKER_USERNAME` / `DOCKER_PASSWORD`
|
|
6291
|
+
* secrets -- which the Forgejo act fork auto-injects into every `container:`
|
|
6292
|
+
* image pull regardless of the target registry.
|
|
6353
6293
|
*
|
|
6354
|
-
*
|
|
6355
|
-
*
|
|
6356
|
-
*
|
|
6294
|
+
* When `host` is `null` (couldn't be derived at gen time), the secret references
|
|
6295
|
+
* fall back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` and the
|
|
6296
|
+
* workflow keeps `DOCKER_REGISTRY_HOST` / `DOCKER_REGISTRY_NAMESPACE` so the CLI
|
|
6297
|
+
* resolves the registry at runtime.
|
|
6357
6298
|
*/
|
|
6358
|
-
function
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
if (token.includes("r")) recursive = true;
|
|
6370
|
-
if (token.includes("w")) workspaceRoot = true;
|
|
6371
|
-
}
|
|
6372
|
-
}
|
|
6373
|
-
return recursive && workspaceRoot;
|
|
6374
|
-
});
|
|
6299
|
+
function dockerPublishEnv(host) {
|
|
6300
|
+
const { username, password } = dockerSecretNames(host);
|
|
6301
|
+
return {
|
|
6302
|
+
...host === null ? {
|
|
6303
|
+
DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
|
|
6304
|
+
DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
|
|
6305
|
+
} : {},
|
|
6306
|
+
DOCKER_USERNAME: actionsExpr(`secrets.${username}`),
|
|
6307
|
+
DOCKER_PASSWORD: actionsExpr(`secrets.${password}`),
|
|
6308
|
+
RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
|
|
6309
|
+
};
|
|
6375
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";
|
|
6376
6313
|
/**
|
|
6377
|
-
*
|
|
6378
|
-
* human-readable hint, or undefined when they look sound.
|
|
6314
|
+
* Retire a workflow file tooling used to generate and no longer writes.
|
|
6379
6315
|
*
|
|
6380
|
-
*
|
|
6381
|
-
*
|
|
6382
|
-
*
|
|
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.
|
|
6383
6324
|
*/
|
|
6384
|
-
function
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
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
|
+
};
|
|
6392
6341
|
}
|
|
6342
|
+
//#endregion
|
|
6343
|
+
//#region src/generators/publish-ci.ts
|
|
6344
|
+
const RegistryHostSchema = z.object({ registryHost: z.string().optional() });
|
|
6393
6345
|
/**
|
|
6394
|
-
*
|
|
6395
|
-
*
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
|
|
6402
|
-
|
|
6346
|
+
* Resolve the docker-registry host at gen time so the workflow generator can
|
|
6347
|
+
* pick registry-specific secret names. Honors `.tooling.json#registryHost`
|
|
6348
|
+
* first, then git remote `origin` (the operational host, not a mirror-pointed
|
|
6349
|
+
* package.json), then derives from `package.json#repository` using the same
|
|
6350
|
+
* parser as the runtime (`parseGitUrl` handles https, git+https, and ssh shapes).
|
|
6351
|
+
* Returns null if neither source yields a host -- in that case the generator
|
|
6352
|
+
* falls back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` secrets and
|
|
6353
|
+
* lets the runtime resolve the host from `DOCKER_REGISTRY_HOST` (still emitted
|
|
6354
|
+
* into the step).
|
|
6355
|
+
*/
|
|
6356
|
+
function resolveGenTimeHost(ctx) {
|
|
6357
|
+
const toolingRaw = ctx.read(".tooling.json");
|
|
6358
|
+
if (toolingRaw) try {
|
|
6359
|
+
const parsed = RegistryHostSchema.safeParse(JSON.parse(toolingRaw));
|
|
6360
|
+
if (parsed.success && parsed.data.registryHost && parsed.data.registryHost.length > 0) return parsed.data.registryHost;
|
|
6361
|
+
} catch (error) {
|
|
6362
|
+
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6403
6363
|
}
|
|
6404
|
-
|
|
6364
|
+
const originUrl = detectGitRemoteUrl(ctx.targetDir);
|
|
6365
|
+
if (originUrl !== null) {
|
|
6366
|
+
const parsedOrigin = parseGitUrl(originUrl);
|
|
6367
|
+
if (parsedOrigin) return registryHostFromHostname(parsedOrigin.hostname);
|
|
6368
|
+
}
|
|
6369
|
+
const raw = ctx.read("package.json");
|
|
6370
|
+
if (!raw) return null;
|
|
6371
|
+
const repo = parsePackageJson(raw)?.repository;
|
|
6372
|
+
let urlStr = null;
|
|
6373
|
+
if (typeof repo === "string") urlStr = repo;
|
|
6374
|
+
else if (repo && typeof repo === "object") urlStr = repo.url;
|
|
6375
|
+
if (typeof urlStr !== "string") return null;
|
|
6376
|
+
const parsed = parseGitUrl(urlStr);
|
|
6377
|
+
if (!parsed) return null;
|
|
6378
|
+
return registryHostFromHostname(parsed.hostname);
|
|
6405
6379
|
}
|
|
6406
|
-
|
|
6407
|
-
|
|
6380
|
+
const PostReleaseHookSchema = z.object({
|
|
6381
|
+
postReleaseHook: z.string().optional(),
|
|
6382
|
+
postReleaseHookSecrets: z.array(z.string()).optional()
|
|
6383
|
+
});
|
|
6384
|
+
const ReleaseAssetsSchema = z.object({ releaseAssets: z.array(ReleaseAssetSchema).optional() });
|
|
6385
|
+
const VersionStampConfigSchema = z.object({ versionStamp: z.array(VersionStampSchema).optional() });
|
|
6386
|
+
const DeploySchema = z.object({ deploy: DeployConfigSchema.optional() });
|
|
6408
6387
|
/**
|
|
6409
|
-
*
|
|
6410
|
-
* honest minimum this package declares. `mise.toml`'s `tools.node` (`generators/mise.ts`) is the
|
|
6411
|
-
* exact pin every consumer -- the agent's containers, generated CI, a developer's shell -- now
|
|
6412
|
-
* installs and runs, seeded from this same floor on first generation.
|
|
6388
|
+
* Read the `deploy` block from .tooling.json, if configured.
|
|
6413
6389
|
*
|
|
6414
|
-
*
|
|
6415
|
-
*
|
|
6416
|
-
* step and `mise install` respectively, so the field is removed wherever this generator finds it
|
|
6417
|
-
* (`applyMetadataDefaults`, `generateWorkspaceEngines`) rather than kept in sync.
|
|
6390
|
+
* The full file is already validated against ToolingConfigSchema at sync time,
|
|
6391
|
+
* so an invalid `deploy` block fails earlier -- undefined here means "not set".
|
|
6418
6392
|
*/
|
|
6419
|
-
|
|
6420
|
-
const
|
|
6421
|
-
|
|
6422
|
-
|
|
6393
|
+
function getDeployConfig(ctx) {
|
|
6394
|
+
const raw = ctx.read(".tooling.json");
|
|
6395
|
+
if (!raw) return void 0;
|
|
6396
|
+
try {
|
|
6397
|
+
const result = DeploySchema.safeParse(JSON.parse(raw));
|
|
6398
|
+
if (!result.success) return void 0;
|
|
6399
|
+
return result.data.deploy;
|
|
6400
|
+
} catch (error) {
|
|
6401
|
+
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6402
|
+
return;
|
|
6403
|
+
}
|
|
6423
6404
|
}
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6405
|
+
/** Read the postReleaseHook command override from .tooling.json, if configured. */
|
|
6406
|
+
function getPostReleaseHookCommand(ctx) {
|
|
6407
|
+
const raw = ctx.read(".tooling.json");
|
|
6408
|
+
if (!raw) return void 0;
|
|
6409
|
+
try {
|
|
6410
|
+
const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
|
|
6411
|
+
if (!result.success) return void 0;
|
|
6412
|
+
return result.data.postReleaseHook;
|
|
6413
|
+
} catch (error) {
|
|
6414
|
+
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6415
|
+
return;
|
|
6416
|
+
}
|
|
6417
|
+
}
|
|
6418
|
+
/** Read the postReleaseHookSecrets array from .tooling.json, if configured. */
|
|
6419
|
+
function getPostReleaseHookSecrets(ctx) {
|
|
6420
|
+
const raw = ctx.read(".tooling.json");
|
|
6421
|
+
if (!raw) return void 0;
|
|
6422
|
+
try {
|
|
6423
|
+
const result = PostReleaseHookSchema.safeParse(JSON.parse(raw));
|
|
6424
|
+
if (!result.success) return void 0;
|
|
6425
|
+
return result.data.postReleaseHookSecrets;
|
|
6426
|
+
} catch (error) {
|
|
6427
|
+
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6428
|
+
return;
|
|
6429
|
+
}
|
|
6427
6430
|
}
|
|
6428
6431
|
/**
|
|
6429
|
-
*
|
|
6430
|
-
*
|
|
6431
|
-
*
|
|
6432
|
-
*
|
|
6433
|
-
*
|
|
6432
|
+
* Names of the Docker images `detectDockerPackages` discovers for this repo --
|
|
6433
|
+
* by package convention, compose service, or `.tooling.json#docker` override.
|
|
6434
|
+
* Routed through the same chokepoint every other Docker consumer uses, so a
|
|
6435
|
+
* compose-only image (no convention Dockerfile, no override) still gates the
|
|
6436
|
+
* scripts and workflow steps below instead of silently dropping them.
|
|
6434
6437
|
*/
|
|
6435
|
-
function
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6438
|
+
function getDockerPackageNames(ctx) {
|
|
6439
|
+
return detectContextDockerPackages(ctx).map((pkg) => pkg.imageName);
|
|
6440
|
+
}
|
|
6441
|
+
/** Check whether any Docker packages exist by convention or .tooling.json config. */
|
|
6442
|
+
function hasDockerPackages(ctx) {
|
|
6443
|
+
return getDockerPackageNames(ctx).length > 0;
|
|
6444
|
+
}
|
|
6445
|
+
/** Paths (relative to repo root) of packages whose package.json has a `build:release-assets` script. */
|
|
6446
|
+
function detectReleaseAssetsConventionDirs(ctx) {
|
|
6447
|
+
const dirs = [];
|
|
6448
|
+
if (ctx.config.structure === "monorepo") {
|
|
6449
|
+
const packages = getMonorepoPackages(ctx.targetDir);
|
|
6450
|
+
for (const pkg of packages) {
|
|
6451
|
+
const dirName = pkg.name.split("/").pop() ?? pkg.name;
|
|
6452
|
+
const pkgRaw = ctx.read(`packages/${dirName}/package.json`);
|
|
6453
|
+
if (!pkgRaw) continue;
|
|
6454
|
+
if (parsePackageJson(pkgRaw)?.scripts?.["build:release-assets"]) dirs.push(`packages/${dirName}`);
|
|
6455
|
+
}
|
|
6456
|
+
} else {
|
|
6457
|
+
const pkgRaw = ctx.read("package.json");
|
|
6458
|
+
if (pkgRaw) {
|
|
6459
|
+
if (parsePackageJson(pkgRaw)?.scripts?.["build:release-assets"]) dirs.push(".");
|
|
6460
|
+
}
|
|
6461
|
+
}
|
|
6462
|
+
return dirs;
|
|
6463
|
+
}
|
|
6464
|
+
/** Check whether release assets are configured by convention or in .tooling.json. */
|
|
6465
|
+
function hasReleaseAssets(ctx) {
|
|
6466
|
+
if (detectReleaseAssetsConventionDirs(ctx).length > 0) return true;
|
|
6467
|
+
const raw = ctx.read(".tooling.json");
|
|
6468
|
+
if (!raw) return false;
|
|
6469
|
+
try {
|
|
6470
|
+
const result = ReleaseAssetsSchema.safeParse(JSON.parse(raw));
|
|
6471
|
+
if (!result.success) return false;
|
|
6472
|
+
return !!result.data.releaseAssets && result.data.releaseAssets.length > 0;
|
|
6473
|
+
} catch (error) {
|
|
6474
|
+
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6475
|
+
return false;
|
|
6447
6476
|
}
|
|
6448
|
-
return floor;
|
|
6449
6477
|
}
|
|
6450
6478
|
/**
|
|
6451
|
-
*
|
|
6452
|
-
*
|
|
6453
|
-
*
|
|
6454
|
-
*
|
|
6455
|
-
* `
|
|
6456
|
-
*
|
|
6457
|
-
* Returns `undefined` when no floor can be determined (e.g. `*`, `latest`, an
|
|
6458
|
-
* alternative with only an upper bound) -- callers must warn rather than guess.
|
|
6479
|
+
* Check whether the repo configures `versionStamp`. A repo with a `versionStamp` block but no
|
|
6480
|
+
* `releaseAssets` and no `build:release-assets` script would otherwise get no assets step, and
|
|
6481
|
+
* nothing would ever run the stamp -- see the two `hasReleaseAssets(ctx) || hasVersionStamp(ctx)`
|
|
6482
|
+
* call sites in `ci.ts` and `release-ci.ts`. Deliberately not folded into `hasReleaseAssets`
|
|
6483
|
+
* itself: `gitignore.ts` shares that predicate to decide whether to ignore `release-assets/`, and
|
|
6484
|
+
* a stamp-only repo produces no such directory.
|
|
6459
6485
|
*/
|
|
6460
|
-
function
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
if (!
|
|
6486
|
+
function hasVersionStamp(ctx) {
|
|
6487
|
+
const raw = ctx.read(".tooling.json");
|
|
6488
|
+
if (!raw) return false;
|
|
6489
|
+
try {
|
|
6490
|
+
const result = VersionStampConfigSchema.safeParse(JSON.parse(raw));
|
|
6491
|
+
if (!result.success) return false;
|
|
6492
|
+
return !!result.data.versionStamp && result.data.versionStamp.length > 0;
|
|
6493
|
+
} catch (error) {
|
|
6494
|
+
log$1.warn(`Failed to parse .tooling.json: ${error instanceof Error ? error.message : error}`);
|
|
6495
|
+
return false;
|
|
6466
6496
|
}
|
|
6467
|
-
return min;
|
|
6468
6497
|
}
|
|
6469
6498
|
/**
|
|
6470
|
-
*
|
|
6471
|
-
*
|
|
6472
|
-
*
|
|
6473
|
-
*
|
|
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.
|
|
6474
6504
|
*/
|
|
6475
|
-
function
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6505
|
+
async function generateDeployCi(ctx) {
|
|
6506
|
+
const filePath = "deploy-ci";
|
|
6507
|
+
if (ctx.config.ci === "none") return {
|
|
6508
|
+
filePath,
|
|
6509
|
+
action: "skipped",
|
|
6510
|
+
description: "Publish workflow not applicable"
|
|
6511
|
+
};
|
|
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"
|
|
6516
|
+
};
|
|
6480
6517
|
}
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
"
|
|
6487
|
-
"
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
const
|
|
6506
|
-
|
|
6507
|
-
"
|
|
6508
|
-
}
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6518
|
+
//#endregion
|
|
6519
|
+
//#region src/codebase-health/config.ts
|
|
6520
|
+
const DEFAULT_HEALTH_HISTORY_BRANCH = "bst/health-metrics";
|
|
6521
|
+
const TS_EXTENSIONS = [".ts", ".tsx"];
|
|
6522
|
+
const JS_EXTENSIONS = [
|
|
6523
|
+
".js",
|
|
6524
|
+
".jsx",
|
|
6525
|
+
".mjs",
|
|
6526
|
+
".cjs"
|
|
6527
|
+
];
|
|
6528
|
+
const DEFAULT_DIRECTORY_FILE_LIMIT = 20;
|
|
6529
|
+
const DEFAULT_FAN_LIMIT = 10;
|
|
6530
|
+
const DEFAULT_MODULE_SIZE_LIMIT = 200;
|
|
6531
|
+
/** The extensions dependency-cruiser should glob for a given stack. */
|
|
6532
|
+
function defaultExtensions(stack) {
|
|
6533
|
+
if (stack === "typescript") return TS_EXTENSIONS;
|
|
6534
|
+
if (stack === "javascript") return JS_EXTENSIONS;
|
|
6535
|
+
return [];
|
|
6536
|
+
}
|
|
6537
|
+
/** Tolerant of a key `health` does not declare, shared by both entry points below:
|
|
6538
|
+
* `resolveHealthConfig` (the CLI) and `resolveHealthConfigFromContext` (the generator). The
|
|
6539
|
+
* generator's own caller runs behind `repo:sync`'s strict root read, which has already
|
|
6540
|
+
* rejected the document, so leniency here is unreachable there rather than a semantic change
|
|
6541
|
+
* for that path. */
|
|
6542
|
+
const ToolingShallowSchema$2 = z.object({ health: tolerantBlock(HealthConfigSchema).optional() });
|
|
6543
|
+
function defaultSrc(isMonorepo) {
|
|
6544
|
+
return isMonorepo ? ["packages/*/src"] : ["src"];
|
|
6545
|
+
}
|
|
6546
|
+
function resolveFromOverrides(overrides, isMonorepo, stack) {
|
|
6547
|
+
const o = overrides ?? {};
|
|
6548
|
+
const enabled = o.enabled === true;
|
|
6549
|
+
return {
|
|
6550
|
+
enabled,
|
|
6551
|
+
src: o.src ?? defaultSrc(isMonorepo),
|
|
6552
|
+
exclude: o.exclude ?? [],
|
|
6553
|
+
extensions: o.extensions ?? defaultExtensions(stack),
|
|
6554
|
+
directoryFileLimit: o.directoryFileLimit ?? DEFAULT_DIRECTORY_FILE_LIMIT,
|
|
6555
|
+
fanInLimit: o.fanInLimit ?? DEFAULT_FAN_LIMIT,
|
|
6556
|
+
fanOutLimit: o.fanOutLimit ?? DEFAULT_FAN_LIMIT,
|
|
6557
|
+
moduleSizeLimit: o.moduleSizeLimit ?? DEFAULT_MODULE_SIZE_LIMIT,
|
|
6558
|
+
crossPackage: o.crossPackage ?? true,
|
|
6559
|
+
historyEnabled: enabled && o.history !== "none",
|
|
6560
|
+
historyBranch: o.historyBranch ?? DEFAULT_HEALTH_HISTORY_BRANCH,
|
|
6561
|
+
tolerance: o.tolerance ?? 0,
|
|
6562
|
+
graphMetricsEnabled: stack !== "none",
|
|
6563
|
+
exempt: o.exempt ?? {}
|
|
6564
|
+
};
|
|
6565
|
+
}
|
|
6566
|
+
function resolveHealthConfigFromContext(reader, opts) {
|
|
6567
|
+
return resolveFromOverrides(parseToolingConfigSlice(reader.read(".tooling.json"), ToolingShallowSchema$2, { block: "health" })?.health, opts.isMonorepo, reader.config.stack);
|
|
6568
|
+
}
|
|
6569
|
+
/**
|
|
6570
|
+
* The two facts about a tree that `resolveHealthConfig` detects but does not return: the stack and
|
|
6571
|
+
* whether it is a monorepo. Exported for `health:check`'s reference read, which must resolve the
|
|
6572
|
+
* default branch's `health` block with the *same* context the working tree's block was resolved
|
|
6573
|
+
* with -- and which therefore has to come out of this module rather than a fresh import, because
|
|
6574
|
+
* the command that needs it sits at its fan-out ceiling.
|
|
6575
|
+
*/
|
|
6576
|
+
function detectHealthContext(targetDir) {
|
|
6577
|
+
return {
|
|
6578
|
+
stack: resolveStack(targetDir),
|
|
6579
|
+
isMonorepo: existsSync(path.join(targetDir, "packages"))
|
|
6580
|
+
};
|
|
6581
|
+
}
|
|
6582
|
+
/** Resolve from the real filesystem (used by CLI commands). */
|
|
6583
|
+
function resolveHealthConfig(targetDir) {
|
|
6584
|
+
const { stack, isMonorepo } = detectHealthContext(targetDir);
|
|
6585
|
+
return resolveHealthConfigFromContext({
|
|
6586
|
+
exists: (rel) => existsSync(path.join(targetDir, rel)),
|
|
6587
|
+
read: (rel) => {
|
|
6588
|
+
const full = path.join(targetDir, rel);
|
|
6589
|
+
if (!existsSync(full)) return void 0;
|
|
6590
|
+
try {
|
|
6591
|
+
return readFileSync(full, "utf-8");
|
|
6592
|
+
} catch (_error) {
|
|
6593
|
+
return;
|
|
6594
|
+
}
|
|
6595
|
+
},
|
|
6596
|
+
config: { stack }
|
|
6597
|
+
}, { isMonorepo });
|
|
6598
|
+
}
|
|
6599
|
+
//#endregion
|
|
6600
|
+
//#region src/utils/script-forwarding.ts
|
|
6601
|
+
/**
|
|
6602
|
+
* pnpm appends args forwarded via `pnpm <script> <args>` to the END of the
|
|
6603
|
+
* resolved script string. A forwarded flag therefore only reaches its intended
|
|
6604
|
+
* command when that command is the LAST command in the script. The generated
|
|
6605
|
+
* CI workflow depends on this two-hop forwarding:
|
|
6606
|
+
*
|
|
6607
|
+
* pnpm ci:check --with-coverage
|
|
6608
|
+
* -> pnpm check --skip 'docker:*' --with-coverage
|
|
6609
|
+
* -> bst checks:run --skip 'docker:*' --with-coverage
|
|
6610
|
+
*
|
|
6611
|
+
* If `check` or `ci:check` ends in a different command (e.g. an `&&` chain),
|
|
6612
|
+
* the forwarded flags silently land on the wrong command -- tests run without
|
|
6613
|
+
* coverage and `--skip` becomes a no-op. This module detects that shape so
|
|
6614
|
+
* callers can surface a precise diagnosis instead of a confusing downstream
|
|
6615
|
+
* failure.
|
|
6616
|
+
*/
|
|
6617
|
+
/** A script's commands, split on `&&`, `||`, `|`, and `;`, each trimmed. */
|
|
6618
|
+
function commandSegments(script) {
|
|
6619
|
+
return script.split(/&&|\|\|?|;/).map((segment) => segment.trim());
|
|
6620
|
+
}
|
|
6621
|
+
/** The final command of a script, after splitting on `&&`, `||`, `|`, and `;`. */
|
|
6622
|
+
function lastCommandSegment(script) {
|
|
6623
|
+
return commandSegments(script).at(-1) ?? "";
|
|
6624
|
+
}
|
|
6625
|
+
/**
|
|
6626
|
+
* True when args forwarded to a `check` script would reach `bst checks:run` -- its last command
|
|
6627
|
+
* must invoke `bst checks:run`. Trailing flags like `--add` are fine; only the position of the
|
|
6628
|
+
* invocation matters.
|
|
6629
|
+
*
|
|
6630
|
+
* The leading `[\s/]` alternative is what lets a wrapped invocation (`pnpm exec bst checks:run`,
|
|
6631
|
+
* or an absolute path ending in `/bst`) still count. It also used to admit a `bin.mjs checks:run`
|
|
6632
|
+
* spelling for the tooling repo's own `check` script, which invoked
|
|
6633
|
+
* `node packages/tooling-cli/dist/bin.mjs` directly; that repo now depends on itself as
|
|
6634
|
+
* `workspace:*` and runs `pnpm exec bst checks:run`, so the exception has no caller left.
|
|
6635
|
+
*/
|
|
6636
|
+
function isCheckScriptForwardingSafe(script) {
|
|
6637
|
+
return /(?:^|[\s/])bst\s+checks:run(?=\s|$)/.test(lastCommandSegment(script));
|
|
6638
|
+
}
|
|
6639
|
+
/**
|
|
6640
|
+
* True when args forwarded to a `ci:check` script would reach `pnpm check`
|
|
6641
|
+
* -- its last command must invoke `pnpm check` (or `pnpm run check`).
|
|
6642
|
+
*/
|
|
6643
|
+
function isCiCheckScriptForwardingSafe(script) {
|
|
6644
|
+
return /(?:^|\s)pnpm\s+(?:run\s+)?check(?=\s|$)/.test(lastCommandSegment(script));
|
|
6645
|
+
}
|
|
6646
|
+
/**
|
|
6647
|
+
* Inspect a package.json `scripts` record for `check` / `ci:check` definitions
|
|
6648
|
+
* whose shape would swallow pnpm-forwarded args. Returns a human-readable hint
|
|
6649
|
+
* naming the problem and the fix, or undefined when forwarding is sound.
|
|
6650
|
+
*/
|
|
6651
|
+
function diagnoseCheckScriptForwarding(scripts) {
|
|
6652
|
+
if (!scripts) return void 0;
|
|
6653
|
+
const problems = [];
|
|
6654
|
+
const check = scripts["check"];
|
|
6655
|
+
if (check !== void 0 && !isCheckScriptForwardingSafe(check)) problems.push(`the \`check\` script does not end with \`bst checks:run\` (its last command is \`${lastCommandSegment(check)}\`)`);
|
|
6656
|
+
const ciCheck = scripts["ci:check"];
|
|
6657
|
+
if (ciCheck !== void 0 && !isCiCheckScriptForwardingSafe(ciCheck)) problems.push(`the \`ci:check\` script does not end with \`pnpm check\` (its last command is \`${lastCommandSegment(ciCheck)}\`)`);
|
|
6658
|
+
if (problems.length === 0) return void 0;
|
|
6659
|
+
return `Likely cause: ${problems.join(", and ")}. pnpm appends forwarded args (e.g. \`--with-coverage\`, \`--skip 'docker:*'\`) to the end of the resolved script, so they never reach \`bst checks:run\`. Make \`bst checks:run\` the last command -- move extra checks into \`bst checks:run --add <name,...>\`.`;
|
|
6660
|
+
}
|
|
6661
|
+
/**
|
|
6662
|
+
* The toolchain scripts `MANAGED_SCRIPTS` (package-json.ts) deliberately does NOT
|
|
6663
|
+
* force-converge. They are add-if-absent: once a value exists, sync never looks at
|
|
6664
|
+
* it again -- because force-converging `build` would stomp a Vite project's
|
|
6665
|
+
* `vite build`, which is the entire point of the `builder` axis. The cost of that
|
|
6666
|
+
* freedom is that these scripts can drift into nonsense and nothing notices, so we
|
|
6667
|
+
* diagnose the one drift shape that fails *silently* rather than loudly.
|
|
6668
|
+
*/
|
|
6669
|
+
const TOOLCHAIN_SCRIPTS = [
|
|
6670
|
+
"build",
|
|
6671
|
+
"typecheck",
|
|
6672
|
+
"lint",
|
|
6673
|
+
"test",
|
|
6674
|
+
"knip",
|
|
6675
|
+
"format"
|
|
6676
|
+
];
|
|
6677
|
+
/**
|
|
6678
|
+
* True when a pnpm invocation asks for recursion (`-r`) and then pins itself to the
|
|
6679
|
+
* workspace root (`-w`) -- `pnpm -r -w --parallel exec tsc --noEmit`.
|
|
6680
|
+
*
|
|
6681
|
+
* `-w` wins: the command runs exactly once, in the workspace root, and never descends
|
|
6682
|
+
* into `packages/*`. A monorepo root holds no source, so `tsc --noEmit` there typechecks
|
|
6683
|
+
* nothing and **exits 0**. That is the failure mode this exists for -- the script is not
|
|
6684
|
+
* broken, it is a no-op that reports success, so it survives in CI indefinitely. This
|
|
6685
|
+
* repo's own `typecheck` sat in exactly that state until it was found by accident.
|
|
6686
|
+
*
|
|
6687
|
+
* Scanned per `&&`/`||`/`|`/`;` segment so a chained script can't hide the conflict, and
|
|
6688
|
+
* only up to the first non-flag token so a *script's own* `-w` (e.g. `vitest --watch`,
|
|
6689
|
+
* `tsdown -w`) is never mistaken for pnpm's.
|
|
6690
|
+
*/
|
|
6691
|
+
function recursionIsPinnedToRoot(script) {
|
|
6692
|
+
return script.split(/&&|\|\|?|;/).some((segment) => {
|
|
6693
|
+
const tokens = segment.trim().split(/\s+/);
|
|
6694
|
+
if (tokens[0] !== "pnpm") return false;
|
|
6695
|
+
let recursive = false;
|
|
6696
|
+
let workspaceRoot = false;
|
|
6697
|
+
for (const token of tokens.slice(1)) {
|
|
6698
|
+
if (!token.startsWith("-")) break;
|
|
6699
|
+
if (token === "--recursive") recursive = true;
|
|
6700
|
+
else if (token === "--workspace-root") workspaceRoot = true;
|
|
6701
|
+
else if (/^-[a-zA-Z]+$/.test(token)) {
|
|
6702
|
+
if (token.includes("r")) recursive = true;
|
|
6703
|
+
if (token.includes("w")) workspaceRoot = true;
|
|
6704
|
+
}
|
|
6705
|
+
}
|
|
6706
|
+
return recursive && workspaceRoot;
|
|
6707
|
+
});
|
|
6708
|
+
}
|
|
6709
|
+
/**
|
|
6710
|
+
* Inspect the unmanaged toolchain scripts for silent-no-op shapes. Returns a
|
|
6711
|
+
* human-readable hint, or undefined when they look sound.
|
|
6712
|
+
*
|
|
6713
|
+
* This is a **warning, not a convergence**: sync must not rewrite these scripts (see
|
|
6714
|
+
* {@link TOOLCHAIN_SCRIPTS}), so the only honest move is to tell the human what looks
|
|
6715
|
+
* wrong and let them fix it.
|
|
6716
|
+
*/
|
|
6717
|
+
function diagnoseToolchainScripts(scripts) {
|
|
6718
|
+
if (!scripts) return void 0;
|
|
6719
|
+
const pinned = TOOLCHAIN_SCRIPTS.filter((name) => {
|
|
6720
|
+
const script = scripts[name];
|
|
6721
|
+
return script !== void 0 && recursionIsPinnedToRoot(script);
|
|
6722
|
+
});
|
|
6723
|
+
if (pinned.length === 0) return void 0;
|
|
6724
|
+
return `${pinned.map((name) => `\`${name}\``).join(", ")} ${pinned.length === 1 ? "combines" : "combine"} pnpm's \`-r\` (recursive) with \`-w\` (workspace-root). \`-w\` wins, so the command runs once at the workspace root and never descends into \`packages/*\` -- in a monorepo whose root holds no source, that is a no-op that still exits 0. Drop the \`-w\`.`;
|
|
6725
|
+
}
|
|
6726
|
+
/**
|
|
6727
|
+
* Filesystem wrapper around {@link diagnoseCheckScriptForwarding}: reads
|
|
6728
|
+
* `package.json` from `targetDir` (best-effort) and diagnoses its scripts.
|
|
6729
|
+
*/
|
|
6730
|
+
function diagnoseCheckScriptForwardingAt(targetDir) {
|
|
6731
|
+
let scripts;
|
|
6732
|
+
try {
|
|
6733
|
+
scripts = parsePackageJson(readFileSync(path.join(targetDir, "package.json"), "utf-8"))?.scripts;
|
|
6734
|
+
} catch (_error) {
|
|
6735
|
+
return;
|
|
6736
|
+
}
|
|
6737
|
+
return diagnoseCheckScriptForwarding(scripts);
|
|
6738
|
+
}
|
|
6739
|
+
//#endregion
|
|
6740
|
+
//#region src/generators/package-json.ts
|
|
6741
|
+
/**
|
|
6742
|
+
* Single source of truth for the supported Node floor: `engines.node` (`>=<floor>`) is the
|
|
6743
|
+
* honest minimum this package declares. `mise.toml`'s `tools.node` (`generators/mise.ts`) is the
|
|
6744
|
+
* exact pin every consumer -- the agent's containers, generated CI, a developer's shell -- now
|
|
6745
|
+
* installs and runs, seeded on first generation from the highest of this floor, `engines.node`,
|
|
6746
|
+
* and the `devEngines.runtime` range removed below.
|
|
6747
|
+
*
|
|
6748
|
+
* `devEngines.runtime` used to carry a caret form of this floor for `actions/setup-node` and
|
|
6749
|
+
* pnpm's runtime-download check to read; both are retired by the shared "Install mise" workflow
|
|
6750
|
+
* step and `mise install` respectively, so the field is removed wherever this generator finds it
|
|
6751
|
+
* (`applyMetadataDefaults`, `generateWorkspaceEngines`) rather than kept in sync.
|
|
6752
|
+
*/
|
|
6753
|
+
const NODE_ENGINES_FLOOR = "24.13.0";
|
|
6754
|
+
const ENGINES_NODE = `>=${NODE_ENGINES_FLOOR}`;
|
|
6755
|
+
function versionPart(s) {
|
|
6756
|
+
return s && s !== "x" && s !== "*" ? Number(s) : 0;
|
|
6757
|
+
}
|
|
6758
|
+
function compareTuples(a, b) {
|
|
6759
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
6760
|
+
return 0;
|
|
6761
|
+
}
|
|
6762
|
+
/**
|
|
6763
|
+
* Effective lower bound of a single conjunction (space-separated comparators like
|
|
6764
|
+
* `>=24.14.0 <25.0.0`). Upper bounds (`<`, `<=`) don't raise the floor and are
|
|
6765
|
+
* ignored; the floor is the **max** of the remaining lower-bound tokens (`>=`,
|
|
6766
|
+
* `>`, `^`, `~`, `=`, bare, `<major>.x`). Missing/wildcard positions read as 0.
|
|
6767
|
+
* `undefined` when no token carries a numeric lower bound.
|
|
6768
|
+
*/
|
|
6769
|
+
function conjunctionFloor(conjunction) {
|
|
6770
|
+
let floor;
|
|
6771
|
+
for (const token of conjunction.trim().split(/\s+/)) {
|
|
6772
|
+
if (!token || token.startsWith("<")) continue;
|
|
6773
|
+
const match = /(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(token);
|
|
6774
|
+
if (!match) continue;
|
|
6775
|
+
const candidate = [
|
|
6776
|
+
Number(match[1]),
|
|
6777
|
+
versionPart(match[2]),
|
|
6778
|
+
versionPart(match[3])
|
|
6779
|
+
];
|
|
6780
|
+
if (!floor || compareTuples(candidate, floor) > 0) floor = candidate;
|
|
6781
|
+
}
|
|
6782
|
+
return floor;
|
|
6783
|
+
}
|
|
6784
|
+
/**
|
|
6785
|
+
* Lowest version a semver range permits, as a `[major, minor, patch]` tuple --
|
|
6786
|
+
* what pnpm resolves a `devEngines.runtime.version` to when cross-checking
|
|
6787
|
+
* `engines.node`. Handles floor-first forms (`>=24.14.0`, `^24.13.0`, `24.x`,
|
|
6788
|
+
* `24`), compound ranges in any order (`>=24.14.0 <25.0.0`, `<25 >=24.14.0` ->
|
|
6789
|
+
* `24.14.0`), and `||` alternation (the minimum across alternatives).
|
|
6790
|
+
*
|
|
6791
|
+
* Returns `undefined` when no floor can be determined (e.g. `*`, `latest`, an
|
|
6792
|
+
* alternative with only an upper bound) -- callers must warn rather than guess.
|
|
6793
|
+
*/
|
|
6794
|
+
function minVersionTuple(range) {
|
|
6795
|
+
let min;
|
|
6796
|
+
for (const alternative of range.split("||")) {
|
|
6797
|
+
const floor = conjunctionFloor(alternative);
|
|
6798
|
+
if (!floor) return void 0;
|
|
6799
|
+
if (!min || compareTuples(floor, min) < 0) min = floor;
|
|
6800
|
+
}
|
|
6801
|
+
return min;
|
|
6802
|
+
}
|
|
6803
|
+
/**
|
|
6804
|
+
* Remove `devEngines.runtime` from a package.json object in place, dropping `devEngines`
|
|
6805
|
+
* entirely once it is left empty. Returns `true` when something was removed, so callers can push
|
|
6806
|
+
* their own change description (the wording differs between the root generator and the
|
|
6807
|
+
* nested-workspace pass).
|
|
6808
|
+
*/
|
|
6809
|
+
function removeDevEnginesRuntime(pkg) {
|
|
6810
|
+
if (!pkg["devEngines"]?.runtime) return false;
|
|
6811
|
+
delete pkg["devEngines"].runtime;
|
|
6812
|
+
if (Object.keys(pkg["devEngines"]).length === 0) delete pkg["devEngines"];
|
|
6813
|
+
return true;
|
|
6814
|
+
}
|
|
6815
|
+
/** Scripts every repo gets, whatever its language. */
|
|
6816
|
+
const HARNESS_SCRIPTS = {
|
|
6817
|
+
check: "bst checks:run",
|
|
6818
|
+
"ci:check": "pnpm check --skip 'docker:*'",
|
|
6819
|
+
"tooling:check": "bst repo:sync --check",
|
|
6820
|
+
"docs:check": "bst docs:check",
|
|
6821
|
+
"tooling:sync": "bst repo:sync"
|
|
6822
|
+
};
|
|
6823
|
+
/** Scripts that need a JS/TS toolchain. */
|
|
6824
|
+
const JS_SCRIPTS_SINGLE = {
|
|
6825
|
+
test: "vitest run",
|
|
6826
|
+
"test:coverage": "vitest run --coverage",
|
|
6827
|
+
lint: "oxlint",
|
|
6828
|
+
"lint:report": "bst lint:report",
|
|
6829
|
+
knip: "knip"
|
|
6830
|
+
};
|
|
6831
|
+
const JS_SCRIPTS_MONOREPO = {
|
|
6832
|
+
test: "pnpm -r test",
|
|
6833
|
+
"test:coverage": "vitest run --coverage",
|
|
6834
|
+
lint: "oxlint",
|
|
6835
|
+
"lint:report": "bst lint:report",
|
|
6836
|
+
knip: "knip"
|
|
6837
|
+
};
|
|
6838
|
+
/** Scripts that only make sense with a TypeScript compiler. */
|
|
6839
|
+
const TS_SCRIPTS_SINGLE = {
|
|
6840
|
+
typecheck: "tsc --noEmit",
|
|
6841
|
+
"lint:types": "bst lint:types"
|
|
6842
|
+
};
|
|
6843
|
+
const TS_SCRIPTS_MONOREPO = {
|
|
6844
|
+
typecheck: "pnpm -r --parallel run typecheck",
|
|
6845
|
+
"lint:types": "bst lint:types"
|
|
6846
|
+
};
|
|
6847
|
+
/** Scripts owned by the builder. Only tsdown is ours to write. */
|
|
6848
|
+
const TSDOWN_SCRIPTS_SINGLE = {
|
|
6849
|
+
build: "tsdown",
|
|
6850
|
+
dev: "tsdown --watch"
|
|
6851
|
+
};
|
|
6852
|
+
const TSDOWN_SCRIPTS_MONOREPO = { build: "pnpm -r build" };
|
|
6853
|
+
/** Compose the standard `scripts` map from stack/builder/structure. Content-identical
|
|
6854
|
+
* (same keys, same values) to the old flat tables for a typescript + tsdown project
|
|
6855
|
+
* (single or monorepo) -- key insertion order differs since harness scripts now compose
|
|
6856
|
+
* first rather than last, but that's immaterial: `mergeManagedScripts` mutates existing
|
|
6857
|
+
* keys in place and only appends keys that were previously absent. */
|
|
6524
6858
|
function standardScripts(config) {
|
|
6525
6859
|
const mono = config.structure === "monorepo";
|
|
6526
6860
|
const scripts = { ...HARNESS_SCRIPTS };
|
|
@@ -7119,7 +7453,7 @@ function buildManagedDevDeps(ctx) {
|
|
|
7119
7453
|
const devDeps = { ...rootDevDeps(ctx.config) };
|
|
7120
7454
|
if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
|
|
7121
7455
|
if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.14.5";
|
|
7122
|
-
if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.87.
|
|
7456
|
+
if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.87.1";
|
|
7123
7457
|
if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.16.0";
|
|
7124
7458
|
if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
|
|
7125
7459
|
"@changesets/cli": "3.0.3",
|
|
@@ -8351,1411 +8685,1110 @@ async function generateOxfmt(ctx) {
|
|
|
8351
8685
|
let jsonValue;
|
|
8352
8686
|
try {
|
|
8353
8687
|
jsonValue = JSON.parse(existing);
|
|
8354
|
-
} catch {
|
|
8355
|
-
const config = { ignorePatterns: required };
|
|
8356
|
-
ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
8357
|
-
return {
|
|
8358
|
-
filePath,
|
|
8359
|
-
action: "updated",
|
|
8360
|
-
description: "Replaced malformed .oxfmtrc.json"
|
|
8361
|
-
};
|
|
8362
|
-
}
|
|
8363
|
-
const parseResult = OxfmtConfigSchema.safeParse(jsonValue);
|
|
8364
|
-
if (!parseResult.success) {
|
|
8365
|
-
const config = { ignorePatterns: required };
|
|
8366
|
-
ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
8367
|
-
return {
|
|
8368
|
-
filePath,
|
|
8369
|
-
action: "updated",
|
|
8370
|
-
description: "Replaced malformed .oxfmtrc.json"
|
|
8371
|
-
};
|
|
8372
|
-
}
|
|
8373
|
-
const parsed = parseResult.data;
|
|
8374
|
-
const ignorePatterns = Array.isArray(parsed["ignorePatterns"]) ? parsed["ignorePatterns"] : [];
|
|
8375
|
-
const strings = ignorePatterns.filter((p) => typeof p === "string");
|
|
8376
|
-
const existingSet = new Set(strings);
|
|
8377
|
-
const missing = required.filter((p) => !existingSet.has(p));
|
|
8378
|
-
const kept = strings.filter((p) => !SUPERSEDED_IGNORE_PATTERNS.has(p));
|
|
8379
|
-
const removed = strings.filter((p) => SUPERSEDED_IGNORE_PATTERNS.has(p));
|
|
8380
|
-
const nonStringPatterns = ignorePatterns.filter((p) => typeof p !== "string");
|
|
8381
|
-
if (missing.length === 0 && removed.length === 0) return {
|
|
8382
|
-
filePath,
|
|
8383
|
-
action: "skipped",
|
|
8384
|
-
description: "Existing oxfmt config already has required ignorePatterns"
|
|
8385
|
-
};
|
|
8386
|
-
parsed["ignorePatterns"] = [
|
|
8387
|
-
...kept,
|
|
8388
|
-
...nonStringPatterns,
|
|
8389
|
-
...missing
|
|
8390
|
-
];
|
|
8391
|
-
ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
|
|
8392
|
-
const changeParts = [];
|
|
8393
|
-
if (missing.length > 0) changeParts.push(`Added ignorePatterns: ${missing.join(", ")}`);
|
|
8394
|
-
if (removed.length > 0) changeParts.push(`removed: ${removed.join(", ")}`);
|
|
8395
|
-
return {
|
|
8396
|
-
filePath,
|
|
8397
|
-
action: "updated",
|
|
8398
|
-
description: changeParts.join("; ")
|
|
8399
|
-
};
|
|
8400
|
-
}
|
|
8401
|
-
async function generatePrettier(ctx) {
|
|
8402
|
-
const filePath = ".prettierrc";
|
|
8403
|
-
if (ctx.exists(filePath)) return {
|
|
8404
|
-
filePath,
|
|
8405
|
-
action: "skipped",
|
|
8406
|
-
description: "Existing prettier config preserved"
|
|
8407
|
-
};
|
|
8408
|
-
ctx.write(filePath, PRETTIER_CONFIG);
|
|
8409
|
-
return {
|
|
8410
|
-
filePath,
|
|
8411
|
-
action: "created",
|
|
8412
|
-
description: "Generated .prettierrc"
|
|
8413
|
-
};
|
|
8414
|
-
}
|
|
8415
|
-
//#endregion
|
|
8416
|
-
//#region src/generators/tsdown.ts
|
|
8417
|
-
/**
|
|
8418
|
-
* Exported so `package-json.ts#reclaimManagedDevDeps` can gate the `tsdown`
|
|
8419
|
-
* devDependency reclaim on the same generated-template evidence this generator
|
|
8420
|
-
* uses for the config file itself -- the two must never disagree about what
|
|
8421
|
-
* "no longer used" means (see the block comment there).
|
|
8422
|
-
*/
|
|
8423
|
-
const TSDOWN_CONFIG = `import { defineConfig } from "tsdown";
|
|
8424
|
-
|
|
8425
|
-
export default defineConfig({
|
|
8426
|
-
entry: ["src/index.ts"],
|
|
8427
|
-
});
|
|
8428
|
-
`;
|
|
8429
|
-
async function generateTsdown(ctx) {
|
|
8430
|
-
const filePath = "tsdown.config.ts";
|
|
8431
|
-
if (ctx.config.structure === "monorepo") return {
|
|
8432
|
-
filePath,
|
|
8433
|
-
action: "skipped",
|
|
8434
|
-
description: "Monorepo: tsdown config belongs in individual packages"
|
|
8435
|
-
};
|
|
8436
|
-
if (ctx.config.builder !== "tsdown") {
|
|
8437
|
-
if (ctx.read(filePath) !== void 0 && isGeneratedTemplate(ctx, filePath, TSDOWN_CONFIG)) return reportStaleConfig(filePath, `builder is ${ctx.config.builder}`);
|
|
8438
|
-
return {
|
|
8439
|
-
filePath,
|
|
8440
|
-
action: "skipped",
|
|
8441
|
-
description: `Builder is ${ctx.config.builder}; tsdown config not managed`
|
|
8442
|
-
};
|
|
8443
|
-
}
|
|
8444
|
-
const existing = ctx.read(filePath);
|
|
8445
|
-
const config = formattedTemplate(ctx, filePath, TSDOWN_CONFIG);
|
|
8446
|
-
if (existing) {
|
|
8447
|
-
if (existing === config) return {
|
|
8448
|
-
filePath,
|
|
8449
|
-
action: "skipped",
|
|
8450
|
-
description: "Already configured"
|
|
8451
|
-
};
|
|
8452
|
-
if (await ctx.confirmOverwrite(filePath) === "skip") return {
|
|
8453
|
-
filePath,
|
|
8454
|
-
action: "skipped",
|
|
8455
|
-
description: "Existing tsdown config preserved"
|
|
8456
|
-
};
|
|
8457
|
-
}
|
|
8458
|
-
ctx.write(filePath, config);
|
|
8459
|
-
return {
|
|
8460
|
-
filePath,
|
|
8461
|
-
action: existing ? "updated" : "created",
|
|
8462
|
-
description: "Generated tsdown.config.ts"
|
|
8463
|
-
};
|
|
8464
|
-
}
|
|
8465
|
-
//#endregion
|
|
8466
|
-
//#region src/generators/mise.ts
|
|
8467
|
-
/**
|
|
8468
|
-
* `mise.toml` owns exactly two keys, `tools.node` and `tools.pnpm`, and carries every other byte
|
|
8469
|
-
* -- other tools, `[env]`, `[tasks.*]`, comments -- through untouched. `smol-toml`'s `parse` is
|
|
8470
|
-
* used only to validate the file and to inspect the owned keys' shapes; it preserves no layout, so
|
|
8471
|
-
* every mutation below is a textual edit against the raw source rather than a reserialization.
|
|
8472
|
-
* `generators/package-json.ts` documents the same "manage the keys, not the file" posture for
|
|
8473
|
-
* `devEngines.runtime`.
|
|
8474
|
-
*/
|
|
8475
|
-
const TOOLS_HEADER_RE = /^\[tools\]\s*$/m;
|
|
8476
|
-
/** Any top-level table header (`[x]` or `[x.y]`) or array-of-tables header (`[[x]]`), used to find
|
|
8477
|
-
* where `[tools]`'s own range ends -- an array-of-tables entry is a fresh table just as much as a
|
|
8478
|
-
* `[x]` header is, so it must terminate the body range too, or an owned line appended past it
|
|
8479
|
-
* lands inside that entry instead of `[tools]`. */
|
|
8480
|
-
const TOP_LEVEL_HEADER_RE = /^\[{1,2}[^[].*\]{1,2}\s*$/m;
|
|
8481
|
-
const PNPM_LINE_RE = /^pnpm\s*=.*$/m;
|
|
8482
|
-
/** Tolerant shape of the file's `[tools]` table: string or table form for every key, so a
|
|
8483
|
-
* table-form `node`/`pnpm` is caught rather than mistaken for absent. */
|
|
8484
|
-
const ToolsTableSchema = z.record(z.string(), z.union([z.string(), z.record(z.string(), z.unknown())]));
|
|
8485
|
-
/** Tolerant shape of the whole parsed document -- only `tools` is read. */
|
|
8486
|
-
const ParsedTomlSchema = z.looseObject({ tools: z.unknown().optional() });
|
|
8487
|
-
/** The `[tools]` table's body range: right after its header line to the next top-level header or
|
|
8488
|
-
* EOF. `undefined` when there is no `[tools]` table at all. */
|
|
8489
|
-
function toolsRange(source) {
|
|
8490
|
-
const headerMatch = TOOLS_HEADER_RE.exec(source);
|
|
8491
|
-
if (!headerMatch) return void 0;
|
|
8492
|
-
const start = headerMatch.index + headerMatch[0].length;
|
|
8493
|
-
const rest = source.slice(start);
|
|
8494
|
-
const nextHeader = TOP_LEVEL_HEADER_RE.exec(rest);
|
|
8495
|
-
return {
|
|
8496
|
-
start,
|
|
8497
|
-
end: nextHeader ? start + nextHeader.index : source.length
|
|
8498
|
-
};
|
|
8499
|
-
}
|
|
8500
|
-
/** Strip the version out of `packageManager` (`pnpm@11.27.0` -> `11.27.0`). */
|
|
8501
|
-
function pnpmVersionFromPackageManager(packageManager) {
|
|
8502
|
-
return /^pnpm@(\S+)$/.exec(packageManager ?? "")?.[1];
|
|
8503
|
-
}
|
|
8504
|
-
/** The exact pin to seed `tools.node` with: the higher of `enginesNode`'s own floor (e.g. a repo
|
|
8505
|
-
* whose `package.json` already declares `>=24.14.0`) and {@link NODE_ENGINES_FLOOR}, so a loose
|
|
8506
|
-
* `engines.node` (`>=24`, `24.x`) can never seed a pin below the supported floor -- the same
|
|
8507
|
-
* derive-from-engines discipline the retired `devEngines.runtime` generator used. */
|
|
8508
|
-
function nodePinFor(enginesNode) {
|
|
8509
|
-
const tuple = enginesNode ? minVersionTuple(enginesNode) : void 0;
|
|
8510
|
-
const floorTuple = minVersionTuple(NODE_ENGINES_FLOOR);
|
|
8511
|
-
if (!tuple || !floorTuple) return NODE_ENGINES_FLOOR;
|
|
8512
|
-
return compareTuples(tuple, floorTuple) > 0 ? tuple.join(".") : NODE_ENGINES_FLOOR;
|
|
8513
|
-
}
|
|
8514
|
-
/** The two owned lines, in order, for a file created from scratch (or a `[tools]` table added to
|
|
8515
|
-
* one that had none). */
|
|
8516
|
-
function ownedLines(pnpmVersion, enginesNode) {
|
|
8517
|
-
const lines = [`[tools]`, `node = "${nodePinFor(enginesNode)}"`];
|
|
8518
|
-
if (pnpmVersion) lines.push(`pnpm = "${pnpmVersion}"`);
|
|
8519
|
-
return lines;
|
|
8520
|
-
}
|
|
8521
|
-
/** Append a `key = "value"` line to a `[tools]` body, respecting whatever trailing newline it
|
|
8522
|
-
* already has. */
|
|
8523
|
-
function appendLine(body, line) {
|
|
8524
|
-
return (body.endsWith("\n") || body.length === 0 ? body : body + "\n") + line + "\n";
|
|
8525
|
-
}
|
|
8526
|
-
/** `action: "failed"` naming an owned key present in table form. */
|
|
8527
|
-
function tableFormFailure(filePath, key) {
|
|
8528
|
-
return {
|
|
8529
|
-
filePath,
|
|
8530
|
-
action: "failed",
|
|
8531
|
-
description: `mise.toml: tools.${key} is a table; repo:sync owns it as a plain string pin`
|
|
8532
|
-
};
|
|
8533
|
-
}
|
|
8534
|
-
/** `true` when `pinned` provably resolves below `enginesNode`'s floor. Only two pin shapes are
|
|
8535
|
-
* compared: an exact `X.Y.Z` against the whole floor, and a bare major (`"24"`, what `mise use
|
|
8536
|
-
* node@24` writes) against the floor's major alone. Anything else -- `"24.12"`, `"lts"`, a prefix
|
|
8537
|
-
* mise resolves -- is left for mise to resolve, because the toolchain gate in
|
|
8538
|
-
* `commands/sync-gates.ts` treats such pins as legitimate and checks the version mise resolves
|
|
8539
|
-
* them to. Reading `"24"` as `24.0.0` would refuse a pin that resolves to the newest 24.x. An
|
|
8540
|
-
* unparseable `enginesNode` compares nothing. */
|
|
8541
|
-
function isBelowFloor(pinned, enginesNode) {
|
|
8542
|
-
const floor = minVersionTuple(enginesNode);
|
|
8543
|
-
if (floor === void 0) return false;
|
|
8544
|
-
const exact = /^(\d+)\.(\d+)\.(\d+)$/.exec(pinned);
|
|
8545
|
-
if (exact) return compareTuples([
|
|
8546
|
-
Number(exact[1]),
|
|
8547
|
-
Number(exact[2]),
|
|
8548
|
-
Number(exact[3])
|
|
8549
|
-
], floor) < 0;
|
|
8550
|
-
const major = /^(\d+)$/.exec(pinned);
|
|
8551
|
-
if (major) return Number(major[1]) < floor[0];
|
|
8552
|
-
return false;
|
|
8553
|
-
}
|
|
8554
|
-
/** Resolve the two facts `mise.toml` needs from `package.json`: the engines floor to validate an
|
|
8555
|
-
* existing `tools.node` pin against, and the pnpm version to keep `tools.pnpm` in step with. */
|
|
8556
|
-
function readPackageJsonFacts(ctx) {
|
|
8557
|
-
const pkgRaw = ctx.read("package.json");
|
|
8558
|
-
const pkg = pkgRaw ? parsePackageJson(pkgRaw) : void 0;
|
|
8559
|
-
return {
|
|
8560
|
-
enginesNode: pkg?.engines?.["node"],
|
|
8561
|
-
pnpmVersion: pnpmVersionFromPackageManager(pkg?.packageManager)
|
|
8562
|
-
};
|
|
8563
|
-
}
|
|
8564
|
-
/** Edit an existing `[tools]` body in place: add `node` only when absent, rewrite `pnpm` only on
|
|
8565
|
-
* drift. Returns the new body and the change descriptions, or `undefined` for the body when
|
|
8566
|
-
* nothing changed. */
|
|
8567
|
-
function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, enginesNode) {
|
|
8568
|
-
let newBody = body;
|
|
8569
|
-
const changes = [];
|
|
8570
|
-
if (existingNode === void 0) {
|
|
8571
|
-
const pin = nodePinFor(enginesNode);
|
|
8572
|
-
newBody = appendLine(newBody, `node = "${pin}"`);
|
|
8573
|
-
changes.push(`added tools.node = "${pin}"`);
|
|
8574
|
-
}
|
|
8575
|
-
if (pnpmVersion && existingPnpm !== pnpmVersion) {
|
|
8576
|
-
if (existingPnpm === void 0) {
|
|
8577
|
-
newBody = appendLine(newBody, `pnpm = "${pnpmVersion}"`);
|
|
8578
|
-
changes.push(`added tools.pnpm = "${pnpmVersion}"`);
|
|
8579
|
-
} else {
|
|
8580
|
-
newBody = newBody.replace(PNPM_LINE_RE, (line) => {
|
|
8581
|
-
return `pnpm = "${pnpmVersion}"${/(\s*#.*)$/.exec(line)?.[1] ?? ""}`;
|
|
8582
|
-
});
|
|
8583
|
-
changes.push(`updated tools.pnpm ${existingPnpm} -> ${pnpmVersion}`);
|
|
8584
|
-
}
|
|
8585
|
-
}
|
|
8586
|
-
return {
|
|
8587
|
-
body: newBody,
|
|
8588
|
-
changes
|
|
8589
|
-
};
|
|
8590
|
-
}
|
|
8591
|
-
async function generateMise(ctx) {
|
|
8592
|
-
const filePath = "mise.toml";
|
|
8593
|
-
const { enginesNode, pnpmVersion } = readPackageJsonFacts(ctx);
|
|
8594
|
-
const existing = ctx.read(filePath);
|
|
8595
|
-
if (!existing) {
|
|
8596
|
-
ctx.write(filePath, ownedLines(pnpmVersion, enginesNode).join("\n") + "\n");
|
|
8597
|
-
return {
|
|
8598
|
-
filePath,
|
|
8599
|
-
action: "created",
|
|
8600
|
-
description: "Generated mise.toml"
|
|
8601
|
-
};
|
|
8602
|
-
}
|
|
8603
|
-
let parsed;
|
|
8604
|
-
try {
|
|
8605
|
-
parsed = parse$2(existing);
|
|
8606
|
-
} catch (error) {
|
|
8688
|
+
} catch {
|
|
8689
|
+
const config = { ignorePatterns: required };
|
|
8690
|
+
ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
8607
8691
|
return {
|
|
8608
8692
|
filePath,
|
|
8609
|
-
action: "
|
|
8610
|
-
description:
|
|
8693
|
+
action: "updated",
|
|
8694
|
+
description: "Replaced malformed .oxfmtrc.json"
|
|
8611
8695
|
};
|
|
8612
8696
|
}
|
|
8613
|
-
const
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
if (typeof toolsTable["pnpm"] === "object") return tableFormFailure(filePath, "pnpm");
|
|
8619
|
-
const existingNode = toolsTable["node"];
|
|
8620
|
-
const existingPnpm = toolsTable["pnpm"];
|
|
8621
|
-
if (existingNode !== void 0 && enginesNode !== void 0 && isBelowFloor(existingNode, enginesNode)) return {
|
|
8622
|
-
filePath,
|
|
8623
|
-
action: "failed",
|
|
8624
|
-
description: `mise.toml: tools.node "${existingNode}" is below engines.node "${enginesNode}"`
|
|
8625
|
-
};
|
|
8626
|
-
const range = toolsRange(existing);
|
|
8627
|
-
let text;
|
|
8628
|
-
let changes;
|
|
8629
|
-
if (range === void 0) {
|
|
8630
|
-
if (Object.keys(toolsTable).length > 0) return {
|
|
8697
|
+
const parseResult = OxfmtConfigSchema.safeParse(jsonValue);
|
|
8698
|
+
if (!parseResult.success) {
|
|
8699
|
+
const config = { ignorePatterns: required };
|
|
8700
|
+
ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
8701
|
+
return {
|
|
8631
8702
|
filePath,
|
|
8632
|
-
action: "
|
|
8633
|
-
description:
|
|
8703
|
+
action: "updated",
|
|
8704
|
+
description: "Replaced malformed .oxfmtrc.json"
|
|
8634
8705
|
};
|
|
8635
|
-
text = ownedLines(pnpmVersion, enginesNode).join("\n") + "\n\n" + existing;
|
|
8636
|
-
changes = ["added [tools] table"];
|
|
8637
|
-
} else {
|
|
8638
|
-
const body = existing.slice(range.start, range.end);
|
|
8639
|
-
const edited = editToolsBody(body, existingNode, existingPnpm, pnpmVersion, enginesNode);
|
|
8640
|
-
changes = edited.changes;
|
|
8641
|
-
text = edited.body === body ? existing : existing.slice(0, range.start) + edited.body + existing.slice(range.end);
|
|
8642
8706
|
}
|
|
8643
|
-
|
|
8707
|
+
const parsed = parseResult.data;
|
|
8708
|
+
const ignorePatterns = Array.isArray(parsed["ignorePatterns"]) ? parsed["ignorePatterns"] : [];
|
|
8709
|
+
const strings = ignorePatterns.filter((p) => typeof p === "string");
|
|
8710
|
+
const existingSet = new Set(strings);
|
|
8711
|
+
const missing = required.filter((p) => !existingSet.has(p));
|
|
8712
|
+
const kept = strings.filter((p) => !SUPERSEDED_IGNORE_PATTERNS.has(p));
|
|
8713
|
+
const removed = strings.filter((p) => SUPERSEDED_IGNORE_PATTERNS.has(p));
|
|
8714
|
+
const nonStringPatterns = ignorePatterns.filter((p) => typeof p !== "string");
|
|
8715
|
+
if (missing.length === 0 && removed.length === 0) return {
|
|
8644
8716
|
filePath,
|
|
8645
8717
|
action: "skipped",
|
|
8646
|
-
description: "
|
|
8718
|
+
description: "Existing oxfmt config already has required ignorePatterns"
|
|
8647
8719
|
};
|
|
8648
|
-
|
|
8720
|
+
parsed["ignorePatterns"] = [
|
|
8721
|
+
...kept,
|
|
8722
|
+
...nonStringPatterns,
|
|
8723
|
+
...missing
|
|
8724
|
+
];
|
|
8725
|
+
ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
|
|
8726
|
+
const changeParts = [];
|
|
8727
|
+
if (missing.length > 0) changeParts.push(`Added ignorePatterns: ${missing.join(", ")}`);
|
|
8728
|
+
if (removed.length > 0) changeParts.push(`removed: ${removed.join(", ")}`);
|
|
8649
8729
|
return {
|
|
8650
8730
|
filePath,
|
|
8651
8731
|
action: "updated",
|
|
8652
|
-
description:
|
|
8732
|
+
description: changeParts.join("; ")
|
|
8653
8733
|
};
|
|
8654
8734
|
}
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
current.warn(message);
|
|
8668
|
-
},
|
|
8669
|
-
debug(message) {
|
|
8670
|
-
current.debug(message);
|
|
8671
|
-
}
|
|
8672
|
-
};
|
|
8673
|
-
//#endregion
|
|
8674
|
-
//#region ../forgejo/src/releases.ts
|
|
8675
|
-
/** Patient-repair attempts (delete frozen release, wait, retry) before escalating. */
|
|
8676
|
-
const SETTLE_ATTEMPTS = 4;
|
|
8677
|
-
/** Base backoff between patient-repair attempts; doubles each attempt. */
|
|
8678
|
-
const SETTLE_BASE_DELAY_MS = 2e3;
|
|
8679
|
-
/** Cap on a single patient-repair backoff. */
|
|
8680
|
-
const SETTLE_MAX_DELAY_MS = 16e3;
|
|
8681
|
-
/** Check whether a Forgejo release already exists for a given tag. */
|
|
8682
|
-
async function findRelease(executor, conn, tag) {
|
|
8683
|
-
const encodedTag = encodeURIComponent(tag);
|
|
8684
|
-
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
|
|
8685
|
-
const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
|
|
8686
|
-
if (res.status === 200) return true;
|
|
8687
|
-
if (res.status === 404) return false;
|
|
8688
|
-
throw new TransientError(await forgejoErrorMessage(res, {
|
|
8689
|
-
verb: "check release",
|
|
8690
|
-
resource: tag
|
|
8691
|
-
}));
|
|
8692
|
-
}
|
|
8693
|
-
const ReleaseSchema = z.object({
|
|
8694
|
-
id: z.number(),
|
|
8695
|
-
tag_name: z.string().optional(),
|
|
8696
|
-
created_at: z.string().optional(),
|
|
8697
|
-
published_at: z.string().optional(),
|
|
8698
|
-
html_url: z.string().nullish(),
|
|
8699
|
-
prerelease: z.boolean().optional()
|
|
8700
|
-
});
|
|
8701
|
-
/**
|
|
8702
|
-
* Detect the signature of a broken Forgejo release: a `created_at` that maps to
|
|
8703
|
-
* the Unix epoch (`created_unix = 0`).
|
|
8704
|
-
*
|
|
8705
|
-
* Forgejo auto-creates a "tag placeholder" release row when a tag is pushed.
|
|
8706
|
-
* If our `POST /releases` races that placeholder, Forgejo promotes the
|
|
8707
|
-
* placeholder in place (flipping `is_tag` to false) **without** resetting
|
|
8708
|
-
* `created_unix`, leaving a published release stuck at the epoch. Such a
|
|
8709
|
-
* release sorts as the oldest, so `/releases/latest` never returns it.
|
|
8710
|
-
*
|
|
8711
|
-
* The epoch renders as `1969-12-31T18:00:00-06:00` or `1970-01-01T00:00:00Z`
|
|
8712
|
-
* depending on the server timezone; both parse to `<= 0` ms. A missing or
|
|
8713
|
-
* unparseable date is treated as broken too, so the caller repairs rather than
|
|
8714
|
-
* ships it.
|
|
8715
|
-
*/
|
|
8716
|
-
function isEpochReleaseDate(date) {
|
|
8717
|
-
if (!date) return true;
|
|
8718
|
-
const ms = new Date(date).getTime();
|
|
8719
|
-
return Number.isNaN(ms) || ms <= 0;
|
|
8720
|
-
}
|
|
8721
|
-
/**
|
|
8722
|
-
* Fetch the Forgejo release for a tag, returning the parsed object or `null`
|
|
8723
|
-
* when no release exists (404). Unlike {@link findRelease}, this exposes the
|
|
8724
|
-
* release `id` and dates so callers can inspect/repair an epoch-dated release.
|
|
8725
|
-
*/
|
|
8726
|
-
async function getReleaseByTag(executor, conn, tag) {
|
|
8727
|
-
const encodedTag = encodeURIComponent(tag);
|
|
8728
|
-
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
|
|
8729
|
-
const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
|
|
8730
|
-
if (res.status === 404) return null;
|
|
8731
|
-
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
8732
|
-
verb: "check release",
|
|
8733
|
-
resource: tag
|
|
8734
|
-
}));
|
|
8735
|
-
const parsed = ReleaseSchema.safeParse(await res.json());
|
|
8736
|
-
if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
|
|
8737
|
-
return parsed.data;
|
|
8738
|
-
}
|
|
8739
|
-
/**
|
|
8740
|
-
* Create a Forgejo release for a given tag.
|
|
8741
|
-
*
|
|
8742
|
-
* When `options.target` (a commit SHA or branch) is provided and the tag does
|
|
8743
|
-
* not yet exist, Forgejo **creates the tag itself** from that commitish and
|
|
8744
|
-
* stamps `created_unix = now` synchronously -- sidestepping the buggy async
|
|
8745
|
-
* stamping of a pushed tag. The created tag is lightweight.
|
|
8746
|
-
*/
|
|
8747
|
-
async function createRelease(executor, conn, tag, options = {}) {
|
|
8748
|
-
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
|
|
8749
|
-
const payload = {
|
|
8750
|
-
tag_name: tag,
|
|
8751
|
-
name: tag,
|
|
8752
|
-
body: `Published ${tag}`,
|
|
8753
|
-
prerelease: options.prerelease ?? false
|
|
8735
|
+
async function generatePrettier(ctx) {
|
|
8736
|
+
const filePath = ".prettierrc";
|
|
8737
|
+
if (ctx.exists(filePath)) return {
|
|
8738
|
+
filePath,
|
|
8739
|
+
action: "skipped",
|
|
8740
|
+
description: "Existing prettier config preserved"
|
|
8741
|
+
};
|
|
8742
|
+
ctx.write(filePath, PRETTIER_CONFIG);
|
|
8743
|
+
return {
|
|
8744
|
+
filePath,
|
|
8745
|
+
action: "created",
|
|
8746
|
+
description: "Generated .prettierrc"
|
|
8754
8747
|
};
|
|
8755
|
-
if (options.target) payload["target_commitish"] = options.target;
|
|
8756
|
-
const res = await executor.fetch(url, {
|
|
8757
|
-
method: "POST",
|
|
8758
|
-
headers: {
|
|
8759
|
-
Authorization: `token ${conn.token}`,
|
|
8760
|
-
"Content-Type": "application/json"
|
|
8761
|
-
},
|
|
8762
|
-
body: JSON.stringify(payload)
|
|
8763
|
-
});
|
|
8764
|
-
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
8765
|
-
verb: "create release",
|
|
8766
|
-
resource: tag
|
|
8767
|
-
}));
|
|
8768
|
-
}
|
|
8769
|
-
/**
|
|
8770
|
-
* Delete a git tag from the repository (`DELETE /repos/{repo}/tags/{tag}`).
|
|
8771
|
-
*
|
|
8772
|
-
* Used by {@link ensureRelease} to take tag ownership away from the buggy
|
|
8773
|
-
* push-ingestion path: once the tag is gone, recreating the release with a
|
|
8774
|
-
* `target_commitish` makes Forgejo create the tag fresh with a real date. A 404
|
|
8775
|
-
* (already gone) is treated as success.
|
|
8776
|
-
*/
|
|
8777
|
-
async function deleteTag(executor, conn, tag) {
|
|
8778
|
-
const encodedTag = encodeURIComponent(tag);
|
|
8779
|
-
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
|
|
8780
|
-
const res = await executor.fetch(url, {
|
|
8781
|
-
method: "DELETE",
|
|
8782
|
-
headers: { Authorization: `token ${conn.token}` }
|
|
8783
|
-
});
|
|
8784
|
-
if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
|
|
8785
|
-
verb: "delete tag",
|
|
8786
|
-
resource: tag
|
|
8787
|
-
}));
|
|
8788
|
-
}
|
|
8789
|
-
z.looseObject({
|
|
8790
|
-
name: z.string(),
|
|
8791
|
-
id: z.string(),
|
|
8792
|
-
commit: z.object({ sha: z.string().regex(/^[0-9a-f]{40}$/) })
|
|
8793
|
-
});
|
|
8794
|
-
/**
|
|
8795
|
-
* Delete a Forgejo release by id. Does **not** delete the underlying git tag
|
|
8796
|
-
* (no `delete_tag` query param) -- the tag is the source of truth, and a
|
|
8797
|
-
* subsequent `POST /releases` relies on it still existing to insert a fresh
|
|
8798
|
-
* row with `created_unix = now`. A 404 (already gone) is treated as success.
|
|
8799
|
-
*/
|
|
8800
|
-
async function deleteRelease(executor, conn, releaseId) {
|
|
8801
|
-
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
|
|
8802
|
-
const res = await executor.fetch(url, {
|
|
8803
|
-
method: "DELETE",
|
|
8804
|
-
headers: { Authorization: `token ${conn.token}` }
|
|
8805
|
-
});
|
|
8806
|
-
if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
|
|
8807
|
-
verb: "delete release",
|
|
8808
|
-
resource: String(releaseId)
|
|
8809
|
-
}));
|
|
8810
|
-
}
|
|
8811
|
-
/**
|
|
8812
|
-
* Flag an existing Forgejo release as a prerelease (`PATCH /repos/{repo}/releases/{id}`).
|
|
8813
|
-
*
|
|
8814
|
-
* {@link ensureRelease} uses this on the path where the release was already there, and so does a
|
|
8815
|
-
* rerun, so a rerun over a release an earlier unflagged run created can still
|
|
8816
|
-
* keep the forge's Latest pointer off a prerelease. It sets the flag and never clears it, which is
|
|
8817
|
-
* why the body is fixed at
|
|
8818
|
-
* `{ prerelease: true }` and why the write is a fixed-field PATCH: `{ idempotent: true }`, the
|
|
8819
|
-
* convention every other fixed-field PATCH in this package passes (see `updateForgejoComment` in
|
|
8820
|
-
* `issue-comments.ts`), because a re-sent convergent PATCH is a no-op.
|
|
8821
|
-
*
|
|
8822
|
-
* The JSON body is {@link createRelease}'s shape (one `Authorization` header plus a content type),
|
|
8823
|
-
* but unlike {@link deleteRelease} against the same URL a 404 is **not** tolerated here: a row that
|
|
8824
|
-
* vanished mid-run means the flag was not applied, and the caller must know.
|
|
8825
|
-
*/
|
|
8826
|
-
async function markReleasePrerelease(executor, conn, releaseId, tag) {
|
|
8827
|
-
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
|
|
8828
|
-
const res = await executor.fetch(url, {
|
|
8829
|
-
method: "PATCH",
|
|
8830
|
-
headers: {
|
|
8831
|
-
Authorization: `token ${conn.token}`,
|
|
8832
|
-
"Content-Type": "application/json"
|
|
8833
|
-
},
|
|
8834
|
-
body: JSON.stringify({ prerelease: true })
|
|
8835
|
-
}, { idempotent: true });
|
|
8836
|
-
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
8837
|
-
verb: "edit release",
|
|
8838
|
-
resource: tag
|
|
8839
|
-
}));
|
|
8840
8748
|
}
|
|
8749
|
+
//#endregion
|
|
8750
|
+
//#region src/generators/tsdown.ts
|
|
8841
8751
|
/**
|
|
8842
|
-
*
|
|
8843
|
-
*
|
|
8844
|
-
*
|
|
8845
|
-
*
|
|
8846
|
-
* {@link isEpochReleaseDate}) -- a published-but-invisible release, because
|
|
8847
|
-
* `/releases/latest` sorts by date. The failure is intermittent. The repair:
|
|
8848
|
-
*
|
|
8849
|
-
* 1. A real-dated release already exists -> `"exists"`.
|
|
8850
|
-
* 2. Otherwise create the release and read it back. On epoch, delete the frozen
|
|
8851
|
-
* release and wait (increasing backoff) for Forgejo to settle the tag's date,
|
|
8852
|
-
* then retry -- **preserving the pushed (annotated) tag**. The frozen release
|
|
8853
|
-
* is never deleted-and-left-empty.
|
|
8854
|
-
* 3. If the date still never settles and a `target` commit is known, delete the
|
|
8855
|
-
* tag and recreate the release with `target_commitish` so **Forgejo creates
|
|
8856
|
-
* the tag itself**, stamped `now`. The tag becomes lightweight. -> `"repaired"`.
|
|
8857
|
-
* 4. If nothing yields a real date, leave a visible release row (rather than only
|
|
8858
|
-
* an invisible tag) and throw loudly.
|
|
8859
|
-
*
|
|
8860
|
-
* Returns `{ status, release }` -- see {@link EnsureReleaseOutcome}.
|
|
8752
|
+
* Exported so `package-json.ts#reclaimManagedDevDeps` can gate the `tsdown`
|
|
8753
|
+
* devDependency reclaim on the same generated-template evidence this generator
|
|
8754
|
+
* uses for the config file itself -- the two must never disagree about what
|
|
8755
|
+
* "no longer used" means (see the block comment there).
|
|
8861
8756
|
*/
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8757
|
+
const TSDOWN_CONFIG = `import { defineConfig } from "tsdown";
|
|
8758
|
+
|
|
8759
|
+
export default defineConfig({
|
|
8760
|
+
entry: ["src/index.ts"],
|
|
8761
|
+
});
|
|
8762
|
+
`;
|
|
8763
|
+
async function generateTsdown(ctx) {
|
|
8764
|
+
const filePath = "tsdown.config.ts";
|
|
8765
|
+
if (ctx.config.structure === "monorepo") return {
|
|
8766
|
+
filePath,
|
|
8767
|
+
action: "skipped",
|
|
8768
|
+
description: "Monorepo: tsdown config belongs in individual packages"
|
|
8769
|
+
};
|
|
8770
|
+
if (ctx.config.builder !== "tsdown") {
|
|
8771
|
+
if (ctx.read(filePath) !== void 0 && isGeneratedTemplate(ctx, filePath, TSDOWN_CONFIG)) return reportStaleConfig(filePath, `builder is ${ctx.config.builder}`);
|
|
8868
8772
|
return {
|
|
8869
|
-
|
|
8870
|
-
|
|
8773
|
+
filePath,
|
|
8774
|
+
action: "skipped",
|
|
8775
|
+
description: `Builder is ${ctx.config.builder}; tsdown config not managed`
|
|
8871
8776
|
};
|
|
8872
8777
|
}
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
if (
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
prerelease: options.prerelease
|
|
8886
|
-
});
|
|
8887
|
-
} catch (error) {
|
|
8888
|
-
const raced = await getReleaseByTag(executor, conn, tag);
|
|
8889
|
-
if (raced && !isEpochReleaseDate(raced.created_at)) return {
|
|
8890
|
-
status: "race",
|
|
8891
|
-
release: releaseRef(raced)
|
|
8892
|
-
};
|
|
8893
|
-
current = raced;
|
|
8894
|
-
if (attempt >= attempts) break;
|
|
8895
|
-
log.warn(`Release creation attempt ${String(attempt)}/${String(attempts)} failed: ${describeError(error)}`);
|
|
8896
|
-
await settleBackoff(attempt, baseDelayMs);
|
|
8897
|
-
continue;
|
|
8898
|
-
}
|
|
8899
|
-
current = await getReleaseByTag(executor, conn, tag);
|
|
8900
|
-
if (current && !isEpochReleaseDate(current.created_at)) return {
|
|
8901
|
-
status: repairing ? "repaired" : "created",
|
|
8902
|
-
release: releaseRef(current)
|
|
8778
|
+
const existing = ctx.read(filePath);
|
|
8779
|
+
const config = formattedTemplate(ctx, filePath, TSDOWN_CONFIG);
|
|
8780
|
+
if (existing) {
|
|
8781
|
+
if (existing === config) return {
|
|
8782
|
+
filePath,
|
|
8783
|
+
action: "skipped",
|
|
8784
|
+
description: "Already configured"
|
|
8785
|
+
};
|
|
8786
|
+
if (await ctx.confirmOverwrite(filePath) === "skip") return {
|
|
8787
|
+
filePath,
|
|
8788
|
+
action: "skipped",
|
|
8789
|
+
description: "Existing tsdown config preserved"
|
|
8903
8790
|
};
|
|
8904
|
-
if (attempt < attempts) log.warn(`Release for ${tag} came back epoch-dated (attempt ${String(attempt)}/${String(attempts)}) -- waiting for Forgejo to settle the tag's date before retrying`);
|
|
8905
|
-
}
|
|
8906
|
-
const recreated = options.target ? await recreateTagViaApi(executor, conn, tag, {
|
|
8907
|
-
target: options.target,
|
|
8908
|
-
prerelease: options.prerelease,
|
|
8909
|
-
current,
|
|
8910
|
-
attempts,
|
|
8911
|
-
baseDelayMs
|
|
8912
|
-
}) : null;
|
|
8913
|
-
if (recreated) return {
|
|
8914
|
-
status: "repaired",
|
|
8915
|
-
release: releaseRef(recreated)
|
|
8916
|
-
};
|
|
8917
|
-
throw new TransientError(`Release for ${tag} never got a real (non-epoch) created date after ${String(attempts)} attempts. This is an intermittent Forgejo bug: a pushed tag's date settles asynchronously and a racing release creation freezes it at the Unix epoch (1969/1970). ` + (options.target ? `Recreating the tag via the API did not help -- investigate the server or upgrade Forgejo.` : `Pass the release tag's target commit so the tag can be recreated, or retry the release.`));
|
|
8918
|
-
}
|
|
8919
|
-
async function recreateTagViaApi(executor, conn, tag, opts) {
|
|
8920
|
-
const { target, prerelease, current, attempts, baseDelayMs } = opts;
|
|
8921
|
-
log.warn(`Release for ${tag} stayed epoch-dated -- recreating the tag via the API so Forgejo stamps a real date (the tag becomes lightweight)`);
|
|
8922
|
-
if (current) await deleteRelease(executor, conn, current.id);
|
|
8923
|
-
await deleteTag(executor, conn, tag);
|
|
8924
|
-
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
8925
|
-
try {
|
|
8926
|
-
await createRelease(executor, conn, tag, {
|
|
8927
|
-
target,
|
|
8928
|
-
prerelease
|
|
8929
|
-
});
|
|
8930
|
-
} catch (error) {
|
|
8931
|
-
if (attempt >= attempts) throw new TransientError(`Release for ${tag} was deleted to repair its epoch date, but recreating it from ${target} failed: ${describeError(error)}. The tag no longer exists on the server -- re-push it (or re-run the release) to restore it.`);
|
|
8932
|
-
await settleBackoff(attempt, baseDelayMs);
|
|
8933
|
-
continue;
|
|
8934
|
-
}
|
|
8935
|
-
const recreated = await getReleaseByTag(executor, conn, tag);
|
|
8936
|
-
if (recreated && !isEpochReleaseDate(recreated.created_at)) return recreated;
|
|
8937
|
-
return null;
|
|
8938
8791
|
}
|
|
8939
|
-
|
|
8940
|
-
}
|
|
8941
|
-
/**
|
|
8942
|
-
* The `{ id, url }` projection of a release row carried back to a caller. A server that omits
|
|
8943
|
-
* `html_url` yields `url: null` -- the id is still the row's identity.
|
|
8944
|
-
*/
|
|
8945
|
-
function releaseRef(release) {
|
|
8792
|
+
ctx.write(filePath, config);
|
|
8946
8793
|
return {
|
|
8947
|
-
|
|
8948
|
-
|
|
8794
|
+
filePath,
|
|
8795
|
+
action: existing ? "updated" : "created",
|
|
8796
|
+
description: "Generated tsdown.config.ts"
|
|
8949
8797
|
};
|
|
8950
8798
|
}
|
|
8951
|
-
/** Exponential backoff (capped) between patient-repair attempts; a non-positive base is a no-op. */
|
|
8952
|
-
async function settleBackoff(attempt, baseDelayMs) {
|
|
8953
|
-
if (baseDelayMs <= 0) return;
|
|
8954
|
-
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), SETTLE_MAX_DELAY_MS);
|
|
8955
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
8956
|
-
}
|
|
8957
|
-
/** Upload an asset file to a Forgejo release. */
|
|
8958
|
-
async function uploadReleaseAsset(executor, conn, tag, options) {
|
|
8959
|
-
const encodedTag = encodeURIComponent(tag);
|
|
8960
|
-
const releaseUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
|
|
8961
|
-
const releaseRes = await executor.fetch(releaseUrl, { headers: { Authorization: `token ${conn.token}` } });
|
|
8962
|
-
if (!releaseRes.ok) throw new TransientError(await forgejoErrorMessage(releaseRes, {
|
|
8963
|
-
verb: "get release",
|
|
8964
|
-
resource: tag
|
|
8965
|
-
}));
|
|
8966
|
-
const parsed = z.object({ id: z.number() }).safeParse(await releaseRes.json());
|
|
8967
|
-
if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
|
|
8968
|
-
const releaseId = parsed.data.id;
|
|
8969
|
-
const uploadUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}/assets?name=${encodeURIComponent(options.assetName)}`;
|
|
8970
|
-
const form = new FormData();
|
|
8971
|
-
form.append("attachment", new Blob([options.fileBuffer]), options.assetName);
|
|
8972
|
-
const res = await executor.fetch(uploadUrl, {
|
|
8973
|
-
method: "POST",
|
|
8974
|
-
headers: { Authorization: `token ${conn.token}` },
|
|
8975
|
-
body: form
|
|
8976
|
-
}, { timeoutMs: LONG_FETCH_TIMEOUT_MS });
|
|
8977
|
-
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
8978
|
-
verb: "upload asset",
|
|
8979
|
-
resource: `${options.assetName} to ${tag}`
|
|
8980
|
-
}));
|
|
8981
|
-
}
|
|
8982
8799
|
//#endregion
|
|
8983
|
-
//#region src/
|
|
8984
|
-
const FOREIGN_VERSION_FILES = [{
|
|
8985
|
-
file: "Cargo.toml",
|
|
8986
|
-
sections: ["package"]
|
|
8987
|
-
}, {
|
|
8988
|
-
file: "pyproject.toml",
|
|
8989
|
-
sections: ["project", "tool.poetry"]
|
|
8990
|
-
}];
|
|
8991
|
-
/** A static `version = "..."` assignment. `version.workspace = true` is deliberately not a match. */
|
|
8992
|
-
const STATIC_VERSION = /^\s*version\s*=\s*"[^"]*"/;
|
|
8800
|
+
//#region src/generators/mise.ts
|
|
8993
8801
|
/**
|
|
8994
|
-
*
|
|
8995
|
-
*
|
|
8996
|
-
*
|
|
8997
|
-
*
|
|
8998
|
-
*
|
|
8999
|
-
*
|
|
8802
|
+
* `mise.toml` owns exactly two keys, `tools.node` and `tools.pnpm`, and carries every other byte
|
|
8803
|
+
* -- other tools, `[env]`, `[tasks.*]`, comments -- through untouched. `smol-toml`'s `parse` is
|
|
8804
|
+
* used only to validate the file and to inspect the owned keys' shapes; it preserves no layout, so
|
|
8805
|
+
* every mutation below is a textual edit against the raw source rather than a reserialization.
|
|
8806
|
+
* `generators/package-json.ts` documents the same "manage the keys, not the file" posture for
|
|
8807
|
+
* `devEngines.runtime`.
|
|
9000
8808
|
*/
|
|
9001
|
-
|
|
9002
|
-
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
8809
|
+
const TOOLS_HEADER_RE = /^\[tools\]\s*$/m;
|
|
8810
|
+
/** Any top-level table header (`[x]` or `[x.y]`) or array-of-tables header (`[[x]]`), used to find
|
|
8811
|
+
* where `[tools]`'s own range ends -- an array-of-tables entry is a fresh table just as much as a
|
|
8812
|
+
* `[x]` header is, so it must terminate the body range too, or an owned line appended past it
|
|
8813
|
+
* lands inside that entry instead of `[tools]`. */
|
|
8814
|
+
const TOP_LEVEL_HEADER_RE = /^\[{1,2}[^[].*\]{1,2}\s*$/m;
|
|
8815
|
+
const PNPM_LINE_RE = /^pnpm\s*=.*$/m;
|
|
8816
|
+
/** Tolerant shape of the file's `[tools]` table: string or table form for every key, so a
|
|
8817
|
+
* table-form `node`/`pnpm` is caught rather than mistaken for absent. */
|
|
8818
|
+
const ToolsTableSchema = z.record(z.string(), z.union([z.string(), z.record(z.string(), z.unknown())]));
|
|
8819
|
+
/** Tolerant shape of the whole parsed document -- only `tools` is read. */
|
|
8820
|
+
const ParsedTomlSchema = z.looseObject({ tools: z.unknown().optional() });
|
|
8821
|
+
/** The `[tools]` table's body range: right after its header line to the next top-level header or
|
|
8822
|
+
* EOF. `undefined` when there is no `[tools]` table at all. */
|
|
8823
|
+
function toolsRange(source) {
|
|
8824
|
+
const headerMatch = TOOLS_HEADER_RE.exec(source);
|
|
8825
|
+
if (!headerMatch) return void 0;
|
|
8826
|
+
const start = headerMatch.index + headerMatch[0].length;
|
|
8827
|
+
const rest = source.slice(start);
|
|
8828
|
+
const nextHeader = TOP_LEVEL_HEADER_RE.exec(rest);
|
|
8829
|
+
return {
|
|
8830
|
+
start,
|
|
8831
|
+
end: nextHeader ? start + nextHeader.index : source.length
|
|
8832
|
+
};
|
|
9014
8833
|
}
|
|
9015
|
-
/**
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
*
|
|
9019
|
-
* On a `stack: none` repo this is proof that `package.json` is only the bst
|
|
9020
|
-
* harness and the real version lives elsewhere -- the trigger for `release:simple`
|
|
9021
|
-
* to refuse rather than silently bump the wrong file.
|
|
9022
|
-
*/
|
|
9023
|
-
function detectForeignVersionOfRecord(targetDir) {
|
|
9024
|
-
for (const { file, sections } of FOREIGN_VERSION_FILES) {
|
|
9025
|
-
const full = path.join(targetDir, file);
|
|
9026
|
-
if (!existsSync(full)) continue;
|
|
9027
|
-
let content;
|
|
9028
|
-
try {
|
|
9029
|
-
content = readFileSync(full, "utf-8");
|
|
9030
|
-
} catch (_error) {
|
|
9031
|
-
continue;
|
|
9032
|
-
}
|
|
9033
|
-
if (sectionHasStaticVersion(content, sections)) return file;
|
|
9034
|
-
}
|
|
8834
|
+
/** Strip the version out of `packageManager` (`pnpm@11.27.0` -> `11.27.0`). */
|
|
8835
|
+
function pnpmVersionFromPackageManager(packageManager) {
|
|
8836
|
+
return /^pnpm@(\S+)$/.exec(packageManager ?? "")?.[1];
|
|
9035
8837
|
}
|
|
9036
|
-
/**
|
|
9037
|
-
*
|
|
9038
|
-
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
|
|
9045
|
-
|
|
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;
|
|
9046
8857
|
}
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
function
|
|
9050
|
-
const
|
|
8858
|
+
/** The two owned lines, in order, for a file created from scratch (or a `[tools]` table added to
|
|
8859
|
+
* one that had none). */
|
|
8860
|
+
function ownedLines(pnpmVersion, nodePin) {
|
|
8861
|
+
const lines = [`[tools]`, `node = "${nodePin}"`];
|
|
8862
|
+
if (pnpmVersion) lines.push(`pnpm = "${pnpmVersion}"`);
|
|
8863
|
+
return lines;
|
|
8864
|
+
}
|
|
8865
|
+
/** Append a `key = "value"` line to a `[tools]` body, respecting whatever trailing newline it
|
|
8866
|
+
* already has. */
|
|
8867
|
+
function appendLine(body, line) {
|
|
8868
|
+
return (body.endsWith("\n") || body.length === 0 ? body : body + "\n") + line + "\n";
|
|
8869
|
+
}
|
|
8870
|
+
/** `action: "failed"` naming an owned key present in table form. */
|
|
8871
|
+
function tableFormFailure(filePath, key) {
|
|
9051
8872
|
return {
|
|
9052
|
-
|
|
9053
|
-
|
|
8873
|
+
filePath,
|
|
8874
|
+
action: "failed",
|
|
8875
|
+
description: `mise.toml: tools.${key} is a table; repo:sync owns it as a plain string pin`
|
|
9054
8876
|
};
|
|
9055
8877
|
}
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
8878
|
+
/** `true` when `pinned` provably resolves below `enginesNode`'s floor. Only two pin shapes are
|
|
8879
|
+
* compared: an exact `X.Y.Z` against the whole floor, and a bare major (`"24"`, what `mise use
|
|
8880
|
+
* node@24` writes) against the floor's major alone. Anything else -- `"24.12"`, `"lts"`, a prefix
|
|
8881
|
+
* mise resolves -- is left for mise to resolve, because the toolchain gate in
|
|
8882
|
+
* `commands/sync-gates.ts` treats such pins as legitimate and checks the version mise resolves
|
|
8883
|
+
* them to. Reading `"24"` as `24.0.0` would refuse a pin that resolves to the newest 24.x. An
|
|
8884
|
+
* unparseable `enginesNode` compares nothing. */
|
|
8885
|
+
function isBelowFloor(pinned, enginesNode) {
|
|
8886
|
+
const floor = minVersionTuple(enginesNode);
|
|
8887
|
+
if (floor === void 0) return false;
|
|
8888
|
+
const exact = /^(\d+)\.(\d+)\.(\d+)$/.exec(pinned);
|
|
8889
|
+
if (exact) return compareTuples([
|
|
8890
|
+
Number(exact[1]),
|
|
8891
|
+
Number(exact[2]),
|
|
8892
|
+
Number(exact[3])
|
|
8893
|
+
], floor) < 0;
|
|
8894
|
+
const major = /^(\d+)$/.exec(pinned);
|
|
8895
|
+
if (major) return Number(major[1]) < floor[0];
|
|
8896
|
+
return false;
|
|
9061
8897
|
}
|
|
9062
|
-
/**
|
|
9063
|
-
|
|
9064
|
-
|
|
8898
|
+
/** Resolve the two facts `mise.toml` needs from `package.json`: the engines floor to validate an
|
|
8899
|
+
* existing `tools.node` pin against, and the pnpm version to keep `tools.pnpm` in step with. */
|
|
8900
|
+
function readPackageJsonFacts(ctx) {
|
|
8901
|
+
const pkgRaw = ctx.read("package.json");
|
|
8902
|
+
const pkg = pkgRaw ? parsePackageJson(pkgRaw) : void 0;
|
|
8903
|
+
return {
|
|
8904
|
+
enginesNode: pkg?.engines?.["node"],
|
|
8905
|
+
devEngines: pkg?.devEngines,
|
|
8906
|
+
pnpmVersion: pnpmVersionFromPackageManager(pkg?.packageManager)
|
|
8907
|
+
};
|
|
9065
8908
|
}
|
|
9066
|
-
/**
|
|
9067
|
-
*
|
|
9068
|
-
*
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
8909
|
+
/** Edit an existing `[tools]` body in place: add `node` only when absent, rewrite `pnpm` only on
|
|
8910
|
+
* drift. Returns the new body and the change descriptions, or `undefined` for the body when
|
|
8911
|
+
* nothing changed. */
|
|
8912
|
+
function editToolsBody(body, existingNode, existingPnpm, pnpmVersion, nodePin) {
|
|
8913
|
+
let newBody = body;
|
|
8914
|
+
const changes = [];
|
|
8915
|
+
if (existingNode === void 0) {
|
|
8916
|
+
newBody = appendLine(newBody, `node = "${nodePin}"`);
|
|
8917
|
+
changes.push(`added tools.node = "${nodePin}"`);
|
|
8918
|
+
}
|
|
8919
|
+
if (pnpmVersion && existingPnpm !== pnpmVersion) {
|
|
8920
|
+
if (existingPnpm === void 0) {
|
|
8921
|
+
newBody = appendLine(newBody, `pnpm = "${pnpmVersion}"`);
|
|
8922
|
+
changes.push(`added tools.pnpm = "${pnpmVersion}"`);
|
|
8923
|
+
} else {
|
|
8924
|
+
newBody = newBody.replace(PNPM_LINE_RE, (line) => {
|
|
8925
|
+
return `pnpm = "${pnpmVersion}"${/(\s*#.*)$/.exec(line)?.[1] ?? ""}`;
|
|
8926
|
+
});
|
|
8927
|
+
changes.push(`updated tools.pnpm ${existingPnpm} -> ${pnpmVersion}`);
|
|
8928
|
+
}
|
|
8929
|
+
}
|
|
8930
|
+
return {
|
|
8931
|
+
body: newBody,
|
|
8932
|
+
changes
|
|
8933
|
+
};
|
|
9082
8934
|
}
|
|
9083
8935
|
/**
|
|
9084
|
-
* `
|
|
9085
|
-
*
|
|
9086
|
-
*
|
|
9087
|
-
* `release:assets` stamp phase and the `repo:sync` static gate report through this one
|
|
9088
|
-
* function so the two messages cannot drift apart.
|
|
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.
|
|
9089
8939
|
*/
|
|
9090
|
-
function
|
|
9091
|
-
const
|
|
9092
|
-
|
|
9093
|
-
const
|
|
9094
|
-
const
|
|
9095
|
-
if (
|
|
9096
|
-
|
|
8940
|
+
async function generateMise(ctx, priorDevEngines) {
|
|
8941
|
+
const filePath = "mise.toml";
|
|
8942
|
+
const { enginesNode, devEngines, pnpmVersion } = readPackageJsonFacts(ctx);
|
|
8943
|
+
const nodePin = nodePinFor(enginesNode, devEnginesNodeVersion(priorDevEngines ?? devEngines));
|
|
8944
|
+
const existing = ctx.read(filePath);
|
|
8945
|
+
if (!existing) {
|
|
8946
|
+
ctx.write(filePath, ownedLines(pnpmVersion, nodePin).join("\n") + "\n");
|
|
8947
|
+
return {
|
|
8948
|
+
filePath,
|
|
8949
|
+
action: "created",
|
|
8950
|
+
description: "Generated mise.toml"
|
|
8951
|
+
};
|
|
8952
|
+
}
|
|
8953
|
+
let parsed;
|
|
8954
|
+
try {
|
|
8955
|
+
parsed = parse$2(existing);
|
|
8956
|
+
} catch (error) {
|
|
8957
|
+
return {
|
|
8958
|
+
filePath,
|
|
8959
|
+
action: "failed",
|
|
8960
|
+
description: `Could not parse existing mise.toml -- ${error instanceof Error ? error.message : String(error)}`
|
|
8961
|
+
};
|
|
8962
|
+
}
|
|
8963
|
+
const document = ParsedTomlSchema.safeParse(parsed);
|
|
8964
|
+
const parsedTools = document.success ? document.data.tools ?? {} : {};
|
|
8965
|
+
const toolsResult = ToolsTableSchema.safeParse(parsedTools);
|
|
8966
|
+
const toolsTable = toolsResult.success ? toolsResult.data : {};
|
|
8967
|
+
if (typeof toolsTable["node"] === "object") return tableFormFailure(filePath, "node");
|
|
8968
|
+
if (typeof toolsTable["pnpm"] === "object") return tableFormFailure(filePath, "pnpm");
|
|
8969
|
+
const existingNode = toolsTable["node"];
|
|
8970
|
+
const existingPnpm = toolsTable["pnpm"];
|
|
8971
|
+
if (existingNode !== void 0 && enginesNode !== void 0 && isBelowFloor(existingNode, enginesNode)) return {
|
|
8972
|
+
filePath,
|
|
8973
|
+
action: "failed",
|
|
8974
|
+
description: `mise.toml: tools.node "${existingNode}" is below engines.node "${enginesNode}"`
|
|
8975
|
+
};
|
|
8976
|
+
const range = toolsRange(existing);
|
|
8977
|
+
let text;
|
|
8978
|
+
let changes;
|
|
8979
|
+
if (range === void 0) {
|
|
8980
|
+
if (Object.keys(toolsTable).length > 0) return {
|
|
8981
|
+
filePath,
|
|
8982
|
+
action: "failed",
|
|
8983
|
+
description: `mise.toml: tools.* is declared with dotted keys; repo:sync only manages a [tools] table`
|
|
8984
|
+
};
|
|
8985
|
+
text = ownedLines(pnpmVersion, nodePin).join("\n") + "\n\n" + existing;
|
|
8986
|
+
changes = ["added [tools] table"];
|
|
8987
|
+
} else {
|
|
8988
|
+
const body = existing.slice(range.start, range.end);
|
|
8989
|
+
const edited = editToolsBody(body, existingNode, existingPnpm, pnpmVersion, nodePin);
|
|
8990
|
+
changes = edited.changes;
|
|
8991
|
+
text = edited.body === body ? existing : existing.slice(0, range.start) + edited.body + existing.slice(range.end);
|
|
8992
|
+
}
|
|
8993
|
+
if (changes.length === 0) return {
|
|
8994
|
+
filePath,
|
|
8995
|
+
action: "skipped",
|
|
8996
|
+
description: "Already up to spec"
|
|
8997
|
+
};
|
|
8998
|
+
ctx.write(filePath, text);
|
|
8999
|
+
return {
|
|
9000
|
+
filePath,
|
|
9001
|
+
action: "updated",
|
|
9002
|
+
description: changes.join(", ")
|
|
9003
|
+
};
|
|
9097
9004
|
}
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
const split = splitLines(content);
|
|
9107
|
-
const [index] = matchingLineIndexes(content, entry);
|
|
9108
|
-
const hasCr = split.lines[index].endsWith("\r");
|
|
9109
|
-
const replacement = expectedStampLine(entry, version);
|
|
9110
|
-
split.lines[index] = hasCr ? `${replacement}\r` : replacement;
|
|
9111
|
-
return joinLines(split);
|
|
9005
|
+
let current = {
|
|
9006
|
+
warn(message) {
|
|
9007
|
+
process.stderr.write(`${message}\n`);
|
|
9008
|
+
},
|
|
9009
|
+
debug() {}
|
|
9010
|
+
};
|
|
9011
|
+
function setForgejoLogger(logger) {
|
|
9012
|
+
current = logger;
|
|
9112
9013
|
}
|
|
9014
|
+
/** Stable call-site surface: modules bind `log` once; injection swaps the target underneath. */
|
|
9015
|
+
const log = {
|
|
9016
|
+
warn(message) {
|
|
9017
|
+
current.warn(message);
|
|
9018
|
+
},
|
|
9019
|
+
debug(message) {
|
|
9020
|
+
current.debug(message);
|
|
9021
|
+
}
|
|
9022
|
+
};
|
|
9113
9023
|
//#endregion
|
|
9114
|
-
//#region src/
|
|
9115
|
-
|
|
9116
|
-
const
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
})
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
9129
|
-
|
|
9130
|
-
|
|
9131
|
-
|
|
9132
|
-
if (!raw) return;
|
|
9133
|
-
if (!parsePackageJson(raw)?.scripts?.[CONVENTION_SCRIPT]) return;
|
|
9134
|
-
targets.push({
|
|
9135
|
-
commandCwd: absDir,
|
|
9136
|
-
command: [
|
|
9137
|
-
"pnpm",
|
|
9138
|
-
"run",
|
|
9139
|
-
CONVENTION_SCRIPT
|
|
9140
|
-
],
|
|
9141
|
-
files: [],
|
|
9142
|
-
collectFromDir: path.join(absDir, CONVENTION_OUTPUT_DIR)
|
|
9143
|
-
});
|
|
9144
|
-
};
|
|
9145
|
-
checkDir(".");
|
|
9146
|
-
for (const dir of executor.listPackageDirs(cwd)) checkDir(path.join("packages", dir));
|
|
9147
|
-
return targets;
|
|
9024
|
+
//#region ../forgejo/src/releases.ts
|
|
9025
|
+
/** Patient-repair attempts (delete frozen release, wait, retry) before escalating. */
|
|
9026
|
+
const SETTLE_ATTEMPTS = 4;
|
|
9027
|
+
/** Base backoff between patient-repair attempts; doubles each attempt. */
|
|
9028
|
+
const SETTLE_BASE_DELAY_MS = 2e3;
|
|
9029
|
+
/** Cap on a single patient-repair backoff. */
|
|
9030
|
+
const SETTLE_MAX_DELAY_MS = 16e3;
|
|
9031
|
+
/** Check whether a Forgejo release already exists for a given tag. */
|
|
9032
|
+
async function findRelease(executor, conn, tag) {
|
|
9033
|
+
const encodedTag = encodeURIComponent(tag);
|
|
9034
|
+
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
|
|
9035
|
+
const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
|
|
9036
|
+
if (res.status === 200) return true;
|
|
9037
|
+
if (res.status === 404) return false;
|
|
9038
|
+
throw new TransientError(await forgejoErrorMessage(res, {
|
|
9039
|
+
verb: "check release",
|
|
9040
|
+
resource: tag
|
|
9041
|
+
}));
|
|
9148
9042
|
}
|
|
9043
|
+
const ReleaseSchema = z.object({
|
|
9044
|
+
id: z.number(),
|
|
9045
|
+
tag_name: z.string().optional(),
|
|
9046
|
+
created_at: z.string().optional(),
|
|
9047
|
+
published_at: z.string().optional(),
|
|
9048
|
+
html_url: z.string().nullish(),
|
|
9049
|
+
prerelease: z.boolean().optional()
|
|
9050
|
+
});
|
|
9149
9051
|
/**
|
|
9150
|
-
*
|
|
9151
|
-
*
|
|
9152
|
-
*
|
|
9153
|
-
*
|
|
9052
|
+
* Detect the signature of a broken Forgejo release: a `created_at` that maps to
|
|
9053
|
+
* the Unix epoch (`created_unix = 0`).
|
|
9054
|
+
*
|
|
9055
|
+
* Forgejo auto-creates a "tag placeholder" release row when a tag is pushed.
|
|
9056
|
+
* If our `POST /releases` races that placeholder, Forgejo promotes the
|
|
9057
|
+
* placeholder in place (flipping `is_tag` to false) **without** resetting
|
|
9058
|
+
* `created_unix`, leaving a published release stuck at the epoch. Such a
|
|
9059
|
+
* release sorts as the oldest, so `/releases/latest` never returns it.
|
|
9060
|
+
*
|
|
9061
|
+
* The epoch renders as `1969-12-31T18:00:00-06:00` or `1970-01-01T00:00:00Z`
|
|
9062
|
+
* depending on the server timezone; both parse to `<= 0` ms. A missing or
|
|
9063
|
+
* unparseable date is treated as broken too, so the caller repairs rather than
|
|
9064
|
+
* ships it.
|
|
9154
9065
|
*/
|
|
9155
|
-
function
|
|
9156
|
-
|
|
9157
|
-
const
|
|
9158
|
-
|
|
9159
|
-
const pkg = parsePackageJson(raw);
|
|
9160
|
-
if (!pkg?.version) throw new FatalError(`release:assets could not find a "version" field in package.json at ${pkgPath}. versionStamp needs a version to stamp.`);
|
|
9161
|
-
return pkg.version;
|
|
9066
|
+
function isEpochReleaseDate(date) {
|
|
9067
|
+
if (!date) return true;
|
|
9068
|
+
const ms = new Date(date).getTime();
|
|
9069
|
+
return Number.isNaN(ms) || ms <= 0;
|
|
9162
9070
|
}
|
|
9163
9071
|
/**
|
|
9164
|
-
*
|
|
9165
|
-
*
|
|
9166
|
-
*
|
|
9072
|
+
* Fetch the Forgejo release for a tag, returning the parsed object or `null`
|
|
9073
|
+
* when no release exists (404). Unlike {@link findRelease}, this exposes the
|
|
9074
|
+
* release `id` and dates so callers can inspect/repair an epoch-dated release.
|
|
9167
9075
|
*/
|
|
9168
|
-
function
|
|
9169
|
-
const
|
|
9170
|
-
const
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
stamped.push({
|
|
9181
|
-
path: filePath,
|
|
9182
|
-
expectedLine
|
|
9183
|
-
});
|
|
9184
|
-
}
|
|
9185
|
-
return stamped;
|
|
9076
|
+
async function getReleaseByTag(executor, conn, tag) {
|
|
9077
|
+
const encodedTag = encodeURIComponent(tag);
|
|
9078
|
+
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
|
|
9079
|
+
const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
|
|
9080
|
+
if (res.status === 404) return null;
|
|
9081
|
+
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
9082
|
+
verb: "check release",
|
|
9083
|
+
resource: tag
|
|
9084
|
+
}));
|
|
9085
|
+
const parsed = ReleaseSchema.safeParse(await res.json());
|
|
9086
|
+
if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
|
|
9087
|
+
return parsed.data;
|
|
9186
9088
|
}
|
|
9187
9089
|
/**
|
|
9188
|
-
*
|
|
9189
|
-
*
|
|
9190
|
-
*
|
|
9191
|
-
*
|
|
9090
|
+
* Create a Forgejo release for a given tag.
|
|
9091
|
+
*
|
|
9092
|
+
* When `options.target` (a commit SHA or branch) is provided and the tag does
|
|
9093
|
+
* not yet exist, Forgejo **creates the tag itself** from that commitish and
|
|
9094
|
+
* stamps `created_unix = now` synchronously -- sidestepping the buggy async
|
|
9095
|
+
* stamping of a pushed tag. The created tag is lightweight.
|
|
9192
9096
|
*/
|
|
9193
|
-
function
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9097
|
+
async function createRelease(executor, conn, tag, options = {}) {
|
|
9098
|
+
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
|
|
9099
|
+
const payload = {
|
|
9100
|
+
tag_name: tag,
|
|
9101
|
+
name: tag,
|
|
9102
|
+
body: `Published ${tag}`,
|
|
9103
|
+
prerelease: options.prerelease ?? false
|
|
9104
|
+
};
|
|
9105
|
+
if (options.target) payload["target_commitish"] = options.target;
|
|
9106
|
+
const res = await executor.fetch(url, {
|
|
9107
|
+
method: "POST",
|
|
9108
|
+
headers: {
|
|
9109
|
+
Authorization: `token ${conn.token}`,
|
|
9110
|
+
"Content-Type": "application/json"
|
|
9111
|
+
},
|
|
9112
|
+
body: JSON.stringify(payload)
|
|
9113
|
+
});
|
|
9114
|
+
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
9115
|
+
verb: "create release",
|
|
9116
|
+
resource: tag
|
|
9117
|
+
}));
|
|
9198
9118
|
}
|
|
9199
9119
|
/**
|
|
9200
|
-
*
|
|
9201
|
-
*
|
|
9202
|
-
*
|
|
9203
|
-
*
|
|
9120
|
+
* Delete a git tag from the repository (`DELETE /repos/{repo}/tags/{tag}`).
|
|
9121
|
+
*
|
|
9122
|
+
* Used by {@link ensureRelease} to take tag ownership away from the buggy
|
|
9123
|
+
* push-ingestion path: once the tag is gone, recreating the release with a
|
|
9124
|
+
* `target_commitish` makes Forgejo create the tag fresh with a real date. A 404
|
|
9125
|
+
* (already gone) is treated as success.
|
|
9204
9126
|
*/
|
|
9205
|
-
function
|
|
9206
|
-
|
|
9207
|
-
const
|
|
9208
|
-
|
|
9209
|
-
|
|
9210
|
-
|
|
9211
|
-
}
|
|
9212
|
-
function runBuild(executor, target, config) {
|
|
9213
|
-
if (!target.command || target.command.length === 0) return;
|
|
9214
|
-
const [file, ...args] = target.command;
|
|
9215
|
-
if (!file) return;
|
|
9216
|
-
const label = target.commandCwd === config.cwd ? "root" : path.relative(config.cwd, target.commandCwd);
|
|
9217
|
-
log$1.info(`Building assets in ${label}: ${target.command.join(" ")}`);
|
|
9218
|
-
executor.execInherit(file, args, { cwd: target.commandCwd });
|
|
9219
|
-
debug(config, `Asset build completed for ${label}`);
|
|
9220
|
-
}
|
|
9221
|
-
function collectFromDir(executor, target) {
|
|
9222
|
-
if (!target.collectFromDir) return;
|
|
9223
|
-
const before = target.files.length;
|
|
9224
|
-
for (const entry of executor.listFiles(target.collectFromDir)) target.files.push({ path: path.join(target.collectFromDir, entry) });
|
|
9225
|
-
if (target.files.length === before) throw new FatalError(`${CONVENTION_SCRIPT} ran in ${target.commandCwd} but emitted no files to ${target.collectFromDir}. Update your script to write artifacts there, or declare assets explicitly via releaseAssets in .tooling.json.`);
|
|
9226
|
-
}
|
|
9227
|
-
function uploadToGitHub(executor, config, tag, asset) {
|
|
9228
|
-
const assetName = asset.name ?? path.basename(asset.path);
|
|
9229
|
-
const result = executor.exec("gh", [
|
|
9230
|
-
"release",
|
|
9231
|
-
"upload",
|
|
9232
|
-
tag,
|
|
9233
|
-
asset.path,
|
|
9234
|
-
"--clobber"
|
|
9235
|
-
], { cwd: config.cwd });
|
|
9236
|
-
debugExec(config, `gh release upload ${tag} ${assetName}`, result);
|
|
9237
|
-
if (result.exitCode !== 0) throw new FatalError(`GitHub release upload failed for ${assetName}: ${result.stderr || result.stdout}`);
|
|
9238
|
-
log$1.info(`Uploaded ${assetName} to ${tag} (GitHub)`);
|
|
9239
|
-
}
|
|
9240
|
-
async function uploadToForgejo(executor, conn, tag, asset) {
|
|
9241
|
-
const assetName = asset.name ?? path.basename(asset.path);
|
|
9242
|
-
const fileBuffer = executor.readFileBuffer(asset.path);
|
|
9243
|
-
if (!fileBuffer) throw new FatalError(`Asset file not found: ${asset.path}`);
|
|
9244
|
-
await uploadReleaseAsset(executor, conn, tag, {
|
|
9245
|
-
assetName,
|
|
9246
|
-
fileBuffer
|
|
9127
|
+
async function deleteTag(executor, conn, tag) {
|
|
9128
|
+
const encodedTag = encodeURIComponent(tag);
|
|
9129
|
+
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
|
|
9130
|
+
const res = await executor.fetch(url, {
|
|
9131
|
+
method: "DELETE",
|
|
9132
|
+
headers: { Authorization: `token ${conn.token}` }
|
|
9247
9133
|
});
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
9256
|
-
|
|
9257
|
-
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
|
|
9266
|
-
|
|
9267
|
-
|
|
9268
|
-
|
|
9269
|
-
|
|
9270
|
-
|
|
9271
|
-
"
|
|
9272
|
-
|
|
9273
|
-
|
|
9274
|
-
if (tagResult.exitCode !== 0) throw new FatalError("Could not determine current tags");
|
|
9275
|
-
const tags = tagResult.stdout.trim().split("\n").filter(Boolean);
|
|
9276
|
-
if (tags.length === 0) {
|
|
9277
|
-
log$1.info("No tags pointing to HEAD, skipping upload");
|
|
9278
|
-
return;
|
|
9279
|
-
}
|
|
9280
|
-
let uploaded = 0;
|
|
9281
|
-
for (const tag of tags) {
|
|
9282
|
-
if (!await releaseExists(executor, config, tag)) {
|
|
9283
|
-
log$1.info(`No release found for ${tag}, skipping (likely a sliding tag)`);
|
|
9284
|
-
continue;
|
|
9285
|
-
}
|
|
9286
|
-
log$1.info(`Uploading assets to release ${tag}`);
|
|
9287
|
-
for (const target of targets) for (const asset of target.files) if (config.platform.type === "github") uploadToGitHub(executor, config, tag, asset);
|
|
9288
|
-
else await uploadToForgejo(executor, config.platform.conn, tag, asset);
|
|
9289
|
-
uploaded++;
|
|
9290
|
-
}
|
|
9291
|
-
if (uploaded === 0) log$1.info("No releases found for any tag pointing to HEAD, skipping upload");
|
|
9292
|
-
}
|
|
9293
|
-
async function releaseExists(executor, config, tag) {
|
|
9294
|
-
if (!config.platform) return false;
|
|
9295
|
-
if (config.platform.type === "forgejo") return findRelease(executor, config.platform.conn, tag);
|
|
9296
|
-
const result = executor.exec("gh", [
|
|
9297
|
-
"release",
|
|
9298
|
-
"view",
|
|
9299
|
-
tag
|
|
9300
|
-
], { cwd: config.cwd });
|
|
9301
|
-
debugExec(config, `gh release view ${tag}`, result);
|
|
9302
|
-
return result.exitCode === 0;
|
|
9134
|
+
if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
|
|
9135
|
+
verb: "delete tag",
|
|
9136
|
+
resource: tag
|
|
9137
|
+
}));
|
|
9138
|
+
}
|
|
9139
|
+
z.looseObject({
|
|
9140
|
+
name: z.string(),
|
|
9141
|
+
id: z.string(),
|
|
9142
|
+
commit: z.object({ sha: z.string().regex(/^[0-9a-f]{40}$/) })
|
|
9143
|
+
});
|
|
9144
|
+
/**
|
|
9145
|
+
* Delete a Forgejo release by id. Does **not** delete the underlying git tag
|
|
9146
|
+
* (no `delete_tag` query param) -- the tag is the source of truth, and a
|
|
9147
|
+
* subsequent `POST /releases` relies on it still existing to insert a fresh
|
|
9148
|
+
* row with `created_unix = now`. A 404 (already gone) is treated as success.
|
|
9149
|
+
*/
|
|
9150
|
+
async function deleteRelease(executor, conn, releaseId) {
|
|
9151
|
+
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
|
|
9152
|
+
const res = await executor.fetch(url, {
|
|
9153
|
+
method: "DELETE",
|
|
9154
|
+
headers: { Authorization: `token ${conn.token}` }
|
|
9155
|
+
});
|
|
9156
|
+
if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
|
|
9157
|
+
verb: "delete release",
|
|
9158
|
+
resource: String(releaseId)
|
|
9159
|
+
}));
|
|
9303
9160
|
}
|
|
9304
|
-
//#endregion
|
|
9305
|
-
//#region src/generators/gitignore.ts
|
|
9306
|
-
/** Entries that every project should have -- repo:sync --check flags these as missing. */
|
|
9307
|
-
const REQUIRED_ENTRIES = [
|
|
9308
|
-
"node_modules/",
|
|
9309
|
-
".pnpm-store/",
|
|
9310
|
-
"dist/",
|
|
9311
|
-
"*.tsbuildinfo",
|
|
9312
|
-
".env",
|
|
9313
|
-
".env.*",
|
|
9314
|
-
"!.env.example"
|
|
9315
|
-
];
|
|
9316
9161
|
/**
|
|
9317
|
-
*
|
|
9162
|
+
* Flag an existing Forgejo release as a prerelease (`PATCH /repos/{repo}/releases/{id}`).
|
|
9318
9163
|
*
|
|
9319
|
-
*
|
|
9320
|
-
*
|
|
9321
|
-
*
|
|
9322
|
-
*
|
|
9323
|
-
*
|
|
9324
|
-
*
|
|
9325
|
-
*
|
|
9164
|
+
* {@link ensureRelease} uses this on the path where the release was already there, and so does a
|
|
9165
|
+
* rerun, so a rerun over a release an earlier unflagged run created can still
|
|
9166
|
+
* keep the forge's Latest pointer off a prerelease. It sets the flag and never clears it, which is
|
|
9167
|
+
* why the body is fixed at
|
|
9168
|
+
* `{ prerelease: true }` and why the write is a fixed-field PATCH: `{ idempotent: true }`, the
|
|
9169
|
+
* convention every other fixed-field PATCH in this package passes (see `updateForgejoComment` in
|
|
9170
|
+
* `issue-comments.ts`), because a re-sent convergent PATCH is a no-op.
|
|
9171
|
+
*
|
|
9172
|
+
* The JSON body is {@link createRelease}'s shape (one `Authorization` header plus a content type),
|
|
9173
|
+
* but unlike {@link deleteRelease} against the same URL a 404 is **not** tolerated here: a row that
|
|
9174
|
+
* vanished mid-run means the flag was not applied, and the caller must know.
|
|
9326
9175
|
*/
|
|
9327
|
-
|
|
9328
|
-
|
|
9329
|
-
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
9176
|
+
async function markReleasePrerelease(executor, conn, releaseId, tag) {
|
|
9177
|
+
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}`;
|
|
9178
|
+
const res = await executor.fetch(url, {
|
|
9179
|
+
method: "PATCH",
|
|
9180
|
+
headers: {
|
|
9181
|
+
Authorization: `token ${conn.token}`,
|
|
9182
|
+
"Content-Type": "application/json"
|
|
9183
|
+
},
|
|
9184
|
+
body: JSON.stringify({ prerelease: true })
|
|
9185
|
+
}, { idempotent: true });
|
|
9186
|
+
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
9187
|
+
verb: "edit release",
|
|
9188
|
+
resource: tag
|
|
9189
|
+
}));
|
|
9190
|
+
}
|
|
9334
9191
|
/**
|
|
9335
|
-
*
|
|
9336
|
-
*
|
|
9337
|
-
*
|
|
9338
|
-
*
|
|
9192
|
+
* Ensure a Forgejo release exists for a tag **with a real created date**.
|
|
9193
|
+
*
|
|
9194
|
+
* Forgejo stamps a pushed annotated tag's date *asynchronously*; a release
|
|
9195
|
+
* creation that races that stamp freezes the row at the Unix epoch (see
|
|
9196
|
+
* {@link isEpochReleaseDate}) -- a published-but-invisible release, because
|
|
9197
|
+
* `/releases/latest` sorts by date. The failure is intermittent. The repair:
|
|
9198
|
+
*
|
|
9199
|
+
* 1. A real-dated release already exists -> `"exists"`.
|
|
9200
|
+
* 2. Otherwise create the release and read it back. On epoch, delete the frozen
|
|
9201
|
+
* release and wait (increasing backoff) for Forgejo to settle the tag's date,
|
|
9202
|
+
* then retry -- **preserving the pushed (annotated) tag**. The frozen release
|
|
9203
|
+
* is never deleted-and-left-empty.
|
|
9204
|
+
* 3. If the date still never settles and a `target` commit is known, delete the
|
|
9205
|
+
* tag and recreate the release with `target_commitish` so **Forgejo creates
|
|
9206
|
+
* the tag itself**, stamped `now`. The tag becomes lightweight. -> `"repaired"`.
|
|
9207
|
+
* 4. If nothing yields a real date, leave a visible release row (rather than only
|
|
9208
|
+
* an invisible tag) and throw loudly.
|
|
9209
|
+
*
|
|
9210
|
+
* Returns `{ status, release }` -- see {@link EnsureReleaseOutcome}.
|
|
9339
9211
|
*/
|
|
9340
|
-
function
|
|
9341
|
-
const
|
|
9342
|
-
|
|
9343
|
-
|
|
9344
|
-
|
|
9345
|
-
|
|
9346
|
-
function normalizeEntry(entry) {
|
|
9347
|
-
let s = entry.trim();
|
|
9348
|
-
if (s.startsWith("/")) s = s.slice(1);
|
|
9349
|
-
if (s.endsWith("/")) s = s.slice(0, -1);
|
|
9350
|
-
return s;
|
|
9351
|
-
}
|
|
9352
|
-
async function generateGitignore(ctx) {
|
|
9353
|
-
const filePath = ".gitignore";
|
|
9354
|
-
const allEntries = [
|
|
9355
|
-
...REQUIRED_ENTRIES,
|
|
9356
|
-
...OPTIONAL_ENTRIES,
|
|
9357
|
-
...conditionalEntries(ctx)
|
|
9358
|
-
];
|
|
9359
|
-
const existing = ctx.read(filePath);
|
|
9360
|
-
if (existing) {
|
|
9361
|
-
const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
|
|
9362
|
-
const missing = allEntries.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
|
|
9363
|
-
if (missing.length === 0) return {
|
|
9364
|
-
filePath,
|
|
9365
|
-
action: "skipped",
|
|
9366
|
-
description: "Already has all standard entries"
|
|
9367
|
-
};
|
|
9368
|
-
const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
|
|
9369
|
-
const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
|
|
9370
|
-
ctx.write(filePath, updated);
|
|
9371
|
-
if (missingRequired.length === 0) return {
|
|
9372
|
-
filePath,
|
|
9373
|
-
action: "skipped",
|
|
9374
|
-
description: "Only optional entries missing"
|
|
9375
|
-
};
|
|
9212
|
+
async function ensureRelease(executor, conn, tag, options = {}) {
|
|
9213
|
+
const attempts = options.attempts ?? SETTLE_ATTEMPTS;
|
|
9214
|
+
const baseDelayMs = options.baseDelayMs ?? SETTLE_BASE_DELAY_MS;
|
|
9215
|
+
const existing = await getReleaseByTag(executor, conn, tag);
|
|
9216
|
+
if (existing && !isEpochReleaseDate(existing.created_at)) {
|
|
9217
|
+
if (options.prerelease === true && existing.prerelease === false) await markReleasePrerelease(executor, conn, existing.id, tag);
|
|
9376
9218
|
return {
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
description: `Appended ${String(missing.length)} missing entries`
|
|
9219
|
+
status: "exists",
|
|
9220
|
+
release: releaseRef(existing)
|
|
9380
9221
|
};
|
|
9381
9222
|
}
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9385
|
-
|
|
9386
|
-
|
|
9223
|
+
let current = existing;
|
|
9224
|
+
let repairing = false;
|
|
9225
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
9226
|
+
if (current && isEpochReleaseDate(current.created_at)) {
|
|
9227
|
+
await deleteRelease(executor, conn, current.id);
|
|
9228
|
+
current = null;
|
|
9229
|
+
repairing = true;
|
|
9230
|
+
await settleBackoff(attempt, baseDelayMs);
|
|
9231
|
+
}
|
|
9232
|
+
try {
|
|
9233
|
+
await createRelease(executor, conn, tag, {
|
|
9234
|
+
target: options.target,
|
|
9235
|
+
prerelease: options.prerelease
|
|
9236
|
+
});
|
|
9237
|
+
} catch (error) {
|
|
9238
|
+
const raced = await getReleaseByTag(executor, conn, tag);
|
|
9239
|
+
if (raced && !isEpochReleaseDate(raced.created_at)) return {
|
|
9240
|
+
status: "race",
|
|
9241
|
+
release: releaseRef(raced)
|
|
9242
|
+
};
|
|
9243
|
+
current = raced;
|
|
9244
|
+
if (attempt >= attempts) break;
|
|
9245
|
+
log.warn(`Release creation attempt ${String(attempt)}/${String(attempts)} failed: ${describeError(error)}`);
|
|
9246
|
+
await settleBackoff(attempt, baseDelayMs);
|
|
9247
|
+
continue;
|
|
9248
|
+
}
|
|
9249
|
+
current = await getReleaseByTag(executor, conn, tag);
|
|
9250
|
+
if (current && !isEpochReleaseDate(current.created_at)) return {
|
|
9251
|
+
status: repairing ? "repaired" : "created",
|
|
9252
|
+
release: releaseRef(current)
|
|
9253
|
+
};
|
|
9254
|
+
if (attempt < attempts) log.warn(`Release for ${tag} came back epoch-dated (attempt ${String(attempt)}/${String(attempts)}) -- waiting for Forgejo to settle the tag's date before retrying`);
|
|
9255
|
+
}
|
|
9256
|
+
const recreated = options.target ? await recreateTagViaApi(executor, conn, tag, {
|
|
9257
|
+
target: options.target,
|
|
9258
|
+
prerelease: options.prerelease,
|
|
9259
|
+
current,
|
|
9260
|
+
attempts,
|
|
9261
|
+
baseDelayMs
|
|
9262
|
+
}) : null;
|
|
9263
|
+
if (recreated) return {
|
|
9264
|
+
status: "repaired",
|
|
9265
|
+
release: releaseRef(recreated)
|
|
9387
9266
|
};
|
|
9267
|
+
throw new TransientError(`Release for ${tag} never got a real (non-epoch) created date after ${String(attempts)} attempts. This is an intermittent Forgejo bug: a pushed tag's date settles asynchronously and a racing release creation freezes it at the Unix epoch (1969/1970). ` + (options.target ? `Recreating the tag via the API did not help -- investigate the server or upgrade Forgejo.` : `Pass the release tag's target commit so the tag can be recreated, or retry the release.`));
|
|
9388
9268
|
}
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
|
|
9402
|
-
|
|
9403
|
-
|
|
9404
|
-
|
|
9405
|
-
const
|
|
9406
|
-
if (
|
|
9407
|
-
|
|
9408
|
-
|
|
9409
|
-
|
|
9410
|
-
return rest.has("text=auto") && rest.has("eol=lf");
|
|
9411
|
-
});
|
|
9269
|
+
async function recreateTagViaApi(executor, conn, tag, opts) {
|
|
9270
|
+
const { target, prerelease, current, attempts, baseDelayMs } = opts;
|
|
9271
|
+
log.warn(`Release for ${tag} stayed epoch-dated -- recreating the tag via the API so Forgejo stamps a real date (the tag becomes lightweight)`);
|
|
9272
|
+
if (current) await deleteRelease(executor, conn, current.id);
|
|
9273
|
+
await deleteTag(executor, conn, tag);
|
|
9274
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
9275
|
+
try {
|
|
9276
|
+
await createRelease(executor, conn, tag, {
|
|
9277
|
+
target,
|
|
9278
|
+
prerelease
|
|
9279
|
+
});
|
|
9280
|
+
} catch (error) {
|
|
9281
|
+
if (attempt >= attempts) throw new TransientError(`Release for ${tag} was deleted to repair its epoch date, but recreating it from ${target} failed: ${describeError(error)}. The tag no longer exists on the server -- re-push it (or re-run the release) to restore it.`);
|
|
9282
|
+
await settleBackoff(attempt, baseDelayMs);
|
|
9283
|
+
continue;
|
|
9284
|
+
}
|
|
9285
|
+
const recreated = await getReleaseByTag(executor, conn, tag);
|
|
9286
|
+
if (recreated && !isEpochReleaseDate(recreated.created_at)) return recreated;
|
|
9287
|
+
return null;
|
|
9288
|
+
}
|
|
9289
|
+
return null;
|
|
9412
9290
|
}
|
|
9413
9291
|
/**
|
|
9414
|
-
*
|
|
9415
|
-
*
|
|
9416
|
-
* from `utils/generated-header.ts` -- this file is merged into like `.gitignore`, not
|
|
9417
|
-
* wholesale-regenerated. The rule is required, not optional: a missing rule is
|
|
9418
|
-
* created/updated and fails `repo:sync --check` like any other drift.
|
|
9292
|
+
* The `{ id, url }` projection of a release row carried back to a caller. A server that omits
|
|
9293
|
+
* `html_url` yields `url: null` -- the id is still the row's identity.
|
|
9419
9294
|
*/
|
|
9420
|
-
|
|
9421
|
-
const filePath = ".gitattributes";
|
|
9422
|
-
const existing = ctx.read(filePath);
|
|
9423
|
-
if (existing === void 0 || existing.trim() === "") {
|
|
9424
|
-
ctx.write(filePath, `${MANAGED_HEADER}\n${RULE_LINE}\n`);
|
|
9425
|
-
return {
|
|
9426
|
-
filePath,
|
|
9427
|
-
action: existing === void 0 ? "created" : "updated",
|
|
9428
|
-
description: "Generated .gitattributes"
|
|
9429
|
-
};
|
|
9430
|
-
}
|
|
9431
|
-
if (hasLfRule(existing)) return {
|
|
9432
|
-
filePath,
|
|
9433
|
-
action: "skipped",
|
|
9434
|
-
description: "Already has the LF rule"
|
|
9435
|
-
};
|
|
9436
|
-
const updated = `${MANAGED_HEADER}\n${PREPEND_REASON}\n${RULE_LINE}\n\n${existing}`;
|
|
9437
|
-
ctx.write(filePath, updated);
|
|
9295
|
+
function releaseRef(release) {
|
|
9438
9296
|
return {
|
|
9439
|
-
|
|
9440
|
-
|
|
9441
|
-
description: "Prepended the LF rule"
|
|
9297
|
+
id: release.id,
|
|
9298
|
+
url: release.html_url ?? null
|
|
9442
9299
|
};
|
|
9443
9300
|
}
|
|
9301
|
+
/** Exponential backoff (capped) between patient-repair attempts; a non-positive base is a no-op. */
|
|
9302
|
+
async function settleBackoff(attempt, baseDelayMs) {
|
|
9303
|
+
if (baseDelayMs <= 0) return;
|
|
9304
|
+
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), SETTLE_MAX_DELAY_MS);
|
|
9305
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
9306
|
+
}
|
|
9307
|
+
/** Upload an asset file to a Forgejo release. */
|
|
9308
|
+
async function uploadReleaseAsset(executor, conn, tag, options) {
|
|
9309
|
+
const encodedTag = encodeURIComponent(tag);
|
|
9310
|
+
const releaseUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
|
|
9311
|
+
const releaseRes = await executor.fetch(releaseUrl, { headers: { Authorization: `token ${conn.token}` } });
|
|
9312
|
+
if (!releaseRes.ok) throw new TransientError(await forgejoErrorMessage(releaseRes, {
|
|
9313
|
+
verb: "get release",
|
|
9314
|
+
resource: tag
|
|
9315
|
+
}));
|
|
9316
|
+
const parsed = z.object({ id: z.number() }).safeParse(await releaseRes.json());
|
|
9317
|
+
if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
|
|
9318
|
+
const releaseId = parsed.data.id;
|
|
9319
|
+
const uploadUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/${String(releaseId)}/assets?name=${encodeURIComponent(options.assetName)}`;
|
|
9320
|
+
const form = new FormData();
|
|
9321
|
+
form.append("attachment", new Blob([options.fileBuffer]), options.assetName);
|
|
9322
|
+
const res = await executor.fetch(uploadUrl, {
|
|
9323
|
+
method: "POST",
|
|
9324
|
+
headers: { Authorization: `token ${conn.token}` },
|
|
9325
|
+
body: form
|
|
9326
|
+
}, { timeoutMs: LONG_FETCH_TIMEOUT_MS });
|
|
9327
|
+
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
9328
|
+
verb: "upload asset",
|
|
9329
|
+
resource: `${options.assetName} to ${tag}`
|
|
9330
|
+
}));
|
|
9331
|
+
}
|
|
9444
9332
|
//#endregion
|
|
9445
|
-
//#region src/release/publish/
|
|
9446
|
-
const
|
|
9333
|
+
//#region src/release/publish/version-of-record.ts
|
|
9334
|
+
const FOREIGN_VERSION_FILES = [{
|
|
9335
|
+
file: "Cargo.toml",
|
|
9336
|
+
sections: ["package"]
|
|
9337
|
+
}, {
|
|
9338
|
+
file: "pyproject.toml",
|
|
9339
|
+
sections: ["project", "tool.poetry"]
|
|
9340
|
+
}];
|
|
9341
|
+
/** A static `version = "..."` assignment. `version.workspace = true` is deliberately not a match. */
|
|
9342
|
+
const STATIC_VERSION = /^\s*version\s*=\s*"[^"]*"/;
|
|
9447
9343
|
/**
|
|
9448
|
-
*
|
|
9449
|
-
*
|
|
9450
|
-
*
|
|
9344
|
+
* True when one of the named TOML tables holds a static `version = "..."`. A
|
|
9345
|
+
* dependency-free section scan, not a full TOML parse: each `[table]` header
|
|
9346
|
+
* (or `[[array]]`) opens a section that runs until the next header, so a
|
|
9347
|
+
* `version` under `[tool.poetry.dependencies]` never counts as the package
|
|
9348
|
+
* version. The failure mode is a false negative (a version we don't recognize),
|
|
9349
|
+
* which lands on today's behavior -- no regression.
|
|
9451
9350
|
*/
|
|
9452
|
-
function
|
|
9453
|
-
|
|
9454
|
-
|
|
9455
|
-
|
|
9456
|
-
|
|
9457
|
-
|
|
9458
|
-
|
|
9351
|
+
function sectionHasStaticVersion(content, sections) {
|
|
9352
|
+
const wanted = new Set(sections);
|
|
9353
|
+
let current;
|
|
9354
|
+
for (const rawLine of content.split("\n")) {
|
|
9355
|
+
const line = rawLine.trim();
|
|
9356
|
+
const header = /^\[+\s*([^\]]+?)\s*\]+/.exec(line);
|
|
9357
|
+
if (header) {
|
|
9358
|
+
current = header[1];
|
|
9359
|
+
continue;
|
|
9360
|
+
}
|
|
9361
|
+
if (current !== void 0 && wanted.has(current) && STATIC_VERSION.test(line)) return true;
|
|
9459
9362
|
}
|
|
9460
|
-
|
|
9461
|
-
function isRecord(value) {
|
|
9462
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9363
|
+
return false;
|
|
9463
9364
|
}
|
|
9464
9365
|
/**
|
|
9465
|
-
*
|
|
9466
|
-
*
|
|
9467
|
-
* npm and pnpm use one `registry` key for reads and writes alike, so a project
|
|
9468
|
-
* `.npmrc` aimed at an install cache silently repoints `pnpm publish` too. A
|
|
9469
|
-
* package that never declares its destination inherits one, and an inherited
|
|
9470
|
-
* destination can be changed by a file that never mentions publishing.
|
|
9366
|
+
* The filename of a foreign version-of-record (`Cargo.toml`, `pyproject.toml`)
|
|
9367
|
+
* that carries an explicit static version, or `undefined` when none is present.
|
|
9471
9368
|
*
|
|
9472
|
-
*
|
|
9473
|
-
*
|
|
9474
|
-
*
|
|
9369
|
+
* On a `stack: none` repo this is proof that `package.json` is only the bst
|
|
9370
|
+
* harness and the real version lives elsewhere -- the trigger for `release:simple`
|
|
9371
|
+
* to refuse rather than silently bump the wrong file.
|
|
9475
9372
|
*/
|
|
9476
|
-
function
|
|
9477
|
-
const
|
|
9478
|
-
|
|
9479
|
-
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
if (registry === void 0) {
|
|
9485
|
-
issues.push({
|
|
9486
|
-
kind: "missing",
|
|
9487
|
-
package: pkg.name,
|
|
9488
|
-
file,
|
|
9489
|
-
message: `${pkg.name} is published from this repo but declares no publishConfig.registry, so its release destination is inherited -- a project .npmrc can repoint it silently. In ${file}, ${FIX}.`
|
|
9490
|
-
});
|
|
9373
|
+
function detectForeignVersionOfRecord(targetDir) {
|
|
9374
|
+
for (const { file, sections } of FOREIGN_VERSION_FILES) {
|
|
9375
|
+
const full = path.join(targetDir, file);
|
|
9376
|
+
if (!existsSync(full)) continue;
|
|
9377
|
+
let content;
|
|
9378
|
+
try {
|
|
9379
|
+
content = readFileSync(full, "utf-8");
|
|
9380
|
+
} catch (_error) {
|
|
9491
9381
|
continue;
|
|
9492
9382
|
}
|
|
9493
|
-
if (
|
|
9494
|
-
kind: "malformed",
|
|
9495
|
-
package: pkg.name,
|
|
9496
|
-
file,
|
|
9497
|
-
message: `${pkg.name} declares publishConfig.registry as ${JSON.stringify(registry)}, which is not an absolute http(s) URL and cannot be published to. In ${file}, ${FIX}.`
|
|
9498
|
-
});
|
|
9383
|
+
if (sectionHasStaticVersion(content, sections)) return file;
|
|
9499
9384
|
}
|
|
9500
|
-
return issues;
|
|
9501
9385
|
}
|
|
9502
9386
|
/**
|
|
9503
|
-
* The
|
|
9504
|
-
*
|
|
9505
|
-
*
|
|
9506
|
-
*
|
|
9507
|
-
*
|
|
9508
|
-
* `repo:sync` never reaches a generator with an undeclared or malformed registry. A
|
|
9509
|
-
* caller that invokes a generator directly (or a repo whose publishable set is empty,
|
|
9510
|
-
* e.g. every package private) still gets a safe empty result.
|
|
9387
|
+
* The version-of-record conflict `release:simple` refuses on: the filename when
|
|
9388
|
+
* `stack: none` (so `package.json` is provably just the harness) meets a
|
|
9389
|
+
* `releaseStrategy: simple` and a foreign version-of-record file, else
|
|
9390
|
+
* `undefined`. A `javascript` repo (its `package.json` legitimately IS the
|
|
9391
|
+
* version) and an empty `stack: none` repo (no competing file) are both allowed.
|
|
9511
9392
|
*/
|
|
9512
|
-
function
|
|
9513
|
-
|
|
9514
|
-
|
|
9515
|
-
const manifest = structure === "single" ? rootPackageJson ?? readPackageJson(pkg.dir) : readPackageJson(pkg.dir);
|
|
9516
|
-
if (!manifest) continue;
|
|
9517
|
-
const pc = manifest.publishConfig;
|
|
9518
|
-
const registry = isRecord(pc) ? pc["registry"] : void 0;
|
|
9519
|
-
if (typeof registry === "string" && isUsableRegistry(registry) && !registries.includes(registry)) registries.push(registry);
|
|
9520
|
-
}
|
|
9521
|
-
return registries;
|
|
9393
|
+
function releaseTargetConflict(stack, releaseStrategy, targetDir) {
|
|
9394
|
+
if (stack !== "none" || releaseStrategy !== "simple") return void 0;
|
|
9395
|
+
return detectForeignVersionOfRecord(targetDir);
|
|
9522
9396
|
}
|
|
9523
9397
|
//#endregion
|
|
9524
|
-
//#region src/
|
|
9398
|
+
//#region src/release/publish/version-stamp.ts
|
|
9399
|
+
function splitLines(content) {
|
|
9400
|
+
const trailingNewline = content.endsWith("\n");
|
|
9401
|
+
return {
|
|
9402
|
+
lines: (trailingNewline ? content.slice(0, -1) : content).split("\n"),
|
|
9403
|
+
trailingNewline
|
|
9404
|
+
};
|
|
9405
|
+
}
|
|
9406
|
+
function joinLines({ lines, trailingNewline }) {
|
|
9407
|
+
return lines.join("\n") + (trailingNewline ? "\n" : "");
|
|
9408
|
+
}
|
|
9409
|
+
function stripCr(line) {
|
|
9410
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
9411
|
+
}
|
|
9412
|
+
/** `prefix + version + suffix` -- the exact line a matching entry is replaced with. */
|
|
9413
|
+
function expectedStampLine(entry, version) {
|
|
9414
|
+
return entry.prefix + version + (entry.suffix ?? "");
|
|
9415
|
+
}
|
|
9525
9416
|
/**
|
|
9526
|
-
*
|
|
9527
|
-
*
|
|
9528
|
-
*
|
|
9529
|
-
*
|
|
9530
|
-
* agree with them, so the two packages cannot drift without a failing test on either side.
|
|
9531
|
-
*
|
|
9532
|
-
* Each constant sits under its own `// renovate:` marker comment, in the three-line grammar
|
|
9533
|
-
* `packages/agent/docker/agent-goose-image/goose.sha256` uses for its own digest line, so the same
|
|
9534
|
-
* shape of custom regex manager in the root `renovate.json` can rewrite both this version and this
|
|
9535
|
-
* digest (`customManagers` entries scoped to this file).
|
|
9417
|
+
* Indexes (into `content.split("\n")`, post trailing-newline removal) of every line that
|
|
9418
|
+
* starts with `entry.prefix`, ends with `entry.suffix` (default: none), and is at least
|
|
9419
|
+
* `prefix.length + suffix.length` characters long so the two cannot overlap on a
|
|
9420
|
+
* degenerate line. Compared against each line with a trailing `\r` stripped.
|
|
9536
9421
|
*/
|
|
9537
|
-
|
|
9538
|
-
const
|
|
9539
|
-
|
|
9540
|
-
|
|
9541
|
-
|
|
9542
|
-
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
//#endregion
|
|
9548
|
-
//#region src/generators/ci-utils.ts
|
|
9549
|
-
/** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
|
|
9550
|
-
function actionsExpr(expr) {
|
|
9551
|
-
return `\${{ ${expr} }}`;
|
|
9422
|
+
function matchingLineIndexes(content, entry) {
|
|
9423
|
+
const suffix = entry.suffix ?? "";
|
|
9424
|
+
const minLength = entry.prefix.length + suffix.length;
|
|
9425
|
+
const { lines } = splitLines(content);
|
|
9426
|
+
const indexes = [];
|
|
9427
|
+
lines.forEach((rawLine, index) => {
|
|
9428
|
+
const line = stripCr(rawLine);
|
|
9429
|
+
if (line.startsWith(entry.prefix) && line.endsWith(suffix) && line.length >= minLength) indexes.push(index);
|
|
9430
|
+
});
|
|
9431
|
+
return indexes;
|
|
9552
9432
|
}
|
|
9553
|
-
const MAIN_BRANCH_REF = "refs/heads/main";
|
|
9554
|
-
/** `if:` condition that gates a step to push events on the default branch. */
|
|
9555
|
-
const IS_MAIN_BRANCH = `github.ref == '${MAIN_BRANCH_REF}'`;
|
|
9556
9433
|
/**
|
|
9557
|
-
*
|
|
9558
|
-
*
|
|
9559
|
-
*
|
|
9560
|
-
*
|
|
9561
|
-
*
|
|
9562
|
-
* wastes minutes; a half-published release costs a manual unwind.
|
|
9563
|
-
*
|
|
9564
|
-
* So several main runs can be in flight at once, each versioning from its own triggering commit.
|
|
9565
|
-
* Overlap safety rests on one invariant: **main advances solely via commits that trigger a CI
|
|
9566
|
-
* run.** The `[skip ci]` coverage, health, and egress records go to `bst/*` orphan branches and
|
|
9567
|
-
* never to main; a `[skip ci]` commit pushed straight to main would leave a superseded run skipping
|
|
9568
|
-
* and its changesets stranded until the next push, which is why the supersession guard skips rather
|
|
9569
|
-
* than fails. Three guards carry the overlap itself: supersession plus a `--force-with-lease`
|
|
9570
|
-
* version push (`release/publish/version.ts`), the `headBumpedVersion` publish guard plus reading
|
|
9571
|
-
* remote tags and releases before acting (`commands/release-changesets.ts`,
|
|
9572
|
-
* `release/publish/publish.ts`), and floating git and Docker tags that only move forward
|
|
9573
|
-
* (`release/publish/floating-tags.ts`, `release/docker.ts`). Such a run may also version pending
|
|
9574
|
-
* changesets after it publishes -- the stale version-PR merge -- under the same supersession guard
|
|
9575
|
-
* and push lease as any other version run. A later commit publishes a bump only when its tag is
|
|
9576
|
-
* missing and every main run since the package's last release tag has settled
|
|
9577
|
-
* (`release/publish/unpublished-bump.ts`), so no run that could still publish it is in flight; a
|
|
9578
|
-
* manual re-run of one of those runs is the overlap left. On Forgejo the concurrency block is
|
|
9579
|
-
* only a PR-run cancellation mechanism -- it holds a main run in its group and, in Forgejo 16,
|
|
9580
|
-
* intermittently fails to release it, which is why main runs get a group of their own there. GitHub's shape stays shared
|
|
9581
|
-
* because its queue works: one run, one pending, a newer arrival replacing the pending, so
|
|
9582
|
-
* `changesets/action@v1` never needs a guard of its own. Any new side effect in a main run must be
|
|
9583
|
-
* idempotent under overlap or be listed here with the guard that makes it so.
|
|
9434
|
+
* `undefined` when exactly one line in `content` matches `entry`; otherwise the
|
|
9435
|
+
* user-facing sentence describing the zero- or many-match failure, naming the file, the
|
|
9436
|
+
* prefix, and the fix (a more specific prefix, or a second `versionStamp` entry). Both the
|
|
9437
|
+
* `release:assets` stamp phase and the `repo:sync` static gate report through this one
|
|
9438
|
+
* function so the two messages cannot drift apart.
|
|
9584
9439
|
*/
|
|
9585
|
-
|
|
9440
|
+
function describeStampProblem(content, entry) {
|
|
9441
|
+
const indexes = matchingLineIndexes(content, entry);
|
|
9442
|
+
if (indexes.length === 1) return void 0;
|
|
9443
|
+
const suffixClause = entry.suffix === void 0 ? "" : ` and suffix ${JSON.stringify(entry.suffix)}`;
|
|
9444
|
+
const prefixClause = `prefix ${JSON.stringify(entry.prefix)}${suffixClause}`;
|
|
9445
|
+
if (indexes.length === 0) return `${entry.file}: no line matches ${prefixClause}. Fix the prefix/suffix to match the file's actual content.`;
|
|
9446
|
+
return `${entry.file}: ${indexes.length} lines match ${prefixClause}, which is ambiguous. Use a more specific prefix, or split this into a second versionStamp entry.`;
|
|
9447
|
+
}
|
|
9448
|
+
/**
|
|
9449
|
+
* Replaces the single line in `content` matching `entry` with `expectedStampLine(entry,
|
|
9450
|
+
* version)`, preserving that line's trailing `\r` and the file's trailing newline (or its
|
|
9451
|
+
* absence). Idempotent: stamping an already-stamped line reproduces the file byte for
|
|
9452
|
+
* byte. Precondition: `describeStampProblem(content, entry)` is `undefined` -- callers
|
|
9453
|
+
* must check that first, since this indexes the sole match directly.
|
|
9454
|
+
*/
|
|
9455
|
+
function stampContent(content, entry, version) {
|
|
9456
|
+
const split = splitLines(content);
|
|
9457
|
+
const [index] = matchingLineIndexes(content, entry);
|
|
9458
|
+
const hasCr = split.lines[index].endsWith("\r");
|
|
9459
|
+
const replacement = expectedStampLine(entry, version);
|
|
9460
|
+
split.lines[index] = hasCr ? `${replacement}\r` : replacement;
|
|
9461
|
+
return joinLines(split);
|
|
9462
|
+
}
|
|
9463
|
+
//#endregion
|
|
9464
|
+
//#region src/release/publish/assets.ts
|
|
9465
|
+
const CONVENTION_SCRIPT = "build:release-assets";
|
|
9466
|
+
const CONVENTION_OUTPUT_DIR = "release-assets";
|
|
9467
|
+
function configuredTargets(cwd, toolingConfig) {
|
|
9468
|
+
return (toolingConfig?.releaseAssets ?? []).map((asset) => ({
|
|
9469
|
+
commandCwd: cwd,
|
|
9470
|
+
command: asset.command,
|
|
9471
|
+
files: [{
|
|
9472
|
+
path: path.resolve(cwd, asset.file),
|
|
9473
|
+
...asset.name && { name: asset.name }
|
|
9474
|
+
}]
|
|
9475
|
+
}));
|
|
9476
|
+
}
|
|
9477
|
+
function conventionTargets(executor, cwd) {
|
|
9478
|
+
const targets = [];
|
|
9479
|
+
const checkDir = (relDir) => {
|
|
9480
|
+
const absDir = path.resolve(cwd, relDir);
|
|
9481
|
+
const raw = executor.readFile(path.join(absDir, "package.json"));
|
|
9482
|
+
if (!raw) return;
|
|
9483
|
+
if (!parsePackageJson(raw)?.scripts?.[CONVENTION_SCRIPT]) return;
|
|
9484
|
+
targets.push({
|
|
9485
|
+
commandCwd: absDir,
|
|
9486
|
+
command: [
|
|
9487
|
+
"pnpm",
|
|
9488
|
+
"run",
|
|
9489
|
+
CONVENTION_SCRIPT
|
|
9490
|
+
],
|
|
9491
|
+
files: [],
|
|
9492
|
+
collectFromDir: path.join(absDir, CONVENTION_OUTPUT_DIR)
|
|
9493
|
+
});
|
|
9494
|
+
};
|
|
9495
|
+
checkDir(".");
|
|
9496
|
+
for (const dir of executor.listPackageDirs(cwd)) checkDir(path.join("packages", dir));
|
|
9497
|
+
return targets;
|
|
9498
|
+
}
|
|
9586
9499
|
/**
|
|
9587
|
-
*
|
|
9588
|
-
*
|
|
9500
|
+
* The release version `versionStamp` writes into each configured file: the root
|
|
9501
|
+
* `package.json#version`, the same source `release:simple` reads. A missing file, unparseable
|
|
9502
|
+
* JSON, and a manifest with no `version` are all rejected -- a missing version is the "ships
|
|
9503
|
+
* 0.0.0" failure this design exists to catch, never a skip.
|
|
9589
9504
|
*/
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
const
|
|
9505
|
+
function resolveStampVersion(executor, cwd) {
|
|
9506
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
9507
|
+
const raw = executor.readFile(pkgPath);
|
|
9508
|
+
if (raw === null) throw new FatalError(`release:assets could not read package.json at ${pkgPath} to resolve the version for versionStamp.`);
|
|
9509
|
+
const pkg = parsePackageJson(raw);
|
|
9510
|
+
if (!pkg?.version) throw new FatalError(`release:assets could not find a "version" field in package.json at ${pkgPath}. versionStamp needs a version to stamp.`);
|
|
9511
|
+
return pkg.version;
|
|
9512
|
+
}
|
|
9593
9513
|
/**
|
|
9594
|
-
*
|
|
9595
|
-
*
|
|
9596
|
-
*
|
|
9597
|
-
* what npm looks up at publish time -- npm-registry-fetch walks the request path
|
|
9598
|
-
* upward through ancestor keys until one matches.
|
|
9514
|
+
* Stamps every `versionStamp` entry with `version`, before any build command runs. Throws
|
|
9515
|
+
* `describeStampProblem`'s message on a zero- or many-match config, so the two consumers
|
|
9516
|
+
* (this phase and `repo:sync`'s static gate) cannot report the failure differently.
|
|
9599
9517
|
*/
|
|
9600
|
-
function
|
|
9601
|
-
|
|
9518
|
+
function stampVersionFiles(executor, cwd, toolingConfig, version) {
|
|
9519
|
+
const entries = toolingConfig?.versionStamp ?? [];
|
|
9520
|
+
const stamped = [];
|
|
9521
|
+
for (const entry of entries) {
|
|
9522
|
+
const filePath = path.resolve(cwd, entry.file);
|
|
9523
|
+
const content = executor.readFile(filePath);
|
|
9524
|
+
if (content === null) throw new FatalError(`versionStamp file not found: ${entry.file} (resolved to ${filePath})`);
|
|
9525
|
+
const problem = describeStampProblem(content, entry);
|
|
9526
|
+
if (problem) throw new FatalError(`versionStamp: ${problem}`);
|
|
9527
|
+
const expectedLine = expectedStampLine(entry, version);
|
|
9528
|
+
executor.writeFile(filePath, stampContent(content, entry, version));
|
|
9529
|
+
log$1.info(`Stamped ${entry.file}: ${expectedLine}`);
|
|
9530
|
+
stamped.push({
|
|
9531
|
+
path: filePath,
|
|
9532
|
+
expectedLine
|
|
9533
|
+
});
|
|
9534
|
+
}
|
|
9535
|
+
return stamped;
|
|
9602
9536
|
}
|
|
9603
|
-
const NODE_AUTH_TOKEN_VAR = ["$", "{NODE_AUTH_TOKEN}"].join("");
|
|
9604
9537
|
/**
|
|
9605
|
-
*
|
|
9606
|
-
*
|
|
9607
|
-
*
|
|
9608
|
-
*
|
|
9609
|
-
* empty (every package private), since there is then no npmrc line for a real
|
|
9610
|
-
* secret to pair with.
|
|
9611
|
-
*
|
|
9612
|
-
* The lines go into a temp userconfig exported only inside the publishing
|
|
9613
|
-
* step's shell, never `~/.npmrc` or `$GITHUB_ENV`: pnpm 11 warns
|
|
9614
|
-
* `Failed to replace env in config` on every call that reads the placeholder
|
|
9615
|
-
* while the token is unset or empty, so a line in a shared file makes install, `ci:check`,
|
|
9616
|
-
* and every step after the publish warn. An empty job-level default does not
|
|
9617
|
-
* silence it, and `${NODE_AUTH_TOKEN:-}` is rejected as an empty fallback and
|
|
9618
|
-
* warns in every case. The `-n` guard keeps a run without the secret quiet; the
|
|
9619
|
-
* single quotes keep the token itself off disk, since npm expands it at read time.
|
|
9538
|
+
* Re-reads each stamped file and requires exactly one line equal to its `expectedLine`. Runs
|
|
9539
|
+
* after the build loop -- the only failure this read-back can catch that the stamp phase did not
|
|
9540
|
+
* already is the build overwriting the stamp (a `build:release-assets` script that regenerates or
|
|
9541
|
+
* copies over the stamped file).
|
|
9620
9542
|
*/
|
|
9621
|
-
function
|
|
9622
|
-
const
|
|
9623
|
-
|
|
9624
|
-
|
|
9625
|
-
|
|
9626
|
-
};
|
|
9627
|
-
const setup = [
|
|
9628
|
-
"if [ -n \"$NODE_AUTH_TOKEN\" ]; then",
|
|
9629
|
-
" NPM_CONFIG_USERCONFIG=\"$(mktemp)\"",
|
|
9630
|
-
" export NPM_CONFIG_USERCONFIG",
|
|
9631
|
-
" if [ -f ~/.npmrc ]; then cat ~/.npmrc > \"$NPM_CONFIG_USERCONFIG\"; fi",
|
|
9632
|
-
...registries.map((registry) => ` printf '\\n%s\\n' '${npmrcAuthKey(registry)}:_authToken=${NODE_AUTH_TOKEN_VAR}' >> "$NPM_CONFIG_USERCONFIG"`),
|
|
9633
|
-
"fi"
|
|
9634
|
-
];
|
|
9635
|
-
return {
|
|
9636
|
-
publishRun: (command) => [...setup, command].join("\n"),
|
|
9637
|
-
tokenEnv: { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
|
|
9638
|
-
};
|
|
9543
|
+
function verifyStamp(executor, stamped) {
|
|
9544
|
+
for (const { path: filePath, expectedLine } of stamped) {
|
|
9545
|
+
const matches = (executor.readFile(filePath) ?? "").split("\n").filter((line) => (line.endsWith("\r") ? line.slice(0, -1) : line) === expectedLine).length;
|
|
9546
|
+
if (matches !== 1) throw new FatalError(`versionStamp verify failed for ${filePath}: expected exactly one line "${expectedLine}" after the build, found ${matches}. The build likely overwrote the stamp -- check for a script that regenerates or copies over this file.`);
|
|
9547
|
+
}
|
|
9639
9548
|
}
|
|
9640
|
-
const MISE_RELEASE_URL = `https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64`;
|
|
9641
9549
|
/**
|
|
9642
|
-
* The
|
|
9643
|
-
*
|
|
9644
|
-
*
|
|
9645
|
-
*
|
|
9646
|
-
* `~/.local/bin` install location to `$GITHUB_PATH` so every later step sees node, pnpm, and any
|
|
9647
|
-
* other declared tool on PATH with no prefix.
|
|
9550
|
+
* The whole stamp phase, gated on a non-empty `versionStamp`: refuse a foreign
|
|
9551
|
+
* version-of-record, resolve the release version, and stamp every entry. Returns `[]`
|
|
9552
|
+
* untouched (no real-filesystem read, no executor read) when the repo configures no
|
|
9553
|
+
* `versionStamp`, so no existing call path changes behaviour.
|
|
9648
9554
|
*/
|
|
9649
|
-
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
9654
|
-
|
|
9655
|
-
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
");
|
|
9662
|
-
|
|
9663
|
-
|
|
9664
|
-
|
|
9665
|
-
|
|
9666
|
-
|
|
9667
|
-
|
|
9668
|
-
|
|
9669
|
-
|
|
9670
|
-
|
|
9555
|
+
function runVersionStampPhase(executor, config) {
|
|
9556
|
+
if ((config.toolingConfig?.versionStamp ?? []).length === 0) return [];
|
|
9557
|
+
const conflict = detectForeignVersionOfRecord(config.cwd);
|
|
9558
|
+
if (conflict) throw new FatalError(`release:assets stamps package.json's version into versionStamp files, but this repo's version-of-record is ${conflict}. A stamp would carry a version the ${conflict} artifact never gets. Release this repo through its own toolchain, or remove the versionStamp block.`);
|
|
9559
|
+
const version = resolveStampVersion(executor, config.cwd);
|
|
9560
|
+
return stampVersionFiles(executor, config.cwd, config.toolingConfig, version);
|
|
9561
|
+
}
|
|
9562
|
+
function runBuild(executor, target, config) {
|
|
9563
|
+
if (!target.command || target.command.length === 0) return;
|
|
9564
|
+
const [file, ...args] = target.command;
|
|
9565
|
+
if (!file) return;
|
|
9566
|
+
const label = target.commandCwd === config.cwd ? "root" : path.relative(config.cwd, target.commandCwd);
|
|
9567
|
+
log$1.info(`Building assets in ${label}: ${target.command.join(" ")}`);
|
|
9568
|
+
executor.execInherit(file, args, { cwd: target.commandCwd });
|
|
9569
|
+
debug(config, `Asset build completed for ${label}`);
|
|
9570
|
+
}
|
|
9571
|
+
function collectFromDir(executor, target) {
|
|
9572
|
+
if (!target.collectFromDir) return;
|
|
9573
|
+
const before = target.files.length;
|
|
9574
|
+
for (const entry of executor.listFiles(target.collectFromDir)) target.files.push({ path: path.join(target.collectFromDir, entry) });
|
|
9575
|
+
if (target.files.length === before) throw new FatalError(`${CONVENTION_SCRIPT} ran in ${target.commandCwd} but emitted no files to ${target.collectFromDir}. Update your script to write artifacts there, or declare assets explicitly via releaseAssets in .tooling.json.`);
|
|
9576
|
+
}
|
|
9577
|
+
function uploadToGitHub(executor, config, tag, asset) {
|
|
9578
|
+
const assetName = asset.name ?? path.basename(asset.path);
|
|
9579
|
+
const result = executor.exec("gh", [
|
|
9580
|
+
"release",
|
|
9581
|
+
"upload",
|
|
9582
|
+
tag,
|
|
9583
|
+
asset.path,
|
|
9584
|
+
"--clobber"
|
|
9585
|
+
], { cwd: config.cwd });
|
|
9586
|
+
debugExec(config, `gh release upload ${tag} ${assetName}`, result);
|
|
9587
|
+
if (result.exitCode !== 0) throw new FatalError(`GitHub release upload failed for ${assetName}: ${result.stderr || result.stdout}`);
|
|
9588
|
+
log$1.info(`Uploaded ${assetName} to ${tag} (GitHub)`);
|
|
9589
|
+
}
|
|
9590
|
+
async function uploadToForgejo(executor, conn, tag, asset) {
|
|
9591
|
+
const assetName = asset.name ?? path.basename(asset.path);
|
|
9592
|
+
const fileBuffer = executor.readFileBuffer(asset.path);
|
|
9593
|
+
if (!fileBuffer) throw new FatalError(`Asset file not found: ${asset.path}`);
|
|
9594
|
+
await uploadReleaseAsset(executor, conn, tag, {
|
|
9595
|
+
assetName,
|
|
9596
|
+
fileBuffer
|
|
9597
|
+
});
|
|
9598
|
+
log$1.info(`Uploaded ${assetName} to ${tag} (Forgejo)`);
|
|
9599
|
+
}
|
|
9600
|
+
async function runReleaseAssets(executor, config) {
|
|
9601
|
+
debug(config, `Config: dryRun=${String(config.dryRun)} cwd=${config.cwd}`);
|
|
9602
|
+
const targets = [...configuredTargets(config.cwd, config.toolingConfig), ...conventionTargets(executor, config.cwd)];
|
|
9603
|
+
const stampedFiles = runVersionStampPhase(executor, config);
|
|
9604
|
+
for (const target of targets) {
|
|
9605
|
+
runBuild(executor, target, config);
|
|
9606
|
+
collectFromDir(executor, target);
|
|
9607
|
+
}
|
|
9608
|
+
verifyStamp(executor, stampedFiles);
|
|
9609
|
+
if (targets.length === 0) {
|
|
9610
|
+
log$1.info(`No release assets configured in .tooling.json or by convention (${CONVENTION_SCRIPT} script)`);
|
|
9611
|
+
return;
|
|
9612
|
+
}
|
|
9613
|
+
if (config.dryRun) {
|
|
9614
|
+
log$1.info("[dry-run] Skipping upload");
|
|
9615
|
+
return;
|
|
9616
|
+
}
|
|
9617
|
+
if (!config.platform) throw new FatalError("Platform connection is required for asset upload");
|
|
9618
|
+
const tagResult = executor.exec("git", [
|
|
9619
|
+
"tag",
|
|
9620
|
+
"--points-at",
|
|
9621
|
+
"HEAD"
|
|
9622
|
+
], { cwd: config.cwd });
|
|
9623
|
+
debugExec(config, "git tag --points-at HEAD", tagResult);
|
|
9624
|
+
if (tagResult.exitCode !== 0) throw new FatalError("Could not determine current tags");
|
|
9625
|
+
const tags = tagResult.stdout.trim().split("\n").filter(Boolean);
|
|
9626
|
+
if (tags.length === 0) {
|
|
9627
|
+
log$1.info("No tags pointing to HEAD, skipping upload");
|
|
9628
|
+
return;
|
|
9629
|
+
}
|
|
9630
|
+
let uploaded = 0;
|
|
9631
|
+
for (const tag of tags) {
|
|
9632
|
+
if (!await releaseExists(executor, config, tag)) {
|
|
9633
|
+
log$1.info(`No release found for ${tag}, skipping (likely a sliding tag)`);
|
|
9634
|
+
continue;
|
|
9671
9635
|
}
|
|
9672
|
-
|
|
9636
|
+
log$1.info(`Uploading assets to release ${tag}`);
|
|
9637
|
+
for (const target of targets) for (const asset of target.files) if (config.platform.type === "github") uploadToGitHub(executor, config, tag, asset);
|
|
9638
|
+
else await uploadToForgejo(executor, config.platform.conn, tag, asset);
|
|
9639
|
+
uploaded++;
|
|
9640
|
+
}
|
|
9641
|
+
if (uploaded === 0) log$1.info("No releases found for any tag pointing to HEAD, skipping upload");
|
|
9673
9642
|
}
|
|
9674
|
-
|
|
9675
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
9678
|
-
|
|
9679
|
-
|
|
9643
|
+
async function releaseExists(executor, config, tag) {
|
|
9644
|
+
if (!config.platform) return false;
|
|
9645
|
+
if (config.platform.type === "forgejo") return findRelease(executor, config.platform.conn, tag);
|
|
9646
|
+
const result = executor.exec("gh", [
|
|
9647
|
+
"release",
|
|
9648
|
+
"view",
|
|
9649
|
+
tag
|
|
9650
|
+
], { cwd: config.cwd });
|
|
9651
|
+
debugExec(config, `gh release view ${tag}`, result);
|
|
9652
|
+
return result.exitCode === 0;
|
|
9653
|
+
}
|
|
9654
|
+
//#endregion
|
|
9655
|
+
//#region src/generators/gitignore.ts
|
|
9656
|
+
/** Entries that every project should have -- repo:sync --check flags these as missing. */
|
|
9657
|
+
const REQUIRED_ENTRIES = [
|
|
9658
|
+
"node_modules/",
|
|
9659
|
+
".pnpm-store/",
|
|
9660
|
+
"dist/",
|
|
9661
|
+
"*.tsbuildinfo",
|
|
9662
|
+
".env",
|
|
9663
|
+
".env.*",
|
|
9664
|
+
"!.env.example"
|
|
9665
|
+
];
|
|
9680
9666
|
/**
|
|
9681
|
-
*
|
|
9682
|
-
* install the pinned `mise`, `mise install` the repo's `mise.toml` toolchain, export its env into
|
|
9683
|
-
* the rest of the job, and cache the pnpm store. `MISE_VERSION`/`MISE_LINUX_X64_SHA256`
|
|
9684
|
-
* (`mise-pin.ts`) are the same pin the agent runner's base image installs, checked by
|
|
9685
|
-
* `runner-base-image-dockerfile.test.ts`.
|
|
9667
|
+
* Tooling-specific entries added during init/update but not required for repo:sync --check.
|
|
9686
9668
|
*
|
|
9687
|
-
*
|
|
9688
|
-
*
|
|
9669
|
+
* `.superpowers/` is agent scratch state (task briefs, review diffs, the SDD progress ledger)
|
|
9670
|
+
* that the superpowers plugin writes into whatever repo it runs in. The plugin's own protection
|
|
9671
|
+
* is a self-ignoring `.superpowers/sdd/.gitignore` it creates as a side effect of running a
|
|
9672
|
+
* skill -- untracked, so it never reaches a clone, and scoped to `sdd/`, so any sibling folder a
|
|
9673
|
+
* future skill writes lands untracked-but-not-ignored and one `git add -A` commits it. Ignore
|
|
9674
|
+
* the whole directory here for the same reason as `.tooling-archived/`: it is tool-generated
|
|
9675
|
+
* scratch that appears without the user asking, so the tool should be what ignores it.
|
|
9689
9676
|
*/
|
|
9690
|
-
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
|
|
9695
|
-
|
|
9696
|
-
|
|
9697
|
-
sha256: MISE_LINUX_X64_SHA256
|
|
9698
|
-
}
|
|
9699
|
-
} }, pnpmStoreCacheStep()];
|
|
9700
|
-
}
|
|
9677
|
+
const OPTIONAL_ENTRIES = [
|
|
9678
|
+
".tooling-migrate.md",
|
|
9679
|
+
".tooling-archived/",
|
|
9680
|
+
".claude/hooks/bst-hook.audit.local.jsonl*",
|
|
9681
|
+
".superpowers/",
|
|
9682
|
+
"coverage/"
|
|
9683
|
+
];
|
|
9701
9684
|
/**
|
|
9702
|
-
*
|
|
9703
|
-
*
|
|
9704
|
-
*
|
|
9705
|
-
*
|
|
9685
|
+
* Optional entries added only when a feature is actually configured.
|
|
9686
|
+
* Patterns are written without a leading slash so they match at any depth --
|
|
9687
|
+
* a single `release-assets/` entry in the root .gitignore covers monorepo
|
|
9688
|
+
* subpackage output like `packages/app/release-assets/` too.
|
|
9706
9689
|
*/
|
|
9707
|
-
function
|
|
9708
|
-
const
|
|
9709
|
-
|
|
9710
|
-
|
|
9711
|
-
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
return
|
|
9718
|
-
|
|
9719
|
-
|
|
9720
|
-
|
|
9721
|
-
|
|
9722
|
-
|
|
9723
|
-
|
|
9724
|
-
|
|
9725
|
-
} },
|
|
9726
|
-
pnpmStoreCacheStep(),
|
|
9727
|
-
{ step: {
|
|
9728
|
-
name: "Install mise",
|
|
9729
|
-
env: { GITHUB_TOKEN: "" },
|
|
9730
|
-
run: installScript
|
|
9731
|
-
} }
|
|
9690
|
+
function conditionalEntries(ctx) {
|
|
9691
|
+
const entries = [];
|
|
9692
|
+
if (hasReleaseAssets(ctx)) entries.push(`${CONVENTION_OUTPUT_DIR}/`);
|
|
9693
|
+
return entries;
|
|
9694
|
+
}
|
|
9695
|
+
/** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
|
|
9696
|
+
function normalizeEntry(entry) {
|
|
9697
|
+
let s = entry.trim();
|
|
9698
|
+
if (s.startsWith("/")) s = s.slice(1);
|
|
9699
|
+
if (s.endsWith("/")) s = s.slice(0, -1);
|
|
9700
|
+
return s;
|
|
9701
|
+
}
|
|
9702
|
+
async function generateGitignore(ctx) {
|
|
9703
|
+
const filePath = ".gitignore";
|
|
9704
|
+
const allEntries = [
|
|
9705
|
+
...REQUIRED_ENTRIES,
|
|
9706
|
+
...OPTIONAL_ENTRIES,
|
|
9707
|
+
...conditionalEntries(ctx)
|
|
9732
9708
|
];
|
|
9709
|
+
const existing = ctx.read(filePath);
|
|
9710
|
+
if (existing) {
|
|
9711
|
+
const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
|
|
9712
|
+
const missing = allEntries.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
|
|
9713
|
+
if (missing.length === 0) return {
|
|
9714
|
+
filePath,
|
|
9715
|
+
action: "skipped",
|
|
9716
|
+
description: "Already has all standard entries"
|
|
9717
|
+
};
|
|
9718
|
+
const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
|
|
9719
|
+
const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
|
|
9720
|
+
ctx.write(filePath, updated);
|
|
9721
|
+
if (missingRequired.length === 0) return {
|
|
9722
|
+
filePath,
|
|
9723
|
+
action: "skipped",
|
|
9724
|
+
description: "Only optional entries missing"
|
|
9725
|
+
};
|
|
9726
|
+
return {
|
|
9727
|
+
filePath,
|
|
9728
|
+
action: "updated",
|
|
9729
|
+
description: `Appended ${String(missing.length)} missing entries`
|
|
9730
|
+
};
|
|
9731
|
+
}
|
|
9732
|
+
ctx.write(filePath, allEntries.join("\n") + "\n");
|
|
9733
|
+
return {
|
|
9734
|
+
filePath,
|
|
9735
|
+
action: "created",
|
|
9736
|
+
description: "Generated .gitignore"
|
|
9737
|
+
};
|
|
9733
9738
|
}
|
|
9739
|
+
//#endregion
|
|
9740
|
+
//#region src/generators/gitattributes.ts
|
|
9741
|
+
const MANAGED_HEADER = ["# Managed by @bensandee/tooling: LF working tree on every platform, overriding", "# core.autocrlf/core.eol at checkout. Rules below this line win over it."].join("\n");
|
|
9742
|
+
/** Placed only when prepending above a consumer's existing content -- explains, in the file
|
|
9743
|
+
* itself, why the rule goes first rather than last (the code has no other way to show it). */
|
|
9744
|
+
const PREPEND_REASON = ["# Prepended above your existing rules: .gitattributes is last-match-wins, so a rule", "# placed below would override any binary/-text marks you already have."].join("\n");
|
|
9745
|
+
const RULE_LINE = "* text=auto eol=lf";
|
|
9734
9746
|
/**
|
|
9735
|
-
*
|
|
9736
|
-
*
|
|
9737
|
-
*
|
|
9738
|
-
*
|
|
9739
|
-
*
|
|
9740
|
-
* stop relying on the user/org-level `DOCKER_USERNAME` / `DOCKER_PASSWORD`
|
|
9741
|
-
* secrets -- which the Forgejo act fork auto-injects into every `container:`
|
|
9742
|
-
* image pull regardless of the target registry.
|
|
9743
|
-
*
|
|
9744
|
-
* When `host` is `null` (couldn't be derived at gen time), the secret references
|
|
9745
|
-
* fall back to neutral `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` and the
|
|
9746
|
-
* workflow keeps `DOCKER_REGISTRY_HOST` / `DOCKER_REGISTRY_NAMESPACE` so the CLI
|
|
9747
|
-
* resolves the registry at runtime.
|
|
9747
|
+
* True when `content` already carries a rule equivalent to `* text=auto eol=lf`: a
|
|
9748
|
+
* non-comment line whose first whitespace-separated token is exactly `*` and whose
|
|
9749
|
+
* remaining tokens include both `text=auto` and `eol=lf`, in either order. Token-based so
|
|
9750
|
+
* reordered or extra-whitespace variants still count, but a narrower pattern (`*.ts ...`)
|
|
9751
|
+
* or a commented-out line does not.
|
|
9748
9752
|
*/
|
|
9749
|
-
function
|
|
9750
|
-
|
|
9753
|
+
function hasLfRule(content) {
|
|
9754
|
+
return content.split("\n").some((line) => {
|
|
9755
|
+
const trimmed = line.trim();
|
|
9756
|
+
if (trimmed.startsWith("#")) return false;
|
|
9757
|
+
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
9758
|
+
if (tokens[0] !== "*") return false;
|
|
9759
|
+
const rest = new Set(tokens.slice(1));
|
|
9760
|
+
return rest.has("text=auto") && rest.has("eol=lf");
|
|
9761
|
+
});
|
|
9762
|
+
}
|
|
9763
|
+
/**
|
|
9764
|
+
* Scaffold `.gitattributes` with `* text=auto eol=lf`, the rule that makes the working tree
|
|
9765
|
+
* LF regardless of a developer's global `core.autocrlf`/`core.eol`. No managed-file header
|
|
9766
|
+
* from `utils/generated-header.ts` -- this file is merged into like `.gitignore`, not
|
|
9767
|
+
* wholesale-regenerated. The rule is required, not optional: a missing rule is
|
|
9768
|
+
* created/updated and fails `repo:sync --check` like any other drift.
|
|
9769
|
+
*/
|
|
9770
|
+
async function generateGitattributes(ctx) {
|
|
9771
|
+
const filePath = ".gitattributes";
|
|
9772
|
+
const existing = ctx.read(filePath);
|
|
9773
|
+
if (existing === void 0 || existing.trim() === "") {
|
|
9774
|
+
ctx.write(filePath, `${MANAGED_HEADER}\n${RULE_LINE}\n`);
|
|
9775
|
+
return {
|
|
9776
|
+
filePath,
|
|
9777
|
+
action: existing === void 0 ? "created" : "updated",
|
|
9778
|
+
description: "Generated .gitattributes"
|
|
9779
|
+
};
|
|
9780
|
+
}
|
|
9781
|
+
if (hasLfRule(existing)) return {
|
|
9782
|
+
filePath,
|
|
9783
|
+
action: "skipped",
|
|
9784
|
+
description: "Already has the LF rule"
|
|
9785
|
+
};
|
|
9786
|
+
const updated = `${MANAGED_HEADER}\n${PREPEND_REASON}\n${RULE_LINE}\n\n${existing}`;
|
|
9787
|
+
ctx.write(filePath, updated);
|
|
9751
9788
|
return {
|
|
9752
|
-
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
} : {},
|
|
9756
|
-
DOCKER_USERNAME: actionsExpr(`secrets.${username}`),
|
|
9757
|
-
DOCKER_PASSWORD: actionsExpr(`secrets.${password}`),
|
|
9758
|
-
RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
|
|
9789
|
+
filePath,
|
|
9790
|
+
action: "updated",
|
|
9791
|
+
description: "Prepended the LF rule"
|
|
9759
9792
|
};
|
|
9760
9793
|
}
|
|
9761
9794
|
//#endregion
|
|
@@ -9952,21 +9985,36 @@ function buildSteps(strategy, ctx) {
|
|
|
9952
9985
|
default: return null;
|
|
9953
9986
|
}
|
|
9954
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
|
+
}
|
|
9955
10003
|
async function generateReleaseCi(ctx) {
|
|
9956
10004
|
const filePath = "release-ci";
|
|
9957
|
-
|
|
10005
|
+
const isGitHub = ctx.config.ci === "github";
|
|
10006
|
+
if (ctx.config.ci === "none" || ctx.config.releaseStrategy === "none") return {
|
|
9958
10007
|
filePath,
|
|
9959
10008
|
action: "skipped",
|
|
9960
10009
|
description: "Release CI workflow not applicable"
|
|
9961
10010
|
};
|
|
9962
|
-
|
|
9963
|
-
if (ctx.config.releaseStrategy === "changesets") return {
|
|
10011
|
+
if (!hasReleaseWorkflow(ctx.config.releaseStrategy)) return {
|
|
9964
10012
|
filePath,
|
|
9965
10013
|
action: "skipped",
|
|
9966
10014
|
description: "Release step included in CI workflow"
|
|
9967
10015
|
};
|
|
9968
|
-
const
|
|
9969
|
-
const workflowPath =
|
|
10016
|
+
const publishesNpm = ctx.config.publishNpm === true;
|
|
10017
|
+
const workflowPath = releaseWorkflowPath(ctx.config.ci);
|
|
9970
10018
|
const hasDocker = hasDockerPackages(ctx) && ctx.config.publishDocker;
|
|
9971
10019
|
const hasAssets = hasReleaseAssets(ctx) || hasVersionStamp(ctx);
|
|
9972
10020
|
const host = resolveGenTimeHost(ctx);
|
|
@@ -10173,7 +10221,13 @@ function checkSteps(ci, coverageEnabled, emitDockerSweepStep) {
|
|
|
10173
10221
|
} }
|
|
10174
10222
|
];
|
|
10175
10223
|
}
|
|
10176
|
-
/**
|
|
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
|
+
*/
|
|
10177
10231
|
function ciWorkflowPath(ci, releaseStrategy) {
|
|
10178
10232
|
return `${ci === "github" ? ".github/workflows" : ".forgejo/workflows"}/${releaseStrategy === "changesets" ? "ci.yml" : "check.yml"}`;
|
|
10179
10233
|
}
|
|
@@ -11172,7 +11226,7 @@ function buildConfig$1(config) {
|
|
|
11172
11226
|
].join("\n");
|
|
11173
11227
|
}
|
|
11174
11228
|
/** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
|
|
11175
|
-
const ARCHIVE_DIR
|
|
11229
|
+
const ARCHIVE_DIR = ".tooling-archived";
|
|
11176
11230
|
/** Common client-side git hooks that husky may have configured. */
|
|
11177
11231
|
const HUSKY_HOOK_NAMES = [
|
|
11178
11232
|
"pre-commit",
|
|
@@ -11206,7 +11260,7 @@ function archiveHuskyHooks(ctx, results) {
|
|
|
11206
11260
|
results.push({
|
|
11207
11261
|
filePath: huskyPath,
|
|
11208
11262
|
action: "archived",
|
|
11209
|
-
description: `Moved to ${ARCHIVE_DIR
|
|
11263
|
+
description: `Moved to ${ARCHIVE_DIR}/${huskyPath}`
|
|
11210
11264
|
});
|
|
11211
11265
|
found = true;
|
|
11212
11266
|
}
|
|
@@ -11221,7 +11275,7 @@ function archiveLintStagedConfigs(ctx, results) {
|
|
|
11221
11275
|
results.push({
|
|
11222
11276
|
filePath: lsPath,
|
|
11223
11277
|
action: "archived",
|
|
11224
|
-
description: `Moved to ${ARCHIVE_DIR
|
|
11278
|
+
description: `Moved to ${ARCHIVE_DIR}/${lsPath}`
|
|
11225
11279
|
});
|
|
11226
11280
|
found = true;
|
|
11227
11281
|
}
|
|
@@ -11429,12 +11483,17 @@ async function generateVscodeSettings(ctx) {
|
|
|
11429
11483
|
}
|
|
11430
11484
|
//#endregion
|
|
11431
11485
|
//#region src/generators/coverage-ci.ts
|
|
11432
|
-
/** Where `ctx.remove()` puts what it archives. Named here only to describe it to the user. */
|
|
11433
|
-
const ARCHIVE_DIR = ".tooling-archived";
|
|
11434
11486
|
/**
|
|
11435
|
-
* Cleanup-only generator: coverage steps now run inline in
|
|
11436
|
-
*
|
|
11437
|
-
*
|
|
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.
|
|
11438
11497
|
*/
|
|
11439
11498
|
async function generateCoverageCi(ctx) {
|
|
11440
11499
|
const filePath = "coverage-ci";
|
|
@@ -11444,19 +11503,23 @@ async function generateCoverageCi(ctx) {
|
|
|
11444
11503
|
description: "CI workflow not requested"
|
|
11445
11504
|
};
|
|
11446
11505
|
const workflowPath = ctx.config.ci === "github" ? ".github/workflows/coverage.yml" : ".forgejo/workflows/coverage.yml";
|
|
11447
|
-
|
|
11448
|
-
|
|
11449
|
-
return {
|
|
11450
|
-
filePath: workflowPath,
|
|
11451
|
-
action: "archived",
|
|
11452
|
-
description: `Moved to ${ARCHIVE_DIR}/${workflowPath} (coverage now runs inline in ci.yml)`
|
|
11453
|
-
};
|
|
11454
|
-
}
|
|
11455
|
-
return {
|
|
11506
|
+
const existing = ctx.read(workflowPath);
|
|
11507
|
+
if (existing === void 0) return {
|
|
11456
11508
|
filePath,
|
|
11457
11509
|
action: "skipped",
|
|
11458
11510
|
description: "Coverage runs inline in ci.yml"
|
|
11459
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
|
+
};
|
|
11460
11523
|
}
|
|
11461
11524
|
//#endregion
|
|
11462
11525
|
//#region src/docs-check/blocks.ts
|
|
@@ -12937,12 +13000,74 @@ async function generateVersionrc(ctx) {
|
|
|
12937
13000
|
};
|
|
12938
13001
|
}
|
|
12939
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
|
|
12940
13064
|
//#region src/generators/pipeline.ts
|
|
12941
13065
|
/** Run all generators sequentially and return their results. */
|
|
12942
13066
|
async function runGenerators(ctx) {
|
|
12943
13067
|
const results = [];
|
|
13068
|
+
const priorDevEngines = ctx.packageJson?.devEngines;
|
|
12944
13069
|
results.push(await generatePackageJson(ctx));
|
|
12945
|
-
results.push(await generateMise(ctx));
|
|
13070
|
+
results.push(await generateMise(ctx, priorDevEngines));
|
|
12946
13071
|
results.push(...await generateWorkspaceEngines(ctx));
|
|
12947
13072
|
results.push(await generatePnpmWorkspace(ctx));
|
|
12948
13073
|
results.push(...await generateTsconfig(ctx));
|
|
@@ -12961,6 +13086,7 @@ async function runGenerators(ctx) {
|
|
|
12961
13086
|
results.push(await generateVersionrc(ctx));
|
|
12962
13087
|
results.push(await generateChangesets(ctx));
|
|
12963
13088
|
results.push(await generateReleaseCi(ctx));
|
|
13089
|
+
results.push(...await generateStaleCiWorkflows(ctx));
|
|
12964
13090
|
results.push(await generateDeployCi(ctx));
|
|
12965
13091
|
results.push(await generateCoverageCi(ctx));
|
|
12966
13092
|
results.push(...await generateClaudeMd(ctx));
|
|
@@ -12993,7 +13119,7 @@ function reportFailures(failed, command) {
|
|
|
12993
13119
|
function generateMigratePrompt(results, config, detected) {
|
|
12994
13120
|
const sections = [];
|
|
12995
13121
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
12996
|
-
sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.
|
|
13122
|
+
sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.1 repo:sync\` on ${timestamp}_\n`);
|
|
12997
13123
|
const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
|
|
12998
13124
|
if (changed.length > 0) {
|
|
12999
13125
|
const items = changed.map((r) => {
|
|
@@ -22082,12 +22208,16 @@ const PROBE_TIMEOUT_SECONDS = "5";
|
|
|
22082
22208
|
* Pinned by digest, not a tag: a tag-pinned probe image would re-pull (and
|
|
22083
22209
|
* could silently change) on every check run, reintroducing this exact
|
|
22084
22210
|
* issue's silent-timeout symptom on the very shared-agent CI runners it
|
|
22085
|
-
* targets, since registry egress is often restricted there.
|
|
22086
|
-
*
|
|
22087
|
-
*
|
|
22088
|
-
*
|
|
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.
|
|
22089
22219
|
*/
|
|
22090
|
-
const PROBE_IMAGE = "curlimages/curl@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
|
|
22220
|
+
const PROBE_IMAGE = "curlimages/curl:8.21.0@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13";
|
|
22091
22221
|
/** Look up the health status of a specific service from container info. */
|
|
22092
22222
|
function getContainerHealth(containers, serviceName) {
|
|
22093
22223
|
return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
|
|
@@ -32363,6 +32493,11 @@ function taskLabelNumber(text) {
|
|
|
32363
32493
|
* statement speaks for every future slice, the progress note only for this one. `**Progress:**`
|
|
32364
32494
|
* and `**Blocked:**` alone declare nothing.
|
|
32365
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
|
+
*
|
|
32366
32501
|
* A box is the **retire task** when its title starts with optional `**` or `__`, then the
|
|
32367
32502
|
* literal `Retire this plan`, word-bounded (not followed by a letter or digit). That
|
|
32368
32503
|
* classification carries no judgement about whether the task is done; `isOutstanding` reads a
|
|
@@ -32390,6 +32525,49 @@ function noteKind(label) {
|
|
|
32390
32525
|
if (label === "Blocked") return "blocked";
|
|
32391
32526
|
return "declined";
|
|
32392
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
|
+
}
|
|
32393
32571
|
/**
|
|
32394
32572
|
* The checkbox marker stripped, then a `**`/`__` span wrapping the whole title unwrapped (only
|
|
32395
32573
|
* when the closing marker matches the opening one, so a title merely starting or ending with the
|
|
@@ -32426,7 +32604,7 @@ function parsePlanBoxes(planText) {
|
|
|
32426
32604
|
break;
|
|
32427
32605
|
}
|
|
32428
32606
|
}
|
|
32429
|
-
const title = unwrapEmphasis(box.rawTitle);
|
|
32607
|
+
const title = stripStrong(unwrapEmphasis(firstSentence$1(boxParagraph(lines, fenced, box.index, blockEnd, box.rawTitle))));
|
|
32430
32608
|
const retireTask = RETIRE_TASK_REGEX.test(title);
|
|
32431
32609
|
if (box.checked) return {
|
|
32432
32610
|
line: box.index,
|
|
@@ -32959,7 +33137,7 @@ setForgejoLogger(log$1);
|
|
|
32959
33137
|
* `local <sha>[-dirty]` otherwise -- so an artifact built from the working tree
|
|
32960
33138
|
* can't pass itself off as the published release whose version it carries.
|
|
32961
33139
|
*/
|
|
32962
|
-
const displayVersion = "0.87.
|
|
33140
|
+
const displayVersion = "0.87.1";
|
|
32963
33141
|
/**
|
|
32964
33142
|
* The registered `bst` subcommand map, exported so both the JSON entry path below (resolving a
|
|
32965
33143
|
* command name before citty parses anything) and the coverage test (enumerating every
|