@bensandee/tooling 0.61.0 → 0.62.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 (3) hide show
  1. package/README.md +5 -1
  2. package/dist/bin.mjs +335 -119
  3. package/package.json +3 -3
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,21 +844,27 @@ function parseGitUrl(urlStr) {
844
844
  }
845
845
  }
846
846
  /**
847
- * Configure the git remote origin to use a Forgejo token for push auth.
847
+ * Build per-invocation git auth for release pushes.
848
848
  *
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.
849
+ * Pure: no executor side effects, no `.git/config` mutation. Caller threads
850
+ * `auth.args` + `auth.url` through every `git push` / `git ls-remote` that
851
+ * needs to authenticate as the RELEASE_TOKEN owner.
852
+ *
853
+ * Background: `actions/checkout@v6` mounts its Authorization header through
854
+ * `includeIf.gitdir:<workspace>/.git.path → /tmp/git-credentials-*.config`
855
+ * (the file holds `http.<server>/.extraheader = Authorization: Basic <runner
856
+ * pseudo-user>`). That header overrides URL-embedded auth in the request,
857
+ * which is why pushes from CI authenticated as `uid: -2` and tripped the
858
+ * Forgejo pre-receive hook. Setting `extraheader=` to empty at the
859
+ * command-line layer wins over the include, so the only Authorization git
860
+ * sends is the one from `auth.url`.
852
861
  */
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}`;
856
- executor.exec("git", [
857
- "remote",
858
- "set-url",
859
- "origin",
860
- authUrl
861
- ], { cwd });
862
+ function buildGitAuth(coords) {
863
+ const host = coords.serverUrl.replace(/^https?:\/\//, "");
864
+ return {
865
+ args: ["-c", `http.${coords.serverUrl}/.extraheader=`],
866
+ url: `https://x-access-token:${coords.token}@${host}/${coords.repository}`
867
+ };
862
868
  }
863
869
  /** Resolve the CI bot actor name for the given platform. */
864
870
  function ciActorName(platform) {
@@ -1580,11 +1586,21 @@ function buildWorkflowYaml(options) {
1580
1586
  doc["on"] = options.on;
1581
1587
  if (options.permissions) doc["permissions"] = options.permissions;
1582
1588
  if (options.concurrency) doc["concurrency"] = options.concurrency;
1583
- doc["jobs"] = { [options.jobName]: {
1589
+ const jobs = { [options.jobName]: {
1584
1590
  "runs-on": options.runsOn ?? "ubuntu-latest",
1585
1591
  ...options.jobEnv && { env: options.jobEnv },
1592
+ ...options.jobOutputs && { outputs: options.jobOutputs },
1586
1593
  steps: options.steps.map((s) => s.step)
1587
1594
  } };
1595
+ for (const [name, def] of Object.entries(options.extraJobs ?? {})) jobs[name] = {
1596
+ ...def.needs && { needs: def.needs },
1597
+ ...def.if && { if: def.if },
1598
+ ...def["continue-on-error"] && { "continue-on-error": true },
1599
+ "runs-on": def.runsOn ?? "ubuntu-latest",
1600
+ ...def.env && { env: def.env },
1601
+ steps: def.steps.map((s) => s.step)
1602
+ };
1603
+ doc["jobs"] = jobs;
1588
1604
  const readable = stringify(doc, {
1589
1605
  lineWidth: 0,
1590
1606
  nullStr: ""
@@ -2206,7 +2222,7 @@ async function generatePackageJson(ctx) {
2206
2222
  const devDeps = { ...ROOT_DEV_DEPS };
2207
2223
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
2208
2224
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.11.3";
2209
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.61.0";
2225
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.62.1";
2210
2226
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.0";
2211
2227
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
2212
2228
  "@changesets/cli": "2.31.0",
@@ -3172,6 +3188,67 @@ async function responseBodyText(res) {
3172
3188
  return "(could not read response body)";
3173
3189
  }
3174
3190
  }
3191
+ const SwaggerEnvelopeSchema = z.union([z.object({ message: z.literal("") }), z.object({
3192
+ message: z.string(),
3193
+ url: z.string().regex(/\/api\/swagger$/)
3194
+ })]);
3195
+ /**
3196
+ * Detect Forgejo's signature "route not handled" response shape — empty body
3197
+ * or a JSON envelope with either an empty `message` or a `url` pointing at
3198
+ * `/api/swagger`. Forgejo returns this from a 500 (and sometimes 404) when no
3199
+ * route handles the request, even though the status code suggests a
3200
+ * server-side fault.
3201
+ */
3202
+ function isForgejoGenericServerErrorBody(body) {
3203
+ if (body === "") return true;
3204
+ try {
3205
+ const parsed = JSON.parse(body);
3206
+ return SwaggerEnvelopeSchema.safeParse(parsed).success;
3207
+ } catch (_error) {
3208
+ return false;
3209
+ }
3210
+ }
3211
+ const DISPATCH_VERB_PATTERN = /\bdispatch\b/i;
3212
+ /**
3213
+ * Map a non-2xx Forgejo response to a human-readable hint.
3214
+ *
3215
+ * Returns the most-likely cause first; callers still surface the raw body so an
3216
+ * uncommon failure mode isn't masked. Returns "" for unmapped codes (2xx/3xx,
3217
+ * teapots) so the caller can decide whether to include a Hint line at all.
3218
+ */
3219
+ function forgejoErrorHint(status, body, operation) {
3220
+ const isDispatch = DISPATCH_VERB_PATTERN.test(operation.verb);
3221
+ const resource = operation.resource ?? "the resource";
3222
+ if (status === 401 || status === 403) return `Forgejo rejected the token. It needs the scope required for "${operation.verb}" on ${resource}.`;
3223
+ if (status === 404) {
3224
+ 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.`;
3225
+ return isDispatch ? `${base} For dispatch, also confirm the workflow has an "on: workflow_dispatch:" trigger.` : base;
3226
+ }
3227
+ if (status === 400 || status === 422) {
3228
+ const base = `Forgejo rejected the request payload for "${operation.verb}". Check for missing or invalid fields.`;
3229
+ return isDispatch ? `${base} For dispatch, common causes are a missing "on: workflow_dispatch:" trigger or a required input not being provided.` : base;
3230
+ }
3231
+ if (status >= 500 && status < 600) {
3232
+ if (isForgejoGenericServerErrorBody(body)) {
3233
+ const ref = operation.ref ? ` on ${operation.ref}` : "";
3234
+ 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.`;
3235
+ }
3236
+ return `Forgejo server error — usually transient. Retry; if it persists, check the server logs.`;
3237
+ }
3238
+ return "";
3239
+ }
3240
+ /**
3241
+ * Build a richer error message for a non-2xx Forgejo API response.
3242
+ *
3243
+ * Reads, truncates, and redacts the body via {@link responseBodyText}; appends
3244
+ * a `Hint:` line when {@link forgejoErrorHint} has one; appends the raw body as
3245
+ * a `Response:` block. Callers still pick the error class (Transient vs Fatal).
3246
+ */
3247
+ async function forgejoErrorMessage(res, operation) {
3248
+ const body = await responseBodyText(res);
3249
+ const hint = forgejoErrorHint(res.status, body, operation);
3250
+ return `Failed to ${operation.verb} on Forgejo: ${String(res.status)} ${res.statusText}` + (hint ? `\nHint: ${hint}` : "") + (body ? `\nResponse: ${body}` : "");
3251
+ }
3175
3252
  const PullRequestSchema = z.array(z.object({
3176
3253
  number: z.number(),
3177
3254
  head: z.object({ ref: z.string() })
@@ -3185,10 +3262,10 @@ const PullRequestSchema = z.array(z.object({
3185
3262
  async function findOpenPr(executor, conn, head) {
3186
3263
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?state=open`;
3187
3264
  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
- }
3265
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3266
+ verb: "list PRs",
3267
+ resource: conn.repository
3268
+ }));
3192
3269
  const parsed = PullRequestSchema.safeParse(await res.json());
3193
3270
  if (!parsed.success) throw new UnexpectedError(`Unexpected PR list response: ${parsed.error.message}`);
3194
3271
  return parsed.data.find((pr) => pr.head.ref === head)?.number ?? null;
@@ -3210,10 +3287,10 @@ async function createPr(executor, conn, options) {
3210
3287
  },
3211
3288
  body: JSON.stringify(payload)
3212
3289
  });
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
- }
3290
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3291
+ verb: "create PR",
3292
+ resource: `${options.head}→${options.base}`
3293
+ }));
3217
3294
  }
3218
3295
  /** Update an existing pull request's title and body. */
3219
3296
  async function updatePr(executor, conn, prNumber, options) {
@@ -3229,10 +3306,10 @@ async function updatePr(executor, conn, prNumber, options) {
3229
3306
  body: options.body
3230
3307
  })
3231
3308
  });
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
- }
3309
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3310
+ verb: "update PR",
3311
+ resource: `#${String(prNumber)}`
3312
+ }));
3236
3313
  }
3237
3314
  /** Merge a pull request by number. */
3238
3315
  async function mergePr(executor, conn, prNumber, options) {
@@ -3248,10 +3325,10 @@ async function mergePr(executor, conn, prNumber, options) {
3248
3325
  delete_branch_after_merge: options?.deleteBranch ?? true
3249
3326
  })
3250
3327
  });
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
- }
3328
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3329
+ verb: "merge PR",
3330
+ resource: `#${String(prNumber)}`
3331
+ }));
3255
3332
  }
3256
3333
  /** Check whether a Forgejo release already exists for a given tag. */
3257
3334
  async function findRelease(executor, conn, tag) {
@@ -3260,8 +3337,10 @@ async function findRelease(executor, conn, tag) {
3260
3337
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3261
3338
  if (res.status === 200) return true;
3262
3339
  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}`);
3340
+ throw new TransientError(await forgejoErrorMessage(res, {
3341
+ verb: "check release",
3342
+ resource: tag
3343
+ }));
3265
3344
  }
3266
3345
  /** Create a Forgejo release for a given tag. */
3267
3346
  async function createRelease(executor, conn, tag) {
@@ -3278,10 +3357,10 @@ async function createRelease(executor, conn, tag) {
3278
3357
  body: `Published ${tag}`
3279
3358
  })
3280
3359
  });
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
- }
3360
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3361
+ verb: "create release",
3362
+ resource: tag
3363
+ }));
3285
3364
  }
3286
3365
  /**
3287
3366
  * Ensure a Forgejo release exists for a tag, creating it if necessary.
@@ -3313,10 +3392,10 @@ async function uploadReleaseAsset(executor, conn, tag, options) {
3313
3392
  const encodedTag = encodeURIComponent(tag);
3314
3393
  const releaseUrl = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
3315
3394
  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
- }
3395
+ if (!releaseRes.ok) throw new TransientError(await forgejoErrorMessage(releaseRes, {
3396
+ verb: "get release",
3397
+ resource: tag
3398
+ }));
3320
3399
  const parsed = z.object({ id: z.number() }).safeParse(await releaseRes.json());
3321
3400
  if (!parsed.success) throw new UnexpectedError(`Unexpected release response: ${parsed.error.message}`);
3322
3401
  const releaseId = parsed.data.id;
@@ -3328,10 +3407,10 @@ async function uploadReleaseAsset(executor, conn, tag, options) {
3328
3407
  headers: { Authorization: `token ${conn.token}` },
3329
3408
  body: form
3330
3409
  });
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
- }
3410
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3411
+ verb: "upload asset",
3412
+ resource: `${options.assetName} to ${tag}`
3413
+ }));
3335
3414
  }
3336
3415
  const ActionSecretListSchema = z.object({
3337
3416
  secrets: z.array(z.object({ name: z.string() })),
@@ -3362,10 +3441,10 @@ async function createAccessToken(executor, serverUrl, username, password, tokenN
3362
3441
  scopes
3363
3442
  })
3364
3443
  });
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
- }
3444
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3445
+ verb: "create access token",
3446
+ resource: username
3447
+ }));
3369
3448
  const parsed = AccessTokenResponseSchema.safeParse(await res.json());
3370
3449
  if (!parsed.success) throw new UnexpectedError(`Unexpected token response: ${parsed.error.message}`);
3371
3450
  return parsed.data.sha1;
@@ -3386,10 +3465,10 @@ async function setActionSecret(executor, conn, secretName, secretValue) {
3386
3465
  },
3387
3466
  body: JSON.stringify({ data: secretValue })
3388
3467
  });
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
- }
3468
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3469
+ verb: "set secret",
3470
+ resource: secretName
3471
+ }));
3393
3472
  }
3394
3473
  /**
3395
3474
  * Set (or overwrite) a Forgejo Actions secret at the user level.
@@ -3412,10 +3491,10 @@ async function setUserActionSecret(executor, serverUrl, authorization, secretNam
3412
3491
  headers,
3413
3492
  body: JSON.stringify({ data: secretValue })
3414
3493
  });
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
- }
3494
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3495
+ verb: "set user secret",
3496
+ resource: secretName
3497
+ }));
3419
3498
  }
3420
3499
  /**
3421
3500
  * List Forgejo Actions secret names for a repository.
@@ -3431,10 +3510,10 @@ async function listActionSecrets(executor, conn) {
3431
3510
  for (;;) {
3432
3511
  const url = `${conn.serverUrl}/api/v1/repos/${owner}/${repo}/actions/secrets?page=${String(page)}&limit=${String(limit)}`;
3433
3512
  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
- }
3513
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3514
+ verb: "list secrets",
3515
+ resource: `${owner}/${repo}`
3516
+ }));
3438
3517
  const parsed = ActionSecretListSchema.safeParse(await res.json());
3439
3518
  if (!parsed.success) throw new UnexpectedError(`Unexpected secrets list response: ${parsed.error.message}`);
3440
3519
  for (const secret of parsed.data.secrets) names.push(secret.name);
@@ -3455,10 +3534,10 @@ async function deleteActionSecret(executor, conn, secretName) {
3455
3534
  method: "DELETE",
3456
3535
  headers: { Authorization: `token ${conn.token}` }
3457
3536
  });
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
- }
3537
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3538
+ verb: "delete secret",
3539
+ resource: secretName
3540
+ }));
3462
3541
  }
3463
3542
  const WebhookSchema = z.object({
3464
3543
  id: z.number(),
@@ -3467,6 +3546,11 @@ const WebhookSchema = z.object({
3467
3546
  config: z.record(z.string(), z.string()).optional()
3468
3547
  });
3469
3548
  const WebhookListSchema = z.array(WebhookSchema);
3549
+ /**
3550
+ * Returns a status struct instead of throwing, so it deliberately does not use
3551
+ * {@link forgejoErrorMessage} — the caller branches on `status` rather than
3552
+ * catching an error.
3553
+ */
3470
3554
  async function fetchHooks(executor, conn, endpoint) {
3471
3555
  const res = await executor.fetch(`${conn.serverUrl}/api/v1${endpoint}`, { headers: { Authorization: `token ${conn.token}` } });
3472
3556
  if (res.status === 401 || res.status === 403) return {
@@ -3531,17 +3615,17 @@ async function hasAdminAccess(executor, conn) {
3531
3615
  const OrgSchema = z.object({ username: z.string() });
3532
3616
  const OrgListSchema = z.array(OrgSchema);
3533
3617
  const PAGE_LIMIT = 50;
3534
- async function paginate(executor, conn, endpoint, extract) {
3618
+ async function paginate(executor, conn, endpoint, verb, extract) {
3535
3619
  const all = [];
3536
3620
  let page = 1;
3537
3621
  const separator = endpoint.includes("?") ? "&" : "?";
3538
3622
  for (;;) {
3539
3623
  const url = `${conn.serverUrl}/api/v1${endpoint}${separator}page=${String(page)}&limit=${String(PAGE_LIMIT)}`;
3540
3624
  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
- }
3625
+ if (!res.ok) throw new TransientError(await forgejoErrorMessage(res, {
3626
+ verb,
3627
+ resource: `${endpoint} (page ${String(page)})`
3628
+ }));
3545
3629
  const items = extract(await res.json());
3546
3630
  all.push(...items);
3547
3631
  if (items.length < PAGE_LIMIT) break;
@@ -3555,7 +3639,7 @@ async function paginate(executor, conn, endpoint, extract) {
3555
3639
  */
3556
3640
  async function listOrgs(executor, conn, mode) {
3557
3641
  const endpoint = mode === "admin" ? "/admin/orgs" : "/user/orgs";
3558
- return (await paginate(executor, conn, endpoint, (raw) => {
3642
+ return (await paginate(executor, conn, endpoint, "list orgs", (raw) => {
3559
3643
  const parsed = OrgListSchema.safeParse(raw);
3560
3644
  if (!parsed.success) throw new UnexpectedError(`Unexpected ${endpoint} response: ${parsed.error.message}`);
3561
3645
  return parsed.data;
@@ -3564,7 +3648,7 @@ async function listOrgs(executor, conn, mode) {
3564
3648
  const RepoSearchSchema = z.object({ data: z.array(z.object({ full_name: z.string() })) });
3565
3649
  /** List repositories visible to the token via `/repos/search`. */
3566
3650
  async function searchRepos(executor, conn) {
3567
- return (await paginate(executor, conn, "/repos/search", (raw) => {
3651
+ return (await paginate(executor, conn, "/repos/search", "list repos", (raw) => {
3568
3652
  const parsed = RepoSearchSchema.safeParse(raw);
3569
3653
  if (!parsed.success) throw new UnexpectedError(`Unexpected /repos/search response: ${parsed.error.message}`);
3570
3654
  return parsed.data.data;
@@ -3772,7 +3856,11 @@ async function generateGitignore(ctx) {
3772
3856
  }
3773
3857
  //#endregion
3774
3858
  //#region src/generators/release-ci.ts
3775
- const DEFAULT_POST_RELEASE_COMMAND = ["pnpm exec bst webhook:send", " \"$POST_RELEASE_HOOK_URL\""].join("");
3859
+ const DEFAULT_POST_RELEASE_COMMAND = [
3860
+ "pnpm exec bst webhook:send",
3861
+ " \"$POST_RELEASE_HOOK_URL\"",
3862
+ " --tag \"$RELEASE_TAG\""
3863
+ ].join("");
3776
3864
  /** Map secret names to `${{ secrets.<NAME> }}` env entries. */
3777
3865
  function secretsEnv(secrets) {
3778
3866
  const env = {};
@@ -3798,6 +3886,26 @@ function postReleaseHookStep(opts) {
3798
3886
  } };
3799
3887
  }
3800
3888
  /**
3889
+ * Post-release hook as a separate job with `continue-on-error: true` so a
3890
+ * webhook failure (HTTP 5xx, DNS, etc.) shows up as a red job in the run UI
3891
+ * without failing the overall workflow conclusion.
3892
+ */
3893
+ function postReleaseHookJob(opts) {
3894
+ return {
3895
+ needs: opts.needsJob,
3896
+ if: opts.publishCondition,
3897
+ "continue-on-error": true,
3898
+ steps: [...commonSteps(opts.nodeVersionYaml, opts.publishesNpm), { step: {
3899
+ name: "Post-release hook",
3900
+ env: {
3901
+ ...hookSecretEnv(opts.secrets),
3902
+ RELEASE_TAG: opts.releaseTagExpr
3903
+ },
3904
+ run: opts.command ?? DEFAULT_POST_RELEASE_COMMAND
3905
+ } }]
3906
+ };
3907
+ }
3908
+ /**
3801
3909
  * Step that dispatches a `workflow_dispatch` event to another repo after a
3802
3910
  * release, triggering a downstream deploy. The release tag is forwarded from
3803
3911
  * the `release` step's `tag` output.
@@ -3845,7 +3953,7 @@ function commonSteps(nodeVersionYaml, publishesNpm) {
3845
3953
  ];
3846
3954
  }
3847
3955
  function releaseItSteps(ci, nodeVersionYaml, publishesNpm) {
3848
- const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
3956
+ const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
3849
3957
  const npmEnv = publishesNpm ? { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") } : {};
3850
3958
  return [...commonSteps(nodeVersionYaml, publishesNpm), { step: {
3851
3959
  run: "pnpm release-it --ci",
@@ -3881,7 +3989,11 @@ function simpleWorkflowDispatchTrigger() {
3881
3989
  }
3882
3990
  /** Platform connection env so a `bst` step can resolve the git host. */
3883
3991
  function connectionEnv(ci) {
3884
- return ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
3992
+ if (ci === "github") return {
3993
+ RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
3994
+ GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
3995
+ };
3996
+ return {
3885
3997
  FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
3886
3998
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
3887
3999
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
@@ -3970,15 +4082,27 @@ async function generateReleaseCi(ctx) {
3970
4082
  secrets: hookSecrets
3971
4083
  }));
3972
4084
  }
4085
+ let extraJobs;
4086
+ let jobOutputs;
3973
4087
  if (ctx.config.releaseStrategy === "release-it") steps.push(postReleaseHookStep({
3974
4088
  command: getPostReleaseHookCommand(ctx),
3975
4089
  secrets: hookSecrets
3976
4090
  }));
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
- }));
4091
+ else if (ctx.config.releaseStrategy === "simple") {
4092
+ jobOutputs = {
4093
+ pushed: actionsExpr("steps.release.outputs.pushed"),
4094
+ tag: actionsExpr("steps.release.outputs.tag")
4095
+ };
4096
+ extraJobs = { "post-release-hook": postReleaseHookJob({
4097
+ needsJob: "release",
4098
+ publishCondition: "needs.release.outputs.pushed == 'true'",
4099
+ releaseTagExpr: actionsExpr("needs.release.outputs.tag"),
4100
+ command: getPostReleaseHookCommand(ctx),
4101
+ secrets: hookSecrets,
4102
+ nodeVersionYaml,
4103
+ publishesNpm
4104
+ }) };
4105
+ }
3982
4106
  const on = ctx.config.releaseStrategy === "simple" ? simpleWorkflowDispatchTrigger() : { workflow_dispatch: null };
3983
4107
  const content = buildWorkflowYaml({
3984
4108
  ci: ctx.config.ci,
@@ -3986,7 +4110,9 @@ async function generateReleaseCi(ctx) {
3986
4110
  on,
3987
4111
  ...isGitHub && { permissions: { contents: "write" } },
3988
4112
  jobName: "release",
3989
- steps
4113
+ ...jobOutputs && { jobOutputs },
4114
+ steps,
4115
+ ...extraJobs && { extraJobs }
3990
4116
  });
3991
4117
  const alreadyExists = ctx.exists(workflowPath);
3992
4118
  const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
@@ -4043,7 +4169,7 @@ function changesetsReleaseStep(ci, publishesNpm) {
4043
4169
  version: "pnpm changeset version"
4044
4170
  },
4045
4171
  env: {
4046
- GITHUB_TOKEN: actionsExpr("github.token"),
4172
+ GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
4047
4173
  ...publishesNpm && { NPM_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
4048
4174
  }
4049
4175
  } };
@@ -4112,8 +4238,16 @@ async function generateCi(ctx) {
4112
4238
  const hasAssets = isChangesets && hasReleaseAssets(ctx);
4113
4239
  const host = hasDocker ? resolveGenTimeHost(ctx) : null;
4114
4240
  const publishedIf = "steps.release.outputs.published == 'true'";
4241
+ const githubChangesetsDeriveTagStep = { step: {
4242
+ name: "Derive release tag",
4243
+ id: "derive-tag",
4244
+ if: publishedIf,
4245
+ env: { PUBLISHED_PACKAGES: actionsExpr("steps.release.outputs.publishedPackages") },
4246
+ run: "echo \"tag=$(echo \"$PUBLISHED_PACKAGES\" | jq -r '.[0] | \"\\(.name)@\\(.version)\"')\" >> \"$GITHUB_OUTPUT\""
4247
+ } };
4248
+ const changesetsTagOutputExpr = isGitHub ? actionsExpr("steps.derive-tag.outputs.tag") : actionsExpr("steps.release.outputs.tag");
4115
4249
  const releaseEnv = {};
4116
- if (isGitHub) releaseEnv["GITHUB_TOKEN"] = actionsExpr("github.token");
4250
+ if (isGitHub) releaseEnv["GITHUB_TOKEN"] = actionsExpr("secrets.RELEASE_TOKEN");
4117
4251
  else {
4118
4252
  releaseEnv["FORGEJO_SERVER_URL"] = actionsExpr("github.server_url");
4119
4253
  releaseEnv["FORGEJO_REPOSITORY"] = actionsExpr("github.repository");
@@ -4123,6 +4257,7 @@ async function generateCi(ctx) {
4123
4257
  ...checkSteps(nodeVersionYaml, isChangesets && publishesNpm, coverage.enabled),
4124
4258
  ...emitCoverageSteps ? coverageHistorySteps() : [],
4125
4259
  ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm)] : [],
4260
+ ...isChangesets && isGitHub ? [githubChangesetsDeriveTagStep] : [],
4126
4261
  ...hasAssets ? [{ step: {
4127
4262
  name: "Upload Release Assets",
4128
4263
  if: publishedIf,
@@ -4141,13 +4276,25 @@ async function generateCi(ctx) {
4141
4276
  releaseEnv,
4142
4277
  condition: publishedIf,
4143
4278
  secrets: hookSecrets
4144
- })] : [],
4145
- ...isChangesets ? [postReleaseHookStep({
4146
- command: hookCommand,
4147
- secrets: hookSecrets,
4148
- condition: publishedIf
4149
4279
  })] : []
4150
4280
  ];
4281
+ let extraJobs;
4282
+ let jobOutputs;
4283
+ if (isChangesets) {
4284
+ jobOutputs = {
4285
+ published: actionsExpr("steps.release.outputs.published"),
4286
+ tag: changesetsTagOutputExpr
4287
+ };
4288
+ extraJobs = { "post-release-hook": postReleaseHookJob({
4289
+ needsJob: "check",
4290
+ publishCondition: "needs.check.outputs.published == 'true'",
4291
+ releaseTagExpr: actionsExpr("needs.check.outputs.tag"),
4292
+ command: hookCommand,
4293
+ secrets: hookSecrets,
4294
+ nodeVersionYaml,
4295
+ publishesNpm
4296
+ }) };
4297
+ }
4151
4298
  const content = buildWorkflowYaml({
4152
4299
  ci: ctx.config.ci,
4153
4300
  name: "CI",
@@ -4163,7 +4310,9 @@ async function generateCi(ctx) {
4163
4310
  ...isChangesets && { concurrency: CI_CONCURRENCY },
4164
4311
  jobName: "check",
4165
4312
  ...emitCoverageSteps && { jobEnv: IS_MAIN_JOB_ENV },
4166
- steps
4313
+ ...jobOutputs && { jobOutputs },
4314
+ steps,
4315
+ ...extraJobs && { extraJobs }
4167
4316
  });
4168
4317
  const filePath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
4169
4318
  const alreadyExists = ctx.exists(filePath);
@@ -5273,7 +5422,7 @@ async function runGenerators(ctx) {
5273
5422
  function generateMigratePrompt(results, config, detected) {
5274
5423
  const sections = [];
5275
5424
  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`);
5425
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.62.1 repo:sync\` on ${timestamp}_\n`);
5277
5426
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
5278
5427
  if (changed.length > 0) {
5279
5428
  const items = changed.map((r) => {
@@ -5938,9 +6087,11 @@ async function runVersionMode(executor, config) {
5938
6087
  };
5939
6088
  }
5940
6089
  debug(config, `Force-pushing version commit to ${BRANCH}`);
6090
+ const auth = buildGitAuth(config);
5941
6091
  const pushResult = executor.exec("git", [
6092
+ ...auth.args,
5942
6093
  "push",
5943
- "origin",
6094
+ auth.url,
5944
6095
  `HEAD:refs/heads/${BRANCH}`,
5945
6096
  "--force"
5946
6097
  ], { cwd: config.cwd });
@@ -6001,10 +6152,12 @@ async function runPublishMode(executor, config) {
6001
6152
  debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
6002
6153
  const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
6003
6154
  debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
6155
+ const auth = buildGitAuth(config);
6004
6156
  const remoteTags = parseRemoteTags(executor.exec("git", [
6157
+ ...auth.args,
6005
6158
  "ls-remote",
6006
6159
  "--tags",
6007
- "origin"
6160
+ auth.url
6008
6161
  ], { cwd: config.cwd }).stdout);
6009
6162
  debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
6010
6163
  const remoteSet = new Set(remoteTags);
@@ -6053,8 +6206,9 @@ async function runPublishMode(executor, config) {
6053
6206
  if (tagResult.exitCode !== 0) throw new FatalError(`Failed to create tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
6054
6207
  }
6055
6208
  const pushTagResult = executor.exec("git", [
6209
+ ...auth.args,
6056
6210
  "push",
6057
- "origin",
6211
+ auth.url,
6058
6212
  `refs/tags/${tag}`
6059
6213
  ], { cwd: config.cwd });
6060
6214
  if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
@@ -6164,8 +6318,6 @@ async function runRelease(config, executor) {
6164
6318
  }
6165
6319
  const identity = await configureGitIdentity(executor, "forgejo", config.cwd, config);
6166
6320
  log.info(`Git identity: ${identity.name} <${identity.email}>`);
6167
- configureGitAuth(executor, config, config.cwd);
6168
- debug(config, `Configured git remote auth for ${config.repository}`);
6169
6321
  const changesetFiles = executor.listChangesetFiles(config.cwd);
6170
6322
  debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
6171
6323
  if (changesetFiles.length > 0) {
@@ -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)}`);
@@ -6528,23 +6716,32 @@ async function runSimpleRelease(executor, config) {
6528
6716
  };
6529
6717
  }
6530
6718
  let pushed = false;
6719
+ const authCoords = resolveAuthCoordinates(executor, config, releaseToken);
6720
+ let auth;
6721
+ if (authCoords) {
6722
+ auth = buildGitAuth(authCoords);
6723
+ debug(config, `Built git auth for ${authCoords.repository}`);
6724
+ } else debug(config, "Skipping git auth: could not resolve server URL / repository");
6531
6725
  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
- }
6536
6726
  const branch = executor.exec("git", [
6537
6727
  "rev-parse",
6538
6728
  "--abbrev-ref",
6539
6729
  "HEAD"
6540
6730
  ], { cwd: config.cwd }).stdout.trim() || "main";
6541
6731
  debug(config, `Pushing ${tag} to origin/${branch} with --follow-tags`);
6542
- const pushResult = executor.exec("git", [
6732
+ const pushArgs = auth ? [
6733
+ ...auth.args,
6734
+ "push",
6735
+ "--follow-tags",
6736
+ auth.url,
6737
+ branch
6738
+ ] : [
6543
6739
  "push",
6544
6740
  "--follow-tags",
6545
6741
  "origin",
6546
6742
  branch
6547
- ], { cwd: config.cwd });
6743
+ ];
6744
+ const pushResult = executor.exec("git", pushArgs, { cwd: config.cwd });
6548
6745
  debugExec(config, "git push", pushResult);
6549
6746
  if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
6550
6747
  pushed = true;
@@ -6562,12 +6759,19 @@ async function runSimpleRelease(executor, config) {
6562
6759
  ], { cwd: config.cwd });
6563
6760
  if (slidingTagResult.exitCode !== 0) throw new FatalError(`Failed to create sliding tag ${slidingTag}: ${slidingTagResult.stderr || slidingTagResult.stdout}`);
6564
6761
  }
6565
- const forcePushResult = executor.exec("git", [
6762
+ const forcePushArgs = auth ? [
6763
+ ...auth.args,
6764
+ "push",
6765
+ auth.url,
6766
+ ...slidingTags,
6767
+ "--force"
6768
+ ] : [
6566
6769
  "push",
6567
6770
  "origin",
6568
6771
  ...slidingTags,
6569
6772
  "--force"
6570
- ], { cwd: config.cwd });
6773
+ ];
6774
+ const forcePushResult = executor.exec("git", forcePushArgs, { cwd: config.cwd });
6571
6775
  debugExec(config, "force-push sliding tags", forcePushResult);
6572
6776
  if (forcePushResult.exitCode !== 0) log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
6573
6777
  else log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
@@ -8450,7 +8654,8 @@ function maskSecret(secret) {
8450
8654
  }
8451
8655
  /**
8452
8656
  * Send a webhook and log the result. Intended for CLI usage.
8453
- * Never throws.
8657
+ * Returns the underlying result so the caller can decide whether to
8658
+ * surface a non-zero exit. Never throws.
8454
8659
  */
8455
8660
  async function sendWebhookWithLogging(url, secret, payload) {
8456
8661
  const result = await sendWebhook({
@@ -8460,10 +8665,27 @@ async function sendWebhookWithLogging(url, secret, payload) {
8460
8665
  });
8461
8666
  const context = `url=${url}, secret=${maskSecret(secret)}`;
8462
8667
  if (result.ok) log.info(`Post-release hook completed successfully (${context})`);
8463
- else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"} (${context})`);
8668
+ else log.error(`Post-release hook failed: ${result.error ?? "unknown error"} (${context})`);
8669
+ return result;
8464
8670
  }
8465
8671
  //#endregion
8466
8672
  //#region src/commands/webhook-send.ts
8673
+ /**
8674
+ * Choose the payload source for `webhook:send`. When `--tag` or
8675
+ * `--repository` is set, build from flags via `buildSimplePayload` — this
8676
+ * makes the explicit tag win over `GITHUB_REF`, which is often `refs/heads/main`
8677
+ * (not a tag) when the post-release hook fires.
8678
+ */
8679
+ function resolveWebhookPayload(args, env = process.env) {
8680
+ if (args.tag || args.repository) return buildSimplePayload({
8681
+ tag: args.tag ?? "",
8682
+ repository: args.repository ?? env["GITHUB_REPOSITORY"] ?? "",
8683
+ serverUrl: args["server-url"] ?? env["GITHUB_SERVER_URL"] ?? "https://github.com",
8684
+ actor: args.actor ?? env["GITHUB_ACTOR"] ?? "github-actions[bot]",
8685
+ prerelease: args.prerelease ?? false
8686
+ });
8687
+ return buildPayloadFromEnv();
8688
+ }
8467
8689
  const webhookSendCommand = defineCommand({
8468
8690
  meta: {
8469
8691
  name: "webhook:send",
@@ -8501,14 +8723,8 @@ const webhookSendCommand = defineCommand({
8501
8723
  return;
8502
8724
  }
8503
8725
  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);
8726
+ const payload = resolveWebhookPayload(args);
8727
+ if (!(await sendWebhookWithLogging(args.url, secret || void 0, payload)).ok) process.exitCode = 1;
8512
8728
  }
8513
8729
  });
8514
8730
  //#endregion
@@ -8921,7 +9137,7 @@ const out = (msg) => console.log(msg);
8921
9137
  const main = defineCommand({
8922
9138
  meta: {
8923
9139
  name: "bst",
8924
- version: "0.61.0",
9140
+ version: "0.62.1",
8925
9141
  description: "Bootstrap and maintain standardized TypeScript project tooling"
8926
9142
  },
8927
9143
  subCommands: {
@@ -8994,7 +9210,7 @@ const main = defineCommand({
8994
9210
  })
8995
9211
  }
8996
9212
  });
8997
- console.log(`@bensandee/tooling v0.61.0`);
9213
+ console.log(`@bensandee/tooling v0.62.1`);
8998
9214
  async function run() {
8999
9215
  await runMain(main);
9000
9216
  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.1",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,8 +54,8 @@
54
54
  "prettier": "3.8.3",
55
55
  "release-it": "20.0.1",
56
56
  "tsdown": "0.22.0",
57
- "@bensandee/claude-hooks": "0.8.1",
58
- "@bensandee/config": "0.11.3"
57
+ "@bensandee/config": "0.11.3",
58
+ "@bensandee/claude-hooks": "0.8.1"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsdown",