@rizom/ops 0.2.0-alpha.17 → 0.2.0-alpha.170
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.
- package/README.md +3 -1
- package/dist/brains-ops.js +282 -195
- package/dist/cert-bootstrap.d.ts +3 -1
- package/dist/content-repo-ref.d.ts +10 -0
- package/dist/content-repo.d.ts +1 -0
- package/dist/deploy.js +99 -166
- package/dist/entries/deploy.d.ts +3 -2
- package/dist/images.d.ts +76 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +283 -195
- package/dist/load-registry.d.ts +21 -2
- package/dist/observed-status.d.ts +1 -1
- package/dist/origin-ca.d.ts +1 -1
- package/dist/parse-args.d.ts +3 -0
- package/dist/push-secrets.d.ts +2 -9
- package/dist/push-target.d.ts +1 -2
- package/dist/run-command.d.ts +1 -1
- package/dist/run-subprocess.d.ts +1 -6
- package/dist/schema.d.ts +62 -155
- package/dist/secrets-encrypt.d.ts +5 -13
- package/dist/ssh-key-bootstrap.d.ts +1 -26
- package/dist/user-add.d.ts +15 -0
- package/dist/verify-user.d.ts +19 -0
- package/package.json +44 -42
- package/templates/rover-pilot/.env.schema +17 -3
- package/templates/rover-pilot/.github/workflows/build.yml +24 -12
- package/templates/rover-pilot/.github/workflows/deploy.yml +63 -18
- package/templates/rover-pilot/.github/workflows/reconcile.yml +21 -1
- package/templates/rover-pilot/README.md +3 -1
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +13 -5
- package/templates/rover-pilot/deploy/scripts/resolve-build-config.ts +53 -0
- package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +13 -3
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +55 -8
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
- package/templates/rover-pilot/docs/onboarding-checklist.md +32 -12
- package/templates/rover-pilot/docs/operator-playbook.md +117 -6
- package/templates/rover-pilot/docs/user-onboarding.md +67 -332
- package/templates/rover-pilot/pilot.yaml +1 -1
- package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
- package/templates/rover-pilot/deploy/Caddyfile +0 -72
- package/templates/rover-pilot/deploy/Dockerfile +0 -38
- package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
|
@@ -10,12 +10,17 @@ on:
|
|
|
10
10
|
push:
|
|
11
11
|
branches: ["main"]
|
|
12
12
|
paths:
|
|
13
|
+
- users/*.yaml
|
|
13
14
|
- users/*/.env
|
|
14
15
|
- users/*/brain.yaml
|
|
15
16
|
- users/*/content/**
|
|
16
17
|
- users/*.secrets.yaml.age
|
|
17
18
|
- deploy/**
|
|
18
19
|
- .github/workflows/deploy.yml
|
|
20
|
+
workflow_run:
|
|
21
|
+
workflows: [Reconcile]
|
|
22
|
+
types: [completed]
|
|
23
|
+
branches: [main]
|
|
19
24
|
|
|
20
25
|
permissions:
|
|
21
26
|
contents: write
|
|
@@ -23,13 +28,16 @@ permissions:
|
|
|
23
28
|
|
|
24
29
|
jobs:
|
|
25
30
|
resolve_handles:
|
|
31
|
+
if: >
|
|
32
|
+
github.event_name != 'workflow_run' ||
|
|
33
|
+
github.event.workflow_run.conclusion == 'success'
|
|
26
34
|
runs-on: ubuntu-latest
|
|
27
35
|
outputs:
|
|
28
36
|
handles_json: ${{ steps.resolve.outputs.handles_json }}
|
|
29
37
|
steps:
|
|
30
38
|
- uses: actions/checkout@v5
|
|
31
39
|
with:
|
|
32
|
-
ref: ${{ github.sha }}
|
|
40
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
33
41
|
fetch-depth: 0
|
|
34
42
|
|
|
35
43
|
- uses: oven-sh/setup-bun@v2
|
|
@@ -41,7 +49,7 @@ jobs:
|
|
|
41
49
|
id: resolve
|
|
42
50
|
env:
|
|
43
51
|
HANDLE_INPUT: ${{ inputs.handle || '' }}
|
|
44
|
-
BEFORE_SHA: ${{ github.event.before || '' }}
|
|
52
|
+
BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
|
|
45
53
|
run: bun deploy/scripts/resolve-deploy-handles.ts
|
|
46
54
|
|
|
47
55
|
no_changes:
|
|
@@ -68,7 +76,7 @@ jobs:
|
|
|
68
76
|
steps:
|
|
69
77
|
- uses: actions/checkout@v5
|
|
70
78
|
with:
|
|
71
|
-
ref: ${{ github.sha }}
|
|
79
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
72
80
|
|
|
73
81
|
- uses: oven-sh/setup-bun@v2
|
|
74
82
|
|
|
@@ -96,7 +104,8 @@ jobs:
|
|
|
96
104
|
env:
|
|
97
105
|
SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
|
|
98
106
|
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
99
|
-
|
|
107
|
+
SETUP_EMAIL_API_KEY: ${{ secrets.SETUP_EMAIL_API_KEY }}
|
|
108
|
+
SETUP_EMAIL_FROM: ${{ secrets.SETUP_EMAIL_FROM }}
|
|
100
109
|
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
|
|
101
110
|
HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
|
|
102
111
|
HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
|
|
@@ -104,13 +113,16 @@ jobs:
|
|
|
104
113
|
KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
|
|
105
114
|
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
|
106
115
|
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
CONFIG_CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
|
|
117
|
+
SHARED_CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
118
|
+
SHARED_CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
119
|
+
SHARED_PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
110
120
|
run: |
|
|
111
121
|
export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
|
|
112
122
|
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
113
|
-
export
|
|
123
|
+
export CF_ZONE_ID="${CONFIG_CF_ZONE_ID:-${CF_ZONE_ID:-$SHARED_CF_ZONE_ID}}"
|
|
124
|
+
export CERTIFICATE_PEM="${CERTIFICATE_PEM:-$SHARED_CERTIFICATE_PEM}"
|
|
125
|
+
export PRIVATE_KEY_PEM="${PRIVATE_KEY_PEM:-$SHARED_PRIVATE_KEY_PEM}"
|
|
114
126
|
bun deploy/scripts/validate-secrets.ts
|
|
115
127
|
|
|
116
128
|
- name: Seed content repo
|
|
@@ -122,7 +134,7 @@ jobs:
|
|
|
122
134
|
bun deploy/scripts/sync-content-repo.ts
|
|
123
135
|
|
|
124
136
|
- name: Log in to GHCR
|
|
125
|
-
uses: docker/login-action@
|
|
137
|
+
uses: docker/login-action@v4
|
|
126
138
|
with:
|
|
127
139
|
registry: ghcr.io
|
|
128
140
|
username: ${{ github.actor }}
|
|
@@ -130,7 +142,7 @@ jobs:
|
|
|
130
142
|
|
|
131
143
|
- name: Wait for shared image tag
|
|
132
144
|
run: |
|
|
133
|
-
IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}
|
|
145
|
+
IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
|
|
134
146
|
for attempt in $(seq 1 24); do
|
|
135
147
|
if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
|
|
136
148
|
exit 0
|
|
@@ -168,14 +180,16 @@ jobs:
|
|
|
168
180
|
env:
|
|
169
181
|
SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
|
|
170
182
|
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
171
|
-
|
|
183
|
+
SETUP_EMAIL_API_KEY: ${{ secrets.SETUP_EMAIL_API_KEY }}
|
|
184
|
+
SETUP_EMAIL_FROM: ${{ secrets.SETUP_EMAIL_FROM }}
|
|
172
185
|
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
|
173
|
-
|
|
174
|
-
|
|
186
|
+
SHARED_CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
187
|
+
SHARED_PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
175
188
|
run: |
|
|
176
189
|
export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
|
|
177
190
|
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
178
|
-
export
|
|
191
|
+
export CERTIFICATE_PEM="${CERTIFICATE_PEM:-$SHARED_CERTIFICATE_PEM}"
|
|
192
|
+
export PRIVATE_KEY_PEM="${PRIVATE_KEY_PEM:-$SHARED_PRIVATE_KEY_PEM}"
|
|
179
193
|
bun deploy/scripts/write-kamal-secrets.ts
|
|
180
194
|
|
|
181
195
|
- name: Provision server
|
|
@@ -191,12 +205,18 @@ jobs:
|
|
|
191
205
|
- name: Update Cloudflare DNS
|
|
192
206
|
env:
|
|
193
207
|
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
194
|
-
|
|
208
|
+
CONFIG_CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
|
|
209
|
+
SHARED_CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
195
210
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
211
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
196
212
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
197
213
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
198
214
|
run: |
|
|
215
|
+
export CF_ZONE_ID="${CONFIG_CF_ZONE_ID:-${CF_ZONE_ID:-$SHARED_CF_ZONE_ID}}"
|
|
199
216
|
bun deploy/scripts/update-dns.ts
|
|
217
|
+
if [ -n "$WWW_DOMAIN" ]; then
|
|
218
|
+
BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
219
|
+
fi
|
|
200
220
|
BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
201
221
|
|
|
202
222
|
- name: Validate SSH key
|
|
@@ -220,10 +240,11 @@ jobs:
|
|
|
220
240
|
- name: Deploy
|
|
221
241
|
env:
|
|
222
242
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
223
|
-
VERSION:
|
|
243
|
+
VERSION: ${{ steps.user_config.outputs.image_tag }}
|
|
224
244
|
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
225
245
|
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
226
246
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
247
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
227
248
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
228
249
|
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
229
250
|
run: kamal setup --skip-push -c deploy/kamal/deploy.yml
|
|
@@ -232,9 +253,13 @@ jobs:
|
|
|
232
253
|
env:
|
|
233
254
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
234
255
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
256
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
235
257
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
236
258
|
run: |
|
|
237
259
|
curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
|
|
260
|
+
if [ -n "$WWW_DOMAIN" ]; then
|
|
261
|
+
curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
|
|
262
|
+
fi
|
|
238
263
|
curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
|
|
239
264
|
|
|
240
265
|
- name: Upload generated config
|
|
@@ -271,7 +296,7 @@ jobs:
|
|
|
271
296
|
steps:
|
|
272
297
|
- uses: actions/checkout@v5
|
|
273
298
|
with:
|
|
274
|
-
ref: ${{ github.sha }}
|
|
299
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
275
300
|
|
|
276
301
|
- uses: oven-sh/setup-bun@v2
|
|
277
302
|
|
|
@@ -290,11 +315,31 @@ jobs:
|
|
|
290
315
|
|
|
291
316
|
- name: Commit generated config
|
|
292
317
|
run: |
|
|
318
|
+
# A newly added user's generated directory is untracked, and
|
|
319
|
+
# `git diff` cannot see untracked files — without intent-to-add
|
|
320
|
+
# the new config is silently dropped here and every later deploy
|
|
321
|
+
# skips that user (their files never appear in a commit range).
|
|
322
|
+
git add --intent-to-add -- users views
|
|
323
|
+
|
|
293
324
|
if git diff --quiet -- users views; then
|
|
294
325
|
exit 0
|
|
295
326
|
fi
|
|
327
|
+
|
|
328
|
+
git fetch origin "${{ github.ref_name }}"
|
|
329
|
+
if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
|
|
330
|
+
exit 0
|
|
331
|
+
fi
|
|
332
|
+
|
|
333
|
+
patch_file="$(mktemp)"
|
|
334
|
+
git diff --binary -- users views > "$patch_file"
|
|
335
|
+
git reset --hard "origin/${{ github.ref_name }}"
|
|
336
|
+
git apply --3way --index "$patch_file"
|
|
337
|
+
|
|
338
|
+
if git diff --cached --quiet -- users views; then
|
|
339
|
+
exit 0
|
|
340
|
+
fi
|
|
341
|
+
|
|
296
342
|
git config user.name "github-actions[bot]"
|
|
297
343
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
298
|
-
git add users views
|
|
299
344
|
git commit -m "chore(ops): reconcile generated config"
|
|
300
345
|
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
|
|
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>`
|
|
@@ -20,8 +20,16 @@ const pilot = parseFlatYaml(readFileSync("pilot.yaml", "utf8"));
|
|
|
20
20
|
|
|
21
21
|
writeGitHubEnv("AI_API_KEY", secrets["aiApiKey"] ?? "");
|
|
22
22
|
writeGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"] ?? "");
|
|
23
|
-
writeGitHubEnv("MCP_AUTH_TOKEN", secrets["mcpAuthToken"] ?? "");
|
|
24
23
|
writeGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
|
|
24
|
+
writeGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"] ?? "");
|
|
25
|
+
writeGitHubEnv(
|
|
26
|
+
"CERTIFICATE_PEM",
|
|
27
|
+
decodeEscapedSecret(secrets["certificatePem"]),
|
|
28
|
+
);
|
|
29
|
+
writeGitHubEnv(
|
|
30
|
+
"PRIVATE_KEY_PEM",
|
|
31
|
+
decodeEscapedSecret(secrets["privateKeyPem"]),
|
|
32
|
+
);
|
|
25
33
|
|
|
26
34
|
writeGitHubOutput(
|
|
27
35
|
"shared_ai_api_key_secret_name",
|
|
@@ -31,10 +39,6 @@ writeGitHubOutput(
|
|
|
31
39
|
"shared_git_sync_token_secret_name",
|
|
32
40
|
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
33
41
|
);
|
|
34
|
-
writeGitHubOutput(
|
|
35
|
-
"shared_mcp_auth_token_secret_name",
|
|
36
|
-
requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
|
|
37
|
-
);
|
|
38
42
|
|
|
39
43
|
function extractAgeIdentity(contents: string): string {
|
|
40
44
|
const line = contents
|
|
@@ -70,6 +74,10 @@ function parseFlatYaml(contents: string): Record<string, string> {
|
|
|
70
74
|
return result;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
function decodeEscapedSecret(value: string | undefined): string {
|
|
78
|
+
return value?.replace(/\\n/g, "\n") ?? "";
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
function requireFlatValue(
|
|
74
82
|
values: Record<string, string>,
|
|
75
83
|
key: string,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { loadPilotRegistry } from "@rizom/ops";
|
|
4
|
+
import { writeGitHubEnv, writeGitHubOutput } from "./helpers";
|
|
5
|
+
|
|
6
|
+
const requestedBrainVersion = process.env["BRAIN_VERSION_INPUT"]?.trim();
|
|
7
|
+
const registry = await loadPilotRegistry(process.cwd());
|
|
8
|
+
const brainVersion =
|
|
9
|
+
requestedBrainVersion && requestedBrainVersion.length > 0
|
|
10
|
+
? requestedBrainVersion
|
|
11
|
+
: registry.pilot.brainVersion;
|
|
12
|
+
|
|
13
|
+
const sitePackages = [
|
|
14
|
+
...new Set(
|
|
15
|
+
registry.users
|
|
16
|
+
.filter((user) => user.brainVersion === brainVersion)
|
|
17
|
+
.flatMap((user) =>
|
|
18
|
+
user.siteOverride
|
|
19
|
+
? [`${user.siteOverride.package}@${user.siteOverride.version}`]
|
|
20
|
+
: [],
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
].sort();
|
|
24
|
+
const sitePackagesValue = sitePackages.join(" ");
|
|
25
|
+
const imageTag = buildImageTag(brainVersion, sitePackages);
|
|
26
|
+
|
|
27
|
+
writeGitHubEnv("BRAIN_VERSION", brainVersion);
|
|
28
|
+
writeGitHubEnv("SITE_PACKAGES", sitePackagesValue);
|
|
29
|
+
writeGitHubEnv("IMAGE_TAG", imageTag);
|
|
30
|
+
writeGitHubOutput("brain_version", brainVersion);
|
|
31
|
+
writeGitHubOutput("site_packages", sitePackagesValue);
|
|
32
|
+
writeGitHubOutput("image_tag", imageTag);
|
|
33
|
+
|
|
34
|
+
console.log(`BRAIN_VERSION=${brainVersion}`);
|
|
35
|
+
console.log(`IMAGE_TAG=${imageTag}`);
|
|
36
|
+
console.log(
|
|
37
|
+
sitePackages.length > 0
|
|
38
|
+
? `SITE_PACKAGES=${sitePackagesValue}`
|
|
39
|
+
: "SITE_PACKAGES=(none)",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
function buildImageTag(brainVersion: string, sitePackages: string[]): string {
|
|
43
|
+
if (sitePackages.length === 0) {
|
|
44
|
+
return `brain-${brainVersion}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const siteHash = createHash("sha256")
|
|
48
|
+
.update(sitePackages.join("\n"))
|
|
49
|
+
.digest("hex")
|
|
50
|
+
.slice(0, 12);
|
|
51
|
+
|
|
52
|
+
return `brain-${brainVersion}-sites-${siteHash}`;
|
|
53
|
+
}
|
|
@@ -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 =
|
|
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 (
|
|
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
|
}
|
|
@@ -34,6 +43,7 @@ const handles = [
|
|
|
34
43
|
.map((path) => {
|
|
35
44
|
const match =
|
|
36
45
|
path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
|
|
46
|
+
path.match(/^users\/([^/]+)\.yaml$/) ??
|
|
37
47
|
path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
|
|
38
48
|
return match?.[1] ?? null;
|
|
39
49
|
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { loadPilotRegistry } from "@rizom/ops";
|
|
2
4
|
|
|
3
5
|
import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
|
|
4
6
|
|
|
@@ -7,6 +9,14 @@ const envPath = `users/${handle}/.env`;
|
|
|
7
9
|
const brainYamlPath = `users/${handle}/brain.yaml`;
|
|
8
10
|
|
|
9
11
|
const envEntries = parseEnvFile(envPath);
|
|
12
|
+
const registry = await loadPilotRegistry(process.cwd());
|
|
13
|
+
const user = registry.users.find((candidate) => candidate.handle === handle);
|
|
14
|
+
if (!user) {
|
|
15
|
+
throw new Error(`Unknown user handle: ${handle}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
|
|
19
|
+
const imageTag = resolveImageTag(registry, brainVersion);
|
|
10
20
|
const repository = process.env["GITHUB_REPOSITORY"] ?? "";
|
|
11
21
|
const repositoryOwner = repository.split("/")[0] ?? "";
|
|
12
22
|
|
|
@@ -17,23 +27,28 @@ if (!brainDomain) {
|
|
|
17
27
|
throw new Error(`Missing domain in ${brainYamlPath}`);
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
const pilotSubdomainPrefix = `${handle}.`;
|
|
31
|
+
const pilotZone =
|
|
32
|
+
brainDomain.startsWith(pilotSubdomainPrefix) &&
|
|
33
|
+
brainDomain.length > pilotSubdomainPrefix.length
|
|
34
|
+
? brainDomain.slice(pilotSubdomainPrefix.length)
|
|
23
35
|
: "";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
const
|
|
36
|
+
const previewDomain = pilotZone
|
|
37
|
+
? `${handle}-preview.${pilotZone}`
|
|
38
|
+
: `preview.${brainDomain}`;
|
|
39
|
+
const wwwDomain = pilotZone ? "" : `www.${brainDomain}`;
|
|
28
40
|
|
|
29
41
|
const outputs: Record<string, string> = {
|
|
30
|
-
brain_version:
|
|
42
|
+
brain_version: brainVersion,
|
|
31
43
|
content_repo: envEntries["CONTENT_REPO"] ?? "",
|
|
32
44
|
brain_domain: brainDomain,
|
|
33
45
|
preview_domain: previewDomain,
|
|
46
|
+
www_domain: wwwDomain,
|
|
47
|
+
cloudflare_zone_id: user.cloudflareZoneId ?? "",
|
|
34
48
|
brain_yaml_path: brainYamlPath,
|
|
35
49
|
instance_name: `rover-${handle}`,
|
|
36
50
|
image_repository: `ghcr.io/${repository}`,
|
|
51
|
+
image_tag: imageTag,
|
|
37
52
|
registry_username: repositoryOwner,
|
|
38
53
|
};
|
|
39
54
|
|
|
@@ -47,3 +62,35 @@ for (const key of required) {
|
|
|
47
62
|
for (const [key, value] of Object.entries(outputs)) {
|
|
48
63
|
writeGitHubOutput(key, value);
|
|
49
64
|
}
|
|
65
|
+
|
|
66
|
+
function resolveImageTag(
|
|
67
|
+
registry: Awaited<ReturnType<typeof loadPilotRegistry>>,
|
|
68
|
+
brainVersion: string,
|
|
69
|
+
): string {
|
|
70
|
+
if (!brainVersion) {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sitePackages = [
|
|
75
|
+
...new Set(
|
|
76
|
+
registry.users
|
|
77
|
+
.filter((user) => user.brainVersion === brainVersion)
|
|
78
|
+
.flatMap((user) =>
|
|
79
|
+
user.siteOverride
|
|
80
|
+
? [`${user.siteOverride.package}@${user.siteOverride.version}`]
|
|
81
|
+
: [],
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
].sort();
|
|
85
|
+
|
|
86
|
+
if (sitePackages.length === 0) {
|
|
87
|
+
return `brain-${brainVersion}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const siteHash = createHash("sha256")
|
|
91
|
+
.update(sitePackages.join("\n"))
|
|
92
|
+
.digest("hex")
|
|
93
|
+
.slice(0, 12);
|
|
94
|
+
|
|
95
|
+
return `brain-${brainVersion}-sites-${siteHash}`;
|
|
96
|
+
}
|
|
@@ -16,8 +16,11 @@ interface CloudflareResult {
|
|
|
16
16
|
result?: Array<{ id: string }>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async function
|
|
20
|
-
|
|
19
|
+
async function findRecordId(
|
|
20
|
+
name: string,
|
|
21
|
+
type: "A" | "CNAME",
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
|
|
21
24
|
const lookup = await fetch(lookupUrl, { headers });
|
|
22
25
|
const payload = (await readJsonResponse(
|
|
23
26
|
lookup,
|
|
@@ -27,9 +30,16 @@ async function upsertRecord(name: string): Promise<void> {
|
|
|
27
30
|
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
return payload.result?.[0]?.id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function upsertRecord(name: string): Promise<void> {
|
|
37
|
+
// Prefer an existing A record. If the hostname currently has a CNAME,
|
|
38
|
+
// replace that CNAME in-place so deploys can claim legacy www aliases.
|
|
39
|
+
const existing =
|
|
40
|
+
(await findRecordId(name, "A")) ?? (await findRecordId(name, "CNAME"));
|
|
31
41
|
const url = existing
|
|
32
|
-
? `${baseUrl}/zones/${zoneId}/dns_records/${existing
|
|
42
|
+
? `${baseUrl}/zones/${zoneId}/dns_records/${existing}`
|
|
33
43
|
: `${baseUrl}/zones/${zoneId}/dns_records`;
|
|
34
44
|
|
|
35
45
|
const response = await fetch(url, {
|
|
@@ -4,23 +4,43 @@
|
|
|
4
4
|
2. Run `bunx brains-ops age-key:bootstrap <repo> --push-to gh`.
|
|
5
5
|
3. Fill in `pilot.yaml`.
|
|
6
6
|
- keep your pinned `brainVersion`
|
|
7
|
-
- confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `
|
|
7
|
+
- confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `contentRepoAdminToken`
|
|
8
|
+
- use different tokens for `contentRepoAdminToken` and `gitSyncToken`: admin creates/checks content repos; sync is used by runtime directory-sync
|
|
8
9
|
- confirm `agePublicKey`
|
|
9
|
-
4.
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
|
|
10
|
+
4. Run `bunx brains-ops user:add <repo> <handle> --cohort <cohort>`.
|
|
11
|
+
- Web chat is the primary interface; it needs no per-user setup beyond the passkey.
|
|
12
|
+
- `user:add` currently writes `discord: enabled: true`; set it to `false` unless the user's cohort actually uses Discord.
|
|
13
|
+
- if the user should be an anchor on Discord, add `--anchor-id <discord-user-id>`.
|
|
14
|
+
- the command creates `users/<handle>.yaml`, `users/<handle>.secrets.yaml`, and the cohort membership without duplicating existing entries.
|
|
15
|
+
5. Edit the generated user file if the anchor profile needs richer metadata.
|
|
16
|
+
- Set `setup.delivery: email` and `setup.email` so the user gets the passkey setup email — this is the default onboarding path.
|
|
17
|
+
- For ATProto publishing, add `atproto.identifier` to the user file; put only `atprotoAppPassword` in the per-user secrets file.
|
|
18
|
+
- Ensure `SETUP_EMAIL_API_KEY` and `SETUP_EMAIL_FROM` exist as GitHub Secrets before deploying any email-setup user.
|
|
13
19
|
6. Run `bunx brains-ops render <repo>`.
|
|
14
20
|
7. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
|
|
15
21
|
8. Run `bunx brains-ops cert:bootstrap <repo> --push-to gh`.
|
|
16
|
-
9. Keep raw user secret material locally for now (`.env.local`, file-backed env vars, or equivalent local inputs).
|
|
22
|
+
9. Keep raw user secret material locally for now (`.env.local`, file-backed env vars, or equivalent local inputs), including `CONTENT_REPO_ADMIN_TOKEN` for operator onboarding.
|
|
17
23
|
10. Run `bunx brains-ops secrets:encrypt <repo> <handle>`.
|
|
18
24
|
11. Commit and push `users/<handle>.secrets.yaml.age`.
|
|
19
25
|
12. Run `bunx brains-ops onboard <repo> <handle>`.
|
|
20
|
-
13. Verify the deployed
|
|
21
|
-
-
|
|
22
|
-
|
|
26
|
+
13. Verify the deployed Rover contract:
|
|
27
|
+
- all presets:
|
|
28
|
+
- `https://<handle>.rizom.ai/health` returns `200`
|
|
29
|
+
- `https://<handle>.rizom.ai/chat` loads the web chat and accepts passkey sign-in
|
|
30
|
+
- `https://<handle>.rizom.ai/` loads the dashboard (or site surface on `default` preset)
|
|
31
|
+
- `https://<handle>.rizom.ai/cms` loads the CMS/login surface
|
|
32
|
+
- unauthenticated `POST https://<handle>.rizom.ai/mcp` returns the expected auth failure
|
|
33
|
+
- content repo exists and runtime sync is healthy
|
|
34
|
+
- background jobs are not repeatedly failing, except for expected missing optional integrations
|
|
35
|
+
- for `presetOverride: default` users:
|
|
36
|
+
- initial site build completes
|
|
23
37
|
14. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the shared image tag, refreshes generated user env files, and redeploys affected users.
|
|
24
|
-
15.
|
|
25
|
-
16.
|
|
26
|
-
|
|
38
|
+
15. Confirm the user received the setup email, registered their passkey, and can sign in to web chat at `https://<handle>.rizom.ai/chat`. That completes the default onboarding; everything below is per-cohort extras.
|
|
39
|
+
16. Hand over the browser surfaces:
|
|
40
|
+
- Chat (primary): `https://<handle>.rizom.ai/chat`
|
|
41
|
+
- Dashboard: `https://<handle>.rizom.ai/`
|
|
42
|
+
- CMS: `https://<handle>.rizom.ai/cms`, plus GitHub token guidance if CMS editing is part of their cohort
|
|
43
|
+
17. For Discord-enabled cohorts, hand the Discord setup details to the user as a secondary chat surface.
|
|
44
|
+
18. If they need direct client access (MCP), use OAuth/passkey-capable clients where possible.
|
|
45
|
+
19. If you are also giving them a content repo workflow, describe it as optional and frame git/Obsidian as an advanced file-based path, not the default.
|
|
46
|
+
20. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.
|