P3.5: release promote / rollback over the persisted store

promote points an (app, channel) at a release id — cross-channel
promotion allowed, missing channel created, manual policy gate stubbed.
rollback moves the pointer to the previous PUBLISHED release in the
channel's publish-order lineage (cross-promoted pointer falls back to the
channel's own latest; at the earliest release it refuses with
rollback.no_previous). Both append a cli-actor audit event and re-persist
db.json; failures follow the P3.4b contract (dotted-code JSON error,
exit 1, store untouched). Acceptance pinned in tests/release_ops.sx;
cli_dispatch reworked off the removed stubs.
This commit is contained in:
agra
2026-06-12 00:27:20 +03:00
parent 3c9a15ec80
commit 93372ea4f0
5 changed files with 580 additions and 67 deletions

View File

@@ -4,9 +4,9 @@
// Wires the real process argv (via `std.cli`'s `os_args`) to subcommand
// handlers through `cli.parse`.
//
// dist ci publish REAL local publish pipeline (P3.4a) — see publish.sx
// dist release promote STUB (real logic lands in P3.5)
// dist release rollback STUB (real logic lands in P3.5)
// dist ci publish local publish pipeline (P3.4a/b) — see publish.sx
// dist release promote point a channel at a release (P3.5) — see release/ops.sx
// dist release rollback channel pointer to the previous release (P3.5)
//
// EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with
// `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing
@@ -28,6 +28,7 @@
#import "modules/std/cli.sx";
jout :: #import "json_out.sx";
pl :: #import "publish/publish.sx";
ops :: #import "release/ops.sx";
// Direct stderr writer (fd 2), so human help/usage/progress never lands on
// stdout's data stream. `out` (std builtin) targets stdout (fd 1).
@@ -42,7 +43,7 @@ emit_human :: (s: string, json_mode: bool) {
if json_mode { eputs(s); } else { out(s); }
}
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
// True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool {
@@ -123,35 +124,60 @@ handle_ci_publish :: (p: *Parsed, json_mode: bool) {
}
}
// ── Stub handlers (real logic lands in P3.5) ─────────────────────────
// Honest stubs: acknowledge the command and emit a known result.
// `dist release promote` — point an (app, channel) at a release over the
// persisted store (P3.5). Same rendering contract as publish: JSON object
// on stdout under --json (human note on stderr), readable summary
// otherwise, `report_failure` on abort.
handle_release_promote :: (p: *Parsed, json_mode: bool) {
ack("release promote", json_mode);
fail : jout.CliFailure = .{};
o, e := ops.run_promote(p.value_of("local-store"), p.value_of("app"),
p.value_of("channel"), p.value_of("release"), @fail);
if e {
report_failure("release promote", fail, json_mode);
}
if !e {
if !json_mode {
out(ops.promote_human(@o));
return;
}
eputs("dist: release promote ok\n");
raw : [4096]u8 = ---;
werr := false;
n := ops.write_promote_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
if werr {
eputs("dist: internal error: JSON serialization failed\n");
exit_command_failed();
}
out(string.{ ptr = @raw[0], len = n });
out("\n");
}
}
// `dist release rollback` — move an (app, channel) back to its previous
// published release (P3.5). Rendering contract as above.
handle_release_rollback :: (p: *Parsed, json_mode: bool) {
ack("release rollback", json_mode);
}
// Emit a stub command's acknowledgement. In json mode: a single JSON
// object on stdout (and a human progress note on stderr). Otherwise: a
// human acknowledgement on stdout.
ack :: (cmd: string, json_mode: bool) {
if !json_mode {
out(concat(concat("dist: ", cmd), " (stub) ok\n"));
return;
fail : jout.CliFailure = .{};
o, e := ops.run_rollback(p.value_of("local-store"), p.value_of("app"),
p.value_of("channel"), @fail);
if e {
report_failure("release rollback", fail, json_mode);
}
eputs(concat(concat("dist: ", cmd), " (stub) acknowledged\n"));
raw : [4096]u8 = ---;
werr := false;
n := jout.write_stub(cmd, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
if werr {
eputs("dist: internal error: JSON serialization failed\n");
exit_usage();
if !e {
if !json_mode {
out(ops.rollback_human(@o));
return;
}
eputs("dist: release rollback ok\n");
raw : [4096]u8 = ---;
werr := false;
n := ops.write_rollback_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
if werr {
eputs("dist: internal error: JSON serialization failed\n");
exit_command_failed();
}
out(string.{ ptr = @raw[0], len = n });
out("\n");
}
out(string.{ ptr = @raw[0], len = n });
out("\n");
}
// Route a parsed (group, command) to its handler. `parse` only returns a
@@ -187,19 +213,28 @@ main :: () -> ! {
}
// Command table + flag specs live in this scope; `Parsed` holds VIEWS
// into them, used before `main` returns. `ci publish` requires
// `--manifest` + `--local-store`; the global `--json` is recognized by
// the parser without being declared here. promote/rollback flags arrive
// with their real handlers in P3.5.
no_flags : []FlagSpec = .[];
// into them, used before `main` returns. Every value flag below is
// required; the global `--json` is recognized by the parser without
// being declared here.
publish_flags : []FlagSpec = .[
FlagSpec.{ name = "manifest", takes_value = true, required = true },
FlagSpec.{ name = "local-store", takes_value = true, required = true },
];
promote_flags : []FlagSpec = .[
FlagSpec.{ name = "app", takes_value = true, required = true },
FlagSpec.{ name = "channel", takes_value = true, required = true },
FlagSpec.{ name = "release", takes_value = true, required = true },
FlagSpec.{ name = "local-store", takes_value = true, required = true },
];
rollback_flags : []FlagSpec = .[
FlagSpec.{ name = "app", takes_value = true, required = true },
FlagSpec.{ name = "channel", takes_value = true, required = true },
FlagSpec.{ name = "local-store", takes_value = true, required = true },
];
cmds : []Command = .[
Command.{ group = "ci", command = "publish", flags = publish_flags },
Command.{ group = "release", command = "promote", flags = no_flags },
Command.{ group = "release", command = "rollback", flags = no_flags },
Command.{ group = "release", command = "promote", flags = promote_flags },
Command.{ group = "release", command = "rollback", flags = rollback_flags },
];
diag : Diag = .{};

View File

@@ -35,17 +35,3 @@ write_error :: (f: CliFailure, dst: []u8) -> (s64, !JsonError) {
n := try write_to_buffer(root, dst);
return n;
}
// Serialize a stub command's machine result — `{ "command": <cmd>,
// "status": "ok", "stub": true }` — into the caller-owned `dst`, returning
// the number of bytes written. Overflow surfaces on the error channel.
write_stub :: (cmd: string, dst: []u8) -> (s64, !JsonError) {
gpa := GPA.init();
obj : Object = .{};
obj.put("command", .str(cmd), xx gpa);
obj.put("status", .str("ok"), xx gpa);
obj.put("stub", .bool_(true), xx gpa);
root : Value = .object(obj);
n := try write_to_buffer(root, dst);
return n;
}

298
src/release/ops.sx Normal file
View File

@@ -0,0 +1,298 @@
// =====================================================================
// ops.sx — standalone channel operations over the persisted store
// (subplan 03 / P3.5): `dist release promote` and `dist release rollback`.
//
// Both load `<store>/db.json`, mutate ONE channel pointer, append an audit
// event, and re-persist. They are the human counterpart to the CI publish:
// CI writes releases; a release manager moves channel pointers.
//
// PROMOTE points an (app, channel) at a given release id. The release must
// exist and belong to the app; it does NOT have to target that channel —
// promotion across channels (a beta release promoted onto stable) is the
// point of the command. A missing channel is created. The policy gate is
// the v0 stub: `.manual` always allows.
//
// ROLLBACK moves the channel pointer to the PREVIOUS valid release for
// that channel. "Previous" is publish order: among the app's PUBLISHED
// releases targeting this channel (`release.channel == name`,
// `published_at > 0` — rollback never selects an unpublished release), the
// one immediately before the current pointer. When the current pointer is
// not in that lineage (it was cross-promoted from another channel),
// rollback returns to the channel's own latest release. At the earliest
// release — or with no lineage at all — there is nothing to roll back to.
//
// FAILURE CONTRACT (mirrors P3.4b): every abort happens before `db.save`,
// so a failed operation never changes db.json. Each raise site first
// writes a `jout.CliFailure` (stable dotted code + human message).
// =====================================================================
#import "modules/std.sx";
#import "modules/std/json.sx";
#import "modules/std/fs.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
#import "../domain/artifact.sx";
#import "../domain/channel.sx";
#import "../domain/audit.sx";
#import "../domain/validate.sx";
#import "../repo/repo.sx";
db :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
pl :: #import "../publish/publish.sx";
// Failure classes for a channel operation. The precise reason travels in
// the caller's `jout.CliFailure` (see the failure contract above).
// Load — db.json absent or unreadable (no publishable state).
// NotFound — the named app / release / channel does not exist.
// Invalid — the aggregate is inconsistent (release of another app,
// channel that fails domain validation, nothing to roll
// back to).
// Persist — db.json could not be re-written.
OpError :: error {
Load,
NotFound,
Invalid,
Persist,
}
PromoteOutcome :: struct {
app_id: string;
channel: string;
release_id: string;
version: string;
previous_release_id: string; // "" when the channel was just created
}
RollbackOutcome :: struct {
app_id: string;
channel: string;
from_release_id: string;
to_release_id: string;
to_version: string;
}
// ── shared steps ──────────────────────────────────────────────────────
// Load the persisted model, or fail with `store.load` when the store has
// no db.json (nothing was ever published there).
op_load_repo :: (store_dir: string, fail_out: *jout.CliFailure) -> (Repo, !OpError) {
if !exists(path_join(store_dir, "db.json")) {
fail_out.code = "store.load";
fail_out.message = concat("no db.json under the store (nothing published yet): ", store_dir);
raise error.Load;
}
loaded, le := db.load(store_dir);
if le {
fail_out.code = "store.load";
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
raise error.Load;
}
return loaded;
}
op_find_app :: (repo: *Repo, slug: string, code: string, fail_out: *jout.CliFailure) -> (App, !OpError) {
a := repo.find_app_by_slug(slug);
if a == null {
fail_out.code = code;
fail_out.message = concat("no app with that slug in the store: ", slug);
raise error.NotFound;
}
return a!;
}
op_save :: (repo: *Repo, store_dir: string, fail_out: *jout.CliFailure) -> !OpError {
werr := false;
db.save(repo, store_dir) catch { werr = true; };
if werr {
fail_out.code = "persist.save";
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
raise error.Persist;
}
return;
}
// ── promote ───────────────────────────────────────────────────────────
run_promote :: (store_dir: string, app_slug: string, channel_name: string, release_id: string, fail_out: *jout.CliFailure) -> (PromoteOutcome, !OpError) {
repo := try op_load_repo(store_dir, fail_out);
app := try op_find_app(@repo, app_slug, "promote.unknown_app", fail_out);
relq := repo.get_release(release_id);
if relq == null {
fail_out.code = "promote.unknown_release";
fail_out.message = concat("no release with that id in the store: ", release_id);
raise error.NotFound;
}
rel := relq!;
if rel.app_id != app.id {
fail_out.code = "promote.wrong_app";
fail_out.message = concat("release belongs to a different app: ", release_id);
raise error.Invalid;
}
// Policy gate (v0 stub): `.manual` always allows. A real gate (stable
// requires passed validations, percentage rollouts) lands with policies.
prev := "";
chan : Channel = .{};
cq := repo.get_channel(app.id, channel_name);
if cq == null {
chan = Channel.{
app_id = app.id, name = channel_name, current_release_id = release_id,
policy = .manual, rollout_percent = 100,
};
} else {
chan = cq!;
prev = chan.current_release_id;
chan.current_release_id = release_id;
}
cverr := false;
validate_channel(chan) catch { cverr = true; };
if cverr {
fail_out.code = "promote.bad_channel";
fail_out.message = concat("channel fails domain validation: ", channel_name);
raise error.Invalid;
}
if cq == null { repo.create_channel(chan); }
else { repo.update_channel(chan); }
now := pl.now_secs();
repo.create_audit_event(AuditEvent.{
id = concat(concat(concat(concat("evt-cli-promote-", app.id), "-"), channel_name), concat("-", release_id)),
actor = "cli", action = "channel.promote", target_type = "channel",
target_id = channel_name, metadata = release_id, created_at = now,
});
try op_save(@repo, store_dir, fail_out);
return PromoteOutcome.{
app_id = app.id, channel = channel_name,
release_id = release_id, version = rel.version,
previous_release_id = prev,
};
}
// ── rollback ──────────────────────────────────────────────────────────
run_rollback :: (store_dir: string, app_slug: string, channel_name: string, fail_out: *jout.CliFailure) -> (RollbackOutcome, !OpError) {
repo := try op_load_repo(store_dir, fail_out);
app := try op_find_app(@repo, app_slug, "rollback.unknown_app", fail_out);
cq := repo.get_channel(app.id, channel_name);
if cq == null {
fail_out.code = "rollback.unknown_channel";
fail_out.message = concat("the app has no channel with that name: ", channel_name);
raise error.NotFound;
}
chan := cq!;
from := chan.current_release_id;
// The channel's lineage: the app's PUBLISHED releases targeting this
// channel, in publish (insertion) order. The target is the entry just
// before the current pointer — or the latest entry when the pointer
// was cross-promoted from another channel's lineage.
target_id := "";
target_version := "";
prev_id := "";
prev_version := "";
found_current := false;
i := 0;
while i < repo.releases.len {
r := repo.releases.items[i];
if r.app_id == app.id and r.channel == channel_name and r.published_at > 0 {
if r.id == from {
found_current = true;
target_id = prev_id;
target_version = prev_version;
break;
}
prev_id = r.id;
prev_version = r.version;
}
i += 1;
}
if !found_current {
target_id = prev_id; // latest of the channel's own lineage
target_version = prev_version;
}
if target_id.len == 0 {
fail_out.code = "rollback.no_previous";
fail_out.message = concat("no previous published release to roll back to on channel: ", channel_name);
raise error.Invalid;
}
chan.current_release_id = target_id;
repo.update_channel(chan);
now := pl.now_secs();
repo.create_audit_event(AuditEvent.{
id = concat(concat(concat(concat("evt-cli-rollback-", app.id), "-"), channel_name), concat("-", target_id)),
actor = "cli", action = "channel.rollback", target_type = "channel",
target_id = channel_name, metadata = target_id, created_at = now,
});
try op_save(@repo, store_dir, fail_out);
return RollbackOutcome.{
app_id = app.id, channel = channel_name,
from_release_id = from,
to_release_id = target_id, to_version = target_version,
};
}
// ── rendering ─────────────────────────────────────────────────────────
// `{"status":"promoted","app_id":...,"channel":...,"release":{"id":...,
// "version":...},"previous_release_id":...}` — member order fixed by
// `std.json`'s insertion-order guarantee.
write_promote_json :: (o: *PromoteOutcome, dst: []u8) -> (s64, !JsonError) {
gpa := GPA.init();
root : Object = .{};
root.put("status", .str("promoted"), xx gpa);
root.put("app_id", .str(o.app_id), xx gpa);
root.put("channel", .str(o.channel), xx gpa);
rel : Object = .{};
rel.put("id", .str(o.release_id), xx gpa);
rel.put("version", .str(o.version), xx gpa);
root.put("release", .object(rel), xx gpa);
root.put("previous_release_id", .str(o.previous_release_id), xx gpa);
rootv : Value = .object(root);
n := try write_to_buffer(rootv, dst);
return n;
}
// `{"status":"rolled_back","app_id":...,"channel":...,"from_release_id":...,
// "to":{"id":...,"version":...}}`.
write_rollback_json :: (o: *RollbackOutcome, dst: []u8) -> (s64, !JsonError) {
gpa := GPA.init();
root : Object = .{};
root.put("status", .str("rolled_back"), xx gpa);
root.put("app_id", .str(o.app_id), xx gpa);
root.put("channel", .str(o.channel), xx gpa);
root.put("from_release_id", .str(o.from_release_id), xx gpa);
to : Object = .{};
to.put("id", .str(o.to_release_id), xx gpa);
to.put("version", .str(o.to_version), xx gpa);
root.put("to", .object(to), xx gpa);
rootv : Value = .object(root);
n := try write_to_buffer(rootv, dst);
return n;
}
promote_human :: (o: *PromoteOutcome) -> string {
s := concat("promoted ", o.release_id);
s = concat(s, concat(" (", concat(o.version, ")")));
s = concat(s, concat(" onto channel ", o.channel));
if o.previous_release_id.len > 0 {
s = concat(s, concat(" (was ", concat(o.previous_release_id, ")")));
}
return concat(s, "\n");
}
rollback_human :: (o: *RollbackOutcome) -> string {
s := concat("rolled back channel ", o.channel);
s = concat(s, concat(": ", o.from_release_id));
s = concat(s, concat(" -> ", o.to_release_id));
s = concat(s, concat(" (", concat(o.to_version, ")")));
return concat(s, "\n");
}

View File

@@ -8,13 +8,15 @@
//
// 1. no args → human help/usage on STDERR + EX_USAGE (64).
// 2. unknown command → human error on STDERR + EX_USAGE (64).
// 3. `release promote --json` → STDOUT is a SINGLE valid JSON object
// (parses via std.json with no trailing junk); the human acknowledgement
// is on STDERR, never stdout. (`release promote` is still a stub; the
// real `ci publish` json output is exercised by publish_happy.sx.)
// 3. a fully-flagged `release promote --json` against a store that was
// never published → exit 1 (command failed, NOT usage), and STDOUT is
// a SINGLE valid JSON error object (parses via std.json with no
// trailing junk; status "error", code "store.load"); the human
// sentence is on STDERR, never stdout. (Success-path json output is
// exercised by publish_happy.sx / release_ops.sx.)
// 4. `--help` → lists the `ci` / `release` groups, exits 0.
// 5. `ci publish --json` with NO required flags → EX_USAGE (64), error on
// stderr (the --manifest / --local-store contract).
// 5. `ci publish --json` / `release promote --json` with NO required
// flags → EX_USAGE (64), error on stderr (the required-flag contract).
//
// `make test` depends on `build`, so `build/dist` exists before this runs;
// the relative path resolves from the repo root (the `make test` cwd).
@@ -65,27 +67,31 @@ main :: () -> s32 {
proc.assert(false, "spawn build/dist bogus failed");
}
// ── 3a. `--json` stdout purity: a single valid JSON object, nothing
// else. `2>/dev/null` drops the human note so the pipe carries
// ONLY stdout; std.json.parse rejects trailing junk. ─────────
if r := proc.run("build/dist release promote --json 2>/dev/null") {
proc.assert(r.exit_code == 0, "stub --json command must succeed (EX_OK)");
// ── 3a. `--json` stdout purity on a FAILURE path: a single valid
// JSON error object, nothing else. The store dir was never
// published into, so the command fails with `store.load` and
// exit 1 (command failed — distinct from usage's 64).
// `2>/dev/null` drops the human note so the pipe carries ONLY
// stdout; std.json.parse rejects trailing junk. ──────────────
PROMOTE :: "build/dist release promote --app x --channel beta --release rel-x --local-store .sx-tmp/cli_dispatch_nostore --json";
if r := proc.run(concat(PROMOTE, " 2>/dev/null")) {
proc.assert(r.exit_code == 1, "failed command must exit 1 (not EX_USAGE)");
v, e := json.parse(r.stdout, xx gpa);
proc.assert(!e, "stdout in --json mode must be a single valid JSON object (parse failed / trailing junk)");
if !e {
o := v.object;
proc.assert(o.len == 3, "stub json object carries command/status/stub");
proc.assert(o.items[0].key == "command" and o.items[0].val.str == "release promote",
"stub json names the dispatched command");
proc.assert(o.items[1].key == "status" and o.items[1].val.str == "ok",
"stub json reports status ok");
proc.assert(o.items[0].key == "status" and o.items[0].val.str == "error",
"failure json reports status error");
eo := o.items[1].val.object;
proc.assert(eo.items[0].key == "code" and eo.items[0].val.str == "store.load",
"failure json names the store.load code");
}
} else {
proc.assert(false, "spawn build/dist release promote --json failed");
}
// ── 3b. `--json` mode keeps human text on STDERR (not stdout) ──────
if r := proc.run("build/dist release promote --json 2>&1 1>/dev/null") {
if r := proc.run(concat(PROMOTE, " 2>&1 1>/dev/null")) {
proc.assert(r.stdout.len > 0, "--json mode must still emit human text to stderr");
} else {
proc.assert(false, "spawn build/dist release promote --json (stderr) failed");
@@ -100,7 +106,7 @@ main :: () -> s32 {
proc.assert(false, "spawn build/dist --help failed");
}
// ── 5. `ci publish` requires --manifest / --local-store ───────────
// ── 5. required flags: ci publish AND release promote ─────────────
// Missing a required flag is a usage error: EX_USAGE (64), human
// diagnostic on stderr (`2>&1 1>/dev/null` captures the stderr text).
if r := proc.run("build/dist ci publish --json 2>&1 1>/dev/null") {
@@ -109,6 +115,12 @@ main :: () -> s32 {
} else {
proc.assert(false, "spawn build/dist ci publish (no flags) failed");
}
if r := proc.run("build/dist release promote --json 2>&1 1>/dev/null") {
proc.assert(r.exit_code == 64, "release promote without required flags must exit EX_USAGE (64)");
proc.assert(contains(r.stdout, "missing required flag"), "missing-flag error names the failure on stderr");
} else {
proc.assert(false, "spawn build/dist release promote (no flags) failed");
}
print("cli_dispatch: ok\n");
return 0;

182
tests/release_ops.sx Normal file
View File

@@ -0,0 +1,182 @@
// Pinned acceptance for P3.5 — `dist release promote` / `dist release
// rollback` over the persisted store.
//
// Drives the BUILT `build/dist` binary (via `process.run`, like
// publish_persist.sx) through the slice plan's scripted scenario:
//
// 1. Publish release A (1.2.3, channel beta) then B (1.2.4, beta) into
// one store → beta points at B.
// 2. `release rollback --app --channel beta` → exit 0, JSON
// `rolled_back` from B to A; db.json's beta points at A; a
// `channel.rollback` audit event (actor "cli") is recorded.
// 3. `release promote --release <B>` → exit 0, JSON `promoted` with
// previous_release_id A; beta points at B again; a `channel.promote`
// audit event by actor "cli" is recorded (distinct from the publish
// pipeline's "ci" promote events).
// 4. Promoting an UNKNOWN release id → exit 1 + JSON error
// (`promote.unknown_release`); db.json unchanged (beta still → B).
// 5. Rollback again (B → A), then rollback at the EARLIEST release →
// exit 1 + `rollback.no_previous`; beta still → A. A failed op never
// moves the pointer.
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
STORE :: ".sx-tmp/release_ops";
MDIR :: ".sx-tmp/release_ops_m";
MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"beta\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"beta\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
REL_A :: "rel-acme-app-1.2.3";
REL_B :: "rel-acme-app-1.2.4";
get :: (o: Object, key: string) -> Value {
i := 0;
while i < o.len {
if o.items[i].key == key { return o.items[i].val; }
i += 1;
}
process.assert(false, concat("missing json key: ", key));
dummy : Value = .null_;
return dummy;
}
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
// Count audit events matching (actor, action) — distinguishes the CLI's
// channel events from the publish pipeline's "ci" ones.
count_actor_action :: (events: Array, actor: string, action: string) -> s64 {
c : s64 = 0;
i := 0;
while i < events.len {
eo := events.items[i].object;
if get_str(eo, "actor") == actor and get_str(eo, "action") == action { c += 1; }
i += 1;
}
return c;
}
write_file :: (path: string, body: string) {
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
process.run(cmd);
}
load_db :: (scratch: Allocator) -> Object {
db_bytes := fs.read_file(path_join(STORE, "db.json"));
process.assert(db_bytes != null, "db.json must exist under the store");
dv, de := parse(db_bytes!, scratch);
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
return dv.object;
}
// The beta channel's current_release_id, read fresh from db.json.
beta_pointer :: (scratch: Allocator) -> string {
dbo := load_db(scratch);
chans := get_arr(dbo, "channels");
process.assert(chans.len == 1, "store has exactly one channel");
return get_str(chans.items[0].object, "current_release_id");
}
publish_cmd :: (mpath: string) -> string {
c := concat("build/dist ci publish --manifest ", mpath);
c = concat(c, concat(" --local-store ", STORE));
return concat(c, " --json 2>/dev/null");
}
promote_cmd :: (release_id: string) -> string {
c := concat("build/dist release promote --app acme-app --channel beta --release ", release_id);
c = concat(c, concat(" --local-store ", STORE));
return concat(c, " --json 2>/dev/null");
}
ROLLBACK_CMD :: "build/dist release rollback --app acme-app --channel beta --local-store .sx-tmp/release_ops --json 2>/dev/null";
main :: () -> s32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 1 << 20);
defer arena.deinit();
process.run(concat("rm -rf ", STORE));
process.run(concat("rm -rf ", MDIR));
process.run(concat("mkdir -p ", MDIR));
write_file(path_join(MDIR, "a.json"), MANIFEST_A);
write_file(path_join(MDIR, "b.json"), MANIFEST_B);
// ── 1. Publish A then B → beta points at B ──────────────────────
ra := process.run(publish_cmd(path_join(MDIR, "a.json")));
process.assert(ra != null and ra!.exit_code == 0, "publish A must exit 0");
rb := process.run(publish_cmd(path_join(MDIR, "b.json")));
process.assert(rb != null and rb!.exit_code == 0, "publish B must exit 0");
process.assert(beta_pointer(xx arena) == REL_B, "after publishes: beta -> B");
print(" published A then B; beta -> B\n");
// ── 2. Rollback: beta moves B -> A, audited ──────────────────────
rr := process.run(ROLLBACK_CMD);
process.assert(rr != null, "spawn rollback failed");
process.assert(rr!.exit_code == 0, "rollback must exit 0");
rv, re := parse(rr!.stdout, xx arena);
if re { process.assert(false, "rollback stdout must be one JSON object"); return 1; }
ro := rv.object;
process.assert(get_str(ro, "status") == "rolled_back", "rollback json status");
process.assert(get_str(ro, "from_release_id") == REL_B, "rollback json from B");
process.assert(get_str(get_obj(ro, "to"), "id") == REL_A, "rollback json to A");
process.assert(beta_pointer(xx arena) == REL_A, "after rollback: beta -> A");
db2 := load_db(xx arena);
process.assert(count_actor_action(get_arr(db2, "audit_events"), "cli", "channel.rollback") == 1,
"rollback recorded one cli channel.rollback audit event");
print(" rollback: beta B -> A, audited\n");
// ── 3. Promote B back: beta -> B, previous is A, audited ─────────
rp := process.run(promote_cmd(REL_B));
process.assert(rp != null, "spawn promote failed");
process.assert(rp!.exit_code == 0, "promote must exit 0");
pv, pe := parse(rp!.stdout, xx arena);
if pe { process.assert(false, "promote stdout must be one JSON object"); return 1; }
po := pv.object;
process.assert(get_str(po, "status") == "promoted", "promote json status");
process.assert(get_str(get_obj(po, "release"), "id") == REL_B, "promote json release B");
process.assert(get_str(po, "previous_release_id") == REL_A, "promote json previous A");
process.assert(beta_pointer(xx arena) == REL_B, "after promote: beta -> B");
db3 := load_db(xx arena);
process.assert(count_actor_action(get_arr(db3, "audit_events"), "cli", "channel.promote") == 1,
"promote recorded one cli channel.promote audit event");
print(" promote: beta -> B (was A), audited\n");
// ── 4. Promote an unknown release id → exit 1 + JSON error, db
// unchanged ─────────────────────────────────────────────────
rn := process.run(promote_cmd("rel-nope"));
process.assert(rn != null, "spawn promote-unknown failed");
process.assert(rn!.exit_code == 1, "promote of unknown release must exit 1");
nv, ne := parse(rn!.stdout, xx arena);
if ne { process.assert(false, "promote-unknown stdout must be one JSON object"); return 1; }
no := nv.object;
process.assert(get_str(no, "status") == "error", "promote-unknown json status error");
process.assert(get_str(get_obj(no, "error"), "code") == "promote.unknown_release",
"promote-unknown json names the code");
process.assert(beta_pointer(xx arena) == REL_B, "after failed promote: beta unchanged (-> B)");
print(" promote unknown release: exit 1 + JSON error, beta unchanged\n");
// ── 5. Rollback to the earliest, then once more → no_previous ────
r2 := process.run(ROLLBACK_CMD);
process.assert(r2 != null and r2!.exit_code == 0, "second rollback must exit 0 (B -> A)");
process.assert(beta_pointer(xx arena) == REL_A, "after second rollback: beta -> A");
r3 := process.run(ROLLBACK_CMD);
process.assert(r3 != null, "spawn third rollback failed");
process.assert(r3!.exit_code == 1, "rollback at the earliest release must exit 1");
v3, e3 := parse(r3!.stdout, xx arena);
if e3 { process.assert(false, "no-previous stdout must be one JSON object"); return 1; }
o3 := v3.object;
process.assert(get_str(get_obj(o3, "error"), "code") == "rollback.no_previous",
"no-previous json names the code");
process.assert(beta_pointer(xx arena) == REL_A, "after failed rollback: beta unchanged (-> A)");
print(" rollback at earliest: exit 1 + no_previous, beta unchanged\n");
process.run(concat("rm -rf ", STORE));
process.run(concat("rm -rf ", MDIR));
print("release_ops: ALL CASES PASS\n");
return 0;
}