@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.
Files changed (2) hide show
  1. package/dist/bin.mjs +322 -73
  2. 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
- * Node major pinned for the dev/CI runtime via `devEngines.runtime`.
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
- * `engines.node`, and a bounded `<major>.x` resolves to the latest patch of that
2013
- * one major. Without it, a `>=`-range `engines.node` makes setup-node install the
2014
- * newest available major (e.g. Node 26), silently drifting CI off the LTS line.
2015
- * `engines.node` stays the honest minimum-supported floor; this is the version CI
2016
- * actually runs. `onFail: "warn"` keeps a local Node mismatch non-fatal.
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 DEV_NODE_VERSION = "24.x";
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: DEV_NODE_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: ">=24.13.0" };
2336
- changes.push("set engines.node >= 24.13.0");
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.63.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: ">=24.13.0" },
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
- const RETRY_ATTEMPTS = 3;
3305
- const RETRY_BASE_DELAY_MS = 1e3;
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
- /** Create a Forgejo release for a given tag. */
3514
- async function createRelease(executor, conn, tag) {
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
- * The tag is pushed before this runs, so Forgejo may have already auto-created
3554
- * a tag-placeholder release. A naive create can leave the release stuck at the
3555
- * Unix epoch (see {@link isEpochReleaseDate}) — a published-but-invisible
3556
- * release, because `/releases/latest` sorts by date. This function guarantees a
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 → return `"exists"`.
3560
- * 2. An epoch-dated release/placeholder exists → delete it first.
3561
- * 3. Create the release, then **read it back**. Forgejo returns 2xx even when it
3562
- * promotes a placeholder in place (keeping `created_unix = 0`), so a 2xx does
3563
- * not prove a real date. If the read-back is epoch-dated, delete and retry.
3564
- * 4. The date stays epoch after all retries → throw (fail the job loudly rather
3565
- * than silently ship an invisible release).
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
- * Retries on transient errors with exponential backoff. Returns
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
- if (existing) {
3575
- log.warn(`Release for ${tag} has an epoch (1969/1970) date — deleting and recreating to fix the timestamp`);
3576
- await deleteRelease(executor, conn, existing.id);
3577
- repairing = true;
3578
- }
3579
- for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
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
- if (raced) {
3586
- await deleteRelease(executor, conn, raced.id);
3587
- repairing = true;
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
- const created = await getReleaseByTag(executor, conn, tag);
3595
- if (created && !isEpochReleaseDate(created.created_at)) return repairing ? "repaired" : "created";
3596
- if (created) {
3597
- await deleteRelease(executor, conn, created.id);
3598
- repairing = true;
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
- if (attempt >= RETRY_ATTEMPTS) throw new TransientError(`Release for ${tag} still has an epoch (1969/1970) created date after ${String(RETRY_ATTEMPTS)} attempts — Forgejo keeps promoting a tag placeholder. The git tag exists but the release row cannot get a real timestamp; investigate the server.`);
3601
- log.warn(`Release for ${tag} came back epoch-dated (attempt ${String(attempt)}/${String(RETRY_ATTEMPTS)}) — deleting and recreating`);
3602
- await backoff(attempt);
3835
+ const recreated = await getReleaseByTag(executor, conn, tag);
3836
+ return Boolean(recreated && !isEpochReleaseDate(recreated.created_at));
3603
3837
  }
3604
- throw new TransientError(`Failed to create release for ${tag} after ${String(RETRY_ATTEMPTS)} attempts`);
3838
+ return false;
3605
3839
  }
3606
- /** Exponential backoff between release-creation attempts. */
3607
- async function backoff(attempt) {
3608
- const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
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.63.0 repo:sync\` on ${timestamp}_\n`);
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
- if (await ensureRelease(executor, conn, args.tag) === "exists") log.info(`Release for ${args.tag} already exists — skipping`);
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
- if (await ensureRelease(executor, config.platform.conn, tag) === "exists") {
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.63.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.63.0`);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.63.0",
3
+ "version": "0.64.1",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {