@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.
- 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 +64 -17
- package/templates/rover-pilot/.github/workflows/deploy.yml +110 -61
- 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 +48 -6
- package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
- package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +12 -3
- package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +63 -9
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
- package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +1 -1
- 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 -67
- package/templates/rover-pilot/deploy/Dockerfile +0 -38
- 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
|
-
|
|
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
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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@
|
|
81
|
+
uses: docker/setup-buildx-action@v4
|
|
36
82
|
|
|
37
83
|
- name: Extract image metadata
|
|
38
84
|
id: meta
|
|
39
|
-
uses: docker/metadata-action@
|
|
85
|
+
uses: docker/metadata-action@v6
|
|
40
86
|
with:
|
|
41
|
-
images:
|
|
87
|
+
images: ghcr.io/${{ github.repository }}
|
|
42
88
|
tags: |
|
|
43
|
-
type=raw,value
|
|
89
|
+
type=raw,value=${{ matrix.image.tag }}
|
|
44
90
|
|
|
45
91
|
- name: Log in to GHCR
|
|
46
|
-
uses: docker/login-action@
|
|
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@
|
|
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=${{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
166
|
+
- name: Wait for image tag
|
|
132
167
|
run: |
|
|
133
|
-
|
|
134
|
-
|
|
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 "
|
|
139
|
-
sleep
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
"
|
|
36
|
-
requireFlatValue(pilot, "
|
|
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,
|
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|