diff --git a/daemon/git.ts b/daemon/git.ts index bcf0ac207..425d3a90c 100644 --- a/daemon/git.ts +++ b/daemon/git.ts @@ -21,6 +21,13 @@ const GITHUB_APP_KEY = Deno.env.get("GITHUB_APP_KEY"); const BUILD_FILES_DIR = Deno.env.get("BUILD_FILES_DIR"); const ADMIN_DOMAIN = "https://admin.deco.cx"; +const envBranchName = (): string | undefined => { + const envName = Deno.env.get("DECO_ENV_NAME"); + return envName ? `deco/env/${envName}` : undefined; +}; + +export const ENV_BRANCH = envBranchName(); + /** * Parse owner and repo name from a GitHub URL. * Handles HTTPS (https://github.com/owner/repo.git) and @@ -392,7 +399,9 @@ const validateRawArgs = (args: string[]): string | null => { const subcommand = args[0]; if (!ALLOWED_SUBCOMMANDS.has(subcommand)) { - return `Subcommand "${subcommand}" is not allowed. Allowed: ${[...ALLOWED_SUBCOMMANDS].join(", ")}`; + return `Subcommand "${subcommand}" is not allowed. Allowed: ${ + [...ALLOWED_SUBCOMMANDS].join(", ") + }`; } const blockedFlag = args.find((arg) => { @@ -794,17 +803,72 @@ export const ensureGit = async ({ ? `https://github.com/deco-sites/${site}.git` : `git@github.com:deco-sites/${site}.git`); + const upstreamBranch = branch ?? DEFAULT_TRACKING_BRANCH; + + // When DECO_ENV_NAME is set, each env pod tracks its own branch + // (deco/env/) and uses the upstream branch (main) as the rebase + // target. The branch may not exist yet on first boot, so we probe with + // ls-remote and fall back to cloning the upstream + creating it locally. + let envBranchExists = false; + if (ENV_BRANCH) { + try { + const remoteRefs = await git.listRemote([ + "--heads", + cloneUrl, + ENV_BRANCH, + ]); + envBranchExists = remoteRefs.trim().length > 0; + } catch (err) { + console.warn( + `[ensureGit] ls-remote failed for ${ENV_BRANCH}, falling back to ${upstreamBranch}:`, + err, + ); + } + } + + const branchToClone = envBranchExists ? ENV_BRANCH! : upstreamBranch; + await git .clone(cloneUrl, ".", [ "--depth", "1", - "--single-branch", + // In env mode we also want refs for upstreamBranch so assertRebased + // can rebase onto it; --single-branch would restrict refspec to the + // checked-out branch only. + ...(ENV_BRANCH ? [] : ["--single-branch"]), "--branch", - branch ?? DEFAULT_TRACKING_BRANCH, + branchToClone, ]) .submoduleInit() .submoduleUpdate(["--depth", "1"]); + if (ENV_BRANCH) { + if (!envBranchExists) { + // Cloned upstream — create the env branch locally. First push will + // create it on origin via flushEnvBranch. + await git.checkoutLocalBranch(ENV_BRANCH); + } + // Make sure origin/ is fetched so assertRebased has a target. + await git + .fetch(["origin", upstreamBranch, "--depth", "1"]) + .catch((err) => { + console.warn( + `[ensureGit] fetch origin ${upstreamBranch} failed:`, + err, + ); + }); + // Point the env branch's upstream at origin/ so status.tracking + // resolves there and assertRebased rebases env state onto upstream. + await git + .branch([`--set-upstream-to=origin/${upstreamBranch}`, ENV_BRANCH]) + .catch((err) => { + console.warn( + `[ensureGit] set-upstream-to origin/${upstreamBranch} for ${ENV_BRANCH} failed:`, + err, + ); + }); + } + // Exclude AI agent artifacts from git tracking const excludeFile = join(Deno.cwd(), ".git/info/exclude"); try { @@ -842,6 +906,51 @@ export const ensureGit = async ({ } }; +/** + * Commits the current working tree and pushes it to `deco/env/` + * on origin. Used to persist env-pod state across restarts in place of an EBS + * PVC. No-op when DECO_ENV_NAME is unset (production pods, sandbox mode). + * + * The function: + * - refreshes the GitHub App netrc token so push auth is fresh, + * - skips if the working tree is clean (no commit, no network), + * - acquires the write lock so it doesn't race with publish/rebase/file-watch, + * - never throws (failures are logged so a flaky push doesn't crash shutdown). + */ +export const flushEnvBranch = async (): Promise => { + if (!ENV_BRANCH) return; + + using _ = await lockerGitAPI.lock.wlock(); + + try { + if (GITHUB_APP_CONFIGURED || GITHUB_APP_KEY) { + const remoteUrl = await git.remote(["get-url", "origin"]).catch( + () => undefined, + ); + const repoOverride = remoteUrl && isGitHubUrl(remoteUrl) + ? parseGitHubOwnerRepo(remoteUrl) ?? undefined + : undefined; + await setupGithubTokenNetrc(repoOverride); + } + + const status = await git.status(); + if (status.files.length === 0) { + return; + } + + const message = `auto: env snapshot ${new Date().toISOString()}`; + await git + .add(["-A"]) + .commit(message, { "--no-verify": null }); + // Explicit refspec: the upstream is origin/ so a + // bare `git push` would push env state onto upstream. We push to the env + // branch by name instead. + await git.push(["origin", `HEAD:refs/heads/${ENV_BRANCH}`]); + } catch (err) { + console.error(`[flushEnvBranch] Failed to flush ${ENV_BRANCH}:`, err); + } +}; + interface Options { build: Deno.Command | null; site: string; diff --git a/daemon/main.ts b/daemon/main.ts index 3de202b2b..b22e0dbd8 100644 --- a/daemon/main.ts +++ b/daemon/main.ts @@ -23,7 +23,13 @@ import { setSiteName, } from "./daemon.ts"; import { watchFS } from "./fs/api.ts"; -import { ensureGit, getGitHubPackageTokens, lockerGitAPI } from "./git.ts"; +import { + ensureGit, + ENV_BRANCH, + flushEnvBranch, + getGitHubPackageTokens, + lockerGitAPI, +} from "./git.ts"; import { logs } from "./loggings/stream.ts"; import { watchMeta } from "./meta.ts"; import { @@ -155,6 +161,41 @@ globalThis.addEventListener( }, ); +// SIGTERM flush: commit + push any WIP to deco/env/ before exit so the +// next pod cold-starts from the latest state. Capped with a hard timeout so a +// hung push can't keep us past terminationGracePeriodSeconds. +if (ENV_BRANCH) { + const SHUTDOWN_FLUSH_TIMEOUT_MS = 60_000; + let flushing = false; + const onShutdown = async (signal: string) => { + if (flushing) return; + flushing = true; + console.log(`[shutdown] received ${signal}, flushing ${ENV_BRANCH}`); + try { + await Promise.race([ + flushEnvBranch(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("shutdown flush timed out")), + SHUTDOWN_FLUSH_TIMEOUT_MS, + ) + ), + ]); + console.log(`[shutdown] flush complete`); + } catch (err) { + console.error(`[shutdown] flush failed:`, err); + } + Deno.exit(0); + }; + Deno.addSignalListener("SIGTERM", () => onShutdown("SIGTERM")); + try { + // SIGINT is supported on Unix; the call throws on platforms where it isn't. + Deno.addSignalListener("SIGINT", () => onShutdown("SIGINT")); + } catch { + // Ignore — SIGINT not available on this platform. + } +} + const createBundler = (appName?: string) => { const bundler = bundleApp(Deno.cwd()); @@ -211,6 +252,14 @@ const persistState = throttle(async () => { await Promise.all([persist(), delay(2 * 60 * 1_000)]); }); +// When running as an env pod (DECO_ENV_NAME set), the workspace is persisted +// by pushing to the deco/env/ branch on origin instead of via the +// SOURCE_ASSET_PATH tarball. Throttled to one flush per 2 min so file-watch +// chatter doesn't spam git pushes. +const flushEnvState = throttle(async () => { + await Promise.all([flushEnvBranch(), delay(2 * 60 * 1_000)]); +}); + // Watch for changes in filesystem // TODO: we should be able to completely remove this after in some point in the future const watch = async (signal?: AbortSignal) => { @@ -262,6 +311,9 @@ const watch = async (signal?: AbortSignal) => { // TODO: We should be able to remove this after we migrate to ebs persistState(); + + // Env-pod state lives on the deco/env/ branch on origin. + flushEnvState(); } };