@rizom/ops 0.2.0-alpha.26 → 0.2.0-alpha.261

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +33 -2
  2. package/dist/brains-ops.js +407 -313
  3. package/dist/cert-bootstrap.d.ts +3 -1
  4. package/dist/content-repo-ref.d.ts +10 -0
  5. package/dist/content-repo.d.ts +1 -0
  6. package/dist/deploy.js +99 -166
  7. package/dist/directory-sync-stress-system.d.ts +63 -0
  8. package/dist/directory-sync-stress.d.ts +105 -0
  9. package/dist/entries/deploy.d.ts +3 -2
  10. package/dist/images.d.ts +76 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.js +443 -308
  13. package/dist/legacy-pilot-migration.d.ts +11 -0
  14. package/dist/load-registry.d.ts +55 -6
  15. package/dist/observed-status.d.ts +1 -1
  16. package/dist/origin-ca.d.ts +1 -1
  17. package/dist/parse-args.d.ts +2 -10
  18. package/dist/preview-domain.d.ts +9 -0
  19. package/dist/push-secrets.d.ts +2 -9
  20. package/dist/push-target.d.ts +1 -2
  21. package/dist/reconcile-dry-run.d.ts +9 -0
  22. package/dist/run-command.d.ts +8 -3
  23. package/dist/run-subprocess.d.ts +1 -6
  24. package/dist/schema.d.ts +100 -162
  25. package/dist/secrets-encrypt.d.ts +7 -13
  26. package/dist/ssh-key-bootstrap.d.ts +1 -26
  27. package/dist/stage-legacy-crossover.d.ts +20 -0
  28. package/dist/user-add.d.ts +15 -0
  29. package/dist/verify-user.d.ts +19 -0
  30. package/package.json +46 -42
  31. package/templates/rover-pilot/.env.schema +24 -3
  32. package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
  33. package/templates/rover-pilot/.github/workflows/build.yml +64 -17
  34. package/templates/rover-pilot/.github/workflows/deploy.yml +70 -60
  35. package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +100 -0
  36. package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
  37. package/templates/rover-pilot/README.md +13 -6
  38. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -24
  39. package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
  40. package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
  41. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +12 -3
  42. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  43. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +44 -9
  44. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
  45. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  46. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
  47. package/templates/rover-pilot/docs/canonical-crossover-record.md +104 -0
  48. package/templates/rover-pilot/docs/onboarding-checklist.md +29 -13
  49. package/templates/rover-pilot/docs/operator-playbook.md +234 -24
  50. package/templates/rover-pilot/docs/user-onboarding.md +49 -343
  51. package/templates/rover-pilot/pilot.yaml +3 -4
  52. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  53. package/templates/rover-pilot/deploy/Dockerfile +0 -30
  54. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
package/package.json CHANGED
@@ -1,67 +1,71 @@
1
1
  {
2
2
  "name": "@rizom/ops",
3
+ "version": "0.2.0-alpha.261",
3
4
  "description": "Operator CLI for managing private brain fleet registry repos",
4
- "publishConfig": {
5
- "access": "public"
5
+ "keywords": [
6
+ "brains",
7
+ "ops",
8
+ "cli",
9
+ "deploy",
10
+ "fleet",
11
+ "operator"
12
+ ],
13
+ "license": "AGPL-3.0-only",
14
+ "author": "Yeehaa <yeehaa@rizom.ai> (https://rizom.ai)",
15
+ "bin": {
16
+ "brains-ops": "./dist/brains-ops.js"
17
+ },
18
+ "bugs": "https://github.com/rizom-ai/brains/issues",
19
+ "dependencies": {
20
+ "age-encryption": "^0.3.0",
21
+ "yaml": "^2.9.0"
22
+ },
23
+ "devDependencies": {
24
+ "@brains/deploy-support": "workspace:*",
25
+ "@brains/eslint-config": "workspace:*",
26
+ "@brains/site-composition": "workspace:*",
27
+ "@brains/typescript-config": "workspace:*",
28
+ "@brains/utils": "workspace:*",
29
+ "@types/bun": "^1.3.14",
30
+ "typescript": "^7.0.2"
31
+ },
32
+ "engines": {
33
+ "bun": ">=1.3.3"
6
34
  },
7
- "version": "0.2.0-alpha.26",
8
- "type": "module",
9
35
  "exports": {
10
36
  ".": {
11
37
  "types": "./dist/index.d.ts",
12
38
  "import": "./dist/index.js"
13
39
  },
14
40
  "./deploy": {
15
- "bun": "./dist/deploy.js",
16
41
  "types": "./dist/deploy.d.ts",
17
- "import": "./dist/deploy.js"
42
+ "import": "./dist/deploy.js",
43
+ "bun": "./dist/deploy.js"
18
44
  }
19
45
  },
20
- "main": "./dist/index.js",
21
- "types": "./dist/index.d.ts",
22
- "bin": {
23
- "brains-ops": "./dist/brains-ops.js"
24
- },
25
46
  "files": [
26
47
  "dist",
27
48
  "templates"
28
49
  ],
29
- "scripts": {
30
- "build": "bun scripts/build.ts",
31
- "prepublishOnly": "bun scripts/build.ts",
32
- "typecheck": "tsc --noEmit",
33
- "lint": "eslint . --ext .ts",
34
- "lint:fix": "eslint . --ext .ts --fix",
35
- "test": "bun test --timeout 20000"
36
- },
37
- "dependencies": {
38
- "age-encryption": "^0.3.0"
39
- },
40
- "devDependencies": {
41
- "@brains/eslint-config": "workspace:*",
42
- "@brains/typescript-config": "workspace:*",
43
- "@brains/utils": "workspace:*",
44
- "@types/bun": "latest",
45
- "typescript": "^5.3.3"
50
+ "homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
51
+ "main": "./dist/index.js",
52
+ "publishConfig": {
53
+ "access": "public"
46
54
  },
47
55
  "repository": {
48
56
  "type": "git",
49
57
  "url": "https://github.com/rizom-ai/brains.git",
50
58
  "directory": "packages/brains-ops"
51
59
  },
52
- "license": "Apache-2.0",
53
- "author": "Yeehaa <yeehaa@rizom.ai> (https://rizom.ai)",
54
- "homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
55
- "bugs": "https://github.com/rizom-ai/brains/issues",
56
- "engines": {
57
- "bun": ">=1.3.3"
60
+ "scripts": {
61
+ "build": "bun scripts/build.ts",
62
+ "lint": "eslint . --max-warnings 0",
63
+ "lint:fix": "eslint . --max-warnings 0 --fix",
64
+ "prepublishOnly": "bun scripts/build.ts",
65
+ "test": "bun run build && bun test --timeout 20000",
66
+ "test:smoke": "bun run build && RUN_SMOKE_TESTS=1 bun test --timeout 60000",
67
+ "typecheck": "tsc --noEmit"
58
68
  },
59
- "keywords": [
60
- "brains",
61
- "ops",
62
- "cli",
63
- "deploy",
64
- "fleet",
65
- "operator"
66
- ]
69
+ "type": "module",
70
+ "types": "./dist/index.d.ts"
67
71
  }
@@ -14,16 +14,37 @@ AI_API_KEY=
14
14
  # @required @sensitive
15
15
  GIT_SYNC_TOKEN=
16
16
 
17
- # MCP interface
18
- # Comes from the decrypted users/<handle>.secrets.yaml.age file.
17
+ # Content repo administration
18
+ # Local/operator secret only. Used by brains-ops to create missing GitHub repos;
19
+ # do not deploy it into Rover runtime config.
19
20
  # @required @sensitive
20
- MCP_AUTH_TOKEN=
21
+ CONTENT_REPO_ADMIN_TOKEN=
21
22
 
22
23
  # Discord (optional, per-user)
23
24
  # Comes from the decrypted users/<handle>.secrets.yaml.age file when enabled.
24
25
  # @sensitive
25
26
  DISCORD_BOT_TOKEN=
26
27
 
28
+ # Stored with the per-user Discord credentials and injected at deploy time.
29
+ # @sensitive
30
+ DISCORD_PUBLIC_KEY=
31
+
32
+ # @sensitive
33
+ DISCORD_APPLICATION_ID=
34
+
35
+ # AT Protocol publishing/discovery (optional, per-user)
36
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file when configured.
37
+ # @sensitive
38
+ ATPROTO_APP_PASSWORD=
39
+
40
+ # Setup email delivery (optional, shared)
41
+ # Used when a user file declares setup.delivery: email.
42
+ # @sensitive
43
+ SETUP_EMAIL_API_KEY=
44
+
45
+ # @sensitive
46
+ SETUP_EMAIL_FROM=
47
+
27
48
  # ---- deploy/provision vars ----
28
49
 
29
50
  # @required @sensitive
@@ -0,0 +1,47 @@
1
+ name: Load Bitwarden environment through Varlock
2
+ description: Load, mask, and export the pilot environment from Varlock
3
+
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Load shared environment
8
+ shell: bash
9
+ run: |
10
+ for attempt in 1 2 3; do
11
+ if bunx varlock@1.1.0 load --path .env.schema --format json --compact > /tmp/varlock-env.json; then
12
+ break
13
+ fi
14
+ if [ "$attempt" = "3" ]; then
15
+ exit 1
16
+ fi
17
+ echo "Varlock load failed (attempt $attempt/3); retrying in 5s..."
18
+ sleep 5
19
+ done
20
+ node <<'NODE'
21
+ import { appendFileSync, readFileSync, rmSync } from "node:fs";
22
+ const env = JSON.parse(readFileSync("/tmp/varlock-env.json", "utf8"));
23
+ const output = process.env.GITHUB_ENV;
24
+ if (!output) throw new Error("Missing GITHUB_ENV");
25
+ const newline = "\n";
26
+ const chunks = [];
27
+ for (const [key, value] of Object.entries(env)) {
28
+ if (key === "BWS_ACCESS_TOKEN" || value === null || value === undefined) continue;
29
+ const text = String(value).replace(/\r\n/g, newline);
30
+ const escapedMask = text
31
+ .replace(/%/g, "%25")
32
+ .replace(/\r/g, "%0D")
33
+ .replace(/\n/g, "%0A");
34
+ if (escapedMask.length > 0) {
35
+ process.stdout.write(`::add-mask::${escapedMask}${newline}`);
36
+ }
37
+ if (text.includes(newline)) {
38
+ let delimiter = `VARLOCK_${key}_EOF`;
39
+ while (text.includes(delimiter)) delimiter += "_X";
40
+ chunks.push(`${key}<<${delimiter}${newline}${text}${newline}${delimiter}`);
41
+ } else {
42
+ chunks.push(`${key}=${text}`);
43
+ }
44
+ }
45
+ appendFileSync(output, `${chunks.join(newline)}${newline}`);
46
+ rmSync("/tmp/varlock-env.json", { force: true });
47
+ NODE
@@ -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
- build:
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
- - name: Extract image metadata inputs
25
- run: |
26
- BRAIN_VERSION="$(grep '^brainVersion:' pilot.yaml | sed 's/^brainVersion:[[:space:]]*//' | tr -d '"' | tr -d "'")"
27
- if [ -z "$BRAIN_VERSION" ]; then
28
- echo "Missing brainVersion in pilot.yaml" >&2
29
- exit 1
30
- fi
31
- echo "BRAIN_VERSION=$BRAIN_VERSION" >> "$GITHUB_ENV"
32
- echo "IMAGE_REPOSITORY=ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}" >> "$GITHUB_ENV"
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@v3
81
+ uses: docker/setup-buildx-action@v4
36
82
 
37
83
  - name: Extract image metadata
38
84
  id: meta
39
- uses: docker/metadata-action@v5
85
+ uses: docker/metadata-action@v6
40
86
  with:
41
- images: ${{ env.IMAGE_REPOSITORY }}
87
+ images: ghcr.io/${{ github.repository }}
42
88
  tags: |
43
- type=raw,value=brain-${{ env.BRAIN_VERSION }}
89
+ type=raw,value=${{ matrix.image.tag }}
44
90
 
45
91
  - name: Log in to GHCR
46
- uses: docker/login-action@v3
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@v6
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=${{ env.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,36 @@ jobs:
68
80
  steps:
69
81
  - uses: actions/checkout@v5
70
82
  with:
71
- ref: ${{ github.sha }}
83
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
72
84
 
73
85
  - uses: oven-sh/setup-bun@v2
74
86
 
75
87
  - name: Install operator tooling
76
88
  run: bun install
77
89
 
90
+ - name: Load shared env via Varlock
91
+ uses: ./.github/actions/varlock-env
92
+ env:
93
+ BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
94
+
78
95
  - name: Decrypt user secrets
79
96
  id: user_secrets
80
- env:
81
- AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
82
97
  run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
83
98
 
84
99
  - name: Reconcile selected user config
85
- env:
86
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
87
- run: |
88
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
89
- bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
100
+ run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
90
101
 
91
102
  - name: Resolve generated user config
92
103
  id: user_config
93
104
  run: bun deploy/scripts/resolve-user-config.ts
94
105
 
95
106
  - name: Validate selected secrets
96
- env:
97
- SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
98
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
99
- SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
100
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
101
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
102
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
103
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
104
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
105
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
106
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
107
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
108
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
109
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
110
- run: |
111
- export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
112
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
113
- export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
114
- bun deploy/scripts/validate-secrets.ts
107
+ run: bun deploy/scripts/validate-secrets.ts
115
108
 
116
109
  - name: Seed content repo
117
110
  env:
118
111
  CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
119
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
120
- run: |
121
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
122
- bun deploy/scripts/sync-content-repo.ts
112
+ run: bun deploy/scripts/sync-content-repo.ts
123
113
 
124
114
  - name: Log in to GHCR
125
115
  uses: docker/login-action@v3
@@ -128,15 +118,18 @@ jobs:
128
118
  username: ${{ github.actor }}
129
119
  password: ${{ secrets.GITHUB_TOKEN }}
130
120
 
131
- - name: Wait for shared image tag
121
+ - name: Wait for image tag
132
122
  run: |
133
- IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:brain-${{ steps.user_config.outputs.brain_version }}"
134
- for attempt in $(seq 1 24); do
123
+ # The Build workflow fires off the same config push (it derives the
124
+ # declared image set from the registry), so this wait must cover a
125
+ # full image build, not just registry propagation.
126
+ IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
127
+ for attempt in $(seq 1 60); do
135
128
  if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
136
129
  exit 0
137
130
  fi
138
- echo "Shared image tag not ready yet ($attempt/24): $IMAGE_TAG"
139
- sleep 10
131
+ echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
132
+ sleep 20
140
133
  done
141
134
  echo "Timed out waiting for $IMAGE_TAG" >&2
142
135
  exit 1
@@ -147,8 +140,6 @@ jobs:
147
140
  ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
148
141
 
149
142
  - name: Write Kamal SSH key
150
- env:
151
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
152
143
  run: bun deploy/scripts/write-ssh-key.ts
153
144
 
154
145
  - name: Configure SSH client
@@ -165,38 +156,26 @@ jobs:
165
156
  chmod 600 ~/.ssh/config
166
157
 
167
158
  - name: Write .kamal/secrets
168
- env:
169
- SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
170
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
171
- SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
172
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
173
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
174
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
175
- run: |
176
- export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
177
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
178
- export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
179
- bun deploy/scripts/write-kamal-secrets.ts
159
+ run: bun deploy/scripts/write-kamal-secrets.ts
180
160
 
181
161
  - name: Provision server
182
162
  id: provision
183
163
  env:
184
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
185
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
186
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
187
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
188
164
  INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
189
165
  run: bun deploy/scripts/provision-server.ts
190
166
 
191
167
  - name: Update Cloudflare DNS
192
168
  env:
193
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
194
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
169
+ CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
195
170
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
171
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
196
172
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
197
173
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
198
174
  run: |
199
175
  bun deploy/scripts/update-dns.ts
176
+ if [ -n "$WWW_DOMAIN" ]; then
177
+ BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
178
+ fi
200
179
  BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
201
180
 
202
181
  - name: Validate SSH key
@@ -217,24 +196,49 @@ jobs:
217
196
  echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
218
197
  exit 1
219
198
 
199
+ - name: Release operator-confirmed stale deploy lock
200
+ if: ${{ inputs.release_stale_lock }}
201
+ env:
202
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
203
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
204
+ IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
205
+ REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
206
+ BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
207
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
208
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
209
+ BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
210
+ run: kamal lock release -c deploy/kamal/deploy.yml
211
+
220
212
  - name: Deploy
221
213
  env:
222
214
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
223
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
215
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
224
216
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
225
217
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
226
218
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
219
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
227
220
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
228
221
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
229
222
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
230
223
 
224
+ - name: Install container health watchdog
225
+ env:
226
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
227
+ run: |
228
+ SSH_USER="$(ruby -e 'require "yaml"; config = YAML.load_file("deploy/kamal/deploy.yml") || {}; puts(config.dig("ssh", "user") || "root")')" \
229
+ bun deploy/scripts/install-health-watchdog.ts
230
+
231
231
  - name: Verify origin TLS
232
232
  env:
233
233
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
234
234
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
235
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
235
236
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
236
237
  run: |
237
238
  curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
239
+ if [ -n "$WWW_DOMAIN" ]; then
240
+ curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
241
+ fi
238
242
  curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
239
243
 
240
244
  - name: Upload generated config
@@ -271,7 +275,7 @@ jobs:
271
275
  steps:
272
276
  - uses: actions/checkout@v5
273
277
  with:
274
- ref: ${{ github.sha }}
278
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
275
279
 
276
280
  - uses: oven-sh/setup-bun@v2
277
281
 
@@ -290,6 +294,12 @@ jobs:
290
294
 
291
295
  - name: Commit generated config
292
296
  run: |
297
+ # A newly added user's generated directory is untracked, and
298
+ # `git diff` cannot see untracked files — without intent-to-add
299
+ # the new config is silently dropped here and every later deploy
300
+ # skips that user (their files never appear in a commit range).
301
+ git add --intent-to-add -- users views
302
+
293
303
  if git diff --quiet -- users views; then
294
304
  exit 0
295
305
  fi