// ===================================================================== // publish.sx — the local `dist ci publish` SUCCESS 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, finds or // creates the app, drafts a release, content-addresses every artifact into // `/objects/`, validates each stored file, commits the whole // aggregate through the integrity-checked repo transaction (channel // promotion included), records an audit event per upload / publish / // promotion, persists `/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. (P3.4b will declare a wrong // size/sha256 to drive the abort path; the repo transaction's rollback is // left intact for it.) // // LOCAL DOWNLOAD URL FORM: `file:///objects/`, where // is the `--local-store` directory resolved to an absolute path // (left as-is if already absolute, else joined onto the process cwd). // // ALLOCATION: everything the published `Repo` and the returned outcome hold // is allocated from `context.allocator` (the process-lifetime default), so a // one-shot publish never frees and never dangles. // ===================================================================== #import "modules/std.sx"; #import "modules/std/json.sx"; #import "modules/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"; // 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 written. 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 } // ── pipeline ────────────────────────────────────────────────────────── run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !PublishError) { alloc := context.allocator; // 1. Validate the manifest (parse + on-disk artifact existence). m, me := mani.load_manifest(manifest_path, alloc); if me { raise error.Manifest; } base_dir := dirname(manifest_path); abs := abs_store(store_dir); now := now_secs(); repo := Repo.init(); 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 { app_id = existing!.id; } // 3. Draft the release for this version/channel. release_id := concat(concat(concat("rel-", slug), "-"), m.version); rel := Release.{ id = release_id, app_id = app_id, version = m.version, build = 1, channel = m.channel, notes = "", created_by = "ci", created_at = now, published_at = now, }; // 4. Per artifact: store by digest -> validate -> build the entity. arts : List(Artifact) = .{}; out_arts : List(PublishedArtifact) = .{}; i := 0; while i < m.artifacts.len { ma := m.artifacts.items[i]; src := path_join(base_dir, ma.path); // Content-address the bytes in memory via `put_bytes`. The streaming // `put_file` path hashes through `hash.sha256_file`, which the sx // LLVM backend miscompiles (codegen crash under `sx build`); reading // the bytes and hashing them in memory yields the identical // `objects/` key with no streaming hash. sb := read_file(src); if sb == null { raise error.Store; } bytes := sb!; actual_size := bytes.len; key, se := st.put_bytes(bytes); if se { 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 { 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; repo.publish(rel, @arts, chan) catch { pe = true; }; if pe { 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 { 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; }