@rizom/ops 0.2.0-alpha.28 → 0.2.0-alpha.280

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 (59) hide show
  1. package/README.md +39 -2
  2. package/dist/brains-ops.js +404 -314
  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/directory-sync-stress-system.d.ts +74 -0
  8. package/dist/directory-sync-stress.d.ts +106 -0
  9. package/dist/entries/deploy.d.ts +3 -2
  10. package/dist/images.d.ts +79 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.js +479 -298
  13. package/dist/legacy-pilot-migration.d.ts +11 -0
  14. package/dist/load-registry.d.ts +57 -6
  15. package/dist/observed-status.d.ts +1 -1
  16. package/dist/origin-ca.d.ts +1 -1
  17. package/dist/parse-args.d.ts +2 -10
  18. package/dist/preview-domain.d.ts +9 -0
  19. package/dist/push-secrets.d.ts +2 -9
  20. package/dist/push-target.d.ts +1 -2
  21. package/dist/reconcile-dry-run.d.ts +9 -0
  22. package/dist/run-command.d.ts +9 -3
  23. package/dist/run-subprocess.d.ts +1 -6
  24. package/dist/schema.d.ts +114 -162
  25. package/dist/secrets-encrypt.d.ts +7 -13
  26. package/dist/ssh-key-bootstrap.d.ts +1 -26
  27. package/dist/stage-legacy-crossover.d.ts +20 -0
  28. package/dist/stress-command.d.ts +14 -0
  29. package/dist/stress-git-checkout.d.ts +21 -0
  30. package/dist/stress-health-monitor.d.ts +46 -0
  31. package/dist/upgrade.d.ts +10 -0
  32. package/dist/user-add.d.ts +15 -0
  33. package/dist/verify-user.d.ts +19 -0
  34. package/package.json +47 -42
  35. package/templates/rover-pilot/.env.schema +24 -3
  36. package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
  37. package/templates/rover-pilot/.github/workflows/build.yml +70 -17
  38. package/templates/rover-pilot/.github/workflows/deploy.yml +71 -61
  39. package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
  40. package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
  41. package/templates/rover-pilot/.github/workflows/upgrade.yml +58 -0
  42. package/templates/rover-pilot/README.md +14 -6
  43. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -24
  44. package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
  45. package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
  46. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +12 -3
  47. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  48. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +44 -9
  49. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
  50. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  51. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
  52. package/templates/rover-pilot/docs/canonical-crossover-record.md +104 -0
  53. package/templates/rover-pilot/docs/onboarding-checklist.md +29 -13
  54. package/templates/rover-pilot/docs/operator-playbook.md +241 -24
  55. package/templates/rover-pilot/docs/user-onboarding.md +49 -343
  56. package/templates/rover-pilot/pilot.yaml +3 -4
  57. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  58. package/templates/rover-pilot/deploy/Dockerfile +0 -30
  59. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
@@ -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,36 @@ 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
+ uses: ./.github/actions/varlock-env
92
+ env:
93
+ BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
94
+
78
95
  - name: Decrypt user secrets
79
96
  id: user_secrets
80
- env:
81
- AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
82
97
  run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
83
98
 
84
99
  - 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"
100
+ run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
90
101
 
91
102
  - name: Resolve generated user config
92
103
  id: user_config
93
104
  run: bun deploy/scripts/resolve-user-config.ts
94
105
 
95
106
  - 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
107
+ run: bun deploy/scripts/validate-secrets.ts
115
108
 
116
109
  - name: Seed content repo
117
110
  env:
118
111
  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
112
+ run: bun deploy/scripts/sync-content-repo.ts
123
113
 
124
114
  - name: Log in to GHCR
125
115
  uses: docker/login-action@v3
@@ -128,15 +118,18 @@ jobs:
128
118
  username: ${{ github.actor }}
129
119
  password: ${{ secrets.GITHUB_TOKEN }}
130
120
 
131
- - name: Wait for shared image tag
121
+ - name: Wait for image tag
132
122
  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
123
+ # The Build workflow fires off the same config push (it derives the
124
+ # declared image set from the registry), so this wait must cover a
125
+ # full image build, not just registry propagation.
126
+ IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
127
+ for attempt in $(seq 1 60); do
135
128
  if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
136
129
  exit 0
137
130
  fi
138
- echo "Shared image tag not ready yet ($attempt/24): $IMAGE_TAG"
139
- sleep 10
131
+ echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
132
+ sleep 20
140
133
  done
141
134
  echo "Timed out waiting for $IMAGE_TAG" >&2
142
135
  exit 1
@@ -147,8 +140,6 @@ jobs:
147
140
  ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
148
141
 
149
142
  - name: Write Kamal SSH key
150
- env:
151
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
152
143
  run: bun deploy/scripts/write-ssh-key.ts
153
144
 
154
145
  - name: Configure SSH client
@@ -165,38 +156,26 @@ jobs:
165
156
  chmod 600 ~/.ssh/config
166
157
 
167
158
  - 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
159
+ run: bun deploy/scripts/write-kamal-secrets.ts
180
160
 
181
161
  - name: Provision server
182
162
  id: provision
183
163
  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
164
  INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
189
165
  run: bun deploy/scripts/provision-server.ts
190
166
 
191
167
  - name: Update Cloudflare DNS
192
168
  env:
193
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
194
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
169
+ CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
195
170
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
171
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
196
172
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
197
173
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
198
174
  run: |
199
175
  bun deploy/scripts/update-dns.ts
176
+ if [ -n "$WWW_DOMAIN" ]; then
177
+ BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
178
+ fi
200
179
  BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
201
180
 
202
181
  - name: Validate SSH key
@@ -217,24 +196,49 @@ jobs:
217
196
  echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
218
197
  exit 1
219
198
 
199
+ - name: Release operator-confirmed stale deploy lock
200
+ if: ${{ inputs.release_stale_lock }}
201
+ env:
202
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
203
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
204
+ IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
205
+ REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
206
+ BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
207
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
208
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
209
+ BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
210
+ run: kamal lock release -c deploy/kamal/deploy.yml
211
+
220
212
  - name: Deploy
221
213
  env:
222
214
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
223
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
215
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
224
216
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
225
217
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
226
218
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
219
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
227
220
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
228
221
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
229
222
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
230
223
 
224
+ - name: Install container health watchdog
225
+ env:
226
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
227
+ run: |
228
+ SSH_USER="$(ruby -e 'require "yaml"; config = YAML.load_file("deploy/kamal/deploy.yml") || {}; puts(config.dig("ssh", "user") || "root")')" \
229
+ bun deploy/scripts/install-health-watchdog.ts
230
+
231
231
  - name: Verify origin TLS
232
232
  env:
233
233
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
234
234
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
235
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
235
236
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
236
237
  run: |
237
- curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
238
+ curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health/live"
239
+ if [ -n "$WWW_DOMAIN" ]; then
240
+ curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
241
+ fi
238
242
  curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
239
243
 
240
244
  - name: Upload generated config
@@ -271,7 +275,7 @@ jobs:
271
275
  steps:
272
276
  - uses: actions/checkout@v5
273
277
  with:
274
- ref: ${{ github.sha }}
278
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
275
279
 
276
280
  - uses: oven-sh/setup-bun@v2
277
281
 
@@ -290,6 +294,12 @@ jobs:
290
294
 
291
295
  - name: Commit generated config
292
296
  run: |
297
+ # A newly added user's generated directory is untracked, and
298
+ # `git diff` cannot see untracked files — without intent-to-add
299
+ # the new config is silently dropped here and every later deploy
300
+ # skips that user (their files never appear in a commit range).
301
+ git add --intent-to-add -- users views
302
+
293
303
  if git diff --quiet -- users views; then
294
304
  exit 0
295
305
  fi
@@ -0,0 +1,119 @@
1
+ name: Directory Sync Stress
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "23 4 * * 1"
6
+ workflow_dispatch:
7
+ inputs:
8
+ handle:
9
+ description: Smoke handle only
10
+ required: true
11
+ type: string
12
+ default: smoke
13
+ profile:
14
+ description: Workload profile
15
+ required: true
16
+ type: choice
17
+ default: regression
18
+ options:
19
+ - regression
20
+ - load
21
+ - stress
22
+ confirm:
23
+ description: Type stress:<handle> to confirm reversible content writes
24
+ required: true
25
+ type: string
26
+ verify_only:
27
+ description: Verify the content credential path without remote writes or a workload
28
+ required: false
29
+ type: boolean
30
+ default: false
31
+
32
+ permissions:
33
+ contents: read
34
+
35
+ concurrency:
36
+ group: directory-sync-stress-${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}
37
+ cancel-in-progress: false
38
+
39
+ jobs:
40
+ stress:
41
+ runs-on: ubuntu-latest
42
+ timeout-minutes: 75
43
+ steps:
44
+ - uses: actions/checkout@v5
45
+
46
+ - uses: oven-sh/setup-bun@v2
47
+
48
+ - name: Install operator tooling
49
+ run: bun install
50
+
51
+ - name: Load Bitwarden environment through Varlock
52
+ uses: ./.github/actions/varlock-env
53
+ env:
54
+ BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
55
+
56
+ - name: Verify smoke directory-sync credential path
57
+ if: ${{ github.event_name != 'schedule' && inputs.verify_only }}
58
+ env:
59
+ HANDLE_INPUT: ${{ inputs.handle }}
60
+ CONFIRM_INPUT: ${{ inputs.confirm }}
61
+ run: |
62
+ bunx brains-ops stress:directory-sync:verify-access "$GITHUB_WORKSPACE" "$HANDLE_INPUT" \
63
+ --confirm "$CONFIRM_INPUT" \
64
+ --artifacts-dir "$RUNNER_TEMP/directory-sync-stress"
65
+
66
+ - name: Run smoke directory-sync stress profile
67
+ if: ${{ github.event_name == 'schedule' || !inputs.verify_only }}
68
+ env:
69
+ HANDLE_INPUT: ${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}
70
+ PROFILE_INPUT: ${{ github.event_name == 'schedule' && 'regression' || inputs.profile }}
71
+ CONFIRM_INPUT: ${{ github.event_name == 'schedule' && 'stress:smoke' || inputs.confirm }}
72
+ run: |
73
+ bunx brains-ops stress:directory-sync "$GITHUB_WORKSPACE" "$HANDLE_INPUT" \
74
+ --profile "$PROFILE_INPUT" \
75
+ --confirm "$CONFIRM_INPUT" \
76
+ --artifacts-dir "$RUNNER_TEMP/directory-sync-stress"
77
+
78
+ - name: Upload stress evidence
79
+ if: always()
80
+ uses: actions/upload-artifact@v4
81
+ with:
82
+ name: directory-sync-stress-${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}-${{ github.event_name == 'schedule' && 'regression' || inputs.profile }}-${{ github.run_id }}
83
+ path: ${{ runner.temp }}/directory-sync-stress
84
+ if-no-files-found: warn
85
+
86
+ cleanup:
87
+ needs: stress
88
+ if: ${{ always() && (github.event_name == 'schedule' || !inputs.verify_only) }}
89
+ runs-on: ubuntu-latest
90
+ timeout-minutes: 20
91
+ steps:
92
+ - uses: actions/checkout@v5
93
+
94
+ - uses: oven-sh/setup-bun@v2
95
+
96
+ - name: Install operator tooling
97
+ run: bun install
98
+
99
+ - name: Load Bitwarden environment through Varlock
100
+ uses: ./.github/actions/varlock-env
101
+ env:
102
+ BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
103
+
104
+ - name: Remove residual stress probes
105
+ env:
106
+ HANDLE_INPUT: ${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}
107
+ CONFIRM_INPUT: ${{ github.event_name == 'schedule' && 'stress:smoke' || inputs.confirm }}
108
+ run: |
109
+ bunx brains-ops stress:directory-sync:cleanup "$GITHUB_WORKSPACE" "$HANDLE_INPUT" \
110
+ --confirm "$CONFIRM_INPUT" \
111
+ --artifacts-dir "$RUNNER_TEMP/directory-sync-stress-cleanup"
112
+
113
+ - name: Upload cleanup evidence
114
+ if: always()
115
+ uses: actions/upload-artifact@v4
116
+ with:
117
+ name: directory-sync-stress-cleanup-${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}-${{ github.run_id }}
118
+ path: ${{ runner.temp }}/directory-sync-stress-cleanup
119
+ if-no-files-found: warn
@@ -35,21 +35,27 @@ jobs:
35
35
 
36
36
  - name: Commit generated outputs
37
37
  run: |
38
- if git diff --quiet -- views users; then
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 -- users
43
+
44
+ if git diff --quiet -- users; then
39
45
  exit 0
40
46
  fi
41
47
 
42
48
  git fetch origin "${{ github.ref_name }}"
43
- if git diff --quiet "origin/${{ github.ref_name }}" -- views users; then
49
+ if git diff --quiet "origin/${{ github.ref_name }}" -- users; then
44
50
  exit 0
45
51
  fi
46
52
 
47
53
  patch_file="$(mktemp)"
48
- git diff --binary -- views users > "$patch_file"
54
+ git diff --binary -- users > "$patch_file"
49
55
  git reset --hard "origin/${{ github.ref_name }}"
50
56
  git apply --3way --index "$patch_file"
51
57
 
52
- if git diff --cached --quiet -- views users; then
58
+ if git diff --cached --quiet -- users; then
53
59
  exit 0
54
60
  fi
55
61
 
@@ -0,0 +1,58 @@
1
+ name: Upgrade
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ version:
7
+ description: Explicit @rizom/ops version (empty means latest)
8
+ required: false
9
+ type: string
10
+ schedule:
11
+ # Weekday morning check for new operator tooling releases.
12
+ - cron: "17 6 * * 1-5"
13
+
14
+ permissions:
15
+ contents: write
16
+ pull-requests: write
17
+
18
+ concurrency:
19
+ group: upgrade
20
+ cancel-in-progress: false
21
+
22
+ jobs:
23
+ upgrade:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v5
27
+
28
+ - uses: oven-sh/setup-bun@v2
29
+
30
+ - name: Install operator tooling
31
+ run: bun install
32
+
33
+ - name: Upgrade operator tooling
34
+ env:
35
+ VERSION_INPUT: ${{ inputs.version }}
36
+ run: bunx brains-ops upgrade . --to "${VERSION_INPUT:-latest}"
37
+
38
+ - name: Open upgrade PR
39
+ env:
40
+ GH_TOKEN: ${{ github.token }}
41
+ run: |
42
+ if [ -z "$(git status --porcelain)" ]; then
43
+ echo "Already up to date."
44
+ exit 0
45
+ fi
46
+ VERSION=$(bun -e 'console.log(JSON.parse(await Bun.file("package.json").text()).devDependencies["@rizom/ops"])')
47
+ BRANCH="ops/upgrade-rizom-ops-$VERSION"
48
+ git config user.name "github-actions[bot]"
49
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
50
+ git checkout -B "$BRANCH"
51
+ git add -A
52
+ git commit -m "chore(ops): upgrade @rizom/ops to $VERSION"
53
+ git push --force -u origin "$BRANCH"
54
+ gh pr create \
55
+ --title "chore(ops): upgrade @rizom/ops to $VERSION" \
56
+ --body "Automated operator tooling upgrade. Review the scaffold diff, then merge; deploys stay operator-dispatched." \
57
+ --head "$BRANCH" \
58
+ || echo "PR already open for $BRANCH"
@@ -1,6 +1,6 @@
1
- # rover-pilot
1
+ # private brain pilot
2
2
 
3
- Private desired-state repo for the rover pilot.
3
+ Private desired-state repository for the hosted brain pilot. The generated repository/service name may remain `rover-pilot` for deployment identity compatibility.
4
4
 
5
5
  This is a single operator-owned repo. Pilot users do not get their own brain repos.
6
6
  Per-user deploy config lives under `users/<handle>/`, while content stays in per-user content repos.
@@ -29,18 +29,26 @@ 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. Every external site and theme package uses its own required exact version pin; ops never infers either version from the brain or from another package.
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
- - `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
40
- - `brains-ops onboard <repo> <handle>`
40
+ - `brains-ops upgrade <repo>` — bumps `@rizom/ops` (or `--to <version>`) and refreshes the generated scaffold; the Upgrade workflow runs this on a schedule and opens a PR
41
+ - `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health/ready`, and unauthenticated `/mcp` status checks
42
+ - `brains-ops user:add <repo> <handle> --cohort <cohort>` — scaffolds a user file, per-user secrets template, and cohort membership
43
+ - `brains-ops onboard <repo> <handle>` — creates/seeds the user's content repo with separate admin and sync tokens
41
44
  - `brains-ops age-key:bootstrap <repo>`
42
45
  - `brains-ops ssh-key:bootstrap <repo>`
43
46
  - `brains-ops cert:bootstrap <repo>`
44
47
  - `brains-ops secrets:encrypt <repo> <handle>`
45
48
  - `brains-ops reconcile-cohort <repo> <cohort>`
46
49
  - `brains-ops reconcile-all <repo>`
50
+ - `brains-ops reconcile-all <repo> --dry-run` (isolated, no external content-repository access; lists both passes' changed files)
51
+
52
+ `render` owns the observational `views/users.md` projection. Reconcile owns generated per-user config and never rewrites observed status rows.
53
+
54
+ Use `docs/canonical-crossover-record.md` to record exact forward and rollback artifact pins before an approved crossover window.
@@ -15,13 +15,52 @@ 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
+ writeSecretGitHubEnv("DISCORD_PUBLIC_KEY", secrets["discordPublicKey"]);
29
+ writeSecretGitHubEnv("DISCORD_APPLICATION_ID", secrets["discordApplicationId"]);
30
+ // Per-user AT Protocol publishing credential (optional; from the user's
31
+ // encrypted secrets file). The scaffold wires this so a pilot can publish its
32
+ // brain's agent card to its PDS; a deployment that doesn't publish simply
33
+ // leaves it unset.
34
+ writeSecretGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"]);
35
+ // Per-user custom-domain TLS overrides the shared fleet certificate when a
36
+ // complete certificate/key pair is present in the encrypted user secrets.
37
+ const certificatePem = decodeEscapedSecret(secrets["certificatePem"]);
38
+ const privateKeyPem = decodeEscapedSecret(secrets["privateKeyPem"]);
39
+ if (Boolean(certificatePem) !== Boolean(privateKeyPem)) {
40
+ throw new Error(
41
+ "Custom-domain TLS secrets require both certificatePem and privateKeyPem",
42
+ );
43
+ }
44
+ // A corrupted stored value would otherwise only surface as a kamal-proxy
45
+ // "unable to load certificate" failure after the container has already booted.
46
+ assertPemShape("certificatePem", certificatePem);
47
+ assertPemShape("privateKeyPem", privateKeyPem);
48
+ writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
49
+ writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
50
+
51
+ function assertPemShape(name: string, value: string | undefined): void {
52
+ if (value === undefined || value.trim().length === 0) {
53
+ return;
54
+ }
55
+ if (
56
+ !/-----BEGIN [A-Z0-9 ]+-----/.test(value) ||
57
+ !/-----END [A-Z0-9 ]+-----/.test(value)
58
+ ) {
59
+ throw new Error(
60
+ `Secret ${name} is not PEM-shaped after unescaping; the stored value is corrupt`,
61
+ );
62
+ }
63
+ }
25
64
 
26
65
  writeGitHubOutput(
27
66
  "shared_ai_api_key_secret_name",
@@ -32,10 +71,29 @@ writeGitHubOutput(
32
71
  requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
33
72
  );
34
73
  writeGitHubOutput(
35
- "shared_mcp_auth_token_secret_name",
36
- requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
74
+ "shared_content_repo_admin_token_secret_name",
75
+ requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
37
76
  );
38
77
 
78
+ function writeSecretGitHubEnv(name: string, value: string | undefined): void {
79
+ if (!value || value.trim().length === 0) {
80
+ return;
81
+ }
82
+
83
+ maskGitHubSecret(value);
84
+ writeGitHubEnv(name, value);
85
+ }
86
+
87
+ function maskGitHubSecret(value: string): void {
88
+ const escaped = value
89
+ .replace(/%/g, "%25")
90
+ .replace(/\r/g, "%0D")
91
+ .replace(/\n/g, "%0A");
92
+ if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
93
+ console.log(`::add-mask::${escaped}`);
94
+ }
95
+ }
96
+
39
97
  function extractAgeIdentity(contents: string): string {
40
98
  const line = contents
41
99
  .split(/\r?\n/)
@@ -49,27 +107,25 @@ function extractAgeIdentity(contents: string): string {
49
107
  return line;
50
108
  }
51
109
 
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
- }
110
+ function parseStringYamlMapping(contents: string): Record<string, string> {
111
+ const parsed: unknown = Bun.YAML.parse(contents);
112
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
113
+ throw new Error("Expected a YAML mapping");
114
+ }
60
115
 
61
- const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
62
- if (!match) {
63
- continue;
116
+ const result: Record<string, string> = {};
117
+ for (const [key, value] of Object.entries(parsed)) {
118
+ if (typeof value === "string") {
119
+ result[key] = value;
64
120
  }
65
-
66
- const [, key, rawValue] = match;
67
- result[key] = rawValue.replace(/^['"]|['"]$/g, "");
68
121
  }
69
-
70
122
  return result;
71
123
  }
72
124
 
125
+ function decodeEscapedSecret(value: string | undefined): string | undefined {
126
+ return value?.replace(/\\n/g, "\n");
127
+ }
128
+
73
129
  function requireFlatValue(
74
130
  values: Record<string, string>,
75
131
  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";