@rizom/ops 0.2.0-alpha.12 → 0.2.0-alpha.120
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 +7 -3
- package/dist/age-key-bootstrap.d.ts +17 -0
- package/dist/brains-ops.js +281 -160
- package/dist/cert-bootstrap.d.ts +3 -3
- package/dist/content-repo.d.ts +4 -0
- package/dist/deploy.js +3 -170
- package/dist/entries/deploy.d.ts +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +280 -159
- package/dist/load-registry.d.ts +12 -3
- package/dist/observed-status.d.ts +1 -1
- package/dist/origin-ca.d.ts +1 -1
- package/dist/parse-args.d.ts +2 -0
- package/dist/push-secrets.d.ts +1 -1
- package/dist/run-command.d.ts +1 -2
- package/dist/schema.d.ts +64 -10
- package/dist/secrets-encrypt.d.ts +32 -0
- package/dist/secrets-push.d.ts +1 -1
- package/dist/ssh-key-bootstrap.d.ts +1 -1
- package/dist/user-add.d.ts +15 -0
- package/dist/verify-user.d.ts +19 -0
- package/package.json +7 -3
- package/templates/rover-pilot/.env.schema +21 -2
- package/templates/rover-pilot/.github/workflows/build.yml +13 -5
- package/templates/rover-pilot/.github/workflows/deploy.yml +64 -27
- package/templates/rover-pilot/.github/workflows/reconcile.yml +16 -2
- package/templates/rover-pilot/README.md +6 -3
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +79 -0
- package/templates/rover-pilot/deploy/scripts/provision-server.ts +1 -1
- package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +15 -6
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +12 -12
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
- package/templates/rover-pilot/docs/onboarding-checklist.md +43 -14
- package/templates/rover-pilot/docs/operator-playbook.md +159 -10
- package/templates/rover-pilot/docs/user-onboarding.md +156 -295
- package/templates/rover-pilot/package.json +3 -0
- package/templates/rover-pilot/pilot.yaml +3 -0
- package/templates/rover-pilot/users/alice.yaml +2 -1
- package/dist/user-secret-names.d.ts +0 -6
- 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
|
@@ -13,8 +13,13 @@ on:
|
|
|
13
13
|
- users/*/.env
|
|
14
14
|
- users/*/brain.yaml
|
|
15
15
|
- users/*/content/**
|
|
16
|
+
- users/*.secrets.yaml.age
|
|
16
17
|
- deploy/**
|
|
17
18
|
- .github/workflows/deploy.yml
|
|
19
|
+
workflow_run:
|
|
20
|
+
workflows: [Reconcile]
|
|
21
|
+
types: [completed]
|
|
22
|
+
branches: [main]
|
|
18
23
|
|
|
19
24
|
permissions:
|
|
20
25
|
contents: write
|
|
@@ -22,13 +27,16 @@ permissions:
|
|
|
22
27
|
|
|
23
28
|
jobs:
|
|
24
29
|
resolve_handles:
|
|
30
|
+
if: >
|
|
31
|
+
github.event_name != 'workflow_run' ||
|
|
32
|
+
github.event.workflow_run.conclusion == 'success'
|
|
25
33
|
runs-on: ubuntu-latest
|
|
26
34
|
outputs:
|
|
27
35
|
handles_json: ${{ steps.resolve.outputs.handles_json }}
|
|
28
36
|
steps:
|
|
29
37
|
- uses: actions/checkout@v5
|
|
30
38
|
with:
|
|
31
|
-
ref: ${{ github.sha }}
|
|
39
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
32
40
|
fetch-depth: 0
|
|
33
41
|
|
|
34
42
|
- uses: oven-sh/setup-bun@v2
|
|
@@ -40,7 +48,7 @@ jobs:
|
|
|
40
48
|
id: resolve
|
|
41
49
|
env:
|
|
42
50
|
HANDLE_INPUT: ${{ inputs.handle || '' }}
|
|
43
|
-
BEFORE_SHA: ${{ github.event.before || '' }}
|
|
51
|
+
BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
|
|
44
52
|
run: bun deploy/scripts/resolve-deploy-handles.ts
|
|
45
53
|
|
|
46
54
|
no_changes:
|
|
@@ -67,23 +75,25 @@ jobs:
|
|
|
67
75
|
steps:
|
|
68
76
|
- uses: actions/checkout@v5
|
|
69
77
|
with:
|
|
70
|
-
ref: ${{ github.sha }}
|
|
78
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
71
79
|
|
|
72
80
|
- uses: oven-sh/setup-bun@v2
|
|
73
81
|
|
|
74
82
|
- name: Install operator tooling
|
|
75
83
|
run: bun install
|
|
76
84
|
|
|
77
|
-
- name:
|
|
78
|
-
id:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
- name: Decrypt user secrets
|
|
86
|
+
id: user_secrets
|
|
87
|
+
env:
|
|
88
|
+
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
|
89
|
+
run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
|
|
82
90
|
|
|
83
91
|
- name: Reconcile selected user config
|
|
84
92
|
env:
|
|
85
|
-
|
|
86
|
-
run:
|
|
93
|
+
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
94
|
+
run: |
|
|
95
|
+
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
96
|
+
bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
|
|
87
97
|
|
|
88
98
|
- name: Resolve generated user config
|
|
89
99
|
id: user_config
|
|
@@ -91,10 +101,10 @@ jobs:
|
|
|
91
101
|
|
|
92
102
|
- name: Validate selected secrets
|
|
93
103
|
env:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
|
|
105
|
+
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
106
|
+
SETUP_EMAIL_API_KEY: ${{ secrets.SETUP_EMAIL_API_KEY }}
|
|
107
|
+
SETUP_EMAIL_FROM: ${{ secrets.SETUP_EMAIL_FROM }}
|
|
98
108
|
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
|
|
99
109
|
HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
|
|
100
110
|
HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
|
|
@@ -105,16 +115,21 @@ jobs:
|
|
|
105
115
|
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
106
116
|
CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
107
117
|
PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
108
|
-
run:
|
|
118
|
+
run: |
|
|
119
|
+
export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
|
|
120
|
+
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
121
|
+
bun deploy/scripts/validate-secrets.ts
|
|
109
122
|
|
|
110
123
|
- name: Seed content repo
|
|
111
124
|
env:
|
|
112
125
|
CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
|
|
113
|
-
|
|
114
|
-
run:
|
|
126
|
+
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
127
|
+
run: |
|
|
128
|
+
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
129
|
+
bun deploy/scripts/sync-content-repo.ts
|
|
115
130
|
|
|
116
131
|
- name: Log in to GHCR
|
|
117
|
-
uses: docker/login-action@
|
|
132
|
+
uses: docker/login-action@v4
|
|
118
133
|
with:
|
|
119
134
|
registry: ghcr.io
|
|
120
135
|
username: ${{ github.actor }}
|
|
@@ -158,14 +173,17 @@ jobs:
|
|
|
158
173
|
|
|
159
174
|
- name: Write .kamal/secrets
|
|
160
175
|
env:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
|
|
177
|
+
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
178
|
+
SETUP_EMAIL_API_KEY: ${{ secrets.SETUP_EMAIL_API_KEY }}
|
|
179
|
+
SETUP_EMAIL_FROM: ${{ secrets.SETUP_EMAIL_FROM }}
|
|
165
180
|
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
|
166
181
|
CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
167
182
|
PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
168
|
-
run:
|
|
183
|
+
run: |
|
|
184
|
+
export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
|
|
185
|
+
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
186
|
+
bun deploy/scripts/write-kamal-secrets.ts
|
|
169
187
|
|
|
170
188
|
- name: Provision server
|
|
171
189
|
id: provision
|
|
@@ -182,10 +200,11 @@ jobs:
|
|
|
182
200
|
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
183
201
|
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
184
202
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
203
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
185
204
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
186
205
|
run: |
|
|
187
206
|
bun deploy/scripts/update-dns.ts
|
|
188
|
-
BRAIN_DOMAIN="
|
|
207
|
+
BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
189
208
|
|
|
190
209
|
- name: Validate SSH key
|
|
191
210
|
run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
|
|
@@ -212,6 +231,7 @@ jobs:
|
|
|
212
231
|
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
213
232
|
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
214
233
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
234
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
215
235
|
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
216
236
|
run: kamal setup --skip-push -c deploy/kamal/deploy.yml
|
|
217
237
|
|
|
@@ -219,7 +239,10 @@ jobs:
|
|
|
219
239
|
env:
|
|
220
240
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
221
241
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
222
|
-
|
|
242
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
243
|
+
run: |
|
|
244
|
+
curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
|
|
245
|
+
curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
|
|
223
246
|
|
|
224
247
|
- name: Upload generated config
|
|
225
248
|
uses: actions/upload-artifact@v4
|
|
@@ -255,7 +278,7 @@ jobs:
|
|
|
255
278
|
steps:
|
|
256
279
|
- uses: actions/checkout@v5
|
|
257
280
|
with:
|
|
258
|
-
ref: ${{ github.sha }}
|
|
281
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
259
282
|
|
|
260
283
|
- uses: oven-sh/setup-bun@v2
|
|
261
284
|
|
|
@@ -277,8 +300,22 @@ jobs:
|
|
|
277
300
|
if git diff --quiet -- users views; then
|
|
278
301
|
exit 0
|
|
279
302
|
fi
|
|
303
|
+
|
|
304
|
+
git fetch origin "${{ github.ref_name }}"
|
|
305
|
+
if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
|
|
306
|
+
exit 0
|
|
307
|
+
fi
|
|
308
|
+
|
|
309
|
+
patch_file="$(mktemp)"
|
|
310
|
+
git diff --binary -- users views > "$patch_file"
|
|
311
|
+
git reset --hard "origin/${{ github.ref_name }}"
|
|
312
|
+
git apply --3way --index "$patch_file"
|
|
313
|
+
|
|
314
|
+
if git diff --cached --quiet -- users views; then
|
|
315
|
+
exit 0
|
|
316
|
+
fi
|
|
317
|
+
|
|
280
318
|
git config user.name "github-actions[bot]"
|
|
281
319
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
282
|
-
git add users views
|
|
283
320
|
git commit -m "chore(ops): reconcile generated config"
|
|
284
321
|
git push origin HEAD:${{ github.ref_name }}
|
|
@@ -38,8 +38,22 @@ jobs:
|
|
|
38
38
|
if git diff --quiet -- views users; then
|
|
39
39
|
exit 0
|
|
40
40
|
fi
|
|
41
|
+
|
|
42
|
+
git fetch origin "${{ github.ref_name }}"
|
|
43
|
+
if git diff --quiet "origin/${{ github.ref_name }}" -- views users; then
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
patch_file="$(mktemp)"
|
|
48
|
+
git diff --binary -- views users > "$patch_file"
|
|
49
|
+
git reset --hard "origin/${{ github.ref_name }}"
|
|
50
|
+
git apply --3way --index "$patch_file"
|
|
51
|
+
|
|
52
|
+
if git diff --cached --quiet -- views users; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
41
56
|
git config user.name "github-actions[bot]"
|
|
42
57
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
43
|
-
git add views users
|
|
44
58
|
git commit -m "chore(ops): reconcile pilot outputs"
|
|
45
|
-
git push
|
|
59
|
+
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,9 +38,11 @@ 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
|
|
43
|
+
- `brains-ops age-key:bootstrap <repo>`
|
|
41
44
|
- `brains-ops ssh-key:bootstrap <repo>`
|
|
42
|
-
- `brains-ops cert:bootstrap <repo
|
|
43
|
-
- `brains-ops secrets:
|
|
45
|
+
- `brains-ops cert:bootstrap <repo>`
|
|
46
|
+
- `brains-ops secrets:encrypt <repo> <handle>`
|
|
44
47
|
- `brains-ops reconcile-cohort <repo> <cohort>`
|
|
45
48
|
- `brains-ops reconcile-all <repo>`
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { Decrypter, armor } from "age-encryption";
|
|
4
|
+
|
|
5
|
+
import { requireEnv, writeGitHubEnv, writeGitHubOutput } from "./helpers";
|
|
6
|
+
|
|
7
|
+
const handle = process.argv[2] ?? requireEnv("HANDLE");
|
|
8
|
+
const ageSecretKey = extractAgeIdentity(requireEnv("AGE_SECRET_KEY"));
|
|
9
|
+
const encryptedPath = `users/${handle}.secrets.yaml.age`;
|
|
10
|
+
|
|
11
|
+
const armored = readFileSync(encryptedPath, "utf8");
|
|
12
|
+
const decoded = armor.decode(armored);
|
|
13
|
+
|
|
14
|
+
const decrypter = new Decrypter();
|
|
15
|
+
decrypter.addIdentity(ageSecretKey);
|
|
16
|
+
|
|
17
|
+
const plaintext = await decrypter.decrypt(decoded, "text");
|
|
18
|
+
const secrets = 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("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
|
|
24
|
+
writeGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"] ?? "");
|
|
25
|
+
|
|
26
|
+
writeGitHubOutput(
|
|
27
|
+
"shared_ai_api_key_secret_name",
|
|
28
|
+
requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
|
|
29
|
+
);
|
|
30
|
+
writeGitHubOutput(
|
|
31
|
+
"shared_git_sync_token_secret_name",
|
|
32
|
+
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
function extractAgeIdentity(contents: string): string {
|
|
36
|
+
const line = contents
|
|
37
|
+
.split(/\r?\n/)
|
|
38
|
+
.map((entry) => entry.trim())
|
|
39
|
+
.find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
|
|
40
|
+
|
|
41
|
+
if (!line) {
|
|
42
|
+
throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return line;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseFlatYaml(contents: string): Record<string, string> {
|
|
49
|
+
const result: Record<string, string> = {};
|
|
50
|
+
|
|
51
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
52
|
+
const line = rawLine.trim();
|
|
53
|
+
if (!line || line.startsWith("#")) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
58
|
+
if (!match) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const [, key, rawValue] = match;
|
|
63
|
+
result[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requireFlatValue(
|
|
70
|
+
values: Record<string, string>,
|
|
71
|
+
key: string,
|
|
72
|
+
label: string,
|
|
73
|
+
): string {
|
|
74
|
+
const value = values[key];
|
|
75
|
+
if (!value) {
|
|
76
|
+
throw new Error(`Missing ${key} in ${label}`);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
@@ -101,7 +101,7 @@ while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
|
|
|
101
101
|
server = await getServer(server.id);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
const serverIp = server.public_net
|
|
104
|
+
const serverIp = server.public_net.ipv4.ip;
|
|
105
105
|
if (!serverIp) {
|
|
106
106
|
throw new Error(`Server ${server.id} running but has no IPv4 address`);
|
|
107
107
|
}
|
|
@@ -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
|
}
|
|
@@ -32,9 +41,9 @@ const handles = [
|
|
|
32
41
|
diffOutput
|
|
33
42
|
.split(/\r?\n/)
|
|
34
43
|
.map((path) => {
|
|
35
|
-
const match =
|
|
36
|
-
/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)
|
|
37
|
-
|
|
44
|
+
const match =
|
|
45
|
+
path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
|
|
46
|
+
path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
|
|
38
47
|
return match?.[1] ?? null;
|
|
39
48
|
})
|
|
40
49
|
.filter((handle): handle is string => handle !== null)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
2
3
|
import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
|
|
3
4
|
|
|
4
5
|
const handle = requireEnv("HANDLE");
|
|
@@ -12,32 +13,31 @@ const repositoryOwner = repository.split("/")[0] ?? "";
|
|
|
12
13
|
const brainYaml = readFileSync(brainYamlPath, "utf8");
|
|
13
14
|
const domainMatch = brainYaml.match(/^domain:\s*(.+)$/m);
|
|
14
15
|
const brainDomain = domainMatch?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
15
|
-
|
|
16
16
|
if (!brainDomain) {
|
|
17
17
|
throw new Error(`Missing domain in ${brainYamlPath}`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const zone =
|
|
21
|
+
brainDomain.startsWith(`${handle}.`) && brainDomain.length > handle.length + 1
|
|
22
|
+
? brainDomain.slice(handle.length + 1)
|
|
23
|
+
: "";
|
|
24
|
+
if (!zone) {
|
|
25
|
+
throw new Error(`Could not derive preview domain from ${brainDomain}`);
|
|
26
|
+
}
|
|
27
|
+
const previewDomain = `${handle}-preview.${zone}`;
|
|
28
|
+
|
|
20
29
|
const outputs: Record<string, string> = {
|
|
21
30
|
brain_version: envEntries["BRAIN_VERSION"] ?? "",
|
|
22
|
-
ai_api_key_secret_name: envEntries["AI_API_KEY_SECRET"] ?? "",
|
|
23
|
-
git_sync_token_secret_name: envEntries["GIT_SYNC_TOKEN_SECRET"] ?? "",
|
|
24
|
-
mcp_auth_token_secret_name: envEntries["MCP_AUTH_TOKEN_SECRET"] ?? "",
|
|
25
|
-
discord_bot_token_secret_name: envEntries["DISCORD_BOT_TOKEN_SECRET"] ?? "",
|
|
26
31
|
content_repo: envEntries["CONTENT_REPO"] ?? "",
|
|
27
32
|
brain_domain: brainDomain,
|
|
33
|
+
preview_domain: previewDomain,
|
|
28
34
|
brain_yaml_path: brainYamlPath,
|
|
29
35
|
instance_name: `rover-${handle}`,
|
|
30
36
|
image_repository: `ghcr.io/${repository}`,
|
|
31
37
|
registry_username: repositoryOwner,
|
|
32
38
|
};
|
|
33
39
|
|
|
34
|
-
const required = [
|
|
35
|
-
"brain_version",
|
|
36
|
-
"ai_api_key_secret_name",
|
|
37
|
-
"git_sync_token_secret_name",
|
|
38
|
-
"mcp_auth_token_secret_name",
|
|
39
|
-
"registry_username",
|
|
40
|
-
];
|
|
40
|
+
const required = ["brain_version", "registry_username"];
|
|
41
41
|
for (const key of required) {
|
|
42
42
|
if (!outputs[key]) {
|
|
43
43
|
throw new Error(`Missing ${key} (derived from ${envPath})`);
|
|
@@ -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, {
|
|
@@ -1,17 +1,46 @@
|
|
|
1
1
|
# Onboarding Checklist
|
|
2
2
|
|
|
3
3
|
1. Run `bun install` so the repo uses its pinned `@rizom/ops` version.
|
|
4
|
-
2.
|
|
5
|
-
3.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
4
|
+
2. Run `bunx brains-ops age-key:bootstrap <repo> --push-to gh`.
|
|
5
|
+
3. Fill in `pilot.yaml`.
|
|
6
|
+
- keep your pinned `brainVersion`
|
|
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
|
|
9
|
+
- confirm `agePublicKey`
|
|
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.
|
|
19
|
+
6. Run `bunx brains-ops render <repo>`.
|
|
20
|
+
7. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
|
|
21
|
+
8. Run `bunx brains-ops cert:bootstrap <repo> --push-to gh`.
|
|
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.
|
|
23
|
+
10. Run `bunx brains-ops secrets:encrypt <repo> <handle>`.
|
|
24
|
+
11. Commit and push `users/<handle>.secrets.yaml.age`.
|
|
25
|
+
12. Run `bunx brains-ops onboard <repo> <handle>`.
|
|
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
|
|
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.
|
|
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.
|