diff --git a/.agents/subplans/01-language-and-stdlib.md b/.agents/subplans/01-language-and-stdlib.md index 51e3592..9f85fdd 100644 --- a/.agents/subplans/01-language-and-stdlib.md +++ b/.agents/subplans/01-language-and-stdlib.md @@ -1,5 +1,16 @@ # Subplan 01 - sx Language And Standard Library +> **Status (2026-06-11):** largely superseded by sx itself. Slice 1 shipped +> as-built with a different design — there is no `pub`; aliases are the +> re-export mechanism and `modules/std.sx` is the barrel. Slice 2 is settled +> (`!` error channel, `catch (e)` bindings). Slices 5–7 partially shipped +> (`std.fs`, `std.process`, `std.hash` SHA-256, `std.json`, `std.cli`, +> `std.log`). Still open: Unicode/String model (Slice 3), +> HashMap/StringBuilder (Slice 4), bytes/paths (Slice 5 remainder), +> time/random/encoding (Slice 6 remainder), config (Slice 7 remainder), and +> HTTP/TLS/SQLite/archive/testing helpers (Slice 8). See "sx Foundation +> Status" in `PLAN.md`. + ## Goal Build the language and std primitives required before the distribution platform diff --git a/.agents/subplans/08-orchestration-and-qa.md b/.agents/subplans/08-orchestration-and-qa.md deleted file mode 100644 index 192eed5..0000000 --- a/.agents/subplans/08-orchestration-and-qa.md +++ /dev/null @@ -1,200 +0,0 @@ -# Subplan 08 - Orchestration, Checkpoints, And QA - -## Goal - -Keep agent work resumable, auditable, and constrained. - -## Required Files - -- `.agents/ORCHESTRATION.md` -- `.agents/CHECKPOINT.md` -- `.agents/checkpoint.json` -- `.agents/subplans/README.md` -- `.agents/runs//...` - -## Run Creation - -For each substantial task: - -1. Create `.agents/runs//`. -2. Copy relevant acceptance criteria into `brief.md`. -3. Record the active branch. -4. Record allowed write paths. -5. Update checkpoint before invoking Opus. - -Manager planning sessions count as substantial tasks when the user expects -observability. Create a run record for planning work too, with Codex manager as -the active agent. - -## Agent Liveness - -Each active run should include: - -```txt -.agents/runs// - state.json - agents.json -``` - -`state.json` records: - -- run id -- current phase -- current branch -- input artifact -- input hash -- expected output artifact -- retry count -- next action -- blocker, if any - -`agents.json` records: - -- role -- status: queued, running, completed, failed, dead, restarted -- started_at -- heartbeat_at -- lease_expires_at -- thread id, process id, or tool call id when available -- last_error - -## Status And Progress Tail - -Use the local status command from the workspace root: - -```sh -node .agents/scripts/status.mjs --tail 40 -``` - -For a browser dashboard: - -```sh -node .agents/scripts/observe.mjs --port 4317 -``` - -Then open `http://127.0.0.1:4317`. - -The command reads: - -- `.agents/checkpoint.json` -- every `.agents/runs//state.json` -- every `.agents/runs//agents.json` - -It prints: - -- all known runs -- current phase and branch -- all recorded agents and their lease status -- expired leases -- blockers -- the next action -- the tail of the active run's progress file - -Progress files are checked in this order: - -- `progress.log` -- `implementation-log.md` -- `validation.md` -- `opus-proposal.md` -- `snarky-review.md` - -Managers should append progress events to `progress.log` whenever possible. -Human-readable phase artifacts still stay in their named markdown files. - -## Agent Restart Policy - -If an agent dies, the manager restarts the role from durable files, not memory. - -Snarky restart: - -- Read `PLAN.md`, `.agents/ORCHESTRATION.md`, checkpoint files, and active run - artifacts. -- Re-run the current Snarky phase using the same input artifact. -- Replace only the expected Snarky output for that phase. - -Opus proposal/review restart: - -- Re-run the same `opus-runner` planning tool with the same input artifact. -- Keep previous failed output, if any, as diagnostic context. -- Do not advance until the expected output validates. -- Use a lease and CLI/tool timeout of at least 30 minutes. - -Opus implementation restart: - -- Check current branch. -- Check dirty state. -- If the branch is clean, retry the same implementation instruction. -- If the branch is dirty, manager must inspect the diff and decide whether to - continue, ask Opus to repair, or ask the user. -- Never auto-reset or discard partial Opus edits. -- Use a lease and CLI/tool timeout of at least 30 minutes. - -Retry limits: - -- Retry a dead planning phase up to 2 times. -- Retry an implementation phase up to 1 time without user input. -- After the retry cap, record a blocker in checkpoint files. - -## Checkpoint Policy - -Update checkpoints: - -- at the start of a run -- after Snarky brief -- after Opus proposal -- after concern resolution -- before Opus implementation -- after implementation -- after validation -- before ending the turn - -Checkpoint must include: - -- timestamp -- current phase -- current branch -- active run id -- completed artifacts -- next action -- blockers -- commands/checks already run - -## Validation Layers - -Manager validation: - -- git branch and diff check -- out-of-scope file check -- syntax checks -- unit/integration tests when available -- browser/screenshot checks for UI work when available - -Snarky validation: - -- product requirements -- acceptance criteria -- install flow accuracy -- scope discipline - -Opus validation: - -- layout/design quality -- interaction clarity -- technical design concerns - -## Resume Procedure - -After power loss or interruption: - -1. Read `.agents/CHECKPOINT.md`. -2. Read `.agents/checkpoint.json`. -3. Check git branch and dirty state. -4. Read the active run directory if present. -5. Continue from `next_action`. -6. Do not assume an Opus implementation completed unless validation is recorded. - -## Current Known Setup Issue - -The distribution workspace is not currently a git repository. Branch-based Opus -implementation requires initializing git or moving these files into a repo with -a clean baseline commit. diff --git a/.agents/subplans/README.md b/.agents/subplans/README.md index fc97f0b..fd7fdf1 100644 --- a/.agents/subplans/README.md +++ b/.agents/subplans/README.md @@ -1,31 +1,25 @@ # Detailed Subplan Index -These subplans break `PLAN.md` into implementation slices that can be handed to -Snarky and Opus without reloading the whole plan every time. +These subplans break `PLAN.md` into implementation slices. ## Reading Order -1. `01-language-and-stdlib.md` +1. `01-language-and-stdlib.md` (largely superseded — see "sx Foundation + Status" in `PLAN.md` for the as-built state and remaining gaps) 2. `02-domain-and-storage.md` 3. `03-cli-and-ci.md` 4. `04-http-api-and-install.md` 5. `05-artifact-validation.md` 6. `06-admin-ui.md` 7. `07-packaging-nas.md` -8. `08-orchestration-and-qa.md` -## Session Contract +## Working Contract -- Read `PLAN.md`, `.agents/ORCHESTRATION.md`, `.agents/CHECKPOINT.md`, and - the active subplan before doing work. -- Keep work sequential. Do not use parallel implementation agents. -- Use git branches for implementation. Do not use worktrees. -- For product or UX scope, Snarky writes acceptance criteria and has final say. -- For layout and visual design, Opus has final say. -- For technical problems, consult Opus and resolve by consensus. -- Opus is the only role that writes application code during Opus phases. -- Update `.agents/CHECKPOINT.md` and `.agents/checkpoint.json` after every - completed slice, before any risky handoff, and after validation. +- The multi-agent flow harness is retired; work happens directly in-session. +- Read `PLAN.md` and the active subplan before doing work. The active + milestone slice plan and step progress live in `current/`. +- Implementation is branch-based; `make test` must be green at each step + boundary. ## Slice Exit Criteria @@ -35,10 +29,3 @@ Every slice should finish with: - Tests/checks run listed. - Known risks listed. - Next slice named. -- Checkpoint updated. - -## Current Priority - -The first blocking priority is `01-language-and-stdlib.md`. Product code should -wait until the required `sx` primitives exist or an explicit temporary shim is -approved. diff --git a/Makefile b/Makefile index c074109..453c227 100644 --- a/Makefile +++ b/Makefile @@ -26,9 +26,13 @@ build: test: build @SX="$(SX)" ./tests/run.sh -# Placeholder for the end-to-end publish flow — becomes real in P3.4. -publish-example: - @echo "publish-example: not implemented yet (becomes real in P3.4)" +# End-to-end local publish of examples/dist.json into a fresh .sx-tmp/ +# store, emitting the machine-readable JSON result on stdout. Depends on +# `build` so build/dist exists; the store is reset first so re-runs don't +# collide on the release id. +publish-example: build + @rm -rf .sx-tmp/publish-example + ./$(BUILD_DIR)/dist ci publish --manifest examples/dist.json --local-store .sx-tmp/publish-example --json clean: @rm -rf $(BUILD_DIR) diff --git a/PLAN.md b/PLAN.md index b05f9a5..4c1ce0e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -156,48 +156,53 @@ Deployment direction: - UGREEN NAS deployment through Docker/Container Manager is a first-version requirement. -## sx Foundation Work +## sx Foundation Status -Before the product can be implemented well, `sx` needs a stronger language and -standard library foundation. +Re-evaluated 2026-06-11 against the current sx tree. The original foundation +asks have largely landed, with one design difference: sx has no `pub` keyword. +Visibility is import-scoped; aliases are the re-export mechanism +(`print :: core.print`), and a module's namespace tail carries one level into +flat importers. `modules/std.sx` is the curated barrel this plan asked for. -Language/module needs: +Delivered in sx and used by this repo: -- `pub` exports so std modules do not leak private helpers. -- Alias imports and curated namespace barrels. -- Namespace member re-export syntax such as `pub print :: core.print`. -- Error handling that follows the real sx model. `!` is an error channel, not a - generic result wrapper. +- Module system: alias imports, alias re-exports, namespace barrels, + one-level carry, dir-vs-file ambiguity rejection. +- Error handling: the `!` error channel with `raise`/`catch`/`onfail` + (bindings take parens), `?T` optionals. +- `std` modules under `modules/std/`: core, fmt, list, mem (typed allocator + helpers over `alloc_bytes`/`dealloc_bytes`), fs, process, socket (raw TCP), + json, xml, cli, hash (streaming SHA-256), log (leveled, no timestamps yet), + test (bare assert). -Standard library needs, at overview level: +Still missing from sx — the forward wishlist; each item either blocks a later +subplan or has an explicit local workaround: -- Collections: extended `List`, `HashMap` +- Collections: `HashMap` (linear scan over `List` pairs meanwhile). - Strings: validated UTF-8 `String`, `StringBuilder`, explicit Unicode model -- Bytes and paths -- Filesystem and process APIs -- Time, random, hashing, and encoding -- JSON, URL, MIME, config, CLI, and logging -- HTTP server/client and TLS boundary -- SQLite -- Archive inspection -- Testing helpers - -Unicode must be specified precisely: - -- `String` is validated UTF-8 bytes. -- Byte length, scalar values, grapheme clusters, and display width are distinct. -- APIs must clearly say which unit they operate on. -- Invalid UTF-8 must not silently become a `String`. + (byte length, scalar values, grapheme clusters, and display width are + distinct; invalid UTF-8 must not silently become a `String`). +- Bytes and a full path module (only `path_join`/`basename`/`dirname` today). +- Time/clock (publish shims `time(2)` via FFI), random, encodings + (base64url, percent). +- HTTP server/client and TLS boundary — blocks subplan 04 and remote publish + (subplan 03 Slice 3). +- SQLite — blocks subplan 02 Slice 2 (`db.json` stands in). +- Archive inspection — blocks deep IPA/APK validation (subplan 05). +- Config layering (env/file/CLI) and richer testing helpers. ## Implementation Phases -Phase 0 - sx language/module prerequisites: +Phase 0 - sx language/module prerequisites (done, as-built): -- Add `pub` support, alias imports, and namespace member re-exports. +- Delivered in sx via alias re-exports and namespace barrels; there is no + `pub` keyword. -Phase 1 - standard library foundation: +Phase 1 - standard library foundation (partial): -- Build the std primitives needed for CLI, HTTP, storage, validation, and tests. +- Delivered: fs, process, json, cli, hash, log, test, socket, mem. +- Outstanding: HashMap, StringBuilder/Unicode model, time, random, encodings, + HTTP/TLS, SQLite, archive (see "sx Foundation Status"). Phase 2 - product domain: @@ -305,21 +310,10 @@ verifiable from the repo: ## Detailed Execution -`PLAN.md` is the overview. Detailed implementation breakdowns live in -`.agents/subplans/`. +`PLAN.md` is the overview. Slice breakdowns live in `.agents/subplans/` +(01–07). The active milestone slice plan and step progress live in `current/` +(`PLAN.md`, `CHECKPOINT-DISTRIBUTION.md`). -Before starting or resuming work, read: - -- `.agents/ORCHESTRATION.md` -- `.agents/CHECKPOINT.md` -- `.agents/checkpoint.json` -- the active `.agents/subplans/*.md` file - -The workflow is sequential and branch-based: - -- Codex manages orchestration and validation. -- Snarky owns product briefs and final product acceptance. -- Opus owns layout/design decisions. -- Opus is the only role that writes code during Opus implementation phases. -- Implementation uses git branches, not worktrees. -- Checkpoints are updated after every completed slice and before stopping work. +The multi-agent flow harness that originally executed this plan is retired; +work proceeds directly in-session, branch-based, with `make test` green at +each step boundary. diff --git a/src/dist.sx b/src/dist.sx index 1a17673..90ad89c 100644 --- a/src/dist.sx +++ b/src/dist.sx @@ -2,29 +2,33 @@ // dist.sx — the `dist` distribution CLI entry point (subplan 03, Slice 1). // // Wires the real process argv (via `std.cli`'s `os_args`) to subcommand -// handlers through `cli.parse`. The three groups/commands are dispatched -// to STUB handlers for now — they acknowledge and emit a known result; the -// real publish/promote/rollback logic lands in P3.4/P3.5. +// handlers through `cli.parse`. // -// dist ci publish -// dist release promote -// dist release rollback +// 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 // group/command/flag ends with `exit_usage()` (EX_USAGE = 64). An -// explicit `-h`/`--help` is not an error and ends 0. +// explicit `-h`/`--help` is not an error and ends 0. A command that parsed +// correctly but FAILED in execution (publish abort) ends 1 — under `--json` +// the failure is also a single machine-readable error object on stdout +// (`{"status":"error","error":{code,message}}`). // // `--json` PURITY: every command accepts the reserved global `--json` // flag (surfaced by the parser as `parsed.json`). In json mode stdout // carries ONLY the machine-readable JSON object (emitted via `std.json`, -// isolated in `json_out.sx`); ALL human-readable text (help, progress, -// errors) goes to stderr. In non-json mode human text on stdout is fine. +// isolated in `json_out.sx` / `publish.sx`); ALL human-readable text (help, +// progress, errors) goes to stderr. In non-json mode human text on stdout +// is fine. // ===================================================================== #import "modules/std.sx"; #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). @@ -39,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 [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI (stub)\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 64 usage error (no command, or an unknown/missing command or flag)\n"; +HELP :: "dist — application distribution CLI\n\nUsage:\n dist [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest publish manifest (dist.json) to read\n --local-store local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app app the channel belongs to\n --channel channel to move\n --release release id to promote\n --local-store local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app app the channel belongs to\n --channel channel to roll back\n --local-store 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 { @@ -61,45 +65,123 @@ error_phrase :: (e: CliError) -> string { return "usage error"; } -// ── Stub handlers ──────────────────────────────────────────────────── -// Honest stubs: acknowledge the command and emit a known result. NO real -// publish/promote/rollback logic (that is P3.4/P3.5). +// ── Handlers ───────────────────────────────────────────────────────── +// A command that parsed correctly but failed in execution exits 1 — +// distinct from the parser's EX_USAGE (64), so CI can tell "you called it +// wrong" from "the publish itself aborted". +exit_command_failed :: () -> noreturn { process.exit(1); } + +// Report a failed command and exit 1: the human sentence goes to stderr; +// under `--json` the machine-readable error object is the only thing on +// stdout (the purity contract holds on failure paths too). +report_failure :: (cmd: string, fail: jout.CliFailure, json_mode: bool) -> noreturn { + eputs(concat(concat(concat(concat("dist: ", cmd), " failed: "), fail.message), "\n")); + if json_mode { + raw : [4096]u8 = ---; + werr := false; + n := jout.write_error(fail, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 }; + if !werr { + out(string.{ ptr = @raw[0], len = n }); + out("\n"); + } + } + exit_command_failed(); +} + +// `dist ci publish` — the real local publish pipeline (P3.4a). Runs the +// publish, then renders the outcome: in json mode a single stable JSON +// object on stdout with the human progress note on stderr (the `--json` +// purity contract); otherwise a readable summary on stdout. A failed +// publish reports through `report_failure` (stderr sentence, JSON error +// object under --json, exit 1). handle_ci_publish :: (p: *Parsed, json_mode: bool) { - ack("ci publish", json_mode); + manifest_path := p.value_of("manifest"); + store_dir := p.value_of("local-store"); + + fail : jout.CliFailure = .{}; + o, e := pl.run_publish(manifest_path, store_dir, @fail); + if e { + report_failure("ci publish", fail, json_mode); + } + + if !e { + if !json_mode { + out(pl.human_summary(@o)); + return; + } + + eputs("dist: ci publish ok\n"); + raw : [16384]u8 = ---; + werr := false; + n := pl.write_json(@o, string.{ ptr = @raw[0], len = 16384 }) catch { werr = true; 0 }; + if werr { + eputs("dist: internal error: JSON serialization failed\n"); + exit_usage(); + } + out(string.{ ptr = @raw[0], len = n }); + out("\n"); + } } + +// `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); + 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); + } + 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"); + } } -// 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; - } - - 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(); - } - out(string.{ ptr = @raw[0], len = n }); - out("\n"); -} - -// Route a parsed (group, command) to its stub handler. `parse` only -// returns a (group, command) present in the table, so one arm always -// matches. +// Route a parsed (group, command) to its handler. `parse` only returns a +// (group, command) present in the table, so one arm always matches. dispatch :: (p: *Parsed, json_mode: bool) { if p.group == "ci" and p.command == "publish" { handle_ci_publish(p, json_mode); return; } if p.group == "release" and p.command == "promote" { handle_release_promote(p, json_mode); return; } @@ -131,14 +213,28 @@ main :: () -> ! { } // Command table + flag specs live in this scope; `Parsed` holds VIEWS - // into them, used before `main` returns. Per-command flags arrive with - // the real handlers in P3.4/P3.5 — the global `--json` is recognized by - // the parser without being declared here. - 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 = no_flags }, - Command.{ group = "release", command = "promote", flags = no_flags }, - Command.{ group = "release", command = "rollback", flags = no_flags }, + Command.{ group = "ci", command = "publish", flags = publish_flags }, + Command.{ group = "release", command = "promote", flags = promote_flags }, + Command.{ group = "release", command = "rollback", flags = rollback_flags }, ]; diag : Diag = .{}; diff --git a/src/infra/.gitkeep b/src/infra/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/json_out.sx b/src/json_out.sx index 6af9e25..5ecd16a 100644 --- a/src/json_out.sx +++ b/src/json_out.sx @@ -9,15 +9,28 @@ #import "modules/std.sx"; #import "modules/std/json.sx"; -// Serialize a stub command's machine result — `{ "command": , -// "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) { +// A machine-readable command failure: a stable dotted `code` naming the +// failing stage and sub-reason (e.g. "validation.digest_mismatch") plus a +// human sentence. Pipelines fill one of these at the raise site, so the +// CLI can report precisely what failed without the error channel having to +// carry data. +CliFailure :: struct { + code: string = ""; + message: string = ""; +} + +// Serialize a failure as the `--json` error object — `{ "status": "error", +// "error": { "code": , "message": } }` — into the +// caller-owned `dst`, returning the bytes written. Overflow surfaces on +// the error channel. +write_error :: (f: CliFailure, 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); + obj.put("status", .str("error"), xx gpa); + eo : Object = .{}; + eo.put("code", .str(f.code), xx gpa); + eo.put("message", .str(f.message), xx gpa); + obj.put("error", .object(eo), xx gpa); root : Value = .object(obj); n := try write_to_buffer(root, dst); return n; diff --git a/src/manifest/manifest.sx b/src/manifest/manifest.sx index 82fd50d..2e2c0ff 100644 --- a/src/manifest/manifest.sx +++ b/src/manifest/manifest.sx @@ -25,7 +25,11 @@ #import "modules/std.sx"; #import "modules/std/json.sx"; -#import "modules/fs.sx"; +// Also reached through an alias so the json reader is called as `jsonp.parse` +// — a bare `parse` would bind to `std.cli`'s `parse` once both modules share +// one program (the `dist` CLI), which returns a different type. +jsonp :: #import "modules/std/json.sx"; +#import "modules/std/fs.sx"; #import "../domain/platform.sx"; #import "../domain/validate.sx"; @@ -49,12 +53,20 @@ ManifestErr :: error { // One artifact entry. `platform` + `path` are required; `filename`, // `content_type`, and `metadata` are optional overrides that default to "" // when absent. `metadata` is an opaque string mirroring Artifact.metadata. +// +// `size` / `sha256` are OPTIONAL declared expectations the publisher checks +// the on-disk file against: present -> that value IS the expectation; +// absent (`size == -1` / `sha256 == ""`) -> the publisher derives the +// expectation from the stored object, so a no-declaration manifest still +// validates trivially. ManifestArtifact :: struct { platform: Platform; path: string; filename: string; content_type: string; metadata: string; + size: s64 = -1; + sha256: string = ""; } // A single publish: which app/version/channel, and the artifacts that make @@ -71,7 +83,7 @@ Manifest :: struct { // Copy `s` into `alloc`-owned, null-terminated storage so the manifest // survives the source bytes / parse scratch being dropped. dup_str :: (s: string, alloc: Allocator) -> string { - raw : [*]u8 = xx alloc.alloc(s.len + 1); + raw : [*]u8 = xx alloc.alloc_bytes(s.len + 1); if s.len > 0 { memcpy(raw, s.ptr, s.len); } raw[s.len] = 0; return string.{ ptr = raw, len = s.len }; @@ -107,6 +119,16 @@ opt_str :: (o: Object, key: string, alloc: Allocator) -> (string, !ManifestErr) return dup_str(val.str, alloc); } +// Optional integer field. Absent -> `default` (a documented default, not a +// silent one); present-but-not-an-integer -> WrongType. +opt_int :: (o: Object, key: string, default: s64) -> (s64, !ManifestErr) { + v := obj_find(o, key); + if v == null { return default; } + val := v!; + if val != .int_ { raise error.WrongType; } + return val.int_; +} + // Required array field. Absent -> MissingField; present-but-not-an-array -> // WrongType. req_arr :: (o: Object, key: string) -> (Array, !ManifestErr) { @@ -141,6 +163,8 @@ artifact_from_json :: (o: Object, alloc: Allocator) -> (ManifestArtifact, !Manif a.filename = try opt_str(o, "filename", alloc); a.content_type = try opt_str(o, "content_type", alloc); a.metadata = try opt_str(o, "metadata", alloc); + a.size = try opt_int(o, "size", -1); + a.sha256 = try opt_str(o, "sha256", alloc); return a; } @@ -151,7 +175,7 @@ artifact_from_json :: (o: Object, alloc: Allocator) -> (ManifestArtifact, !Manif // (WrongType), a missing/empty required field (MissingField), and an unknown // artifact platform (UnknownPlatform). All strings are copied into `alloc`. parse_manifest :: (src: string, alloc: Allocator) -> (Manifest, !ManifestErr) { - root, pe := parse(src, alloc); + root, pe := jsonp.parse(src, alloc); if pe { raise error.BadJson; } if root != .object { raise error.WrongType; } ro := root.object; diff --git a/src/publish/publish.sx b/src/publish/publish.sx new file mode 100644 index 0000000..5766bb2 --- /dev/null +++ b/src/publish/publish.sx @@ -0,0 +1,408 @@ +// ===================================================================== +// publish.sx — the local `dist ci publish` pipeline (subplan 03, Slice 1). +// Wires the prior modules into one end-to-end publish: +// +// manifest (P3.2) -> store (P2.2) -> common validation (P3.3) -> +// repository transaction + audit (P2.3) -> db.json persistence (P2.3) +// +// `run_publish(manifest_path, store_dir)` validates the manifest, LOADS any +// prior `/db.json` so separate invocations share state (a new version +// accumulates; a duplicate release id is rejected), finds or creates the app, +// drafts a release, content-addresses every artifact into +// `/objects/`, validates each stored file, commits the whole +// aggregate through the integrity-checked repo transaction (channel +// promotion included), records an audit event per upload / publish / +// promotion, persists the merged `/db.json`, and returns a +// `PublishOutcome` the CLI renders as stable JSON or a human summary. +// +// DECLARED-vs-DERIVED EXPECTATIONS (PO ruling): a manifest artifact may +// DECLARE `size` / `sha256`; when it does, that value is the expectation the +// common validation pass checks the on-disk file against. When it does NOT +// (size == -1 / sha256 == ""), the expectation is DERIVED from the stored +// object — the size read off disk and the sha256 returned by the store — so +// a no-declaration manifest validates trivially. +// +// FAILURE CONTRACT (P3.4b): every abort happens BEFORE `db.save`, and the +// repo transaction rolls itself back, so a failed publish never changes +// db.json — no partially-published release, no moved channel pointer. Each +// raise site first writes a `jout.CliFailure` (stable dotted code + human +// message naming the offending input) for the CLI to report. +// +// LOCAL DOWNLOAD URL FORM: `file:///objects/`, where +// is the `--local-store` directory resolved to an absolute path +// (left as-is if already absolute, else joined onto the process cwd). +// +// ALLOCATION: everything the published `Repo` and the returned outcome hold +// is allocated from `context.allocator` (the process-lifetime default), so a +// one-shot publish never frees and never dangles. +// ===================================================================== + +#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 "../store/store.sx"; +#import "../repo/repo.sx"; +#import "../validation/artifact_file.sx"; +mani :: #import "../manifest/manifest.sx"; +db :: #import "../repo/db.sx"; +jout :: #import "../json_out.sx"; + +// libc handles the two facts the publish needs from the OS: the wall-clock +// time stamped onto the release / audit events, and the process cwd used to +// absolutize a relative `--local-store` into the download URL. +cstd :: #library "c"; +c_time :: (tloc: *s64) -> s64 #foreign cstd "time"; +c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd"; + +// Failure classes for the publish pipeline. One distinct tag per stage so +// the CLI (and P3.4b) can report precisely where a publish stopped. +// Manifest — the manifest failed to load / parse / validate. +// Store — an artifact's bytes could not be content-addressed. +// Validation — a stored artifact failed the common validation pass. +// Transaction — the repo's integrity-checked publish rejected the aggregate. +// Persist — db.json could not be loaded at startup or written at the end. +PublishError :: error { + Manifest, + Store, + Validation, + Transaction, + Persist, +} + +// One artifact as the CLI reports it: its id, platform, size, content +// digest, and the local download URL its bytes live at. +PublishedArtifact :: struct { + id: string; + platform_name: string; + size_bytes: s64; + sha256: string; + url: string; +} + +// The machine-readable result of a successful publish: the release identity +// and the artifacts that were stored under it. +PublishOutcome :: struct { + release_id: string; + app_id: string; + version: string; + channel: string; + artifacts: List(PublishedArtifact); +} + +// ── helpers ─────────────────────────────────────────────────────────── + +// Current wall-clock time as unix epoch seconds (time(2) via a local slot, +// so no null pointer is needed). +now_secs :: () -> s64 { + t : s64 = 0; + return c_time(@t); +} + +// Resolve `dir` to an absolute path: returned unchanged when already +// absolute, else joined onto the process cwd. Falls back to `dir` if the +// cwd can't be read. +abs_store :: (dir: string) -> string { + if dir.len > 0 and dir[0] == 47 { return dir; } // 47='/' already absolute + buf : [4096]u8 = ---; + r := c_getcwd(@buf[0], 4096); + if cast(s64) r == 0 { return dir; } + n := 0; + while buf[n] != 0 { n += 1; } + cwd := string.{ ptr = @buf[0], len = n }; + return path_join(cwd, dir); +} + +// The content type to record when the manifest doesn't declare one — the +// canonical type for the platform's artifact form (each is on the common +// validation allow-list). +default_content_type :: (p: Platform) -> string { + if p == .ios { return "application/octet-stream"; } + if p == .android_apk { return "application/vnd.android.package-archive"; } + if p == .macos { return "application/x-apple-diskimage"; } + if p == .linux { return "application/octet-stream"; } + return "application/x-msdownload"; // windows +} + +// ── failure detail ──────────────────────────────────────────────────── +// The error channel carries only a tag; the precise reason (which manifest +// field, which validation check, which artifact) is written into the +// caller's `jout.CliFailure` at the raise site, as a stable dotted code + +// a human sentence naming the offending input where one exists. + +manifest_code :: (e: mani.ManifestErr) -> string { + if e == error.BadJson { return "manifest.bad_json"; } + if e == error.WrongType { return "manifest.wrong_type"; } + if e == error.MissingField { return "manifest.missing_field"; } + if e == error.UnknownPlatform { return "manifest.unknown_platform"; } + if e == error.MissingArtifact { return "manifest.missing_artifact"; } + return "manifest.io"; +} + +manifest_message :: (e: mani.ManifestErr, path: string) -> string { + if e == error.BadJson { return concat("manifest is not valid JSON: ", path); } + if e == error.WrongType { return concat("manifest field has the wrong JSON type: ", path); } + if e == error.MissingField { return concat("manifest is missing a required field: ", path); } + if e == error.UnknownPlatform { return concat("manifest names an unknown platform id: ", path); } + if e == error.MissingArtifact { return concat("manifest references an artifact file that does not exist: ", path); } + return concat("manifest file could not be read: ", path); +} + +validation_code :: (r: ValidationReason) -> string { + if r == .missing_file { return "validation.missing_file"; } + if r == .size_mismatch { return "validation.size_mismatch"; } + if r == .digest_mismatch { return "validation.digest_mismatch"; } + if r == .content_type_denied { return "validation.content_type_denied"; } + return "validation.extension_mismatch"; +} + +validation_message :: (r: ValidationReason, path: string) -> string { + if r == .missing_file { return concat("artifact file disappeared before validation: ", path); } + if r == .size_mismatch { return concat("artifact size does not match the declared size: ", path); } + if r == .digest_mismatch { return concat("artifact sha256 does not match the declared digest: ", path); } + if r == .content_type_denied { return concat("artifact content type is not on the allow-list: ", path); } + return concat("artifact extension does not match its platform: ", path); +} + +// ── pipeline ────────────────────────────────────────────────────────── + +run_publish :: (manifest_path: string, store_dir: string, fail_out: *jout.CliFailure) -> (PublishOutcome, !PublishError) { + alloc := context.allocator; + + // 1. Validate the manifest (parse + on-disk artifact existence). + m, me := mani.load_manifest(manifest_path, alloc); + if me { + fail_out.code = manifest_code(me); + fail_out.message = manifest_message(me, manifest_path); + raise error.Manifest; + } + + base_dir := dirname(manifest_path); + abs := abs_store(store_dir); + now := now_secs(); + + // Seed the Repo from any prior state so separate CLI invocations SHARE + // state through the store: a pre-existing `/db.json` is loaded so + // find-or-create sees earlier apps and the integrity transaction sees + // earlier releases. A new version then ACCUMULATES (the app is found, not + // duplicated); re-publishing the SAME release id is rejected as a + // duplicate by the transaction. An absent db.json starts empty. The loaded + // model grows through its own owning allocator (`context.allocator`, the + // process-lifetime default), per the long-lived-container rule. + repo := Repo.init(); + if exists(path_join(store_dir, "db.json")) { + loaded, le := db.load(store_dir); + if le { + fail_out.code = "persist.load"; + fail_out.message = concat("existing db.json under the store could not be loaded: ", store_dir); + raise error.Persist; + } + repo = loaded; + } + st := Store.init(store_dir); + + // 2. Find or create the app (keyed by slug). + slug := m.app; + app_id := ""; + existing := repo.find_app_by_slug(slug); + if existing == null { + app_id = concat("app-", slug); + repo.create_app(App.{ + id = app_id, slug = slug, display_name = slug, + owner = "ci", visibility = .private, + created_at = now, updated_at = now, + }); + } else { + found := existing!; + app_id = found.id; + } + + // 3. Draft the release for this version/channel. + release_id := concat(concat(concat("rel-", slug), "-"), m.version); + rel := Release.{ + id = release_id, app_id = app_id, version = m.version, build = 1, + channel = m.channel, notes = "", created_by = "ci", + created_at = now, published_at = now, + }; + + // 4. Per artifact: store by digest -> validate -> build the entity. + arts : List(Artifact) = .{}; + out_arts : List(PublishedArtifact) = .{}; + + i := 0; + while i < m.artifacts.len { + ma := m.artifacts.items[i]; + src := path_join(base_dir, ma.path); + + // Content-address the bytes in memory via `put_bytes`. The streaming + // `put_file` path hashes through `hash.sha256_file`, which the sx + // LLVM backend miscompiles (codegen crash under `sx build`); reading + // the bytes and hashing them in memory yields the identical + // `objects/` key with no streaming hash. + sb := read_file(src); + if sb == null { + fail_out.code = "store.read"; + fail_out.message = concat("artifact file could not be read: ", src); + raise error.Store; + } + bytes := sb!; + actual_size := bytes.len; + + key, se := st.put_bytes(bytes); + if se { + fail_out.code = "store.write"; + fail_out.message = concat("artifact bytes could not be content-addressed into the store: ", src); + raise error.Store; + } + + // Declared expectation when present; derived from the stored object + // otherwise (so a no-declaration manifest validates trivially). + exp_size := if ma.size >= 0 then ma.size else actual_size; + exp_sha := if ma.sha256.len > 0 then ma.sha256 else key; + ct := if ma.content_type.len > 0 then ma.content_type else default_content_type(ma.platform); + + outcome := validate_artifact_file(src, exp_size, exp_sha, ma.platform, ct); + if outcome.status != .valid { + fail_out.code = validation_code(outcome.reason); + fail_out.message = validation_message(outcome.reason, src); + raise error.Validation; + } + + fname := if ma.filename.len > 0 then ma.filename else basename(src); + pname := db.platform_str(ma.platform); + + art := Artifact.{ + id = concat(concat(release_id, "-"), pname), + app_id = app_id, release_id = release_id, + platform = ma.platform, filename = fname, content_type = ct, + size_bytes = actual_size, sha256 = key, storage_key = key, + metadata = ma.metadata, validation_status = .valid, + }; + arts.append(art, alloc); + + url := concat(concat(concat("file://", abs), "/objects/"), key); + out_arts.append(PublishedArtifact.{ + id = art.id, platform_name = pname, size_bytes = actual_size, + sha256 = key, url = url, + }, alloc); + + i += 1; + } + + // 5. Publish via the integrity-checked transaction (channel promotion + // included). Rollback on failure is left intact for P3.4b. + chan := Channel.{ + app_id = app_id, name = m.channel, current_release_id = "", + policy = .manual, rollout_percent = 100, + }; + pe := false; + integ := false; + repo.publish(rel, @arts, chan) catch (e) { pe = true; integ = (e == error.Integrity); }; + if pe { + if integ { + fail_out.code = "transaction.integrity"; + fail_out.message = concat("publish rejected: the release/artifact/channel aggregate is identity-inconsistent (e.g. duplicate release id): ", release_id); + } else { + fail_out.code = "transaction.validation"; + fail_out.message = concat("publish rejected: the release, an artifact, or the channel failed domain validation: ", release_id); + } + raise error.Transaction; + } + + // Audit trail — only after a committed publish: one upload event per + // artifact, one publish event, one channel-promotion event. + j := 0; + while j < arts.len { + aid := arts.items[j].id; + repo.create_audit_event(AuditEvent.{ + id = concat("evt-upload-", aid), actor = "ci", + action = "artifact.upload", target_type = "artifact", + target_id = aid, metadata = "", created_at = now, + }); + j += 1; + } + repo.create_audit_event(AuditEvent.{ + id = concat("evt-publish-", release_id), actor = "ci", + action = "release.publish", target_type = "release", + target_id = release_id, metadata = "", created_at = now, + }); + repo.create_audit_event(AuditEvent.{ + id = concat(concat(concat("evt-promote-", app_id), "-"), m.channel), + actor = "ci", action = "channel.promote", target_type = "channel", + target_id = m.channel, metadata = "", created_at = now, + }); + + // 6. Persist the whole model under the store. + persist_err := false; + db.save(repo, store_dir) catch { persist_err = true; }; + if persist_err { + fail_out.code = "persist.save"; + fail_out.message = concat("db.json could not be written under the store: ", store_dir); + raise error.Persist; + } + + return PublishOutcome.{ + release_id = release_id, app_id = app_id, + version = m.version, channel = m.channel, + artifacts = out_arts, + }; +} + +// ── rendering ───────────────────────────────────────────────────────── + +// Serialize a successful publish as one stable JSON object into the caller +// buffer `dst`, returning the bytes written. Member order is fixed (status, +// release{id,app_id,version,channel}, artifacts[]{id,platform,size_bytes, +// sha256,url}) by `std.json`'s insertion-order guarantee. Overflow surfaces +// on the error channel. +write_json :: (o: *PublishOutcome, dst: []u8) -> (s64, !JsonError) { + gpa := GPA.init(); + root : Object = .{}; + root.put("status", .str("published"), xx gpa); + + rel : Object = .{}; + rel.put("id", .str(o.release_id), xx gpa); + rel.put("app_id", .str(o.app_id), xx gpa); + rel.put("version", .str(o.version), xx gpa); + rel.put("channel", .str(o.channel), xx gpa); + root.put("release", .object(rel), xx gpa); + + arts : Array = .{}; + i := 0; + while i < o.artifacts.len { + a := o.artifacts.items[i]; + ao : Object = .{}; + ao.put("id", .str(a.id), xx gpa); + ao.put("platform", .str(a.platform_name), xx gpa); + ao.put("size_bytes", .int_(a.size_bytes), xx gpa); + ao.put("sha256", .str(a.sha256), xx gpa); + ao.put("url", .str(a.url), xx gpa); + arts.add(.object(ao), xx gpa); + i += 1; + } + root.put("artifacts", .array(arts), xx gpa); + + rootv : Value = .object(root); + n := try write_to_buffer(rootv, dst); + return n; +} + +// A readable one-publish summary for non-json mode: the release line plus +// one indented line per artifact (platform, digest, download URL). +human_summary :: (o: *PublishOutcome) -> string { + s := concat("published release ", o.release_id); + s = concat(s, concat(" (", concat(o.app_id, concat(" ", concat(o.version, ")"))))); + s = concat(s, concat(" on channel ", concat(o.channel, "\n"))); + i := 0; + while i < o.artifacts.len { + a := o.artifacts.items[i]; + s = concat(s, concat(" ", concat(a.platform_name, concat(" ", concat(a.sha256, concat(" ", concat(a.url, "\n"))))))); + i += 1; + } + return s; +} diff --git a/src/release/ops.sx b/src/release/ops.sx new file mode 100644 index 0000000..7532e37 --- /dev/null +++ b/src/release/ops.sx @@ -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 `/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"); +} diff --git a/src/repo/db.sx b/src/repo/db.sx index f7e2932..6c3908c 100644 --- a/src/repo/db.sx +++ b/src/repo/db.sx @@ -21,7 +21,11 @@ #import "modules/std.sx"; #import "modules/std/json.sx"; -#import "modules/fs.sx"; +// Also reached through an alias so the json reader is called as `jsonp.parse` +// — a bare `parse` would bind to `std.cli`'s `parse` once both modules share +// one program (the `dist` CLI), which returns a different type. +jsonp :: #import "modules/std/json.sx"; +#import "modules/std/fs.sx"; #import "../domain/platform.sx"; #import "../domain/app.sx"; #import "../domain/release.sx"; @@ -222,17 +226,24 @@ save :: (self: *Repo, root_dir: string) -> !LoadErr { } // ── read-back helpers (strict; copy strings into `alloc`) ──────────── +// These carry a `db_` prefix because the `dist` program links this module +// alongside `manifest.sx`, which declares its own same-purpose `dup_str` / +// `obj_find` / `req_obj` / `req_arr` / `artifact_from_json`. sx resolves a +// bare top-level name across the WHOLE program (see the `jsonp` note above), +// so an unprefixed name here would bind to manifest's version and mismatch +// this module's `LoadErr` error set. The prefix keeps the load path bound to +// its own helpers. // Copy `s` into `alloc`-owned, null-terminated storage so it survives the // parse scratch / source buffer being freed. -dup_str :: (s: string, alloc: Allocator) -> string { - raw : [*]u8 = xx alloc.alloc(s.len + 1); +db_dup_str :: (s: string, alloc: Allocator) -> string { + raw : [*]u8 = xx alloc.alloc_bytes(s.len + 1); if s.len > 0 { memcpy(raw, s.ptr, s.len); } raw[s.len] = 0; return string.{ ptr = raw, len = s.len }; } -obj_find :: (o: Object, key: string) -> ?Value { +db_obj_find :: (o: Object, key: string) -> ?Value { i := 0; while i < o.len { if o.items[i].key == key { return o.items[i].val; } @@ -243,17 +254,17 @@ obj_find :: (o: Object, key: string) -> ?Value { // Required string field, copied into `alloc`. req_str :: (o: Object, key: string, alloc: Allocator) -> (string, !LoadErr) { - v := obj_find(o, key); + v := db_obj_find(o, key); if v == null { raise error.BadShape; } val := v!; if val != .str { raise error.BadShape; } - return dup_str(val.str, alloc); + return db_dup_str(val.str, alloc); } // Required string field as a borrowed VIEW (for enum-name parsing only; // not stored, so no copy needed). req_str_view :: (o: Object, key: string) -> (string, !LoadErr) { - v := obj_find(o, key); + v := db_obj_find(o, key); if v == null { raise error.BadShape; } val := v!; if val != .str { raise error.BadShape; } @@ -261,22 +272,22 @@ req_str_view :: (o: Object, key: string) -> (string, !LoadErr) { } req_int :: (o: Object, key: string) -> (s64, !LoadErr) { - v := obj_find(o, key); + v := db_obj_find(o, key); if v == null { raise error.BadShape; } val := v!; if val != .int_ { raise error.BadShape; } return val.int_; } -req_arr :: (o: Object, key: string) -> (Array, !LoadErr) { - v := obj_find(o, key); +db_req_arr :: (o: Object, key: string) -> (Array, !LoadErr) { + v := db_obj_find(o, key); if v == null { raise error.BadShape; } val := v!; if val != .array { raise error.BadShape; } return val.array; } -req_obj :: (v: Value) -> (Object, !LoadErr) { +db_req_obj :: (v: Value) -> (Object, !LoadErr) { if v != .object { raise error.BadShape; } return v.object; } @@ -287,10 +298,10 @@ app_from_json :: (o: Object, alloc: Allocator) -> (App, !LoadErr) { a.id = try req_str(o, "id", alloc); a.slug = try req_str(o, "slug", alloc); a.display_name = try req_str(o, "display_name", alloc); - bids := try req_arr(o, "bundle_ids"); + bids := try db_req_arr(o, "bundle_ids"); i := 0; while i < bids.len { - bo := try req_obj(bids.items[i]); + bo := try db_req_obj(bids.items[i]); p := try platform_from(try req_str_view(bo, "platform")); bid : BundleId = .{ platform = p, value = try req_str(bo, "value", alloc) }; a.bundle_ids.append(bid, alloc); @@ -317,7 +328,7 @@ release_from_json :: (o: Object, alloc: Allocator) -> (Release, !LoadErr) { return r; } -artifact_from_json :: (o: Object, alloc: Allocator) -> (Artifact, !LoadErr) { +db_artifact_from_json :: (o: Object, alloc: Allocator) -> (Artifact, !LoadErr) { a : Artifact = .{}; a.id = try req_str(o, "id", alloc); a.app_id = try req_str(o, "app_id", alloc); @@ -360,46 +371,46 @@ audit_from_json :: (o: Object, alloc: Allocator) -> (AuditEvent, !LoadErr) { load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr { oa := repo.own_allocator; - root_val, pe := parse(bytes, scratch); + root_val, pe := jsonp.parse(bytes, scratch); if pe { raise error.Parse; } - ro := try req_obj(root_val); + ro := try db_req_obj(root_val); - apps_arr := try req_arr(ro, "apps"); + apps_arr := try db_req_arr(ro, "apps"); i := 0; while i < apps_arr.len { - ao := try req_obj(apps_arr.items[i]); + ao := try db_req_obj(apps_arr.items[i]); repo.create_app(try app_from_json(ao, oa)); i += 1; } - rel_arr := try req_arr(ro, "releases"); + rel_arr := try db_req_arr(ro, "releases"); i = 0; while i < rel_arr.len { - o := try req_obj(rel_arr.items[i]); + o := try db_req_obj(rel_arr.items[i]); repo.create_release(try release_from_json(o, oa)); i += 1; } - art_arr := try req_arr(ro, "artifacts"); + art_arr := try db_req_arr(ro, "artifacts"); i = 0; while i < art_arr.len { - o := try req_obj(art_arr.items[i]); - repo.create_artifact(try artifact_from_json(o, oa)); + o := try db_req_obj(art_arr.items[i]); + repo.create_artifact(try db_artifact_from_json(o, oa)); i += 1; } - chan_arr := try req_arr(ro, "channels"); + chan_arr := try db_req_arr(ro, "channels"); i = 0; while i < chan_arr.len { - o := try req_obj(chan_arr.items[i]); + o := try db_req_obj(chan_arr.items[i]); repo.create_channel(try channel_from_json(o, oa)); i += 1; } - ev_arr := try req_arr(ro, "audit_events"); + ev_arr := try db_req_arr(ro, "audit_events"); i = 0; while i < ev_arr.len { - o := try req_obj(ev_arr.items[i]); + o := try db_req_obj(ev_arr.items[i]); repo.create_audit_event(try audit_from_json(o, oa)); i += 1; } diff --git a/src/store/store.sx b/src/store/store.sx index 367d870..1a8727c 100644 --- a/src/store/store.sx +++ b/src/store/store.sx @@ -26,7 +26,7 @@ // ===================================================================== #import "modules/std.sx"; -fs :: #import "modules/fs.sx"; +fs :: #import "modules/std/fs.sx"; hash :: #import "modules/std/hash.sx"; // Failure classes for a put. `Stage` covers a failed staging write, diff --git a/src/validation/artifact_file.sx b/src/validation/artifact_file.sx index 815e7dc..54b8cb8 100644 --- a/src/validation/artifact_file.sx +++ b/src/validation/artifact_file.sx @@ -23,7 +23,7 @@ // ===================================================================== #import "modules/std.sx"; -#import "modules/fs.sx"; +#import "modules/std/fs.sx"; #import "modules/std/hash.sx"; #import "../domain/platform.sx"; #import "../domain/artifact.sx"; diff --git a/tests/cli_dispatch.sx b/tests/cli_dispatch.sx index ec7f2ca..950c07c 100644 --- a/tests/cli_dispatch.sx +++ b/tests/cli_dispatch.sx @@ -8,17 +8,22 @@ // // 1. no args → human help/usage on STDERR + EX_USAGE (64). // 2. unknown command → human error on STDERR + EX_USAGE (64). -// 3. `ci publish --json` → STDOUT is a SINGLE valid JSON object (parses -// via std.json with no trailing junk); the human acknowledgement is -// on STDERR, never stdout. +// 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` / `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). // ===================================================================== #import "modules/std.sx"; -proc :: #import "modules/process.sx"; +proc :: #import "modules/std/process.sx"; json :: #import "modules/std/json.sx"; // True iff `needle` occurs in `hay`. Plain scan — the captured streams are @@ -62,30 +67,34 @@ 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 ci publish --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 == "ci publish", - "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 ci publish --json failed"); + 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 ci publish --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 ci publish --json (stderr) failed"); + proc.assert(false, "spawn build/dist release promote --json (stderr) failed"); } // ── 4. `--help` lists the ci / release groups, exits 0 ──────────── @@ -97,6 +106,22 @@ main :: () -> s32 { proc.assert(false, "spawn build/dist --help failed"); } + // ── 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") { + proc.assert(r.exit_code == 64, "ci publish 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 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; } diff --git a/tests/domain_validate.sx b/tests/domain_validate.sx index a5b8391..5423d82 100644 --- a/tests/domain_validate.sx +++ b/tests/domain_validate.sx @@ -108,7 +108,7 @@ check_rejects_bad_slug :: () -> bool { a := valid_app(); a.slug = "Bad_Slug"; // uppercase + underscore matched := false; - validate_app(a) catch e { matched = (e == error.BadSlug); }; + validate_app(a) catch (e) { matched = (e == error.BadSlug); }; return matched; } @@ -116,7 +116,7 @@ check_rejects_empty_version :: () -> bool { r := valid_release(); r.version = ""; matched := false; - validate_release(r) catch e { matched = (e == error.EmptyVersion); }; + validate_release(r) catch (e) { matched = (e == error.EmptyVersion); }; return matched; } @@ -124,7 +124,7 @@ check_rejects_bad_version :: () -> bool { r := valid_release(); r.version = "1.2"; // missing PATCH component matched := false; - validate_release(r) catch e { matched = (e == error.BadVersion); }; + validate_release(r) catch (e) { matched = (e == error.BadVersion); }; return matched; } @@ -137,7 +137,7 @@ check_rejects_bad_channel :: () -> bool { c := valid_channel(); c.name = "Bad Channel"; // space + uppercase matched := false; - validate_channel(c) catch e { matched = (e == error.BadChannelName); }; + validate_channel(c) catch (e) { matched = (e == error.BadChannelName); }; return matched; } @@ -145,7 +145,7 @@ check_rejects_empty_content_type :: () -> bool { a := valid_artifact(); a.content_type = ""; // required string cleared matched := false; - validate_artifact(a) catch e { matched = (e == error.MissingField); }; + validate_artifact(a) catch (e) { matched = (e == error.MissingField); }; return matched; } @@ -153,7 +153,7 @@ check_rejects_bad_size :: () -> bool { a := valid_artifact(); a.size_bytes = -1; // a content-addressed artifact has positive bytes matched := false; - validate_artifact(a) catch e { matched = (e == error.BadSize); }; + validate_artifact(a) catch (e) { matched = (e == error.BadSize); }; return matched; } @@ -161,7 +161,7 @@ check_rejects_bad_digest :: () -> bool { a := valid_artifact(); a.sha256 = "not-a-sha"; // not 64 lowercase-hex chars matched := false; - validate_artifact(a) catch e { matched = (e == error.BadDigest); }; + validate_artifact(a) catch (e) { matched = (e == error.BadDigest); }; return matched; } diff --git a/tests/manifest_parse.sx b/tests/manifest_parse.sx index a3d111c..1e83cfd 100644 --- a/tests/manifest_parse.sx +++ b/tests/manifest_parse.sx @@ -60,7 +60,7 @@ check_missing_artifact_path :: (alloc: Allocator) -> bool { if pe { return false; } // parse must not fail here raised := false; matched := false; - validate_manifest(m, "examples") catch err { raised = true; matched = (err == error.MissingArtifact); }; + validate_manifest(m, "examples") catch (err) { raised = true; matched = (err == error.MissingArtifact); }; return raised and matched; } diff --git a/tests/publish_fail.sx b/tests/publish_fail.sx new file mode 100644 index 0000000..8247326 --- /dev/null +++ b/tests/publish_fail.sx @@ -0,0 +1,142 @@ +// Pinned acceptance for P3.4b — failure paths are loud and machine-readable. +// +// Drives the BUILT `build/dist` binary (via `process.run`, like +// publish_persist.sx) through every publish failure class the slice plan +// names — a malformed manifest, a missing artifact file, an unknown +// platform id, a declared-size mismatch, and a declared-sha256 mismatch — +// and asserts the P3.4b contract for each: +// +// * exit code 1 (command failed; NOT the parser's EX_USAGE 64), +// * stdout under `--json` is a SINGLE JSON object +// `{"status":"error","error":{"code":,"message":...}}`, +// * nothing is persisted: a fresh store gains no db.json. +// +// The no-partial-state crux is then asserted against a NON-EMPTY store: +// publish version A successfully, fail version B on a digest mismatch into +// the SAME store, and require db.json byte-state unchanged — one release, +// channel still pointing at A (no partially-published release, no moved +// channel 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/publish_fail"; +MDIR :: ".sx-tmp/publish_fail_m"; + +// Manifests share the committed 5-byte fixtures; paths resolve relative to +// the manifest's own directory (MDIR), so ../../examples/fixtures reaches +// them. +GOOD_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; +BAD_DIGEST :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\"}]}"; +BAD_SIZE :: "{\"app\":\"acme-app\",\"version\":\"1.2.5\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"size\":9999}]}"; +BAD_PLATFORM:: "{\"app\":\"acme-app\",\"version\":\"1.2.6\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"playstation\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; +NO_ARTIFACT :: "{\"app\":\"acme-app\",\"version\":\"1.2.7\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"no-such-file.apk\"}]}"; +NOT_JSON :: "this is not a manifest"; + +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; } + +publish_cmd :: (mpath: string, store: string) -> string { + c := concat("build/dist ci publish --manifest ", mpath); + c = concat(c, concat(" --local-store ", store)); + return concat(c, " --json 2>/dev/null"); +} + +// Write `body` to `path` via the shell (single-quoted, so the JSON's double +// quotes pass through literally). +write_file :: (path: string, body: string) { + cmd := concat(concat(concat("printf '%s' '", body), "' > "), path); + process.run(cmd); +} + +// Run one failing publish and assert the full failure contract: exit 1, +// stdout is exactly one JSON error object, and its error.code equals +// `want_code`. `store` distinguishes the per-case fresh stores. +assert_fails :: (label: string, mpath: string, store: string, want_code: string, scratch: Allocator) { + r := process.run(publish_cmd(mpath, store)); + process.assert(r != null, concat("spawn failed: ", label)); + res := r!; + process.assert(res.exit_code == 1, concat("must exit 1 (command failed): ", label)); + + v, e := parse(res.stdout, scratch); + if e { process.assert(false, concat("stdout must be one JSON object: ", label)); return; } + o := v.object; + process.assert(get_str(o, "status") == "error", concat("status must be \"error\": ", label)); + eo := get_obj(o, "error"); + process.assert(get_str(eo, "code") == want_code, concat("error.code mismatch: ", label)); + process.assert(get_str(eo, "message").len > 0, concat("error.message must be non-empty: ", label)); + out(concat(concat(" ", label), ": exit 1 + JSON error ok\n")); +} + +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, "good_a.json"), GOOD_A); + write_file(path_join(MDIR, "bad_digest.json"), BAD_DIGEST); + write_file(path_join(MDIR, "bad_size.json"), BAD_SIZE); + write_file(path_join(MDIR, "bad_platform.json"), BAD_PLATFORM); + write_file(path_join(MDIR, "no_artifact.json"), NO_ARTIFACT); + write_file(path_join(MDIR, "not_json.json"), NOT_JSON); + + // ── each failure class: exit 1 + the precise dotted code, into a + // fresh per-case store that must gain NO db.json ──────────────── + assert_fails("digest mismatch", path_join(MDIR, "bad_digest.json"), concat(STORE, "-digest"), "validation.digest_mismatch", xx arena); + assert_fails("size mismatch", path_join(MDIR, "bad_size.json"), concat(STORE, "-size"), "validation.size_mismatch", xx arena); + assert_fails("unknown platform", path_join(MDIR, "bad_platform.json"), concat(STORE, "-platform"), "manifest.unknown_platform", xx arena); + assert_fails("missing artifact", path_join(MDIR, "no_artifact.json"), concat(STORE, "-missing"), "manifest.missing_artifact", xx arena); + assert_fails("malformed manifest",path_join(MDIR, "not_json.json"), concat(STORE, "-badjson"), "manifest.bad_json", xx arena); + + process.assert(!fs.exists(concat(STORE, "-digest/db.json")), + "failed publish into a fresh store must not create db.json"); + + // ── no-partial-state against a NON-EMPTY store ──────────────────── + // Publish A successfully, then fail B on a digest mismatch into the + // SAME store: db.json must be unchanged (one release, channel → A). + ra := process.run(publish_cmd(path_join(MDIR, "good_a.json"), STORE)); + process.assert(ra != null, "spawn publish A failed"); + process.assert(ra!.exit_code == 0, "publish A must exit 0"); + + rb := process.run(publish_cmd(path_join(MDIR, "bad_digest.json"), STORE)); + process.assert(rb != null, "spawn failing publish B failed"); + process.assert(rb!.exit_code == 1, "publish B must exit 1 (digest mismatch)"); + + db_bytes := fs.read_file(path_join(STORE, "db.json")); + process.assert(db_bytes != null, "db.json from publish A must still exist"); + dv, de := parse(db_bytes!, xx arena); + if de { process.assert(false, "db.json must be valid JSON"); return 1; } + dbo := dv.object; + process.assert(get_arr(dbo, "releases").len == 1, "after failed B: db still has ONE release (A)"); + chans := get_arr(dbo, "channels"); + process.assert(chans.len == 1, "after failed B: one channel"); + process.assert(get_str(chans.items[0].object, "current_release_id") == "rel-acme-app-1.2.3", + "after failed B: channel still points at A (no moved pointer)"); + print(" non-empty store: failed publish left db.json unchanged\n"); + + process.run(concat("rm -rf ", concat(STORE, "-digest"))); + process.run(concat("rm -rf ", concat(STORE, "-size"))); + process.run(concat("rm -rf ", concat(STORE, "-platform"))); + process.run(concat("rm -rf ", concat(STORE, "-missing"))); + process.run(concat("rm -rf ", concat(STORE, "-badjson"))); + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + print("publish_fail: ALL CASES PASS\n"); + return 0; +} diff --git a/tests/publish_happy.sx b/tests/publish_happy.sx new file mode 100644 index 0000000..f04cac8 --- /dev/null +++ b/tests/publish_happy.sx @@ -0,0 +1,171 @@ +// Acceptance for P3.4a — the local `dist ci publish` SUCCESS pipeline. +// +// Drives the BUILT `build/dist` binary (via `process.run`, like +// cli_dispatch.sx) on `examples/dist.json` into a FRESH `.sx-tmp/` store and +// asserts the end-to-end publish: +// +// 1. exit 0; stdout in `--json` mode is a SINGLE valid JSON object +// (parsed via std.json, no trailing junk). +// 2. the emitted release id / artifact ids / sha256 / local URLs MATCH the +// store: each `/objects/` exists and re-hashes (std.hash) +// to its own key, and each url is `file:///objects/`. +// 3. `/db.json` (re-parsed via std.json) records the release, both +// artifacts (storage_key == sha256, validation_status valid), the +// channel pointer (current_release_id == the release), and an audit +// event per upload/publish/promotion. +// +// This FAILS against the pre-P3.4a stub (which rejects --manifest / +// --local-store as unknown flags, exiting 64, and writes no store) and +// PASSES against the real pipeline. Fresh store per run. +#import "modules/std.sx"; +#import "modules/std/json.sx"; +process :: #import "modules/std/process.sx"; +fs :: #import "modules/std/fs.sx"; +hash :: #import "modules/std/hash.sx"; + +cstd :: #library "c"; +c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd"; + +STORE_REL :: ".sx-tmp/publish_happy"; + +// Process cwd, so the absolute store path / download URLs can be rebuilt +// exactly as the publish does. +cwd :: () -> string { + buf : [4096]u8 = ---; + r := c_getcwd(@buf[0], 4096); + process.assert(cast(s64) r != 0, "getcwd must succeed"); + n := 0; + while buf[n] != 0 { n += 1; } + return substr(string.{ ptr = @buf[0], len = n }, 0, n); +} + +// Fetch a member value by key, asserting presence (the publish output is a +// fixed shape, so an absent key is a hard failure). +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; } + +// True iff the object at `path` re-hashes (std.hash) to `want` (its key). +rehashes_to :: (path: string, want: string) -> bool { + maybe := hash.sha256_file(path); + if maybe == null { return false; } + d := maybe!; + view := string.{ ptr = @d[0], len = 64 }; + return view == want; +} + +// Count audit events whose "action" equals `action`. +count_action :: (events: Array, action: string) -> s64 { + c : s64 = 0; + i := 0; + while i < events.len { + eo := events.items[i].object; + if get_str(eo, "action") == action { c += 1; } + i += 1; + } + return c; +} + +main :: () -> s32 { + gpa := GPA.init(); + arena := Arena.init(xx gpa, 1 << 20); + defer arena.deinit(); + + base := cwd(); + store_abs := path_join(base, STORE_REL); + + // Fresh store, even after a crashed prior run. + process.run(concat("rm -rf ", STORE_REL)); + + // ── 1. Run the real publish; stdout must be one JSON object, exit 0 ── + r := process.run("build/dist ci publish --manifest examples/dist.json --local-store .sx-tmp/publish_happy --json 2>/dev/null"); + process.assert(r != null, "spawn build/dist ci publish failed"); + res := r!; + process.assert(res.exit_code == 0, "publish must exit 0 (EX_OK)"); + + v, e := parse(res.stdout, xx arena); + if e { process.assert(false, "stdout must be a single valid JSON object (parse failed / trailing junk)"); return 1; } + root := v.object; + + process.assert(get_str(root, "status") == "published", "status must be published"); + + rel := get_obj(root, "release"); + rel_id := get_str(rel, "id"); + process.assert(rel_id == "rel-acme-app-1.2.3", "release id derived from slug+version"); + process.assert(get_str(rel, "version") == "1.2.3", "release version"); + process.assert(get_str(rel, "channel") == "stable", "release channel"); + print(" release {} published\n", rel_id); + + // ── 2. Each artifact: object exists at objects/, re-hashes to + // its key, and its url is file:///objects/ ── + arts := get_arr(root, "artifacts"); + process.assert(arts.len == 2, "two artifacts published"); + i := 0; + while i < arts.len { + ao := arts.items[i].object; + sha := get_str(ao, "sha256"); + url := get_str(ao, "url"); + process.assert(sha.len == 64, "sha256 is a 64-char digest"); + + obj_path := path_join(STORE_REL, concat("objects/", sha)); + process.assert(fs.exists(obj_path), "object exists at objects/"); + process.assert(rehashes_to(obj_path, sha), "stored object re-hashes to its key"); + + want_url := concat(concat(concat("file://", store_abs), "/objects/"), sha); + process.assert(url == want_url, "url is file:///objects/"); + + process.assert(get_str(ao, "id").len > 0, "artifact id present"); + i += 1; + } + print(" {} artifacts stored + re-hash to their keys\n", arts.len); + + // ── 3. db.json records the published aggregate ────────────────────── + db_bytes := fs.read_file(path_join(STORE_REL, "db.json")); + process.assert(db_bytes != null, "db.json must exist under the store"); + dv, de := parse(db_bytes!, xx arena); + if de { process.assert(false, "db.json must be valid JSON"); return 1; } + dbo := dv.object; + + db_rels := get_arr(dbo, "releases"); + process.assert(db_rels.len == 1, "db: one release"); + process.assert(get_str(db_rels.items[0].object, "id") == rel_id, "db: release id matches"); + + db_arts := get_arr(dbo, "artifacts"); + process.assert(db_arts.len == 2, "db: two artifacts"); + j := 0; + while j < db_arts.len { + dao := db_arts.items[j].object; + process.assert(get_str(dao, "storage_key") == get_str(dao, "sha256"), + "db: storage_key == sha256 (content-addressed)"); + process.assert(get_str(dao, "validation_status") == "valid", + "db: artifact validation passed"); + process.assert(get_str(dao, "release_id") == rel_id, "db: artifact belongs to the release"); + j += 1; + } + + db_chans := get_arr(dbo, "channels"); + process.assert(db_chans.len == 1, "db: one channel"); + ch := db_chans.items[0].object; + process.assert(get_str(ch, "name") == "stable", "db: channel name"); + process.assert(get_str(ch, "current_release_id") == rel_id, "db: channel points at the release"); + + db_events := get_arr(dbo, "audit_events"); + process.assert(count_action(db_events, "artifact.upload") == 2, "db: one upload event per artifact"); + process.assert(count_action(db_events, "release.publish") == 1, "db: one publish event"); + process.assert(count_action(db_events, "channel.promote") == 1, "db: one promotion event"); + print(" db.json records release/artifacts/channel + {} audit events\n", db_events.len); + + process.run(concat("rm -rf ", STORE_REL)); + print("publish_happy: ALL CASES PASS\n"); + return 0; +} diff --git a/tests/publish_persist.sx b/tests/publish_persist.sx new file mode 100644 index 0000000..f0a967c --- /dev/null +++ b/tests/publish_persist.sx @@ -0,0 +1,185 @@ +// Regression for P3.4a-001 — `dist ci publish` must LOAD an existing +// `/db.json` before publishing, so separate CLI invocations SHARE +// state through the store (not start from an empty Repo and clobber it). +// +// Drives the BUILT `build/dist` binary (via `process.run`, like +// publish_happy.sx) twice into ONE store and asserts cross-invocation +// persistence: +// +// 1. Publish version A (1.2.3) into a fresh store → db.json has the release. +// 2. Publish a DIFFERENT version B (1.2.4) of the SAME app into the SAME +// store → exit 0, and db.json now records BOTH releases under ONE app +// (the app is FOUND, not duplicated); the channel points at the latest +// release B; both content-addressed objects exist. +// 3. Re-publishing the SAME release id (A again) into the same store FAILS +// (exit != 0 — the P2.3 integrity transaction rejects the duplicate +// release id) and leaves db.json UNCHANGED (still two releases). +// +// FAIL-BEFORE / PASS-AFTER: against the pre-fix publish (which never reads +// db.json and so begins every invocation from an EMPTY Repo) step 2 CLOBBERS +// A — db.json ends with one release, not two — and step 3 "succeeds" (exit 0) +// and overwrites. Both assertions fail. After the load-then-merge fix they +// pass. Fresh store per run. +#import "modules/std.sx"; +#import "modules/std/json.sx"; +process :: #import "modules/std/process.sx"; +fs :: #import "modules/std/fs.sx"; + +STORE :: ".sx-tmp/publish_persist"; +MDIR :: ".sx-tmp/publish_persist_m"; +A_PATH :: ".sx-tmp/publish_persist_m/a.json"; +B_PATH :: ".sx-tmp/publish_persist_m/b.json"; + +// Two manifests for the SAME app/channel/fixtures, differing only in version. +// Artifact paths resolve relative to the manifest's own directory (MDIR), so +// `../../examples/fixtures/...` reaches the repo's committed fixtures. +MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"},{\"platform\":\"ios\",\"path\":\"../../examples/fixtures/acme-1.2.3-ios.ipa\"}]}"; +MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"},{\"platform\":\"ios\",\"path\":\"../../examples/fixtures/acme-1.2.3-ios.ipa\"}]}"; + +// Fetch a member value by key, asserting presence (the publish/db output is a +// fixed shape, so an absent key is a hard failure). +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 whose "action" equals `action`. +count_action :: (events: Array, action: string) -> s64 { + c : s64 = 0; + i := 0; + while i < events.len { + eo := events.items[i].object; + if get_str(eo, "action") == action { c += 1; } + i += 1; + } + return c; +} + +// True iff `releases` (a db.json array) contains a release with id `id`. +has_release :: (releases: Array, id: string) -> bool { + i := 0; + while i < releases.len { + if get_str(releases.items[i].object, "id") == id { return true; } + i += 1; + } + return false; +} + +// `build/dist ci publish` for `mpath` into the shared store, JSON mode, +// stderr suppressed so stdout stays pure for the parser. +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"); +} + +// Write `body` to `path` via the shell (single-quoted, so the JSON's double +// quotes pass through literally). +write_file :: (path: string, body: string) { + cmd := concat(concat(concat("printf '%s' '", body), "' > "), path); + process.run(cmd); +} + +// Parse `/db.json` into its root object (re-read fresh each call). +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; +} + +main :: () -> s32 { + gpa := GPA.init(); + arena := Arena.init(xx gpa, 1 << 20); + defer arena.deinit(); + + // Fresh store + manifest dir, even after a crashed prior run. + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + process.run(concat("mkdir -p ", MDIR)); + write_file(A_PATH, MANIFEST_A); + write_file(B_PATH, MANIFEST_B); + + rel_a := "rel-acme-app-1.2.3"; + rel_b := "rel-acme-app-1.2.4"; + + // ── 1. Publish version A into the fresh store ─────────────────────── + ra := process.run(publish_cmd(A_PATH)); + process.assert(ra != null, "spawn publish A failed"); + res_a := ra!; + process.assert(res_a.exit_code == 0, "publish A must exit 0"); + va, ea := parse(res_a.stdout, xx arena); + if ea { process.assert(false, "publish A stdout must be one JSON object"); return 1; } + process.assert(get_str(get_obj(va.object, "release"), "id") == rel_a, "A release id"); + + db1 := load_db(xx arena); + process.assert(get_arr(db1, "releases").len == 1, "after A: db has one release"); + process.assert(get_arr(db1, "apps").len == 1, "after A: db has one app"); + print(" A published; db has 1 release\n"); + + // ── 2. Publish a DIFFERENT version B into the SAME store ──────────── + rb := process.run(publish_cmd(B_PATH)); + process.assert(rb != null, "spawn publish B failed"); + res_b := rb!; + process.assert(res_b.exit_code == 0, "publish B must exit 0 (accumulates)"); + vb, eb := parse(res_b.stdout, xx arena); + if eb { process.assert(false, "publish B stdout must be one JSON object"); return 1; } + process.assert(get_str(get_obj(vb.object, "release"), "id") == rel_b, "B release id"); + + db2 := load_db(xx arena); + + // The crux: BOTH releases under ONE app (app found, not duplicated). + db2_rels := get_arr(db2, "releases"); + process.assert(db2_rels.len == 2, "after B: db records BOTH releases (no clobber)"); + process.assert(has_release(db2_rels, rel_a), "after B: release A still present"); + process.assert(has_release(db2_rels, rel_b), "after B: release B present"); + process.assert(get_arr(db2, "apps").len == 1, "after B: still ONE app (found, not duplicated)"); + + // Four artifacts (two per release); channel promoted to the latest (B). + process.assert(get_arr(db2, "artifacts").len == 4, "after B: four artifacts (two per release)"); + db2_chans := get_arr(db2, "channels"); + process.assert(db2_chans.len == 1, "after B: one channel"); + process.assert(get_str(db2_chans.items[0].object, "current_release_id") == rel_b, + "after B: channel promoted to the latest release"); + + // Each artifact's content-addressed object exists on disk. + db2_arts := get_arr(db2, "artifacts"); + k := 0; + while k < db2_arts.len { + sha := get_str(db2_arts.items[k].object, "sha256"); + process.assert(fs.exists(path_join(STORE, concat("objects/", sha))), + "after B: object exists at objects/"); + k += 1; + } + + // Audit accumulated across both publishes (>= 2 publish events). + process.assert(count_action(get_arr(db2, "audit_events"), "release.publish") == 2, + "after B: one publish event per release"); + print(" B accumulated; db has 2 releases under 1 app\n"); + + // ── 3. Re-publish the SAME release id (A) → duplicate is rejected ─── + rdup := process.run(publish_cmd(A_PATH)); + process.assert(rdup != null, "spawn duplicate publish failed"); + res_dup := rdup!; + process.assert(res_dup.exit_code != 0, "re-publishing the same release id must FAIL (duplicate)"); + + db3 := load_db(xx arena); + process.assert(get_arr(db3, "releases").len == 2, "after duplicate: db UNCHANGED (still two releases)"); + process.assert(get_arr(db3, "apps").len == 1, "after duplicate: still one app"); + print(" duplicate release id rejected; db unchanged\n"); + + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + print("publish_persist: ALL CASES PASS\n"); + return 0; +} diff --git a/tests/release_ops.sx b/tests/release_ops.sx new file mode 100644 index 0000000..cc14a5a --- /dev/null +++ b/tests/release_ops.sx @@ -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 ` → 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; +} diff --git a/tests/repo_owns_allocator.sx b/tests/repo_owns_allocator.sx index 471fe90..68960cd 100644 --- a/tests/repo_owns_allocator.sx +++ b/tests/repo_owns_allocator.sx @@ -11,7 +11,7 @@ // growth point wrongly used `context.allocator`, the count would not move // (the default allocator is a different, untracked one). #import "modules/std.sx"; -process :: #import "modules/process.sx"; +process :: #import "modules/std/process.sx"; #import "../src/domain/platform.sx"; #import "../src/domain/app.sx"; #import "../src/domain/release.sx"; diff --git a/tests/repo_roundtrip.sx b/tests/repo_roundtrip.sx index 2416da6..c85368a 100644 --- a/tests/repo_roundtrip.sx +++ b/tests/repo_roundtrip.sx @@ -11,8 +11,8 @@ // every assertion holds (process.assert aborts otherwise). #import "modules/std.sx"; #import "modules/std/json.sx"; -#import "modules/fs.sx"; -process :: #import "modules/process.sx"; +#import "modules/std/fs.sx"; +process :: #import "modules/std/process.sx"; #import "../src/domain/platform.sx"; #import "../src/domain/app.sx"; #import "../src/domain/release.sx"; diff --git a/tests/repo_transaction.sx b/tests/repo_transaction.sx index 4bd5474..1659991 100644 --- a/tests/repo_transaction.sx +++ b/tests/repo_transaction.sx @@ -19,7 +19,7 @@ // are absent. // Uses a fresh `` under `.sx-tmp/` and cleans up. #import "modules/std.sx"; -process :: #import "modules/process.sx"; +process :: #import "modules/std/process.sx"; #import "../src/domain/platform.sx"; #import "../src/domain/app.sx"; #import "../src/domain/release.sx"; @@ -99,7 +99,7 @@ main :: () -> s32 { arts1.append(mk_artifact("art_01b", "rel_01", "not-a-sha")); // invalid digest failed := false; was_validation := false; - repo.publish(mk_release("rel_01", "1.1.0"), @arts1, the_channel()) catch e { + repo.publish(mk_release("rel_01", "1.1.0"), @arts1, the_channel()) catch (e) { failed = true; was_validation = (e == error.Validation); }; @@ -123,7 +123,7 @@ main :: () -> s32 { arts2.append(mk_artifact("art_02", "WRONG", DIGEST_B)); // release_id mismatch ifailed := false; was_integrity := false; - repo.publish(mk_release("rel_02", "1.2.0"), @arts2, the_channel()) catch e { + repo.publish(mk_release("rel_02", "1.2.0"), @arts2, the_channel()) catch (e) { ifailed = true; was_integrity = (e == error.Integrity); }; @@ -145,7 +145,7 @@ main :: () -> s32 { arts_xc.append(mk_artifact("art_xc", "rel_xc", DIGEST_B)); // self-consistent artifact xc_failed := false; xc_integrity := false; - repo.publish(mk_release("rel_xc", "1.3.0"), @arts_xc, mk_channel_for("app_02")) catch e { + repo.publish(mk_release("rel_xc", "1.3.0"), @arts_xc, mk_channel_for("app_02")) catch (e) { xc_failed = true; xc_integrity = (e == error.Integrity); }; @@ -170,7 +170,7 @@ main :: () -> s32 { arts_xa.append(mk_artifact_for("art_xa", "app_02", "rel_xa", DIGEST_B)); xa_failed := false; xa_integrity := false; - repo.publish(mk_release("rel_xa", "1.4.0"), @arts_xa, the_channel()) catch e { + repo.publish(mk_release("rel_xa", "1.4.0"), @arts_xa, the_channel()) catch (e) { xa_failed = true; xa_integrity = (e == error.Integrity); }; @@ -196,7 +196,7 @@ main :: () -> s32 { arts_cn.append(mk_artifact("art_cn", "rel_cn", DIGEST_B)); // self-consistent artifact cn_failed := false; cn_integrity := false; - repo.publish(mk_release("rel_cn", "1.5.0"), @arts_cn, mk_named_channel("app_01", "beta")) catch e { + repo.publish(mk_release("rel_cn", "1.5.0"), @arts_cn, mk_named_channel("app_01", "beta")) catch (e) { cn_failed = true; cn_integrity = (e == error.Integrity); }; @@ -222,7 +222,7 @@ main :: () -> s32 { arts_dup.append(mk_artifact("art_dup", "rel_00", DIGEST_B)); dup_failed := false; dup_integrity := false; - repo.publish(mk_release("rel_00", "9.9.9"), @arts_dup, the_channel()) catch e { + repo.publish(mk_release("rel_00", "9.9.9"), @arts_dup, the_channel()) catch (e) { dup_failed = true; dup_integrity = (e == error.Integrity); }; diff --git a/tests/store_content_addressed.sx b/tests/store_content_addressed.sx index 5832028..efdb23c 100644 --- a/tests/store_content_addressed.sx +++ b/tests/store_content_addressed.sx @@ -13,9 +13,9 @@ // 4. put_file — a file source produces the same key and bytes. // Exits 0 only if every assertion holds (process.assert aborts otherwise). #import "modules/std.sx"; -fs :: #import "modules/fs.sx"; +fs :: #import "modules/std/fs.sx"; hash :: #import "modules/std/hash.sx"; -process :: #import "modules/process.sx"; +process :: #import "modules/std/process.sx"; #import "../src/store/store.sx"; // SHA-256("abc"), the FIPS 180-4 one-block known-answer vector.