@bensandee/tooling 0.63.0 → 0.64.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 +322 -73
- package/package.json +1 -1
package/dist/bin.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { execFileSync, execSync } from "node:child_process";
|
|
9
9
|
import JSON5 from "json5";
|
|
10
|
-
import { parse } from "jsonc-parser";
|
|
10
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
|
|
13
13
|
import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify, visit } from "yaml";
|
|
@@ -2006,23 +2006,149 @@ function diagnoseCheckScriptForwardingAt(targetDir) {
|
|
|
2006
2006
|
//#endregion
|
|
2007
2007
|
//#region src/generators/package-json.ts
|
|
2008
2008
|
/**
|
|
2009
|
-
*
|
|
2009
|
+
* Single source of truth for the supported Node floor. `engines.node` is the
|
|
2010
|
+
* honest minimum (`>=<floor>`); `devEngines.runtime.version` is the caret form
|
|
2011
|
+
* (`^<floor>` = `>=floor <nextmajor>`), which serves two masters at once:
|
|
2010
2012
|
*
|
|
2011
|
-
* `actions/setup-node` reads `devEngines.runtime` (for `name: "node"`) before
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
2014
|
-
*
|
|
2015
|
-
* `
|
|
2016
|
-
*
|
|
2013
|
+
* - `actions/setup-node` reads `devEngines.runtime` (for `name: "node"`) before
|
|
2014
|
+
* `engines.node`. The `<nextmajor>` upper bound keeps it on the pinned LTS
|
|
2015
|
+
* major (resolving to the latest patch within it) instead of floating to the
|
|
2016
|
+
* newest released major (e.g. Node 26) the way a bare `>=` range would.
|
|
2017
|
+
* - pnpm resolves `devEngines.runtime.version` to its floor and checks it
|
|
2018
|
+
* against `engines.node`. The caret's floor equals the engines floor, so the
|
|
2019
|
+
* two never contradict — a `<major>.x` floor of `<major>.0.0` sits below
|
|
2020
|
+
* `>=<floor>` and makes `pnpm install` emit a spurious "Unsupported engine"
|
|
2021
|
+
* warning. The floor MUST track `engines.node`, so it is derived from it
|
|
2022
|
+
* rather than hard-coded.
|
|
2023
|
+
*
|
|
2024
|
+
* `onFail: "warn"` keeps a local Node mismatch non-fatal.
|
|
2017
2025
|
*/
|
|
2018
|
-
const
|
|
2026
|
+
const NODE_ENGINES_FLOOR = "24.13.0";
|
|
2027
|
+
const ENGINES_NODE = `>=${NODE_ENGINES_FLOOR}`;
|
|
2028
|
+
/** The `^<floor>` runtime range whose floor satisfies a `>=<floor>` engines range. */
|
|
2029
|
+
function caretRange(floor) {
|
|
2030
|
+
return `^${floor}`;
|
|
2031
|
+
}
|
|
2019
2032
|
function devEnginesRuntime() {
|
|
2020
2033
|
return { runtime: {
|
|
2021
2034
|
name: "node",
|
|
2022
|
-
version:
|
|
2035
|
+
version: caretRange(NODE_ENGINES_FLOOR),
|
|
2023
2036
|
onFail: "warn"
|
|
2024
2037
|
} };
|
|
2025
2038
|
}
|
|
2039
|
+
function versionPart(s) {
|
|
2040
|
+
return s && s !== "x" && s !== "*" ? Number(s) : 0;
|
|
2041
|
+
}
|
|
2042
|
+
function compareTuples(a, b) {
|
|
2043
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
2044
|
+
return 0;
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Effective lower bound of a single conjunction (space-separated comparators like
|
|
2048
|
+
* `>=24.14.0 <25.0.0`). Upper bounds (`<`, `<=`) don't raise the floor and are
|
|
2049
|
+
* ignored; the floor is the **max** of the remaining lower-bound tokens (`>=`,
|
|
2050
|
+
* `>`, `^`, `~`, `=`, bare, `<major>.x`). Missing/wildcard positions read as 0.
|
|
2051
|
+
* `undefined` when no token carries a numeric lower bound.
|
|
2052
|
+
*/
|
|
2053
|
+
function conjunctionFloor(conjunction) {
|
|
2054
|
+
let floor;
|
|
2055
|
+
for (const token of conjunction.trim().split(/\s+/)) {
|
|
2056
|
+
if (!token || token.startsWith("<")) continue;
|
|
2057
|
+
const match = /(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(token);
|
|
2058
|
+
if (!match) continue;
|
|
2059
|
+
const candidate = [
|
|
2060
|
+
Number(match[1]),
|
|
2061
|
+
versionPart(match[2]),
|
|
2062
|
+
versionPart(match[3])
|
|
2063
|
+
];
|
|
2064
|
+
if (!floor || compareTuples(candidate, floor) > 0) floor = candidate;
|
|
2065
|
+
}
|
|
2066
|
+
return floor;
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Lowest version a semver range permits, as a `[major, minor, patch]` tuple —
|
|
2070
|
+
* what pnpm resolves a `devEngines.runtime.version` to when cross-checking
|
|
2071
|
+
* `engines.node`. Handles floor-first forms (`>=24.14.0`, `^24.13.0`, `24.x`,
|
|
2072
|
+
* `24`), compound ranges in any order (`>=24.14.0 <25.0.0`, `<25 >=24.14.0` →
|
|
2073
|
+
* `24.14.0`), and `||` alternation (the minimum across alternatives).
|
|
2074
|
+
*
|
|
2075
|
+
* Returns `undefined` when no floor can be determined (e.g. `*`, `latest`, an
|
|
2076
|
+
* alternative with only an upper bound) — callers must warn rather than guess.
|
|
2077
|
+
*/
|
|
2078
|
+
function minVersionTuple(range) {
|
|
2079
|
+
let min;
|
|
2080
|
+
for (const alternative of range.split("||")) {
|
|
2081
|
+
const floor = conjunctionFloor(alternative);
|
|
2082
|
+
if (!floor) return void 0;
|
|
2083
|
+
if (!min || compareTuples(floor, min) < 0) min = floor;
|
|
2084
|
+
}
|
|
2085
|
+
return min;
|
|
2086
|
+
}
|
|
2087
|
+
/** True when `range`'s floor is >= `enginesNode`'s floor (i.e. pnpm won't warn). */
|
|
2088
|
+
function runtimeFloorSatisfies(range, enginesNode) {
|
|
2089
|
+
const dev = minVersionTuple(range);
|
|
2090
|
+
const eng = minVersionTuple(enginesNode);
|
|
2091
|
+
if (!dev || !eng) return true;
|
|
2092
|
+
return compareTuples(dev, eng) >= 0;
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Decide how to reconcile a package's `devEngines.runtime` (node entries) with
|
|
2096
|
+
* its `engines.node` floor, without mutating anything. The caret form `^<floor>`
|
|
2097
|
+
* keeps pnpm quiet (its floor equals the engines floor) while staying bounded to
|
|
2098
|
+
* the same major for `setup-node`.
|
|
2099
|
+
*
|
|
2100
|
+
* - `noop` — no node runtime declared, or every node runtime already satisfies
|
|
2101
|
+
* the engines floor (a deliberate higher pin is left alone). Never adds a
|
|
2102
|
+
* `devEngines.runtime` that isn't already there.
|
|
2103
|
+
* - `warn` — a node runtime is declared but `engines.node` has no determinable
|
|
2104
|
+
* floor; leave it unchanged rather than guess.
|
|
2105
|
+
* - `fix` — `staleIndices` index the node runtimes (in array order, or `[0]`
|
|
2106
|
+
* for the single-object form) whose `version` must become `desired`.
|
|
2107
|
+
*/
|
|
2108
|
+
function planDevEnginesFix(pkg, enginesNode) {
|
|
2109
|
+
const runtimeField = pkg["devEngines"]?.runtime;
|
|
2110
|
+
if (!runtimeField) return { kind: "noop" };
|
|
2111
|
+
const nodeEntries = (Array.isArray(runtimeField) ? runtimeField : [runtimeField]).map((runtime, index) => ({
|
|
2112
|
+
runtime,
|
|
2113
|
+
index
|
|
2114
|
+
})).filter(({ runtime }) => runtime.name === "node" && typeof runtime.version === "string");
|
|
2115
|
+
if (nodeEntries.length === 0) return { kind: "noop" };
|
|
2116
|
+
const floor = minVersionTuple(enginesNode);
|
|
2117
|
+
if (!floor) return {
|
|
2118
|
+
kind: "warn",
|
|
2119
|
+
message: `could not determine a floor from engines.node "${enginesNode}"`
|
|
2120
|
+
};
|
|
2121
|
+
const desired = caretRange(`${floor[0]}.${floor[1]}.${floor[2]}`);
|
|
2122
|
+
const staleIndices = nodeEntries.filter(({ runtime }) => !runtimeFloorSatisfies(runtime.version ?? "", enginesNode)).map(({ index }) => index);
|
|
2123
|
+
if (staleIndices.length === 0) return { kind: "noop" };
|
|
2124
|
+
return {
|
|
2125
|
+
kind: "fix",
|
|
2126
|
+
desired,
|
|
2127
|
+
staleIndices
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Correct an existing `devEngines.runtime` (node) in place so its floor matches
|
|
2132
|
+
* `engines.node` — only the `version` changes; `name` and `onFail` are
|
|
2133
|
+
* preserved. Never adds `devEngines.runtime` to a package that lacks it.
|
|
2134
|
+
*/
|
|
2135
|
+
function ensureDevEnginesConsistent(pkg, enginesNode, changes) {
|
|
2136
|
+
const plan = planDevEnginesFix(pkg, enginesNode);
|
|
2137
|
+
if (plan.kind === "warn") {
|
|
2138
|
+
log.warn(`package.json: ${plan.message}; leaving devEngines.runtime unchanged`);
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
if (plan.kind !== "fix") return;
|
|
2142
|
+
const runtimeField = pkg["devEngines"]?.runtime;
|
|
2143
|
+
const runtimes = Array.isArray(runtimeField) ? runtimeField : runtimeField ? [runtimeField] : [];
|
|
2144
|
+
for (const index of plan.staleIndices) {
|
|
2145
|
+
const runtime = runtimes[index];
|
|
2146
|
+
if (!runtime) continue;
|
|
2147
|
+
const previous = runtime.version;
|
|
2148
|
+
runtime.version = plan.desired;
|
|
2149
|
+
changes.push(`bumped devEngines.runtime node ${previous} → ${plan.desired}`);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2026
2152
|
const STANDARD_SCRIPTS_SINGLE = {
|
|
2027
2153
|
build: "tsdown",
|
|
2028
2154
|
dev: "tsdown --watch",
|
|
@@ -2332,13 +2458,11 @@ function applyMetadataDefaults(pkg, ctx, changes) {
|
|
|
2332
2458
|
}
|
|
2333
2459
|
}
|
|
2334
2460
|
if (!pkg["engines"]) {
|
|
2335
|
-
pkg["engines"] = { node:
|
|
2336
|
-
changes.push(
|
|
2337
|
-
}
|
|
2338
|
-
if (!pkg["devEngines"]) {
|
|
2339
|
-
pkg["devEngines"] = devEnginesRuntime();
|
|
2340
|
-
changes.push(`set devEngines.runtime node ${DEV_NODE_VERSION}`);
|
|
2461
|
+
pkg["engines"] = { node: ENGINES_NODE };
|
|
2462
|
+
changes.push(`set engines.node ${ENGINES_NODE}`);
|
|
2341
2463
|
}
|
|
2464
|
+
const enginesNode = pkg["engines"]?.["node"];
|
|
2465
|
+
if (typeof enginesNode === "string") ensureDevEnginesConsistent(pkg, enginesNode, changes);
|
|
2342
2466
|
const pinnedPnpm = {
|
|
2343
2467
|
"@changesets/cli": "2.31.0",
|
|
2344
2468
|
"@release-it/bumper": "7.0.6",
|
|
@@ -2392,7 +2516,7 @@ function buildManagedDevDeps(ctx) {
|
|
|
2392
2516
|
const devDeps = { ...ROOT_DEV_DEPS };
|
|
2393
2517
|
if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
|
|
2394
2518
|
devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.12.0";
|
|
2395
|
-
if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.
|
|
2519
|
+
if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.64.1";
|
|
2396
2520
|
if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.1";
|
|
2397
2521
|
if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
|
|
2398
2522
|
"@changesets/cli": "2.31.0",
|
|
@@ -2472,7 +2596,7 @@ async function generatePackageJson(ctx) {
|
|
|
2472
2596
|
type: "module",
|
|
2473
2597
|
scripts: allScripts,
|
|
2474
2598
|
devDependencies: devDeps,
|
|
2475
|
-
engines: { node:
|
|
2599
|
+
engines: { node: ENGINES_NODE },
|
|
2476
2600
|
devEngines: devEnginesRuntime(),
|
|
2477
2601
|
packageManager: `pnpm@${{
|
|
2478
2602
|
"@changesets/cli": "2.31.0",
|
|
@@ -2498,6 +2622,63 @@ async function generatePackageJson(ctx) {
|
|
|
2498
2622
|
description: "Generated package.json"
|
|
2499
2623
|
};
|
|
2500
2624
|
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Reconcile `devEngines.runtime` against `engines.node` in nested workspace
|
|
2627
|
+
* packages — pnpm's "Unsupported engine" warning fires per-package, so a
|
|
2628
|
+
* subpackage that declares both fields can warn even when the root is clean.
|
|
2629
|
+
*
|
|
2630
|
+
* Unlike the root `package.json` (a fully managed file), a subpackage's
|
|
2631
|
+
* `package.json` is otherwise owned by the user, so this edits *only* the stale
|
|
2632
|
+
* `version` string via `jsonc-parser` rather than reserialising the whole file
|
|
2633
|
+
* (which would reorder keys and produce a large surprising diff). It never adds
|
|
2634
|
+
* `engines.node` or `devEngines.runtime` — it touches a package only when both
|
|
2635
|
+
* already exist and a node runtime's floor is below the engines floor.
|
|
2636
|
+
*
|
|
2637
|
+
* Scans `packages/*` (the same convention as the rest of the tool); other
|
|
2638
|
+
* workspace layouts (`apps/*`, custom globs) are out of scope.
|
|
2639
|
+
*/
|
|
2640
|
+
async function generateWorkspaceEngines(ctx) {
|
|
2641
|
+
if (ctx.config.structure !== "monorepo") return [];
|
|
2642
|
+
const results = [];
|
|
2643
|
+
for (const member of getMonorepoPackages(ctx.targetDir)) {
|
|
2644
|
+
const result = reconcileWorkspaceDevEngines(ctx, path.join(path.relative(ctx.targetDir, member.dir), "package.json"));
|
|
2645
|
+
if (result) results.push(result);
|
|
2646
|
+
}
|
|
2647
|
+
return results;
|
|
2648
|
+
}
|
|
2649
|
+
/** Surgically fix a single nested package.json's devEngines, preserving its formatting. */
|
|
2650
|
+
function reconcileWorkspaceDevEngines(ctx, relPath) {
|
|
2651
|
+
const raw = ctx.read(relPath);
|
|
2652
|
+
if (!raw) return void 0;
|
|
2653
|
+
const pkg = parsePackageJson(raw);
|
|
2654
|
+
if (!pkg) return void 0;
|
|
2655
|
+
const enginesNode = pkg["engines"]?.["node"];
|
|
2656
|
+
if (typeof enginesNode !== "string") return void 0;
|
|
2657
|
+
const plan = planDevEnginesFix(pkg, enginesNode);
|
|
2658
|
+
if (plan.kind === "warn") {
|
|
2659
|
+
log.warn(`${relPath}: ${plan.message}; leaving devEngines.runtime unchanged`);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
if (plan.kind !== "fix") return void 0;
|
|
2663
|
+
const isArray = Array.isArray(pkg["devEngines"]?.runtime);
|
|
2664
|
+
let text = raw;
|
|
2665
|
+
for (const index of plan.staleIndices) text = applyEdits(text, modify(text, isArray ? [
|
|
2666
|
+
"devEngines",
|
|
2667
|
+
"runtime",
|
|
2668
|
+
index,
|
|
2669
|
+
"version"
|
|
2670
|
+
] : [
|
|
2671
|
+
"devEngines",
|
|
2672
|
+
"runtime",
|
|
2673
|
+
"version"
|
|
2674
|
+
], plan.desired, {}));
|
|
2675
|
+
ctx.write(relPath, text);
|
|
2676
|
+
return {
|
|
2677
|
+
filePath: relPath,
|
|
2678
|
+
action: "updated",
|
|
2679
|
+
description: `bumped devEngines.runtime node → ${plan.desired}`
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2501
2682
|
//#endregion
|
|
2502
2683
|
//#region src/generators/tsconfig.ts
|
|
2503
2684
|
async function generateTsconfig(ctx) {
|
|
@@ -3301,8 +3482,12 @@ async function generateTsdown(ctx) {
|
|
|
3301
3482
|
}
|
|
3302
3483
|
//#endregion
|
|
3303
3484
|
//#region src/release/forgejo.ts
|
|
3304
|
-
|
|
3305
|
-
const
|
|
3485
|
+
/** Patient-repair attempts (delete frozen release, wait, retry) before escalating. */
|
|
3486
|
+
const SETTLE_ATTEMPTS = 4;
|
|
3487
|
+
/** Base backoff between patient-repair attempts; doubles each attempt. */
|
|
3488
|
+
const SETTLE_BASE_DELAY_MS = 2e3;
|
|
3489
|
+
/** Cap on a single patient-repair backoff. */
|
|
3490
|
+
const SETTLE_MAX_DELAY_MS = 16e3;
|
|
3306
3491
|
/** Safely read response body text for inclusion in error messages. */
|
|
3307
3492
|
async function responseBodyText(res) {
|
|
3308
3493
|
try {
|
|
@@ -3510,20 +3695,29 @@ async function getReleaseByTag(executor, conn, tag) {
|
|
|
3510
3695
|
if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
|
|
3511
3696
|
return parsed.data;
|
|
3512
3697
|
}
|
|
3513
|
-
/**
|
|
3514
|
-
|
|
3698
|
+
/**
|
|
3699
|
+
* Create a Forgejo release for a given tag.
|
|
3700
|
+
*
|
|
3701
|
+
* When `options.target` (a commit SHA or branch) is provided and the tag does
|
|
3702
|
+
* not yet exist, Forgejo **creates the tag itself** from that commitish and
|
|
3703
|
+
* stamps `created_unix = now` synchronously — sidestepping the buggy async
|
|
3704
|
+
* stamping of a pushed tag. The created tag is lightweight.
|
|
3705
|
+
*/
|
|
3706
|
+
async function createRelease(executor, conn, tag, options = {}) {
|
|
3515
3707
|
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
|
|
3708
|
+
const payload = {
|
|
3709
|
+
tag_name: tag,
|
|
3710
|
+
name: tag,
|
|
3711
|
+
body: `Published ${tag}`
|
|
3712
|
+
};
|
|
3713
|
+
if (options.target) payload["target_commitish"] = options.target;
|
|
3516
3714
|
const res = await executor.fetch(url, {
|
|
3517
3715
|
method: "POST",
|
|
3518
3716
|
headers: {
|
|
3519
3717
|
Authorization: `token ${conn.token}`,
|
|
3520
3718
|
"Content-Type": "application/json"
|
|
3521
3719
|
},
|
|
3522
|
-
body: JSON.stringify(
|
|
3523
|
-
tag_name: tag,
|
|
3524
|
-
name: tag,
|
|
3525
|
-
body: `Published ${tag}`
|
|
3526
|
-
})
|
|
3720
|
+
body: JSON.stringify(payload)
|
|
3527
3721
|
});
|
|
3528
3722
|
if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
|
|
3529
3723
|
verb: "create release",
|
|
@@ -3531,6 +3725,26 @@ async function createRelease(executor, conn, tag) {
|
|
|
3531
3725
|
}));
|
|
3532
3726
|
}
|
|
3533
3727
|
/**
|
|
3728
|
+
* Delete a git tag from the repository (`DELETE /repos/{repo}/tags/{tag}`).
|
|
3729
|
+
*
|
|
3730
|
+
* Used by {@link ensureRelease} to take tag ownership away from the buggy
|
|
3731
|
+
* push-ingestion path: once the tag is gone, recreating the release with a
|
|
3732
|
+
* `target_commitish` makes Forgejo create the tag fresh with a real date. A 404
|
|
3733
|
+
* (already gone) is treated as success.
|
|
3734
|
+
*/
|
|
3735
|
+
async function deleteTag(executor, conn, tag) {
|
|
3736
|
+
const encodedTag = encodeURIComponent(tag);
|
|
3737
|
+
const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/tags/${encodedTag}`;
|
|
3738
|
+
const res = await executor.fetch(url, {
|
|
3739
|
+
method: "DELETE",
|
|
3740
|
+
headers: { Authorization: `token ${conn.token}` }
|
|
3741
|
+
});
|
|
3742
|
+
if (!res.ok && res.status !== 404) throw new TransientError(await forgejoErrorMessage(res, {
|
|
3743
|
+
verb: "delete tag",
|
|
3744
|
+
resource: tag
|
|
3745
|
+
}));
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3534
3748
|
* Delete a Forgejo release by id. Does **not** delete the underlying git tag
|
|
3535
3749
|
* (no `delete_tag` query param) — the tag is the source of truth, and a
|
|
3536
3750
|
* subsequent `POST /releases` relies on it still existing to insert a fresh
|
|
@@ -3550,62 +3764,83 @@ async function deleteRelease(executor, conn, releaseId) {
|
|
|
3550
3764
|
/**
|
|
3551
3765
|
* Ensure a Forgejo release exists for a tag **with a real created date**.
|
|
3552
3766
|
*
|
|
3553
|
-
*
|
|
3554
|
-
*
|
|
3555
|
-
*
|
|
3556
|
-
*
|
|
3557
|
-
* real date or throws:
|
|
3767
|
+
* Forgejo stamps a pushed annotated tag's date *asynchronously*; a release
|
|
3768
|
+
* creation that races that stamp freezes the row at the Unix epoch (see
|
|
3769
|
+
* {@link isEpochReleaseDate}) — a published-but-invisible release, because
|
|
3770
|
+
* `/releases/latest` sorts by date. The failure is intermittent. The repair:
|
|
3558
3771
|
*
|
|
3559
|
-
* 1. A real-dated release already exists →
|
|
3560
|
-
* 2.
|
|
3561
|
-
*
|
|
3562
|
-
*
|
|
3563
|
-
*
|
|
3564
|
-
*
|
|
3565
|
-
*
|
|
3772
|
+
* 1. A real-dated release already exists → `"exists"`.
|
|
3773
|
+
* 2. Otherwise create the release and read it back. On epoch, delete the frozen
|
|
3774
|
+
* release and wait (increasing backoff) for Forgejo to settle the tag's date,
|
|
3775
|
+
* then retry — **preserving the pushed (annotated) tag**. The frozen release
|
|
3776
|
+
* is never deleted-and-left-empty.
|
|
3777
|
+
* 3. If the date still never settles and a `target` commit is known, delete the
|
|
3778
|
+
* tag and recreate the release with `target_commitish` so **Forgejo creates
|
|
3779
|
+
* the tag itself**, stamped `now`. The tag becomes lightweight. → `"repaired"`.
|
|
3780
|
+
* 4. If nothing yields a real date, leave a visible release row (rather than only
|
|
3781
|
+
* an invisible tag) and throw loudly.
|
|
3566
3782
|
*
|
|
3567
|
-
*
|
|
3568
|
-
* `"created" | "exists" | "race" | "repaired"`.
|
|
3783
|
+
* Returns `"created" | "exists" | "race" | "repaired"`.
|
|
3569
3784
|
*/
|
|
3570
|
-
async function ensureRelease(executor, conn, tag) {
|
|
3785
|
+
async function ensureRelease(executor, conn, tag, options = {}) {
|
|
3786
|
+
const attempts = options.attempts ?? SETTLE_ATTEMPTS;
|
|
3787
|
+
const baseDelayMs = options.baseDelayMs ?? SETTLE_BASE_DELAY_MS;
|
|
3571
3788
|
const existing = await getReleaseByTag(executor, conn, tag);
|
|
3572
3789
|
if (existing && !isEpochReleaseDate(existing.created_at)) return "exists";
|
|
3790
|
+
let current = existing;
|
|
3573
3791
|
let repairing = false;
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3792
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
3793
|
+
if (current && isEpochReleaseDate(current.created_at)) {
|
|
3794
|
+
await deleteRelease(executor, conn, current.id);
|
|
3795
|
+
current = null;
|
|
3796
|
+
repairing = true;
|
|
3797
|
+
await settleBackoff(attempt, baseDelayMs);
|
|
3798
|
+
}
|
|
3580
3799
|
try {
|
|
3581
3800
|
await createRelease(executor, conn, tag);
|
|
3582
3801
|
} catch (error) {
|
|
3583
3802
|
const raced = await getReleaseByTag(executor, conn, tag);
|
|
3584
3803
|
if (raced && !isEpochReleaseDate(raced.created_at)) return "race";
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
if (attempt >= RETRY_ATTEMPTS) throw error;
|
|
3590
|
-
log.warn(`Release creation attempt ${String(attempt)}/${String(RETRY_ATTEMPTS)} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3591
|
-
await backoff(attempt);
|
|
3804
|
+
current = raced;
|
|
3805
|
+
if (attempt >= attempts) break;
|
|
3806
|
+
log.warn(`Release creation attempt ${String(attempt)}/${String(attempts)} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3807
|
+
await settleBackoff(attempt, baseDelayMs);
|
|
3592
3808
|
continue;
|
|
3593
3809
|
}
|
|
3594
|
-
|
|
3595
|
-
if (
|
|
3596
|
-
if (
|
|
3597
|
-
|
|
3598
|
-
|
|
3810
|
+
current = await getReleaseByTag(executor, conn, tag);
|
|
3811
|
+
if (current && !isEpochReleaseDate(current.created_at)) return repairing ? "repaired" : "created";
|
|
3812
|
+
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`);
|
|
3813
|
+
}
|
|
3814
|
+
if (options.target && await recreateTagViaApi(executor, conn, tag, {
|
|
3815
|
+
target: options.target,
|
|
3816
|
+
current,
|
|
3817
|
+
attempts,
|
|
3818
|
+
baseDelayMs
|
|
3819
|
+
})) return "repaired";
|
|
3820
|
+
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.`));
|
|
3821
|
+
}
|
|
3822
|
+
async function recreateTagViaApi(executor, conn, tag, opts) {
|
|
3823
|
+
const { target, current, attempts, baseDelayMs } = opts;
|
|
3824
|
+
log.warn(`Release for ${tag} stayed epoch-dated — recreating the tag via the API so Forgejo stamps a real date (the tag becomes lightweight)`);
|
|
3825
|
+
if (current) await deleteRelease(executor, conn, current.id);
|
|
3826
|
+
await deleteTag(executor, conn, tag);
|
|
3827
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
3828
|
+
try {
|
|
3829
|
+
await createRelease(executor, conn, tag, { target });
|
|
3830
|
+
} catch (error) {
|
|
3831
|
+
if (attempt >= attempts) throw new TransientError(`Release for ${tag} was deleted to repair its epoch date, but recreating it from ${target} failed: ${error instanceof Error ? error.message : String(error)}. The tag no longer exists on the server — re-push it (or re-run the release) to restore it.`);
|
|
3832
|
+
await settleBackoff(attempt, baseDelayMs);
|
|
3833
|
+
continue;
|
|
3599
3834
|
}
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
await backoff(attempt);
|
|
3835
|
+
const recreated = await getReleaseByTag(executor, conn, tag);
|
|
3836
|
+
return Boolean(recreated && !isEpochReleaseDate(recreated.created_at));
|
|
3603
3837
|
}
|
|
3604
|
-
|
|
3838
|
+
return false;
|
|
3605
3839
|
}
|
|
3606
|
-
/** Exponential backoff between
|
|
3607
|
-
async function
|
|
3608
|
-
|
|
3840
|
+
/** Exponential backoff (capped) between patient-repair attempts; a non-positive base is a no-op. */
|
|
3841
|
+
async function settleBackoff(attempt, baseDelayMs) {
|
|
3842
|
+
if (baseDelayMs <= 0) return;
|
|
3843
|
+
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), SETTLE_MAX_DELAY_MS);
|
|
3609
3844
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
3610
3845
|
}
|
|
3611
3846
|
/** Upload an asset file to a Forgejo release. */
|
|
@@ -5701,6 +5936,7 @@ async function generateVersionrc(ctx) {
|
|
|
5701
5936
|
async function runGenerators(ctx) {
|
|
5702
5937
|
const results = [];
|
|
5703
5938
|
results.push(await generatePackageJson(ctx));
|
|
5939
|
+
results.push(...await generateWorkspaceEngines(ctx));
|
|
5704
5940
|
results.push(await generatePnpmWorkspace(ctx));
|
|
5705
5941
|
results.push(...await generateTsconfig(ctx));
|
|
5706
5942
|
results.push(await generateTsdown(ctx));
|
|
@@ -5734,7 +5970,7 @@ async function runGenerators(ctx) {
|
|
|
5734
5970
|
function generateMigratePrompt(results, config, detected) {
|
|
5735
5971
|
const sections = [];
|
|
5736
5972
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
5737
|
-
sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.
|
|
5973
|
+
sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.64.1 repo:sync\` on ${timestamp}_\n`);
|
|
5738
5974
|
const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
|
|
5739
5975
|
if (changed.length > 0) {
|
|
5740
5976
|
const items = changed.map((r) => {
|
|
@@ -6214,6 +6450,17 @@ function computeSlidingTags(version) {
|
|
|
6214
6450
|
"latest"
|
|
6215
6451
|
];
|
|
6216
6452
|
}
|
|
6453
|
+
/**
|
|
6454
|
+
* Resolve the commit SHA a tag points at (`git rev-parse <tag>^{commit}`), or
|
|
6455
|
+
* `undefined` if it can't be resolved. Used to pass a `target` to
|
|
6456
|
+
* {@link ensureRelease} so it can recreate a tag through Forgejo's API when the
|
|
6457
|
+
* pushed tag's release date never settles.
|
|
6458
|
+
*/
|
|
6459
|
+
function resolveTagCommit(executor, cwd, tag) {
|
|
6460
|
+
const result = executor.exec("git", ["rev-parse", `${tag}^{commit}`], { cwd });
|
|
6461
|
+
const sha = result.stdout.trim();
|
|
6462
|
+
return result.exitCode === 0 && sha ? sha : void 0;
|
|
6463
|
+
}
|
|
6217
6464
|
/** Parse "New tag:" lines from changeset publish output. */
|
|
6218
6465
|
function parseNewTags(output) {
|
|
6219
6466
|
const tags = [];
|
|
@@ -6532,7 +6779,7 @@ async function runPublishMode(executor, config) {
|
|
|
6532
6779
|
for (const tag of allTags) try {
|
|
6533
6780
|
pushTagToRemote(executor, config, auth, tag, remoteSet);
|
|
6534
6781
|
debug(config, `Ensuring Forgejo release for ${tag}`);
|
|
6535
|
-
if (await ensureRelease(executor, conn, tag) === "exists") log.warn(`Release for ${tag} already exists — skipping`);
|
|
6782
|
+
if (await ensureRelease(executor, conn, tag, { target: resolveTagCommit(executor, config.cwd, tag) }) === "exists") log.warn(`Release for ${tag} already exists — skipping`);
|
|
6536
6783
|
else log.info(`Created release for ${tag}`);
|
|
6537
6784
|
} catch (error) {
|
|
6538
6785
|
errors.push({
|
|
@@ -6869,7 +7116,8 @@ const createForgejoReleaseCommand = defineCommand({
|
|
|
6869
7116
|
if (resolved.type !== "forgejo") throw new FatalError("forgejo:create-release requires a Forgejo repository");
|
|
6870
7117
|
const executor = createRealExecutor();
|
|
6871
7118
|
const conn = resolved.conn;
|
|
6872
|
-
|
|
7119
|
+
const target = resolveTagCommit(executor, process.cwd(), args.tag);
|
|
7120
|
+
if (await ensureRelease(executor, conn, args.tag, { target }) === "exists") log.info(`Release for ${args.tag} already exists — skipping`);
|
|
6873
7121
|
else log.info(`Created Forgejo release for ${args.tag}`);
|
|
6874
7122
|
}
|
|
6875
7123
|
});
|
|
@@ -7148,7 +7396,8 @@ async function runSimpleRelease(executor, config) {
|
|
|
7148
7396
|
async function createPlatformRelease(executor, config, tag) {
|
|
7149
7397
|
if (!config.platform) return false;
|
|
7150
7398
|
if (config.platform.type === "forgejo") {
|
|
7151
|
-
|
|
7399
|
+
const target = resolveTagCommit(executor, config.cwd, tag);
|
|
7400
|
+
if (await ensureRelease(executor, config.platform.conn, tag, { target }) === "exists") {
|
|
7152
7401
|
debug(config, `Release for ${tag} already exists, skipping`);
|
|
7153
7402
|
return false;
|
|
7154
7403
|
}
|
|
@@ -10214,7 +10463,7 @@ const out = (msg) => console.log(msg);
|
|
|
10214
10463
|
const main = defineCommand({
|
|
10215
10464
|
meta: {
|
|
10216
10465
|
name: "bst",
|
|
10217
|
-
version: "0.
|
|
10466
|
+
version: "0.64.1",
|
|
10218
10467
|
description: "Bootstrap and maintain standardized TypeScript project tooling"
|
|
10219
10468
|
},
|
|
10220
10469
|
subCommands: {
|
|
@@ -10286,7 +10535,7 @@ const main = defineCommand({
|
|
|
10286
10535
|
})
|
|
10287
10536
|
}
|
|
10288
10537
|
});
|
|
10289
|
-
console.log(`@bensandee/tooling v0.
|
|
10538
|
+
console.log(`@bensandee/tooling v0.64.1`);
|
|
10290
10539
|
async function run() {
|
|
10291
10540
|
await runMain(main);
|
|
10292
10541
|
process.exit(process.exitCode ?? 0);
|