@rizom/ops 0.2.0-alpha.350 → 0.2.0-alpha.352
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/brains-ops.js +219 -219
- package/dist/deploy.js +22 -23
- package/dist/directory-sync-stress.d.ts +92 -82
- package/dist/entries/deploy.d.ts +1 -1
- package/dist/images.d.ts +23 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +242 -243
- package/dist/schema.d.ts +34 -32
- package/dist/stage-legacy-crossover.d.ts +6 -4
- package/package.json +1 -1
- package/templates/rover-pilot/.github/workflows/build.yml +1 -1
- package/templates/rover-pilot/README.md +2 -2
- package/templates/rover-pilot/deploy/scripts/create-predeploy-backup.ts +25 -14
- package/templates/rover-pilot/deploy/scripts/helpers.ts +1 -2
- package/templates/rover-pilot/deploy/scripts/provision-server.ts +51 -21
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +4 -8
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +35 -12
- package/templates/rover-pilot/docs/operator-playbook.md +15 -11
package/dist/schema.d.ts
CHANGED
|
@@ -28,38 +28,34 @@ export declare const profileKindSchema: z.ZodEnum<{
|
|
|
28
28
|
team: "team";
|
|
29
29
|
organization: "organization";
|
|
30
30
|
}>;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
bundleContract: typeof CAPABILITY_BUNDLE_CONTRACT;
|
|
34
|
-
bundles: CanonicalBundleId[];
|
|
35
|
-
add?: string[] | undefined;
|
|
36
|
-
remove?: string[] | undefined;
|
|
37
|
-
githubOrg: string;
|
|
38
|
-
contentRepoPrefix: string;
|
|
39
|
-
domainSuffix: string;
|
|
40
|
-
aiApiKey: string;
|
|
41
|
-
gitSyncToken: string;
|
|
42
|
-
contentRepoAdminToken: string;
|
|
43
|
-
agePublicKey: string;
|
|
44
|
-
}
|
|
45
|
-
export interface SiteOverrideConfig {
|
|
46
|
-
package: string;
|
|
47
|
-
version: string;
|
|
48
|
-
theme?: string | undefined;
|
|
49
|
-
themeVersion?: string | undefined;
|
|
50
|
-
}
|
|
51
|
-
export interface CohortConfig {
|
|
52
|
-
members: string[];
|
|
53
|
-
brainVersionOverride?: string | undefined;
|
|
54
|
-
bundlesOverride?: CanonicalBundleId[] | undefined;
|
|
55
|
-
addOverride?: string[] | undefined;
|
|
56
|
-
removeOverride?: string[] | undefined;
|
|
57
|
-
aiApiKeyOverride?: string | undefined;
|
|
58
|
-
gitSyncTokenOverride?: string | undefined;
|
|
59
|
-
}
|
|
31
|
+
declare const canonicalBundlesSchema: z.ZodArray<typeof canonicalBundleIdSchema>;
|
|
32
|
+
declare const memberIdsSchema: z.ZodArray<z.ZodString>;
|
|
60
33
|
/** Sole active desired-state contract for the canonical brain. */
|
|
61
|
-
export declare const pilotSchema: z.
|
|
62
|
-
|
|
34
|
+
export declare const pilotSchema: z.ZodObject<{
|
|
35
|
+
brainVersion: z.ZodString;
|
|
36
|
+
bundleContract: z.ZodLiteral<typeof CAPABILITY_BUNDLE_CONTRACT>;
|
|
37
|
+
bundles: typeof canonicalBundlesSchema;
|
|
38
|
+
add: z.ZodOptional<typeof memberIdsSchema>;
|
|
39
|
+
remove: z.ZodOptional<typeof memberIdsSchema>;
|
|
40
|
+
githubOrg: z.ZodString;
|
|
41
|
+
contentRepoPrefix: z.ZodString;
|
|
42
|
+
domainSuffix: z.ZodString;
|
|
43
|
+
aiApiKey: z.ZodString;
|
|
44
|
+
gitSyncToken: z.ZodString;
|
|
45
|
+
contentRepoAdminToken: z.ZodString;
|
|
46
|
+
agePublicKey: z.ZodString;
|
|
47
|
+
}, z.core.$strict>;
|
|
48
|
+
export type PilotConfig = z.output<typeof pilotSchema>;
|
|
49
|
+
export declare const cohortSchema: z.ZodObject<{
|
|
50
|
+
members: z.ZodArray<z.ZodString>;
|
|
51
|
+
brainVersionOverride: z.ZodOptional<z.ZodString>;
|
|
52
|
+
bundlesOverride: z.ZodOptional<typeof canonicalBundlesSchema>;
|
|
53
|
+
addOverride: z.ZodOptional<typeof memberIdsSchema>;
|
|
54
|
+
removeOverride: z.ZodOptional<typeof memberIdsSchema>;
|
|
55
|
+
aiApiKeyOverride: z.ZodOptional<z.ZodString>;
|
|
56
|
+
gitSyncTokenOverride: z.ZodOptional<z.ZodString>;
|
|
57
|
+
}, z.core.$strict>;
|
|
58
|
+
export type CohortConfig = z.output<typeof cohortSchema>;
|
|
63
59
|
declare const anchorProfileSocialLinkSchema: z.ZodObject<{
|
|
64
60
|
platform: z.ZodEnum<{
|
|
65
61
|
github: "github";
|
|
@@ -109,7 +105,13 @@ declare const atprotoSchema: z.ZodObject<{
|
|
|
109
105
|
lexiconAuthority: z.ZodOptional<z.ZodBoolean>;
|
|
110
106
|
jetstream: z.ZodOptional<typeof atprotoJetstreamSchema>;
|
|
111
107
|
}>;
|
|
112
|
-
export declare const siteOverrideSchema: z.
|
|
108
|
+
export declare const siteOverrideSchema: z.ZodObject<{
|
|
109
|
+
package: z.ZodString;
|
|
110
|
+
version: z.ZodString;
|
|
111
|
+
theme: z.ZodOptional<z.ZodString>;
|
|
112
|
+
themeVersion: z.ZodOptional<z.ZodString>;
|
|
113
|
+
}, z.core.$strict>;
|
|
114
|
+
export type SiteOverrideConfig = z.output<typeof siteOverrideSchema>;
|
|
113
115
|
declare const playbooksSchema: z.ZodObject<{
|
|
114
116
|
onboarding: z.ZodOptional<z.ZodBoolean>;
|
|
115
117
|
}>;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { z } from "@brains/utils/zod";
|
|
1
2
|
import { type CapabilityBundleReview } from "./capability-bundle-migration";
|
|
2
|
-
import {
|
|
3
|
+
import { siteOverrideSchema } from "./schema";
|
|
3
4
|
export interface StagedCrossover {
|
|
4
5
|
outputDir: string;
|
|
5
6
|
changedFiles: string[];
|
|
6
7
|
}
|
|
7
|
-
|
|
8
|
-
sites:
|
|
9
|
-
}
|
|
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>;
|
|
10
12
|
export type ReviewedSitePins = ReviewedSitePinManifest["sites"];
|
|
11
13
|
export interface StageLegacyCrossoverOptions {
|
|
12
14
|
sitePins?: ReviewedSitePins | undefined;
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ on:
|
|
|
8
8
|
required: false
|
|
9
9
|
type: string
|
|
10
10
|
site_packages:
|
|
11
|
-
description:
|
|
11
|
+
description: "Space-separated exact site/theme packages for one explicit image build. Shared-fleet automatic builds derive the version-wide package union from desired state."
|
|
12
12
|
required: false
|
|
13
13
|
type: string
|
|
14
14
|
overwrite:
|
|
@@ -30,8 +30,8 @@ The repo also checks in its deploy contract:
|
|
|
30
30
|
|
|
31
31
|
`.env.schema` is the single source of truth for required and sensitive deploy vars.
|
|
32
32
|
Use separate GitHub tokens: `CONTENT_REPO_ADMIN_TOKEN` for operator-side content repo creation/checks, and `GIT_SYNC_TOKEN` for runtime directory-sync git access.
|
|
33
|
-
The
|
|
34
|
-
When
|
|
33
|
+
The fleet publishes one `brain-${brainVersion}` image per effective Brain version and installs the union of exact site/theme package pins required by instances on that version.
|
|
34
|
+
When an effective brain version (`pilot.yaml.brainVersion`, or a cohort override) changes and you push, CI builds every missing version image, refreshes generated user env files, and redeploys affected users. Every external site and theme package keeps its own exact version pin; change that package set only together with a fresh Brain version so published tags remain immutable.
|
|
35
35
|
When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
|
|
36
36
|
|
|
37
37
|
## Commands
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
3
|
import { appendFileSync } from "node:fs";
|
|
4
|
+
import { isPlainRecord } from "@brains/utils/predicates";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The slice of `@libsql/client` this script uses.
|
|
8
|
+
*
|
|
9
|
+
* Declared here because the import is dynamic — the package is optional, and
|
|
10
|
+
* only the libsql quick-check driver needs it.
|
|
11
|
+
*/
|
|
12
|
+
interface LibsqlModule {
|
|
13
|
+
createClient(options: { url: string }): {
|
|
14
|
+
execute(sql: string): Promise<{
|
|
15
|
+
rows: Array<Record<string | number, unknown>>;
|
|
16
|
+
}>;
|
|
17
|
+
close(): void;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
4
20
|
|
|
5
21
|
export const DEFAULT_PREDEPLOY_BACKUP_RETENTION_COUNT = 5;
|
|
6
22
|
export const PREDEPLOY_BACKUP_TOOL_VERSION = "brains-predeploy-backup-v1";
|
|
@@ -157,10 +173,8 @@ async function quickCheck(
|
|
|
157
173
|
if (driver === "bun") {
|
|
158
174
|
const database = new Database(path, { readonly: true });
|
|
159
175
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
unknown
|
|
163
|
-
> | null;
|
|
176
|
+
const result: unknown = database.query("PRAGMA quick_check").get();
|
|
177
|
+
const row = isPlainRecord(result) ? result : null;
|
|
164
178
|
return String(row?.["quick_check"] ?? row?.["0"] ?? "");
|
|
165
179
|
} finally {
|
|
166
180
|
database.close(false);
|
|
@@ -168,14 +182,10 @@ async function quickCheck(
|
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
const moduleName = "@libsql/client";
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}>;
|
|
176
|
-
close(): void;
|
|
177
|
-
};
|
|
178
|
-
};
|
|
185
|
+
// Annotated rather than asserted: a dynamic import of an optional dependency
|
|
186
|
+
// resolves to `any`, so naming the slice this script uses is a checked
|
|
187
|
+
// assignment instead of a claim about the whole module.
|
|
188
|
+
const libsql: LibsqlModule = await import(moduleName);
|
|
179
189
|
const client = libsql.createClient({ url: `file:${path}` });
|
|
180
190
|
try {
|
|
181
191
|
const result = await client.execute("PRAGMA quick_check");
|
|
@@ -198,9 +208,10 @@ function vectorDigestDatabase(database: Database): {
|
|
|
198
208
|
const counts: Record<string, number> = {};
|
|
199
209
|
for (const [table, order] of tables) {
|
|
200
210
|
hasher.update(`table:${table}\n`);
|
|
201
|
-
const
|
|
211
|
+
const selected: unknown[] = database
|
|
202
212
|
.query(`SELECT * FROM ${table} ORDER BY ${order}`)
|
|
203
|
-
.all()
|
|
213
|
+
.all();
|
|
214
|
+
const rows = selected.filter(isPlainRecord);
|
|
204
215
|
counts[table] = rows.length;
|
|
205
216
|
for (const row of rows) {
|
|
206
217
|
for (const [key, value] of Object.entries(row)) {
|
|
@@ -30,19 +30,54 @@ function sleep(ms: number): Promise<void> {
|
|
|
30
30
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* These scripts are copied verbatim into generated projects, so they stay
|
|
35
|
+
* dependency-free: the API payloads are checked by hand rather than with a
|
|
36
|
+
* schema library. readJsonResponse returns unknown; asserting the Hetzner
|
|
37
|
+
* response shape onto it meant a changed or error payload surfaced as an
|
|
38
|
+
* undefined field later instead of a failed lookup here.
|
|
39
|
+
*/
|
|
40
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isHetznerServer(value: unknown): value is HetznerServer {
|
|
45
|
+
if (!isRecord(value)) return false;
|
|
46
|
+
if (typeof value["id"] !== "number") return false;
|
|
47
|
+
if (typeof value["status"] !== "string") return false;
|
|
48
|
+
const publicNet = value["public_net"];
|
|
49
|
+
if (publicNet === undefined) return true;
|
|
50
|
+
if (!isRecord(publicNet)) return false;
|
|
51
|
+
const ipv4 = publicNet["ipv4"];
|
|
52
|
+
if (ipv4 === undefined) return true;
|
|
53
|
+
if (!isRecord(ipv4)) return false;
|
|
54
|
+
const ip = ipv4["ip"];
|
|
55
|
+
return ip === undefined || typeof ip === "string";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readServer(payload: unknown): HetznerServer | undefined {
|
|
59
|
+
if (!isRecord(payload)) return undefined;
|
|
60
|
+
const server = payload["server"];
|
|
61
|
+
return isHetznerServer(server) ? server : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readServers(payload: unknown): HetznerServer[] | undefined {
|
|
65
|
+
if (!isRecord(payload)) return undefined;
|
|
66
|
+
const servers = payload["servers"];
|
|
67
|
+
return Array.isArray(servers) && servers.every(isHetznerServer)
|
|
68
|
+
? servers
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
async function listServers(): Promise<HetznerServer[]> {
|
|
34
73
|
const url = `${baseUrl}/servers?label_selector=${encodeURIComponent(labelSelector)}`;
|
|
35
74
|
const response = await fetch(url, { headers });
|
|
36
|
-
const payload =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)) as {
|
|
40
|
-
servers?: HetznerServer[];
|
|
41
|
-
};
|
|
42
|
-
if (!response.ok || !payload.servers) {
|
|
75
|
+
const payload = await readJsonResponse(response, "Hetzner server lookup");
|
|
76
|
+
const servers = readServers(payload);
|
|
77
|
+
if (!response.ok || !servers) {
|
|
43
78
|
throw new Error(`Hetzner server lookup failed: ${JSON.stringify(payload)}`);
|
|
44
79
|
}
|
|
45
|
-
return
|
|
80
|
+
return servers;
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
async function createServer(): Promise<HetznerServer> {
|
|
@@ -58,27 +93,22 @@ async function createServer(): Promise<HetznerServer> {
|
|
|
58
93
|
labels: { brain: instanceName },
|
|
59
94
|
}),
|
|
60
95
|
});
|
|
61
|
-
const payload =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)) as {
|
|
65
|
-
server?: HetznerServer;
|
|
66
|
-
};
|
|
67
|
-
if (!response.ok || !payload.server) {
|
|
96
|
+
const payload = await readJsonResponse(response, "Hetzner server create");
|
|
97
|
+
const server = readServer(payload);
|
|
98
|
+
if (!response.ok || !server) {
|
|
68
99
|
throw new Error(`Hetzner server create failed: ${JSON.stringify(payload)}`);
|
|
69
100
|
}
|
|
70
|
-
return
|
|
101
|
+
return server;
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
async function getServer(id: number): Promise<HetznerServer> {
|
|
74
105
|
const response = await fetch(`${baseUrl}/servers/${id}`, { headers });
|
|
75
|
-
const payload =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!response.ok || !payload.server) {
|
|
106
|
+
const payload = await readJsonResponse(response, "Hetzner server poll");
|
|
107
|
+
const server = readServer(payload);
|
|
108
|
+
if (!response.ok || !server) {
|
|
79
109
|
throw new Error(`Hetzner server poll failed: ${JSON.stringify(payload)}`);
|
|
80
110
|
}
|
|
81
|
-
return
|
|
111
|
+
return server;
|
|
82
112
|
}
|
|
83
113
|
|
|
84
114
|
let server: HetznerServer | undefined = (await listServers())[0];
|
|
@@ -5,8 +5,7 @@ import { derivePreviewDomain, loadPilotRegistry } from "@rizom/ops";
|
|
|
5
5
|
import {
|
|
6
6
|
parseEnvFile,
|
|
7
7
|
requireEnv,
|
|
8
|
-
|
|
9
|
-
siteImageTag,
|
|
8
|
+
runtimeImageTag,
|
|
10
9
|
writeGitHubOutput,
|
|
11
10
|
} from "./helpers";
|
|
12
11
|
|
|
@@ -43,12 +42,9 @@ const wwwDomain = isFleetDomain(
|
|
|
43
42
|
|
|
44
43
|
const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
|
|
45
44
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
// build uses so the tag we wait for and run matches exactly what was pushed.
|
|
50
|
-
const sitePackages = sitePackagesFor(user.siteOverride);
|
|
51
|
-
const imageTag = siteImageTag(brainVersion, sitePackages);
|
|
45
|
+
// Build and Deploy share one tag function: every instance on a Brain version
|
|
46
|
+
// runs the same immutable fleet image.
|
|
47
|
+
const imageTag = runtimeImageTag(brainVersion);
|
|
52
48
|
|
|
53
49
|
const outputs: Record<string, string> = {
|
|
54
50
|
brain_version: brainVersion,
|
|
@@ -16,18 +16,43 @@ interface CloudflareResult {
|
|
|
16
16
|
result?: Array<{ id: string }>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Copied verbatim into generated projects, so this stays dependency-free and
|
|
21
|
+
* checks the payload by hand. readJsonResponse returns unknown; asserting
|
|
22
|
+
* CloudflareResult onto it meant an error body read as `success: undefined`
|
|
23
|
+
* rather than failing here.
|
|
24
|
+
*/
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readCloudflareResult(payload: unknown): CloudflareResult | undefined {
|
|
30
|
+
if (!isRecord(payload)) return undefined;
|
|
31
|
+
if (typeof payload["success"] !== "boolean") return undefined;
|
|
32
|
+
const result = payload["result"];
|
|
33
|
+
if (result === undefined) return { success: payload["success"] };
|
|
34
|
+
if (
|
|
35
|
+
!Array.isArray(result) ||
|
|
36
|
+
!result.every(
|
|
37
|
+
(entry): entry is { id: string } =>
|
|
38
|
+
isRecord(entry) && typeof entry["id"] === "string",
|
|
39
|
+
)
|
|
40
|
+
) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return { success: payload["success"], result };
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
async function findRecordId(
|
|
20
47
|
name: string,
|
|
21
48
|
type: "A" | "CNAME",
|
|
22
49
|
): Promise<string | undefined> {
|
|
23
50
|
const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
|
|
24
51
|
const lookup = await fetch(lookupUrl, { headers });
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (!lookup.ok || !payload.success) {
|
|
30
|
-
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
|
|
52
|
+
const raw = await readJsonResponse(lookup, "Cloudflare DNS lookup");
|
|
53
|
+
const payload = readCloudflareResult(raw);
|
|
54
|
+
if (!lookup.ok || !payload?.success) {
|
|
55
|
+
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(raw)}`);
|
|
31
56
|
}
|
|
32
57
|
|
|
33
58
|
return payload.result?.[0]?.id;
|
|
@@ -53,12 +78,10 @@ async function upsertRecord(name: string): Promise<void> {
|
|
|
53
78
|
proxied: true,
|
|
54
79
|
}),
|
|
55
80
|
});
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!response.ok || !result.success) {
|
|
61
|
-
throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(result)}`);
|
|
81
|
+
const raw = await readJsonResponse(response, "Cloudflare DNS upsert");
|
|
82
|
+
const result = readCloudflareResult(raw);
|
|
83
|
+
if (!response.ok || !result?.success) {
|
|
84
|
+
throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(raw)}`);
|
|
62
85
|
}
|
|
63
86
|
}
|
|
64
87
|
|
|
@@ -16,10 +16,11 @@ Treat these as checked-in deploy artifacts in the pilot repo:
|
|
|
16
16
|
`.env.schema` is the single source of truth for required and sensitive deploy vars.
|
|
17
17
|
The deploy scripts and workflows should read from that contract instead of inventing a second list.
|
|
18
18
|
|
|
19
|
-
The
|
|
19
|
+
The fleet has one image topology:
|
|
20
20
|
|
|
21
|
-
-
|
|
22
|
-
-
|
|
21
|
+
- one immutable `brain-${brainVersion}` image is published for each effective Brain version
|
|
22
|
+
- each image contains the union of exact site/theme package pins required by instances on that version
|
|
23
|
+
- conflicting versions of one package fail image resolution before build
|
|
23
24
|
- generated `users/<handle>/.env` carries `BRAIN_VERSION=<brainVersion>`
|
|
24
25
|
- build and deploy derive the same effective image tag from the resolved registry
|
|
25
26
|
|
|
@@ -27,7 +28,7 @@ The default pilot image tag is `brain-${brainVersion}`:
|
|
|
27
28
|
|
|
28
29
|
When `pilot.yaml.brainVersion` changes and you push:
|
|
29
30
|
|
|
30
|
-
1. build publishes
|
|
31
|
+
1. build publishes each missing version image with its declared package union
|
|
31
32
|
2. reconcile refreshes generated `users/<handle>/.env`
|
|
32
33
|
3. deploy runs for handles whose generated config changed
|
|
33
34
|
4. generated file commits happen once in a final aggregation step after the deploy matrix finishes
|
|
@@ -260,21 +261,24 @@ siteOverride:
|
|
|
260
261
|
themeVersion: <exact-theme-version>
|
|
261
262
|
```
|
|
262
263
|
|
|
263
|
-
Missing external package versions fail desired-state validation.
|
|
264
|
-
|
|
265
|
-
|
|
264
|
+
Missing external package versions fail desired-state validation. Every instance on one
|
|
265
|
+
Brain version uses the same image and exact package union. Change a site/theme package
|
|
266
|
+
pin only together with a fresh Brain version; published `brain-${brainVersion}` tags
|
|
267
|
+
remain immutable. Conflicting pins for one package on the same Brain version fail before
|
|
268
|
+
build. Bundled `@brains/*` themes omit `themeVersion` because they are not installed as
|
|
266
269
|
separate packages.
|
|
267
270
|
|
|
268
271
|
### Custom-package canary and rollback
|
|
269
272
|
|
|
270
273
|
1. Confirm the exact site/theme versions are public-installable without npm credentials.
|
|
271
|
-
2. Apply the exact package names and versions to one healthy canonical site canary.
|
|
272
|
-
3.
|
|
274
|
+
2. Apply the exact package names and versions to one healthy canonical site canary and select a fresh Brain version for that package set.
|
|
275
|
+
3. Push desired state and let the normal Build → Reconcile → Deploy chain create the shared version image and update the canary.
|
|
273
276
|
4. Run `bunx brains-ops verify-user . <handle>`.
|
|
274
277
|
5. Manually verify the site, theme, Studio, content sync, and passkey sign-in before adding more users.
|
|
275
278
|
|
|
276
|
-
To roll back, remove or change `siteOverride
|
|
277
|
-
|
|
279
|
+
To roll back, remove or change `siteOverride` while selecting a fresh Brain version,
|
|
280
|
+
then reconcile and redeploy that user. Never mutate an existing image tag; coordinate any
|
|
281
|
+
other users intentionally sharing the selected version.
|
|
278
282
|
|
|
279
283
|
## Setup email checklist
|
|
280
284
|
|