@rizom/ops 0.2.0-alpha.2 → 0.2.0-alpha.200

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 (56) hide show
  1. package/README.md +9 -3
  2. package/dist/age-key-bootstrap.d.ts +17 -0
  3. package/dist/brains-ops.js +288 -173
  4. package/dist/cert-bootstrap.d.ts +24 -0
  5. package/dist/content-repo-ref.d.ts +10 -0
  6. package/dist/content-repo.d.ts +13 -0
  7. package/dist/default-user-runner.d.ts +1 -1
  8. package/dist/deploy.js +99 -166
  9. package/dist/entries/deploy.d.ts +3 -2
  10. package/dist/images.d.ts +76 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.js +289 -173
  13. package/dist/load-registry.d.ts +38 -3
  14. package/dist/observed-status.d.ts +12 -0
  15. package/dist/onboard-user.d.ts +2 -2
  16. package/dist/origin-ca.d.ts +1 -0
  17. package/dist/parse-args.d.ts +4 -0
  18. package/dist/push-secrets.d.ts +2 -0
  19. package/dist/push-target.d.ts +1 -0
  20. package/dist/reconcile-all.d.ts +2 -2
  21. package/dist/reconcile-cohort.d.ts +2 -2
  22. package/dist/reconcile-lib.d.ts +4 -2
  23. package/dist/run-command.d.ts +8 -2
  24. package/dist/run-subprocess.d.ts +1 -0
  25. package/dist/schema.d.ts +68 -64
  26. package/dist/secrets-encrypt.d.ts +24 -0
  27. package/dist/secrets-push.d.ts +2 -5
  28. package/dist/ssh-key-bootstrap.d.ts +1 -0
  29. package/dist/user-add.d.ts +15 -0
  30. package/dist/user-runner.d.ts +5 -0
  31. package/dist/verify-user.d.ts +19 -0
  32. package/package.json +44 -40
  33. package/templates/rover-pilot/.env.schema +21 -2
  34. package/templates/rover-pilot/.github/workflows/build.yml +65 -17
  35. package/templates/rover-pilot/.github/workflows/deploy.yml +150 -45
  36. package/templates/rover-pilot/.github/workflows/reconcile.yml +22 -2
  37. package/templates/rover-pilot/README.md +8 -3
  38. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +119 -0
  39. package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
  40. package/templates/rover-pilot/deploy/scripts/provision-server.ts +1 -1
  41. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +15 -4
  42. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  43. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +68 -14
  44. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +183 -0
  45. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  46. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +1 -1
  47. package/templates/rover-pilot/docs/onboarding-checklist.md +43 -8
  48. package/templates/rover-pilot/docs/operator-playbook.md +182 -0
  49. package/templates/rover-pilot/docs/user-onboarding.md +119 -0
  50. package/templates/rover-pilot/package.json +3 -0
  51. package/templates/rover-pilot/pilot.yaml +3 -0
  52. package/templates/rover-pilot/users/alice.yaml +5 -1
  53. package/dist/user-secret-names.d.ts +0 -6
  54. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  55. package/templates/rover-pilot/deploy/Dockerfile +0 -15
  56. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -39
@@ -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,49 +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
103
+ target: fleet
57
104
  push: true
58
105
  build-args: |
59
- BRAIN_VERSION=${{ env.BRAIN_VERSION }}
106
+ BRAIN_VERSION=${{ matrix.image.brain_version }}
107
+ SITE_PACKAGES=${{ matrix.image.site_packages }}
60
108
  tags: ${{ steps.meta.outputs.tags }}
61
109
  labels: |
62
110
  ${{ steps.meta.outputs.labels }}
@@ -7,13 +7,24 @@ on:
7
7
  description: User handle
8
8
  required: true
9
9
  type: string
10
+ release_stale_lock:
11
+ description: Release an operator-confirmed stale Kamal deploy lock before retrying
12
+ required: false
13
+ type: boolean
14
+ default: false
10
15
  push:
11
16
  branches: ["main"]
12
17
  paths:
13
18
  - users/*/.env
14
19
  - users/*/brain.yaml
20
+ - users/*/content/**
21
+ - users/*.secrets.yaml.age
15
22
  - deploy/**
16
23
  - .github/workflows/deploy.yml
24
+ workflow_run:
25
+ workflows: [Reconcile]
26
+ types: [completed]
27
+ branches: [main]
17
28
 
18
29
  permissions:
19
30
  contents: write
@@ -21,13 +32,16 @@ permissions:
21
32
 
22
33
  jobs:
23
34
  resolve_handles:
35
+ if: >
36
+ github.event_name != 'workflow_run' ||
37
+ github.event.workflow_run.conclusion == 'success'
24
38
  runs-on: ubuntu-latest
25
39
  outputs:
26
40
  handles_json: ${{ steps.resolve.outputs.handles_json }}
27
41
  steps:
28
42
  - uses: actions/checkout@v5
29
43
  with:
30
- ref: ${{ github.sha }}
44
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
31
45
  fetch-depth: 0
32
46
 
33
47
  - uses: oven-sh/setup-bun@v2
@@ -39,7 +53,7 @@ jobs:
39
53
  id: resolve
40
54
  env:
41
55
  HANDLE_INPUT: ${{ inputs.handle || '' }}
42
- BEFORE_SHA: ${{ github.event.before || '' }}
56
+ BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
43
57
  run: bun deploy/scripts/resolve-deploy-handles.ts
44
58
 
45
59
  no_changes:
@@ -66,13 +80,72 @@ jobs:
66
80
  steps:
67
81
  - uses: actions/checkout@v5
68
82
  with:
69
- ref: ${{ github.sha }}
83
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
70
84
 
71
85
  - uses: oven-sh/setup-bun@v2
72
86
 
73
87
  - name: Install operator tooling
74
88
  run: bun install
75
89
 
90
+ - name: Load shared env via varlock
91
+ env:
92
+ BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
93
+ run: |
94
+ for attempt in 1 2 3; do
95
+ if bunx varlock@1.1.0 load --path .env.schema --format json --compact > /tmp/varlock-env.json; then
96
+ break
97
+ fi
98
+ if [ "$attempt" = "3" ]; then
99
+ exit 1
100
+ fi
101
+ echo "Varlock load failed (attempt $attempt/3); retrying in 5s..."
102
+ sleep 5
103
+ done
104
+ node <<'NODE'
105
+ import { appendFileSync, readFileSync } from "node:fs";
106
+ const env = JSON.parse(readFileSync('/tmp/varlock-env.json', 'utf8'));
107
+ const githubEnvPath = process.env.GITHUB_ENV;
108
+ if (!githubEnvPath) {
109
+ throw new Error('Missing GITHUB_ENV');
110
+ }
111
+
112
+ const newline = String.fromCharCode(10);
113
+ const carriageReturn = String.fromCharCode(13);
114
+ const chunks = [];
115
+ for (const [key, value] of Object.entries(env)) {
116
+ if (key === 'BWS_ACCESS_TOKEN' || value === null || value === undefined) {
117
+ continue;
118
+ }
119
+
120
+ const text = String(value)
121
+ .split(carriageReturn + newline)
122
+ .join(newline);
123
+ const escapedMask = text
124
+ .replace(/%/g, '%25')
125
+ .replace(/\r/g, '%0D')
126
+ .replace(/\n/g, '%0A');
127
+ if (escapedMask.length > 0) {
128
+ process.stdout.write('::add-mask::' + escapedMask + newline);
129
+ }
130
+
131
+ if (text.includes(newline)) {
132
+ let delimiter = `VARLOCK_${key}_EOF`;
133
+ while (text.includes(delimiter)) {
134
+ delimiter += '_X';
135
+ }
136
+ chunks.push(`${key}<<${delimiter}${newline}${text}${newline}${delimiter}`);
137
+ } else {
138
+ chunks.push(`${key}=${text}`);
139
+ }
140
+ }
141
+
142
+ appendFileSync(githubEnvPath, chunks.join(newline) + newline);
143
+ NODE
144
+
145
+ - name: Decrypt user secrets
146
+ id: user_secrets
147
+ run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
148
+
76
149
  - name: Reconcile selected user config
77
150
  run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
78
151
 
@@ -81,23 +154,13 @@ jobs:
81
154
  run: bun deploy/scripts/resolve-user-config.ts
82
155
 
83
156
  - name: Validate selected secrets
84
- env:
85
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
86
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
87
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
88
- DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
89
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
90
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
91
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
92
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
93
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
94
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
95
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
96
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
97
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
98
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
99
157
  run: bun deploy/scripts/validate-secrets.ts
100
158
 
159
+ - name: Seed content repo
160
+ env:
161
+ CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
162
+ run: bun deploy/scripts/sync-content-repo.ts
163
+
101
164
  - name: Log in to GHCR
102
165
  uses: docker/login-action@v3
103
166
  with:
@@ -105,15 +168,18 @@ jobs:
105
168
  username: ${{ github.actor }}
106
169
  password: ${{ secrets.GITHUB_TOKEN }}
107
170
 
108
- - name: Wait for shared image tag
171
+ - name: Wait for image tag
109
172
  run: |
110
- IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:brain-${{ steps.user_config.outputs.brain_version }}"
111
- for attempt in $(seq 1 24); do
173
+ # The Build workflow fires off the same config push (it derives the
174
+ # declared image set from the registry), so this wait must cover a
175
+ # full image build, not just registry propagation.
176
+ IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
177
+ for attempt in $(seq 1 60); do
112
178
  if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
113
179
  exit 0
114
180
  fi
115
- echo "Shared image tag not ready yet ($attempt/24): $IMAGE_TAG"
116
- sleep 10
181
+ echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
182
+ sleep 20
117
183
  done
118
184
  echo "Timed out waiting for $IMAGE_TAG" >&2
119
185
  exit 1
@@ -124,8 +190,6 @@ jobs:
124
190
  ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
125
191
 
126
192
  - name: Write Kamal SSH key
127
- env:
128
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
129
193
  run: bun deploy/scripts/write-ssh-key.ts
130
194
 
131
195
  - name: Configure SSH client
@@ -142,33 +206,27 @@ jobs:
142
206
  chmod 600 ~/.ssh/config
143
207
 
144
208
  - name: Write .kamal/secrets
145
- env:
146
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
147
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
148
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
149
- DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
150
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
151
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
152
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
153
209
  run: bun deploy/scripts/write-kamal-secrets.ts
154
210
 
155
211
  - name: Provision server
156
212
  id: provision
157
213
  env:
158
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
159
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
160
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
161
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
162
214
  INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
163
215
  run: bun deploy/scripts/provision-server.ts
164
216
 
165
217
  - name: Update Cloudflare DNS
166
218
  env:
167
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
168
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
219
+ CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
169
220
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
221
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
222
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
170
223
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
171
- run: bun deploy/scripts/update-dns.ts
224
+ run: |
225
+ bun deploy/scripts/update-dns.ts
226
+ if [ -n "$WWW_DOMAIN" ]; then
227
+ BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
228
+ fi
229
+ BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
172
230
 
173
231
  - name: Validate SSH key
174
232
  run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
@@ -188,17 +246,43 @@ jobs:
188
246
  echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
189
247
  exit 1
190
248
 
249
+ - name: Release operator-confirmed stale deploy lock
250
+ if: ${{ inputs.release_stale_lock }}
251
+ env:
252
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
253
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
254
+ IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
255
+ REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
256
+ BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
257
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
258
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
259
+ BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
260
+ run: kamal lock release -c deploy/kamal/deploy.yml
261
+
191
262
  - name: Deploy
192
263
  env:
193
264
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
194
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
265
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
266
+ IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
267
+ REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
268
+ BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
269
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
270
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
271
+ BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
195
272
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
196
273
 
197
274
  - name: Verify origin TLS
198
275
  env:
199
276
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
200
277
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
201
- run: curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
278
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
279
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
280
+ run: |
281
+ curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
282
+ if [ -n "$WWW_DOMAIN" ]; then
283
+ curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
284
+ fi
285
+ curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
202
286
 
203
287
  - name: Upload generated config
204
288
  uses: actions/upload-artifact@v4
@@ -207,6 +291,7 @@ jobs:
207
291
  path: |
208
292
  users/${{ matrix.handle }}/brain.yaml
209
293
  users/${{ matrix.handle }}/.env
294
+ users/${{ matrix.handle }}/content
210
295
 
211
296
  - name: Dump remote proxy diagnostics
212
297
  if: failure()
@@ -233,7 +318,7 @@ jobs:
233
318
  steps:
234
319
  - uses: actions/checkout@v5
235
320
  with:
236
- ref: ${{ github.sha }}
321
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
237
322
 
238
323
  - uses: oven-sh/setup-bun@v2
239
324
 
@@ -252,11 +337,31 @@ jobs:
252
337
 
253
338
  - name: Commit generated config
254
339
  run: |
340
+ # A newly added user's generated directory is untracked, and
341
+ # `git diff` cannot see untracked files — without intent-to-add
342
+ # the new config is silently dropped here and every later deploy
343
+ # skips that user (their files never appear in a commit range).
344
+ git add --intent-to-add -- users views
345
+
255
346
  if git diff --quiet -- users views; then
256
347
  exit 0
257
348
  fi
349
+
350
+ git fetch origin "${{ github.ref_name }}"
351
+ if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
352
+ exit 0
353
+ fi
354
+
355
+ patch_file="$(mktemp)"
356
+ git diff --binary -- users views > "$patch_file"
357
+ git reset --hard "origin/${{ github.ref_name }}"
358
+ git apply --3way --index "$patch_file"
359
+
360
+ if git diff --cached --quiet -- users views; then
361
+ exit 0
362
+ fi
363
+
258
364
  git config user.name "github-actions[bot]"
259
365
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
260
- git add users views
261
366
  git commit -m "chore(ops): reconcile generated config"
262
- git push
367
+ 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
- git push
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.
@@ -36,8 +37,12 @@ When a push changes only deploy contract files, CI prints `No affected user conf
36
37
  ## Commands
37
38
 
38
39
  - `brains-ops init <repo>`
39
- - `brains-ops render <repo>`
40
- - `brains-ops onboard <repo> <handle>`
41
- - `brains-ops secrets:push <repo> <handle>`
40
+ - `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
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
43
+ - `brains-ops age-key:bootstrap <repo>`
44
+ - `brains-ops ssh-key:bootstrap <repo>`
45
+ - `brains-ops cert:bootstrap <repo>`
46
+ - `brains-ops secrets:encrypt <repo> <handle>`
42
47
  - `brains-ops reconcile-cohort <repo> <cohort>`
43
48
  - `brains-ops reconcile-all <repo>`
@@ -0,0 +1,119 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { Decrypter, armor } from "age-encryption";
4
+
5
+ import { requireEnv, writeGitHubEnv, writeGitHubOutput } from "./helpers";
6
+
7
+ const handle = process.argv[2] ?? requireEnv("HANDLE");
8
+ const ageSecretKey = extractAgeIdentity(requireEnv("AGE_SECRET_KEY"));
9
+ const encryptedPath = `users/${handle}.secrets.yaml.age`;
10
+
11
+ const armored = readFileSync(encryptedPath, "utf8");
12
+ const decoded = armor.decode(armored);
13
+
14
+ const decrypter = new Decrypter();
15
+ decrypter.addIdentity(ageSecretKey);
16
+
17
+ const plaintext = await decrypter.decrypt(decoded, "text");
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
+ "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.
32
+ writeSecretGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"]);
33
+ // Per-user custom-domain TLS overrides the shared fleet certificate when a
34
+ // complete certificate/key pair is present in the encrypted user secrets.
35
+ const certificatePem = decodeEscapedSecret(secrets["certificatePem"]);
36
+ const privateKeyPem = decodeEscapedSecret(secrets["privateKeyPem"]);
37
+ if (Boolean(certificatePem) !== Boolean(privateKeyPem)) {
38
+ throw new Error(
39
+ "Custom-domain TLS secrets require both certificatePem and privateKeyPem",
40
+ );
41
+ }
42
+ writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
43
+ writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
44
+
45
+ writeGitHubOutput(
46
+ "shared_ai_api_key_secret_name",
47
+ requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
48
+ );
49
+ writeGitHubOutput(
50
+ "shared_git_sync_token_secret_name",
51
+ requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
52
+ );
53
+ writeGitHubOutput(
54
+ "shared_content_repo_admin_token_secret_name",
55
+ requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
56
+ );
57
+
58
+ function writeSecretGitHubEnv(name: string, value: string | undefined): void {
59
+ if (!value || value.trim().length === 0) {
60
+ return;
61
+ }
62
+
63
+ maskGitHubSecret(value);
64
+ writeGitHubEnv(name, value);
65
+ }
66
+
67
+ function maskGitHubSecret(value: string): void {
68
+ const escaped = value
69
+ .replace(/%/g, "%25")
70
+ .replace(/\r/g, "%0D")
71
+ .replace(/\n/g, "%0A");
72
+ if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
73
+ console.log(`::add-mask::${escaped}`);
74
+ }
75
+ }
76
+
77
+ function extractAgeIdentity(contents: string): string {
78
+ const line = contents
79
+ .split(/\r?\n/)
80
+ .map((entry) => entry.trim())
81
+ .find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
82
+
83
+ if (!line) {
84
+ throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
85
+ }
86
+
87
+ return line;
88
+ }
89
+
90
+ function parseStringYamlMapping(contents: string): Record<string, string> {
91
+ const parsed: unknown = Bun.YAML.parse(contents);
92
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
93
+ throw new Error("Expected a YAML mapping");
94
+ }
95
+
96
+ const result: Record<string, string> = {};
97
+ for (const [key, value] of Object.entries(parsed)) {
98
+ if (typeof value === "string") {
99
+ result[key] = value;
100
+ }
101
+ }
102
+ return result;
103
+ }
104
+
105
+ function decodeEscapedSecret(value: string | undefined): string | undefined {
106
+ return value?.replace(/\\n/g, "\n");
107
+ }
108
+
109
+ function requireFlatValue(
110
+ values: Record<string, string>,
111
+ key: string,
112
+ label: string,
113
+ ): string {
114
+ const value = values[key];
115
+ if (!value) {
116
+ throw new Error(`Missing ${key} in ${label}`);
117
+ }
118
+ return value;
119
+ }
@@ -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";
@@ -101,7 +101,7 @@ while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
101
101
  server = await getServer(server.id);
102
102
  }
103
103
 
104
- const serverIp = server.public_net?.ipv4?.ip;
104
+ const serverIp = server.public_net.ipv4.ip;
105
105
  if (!serverIp) {
106
106
  throw new Error(`Server ${server.id} running but has no IPv4 address`);
107
107
  }