diff --git a/scripts/release/lib/dotenv.mjs b/scripts/release/lib/dotenv.mjs new file mode 100644 index 00000000..138ecb16 --- /dev/null +++ b/scripts/release/lib/dotenv.mjs @@ -0,0 +1,18 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; + +export async function loadEnvFile(filePath) { + if (!existsSync(filePath)) return; + const content = await readFile(filePath, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) continue; + const key = line.slice(0, separatorIndex).trim(); + if (!key || process.env[key]) continue; + const rawValue = line.slice(separatorIndex + 1).trim(); + if (!rawValue) continue; + process.env[key] = rawValue.replace(/^['"]|['"]$/g, ""); + } +} diff --git a/scripts/release/lib/process.mjs b/scripts/release/lib/process.mjs new file mode 100644 index 00000000..48e93dff --- /dev/null +++ b/scripts/release/lib/process.mjs @@ -0,0 +1,46 @@ +import { spawn } from "node:child_process"; + +export function createRunner(rootDir) { + return 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}`)); + }); + }); + }; +} diff --git a/scripts/release/lib/snapshot.mjs b/scripts/release/lib/snapshot.mjs new file mode 100644 index 00000000..0024d7b9 --- /dev/null +++ b/scripts/release/lib/snapshot.mjs @@ -0,0 +1,77 @@ +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import { join } from "node:path"; + +export function createSnapshotHelpers(run, getGitHubHttpAuthHeader) { + async function createSnapshotBranch({ + repo, + branchName, + rootDir, + syncExcludes, + commitMessage, + afterRsync, + }) { + 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"]); + if (afterRsync) { + await afterRsync(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."); + } + + 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 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"]); + } + + return { + createSnapshotBranch, + cleanupRemoteBranch, + cleanupLocalWorktree, + }; +} diff --git a/scripts/release/lib/workflow.mjs b/scripts/release/lib/workflow.mjs new file mode 100644 index 00000000..060addd7 --- /dev/null +++ b/scripts/release/lib/workflow.mjs @@ -0,0 +1,116 @@ +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import { join } from "node:path"; + +export function createWorkflowHelpers(run) { + 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 assertRemoteWorkflowExists(repo, workflowFile, errorMessage) { + try { + await run("gh", [ + "api", + `repos/${repo.nameWithOwner}/contents/${workflowFile}?ref=${repo.defaultBranch}`, + ], { capture: true }); + } catch { + throw new Error(errorMessage); + } + } + + 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 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 cleanupRemoteTransportRelease(repo, releaseTag) { + await run("gh", ["release", "delete", releaseTag, "--repo", repo.nameWithOwner, "--yes"]); + } + + return { + ghJson, + getGitHubHttpAuthHeader, + getRepoInfo, + assertRemoteWorkflowExists, + waitForWorkflowRun, + waitForWorkflowCompletion, + downloadRemoteAssets, + cleanupRemoteTransportRelease, + }; +}