@rizom/ops 0.2.0-alpha.17 → 0.2.0-alpha.170

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 (42) hide show
  1. package/README.md +3 -1
  2. package/dist/brains-ops.js +282 -195
  3. package/dist/cert-bootstrap.d.ts +3 -1
  4. package/dist/content-repo-ref.d.ts +10 -0
  5. package/dist/content-repo.d.ts +1 -0
  6. package/dist/deploy.js +99 -166
  7. package/dist/entries/deploy.d.ts +3 -2
  8. package/dist/images.d.ts +76 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +283 -195
  11. package/dist/load-registry.d.ts +21 -2
  12. package/dist/observed-status.d.ts +1 -1
  13. package/dist/origin-ca.d.ts +1 -1
  14. package/dist/parse-args.d.ts +3 -0
  15. package/dist/push-secrets.d.ts +2 -9
  16. package/dist/push-target.d.ts +1 -2
  17. package/dist/run-command.d.ts +1 -1
  18. package/dist/run-subprocess.d.ts +1 -6
  19. package/dist/schema.d.ts +62 -155
  20. package/dist/secrets-encrypt.d.ts +5 -13
  21. package/dist/ssh-key-bootstrap.d.ts +1 -26
  22. package/dist/user-add.d.ts +15 -0
  23. package/dist/verify-user.d.ts +19 -0
  24. package/package.json +44 -42
  25. package/templates/rover-pilot/.env.schema +17 -3
  26. package/templates/rover-pilot/.github/workflows/build.yml +24 -12
  27. package/templates/rover-pilot/.github/workflows/deploy.yml +63 -18
  28. package/templates/rover-pilot/.github/workflows/reconcile.yml +21 -1
  29. package/templates/rover-pilot/README.md +3 -1
  30. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +13 -5
  31. package/templates/rover-pilot/deploy/scripts/resolve-build-config.ts +53 -0
  32. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +13 -3
  33. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +55 -8
  34. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  35. package/templates/rover-pilot/docs/onboarding-checklist.md +32 -12
  36. package/templates/rover-pilot/docs/operator-playbook.md +117 -6
  37. package/templates/rover-pilot/docs/user-onboarding.md +67 -332
  38. package/templates/rover-pilot/pilot.yaml +1 -1
  39. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  40. package/templates/rover-pilot/deploy/Caddyfile +0 -72
  41. package/templates/rover-pilot/deploy/Dockerfile +0 -38
  42. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
@@ -10,12 +10,17 @@ on:
10
10
  push:
11
11
  branches: ["main"]
12
12
  paths:
13
+ - users/*.yaml
13
14
  - users/*/.env
14
15
  - users/*/brain.yaml
15
16
  - users/*/content/**
16
17
  - users/*.secrets.yaml.age
17
18
  - deploy/**
18
19
  - .github/workflows/deploy.yml
20
+ workflow_run:
21
+ workflows: [Reconcile]
22
+ types: [completed]
23
+ branches: [main]
19
24
 
20
25
  permissions:
21
26
  contents: write
@@ -23,13 +28,16 @@ permissions:
23
28
 
24
29
  jobs:
25
30
  resolve_handles:
31
+ if: >
32
+ github.event_name != 'workflow_run' ||
33
+ github.event.workflow_run.conclusion == 'success'
26
34
  runs-on: ubuntu-latest
27
35
  outputs:
28
36
  handles_json: ${{ steps.resolve.outputs.handles_json }}
29
37
  steps:
30
38
  - uses: actions/checkout@v5
31
39
  with:
32
- ref: ${{ github.sha }}
40
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
33
41
  fetch-depth: 0
34
42
 
35
43
  - uses: oven-sh/setup-bun@v2
@@ -41,7 +49,7 @@ jobs:
41
49
  id: resolve
42
50
  env:
43
51
  HANDLE_INPUT: ${{ inputs.handle || '' }}
44
- BEFORE_SHA: ${{ github.event.before || '' }}
52
+ BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
45
53
  run: bun deploy/scripts/resolve-deploy-handles.ts
46
54
 
47
55
  no_changes:
@@ -68,7 +76,7 @@ jobs:
68
76
  steps:
69
77
  - uses: actions/checkout@v5
70
78
  with:
71
- ref: ${{ github.sha }}
79
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
72
80
 
73
81
  - uses: oven-sh/setup-bun@v2
74
82
 
@@ -96,7 +104,8 @@ jobs:
96
104
  env:
97
105
  SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
98
106
  SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
99
- SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
107
+ SETUP_EMAIL_API_KEY: ${{ secrets.SETUP_EMAIL_API_KEY }}
108
+ SETUP_EMAIL_FROM: ${{ secrets.SETUP_EMAIL_FROM }}
100
109
  HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
101
110
  HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
102
111
  HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
@@ -104,13 +113,16 @@ jobs:
104
113
  KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
105
114
  KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
106
115
  CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
107
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
108
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
109
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
116
+ CONFIG_CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
117
+ SHARED_CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
118
+ SHARED_CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
119
+ SHARED_PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
110
120
  run: |
111
121
  export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
112
122
  export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
113
- export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
123
+ export CF_ZONE_ID="${CONFIG_CF_ZONE_ID:-${CF_ZONE_ID:-$SHARED_CF_ZONE_ID}}"
124
+ export CERTIFICATE_PEM="${CERTIFICATE_PEM:-$SHARED_CERTIFICATE_PEM}"
125
+ export PRIVATE_KEY_PEM="${PRIVATE_KEY_PEM:-$SHARED_PRIVATE_KEY_PEM}"
114
126
  bun deploy/scripts/validate-secrets.ts
115
127
 
116
128
  - name: Seed content repo
@@ -122,7 +134,7 @@ jobs:
122
134
  bun deploy/scripts/sync-content-repo.ts
123
135
 
124
136
  - name: Log in to GHCR
125
- uses: docker/login-action@v3
137
+ uses: docker/login-action@v4
126
138
  with:
127
139
  registry: ghcr.io
128
140
  username: ${{ github.actor }}
@@ -130,7 +142,7 @@ jobs:
130
142
 
131
143
  - name: Wait for shared image tag
132
144
  run: |
133
- IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:brain-${{ steps.user_config.outputs.brain_version }}"
145
+ IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
134
146
  for attempt in $(seq 1 24); do
135
147
  if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
136
148
  exit 0
@@ -168,14 +180,16 @@ jobs:
168
180
  env:
169
181
  SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
170
182
  SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
171
- SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
183
+ SETUP_EMAIL_API_KEY: ${{ secrets.SETUP_EMAIL_API_KEY }}
184
+ SETUP_EMAIL_FROM: ${{ secrets.SETUP_EMAIL_FROM }}
172
185
  KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
173
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
174
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
186
+ SHARED_CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
187
+ SHARED_PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
175
188
  run: |
176
189
  export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
177
190
  export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
178
- export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
191
+ export CERTIFICATE_PEM="${CERTIFICATE_PEM:-$SHARED_CERTIFICATE_PEM}"
192
+ export PRIVATE_KEY_PEM="${PRIVATE_KEY_PEM:-$SHARED_PRIVATE_KEY_PEM}"
179
193
  bun deploy/scripts/write-kamal-secrets.ts
180
194
 
181
195
  - name: Provision server
@@ -191,12 +205,18 @@ jobs:
191
205
  - name: Update Cloudflare DNS
192
206
  env:
193
207
  CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
194
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
208
+ CONFIG_CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
209
+ SHARED_CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
195
210
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
211
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
196
212
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
197
213
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
198
214
  run: |
215
+ export CF_ZONE_ID="${CONFIG_CF_ZONE_ID:-${CF_ZONE_ID:-$SHARED_CF_ZONE_ID}}"
199
216
  bun deploy/scripts/update-dns.ts
217
+ if [ -n "$WWW_DOMAIN" ]; then
218
+ BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
219
+ fi
200
220
  BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
201
221
 
202
222
  - name: Validate SSH key
@@ -220,10 +240,11 @@ jobs:
220
240
  - name: Deploy
221
241
  env:
222
242
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
223
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
243
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
224
244
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
225
245
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
226
246
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
247
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
227
248
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
228
249
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
229
250
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
@@ -232,9 +253,13 @@ jobs:
232
253
  env:
233
254
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
234
255
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
256
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
235
257
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
236
258
  run: |
237
259
  curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
260
+ if [ -n "$WWW_DOMAIN" ]; then
261
+ curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
262
+ fi
238
263
  curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
239
264
 
240
265
  - name: Upload generated config
@@ -271,7 +296,7 @@ jobs:
271
296
  steps:
272
297
  - uses: actions/checkout@v5
273
298
  with:
274
- ref: ${{ github.sha }}
299
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
275
300
 
276
301
  - uses: oven-sh/setup-bun@v2
277
302
 
@@ -290,11 +315,31 @@ jobs:
290
315
 
291
316
  - name: Commit generated config
292
317
  run: |
318
+ # A newly added user's generated directory is untracked, and
319
+ # `git diff` cannot see untracked files — without intent-to-add
320
+ # the new config is silently dropped here and every later deploy
321
+ # skips that user (their files never appear in a commit range).
322
+ git add --intent-to-add -- users views
323
+
293
324
  if git diff --quiet -- users views; then
294
325
  exit 0
295
326
  fi
327
+
328
+ git fetch origin "${{ github.ref_name }}"
329
+ if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
330
+ exit 0
331
+ fi
332
+
333
+ patch_file="$(mktemp)"
334
+ git diff --binary -- users views > "$patch_file"
335
+ git reset --hard "origin/${{ github.ref_name }}"
336
+ git apply --3way --index "$patch_file"
337
+
338
+ if git diff --cached --quiet -- users views; then
339
+ exit 0
340
+ fi
341
+
296
342
  git config user.name "github-actions[bot]"
297
343
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
298
- git add users views
299
344
  git commit -m "chore(ops): reconcile generated config"
300
345
  git push origin HEAD:${{ github.ref_name }}
@@ -35,11 +35,31 @@ jobs:
35
35
 
36
36
  - name: Commit generated outputs
37
37
  run: |
38
+ # A newly added user's generated directory is untracked, and
39
+ # `git diff` cannot see untracked files — without intent-to-add
40
+ # the new config is silently dropped here and every later deploy
41
+ # skips that user (their files never appear in a commit range).
42
+ git add --intent-to-add -- views users
43
+
38
44
  if git diff --quiet -- views users; then
39
45
  exit 0
40
46
  fi
47
+
48
+ git fetch origin "${{ github.ref_name }}"
49
+ if git diff --quiet "origin/${{ github.ref_name }}" -- views users; then
50
+ exit 0
51
+ fi
52
+
53
+ patch_file="$(mktemp)"
54
+ git diff --binary -- views users > "$patch_file"
55
+ git reset --hard "origin/${{ github.ref_name }}"
56
+ git apply --3way --index "$patch_file"
57
+
58
+ if git diff --cached --quiet -- views users; then
59
+ exit 0
60
+ fi
61
+
41
62
  git config user.name "github-actions[bot]"
42
63
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
43
- git add views users
44
64
  git commit -m "chore(ops): reconcile pilot outputs"
45
65
  git push origin HEAD:${{ github.ref_name }}
@@ -29,6 +29,7 @@ The repo also checks in its deploy contract:
29
29
  - `.github/workflows/*`
30
30
 
31
31
  `.env.schema` is the single source of truth for required and sensitive deploy vars.
32
+ Use separate GitHub tokens: `CONTENT_REPO_ADMIN_TOKEN` for operator-side content repo creation/checks, and `GIT_SYNC_TOKEN` for runtime directory-sync git access.
32
33
  The shared pilot image tag is `brain-${brainVersion}` end to end.
33
34
  When `pilot.yaml.brainVersion` changes and you push, CI rebuilds the shared tag, refreshes generated user env files, and redeploys affected users.
34
35
  When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
@@ -37,7 +38,8 @@ When a push changes only deploy contract files, CI prints `No affected user conf
37
38
 
38
39
  - `brains-ops init <repo>`
39
40
  - `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
40
- - `brains-ops onboard <repo> <handle>`
41
+ - `brains-ops user:add <repo> <handle> --cohort <cohort>` — scaffolds a user file, per-user secrets template, and cohort membership
42
+ - `brains-ops onboard <repo> <handle>` — creates/seeds the user's content repo with separate admin and sync tokens
41
43
  - `brains-ops age-key:bootstrap <repo>`
42
44
  - `brains-ops ssh-key:bootstrap <repo>`
43
45
  - `brains-ops cert:bootstrap <repo>`
@@ -20,8 +20,16 @@ const pilot = parseFlatYaml(readFileSync("pilot.yaml", "utf8"));
20
20
 
21
21
  writeGitHubEnv("AI_API_KEY", secrets["aiApiKey"] ?? "");
22
22
  writeGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"] ?? "");
23
- writeGitHubEnv("MCP_AUTH_TOKEN", secrets["mcpAuthToken"] ?? "");
24
23
  writeGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
24
+ writeGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"] ?? "");
25
+ writeGitHubEnv(
26
+ "CERTIFICATE_PEM",
27
+ decodeEscapedSecret(secrets["certificatePem"]),
28
+ );
29
+ writeGitHubEnv(
30
+ "PRIVATE_KEY_PEM",
31
+ decodeEscapedSecret(secrets["privateKeyPem"]),
32
+ );
25
33
 
26
34
  writeGitHubOutput(
27
35
  "shared_ai_api_key_secret_name",
@@ -31,10 +39,6 @@ writeGitHubOutput(
31
39
  "shared_git_sync_token_secret_name",
32
40
  requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
33
41
  );
34
- writeGitHubOutput(
35
- "shared_mcp_auth_token_secret_name",
36
- requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
37
- );
38
42
 
39
43
  function extractAgeIdentity(contents: string): string {
40
44
  const line = contents
@@ -70,6 +74,10 @@ function parseFlatYaml(contents: string): Record<string, string> {
70
74
  return result;
71
75
  }
72
76
 
77
+ function decodeEscapedSecret(value: string | undefined): string {
78
+ return value?.replace(/\\n/g, "\n") ?? "";
79
+ }
80
+
73
81
  function requireFlatValue(
74
82
  values: Record<string, string>,
75
83
  key: string,
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bun
2
+ import { createHash } from "node:crypto";
3
+ import { loadPilotRegistry } from "@rizom/ops";
4
+ import { writeGitHubEnv, writeGitHubOutput } from "./helpers";
5
+
6
+ const requestedBrainVersion = process.env["BRAIN_VERSION_INPUT"]?.trim();
7
+ const registry = await loadPilotRegistry(process.cwd());
8
+ const brainVersion =
9
+ requestedBrainVersion && requestedBrainVersion.length > 0
10
+ ? requestedBrainVersion
11
+ : registry.pilot.brainVersion;
12
+
13
+ const sitePackages = [
14
+ ...new Set(
15
+ registry.users
16
+ .filter((user) => user.brainVersion === brainVersion)
17
+ .flatMap((user) =>
18
+ user.siteOverride
19
+ ? [`${user.siteOverride.package}@${user.siteOverride.version}`]
20
+ : [],
21
+ ),
22
+ ),
23
+ ].sort();
24
+ const sitePackagesValue = sitePackages.join(" ");
25
+ const imageTag = buildImageTag(brainVersion, sitePackages);
26
+
27
+ writeGitHubEnv("BRAIN_VERSION", brainVersion);
28
+ writeGitHubEnv("SITE_PACKAGES", sitePackagesValue);
29
+ writeGitHubEnv("IMAGE_TAG", imageTag);
30
+ writeGitHubOutput("brain_version", brainVersion);
31
+ writeGitHubOutput("site_packages", sitePackagesValue);
32
+ writeGitHubOutput("image_tag", imageTag);
33
+
34
+ console.log(`BRAIN_VERSION=${brainVersion}`);
35
+ console.log(`IMAGE_TAG=${imageTag}`);
36
+ console.log(
37
+ sitePackages.length > 0
38
+ ? `SITE_PACKAGES=${sitePackagesValue}`
39
+ : "SITE_PACKAGES=(none)",
40
+ );
41
+
42
+ function buildImageTag(brainVersion: string, sitePackages: string[]): string {
43
+ if (sitePackages.length === 0) {
44
+ return `brain-${brainVersion}`;
45
+ }
46
+
47
+ const siteHash = createHash("sha256")
48
+ .update(sitePackages.join("\n"))
49
+ .digest("hex")
50
+ .slice(0, 12);
51
+
52
+ return `brain-${brainVersion}-sites-${siteHash}`;
53
+ }
@@ -9,14 +9,23 @@ if (eventName === "workflow_dispatch") {
9
9
  process.exit(0);
10
10
  }
11
11
 
12
- if (eventName !== "push") {
12
+ if (eventName !== "push" && eventName !== "workflow_run") {
13
13
  throw new Error(`Unsupported GITHUB_EVENT_NAME: ${eventName}`);
14
14
  }
15
15
 
16
16
  const beforeSha = requireEnv("BEFORE_SHA");
17
- const currentSha = requireEnv("GITHUB_SHA");
17
+ const currentSha =
18
+ eventName === "workflow_run"
19
+ ? execFileSync("git", ["rev-parse", "HEAD"], {
20
+ encoding: "utf8",
21
+ }).trim()
22
+ : requireEnv("GITHUB_SHA");
18
23
 
19
- if (!isUsableGitRevision(beforeSha) || !isUsableGitRevision(currentSha)) {
24
+ if (
25
+ !isUsableGitRevision(beforeSha) ||
26
+ !isUsableGitRevision(currentSha) ||
27
+ beforeSha === currentSha
28
+ ) {
20
29
  writeGitHubOutput("handles_json", JSON.stringify([]));
21
30
  process.exit(0);
22
31
  }
@@ -34,6 +43,7 @@ const handles = [
34
43
  .map((path) => {
35
44
  const match =
36
45
  path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
46
+ path.match(/^users\/([^/]+)\.yaml$/) ??
37
47
  path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
38
48
  return match?.[1] ?? null;
39
49
  })
@@ -1,4 +1,6 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFileSync } from "node:fs";
3
+ import { loadPilotRegistry } from "@rizom/ops";
2
4
 
3
5
  import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
4
6
 
@@ -7,6 +9,14 @@ const envPath = `users/${handle}/.env`;
7
9
  const brainYamlPath = `users/${handle}/brain.yaml`;
8
10
 
9
11
  const envEntries = parseEnvFile(envPath);
12
+ const registry = await loadPilotRegistry(process.cwd());
13
+ const user = registry.users.find((candidate) => candidate.handle === handle);
14
+ if (!user) {
15
+ throw new Error(`Unknown user handle: ${handle}`);
16
+ }
17
+
18
+ const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
19
+ const imageTag = resolveImageTag(registry, brainVersion);
10
20
  const repository = process.env["GITHUB_REPOSITORY"] ?? "";
11
21
  const repositoryOwner = repository.split("/")[0] ?? "";
12
22
 
@@ -17,23 +27,28 @@ if (!brainDomain) {
17
27
  throw new Error(`Missing domain in ${brainYamlPath}`);
18
28
  }
19
29
 
20
- const zone =
21
- brainDomain.startsWith(`${handle}.`) && brainDomain.length > handle.length + 1
22
- ? brainDomain.slice(handle.length + 1)
30
+ const pilotSubdomainPrefix = `${handle}.`;
31
+ const pilotZone =
32
+ brainDomain.startsWith(pilotSubdomainPrefix) &&
33
+ brainDomain.length > pilotSubdomainPrefix.length
34
+ ? brainDomain.slice(pilotSubdomainPrefix.length)
23
35
  : "";
24
- if (!zone) {
25
- throw new Error(`Could not derive preview domain from ${brainDomain}`);
26
- }
27
- const previewDomain = `${handle}-preview.${zone}`;
36
+ const previewDomain = pilotZone
37
+ ? `${handle}-preview.${pilotZone}`
38
+ : `preview.${brainDomain}`;
39
+ const wwwDomain = pilotZone ? "" : `www.${brainDomain}`;
28
40
 
29
41
  const outputs: Record<string, string> = {
30
- brain_version: envEntries["BRAIN_VERSION"] ?? "",
42
+ brain_version: brainVersion,
31
43
  content_repo: envEntries["CONTENT_REPO"] ?? "",
32
44
  brain_domain: brainDomain,
33
45
  preview_domain: previewDomain,
46
+ www_domain: wwwDomain,
47
+ cloudflare_zone_id: user.cloudflareZoneId ?? "",
34
48
  brain_yaml_path: brainYamlPath,
35
49
  instance_name: `rover-${handle}`,
36
50
  image_repository: `ghcr.io/${repository}`,
51
+ image_tag: imageTag,
37
52
  registry_username: repositoryOwner,
38
53
  };
39
54
 
@@ -47,3 +62,35 @@ for (const key of required) {
47
62
  for (const [key, value] of Object.entries(outputs)) {
48
63
  writeGitHubOutput(key, value);
49
64
  }
65
+
66
+ function resolveImageTag(
67
+ registry: Awaited<ReturnType<typeof loadPilotRegistry>>,
68
+ brainVersion: string,
69
+ ): string {
70
+ if (!brainVersion) {
71
+ return "";
72
+ }
73
+
74
+ const sitePackages = [
75
+ ...new Set(
76
+ registry.users
77
+ .filter((user) => user.brainVersion === brainVersion)
78
+ .flatMap((user) =>
79
+ user.siteOverride
80
+ ? [`${user.siteOverride.package}@${user.siteOverride.version}`]
81
+ : [],
82
+ ),
83
+ ),
84
+ ].sort();
85
+
86
+ if (sitePackages.length === 0) {
87
+ return `brain-${brainVersion}`;
88
+ }
89
+
90
+ const siteHash = createHash("sha256")
91
+ .update(sitePackages.join("\n"))
92
+ .digest("hex")
93
+ .slice(0, 12);
94
+
95
+ return `brain-${brainVersion}-sites-${siteHash}`;
96
+ }
@@ -16,8 +16,11 @@ interface CloudflareResult {
16
16
  result?: Array<{ id: string }>;
17
17
  }
18
18
 
19
- async function upsertRecord(name: string): Promise<void> {
20
- const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=A&name=${encodeURIComponent(name)}`;
19
+ async function findRecordId(
20
+ name: string,
21
+ type: "A" | "CNAME",
22
+ ): Promise<string | undefined> {
23
+ const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
21
24
  const lookup = await fetch(lookupUrl, { headers });
22
25
  const payload = (await readJsonResponse(
23
26
  lookup,
@@ -27,9 +30,16 @@ async function upsertRecord(name: string): Promise<void> {
27
30
  throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
28
31
  }
29
32
 
30
- const existing = payload.result?.[0];
33
+ return payload.result?.[0]?.id;
34
+ }
35
+
36
+ async function upsertRecord(name: string): Promise<void> {
37
+ // Prefer an existing A record. If the hostname currently has a CNAME,
38
+ // replace that CNAME in-place so deploys can claim legacy www aliases.
39
+ const existing =
40
+ (await findRecordId(name, "A")) ?? (await findRecordId(name, "CNAME"));
31
41
  const url = existing
32
- ? `${baseUrl}/zones/${zoneId}/dns_records/${existing.id}`
42
+ ? `${baseUrl}/zones/${zoneId}/dns_records/${existing}`
33
43
  : `${baseUrl}/zones/${zoneId}/dns_records`;
34
44
 
35
45
  const response = await fetch(url, {
@@ -4,23 +4,43 @@
4
4
  2. Run `bunx brains-ops age-key:bootstrap <repo> --push-to gh`.
5
5
  3. Fill in `pilot.yaml`.
6
6
  - keep your pinned `brainVersion`
7
- - confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `mcpAuthToken`
7
+ - confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `contentRepoAdminToken`
8
+ - use different tokens for `contentRepoAdminToken` and `gitSyncToken`: admin creates/checks content repos; sync is used by runtime directory-sync
8
9
  - confirm `agePublicKey`
9
- 4. Add or edit `users/<handle>.yaml`.
10
- - Discord is enabled by default for pilot users
11
- - if the user should be an anchor there, set `discord.anchorUserId` to their Discord user ID
12
- 5. Add the user to a cohort in `cohorts/*.yaml`.
10
+ 4. Run `bunx brains-ops user:add <repo> <handle> --cohort <cohort>`.
11
+ - Web chat is the primary interface; it needs no per-user setup beyond the passkey.
12
+ - `user:add` currently writes `discord: enabled: true`; set it to `false` unless the user's cohort actually uses Discord.
13
+ - if the user should be an anchor on Discord, add `--anchor-id <discord-user-id>`.
14
+ - the command creates `users/<handle>.yaml`, `users/<handle>.secrets.yaml`, and the cohort membership without duplicating existing entries.
15
+ 5. Edit the generated user file if the anchor profile needs richer metadata.
16
+ - Set `setup.delivery: email` and `setup.email` so the user gets the passkey setup email — this is the default onboarding path.
17
+ - For ATProto publishing, add `atproto.identifier` to the user file; put only `atprotoAppPassword` in the per-user secrets file.
18
+ - Ensure `SETUP_EMAIL_API_KEY` and `SETUP_EMAIL_FROM` exist as GitHub Secrets before deploying any email-setup user.
13
19
  6. Run `bunx brains-ops render <repo>`.
14
20
  7. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
15
21
  8. Run `bunx brains-ops cert:bootstrap <repo> --push-to gh`.
16
- 9. Keep raw user secret material locally for now (`.env.local`, file-backed env vars, or equivalent local inputs).
22
+ 9. Keep raw user secret material locally for now (`.env.local`, file-backed env vars, or equivalent local inputs), including `CONTENT_REPO_ADMIN_TOKEN` for operator onboarding.
17
23
  10. Run `bunx brains-ops secrets:encrypt <repo> <handle>`.
18
24
  11. Commit and push `users/<handle>.secrets.yaml.age`.
19
25
  12. Run `bunx brains-ops onboard <repo> <handle>`.
20
- 13. Verify the deployed rover core contract:
21
- - `https://<handle>.rizom.ai/health` returns `200`
22
- - unauthenticated `POST https://<handle>.rizom.ai/mcp` returns `401`
26
+ 13. Verify the deployed Rover contract:
27
+ - all presets:
28
+ - `https://<handle>.rizom.ai/health` returns `200`
29
+ - `https://<handle>.rizom.ai/chat` loads the web chat and accepts passkey sign-in
30
+ - `https://<handle>.rizom.ai/` loads the dashboard (or site surface on `default` preset)
31
+ - `https://<handle>.rizom.ai/cms` loads the CMS/login surface
32
+ - unauthenticated `POST https://<handle>.rizom.ai/mcp` returns the expected auth failure
33
+ - content repo exists and runtime sync is healthy
34
+ - background jobs are not repeatedly failing, except for expected missing optional integrations
35
+ - for `presetOverride: default` users:
36
+ - initial site build completes
23
37
  14. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the shared image tag, refreshes generated user env files, and redeploys affected users.
24
- 15. Hand the Discord setup details to the user. If they need direct client access, also hand over the MCP connection details.
25
- 16. If you are also giving them a content repo workflow, describe it first as a normal git repo of markdown/text files; mention Obsidian only as an optional editor.
26
- 17. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.
38
+ 15. Confirm the user received the setup email, registered their passkey, and can sign in to web chat at `https://<handle>.rizom.ai/chat`. That completes the default onboarding; everything below is per-cohort extras.
39
+ 16. Hand over the browser surfaces:
40
+ - Chat (primary): `https://<handle>.rizom.ai/chat`
41
+ - Dashboard: `https://<handle>.rizom.ai/`
42
+ - CMS: `https://<handle>.rizom.ai/cms`, plus GitHub token guidance if CMS editing is part of their cohort
43
+ 17. For Discord-enabled cohorts, hand the Discord setup details to the user as a secondary chat surface.
44
+ 18. If they need direct client access (MCP), use OAuth/passkey-capable clients where possible.
45
+ 19. If you are also giving them a content repo workflow, describe it as optional and frame git/Obsidian as an advanced file-based path, not the default.
46
+ 20. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.