From df1b557d7ec0e5a89d66e2b0d7bc15a38642662a Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 28 Jul 2026 20:22:32 -0400 Subject: [PATCH] feat(repo): add the build and release lane entry scripts The repo docs claimed ownership of source patches, builtins, SEA packaging, platform artifacts, and release assembly, and the README pointed installers at gh release download, but the repo had zero releases and zero repo-owned build/release scripts - an agent asked to build or release node-smol had nothing to run. Flagged by the 2026-07-28 fleet code-as-law audit. What this adds: - scripts/repo/build.mts: real entry for what is buildable today. The default base target bakes the compile-environment image(s) declared in .config/repo/socket-wheelhouse.json docker.prebakes via docker buildx, mirroring the prebake-publish workflow; local runs load the host platform, --push covers the declared matrix. --dry-run prints the exact commands with preconditions. --target binary fails loud, naming exactly what was never ported from socket-btm (the pre-drop tree is socket-btm@55f8f23a packages/node-smol-builder) instead of pretending a lane exists. - scripts/repo/release.mts: release assembly from a built output dir. Dry-run is the default; --publish computes SHA-256 checksums, writes checksums.txt, verifies no release exists for the tag, and cuts a DRAFT via gh release create --draft. Undrafting stays a separate deliberate act. Fails loud on an empty asset dir. - scripts/repo/paths.mts: repo-tier path anchor inheriting the fleet paths module. - package.json: build:repo and release script entries (build is owned by the fleet hook-bundle build, so the repo lane gets a segmented name). - README: the Install section now states there are no releases yet and points at scripts/repo/build.mts for source builds. - CLAUDE.md: the .gitmodules pinning claim now describes the actual pin shape (branch = main + shallow = true, no sha256) with a TODO for the intended ref+sha256 contract; the sha256 pin itself is follow-up work, not part of this change. --- CLAUDE.md | 2 +- README.md | 17 ++- package.json | 2 + scripts/repo/build.mts | 313 +++++++++++++++++++++++++++++++++++++++ scripts/repo/paths.mts | 28 ++++ scripts/repo/release.mts | 254 +++++++++++++++++++++++++++++++ 6 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 scripts/repo/build.mts create mode 100644 scripts/repo/paths.mts create mode 100644 scripts/repo/release.mts diff --git a/CLAUDE.md b/CLAUDE.md index 52931fc0..fe13adb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,5 +109,5 @@ ## 🏗️ node-smol-Specific - node-smol owns Socket's customized Node.js distribution: source patches, builtins, SEA packaging, platform artifacts, and release assembly; binaries ship as GitHub release assets, never npm. -- Reusable native capabilities stay Rust-canonical in their owner repos — `upstream/` holds shallow, pinned source references (`.gitmodules` `ref`+`sha256:` pins), and adapters consume them through a `.node` addon or a narrow `node:smol-*` builtin contract. +- Reusable native capabilities stay Rust-canonical in their owner repos — `upstream/` holds shallow single-branch source references, and adapters consume them through a `.node` addon or a narrow `node:smol-*` builtin contract. ACTUAL pin shape today: `.gitmodules` carries `branch = main` + `shallow = true` only (stuie publishes no release tags, so there is no `ref` and no `sha256:` yet). TODO: adopt the intended `ref`+`sha256:` pin contract (set via `gen/gitmodules-hash --set`) once stuie ships a taggable release — until then, do not describe the pins as content-addressed. - The Node runtime pin cascades from the wheelhouse (`.node-version` + the fleet setup-action defaults + the node-base builder image) — never hand-edit it here; bump via `cascade-fleet.mts --node`. diff --git a/README.md b/README.md index a5551d9a..6b64a17f 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,26 @@ references through a `.node` addon or a narrow `node:smol-*` builtin contract. ## Install -Binaries ship as GitHub release assets — download the artifact for your -platform from the releases page. +**There are no releases yet** — this repo has published zero GitHub releases, +so there is nothing to download today. Once the first release is cut, binaries +will ship as GitHub release assets: ```sh +# Not functional yet: SocketDev/node-smol has no releases (checked 2026-07-28). gh release download --repo SocketDev/node-smol ``` +Until then, the build lane is `scripts/repo/build.mts`: + +```sh +node scripts/repo/build.mts --dry-run # print the exact build plan +node scripts/repo/build.mts # bake the compile-environment image +node scripts/repo/build.mts --target binary # fails loud: the binary lane is not ported yet +``` + +Release assembly lives in `scripts/repo/release.mts` (dry-run by default; +`--publish` cuts a draft release via `gh release create --draft`). + ## Usage Run the downloaded binary in place of `node`: diff --git a/package.json b/package.json index 0172678e..ca1d2f68 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "scripts": { "build": "node scripts/fleet/build-hook-bundle.mts", + "build:repo": "node scripts/repo/build.mts", "check": "node scripts/fleet/check.mts", "check:paths": "node scripts/fleet/check/paths-are-canonical.mts", "ci:local": "node scripts/fleet/agent-ci-skip-locks.mts run --all --quiet --pause-on-failure --github-token", @@ -30,6 +31,7 @@ "lockstep:emit-mirror-globs": "node scripts/fleet/lockstep-emit-mirror-globs.mts", "lockstep:emit-schema": "node scripts/fleet/lockstep-emit-schema.mts", "prepare": "node scripts/fleet/install-git-hooks.mts && node scripts/fleet/prepare.mts", + "release": "node scripts/repo/release.mts", "security": "node scripts/fleet/security.mts", "sync-package-manager-pins": "node scripts/fleet/sync-package-manager-pins.mts", "test": "node scripts/fleet/test.mts", diff --git a/scripts/repo/build.mts b/scripts/repo/build.mts new file mode 100644 index 00000000..44aab447 --- /dev/null +++ b/scripts/repo/build.mts @@ -0,0 +1,313 @@ +/* + * @file Repo build lane entry. `node scripts/repo/build.mts` orchestrates what + * this repo can actually build TODAY, and refuses loudly to pretend about + * the rest. + * Targets: + * + * - `--target base` (default) — REAL. Bakes the compile-environment image(s) + * declared in `.config/repo/socket-wheelhouse.json` `docker.prebakes` via + * `docker buildx build`, exactly like the prebake-publish workflow + * (.github/workflows/prebake-publish.yml) does on dispatch. Local default + * builds the host platform only and `--load`s it into the docker daemon; + * `--push` builds every declared platform and pushes to GHCR (needs a + * `docker login ghcr.io` with packages:write — the workflow dispatch is the + * canonical publisher, this flag exists for break-glass use). + * - `--target binary` — NOT PORTED YET. The node-smol binary build (Node source + * checkout + Socket patch series + builtins + SEA packaging + + * strip/compress) still lives in socket-btm's history; see the fail-loud + * message below for exactly what is missing and where the sources are. + * `--dry-run` prints the exact commands with their preconditions instead of + * running them. USAGE: node scripts/repo/build.mts [--target base|binary] + * [--dry-run] [--push] [--platforms linux/arm64,...] + */ + +import { existsSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { parseArgs } from 'node:util' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +import { isMainModule } from '../fleet/_shared/is-main-module.mts' +import { runCapture, runInherit } from '../fleet/publish-infra/shared.mts' +import { loadSocketWheelhouseConfig, REPO_ROOT } from './paths.mts' + +const logger = getDefaultLogger() + +/** + * One `docker.prebakes.prebakes[]` entry, narrowed from the untyped config + * (the fleet loader validates "JSON object" only — schema validation is + * wheelhouse-side). + */ +export interface PrebakeEntry { + dockerfile: string + name: string + platforms: string[] +} + +export interface PrebakePlan { + entries: PrebakeEntry[] + registry: string +} + +/** + * Type guard for a plain JSON object — keeps the config walk free of type + * assertions. + */ +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Extract the prebake plan from a parsed socket-wheelhouse.json root. Returns + * an error string (for fail-loud reporting) when the shape is not usable. + */ +export function extractPrebakePlan( + configValue: Record, +): PrebakePlan | string { + const docker = configValue['docker'] + if (!isRecord(docker)) { + return 'config has no docker section' + } + const prebakes = docker['prebakes'] + if (!isRecord(prebakes)) { + return 'config has no docker.prebakes section' + } + const registry = prebakes['registry'] + if (typeof registry !== 'string' || !registry) { + return 'docker.prebakes.registry is missing' + } + const rawEntries = prebakes['prebakes'] + if (!Array.isArray(rawEntries) || rawEntries.length === 0) { + return 'docker.prebakes.prebakes declares no images' + } + const entries: PrebakeEntry[] = [] + for (let i = 0, { length } = rawEntries; i < length; i += 1) { + const entry: unknown = rawEntries[i] + if (!isRecord(entry)) { + return `docker.prebakes.prebakes[${i}] is not an object` + } + const name = entry['name'] + const dockerfile = entry['dockerfile'] + if (typeof name !== 'string' || typeof dockerfile !== 'string') { + return `docker.prebakes.prebakes[${i}] is missing name/dockerfile` + } + const rawPlatforms = entry['platforms'] + const platforms = Array.isArray(rawPlatforms) + ? rawPlatforms.filter((p): p is string => typeof p === 'string') + : ['linux/amd64', 'linux/arm64'] + entries.push({ dockerfile, name, platforms }) + } + return { entries, registry } +} + +/** + * The docker platform string for the machine we are on — what a no-`--push` + * build can actually `--load` into the local daemon (buildx refuses to + * `--load` a multi-platform result). + */ +export function hostDockerPlatform(): string { + return os.arch() === 'arm64' ? 'linux/arm64' : 'linux/amd64' +} + +export interface BuildxArgsOptions { + push?: boolean | undefined +} + +/** + * Build the exact `docker buildx build` argv for one prebake entry — shared + * between dry-run printing and real execution so the printed plan can never + * drift from what runs. + */ +export function buildxArgs( + entry: PrebakeEntry, + registry: string, + platforms: string, + options?: BuildxArgsOptions | undefined, +): string[] { + const opts = { __proto__: null, ...options } as BuildxArgsOptions + const push = opts.push ?? false + return [ + 'buildx', + 'build', + '-f', + entry.dockerfile, + '-t', + `${registry}/${entry.name}:latest`, + '--platform', + platforms, + push ? '--push' : '--load', + '.', + ] +} + +const BINARY_LANE_MISSING = `build.mts --target binary: no binary lane exists in this repo yet — refusing to fake one. + What: the node-smol binary build (pinned Node source checkout, Socket patch + series, builtins/additions tree, SEA packaging, strip + compress, + platform artifact naming) has not been extracted into this repo. + Where: SocketDev/socket-btm .config/repo/node-smol-rust-extraction.json + marks packages/node-smol-builder and packages/npm as destined for + node-smol with removalStatus "removed" — the sources were dropped + from socket-btm (refactor(btm)!, commit 40b7e82a) before landing + here. The pre-drop tree is socket-btm@55f8f23a + packages/node-smol-builder (entry: scripts/common/shared/build.mts). + Saw: this repo carries no Node source pin, no Node patch series (patches/ + holds only a pnpm tool patch), no additions/ builtins tree, and no + SEA/strip/compress scripts. Only the compile-environment image + (--target base) is buildable today. + Fix: port the builder from socket-btm@55f8f23a packages/node-smol-builder, + then wire this target to its entry script. Track the port before + cutting any GitHub release (scripts/repo/release.mts).` + +interface BaseTargetOptions { + dryRun?: boolean | undefined + platformsOverride?: string | undefined + push?: boolean | undefined +} + +/** + * Bake the declared prebake images. Returns the process exit code. + */ +async function runBaseTarget( + options?: BaseTargetOptions | undefined, +): Promise { + const opts = { __proto__: null, ...options } as BaseTargetOptions + const dryRun = opts.dryRun ?? false + const { platformsOverride } = opts + const push = opts.push ?? false + const loaded = loadSocketWheelhouseConfig(REPO_ROOT) + if (!loaded) { + logger.error( + 'build.mts: cannot load .config/repo/socket-wheelhouse.json.\n' + + ' What: the prebake declarations (registry, dockerfile, platforms) live there.\n' + + ' Where: .config/repo/socket-wheelhouse.json (single source of truth,\n' + + ' shared with .github/workflows/prebake-publish.yml).\n' + + ' Saw: file absent or unparseable.\n' + + ' Fix: restore the config; do not hand-inline image names here.', + ) + return 1 + } + const plan = extractPrebakePlan(loaded.value) + if (typeof plan === 'string') { + logger.error( + `build.mts: unusable prebake config — ${plan}.\n` + + ` Where: ${loaded.location.path}\n` + + ' Fix: repair the docker.prebakes section to match the wheelhouse schema.', + ) + return 1 + } + + for (let i = 0, { length } = plan.entries; i < length; i += 1) { + const entry = plan.entries[i]! + const dockerfileAbs = path.join(REPO_ROOT, entry.dockerfile) + if (!existsSync(dockerfileAbs)) { + logger.error( + `build.mts: declared Dockerfile is missing — ${entry.dockerfile}.\n` + + ` Where: ${loaded.location.path} entry "${entry.name}"\n` + + ' Fix: restore the Dockerfile or fix the declaration.', + ) + return 1 + } + const platforms = + platformsOverride ?? + (push ? entry.platforms.join(',') : hostDockerPlatform()) + if (!push && platforms.includes(',')) { + logger.error( + 'build.mts: buildx cannot --load a multi-platform build.\n' + + ` Saw: --platforms ${platforms} without --push.\n` + + ' Fix: pass a single platform, or add --push (needs docker login\n' + + ' ghcr.io with packages:write).', + ) + return 1 + } + const args = buildxArgs(entry, plan.registry, platforms, { push }) + if (dryRun) { + logger.log(`plan [${entry.name}]: docker ${args.join(' ')}`) + logger.log(` cwd: ${REPO_ROOT}`) + logger.log( + ' preconditions: docker CLI + buildx plugin installed;' + + ' the FROM image (FLEET_BASE arg default,' + + ' ghcr.io/socketdev/socket-wheelhouse/c-base:latest) must be pullable', + ) + if (push) { + logger.log( + ' --push additionally needs docker login ghcr.io with' + + ' packages:write (canonical publisher: dispatch' + + ' .github/workflows/prebake-publish.yml instead)', + ) + } + continue + } + const dockerProbe = await runCapture('docker', ['--version'], REPO_ROOT) + if (dockerProbe.code !== 0) { + logger.error( + 'build.mts: docker CLI not found on PATH.\n' + + ' Fix: install Docker (or run the bake in CI by dispatching\n' + + ' .github/workflows/prebake-publish.yml).', + ) + return 1 + } + const buildxProbe = await runCapture( + 'docker', + ['buildx', 'version'], + REPO_ROOT, + ) + if (buildxProbe.code !== 0) { + logger.error( + 'build.mts: docker buildx plugin not available.\n' + + ' Fix: install the buildx plugin (ships with Docker Desktop;\n' + + ' docker-buildx-plugin on Linux).', + ) + return 1 + } + logger.log(`baking ${entry.name} (${platforms})…`) + const code = await runInherit('docker', args, REPO_ROOT) + if (code !== 0) { + logger.error(`build.mts: docker buildx exited ${code} for ${entry.name}`) + return code + } + logger.success(`built ${plan.registry}/${entry.name}:latest`) + } + return 0 +} + +export async function main(): Promise { + const { values } = parseArgs({ + allowPositionals: false, + args: process.argv.slice(2), + options: { + 'dry-run': { default: false, type: 'boolean' }, + platforms: { type: 'string' }, + push: { default: false, type: 'boolean' }, + target: { default: 'base', type: 'string' }, + }, + }) + const target = values.target + if (target === 'binary') { + logger.error(BINARY_LANE_MISSING) + process.exitCode = 1 + return + } + if (target !== 'base') { + logger.error( + `build.mts: unknown --target ${JSON.stringify(target)} — valid targets: base, binary.`, + ) + process.exitCode = 1 + return + } + process.exitCode = await runBaseTarget({ + dryRun: values['dry-run'], + platformsOverride: values.platforms, + push: values.push, + }) +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not execute the script. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(e) + process.exitCode = 1 + }) +} diff --git a/scripts/repo/paths.mts b/scripts/repo/paths.mts new file mode 100644 index 00000000..efa4de09 --- /dev/null +++ b/scripts/repo/paths.mts @@ -0,0 +1,28 @@ +/* + * @file Canonical paths for the repo-tier build/release scripts. Inherits the + * fleet paths (1 path, 1 reference) and adds the node-smol-specific build + * output locations. The Dockerfile path is deliberately NOT constructed + * here: `.config/repo/socket-wheelhouse.json` `docker.prebakes[].dockerfile` + * is its single source of truth (the prebake-publish workflow reads the same + * field), so `build.mts` resolves it from the loaded config instead of a + * second hand-maintained copy. + */ + +import path from 'node:path' + +import { REPO_ROOT } from '../fleet/paths.mts' + +export * from '../fleet/paths.mts' + +/** + * Root of untracked build outputs (fleet rule: build outputs live under + * `/build/`, never git-tracked). + */ +export const BUILD_DIR = path.join(REPO_ROOT, 'build') + +/** + * Default staging directory `release.mts` reads assets from. Empty until the + * binary build lane exists — `build.mts --target binary` names what is + * missing. + */ +export const RELEASE_ASSETS_DIR = path.join(BUILD_DIR, 'release') diff --git a/scripts/repo/release.mts b/scripts/repo/release.mts new file mode 100644 index 00000000..2fa69eb5 --- /dev/null +++ b/scripts/repo/release.mts @@ -0,0 +1,254 @@ +/* + * @file Repo release-assembly lane. Assembles GitHub release assets from a + * built output directory, writes a SHA-256 `checksums.txt` manifest, and + * cuts a DRAFT release via `gh release create --draft`. Honesty rules: + * + * - DRY-RUN IS THE DEFAULT. Without `--publish` it prints the exact `gh release + * create` command, the computed per-asset digests, and every precondition — + * nothing is uploaded. + * - The repo has ZERO releases today and no binary build lane + * (`scripts/repo/build.mts --target binary` names what is missing), so + * until that lane is ported the asset directory must be populated by hand + * or by CI — this script fails loud when it is empty rather than cutting an + * assetless release. + * - The release is created as a DRAFT. Undrafting via `gh release edit + * --draft=false` is a separate deliberate act — fleet releases are + * immutable once public, and the github-release workflow's order rule + * applies: publish gates run before the final release marker exists. USAGE: + * node scripts/repo/release.mts --tag v0.1.0 [--dir build/release] + * [--notes-file ] [--publish] + */ + +import crypto from 'node:crypto' +import { + createReadStream, + existsSync, + readdirSync, + writeFileSync, +} from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pipeline } from 'node:stream/promises' +import { parseArgs } from 'node:util' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +import { isMainModule } from '../fleet/_shared/is-main-module.mts' +import { runCapture, runInherit } from '../fleet/publish-infra/shared.mts' +import { RELEASE_ASSETS_DIR, REPO_ROOT } from './paths.mts' + +const logger = getDefaultLogger() + +const CHECKSUMS_BASENAME = 'checksums.txt' + +const TAG_PATTERN = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ + +/** + * Recursively list the release-asset files under `dir`, excluding a previously + * generated checksums manifest — that file is regenerated every run. Returns + * absolute paths, sorted for deterministic manifests. + */ +export function collectAssets(dir: string): string[] { + const dirents = readdirSync(dir, { recursive: true, withFileTypes: true }) + const files: string[] = [] + for (let i = 0, { length } = dirents; i < length; i += 1) { + const dirent = dirents[i]! + if (!dirent.isFile() || dirent.name === CHECKSUMS_BASENAME) { + continue + } + files.push(path.join(dirent.parentPath, dirent.name)) + } + return files.toSorted() +} + +/** + * SHA-256 of one file, streamed (release binaries are tens of MB). + */ +export async function sha256File(file: string): Promise { + const hash = crypto.createHash('sha256') + await pipeline(createReadStream(file), hash) + return hash.digest('hex') +} + +/** + * Render the `checksums.txt` body — `shasum -a 256 -c` compatible + * (``), one line per asset, sorted by name. + */ +export function renderChecksums( + digests: Array<{ digest: string; file: string }>, +): string { + return `${digests + .map(entry => `${entry.digest} ${path.basename(entry.file)}`) + .join('\n')}\n` +} + +const EMPTY_DIR_MESSAGE = (dir: string) => + `release.mts: no release assets found — refusing to cut an empty release. + What: the assembly step needs built platform artifacts to upload. + Where: ${dir} + Saw: directory missing or contains no files. + Fix: this repo has no binary build lane yet — run + node scripts/repo/build.mts --target binary for exactly what is + missing and where the socket-btm sources are. Once artifacts exist, + place them under the directory above (or pass --dir) and re-run.` + +export async function main(): Promise { + const { values } = parseArgs({ + allowPositionals: false, + args: process.argv.slice(2), + options: { + dir: { type: 'string' }, + 'notes-file': { type: 'string' }, + publish: { default: false, type: 'boolean' }, + tag: { type: 'string' }, + }, + }) + const publish = values.publish + const dir = values.dir + ? path.resolve(REPO_ROOT, values.dir) + : RELEASE_ASSETS_DIR + + const tag = values.tag + if (!tag || !TAG_PATTERN.test(tag)) { + logger.error( + `release.mts: --tag is required and must look like v1.2.3 (saw ${JSON.stringify(tag ?? '')}).\n` + + ' Fix: pass --tag vX.Y.Z (the USER names the version — never invent one).', + ) + process.exitCode = 1 + return + } + + if (!existsSync(dir)) { + logger.error(EMPTY_DIR_MESSAGE(dir)) + process.exitCode = 1 + return + } + const assets = collectAssets(dir) + if (assets.length === 0) { + logger.error(EMPTY_DIR_MESSAGE(dir)) + process.exitCode = 1 + return + } + + const digests: Array<{ digest: string; file: string }> = [] + for (let i = 0, { length } = assets; i < length; i += 1) { + const file = assets[i]! + // oxlint-disable-next-line no-await-in-loop -- sequential hashing keeps memory flat and the manifest ordering obvious. + digests.push({ digest: await sha256File(file), file }) + } + const checksumsPath = path.join(dir, CHECKSUMS_BASENAME) + const checksumsBody = renderChecksums(digests) + + const notesFile = values['notes-file'] + if (notesFile && !existsSync(path.resolve(REPO_ROOT, notesFile))) { + logger.error( + `release.mts: --notes-file does not exist: ${notesFile}\n` + + ' Fix: pass a readable notes file, or omit for generated notes.', + ) + process.exitCode = 1 + return + } + const notesArgs = notesFile + ? ['--notes-file', path.resolve(REPO_ROOT, notesFile)] + : [ + '--notes', + `node-smol ${tag} — platform binaries plus a SHA-256 manifest (${CHECKSUMS_BASENAME}). Verify downloads with: shasum -a 256 -c ${CHECKSUMS_BASENAME}`, + ] + const ghArgs = [ + 'release', + 'create', + tag, + '--draft', + '--title', + tag, + ...notesArgs, + ...assets, + checksumsPath, + ] + + logger.log(`assets (${assets.length}) from ${dir}:`) + for (let i = 0, { length } = digests; i < length; i += 1) { + const entry = digests[i]! + logger.log(` ${entry.digest} ${path.relative(dir, entry.file)}`) + } + + if (!publish) { + logger.log('') + logger.log( + `plan (dry-run — pass --publish to execute): gh ${ghArgs.join(' ')}`, + ) + logger.log(` cwd: ${REPO_ROOT}`) + logger.log( + ` would write: ${checksumsPath} (${digests.length} line${digests.length === 1 ? '' : 's'})`, + ) + logger.log( + ' preconditions: gh CLI authenticated with repo write access;' + + ` no existing release for ${tag} (verify: gh release view ${tag});` + + ' the version was named by the user', + ) + logger.log( + ` follow-up: gh release edit ${tag} --draft=false (deliberate, separate act — publishing makes the release immutable)`, + ) + return + } + + // Real cut. Verify state before acting (fleet rule): the gh CLI must be + // authenticated, and the release must not already exist. + const ghProbe = await runCapture('gh', ['--version'], REPO_ROOT) + if (ghProbe.code !== 0) { + logger.error('release.mts: gh CLI not found on PATH.') + logger.error( + ' Fix: install the GitHub CLI from https://cli.github.com and run gh auth login.', + ) + process.exitCode = 1 + return + } + const authProbe = await runCapture('gh', ['auth', 'status'], REPO_ROOT) + if (authProbe.code !== 0) { + logger.error('release.mts: gh CLI is not authenticated.') + logger.error( + ' Fix: gh auth login — cutting releases needs repo write access.', + ) + process.exitCode = 1 + return + } + const existing = await runCapture( + 'gh', + ['release', 'view', tag, '--json', 'tagName,isDraft'], + REPO_ROOT, + ) + if (existing.code === 0) { + logger.error( + `release.mts: a release for ${tag} already exists — refusing to double-cut.\n` + + ` Saw: gh release view ${tag} resolved: ${existing.stdout.trim()}\n` + + ' Fix: pick a new version (the USER names it), or edit the existing\n' + + ' draft via gh release upload / gh release edit.', + ) + process.exitCode = 1 + return + } + + writeFileSync(checksumsPath, checksumsBody) + logger.log(`wrote ${checksumsPath}`) + const code = await runInherit('gh', ghArgs, REPO_ROOT) + if (code !== 0) { + logger.error(`release.mts: gh release create exited ${code}`) + process.exitCode = code + return + } + logger.success( + `draft release ${tag} created with ${assets.length + 1} assets`, + ) + logger.log( + `Next: gh release edit ${tag} --draft=false — a separate deliberate act; public releases are immutable.`, + ) +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not execute the script. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(e) + process.exitCode = 1 + }) +}