#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; export const REPO = "different-ai/openwork"; const MAX_BUFFER = 128 * 1024 * 1024; const log = (message) => console.log(` ${message}`); const heading = (message) => console.log(`\n▸ ${message}`); export function parseArgs(args) { const options = { bad: null, to: null, execute: false, keepBadListed: false, }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--execute") { options.execute = true; continue; } if (arg === "--keep-bad-listed") { options.keepBadListed = true; continue; } if (["--bad", "--to"].includes(arg)) { const value = args[index + 1]; if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value.`); options[arg.slice(2)] = value; index += 1; continue; } throw new Error(`Unknown argument: ${arg}`); } return options; } export function parseSemver(tag) { const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/); return match ? match.slice(1).map(Number) : null; } export function compareSemver(leftTag, rightTag) { const left = parseSemver(leftTag); const right = parseSemver(rightTag); if (!left) throw new Error(`Release tag '${leftTag}' is not valid semver (expected vX.Y.Z).`); if (!right) throw new Error(`Release tag '${rightTag}' is not valid semver (expected vX.Y.Z).`); for (let index = 0; index < left.length; index += 1) { if (left[index] !== right[index]) return left[index] - right[index]; } return 0; } function releaseTag(release) { return release.tag_name; } function findRelease(releases, tag) { return releases.find((release) => releaseTag(release) === tag) ?? null; } function validateBadRelease(releases, badTag) { const bad = findRelease(releases, badTag); if (!bad) throw new Error(`Bad release not found: ${badTag}.`); if (bad.draft) throw new Error(`Bad release ${badTag} is a draft, not a published release.`); if (!parseSemver(badTag)) { throw new Error(`Bad release tag '${badTag}' is not valid semver (expected vX.Y.Z).`); } return bad; } export function selectRollbackTarget(releases, badTag) { validateBadRelease(releases, badTag); const candidates = releases.filter((release) => { const tag = releaseTag(release); return !release.draft && !release.prerelease && parseSemver(tag) && compareSemver(tag, badTag) < 0; }); candidates.sort((left, right) => compareSemver(releaseTag(right), releaseTag(left))); if (!candidates[0]) { throw new Error(`No published, non-prerelease release exists below ${badTag}.`); } return candidates[0]; } export function resolveRollback(releases, badTag, requestedTargetTag = null) { const bad = validateBadRelease(releases, badTag); const targetTag = requestedTargetTag ?? releaseTag(selectRollbackTarget(releases, badTag)); const target = findRelease(releases, targetTag); if (!target) throw new Error(`Target release not found: ${targetTag}.`); if (badTag === targetTag) throw new Error(`Bad and target releases must differ (${badTag}).`); if (target.draft) throw new Error(`Target release ${targetTag} is a draft, not a published release.`); if (target.prerelease) throw new Error(`Target release ${targetTag} is a prerelease, not a stable published release.`); if (!parseSemver(targetTag)) { throw new Error(`Target release tag '${targetTag}' is not valid semver (expected vX.Y.Z).`); } if (compareSemver(targetTag, badTag) >= 0) { throw new Error(`Target release ${targetTag} must be lower than bad release ${badTag}.`); } return { bad, target }; } export function hasUpdaterManifest(release) { return (release.assets ?? []).some(({ name }) => ( /^latest.*\.yml$/i.test(name) || name.toLowerCase() === "latest.json" )); } export function rollbackBanner(targetTag, now = new Date()) { const date = now.toISOString().slice(0, 10); return `> ⚠️ Rolled back on ${date}. Do not install; use ${targetTag} instead.`; } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export function hasRollbackBanner(body, targetTag) { const target = escapeRegExp(targetTag); return new RegExp( `^> ⚠️ Rolled back on \\d{4}-\\d{2}-\\d{2}\\. Do not install; use ${target} instead\\.$`, "m", ).test(body ?? ""); } export function buildRollbackBody(body, targetTag, now = new Date()) { const existingBody = body ?? ""; if (hasRollbackBanner(existingBody, targetTag)) return existingBody; const banner = rollbackBanner(targetTag, now); return existingBody ? `${banner}\n\n${existingBody}` : banner; } export function buildRollbackPlan({ bad, target, latestTag, keepBadListed = false, now = new Date(), }) { const badTag = releaseTag(bad); const targetTag = releaseTag(target); const nextBody = buildRollbackBody(bad.body, targetTag, now); return [ { name: `Re-point Latest from ${latestTag} to ${targetTag}`, skip: latestTag === targetTag, skipReason: `${targetTag} is already Latest`, args: ["release", "edit", targetTag, "--repo", REPO, "--latest"], }, { name: `Demote ${badTag} to prerelease (assets and tag stay intact)`, skip: keepBadListed || bad.prerelease, skipReason: keepBadListed ? "--keep-bad-listed was supplied" : `${badTag} is already a prerelease`, args: ["release", "edit", badTag, "--repo", REPO, "--prerelease"], }, { name: `Prepend the rollback banner to ${badTag} release notes`, skip: nextBody === (bad.body ?? ""), skipReason: "rollback banner is already present", args: [ "api", "--method", "PATCH", `repos/${REPO}/releases/${bad.id}`, "-f", `body=${nextBody}`, ], }, ]; } function parseReleasePages(output) { const parsed = JSON.parse(output); if (!Array.isArray(parsed)) throw new Error("Expected a JSON array from gh api."); if (parsed.every(Array.isArray)) return parsed.flat(); if (parsed.every((release) => !Array.isArray(release))) return parsed; throw new Error("Expected gh api pagination output to contain release arrays."); } function defaultGh(args) { return execFileSync("gh", args, { encoding: "utf8", maxBuffer: MAX_BUFFER, }).trim(); } function loadReleases(gh) { return parseReleasePages(gh([ "api", "--paginate", "--slurp", `repos/${REPO}/releases?per_page=100`, ])); } function loadLatestRelease(gh) { return JSON.parse(gh(["api", `repos/${REPO}/releases/latest`])); } function nextPatch(tag) { const [major, minor, patch] = parseSemver(tag); return `${major}.${minor}.${patch + 1}`; } function printGuidance(badTag, targetTag) { const badVersion = badTag.slice(1); const nextVersion = nextPatch(badTag); heading("Next steps — reissue from last-good code (not automated)"); log("Updaters never downgrade. Reissue the last-good code at a version higher than the bad release."); log("Versions live in tags only — tag the last-good commit with the higher version (no commits needed):"); console.log(""); console.log(` git tag v${nextVersion} ${targetTag} # or any version higher than ${badTag}`); console.log(` git push origin v${nextVersion} # admins only; triggers Release App`); console.log(""); log(`After npm publish: npm deprecate openwork-server@${badVersion} "rolled back — use ${nextVersion}"`); log("The org install door follows published GitHub releases at runtime: demoting the bad release to"); log("prerelease removes it from den-api's published list; the reissue becomes latest once published."); } export function runRollback(args, dependencies = {}) { const gh = dependencies.gh ?? defaultGh; const now = dependencies.now ?? new Date(); const options = parseArgs(args); const releases = loadReleases(gh); const latest = loadLatestRelease(gh); const badTag = options.bad ?? releaseTag(latest); const { bad, target } = resolveRollback(releases, badTag, options.to); const targetTag = releaseTag(target); const plan = buildRollbackPlan({ bad, target, latestTag: releaseTag(latest), keepBadListed: options.keepBadListed, now, }); console.log("Release rollback"); log(`Repository: ${REPO}`); log(`Bad release: ${badTag}`); log(`Last good: ${targetTag}`); log(`Mode: ${options.execute ? "EXECUTE" : "DRY RUN"}`); if (!hasUpdaterManifest(target)) { console.error(`\n ⚠ WARNING: ${targetTag} is missing updater manifests (latest*.yml or latest.json).`); console.error(" ⚠ Re-pointing Latest may leave desktop auto-update downloads unavailable."); } heading("Stop-the-bleed plan"); for (let index = 0; index < plan.length; index += 1) { const step = plan[index]; log(`${index + 1}. ${step.name}${step.skip ? ` — skip: ${step.skipReason}` : ""}`); } if (options.execute) { heading("Executing rollback"); for (let index = 0; index < plan.length; index += 1) { const step = plan[index]; if (step.skip) { log(`${index + 1}. Skipped — ${step.skipReason}.`); continue; } log(`${index + 1}. ${step.name}`); gh(step.args); log(`✓ Step ${index + 1} complete`); } } printGuidance(badTag, targetTag); if (options.execute) { console.log("\n ✓ Stop-the-bleed rollback complete. Reissue is still required for already-updated clients."); } else { console.log(`\nDRY RUN — no changes made. Re-run with --bad ${badTag} --execute.`); } return { bad, target, plan, execute: options.execute }; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { runRollback(process.argv.slice(2)); } catch (error) { console.error(` ✗ ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } }