@rizom/ops 0.2.0-alpha.19 → 0.2.0-alpha.190

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 (45) 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 +64 -17
  27. package/templates/rover-pilot/.github/workflows/deploy.yml +100 -61
  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 +34 -6
  31. package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
  32. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +12 -3
  33. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  34. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +22 -2
  35. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
  36. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  37. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +1 -1
  38. package/templates/rover-pilot/docs/onboarding-checklist.md +32 -12
  39. package/templates/rover-pilot/docs/operator-playbook.md +117 -6
  40. package/templates/rover-pilot/docs/user-onboarding.md +67 -332
  41. package/templates/rover-pilot/pilot.yaml +1 -1
  42. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  43. package/templates/rover-pilot/deploy/Caddyfile +0 -67
  44. package/templates/rover-pilot/deploy/Dockerfile +0 -38
  45. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
@@ -2,10 +2,24 @@ name: Build
2
2
 
3
3
  on:
4
4
  workflow_dispatch:
5
+ inputs:
6
+ brain_version:
7
+ description: Force one explicit build at this brain version (skips the registry-derived resolve)
8
+ required: false
9
+ type: string
10
+ site_packages:
11
+ description: 'Space-separated site packages for a per-instance site image (e.g. "@rizom/site-rizom-ai@0.2.0-alpha.148"). Empty builds the default fleet image.'
12
+ required: false
13
+ type: string
5
14
  push:
6
15
  branches: ["main"]
7
16
  paths:
17
+ # The image set is a pure function of the declared fleet state: a push
18
+ # that changes it builds exactly what it declares, so the deploy (fired
19
+ # off the same push via Reconcile) finds its tag ready.
8
20
  - pilot.yaml
21
+ - cohorts/**
22
+ - users/*.yaml
9
23
  - deploy/**
10
24
  - .github/workflows/build.yml
11
25
 
@@ -14,50 +28,83 @@ permissions:
14
28
  packages: write
15
29
 
16
30
  jobs:
17
- build:
31
+ resolve:
18
32
  runs-on: ubuntu-latest
33
+ outputs:
34
+ images_json: ${{ steps.resolve.outputs.images_json }}
19
35
  steps:
20
36
  - uses: actions/checkout@v5
21
37
  with:
22
38
  ref: ${{ github.sha }}
23
39
 
24
- - name: Extract image metadata inputs
25
- run: |
26
- BRAIN_VERSION="$(grep '^brainVersion:' pilot.yaml | sed 's/^brainVersion:[[:space:]]*//' | tr -d '"' | tr -d "'")"
27
- if [ -z "$BRAIN_VERSION" ]; then
28
- echo "Missing brainVersion in pilot.yaml" >&2
29
- exit 1
30
- fi
31
- echo "BRAIN_VERSION=$BRAIN_VERSION" >> "$GITHUB_ENV"
32
- echo "IMAGE_REPOSITORY=ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}" >> "$GITHUB_ENV"
40
+ - uses: oven-sh/setup-bun@v2
41
+
42
+ - name: Install operator tooling
43
+ run: bun install
44
+
45
+ - name: Log in to GHCR
46
+ uses: docker/login-action@v4
47
+ with:
48
+ registry: ghcr.io
49
+ username: ${{ github.actor }}
50
+ password: ${{ secrets.GITHUB_TOKEN }}
51
+
52
+ - name: Resolve images to build
53
+ id: resolve
54
+ env:
55
+ BRAIN_VERSION_INPUT: ${{ inputs.brain_version || '' }}
56
+ SITE_PACKAGES_INPUT: ${{ inputs.site_packages || '' }}
57
+ run: bun deploy/scripts/resolve-missing-images.ts
58
+
59
+ no_builds:
60
+ needs: resolve
61
+ if: needs.resolve.outputs.images_json == '[]'
62
+ runs-on: ubuntu-latest
63
+ steps:
64
+ - name: Skip when every declared image exists
65
+ run: echo "All declared images already exist; nothing to build."
66
+
67
+ build:
68
+ needs: resolve
69
+ if: needs.resolve.outputs.images_json != '[]'
70
+ runs-on: ubuntu-latest
71
+ strategy:
72
+ fail-fast: false
73
+ matrix:
74
+ image: ${{ fromJson(needs.resolve.outputs.images_json) }}
75
+ steps:
76
+ - uses: actions/checkout@v5
77
+ with:
78
+ ref: ${{ github.sha }}
33
79
 
34
80
  - name: Set up Docker Buildx
35
- uses: docker/setup-buildx-action@v3
81
+ uses: docker/setup-buildx-action@v4
36
82
 
37
83
  - name: Extract image metadata
38
84
  id: meta
39
- uses: docker/metadata-action@v5
85
+ uses: docker/metadata-action@v6
40
86
  with:
41
- images: ${{ env.IMAGE_REPOSITORY }}
87
+ images: ghcr.io/${{ github.repository }}
42
88
  tags: |
43
- type=raw,value=brain-${{ env.BRAIN_VERSION }}
89
+ type=raw,value=${{ matrix.image.tag }}
44
90
 
45
91
  - name: Log in to GHCR
46
- uses: docker/login-action@v3
92
+ uses: docker/login-action@v4
47
93
  with:
48
94
  registry: ghcr.io
49
95
  username: ${{ github.actor }}
50
96
  password: ${{ secrets.GITHUB_TOKEN }}
51
97
 
52
98
  - name: Build and push image
53
- uses: docker/build-push-action@v6
99
+ uses: docker/build-push-action@v7
54
100
  with:
55
101
  context: .
56
102
  file: deploy/Dockerfile
57
103
  target: fleet
58
104
  push: true
59
105
  build-args: |
60
- BRAIN_VERSION=${{ env.BRAIN_VERSION }}
106
+ BRAIN_VERSION=${{ matrix.image.brain_version }}
107
+ SITE_PACKAGES=${{ matrix.image.site_packages }}
61
108
  tags: ${{ steps.meta.outputs.tags }}
62
109
  labels: |
63
110
  ${{ steps.meta.outputs.labels }}
@@ -16,6 +16,10 @@ on:
16
16
  - users/*.secrets.yaml.age
17
17
  - deploy/**
18
18
  - .github/workflows/deploy.yml
19
+ workflow_run:
20
+ workflows: [Reconcile]
21
+ types: [completed]
22
+ branches: [main]
19
23
 
20
24
  permissions:
21
25
  contents: write
@@ -23,13 +27,16 @@ permissions:
23
27
 
24
28
  jobs:
25
29
  resolve_handles:
30
+ if: >
31
+ github.event_name != 'workflow_run' ||
32
+ github.event.workflow_run.conclusion == 'success'
26
33
  runs-on: ubuntu-latest
27
34
  outputs:
28
35
  handles_json: ${{ steps.resolve.outputs.handles_json }}
29
36
  steps:
30
37
  - uses: actions/checkout@v5
31
38
  with:
32
- ref: ${{ github.sha }}
39
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
33
40
  fetch-depth: 0
34
41
 
35
42
  - uses: oven-sh/setup-bun@v2
@@ -41,7 +48,7 @@ jobs:
41
48
  id: resolve
42
49
  env:
43
50
  HANDLE_INPUT: ${{ inputs.handle || '' }}
44
- BEFORE_SHA: ${{ github.event.before || '' }}
51
+ BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
45
52
  run: bun deploy/scripts/resolve-deploy-handles.ts
46
53
 
47
54
  no_changes:
@@ -68,58 +75,86 @@ jobs:
68
75
  steps:
69
76
  - uses: actions/checkout@v5
70
77
  with:
71
- ref: ${{ github.sha }}
78
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
72
79
 
73
80
  - uses: oven-sh/setup-bun@v2
74
81
 
75
82
  - name: Install operator tooling
76
83
  run: bun install
77
84
 
85
+ - name: Load shared env via varlock
86
+ env:
87
+ BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
88
+ run: |
89
+ for attempt in 1 2 3; do
90
+ if bunx varlock@1.1.0 load --path .env.schema --format json --compact > /tmp/varlock-env.json; then
91
+ break
92
+ fi
93
+ if [ "$attempt" = "3" ]; then
94
+ exit 1
95
+ fi
96
+ echo "Varlock load failed (attempt $attempt/3); retrying in 5s..."
97
+ sleep 5
98
+ done
99
+ node <<'NODE'
100
+ import { appendFileSync, readFileSync } from "node:fs";
101
+ const env = JSON.parse(readFileSync('/tmp/varlock-env.json', 'utf8'));
102
+ const githubEnvPath = process.env.GITHUB_ENV;
103
+ if (!githubEnvPath) {
104
+ throw new Error('Missing GITHUB_ENV');
105
+ }
106
+
107
+ const newline = String.fromCharCode(10);
108
+ const carriageReturn = String.fromCharCode(13);
109
+ const chunks = [];
110
+ for (const [key, value] of Object.entries(env)) {
111
+ if (key === 'BWS_ACCESS_TOKEN' || value === null || value === undefined) {
112
+ continue;
113
+ }
114
+
115
+ const text = String(value)
116
+ .split(carriageReturn + newline)
117
+ .join(newline);
118
+ const escapedMask = text
119
+ .replace(/%/g, '%25')
120
+ .replace(/\r/g, '%0D')
121
+ .replace(/\n/g, '%0A');
122
+ if (escapedMask.length > 0) {
123
+ process.stdout.write('::add-mask::' + escapedMask + newline);
124
+ }
125
+
126
+ if (text.includes(newline)) {
127
+ let delimiter = `VARLOCK_${key}_EOF`;
128
+ while (text.includes(delimiter)) {
129
+ delimiter += '_X';
130
+ }
131
+ chunks.push(`${key}<<${delimiter}${newline}${text}${newline}${delimiter}`);
132
+ } else {
133
+ chunks.push(`${key}=${text}`);
134
+ }
135
+ }
136
+
137
+ appendFileSync(githubEnvPath, chunks.join(newline) + newline);
138
+ NODE
139
+
78
140
  - name: Decrypt user secrets
79
141
  id: user_secrets
80
- env:
81
- AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
82
142
  run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
83
143
 
84
144
  - name: Reconcile selected user config
85
- env:
86
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
87
- run: |
88
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
89
- bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
145
+ run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
90
146
 
91
147
  - name: Resolve generated user config
92
148
  id: user_config
93
149
  run: bun deploy/scripts/resolve-user-config.ts
94
150
 
95
151
  - name: Validate selected secrets
96
- env:
97
- SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
98
- 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] }}
100
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
101
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
102
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
103
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
104
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
105
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
106
- 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 }}
110
- run: |
111
- export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
112
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
113
- export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
114
- bun deploy/scripts/validate-secrets.ts
152
+ run: bun deploy/scripts/validate-secrets.ts
115
153
 
116
154
  - name: Seed content repo
117
155
  env:
118
156
  CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
119
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
120
- run: |
121
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
122
- bun deploy/scripts/sync-content-repo.ts
157
+ run: bun deploy/scripts/sync-content-repo.ts
123
158
 
124
159
  - name: Log in to GHCR
125
160
  uses: docker/login-action@v3
@@ -128,15 +163,18 @@ jobs:
128
163
  username: ${{ github.actor }}
129
164
  password: ${{ secrets.GITHUB_TOKEN }}
130
165
 
131
- - name: Wait for shared image tag
166
+ - name: Wait for image tag
132
167
  run: |
133
- IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:brain-${{ steps.user_config.outputs.brain_version }}"
134
- for attempt in $(seq 1 24); do
168
+ # The Build workflow fires off the same config push (it derives the
169
+ # declared image set from the registry), so this wait must cover a
170
+ # full image build, not just registry propagation.
171
+ IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
172
+ for attempt in $(seq 1 60); do
135
173
  if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
136
174
  exit 0
137
175
  fi
138
- echo "Shared image tag not ready yet ($attempt/24): $IMAGE_TAG"
139
- sleep 10
176
+ echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
177
+ sleep 20
140
178
  done
141
179
  echo "Timed out waiting for $IMAGE_TAG" >&2
142
180
  exit 1
@@ -147,8 +185,6 @@ jobs:
147
185
  ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
148
186
 
149
187
  - name: Write Kamal SSH key
150
- env:
151
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
152
188
  run: bun deploy/scripts/write-ssh-key.ts
153
189
 
154
190
  - name: Configure SSH client
@@ -165,33 +201,16 @@ jobs:
165
201
  chmod 600 ~/.ssh/config
166
202
 
167
203
  - name: Write .kamal/secrets
168
- env:
169
- SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
170
- 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] }}
172
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
173
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
174
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
175
- run: |
176
- export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
177
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
178
- export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
179
- bun deploy/scripts/write-kamal-secrets.ts
204
+ run: bun deploy/scripts/write-kamal-secrets.ts
180
205
 
181
206
  - name: Provision server
182
207
  id: provision
183
208
  env:
184
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
185
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
186
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
187
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
188
209
  INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
189
210
  run: bun deploy/scripts/provision-server.ts
190
211
 
191
212
  - name: Update Cloudflare DNS
192
213
  env:
193
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
194
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
195
214
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
196
215
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
197
216
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
@@ -220,7 +239,7 @@ jobs:
220
239
  - name: Deploy
221
240
  env:
222
241
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
223
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
242
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
224
243
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
225
244
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
226
245
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
@@ -271,7 +290,7 @@ jobs:
271
290
  steps:
272
291
  - uses: actions/checkout@v5
273
292
  with:
274
- ref: ${{ github.sha }}
293
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
275
294
 
276
295
  - uses: oven-sh/setup-bun@v2
277
296
 
@@ -290,11 +309,31 @@ jobs:
290
309
 
291
310
  - name: Commit generated config
292
311
  run: |
312
+ # A newly added user's generated directory is untracked, and
313
+ # `git diff` cannot see untracked files — without intent-to-add
314
+ # the new config is silently dropped here and every later deploy
315
+ # skips that user (their files never appear in a commit range).
316
+ git add --intent-to-add -- users views
317
+
293
318
  if git diff --quiet -- users views; then
294
319
  exit 0
295
320
  fi
321
+
322
+ git fetch origin "${{ github.ref_name }}"
323
+ if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
324
+ exit 0
325
+ fi
326
+
327
+ patch_file="$(mktemp)"
328
+ git diff --binary -- users views > "$patch_file"
329
+ git reset --hard "origin/${{ github.ref_name }}"
330
+ git apply --3way --index "$patch_file"
331
+
332
+ if git diff --cached --quiet -- users views; then
333
+ exit 0
334
+ fi
335
+
296
336
  git config user.name "github-actions[bot]"
297
337
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
298
- git add users views
299
338
  git commit -m "chore(ops): reconcile generated config"
300
339
  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>`
@@ -18,10 +18,19 @@ const plaintext = await decrypter.decrypt(decoded, "text");
18
18
  const secrets = parseFlatYaml(plaintext);
19
19
  const pilot = parseFlatYaml(readFileSync("pilot.yaml", "utf8"));
20
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"] ?? "");
21
+ writeSecretGitHubEnv("AI_API_KEY", secrets["aiApiKey"]);
22
+ writeSecretGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"]);
23
+ writeSecretGitHubEnv(
24
+ "CMS_CONTENT_REPO_PAT",
25
+ secrets["cmsContentRepoPat"] ?? secrets["gitSyncToken"],
26
+ );
27
+ writeSecretGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"]);
28
+ // Per-user AT Protocol publishing credential (optional; from the user's
29
+ // encrypted secrets file). The scaffold wires this so a pilot can publish its
30
+ // brain's agent card to its PDS; a deployment that doesn't publish simply
31
+ // leaves it unset. TLS material (CERTIFICATE_PEM/PRIVATE_KEY_PEM) is handled by
32
+ // the kamal proxy block via shared env, not here.
33
+ writeSecretGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"]);
25
34
 
26
35
  writeGitHubOutput(
27
36
  "shared_ai_api_key_secret_name",
@@ -32,10 +41,29 @@ writeGitHubOutput(
32
41
  requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
33
42
  );
34
43
  writeGitHubOutput(
35
- "shared_mcp_auth_token_secret_name",
36
- requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
44
+ "shared_content_repo_admin_token_secret_name",
45
+ requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
37
46
  );
38
47
 
48
+ function writeSecretGitHubEnv(name: string, value: string | undefined): void {
49
+ if (!value || value.trim().length === 0) {
50
+ return;
51
+ }
52
+
53
+ maskGitHubSecret(value);
54
+ writeGitHubEnv(name, value);
55
+ }
56
+
57
+ function maskGitHubSecret(value: string): void {
58
+ const escaped = value
59
+ .replace(/%/g, "%25")
60
+ .replace(/\r/g, "%0D")
61
+ .replace(/\n/g, "%0A");
62
+ if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
63
+ console.log(`::add-mask::${escaped}`);
64
+ }
65
+ }
66
+
39
67
  function extractAgeIdentity(contents: string): string {
40
68
  const line = contents
41
69
  .split(/\r?\n/)
@@ -6,5 +6,8 @@ export {
6
6
  requireEnv,
7
7
  writeGitHubOutput,
8
8
  writeGitHubEnv,
9
+ siteImageTag,
10
+ sitePackagesFor,
11
+ runResolveMissingImages,
9
12
  } from "@rizom/ops/deploy";
10
13
  export type { EnvSchemaEntry } from "@rizom/ops/deploy";
@@ -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
  }
@@ -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,14 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
- import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
3
+ import { loadPilotRegistry } from "@rizom/ops";
4
+
5
+ import {
6
+ parseEnvFile,
7
+ requireEnv,
8
+ sitePackagesFor,
9
+ siteImageTag,
10
+ writeGitHubOutput,
11
+ } from "./helpers";
4
12
 
5
13
  const handle = requireEnv("HANDLE");
6
14
  const envPath = `users/${handle}/.env`;
@@ -26,14 +34,26 @@ if (!zone) {
26
34
  }
27
35
  const previewDomain = `${handle}-preview.${zone}`;
28
36
 
37
+ const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
38
+
39
+ // The image tag is a pure function of this instance's own config: plain
40
+ // `brain-{version}` for a default instance, or its own `brain-{version}-sites-
41
+ // {hash}` when it declares a siteOverride. Resolved through the same helper the
42
+ // build uses so the tag we wait for and run matches exactly what was pushed.
43
+ const registry = await loadPilotRegistry(process.cwd());
44
+ const user = registry.users.find((entry) => entry.handle === handle);
45
+ const sitePackages = sitePackagesFor(user?.siteOverride);
46
+ const imageTag = siteImageTag(brainVersion, sitePackages);
47
+
29
48
  const outputs: Record<string, string> = {
30
- brain_version: envEntries["BRAIN_VERSION"] ?? "",
49
+ brain_version: brainVersion,
31
50
  content_repo: envEntries["CONTENT_REPO"] ?? "",
32
51
  brain_domain: brainDomain,
33
52
  preview_domain: previewDomain,
34
53
  brain_yaml_path: brainYamlPath,
35
54
  instance_name: `rover-${handle}`,
36
55
  image_repository: `ghcr.io/${repository}`,
56
+ image_tag: imageTag,
37
57
  registry_username: repositoryOwner,
38
58
  };
39
59