refactor(release): extract dotenv/snapshot/workflow shared lib
This commit is contained in:
1 parent
466dac117d
commit
d2078446b4
4 files changed
+257
No files matched your search
@@ -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, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user