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

@@ -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;