@bensandee/tooling 0.61.0 → 0.62.0

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 (3) hide show
  1. package/README.md +5 -1
  2. package/dist/bin.mjs +305 -104
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -137,7 +137,7 @@ Opt out entirely with `"pnpmWorkspace": false` in `.tooling.json`; the generator
137
137
  | Command | Description |
138
138
  | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
139
139
  | `tooling release:changesets` | Changesets version/publish for Forgejo CI. Flags: `--dry-run`, `--verbose`. Env: `FORGEJO_SERVER_URL`, `FORGEJO_REPOSITORY`, `RELEASE_TOKEN`. |
140
- | `tooling release:simple` | Streamlined release using commit-and-tag-version. Flags: `--release-as`, `--first-release`, `--prerelease`, `--verbose`. |
140
+ | `tooling release:simple` | Streamlined release using commit-and-tag-version. Flags: `--release-as`, `--first-release`, `--prerelease`, `--verbose`. Env: `RELEASE_TOKEN` (required on every platform). |
141
141
  | `tooling release:trigger` | Trigger a release workflow. |
142
142
  | `tooling workflow:dispatch` | Dispatch a `workflow_dispatch` event for any workflow in any repo on the same git host. Flags: `--repo`, `--workflow` (required), `--ref`, `--input key=value` (repeatable). |
143
143
  | `tooling forgejo:create-release` | Create a Forgejo release from a tag. |
@@ -170,6 +170,10 @@ tooling release:simple --release-as major --prerelease beta # → 2.0.0-beta.0
170
170
 
171
171
  The generated release workflow exposes these as optional `workflow_dispatch` inputs (`bump` and `prerelease`), so bumps can also be controlled from the CI UI.
172
172
 
173
+ **Authentication:** `release:simple` requires `RELEASE_TOKEN` (a Personal Access Token with write access on the target forge) on every platform — Forgejo, GitHub, and GitHub Enterprise alike. The runner's auto-injected token (`github.token` / Forgejo Actions' internal auth) is never used: `actions/checkout@v6` writes that token to git config as an `http.<serverUrl>/.extraheader`, which silently overrides URL-embedded credentials and authenticates pushes as the runner pseudo-user. On Forgejo that pseudo-user is `uid=-2`, which fails the pre-receive hook (`Internal Server Error`); on GitHub Enterprise the same shape can mask custom hook rejections. `release:simple` actively unsets the extraheader and rewrites the remote URL with `RELEASE_TOKEN` before pushing.
174
+
175
+ GitHub consumers that previously relied on `github.token` must mint a PAT (scope: `contents: write`) and store it as the `RELEASE_TOKEN` secret on the repo or org.
176
+
173
177
  #### `workflow:dispatch`
174
178
 
175
179
  Triggers a `workflow_dispatch` event for an arbitrary workflow in an arbitrary repo on the same git host — useful for release→deploy patterns where one repo's CI kicks off a workflow in another (e.g. an infra repo). `release:trigger` is a special case of this command, pinned to `release.yml` in the current repo.
package/dist/bin.mjs CHANGED
@@ -844,15 +844,24 @@ function parseGitUrl(urlStr) {
844
844
  }
845
845
  }
846
846
  /**
847
- * Configure the git remote origin to use a Forgejo token for push auth.
847
+ * Configure the git remote origin to push as `token` regardless of what
848
+ * `actions/checkout` left behind.
848
849
  *
849
- * This is necessary because `actions/checkout` configures git with the automatic
850
- * GITHUB_TOKEN, which may not have push permissions. Using RELEASE_TOKEN ensures
851
- * pushes use the explicitly provided token.
850
+ * `actions/checkout@v6` injects an HTTP Authorization header into git config
851
+ * under `http.<serverUrl>/.extraheader`. That header overrides Basic auth
852
+ * embedded in the remote URL, so a `git remote set-url` alone is not enough —
853
+ * the runner's pseudo-user token still wins and the push authenticates as
854
+ * uid=-2 on Forgejo (and as the runner identity on GitHub Enterprise with
855
+ * custom pre-receive hooks). Unset the extraheader before rewriting the URL.
852
856
  */
853
- function configureGitAuth(executor, conn, cwd) {
854
- const host = conn.serverUrl.replace(/^https?:\/\//, "");
855
- const authUrl = `https://x-access-token:${conn.token}@${host}/${conn.repository}`;
857
+ function configureGitAuth(executor, coords, cwd) {
858
+ const host = coords.serverUrl.replace(/^https?:\/\//, "");
859
+ executor.exec("git", [
860
+ "config",
861
+ "--unset-all",
862
+ `http.${coords.serverUrl}/.extraheader`
863
+ ], { cwd });
864
+ const authUrl = `https://x-access-token:${coords.token}@${host}/${coords.repository}`;
856
865
  executor.exec("git", [
857
866
  "remote",
858
867
  "set-url",
@@ -1580,11 +1589,21 @@ function buildWorkflowYaml(options) {
1580
1589
  doc["on"] = options.on;
1581
1590
  if (options.permissions) doc["permissions"] = options.permissions;
1582
1591
  if (options.concurrency) doc["concurrency"] = options.concurrency;
1583
- doc["jobs"] = { [options.jobName]: {
1592
+ const jobs = { [options.jobName]: {
1584
1593
  "runs-on": options.runsOn ?? "ubuntu-latest",
1585
1594
  ...options.jobEnv && { env: options.jobEnv },
1595
+ ...options.jobOutputs && { outputs: options.jobOutputs },
1586
1596
  steps: options.steps.map((s) => s.step)
1587
1597
  } };
1598
+ for (const [name, def] of Object.entries(options.extraJobs ?? {})) jobs[name] = {
1599
+ ...def.needs && { needs: def.needs },
1600
+ ...def.if && { if: def.if },
1601
+ ...def["continue-on-error"] && { "continue-on-error": true },
1602
+ "runs-on": def.runsOn ?? "ubuntu-latest",
1603
+ ...def.env && { env: def.env },
1604
+ steps: def.steps.map((s) => s.step)
1605
+ };
1606
+ doc["jobs"] = jobs;
1588
1607
  const readable = stringify(doc, {
1589
1608
  lineWidth: 0,
1590
1609
  nullStr: ""
@@ -2206,7 +2225,7 @@ async function generatePackageJson(ctx) {
2206
2225
  const devDeps = { ...ROOT_DEV_DEPS };
2207
2226
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
2208
2227
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.11.3";
2209
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.61.0";
2228
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.62.0";
2210
2229
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.0";
2211
2230
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
2212
2231
  "@changesets/cli": "2.31.0",
@@ -3172,6 +3191,67 @@ async function responseBodyText(res) {
3172
3191
  return "(could not read response body)";
3173
3192
  }
3174
3193
  }
3194
+ const SwaggerEnvelopeSchema = z.union([z.object({ message: z.literal("") }), z.object({
3195
+ message: z.string(),
3196
+ url: z.string().regex(/\/api\/swagger$/)
3197
+ })]);
3198
+ /**
3199
+ * Detect Forgejo's signature "route not handled" response shape — empty body
3200
+ * or a JSON envelope with either an empty `message` or a `url` pointing at
3201
+ * `/api/swagger`. Forgejo returns this from a 500 (and sometimes 404) when no
3202
+ * route handles the request, even though the status code suggests a
3203
+ * server-side fault.
3204
+ */
3205
+ function isForgejoGenericServerErrorBody(body) {
3206
+ if (body === "") return true;
3207
+ try {
3208
+ const parsed = JSON.parse(body);
3209
+ return SwaggerEnvelopeSchema.safeParse(parsed).success;
3210
+ } catch (_error) {
3211
+ return false;
3212
+ }
3213
+ }
3214
+ const DISPATCH_VERB_PATTERN = /\bdispatch\b/i;
3215
+ /**
3216
+ * Map a non-2xx Forgejo response to a human-readable hint.
3217
+ *
3218
+ * Returns the most-likely cause first; callers still surface the raw body so an
3219
+ * uncommon failure mode isn't masked. Returns "" for unmapped codes (2xx/3xx,
3220
+ * teapots) so the caller can decide whether to include a Hint line at all.
3221
+ */
3222
+ function forgejoErrorHint(status, body, operation) {
3223
+ const isDispatch = DISPATCH_VERB_PATTERN.test(operation.verb);
3224
+ const resource = operation.resource ?? "the resource";
3225
+ if (status === 401 || status === 403) return `Forgejo rejected the token. It needs the scope required for "${operation.verb}" on ${resource}.`;
3226
+ if (status === 404) {
3227
+ const base = `"${resource}" was not found on the server. Verify the name${operation.ref ? `, that it exists on ${operation.ref}` : ""}, and that the token can see it.`;
3228
+ return isDispatch ? `${base} For dispatch, also confirm the workflow has an "on: workflow_dispatch:" trigger.` : base;
3229
+ }
3230
+ if (status === 400 || status === 422) {
3231
+ const base = `Forgejo rejected the request payload for "${operation.verb}". Check for missing or invalid fields.`;
3232
+ return isDispatch ? `${base} For dispatch, common causes are a missing "on: workflow_dispatch:" trigger or a required input not being provided.` : base;
3233
+ }
3234
+ if (status >= 500 && status < 600) {
3235
+ if (isForgejoGenericServerErrorBody(body)) {
3236
+ const ref = operation.ref ? ` on ${operation.ref}` : "";
3237
+ return `Forgejo returned a generic server error. Most often this means the requested route is unavailable${isDispatch ? ` (e.g. "${resource}" doesn't exist${ref}, or the workflow lacks an "on: workflow_dispatch:" trigger)` : ""}. Less commonly: the token lacks scope, or it is a server-side bug.`;
3238
+ }
3239
+ return `Forgejo server error — usually transient. Retry; if it persists, check the server logs.`;
3240
+ }
3241
+ return "";
3242
+ }
3243
+ /**
3244
+ * Build a richer error message for a non-2xx Forgejo API response.
3245
+ *
3246
+ * Reads, truncates, and redacts the body via {@link responseBodyText}; appends
3247
+ * a `Hint:` line when {@link forgejoErrorHint} has one; appends the raw body as
3248
+ * a `Response:` block. Callers still pick the error class (Transient vs Fatal).
3249
+ */
3250
+ async function forgejoErrorMessage(res, operation) {
3251
+ const body = await responseBodyText(res);
3252
+ const hint = forgejoErrorHint(res.status, body, operation);
3253
+ return `Failed to ${operation.verb} on Forgejo: ${String(res.status)} ${res.statusText}` + (hint ? `\nHint: ${hint}` : "") + (body ? `\nResponse: ${body}` : "");
3254
+ }
3175
3255
  const PullRequestSchema = z.array(z.object({
3176
3256
  number: z.number(),
3177
3257
  head: z.object({ ref: z.string() })
@@ -3185,10 +3265,10 @@ const PullRequestSchema = z.array(z.object({
3185
3265
  async function findOpenPr(executor, conn, head) {
3186
3266
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?state=open`;
3187
3267
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3188
- if (!res.ok) {
3189
- const body = await responseBodyText(res);
3190
- throw new TransientError(`Failed to list PRs: ${res.status} ${res.statusText}\n${body}`);
3191
- }
3268
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3269
+ verb: "list PRs",
3270
+ resource: conn.repository
3271
+ }));
3192
3272
  const parsed = PullRequestSchema.safeParse(await res.json());
3193
3273
  if (!parsed.success) throw new UnexpectedError(`Unexpected PR list response: ${parsed.error.message}`);
3194
3274
  return parsed.data.find((pr) => pr.head.ref === head)?.number ?? null;
@@ -3210,10 +3290,10 @@ async function createPr(executor, conn, options) {
3210
3290
  },
3211
3291
  body: JSON.stringify(payload)
3212
3292
  });
3213
- if (!res.ok) {
3214
- const body = await responseBodyText(res);
3215
- throw new TransientError(`Failed to create PR: ${res.status} ${res.statusText}\n${body}`);
3216
- }
3293
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3294
+ verb: "create PR",
3295
+ resource: `${options.head}→${options.base}`
3296
+ }));
3217
3297
  }
3218
3298
  /** Update an existing pull request's title and body. */
3219
3299
  async function updatePr(executor, conn, prNumber, options) {
@@ -3229,10 +3309,10 @@ async function updatePr(executor, conn, prNumber, options) {
3229
3309
  body: options.body
3230
3310
  })
3231
3311
  });
3232
- if (!res.ok) {
3233
- const body = await responseBodyText(res);
3234
- throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}\n${body}`);
3235
- }
3312
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3313
+ verb: "update PR",
3314
+ resource: `#${String(prNumber)}`
3315
+ }));
3236
3316
  }
3237
3317
  /** Merge a pull request by number. */
3238
3318
  async function mergePr(executor, conn, prNumber, options) {
@@ -3248,10 +3328,10 @@ async function mergePr(executor, conn, prNumber, options) {
3248
3328
  delete_branch_after_merge: options?.deleteBranch ?? true
3249
3329
  })
3250
3330
  });
3251
- if (!res.ok) {
3252
- const body = await responseBodyText(res);
3253
- throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}\n${body}`);
3254
- }
3331
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3332
+ verb: "merge PR",
3333
+ resource: `#${String(prNumber)}`
3334
+ }));
3255
3335
  }
3256
3336
  /** Check whether a Forgejo release already exists for a given tag. */
3257
3337
  async function findRelease(executor, conn, tag) {
@@ -3260,8 +3340,10 @@ async function findRelease(executor, conn, tag) {
3260
3340
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3261
3341
  if (res.status === 200) return true;
3262
3342
  if (res.status === 404) return false;
3263
- const body = await responseBodyText(res);
3264
- throw new TransientError(`Failed to check release for ${tag}: ${res.status} ${res.statusText}\n${body}`);
3343
+ throw new TransientError(await forgejoErrorMessage(res, {
3344
+ verb: "check release",
3345
+ resource: tag
3346
+ }));
3265
3347
  }
3266
3348
  /** Create a Forgejo release for a given tag. */
3267
3349
  async function createRelease(executor, conn, tag) {
@@ -3278,10 +3360,10 @@ async function createRelease(executor, conn, tag) {
3278
3360
  body: `Published ${tag}`
3279
3361
  })
3280
3362
  });
3281
- if (!res.ok) {
3282
- const body = await responseBodyText(res);
3283
- throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}\n${body}`);
3284
- }
3363
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3364
+ verb: "create release",
3365
+ resource: tag
3366
+ }));
3285
3367
  }
3286
3368
  /**
3287
3369
  * Ensure a Forgejo release exists for a tag, creating it if necessary.
@@ -3313,10 +3395,10 @@ async function uploadReleaseAsset(executor, conn, tag, options) {
3313
3395
  const encodedTag = encodeURIComponent(tag);
3314
3396
  const releaseUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
3315
3397
  const releaseRes = await executor.fetch(releaseUrl, { headers: { Authorization: `token ${conn.token}` } });
3316
- if (!releaseRes.ok) {
3317
- const body = await responseBodyText(releaseRes);
3318
- throw new TransientError(`Failed to get release for ${tag}: ${String(releaseRes.status)} ${releaseRes.statusText}\n${body}`);
3319
- }
3398
+ if (!releaseRes.ok) throw new TransientError(await forgejoErrorMessage(releaseRes, {
3399
+ verb: "get release",
3400
+ resource: tag
3401
+ }));
3320
3402
  const parsed = z.object({ id: z.number() }).safeParse(await releaseRes.json());
3321
3403
  if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
3322
3404
  const releaseId = parsed.data.id;
@@ -3328,10 +3410,10 @@ async function uploadReleaseAsset(executor, conn, tag, options) {
3328
3410
  headers: { Authorization: `token ${conn.token}` },
3329
3411
  body: form
3330
3412
  });
3331
- if (!res.ok) {
3332
- const body = await responseBodyText(res);
3333
- throw new TransientError(`Failed to upload asset ${options.assetName} to ${tag}: ${String(res.status)} ${res.statusText}\n${body}`);
3334
- }
3413
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3414
+ verb: "upload asset",
3415
+ resource: `${options.assetName} to ${tag}`
3416
+ }));
3335
3417
  }
3336
3418
  const ActionSecretListSchema = z.object({
3337
3419
  secrets: z.array(z.object({ name: z.string() })),
@@ -3362,10 +3444,10 @@ async function createAccessToken(executor, serverUrl, username, password, tokenN
3362
3444
  scopes
3363
3445
  })
3364
3446
  });
3365
- if (!res.ok) {
3366
- const body = await responseBodyText(res);
3367
- throw new TransientError(`Failed to create access token: ${String(res.status)} ${res.statusText}\n${body}`);
3368
- }
3447
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3448
+ verb: "create access token",
3449
+ resource: username
3450
+ }));
3369
3451
  const parsed = AccessTokenResponseSchema.safeParse(await res.json());
3370
3452
  if (!parsed.success) throw new UnexpectedError(`Unexpected token response: ${parsed.error.message}`);
3371
3453
  return parsed.data.sha1;
@@ -3386,10 +3468,10 @@ async function setActionSecret(executor, conn, secretName, secretValue) {
3386
3468
  },
3387
3469
  body: JSON.stringify({ data: secretValue })
3388
3470
  });
3389
- if (!res.ok) {
3390
- const body = await responseBodyText(res);
3391
- throw new TransientError(`Failed to set secret ${secretName}: ${String(res.status)} ${res.statusText}\n${body}`);
3392
- }
3471
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3472
+ verb: "set secret",
3473
+ resource: secretName
3474
+ }));
3393
3475
  }
3394
3476
  /**
3395
3477
  * Set (or overwrite) a Forgejo Actions secret at the user level.
@@ -3412,10 +3494,10 @@ async function setUserActionSecret(executor, serverUrl, authorization, secretNam
3412
3494
  headers,
3413
3495
  body: JSON.stringify({ data: secretValue })
3414
3496
  });
3415
- if (!res.ok) {
3416
- const body = await responseBodyText(res);
3417
- throw new TransientError(`Failed to set user secret ${secretName}: ${String(res.status)} ${res.statusText}\n${body}`);
3418
- }
3497
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3498
+ verb: "set user secret",
3499
+ resource: secretName
3500
+ }));
3419
3501
  }
3420
3502
  /**
3421
3503
  * List Forgejo Actions secret names for a repository.
@@ -3431,10 +3513,10 @@ async function listActionSecrets(executor, conn) {
3431
3513
  for (;;) {
3432
3514
  const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets?page=${String(page)}&limit=${String(limit)}`;
3433
3515
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3434
- if (!res.ok) {
3435
- const body = await responseBodyText(res);
3436
- throw new TransientError(`Failed to list secrets: ${String(res.status)} ${res.statusText}\n${body}`);
3437
- }
3516
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3517
+ verb: "list secrets",
3518
+ resource: `${owner}/${repo}`
3519
+ }));
3438
3520
  const parsed = ActionSecretListSchema.safeParse(await res.json());
3439
3521
  if (!parsed.success) throw new UnexpectedError(`Unexpected secrets list response: ${parsed.error.message}`);
3440
3522
  for (const secret of parsed.data.secrets) names.push(secret.name);
@@ -3455,10 +3537,10 @@ async function deleteActionSecret(executor, conn, secretName) {
3455
3537
  method: "DELETE",
3456
3538
  headers: { Authorization: `token ${conn.token}` }
3457
3539
  });
3458
- if (!res.ok) {
3459
- const body = await responseBodyText(res);
3460
- throw new TransientError(`Failed to delete secret ${secretName}: ${String(res.status)} ${res.statusText}\n${body}`);
3461
- }
3540
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3541
+ verb: "delete secret",
3542
+ resource: secretName
3543
+ }));
3462
3544
  }
3463
3545
  const WebhookSchema = z.object({
3464
3546
  id: z.number(),
@@ -3467,6 +3549,11 @@ const WebhookSchema = z.object({
3467
3549
  config: z.record(z.string(), z.string()).optional()
3468
3550
  });
3469
3551
  const WebhookListSchema = z.array(WebhookSchema);
3552
+ /**
3553
+ * Returns a status struct instead of throwing, so it deliberately does not use
3554
+ * {@link forgejoErrorMessage} — the caller branches on `status` rather than
3555
+ * catching an error.
3556
+ */
3470
3557
  async function fetchHooks(executor, conn, endpoint) {
3471
3558
  const res = await executor.fetch(`${conn.serverUrl}/api/v1${endpoint}`, { headers: { Authorization: `token ${conn.token}` } });
3472
3559
  if (res.status === 401 || res.status === 403) return {
@@ -3531,17 +3618,17 @@ async function hasAdminAccess(executor, conn) {
3531
3618
  const OrgSchema = z.object({ username: z.string() });
3532
3619
  const OrgListSchema = z.array(OrgSchema);
3533
3620
  const PAGE_LIMIT = 50;
3534
- async function paginate(executor, conn, endpoint, extract) {
3621
+ async function paginate(executor, conn, endpoint, verb, extract) {
3535
3622
  const all = [];
3536
3623
  let page = 1;
3537
3624
  const separator = endpoint.includes("?") ? "&" : "?";
3538
3625
  for (;;) {
3539
3626
  const url = `${conn.serverUrl}/api/v1${endpoint}${separator}page=${String(page)}&limit=${String(PAGE_LIMIT)}`;
3540
3627
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3541
- if (!res.ok) {
3542
- const body = await responseBodyText(res);
3543
- throw new TransientError(`Failed to fetch ${endpoint} (page ${String(page)}): ${String(res.status)} ${res.statusText}\n${body}`);
3544
- }
3628
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3629
+ verb,
3630
+ resource: `${endpoint} (page ${String(page)})`
3631
+ }));
3545
3632
  const items = extract(await res.json());
3546
3633
  all.push(...items);
3547
3634
  if (items.length < PAGE_LIMIT) break;
@@ -3555,7 +3642,7 @@ async function paginate(executor, conn, endpoint, extract) {
3555
3642
  */
3556
3643
  async function listOrgs(executor, conn, mode) {
3557
3644
  const endpoint = mode === "admin" ? "/admin/orgs" : "/user/orgs";
3558
- return (await paginate(executor, conn, endpoint, (raw) => {
3645
+ return (await paginate(executor, conn, endpoint, "list orgs", (raw) => {
3559
3646
  const parsed = OrgListSchema.safeParse(raw);
3560
3647
  if (!parsed.success) throw new UnexpectedError(`Unexpected ${endpoint} response: ${parsed.error.message}`);
3561
3648
  return parsed.data;
@@ -3564,7 +3651,7 @@ async function listOrgs(executor, conn, mode) {
3564
3651
  const RepoSearchSchema = z.object({ data: z.array(z.object({ full_name: z.string() })) });
3565
3652
  /** List repositories visible to the token via `/repos/search`. */
3566
3653
  async function searchRepos(executor, conn) {
3567
- return (await paginate(executor, conn, "/repos/search", (raw) => {
3654
+ return (await paginate(executor, conn, "/repos/search", "list repos", (raw) => {
3568
3655
  const parsed = RepoSearchSchema.safeParse(raw);
3569
3656
  if (!parsed.success) throw new UnexpectedError(`Unexpected /repos/search response: ${parsed.error.message}`);
3570
3657
  return parsed.data.data;
@@ -3772,7 +3859,11 @@ async function generateGitignore(ctx) {
3772
3859
  }
3773
3860
  //#endregion
3774
3861
  //#region src/generators/release-ci.ts
3775
- const DEFAULT_POST_RELEASE_COMMAND = ["pnpm exec bst webhook:send", " \"$POST_RELEASE_HOOK_URL\""].join("");
3862
+ const DEFAULT_POST_RELEASE_COMMAND = [
3863
+ "pnpm exec bst webhook:send",
3864
+ " \"$POST_RELEASE_HOOK_URL\"",
3865
+ " --tag \"$RELEASE_TAG\""
3866
+ ].join("");
3776
3867
  /** Map secret names to `${{ secrets.<NAME> }}` env entries. */
3777
3868
  function secretsEnv(secrets) {
3778
3869
  const env = {};
@@ -3798,6 +3889,26 @@ function postReleaseHookStep(opts) {
3798
3889
  } };
3799
3890
  }
3800
3891
  /**
3892
+ * Post-release hook as a separate job with `continue-on-error: true` so a
3893
+ * webhook failure (HTTP 5xx, DNS, etc.) shows up as a red job in the run UI
3894
+ * without failing the overall workflow conclusion.
3895
+ */
3896
+ function postReleaseHookJob(opts) {
3897
+ return {
3898
+ needs: opts.needsJob,
3899
+ if: opts.publishCondition,
3900
+ "continue-on-error": true,
3901
+ steps: [...commonSteps(opts.nodeVersionYaml, opts.publishesNpm), { step: {
3902
+ name: "Post-release hook",
3903
+ env: {
3904
+ ...hookSecretEnv(opts.secrets),
3905
+ RELEASE_TAG: opts.releaseTagExpr
3906
+ },
3907
+ run: opts.command ?? DEFAULT_POST_RELEASE_COMMAND
3908
+ } }]
3909
+ };
3910
+ }
3911
+ /**
3801
3912
  * Step that dispatches a `workflow_dispatch` event to another repo after a
3802
3913
  * release, triggering a downstream deploy. The release tag is forwarded from
3803
3914
  * the `release` step's `tag` output.
@@ -3845,7 +3956,7 @@ function commonSteps(nodeVersionYaml, publishesNpm) {
3845
3956
  ];
3846
3957
  }
3847
3958
  function releaseItSteps(ci, nodeVersionYaml, publishesNpm) {
3848
- const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
3959
+ const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
3849
3960
  const npmEnv = publishesNpm ? { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") } : {};
3850
3961
  return [...commonSteps(nodeVersionYaml, publishesNpm), { step: {
3851
3962
  run: "pnpm release-it --ci",
@@ -3881,7 +3992,11 @@ function simpleWorkflowDispatchTrigger() {
3881
3992
  }
3882
3993
  /** Platform connection env so a `bst` step can resolve the git host. */
3883
3994
  function connectionEnv(ci) {
3884
- return ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
3995
+ if (ci === "github") return {
3996
+ RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
3997
+ GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
3998
+ };
3999
+ return {
3885
4000
  FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
3886
4001
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
3887
4002
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
@@ -3970,15 +4085,27 @@ async function generateReleaseCi(ctx) {
3970
4085
  secrets: hookSecrets
3971
4086
  }));
3972
4087
  }
4088
+ let extraJobs;
4089
+ let jobOutputs;
3973
4090
  if (ctx.config.releaseStrategy === "release-it") steps.push(postReleaseHookStep({
3974
4091
  command: getPostReleaseHookCommand(ctx),
3975
4092
  secrets: hookSecrets
3976
4093
  }));
3977
- else if (ctx.config.releaseStrategy === "simple") steps.push(postReleaseHookStep({
3978
- command: getPostReleaseHookCommand(ctx),
3979
- secrets: hookSecrets,
3980
- condition: "steps.release.outputs.pushed == 'true'"
3981
- }));
4094
+ else if (ctx.config.releaseStrategy === "simple") {
4095
+ jobOutputs = {
4096
+ pushed: actionsExpr("steps.release.outputs.pushed"),
4097
+ tag: actionsExpr("steps.release.outputs.tag")
4098
+ };
4099
+ extraJobs = { "post-release-hook": postReleaseHookJob({
4100
+ needsJob: "release",
4101
+ publishCondition: "needs.release.outputs.pushed == 'true'",
4102
+ releaseTagExpr: actionsExpr("needs.release.outputs.tag"),
4103
+ command: getPostReleaseHookCommand(ctx),
4104
+ secrets: hookSecrets,
4105
+ nodeVersionYaml,
4106
+ publishesNpm
4107
+ }) };
4108
+ }
3982
4109
  const on = ctx.config.releaseStrategy === "simple" ? simpleWorkflowDispatchTrigger() : { workflow_dispatch: null };
3983
4110
  const content = buildWorkflowYaml({
3984
4111
  ci: ctx.config.ci,
@@ -3986,7 +4113,9 @@ async function generateReleaseCi(ctx) {
3986
4113
  on,
3987
4114
  ...isGitHub && { permissions: { contents: "write" } },
3988
4115
  jobName: "release",
3989
- steps
4116
+ ...jobOutputs && { jobOutputs },
4117
+ steps,
4118
+ ...extraJobs && { extraJobs }
3990
4119
  });
3991
4120
  const alreadyExists = ctx.exists(workflowPath);
3992
4121
  const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
@@ -4043,7 +4172,7 @@ function changesetsReleaseStep(ci, publishesNpm) {
4043
4172
  version: "pnpm changeset version"
4044
4173
  },
4045
4174
  env: {
4046
- GITHUB_TOKEN: actionsExpr("github.token"),
4175
+ GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
4047
4176
  ...publishesNpm && { NPM_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
4048
4177
  }
4049
4178
  } };
@@ -4112,8 +4241,16 @@ async function generateCi(ctx) {
4112
4241
  const hasAssets = isChangesets && hasReleaseAssets(ctx);
4113
4242
  const host = hasDocker ? resolveGenTimeHost(ctx) : null;
4114
4243
  const publishedIf = "steps.release.outputs.published == 'true'";
4244
+ const githubChangesetsDeriveTagStep = { step: {
4245
+ name: "Derive release tag",
4246
+ id: "derive-tag",
4247
+ if: publishedIf,
4248
+ env: { PUBLISHED_PACKAGES: actionsExpr("steps.release.outputs.publishedPackages") },
4249
+ run: "echo \"tag=$(echo \"$PUBLISHED_PACKAGES\" | jq -r '.[0] | \"\\(.name)@\\(.version)\"')\" >> \"$GITHUB_OUTPUT\""
4250
+ } };
4251
+ const changesetsTagOutputExpr = isGitHub ? actionsExpr("steps.derive-tag.outputs.tag") : actionsExpr("steps.release.outputs.tag");
4115
4252
  const releaseEnv = {};
4116
- if (isGitHub) releaseEnv["GITHUB_TOKEN"] = actionsExpr("github.token");
4253
+ if (isGitHub) releaseEnv["GITHUB_TOKEN"] = actionsExpr("secrets.RELEASE_TOKEN");
4117
4254
  else {
4118
4255
  releaseEnv["FORGEJO_SERVER_URL"] = actionsExpr("github.server_url");
4119
4256
  releaseEnv["FORGEJO_REPOSITORY"] = actionsExpr("github.repository");
@@ -4123,6 +4260,7 @@ async function generateCi(ctx) {
4123
4260
  ...checkSteps(nodeVersionYaml, isChangesets && publishesNpm, coverage.enabled),
4124
4261
  ...emitCoverageSteps ? coverageHistorySteps() : [],
4125
4262
  ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm)] : [],
4263
+ ...isChangesets && isGitHub ? [githubChangesetsDeriveTagStep] : [],
4126
4264
  ...hasAssets ? [{ step: {
4127
4265
  name: "Upload Release Assets",
4128
4266
  if: publishedIf,
@@ -4141,13 +4279,25 @@ async function generateCi(ctx) {
4141
4279
  releaseEnv,
4142
4280
  condition: publishedIf,
4143
4281
  secrets: hookSecrets
4144
- })] : [],
4145
- ...isChangesets ? [postReleaseHookStep({
4146
- command: hookCommand,
4147
- secrets: hookSecrets,
4148
- condition: publishedIf
4149
4282
  })] : []
4150
4283
  ];
4284
+ let extraJobs;
4285
+ let jobOutputs;
4286
+ if (isChangesets) {
4287
+ jobOutputs = {
4288
+ published: actionsExpr("steps.release.outputs.published"),
4289
+ tag: changesetsTagOutputExpr
4290
+ };
4291
+ extraJobs = { "post-release-hook": postReleaseHookJob({
4292
+ needsJob: "check",
4293
+ publishCondition: "needs.check.outputs.published == 'true'",
4294
+ releaseTagExpr: actionsExpr("needs.check.outputs.tag"),
4295
+ command: hookCommand,
4296
+ secrets: hookSecrets,
4297
+ nodeVersionYaml,
4298
+ publishesNpm
4299
+ }) };
4300
+ }
4151
4301
  const content = buildWorkflowYaml({
4152
4302
  ci: ctx.config.ci,
4153
4303
  name: "CI",
@@ -4163,7 +4313,9 @@ async function generateCi(ctx) {
4163
4313
  ...isChangesets && { concurrency: CI_CONCURRENCY },
4164
4314
  jobName: "check",
4165
4315
  ...emitCoverageSteps && { jobEnv: IS_MAIN_JOB_ENV },
4166
- steps
4316
+ ...jobOutputs && { jobOutputs },
4317
+ steps,
4318
+ ...extraJobs && { extraJobs }
4167
4319
  });
4168
4320
  const filePath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
4169
4321
  const alreadyExists = ctx.exists(filePath);
@@ -5273,7 +5425,7 @@ async function runGenerators(ctx) {
5273
5425
  function generateMigratePrompt(results, config, detected) {
5274
5426
  const sections = [];
5275
5427
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
5276
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.61.0 repo:sync\` on ${timestamp}_\n`);
5428
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.62.0 repo:sync\` on ${timestamp}_\n`);
5277
5429
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
5278
5430
  if (changed.length > 0) {
5279
5431
  const items = changed.map((r) => {
@@ -6239,10 +6391,11 @@ async function dispatchForgejo(conn, options, executor) {
6239
6391
  },
6240
6392
  body: JSON.stringify(body)
6241
6393
  });
6242
- if (!res.ok) {
6243
- const text = await res.text();
6244
- throw new FatalError(`Failed to dispatch workflow on Forgejo: ${String(res.status)} ${res.statusText}` + (text ? `\n${text}` : ""));
6245
- }
6394
+ if (!res.ok) throw new FatalError(await forgejoErrorMessage(res, {
6395
+ verb: "dispatch workflow",
6396
+ resource: options.workflow,
6397
+ ref: options.ref
6398
+ }));
6246
6399
  log.info(`Dispatched ${options.workflow} on ${repo} (ref: ${options.ref})`);
6247
6400
  }
6248
6401
  function dispatchGitHub(options, executor) {
@@ -6472,6 +6625,39 @@ function readVersion(executor, cwd) {
6472
6625
  function platformType(config) {
6473
6626
  return config.platform?.type === "github" ? "github" : "forgejo";
6474
6627
  }
6628
+ /**
6629
+ * Resolve the serverUrl + repository the push will target so the auth URL
6630
+ * can be rewritten in `configureGitAuth`. For Forgejo we already have these
6631
+ * in the connection. For GitHub, prefer the Actions-injected env vars
6632
+ * (also set on Forgejo runners that emulate GitHub Actions) and fall back to
6633
+ * parsing package.json so local invocations still work.
6634
+ */
6635
+ function resolveAuthCoordinates(executor, config, token) {
6636
+ if (config.platform?.type === "forgejo") return {
6637
+ serverUrl: config.platform.conn.serverUrl,
6638
+ repository: config.platform.conn.repository,
6639
+ token
6640
+ };
6641
+ const envServer = process.env["GITHUB_SERVER_URL"]?.trim();
6642
+ const envRepo = process.env["GITHUB_REPOSITORY"]?.trim();
6643
+ if (envServer && envRepo) return {
6644
+ serverUrl: envServer,
6645
+ repository: envRepo,
6646
+ token
6647
+ };
6648
+ const pkgRaw = executor.readFile(path.join(config.cwd, "package.json"));
6649
+ if (!pkgRaw) return void 0;
6650
+ const repoField = parsePackageJson(pkgRaw)?.repository;
6651
+ const urlStr = typeof repoField === "string" ? repoField : repoField?.url;
6652
+ if (!urlStr) return void 0;
6653
+ const parsed = parseGitUrl(urlStr);
6654
+ if (!parsed) return void 0;
6655
+ return {
6656
+ serverUrl: `${parsed.protocol}//${parsed.hostname}`,
6657
+ repository: parsed.repository,
6658
+ token
6659
+ };
6660
+ }
6475
6661
  /** Check whether a "tag already exists" failure can be recovered from. */
6476
6662
  function recoverFromExistingTag(executor, config, output) {
6477
6663
  const match = /tag '([^']+)' already exists/.exec(output);
@@ -6486,6 +6672,8 @@ function recoverFromExistingTag(executor, config, output) {
6486
6672
  }
6487
6673
  /** Run the full commit-and-tag-version release flow. */
6488
6674
  async function runSimpleRelease(executor, config) {
6675
+ const releaseToken = process.env["RELEASE_TOKEN"]?.trim();
6676
+ if (!releaseToken) throw new FatalError("RELEASE_TOKEN environment variable is required for release:simple. Create a PAT with write:repository (Forgejo) or contents: write (GitHub) scope on the target forge, store it as the RELEASE_TOKEN secret on your repo (or org), and expose it as env to this step.");
6489
6677
  const platform = platformType(config);
6490
6678
  debug(config, `Platform: ${platform}`);
6491
6679
  debug(config, `Config: dryRun=${String(config.dryRun)} noPush=${String(config.noPush)} noSlidingTags=${String(config.noSlidingTags)} noRelease=${String(config.noRelease)}`);
@@ -6529,10 +6717,11 @@ async function runSimpleRelease(executor, config) {
6529
6717
  }
6530
6718
  let pushed = false;
6531
6719
  if (!config.noPush) {
6532
- if (config.platform?.type === "forgejo") {
6533
- configureGitAuth(executor, config.platform.conn, config.cwd);
6534
- debug(config, `Configured git remote auth for ${config.platform.conn.repository}`);
6535
- }
6720
+ const authCoords = resolveAuthCoordinates(executor, config, releaseToken);
6721
+ if (authCoords) {
6722
+ configureGitAuth(executor, authCoords, config.cwd);
6723
+ debug(config, `Configured git remote auth for ${authCoords.repository}`);
6724
+ } else debug(config, "Skipping git remote auth: could not resolve server URL / repository");
6536
6725
  const branch = executor.exec("git", [
6537
6726
  "rev-parse",
6538
6727
  "--abbrev-ref",
@@ -8450,7 +8639,8 @@ function maskSecret(secret) {
8450
8639
  }
8451
8640
  /**
8452
8641
  * Send a webhook and log the result. Intended for CLI usage.
8453
- * Never throws.
8642
+ * Returns the underlying result so the caller can decide whether to
8643
+ * surface a non-zero exit. Never throws.
8454
8644
  */
8455
8645
  async function sendWebhookWithLogging(url, secret, payload) {
8456
8646
  const result = await sendWebhook({
@@ -8460,10 +8650,27 @@ async function sendWebhookWithLogging(url, secret, payload) {
8460
8650
  });
8461
8651
  const context = `url=${url}, secret=${maskSecret(secret)}`;
8462
8652
  if (result.ok) log.info(`Post-release hook completed successfully (${context})`);
8463
- else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"} (${context})`);
8653
+ else log.error(`Post-release hook failed: ${result.error ?? "unknown error"} (${context})`);
8654
+ return result;
8464
8655
  }
8465
8656
  //#endregion
8466
8657
  //#region src/commands/webhook-send.ts
8658
+ /**
8659
+ * Choose the payload source for `webhook:send`. When `--tag` or
8660
+ * `--repository` is set, build from flags via `buildSimplePayload` — this
8661
+ * makes the explicit tag win over `GITHUB_REF`, which is often `refs/heads/main`
8662
+ * (not a tag) when the post-release hook fires.
8663
+ */
8664
+ function resolveWebhookPayload(args, env = process.env) {
8665
+ if (args.tag || args.repository) return buildSimplePayload({
8666
+ tag: args.tag ?? "",
8667
+ repository: args.repository ?? env["GITHUB_REPOSITORY"] ?? "",
8668
+ serverUrl: args["server-url"] ?? env["GITHUB_SERVER_URL"] ?? "https://github.com",
8669
+ actor: args.actor ?? env["GITHUB_ACTOR"] ?? "github-actions[bot]",
8670
+ prerelease: args.prerelease ?? false
8671
+ });
8672
+ return buildPayloadFromEnv();
8673
+ }
8467
8674
  const webhookSendCommand = defineCommand({
8468
8675
  meta: {
8469
8676
  name: "webhook:send",
@@ -8501,14 +8708,8 @@ const webhookSendCommand = defineCommand({
8501
8708
  return;
8502
8709
  }
8503
8710
  const secret = process.env["WEBHOOK_SECRET"];
8504
- const payload = args.tag || args.repository ? buildSimplePayload({
8505
- tag: args.tag ?? "",
8506
- repository: args.repository ?? process.env["GITHUB_REPOSITORY"] ?? "",
8507
- serverUrl: args["server-url"] ?? process.env["GITHUB_SERVER_URL"] ?? "https://github.com",
8508
- actor: args.actor ?? process.env["GITHUB_ACTOR"] ?? "github-actions[bot]",
8509
- prerelease: args.prerelease ?? false
8510
- }) : buildPayloadFromEnv();
8511
- await sendWebhookWithLogging(args.url, secret || void 0, payload);
8711
+ const payload = resolveWebhookPayload(args);
8712
+ if (!(await sendWebhookWithLogging(args.url, secret || void 0, payload)).ok) process.exitCode = 1;
8512
8713
  }
8513
8714
  });
8514
8715
  //#endregion
@@ -8921,7 +9122,7 @@ const out = (msg) => console.log(msg);
8921
9122
  const main = defineCommand({
8922
9123
  meta: {
8923
9124
  name: "bst",
8924
- version: "0.61.0",
9125
+ version: "0.62.0",
8925
9126
  description: "Bootstrap and maintain standardized TypeScript project tooling"
8926
9127
  },
8927
9128
  subCommands: {
@@ -8994,7 +9195,7 @@ const main = defineCommand({
8994
9195
  })
8995
9196
  }
8996
9197
  });
8997
- console.log(`@bensandee/tooling v0.61.0`);
9198
+ console.log(`@bensandee/tooling v0.62.0`);
8998
9199
  async function run() {
8999
9200
  await runMain(main);
9000
9201
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {