@bensandee/tooling 0.64.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 +123 -56
  2. package/package.json +1 -1
package/dist/bin.mjs CHANGED
@@ -2516,7 +2516,7 @@ function buildManagedDevDeps(ctx) {
2516
2516
  const devDeps = { ...ROOT_DEV_DEPS };
2517
2517
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
2518
2518
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.12.0";
2519
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.64.0";
2519
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.64.1";
2520
2520
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.1";
2521
2521
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
2522
2522
  "@changesets/cli": "2.31.0",
@@ -3482,8 +3482,12 @@ async function generateTsdown(ctx) {
3482
3482
  }
3483
3483
  //#endregion
3484
3484
  //#region src/release/forgejo.ts
3485
- const RETRY_ATTEMPTS = 3;
3486
- 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;
3487
3491
  /** Safely read response body text for inclusion in error messages. */
3488
3492
  async function responseBodyText(res) {
3489
3493
  try {
@@ -3691,20 +3695,29 @@ async function getReleaseByTag(executor, conn, tag) {
3691
3695
  if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
3692
3696
  return parsed.data;
3693
3697
  }
3694
- /** Create a Forgejo release for a given tag. */
3695
- 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 = {}) {
3696
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;
3697
3714
  const res = await executor.fetch(url, {
3698
3715
  method: "POST",
3699
3716
  headers: {
3700
3717
  Authorization: `token ${conn.token}`,
3701
3718
  "Content-Type": "application/json"
3702
3719
  },
3703
- body: JSON.stringify({
3704
- tag_name: tag,
3705
- name: tag,
3706
- body: `Published ${tag}`
3707
- })
3720
+ body: JSON.stringify(payload)
3708
3721
  });
3709
3722
  if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3710
3723
  verb: "create release",
@@ -3712,6 +3725,26 @@ async function createRelease(executor, conn, tag) {
3712
3725
  }));
3713
3726
  }
3714
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
+ /**
3715
3748
  * Delete a Forgejo release by id. Does **not** delete the underlying git tag
3716
3749
  * (no `delete_tag` query param) — the tag is the source of truth, and a
3717
3750
  * subsequent `POST /releases` relies on it still existing to insert a fresh
@@ -3731,62 +3764,83 @@ async function deleteRelease(executor, conn, releaseId) {
3731
3764
  /**
3732
3765
  * Ensure a Forgejo release exists for a tag **with a real created date**.
3733
3766
  *
3734
- * The tag is pushed before this runs, so Forgejo may have already auto-created
3735
- * a tag-placeholder release. A naive create can leave the release stuck at the
3736
- * Unix epoch (see {@link isEpochReleaseDate}) — a published-but-invisible
3737
- * release, because `/releases/latest` sorts by date. This function guarantees a
3738
- * 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:
3739
3771
  *
3740
- * 1. A real-dated release already exists → return `"exists"`.
3741
- * 2. An epoch-dated release/placeholder exists → delete it first.
3742
- * 3. Create the release, then **read it back**. Forgejo returns 2xx even when it
3743
- * promotes a placeholder in place (keeping `created_unix = 0`), so a 2xx does
3744
- * not prove a real date. If the read-back is epoch-dated, delete and retry.
3745
- * 4. The date stays epoch after all retries → throw (fail the job loudly rather
3746
- * 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.
3747
3782
  *
3748
- * Retries on transient errors with exponential backoff. Returns
3749
- * `"created" | "exists" | "race" | "repaired"`.
3783
+ * Returns `"created" | "exists" | "race" | "repaired"`.
3750
3784
  */
3751
- 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;
3752
3788
  const existing = await getReleaseByTag(executor, conn, tag);
3753
3789
  if (existing && !isEpochReleaseDate(existing.created_at)) return "exists";
3790
+ let current = existing;
3754
3791
  let repairing = false;
3755
- if (existing) {
3756
- log.warn(`Release for ${tag} has an epoch (1969/1970) date — deleting and recreating to fix the timestamp`);
3757
- await deleteRelease(executor, conn, existing.id);
3758
- repairing = true;
3759
- }
3760
- 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
+ }
3761
3799
  try {
3762
3800
  await createRelease(executor, conn, tag);
3763
3801
  } catch (error) {
3764
3802
  const raced = await getReleaseByTag(executor, conn, tag);
3765
3803
  if (raced && !isEpochReleaseDate(raced.created_at)) return "race";
3766
- if (raced) {
3767
- await deleteRelease(executor, conn, raced.id);
3768
- repairing = true;
3769
- }
3770
- if (attempt >= RETRY_ATTEMPTS) throw error;
3771
- log.warn(`Release creation attempt ${String(attempt)}/${String(RETRY_ATTEMPTS)} failed: ${error instanceof Error ? error.message : String(error)}`);
3772
- 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);
3773
3808
  continue;
3774
3809
  }
3775
- const created = await getReleaseByTag(executor, conn, tag);
3776
- if (created && !isEpochReleaseDate(created.created_at)) return repairing ? "repaired" : "created";
3777
- if (created) {
3778
- await deleteRelease(executor, conn, created.id);
3779
- 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;
3780
3834
  }
3781
- 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.`);
3782
- log.warn(`Release for ${tag} came back epoch-dated (attempt ${String(attempt)}/${String(RETRY_ATTEMPTS)}) — deleting and recreating`);
3783
- await backoff(attempt);
3835
+ const recreated = await getReleaseByTag(executor, conn, tag);
3836
+ return Boolean(recreated && !isEpochReleaseDate(recreated.created_at));
3784
3837
  }
3785
- throw new TransientError(`Failed to create release for ${tag} after ${String(RETRY_ATTEMPTS)} attempts`);
3838
+ return false;
3786
3839
  }
3787
- /** Exponential backoff between release-creation attempts. */
3788
- async function backoff(attempt) {
3789
- 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);
3790
3844
  await new Promise((resolve) => setTimeout(resolve, delay));
3791
3845
  }
3792
3846
  /** Upload an asset file to a Forgejo release. */
@@ -5916,7 +5970,7 @@ async function runGenerators(ctx) {
5916
5970
  function generateMigratePrompt(results, config, detected) {
5917
5971
  const sections = [];
5918
5972
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
5919
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.64.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`);
5920
5974
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
5921
5975
  if (changed.length > 0) {
5922
5976
  const items = changed.map((r) => {
@@ -6396,6 +6450,17 @@ function computeSlidingTags(version) {
6396
6450
  "latest"
6397
6451
  ];
6398
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
+ }
6399
6464
  /** Parse "New tag:" lines from changeset publish output. */
6400
6465
  function parseNewTags(output) {
6401
6466
  const tags = [];
@@ -6714,7 +6779,7 @@ async function runPublishMode(executor, config) {
6714
6779
  for (const tag of allTags) try {
6715
6780
  pushTagToRemote(executor, config, auth, tag, remoteSet);
6716
6781
  debug(config, `Ensuring Forgejo release for ${tag}`);
6717
- 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`);
6718
6783
  else log.info(`Created release for ${tag}`);
6719
6784
  } catch (error) {
6720
6785
  errors.push({
@@ -7051,7 +7116,8 @@ const createForgejoReleaseCommand = defineCommand({
7051
7116
  if (resolved.type !== "forgejo") throw new FatalError("forgejo:create-release requires a Forgejo repository");
7052
7117
  const executor = createRealExecutor();
7053
7118
  const conn = resolved.conn;
7054
- 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`);
7055
7121
  else log.info(`Created Forgejo release for ${args.tag}`);
7056
7122
  }
7057
7123
  });
@@ -7330,7 +7396,8 @@ async function runSimpleRelease(executor, config) {
7330
7396
  async function createPlatformRelease(executor, config, tag) {
7331
7397
  if (!config.platform) return false;
7332
7398
  if (config.platform.type === "forgejo") {
7333
- 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") {
7334
7401
  debug(config, `Release for ${tag} already exists, skipping`);
7335
7402
  return false;
7336
7403
  }
@@ -10396,7 +10463,7 @@ const out = (msg) => console.log(msg);
10396
10463
  const main = defineCommand({
10397
10464
  meta: {
10398
10465
  name: "bst",
10399
- version: "0.64.0",
10466
+ version: "0.64.1",
10400
10467
  description: "Bootstrap and maintain standardized TypeScript project tooling"
10401
10468
  },
10402
10469
  subCommands: {
@@ -10468,7 +10535,7 @@ const main = defineCommand({
10468
10535
  })
10469
10536
  }
10470
10537
  });
10471
- console.log(`@bensandee/tooling v0.64.0`);
10538
+ console.log(`@bensandee/tooling v0.64.1`);
10472
10539
  async function run() {
10473
10540
  await runMain(main);
10474
10541
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.64.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": {