Skip to content

Commit d4bdcde

Browse files
MajorTalclaude
andcommitted
feat(release-promote): SDK r.project(id).apply.promote + run402 deploy promote CLI
Client-side wiring for the v1.58 gateway endpoint POST /apply/v1/releases/:id/promote (run402-private commit b25ae87b, deploy fix a47961ac). Operator pointer-swap recovery primitive — re-point internal.projects.live_release_id at an existing release without re-running the apply pipeline. SDK: - deploy.types.ts: PromoteOptions, PromoteResult, PromoteDiff exported. Mirror ApplyOptions for the parts that apply (allowWarnings, allowWarningCodes); skip onEvent / idempotencyKey / maxRetries (promote is single-shot). - deploy.ts: Deploy.promote(project, releaseId, opts) method. POSTs to /apply/v1/releases/:id/promote with body { project, allow_warning_codes }. Local validation for releaseId shape (must start with rel_) + project id non-empty. allowWarnings: true expands to the known blocking-warning list (currently [MIGRATIONS_NOT_REVERSIBLE]) — no server-side wildcard accept. - scoped.ts: ScopedApplyHero.promote attached to the hero. Direct-SDK consumers call r.project(id).apply.promote(releaseId, opts). CLI: - cli/lib/deploy-v2.mjs: promoteCmd + parsePromoteArgs + PROMOTE_HELP. Positional <release-id> (must be rel_*), --project (falls back to active project), --allow-warning (repeatable), --allow-warnings (ack all blocking), --quiet. Calls _applyEngine.promote directly (same pattern as the rest of the CLI — getSdk() returns the unwrapped Run402 instance whose project() is async, so the hero path would need an extra await for marginal clarity). - cli/lib/deploy.mjs: 'promote' added to the deploy-subcommand allowlist. - cli/llms-cli.txt: Recovery-from-destructive-apply paragraph with worked example + the full structured-warning + error-code surface. Promote listed in the CLI deploy-surface bullet list. sync.test.ts: SURFACE entry { id: deploy_promote, endpoint: POST /apply/v1/releases/:id/promote, cli: deploy:promote, openclaw: deploy:promote }; SDK_BY_CAPABILITY mapping to _applyEngine.promote. End-to-end verified against the deployed gateway: PROMOTE_TARGET_NOT_FOUND returns the right code + structured envelope + trace_id when a nonexistent release is passed. 667 SDK + 32 sync + 244 astro tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9e9db38 commit d4bdcde

7 files changed

Lines changed: 331 additions & 0 deletions

File tree

cli/lib/deploy-v2.mjs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ exit 0; inspect would_serve and diagnostic_status in the result payload.
267267

268268
export async function runDeployV2(sub, args) {
269269
if (sub === "apply") return await applyCmd(args);
270+
if (sub === "promote") return await promoteCmd(args);
270271
if (sub === "resume") return await resumeCmd(args);
271272
if (sub === "list") return await listCmd(args);
272273
if (sub === "events") return await eventsCmd(args);
@@ -280,6 +281,189 @@ export async function runDeployV2(sub, args) {
280281
});
281282
}
282283

284+
const PROMOTE_HELP = `run402 deploy promote — Operator pointer-swap recovery (v1.58+)
285+
286+
Usage:
287+
run402 deploy promote <release-id> [--project <id>] [--allow-warning <code>] [--allow-warnings] [--quiet]
288+
289+
Re-points the project's live release at an existing release row without
290+
re-running the apply pipeline. Designed for "oops on a real project ID"
291+
recovery — when an apply shipped content the operator regrets, promote
292+
back to the prior release in seconds instead of re-deploying.
293+
294+
Promotable statuses: ready, active, superseded. Releases with status
295+
'failed' or 'staging' are rejected (they never fully landed).
296+
297+
Surfaces structured warnings:
298+
299+
MIGRATIONS_NOT_REVERSIBLE (requires_confirmation: true)
300+
The target release predates migrations applied since. Those
301+
migrations remain applied — the post-promote release runs against
302+
the current schema. Ack with --allow-warning MIGRATIONS_NOT_REVERSIBLE.
303+
304+
FUNCTION_VERSION_MISMATCH (informational, no ack needed)
305+
Overlapping function names have different code_hashes. The Lambda
306+
code is whatever's currently $LATEST.
307+
308+
Worked example: recover from a destructive apply
309+
310+
# rel_old (good) → rel_new (bad, destructive) → promote back
311+
run402 deploy promote rel_old_abc123 --project prj_xyz \\
312+
--allow-warning MIGRATIONS_NOT_REVERSIBLE
313+
314+
Options:
315+
<release-id> Required positional. The release to promote to.
316+
Format: rel_*
317+
--project <id> Project id. Falls back to active project, then
318+
RUN402_PROJECT_ID env var.
319+
--allow-warning <code> Acknowledge a specific blocking warning
320+
(repeatable).
321+
--allow-warnings Acknowledge ALL blocking promote warnings.
322+
Use this for full recovery mode when you've
323+
already inspected the diff.
324+
--quiet | --final-only Suppress per-event stderr; only print the
325+
final JSON envelope on stdout.
326+
327+
Output:
328+
stdout: {
329+
"status": "ok",
330+
"release_id": "rel_old_abc123",
331+
"operation_id": "op_...",
332+
"previous_release_id": "rel_new_xxx",
333+
"diff": { "functions": {...}, "migrations": {...}, "site_paths": {...} },
334+
"warnings": [...]
335+
}
336+
337+
Errors map to structured envelopes with codes:
338+
PROMOTE_TARGET_NOT_FOUND 404 — release id doesn't exist
339+
PROMOTE_PROJECT_MISMATCH 400 — release belongs to another project
340+
PROMOTE_RELEASE_NOT_READY 409 — release status not promotable
341+
PROMOTE_NO_OP 409 — target = current live (use
342+
cache.invalidateAll instead)
343+
PROMOTE_WARNING_REQUIRES_ACK 409 — at least one blocking warning
344+
unacked; details list codes`;
345+
346+
function parsePromoteArgs(args) {
347+
const opts = {
348+
releaseId: null,
349+
project: null,
350+
allowWarnings: false,
351+
allowWarningCodes: [],
352+
quiet: false,
353+
};
354+
const allowedFlags = [
355+
"--project",
356+
"--allow-warning",
357+
"--allow-warnings",
358+
"--quiet",
359+
"--final-only",
360+
"--help",
361+
"-h",
362+
];
363+
364+
for (let i = 0; i < args.length; i++) {
365+
const arg = args[i];
366+
if (arg === "--help" || arg === "-h") {
367+
console.log(PROMOTE_HELP);
368+
process.exit(0);
369+
}
370+
if (arg === "--project" || arg === "--allow-warning") {
371+
const value = args[i + 1];
372+
if (value === undefined || (typeof value === "string" && value.startsWith("--"))) {
373+
fail({
374+
code: "BAD_USAGE",
375+
message: `${arg} requires a value`,
376+
details: { flag: arg },
377+
});
378+
}
379+
if (arg === "--project") {
380+
opts.project = value;
381+
} else {
382+
opts.allowWarningCodes.push(value);
383+
}
384+
i += 1;
385+
continue;
386+
}
387+
if (arg === "--quiet" || arg === "--final-only") {
388+
opts.quiet = true;
389+
continue;
390+
}
391+
if (arg === "--allow-warnings") {
392+
opts.allowWarnings = true;
393+
continue;
394+
}
395+
if (typeof arg === "string" && arg.startsWith("-")) {
396+
fail({
397+
code: "BAD_USAGE",
398+
message: `Unknown flag for deploy promote: ${arg}`,
399+
details: { flag: arg, allowed_flags: allowedFlags },
400+
});
401+
}
402+
// Positional: the release id
403+
if (opts.releaseId !== null) {
404+
fail({
405+
code: "BAD_USAGE",
406+
message: `Unexpected positional argument for deploy promote: ${arg}`,
407+
details: { argument: arg, already_have: opts.releaseId },
408+
});
409+
}
410+
opts.releaseId = arg;
411+
}
412+
413+
if (opts.releaseId === null) {
414+
fail({
415+
code: "BAD_USAGE",
416+
message: "deploy promote requires a release id (positional argument)",
417+
details: { example: "run402 deploy promote rel_abc123" },
418+
});
419+
}
420+
if (typeof opts.releaseId !== "string" || !opts.releaseId.startsWith("rel_")) {
421+
fail({
422+
code: "BAD_USAGE",
423+
message: `Invalid release id: '${opts.releaseId}' (expected rel_*)`,
424+
details: { release_id: opts.releaseId },
425+
});
426+
}
427+
428+
return opts;
429+
}
430+
431+
async function promoteCmd(args) {
432+
const opts = parsePromoteArgs(args);
433+
const projectId = opts.project ?? resolveProjectId(null);
434+
435+
// Preserve the aggressive early-exit when no allowance is configured
436+
// — same as apply.
437+
allowanceAuthHeaders("/apply/v1/releases");
438+
439+
try {
440+
// Call the engine directly (matches the pattern used by apply / resume
441+
// in this file). The `r.project(id).apply.promote` hero exists for
442+
// direct-SDK consumers; the CLI's `getSdk()` returns the unwrapped
443+
// Run402 instance whose `project()` method is async, so going through
444+
// the hero here would require an extra `await`.
445+
const result = await getSdk()._applyEngine.promote(projectId, opts.releaseId, {
446+
allowWarnings: opts.allowWarnings,
447+
allowWarningCodes: opts.allowWarningCodes,
448+
});
449+
if (!opts.quiet) {
450+
// Emit a single structured stderr event so observers can pick it up
451+
// alongside the regular deploy event stream. Promote is a one-shot
452+
// operation; there are no intermediate phase events.
453+
console.error(JSON.stringify({
454+
type: "promote.committed",
455+
release_id: result.release_id,
456+
previous_release_id: result.previous_release_id,
457+
operation_id: result.operation_id,
458+
warnings: result.warnings,
459+
}));
460+
}
461+
console.log(JSON.stringify(result, null, 2));
462+
} catch (err) {
463+
reportSdkError(err);
464+
}
465+
}
466+
283467
async function readStdin() {
284468
const chunks = [];
285469
for await (const chunk of process.stdin) chunks.push(chunk);

cli/lib/deploy.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export async function run(args) {
4949

5050
switch (sub) {
5151
case "apply":
52+
case "promote":
5253
case "resume":
5354
case "list":
5455
case "events":

cli/llms-cli.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,16 @@ run402 deploy resume <operation_id>
322322

323323
The gateway re-runs only the failed phase forward — SQL is never replayed.
324324

325+
Recovery from a destructive apply (v1.58+): when an `apply` shipped content the operator regrets (accidental destructive prune, content overwrite, broken release), `run402 deploy promote <release-id>` re-points the project's live release at a prior release row WITHOUT re-running the apply pipeline. No bytes-upload, no bundling, no migration — just a pointer swap on `internal.projects.live_release_id` plus an ssr_cache flush. Designed for "oops on a real project ID" recovery in seconds rather than re-deploying.
326+
327+
```bash
328+
# rel_old (good) → rel_new (bad, destructive) → promote back
329+
run402 deploy promote rel_old_abc123 --project prj_xyz \
330+
--allow-warning MIGRATIONS_NOT_REVERSIBLE
331+
```
332+
333+
Surfaces structured warnings. `MIGRATIONS_NOT_REVERSIBLE` (requires_confirmation: true) fires when the target release predates migrations applied since — the migrations remain applied, so the post-promote release runs against the current schema. Ack with `--allow-warning MIGRATIONS_NOT_REVERSIBLE`. `FUNCTION_VERSION_MISMATCH` (informational) fires when overlapping function names have different code_hashes; the Lambda code is whatever's currently `$LATEST`. Rejected codes: `PROMOTE_TARGET_NOT_FOUND` (release id doesn't exist), `PROMOTE_PROJECT_MISMATCH` (release belongs to another project), `PROMOTE_RELEASE_NOT_READY` (release status not promotable: must be `ready`, `active`, or `superseded`), `PROMOTE_NO_OP` (target IS already the current live; use `cache.invalidateAll` to refresh cache instead), `PROMOTE_WARNING_REQUIRES_ACK` (at least one blocking warning unacked).
334+
325335
Inspect deploy history: list recent operations for a project, or pull the recorded phase-event stream for a specific operation (after the fact — for live progress events during an in-flight deploy, the `apply` command already streams them on stderr):
326336

327337
```bash
@@ -817,6 +827,7 @@ All admin subcommands require a platform-admin allowance wallet (or an admin OAu
817827
### deploy
818828
- `run402 deploy apply --manifest app.json [--project <id>] [--quiet|--final-only] [--allow-warning <code> ...] [--allow-warnings]` — unified apply primitive (v1.34 unified deploy → v2.0 unified apply with `assets` slice support)
819829
- `run402 deploy resume <operation_id> [--quiet]` — re-run a stuck operation forward
830+
- `run402 deploy promote <release-id> [--project <id>] [--allow-warning <code>] [--allow-warnings]` — operator pointer-swap (re-point live release without re-running the apply pipeline); v1.58+
820831
- `run402 deploy list [--project <id>] [--limit <n>]` — list recent deploy operations
821832
- `run402 deploy events <operation_id> [--project <id>]` — fetch the recorded event stream for an operation
822833
- `run402 deploy release get <release_id> [--project <id>] [--site-limit <n>]` — fetch release inventory

sdk/src/namespaces/deploy.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ import type {
7171
OperationStatus,
7272
PlanRequest,
7373
PlanResponse,
74+
PromoteOptions,
75+
PromoteResult,
7476
ReleaseDiffOptions,
7577
ReleaseInventory,
7678
ReleaseInventoryByIdOptions,
@@ -297,6 +299,81 @@ export class Deploy {
297299
return await pollSnapshotUntilReady(this.client, snapshot, {}, [], emit, opts.project);
298300
}
299301

302+
/**
303+
* Promote an existing release to be the project's current live release —
304+
* a pointer swap on `internal.projects.live_release_id` without re-running
305+
* the apply pipeline. Designed for operator recovery from a destructive
306+
* apply ("oops on a real project ID"). The prior release's bytes,
307+
* functions, and migrations remain persisted; this just routes traffic
308+
* back to them.
309+
*
310+
* Surfaces structured warnings via the result envelope:
311+
*
312+
* - `MIGRATIONS_NOT_REVERSIBLE` (requires_confirmation: true) when the
313+
* target release predates migrations applied since. The migrations
314+
* remain applied; the new live release runs against the current
315+
* schema. Ack via `opts.allowWarningCodes`.
316+
*
317+
* - `FUNCTION_VERSION_MISMATCH` (informational) when overlapping
318+
* function names have different code_hashes. The Lambda code is
319+
* whatever's currently $LATEST.
320+
*
321+
* Rejected cases:
322+
* - `PROMOTE_TARGET_NOT_FOUND` — releaseId doesn't exist
323+
* - `PROMOTE_PROJECT_MISMATCH` — releaseId belongs to a different project
324+
* - `PROMOTE_RELEASE_NOT_READY` — release status isn't promotable
325+
* - `PROMOTE_NO_OP` — releaseId IS already the project's current live
326+
* - `PROMOTE_WARNING_REQUIRES_ACK` — at least one blocking warning unacked
327+
*
328+
* Capability: unified-deploy (v1.58+, release-promote).
329+
*/
330+
async promote(
331+
project: string,
332+
releaseId: string,
333+
opts: PromoteOptions = {},
334+
): Promise<PromoteResult> {
335+
if (!project || typeof project !== "string") {
336+
throw new Run402DeployError(`Invalid project id: "${String(project)}"`, {
337+
code: "BAD_REQUEST",
338+
retryable: false,
339+
context: "promoting release",
340+
});
341+
}
342+
if (!releaseId || !releaseId.startsWith("rel_")) {
343+
throw new Run402DeployError(`Invalid release id: "${releaseId}"`, {
344+
code: "BAD_REQUEST",
345+
retryable: false,
346+
context: "promoting release",
347+
});
348+
}
349+
// Note: `allowWarnings: true` is implemented client-side by enumerating
350+
// every known blocking warning code, since the gateway expects a precise
351+
// list per warning code (no wildcard accept). v1.58 has exactly one
352+
// blocking promote warning (MIGRATIONS_NOT_REVERSIBLE); if more land,
353+
// expand this list.
354+
const ALL_BLOCKING_PROMOTE_WARNINGS = ["MIGRATIONS_NOT_REVERSIBLE"];
355+
const allowCodes =
356+
opts.allowWarnings === true
357+
? ALL_BLOCKING_PROMOTE_WARNINGS
358+
: (opts.allowWarningCodes ?? []);
359+
try {
360+
return await this.client.request<PromoteResult>(
361+
`/apply/v1/releases/${encodeURIComponent(releaseId)}/promote`,
362+
{
363+
method: "POST",
364+
context: "promoting release",
365+
headers: { "content-type": "application/json" },
366+
body: {
367+
project,
368+
allow_warning_codes: allowCodes,
369+
},
370+
},
371+
);
372+
} catch (err) {
373+
throw translateDeployError(err, "promote", null, releaseId);
374+
}
375+
}
376+
300377
/**
301378
* Snapshot a deploy operation. The endpoint requires `apikey` auth, so
302379
* pass the project that owns the operation. (When omitted, the request

sdk/src/namespaces/deploy.types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2368,6 +2368,49 @@ export interface StartOptions {
23682368
allowWarningCodes?: string[];
23692369
}
23702370

2371+
/**
2372+
* Options for the `r.project(id).apply.promote(releaseId, opts?)` operator
2373+
* pointer-swap operation. Mirrors `ApplyOptions` for the parts that apply
2374+
* (`allowWarnings`, `allowWarningCodes`); skips the parts that don't
2375+
* (`onEvent` — promote is a single-shot operation, no per-phase events;
2376+
* `idempotencyKey` — gateway derives idempotency from `(project, release_id)`;
2377+
* `maxRetries` — no plan-time race window).
2378+
*/
2379+
export interface PromoteOptions {
2380+
/** Continue past confirmation-required warnings (e.g. MIGRATIONS_NOT_REVERSIBLE).
2381+
* Default false: the gateway aborts before the pointer swap when a
2382+
* blocking warning isn't covered. */
2383+
allowWarnings?: boolean;
2384+
/** Cover specific confirmation-required warning codes. Every blocking
2385+
* warning must be covered by this list or by `allowWarnings`. */
2386+
allowWarningCodes?: string[];
2387+
}
2388+
2389+
/**
2390+
* Result envelope returned by `r.project(id).apply.promote(releaseId, opts?)`.
2391+
* The promote operation is a single-shot pointer swap; no phase events,
2392+
* no payment-required hook (promote uses existing-release content).
2393+
*/
2394+
export interface PromoteResult {
2395+
status: "ok";
2396+
/** The release id now live on the project. Equal to the input releaseId. */
2397+
release_id: string;
2398+
/** The new `internal.apply_operations` row id (created with kind='promote'). */
2399+
operation_id: string;
2400+
/** The release that was live BEFORE the swap. */
2401+
previous_release_id: string;
2402+
/** Structured diff between previous and new live release. */
2403+
diff: PromoteDiff;
2404+
/** Any structured warnings produced — including ones the caller acked. */
2405+
warnings: WarningEntry[];
2406+
}
2407+
2408+
export interface PromoteDiff {
2409+
functions: { only_in_current: string[]; only_in_target: string[]; changed: string[] };
2410+
migrations: { only_in_current: string[]; only_in_target: string[] };
2411+
site_paths: { added_in_current: number; removed_in_current: number };
2412+
}
2413+
23712414
export interface DeployOperation {
23722415
/** The operation id (also exposed via the snapshot). */
23732416
readonly id: string;

0 commit comments

Comments
 (0)