@rizom/ops 0.2.0-alpha.71 → 0.2.0-alpha.710

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 (73) hide show
  1. package/README.md +84 -2
  2. package/dist/age-key-bootstrap.d.ts +1 -1
  3. package/dist/brains-ops.js +679 -199
  4. package/dist/capability-bundle-migration.d.ts +26 -0
  5. package/dist/cert-bootstrap.d.ts +4 -2
  6. package/dist/content-repo-ref.d.ts +10 -0
  7. package/dist/content-repo.d.ts +1 -1
  8. package/dist/deploy.js +114 -3
  9. package/dist/directory-sync-stress-system.d.ts +75 -0
  10. package/dist/directory-sync-stress.d.ts +117 -0
  11. package/dist/entries/deploy.d.ts +2 -0
  12. package/dist/health-watchdog-smoke.d.ts +54 -0
  13. package/dist/image-inventory.d.ts +4 -0
  14. package/dist/image-types.d.ts +7 -0
  15. package/dist/images.d.ts +89 -0
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.js +693 -201
  18. package/dist/legacy-pilot-migration.d.ts +11 -0
  19. package/dist/legacy-projection-job-recovery.d.ts +29 -0
  20. package/dist/load-registry.d.ts +77 -9
  21. package/dist/observed-status.d.ts +1 -1
  22. package/dist/parse-args.d.ts +2 -12
  23. package/dist/preview-domain.d.ts +9 -0
  24. package/dist/provider-key-migration.d.ts +20 -0
  25. package/dist/reconcile-dry-run.d.ts +9 -0
  26. package/dist/run-command.d.ts +22 -5
  27. package/dist/runtime-image-lock.d.ts +12 -0
  28. package/dist/schema.d.ts +139 -165
  29. package/dist/secrets-encrypt.d.ts +9 -14
  30. package/dist/secrets-push.d.ts +1 -1
  31. package/dist/ssh-key-bootstrap.d.ts +1 -26
  32. package/dist/stage-legacy-crossover.d.ts +25 -0
  33. package/dist/stress-command.d.ts +14 -0
  34. package/dist/stress-git-checkout.d.ts +21 -0
  35. package/dist/stress-health-monitor.d.ts +56 -0
  36. package/dist/upgrade.d.ts +10 -0
  37. package/dist/user-add.d.ts +1 -0
  38. package/dist/user-offboard.d.ts +61 -0
  39. package/dist/verify-user.d.ts +22 -0
  40. package/package.json +51 -44
  41. package/templates/rover-pilot/.env.schema +31 -8
  42. package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
  43. package/templates/rover-pilot/.github/workflows/build.yml +67 -13
  44. package/templates/rover-pilot/.github/workflows/deploy.yml +93 -70
  45. package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
  46. package/templates/rover-pilot/.github/workflows/health-watchdog-smoke.yml +95 -0
  47. package/templates/rover-pilot/.github/workflows/offboard.yml +107 -0
  48. package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
  49. package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
  50. package/templates/rover-pilot/README.md +14 -6
  51. package/templates/rover-pilot/deploy/scripts/create-predeploy-backup.ts +962 -0
  52. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +82 -26
  53. package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
  54. package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
  55. package/templates/rover-pilot/deploy/scripts/provision-server.ts +51 -21
  56. package/templates/rover-pilot/deploy/scripts/pull-local-models.ts +69 -0
  57. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +4 -0
  58. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  59. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +66 -9
  60. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
  61. package/templates/rover-pilot/deploy/scripts/update-dns.ts +58 -12
  62. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
  63. package/templates/rover-pilot/deploy/scripts/verify-runtime-image.ts +18 -0
  64. package/templates/rover-pilot/docs/canonical-crossover-record.md +107 -0
  65. package/templates/rover-pilot/docs/onboarding-checklist.md +24 -15
  66. package/templates/rover-pilot/docs/operator-playbook.md +396 -35
  67. package/templates/rover-pilot/docs/user-onboarding.md +48 -463
  68. package/templates/rover-pilot/package.json +1 -0
  69. package/templates/rover-pilot/pilot.yaml +7 -5
  70. package/dist/origin-ca.d.ts +0 -1
  71. package/dist/push-secrets.d.ts +0 -9
  72. package/dist/push-target.d.ts +0 -2
  73. package/dist/run-subprocess.d.ts +0 -6
@@ -1,26 +1 @@
1
- import { type FetchLike } from "@brains/deploy-support/origin-ca";
2
- import { type RunCommand } from "./run-subprocess";
3
- export interface SshKeyBootstrapOptions {
4
- env?: NodeJS.ProcessEnv | undefined;
5
- fetchImpl?: FetchLike | undefined;
6
- logger?: (message: string) => void;
7
- pushTo?: string | undefined;
8
- runCommand?: RunCommand | undefined;
9
- sshKeygen?: SshKeygen | undefined;
10
- }
11
- export interface SshKeyBootstrapResult {
12
- createdHetznerKey: boolean;
13
- createdLocalKey: boolean;
14
- privateKeyPath: string;
15
- publicKeyPath: string;
16
- sshKeyName: string;
17
- }
18
- export interface SshKeygen {
19
- createEd25519KeyPair: (privateKeyPath: string, comment: string) => void;
20
- derivePublicKey: (privateKeyPath: string) => string;
21
- }
22
- export declare function runPilotSshKeyBootstrap(rootDir: string, options?: SshKeyBootstrapOptions): Promise<{
23
- success: boolean;
24
- message?: string;
25
- }>;
26
- export declare function bootstrapPilotSshKey(rootDir: string, options?: SshKeyBootstrapOptions): Promise<SshKeyBootstrapResult>;
1
+ export { bootstrapSshKey as bootstrapPilotSshKey, runSshKeyBootstrap as runPilotSshKeyBootstrap, type SshKeyBootstrapOptions, type SshKeyBootstrapResult, type SshKeygen, } from "@brains/deploy-support/ssh-key-bootstrap";
@@ -0,0 +1,25 @@
1
+ import { z } from "@brains/utils/zod";
2
+ import { type CapabilityBundleReview } from "./capability-bundle-migration";
3
+ import { siteOverrideSchema } from "./schema";
4
+ export interface StagedCrossover {
5
+ outputDir: string;
6
+ changedFiles: string[];
7
+ }
8
+ declare const reviewedSitePinsSchema: z.ZodObject<{
9
+ sites: z.ZodRecord<z.ZodString, typeof siteOverrideSchema>;
10
+ }, z.core.$strict>;
11
+ type ReviewedSitePinManifest = z.output<typeof reviewedSitePinsSchema>;
12
+ export type ReviewedSitePins = ReviewedSitePinManifest["sites"];
13
+ export interface StageLegacyCrossoverOptions {
14
+ sitePins?: ReviewedSitePins | undefined;
15
+ bundleReview?: CapabilityBundleReview | undefined;
16
+ }
17
+ export declare function parseReviewedSitePins(input: string): ReviewedSitePins;
18
+ export declare function loadReviewedSitePins(filePath: string): Promise<ReviewedSitePins>;
19
+ export declare function loadCapabilityBundleReview(filePath: string): Promise<CapabilityBundleReview>;
20
+ /**
21
+ * Build a complete review copy without mutating the source repository.
22
+ * This is offline migration tooling; active desired-state loading is canonical-only.
23
+ */
24
+ export declare function stageLegacyCrossover(sourceDir: string, outputDir: string, options?: StageLegacyCrossoverOptions): Promise<StagedCrossover>;
25
+ export {};
@@ -0,0 +1,14 @@
1
+ export interface StressCommandOptions {
2
+ cwd?: string;
3
+ env?: NodeJS.ProcessEnv;
4
+ stdin?: string;
5
+ }
6
+ export interface StressCommandResult {
7
+ exitCode: number;
8
+ stdout: string;
9
+ stderr: string;
10
+ }
11
+ export type StressCommandRunner = (command: string, args: readonly string[], options?: StressCommandOptions) => Promise<StressCommandResult>;
12
+ export declare const runStressCommand: StressCommandRunner;
13
+ export declare function requireCommand(runner: StressCommandRunner, command: string, args: readonly string[], options?: StressCommandOptions): Promise<StressCommandResult>;
14
+ export declare function commandError(command: string, args: readonly string[], result: StressCommandResult): Error;
@@ -0,0 +1,21 @@
1
+ import { type StressCommandResult, type StressCommandRunner } from "./stress-command";
2
+ /**
3
+ * Git operations against one content-repo checkout, executed through a
4
+ * stress command runner with token-bearing credentials.
5
+ */
6
+ export declare class GitCheckout {
7
+ #private;
8
+ readonly dir: string;
9
+ constructor(runner: StressCommandRunner, dir: string, token: string);
10
+ /** Clone `repository`'s main branch into `dir` and pin it to origin/main. */
11
+ static clone(runner: StressCommandRunner, repository: string, dir: string, token: string): Promise<GitCheckout>;
12
+ run(args: readonly string[], required?: boolean): Promise<StressCommandResult>;
13
+ output(args: readonly string[]): Promise<string>;
14
+ sync(hardReset?: boolean): Promise<void>;
15
+ /** Stage everything and commit as the ops identity. */
16
+ commitAll(message: string): Promise<void>;
17
+ pushMainWithRebase(): Promise<void>;
18
+ /** Delete every remote stress backup branch; returns the deleted names. */
19
+ pruneStressBackupBranches(): Promise<string[]>;
20
+ private environment;
21
+ }
@@ -0,0 +1,56 @@
1
+ import type { FetchLike } from "@brains/utils/fetch-like";
2
+ import { z } from "@brains/utils/zod";
3
+ import { type StressHealthSample, type StressRuntimeSample } from "./directory-sync-stress";
4
+ /** Entity counts by type, as the runtime's database reports them. */
5
+ export declare const stressEntityCountsSchema: z.ZodArray<z.ZodObject<{
6
+ entityType: z.ZodString;
7
+ count: z.ZodNumber;
8
+ }>>;
9
+ export type StressEntityCounts = z.output<typeof stressEntityCountsSchema>;
10
+ export interface HealthPayload {
11
+ version: string;
12
+ entities: number;
13
+ entityCounts: StressEntityCounts;
14
+ }
15
+ export declare function noteCount(payload: HealthPayload): number;
16
+ export interface StressHealthMonitorOptions {
17
+ domain: string;
18
+ fetchImpl: FetchLike;
19
+ now: () => Date;
20
+ sleep: (milliseconds: number) => Promise<void>;
21
+ /** Take one runtime sample (e.g. docker stats over SSH), if available. */
22
+ sampleRuntime: () => Promise<StressRuntimeSample | undefined>;
23
+ /**
24
+ * Entity counts by type from an authenticated source (the runtime's
25
+ * database over SSH), or undefined when this poll has no reading.
26
+ */
27
+ readEntityCounts: () => Promise<StressEntityCounts | undefined>;
28
+ }
29
+ /**
30
+ * Samples deployment health and runtime resource usage during a stress run,
31
+ * and answers entity-baseline waits from the operational health endpoint and
32
+ * the runtime's own entity counts.
33
+ */
34
+ export declare class StressHealthMonitor {
35
+ #private;
36
+ constructor(options: StressHealthMonitorOptions);
37
+ /** ISO timestamp of the most recent start() call. */
38
+ get startedAt(): string;
39
+ /** The first sampling failure since start(), if any. */
40
+ get error(): unknown;
41
+ get healthSamples(): readonly StressHealthSample[];
42
+ get runtimeSamples(): readonly StressRuntimeSample[];
43
+ /** Health samples between start() and markGateEnd() (or now). */
44
+ gateHealthSamples(): StressHealthSample[];
45
+ /** Exclude samples taken after this point from the gate window. */
46
+ markGateEnd(): void;
47
+ /** Probe optional health endpoints and add responsive ones to the loop. */
48
+ discoverEndpoints(): Promise<void>;
49
+ start(): void;
50
+ /** Stop sampling and wait for the loops; a loop error stays on `error`. */
51
+ stop(): Promise<void>;
52
+ waitForHealthSnapshot(timeoutMs: number): Promise<HealthPayload | undefined>;
53
+ waitForEntityBaseline(expectedNotes: number | undefined, expectedEntities: number | undefined, timeoutMs: number): Promise<HealthPayload | undefined>;
54
+ waitForStableEntityBaseline(expectedNotes: number | undefined, expectedEntities: number | undefined, timeoutMs: number): Promise<HealthPayload | undefined>;
55
+ fetchHealthPayload(): Promise<HealthPayload | undefined>;
56
+ }
@@ -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>;
@@ -1,5 +1,6 @@
1
1
  export interface AddPilotUserOptions {
2
2
  cohort: string;
3
+ discord?: boolean | undefined;
3
4
  anchorId?: string | undefined;
4
5
  }
5
6
  export interface AddPilotUserResult {
@@ -0,0 +1,61 @@
1
+ import type { FetchLike } from "@brains/utils/fetch-like";
2
+ export interface OffboardPilotUsersOptions {
3
+ handles: string[];
4
+ confirmation?: string | undefined;
5
+ dryRun?: boolean | undefined;
6
+ env?: NodeJS.ProcessEnv | undefined;
7
+ fetchImpl?: FetchLike | undefined;
8
+ logger?: ((message: string) => void) | undefined;
9
+ sleep?: ((delayMs: number) => Promise<void>) | undefined;
10
+ driver?: PilotOffboardDriver | undefined;
11
+ }
12
+ export interface PilotOffboardTarget {
13
+ handle: string;
14
+ instanceName: string;
15
+ domain: string;
16
+ previewDomain: string;
17
+ wwwDomain?: string | undefined;
18
+ contentRepo: string;
19
+ cloudflareZoneId: string;
20
+ }
21
+ export interface PilotOffboardServer {
22
+ id: number;
23
+ name: string;
24
+ ip?: string | undefined;
25
+ }
26
+ export interface PilotOffboardDnsRecord {
27
+ id: string;
28
+ name: string;
29
+ type: "A" | "CNAME";
30
+ content: string;
31
+ }
32
+ export interface PilotOffboardInspection {
33
+ server?: PilotOffboardServer | undefined;
34
+ dnsRecords: PilotOffboardDnsRecord[];
35
+ contentRepoArchived: boolean;
36
+ }
37
+ export interface PilotOffboardDriver {
38
+ inspect(target: PilotOffboardTarget): Promise<PilotOffboardInspection>;
39
+ archiveContentRepo(target: PilotOffboardTarget): Promise<void>;
40
+ deleteDnsRecord(target: PilotOffboardTarget, record: PilotOffboardDnsRecord): Promise<void>;
41
+ deleteServer(target: PilotOffboardTarget, server: PilotOffboardServer): Promise<void>;
42
+ }
43
+ export interface OffboardPilotUserPlan {
44
+ target: PilotOffboardTarget;
45
+ inspection: PilotOffboardInspection;
46
+ }
47
+ export interface OffboardPilotUsersResult {
48
+ dryRun: boolean;
49
+ handles: string[];
50
+ confirmation: string;
51
+ plans: OffboardPilotUserPlan[];
52
+ }
53
+ export declare function offboardPilotUsers(rootDir: string, options: OffboardPilotUsersOptions): Promise<OffboardPilotUsersResult>;
54
+ interface DefaultDriverOptions {
55
+ env: NodeJS.ProcessEnv;
56
+ fetchImpl?: FetchLike | undefined;
57
+ contentRepoTokenName: string;
58
+ }
59
+ export declare function createPilotOffboardDriver(options: DefaultDriverOptions): PilotOffboardDriver;
60
+ export declare function assertPilotUsersRemoved(rootDir: string, handles: string[]): Promise<void>;
61
+ export {};
@@ -0,0 +1,22 @@
1
+ import type { FetchLike } from "@brains/utils/fetch-like";
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,69 +1,76 @@
1
1
  {
2
2
  "name": "@rizom/ops",
3
+ "version": "0.2.0-alpha.710",
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
+ "@libsql/client": "^0.17.4",
21
+ "age-encryption": "^0.3.0",
22
+ "yaml": "^2.9.0"
23
+ },
24
+ "devDependencies": {
25
+ "@brains/contracts": "workspace:*",
26
+ "@brains/deploy-support": "workspace:*",
27
+ "@brains/eslint-config": "workspace:*",
28
+ "@brains/job-queue": "workspace:*",
29
+ "@brains/profile": "workspace:*",
30
+ "@brains/site-composition": "workspace:*",
31
+ "@brains/test-utils": "workspace:*",
32
+ "@brains/typescript-config": "workspace:*",
33
+ "@brains/utils": "workspace:*",
34
+ "@types/bun": "^1.4.0",
35
+ "typescript": "^7.0.2"
36
+ },
37
+ "engines": {
38
+ "bun": ">=1.3.3"
6
39
  },
7
- "version": "0.2.0-alpha.71",
8
- "type": "module",
9
40
  "exports": {
10
41
  ".": {
11
42
  "types": "./dist/index.d.ts",
12
43
  "import": "./dist/index.js"
13
44
  },
14
45
  "./deploy": {
15
- "bun": "./dist/deploy.js",
16
46
  "types": "./dist/deploy.d.ts",
17
- "import": "./dist/deploy.js"
47
+ "import": "./dist/deploy.js",
48
+ "bun": "./dist/deploy.js"
18
49
  }
19
50
  },
20
- "main": "./dist/index.js",
21
- "types": "./dist/index.d.ts",
22
- "bin": {
23
- "brains-ops": "./dist/brains-ops.js"
24
- },
25
51
  "files": [
26
52
  "dist",
27
53
  "templates"
28
54
  ],
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 run build && bun test --timeout 20000",
36
- "test:smoke": "bun run build && RUN_SMOKE_TESTS=1 bun test --timeout 60000"
37
- },
38
- "dependencies": {
39
- "age-encryption": "^0.3.0"
40
- },
41
- "devDependencies": {
42
- "@brains/deploy-support": "workspace:*",
43
- "@brains/eslint-config": "workspace:*",
44
- "@brains/typescript-config": "workspace:*",
45
- "@brains/utils": "workspace:*",
46
- "@types/bun": "latest",
47
- "typescript": "^5.3.3"
55
+ "homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
56
+ "main": "./dist/index.js",
57
+ "publishConfig": {
58
+ "access": "public"
48
59
  },
49
60
  "repository": {
50
61
  "type": "git",
51
62
  "url": "https://github.com/rizom-ai/brains.git",
52
63
  "directory": "packages/brains-ops"
53
64
  },
54
- "license": "Apache-2.0",
55
- "author": "Yeehaa <yeehaa@rizom.ai> (https://rizom.ai)",
56
- "homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
57
- "bugs": "https://github.com/rizom-ai/brains/issues",
58
- "engines": {
59
- "bun": ">=1.3.3"
65
+ "scripts": {
66
+ "build": "bun scripts/build.ts",
67
+ "lint": "eslint . --max-warnings 0",
68
+ "lint:fix": "eslint . --max-warnings 0 --fix",
69
+ "prepublishOnly": "bun scripts/build.ts",
70
+ "test": "bun test --timeout 20000",
71
+ "test:smoke": "bun run build && RUN_SMOKE_TESTS=1 bun test --timeout 60000",
72
+ "typecheck": "tsc --noEmit"
60
73
  },
61
- "keywords": [
62
- "brains",
63
- "ops",
64
- "cli",
65
- "deploy",
66
- "fleet",
67
- "operator"
68
- ]
74
+ "type": "module",
75
+ "types": "./dist/index.d.ts"
69
76
  }
@@ -3,11 +3,19 @@
3
3
  # @defaultRequired=false @defaultSensitive=false
4
4
  # ----------
5
5
 
6
- # AI provider
7
- # Shared GitHub secret by default; a per-user override may come from the decrypted
6
+ # OpenAI, the provider of the canonical chat and embedding models
7
+ # Shared secret by default; a per-user override may come from the decrypted
8
8
  # users/<handle>.secrets.yaml.age file at deploy time.
9
9
  # @required @sensitive
10
- AI_API_KEY=
10
+ OPENAI_API_KEY=
11
+
12
+ # Anthropic and Google: unused by the canonical models; the shared deploy
13
+ # config passes every provider's key to the brain.
14
+ # @sensitive
15
+ ANTHROPIC_API_KEY=
16
+
17
+ # @sensitive
18
+ GOOGLE_GENERATIVE_AI_API_KEY=
11
19
 
12
20
  # Git sync
13
21
  # Comes from the decrypted users/<handle>.secrets.yaml.age file.
@@ -20,16 +28,31 @@ GIT_SYNC_TOKEN=
20
28
  # @required @sensitive
21
29
  CONTENT_REPO_ADMIN_TOKEN=
22
30
 
23
- # MCP interface
24
- # Comes from the decrypted users/<handle>.secrets.yaml.age file.
25
- # @required @sensitive
26
- MCP_AUTH_TOKEN=
27
-
28
31
  # Discord (optional, per-user)
29
32
  # Comes from the decrypted users/<handle>.secrets.yaml.age file when enabled.
30
33
  # @sensitive
31
34
  DISCORD_BOT_TOKEN=
32
35
 
36
+ # Stored with the per-user Discord credentials and injected at deploy time.
37
+ # @sensitive
38
+ DISCORD_PUBLIC_KEY=
39
+
40
+ # @sensitive
41
+ DISCORD_APPLICATION_ID=
42
+
43
+ # AT Protocol publishing/discovery (optional, per-user)
44
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file when configured.
45
+ # @sensitive
46
+ ATPROTO_APP_PASSWORD=
47
+
48
+ # Setup email delivery (optional, shared)
49
+ # Used when a user file declares setup.delivery: email.
50
+ # @sensitive
51
+ SETUP_EMAIL_API_KEY=
52
+
53
+ # @sensitive
54
+ SETUP_EMAIL_FROM=
55
+
33
56
  # ---- deploy/provision vars ----
34
57
 
35
58
  # @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: Build one Brain version from its checked-in runtime lock
8
+ required: false
9
+ type: string
10
+ site_packages:
11
+ description: "Exact site/theme pins for an explicit build; a matching checked-in runtime lock is required."
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,22 +33,57 @@ 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
+ with:
47
+ bun-version: 1.4.0
48
+
49
+ - name: Install operator tooling
50
+ run: bun install --frozen-lockfile --ignore-scripts
51
+
52
+ - name: Log in to GHCR
53
+ uses: docker/login-action@v4
54
+ with:
55
+ registry: ghcr.io
56
+ username: ${{ github.actor }}
57
+ password: ${{ secrets.GITHUB_TOKEN }}
58
+
59
+ - name: Resolve images to build
60
+ id: resolve
61
+ env:
62
+ BRAIN_VERSION_INPUT: ${{ inputs.brain_version || '' }}
63
+ SITE_PACKAGES_INPUT: ${{ inputs.site_packages || '' }}
64
+ ALLOW_TAG_OVERWRITE: ${{ inputs.overwrite && 'true' || 'false' }}
65
+ run: bun deploy/scripts/resolve-missing-images.ts
66
+
67
+ no_builds:
68
+ needs: resolve
69
+ if: needs.resolve.outputs.images_json == '[]'
70
+ runs-on: ubuntu-latest
71
+ steps:
72
+ - name: Skip when every declared image exists
73
+ run: echo "All declared images already exist; nothing to build."
74
+
75
+ build:
76
+ needs: resolve
77
+ if: needs.resolve.outputs.images_json != '[]'
78
+ runs-on: ubuntu-latest
79
+ strategy:
80
+ fail-fast: false
81
+ matrix:
82
+ image: ${{ fromJson(needs.resolve.outputs.images_json) }}
83
+ steps:
84
+ - uses: actions/checkout@v5
85
+ with:
86
+ ref: ${{ github.sha }}
33
87
 
34
88
  - name: Set up Docker Buildx
35
89
  uses: docker/setup-buildx-action@v4
@@ -38,9 +92,9 @@ jobs:
38
92
  id: meta
39
93
  uses: docker/metadata-action@v6
40
94
  with:
41
- images: ${{ env.IMAGE_REPOSITORY }}
95
+ images: ghcr.io/${{ github.repository }}
42
96
  tags: |
43
- type=raw,value=brain-${{ env.BRAIN_VERSION }}
97
+ type=raw,value=${{ matrix.image.tag }}
44
98
 
45
99
  - name: Log in to GHCR
46
100
  uses: docker/login-action@v4
@@ -57,7 +111,7 @@ jobs:
57
111
  target: fleet
58
112
  push: true
59
113
  build-args: |
60
- BRAIN_VERSION=${{ env.BRAIN_VERSION }}
114
+ RUNTIME_IMAGE_TAG=${{ matrix.image.tag }}
61
115
  tags: ${{ steps.meta.outputs.tags }}
62
116
  labels: |
63
117
  ${{ steps.meta.outputs.labels }}