mirror of
https://github.com/oblien/openship.git
synced 2026-10-20 06:09:16 +08:00
995 lines
43 KiB
TypeScript
995 lines
43 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* Cut a new Openship release.
|
||
*
|
||
* Usage:
|
||
* bun scripts/release.ts patch # 0.1.0 → 0.1.1
|
||
* bun scripts/release.ts minor # 0.1.0 → 0.2.0
|
||
* bun scripts/release.ts major # 0.1.0 → 1.0.0
|
||
* bun scripts/release.ts rc # 0.1.0 → 0.1.1-rc.1
|
||
* # 0.1.1-rc.1 → 0.1.1-rc.2
|
||
* # 0.1.1-rc.2 → 0.1.1 (promote: rc → stable)
|
||
* bun scripts/release.ts <explicit> # set to literal "0.2.0-beta.3"
|
||
* bun scripts/release.ts --dry-run patch
|
||
* bun scripts/release.ts continue # resume an unpublished release with
|
||
* # current code and verified reuse
|
||
*
|
||
* bun run release # NO args in a terminal → interactive
|
||
* # wizard over every option below. It
|
||
* # prints the equivalent command and
|
||
* # re-runs itself with it, so there is
|
||
* # one code path. Non-TTY (CI/pipes)
|
||
* # keeps the no-arg behaviour: release
|
||
* # the current version as-is.
|
||
*
|
||
* bun scripts/release.ts docker [tag] # publish Docker images ONLY (GHCR),
|
||
* # via the docker-images workflow. A
|
||
* # test/prerelease image build: NO version
|
||
* # bump, NO git tag, NO GitHub release, NO
|
||
* # npm/desktop, and it never moves :latest.
|
||
* # Omit [tag] → images tagged with the short
|
||
* # SHA. `--ref=<branch>` builds that branch.
|
||
*
|
||
* What it does:
|
||
* 1. Refuse if working tree is dirty
|
||
* 2. Refuse if not on main (override with --force-branch)
|
||
* 3. Refuse if HEAD is behind origin/main (you'd push an old commit)
|
||
* 4. Compute next version from apps/api/package.json
|
||
* 5. Sync both root package.json and apps/api/package.json
|
||
* 6. Commit "Bump to vX.Y.Z"
|
||
* 7. Push the bump commit to main
|
||
* 8. Tag vX.Y.Z and push the tag
|
||
* 9. Print the GitHub Actions URL so you can watch
|
||
*
|
||
* The tag-push triggers .github/workflows/release.yml which builds both
|
||
* dist tarballs + SHA-256 sidecars and publishes a GitHub Release.
|
||
* Tags containing `-` (rc.N, beta.N) become prereleases automatically.
|
||
* The release DESCRIPTION is this version's CHANGELOG.md section (see
|
||
* scripts/changelog-notes.ts) — write it before you cut the tag; a version with
|
||
* no section still releases, it just links to the changelog instead.
|
||
*/
|
||
|
||
import { spawnSync } from "node:child_process";
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import { join, dirname } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { buildWizardArgs, type WizardAnswers } from "./release-args";
|
||
import { extractChangelogSection } from "./changelog-notes";
|
||
import { RESUME_TRAILER, resumeRuns, unpublishedReleaseRuns } from "./release-resume";
|
||
import { buildReleaseAdvisory } from "./release-advisory";
|
||
|
||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||
const ROOT_PKG = join(ROOT, "package.json");
|
||
const API_PKG = join(ROOT, "apps/api/package.json");
|
||
// The upgrade-prompt gate. A release ships SILENTLY unless `publish` writes an
|
||
// advisory here for the new version (see release-advisories.json's $comment).
|
||
const ADVISORIES = join(ROOT, "release-advisories.json");
|
||
// Every package.json whose version should track the release. API is the
|
||
// operative source the next version is computed from; the rest are synced to
|
||
// match so the desktop app (forge reads apps/desktop/package.json), web,
|
||
// email, and the npm-published CLI all report the same version as the tag.
|
||
const SYNCED_PKGS = [
|
||
ROOT_PKG,
|
||
API_PKG,
|
||
join(ROOT, "apps/desktop/package.json"),
|
||
join(ROOT, "apps/web/package.json"),
|
||
join(ROOT, "apps/email/package.json"),
|
||
join(ROOT, "apps/cli/package.json"),
|
||
join(ROOT, "packages/openship/package.json"),
|
||
join(ROOT, "packages/platform/package.json"),
|
||
];
|
||
|
||
/* ─── CLI parsing ──────────────────────────────────────────────────── */
|
||
|
||
const args = process.argv.slice(2);
|
||
const dryRun = args.includes("--dry-run");
|
||
const forceBranch = args.includes("--force-branch");
|
||
// `--force` re-releases a tag that ALREADY exists: instead of refusing, delete
|
||
// it (local + origin) and re-push, firing a fresh release run. Off by default
|
||
// and explicit on purpose — re-pointing a tag people may already have pulled
|
||
// silently diverges their bytes. The safe use is salvaging a tag whose release
|
||
// failed and published nothing (see the refusal block below).
|
||
const force = args.includes("--force");
|
||
if (args.includes("--help") || args.includes("-h")) usageAndExit(0);
|
||
|
||
// `publish` is the announcement switch. Without it a release ships SILENTLY —
|
||
// no in-app banner (users still see "vX available" in Settings → Updates and the
|
||
// home Updates block). WITH it, we write a release advisory for this version
|
||
// into release-advisories.json (committed + pinned to the tag), which is the
|
||
// ONLY thing that raises the update banner for users below this version.
|
||
// bun run release patch → silent
|
||
// bun run release patch publish → announced (recommended)
|
||
// bun run release patch publish --critical → critical (always shown)
|
||
// bun run release patch publish --message="…" → custom banner body
|
||
const publish = args.includes("publish");
|
||
const critical = args.includes("--critical");
|
||
const message = args.find((a) => a.startsWith("--message="))?.slice("--message=".length);
|
||
// Who the announcement is FOR. Omitted → every install kind (the legacy
|
||
// default). Values must match @repo/core's AdvisoryMode, since clients filter on
|
||
// them: "desktop" (the Electron app), "selfhosted" (VPS: compose or bare),
|
||
// "cloud" (the managed SaaS). A desktop-installer fix is noise on a VPS.
|
||
const ADVISORY_MODES = ["desktop", "selfhosted", "cloud"] as const;
|
||
type ReleaseMode = (typeof ADVISORY_MODES)[number];
|
||
const modesArg = args.find((a) => a.startsWith("--modes="))?.slice("--modes=".length);
|
||
const modes: ReleaseMode[] | undefined = modesArg
|
||
? modesArg
|
||
.split(",")
|
||
.map((m) => m.trim().toLowerCase())
|
||
.filter(Boolean) as ReleaseMode[]
|
||
: undefined;
|
||
if (modes) {
|
||
const bad = modes.filter((m) => !ADVISORY_MODES.includes(m));
|
||
if (bad.length > 0) {
|
||
console.error(
|
||
`Refusing --modes=${modesArg}: unknown mode(s) ${bad.join(", ")}. ` +
|
||
`Valid: ${ADVISORY_MODES.join(", ")} (omit --modes for all).`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
if (!publish) {
|
||
console.error(
|
||
`Refusing --modes without \`publish\`: modes scope an ANNOUNCEMENT, and a ` +
|
||
`silent release has none. Add \`publish\`, or drop --modes.`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
// The bump is the first positional that ISN'T the `publish` switch.
|
||
const cmd = args.find((a) => !a.startsWith("--") && a !== "publish");
|
||
|
||
/* ─── Interactive mode ─────────────────────────────────────────────────
|
||
*
|
||
* `bun run release` with NO arguments, in a terminal, walks the same option
|
||
* surface as the flags. It builds an argv and RE-EXECUTES this script with it —
|
||
* so the release logic below has exactly one entry shape and can't drift from
|
||
* the flag path. It also prints the equivalent command, so the wizard teaches
|
||
* the flags instead of hiding them.
|
||
*
|
||
* Non-TTY (CI, pipes) keeps the documented no-arg behaviour: release the current
|
||
* version as-is. Any argument at all also skips the wizard.
|
||
*/
|
||
if (args.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
|
||
const chosen = await runWizard();
|
||
if (!chosen) {
|
||
log("Cancelled — nothing was released.");
|
||
process.exit(0);
|
||
}
|
||
log("");
|
||
log(`▶ bun run release ${chosen.join(" ")}`);
|
||
log("");
|
||
const child = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...chosen], {
|
||
stdio: "inherit",
|
||
});
|
||
process.exit(child.status ?? 1);
|
||
}
|
||
|
||
// `docker` is a SEPARATE path: publish container images ONLY (via the
|
||
// docker-images workflow) — no version bump, no git tag, no GitHub release.
|
||
// Branch out here, before any version/semver handling treats "docker" as a bump.
|
||
if (cmd === "docker") {
|
||
releaseDocker();
|
||
process.exit(0);
|
||
}
|
||
if (cmd === "continue") {
|
||
try {
|
||
await continueRelease();
|
||
} catch (error) {
|
||
console.error(error instanceof Error ? error.message : String(error));
|
||
process.exit(1);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
type BumpKind = "patch" | "minor" | "major" | "rc" | "current" | "literal";
|
||
// No arg (or "current") → release the version already in package.json as-is,
|
||
// no bump. Otherwise bump / set the version.
|
||
const bump: { kind: BumpKind; literal?: string } =
|
||
!cmd || cmd === "current"
|
||
? { kind: "current" }
|
||
: cmd === "patch" || cmd === "minor" || cmd === "major" || cmd === "rc"
|
||
? { kind: cmd }
|
||
: { kind: "literal", literal: cmd };
|
||
|
||
if (bump.kind === "literal" && !/^\d+\.\d+\.\d+(-[a-z0-9]+(\.\d+)?)?$/i.test(bump.literal!)) {
|
||
console.error(`Refusing literal version "${bump.literal}" — not a semver string.`);
|
||
process.exit(1);
|
||
}
|
||
|
||
/* ─── Pre-flight checks ────────────────────────────────────────────── */
|
||
|
||
if (!dryRun) {
|
||
preflight();
|
||
}
|
||
|
||
/* ─── Version compute ──────────────────────────────────────────────── */
|
||
|
||
const currentApi = readVersion(API_PKG);
|
||
const currentRoot = readVersion(ROOT_PKG);
|
||
if (currentApi !== currentRoot) {
|
||
log(
|
||
`⚠️ Version drift detected: root=${currentRoot}, apps/api=${currentApi}. ` +
|
||
`Bumping from apps/api's (operative) value.`,
|
||
);
|
||
}
|
||
|
||
const next = computeNext(currentApi, bump);
|
||
const tag = `v${next}`;
|
||
const announcement = publish
|
||
? buildReleaseAdvisory(next, { critical, message, modes }, JSON.parse(readFileSync(ADVISORIES, "utf8")))
|
||
: null;
|
||
|
||
if (publish && tag.includes("-")) {
|
||
console.error(
|
||
`Refusing: "publish" announces a STABLE release, but ${tag} is a prerelease ` +
|
||
`(rc/beta). Clients only pull advisories from the latest STABLE release, so a ` +
|
||
`prerelease announcement is never seen. Drop "publish" for the rc, or promote to ` +
|
||
`stable and publish that.`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
log(`Current version (apps/api): ${currentApi}`);
|
||
log(`Next version: ${next}`);
|
||
log(`Tag: ${tag}`);
|
||
log(`Prerelease: ${tag.includes("-") ? "yes" : "no"}`);
|
||
log(`Announcement: ${announcement ? `yes (${announcement.severity} advisory)` : "no — silent release"}`);
|
||
if (announcement) {
|
||
log(`Audience: ${announcement.modes?.join(", ") ?? "all installs (desktop, selfhosted, cloud)"}`);
|
||
}
|
||
log(`Release notes: ${changelogNotesStatus(next)}`);
|
||
log(``);
|
||
|
||
if (dryRun) {
|
||
if (bump.kind === "current") {
|
||
log(`[dry-run] would tag the current version (no bump) and push ${tag}`);
|
||
} else {
|
||
log(`[dry-run] would update:`);
|
||
for (const p of SYNCED_PKGS) log(` - ${p}`);
|
||
log(`[dry-run] would commit + push, then tag + push ${tag}`);
|
||
}
|
||
if (publish) {
|
||
log(`[dry-run] would write an announcement advisory to release-advisories.json:`);
|
||
log(JSON.stringify(announcement, null, 2));
|
||
}
|
||
if (force && tagExists(tag)) {
|
||
log(`[dry-run] --force: tag ${tag} exists → would DELETE it (local + origin) and re-push`);
|
||
}
|
||
log(`[dry-run] no files written, no git ops executed.`);
|
||
process.exit(0);
|
||
}
|
||
|
||
if (tagExists(tag)) {
|
||
if (!force) {
|
||
console.error(
|
||
`Refusing: tag ${tag} already exists. ` +
|
||
(bump.kind === "current"
|
||
? `Bump the version (patch/minor/major), or pass --force to re-release ${tag}.`
|
||
: `Pick a different version, or pass --force to re-release ${tag}.`),
|
||
);
|
||
process.exit(1);
|
||
}
|
||
// --force: salvage an existing tag. Delete it locally AND on origin so the
|
||
// `git tag` + push below create a FRESH ref — a same-SHA re-push reports
|
||
// "up-to-date" and fires no workflow, which is exactly why a plain re-run of
|
||
// an existing tag does nothing.
|
||
log(`⚠ --force: tag ${tag} exists — deleting it (local + origin) to re-release`);
|
||
retireExistingTag(tag);
|
||
}
|
||
|
||
/* ─── Apply ─────────────────────────────────────────────────────────── */
|
||
|
||
// Files to stage into the (single) release commit. Version bumps + the
|
||
// announcement advisory both ride the SAME commit so the advisory is pinned to
|
||
// the tag clients pull.
|
||
const staged: string[] = [];
|
||
|
||
if (bump.kind !== "current") {
|
||
for (const p of SYNCED_PKGS) writeVersion(p, next);
|
||
log(`✓ updated ${SYNCED_PKGS.length} package.json files`);
|
||
staged.push(...SYNCED_PKGS);
|
||
}
|
||
|
||
if (announcement) {
|
||
const { entry, replaced } = writeAdvisory(announcement);
|
||
log(`✓ wrote announcement advisory "${entry.id}" (${entry.severity})`);
|
||
if (replaced.length) {
|
||
log(` replaced previous advisory(ies): ${replaced.join(", ")}`);
|
||
}
|
||
staged.push(ADVISORIES);
|
||
}
|
||
|
||
if (staged.length > 0) {
|
||
git("add", ...staged);
|
||
// Only commit if something actually changed. Re-releasing the version you're
|
||
// already on (no bump, no publish diff) writes no diff, and `git commit` would
|
||
// abort with "nothing to commit" and kill the release — ship as-is instead.
|
||
const nothingStaged =
|
||
spawnSync("git", ["diff", "--cached", "--quiet"], { cwd: ROOT }).status === 0;
|
||
if (nothingStaged) {
|
||
log(`No changes to commit — releasing as-is.`);
|
||
} else {
|
||
const msg = publish
|
||
? bump.kind === "current"
|
||
? `Announce ${tag}`
|
||
: `Bump to ${tag} + announce`
|
||
: `Bump to ${tag}`;
|
||
git("commit", "-m", msg);
|
||
log(`✓ committed`);
|
||
}
|
||
}
|
||
|
||
git("push", "origin", `refs/heads/${currentBranch()}`);
|
||
log(`✓ pushed ${currentBranch()}${bump.kind === "current" && !publish ? " (releasing current version, no bump)" : ""}`);
|
||
|
||
git("tag", tag);
|
||
// Fully-qualified refspec: a BRANCH sharing the tag's name (e.g. a leftover
|
||
// `v0.2.0` branch) would otherwise make `git push origin v0.2.0` ambiguous
|
||
// ("src refspec … matches more than one").
|
||
git("push", "origin", `refs/tags/${tag}`);
|
||
log(`✓ pushed tag ${tag} — CI is building installers for macOS, Windows & Linux`);
|
||
|
||
/* ─── Final report ─────────────────────────────────────────────────── */
|
||
|
||
const remoteUrl = git("remote", "get-url", "origin", { capture: true }).trim();
|
||
const ghMatch = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
||
const actionsUrl = ghMatch ? `https://github.com/${ghMatch[1]}/${ghMatch[2]}/actions` : "";
|
||
if (ghMatch) {
|
||
const [, owner, repo] = ghMatch;
|
||
log(``);
|
||
log(`Release will appear at:`);
|
||
log(` https://github.com/${owner}/${repo}/releases/tag/${tag}`);
|
||
log(``);
|
||
}
|
||
|
||
// Stream the live build status right here in the terminal.
|
||
watchCi(tag, actionsUrl);
|
||
|
||
/* ─── Helpers ──────────────────────────────────────────────────────── */
|
||
|
||
function usageAndExit(code = 1): never {
|
||
const out = code === 0 ? console.log : console.error;
|
||
out(
|
||
[
|
||
"Usage: bun run release [current|patch|minor|major|rc|x.y.z[-rc.N]] [publish [--critical] [--modes=…] [--message=…]] [--dry-run] [--force-branch]",
|
||
"",
|
||
" (no arg) in a terminal: interactive wizard over all of the below.",
|
||
" non-interactive (CI/pipe): release the current version as-is",
|
||
" current same as no arg",
|
||
" patch 0.1.0 → 0.1.1",
|
||
" minor 0.1.0 → 0.2.0",
|
||
" major 0.1.0 → 1.0.0",
|
||
" rc 0.1.0 → 0.1.1-rc.1",
|
||
" 0.1.1-rc.1 → 0.1.1-rc.2",
|
||
" 0.1.1-rc.2 → 0.1.1 (rc → stable promotion)",
|
||
" <literal> explicit semver string",
|
||
"",
|
||
" docker [tag] publish Docker images ONLY (GHCR) via the docker-images",
|
||
" workflow — no version bump / git tag / GitHub release, and",
|
||
" never moves :latest. Omit [tag] → images tagged short SHA.",
|
||
" `--ref=<branch>` builds that branch (default: current).",
|
||
" e.g. bun run release docker 0.0.0-rc.1",
|
||
" continue resume the current unpublished release with HEAD; reuse",
|
||
" successful checks/builds only when their inputs match.",
|
||
"",
|
||
" publish ANNOUNCE this release: write a release advisory so the in-app",
|
||
" update banner prompts users below this version. WITHOUT it the",
|
||
" release ships SILENTLY (still shown quietly in Settings → Updates",
|
||
" and the home Updates block). Refused for prereleases.",
|
||
" --critical make the announcement critical (always shown, even if muted)",
|
||
" --modes=… WHO sees it: comma list of desktop|selfhosted|cloud.",
|
||
" Omit for all installs. Clients filter on this, so a",
|
||
" desktop-installer notice never reaches a VPS dashboard",
|
||
" (and an edge/compose notice never reaches the desktop app).",
|
||
" e.g. --modes=selfhosted --modes=desktop,cloud",
|
||
" --message=… custom banner body (default points at the release notes)",
|
||
"",
|
||
" e.g. bun run release patch # silent patch",
|
||
" bun run release minor publish # announced (recommended)",
|
||
" bun run release patch publish --critical # announced (critical)",
|
||
"",
|
||
" --dry-run print the plan, don't touch anything (with `publish`, also",
|
||
" prints the exact advisory JSON that would be written)",
|
||
" --force-branch run from a non-main branch (default refuses)",
|
||
" --force re-release an EXISTING tag: delete it (local + origin) and",
|
||
" re-push, firing a fresh build. For salvaging a tag whose",
|
||
" release failed. Refuses without it (re-pointing a pulled",
|
||
" tag diverges users' bytes).",
|
||
" --help, -h show this help",
|
||
"",
|
||
"In every case CI builds installers for macOS (arm64 + x64), Windows,",
|
||
"and Linux and publishes them to the GitHub release. Live build status",
|
||
"streams here after the tag is pushed (needs the `gh` CLI, logged in).",
|
||
].join("\n"),
|
||
);
|
||
process.exit(code);
|
||
}
|
||
|
||
/** What the GitHub release body will say, so a missing CHANGELOG.md section is
|
||
* visible BEFORE the tag is pushed. Purely informational — a version with no
|
||
* section still releases (the workflow falls back to a changelog link). */
|
||
function changelogNotesStatus(version: string): string {
|
||
try {
|
||
const md = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8");
|
||
const section = extractChangelogSection(md, version);
|
||
if (section) return `CHANGELOG.md § ${version} (${section.split("\n").length} lines)`;
|
||
const base = version.match(/^(\d+\.\d+\.\d+)-/)?.[1];
|
||
if (base && extractChangelogSection(md, base)) {
|
||
return `CHANGELOG.md § ${base} (base version — this is a prerelease)`;
|
||
}
|
||
} catch {
|
||
// fall through
|
||
}
|
||
return `⚠ no CHANGELOG.md section for ${version} — the release will link to the changelog instead`;
|
||
}
|
||
|
||
/** True if the tag already exists locally or on origin. */
|
||
function tagExists(t: string): boolean {
|
||
const local = git("tag", "--list", t, { capture: true }).trim();
|
||
if (local) return true;
|
||
const remote = git("ls-remote", "--tags", "origin", t, { capture: true }).trim();
|
||
return remote.length > 0;
|
||
}
|
||
|
||
/** Delete a tag wherever it exists (local + origin) so it can be re-pushed as a
|
||
* FRESH ref. Each side is guarded on presence — `git()` exits on any non-zero,
|
||
* so deleting a side that isn't there would abort the release. */
|
||
function retireExistingTag(t: string): void {
|
||
if (git("tag", "--list", t, { capture: true }).trim()) {
|
||
git("tag", "-d", t);
|
||
log(` ✓ deleted local tag ${t}`);
|
||
}
|
||
if (git("ls-remote", "--tags", "origin", t, { capture: true }).trim()) {
|
||
git("push", "origin", `:refs/tags/${t}`);
|
||
log(` ✓ deleted origin tag ${t}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stream the live status of the release workflow run into this terminal.
|
||
* Uses the `gh` CLI (`gh run watch`), which shows each job spinning →
|
||
* pass/fail and exits when the run finishes. Degrades gracefully (prints the
|
||
* Actions URL) if `gh` is missing, not authed, or the run isn't found yet.
|
||
*/
|
||
function watchCi(t: string, fallbackUrl: string, notBefore = Date.now() - 30_000): void {
|
||
const have = spawnSync("gh", ["--version"], { encoding: "utf8" });
|
||
if (have.status !== 0) {
|
||
if (fallbackUrl) log(`Watch the build: ${fallbackUrl}`);
|
||
return;
|
||
}
|
||
|
||
log(`Waiting for the release run to register on GitHub…`);
|
||
let runId = "";
|
||
for (let i = 0; i < 15 && !runId; i++) {
|
||
Bun.sleepSync(4000);
|
||
const out = spawnSync(
|
||
"gh",
|
||
["run", "list", "--workflow", "release.yml", "--limit", "15", "--json", "databaseId,headBranch,event,createdAt"],
|
||
{ cwd: ROOT, encoding: "utf8" },
|
||
);
|
||
if (out.status !== 0) continue;
|
||
try {
|
||
const runs = JSON.parse(out.stdout ?? "[]") as Array<{
|
||
databaseId: number;
|
||
headBranch: string;
|
||
event: string;
|
||
createdAt: string;
|
||
}>;
|
||
// Tag-triggered runs show headBranch === the tag name.
|
||
const match = runs.find((r) =>
|
||
(r.headBranch === t || r.headBranch === `refs/tags/${t}`) && Date.parse(r.createdAt) >= notBefore,
|
||
);
|
||
if (match) runId = String(match.databaseId);
|
||
} catch {
|
||
// keep polling
|
||
}
|
||
}
|
||
|
||
if (!runId) {
|
||
log(`Couldn't locate the run automatically.`);
|
||
if (fallbackUrl) log(`Watch the build: ${fallbackUrl}`);
|
||
return;
|
||
}
|
||
|
||
log(``);
|
||
log(`▼ live build status (Ctrl-C to stop watching — the build keeps running):`);
|
||
log(``);
|
||
// Streams job-by-job status and exits when the run completes.
|
||
spawnSync("gh", ["run", "watch", runId, "--interval", "6"], { cwd: ROOT, stdio: "inherit" });
|
||
log(``);
|
||
spawnSync("gh", ["run", "view", runId], { cwd: ROOT, stdio: "inherit" });
|
||
}
|
||
|
||
/* ─── Docker image release (GHCR-only, via workflow_dispatch) ────────── */
|
||
|
||
/** Continue an unpublished tag. Compare-and-swap protects against another
|
||
* maintainer moving it between inspection and push. Published bytes are never
|
||
* retagged by this path; they require a new version instead. */
|
||
async function continueRelease(): Promise<void> {
|
||
if (!dryRun) preflight();
|
||
const targetTag = `v${readVersion(API_PKG)}`;
|
||
const repository = ghOwnerRepo();
|
||
if (!repository) throw new Error("Cannot determine the GitHub repository from origin.");
|
||
const repo = `${repository.owner}/${repository.repo}`;
|
||
const remoteRef = git("ls-remote", "--refs", "origin", `refs/tags/${targetTag}`, { capture: true }).trim();
|
||
const remoteOid = remoteRef.split(/\s+/)[0];
|
||
if (!remoteOid || !/^[a-f0-9]{40}$/.test(remoteOid))
|
||
throw new Error(`No existing ${targetTag} to continue. Use release current for a new release.`);
|
||
const releaseResult = spawnSync("gh", ["api", `repos/${repo}/releases/tags/${targetTag}`], { encoding: "utf8", cwd: ROOT });
|
||
if (releaseResult.status === 0 && !JSON.parse(releaseResult.stdout).draft)
|
||
throw new Error(`${targetTag} is already published. Release a new version instead of changing its bytes.`);
|
||
if (releaseResult.status !== 0 && !releaseResult.stderr.includes("HTTP 404"))
|
||
throw new Error("Could not verify whether this GitHub release is already published.");
|
||
const version = targetTag.slice(1);
|
||
const npm = spawnSync("npm", ["view", `openship@${version}`, "version", "--json"], { encoding: "utf8", cwd: ROOT });
|
||
if (npm.status === 0)
|
||
throw new Error(`openship@${version} is already on npm. Release a new version instead.`);
|
||
if (!npm.stderr.includes("E404")) throw new Error("Could not verify whether this version is already on npm.");
|
||
|
||
const previous = await unpublishedReleaseRuns(repo, targetTag);
|
||
if (!previous.some((run) => run.conclusion !== "success"))
|
||
throw new Error(`No failed release run found for ${targetTag}.`);
|
||
// Read the inspected remote object, not a potentially stale local tag.
|
||
git("fetch", "--no-tags", "origin", `refs/tags/${targetTag}`);
|
||
if (git("rev-parse", "FETCH_HEAD", { capture: true }).trim() !== remoteOid)
|
||
throw new Error("The release tag changed during inspection. Retry continuation.");
|
||
git("merge-base", "--is-ancestor", "FETCH_HEAD^{commit}", "HEAD");
|
||
const annotation = git("cat-file", "-p", "FETCH_HEAD", { capture: true });
|
||
const runIds = [...new Set([...previous.map((run) => String(run.id)), ...resumeRuns(annotation)])].slice(0, 12);
|
||
log(`Continue ${targetTag} with ${git("rev-parse", "--short", "HEAD", { capture: true }).trim()}`);
|
||
log(`Previous runs: ${runIds.join(", ")}`);
|
||
log("Successful checks and build artifacts are reused only when their source and workflow inputs match.");
|
||
log("Failed or affected jobs run normally; publishing still requires every gate to pass.");
|
||
if (dryRun) {
|
||
log("[dry-run] would push the current branch and update the unpublished tag with this resume record.");
|
||
return;
|
||
}
|
||
git("push", "origin", `HEAD:refs/heads/${currentBranch()}`);
|
||
const localRef = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${targetTag}`], { encoding: "utf8", cwd: ROOT });
|
||
git("tag", "-f", "-a", targetTag, "HEAD", "-m", `${targetTag}\n\n${RESUME_TRAILER} ${runIds.join(",")}`);
|
||
const started = Date.now() - 5_000;
|
||
const push = spawnSync("git", ["push", `--force-with-lease=refs/tags/${targetTag}:${remoteOid}`, "origin", `refs/tags/${targetTag}`], { stdio: "inherit", cwd: ROOT });
|
||
if (push.status !== 0) {
|
||
if (localRef.status === 0) git("update-ref", `refs/tags/${targetTag}`, localRef.stdout.trim());
|
||
else git("update-ref", "-d", `refs/tags/${targetTag}`);
|
||
throw new Error("The release tag could not be updated; the local tag was restored.");
|
||
}
|
||
watchCi(targetTag, `https://github.com/${repo}/actions`, started);
|
||
}
|
||
|
||
/** owner/repo parsed from origin, or null when origin isn't a GitHub remote. */
|
||
function ghOwnerRepo(): { owner: string; repo: string } | null {
|
||
const url = git("remote", "get-url", "origin", { capture: true }).trim();
|
||
const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
||
return m ? { owner: m[1], repo: m[2] } : null;
|
||
}
|
||
|
||
/**
|
||
* `bun release docker [tag]` — trigger the docker-images workflow to publish the
|
||
* openship-api/dashboard/edge images to GHCR. This is the DOCKER-ONLY path: it
|
||
* dispatches the workflow (no version bump, no git tag, no GitHub release, no
|
||
* npm/desktop) and the dispatch run never moves `:latest`. Untracked/dirty tree
|
||
* is fine — it builds whatever is on the pushed `--ref` branch, not your working
|
||
* copy. Requires the `gh` CLI (or use the Actions UI → "Docker images").
|
||
*/
|
||
function releaseDocker(): void {
|
||
const positionals = args.filter((a) => !a.startsWith("--") && a !== "publish");
|
||
const dockerTag = positionals[1]; // `release docker [tag]`
|
||
const ref = args.find((a) => a.startsWith("--ref="))?.slice("--ref=".length) || currentBranch();
|
||
|
||
const runArgs = ["workflow", "run", "docker-images.yml", "--ref", ref];
|
||
if (dockerTag) runArgs.push("-f", `tag=${dockerTag}`);
|
||
|
||
log(`Docker image publish (GHCR-only)`);
|
||
log(` workflow: docker-images.yml`);
|
||
log(` ref: ${ref}`);
|
||
log(` tag: ${dockerTag ?? "(short SHA)"}`);
|
||
log(` scope: images ONLY — no git tag / GitHub release / npm / desktop; :latest untouched`);
|
||
log(``);
|
||
|
||
if (dryRun) {
|
||
log(`[dry-run] gh ${runArgs.join(" ")}`);
|
||
log(`[dry-run] nothing dispatched.`);
|
||
return;
|
||
}
|
||
|
||
if (spawnSync("gh", ["--version"], { encoding: "utf8" }).status !== 0) {
|
||
console.error(
|
||
`Refusing: the \`gh\` CLI is required for \`release docker\`. Install it + \`gh auth login\`,\n` +
|
||
`or trigger it in the UI: Actions → "Docker images" → Run workflow.`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
const res = spawnSync("gh", runArgs, { cwd: ROOT, stdio: "inherit" });
|
||
if (res.status !== 0) {
|
||
console.error(
|
||
`\ngh workflow run failed. Most common cause: docker-images.yml isn't on the repo's\n` +
|
||
`DEFAULT branch yet — workflow_dispatch only works once the workflow file is on it.\n` +
|
||
`Push the workflow to the default branch, then retry.`,
|
||
);
|
||
process.exit(res.status ?? 1);
|
||
}
|
||
log(`✓ dispatched docker-images.yml (${ref})`);
|
||
|
||
watchDispatch();
|
||
|
||
const or = ghOwnerRepo();
|
||
const shown = dockerTag ?? "<short-sha>";
|
||
log(``);
|
||
log(`Verify when green:`);
|
||
if (or) {
|
||
log(` docker manifest inspect ghcr.io/${or.owner}/openship-edge:${shown} # amd64 + arm64`);
|
||
log(` docker pull ghcr.io/${or.owner}/openship-api:${shown}`);
|
||
log(` Packages: https://github.com/orgs/${or.owner}/packages?repo_name=${or.repo}`);
|
||
}
|
||
}
|
||
|
||
/** Poll for + stream the just-dispatched docker-images run (newest dispatch run). */
|
||
function watchDispatch(): void {
|
||
if (spawnSync("gh", ["--version"], { encoding: "utf8" }).status !== 0) return;
|
||
log(`Waiting for the run to register on GitHub…`);
|
||
let runId = "";
|
||
for (let i = 0; i < 15 && !runId; i++) {
|
||
Bun.sleepSync(4000);
|
||
const out = spawnSync(
|
||
"gh",
|
||
["run", "list", "--workflow", "docker-images.yml", "--event", "workflow_dispatch",
|
||
"--limit", "1", "--json", "databaseId", "--jq", ".[0].databaseId"],
|
||
{ cwd: ROOT, encoding: "utf8" },
|
||
);
|
||
if (out.status === 0) {
|
||
const id = (out.stdout ?? "").trim();
|
||
if (id && id !== "null") runId = id;
|
||
}
|
||
}
|
||
if (!runId) {
|
||
log(`Couldn't locate the run automatically — check: gh run list --workflow docker-images.yml`);
|
||
return;
|
||
}
|
||
log(``);
|
||
log(`▼ live build status (Ctrl-C to stop watching — the build keeps running):`);
|
||
log(``);
|
||
spawnSync("gh", ["run", "watch", runId, "--interval", "6"], { cwd: ROOT, stdio: "inherit" });
|
||
}
|
||
|
||
function preflight(): void {
|
||
// 1. Clean working tree
|
||
const status = git("status", "--porcelain", { capture: true }).trim();
|
||
if (status) {
|
||
console.error(
|
||
`Refusing: working tree is dirty. Commit or stash first.\n${status}`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
// 2. On main
|
||
const branch = currentBranch();
|
||
if (branch !== "main" && !forceBranch) {
|
||
console.error(
|
||
`Refusing: current branch is "${branch}", not "main". ` +
|
||
`Pass --force-branch to release from a different branch.`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
// 3. Up-to-date with origin (refuse if behind — would push a stale tag)
|
||
git("fetch", "origin", branch);
|
||
const behind = git("rev-list", "--count", `HEAD..origin/${branch}`, { capture: true }).trim();
|
||
if (behind !== "0") {
|
||
console.error(
|
||
`Refusing: local "${branch}" is ${behind} commit(s) behind origin. Pull first.`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
function currentBranch(): string {
|
||
return git("rev-parse", "--abbrev-ref", "HEAD", { capture: true }).trim();
|
||
}
|
||
|
||
function readVersion(pkgPath: string): string {
|
||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
|
||
if (!pkg.version) {
|
||
console.error(`Refusing: ${pkgPath} has no "version" field.`);
|
||
process.exit(1);
|
||
}
|
||
return pkg.version;
|
||
}
|
||
|
||
function writeVersion(pkgPath: string, next: string): void {
|
||
const raw = readFileSync(pkgPath, "utf8");
|
||
const re = /("version"\s*:\s*")[^"]+(")/;
|
||
// Error only if the field is genuinely absent — NOT when it's already at the
|
||
// target value (a no-op, common when syncing a drifted package.json).
|
||
if (!re.test(raw)) {
|
||
console.error(`Refusing: could not locate version field in ${pkgPath}.`);
|
||
process.exit(1);
|
||
}
|
||
// Preserve formatting + trailing newline. Surgical replacement of the
|
||
// version field rather than full re-serialization avoids reformatting
|
||
// the whole file (which would create noisy diffs).
|
||
const replaced = raw.replace(re, (_, a, b) => `${a}${next}${b}`);
|
||
if (replaced !== raw) writeFileSync(pkgPath, replaced);
|
||
}
|
||
|
||
interface SemverParts {
|
||
major: number;
|
||
minor: number;
|
||
patch: number;
|
||
/** e.g. "rc.1", "beta.3" — undefined when stable. */
|
||
prerelease?: string;
|
||
}
|
||
|
||
function parseSemver(v: string): SemverParts {
|
||
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
|
||
if (!m) {
|
||
console.error(`Refusing: "${v}" is not a parseable semver.`);
|
||
process.exit(1);
|
||
}
|
||
return {
|
||
major: Number(m[1]),
|
||
minor: Number(m[2]),
|
||
patch: Number(m[3]),
|
||
prerelease: m[4],
|
||
};
|
||
}
|
||
|
||
function formatSemver(p: SemverParts): string {
|
||
const base = `${p.major}.${p.minor}.${p.patch}`;
|
||
return p.prerelease ? `${base}-${p.prerelease}` : base;
|
||
}
|
||
|
||
function computeNext(current: string, bump: { kind: BumpKind; literal?: string }): string {
|
||
if (bump.kind === "current") return current;
|
||
if (bump.kind === "literal") return bump.literal!;
|
||
const parsed = parseSemver(current);
|
||
switch (bump.kind) {
|
||
case "patch":
|
||
return formatSemver({ major: parsed.major, minor: parsed.minor, patch: parsed.patch + (parsed.prerelease ? 0 : 1) });
|
||
case "minor":
|
||
return formatSemver({ major: parsed.major, minor: parsed.minor + 1, patch: 0 });
|
||
case "major":
|
||
return formatSemver({ major: parsed.major + 1, minor: 0, patch: 0 });
|
||
case "rc": {
|
||
// rc → next rc OR rc → stable promotion
|
||
if (parsed.prerelease) {
|
||
const rcMatch = parsed.prerelease.match(/^rc\.(\d+)$/);
|
||
if (rcMatch) {
|
||
// currently rc.N — bump to rc.(N+1)
|
||
return formatSemver({ ...parsed, prerelease: `rc.${Number(rcMatch[1]) + 1}` });
|
||
}
|
||
// some other prerelease (beta.N etc.) — bump patch + start rc.1
|
||
return formatSemver({
|
||
major: parsed.major,
|
||
minor: parsed.minor,
|
||
patch: parsed.patch + 1,
|
||
prerelease: "rc.1",
|
||
});
|
||
}
|
||
// stable → next patch rc.1
|
||
return formatSemver({
|
||
major: parsed.major,
|
||
minor: parsed.minor,
|
||
patch: parsed.patch + 1,
|
||
prerelease: "rc.1",
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function git(
|
||
...args: [string, ...(string | { capture: true })[]]
|
||
): string;
|
||
function git(
|
||
...args: string[]
|
||
): string;
|
||
function git(...args: (string | { capture: true })[]): string {
|
||
const last = args[args.length - 1];
|
||
const capture = typeof last === "object" && last !== null && (last as { capture?: boolean }).capture === true;
|
||
const realArgs = (capture ? args.slice(0, -1) : args) as string[];
|
||
const result = spawnSync("git", realArgs, {
|
||
cwd: ROOT,
|
||
stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit",
|
||
encoding: "utf8",
|
||
});
|
||
if (result.status !== 0) {
|
||
console.error(`git ${realArgs.join(" ")} exited ${result.status}`);
|
||
process.exit(result.status ?? 1);
|
||
}
|
||
return capture ? (result.stdout ?? "") : "";
|
||
}
|
||
|
||
function log(msg: string): void {
|
||
console.log(msg);
|
||
}
|
||
|
||
/** Write the single announcement advisory into release-advisories.json,
|
||
* preserving the file's $comment gate. One active announcement at a time (the
|
||
* newest published version); returns any prior advisory ids it replaced. */
|
||
function writeAdvisory(
|
||
entry: ReturnType<typeof buildReleaseAdvisory>,
|
||
) {
|
||
const raw = JSON.parse(readFileSync(ADVISORIES, "utf8")) as {
|
||
$comment?: string;
|
||
advisories?: Array<{ id?: string }>;
|
||
};
|
||
const replaced = (raw.advisories ?? []).map((a) => a.id ?? "").filter(id => id && id !== entry.id);
|
||
const nextDoc = { ...raw, advisories: [entry] };
|
||
writeFileSync(ADVISORIES, JSON.stringify(nextDoc, null, 2) + "\n");
|
||
return { entry, replaced };
|
||
}
|
||
|
||
/* ─── Interactive wizard ───────────────────────────────────────────────
|
||
*
|
||
* Prompting only: it collects answers and hands them to `buildWizardArgs`
|
||
* (scripts/release-args.ts) to become argv the flag path already understands.
|
||
* null = cancelled.
|
||
*/
|
||
|
||
async function runWizard(): Promise<string[] | null> {
|
||
const { createInterface } = await import("node:readline/promises");
|
||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||
const CANCEL = Symbol("cancel");
|
||
|
||
/** Numbered menu. Enter takes the default; `q` cancels. */
|
||
const pick = async <T extends string>(
|
||
title: string,
|
||
opts: { value: T; label: string; hint?: string }[],
|
||
defaultIndex = 0,
|
||
): Promise<T | typeof CANCEL> => {
|
||
console.log(`\n${title}`);
|
||
opts.forEach((o, i) => {
|
||
const mark = i === defaultIndex ? "›" : " ";
|
||
console.log(` ${mark} ${i + 1}. ${o.label}${o.hint ? ` ${dim(o.hint)}` : ""}`);
|
||
});
|
||
for (;;) {
|
||
const raw = (await rl.question(` choice [${defaultIndex + 1}] `)).trim().toLowerCase();
|
||
if (raw === "q") return CANCEL;
|
||
if (!raw) return opts[defaultIndex]!.value;
|
||
const n = Number(raw);
|
||
if (Number.isInteger(n) && n >= 1 && n <= opts.length) return opts[n - 1]!.value;
|
||
console.log(` ${dim(`enter 1-${opts.length}, or q to cancel`)}`);
|
||
}
|
||
};
|
||
|
||
const ask = async (q: string, def = ""): Promise<string> => {
|
||
const raw = (await rl.question(` ${q}${def ? ` [${def}]` : ""} `)).trim();
|
||
return raw || def;
|
||
};
|
||
|
||
const confirm = async (q: string, def = false): Promise<boolean> => {
|
||
const raw = (await rl.question(` ${q} [${def ? "Y/n" : "y/N"}] `)).trim().toLowerCase();
|
||
if (!raw) return def;
|
||
return raw === "y" || raw === "yes";
|
||
};
|
||
|
||
try {
|
||
const current = readVersion(API_PKG);
|
||
const branch = currentBranch();
|
||
console.log("");
|
||
console.log(` Openship release`);
|
||
console.log(` current version ${current}`);
|
||
console.log(` branch ${branch}${branch === "main" ? "" : dim(" (not main)")}`);
|
||
console.log(dim(` Enter accepts the ›default; q cancels.`));
|
||
|
||
const mode = await pick("What do you want to release?", [
|
||
{ value: "version" as const, label: "A version", hint: "bump + tag + GitHub release + installers" },
|
||
{ value: "docker" as const, label: "Docker images only", hint: "GHCR; no tag, no release, :latest untouched" },
|
||
{ value: "continue" as const, label: "Continue a failed release", hint: "current code; reuse successful, unchanged jobs" },
|
||
]);
|
||
if (mode === CANCEL) return null;
|
||
|
||
const answers: WizardAnswers = { mode, dryRun: false, forceBranch: false };
|
||
|
||
if (mode === "docker") {
|
||
answers.dockerTag = await ask("image tag (empty = short SHA):");
|
||
answers.dockerRef = await ask("branch to build:", branch);
|
||
} else if (mode === "version") {
|
||
// Preview each bump's real target so the choice is concrete.
|
||
const preview = (k: BumpKind) => computeNext(current, { kind: k });
|
||
const bump = await pick(
|
||
"Which version?",
|
||
[
|
||
{ value: "patch" as const, label: "patch", hint: `→ ${preview("patch")}` },
|
||
{ value: "minor" as const, label: "minor", hint: `→ ${preview("minor")}` },
|
||
{ value: "major" as const, label: "major", hint: `→ ${preview("major")}` },
|
||
{ value: "rc" as const, label: "rc", hint: `→ ${preview("rc")}` },
|
||
{ value: "current" as const, label: "current, as-is", hint: `→ ${current} (no bump)` },
|
||
{ value: "custom" as const, label: "custom semver…", hint: "e.g. 0.5.0-beta.2" },
|
||
],
|
||
0,
|
||
);
|
||
if (bump === CANCEL) return null;
|
||
answers.bump = bump;
|
||
|
||
if (bump === "custom") {
|
||
for (;;) {
|
||
const v = await ask("version:");
|
||
if (!v) return null;
|
||
if (/^\d+\.\d+\.\d+(-[a-z0-9]+(\.\d+)?)?$/i.test(v)) {
|
||
answers.literal = v;
|
||
break;
|
||
}
|
||
console.log(` ${dim("not a semver string — e.g. 0.5.0 or 0.5.0-rc.1")}`);
|
||
}
|
||
}
|
||
|
||
const target = bump === "custom" ? answers.literal! : computeNext(current, { kind: bump });
|
||
const isPre = target.includes("-");
|
||
|
||
// `publish` is refused for prereleases downstream — don't offer it.
|
||
if (isPre) {
|
||
console.log(`\n ${dim(`${target} is a prerelease → announcement not available (clients only read advisories from the latest stable).`)}`);
|
||
answers.announce = "silent";
|
||
} else {
|
||
const announce = await pick(
|
||
"Announce it? (raises the in-app update banner)",
|
||
[
|
||
{ value: "silent" as const, label: "Silent", hint: "still listed in Settings → Updates" },
|
||
{ value: "recommended" as const, label: "Announce", hint: "recommended advisory" },
|
||
{ value: "critical" as const, label: "Announce as critical", hint: "shown even if muted" },
|
||
],
|
||
1,
|
||
);
|
||
if (announce === CANCEL) return null;
|
||
answers.announce = announce;
|
||
if (announce !== "silent") {
|
||
// Audience. Most releases only matter to one kind of install — a
|
||
// desktop installer fix is noise on a VPS, an edge/compose fix is
|
||
// noise in the desktop app. Clients filter on this, so scoping it
|
||
// means the people it doesn't apply to are never interrupted.
|
||
const who = await pick(
|
||
"Who should see it?",
|
||
[
|
||
{ value: "all" as const, label: "All installs", hint: "desktop + self-hosted + cloud" },
|
||
{ value: "selfhosted" as const, label: "Self-hosted only", hint: "VPS: compose or bare" },
|
||
{ value: "desktop" as const, label: "Desktop app only", hint: "the Electron installer" },
|
||
{ value: "cloud" as const, label: "Cloud only", hint: "the managed SaaS" },
|
||
],
|
||
0,
|
||
);
|
||
if (who === CANCEL) return null;
|
||
if (who !== "all") answers.modes = [who];
|
||
|
||
const msg = await ask("banner text (empty = default, points at release notes):");
|
||
if (msg) answers.message = msg;
|
||
}
|
||
}
|
||
|
||
// Re-releasing a tag that already exists needs --force downstream (it
|
||
// deletes + re-pushes). Surface it here so the wizard can salvage a
|
||
// failed release instead of dead-ending on "tag already exists".
|
||
if (tagExists(`v${target}`)) {
|
||
console.log(`\n ${dim(`tag v${target} already exists — a prior release used it.`)}`);
|
||
const doForce = await confirm(
|
||
`Re-release v${target}? Deletes the old tag + re-pushes to fire a fresh build`,
|
||
true,
|
||
);
|
||
if (!doForce) return null;
|
||
answers.force = true;
|
||
}
|
||
}
|
||
|
||
// No dry-run prompt: it's pure friction in the wizard (and defaulted to N
|
||
// anyway). The real gate is "Run it?" below, and `--dry-run` is still there
|
||
// on the flag path for anyone who wants the plan-only preview.
|
||
if (branch !== "main") {
|
||
console.log(`\n ${dim(`You are on "${branch}", not main. A real release from here needs --force-branch.`)}`);
|
||
answers.forceBranch = await confirm(`Release from "${branch}" anyway?`, false);
|
||
if (!answers.forceBranch) return null;
|
||
}
|
||
|
||
const built = buildWizardArgs(answers);
|
||
console.log("");
|
||
console.log(` Plan: bun run release ${built.join(" ")}`);
|
||
return (await confirm("Run it?", true)) ? built : null;
|
||
} finally {
|
||
rl.close();
|
||
}
|
||
}
|
||
|
||
/** Dim text when the terminal supports colour; plain otherwise. */
|
||
function dim(s: string): string {
|
||
return process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s;
|
||
}
|