Skip to content

Commit c797040

Browse files
authored
feat(cli): bos promote <block-height> — activate a recorded deployment without rebuilding (ADR 0042 ticket 05) (#780)
The same verified activation path as bos rollback --version — publish history targeting, SRI pin verification, boot-payload check, pointer restore, and the train's lockfile leg freezing the promoted composition into bos.lock.json (the deployment record named its digest all along) — framed as promotion: the payload republishes verbatim with NO rolledBackFrom brand (a promotion is not a rollback), an already-live target is an idempotent no-op rather than a refusal (the train's already-published path handles it), and the version is required — promotion targets a record, not 'previous'. The Railway image leg stays in CI's deploy workflow by design: the CLI holds no Railway credential, and the image plane's restore is the deploy workflow's digest handover. The config/pointer/lockfile plane is complete from here.
1 parent f015cb3 commit c797040

5 files changed

Lines changed: 155 additions & 6 deletions

File tree

‎.changeset/bos-promote.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"everything-dev": minor
3+
---
4+
5+
`bos promote <block-height>` — activate a recorded deployment without rebuilding (ADR 0042 ticket 05, absorbing the unified-rollback plan): the same SRI-verified, boot-checked, pointer-restoring republish as `bos rollback --version`, framed as promotion — no rolledBackFrom brand on the payload, an already-live target is an idempotent no-op rather than a refusal, and the version is required (promotion targets a record, not "previous"). The lockfile consumption rides the train's ticket-04 leg: the promoted composition is frozen into bos.lock.json atomically after confirm, and the deployment record named its digest all along. The Railway image leg stays in CI's deploy workflow (the CLI holds no Railway credential); the config/pointer/lockfile plane is complete from here.

‎packages/everything-dev/src/cli/commands/publish.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,45 @@ export const publishRenderers: Record<string, ResultRenderer> = {
217217
return;
218218
},
219219

220+
promote: async (result, _ctx) => {
221+
const promoteResult = result as any;
222+
if (promoteResult.status === "error") {
223+
console.log();
224+
console.log(colors.error(`${icons.err} Promote failed`));
225+
if (promoteResult.error) {
226+
console.log(` ${colors.dim("Error:")} ${promoteResult.error}`);
227+
}
228+
if (promoteResult.verification) {
229+
console.log();
230+
for (const check of promoteResult.verification) {
231+
const mark = check.ok ? colors.green(icons.ok) : colors.error(icons.err);
232+
const reason = check.reason ? colors.dim(` — ${check.reason}`) : "";
233+
console.log(` ${mark} ${check.remote}${reason}`);
234+
}
235+
}
236+
console.log();
237+
process.exit(1);
238+
}
239+
240+
if (promoteResult.status === "dry-run") {
241+
console.log();
242+
console.log(colors.cyan(`${icons.ok} Promote dry run complete`));
243+
console.log(` ${colors.dim("Registry URL:")} ${promoteResult.registryUrl}`);
244+
console.log();
245+
return;
246+
}
247+
248+
console.log();
249+
console.log(colors.green(`${icons.ok} Promoted — the recorded deployment is live again`));
250+
console.log(` ${colors.dim("Registry URL:")} ${promoteResult.registryUrl}`);
251+
if (promoteResult.txHash) {
252+
console.log(` ${colors.dim("Transaction:")} ${promoteResult.txHash}`);
253+
}
254+
console.log(` ${colors.dim("Lockfile:")} bos.lock.json now freezes the promoted composition`);
255+
console.log();
256+
return;
257+
},
258+
220259
deploy: async (result, _ctx) => {
221260
const deployResult = result as any;
222261
if (deployResult.status === "list") {

‎packages/everything-dev/src/contract.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,37 @@ export const RollbackResultSchema = z.object({
304304
verification: z.array(RollbackRemoteCheckSchema).optional(),
305305
});
306306

307+
export const PromoteOptionsSchema = z.object({
308+
version: z
309+
.string()
310+
.describe(
311+
"The deployment to activate — a block height (or timestamp prefix) from the publish history",
312+
),
313+
force: z
314+
.boolean()
315+
.default(false)
316+
.describe(
317+
"Allow pre-Phase-A snapshots without verifiable manifest pins (their bytes were overwritten in place)",
318+
),
319+
dryRun: z.boolean().default(false).describe("Preview the promotion without publishing"),
320+
verbose: z.boolean().default(false).describe("Show full output instead of clean summary"),
321+
env: z
322+
.enum(["production", "staging"])
323+
.default("production")
324+
.describe("Environment: production or staging"),
325+
network: z.enum(["mainnet", "testnet"]).optional().describe("NEAR network: mainnet or testnet"),
326+
privateKey: z.string().optional(),
327+
wallet: z.boolean().default(false),
328+
registry: z
329+
.string()
330+
.optional()
331+
.describe(
332+
"Override FastKV registry contract account (defaults: dev.everything.near / dev.allthethings.testnet)",
333+
),
334+
});
335+
336+
export const PromoteResultSchema = RollbackResultSchema;
337+
307338
export const PublishResultSchema = z.object({
308339
status: z.enum(["published", "error", "dry-run"]),
309340
registryUrl: z.string(),
@@ -1167,6 +1198,7 @@ export const commandOptionSchemas = {
11671198
publish: PublishOptionsSchema,
11681199
lock: LockOptionsSchema,
11691200
rollback: RollbackOptionsSchema,
1201+
promote: PromoteOptionsSchema,
11701202
deploy: DeployOptionsSchema,
11711203
smoke: SmokeOptionsSchema,
11721204
login: LoginOptionsSchema,
@@ -1231,6 +1263,10 @@ export const bosContract = oc.router({
12311263
.route({ method: "POST", path: "/rollback" })
12321264
.input(RollbackOptionsSchema)
12331265
.output(RollbackResultSchema),
1266+
promote: oc
1267+
.route({ method: "POST", path: "/promote" })
1268+
.input(PromoteOptionsSchema)
1269+
.output(PromoteResultSchema),
12341270
deploy: oc
12351271
.route({ method: "POST", path: "/deploy" })
12361272
.input(DeployOptionsSchema)
@@ -1330,6 +1366,8 @@ export type PublishResult = z.infer<typeof PublishResultSchema>;
13301366
export type LockOptions = z.infer<typeof LockOptionsSchema>;
13311367
export type LockResult = z.infer<typeof LockResultSchema>;
13321368
export type RollbackResult = z.infer<typeof RollbackResultSchema>;
1369+
export type PromoteOptions = z.infer<typeof PromoteOptionsSchema>;
1370+
export type PromoteResult = z.infer<typeof PromoteResultSchema>;
13331371
export type DeployOptions = z.infer<typeof DeployOptionsSchema>;
13341372
export type DeployResult = z.infer<typeof DeployResultSchema>;
13351373
export type DeployLegOutcome = z.infer<typeof DeployLegOutcomeSchema>;
@@ -1464,6 +1502,15 @@ export const cliCommandMeta = {
14641502
summary: "Republish an earlier config snapshot from the registry publish history",
14651503
interactive: true,
14661504
},
1505+
promote: {
1506+
commandPath: ["promote"],
1507+
summary:
1508+
"Activate a recorded deployment without rebuilding — verified republish, no rollback branding (the lockfile + pointer ride the train)",
1509+
interactive: false,
1510+
fields: {
1511+
version: { positional: true },
1512+
},
1513+
},
14671514
deploy: {
14681515
commandPath: ["deploy"],
14691516
summary:

‎packages/everything-dev/src/handlers/deploy.ts‎

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,41 @@ export function registerDeploy(builder: BosBuilder) {
117117
});
118118
}),
119119

120+
promote: builder.promote.effect(function* (_opts, input) {
121+
const session = yield* ResolutionSessionService;
122+
if (!session) {
123+
return {
124+
status: "error" as const,
125+
registryUrl: "",
126+
error: MISSING_CONFIG_MESSAGE,
127+
} satisfies RollbackResult;
128+
}
129+
130+
return yield* Effect.tryPromise({
131+
try: () =>
132+
rollbackBody(session, {
133+
registry: input.registry,
134+
limit: 20,
135+
listOnly: false,
136+
version: input.version,
137+
previous: false,
138+
force: input.force,
139+
env: input.env,
140+
dryRun: input.dryRun,
141+
verbose: input.verbose,
142+
network: input.network,
143+
privateKey: input.privateKey,
144+
wallet: input.wallet,
145+
promote: true,
146+
}),
147+
catch: (error) => ({
148+
status: "error" as const,
149+
registryUrl: "",
150+
error: error instanceof Error ? error.message : String(error),
151+
}),
152+
});
153+
}),
154+
120155
deploy: builder.deploy.effect(function* (_opts, input) {
121156
const session = yield* ResolutionSessionService;
122157
const train = yield* DeployTrainService;
@@ -170,6 +205,11 @@ async function rollbackBody(
170205
network?: "mainnet" | "testnet";
171206
privateKey?: string;
172207
wallet: boolean;
208+
/** Promote framing (ADR 0042 ticket 05): activate a recorded deployment
209+
* without the rolledBackFrom brand; requires an explicit version, and an
210+
* already-live target is a legitimate idempotent no-op rather than a
211+
* refusal. */
212+
promote?: boolean;
173213
},
174214
): Promise<RollbackResult> {
175215
const { account, domain } = session.config ?? {};
@@ -237,14 +277,19 @@ async function rollbackBody(
237277
status: "error",
238278
registryUrl: "",
239279
history: historyEntries,
240-
error: input.version
241-
? `No history entry matches "${input.version}" — pick a block height from the listing`
242-
: "Specify --version <block-height> or --previous (interactive selection happens CLI-side)",
280+
error: input.promote
281+
? "bos promote requires --version <block-height> — the deployment to activate"
282+
: input.version
283+
? `No history entry matches "${input.version}" — pick a block height from the listing`
284+
: "Specify --version <block-height> or --previous (interactive selection happens CLI-side)",
243285
};
244286
}
245287

246288
const liveValue = await fetchLiveConfig({ account, domain, registry: input.registry });
247-
if (liveValue !== undefined && isSnapshotLive(target.value, liveValue)) {
289+
// Promote skips the identical-live refusal: activating the already-live
290+
// deployment is a legitimate idempotent no-op (the train's
291+
// already-published path handles it gracefully).
292+
if (!input.promote && liveValue !== undefined && isSnapshotLive(target.value, liveValue)) {
248293
return {
249294
status: "error",
250295
registryUrl: "",
@@ -294,6 +339,7 @@ async function rollbackBody(
294339
},
295340
{
296341
skipVerify: true,
342+
...(input.promote ? { promote: true } : {}),
297343
restorePlatformPointer: await resolvePointerRestore(account, domain, input.registry),
298344
},
299345
);

‎packages/everything-dev/src/services/deploy/rollback-flow.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,14 @@ export function isSnapshotLive(targetValue: unknown, liveValue: unknown): boolea
8383
export async function publishRollbackSnapshot(
8484
targetValue: unknown,
8585
input: RollbackSnapshotInput,
86-
opts?: { skipVerify?: boolean; restorePlatformPointer?: { key: string; value: string } },
86+
opts?: {
87+
skipVerify?: boolean;
88+
restorePlatformPointer?: { key: string; value: string };
89+
/** Promote framing (ADR 0042 ticket 05): activate a recorded deployment
90+
* without the rollback branding — the payload republishes verbatim with
91+
* NO rolledBackFrom stamp (a promotion is not a rollback). */
92+
promote?: boolean;
93+
},
8794
): Promise<RollbackSnapshotOutcome> {
8895
if (!opts?.skipVerify) {
8996
const verification = await verifyRollbackSnapshot(targetValue as BosConfigInput);
@@ -121,7 +128,12 @@ export async function publishRollbackSnapshot(
121128
// crash (host-less child snapshots have no app.host of their own) and
122129
// lossy (Zod's strip mode dropped keys the schema didn't know); a
123130
// rollback's whole value proposition is byte-faithful republication.
124-
const payload = buildRollbackPayload(targetValue as BosConfigInput, new Date().toISOString());
131+
// Promote skips the rolledBackFrom brand: activating a recorded deployment
132+
// is not a rollback, and the lockfile leg composes from the payload either
133+
// way (ADR 0042 ticket 04).
134+
const payload = opts?.promote
135+
? (targetValue as BosConfigInput)
136+
: buildRollbackPayload(targetValue as BosConfigInput, new Date().toISOString());
125137
const result = await publishToFastKv({
126138
// The declared input type is the parsed BosConfig; the runtime payload is
127139
// the raw snapshot — the same declared-vs-runtime split the train's

0 commit comments

Comments
 (0)