Files

302 lines
9.8 KiB
JavaScript

#!/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);
}
}