@rizom/ops 0.2.0-alpha.22 → 0.2.0-alpha.221
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 +229 -283
- 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 +4 -0
- package/dist/index.js +268 -281
- package/dist/load-registry.d.ts +25 -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/preview-domain.d.ts +9 -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 +64 -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 +45 -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 +128 -61
- package/templates/rover-pilot/.github/workflows/reconcile.yml +21 -1
- package/templates/rover-pilot/README.md +5 -3
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +78 -24
- 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 +44 -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 +33 -13
- package/templates/rover-pilot/docs/operator-playbook.md +183 -10
- 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/Dockerfile +0 -30
- 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 }}
|
|
@@ -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,86 @@ 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
|
+
env:
|
|
92
|
+
BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
|
|
93
|
+
run: |
|
|
94
|
+
for attempt in 1 2 3; do
|
|
95
|
+
if bunx varlock@1.1.0 load --path .env.schema --format json --compact > /tmp/varlock-env.json; then
|
|
96
|
+
break
|
|
97
|
+
fi
|
|
98
|
+
if [ "$attempt" = "3" ]; then
|
|
99
|
+
exit 1
|
|
100
|
+
fi
|
|
101
|
+
echo "Varlock load failed (attempt $attempt/3); retrying in 5s..."
|
|
102
|
+
sleep 5
|
|
103
|
+
done
|
|
104
|
+
node <<'NODE'
|
|
105
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
106
|
+
const env = JSON.parse(readFileSync('/tmp/varlock-env.json', 'utf8'));
|
|
107
|
+
const githubEnvPath = process.env.GITHUB_ENV;
|
|
108
|
+
if (!githubEnvPath) {
|
|
109
|
+
throw new Error('Missing GITHUB_ENV');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const newline = String.fromCharCode(10);
|
|
113
|
+
const carriageReturn = String.fromCharCode(13);
|
|
114
|
+
const chunks = [];
|
|
115
|
+
for (const [key, value] of Object.entries(env)) {
|
|
116
|
+
if (key === 'BWS_ACCESS_TOKEN' || value === null || value === undefined) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const text = String(value)
|
|
121
|
+
.split(carriageReturn + newline)
|
|
122
|
+
.join(newline);
|
|
123
|
+
const escapedMask = text
|
|
124
|
+
.replace(/%/g, '%25')
|
|
125
|
+
.replace(/\r/g, '%0D')
|
|
126
|
+
.replace(/\n/g, '%0A');
|
|
127
|
+
if (escapedMask.length > 0) {
|
|
128
|
+
process.stdout.write('::add-mask::' + escapedMask + newline);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (text.includes(newline)) {
|
|
132
|
+
let delimiter = `VARLOCK_${key}_EOF`;
|
|
133
|
+
while (text.includes(delimiter)) {
|
|
134
|
+
delimiter += '_X';
|
|
135
|
+
}
|
|
136
|
+
chunks.push(`${key}<<${delimiter}${newline}${text}${newline}${delimiter}`);
|
|
137
|
+
} else {
|
|
138
|
+
chunks.push(`${key}=${text}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
appendFileSync(githubEnvPath, chunks.join(newline) + newline);
|
|
143
|
+
NODE
|
|
144
|
+
|
|
78
145
|
- name: Decrypt user secrets
|
|
79
146
|
id: user_secrets
|
|
80
|
-
env:
|
|
81
|
-
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
|
82
147
|
run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
|
|
83
148
|
|
|
84
149
|
- 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"
|
|
150
|
+
run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
|
|
90
151
|
|
|
91
152
|
- name: Resolve generated user config
|
|
92
153
|
id: user_config
|
|
93
154
|
run: bun deploy/scripts/resolve-user-config.ts
|
|
94
155
|
|
|
95
156
|
- 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
|
|
157
|
+
run: bun deploy/scripts/validate-secrets.ts
|
|
115
158
|
|
|
116
159
|
- name: Seed content repo
|
|
117
160
|
env:
|
|
118
161
|
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
|
|
162
|
+
run: bun deploy/scripts/sync-content-repo.ts
|
|
123
163
|
|
|
124
164
|
- name: Log in to GHCR
|
|
125
165
|
uses: docker/login-action@v3
|
|
@@ -128,15 +168,18 @@ jobs:
|
|
|
128
168
|
username: ${{ github.actor }}
|
|
129
169
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
130
170
|
|
|
131
|
-
- name: Wait for
|
|
171
|
+
- name: Wait for image tag
|
|
132
172
|
run: |
|
|
133
|
-
|
|
134
|
-
|
|
173
|
+
# The Build workflow fires off the same config push (it derives the
|
|
174
|
+
# declared image set from the registry), so this wait must cover a
|
|
175
|
+
# full image build, not just registry propagation.
|
|
176
|
+
IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
|
|
177
|
+
for attempt in $(seq 1 60); do
|
|
135
178
|
if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
|
|
136
179
|
exit 0
|
|
137
180
|
fi
|
|
138
|
-
echo "
|
|
139
|
-
sleep
|
|
181
|
+
echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
|
|
182
|
+
sleep 20
|
|
140
183
|
done
|
|
141
184
|
echo "Timed out waiting for $IMAGE_TAG" >&2
|
|
142
185
|
exit 1
|
|
@@ -147,8 +190,6 @@ jobs:
|
|
|
147
190
|
ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
|
|
148
191
|
|
|
149
192
|
- name: Write Kamal SSH key
|
|
150
|
-
env:
|
|
151
|
-
KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
|
|
152
193
|
run: bun deploy/scripts/write-ssh-key.ts
|
|
153
194
|
|
|
154
195
|
- name: Configure SSH client
|
|
@@ -165,38 +206,26 @@ jobs:
|
|
|
165
206
|
chmod 600 ~/.ssh/config
|
|
166
207
|
|
|
167
208
|
- 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
|
|
209
|
+
run: bun deploy/scripts/write-kamal-secrets.ts
|
|
180
210
|
|
|
181
211
|
- name: Provision server
|
|
182
212
|
id: provision
|
|
183
213
|
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
214
|
INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
|
|
189
215
|
run: bun deploy/scripts/provision-server.ts
|
|
190
216
|
|
|
191
217
|
- name: Update Cloudflare DNS
|
|
192
218
|
env:
|
|
193
|
-
|
|
194
|
-
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
219
|
+
CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
|
|
195
220
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
221
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
196
222
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
197
223
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
198
224
|
run: |
|
|
199
225
|
bun deploy/scripts/update-dns.ts
|
|
226
|
+
if [ -n "$WWW_DOMAIN" ]; then
|
|
227
|
+
BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
228
|
+
fi
|
|
200
229
|
BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
201
230
|
|
|
202
231
|
- name: Validate SSH key
|
|
@@ -217,13 +246,27 @@ jobs:
|
|
|
217
246
|
echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
|
|
218
247
|
exit 1
|
|
219
248
|
|
|
249
|
+
- name: Release operator-confirmed stale deploy lock
|
|
250
|
+
if: ${{ inputs.release_stale_lock }}
|
|
251
|
+
env:
|
|
252
|
+
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
253
|
+
VERSION: ${{ steps.user_config.outputs.image_tag }}
|
|
254
|
+
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
255
|
+
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
256
|
+
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
257
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
258
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
259
|
+
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
260
|
+
run: kamal lock release -c deploy/kamal/deploy.yml
|
|
261
|
+
|
|
220
262
|
- name: Deploy
|
|
221
263
|
env:
|
|
222
264
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
223
|
-
VERSION:
|
|
265
|
+
VERSION: ${{ steps.user_config.outputs.image_tag }}
|
|
224
266
|
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
225
267
|
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
226
268
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
269
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
227
270
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
228
271
|
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
229
272
|
run: kamal setup --skip-push -c deploy/kamal/deploy.yml
|
|
@@ -232,9 +275,13 @@ jobs:
|
|
|
232
275
|
env:
|
|
233
276
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
234
277
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
278
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
235
279
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
236
280
|
run: |
|
|
237
281
|
curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
|
|
282
|
+
if [ -n "$WWW_DOMAIN" ]; then
|
|
283
|
+
curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
|
|
284
|
+
fi
|
|
238
285
|
curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
|
|
239
286
|
|
|
240
287
|
- name: Upload generated config
|
|
@@ -271,7 +318,7 @@ jobs:
|
|
|
271
318
|
steps:
|
|
272
319
|
- uses: actions/checkout@v5
|
|
273
320
|
with:
|
|
274
|
-
ref: ${{ github.sha }}
|
|
321
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
275
322
|
|
|
276
323
|
- uses: oven-sh/setup-bun@v2
|
|
277
324
|
|
|
@@ -290,11 +337,31 @@ jobs:
|
|
|
290
337
|
|
|
291
338
|
- name: Commit generated config
|
|
292
339
|
run: |
|
|
340
|
+
# A newly added user's generated directory is untracked, and
|
|
341
|
+
# `git diff` cannot see untracked files — without intent-to-add
|
|
342
|
+
# the new config is silently dropped here and every later deploy
|
|
343
|
+
# skips that user (their files never appear in a commit range).
|
|
344
|
+
git add --intent-to-add -- users views
|
|
345
|
+
|
|
293
346
|
if git diff --quiet -- users views; then
|
|
294
347
|
exit 0
|
|
295
348
|
fi
|
|
349
|
+
|
|
350
|
+
git fetch origin "${{ github.ref_name }}"
|
|
351
|
+
if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
|
|
352
|
+
exit 0
|
|
353
|
+
fi
|
|
354
|
+
|
|
355
|
+
patch_file="$(mktemp)"
|
|
356
|
+
git diff --binary -- users views > "$patch_file"
|
|
357
|
+
git reset --hard "origin/${{ github.ref_name }}"
|
|
358
|
+
git apply --3way --index "$patch_file"
|
|
359
|
+
|
|
360
|
+
if git diff --cached --quiet -- users views; then
|
|
361
|
+
exit 0
|
|
362
|
+
fi
|
|
363
|
+
|
|
296
364
|
git config user.name "github-actions[bot]"
|
|
297
365
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
298
|
-
git add users views
|
|
299
366
|
git commit -m "chore(ops): reconcile generated config"
|
|
300
367
|
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,15 +29,17 @@ 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
|
-
|
|
33
|
-
|
|
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. An omitted `siteOverride.version` follows that effective version; an explicit exact version remains pinned.
|
|
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
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>`
|
|
@@ -15,13 +15,50 @@ const decrypter = new Decrypter();
|
|
|
15
15
|
decrypter.addIdentity(ageSecretKey);
|
|
16
16
|
|
|
17
17
|
const plaintext = await decrypter.decrypt(decoded, "text");
|
|
18
|
-
const secrets =
|
|
19
|
-
const pilot =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
// 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
|
+
// A corrupted stored value would otherwise only surface as a kamal-proxy
|
|
43
|
+
// "unable to load certificate" failure after the container has already booted.
|
|
44
|
+
assertPemShape("certificatePem", certificatePem);
|
|
45
|
+
assertPemShape("privateKeyPem", privateKeyPem);
|
|
46
|
+
writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
|
|
47
|
+
writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
|
|
48
|
+
|
|
49
|
+
function assertPemShape(name: string, value: string | undefined): void {
|
|
50
|
+
if (value === undefined || value.trim().length === 0) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
!/-----BEGIN [A-Z0-9 ]+-----/.test(value) ||
|
|
55
|
+
!/-----END [A-Z0-9 ]+-----/.test(value)
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Secret ${name} is not PEM-shaped after unescaping; the stored value is corrupt`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
25
62
|
|
|
26
63
|
writeGitHubOutput(
|
|
27
64
|
"shared_ai_api_key_secret_name",
|
|
@@ -32,10 +69,29 @@ writeGitHubOutput(
|
|
|
32
69
|
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
33
70
|
);
|
|
34
71
|
writeGitHubOutput(
|
|
35
|
-
"
|
|
36
|
-
requireFlatValue(pilot, "
|
|
72
|
+
"shared_content_repo_admin_token_secret_name",
|
|
73
|
+
requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
|
|
37
74
|
);
|
|
38
75
|
|
|
76
|
+
function writeSecretGitHubEnv(name: string, value: string | undefined): void {
|
|
77
|
+
if (!value || value.trim().length === 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
maskGitHubSecret(value);
|
|
82
|
+
writeGitHubEnv(name, value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function maskGitHubSecret(value: string): void {
|
|
86
|
+
const escaped = value
|
|
87
|
+
.replace(/%/g, "%25")
|
|
88
|
+
.replace(/\r/g, "%0D")
|
|
89
|
+
.replace(/\n/g, "%0A");
|
|
90
|
+
if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
91
|
+
console.log(`::add-mask::${escaped}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
39
95
|
function extractAgeIdentity(contents: string): string {
|
|
40
96
|
const line = contents
|
|
41
97
|
.split(/\r?\n/)
|
|
@@ -49,27 +105,25 @@ function extractAgeIdentity(contents: string): string {
|
|
|
49
105
|
return line;
|
|
50
106
|
}
|
|
51
107
|
|
|
52
|
-
function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!line || line.startsWith("#")) {
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
108
|
+
function parseStringYamlMapping(contents: string): Record<string, string> {
|
|
109
|
+
const parsed: unknown = Bun.YAML.parse(contents);
|
|
110
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
111
|
+
throw new Error("Expected a YAML mapping");
|
|
112
|
+
}
|
|
60
113
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
114
|
+
const result: Record<string, string> = {};
|
|
115
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
116
|
+
if (typeof value === "string") {
|
|
117
|
+
result[key] = value;
|
|
64
118
|
}
|
|
65
|
-
|
|
66
|
-
const [, key, rawValue] = match;
|
|
67
|
-
result[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
|
68
119
|
}
|
|
69
|
-
|
|
70
120
|
return result;
|
|
71
121
|
}
|
|
72
122
|
|
|
123
|
+
function decodeEscapedSecret(value: string | undefined): string | undefined {
|
|
124
|
+
return value?.replace(/\\n/g, "\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
73
127
|
function requireFlatValue(
|
|
74
128
|
values: Record<string, string>,
|
|
75
129
|
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
|
}
|