@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.
- package/dist/bin.mjs +123 -56
- 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.
|
|
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
|
-
|
|
3486
|
-
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;
|
|
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
|
-
/**
|
|
3695
|
-
|
|
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
|
-
*
|
|
3735
|
-
*
|
|
3736
|
-
*
|
|
3737
|
-
*
|
|
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 →
|
|
3741
|
-
* 2.
|
|
3742
|
-
*
|
|
3743
|
-
*
|
|
3744
|
-
*
|
|
3745
|
-
*
|
|
3746
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
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
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
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
|
-
|
|
3776
|
-
if (
|
|
3777
|
-
if (
|
|
3778
|
-
|
|
3779
|
-
|
|
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
|
-
|
|
3782
|
-
|
|
3783
|
-
await backoff(attempt);
|
|
3835
|
+
const recreated = await getReleaseByTag(executor, conn, tag);
|
|
3836
|
+
return Boolean(recreated && !isEpochReleaseDate(recreated.created_at));
|
|
3784
3837
|
}
|
|
3785
|
-
|
|
3838
|
+
return false;
|
|
3786
3839
|
}
|
|
3787
|
-
/** Exponential backoff between
|
|
3788
|
-
async function
|
|
3789
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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);
|