@rizom/ops 0.2.0-alpha.3 → 0.2.0-alpha.300

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 (71) hide show
  1. package/README.md +44 -3
  2. package/dist/age-key-bootstrap.d.ts +17 -0
  3. package/dist/brains-ops.js +453 -173
  4. package/dist/cert-bootstrap.d.ts +24 -0
  5. package/dist/content-repo-ref.d.ts +10 -0
  6. package/dist/content-repo.d.ts +13 -0
  7. package/dist/default-user-runner.d.ts +1 -1
  8. package/dist/deploy.js +99 -166
  9. package/dist/directory-sync-stress-system.d.ts +75 -0
  10. package/dist/directory-sync-stress.d.ts +107 -0
  11. package/dist/entries/deploy.d.ts +3 -2
  12. package/dist/images.d.ts +79 -0
  13. package/dist/index.d.ts +11 -0
  14. package/dist/index.js +523 -152
  15. package/dist/legacy-pilot-migration.d.ts +11 -0
  16. package/dist/load-registry.d.ts +74 -7
  17. package/dist/observed-status.d.ts +12 -0
  18. package/dist/onboard-user.d.ts +2 -2
  19. package/dist/origin-ca.d.ts +1 -0
  20. package/dist/parse-args.d.ts +2 -9
  21. package/dist/preview-domain.d.ts +9 -0
  22. package/dist/push-secrets.d.ts +2 -0
  23. package/dist/push-target.d.ts +1 -0
  24. package/dist/reconcile-all.d.ts +2 -2
  25. package/dist/reconcile-cohort.d.ts +2 -2
  26. package/dist/reconcile-dry-run.d.ts +9 -0
  27. package/dist/reconcile-lib.d.ts +4 -2
  28. package/dist/run-command.d.ts +19 -4
  29. package/dist/run-subprocess.d.ts +1 -0
  30. package/dist/schema.d.ts +119 -70
  31. package/dist/secrets-encrypt.d.ts +26 -0
  32. package/dist/secrets-push.d.ts +2 -5
  33. package/dist/ssh-key-bootstrap.d.ts +1 -0
  34. package/dist/stage-legacy-crossover.d.ts +20 -0
  35. package/dist/stress-command.d.ts +14 -0
  36. package/dist/stress-git-checkout.d.ts +21 -0
  37. package/dist/stress-health-monitor.d.ts +46 -0
  38. package/dist/upgrade.d.ts +10 -0
  39. package/dist/user-add.d.ts +15 -0
  40. package/dist/user-runner.d.ts +5 -0
  41. package/dist/verify-user.d.ts +22 -0
  42. package/package.json +47 -40
  43. package/templates/rover-pilot/.env.schema +28 -2
  44. package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
  45. package/templates/rover-pilot/.github/workflows/build.yml +71 -17
  46. package/templates/rover-pilot/.github/workflows/deploy.yml +110 -45
  47. package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
  48. package/templates/rover-pilot/.github/workflows/reconcile.yml +23 -3
  49. package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
  50. package/templates/rover-pilot/README.md +20 -7
  51. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +139 -0
  52. package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
  53. package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
  54. package/templates/rover-pilot/deploy/scripts/provision-server.ts +1 -1
  55. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +15 -4
  56. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  57. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +49 -14
  58. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +183 -0
  59. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  60. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
  61. package/templates/rover-pilot/docs/canonical-crossover-record.md +104 -0
  62. package/templates/rover-pilot/docs/onboarding-checklist.md +39 -8
  63. package/templates/rover-pilot/docs/operator-playbook.md +298 -9
  64. package/templates/rover-pilot/docs/user-onboarding.md +90 -0
  65. package/templates/rover-pilot/package.json +3 -0
  66. package/templates/rover-pilot/pilot.yaml +5 -3
  67. package/templates/rover-pilot/users/alice.yaml +5 -1
  68. package/dist/user-secret-names.d.ts +0 -6
  69. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  70. package/templates/rover-pilot/deploy/Dockerfile +0 -15
  71. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -39
@@ -0,0 +1,22 @@
1
+ import type { FetchLike } from "@brains/deploy-support/origin-ca";
2
+ import { type ResolvedUser } from "./load-registry";
3
+ export interface VerifyPilotUserOptions {
4
+ fetchImpl?: FetchLike;
5
+ logger?: (message: string) => void;
6
+ operationalRetryAttempts?: number;
7
+ operationalRetryDelayMs?: number;
8
+ sleep?: (delayMs: number) => Promise<void>;
9
+ }
10
+ export interface FailedCheck {
11
+ name: string;
12
+ message: string;
13
+ }
14
+ export interface VerifyPilotUserResult {
15
+ handle: string;
16
+ bundles: ResolvedUser["bundles"];
17
+ domain: string;
18
+ contentRepo: string;
19
+ checks: string[];
20
+ failedChecks: FailedCheck[];
21
+ }
22
+ export declare function verifyPilotUser(rootDir: string, handle: string, options?: VerifyPilotUserOptions): Promise<VerifyPilotUserResult>;
package/package.json CHANGED
@@ -1,65 +1,72 @@
1
1
  {
2
2
  "name": "@rizom/ops",
3
+ "version": "0.2.0-alpha.300",
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/profile": "workspace:*",
27
+ "@brains/site-composition": "workspace:*",
28
+ "@brains/typescript-config": "workspace:*",
29
+ "@brains/utils": "workspace:*",
30
+ "@types/bun": "^1.3.14",
31
+ "typescript": "^7.0.2"
32
+ },
33
+ "engines": {
34
+ "bun": ">=1.3.3"
6
35
  },
7
- "version": "0.2.0-alpha.3",
8
- "type": "module",
9
36
  "exports": {
10
37
  ".": {
11
38
  "types": "./dist/index.d.ts",
12
39
  "import": "./dist/index.js"
13
40
  },
14
41
  "./deploy": {
15
- "bun": "./dist/deploy.js",
16
42
  "types": "./dist/deploy.d.ts",
17
- "import": "./dist/deploy.js"
43
+ "import": "./dist/deploy.js",
44
+ "bun": "./dist/deploy.js"
18
45
  }
19
46
  },
20
- "main": "./dist/index.js",
21
- "types": "./dist/index.d.ts",
22
- "bin": {
23
- "brains-ops": "./dist/brains-ops.js"
24
- },
25
47
  "files": [
26
48
  "dist",
27
49
  "templates"
28
50
  ],
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"
36
- },
37
- "dependencies": {},
38
- "devDependencies": {
39
- "@brains/eslint-config": "workspace:*",
40
- "@brains/typescript-config": "workspace:*",
41
- "@brains/utils": "workspace:*",
42
- "@types/bun": "latest",
43
- "typescript": "^5.3.3"
51
+ "homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
52
+ "main": "./dist/index.js",
53
+ "publishConfig": {
54
+ "access": "public"
44
55
  },
45
56
  "repository": {
46
57
  "type": "git",
47
58
  "url": "https://github.com/rizom-ai/brains.git",
48
59
  "directory": "packages/brains-ops"
49
60
  },
50
- "license": "Apache-2.0",
51
- "author": "Yeehaa <yeehaa@rizom.ai> (https://rizom.ai)",
52
- "homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
53
- "bugs": "https://github.com/rizom-ai/brains/issues",
54
- "engines": {
55
- "bun": ">=1.3.3"
61
+ "scripts": {
62
+ "build": "bun scripts/build.ts",
63
+ "lint": "eslint . --max-warnings 0",
64
+ "lint:fix": "eslint . --max-warnings 0 --fix",
65
+ "prepublishOnly": "bun scripts/build.ts",
66
+ "test": "bun run build && bun test --timeout 20000",
67
+ "test:smoke": "bun run build && RUN_SMOKE_TESTS=1 bun test --timeout 60000",
68
+ "typecheck": "tsc --noEmit"
56
69
  },
57
- "keywords": [
58
- "brains",
59
- "ops",
60
- "cli",
61
- "deploy",
62
- "fleet",
63
- "operator"
64
- ]
70
+ "type": "module",
71
+ "types": "./dist/index.d.ts"
65
72
  }
@@ -4,21 +4,47 @@
4
4
  # ----------
5
5
 
6
6
  # AI provider
7
+ # Shared GitHub secret by default; a per-user override may come from the decrypted
8
+ # users/<handle>.secrets.yaml.age file at deploy time.
7
9
  # @required @sensitive
8
10
  AI_API_KEY=
9
11
 
10
12
  # Git sync
13
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file.
11
14
  # @required @sensitive
12
15
  GIT_SYNC_TOKEN=
13
16
 
14
- # MCP interface
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.
15
20
  # @required @sensitive
16
- MCP_AUTH_TOKEN=
21
+ CONTENT_REPO_ADMIN_TOKEN=
17
22
 
18
23
  # Discord (optional, per-user)
24
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file when enabled.
19
25
  # @sensitive
20
26
  DISCORD_BOT_TOKEN=
21
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
+
22
48
  # ---- deploy/provision vars ----
23
49
 
24
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,29 @@ 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
14
+ overwrite:
15
+ description: Rebuild an existing tag in place. Published tags are immutable; confirm only when replacing a tag whose containers were never deployed.
16
+ required: false
17
+ type: boolean
18
+ default: false
5
19
  push:
6
20
  branches: ["main"]
7
21
  paths:
22
+ # The image set is a pure function of the declared fleet state: a push
23
+ # that changes it builds exactly what it declares, so the deploy (fired
24
+ # off the same push via Reconcile) finds its tag ready.
8
25
  - pilot.yaml
26
+ - cohorts/**
27
+ - users/*.yaml
9
28
  - deploy/**
10
29
  - .github/workflows/build.yml
11
30
 
@@ -14,49 +33,84 @@ permissions:
14
33
  packages: write
15
34
 
16
35
  jobs:
17
- build:
36
+ resolve:
18
37
  runs-on: ubuntu-latest
38
+ outputs:
39
+ images_json: ${{ steps.resolve.outputs.images_json }}
19
40
  steps:
20
41
  - uses: actions/checkout@v5
21
42
  with:
22
43
  ref: ${{ github.sha }}
23
44
 
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"
45
+ - uses: oven-sh/setup-bun@v2
46
+
47
+ - name: Install operator tooling
48
+ run: bun install
49
+
50
+ - name: Log in to GHCR
51
+ uses: docker/login-action@v4
52
+ with:
53
+ registry: ghcr.io
54
+ username: ${{ github.actor }}
55
+ password: ${{ secrets.GITHUB_TOKEN }}
56
+
57
+ - name: Resolve images to build
58
+ id: resolve
59
+ env:
60
+ BRAIN_VERSION_INPUT: ${{ inputs.brain_version || '' }}
61
+ SITE_PACKAGES_INPUT: ${{ inputs.site_packages || '' }}
62
+ ALLOW_TAG_OVERWRITE: ${{ inputs.overwrite && 'true' || 'false' }}
63
+ run: bun deploy/scripts/resolve-missing-images.ts
64
+
65
+ no_builds:
66
+ needs: resolve
67
+ if: needs.resolve.outputs.images_json == '[]'
68
+ runs-on: ubuntu-latest
69
+ steps:
70
+ - name: Skip when every declared image exists
71
+ run: echo "All declared images already exist; nothing to build."
72
+
73
+ build:
74
+ needs: resolve
75
+ if: needs.resolve.outputs.images_json != '[]'
76
+ runs-on: ubuntu-latest
77
+ strategy:
78
+ fail-fast: false
79
+ matrix:
80
+ image: ${{ fromJson(needs.resolve.outputs.images_json) }}
81
+ steps:
82
+ - uses: actions/checkout@v5
83
+ with:
84
+ ref: ${{ github.sha }}
33
85
 
34
86
  - name: Set up Docker Buildx
35
- uses: docker/setup-buildx-action@v3
87
+ uses: docker/setup-buildx-action@v4
36
88
 
37
89
  - name: Extract image metadata
38
90
  id: meta
39
- uses: docker/metadata-action@v5
91
+ uses: docker/metadata-action@v6
40
92
  with:
41
- images: ${{ env.IMAGE_REPOSITORY }}
93
+ images: ghcr.io/${{ github.repository }}
42
94
  tags: |
43
- type=raw,value=brain-${{ env.BRAIN_VERSION }}
95
+ type=raw,value=${{ matrix.image.tag }}
44
96
 
45
97
  - name: Log in to GHCR
46
- uses: docker/login-action@v3
98
+ uses: docker/login-action@v4
47
99
  with:
48
100
  registry: ghcr.io
49
101
  username: ${{ github.actor }}
50
102
  password: ${{ secrets.GITHUB_TOKEN }}
51
103
 
52
104
  - name: Build and push image
53
- uses: docker/build-push-action@v6
105
+ uses: docker/build-push-action@v7
54
106
  with:
55
107
  context: .
56
108
  file: deploy/Dockerfile
109
+ target: fleet
57
110
  push: true
58
111
  build-args: |
59
- BRAIN_VERSION=${{ env.BRAIN_VERSION }}
112
+ BRAIN_VERSION=${{ matrix.image.brain_version }}
113
+ SITE_PACKAGES=${{ matrix.image.site_packages }}
60
114
  tags: ${{ steps.meta.outputs.tags }}
61
115
  labels: |
62
116
  ${{ steps.meta.outputs.labels }}
@@ -7,13 +7,24 @@ 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:
13
18
  - users/*/.env
14
19
  - users/*/brain.yaml
20
+ - users/*/content/**
21
+ - users/*.secrets.yaml.age
15
22
  - deploy/**
16
23
  - .github/workflows/deploy.yml
24
+ workflow_run:
25
+ workflows: [Reconcile]
26
+ types: [completed]
27
+ branches: [main]
17
28
 
18
29
  permissions:
19
30
  contents: write
@@ -21,13 +32,16 @@ permissions:
21
32
 
22
33
  jobs:
23
34
  resolve_handles:
35
+ if: >
36
+ github.event_name != 'workflow_run' ||
37
+ github.event.workflow_run.conclusion == 'success'
24
38
  runs-on: ubuntu-latest
25
39
  outputs:
26
40
  handles_json: ${{ steps.resolve.outputs.handles_json }}
27
41
  steps:
28
42
  - uses: actions/checkout@v5
29
43
  with:
30
- ref: ${{ github.sha }}
44
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
31
45
  fetch-depth: 0
32
46
 
33
47
  - uses: oven-sh/setup-bun@v2
@@ -39,7 +53,7 @@ jobs:
39
53
  id: resolve
40
54
  env:
41
55
  HANDLE_INPUT: ${{ inputs.handle || '' }}
42
- BEFORE_SHA: ${{ github.event.before || '' }}
56
+ BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
43
57
  run: bun deploy/scripts/resolve-deploy-handles.ts
44
58
 
45
59
  no_changes:
@@ -66,13 +80,22 @@ jobs:
66
80
  steps:
67
81
  - uses: actions/checkout@v5
68
82
  with:
69
- ref: ${{ github.sha }}
83
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
70
84
 
71
85
  - uses: oven-sh/setup-bun@v2
72
86
 
73
87
  - name: Install operator tooling
74
88
  run: bun install
75
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
+
95
+ - name: Decrypt user secrets
96
+ id: user_secrets
97
+ run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
98
+
76
99
  - name: Reconcile selected user config
77
100
  run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
78
101
 
@@ -81,23 +104,13 @@ jobs:
81
104
  run: bun deploy/scripts/resolve-user-config.ts
82
105
 
83
106
  - name: Validate selected secrets
84
- env:
85
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
86
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
87
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
88
- DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
89
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
90
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
91
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
92
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
93
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
94
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
95
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
96
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
97
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
98
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
99
107
  run: bun deploy/scripts/validate-secrets.ts
100
108
 
109
+ - name: Seed content repo
110
+ env:
111
+ CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
112
+ run: bun deploy/scripts/sync-content-repo.ts
113
+
101
114
  - name: Log in to GHCR
102
115
  uses: docker/login-action@v3
103
116
  with:
@@ -105,15 +118,18 @@ jobs:
105
118
  username: ${{ github.actor }}
106
119
  password: ${{ secrets.GITHUB_TOKEN }}
107
120
 
108
- - name: Wait for shared image tag
121
+ - name: Wait for image tag
109
122
  run: |
110
- IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:brain-${{ steps.user_config.outputs.brain_version }}"
111
- 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
112
128
  if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
113
129
  exit 0
114
130
  fi
115
- echo "Shared image tag not ready yet ($attempt/24): $IMAGE_TAG"
116
- sleep 10
131
+ echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
132
+ sleep 20
117
133
  done
118
134
  echo "Timed out waiting for $IMAGE_TAG" >&2
119
135
  exit 1
@@ -124,8 +140,6 @@ jobs:
124
140
  ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
125
141
 
126
142
  - name: Write Kamal SSH key
127
- env:
128
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
129
143
  run: bun deploy/scripts/write-ssh-key.ts
130
144
 
131
145
  - name: Configure SSH client
@@ -142,33 +156,27 @@ jobs:
142
156
  chmod 600 ~/.ssh/config
143
157
 
144
158
  - name: Write .kamal/secrets
145
- env:
146
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
147
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
148
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
149
- DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
150
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
151
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
152
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
153
159
  run: bun deploy/scripts/write-kamal-secrets.ts
154
160
 
155
161
  - name: Provision server
156
162
  id: provision
157
163
  env:
158
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
159
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
160
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
161
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
162
164
  INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
163
165
  run: bun deploy/scripts/provision-server.ts
164
166
 
165
167
  - name: Update Cloudflare DNS
166
168
  env:
167
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
168
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
169
+ CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
169
170
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
171
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
172
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
170
173
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
171
- run: bun deploy/scripts/update-dns.ts
174
+ run: |
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
179
+ BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
172
180
 
173
181
  - name: Validate SSH key
174
182
  run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
@@ -188,17 +196,53 @@ jobs:
188
196
  echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
189
197
  exit 1
190
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
+
191
212
  - name: Deploy
192
213
  env:
193
214
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
194
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
215
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
216
+ IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
217
+ REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
218
+ BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
219
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
220
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
221
+ BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
195
222
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
196
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
+
197
231
  - name: Verify origin TLS
198
232
  env:
199
233
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
200
234
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
201
- run: curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
235
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
236
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
237
+ run: |
238
+ curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health/live"
239
+ if [ -n "$WWW_DOMAIN" ]; then
240
+ curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
241
+ fi
242
+ curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
243
+
244
+ - name: Verify operational health
245
+ run: bunx brains-ops verify-user "$GITHUB_WORKSPACE" "$HANDLE"
202
246
 
203
247
  - name: Upload generated config
204
248
  uses: actions/upload-artifact@v4
@@ -207,6 +251,7 @@ jobs:
207
251
  path: |
208
252
  users/${{ matrix.handle }}/brain.yaml
209
253
  users/${{ matrix.handle }}/.env
254
+ users/${{ matrix.handle }}/content
210
255
 
211
256
  - name: Dump remote proxy diagnostics
212
257
  if: failure()
@@ -233,7 +278,7 @@ jobs:
233
278
  steps:
234
279
  - uses: actions/checkout@v5
235
280
  with:
236
- ref: ${{ github.sha }}
281
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
237
282
 
238
283
  - uses: oven-sh/setup-bun@v2
239
284
 
@@ -252,11 +297,31 @@ jobs:
252
297
 
253
298
  - name: Commit generated config
254
299
  run: |
300
+ # A newly added user's generated directory is untracked, and
301
+ # `git diff` cannot see untracked files — without intent-to-add
302
+ # the new config is silently dropped here and every later deploy
303
+ # skips that user (their files never appear in a commit range).
304
+ git add --intent-to-add -- users views
305
+
255
306
  if git diff --quiet -- users views; then
256
307
  exit 0
257
308
  fi
309
+
310
+ git fetch origin "${{ github.ref_name }}"
311
+ if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
312
+ exit 0
313
+ fi
314
+
315
+ patch_file="$(mktemp)"
316
+ git diff --binary -- users views > "$patch_file"
317
+ git reset --hard "origin/${{ github.ref_name }}"
318
+ git apply --3way --index "$patch_file"
319
+
320
+ if git diff --cached --quiet -- users views; then
321
+ exit 0
322
+ fi
323
+
258
324
  git config user.name "github-actions[bot]"
259
325
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
260
- git add users views
261
326
  git commit -m "chore(ops): reconcile generated config"
262
- git push
327
+ git push origin HEAD:${{ github.ref_name }}