diff --git a/.github/workflows/remote-boss-platforms.yml b/.github/workflows/remote-boss-platforms.yml new file mode 100644 index 00000000..b5966c57 --- /dev/null +++ b/.github/workflows/remote-boss-platforms.yml @@ -0,0 +1,75 @@ +name: Remote Boss Platforms + +on: + workflow_dispatch: + inputs: + version: + description: Boss build version + required: true + type: string + release_tag: + description: Draft GitHub release tag used as the Boss transport bucket + required: true + type: string + release_title: + description: Human-readable title for the draft release + required: false + type: string + +concurrency: + group: remote-boss-${{ github.ref }}-${{ inputs.release_tag }} + cancel-in-progress: false + +env: + PNPM_VERSION: "10.24.0" + NODE_VERSION: "22" + +jobs: + build-windows: + name: Boss Windows Installer + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build Boss edition + run: node boss-build/build-boss-edition.mjs --platform=win --version=${{ inputs.version }} + shell: bash + env: + CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }} + - name: Upload release files + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + run: | + set -euo pipefail + gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \ + gh release create "$RELEASE_TAG" \ + --draft \ + --prerelease \ + --title "${RELEASE_TITLE:-Boss Release $GITHUB_RUN_ID}" \ + --notes "Temporary Boss transport generated by remote-boss-platforms.yml." \ + --target "$GITHUB_SHA" + + shopt -s nullglob + files=( + boss-build/build/out/*.exe + boss-build/build/out/*.blockmap + ) + if [ ${#files[@]} -eq 0 ]; then + echo "No Boss Windows release files found." + exit 1 + fi + + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber diff --git a/build/electron-builder.runtime.json b/build/electron-builder.runtime.json index 4f12ac4a..ecbe3d5f 100644 --- a/build/electron-builder.runtime.json +++ b/build/electron-builder.runtime.json @@ -4,18 +4,16 @@ "output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release" }, "extraMetadata": { - "version": "0.4.151" + "version": "0.4.155" }, + "publish": [ + { + "provider": "generic", + "url": "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC" + } + ], "mac": { - "hardenedRuntime": false, - "identity": null, - "target": [ - { - "target": "zip", - "arch": [ - "arm64" - ] - } - ] + "hardenedRuntime": true, + "sign": "/Users/zacharyzhang/Documents/Github/AgentCompany/scripts/electron-builder-mac-sign.mjs" } } diff --git a/landing/components/release-links.generated.ts b/landing/components/release-links.generated.ts index 6a273c61..7491b2b1 100644 --- a/landing/components/release-links.generated.ts +++ b/landing/components/release-links.generated.ts @@ -12,7 +12,7 @@ export type PlatformDownload = { assets: PlatformDownloadAsset[]; }; -export const releaseVersion = "0.4.151"; +export const releaseVersion = "0.4.155"; export const platformDownloads: PlatformDownload[] = [ { @@ -23,13 +23,13 @@ export const platformDownloads: PlatformDownload[] = [ { id: "macos-arm64-dmg", label: "Apple Silicon DMG", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.151-arm64.dmg", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.155-arm64.dmg", note: "arm64", }, { id: "macos-x64-dmg", label: "Intel Mac DMG", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.151.dmg", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.155.dmg", note: "x64", }, ], @@ -42,7 +42,7 @@ export const platformDownloads: PlatformDownload[] = [ { id: "windows-universal-installer", label: "Windows Universal Installer", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.151.exe", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.155.exe", note: "arm64 + x64 + ia32", }, ], @@ -55,37 +55,37 @@ export const platformDownloads: PlatformDownload[] = [ { id: "linux-x64-appimage", label: "Linux x64 AppImage", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.151.AppImage", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.155.AppImage", note: "x64", }, { id: "linux-x64-deb", label: "Linux x64 DEB", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.151_amd64.deb", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.155_amd64.deb", note: "x64", }, { id: "linux-x64-rpm", label: "Linux x64 RPM", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.151.x86_64.rpm", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.155.x86_64.rpm", note: "x64", }, { id: "linux-arm64-appimage", label: "Linux ARM64 AppImage", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.151-arm64.AppImage", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.155-arm64.AppImage", note: "arm64", }, { id: "linux-arm64-deb", label: "Linux ARM64 DEB", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.151_arm64.deb", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.155_arm64.deb", note: "arm64", }, { id: "linux-arm64-rpm", label: "Linux ARM64 RPM", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.151.aarch64.rpm", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.155.aarch64.rpm", note: "arm64", }, ], diff --git a/package.json b/package.json index d6397193..e25a0d9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.151", + "version": "0.4.155", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", @@ -17,6 +17,8 @@ "build:dir": "electron-vite build && node scripts/build-desktop.mjs --dir", "build:all": "node scripts/build-all-platforms.mjs", "release:cloudflare": "node scripts/release-cloudflare.mjs", + "release:cloudflare:public": "node scripts/release-public-cloudflare.mjs", + "release:boss": "node scripts/release-boss.mjs", "release:local": "node scripts/release-local.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/scripts/release-boss.mjs b/scripts/release-boss.mjs new file mode 100644 index 00000000..5c25baea --- /dev/null +++ b/scripts/release-boss.mjs @@ -0,0 +1,416 @@ +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 { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, ".."); +const packageJsonPath = join(rootDir, "package.json"); +const bossOutDir = join(rootDir, "boss-build", "build", "out"); +const remoteBossWorkflowFile = ".github/workflows/remote-boss-platforms.yml"; +const remoteBossWorkflowName = "remote-boss-platforms.yml"; +const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/; +const syncExcludes = [ + ".git", + "node_modules/", + ".pnpm-store/", + "build/", + "local-release/", + "out/", + ".omx/", + ".omc/", + ".claude/", + "landing/.next/", + "landing/out/", + "landing/node_modules/", + ".DS_Store", + ".env.release.local", + "CertificateSigningRequest.certSigningRequest", + "Certificates.p12", + "developerID_application.cer", +]; +const bossSnapshotIncludes = [ + remoteBossWorkflowFile, + "boss-build/boss-runtime.json", + "boss-build/build-boss-desktop.mjs", + "boss-build/build-boss-edition.mjs", + "boss-build/build-boss-mac.mjs", + "boss-build/build-boss.mjs", + "boss-build/build-boss.sh", + "boss-build/electron-builder-boss.yml", +]; + +function parseArgs(argv) { + const options = { + version: null, + deleteRemoteBranch: false, + keepTransportRelease: false, + }; + + for (const arg of argv) { + if (arg.startsWith("--version=")) { + options.version = arg.slice("--version=".length).trim(); + continue; + } + if (arg === "--delete-remote-branch") { + options.deleteRemoteBranch = true; + continue; + } + if (arg === "--keep-transport-release") { + options.keepTransportRelease = true; + continue; + } + if (arg === "--help") { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function printHelp() { + console.log(`Usage: node scripts/release-boss.mjs [options] + +Options: + --version=x.y.z Explicit version. Defaults to current package version. + --delete-remote-branch Delete the GitHub snapshot branch after success. + --keep-transport-release Keep the temporary draft GitHub release after success. + --help Show this help. +`); +} + +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")); +} + +async function forceStageBossSnapshotFiles(tempDir) { + const stagedPaths = bossSnapshotIncludes + .filter((relativePath) => existsSync(join(tempDir, relativePath))); + + if (stagedPaths.length === 0) { + throw new Error("Boss snapshot is missing required boss-build source files after syncing the workspace."); + } + + await run("git", ["-C", tempDir, "add", "--force", ...stagedPaths]); +} + +async function ensureRemoteBuildPrerequisites(repo) { + if (process.platform !== "darwin") { + throw new Error("Boss release currently expects to run on macOS for the local Boss DMG build."); + } + if (!existsSync(join(rootDir, remoteBossWorkflowFile))) { + throw new Error(`Required workflow file missing locally: ${remoteBossWorkflowFile}`); + } + await assertRemoteBossWorkflowExists(repo); + 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 = [ + `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", + "Rejected: Build Boss Windows locally on Apple Silicon | docker/wine packaging is not stable on this host", + "Confidence: medium", + "Scope-risk: narrow", + "Directive: This snapshot branch is transport-only and should be deleted after the Boss build is recovered locally", + "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) { + await run("gh", [ + "workflow", + "run", + remoteBossWorkflowName, + "--repo", + repo.nameWithOwner, + "--ref", + branchName, + "-f", + `version=${version}`, + "-f", + `release_tag=${releaseTag}`, + "-f", + `release_title=${releaseTitle}`, + ]); +} + +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); + for (const fileName of files) { + if (fileName.endsWith(".exe") || fileName.endsWith(".blockmap")) { + await cp(join(downloadRoot, fileName), join(bossOutDir, fileName)); + } + } +} + +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)); + const repo = await getRepoInfo(); + await ensureRemoteBuildPrerequisites(repo); + + const currentVersion = (await readPackageJson()).version; + const nextVersion = options.version ?? currentVersion; + if (!versionPattern.test(nextVersion)) { + throw new Error(`Invalid version: ${nextVersion}`); + } + + const timestamp = Date.now(); + const releaseTag = `boss-transport-${nextVersion}-${timestamp}`; + const branchName = `boss-build-${nextVersion.replace(/\./g, "-")}-${timestamp}`; + const releaseTitle = `Boss Release ${nextVersion}`; + + const tempDir = await createSnapshotBranch(repo, branchName, nextVersion); + let cleanupPending = true; + + try { + await dispatchRemoteBossWorkflow(repo, branchName, nextVersion, releaseTag, releaseTitle); + await buildBossMac(nextVersion); + const runSummary = await waitForWorkflowRun(repo, remoteBossWorkflowName, branchName, 60_000); + const runInfo = await waitForWorkflowCompletion(repo, runSummary.databaseId, 30 * 60 * 1000); + if (runInfo.conclusion !== "success") { + throw new Error(`Boss remote workflow failed: ${runInfo.url}`); + } + const downloadRoot = await downloadRemoteAssets(repo, releaseTag); + await mkdir(bossOutDir, { recursive: true }); + await stageBossRemoteAssets(downloadRoot); + + if (!options.keepTransportRelease) { + await cleanupRemoteTransportRelease(repo, releaseTag); + } + if (options.deleteRemoteBranch) { + await cleanupRemoteBranch(repo, branchName); + } + await cleanupLocalWorktree(tempDir); + cleanupPending = false; + + console.log(JSON.stringify({ + version: nextVersion, + remoteBranch: branchName, + remoteWorkflowRun: runInfo.url, + bossOutDir, + releaseTag, + }, null, 2)); + } finally { + if (cleanupPending) { + try { + await cleanupLocalWorktree(tempDir); + } catch { + // no-op during best-effort temp cleanup + } + } + } +} + +await main(); diff --git a/scripts/release-cloudflare.mjs b/scripts/release-cloudflare.mjs index 6ffaed6a..974c7b24 100644 --- a/scripts/release-cloudflare.mjs +++ b/scripts/release-cloudflare.mjs @@ -1,550 +1,78 @@ import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { mkdtemp, readFile, rm, writeFile, mkdir, cp, readdir } from "node:fs/promises"; -import os from "node:os"; +import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, ".."); const packageJsonPath = join(rootDir, "package.json"); -const landingDir = join(rootDir, "landing"); -const localReleaseDir = join(rootDir, "local-release"); -const remoteWorkflowFile = ".github/workflows/remote-release-platforms.yml"; -const remoteWorkflowName = "remote-release-platforms.yml"; -const pagesProjectName = "agentcompany"; -const pagesBranch = "main"; -const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/; -const syncExcludes = [ - ".git", - ".github/workflows/", - "node_modules/", - ".pnpm-store/", - "build/", - "local-release/", - "out/", - ".omx/", - ".omc/", - ".claude/", - "landing/.next/", - "landing/out/", - "landing/node_modules/", - ".DS_Store", - ".env.release.local", - "CertificateSigningRequest.certSigningRequest", - "Certificates.p12", - "developerID_application.cer", -]; +const rawArgs = process.argv.slice(2); function parseArgs(argv) { const options = { version: null, - skipVerify: false, - deleteRemoteBranch: false, - keepTransportRelease: false, + passThroughArgs: [], + bossArgs: [], }; for (const arg of argv) { + options.passThroughArgs.push(arg); if (arg.startsWith("--version=")) { options.version = arg.slice("--version=".length).trim(); continue; } - if (arg === "--skip-verify") { - options.skipVerify = true; - continue; - } - if (arg === "--delete-remote-branch") { - options.deleteRemoteBranch = true; - continue; - } - if (arg === "--keep-transport-release") { - options.keepTransportRelease = true; + if (arg === "--delete-remote-branch" || arg === "--keep-transport-release") { + options.bossArgs.push(arg); continue; } if (arg === "--help") { - printHelp(); - process.exit(0); + options.bossArgs.push(arg); } - throw new Error(`Unknown argument: ${arg}`); } return options; } -function printHelp() { - console.log(`Usage: node scripts/release-cloudflare.mjs [options] - -Options: - --version=x.y.z Explicit version. Defaults to current patch + 1. - --skip-verify Skip local pnpm verify before packaging. - --delete-remote-branch Delete the GitHub snapshot branch after success. - --keep-transport-release Keep the temporary draft GitHub release after success. - --help Show this help. -`); -} - -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, ""); - } -} - -function run(command, args, options = {}) { - const { - cwd = rootDir, - env = process.env, - input = null, - capture = false, - } = options; - - return new Promise((resolve, reject) => { +async function run(command, args) { + await new Promise((resolve, reject) => { const child = spawn(command, args, { - cwd, - env, - stdio: capture ? ["pipe", "pipe", "pipe"] : ["pipe", "inherit", "inherit"], + cwd: rootDir, + stdio: "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 }); + resolve(); return; } - reject(new Error(stderr || stdout || `${command} ${args.join(" ")} failed with exit code ${code ?? 1}`)); + reject(new Error(`${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 assertRemoteWorkflowExists(repo) { - try { - await run("gh", [ - "api", - `repos/${repo.nameWithOwner}/contents/${remoteWorkflowFile}?ref=${repo.defaultBranch}`, - ], { capture: true }); - } catch { - throw new Error( - `Remote workflow ${remoteWorkflowFile} was not found on ${repo.defaultBranch}. ` + - "Commit and push this automation workflow before using scripts/release-cloudflare.mjs.", - ); - } -} - -async function readPackageJson() { - return JSON.parse(await readFile(packageJsonPath, "utf8")); -} - -function bumpPatchVersion(version) { - const match = versionPattern.exec(version); - if (!match) { - throw new Error(`Unsupported package version format: ${version}`); - } - const [, major, minor, patch] = match; - return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; -} - -async function updatePackageVersion(nextVersion) { - const pkg = await readPackageJson(); - pkg.version = nextVersion; - await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); -} - -async function ensureRemoteBuildPrerequisites(repo) { - if (process.platform !== "darwin") { - throw new Error("This release script currently expects to run on macOS for the signed macOS build."); - } - if (!existsSync(join(rootDir, remoteWorkflowFile))) { - throw new Error(`Required workflow file missing locally: ${remoteWorkflowFile}`); - } - await assertRemoteWorkflowExists(repo); - await run("gh", ["auth", "status"], { capture: true }); - await run("npx", ["wrangler", "whoami", "--json"], { capture: true }); -} - -async function resetReleaseStaging() { - await rm(localReleaseDir, { recursive: true, force: true }); - await rm(join(rootDir, "build", "release"), { recursive: true, force: true }); -} - -async function buildMacAndUpload() { - await run("node", ["scripts/release-local.mjs", "--platforms=mac", "--skip-verify"]); -} - -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"]); - 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 = [ - `Package release ${version} from the current workspace snapshot`, - "", - "Constraint: Windows and Linux artifacts are built on native GitHub runners from a temporary snapshot branch", - "Rejected: Build Windows/Linux locally on Apple Silicon | docker/wine packaging is not stable on this host", - "Confidence: medium", - "Scope-risk: moderate", - "Directive: This snapshot branch is transport-only and should be deleted after the release completes", - "Tested: local verify/build stages before snapshot creation", - "Not-tested: remote 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, branchName, timeoutMs) { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const runs = await ghJson([ - "run", - "list", - "--repo", - repo.nameWithOwner, - "--workflow", - remoteWorkflowName, - "--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 ${remoteWorkflowName} on branch ${branchName}.`); -} - -async function dispatchRemoteWorkflow(repo, branchName, releaseTag, releaseTitle) { - await run("gh", [ - "workflow", - "run", - remoteWorkflowName, - "--repo", - repo.nameWithOwner, - "--ref", - branchName, - "-f", - `release_tag=${releaseTag}`, - "-f", - `release_title=${releaseTitle}`, - ]); -} - -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 stageRemoteAssets(downloadRoot) { - const winDir = join(localReleaseDir, "win"); - const linuxDir = join(localReleaseDir, "linux"); - await rm(winDir, { recursive: true, force: true }); - await rm(linuxDir, { recursive: true, force: true }); - await mkdir(winDir, { recursive: true }); - await mkdir(linuxDir, { recursive: true }); - - const files = await readdir(downloadRoot); - for (const fileName of files) { - const sourcePath = join(downloadRoot, fileName); - if (fileName.endsWith(".exe") || fileName.endsWith(".blockmap") || fileName === "latest.yml") { - await cp(sourcePath, join(winDir, fileName)); - continue; - } - if ( - fileName.endsWith(".AppImage") || - fileName.endsWith(".deb") || - fileName.endsWith(".rpm") || - fileName === "latest-linux.yml" - ) { - await cp(sourcePath, join(linuxDir, fileName)); - } - } -} - -async function uploadWinAndLinuxToR2() { - await loadEnvFile(join(rootDir, ".env.release.local")); - await loadEnvFile(join(rootDir, ".env.release")); - const r2 = await prepareR2ReleaseHost({ rootDir }); - await uploadReleaseFiles({ - rootDir, - releaseDir: join(localReleaseDir, "win"), - bucket: r2.bucket, - releasePrefix: r2.releasePrefix, - }); - await uploadReleaseFiles({ - rootDir, - releaseDir: join(localReleaseDir, "linux"), - bucket: r2.bucket, - releasePrefix: r2.releasePrefix, - }); -} - -async function applyLandingMigrations() { - const migrationsDir = join(landingDir, "migrations"); - if (!existsSync(migrationsDir)) { - return; - } - - const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8"); - const match = wranglerToml.match(/database_name\s*=\s*"([^"]+)"/); - if (!match) { - return; - } - const databaseName = match[1]; - const migrationFiles = (await readdir(migrationsDir)) - .filter((fileName) => fileName.endsWith(".sql")) - .sort(); - - for (const fileName of migrationFiles) { - const command = `printf 'y\\n' | npx wrangler d1 execute ${databaseName} --remote --file migrations/${fileName}`; - await run("bash", ["-lc", command], { cwd: landingDir }); - } -} - -async function buildAndDeployLanding() { - if (!existsSync(join(landingDir, "node_modules"))) { - await run("npm", ["ci"], { cwd: landingDir }); - } - await run("npm", ["run", "build"], { cwd: landingDir }); - const deploy = await run( - "npx", - ["wrangler", "pages", "deploy", "out", "--project-name", pagesProjectName, "--branch", pagesBranch, "--commit-dirty=true"], - { cwd: landingDir, capture: true }, - ); - const output = `${deploy.stdout}\n${deploy.stderr}`; - const urlMatch = output.match(/https:\/\/[^\s]+\.agentcompany\.pages\.dev/); - return urlMatch?.[0] ?? null; -} - -async function verifyPublishedAssets(version, deployUrl) { - const releaseLinksSource = await readFile(join(landingDir, "components", "release-links.generated.ts"), "utf8"); - const requiredFragments = [ - `AgentCompany-0.4.150-arm64.dmg`.replace("0.4.150", version), - `AgentCompany.Setup.${version}.exe`, - `agent-company-${version}.aarch64.rpm`, - `agent-company_${version}_arm64.deb`, - ]; - for (const fragment of requiredFragments) { - if (!releaseLinksSource.includes(fragment)) { - throw new Error(`Missing expected release asset in generated links: ${fragment}`); - } - } - - if (deployUrl) { - const { stdout } = await run("curl", ["-L", "--silent", "-A", "Mozilla/5.0", deployUrl], { capture: true }); - if (!stdout.includes(version)) { - throw new Error(`Deployed landing preview ${deployUrl} does not contain 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 readCurrentVersion() { + const pkg = JSON.parse(await readFile(packageJsonPath, "utf8")); + return pkg.version; } async function main() { - const options = parseArgs(process.argv.slice(2)); - await loadEnvFile(join(rootDir, ".env.release.local")); - await loadEnvFile(join(rootDir, ".env.release")); + const options = parseArgs(rawArgs); - const repo = await getRepoInfo(); - await ensureRemoteBuildPrerequisites(repo); - - const currentVersion = (await readPackageJson()).version; - const nextVersion = options.version ?? bumpPatchVersion(currentVersion); - if (!versionPattern.test(nextVersion)) { - throw new Error(`Invalid version: ${nextVersion}`); + if (rawArgs.includes("--help")) { + console.log("Public release + Boss release wrapper."); + console.log("Runs scripts/release-public-cloudflare.mjs, then scripts/release-boss.mjs using the final package version."); + console.log(""); } - await updatePackageVersion(nextVersion); - await resetReleaseStaging(); + await run("node", ["scripts/release-public-cloudflare.mjs", ...options.passThroughArgs]); - if (!options.skipVerify) { - await run("pnpm", ["verify"]); - } - - await buildMacAndUpload(); - - const timestamp = Date.now(); - const releaseTag = `transport-${nextVersion}-${timestamp}`; - const branchName = `release-build-${nextVersion.replace(/\./g, "-")}-${timestamp}`; - const releaseTitle = `Remote Release ${nextVersion}`; - - const tempDir = await createSnapshotBranch(repo, branchName, nextVersion); - let cleanupPending = true; - - try { - await dispatchRemoteWorkflow(repo, branchName, releaseTag, releaseTitle); - const runSummary = await waitForWorkflowRun(repo, branchName, 60_000); - const runInfo = await waitForWorkflowCompletion(repo, runSummary.databaseId, 45 * 60 * 1000); - if (runInfo.conclusion !== "success") { - throw new Error(`Remote workflow failed: ${runInfo.url}`); - } - - const downloadRoot = await downloadRemoteAssets(repo, releaseTag); - await stageRemoteAssets(downloadRoot); - await uploadWinAndLinuxToR2(); - await run("node", ["scripts/generate-landing-release-links.mjs"]); - await applyLandingMigrations(); - const deployUrl = await buildAndDeployLanding(); - await verifyPublishedAssets(nextVersion, deployUrl); - - if (!options.keepTransportRelease) { - await cleanupRemoteTransportRelease(repo, releaseTag); - } - if (options.deleteRemoteBranch) { - await cleanupRemoteBranch(repo, branchName); - } - await cleanupLocalWorktree(tempDir); - cleanupPending = false; - - console.log(JSON.stringify({ - version: nextVersion, - remoteBranch: branchName, - remoteWorkflowRun: runInfo.url, - releaseTag, - deployUrl, - updateUrl: process.env.AGENTCOMPANY_UPDATE_URL ?? null, - }, null, 2)); - } finally { - if (cleanupPending) { - try { - await cleanupLocalWorktree(tempDir); - } catch { - // no-op during best-effort temp cleanup - } - } - } + const releasedVersion = options.version ?? await readCurrentVersion(); + await run("node", [ + "scripts/release-boss.mjs", + `--version=${releasedVersion}`, + ...options.bossArgs, + ]); } await main(); diff --git a/scripts/release-public-cloudflare.mjs b/scripts/release-public-cloudflare.mjs new file mode 100644 index 00000000..f4f3260a --- /dev/null +++ b/scripts/release-public-cloudflare.mjs @@ -0,0 +1,549 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile, mkdir, cp, readdir } from "node:fs/promises"; +import os from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, ".."); +const packageJsonPath = join(rootDir, "package.json"); +const landingDir = join(rootDir, "landing"); +const localReleaseDir = join(rootDir, "local-release"); +const remoteWorkflowFile = ".github/workflows/remote-release-platforms.yml"; +const remoteWorkflowName = "remote-release-platforms.yml"; +const pagesProjectName = "agentcompany"; +const pagesBranch = "main"; +const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/; +const syncExcludes = [ + ".git", + "node_modules/", + ".pnpm-store/", + "build/", + "local-release/", + "out/", + ".omx/", + ".omc/", + ".claude/", + "landing/.next/", + "landing/out/", + "landing/node_modules/", + ".DS_Store", + ".env.release.local", + "CertificateSigningRequest.certSigningRequest", + "Certificates.p12", + "developerID_application.cer", +]; + +function parseArgs(argv) { + const options = { + version: null, + skipVerify: false, + deleteRemoteBranch: false, + keepTransportRelease: false, + }; + + for (const arg of argv) { + if (arg.startsWith("--version=")) { + options.version = arg.slice("--version=".length).trim(); + continue; + } + if (arg === "--skip-verify") { + options.skipVerify = true; + continue; + } + if (arg === "--delete-remote-branch") { + options.deleteRemoteBranch = true; + continue; + } + if (arg === "--keep-transport-release") { + options.keepTransportRelease = true; + continue; + } + if (arg === "--help") { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function printHelp() { + console.log(`Usage: node scripts/release-cloudflare.mjs [options] + +Options: + --version=x.y.z Explicit version. Defaults to current patch + 1. + --skip-verify Skip local pnpm verify before packaging. + --delete-remote-branch Delete the GitHub snapshot branch after success. + --keep-transport-release Keep the temporary draft GitHub release after success. + --help Show this help. +`); +} + +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, ""); + } +} + +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 assertRemoteWorkflowExists(repo) { + try { + await run("gh", [ + "api", + `repos/${repo.nameWithOwner}/contents/${remoteWorkflowFile}?ref=${repo.defaultBranch}`, + ], { capture: true }); + } catch { + throw new Error( + `Remote workflow ${remoteWorkflowFile} was not found on ${repo.defaultBranch}. ` + + "Commit and push this automation workflow before using scripts/release-cloudflare.mjs.", + ); + } +} + +async function readPackageJson() { + return JSON.parse(await readFile(packageJsonPath, "utf8")); +} + +function bumpPatchVersion(version) { + const match = versionPattern.exec(version); + if (!match) { + throw new Error(`Unsupported package version format: ${version}`); + } + const [, major, minor, patch] = match; + return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; +} + +async function updatePackageVersion(nextVersion) { + const pkg = await readPackageJson(); + pkg.version = nextVersion; + await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); +} + +async function ensureRemoteBuildPrerequisites(repo) { + if (process.platform !== "darwin") { + throw new Error("This release script currently expects to run on macOS for the signed macOS build."); + } + if (!existsSync(join(rootDir, remoteWorkflowFile))) { + throw new Error(`Required workflow file missing locally: ${remoteWorkflowFile}`); + } + await assertRemoteWorkflowExists(repo); + await run("gh", ["auth", "status"], { capture: true }); + await run("npx", ["wrangler", "whoami", "--json"], { capture: true }); +} + +async function resetReleaseStaging() { + await rm(localReleaseDir, { recursive: true, force: true }); + await rm(join(rootDir, "build", "release"), { recursive: true, force: true }); +} + +async function buildMacAndUpload() { + await run("node", ["scripts/release-local.mjs", "--platforms=mac", "--skip-verify"]); +} + +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"]); + 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 = [ + `Package release ${version} from the current workspace snapshot`, + "", + "Constraint: Windows and Linux artifacts are built on native GitHub runners from a temporary snapshot branch", + "Rejected: Build Windows/Linux locally on Apple Silicon | docker/wine packaging is not stable on this host", + "Confidence: medium", + "Scope-risk: moderate", + "Directive: This snapshot branch is transport-only and should be deleted after the release completes", + "Tested: local verify/build stages before snapshot creation", + "Not-tested: remote 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 ${remoteWorkflowName} on branch ${branchName}.`); +} + +async function dispatchRemoteWorkflow(repo, branchName, releaseTag, releaseTitle) { + await run("gh", [ + "workflow", + "run", + remoteWorkflowName, + "--repo", + repo.nameWithOwner, + "--ref", + branchName, + "-f", + `release_tag=${releaseTag}`, + "-f", + `release_title=${releaseTitle}`, + ]); +} + +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 stageRemoteAssets(downloadRoot) { + const winDir = join(localReleaseDir, "win"); + const linuxDir = join(localReleaseDir, "linux"); + await rm(winDir, { recursive: true, force: true }); + await rm(linuxDir, { recursive: true, force: true }); + await mkdir(winDir, { recursive: true }); + await mkdir(linuxDir, { recursive: true }); + + const files = await readdir(downloadRoot); + for (const fileName of files) { + const sourcePath = join(downloadRoot, fileName); + if (fileName.endsWith(".exe") || fileName.endsWith(".blockmap") || fileName === "latest.yml") { + await cp(sourcePath, join(winDir, fileName)); + continue; + } + if ( + fileName.endsWith(".AppImage") || + fileName.endsWith(".deb") || + fileName.endsWith(".rpm") || + fileName === "latest-linux.yml" + ) { + await cp(sourcePath, join(linuxDir, fileName)); + } + } +} + +async function uploadWinAndLinuxToR2() { + await loadEnvFile(join(rootDir, ".env.release.local")); + await loadEnvFile(join(rootDir, ".env.release")); + const r2 = await prepareR2ReleaseHost({ rootDir }); + await uploadReleaseFiles({ + rootDir, + releaseDir: join(localReleaseDir, "win"), + bucket: r2.bucket, + releasePrefix: r2.releasePrefix, + }); + await uploadReleaseFiles({ + rootDir, + releaseDir: join(localReleaseDir, "linux"), + bucket: r2.bucket, + releasePrefix: r2.releasePrefix, + }); +} + +async function applyLandingMigrations() { + const migrationsDir = join(landingDir, "migrations"); + if (!existsSync(migrationsDir)) { + return; + } + + const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8"); + const match = wranglerToml.match(/database_name\s*=\s*"([^"]+)"/); + if (!match) { + return; + } + const databaseName = match[1]; + const migrationFiles = (await readdir(migrationsDir)) + .filter((fileName) => fileName.endsWith(".sql")) + .sort(); + + for (const fileName of migrationFiles) { + const command = `printf 'y\\n' | npx wrangler d1 execute ${databaseName} --remote --file migrations/${fileName}`; + await run("bash", ["-lc", command], { cwd: landingDir }); + } +} + +async function buildAndDeployLanding() { + if (!existsSync(join(landingDir, "node_modules"))) { + await run("npm", ["ci"], { cwd: landingDir }); + } + await run("npm", ["run", "build"], { cwd: landingDir }); + const deploy = await run( + "npx", + ["wrangler", "pages", "deploy", "out", "--project-name", pagesProjectName, "--branch", pagesBranch, "--commit-dirty=true"], + { cwd: landingDir, capture: true }, + ); + const output = `${deploy.stdout}\n${deploy.stderr}`; + const urlMatch = output.match(/https:\/\/[^\s]+\.agentcompany\.pages\.dev/); + return urlMatch?.[0] ?? null; +} + +async function verifyPublishedAssets(version, deployUrl) { + const releaseLinksSource = await readFile(join(landingDir, "components", "release-links.generated.ts"), "utf8"); + const requiredFragments = [ + `AgentCompany-0.4.150-arm64.dmg`.replace("0.4.150", version), + `AgentCompany.Setup.${version}.exe`, + `agent-company-${version}.aarch64.rpm`, + `agent-company_${version}_arm64.deb`, + ]; + for (const fragment of requiredFragments) { + if (!releaseLinksSource.includes(fragment)) { + throw new Error(`Missing expected release asset in generated links: ${fragment}`); + } + } + + if (deployUrl) { + const { stdout } = await run("curl", ["-L", "--silent", "-A", "Mozilla/5.0", deployUrl], { capture: true }); + if (!stdout.includes(version)) { + throw new Error(`Deployed landing preview ${deployUrl} does not contain 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); + + const currentVersion = (await readPackageJson()).version; + const nextVersion = options.version ?? bumpPatchVersion(currentVersion); + if (!versionPattern.test(nextVersion)) { + throw new Error(`Invalid version: ${nextVersion}`); + } + + await updatePackageVersion(nextVersion); + await resetReleaseStaging(); + + if (!options.skipVerify) { + await run("pnpm", ["verify"]); + } + + await buildMacAndUpload(); + + const timestamp = Date.now(); + const releaseTag = `transport-${nextVersion}-${timestamp}`; + const branchName = `release-build-${nextVersion.replace(/\./g, "-")}-${timestamp}`; + const releaseTitle = `Remote Release ${nextVersion}`; + + const tempDir = await createSnapshotBranch(repo, branchName, nextVersion); + let cleanupPending = true; + + try { + await dispatchRemoteWorkflow(repo, branchName, releaseTag, releaseTitle); + const runSummary = await waitForWorkflowRun(repo, remoteWorkflowName, branchName, 60_000); + const runInfo = await waitForWorkflowCompletion(repo, runSummary.databaseId, 45 * 60 * 1000); + if (runInfo.conclusion !== "success") { + throw new Error(`Remote workflow failed: ${runInfo.url}`); + } + + const downloadRoot = await downloadRemoteAssets(repo, releaseTag); + await stageRemoteAssets(downloadRoot); + await uploadWinAndLinuxToR2(); + await run("node", ["scripts/generate-landing-release-links.mjs"]); + await applyLandingMigrations(); + const deployUrl = await buildAndDeployLanding(); + await verifyPublishedAssets(nextVersion, deployUrl); + + if (!options.keepTransportRelease) { + await cleanupRemoteTransportRelease(repo, releaseTag); + } + if (options.deleteRemoteBranch) { + await cleanupRemoteBranch(repo, branchName); + } + await cleanupLocalWorktree(tempDir); + cleanupPending = false; + + console.log(JSON.stringify({ + version: nextVersion, + remoteBranch: branchName, + remoteWorkflowRun: runInfo.url, + releaseTag, + deployUrl, + updateUrl: process.env.AGENTCOMPANY_UPDATE_URL ?? null, + }, null, 2)); + } finally { + if (cleanupPending) { + try { + await cleanupLocalWorktree(tempDir); + } catch { + // no-op during best-effort temp cleanup + } + } + } +} + +await main(); diff --git a/src/main/connector-definitions.ts b/src/main/connector-definitions.ts index 2e7f2b48..4672790d 100644 --- a/src/main/connector-definitions.ts +++ b/src/main/connector-definitions.ts @@ -29,7 +29,7 @@ const claudeCompatModule: ServerAdapterModule = { execute: claudeExecute, testEnvironment: claudeTestEnvironment, sessionCodec: claudeSessionCodec, - nativeContextManagement: true, + nativeContextManagement: false, models: claudeModels, }; diff --git a/src/main/worker/run-log-filter.ts b/src/main/worker/run-log-filter.ts new file mode 100644 index 00000000..6eb23296 --- /dev/null +++ b/src/main/worker/run-log-filter.ts @@ -0,0 +1,58 @@ +/** + * Run-log noise filter. + * + * CLI adapters emit stdout/stderr containing system-level JSON messages, + * hook lifecycle events, skill injection notices, and assistant internals + * (thinking/tool_use). This module strips those lines before they reach + * the user-facing run log on disk. + */ + +const RE_SKILL_INJECTION = /^\[agent-company\] Injected managed skill /; +const RE_OMC_HOOK = /^\[omc:hooks\]/; +const RE_AGENT_COMPANY_LOG = /^\[agent-company\] (?:heartbeat|hook_|system_)/; + +/** Matches system JSON except `init` subtype (which carries session metadata). */ +const RE_SYSTEM_JSON = /^\{"type"\s*:\s*"system"\s*,\s*"subtype"\s*:\s*"(?!init")/; + +/** Matches assistant thinking/tool_use JSON — internal reasoning, not user-visible output. */ +const RE_ASSISTANT_INTERNAL = + /^\{"type"\s*:\s*"assistant"\s*,\s*"subtype"\s*:\s*"(?:thinking|tool_use)"/; + +const RE_COMPACTING = /^Compacting conversation\.\.\./; + +function isNoisyLine(line: string): boolean { + const trimmed = line.trimStart(); + if (trimmed.length === 0) return false; + + return ( + RE_SKILL_INJECTION.test(trimmed) || + RE_OMC_HOOK.test(trimmed) || + RE_AGENT_COMPANY_LOG.test(trimmed) || + RE_SYSTEM_JSON.test(trimmed) || + RE_ASSISTANT_INTERNAL.test(trimmed) || + RE_COMPACTING.test(trimmed) + ); +} + +/** + * Filters a raw log chunk by removing noisy lines. + * @returns The cleaned chunk, or `null` if the entire chunk is noise. + */ +export function filterRunLogChunk( + _stream: "stdout" | "stderr", + chunk: string, +): string | null { + const lines = chunk.split("\n"); + const kept: string[] = []; + + for (const line of lines) { + if (!isNoisyLine(line)) { + kept.push(line); + } + } + + if (kept.length === 0) return null; + + const result = kept.join("\n"); + return result.length === 0 ? null : result; +} diff --git a/src/main/worker/worker-message-handler.ts b/src/main/worker/worker-message-handler.ts index 9c780d71..8efe8c46 100644 --- a/src/main/worker/worker-message-handler.ts +++ b/src/main/worker/worker-message-handler.ts @@ -3,6 +3,7 @@ import { getConnectorDefinition } from "../connectors"; import type { WorkerServiceDependencies, workerMessageSchema } from "./worker-types"; import { createCircuitBreaker } from "./circuit-breaker"; import { handleFailureEscalation, handleSuccessReset } from "./failure-escalation"; +import { filterRunLogChunk } from "./run-log-filter"; const LEASE_RENEWAL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -61,7 +62,10 @@ export async function handleWorkerMessage( } if (event.type === "run-log") { - await db.appendRunLog(event.runId, event.stream, event.chunk); + const filtered = filterRunLogChunk(event.stream, event.chunk); + if (filtered === null) return; + + await db.appendRunLog(event.runId, event.stream, filtered); const runState = db.getRunAuthState(event.runId); if (runState) { persistRunEvent(db, logger, { @@ -70,10 +74,10 @@ export async function handleWorkerMessage( agentId: runState.agentId, eventType: "run_log", stream: event.stream, - message: event.chunk, + message: filtered, }); } - emitEvent(event); + emitEvent({ ...event, chunk: filtered }); // Throttled lease renewal: keep execution lock alive for long-running tasks. // Without this, tasks exceeding the 15-minute stale lock timeout get reclaimed.