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:
@@ -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
182
tests/release_ops.sx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user