// 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; the store's beta channel points at A; a // `channel.rollback` audit event (actor "cli") is recorded. // 3. `release promote --release ` → 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`); the store 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. // // Store-side state is asserted by QUERYING `/dist.db` through the // SQLite bindings. #import "modules/std.sx"; #import "modules/std/json.sx"; process :: #import "modules/std/process.sx"; fs :: #import "modules/std/fs.sx"; sq :: #import "vendors/sqlite/sqlite.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; } write_file :: (path: string, body: string) { cmd := concat(concat(concat("printf '%s' '", body), "' > "), path); process.run(cmd); } // Open the store database read-only (asserts it exists and opens). db_open_ro :: () -> sq.Sqlite { c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY); process.assert(!oe, "dist.db must open as a SQLite database"); c.busy_timeout(2000); return c; } // One-row TEXT scalar with up to two text bindings ("" = unbound). q_text :: (sql: string, p1: string, p2: string) -> string { c := db_open_ro(); st, pe := c.prepare(sql); process.assert(!pe, concat("prepare must succeed: ", sql)); if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; } if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; } rc, se := st.step(); process.assert(!se, concat("step must succeed: ", sql)); process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql)); out := st.column_text(0); st.finalize(); c.close(); return out; } // One-row INTEGER scalar with up to two text bindings ("" = unbound). q_int :: (sql: string, p1: string, p2: string) -> i64 { c := db_open_ro(); st, pe := c.prepare(sql); process.assert(!pe, concat("prepare must succeed: ", sql)); if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; } if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; } rc, se := st.step(); process.assert(!se, concat("step must succeed: ", sql)); process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql)); out := st.column_int64(0); st.finalize(); c.close(); return out; } // The beta channel's current_release_id, read fresh from dist.db. beta_pointer :: () -> string { process.assert(q_int("SELECT COUNT(*) FROM channels", "", "") == 1, "store has exactly one channel"); return q_text("SELECT current_release_id FROM channels", "", ""); } 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 :: () -> i32 { 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() == 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() == REL_A, "after rollback: beta -> A"); process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "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() == REL_B, "after promote: beta -> B"); process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "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() == 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() == 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() == 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; }