diff --git a/scripts/release-boss.mjs b/scripts/release-boss.mjs index 5c25baea..4d147d2f 100644 --- a/scripts/release-boss.mjs +++ b/scripts/release-boss.mjs @@ -1,9 +1,11 @@ -import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtemp, readFile, mkdir, cp, readdir } from "node:fs/promises"; -import os from "node:os"; +import { readFile, mkdir, cp, readdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { loadEnvFile } from "./release/lib/dotenv.mjs"; +import { createRunner } from "./release/lib/process.mjs"; +import { createWorkflowHelpers } from "./release/lib/workflow.mjs"; +import { createSnapshotHelpers } from "./release/lib/snapshot.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, ".."); @@ -42,6 +44,22 @@ const bossSnapshotIncludes = [ "boss-build/electron-builder-boss.yml", ]; +const run = createRunner(rootDir); +const { + getGitHubHttpAuthHeader, + getRepoInfo, + assertRemoteWorkflowExists, + waitForWorkflowRun, + waitForWorkflowCompletion, + downloadRemoteAssets, + cleanupRemoteTransportRelease, +} = createWorkflowHelpers(run); +const { + createSnapshotBranch, + cleanupRemoteBranch, + cleanupLocalWorktree, +} = createSnapshotHelpers(run, getGitHubHttpAuthHeader); + function parseArgs(argv) { const options = { version: null, @@ -83,89 +101,6 @@ Options: `); } -function run(command, args, options = {}) { - const { - cwd = rootDir, - env = process.env, - input = null, - capture = false, - } = options; - - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env, - stdio: capture ? ["pipe", "pipe", "pipe"] : ["pipe", "inherit", "inherit"], - shell: process.platform === "win32", - }); - - let stdout = ""; - let stderr = ""; - if (capture) { - child.stdout.on("data", (chunk) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - } - - if (input) { - child.stdin.write(input); - } - child.stdin.end(); - - child.on("error", reject); - child.on("close", (code) => { - if (code === 0) { - resolve({ stdout, stderr }); - return; - } - reject(new Error(stderr || stdout || `${command} ${args.join(" ")} failed with exit code ${code ?? 1}`)); - }); - }); -} - -async function ghJson(args) { - const { stdout } = await run("gh", args, { capture: true }); - return JSON.parse(stdout); -} - -async function getGitHubHttpAuthHeader() { - const tokenResult = await run("gh", ["auth", "token"], { capture: true }); - const token = tokenResult.stdout.trim(); - if (!token) { - throw new Error("Unable to read a GitHub token from gh auth."); - } - return Buffer.from(`x-access-token:${token}`).toString("base64"); -} - -async function getRepoInfo() { - const info = await ghJson(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"]); - const defaultBranch = info.defaultBranchRef?.name; - if (!info.nameWithOwner || !defaultBranch) { - throw new Error("Unable to resolve GitHub repository name/default branch from gh."); - } - return { - nameWithOwner: info.nameWithOwner, - defaultBranch, - }; -} - -async function assertRemoteBossWorkflowExists(repo) { - try { - await run("gh", [ - "api", - `repos/${repo.nameWithOwner}/contents/${remoteBossWorkflowFile}?ref=${repo.defaultBranch}`, - ], { capture: true }); - } catch { - throw new Error( - `Remote workflow ${remoteBossWorkflowFile} was not found on ${repo.defaultBranch}. ` + - "Commit and push this Boss automation workflow before using scripts/release-boss.mjs.", - ); - } -} - async function readPackageJson() { return JSON.parse(await readFile(packageJsonPath, "utf8")); } @@ -188,41 +123,17 @@ async function ensureRemoteBuildPrerequisites(repo) { if (!existsSync(join(rootDir, remoteBossWorkflowFile))) { throw new Error(`Required workflow file missing locally: ${remoteBossWorkflowFile}`); } - await assertRemoteBossWorkflowExists(repo); + await assertRemoteWorkflowExists( + repo, + remoteBossWorkflowFile, + `Remote workflow ${remoteBossWorkflowFile} was not found on ${repo.defaultBranch}. ` + + "Commit and push this Boss automation workflow before using scripts/release-boss.mjs.", + ); await run("gh", ["auth", "status"], { capture: true }); } -async function createSnapshotBranch(repo, branchName, version) { - const tempDir = await mkdtemp(join(os.tmpdir(), "agentcompany-release-")); - const auth = await getGitHubHttpAuthHeader(); - await run("git", [ - "-c", - `http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}`, - "fetch", - `https://github.com/${repo.nameWithOwner}.git`, - repo.defaultBranch, - ]); - await run("git", ["worktree", "add", "--detach", tempDir, "FETCH_HEAD"]); - await run("git", ["-C", tempDir, "checkout", "-b", branchName]); - - const rsyncArgs = ["-a", "--delete"]; - for (const pattern of syncExcludes) { - rsyncArgs.push("--exclude", pattern); - } - rsyncArgs.push(`${rootDir}/`, `${tempDir}/`); - await run("rsync", rsyncArgs); - - await run("git", ["-C", tempDir, "add", "-A"]); - await forceStageBossSnapshotFiles(tempDir); - const diffStatus = await run("git", ["-C", tempDir, "diff", "--cached", "--quiet"], { capture: true }) - .then(() => false) - .catch(() => true); - - if (!diffStatus) { - throw new Error("Snapshot branch has no staged changes after syncing the workspace."); - } - - const commitMessage = [ +function buildSnapshotCommitMessage(version) { + return [ `Package Boss build ${version} from the current workspace snapshot`, "", "Constraint: Boss Windows artifacts are built on a native GitHub runner from a temporary snapshot branch", @@ -233,47 +144,6 @@ async function createSnapshotBranch(repo, branchName, version) { "Tested: local Boss mac build stages before snapshot creation", "Not-tested: remote Boss Windows packaging before workflow execution", ].join("\n"); - await run("git", ["-C", tempDir, "commit", "-m", commitMessage]); - - await run( - "git", - [ - "-C", - tempDir, - "-c", - `http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}`, - "push", - `https://github.com/${repo.nameWithOwner}.git`, - branchName, - ], - ); - - return tempDir; -} - -async function waitForWorkflowRun(repo, workflowName, branchName, timeoutMs) { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const runs = await ghJson([ - "run", - "list", - "--repo", - repo.nameWithOwner, - "--workflow", - workflowName, - "--branch", - branchName, - "--limit", - "1", - "--json", - "databaseId,status,conclusion,url,displayTitle", - ]); - if (runs.length > 0) { - return runs[0]; - } - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - throw new Error(`Timed out waiting for workflow ${workflowName} on branch ${branchName}.`); } async function dispatchRemoteBossWorkflow(repo, branchName, version, releaseTag, releaseTitle) { @@ -294,40 +164,6 @@ async function dispatchRemoteBossWorkflow(repo, branchName, version, releaseTag, ]); } -async function waitForWorkflowCompletion(repo, runId, timeoutMs) { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const runInfo = await ghJson([ - "run", - "view", - String(runId), - "--repo", - repo.nameWithOwner, - "--json", - "status,conclusion,url", - ]); - if (runInfo.status === "completed") { - return runInfo; - } - await new Promise((resolve) => setTimeout(resolve, 15000)); - } - throw new Error(`Timed out waiting for workflow run ${runId} to complete.`); -} - -async function downloadRemoteAssets(repo, releaseTag) { - const downloadRoot = await mkdtemp(join(os.tmpdir(), "agentcompany-transport-")); - await run("gh", [ - "release", - "download", - releaseTag, - "--repo", - repo.nameWithOwner, - "-D", - downloadRoot, - ]); - return downloadRoot; -} - async function stageBossRemoteAssets(downloadRoot) { await mkdir(bossOutDir, { recursive: true }); const files = await readdir(downloadRoot); @@ -342,21 +178,11 @@ async function buildBossMac(version) { await run("node", ["boss-build/build-boss-edition.mjs", `--platform=mac`, `--version=${version}`]); } -async function cleanupRemoteTransportRelease(repo, releaseTag) { - await run("gh", ["release", "delete", releaseTag, "--repo", repo.nameWithOwner, "--yes"]); -} - -async function cleanupRemoteBranch(repo, branchName) { - await run("gh", ["api", "-X", "DELETE", `repos/${repo.nameWithOwner}/git/refs/heads/${branchName}`]); -} - -async function cleanupLocalWorktree(tempDir) { - await run("git", ["worktree", "remove", "--force", tempDir]); - await run("git", ["worktree", "prune"]); -} - async function main() { const options = parseArgs(process.argv.slice(2)); + await loadEnvFile(join(rootDir, ".env.release.local")); + await loadEnvFile(join(rootDir, ".env.release")); + const repo = await getRepoInfo(); await ensureRemoteBuildPrerequisites(repo); @@ -371,7 +197,14 @@ async function main() { const branchName = `boss-build-${nextVersion.replace(/\./g, "-")}-${timestamp}`; const releaseTitle = `Boss Release ${nextVersion}`; - const tempDir = await createSnapshotBranch(repo, branchName, nextVersion); + const tempDir = await createSnapshotBranch({ + repo, + branchName, + rootDir, + syncExcludes, + commitMessage: buildSnapshotCommitMessage(nextVersion), + afterRsync: forceStageBossSnapshotFiles, + }); let cleanupPending = true; try {