@rizom/ops 0.2.0-alpha.45 → 0.2.0-alpha.450

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +82 -1
  2. package/dist/age-key-bootstrap.d.ts +1 -1
  3. package/dist/brains-ops.js +736 -335
  4. package/dist/capability-bundle-migration.d.ts +22 -0
  5. package/dist/cert-bootstrap.d.ts +4 -2
  6. package/dist/content-repo-ref.d.ts +10 -0
  7. package/dist/content-repo.d.ts +1 -1
  8. package/dist/deploy.js +110 -166
  9. package/dist/directory-sync-stress-system.d.ts +75 -0
  10. package/dist/directory-sync-stress.d.ts +117 -0
  11. package/dist/entries/deploy.d.ts +4 -2
  12. package/dist/health-watchdog-smoke.d.ts +54 -0
  13. package/dist/image-inventory.d.ts +4 -0
  14. package/dist/image-types.d.ts +7 -0
  15. package/dist/images.d.ts +82 -0
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.js +736 -323
  18. package/dist/legacy-pilot-migration.d.ts +11 -0
  19. package/dist/legacy-projection-job-recovery.d.ts +29 -0
  20. package/dist/load-registry.d.ts +61 -6
  21. package/dist/observed-status.d.ts +1 -1
  22. package/dist/parse-args.d.ts +2 -12
  23. package/dist/preview-domain.d.ts +9 -0
  24. package/dist/reconcile-dry-run.d.ts +9 -0
  25. package/dist/run-command.d.ts +20 -4
  26. package/dist/schema.d.ts +126 -163
  27. package/dist/secrets-encrypt.d.ts +7 -13
  28. package/dist/secrets-push.d.ts +1 -1
  29. package/dist/ssh-key-bootstrap.d.ts +1 -26
  30. package/dist/stage-legacy-crossover.d.ts +25 -0
  31. package/dist/stress-command.d.ts +14 -0
  32. package/dist/stress-git-checkout.d.ts +21 -0
  33. package/dist/stress-health-monitor.d.ts +46 -0
  34. package/dist/upgrade.d.ts +10 -0
  35. package/dist/user-offboard.d.ts +61 -0
  36. package/dist/verify-user.d.ts +22 -0
  37. package/package.json +50 -42
  38. package/templates/rover-pilot/.env.schema +20 -5
  39. package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
  40. package/templates/rover-pilot/.github/workflows/build.yml +70 -17
  41. package/templates/rover-pilot/.github/workflows/deploy.yml +74 -57
  42. package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
  43. package/templates/rover-pilot/.github/workflows/health-watchdog-smoke.yml +95 -0
  44. package/templates/rover-pilot/.github/workflows/offboard.yml +107 -0
  45. package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
  46. package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
  47. package/templates/rover-pilot/README.md +13 -5
  48. package/templates/rover-pilot/deploy/scripts/create-predeploy-backup.ts +936 -0
  49. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -24
  50. package/templates/rover-pilot/deploy/scripts/helpers.ts +2 -0
  51. package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
  52. package/templates/rover-pilot/deploy/scripts/provision-server.ts +51 -21
  53. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +4 -0
  54. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  55. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +40 -9
  56. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
  57. package/templates/rover-pilot/deploy/scripts/update-dns.ts +72 -16
  58. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
  59. package/templates/rover-pilot/deploy/scripts/verify-runtime-image.ts +18 -0
  60. package/templates/rover-pilot/docs/canonical-crossover-record.md +107 -0
  61. package/templates/rover-pilot/docs/onboarding-checklist.md +23 -14
  62. package/templates/rover-pilot/docs/operator-playbook.md +357 -29
  63. package/templates/rover-pilot/docs/user-onboarding.md +48 -463
  64. package/templates/rover-pilot/package.json +1 -0
  65. package/templates/rover-pilot/pilot.yaml +6 -4
  66. package/dist/origin-ca.d.ts +0 -1
  67. package/dist/push-secrets.d.ts +0 -9
  68. package/dist/push-target.d.ts +0 -2
  69. package/dist/run-subprocess.d.ts +0 -6
  70. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
@@ -15,13 +15,52 @@ const decrypter = new Decrypter();
15
15
  decrypter.addIdentity(ageSecretKey);
16
16
 
17
17
  const plaintext = await decrypter.decrypt(decoded, "text");
18
- const secrets = parseFlatYaml(plaintext);
19
- const pilot = parseFlatYaml(readFileSync("pilot.yaml", "utf8"));
20
-
21
- writeGitHubEnv("AI_API_KEY", secrets["aiApiKey"] ?? "");
22
- writeGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"] ?? "");
23
- writeGitHubEnv("MCP_AUTH_TOKEN", secrets["mcpAuthToken"] ?? "");
24
- writeGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
18
+ const secrets = parseStringYamlMapping(plaintext);
19
+ const pilot = parseStringYamlMapping(readFileSync("pilot.yaml", "utf8"));
20
+
21
+ writeSecretGitHubEnv("AI_API_KEY", secrets["aiApiKey"]);
22
+ writeSecretGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"]);
23
+ writeSecretGitHubEnv(
24
+ "STUDIO_CONTENT_REPO_PAT",
25
+ secrets["studioContentRepoPat"] ?? secrets["gitSyncToken"],
26
+ );
27
+ writeSecretGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"]);
28
+ writeSecretGitHubEnv("DISCORD_PUBLIC_KEY", secrets["discordPublicKey"]);
29
+ writeSecretGitHubEnv("DISCORD_APPLICATION_ID", secrets["discordApplicationId"]);
30
+ // Per-user AT Protocol publishing credential (optional; from the user's
31
+ // encrypted secrets file). The scaffold wires this so a pilot can publish its
32
+ // brain's agent card to its PDS; a deployment that doesn't publish simply
33
+ // leaves it unset.
34
+ writeSecretGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"]);
35
+ // Per-user custom-domain TLS overrides the shared fleet certificate when a
36
+ // complete certificate/key pair is present in the encrypted user secrets.
37
+ const certificatePem = decodeEscapedSecret(secrets["certificatePem"]);
38
+ const privateKeyPem = decodeEscapedSecret(secrets["privateKeyPem"]);
39
+ if (Boolean(certificatePem) !== Boolean(privateKeyPem)) {
40
+ throw new Error(
41
+ "Custom-domain TLS secrets require both certificatePem and privateKeyPem",
42
+ );
43
+ }
44
+ // A corrupted stored value would otherwise only surface as a kamal-proxy
45
+ // "unable to load certificate" failure after the container has already booted.
46
+ assertPemShape("certificatePem", certificatePem);
47
+ assertPemShape("privateKeyPem", privateKeyPem);
48
+ writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
49
+ writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
50
+
51
+ function assertPemShape(name: string, value: string | undefined): void {
52
+ if (value === undefined || value.trim().length === 0) {
53
+ return;
54
+ }
55
+ if (
56
+ !/-----BEGIN [A-Z0-9 ]+-----/.test(value) ||
57
+ !/-----END [A-Z0-9 ]+-----/.test(value)
58
+ ) {
59
+ throw new Error(
60
+ `Secret ${name} is not PEM-shaped after unescaping; the stored value is corrupt`,
61
+ );
62
+ }
63
+ }
25
64
 
26
65
  writeGitHubOutput(
27
66
  "shared_ai_api_key_secret_name",
@@ -32,10 +71,29 @@ writeGitHubOutput(
32
71
  requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
33
72
  );
34
73
  writeGitHubOutput(
35
- "shared_mcp_auth_token_secret_name",
36
- requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
74
+ "shared_content_repo_admin_token_secret_name",
75
+ requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
37
76
  );
38
77
 
78
+ function writeSecretGitHubEnv(name: string, value: string | undefined): void {
79
+ if (!value || value.trim().length === 0) {
80
+ return;
81
+ }
82
+
83
+ maskGitHubSecret(value);
84
+ writeGitHubEnv(name, value);
85
+ }
86
+
87
+ function maskGitHubSecret(value: string): void {
88
+ const escaped = value
89
+ .replace(/%/g, "%25")
90
+ .replace(/\r/g, "%0D")
91
+ .replace(/\n/g, "%0A");
92
+ if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
93
+ console.log(`::add-mask::${escaped}`);
94
+ }
95
+ }
96
+
39
97
  function extractAgeIdentity(contents: string): string {
40
98
  const line = contents
41
99
  .split(/\r?\n/)
@@ -49,27 +107,25 @@ function extractAgeIdentity(contents: string): string {
49
107
  return line;
50
108
  }
51
109
 
52
- function parseFlatYaml(contents: string): Record<string, string> {
53
- const result: Record<string, string> = {};
54
-
55
- for (const rawLine of contents.split(/\r?\n/)) {
56
- const line = rawLine.trim();
57
- if (!line || line.startsWith("#")) {
58
- continue;
59
- }
110
+ function parseStringYamlMapping(contents: string): Record<string, string> {
111
+ const parsed: unknown = Bun.YAML.parse(contents);
112
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
113
+ throw new Error("Expected a YAML mapping");
114
+ }
60
115
 
61
- const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
62
- if (!match) {
63
- continue;
116
+ const result: Record<string, string> = {};
117
+ for (const [key, value] of Object.entries(parsed)) {
118
+ if (typeof value === "string") {
119
+ result[key] = value;
64
120
  }
65
-
66
- const [, key, rawValue] = match;
67
- result[key] = rawValue.replace(/^['"]|['"]$/g, "");
68
121
  }
69
-
70
122
  return result;
71
123
  }
72
124
 
125
+ function decodeEscapedSecret(value: string | undefined): string | undefined {
126
+ return value?.replace(/\\n/g, "\n");
127
+ }
128
+
73
129
  function requireFlatValue(
74
130
  values: Record<string, string>,
75
131
  key: string,
@@ -6,5 +6,7 @@ export {
6
6
  requireEnv,
7
7
  writeGitHubOutput,
8
8
  writeGitHubEnv,
9
+ runtimeImageTag,
10
+ runResolveMissingImages,
9
11
  } from "@rizom/ops/deploy";
10
12
  export type { EnvSchemaEntry } from "@rizom/ops/deploy";
@@ -0,0 +1,144 @@
1
+ import { requireEnv } from "./helpers";
2
+
3
+ const WATCHDOG_PATH = "/usr/local/sbin/brains-health-watchdog";
4
+ const SERVICE_PATH = "/etc/systemd/system/brains-health-watchdog.service";
5
+ const TIMER_PATH = "/etc/systemd/system/brains-health-watchdog.timer";
6
+
7
+ export const BRAIN_WATCHDOG_LABEL = "ai.rizom.brain.watchdog";
8
+ export const BRAIN_WATCHDOG_LABEL_VALUE = "true";
9
+ export const BRAIN_WATCHDOG_LABEL_FILTER: string = `${BRAIN_WATCHDOG_LABEL}=${BRAIN_WATCHDOG_LABEL_VALUE}`;
10
+
11
+ export const healthWatchdogScript: string = `#!/usr/bin/env bash
12
+ set -euo pipefail
13
+ umask 077
14
+
15
+ lock_path="\${BRAIN_WATCHDOG_LOCK_PATH:-/run/brains-health-watchdog.lock}"
16
+ exec 9>"$lock_path"
17
+ flock -n 9 || exit 0
18
+
19
+ incident_dir="\${BRAIN_WATCHDOG_INCIDENT_DIR:-/var/log/brains-health-watchdog}"
20
+ state_dir="\${BRAIN_WATCHDOG_STATE_DIR:-/var/lib/brains-health-watchdog}"
21
+ MAX_RESTARTS=3
22
+ WINDOW_SECONDS=3600
23
+ mkdir -p "$incident_dir" "$state_dir"
24
+
25
+ write_safe_inspect() {
26
+ docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"state":{{json .State}},"restartPolicy":{{json .HostConfig.RestartPolicy}}}' "$1"
27
+ }
28
+
29
+ while read -r container_id; do
30
+ [ -n "$container_id" ] || continue
31
+ service_name=$(docker inspect --format '{{ index .Config.Labels "service" }}' "$container_id" 2>/dev/null || true)
32
+ container_name=$(docker inspect --format '{{ .Name }}' "$container_id" 2>/dev/null | tr -d '/' || true)
33
+ state_key=$(printf '%s-%s-%s' "$service_name" "$container_name" "$container_id" | tr -c 'A-Za-z0-9_.-' '_')
34
+ state_path="$state_dir/$state_key.restarts"
35
+ recent_path="$state_path.tmp"
36
+ now_epoch=$(date -u +%s)
37
+ cutoff_epoch=$((now_epoch - WINDOW_SECONDS))
38
+ touch "$state_path"
39
+ awk -v cutoff="$cutoff_epoch" '$1 >= cutoff' "$state_path" >"$recent_path"
40
+ restart_count=$(wc -l <"$recent_path")
41
+
42
+ timestamp=$(date -u +%Y%m%dT%H%M%SZ)
43
+ incident_path="$incident_dir/$timestamp-$state_key.log"
44
+ temporary_path="$incident_path.tmp"
45
+
46
+ if [ "$restart_count" -ge "$MAX_RESTARTS" ]; then
47
+ {
48
+ printf '{"timestamp":"%s","event":"restart-suppressed","reason":"restart-budget-exhausted","service":"%s","container":"%s","restartsInWindow":%s}\\n' "$timestamp" "$service_name" "$container_name" "$restart_count"
49
+ write_safe_inspect "$container_id"
50
+ docker logs --timestamps --tail 1000 "$container_id"
51
+ } >"$temporary_path" 2>&1
52
+ mv "$temporary_path" "$incident_path"
53
+ mv "$recent_path" "$state_path"
54
+ logger -t brains-health-watchdog "restart budget exhausted container=$container_name service=$service_name incident=$incident_path"
55
+ continue
56
+ fi
57
+
58
+ {
59
+ printf '{"timestamp":"%s","event":"container-restart","reason":"docker-health-unhealthy","service":"%s","container":"%s"}\\n' "$timestamp" "$service_name" "$container_name"
60
+ write_safe_inspect "$container_id"
61
+ docker logs --timestamps --tail 1000 "$container_id"
62
+ } >"$temporary_path" 2>&1
63
+ mv "$temporary_path" "$incident_path"
64
+ printf '%s\\n' "$now_epoch" >>"$recent_path"
65
+ mv "$recent_path" "$state_path"
66
+
67
+ logger -t brains-health-watchdog "restarting unhealthy container=$container_name service=$service_name incident=$incident_path"
68
+ docker restart "$container_id"
69
+ done < <(docker ps --filter health=unhealthy --filter label=${BRAIN_WATCHDOG_LABEL_FILTER} --format '{{.ID}}')
70
+ `;
71
+
72
+ export const healthWatchdogServiceUnit: string = `[Unit]
73
+ Description=Restart persistently unhealthy Brain containers
74
+ After=docker.service
75
+ Requires=docker.service
76
+
77
+ [Service]
78
+ Type=oneshot
79
+ ExecStart=${WATCHDOG_PATH}
80
+ `;
81
+
82
+ export const healthWatchdogTimerUnit: string = `[Unit]
83
+ Description=Monitor Brain container liveness
84
+
85
+ [Timer]
86
+ OnBootSec=2min
87
+ OnUnitActiveSec=30s
88
+ RandomizedDelaySec=5s
89
+ Persistent=true
90
+ Unit=brains-health-watchdog.service
91
+
92
+ [Install]
93
+ WantedBy=timers.target
94
+ `;
95
+
96
+ function installCommand(path: string, content: string, mode: string): string {
97
+ const encoded = Buffer.from(content, "utf8").toString("base64");
98
+ return `printf '%s' '${encoded}' | $SUDO base64 --decode | $SUDO tee '${path}' >/dev/null\n$SUDO chmod ${mode} '${path}'`;
99
+ }
100
+
101
+ export function buildHealthWatchdogInstallScript(): string {
102
+ return `#!/usr/bin/env bash
103
+ set -euo pipefail
104
+ SUDO=""
105
+ if [ "$(id -u)" -ne 0 ]; then
106
+ SUDO=sudo
107
+ fi
108
+
109
+ ${installCommand(WATCHDOG_PATH, healthWatchdogScript, "0755")}
110
+ ${installCommand(SERVICE_PATH, healthWatchdogServiceUnit, "0644")}
111
+ ${installCommand(TIMER_PATH, healthWatchdogTimerUnit, "0644")}
112
+
113
+ $SUDO systemctl daemon-reload
114
+ $SUDO systemctl enable --now brains-health-watchdog.timer
115
+
116
+ labelled_containers=""
117
+ if ! labelled_containers=$($SUDO docker ps --filter label=${BRAIN_WATCHDOG_LABEL_FILTER} --format '{{.ID}}' 2>/dev/null); then
118
+ echo "Warning: unable to verify running Brain containers with label ${BRAIN_WATCHDOG_LABEL_FILTER}." >&2
119
+ elif [ -z "$labelled_containers" ]; then
120
+ echo "Warning: no running Brain containers found with label ${BRAIN_WATCHDOG_LABEL_FILTER}." >&2
121
+ fi
122
+ `;
123
+ }
124
+
125
+ export async function installHealthWatchdog(): Promise<void> {
126
+ const serverIp = requireEnv("SERVER_IP");
127
+ const sshUser = requireEnv("SSH_USER").trim();
128
+ if (!sshUser) throw new Error("Missing SSH_USER");
129
+ const child = Bun.spawn(["ssh", `${sshUser}@${serverIp}`, "bash -s"], {
130
+ stdin: new Blob([buildHealthWatchdogInstallScript()]),
131
+ stdout: "inherit",
132
+ stderr: "inherit",
133
+ });
134
+ const exitCode = await child.exited;
135
+ if (exitCode !== 0) {
136
+ throw new Error(
137
+ `Health watchdog installation failed with exit ${exitCode}`,
138
+ );
139
+ }
140
+ }
141
+
142
+ if (import.meta.main) {
143
+ await installHealthWatchdog();
144
+ }
@@ -30,19 +30,54 @@ function sleep(ms: number): Promise<void> {
30
30
  return new Promise((resolve) => setTimeout(resolve, ms));
31
31
  }
32
32
 
33
+ /**
34
+ * These scripts are copied verbatim into generated projects, so they stay
35
+ * dependency-free: the API payloads are checked by hand rather than with a
36
+ * schema library. readJsonResponse returns unknown; asserting the Hetzner
37
+ * response shape onto it meant a changed or error payload surfaced as an
38
+ * undefined field later instead of a failed lookup here.
39
+ */
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ function isHetznerServer(value: unknown): value is HetznerServer {
45
+ if (!isRecord(value)) return false;
46
+ if (typeof value["id"] !== "number") return false;
47
+ if (typeof value["status"] !== "string") return false;
48
+ const publicNet = value["public_net"];
49
+ if (publicNet === undefined) return true;
50
+ if (!isRecord(publicNet)) return false;
51
+ const ipv4 = publicNet["ipv4"];
52
+ if (ipv4 === undefined) return true;
53
+ if (!isRecord(ipv4)) return false;
54
+ const ip = ipv4["ip"];
55
+ return ip === undefined || typeof ip === "string";
56
+ }
57
+
58
+ function readServer(payload: unknown): HetznerServer | undefined {
59
+ if (!isRecord(payload)) return undefined;
60
+ const server = payload["server"];
61
+ return isHetznerServer(server) ? server : undefined;
62
+ }
63
+
64
+ function readServers(payload: unknown): HetznerServer[] | undefined {
65
+ if (!isRecord(payload)) return undefined;
66
+ const servers = payload["servers"];
67
+ return Array.isArray(servers) && servers.every(isHetznerServer)
68
+ ? servers
69
+ : undefined;
70
+ }
71
+
33
72
  async function listServers(): Promise<HetznerServer[]> {
34
73
  const url = `${baseUrl}/servers?label_selector=${encodeURIComponent(labelSelector)}`;
35
74
  const response = await fetch(url, { headers });
36
- const payload = (await readJsonResponse(
37
- response,
38
- "Hetzner server lookup",
39
- )) as {
40
- servers?: HetznerServer[];
41
- };
42
- if (!response.ok || !payload.servers) {
75
+ const payload = await readJsonResponse(response, "Hetzner server lookup");
76
+ const servers = readServers(payload);
77
+ if (!response.ok || !servers) {
43
78
  throw new Error(`Hetzner server lookup failed: ${JSON.stringify(payload)}`);
44
79
  }
45
- return payload.servers;
80
+ return servers;
46
81
  }
47
82
 
48
83
  async function createServer(): Promise<HetznerServer> {
@@ -58,27 +93,22 @@ async function createServer(): Promise<HetznerServer> {
58
93
  labels: { brain: instanceName },
59
94
  }),
60
95
  });
61
- const payload = (await readJsonResponse(
62
- response,
63
- "Hetzner server create",
64
- )) as {
65
- server?: HetznerServer;
66
- };
67
- if (!response.ok || !payload.server) {
96
+ const payload = await readJsonResponse(response, "Hetzner server create");
97
+ const server = readServer(payload);
98
+ if (!response.ok || !server) {
68
99
  throw new Error(`Hetzner server create failed: ${JSON.stringify(payload)}`);
69
100
  }
70
- return payload.server;
101
+ return server;
71
102
  }
72
103
 
73
104
  async function getServer(id: number): Promise<HetznerServer> {
74
105
  const response = await fetch(`${baseUrl}/servers/${id}`, { headers });
75
- const payload = (await readJsonResponse(response, "Hetzner server poll")) as {
76
- server?: HetznerServer;
77
- };
78
- if (!response.ok || !payload.server) {
106
+ const payload = await readJsonResponse(response, "Hetzner server poll");
107
+ const server = readServer(payload);
108
+ if (!response.ok || !server) {
79
109
  throw new Error(`Hetzner server poll failed: ${JSON.stringify(payload)}`);
80
110
  }
81
- return payload.server;
111
+ return server;
82
112
  }
83
113
 
84
114
  let server: HetznerServer | undefined = (await listServers())[0];
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import { requireEnv, writeGitHubOutput } from "./helpers";
3
4
 
4
5
  const eventName = requireEnv("GITHUB_EVENT_NAME");
@@ -47,6 +48,9 @@ const handles = [
47
48
  return match?.[1] ?? null;
48
49
  })
49
50
  .filter((handle): handle is string => handle !== null)
51
+ // Deleted generated files belong to retired users and must not be sent
52
+ // through the onboarding/deploy path after an offboarding commit.
53
+ .filter((handle) => existsSync(`users/${handle}.yaml`))
50
54
  .sort((left, right) => left.localeCompare(right)),
51
55
  ),
52
56
  ];
@@ -0,0 +1,13 @@
1
+ import {
2
+ requireEnv,
3
+ runResolveMissingImages,
4
+ writeGitHubOutput,
5
+ } from "./helpers";
6
+
7
+ // The Build workflow's resolve step — the logic lives in @rizom/ops; this
8
+ // pilot repo only supplies its own paths and repository.
9
+ await runResolveMissingImages({
10
+ rootDir: process.cwd(),
11
+ imageRepository: `ghcr.io/${requireEnv("GITHUB_REPOSITORY")}`,
12
+ writeOutput: writeGitHubOutput,
13
+ });
@@ -1,6 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
- import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
3
+ import { derivePreviewDomain, loadPilotRegistry } from "@rizom/ops";
4
+
5
+ import {
6
+ parseEnvFile,
7
+ requireEnv,
8
+ runtimeImageTag,
9
+ writeGitHubOutput,
10
+ } from "./helpers";
4
11
 
5
12
  const handle = requireEnv("HANDLE");
6
13
  const envPath = `users/${handle}/.env`;
@@ -17,23 +24,39 @@ if (!brainDomain) {
17
24
  throw new Error(`Missing domain in ${brainYamlPath}`);
18
25
  }
19
26
 
20
- const zone =
21
- brainDomain.startsWith(`${handle}.`) && brainDomain.length > handle.length + 1
22
- ? brainDomain.slice(handle.length + 1)
23
- : "";
24
- if (!zone) {
25
- throw new Error(`Could not derive preview domain from ${brainDomain}`);
27
+ const registry = await loadPilotRegistry(process.cwd());
28
+ const user = registry.users.find((entry) => entry.handle === handle);
29
+ if (!user) {
30
+ throw new Error(`Unknown user handle: ${handle}`);
26
31
  }
27
- const previewDomain = `${handle}-preview.${zone}`;
32
+ const previewDomain = derivePreviewDomain(brainDomain, {
33
+ sharedDomain: registry.pilot.domainSuffix,
34
+ });
35
+ const wwwDomain = isFleetDomain(
36
+ handle,
37
+ brainDomain,
38
+ registry.pilot.domainSuffix,
39
+ )
40
+ ? ""
41
+ : `www.${brainDomain}`;
42
+
43
+ const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
44
+
45
+ // Build and Deploy share one tag function: every instance on a Brain version
46
+ // runs the same immutable fleet image.
47
+ const imageTag = runtimeImageTag(brainVersion);
28
48
 
29
49
  const outputs: Record<string, string> = {
30
- brain_version: envEntries["BRAIN_VERSION"] ?? "",
50
+ brain_version: brainVersion,
31
51
  content_repo: envEntries["CONTENT_REPO"] ?? "",
32
52
  brain_domain: brainDomain,
33
53
  preview_domain: previewDomain,
54
+ www_domain: wwwDomain,
55
+ cloudflare_zone_id: user.cloudflareZoneId ?? process.env["CF_ZONE_ID"] ?? "",
34
56
  brain_yaml_path: brainYamlPath,
35
57
  instance_name: `rover-${handle}`,
36
58
  image_repository: `ghcr.io/${repository}`,
59
+ image_tag: imageTag,
37
60
  registry_username: repositoryOwner,
38
61
  };
39
62
 
@@ -47,3 +70,11 @@ for (const key of required) {
47
70
  for (const [key, value] of Object.entries(outputs)) {
48
71
  writeGitHubOutput(key, value);
49
72
  }
73
+
74
+ function isFleetDomain(
75
+ userHandle: string,
76
+ domain: string,
77
+ pilotDomainSuffix: string,
78
+ ): boolean {
79
+ return domain === `${userHandle}${pilotDomainSuffix}`;
80
+ }
@@ -5,53 +5,6 @@ import { dirname, join } from "node:path";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { readJsonResponse, requireEnv } from "./helpers";
7
7
 
8
- const handle = requireEnv("HANDLE");
9
- const contentRepo = requireEnv("CONTENT_REPO");
10
- const token = requireEnv("GIT_SYNC_TOKEN");
11
- const sourceDir = join("users", handle, "content");
12
-
13
- if (!existsSync(sourceDir)) {
14
- process.exit(0);
15
- }
16
-
17
- const { owner, repo } = parseRepoSlug(contentRepo);
18
- await ensureGitHubRepo({ owner, repo, token });
19
-
20
- const tempRoot = await mkdtemp(join(tmpdir(), "brains-ops-content-"));
21
- const checkoutDir = join(tempRoot, "repo");
22
- const remoteUrl = buildAuthenticatedRemoteUrl(owner, repo, token);
23
-
24
- runGit(["clone", remoteUrl, checkoutDir]);
25
- runGit(["-C", checkoutDir, "checkout", "-B", "main"]);
26
-
27
- const copiedFiles = await copyMissingFiles(sourceDir, checkoutDir);
28
- if (copiedFiles === 0) {
29
- process.exit(0);
30
- }
31
-
32
- runGit(["-C", checkoutDir, "config", "user.name", "brains-ops[bot]"]);
33
- runGit([
34
- "-C",
35
- checkoutDir,
36
- "config",
37
- "user.email",
38
- "41898282+github-actions[bot]@users.noreply.github.com",
39
- ]);
40
- runGit(["-C", checkoutDir, "add", "."]);
41
-
42
- if (hasNoStagedChanges(checkoutDir)) {
43
- process.exit(0);
44
- }
45
-
46
- runGit([
47
- "-C",
48
- checkoutDir,
49
- "commit",
50
- "-m",
51
- `chore(content): seed ${handle} anchor profile`,
52
- ]);
53
- runGit(["-C", checkoutDir, "push", "origin", "HEAD:main"]);
54
-
55
8
  const STALE_ANCHOR_PROFILE_MARKERS = [
56
9
  "name: Your Name Here",
57
10
  "Delete this and write your own",
@@ -68,6 +21,55 @@ interface GitHubRepoResponse {
68
21
  private?: boolean;
69
22
  }
70
23
 
24
+ async function main(): Promise<void> {
25
+ const handle = requireEnv("HANDLE");
26
+ const contentRepo = requireEnv("CONTENT_REPO");
27
+ const token = requireEnv("GIT_SYNC_TOKEN");
28
+ const sourceDir = join("users", handle, "content");
29
+
30
+ if (!existsSync(sourceDir)) {
31
+ return;
32
+ }
33
+
34
+ const { owner, repo } = parseRepoSlug(contentRepo);
35
+ await ensureGitHubRepo({ owner, repo, token });
36
+
37
+ const tempRoot = await mkdtemp(join(tmpdir(), "brains-ops-content-"));
38
+ const checkoutDir = join(tempRoot, "repo");
39
+ const remoteUrl = buildAuthenticatedRemoteUrl(owner, repo, token);
40
+
41
+ runGit(["clone", remoteUrl, checkoutDir]);
42
+ runGit(["-C", checkoutDir, "checkout", "-B", "main"]);
43
+
44
+ const copiedFiles = await copyMissingFiles(sourceDir, checkoutDir);
45
+ if (copiedFiles === 0) {
46
+ return;
47
+ }
48
+
49
+ runGit(["-C", checkoutDir, "config", "user.name", "brains-ops[bot]"]);
50
+ runGit([
51
+ "-C",
52
+ checkoutDir,
53
+ "config",
54
+ "user.email",
55
+ "41898282+github-actions[bot]@users.noreply.github.com",
56
+ ]);
57
+ runGit(["-C", checkoutDir, "add", "."]);
58
+
59
+ if (hasNoStagedChanges(checkoutDir)) {
60
+ return;
61
+ }
62
+
63
+ runGit([
64
+ "-C",
65
+ checkoutDir,
66
+ "commit",
67
+ "-m",
68
+ `chore(content): seed ${handle} anchor profile`,
69
+ ]);
70
+ runGit(["-C", checkoutDir, "push", "origin", "HEAD:main"]);
71
+ }
72
+
71
73
  async function ensureGitHubRepo(
72
74
  options: EnsureGitHubRepoOptions,
73
75
  ): Promise<void> {
@@ -177,3 +179,5 @@ function isStaleAnchorProfile(content: string): boolean {
177
179
  content.includes(marker),
178
180
  );
179
181
  }
182
+
183
+ await main();