Merge branch 'flow/distribution/P3.4a' into distribution-plan

This commit is contained in:
agra
2026-06-12 00:27:40 +03:00
25 changed files with 1747 additions and 396 deletions

View File

@@ -1,5 +1,16 @@
# Subplan 01 - sx Language And Standard Library # 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 57 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 ## Goal
Build the language and std primitives required before the distribution platform Build the language and std primitives required before the distribution platform

View File

@@ -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-id>/...`
## Run Creation
For each substantial task:
1. Create `.agents/runs/<run-id>/`.
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/<run-id>/
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/<run-id>/state.json`
- every `.agents/runs/<run-id>/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.

View File

@@ -1,31 +1,25 @@
# Detailed Subplan Index # Detailed Subplan Index
These subplans break `PLAN.md` into implementation slices that can be handed to These subplans break `PLAN.md` into implementation slices.
Snarky and Opus without reloading the whole plan every time.
## Reading Order ## 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` 2. `02-domain-and-storage.md`
3. `03-cli-and-ci.md` 3. `03-cli-and-ci.md`
4. `04-http-api-and-install.md` 4. `04-http-api-and-install.md`
5. `05-artifact-validation.md` 5. `05-artifact-validation.md`
6. `06-admin-ui.md` 6. `06-admin-ui.md`
7. `07-packaging-nas.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 multi-agent flow harness is retired; work happens directly in-session.
the active subplan before doing work. - Read `PLAN.md` and the active subplan before doing work. The active
- Keep work sequential. Do not use parallel implementation agents. milestone slice plan and step progress live in `current/`.
- Use git branches for implementation. Do not use worktrees. - Implementation is branch-based; `make test` must be green at each step
- For product or UX scope, Snarky writes acceptance criteria and has final say. boundary.
- 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.
## Slice Exit Criteria ## Slice Exit Criteria
@@ -35,10 +29,3 @@ Every slice should finish with:
- Tests/checks run listed. - Tests/checks run listed.
- Known risks listed. - Known risks listed.
- Next slice named. - 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.

View File

@@ -26,9 +26,13 @@ build:
test: build test: build
@SX="$(SX)" ./tests/run.sh @SX="$(SX)" ./tests/run.sh
# Placeholder for the end-to-end publish flow — becomes real in P3.4. # End-to-end local publish of examples/dist.json into a fresh .sx-tmp/
publish-example: # store, emitting the machine-readable JSON result on stdout. Depends on
@echo "publish-example: not implemented yet (becomes real in P3.4)" # `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: clean:
@rm -rf $(BUILD_DIR) @rm -rf $(BUILD_DIR)

88
PLAN.md
View File

@@ -156,48 +156,53 @@ Deployment direction:
- UGREEN NAS deployment through Docker/Container Manager is a first-version - UGREEN NAS deployment through Docker/Container Manager is a first-version
requirement. requirement.
## sx Foundation Work ## sx Foundation Status
Before the product can be implemented well, `sx` needs a stronger language and Re-evaluated 2026-06-11 against the current sx tree. The original foundation
standard library 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. - Module system: alias imports, alias re-exports, namespace barrels,
- Alias imports and curated namespace barrels. one-level carry, dir-vs-file ambiguity rejection.
- Namespace member re-export syntax such as `pub print :: core.print`. - Error handling: the `!` error channel with `raise`/`catch`/`onfail`
- Error handling that follows the real sx model. `!` is an error channel, not a (bindings take parens), `?T` optionals.
generic result wrapper. - `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 - Strings: validated UTF-8 `String`, `StringBuilder`, explicit Unicode model
- Bytes and paths (byte length, scalar values, grapheme clusters, and display width are
- Filesystem and process APIs distinct; invalid UTF-8 must not silently become a `String`).
- Time, random, hashing, and encoding - Bytes and a full path module (only `path_join`/`basename`/`dirname` today).
- JSON, URL, MIME, config, CLI, and logging - Time/clock (publish shims `time(2)` via FFI), random, encodings
- HTTP server/client and TLS boundary (base64url, percent).
- SQLite - HTTP server/client and TLS boundary — blocks subplan 04 and remote publish
- Archive inspection (subplan 03 Slice 3).
- Testing helpers - SQLite — blocks subplan 02 Slice 2 (`db.json` stands in).
- Archive inspection — blocks deep IPA/APK validation (subplan 05).
Unicode must be specified precisely: - Config layering (env/file/CLI) and richer testing helpers.
- `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`.
## Implementation Phases ## 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: Phase 2 - product domain:
@@ -305,21 +310,10 @@ verifiable from the repo:
## Detailed Execution ## Detailed Execution
`PLAN.md` is the overview. Detailed implementation breakdowns live in `PLAN.md` is the overview. Slice breakdowns live in `.agents/subplans/`
`.agents/subplans/`. (0107). The active milestone slice plan and step progress live in `current/`
(`PLAN.md`, `CHECKPOINT-DISTRIBUTION.md`).
Before starting or resuming work, read: The multi-agent flow harness that originally executed this plan is retired;
work proceeds directly in-session, branch-based, with `make test` green at
- `.agents/ORCHESTRATION.md` each step boundary.
- `.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.

View File

@@ -2,29 +2,33 @@
// dist.sx — the `dist` distribution CLI entry point (subplan 03, Slice 1). // 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 // Wires the real process argv (via `std.cli`'s `os_args`) to subcommand
// handlers through `cli.parse`. The three groups/commands are dispatched // handlers through `cli.parse`.
// to STUB handlers for now — they acknowledge and emit a known result; the
// real publish/promote/rollback logic lands in P3.4/P3.5.
// //
// dist ci publish // dist ci publish local publish pipeline (P3.4a/b) — see publish.sx
// dist release promote // dist release promote point a channel at a release (P3.5) — see release/ops.sx
// dist release rollback // dist release rollback channel pointer to the previous release (P3.5)
// //
// EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with // EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with
// `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing // `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing
// group/command/flag ends with `exit_usage()` (EX_USAGE = 64). An // 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` // `--json` PURITY: every command accepts the reserved global `--json`
// flag (surfaced by the parser as `parsed.json`). In json mode stdout // flag (surfaced by the parser as `parsed.json`). In json mode stdout
// carries ONLY the machine-readable JSON object (emitted via `std.json`, // carries ONLY the machine-readable JSON object (emitted via `std.json`,
// isolated in `json_out.sx`); ALL human-readable text (help, progress, // isolated in `json_out.sx` / `publish.sx`); ALL human-readable text (help,
// errors) goes to stderr. In non-json mode human text on stdout is fine. // progress, errors) goes to stderr. In non-json mode human text on stdout
// is fine.
// ===================================================================== // =====================================================================
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/std/cli.sx"; #import "modules/std/cli.sx";
jout :: #import "json_out.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 // Direct stderr writer (fd 2), so human help/usage/progress never lands on
// stdout's data stream. `out` (std builtin) targets stdout (fd 1). // 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); } if json_mode { eputs(s); } else { out(s); }
} }
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI (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 <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
// True if `name` appears as a token in `args`. // True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool { has_flag :: (args: []string, name: string) -> bool {
@@ -61,34 +65,56 @@ error_phrase :: (e: CliError) -> string {
return "usage error"; return "usage error";
} }
// ── Stub handlers ──────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────
// Honest stubs: acknowledge the command and emit a known result. NO real
// publish/promote/rollback logic (that is P3.4/P3.5).
// 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) { 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");
handle_release_promote :: (p: *Parsed, json_mode: bool) {
ack("release promote", json_mode); fail : jout.CliFailure = .{};
} o, e := pl.run_publish(manifest_path, store_dir, @fail);
handle_release_rollback :: (p: *Parsed, json_mode: bool) { if e {
ack("release rollback", json_mode); report_failure("ci publish", fail, json_mode);
} }
// Emit a stub command's acknowledgement. In json mode: a single JSON if !e {
// 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 { if !json_mode {
out(concat(concat("dist: ", cmd), " (stub) ok\n")); out(pl.human_summary(@o));
return; return;
} }
eputs(concat(concat("dist: ", cmd), " (stub) acknowledged\n")); eputs("dist: ci publish ok\n");
raw : [16384]u8 = ---;
raw : [4096]u8 = ---;
werr := false; werr := false;
n := jout.write_stub(cmd, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 }; n := pl.write_json(@o, string.{ ptr = @raw[0], len = 16384 }) catch { werr = true; 0 };
if werr { if werr {
eputs("dist: internal error: JSON serialization failed\n"); eputs("dist: internal error: JSON serialization failed\n");
exit_usage(); exit_usage();
@@ -96,10 +122,66 @@ ack :: (cmd: string, json_mode: bool) {
out(string.{ ptr = @raw[0], len = n }); out(string.{ ptr = @raw[0], len = n });
out("\n"); out("\n");
} }
}
// Route a parsed (group, command) to its stub handler. `parse` only // `dist release promote` — point an (app, channel) at a release over the
// returns a (group, command) present in the table, so one arm always // persisted store (P3.5). Same rendering contract as publish: JSON object
// matches. // on stdout under --json (human note on stderr), readable summary
// otherwise, `report_failure` on abort.
handle_release_promote :: (p: *Parsed, json_mode: bool) {
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) {
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");
}
}
// 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) { dispatch :: (p: *Parsed, json_mode: bool) {
if p.group == "ci" and p.command == "publish" { handle_ci_publish(p, json_mode); return; } 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; } 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 // Command table + flag specs live in this scope; `Parsed` holds VIEWS
// into them, used before `main` returns. Per-command flags arrive with // into them, used before `main` returns. Every value flag below is
// the real handlers in P3.4/P3.5 — the global `--json` is recognized by // required; the global `--json` is recognized by the parser without
// the parser without being declared here. // being declared here.
no_flags : []FlagSpec = .[]; 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 = .[ cmds : []Command = .[
Command.{ group = "ci", command = "publish", flags = no_flags }, Command.{ group = "ci", command = "publish", flags = publish_flags },
Command.{ group = "release", command = "promote", flags = no_flags }, Command.{ group = "release", command = "promote", flags = promote_flags },
Command.{ group = "release", command = "rollback", flags = no_flags }, Command.{ group = "release", command = "rollback", flags = rollback_flags },
]; ];
diag : Diag = .{}; diag : Diag = .{};

View File

View File

@@ -9,15 +9,28 @@
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/std/json.sx"; #import "modules/std/json.sx";
// Serialize a stub command's machine result — `{ "command": <cmd>, // A machine-readable command failure: a stable dotted `code` naming the
// "status": "ok", "stub": true }` — into the caller-owned `dst`, returning // failing stage and sub-reason (e.g. "validation.digest_mismatch") plus a
// the number of bytes written. Overflow surfaces on the error channel. // human sentence. Pipelines fill one of these at the raise site, so the
write_stub :: (cmd: string, dst: []u8) -> (s64, !JsonError) { // 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": <code>, "message": <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(); gpa := GPA.init();
obj : Object = .{}; obj : Object = .{};
obj.put("command", .str(cmd), xx gpa); obj.put("status", .str("error"), xx gpa);
obj.put("status", .str("ok"), xx gpa); eo : Object = .{};
obj.put("stub", .bool_(true), xx gpa); 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); root : Value = .object(obj);
n := try write_to_buffer(root, dst); n := try write_to_buffer(root, dst);
return n; return n;

View File

@@ -25,7 +25,11 @@
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/std/json.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/platform.sx";
#import "../domain/validate.sx"; #import "../domain/validate.sx";
@@ -49,12 +53,20 @@ ManifestErr :: error {
// One artifact entry. `platform` + `path` are required; `filename`, // One artifact entry. `platform` + `path` are required; `filename`,
// `content_type`, and `metadata` are optional overrides that default to "" // `content_type`, and `metadata` are optional overrides that default to ""
// when absent. `metadata` is an opaque string mirroring Artifact.metadata. // 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 { ManifestArtifact :: struct {
platform: Platform; platform: Platform;
path: string; path: string;
filename: string; filename: string;
content_type: string; content_type: string;
metadata: string; metadata: string;
size: s64 = -1;
sha256: string = "";
} }
// A single publish: which app/version/channel, and the artifacts that make // 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 // Copy `s` into `alloc`-owned, null-terminated storage so the manifest
// survives the source bytes / parse scratch being dropped. // survives the source bytes / parse scratch being dropped.
dup_str :: (s: string, alloc: Allocator) -> string { 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); } if s.len > 0 { memcpy(raw, s.ptr, s.len); }
raw[s.len] = 0; raw[s.len] = 0;
return string.{ ptr = raw, len = s.len }; 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); 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 -> // Required array field. Absent -> MissingField; present-but-not-an-array ->
// WrongType. // WrongType.
req_arr :: (o: Object, key: string) -> (Array, !ManifestErr) { 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.filename = try opt_str(o, "filename", alloc);
a.content_type = try opt_str(o, "content_type", alloc); a.content_type = try opt_str(o, "content_type", alloc);
a.metadata = try opt_str(o, "metadata", 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; 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 // (WrongType), a missing/empty required field (MissingField), and an unknown
// artifact platform (UnknownPlatform). All strings are copied into `alloc`. // artifact platform (UnknownPlatform). All strings are copied into `alloc`.
parse_manifest :: (src: string, alloc: Allocator) -> (Manifest, !ManifestErr) { 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 pe { raise error.BadJson; }
if root != .object { raise error.WrongType; } if root != .object { raise error.WrongType; }
ro := root.object; ro := root.object;

408
src/publish/publish.sx Normal file
View File

@@ -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 `<store>/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
// `<store>/objects/<sha256>`, 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 `<store>/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://<abs-store>/objects/<sha256>`, where
// <abs-store> 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 `<store>/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/<sha256>` 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;
}

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

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

View File

@@ -21,7 +21,11 @@
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/std/json.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/platform.sx";
#import "../domain/app.sx"; #import "../domain/app.sx";
#import "../domain/release.sx"; #import "../domain/release.sx";
@@ -222,17 +226,24 @@ save :: (self: *Repo, root_dir: string) -> !LoadErr {
} }
// ── read-back helpers (strict; copy strings into `alloc`) ──────────── // ── 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 // Copy `s` into `alloc`-owned, null-terminated storage so it survives the
// parse scratch / source buffer being freed. // parse scratch / source buffer being freed.
dup_str :: (s: string, alloc: Allocator) -> string { db_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); } if s.len > 0 { memcpy(raw, s.ptr, s.len); }
raw[s.len] = 0; raw[s.len] = 0;
return string.{ ptr = raw, len = s.len }; return string.{ ptr = raw, len = s.len };
} }
obj_find :: (o: Object, key: string) -> ?Value { db_obj_find :: (o: Object, key: string) -> ?Value {
i := 0; i := 0;
while i < o.len { while i < o.len {
if o.items[i].key == key { return o.items[i].val; } 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`. // Required string field, copied into `alloc`.
req_str :: (o: Object, key: string, alloc: Allocator) -> (string, !LoadErr) { 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; } if v == null { raise error.BadShape; }
val := v!; val := v!;
if val != .str { raise error.BadShape; } 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; // Required string field as a borrowed VIEW (for enum-name parsing only;
// not stored, so no copy needed). // not stored, so no copy needed).
req_str_view :: (o: Object, key: string) -> (string, !LoadErr) { 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; } if v == null { raise error.BadShape; }
val := v!; val := v!;
if val != .str { raise error.BadShape; } 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) { 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; } if v == null { raise error.BadShape; }
val := v!; val := v!;
if val != .int_ { raise error.BadShape; } if val != .int_ { raise error.BadShape; }
return val.int_; return val.int_;
} }
req_arr :: (o: Object, key: string) -> (Array, !LoadErr) { db_req_arr :: (o: Object, key: string) -> (Array, !LoadErr) {
v := obj_find(o, key); v := db_obj_find(o, key);
if v == null { raise error.BadShape; } if v == null { raise error.BadShape; }
val := v!; val := v!;
if val != .array { raise error.BadShape; } if val != .array { raise error.BadShape; }
return val.array; return val.array;
} }
req_obj :: (v: Value) -> (Object, !LoadErr) { db_req_obj :: (v: Value) -> (Object, !LoadErr) {
if v != .object { raise error.BadShape; } if v != .object { raise error.BadShape; }
return v.object; 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.id = try req_str(o, "id", alloc);
a.slug = try req_str(o, "slug", alloc); a.slug = try req_str(o, "slug", alloc);
a.display_name = try req_str(o, "display_name", 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; i := 0;
while i < bids.len { 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")); p := try platform_from(try req_str_view(bo, "platform"));
bid : BundleId = .{ platform = p, value = try req_str(bo, "value", alloc) }; bid : BundleId = .{ platform = p, value = try req_str(bo, "value", alloc) };
a.bundle_ids.append(bid, alloc); a.bundle_ids.append(bid, alloc);
@@ -317,7 +328,7 @@ release_from_json :: (o: Object, alloc: Allocator) -> (Release, !LoadErr) {
return r; return r;
} }
artifact_from_json :: (o: Object, alloc: Allocator) -> (Artifact, !LoadErr) { db_artifact_from_json :: (o: Object, alloc: Allocator) -> (Artifact, !LoadErr) {
a : Artifact = .{}; a : Artifact = .{};
a.id = try req_str(o, "id", alloc); a.id = try req_str(o, "id", alloc);
a.app_id = try req_str(o, "app_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 { load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr {
oa := repo.own_allocator; oa := repo.own_allocator;
root_val, pe := parse(bytes, scratch); root_val, pe := jsonp.parse(bytes, scratch);
if pe { raise error.Parse; } 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; i := 0;
while i < apps_arr.len { 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)); repo.create_app(try app_from_json(ao, oa));
i += 1; i += 1;
} }
rel_arr := try req_arr(ro, "releases"); rel_arr := try db_req_arr(ro, "releases");
i = 0; i = 0;
while i < rel_arr.len { 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)); repo.create_release(try release_from_json(o, oa));
i += 1; i += 1;
} }
art_arr := try req_arr(ro, "artifacts"); art_arr := try db_req_arr(ro, "artifacts");
i = 0; i = 0;
while i < art_arr.len { while i < art_arr.len {
o := try req_obj(art_arr.items[i]); o := try db_req_obj(art_arr.items[i]);
repo.create_artifact(try artifact_from_json(o, oa)); repo.create_artifact(try db_artifact_from_json(o, oa));
i += 1; i += 1;
} }
chan_arr := try req_arr(ro, "channels"); chan_arr := try db_req_arr(ro, "channels");
i = 0; i = 0;
while i < chan_arr.len { 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)); repo.create_channel(try channel_from_json(o, oa));
i += 1; i += 1;
} }
ev_arr := try req_arr(ro, "audit_events"); ev_arr := try db_req_arr(ro, "audit_events");
i = 0; i = 0;
while i < ev_arr.len { 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)); repo.create_audit_event(try audit_from_json(o, oa));
i += 1; i += 1;
} }

View File

@@ -26,7 +26,7 @@
// ===================================================================== // =====================================================================
#import "modules/std.sx"; #import "modules/std.sx";
fs :: #import "modules/fs.sx"; fs :: #import "modules/std/fs.sx";
hash :: #import "modules/std/hash.sx"; hash :: #import "modules/std/hash.sx";
// Failure classes for a put. `Stage` covers a failed staging write, // Failure classes for a put. `Stage` covers a failed staging write,

View File

@@ -23,7 +23,7 @@
// ===================================================================== // =====================================================================
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/fs.sx"; #import "modules/std/fs.sx";
#import "modules/std/hash.sx"; #import "modules/std/hash.sx";
#import "../domain/platform.sx"; #import "../domain/platform.sx";
#import "../domain/artifact.sx"; #import "../domain/artifact.sx";

View File

@@ -8,17 +8,22 @@
// //
// 1. no args → human help/usage on STDERR + EX_USAGE (64). // 1. no args → human help/usage on STDERR + EX_USAGE (64).
// 2. unknown command → human error 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 // 3. a fully-flagged `release promote --json` against a store that was
// via std.json with no trailing junk); the human acknowledgement is // never published → exit 1 (command failed, NOT usage), and STDOUT is
// on STDERR, never stdout. // 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. // 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; // `make test` depends on `build`, so `build/dist` exists before this runs;
// the relative path resolves from the repo root (the `make test` cwd). // the relative path resolves from the repo root (the `make test` cwd).
// ===================================================================== // =====================================================================
#import "modules/std.sx"; #import "modules/std.sx";
proc :: #import "modules/process.sx"; proc :: #import "modules/std/process.sx";
json :: #import "modules/std/json.sx"; json :: #import "modules/std/json.sx";
// True iff `needle` occurs in `hay`. Plain scan — the captured streams are // 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"); proc.assert(false, "spawn build/dist bogus failed");
} }
// ── 3a. `--json` stdout purity: a single valid JSON object, nothing // ── 3a. `--json` stdout purity on a FAILURE path: a single valid
// else. `2>/dev/null` drops the human note so the pipe carries // JSON error object, nothing else. The store dir was never
// ONLY stdout; std.json.parse rejects trailing junk. ───────── // published into, so the command fails with `store.load` and
if r := proc.run("build/dist ci publish --json 2>/dev/null") { // exit 1 (command failed — distinct from usage's 64).
proc.assert(r.exit_code == 0, "stub --json command must succeed (EX_OK)"); // `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); 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)"); proc.assert(!e, "stdout in --json mode must be a single valid JSON object (parse failed / trailing junk)");
if !e { if !e {
o := v.object; o := v.object;
proc.assert(o.len == 3, "stub json object carries command/status/stub"); proc.assert(o.items[0].key == "status" and o.items[0].val.str == "error",
proc.assert(o.items[0].key == "command" and o.items[0].val.str == "ci publish", "failure json reports status error");
"stub json names the dispatched command"); eo := o.items[1].val.object;
proc.assert(o.items[1].key == "status" and o.items[1].val.str == "ok", proc.assert(eo.items[0].key == "code" and eo.items[0].val.str == "store.load",
"stub json reports status ok"); "failure json names the store.load code");
} }
} else { } 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) ────── // ── 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"); proc.assert(r.stdout.len > 0, "--json mode must still emit human text to stderr");
} else { } 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 ──────────── // ── 4. `--help` lists the ci / release groups, exits 0 ────────────
@@ -97,6 +106,22 @@ main :: () -> s32 {
proc.assert(false, "spawn build/dist --help failed"); 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"); print("cli_dispatch: ok\n");
return 0; return 0;
} }

View File

@@ -108,7 +108,7 @@ check_rejects_bad_slug :: () -> bool {
a := valid_app(); a := valid_app();
a.slug = "Bad_Slug"; // uppercase + underscore a.slug = "Bad_Slug"; // uppercase + underscore
matched := false; matched := false;
validate_app(a) catch e { matched = (e == error.BadSlug); }; validate_app(a) catch (e) { matched = (e == error.BadSlug); };
return matched; return matched;
} }
@@ -116,7 +116,7 @@ check_rejects_empty_version :: () -> bool {
r := valid_release(); r := valid_release();
r.version = ""; r.version = "";
matched := false; matched := false;
validate_release(r) catch e { matched = (e == error.EmptyVersion); }; validate_release(r) catch (e) { matched = (e == error.EmptyVersion); };
return matched; return matched;
} }
@@ -124,7 +124,7 @@ check_rejects_bad_version :: () -> bool {
r := valid_release(); r := valid_release();
r.version = "1.2"; // missing PATCH component r.version = "1.2"; // missing PATCH component
matched := false; matched := false;
validate_release(r) catch e { matched = (e == error.BadVersion); }; validate_release(r) catch (e) { matched = (e == error.BadVersion); };
return matched; return matched;
} }
@@ -137,7 +137,7 @@ check_rejects_bad_channel :: () -> bool {
c := valid_channel(); c := valid_channel();
c.name = "Bad Channel"; // space + uppercase c.name = "Bad Channel"; // space + uppercase
matched := false; matched := false;
validate_channel(c) catch e { matched = (e == error.BadChannelName); }; validate_channel(c) catch (e) { matched = (e == error.BadChannelName); };
return matched; return matched;
} }
@@ -145,7 +145,7 @@ check_rejects_empty_content_type :: () -> bool {
a := valid_artifact(); a := valid_artifact();
a.content_type = ""; // required string cleared a.content_type = ""; // required string cleared
matched := false; matched := false;
validate_artifact(a) catch e { matched = (e == error.MissingField); }; validate_artifact(a) catch (e) { matched = (e == error.MissingField); };
return matched; return matched;
} }
@@ -153,7 +153,7 @@ check_rejects_bad_size :: () -> bool {
a := valid_artifact(); a := valid_artifact();
a.size_bytes = -1; // a content-addressed artifact has positive bytes a.size_bytes = -1; // a content-addressed artifact has positive bytes
matched := false; matched := false;
validate_artifact(a) catch e { matched = (e == error.BadSize); }; validate_artifact(a) catch (e) { matched = (e == error.BadSize); };
return matched; return matched;
} }
@@ -161,7 +161,7 @@ check_rejects_bad_digest :: () -> bool {
a := valid_artifact(); a := valid_artifact();
a.sha256 = "not-a-sha"; // not 64 lowercase-hex chars a.sha256 = "not-a-sha"; // not 64 lowercase-hex chars
matched := false; matched := false;
validate_artifact(a) catch e { matched = (e == error.BadDigest); }; validate_artifact(a) catch (e) { matched = (e == error.BadDigest); };
return matched; return matched;
} }

View File

@@ -60,7 +60,7 @@ check_missing_artifact_path :: (alloc: Allocator) -> bool {
if pe { return false; } // parse must not fail here if pe { return false; } // parse must not fail here
raised := false; raised := false;
matched := 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; return raised and matched;
} }

142
tests/publish_fail.sx Normal file
View File

@@ -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":<dotted 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;
}

171
tests/publish_happy.sx Normal file
View File

@@ -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 `<store>/objects/<sha256>` exists and re-hashes (std.hash)
// to its own key, and each url is `file://<abs-store>/objects/<sha256>`.
// 3. `<store>/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/<sha256>, re-hashes to
// its key, and its url is file://<abs-store>/objects/<sha256> ──
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/<sha256>");
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://<abs-store>/objects/<sha256>");
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;
}

185
tests/publish_persist.sx Normal file
View File

@@ -0,0 +1,185 @@
// Regression for P3.4a-001 — `dist ci publish` must LOAD an existing
// `<store>/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 `<STORE>/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/<sha256>");
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;
}

182
tests/release_ops.sx Normal file
View File

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

View File

@@ -11,7 +11,7 @@
// growth point wrongly used `context.allocator`, the count would not move // growth point wrongly used `context.allocator`, the count would not move
// (the default allocator is a different, untracked one). // (the default allocator is a different, untracked one).
#import "modules/std.sx"; #import "modules/std.sx";
process :: #import "modules/process.sx"; process :: #import "modules/std/process.sx";
#import "../src/domain/platform.sx"; #import "../src/domain/platform.sx";
#import "../src/domain/app.sx"; #import "../src/domain/app.sx";
#import "../src/domain/release.sx"; #import "../src/domain/release.sx";

View File

@@ -11,8 +11,8 @@
// every assertion holds (process.assert aborts otherwise). // every assertion holds (process.assert aborts otherwise).
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/std/json.sx"; #import "modules/std/json.sx";
#import "modules/fs.sx"; #import "modules/std/fs.sx";
process :: #import "modules/process.sx"; process :: #import "modules/std/process.sx";
#import "../src/domain/platform.sx"; #import "../src/domain/platform.sx";
#import "../src/domain/app.sx"; #import "../src/domain/app.sx";
#import "../src/domain/release.sx"; #import "../src/domain/release.sx";

View File

@@ -19,7 +19,7 @@
// are absent. // are absent.
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up. // Uses a fresh `<root>` under `.sx-tmp/` and cleans up.
#import "modules/std.sx"; #import "modules/std.sx";
process :: #import "modules/process.sx"; process :: #import "modules/std/process.sx";
#import "../src/domain/platform.sx"; #import "../src/domain/platform.sx";
#import "../src/domain/app.sx"; #import "../src/domain/app.sx";
#import "../src/domain/release.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 arts1.append(mk_artifact("art_01b", "rel_01", "not-a-sha")); // invalid digest
failed := false; failed := false;
was_validation := 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; failed = true;
was_validation = (e == error.Validation); was_validation = (e == error.Validation);
}; };
@@ -123,7 +123,7 @@ main :: () -> s32 {
arts2.append(mk_artifact("art_02", "WRONG", DIGEST_B)); // release_id mismatch arts2.append(mk_artifact("art_02", "WRONG", DIGEST_B)); // release_id mismatch
ifailed := false; ifailed := false;
was_integrity := 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; ifailed = true;
was_integrity = (e == error.Integrity); 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 arts_xc.append(mk_artifact("art_xc", "rel_xc", DIGEST_B)); // self-consistent artifact
xc_failed := false; xc_failed := false;
xc_integrity := 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_failed = true;
xc_integrity = (e == error.Integrity); 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)); arts_xa.append(mk_artifact_for("art_xa", "app_02", "rel_xa", DIGEST_B));
xa_failed := false; xa_failed := false;
xa_integrity := 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_failed = true;
xa_integrity = (e == error.Integrity); 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 arts_cn.append(mk_artifact("art_cn", "rel_cn", DIGEST_B)); // self-consistent artifact
cn_failed := false; cn_failed := false;
cn_integrity := 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_failed = true;
cn_integrity = (e == error.Integrity); cn_integrity = (e == error.Integrity);
}; };
@@ -222,7 +222,7 @@ main :: () -> s32 {
arts_dup.append(mk_artifact("art_dup", "rel_00", DIGEST_B)); arts_dup.append(mk_artifact("art_dup", "rel_00", DIGEST_B));
dup_failed := false; dup_failed := false;
dup_integrity := 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_failed = true;
dup_integrity = (e == error.Integrity); dup_integrity = (e == error.Integrity);
}; };

View File

@@ -13,9 +13,9 @@
// 4. put_file — a file source produces the same key and bytes. // 4. put_file — a file source produces the same key and bytes.
// Exits 0 only if every assertion holds (process.assert aborts otherwise). // Exits 0 only if every assertion holds (process.assert aborts otherwise).
#import "modules/std.sx"; #import "modules/std.sx";
fs :: #import "modules/fs.sx"; fs :: #import "modules/std/fs.sx";
hash :: #import "modules/std/hash.sx"; hash :: #import "modules/std/hash.sx";
process :: #import "modules/process.sx"; process :: #import "modules/std/process.sx";
#import "../src/store/store.sx"; #import "../src/store/store.sx";
// SHA-256("abc"), the FIPS 180-4 one-block known-answer vector. // SHA-256("abc"), the FIPS 180-4 one-block known-answer vector.