@rizom/ops 0.2.0-alpha.34 → 0.2.0-alpha.341

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 (61) hide show
  1. package/README.md +61 -2
  2. package/dist/brains-ops.js +700 -318
  3. package/dist/capability-bundle-migration.d.ts +22 -0
  4. package/dist/cert-bootstrap.d.ts +3 -1
  5. package/dist/content-repo-ref.d.ts +10 -0
  6. package/dist/content-repo.d.ts +1 -0
  7. package/dist/deploy.js +99 -166
  8. package/dist/directory-sync-stress-system.d.ts +75 -0
  9. package/dist/directory-sync-stress.d.ts +107 -0
  10. package/dist/entries/deploy.d.ts +3 -2
  11. package/dist/health-watchdog-smoke.d.ts +54 -0
  12. package/dist/images.d.ts +79 -0
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +776 -308
  15. package/dist/legacy-pilot-migration.d.ts +11 -0
  16. package/dist/load-registry.d.ts +60 -6
  17. package/dist/observed-status.d.ts +1 -1
  18. package/dist/origin-ca.d.ts +1 -1
  19. package/dist/parse-args.d.ts +2 -10
  20. package/dist/preview-domain.d.ts +9 -0
  21. package/dist/push-secrets.d.ts +2 -9
  22. package/dist/push-target.d.ts +1 -2
  23. package/dist/reconcile-dry-run.d.ts +9 -0
  24. package/dist/run-command.d.ts +15 -3
  25. package/dist/run-subprocess.d.ts +1 -6
  26. package/dist/schema.d.ts +123 -162
  27. package/dist/secrets-encrypt.d.ts +7 -13
  28. package/dist/ssh-key-bootstrap.d.ts +1 -26
  29. package/dist/stage-legacy-crossover.d.ts +23 -0
  30. package/dist/stress-command.d.ts +14 -0
  31. package/dist/stress-git-checkout.d.ts +21 -0
  32. package/dist/stress-health-monitor.d.ts +46 -0
  33. package/dist/upgrade.d.ts +10 -0
  34. package/dist/user-add.d.ts +15 -0
  35. package/dist/verify-user.d.ts +22 -0
  36. package/package.json +48 -42
  37. package/templates/rover-pilot/.env.schema +24 -3
  38. package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
  39. package/templates/rover-pilot/.github/workflows/build.yml +70 -17
  40. package/templates/rover-pilot/.github/workflows/deploy.yml +63 -57
  41. package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
  42. package/templates/rover-pilot/.github/workflows/health-watchdog-smoke.yml +95 -0
  43. package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
  44. package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
  45. package/templates/rover-pilot/README.md +16 -6
  46. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -24
  47. package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
  48. package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
  49. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  50. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +44 -9
  51. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
  52. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  53. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
  54. package/templates/rover-pilot/docs/canonical-crossover-record.md +107 -0
  55. package/templates/rover-pilot/docs/onboarding-checklist.md +28 -17
  56. package/templates/rover-pilot/docs/operator-playbook.md +270 -29
  57. package/templates/rover-pilot/docs/user-onboarding.md +48 -463
  58. package/templates/rover-pilot/pilot.yaml +7 -4
  59. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  60. package/templates/rover-pilot/deploy/Dockerfile +0 -30
  61. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
@@ -0,0 +1,46 @@
1
+ import type { FetchLike } from "@brains/deploy-support/origin-ca";
2
+ import { type StressHealthSample, type StressRuntimeSample } from "./directory-sync-stress";
3
+ export interface HealthPayload {
4
+ version: string;
5
+ entities: number;
6
+ entityCounts: {
7
+ entityType: string;
8
+ count: number;
9
+ }[];
10
+ }
11
+ export declare function noteCount(payload: HealthPayload): number;
12
+ export interface StressHealthMonitorOptions {
13
+ domain: string;
14
+ fetchImpl: FetchLike;
15
+ now: () => Date;
16
+ sleep: (milliseconds: number) => Promise<void>;
17
+ /** Take one runtime sample (e.g. docker stats over SSH), if available. */
18
+ sampleRuntime: () => Promise<StressRuntimeSample | undefined>;
19
+ }
20
+ /**
21
+ * Samples deployment health and runtime resource usage during a stress run,
22
+ * and answers entity-baseline waits against the operational health endpoint.
23
+ */
24
+ export declare class StressHealthMonitor {
25
+ #private;
26
+ constructor(options: StressHealthMonitorOptions);
27
+ /** ISO timestamp of the most recent start() call. */
28
+ get startedAt(): string;
29
+ /** The first sampling failure since start(), if any. */
30
+ get error(): unknown;
31
+ get healthSamples(): readonly StressHealthSample[];
32
+ get runtimeSamples(): readonly StressRuntimeSample[];
33
+ /** Health samples between start() and markGateEnd() (or now). */
34
+ gateHealthSamples(): StressHealthSample[];
35
+ /** Exclude samples taken after this point from the gate window. */
36
+ markGateEnd(): void;
37
+ /** Probe optional health endpoints and add responsive ones to the loop. */
38
+ discoverEndpoints(): Promise<void>;
39
+ start(): void;
40
+ /** Stop sampling and wait for the loops; a loop error stays on `error`. */
41
+ stop(): Promise<void>;
42
+ waitForHealthSnapshot(timeoutMs: number): Promise<HealthPayload | undefined>;
43
+ waitForEntityBaseline(expectedNotes: number | undefined, expectedEntities: number | undefined, timeoutMs: number): Promise<HealthPayload | undefined>;
44
+ waitForStableEntityBaseline(expectedNotes: number | undefined, expectedEntities: number | undefined, timeoutMs: number): Promise<HealthPayload | undefined>;
45
+ fetchHealthPayload(): Promise<HealthPayload | undefined>;
46
+ }
@@ -0,0 +1,10 @@
1
+ export type UpgradeExec = (command: string[], cwd: string) => Promise<void>;
2
+ export interface UpgradeOptions {
3
+ exec?: UpgradeExec | undefined;
4
+ version?: string | undefined;
5
+ }
6
+ export interface UpgradeResult {
7
+ from: string;
8
+ to: string;
9
+ }
10
+ export declare function upgradePilotRepo(rootDir: string, options?: UpgradeOptions): Promise<UpgradeResult>;
@@ -0,0 +1,15 @@
1
+ export interface AddPilotUserOptions {
2
+ cohort: string;
3
+ anchorId?: string | undefined;
4
+ }
5
+ export interface AddPilotUserResult {
6
+ handle: string;
7
+ cohort: string;
8
+ userPath: string;
9
+ secretsTemplatePath: string;
10
+ cohortPath: string;
11
+ createdUser: boolean;
12
+ createdSecretsTemplate: boolean;
13
+ addedToCohort: boolean;
14
+ }
15
+ export declare function addPilotUser(rootDir: string, handle: string, options: AddPilotUserOptions): Promise<AddPilotUserResult>;
@@ -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,67 +1,73 @@
1
1
  {
2
2
  "name": "@rizom/ops",
3
+ "version": "0.2.0-alpha.341",
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/test-utils": "workspace:*",
29
+ "@brains/typescript-config": "workspace:*",
30
+ "@brains/utils": "workspace:*",
31
+ "@types/bun": "^1.4.0",
32
+ "typescript": "^7.0.2"
33
+ },
34
+ "engines": {
35
+ "bun": ">=1.3.3"
6
36
  },
7
- "version": "0.2.0-alpha.34",
8
- "type": "module",
9
37
  "exports": {
10
38
  ".": {
11
39
  "types": "./dist/index.d.ts",
12
40
  "import": "./dist/index.js"
13
41
  },
14
42
  "./deploy": {
15
- "bun": "./dist/deploy.js",
16
43
  "types": "./dist/deploy.d.ts",
17
- "import": "./dist/deploy.js"
44
+ "import": "./dist/deploy.js",
45
+ "bun": "./dist/deploy.js"
18
46
  }
19
47
  },
20
- "main": "./dist/index.js",
21
- "types": "./dist/index.d.ts",
22
- "bin": {
23
- "brains-ops": "./dist/brains-ops.js"
24
- },
25
48
  "files": [
26
49
  "dist",
27
50
  "templates"
28
51
  ],
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"
52
+ "homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
53
+ "main": "./dist/index.js",
54
+ "publishConfig": {
55
+ "access": "public"
46
56
  },
47
57
  "repository": {
48
58
  "type": "git",
49
59
  "url": "https://github.com/rizom-ai/brains.git",
50
60
  "directory": "packages/brains-ops"
51
61
  },
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"
62
+ "scripts": {
63
+ "build": "bun scripts/build.ts",
64
+ "lint": "eslint . --max-warnings 0",
65
+ "lint:fix": "eslint . --max-warnings 0 --fix",
66
+ "prepublishOnly": "bun scripts/build.ts",
67
+ "test": "bun run build && bun test --timeout 20000",
68
+ "test:smoke": "bun run build && RUN_SMOKE_TESTS=1 bun test --timeout 60000",
69
+ "typecheck": "tsc --noEmit"
58
70
  },
59
- "keywords": [
60
- "brains",
61
- "ops",
62
- "cli",
63
- "deploy",
64
- "fleet",
65
- "operator"
66
- ]
71
+ "type": "module",
72
+ "types": "./dist/index.d.ts"
67
73
  }
@@ -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,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,50 +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
57
109
  target: fleet
58
110
  push: true
59
111
  build-args: |
60
- BRAIN_VERSION=${{ env.BRAIN_VERSION }}
112
+ BRAIN_VERSION=${{ matrix.image.brain_version }}
113
+ SITE_PACKAGES=${{ matrix.image.site_packages }}
61
114
  tags: ${{ steps.meta.outputs.tags }}
62
115
  labels: |
63
116
  ${{ 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:
@@ -82,51 +87,29 @@ jobs:
82
87
  - name: Install operator tooling
83
88
  run: bun install
84
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
+
85
95
  - name: Decrypt user secrets
86
96
  id: user_secrets
87
- env:
88
- AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
89
97
  run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
90
98
 
91
99
  - name: Reconcile selected user config
92
- env:
93
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
94
- run: |
95
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
96
- bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
100
+ run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
97
101
 
98
102
  - name: Resolve generated user config
99
103
  id: user_config
100
104
  run: bun deploy/scripts/resolve-user-config.ts
101
105
 
102
106
  - name: Validate selected secrets
103
- env:
104
- SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
105
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
106
- SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
107
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
108
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
109
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
110
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
111
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
112
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
113
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
114
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
115
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
116
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
117
- run: |
118
- export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
119
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
120
- export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
121
- bun deploy/scripts/validate-secrets.ts
107
+ run: bun deploy/scripts/validate-secrets.ts
122
108
 
123
109
  - name: Seed content repo
124
110
  env:
125
111
  CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
126
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
127
- run: |
128
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
129
- bun deploy/scripts/sync-content-repo.ts
112
+ run: bun deploy/scripts/sync-content-repo.ts
130
113
 
131
114
  - name: Log in to GHCR
132
115
  uses: docker/login-action@v3
@@ -135,15 +118,18 @@ jobs:
135
118
  username: ${{ github.actor }}
136
119
  password: ${{ secrets.GITHUB_TOKEN }}
137
120
 
138
- - name: Wait for shared image tag
121
+ - name: Wait for image tag
139
122
  run: |
140
- IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:brain-${{ steps.user_config.outputs.brain_version }}"
141
- 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
142
128
  if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
143
129
  exit 0
144
130
  fi
145
- echo "Shared image tag not ready yet ($attempt/24): $IMAGE_TAG"
146
- sleep 10
131
+ echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
132
+ sleep 20
147
133
  done
148
134
  echo "Timed out waiting for $IMAGE_TAG" >&2
149
135
  exit 1
@@ -154,8 +140,6 @@ jobs:
154
140
  ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
155
141
 
156
142
  - name: Write Kamal SSH key
157
- env:
158
- KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
159
143
  run: bun deploy/scripts/write-ssh-key.ts
160
144
 
161
145
  - name: Configure SSH client
@@ -172,38 +156,26 @@ jobs:
172
156
  chmod 600 ~/.ssh/config
173
157
 
174
158
  - name: Write .kamal/secrets
175
- env:
176
- SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
177
- SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
178
- SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
179
- KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
180
- CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
181
- PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
182
- run: |
183
- export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
184
- export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
185
- export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
186
- bun deploy/scripts/write-kamal-secrets.ts
159
+ run: bun deploy/scripts/write-kamal-secrets.ts
187
160
 
188
161
  - name: Provision server
189
162
  id: provision
190
163
  env:
191
- HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
192
- HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
193
- HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
194
- HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
195
164
  INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
196
165
  run: bun deploy/scripts/provision-server.ts
197
166
 
198
167
  - name: Update Cloudflare DNS
199
168
  env:
200
- CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
201
- CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
169
+ CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
202
170
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
171
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
203
172
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
204
173
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
205
174
  run: |
206
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
207
179
  BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
208
180
 
209
181
  - name: Validate SSH key
@@ -224,26 +196,54 @@ jobs:
224
196
  echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
225
197
  exit 1
226
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
+
227
212
  - name: Deploy
228
213
  env:
229
214
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
230
- VERSION: brain-${{ steps.user_config.outputs.brain_version }}
215
+ VERSION: ${{ steps.user_config.outputs.image_tag }}
231
216
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
232
217
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
233
218
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
219
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
234
220
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
235
221
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
236
222
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
237
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
+
238
231
  - name: Verify origin TLS
239
232
  env:
240
233
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
241
234
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
235
+ WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
242
236
  PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
243
237
  run: |
244
- curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
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
245
242
  curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
246
243
 
244
+ - name: Verify operational health
245
+ run: bunx brains-ops verify-user "$GITHUB_WORKSPACE" "$HANDLE"
246
+
247
247
  - name: Upload generated config
248
248
  uses: actions/upload-artifact@v4
249
249
  with:
@@ -297,6 +297,12 @@ jobs:
297
297
 
298
298
  - name: Commit generated config
299
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
+
300
306
  if git diff --quiet -- users views; then
301
307
  exit 0
302
308
  fi