@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.
- package/README.md +5 -1
- package/dist/bin.mjs +335 -119
- 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
|
-
*
|
|
847
|
+
* Build per-invocation git auth for release pushes.
|
|
848
848
|
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
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
|
|
854
|
-
const host =
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
3190
|
-
|
|
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
|
-
|
|
3215
|
-
|
|
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
|
-
|
|
3234
|
-
|
|
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
|
-
|
|
3253
|
-
|
|
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
|
-
|
|
3264
|
-
|
|
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
|
-
|
|
3283
|
-
|
|
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
|
-
|
|
3318
|
-
|
|
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
|
-
|
|
3333
|
-
|
|
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
|
-
|
|
3367
|
-
|
|
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
|
-
|
|
3391
|
-
|
|
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
|
-
|
|
3417
|
-
|
|
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
|
-
|
|
3436
|
-
|
|
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
|
-
|
|
3460
|
-
|
|
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
|
-
|
|
3543
|
-
|
|
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 = [
|
|
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("
|
|
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
|
-
|
|
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")
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6244
|
-
|
|
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
|
|
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
|
-
]
|
|
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
|
|
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
|
-
]
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
8505
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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/
|
|
58
|
-
"@bensandee/
|
|
57
|
+
"@bensandee/config": "0.11.3",
|
|
58
|
+
"@bensandee/claude-hooks": "0.8.1"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "tsdown",
|