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

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 +110 -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 +48 -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 +63 -9
  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,38 +201,26 @@ 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 }}
214
+ CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
195
215
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
216
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
196
217
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
197
218
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
198
219
  run: |
199
220
  bun deploy/scripts/update-dns.ts
221
+ if [ -n "$WWW_DOMAIN" ]; then
222
+ BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
223
+ fi
200
224
  BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
201
225
 
202
226
  - name: Validate SSH key
@@ -220,10 +244,11 @@ jobs:
220
244
  - name: Deploy
221
245
  env:
222
246
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
223
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
247
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
224
248
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
225
249
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
226
250
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
251
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
227
252
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
228
253
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
229
254
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
@@ -232,9 +257,13 @@ jobs:
232
257
  env:
233
258
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
234
259
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
260
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
235
261
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
236
262
  run: |
237
263
  curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
264
+ if [ -n "$WWW_DOMAIN" ]; then
265
+ curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
266
+ fi
238
267
  curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
239
268
 
240
269
  - name: Upload generated config
@@ -271,7 +300,7 @@ jobs:
271
300
  steps:
272
301
  - uses: actions/checkout@v5
273
302
  with:
274
- ref: ${{ github.sha }}
303
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
275
304
 
276
305
  - uses: oven-sh/setup-bun@v2
277
306
 
@@ -290,11 +319,31 @@ jobs:
290
319
 
291
320
  - name: Commit generated config
292
321
  run: |
322
+ # A newly added user's generated directory is untracked, and
323
+ # `git diff` cannot see untracked files — without intent-to-add
324
+ # the new config is silently dropped here and every later deploy
325
+ # skips that user (their files never appear in a commit range).
326
+ git add --intent-to-add -- users views
327
+
293
328
  if git diff --quiet -- users views; then
294
329
  exit 0
295
330
  fi
331
+
332
+ git fetch origin "${{ github.ref_name }}"
333
+ if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
334
+ exit 0
335
+ fi
336
+
337
+ patch_file="$(mktemp)"
338
+ git diff --binary -- users views > "$patch_file"
339
+ git reset --hard "origin/${{ github.ref_name }}"
340
+ git apply --3way --index "$patch_file"
341
+
342
+ if git diff --cached --quiet -- users views; then
343
+ exit 0
344
+ fi
345
+
296
346
  git config user.name "github-actions[bot]"
297
347
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
298
- git add users views
299
348
  git commit -m "chore(ops): reconcile generated config"
300
349
  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,29 @@ 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.
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);
25
44
 
26
45
  writeGitHubOutput(
27
46
  "shared_ai_api_key_secret_name",
@@ -32,10 +51,29 @@ writeGitHubOutput(
32
51
  requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
33
52
  );
34
53
  writeGitHubOutput(
35
- "shared_mcp_auth_token_secret_name",
36
- requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
54
+ "shared_content_repo_admin_token_secret_name",
55
+ requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
37
56
  );
38
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
+
39
77
  function extractAgeIdentity(contents: string): string {
40
78
  const line = contents
41
79
  .split(/\r?\n/)
@@ -70,6 +108,10 @@ function parseFlatYaml(contents: string): Record<string, string> {
70
108
  return result;
71
109
  }
72
110
 
111
+ function decodeEscapedSecret(value: string | undefined): string | undefined {
112
+ return value?.replace(/\\n/g, "\n");
113
+ }
114
+
73
115
  function requireFlatValue(
74
116
  values: Record<string, string>,
75
117
  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
  }
@@ -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
+ });