@rizom/ops 0.2.0-alpha.22 → 0.2.0-alpha.221

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 +229 -283
  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 +4 -0
  10. package/dist/index.js +268 -281
  11. package/dist/load-registry.d.ts +25 -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/preview-domain.d.ts +9 -0
  16. package/dist/push-secrets.d.ts +2 -9
  17. package/dist/push-target.d.ts +1 -2
  18. package/dist/run-command.d.ts +1 -1
  19. package/dist/run-subprocess.d.ts +1 -6
  20. package/dist/schema.d.ts +64 -155
  21. package/dist/secrets-encrypt.d.ts +5 -13
  22. package/dist/ssh-key-bootstrap.d.ts +1 -26
  23. package/dist/user-add.d.ts +15 -0
  24. package/dist/verify-user.d.ts +19 -0
  25. package/package.json +45 -42
  26. package/templates/rover-pilot/.env.schema +17 -3
  27. package/templates/rover-pilot/.github/workflows/build.yml +64 -17
  28. package/templates/rover-pilot/.github/workflows/deploy.yml +128 -61
  29. package/templates/rover-pilot/.github/workflows/reconcile.yml +21 -1
  30. package/templates/rover-pilot/README.md +5 -3
  31. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +78 -24
  32. package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
  33. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +12 -3
  34. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  35. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +44 -9
  36. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
  37. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  38. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +1 -1
  39. package/templates/rover-pilot/docs/onboarding-checklist.md +33 -13
  40. package/templates/rover-pilot/docs/operator-playbook.md +183 -10
  41. package/templates/rover-pilot/docs/user-onboarding.md +67 -332
  42. package/templates/rover-pilot/pilot.yaml +1 -1
  43. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  44. package/templates/rover-pilot/deploy/Dockerfile +0 -30
  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 }}
@@ -7,6 +7,11 @@ 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:
@@ -16,6 +21,10 @@ on:
16
21
  - users/*.secrets.yaml.age
17
22
  - deploy/**
18
23
  - .github/workflows/deploy.yml
24
+ workflow_run:
25
+ workflows: [Reconcile]
26
+ types: [completed]
27
+ branches: [main]
19
28
 
20
29
  permissions:
21
30
  contents: write
@@ -23,13 +32,16 @@ permissions:
23
32
 
24
33
  jobs:
25
34
  resolve_handles:
35
+ if: >
36
+ github.event_name != 'workflow_run' ||
37
+ github.event.workflow_run.conclusion == 'success'
26
38
  runs-on: ubuntu-latest
27
39
  outputs:
28
40
  handles_json: ${{ steps.resolve.outputs.handles_json }}
29
41
  steps:
30
42
  - uses: actions/checkout@v5
31
43
  with:
32
- ref: ${{ github.sha }}
44
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
33
45
  fetch-depth: 0
34
46
 
35
47
  - uses: oven-sh/setup-bun@v2
@@ -41,7 +53,7 @@ jobs:
41
53
  id: resolve
42
54
  env:
43
55
  HANDLE_INPUT: ${{ inputs.handle || '' }}
44
- BEFORE_SHA: ${{ github.event.before || '' }}
56
+ BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
45
57
  run: bun deploy/scripts/resolve-deploy-handles.ts
46
58
 
47
59
  no_changes:
@@ -68,58 +80,86 @@ jobs:
68
80
  steps:
69
81
  - uses: actions/checkout@v5
70
82
  with:
71
- ref: ${{ github.sha }}
83
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
72
84
 
73
85
  - uses: oven-sh/setup-bun@v2
74
86
 
75
87
  - name: Install operator tooling
76
88
  run: bun install
77
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
+
78
145
  - name: Decrypt user secrets
79
146
  id: user_secrets
80
- env:
81
- AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
82
147
  run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
83
148
 
84
149
  - 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"
150
+ run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
90
151
 
91
152
  - name: Resolve generated user config
92
153
  id: user_config
93
154
  run: bun deploy/scripts/resolve-user-config.ts
94
155
 
95
156
  - 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
157
+ run: bun deploy/scripts/validate-secrets.ts
115
158
 
116
159
  - name: Seed content repo
117
160
  env:
118
161
  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
162
+ run: bun deploy/scripts/sync-content-repo.ts
123
163
 
124
164
  - name: Log in to GHCR
125
165
  uses: docker/login-action@v3
@@ -128,15 +168,18 @@ jobs:
128
168
  username: ${{ github.actor }}
129
169
  password: ${{ secrets.GITHUB_TOKEN }}
130
170
 
131
- - name: Wait for shared image tag
171
+ - name: Wait for image tag
132
172
  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
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
135
178
  if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
136
179
  exit 0
137
180
  fi
138
- echo "Shared image tag not ready yet ($attempt/24): $IMAGE_TAG"
139
- sleep 10
181
+ echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
182
+ sleep 20
140
183
  done
141
184
  echo "Timed out waiting for $IMAGE_TAG" >&2
142
185
  exit 1
@@ -147,8 +190,6 @@ jobs:
147
190
  ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
148
191
 
149
192
  - name: Write Kamal SSH key
150
- env:
151
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
152
193
  run: bun deploy/scripts/write-ssh-key.ts
153
194
 
154
195
  - name: Configure SSH client
@@ -165,38 +206,26 @@ jobs:
165
206
  chmod 600 ~/.ssh/config
166
207
 
167
208
  - 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
209
+ run: bun deploy/scripts/write-kamal-secrets.ts
180
210
 
181
211
  - name: Provision server
182
212
  id: provision
183
213
  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
214
  INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
189
215
  run: bun deploy/scripts/provision-server.ts
190
216
 
191
217
  - name: Update Cloudflare DNS
192
218
  env:
193
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
194
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
219
+ CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
195
220
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
221
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
196
222
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
197
223
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
198
224
  run: |
199
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
200
229
  BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
201
230
 
202
231
  - name: Validate SSH key
@@ -217,13 +246,27 @@ jobs:
217
246
  echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
218
247
  exit 1
219
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
+
220
262
  - name: Deploy
221
263
  env:
222
264
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
223
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
265
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
224
266
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
225
267
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
226
268
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
269
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
227
270
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
228
271
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
229
272
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
@@ -232,9 +275,13 @@ jobs:
232
275
  env:
233
276
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
234
277
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
278
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
235
279
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
236
280
  run: |
237
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
238
285
  curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
239
286
 
240
287
  - name: Upload generated config
@@ -271,7 +318,7 @@ jobs:
271
318
  steps:
272
319
  - uses: actions/checkout@v5
273
320
  with:
274
- ref: ${{ github.sha }}
321
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
275
322
 
276
323
  - uses: oven-sh/setup-bun@v2
277
324
 
@@ -290,11 +337,31 @@ jobs:
290
337
 
291
338
  - name: Commit generated config
292
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
+
293
346
  if git diff --quiet -- users views; then
294
347
  exit 0
295
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
+
296
364
  git config user.name "github-actions[bot]"
297
365
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
298
- git add users views
299
366
  git commit -m "chore(ops): reconcile generated config"
300
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
65
  git push origin HEAD:${{ github.ref_name }}
@@ -29,15 +29,17 @@ 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
- The shared pilot image tag is `brain-${brainVersion}` end to end.
33
- When `pilot.yaml.brainVersion` changes and you push, CI rebuilds the shared tag, refreshes generated user env files, and redeploys affected users.
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.
33
+ The default pilot image tag is `brain-${brainVersion}` end to end. A user with `siteOverride` gets an isolated `brain-${brainVersion}-sites-${packageHash}` image instead.
34
+ When the effective brain version (`pilot.yaml.brainVersion`, or a cohort override) changes and you push, CI rebuilds the required default/site tags, refreshes generated user env files, and redeploys affected users. An omitted `siteOverride.version` follows that effective version; an explicit exact version remains pinned.
34
35
  When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
35
36
 
36
37
  ## Commands
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>`
@@ -15,13 +15,50 @@ 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
+ "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
+ // A corrupted stored value would otherwise only surface as a kamal-proxy
43
+ // "unable to load certificate" failure after the container has already booted.
44
+ assertPemShape("certificatePem", certificatePem);
45
+ assertPemShape("privateKeyPem", privateKeyPem);
46
+ writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
47
+ writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
48
+
49
+ function assertPemShape(name: string, value: string | undefined): void {
50
+ if (value === undefined || value.trim().length === 0) {
51
+ return;
52
+ }
53
+ if (
54
+ !/-----BEGIN [A-Z0-9 ]+-----/.test(value) ||
55
+ !/-----END [A-Z0-9 ]+-----/.test(value)
56
+ ) {
57
+ throw new Error(
58
+ `Secret ${name} is not PEM-shaped after unescaping; the stored value is corrupt`,
59
+ );
60
+ }
61
+ }
25
62
 
26
63
  writeGitHubOutput(
27
64
  "shared_ai_api_key_secret_name",
@@ -32,10 +69,29 @@ writeGitHubOutput(
32
69
  requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
33
70
  );
34
71
  writeGitHubOutput(
35
- "shared_mcp_auth_token_secret_name",
36
- requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
72
+ "shared_content_repo_admin_token_secret_name",
73
+ requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
37
74
  );
38
75
 
76
+ function writeSecretGitHubEnv(name: string, value: string | undefined): void {
77
+ if (!value || value.trim().length === 0) {
78
+ return;
79
+ }
80
+
81
+ maskGitHubSecret(value);
82
+ writeGitHubEnv(name, value);
83
+ }
84
+
85
+ function maskGitHubSecret(value: string): void {
86
+ const escaped = value
87
+ .replace(/%/g, "%25")
88
+ .replace(/\r/g, "%0D")
89
+ .replace(/\n/g, "%0A");
90
+ if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
91
+ console.log(`::add-mask::${escaped}`);
92
+ }
93
+ }
94
+
39
95
  function extractAgeIdentity(contents: string): string {
40
96
  const line = contents
41
97
  .split(/\r?\n/)
@@ -49,27 +105,25 @@ function extractAgeIdentity(contents: string): string {
49
105
  return line;
50
106
  }
51
107
 
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
- }
108
+ function parseStringYamlMapping(contents: string): Record<string, string> {
109
+ const parsed: unknown = Bun.YAML.parse(contents);
110
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
111
+ throw new Error("Expected a YAML mapping");
112
+ }
60
113
 
61
- const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
62
- if (!match) {
63
- continue;
114
+ const result: Record<string, string> = {};
115
+ for (const [key, value] of Object.entries(parsed)) {
116
+ if (typeof value === "string") {
117
+ result[key] = value;
64
118
  }
65
-
66
- const [, key, rawValue] = match;
67
- result[key] = rawValue.replace(/^['"]|['"]$/g, "");
68
119
  }
69
-
70
120
  return result;
71
121
  }
72
122
 
123
+ function decodeEscapedSecret(value: string | undefined): string | undefined {
124
+ return value?.replace(/\\n/g, "\n");
125
+ }
126
+
73
127
  function requireFlatValue(
74
128
  values: Record<string, string>,
75
129
  key: string,
@@ -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
  }