.forge/prs/b4e67292-8b4b-4088-ac10-499a9b5bd621/patch.diff 56.4 KiB 1497 lines Raw
1 From c8d4fbc66217f344fccc8249d713b0b3c61240df Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:36:21 +01004 Subject: [PATCH] m3/releases-api: releases+artifacts, api JSON, agents page5  (60 tests)6 7 ---8  src/agents.rs         | 143 +++++++++++++9  src/api.rs            | 170 ++++++++++++++++10  src/build.rs          |  36 +++-11  src/commits.rs        |  40 +++-12  src/feed.rs           | 133 +++++++-----13  src/main.rs           |   6 +14  src/releases.rs       | 464 ++++++++++++++++++++++++++++++++++++++++++15  src/wiki.rs           |   9 +-16  tests/releases_api.rs | 239 ++++++++++++++++++++++17  9 files changed, 1167 insertions(+), 73 deletions(-)18  create mode 100644 src/agents.rs19  create mode 100644 src/api.rs20  create mode 100644 src/releases.rs21  create mode 100644 tests/releases_api.rs22 23 diff --git a/src/agents.rs b/src/agents.rs24 new file mode 10064425 index 0000000..3de113326 --- /dev/null27 +++ b/src/agents.rs28 @@ -0,0 +1,143 @@29 +//! Agent surface (m3, f0rge issue #8): `agents.html` + `agents.md`.30 +//!31 +//! Static instructions for agents: read via raw/api/tarball/bundle, submit32 +//! via the two inbox POSTs. The markdown source is written verbatim to33 +//! `dist/agents.md`; `dist/agents.html` is the same text rendered through34 +//! [`crate::build::markdown_to_html`].35 +//!36 +//! The curl examples POST to the exact m4 route paths `/inbox/issue` and37 +//! `/inbox/pr-prompt` (the m4 server implements those paths; keep the38 +//! examples in sync). License text is quoted verbatim from `DRAFT.md`.39 +//!40 +//! SECURITY: all untrusted values (site name, clone URL, branch, sha) go41 +//! through [`crate::layout::esc`]; the guide body is static text.42 +43 +use std::path::Path;44 +45 +use crate::build::markdown_to_html;46 +use crate::layout::{esc, page};47 +48 +/// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).49 +pub const LICENSE_TEXT: &str = "By submitting this text you irrevocably give it to this project.\nYou keep no rights in it, you are owed nothing for it, and you will\nmake no claim over it — ownership, credit, payment, infringement,\nor otherwise — against the maintainer or anyone who uses it. The\nmaintainer may keep, share, change, or use it for any purpose, in\nany code or license, without notice or payment. If you cannot agree\nto that, do not submit.";50 +51 +fn agents_markdown(52 +    site_name: &str,53 +    clone_url: Option<&str>,54 +    branch: &str,55 +    short_sha: &str,56 +) -> String {57 +    let clone_line = match clone_url {58 +        Some(u) => format!("Clone: `git clone {u}` (branch `{branch}` @ `{short_sha}`)."),59 +        None => format!("No clone URL configured; branch `{branch}` @ `{short_sha}`."),60 +    };61 +    format!(62 +        "# Agents guide — {site_name}\n\n\63 +         You are an agent visiting a hu13 mirror of one repo. No accounts, no JS, no auth.\n\64 +         Read with curl; suggest with two dumb text POSTs. A human (or their agent) triages later.\n\n\65 +         ## Read\n\n\66 +         - Site: `{site_name}`, {clone_line}\n\67 +         - Raw file bytes: `/raw/<path>` (byte-identical to the repo).\n\68 +         - JSON API (compact fixed-order, same data as HTML): `/api/issues.json`, `/api/pulls.json`, `/api/commits.json`, `/api/releases.json`, `/api/wiki.json`.\n\69 +         - Snapshots: `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\70 +         - Releases + artifacts: `/releases/index.html`, `/releases/<ver>.html`, downloads under `/releases/<ver>/<file>`.\n\71 +         - Feed: `/feed.atom` (up to 50 entries, commits + releases newest-first — poll this, not the HTML).\n\72 +         - Human pages mirror the same data: `/commits.html`, `/commit/<sha>.html`, `/issues/index.html`, `/pulls/index.html`, `/tasks/index.html`, `/ci/index.html`, `/wiki/index.html`.\n\n\73 +         ## Submit (the only write path)\n\n\74 +         Exactly two text-only POSTs on the built-in server (static site has forms at `/new-issue.html` and `/new-pr.html`; both are free textboxes):\n\n\75 +         - `POST /inbox/issue` — suggest an issue. Fields: `title` + `body`.\n\76 +         - `POST /inbox/pr-prompt` — suggest a change. Fields: `title` + `prompt`.\n\n\77 +         Both POSTs are `Content-Type: application/x-www-form-urlencoded` (urlencoded-only; `multipart/*` is rejected), UTF-8 only.\n\n\78 +         | field | rule |\n\79 +         | ----- | ---- |\n\80 +         | `title` | required, plain text, <=300 chars |\n\81 +         | `body` / `prompt` | required, plain text, <=20000 chars |\n\82 +         | `handle` | optional, plain text, <=40 chars |\n\83 +         | `contact` | optional, plain text, <=100 chars |\n\84 +         | `license` | required checkbox — must be checked, else `400` |\n\85 +         | honeypot | `honeypot` field must be left empty (filled = silently dropped, still says received) |\n\86 +         | whole request | <=32KB else `413` |\n\n\87 +         On success the server shows a `received` page with a one-time ack uuid (shown once, not in a URL, no lookup endpoint) and reflects nothing back (kills reflected XSS). No GET, no listing, no read-back. Inbox never appears in the site; accepted text shows up later as a real issue/PR.\n\n\88 +         ### License (required — check the box only if you agree)\n\n\89 +         > {license}\n\n\90 +         Stored per submission: license version + checkbox state + body hash + timestamp. No submission without the box checked.\n\n\91 +         ## Philosophy\n\n\92 +         - Issues = detailed actionable text from your user. Write what a maintainer can act on: context, steps, expected vs actual, links to `/raw/` paths.\n\93 +         - PRs = ONE prompt not code. Send a single plain-text prompt describing the change; never send files, diffs, branches, or bundles. Code words inside stay text — never applied, never executed.\n\94 +         - Free textboxes: the forms are free text on purpose — visiting agents are smarter than any template. Keep it dumb, keep it text.\n\n\95 +         ## Curl examples\n\n\96 +         Suggest an issue:\n\n\97 +         ```sh\n\98 +         curl -X POST http://localhost:39313/inbox/issue \\\n\99 +           --data-urlencode \"title=Crash on empty config\" \\\n\100 +           --data-urlencode \"body=Steps to reproduce: ... (detailed actionable text from your user)\" \\\n\101 +           --data-urlencode \"handle=my-bot\" \\\n\102 +           --data-urlencode \"[email protected]\" \\\n\103 +           --data-urlencode \"license=agree\" \\\n\104 +           --data-urlencode \"honeypot=\"\n\105 +         ```\n\n\106 +         Suggest a PR prompt (ONE prompt, not code):\n\n\107 +         ```sh\n\108 +         curl -X POST http://localhost:39313/inbox/pr-prompt \\\n\109 +           --data-urlencode \"title=Retry with backoff\" \\\n\110 +           --data-urlencode \"prompt=In the uploader, retry failed puts with exponential backoff and ... (ONE prompt, not code)\" \\\n\111 +           --data-urlencode \"handle=my-bot\" \\\n\112 +           --data-urlencode \"[email protected]\" \\\n\113 +           --data-urlencode \"license=agree\" \\\n\114 +           --data-urlencode \"honeypot=\"\n\115 +         ```\n\n\116 +         Caps reminder: `title` <=300 chars, `body`/`prompt` <=20000 chars, whole request <=32KB. Rate ~2/hr per IP; be polite.\n",117 +        site_name = site_name,118 +        clone_line = clone_line,119 +        branch = branch,120 +        license = LICENSE_TEXT.replace('\n', "\n> "),121 +    )122 +}123 +124 +/// Render `agents.html` (rendered guide) + `agents.md` (raw markdown copy).125 +pub fn render(126 +    out: &Path,127 +    site_name: &str,128 +    nav: &str,129 +    clone_url: Option<&str>,130 +    branch: &str,131 +    short_sha: &str,132 +) -> Result<(), String> {133 +    let site_e = esc(site_name);134 +    let branch_e = esc(branch);135 +    let sha_e = esc(short_sha);136 +    let url_e = clone_url.map(esc);137 +    let md = agents_markdown(&site_e, url_e.as_deref(), &branch_e, &sha_e);138 +    // NOTE: site_name/branch/sha above are pre-escaped for the markdown139 +    // source so the raw `agents.md` and rendered `agents.html` agree. The140 +    // markdown renderer escapes raw HTML again, so `&amp;` etc. stay safe.141 +    let html_body = markdown_to_html(&md);142 +    let html = page(&format!("agents — {site_name}"), nav, &html_body);143 +    std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;144 +    std::fs::write(out.join("agents.html"), html)145 +        .map_err(|e| format!("hu13: write agents.html: {e}"))?;146 +    std::fs::write(out.join("agents.md"), &md)147 +        .map_err(|e| format!("hu13: write agents.md: {e}"))?;148 +    Ok(())149 +}150 +151 +#[cfg(test)]152 +mod tests {153 +    use super::*;154 +155 +    #[test]156 +    fn curl_examples_hit_m4_routes() {157 +        let md = agents_markdown("test", None, "main", "abc1234");158 +        assert!(md.contains("/inbox/issue"), "issue POST path present");159 +        assert!(160 +            md.contains("/inbox/pr-prompt"),161 +            "pr-prompt POST path present"162 +        );163 +        assert!(md.contains("application/x-www-form-urlencoded"));164 +        assert!(md.contains("<=300"));165 +        assert!(md.contains("<=20000"));166 +        assert!(md.contains("<=32KB"));167 +        assert!(md.contains("ONE prompt not code") || md.contains("ONE prompt"));168 +        assert!(md.contains("detailed actionable text"));169 +        assert!(md.contains("By submitting this text"));170 +    }171 +}172 diff --git a/src/api.rs b/src/api.rs173 new file mode 100644174 index 0000000..cf43f6a175 --- /dev/null176 +++ b/src/api.rs177 @@ -0,0 +1,170 @@178 +//! Machine JSON (m3, f0rge issue #8): `api/*.json` built from the SAME179 +//! structs used for HTML (no divergent logic).180 +//!181 +//! [`render`] takes the already-loaded readers — [`crate::forge::ForgeData`]182 +//! (issues/pulls/tasks/ci), the commit log slice from [`crate::gitx::log_n`]183 +//! (the same vec rendered to `commits.html`), the release slice from184 +//! [`crate::releases::load`], and the wiki pages from [`crate::wiki::render`]185 +//! — and writes compact fixed-order JSON with `serde_json::to_string`186 +//! (never pretty). Array lengths therefore equal the HTML list counts by187 +//! construction; see `tests/releases_api.rs`.188 +//!189 +//! SECURITY: bodies are untrusted text carried as JSON strings (never HTML).190 +191 +use std::path::Path;192 +193 +use serde::Serialize;194 +195 +use crate::forge::ForgeData;196 +use crate::gitx::Commit;197 +use crate::releases::Release;198 +use crate::wiki::WikiPage;199 +200 +/// One issue row (fixed field order).201 +#[derive(Debug, Clone, Serialize)]202 +pub struct IssueJson {203 +    pub number: usize,204 +    pub id: String,205 +    pub title: String,206 +    pub state: String,207 +    pub labels: Vec<String>,208 +    pub assignees: Vec<String>,209 +    pub milestone: Option<String>,210 +    pub created: String,211 +    pub comments: usize,212 +    pub body: String,213 +}214 +215 +/// One pull row (fixed field order).216 +#[derive(Debug, Clone, Serialize)]217 +pub struct PullJson {218 +    pub number: usize,219 +    pub id: String,220 +    pub title: String,221 +    pub state: String,222 +    pub base: String,223 +    pub head: String,224 +    pub draft: bool,225 +    pub created: String,226 +    pub comments: usize,227 +    pub reviews: usize,228 +    pub body: String,229 +}230 +231 +/// One commit row (fixed field order).232 +#[derive(Debug, Clone, Serialize)]233 +pub struct CommitJson {234 +    pub sha: String,235 +    pub subject: String,236 +    pub author: String,237 +    pub date: String,238 +}239 +240 +/// One release row (fixed field order).241 +#[derive(Debug, Clone, Serialize)]242 +pub struct ReleaseJson {243 +    pub version: String,244 +    pub title: Option<String>,245 +    pub created: String,246 +    pub sha: Option<String>,247 +    pub artifacts: usize,248 +    pub body: String,249 +}250 +251 +/// One wiki row (fixed field order).252 +#[derive(Debug, Clone, Serialize)]253 +pub struct WikiJson {254 +    pub path: String,255 +    pub title: String,256 +    pub size: u64,257 +}258 +259 +fn write_json(out: &Path, rel: &str, value: &impl Serialize) -> Result<(), String> {260 +    // Compact fixed-order JSON (serde struct order, no pretty printing).261 +    let text = serde_json::to_string(value).map_err(|e| format!("hu13: encode {rel}: {e}"))?;262 +    let dest = out.join(rel);263 +    if let Some(parent) = dest.parent() {264 +        std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create api dir: {e}"))?;265 +    }266 +    std::fs::write(&dest, text).map_err(|e| format!("hu13: write {rel}: {e}"))267 +}268 +269 +/// Build `api/issues.json`, `api/pulls.json`, `api/commits.json`,270 +/// `api/releases.json`, and `api/wiki.json` from the same structs rendered271 +/// to HTML. Callers must pass the exact slices/vecs used for the HTML pass.272 +pub fn render(273 +    out: &Path,274 +    forge: &ForgeData,275 +    commits: &[Commit],276 +    releases: &[Release],277 +    wiki: &[WikiPage],278 +) -> Result<(), String> {279 +    let issues: Vec<IssueJson> = forge280 +        .issues281 +        .iter()282 +        .map(|i| IssueJson {283 +            number: i.number,284 +            id: i.id.clone(),285 +            title: i.title.clone(),286 +            state: i.state.clone(),287 +            labels: i.labels.clone(),288 +            assignees: i.assignees.clone(),289 +            milestone: i.milestone.clone(),290 +            created: i.created.clone(),291 +            comments: i.comments.len(),292 +            body: i.body.clone(),293 +        })294 +        .collect();295 +    let pulls: Vec<PullJson> = forge296 +        .prs297 +        .iter()298 +        .map(|p| PullJson {299 +            number: p.number,300 +            id: p.id.clone(),301 +            title: p.title.clone(),302 +            state: p.state.clone(),303 +            base: p.base.clone(),304 +            head: p.head.clone(),305 +            draft: p.draft,306 +            created: p.created.clone(),307 +            comments: p.comments.len(),308 +            reviews: p.reviews.len(),309 +            body: p.body.clone(),310 +        })311 +        .collect();312 +    let commits_json: Vec<CommitJson> = commits313 +        .iter()314 +        .map(|c| CommitJson {315 +            sha: c.sha.clone(),316 +            subject: c.subject.clone(),317 +            author: c.author.clone(),318 +            date: c.date.clone(),319 +        })320 +        .collect();321 +    let releases_json: Vec<ReleaseJson> = releases322 +        .iter()323 +        .map(|r| ReleaseJson {324 +            version: r.version.clone(),325 +            title: r.title.clone(),326 +            created: r.created.clone(),327 +            sha: r.sha.clone(),328 +            artifacts: r.artifacts.len(),329 +            body: r.body.clone(),330 +        })331 +        .collect();332 +    let wiki_json: Vec<WikiJson> = wiki333 +        .iter()334 +        .map(|w| WikiJson {335 +            path: w.path.clone(),336 +            title: w.title.clone(),337 +            size: w.size,338 +        })339 +        .collect();340 +341 +    write_json(out, "api/issues.json", &issues)?;342 +    write_json(out, "api/pulls.json", &pulls)?;343 +    write_json(out, "api/commits.json", &commits_json)?;344 +    write_json(out, "api/releases.json", &releases_json)?;345 +    write_json(out, "api/wiki.json", &wiki_json)?;346 +    Ok(())347 +}348 diff --git a/src/build.rs b/src/build.rs349 index 9920bf8..5acf830 100644350 --- a/src/build.rs351 +++ b/src/build.rs352 @@ -1,21 +1,27 @@353 -//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers).354 +//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers + releases/api/agents).355  //!356  //! Reads one branch via [`crate::gitx`], emits `dist/`:357  //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),358  //! `commits.html`, `commit/<sha>.html`, `feed.atom`, `issues/index.html`,359  //! `issues/<uuid>.html`, `pulls/index.html`, `pulls/<uuid>.html`,360 -//! `tasks/index.html`, `ci/index.html`, `wiki/index.html`,361 -//! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.362 +//! `tasks/index.html`, `ci/index.html`, `releases/index.html`,363 +//! `releases/<ver>.html`, `releases/<ver>/<file>` artifacts,364 +//! `wiki/index.html`, `wiki/<path>.html`, `api/*.json`, `agents.html` +365 +//! `agents.md`, `repo.bundle` + `repo.tar.gz`, and `style.css`.366  //!367  //! Bodies from the repo are semi-trusted but treated as untrusted:368  //! markdown goes through [`markdown_to_html`], which escapes raw HTML;369  //! commit messages and diffs are escaped `<pre>` (never markdown).370 +//!371 +//! Machine JSON (`api/*.json`) and HTML are built from one struct pass over372 +//! the same loaded readers (no divergent logic): `forge::load`,373 +//! `commits::load`, `releases::load`, `wiki::render`.374  375  use std::collections::BTreeMap;376  use std::path::Path;377  378  use crate::layout::esc;379 -use crate::{commits, feed, forge, gitx, wiki};380 +use crate::{agents, api, commits, feed, forge, gitx, releases, wiki};381  382  /// Files at or above this size get a placeholder page + download link.383  pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;384 @@ -155,7 +161,9 @@ fn nav_bar(issue_count: usize, pr_count: usize) -> String {385           <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \386           <a href=\"/tasks/index.html\">tasks</a> · \387           <a href=\"/ci/index.html\">ci</a> · \388 -         <a href=\"/wiki/index.html\">wiki</a>"389 +         <a href=\"/releases/index.html\">releases</a> · \390 +         <a href=\"/wiki/index.html\">wiki</a> · \391 +         <a href=\"/agents.html\">agents</a>"392      )393  }394  395 @@ -349,12 +357,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu396          std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;397      }398  399 -    // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.400 -    commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;401 -    feed::render(repo, out, &branch, &name)?;402 -    wiki::render(repo, out, &name, &nav)?;403 +    // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):404 +    // one struct pass — the same loaded vecs feed both HTML and JSON.405 +    let commit_log = commits::load(repo, &branch, opts.max_log)?;406 +    commits::render_loaded(repo, out, &commit_log, &name, &nav)?;407 +    feed::render_loaded(repo, out, &branch, &name, &commit_log)?;408 +    let wiki_pages = wiki::render(repo, out, &name, &nav)?;409      // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.410      forge::render_loaded(&forge_data, out, &name, &nav)?;411 +    let release_data = releases::load(repo);412 +    releases::render_loaded(&release_data, repo, out, &name, &nav)?;413 +    api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;414 +    agents::render(out, &name, &nav, clone_url.as_deref(), &branch, &short_sha)?;415  416      // Snapshot last so the index can link sizes.417      let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;418 @@ -385,10 +399,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu419      body.push_str(&format!(420          "<p><a href=\"/issues/index.html\">issues ({})</a> · \421           <a href=\"/pulls/index.html\">pulls ({})</a> · \422 -         <a href=\"/tasks/index.html\">tasks ({})</a></p>\n",423 +         <a href=\"/tasks/index.html\">tasks ({})</a> · \424 +         <a href=\"/releases/index.html\">releases ({})</a></p>\n",425          forge_data.issues.len(),426          forge_data.prs.len(),427          forge_data.tasks.len(),428 +        release_data.len(),429      ));430      body.push_str(&format!(431          "<p>CI: {}</p>\n",432 diff --git a/src/commits.rs b/src/commits.rs433 index 834971c..593c791 100644434 --- a/src/commits.rs435 +++ b/src/commits.rs436 @@ -16,26 +16,27 @@ pub fn short_sha(sha: &str) -> &str {437      sha.get(..7).unwrap_or(sha)438  }439  440 -/// Render `commits.html` plus one `commit/<sha>.html` per listed commit.441 -///442 -/// Lists at most `max_log` newest-first commits (see `--max-log`).443 -/// Returns the number of commits listed.444 -pub fn render(445 +/// Load up to `max_log` newest-first commits (the same vec rendered to446 +/// `commits.html` and `api/commits.json` — one struct pass, no divergent logic).447 +pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result<Vec<gitx::Commit>, String> {448 +    gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))449 +}450 +451 +/// Render `commits.html` plus one `commit/<sha>.html` per commit from an452 +/// already-loaded log (see [`load`]).453 +pub fn render_loaded(454      repo: &Path,455      out: &Path,456 -    branch: &str,457 -    max_log: usize,458 +    log: &[gitx::Commit],459      site_name: &str,460      nav: &str,461  ) -> Result<usize, String> {462 -    let log = gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))?;463 -464      let mut body = String::from("<h1>Commits</h1>\n");465      if log.is_empty() {466          body.push_str("<p class=\"muted\">No commits yet.</p>\n");467      } else {468          body.push_str("<table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");469 -        for c in &log {470 +        for c in log {471              body.push_str(&format!(472                  "<tr><td><a href=\"/commit/{}.html\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",473                  esc(&c.sha),474 @@ -54,7 +55,7 @@ pub fn render(475  476      std::fs::create_dir_all(out.join("commit"))477          .map_err(|e| format!("hu13: create commit dir: {e}"))?;478 -    for c in &log {479 +    for c in log {480          let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;481          let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;482          let detail = format!(483 @@ -77,6 +78,23 @@ pub fn render(484      Ok(log.len())485  }486  487 +/// Render `commits.html` plus details, loading the log itself.488 +///489 +/// Wrapper around [`load`] + [`render_loaded`] for tests and simple callers.490 +/// Lists at most `max_log` newest-first commits (see `--max-log`).491 +#[allow(dead_code)]492 +pub fn render(493 +    repo: &Path,494 +    out: &Path,495 +    branch: &str,496 +    max_log: usize,497 +    site_name: &str,498 +    nav: &str,499 +) -> Result<usize, String> {500 +    let log = load(repo, branch, max_log)?;501 +    render_loaded(repo, out, &log, site_name, nav)502 +}503 +504  #[cfg(test)]505  mod tests {506      use super::*;507 diff --git a/src/feed.rs b/src/feed.rs508 index 2fa59f8..437d787 100644509 --- a/src/feed.rs510 +++ b/src/feed.rs511 @@ -1,30 +1,21 @@512 -//! `feed.atom` (m2, f0rge issue #6): 50 entries max, commits +513 +//! `feed.atom` (m2/m3, f0rge issues #6 + #8): 50 entries max, commits +514  //! release notes mixed newest-first, written at build time.515  //!516  //! No rss/atom crate (std only): hand-rolled XML writer with [`esc`].517  //! Each entry carries title, `urn:` id, RFC3339 `updated`, link, and an518  //! escaped summary.519 -//!520 -//! Releases: no `src/releases.rs` reader exists yet, so this is521 -//! commits-only for now.522 -//! TODO(m3): mix in `.forge/releases/<version>.md` notes newest-first523 -//! once the m3 releases reader lands (see [`RELEASES_SUPPORTED`] +524 -//! [`read_release_entries`]).525  526  use std::path::Path;527  528  use crate::gitx;529 +use crate::releases;530  531  /// Max entries in `feed.atom` (DRAFT: 50, commits + releases mixed).532  pub const MAX_ENTRIES: usize = 50;533  534 -/// Test hook for m3: `false` while no release reader exists in `src/`.535 -///536 -/// When `src/releases.rs` (or equivalent) lands, set this to `true` and537 -/// implement [`read_release_entries`]; the `releases_required_when_present`538 -/// test flips from "commits-only tolerated" to "release entries required".539 -// TODO(m3): set to true once the releases reader exists.540 -pub const RELEASES_SUPPORTED: bool = false;541 +/// Releases are wired in (m3 releases reader): `true`.542 +#[allow(dead_code)]543 +pub const RELEASES_SUPPORTED: bool = true;544  545  /// One Atom entry (commit or release note).546  #[derive(Debug, Clone, PartialEq, Eq)]547 @@ -35,7 +26,7 @@ pub struct FeedEntry {548      pub id: String,549      /// RFC3339 timestamp (converted from git iso date / release `created`).550      pub updated: String,551 -    /// Site-relative link (`/commit/<sha>.html`, `/releases/...` later).552 +    /// Site-relative link (`/commit/<sha>.html`, `/releases/<ver>.html`).553      pub link: String,554      /// Escaped one-line summary.555      pub summary: String,556 @@ -80,21 +71,51 @@ pub fn to_rfc3339(iso: &str) -> String {557      s.to_string()558  }559  560 -/// Read release-note entries for the feed.561 -///562 -/// Commits-only stub: no release reader exists in `src/` yet, so this563 -/// returns an empty vec.564 -// TODO(m3): read `.forge/releases/<version>.md` notes (title/body) here565 -// and return one `FeedEntry` per release (`urn:release:<version>`).566 +/// Read release-note entries for the feed from the same reader used for567 +/// HTML ([`releases::load`], no divergent logic): one entry per release568 +/// (`urn:release:<version>`, link `/releases/<ver>.html`).569  #[must_use]570 -pub fn read_release_entries(_repo: &Path) -> Vec<FeedEntry> {571 -    // TODO(m3): releases not yet wired; commits-only feed.572 -    Vec::new()573 +pub fn read_release_entries(repo: &Path) -> Vec<FeedEntry> {574 +    releases::load(repo)575 +        .into_iter()576 +        .map(|r| {577 +            let first_line = r578 +                .body579 +                .lines()580 +                .map(str::trim)581 +                .find(|l| !l.is_empty())582 +                .unwrap_or("")583 +                .to_string();584 +            let summary = if first_line.is_empty() {585 +                format!("release {} ({})", r.version, r.created)586 +            } else {587 +                let mut s: String = first_line.chars().take(200).collect();588 +                if s.len() > 200 {589 +                    s.truncate(200);590 +                }591 +                s592 +            };593 +            FeedEntry {594 +                title: r.display_title().to_string(),595 +                id: format!("urn:release:{}", r.version),596 +                updated: r.created.clone(),597 +                link: format!("/releases/{}.html", r.version),598 +                summary,599 +            }600 +        })601 +        .collect()602  }603  604  /// Collect up to [`MAX_ENTRIES`] entries, commits + releases newest-first.605 +#[allow(dead_code)]606  pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, String> {607      let log = gitx::log_n(repo, branch, MAX_ENTRIES).map_err(|e| format!("hu13: {e}"))?;608 +    collect_entries_loaded(repo, &log)609 +}610 +611 +/// Collect entries from an already-loaded commit log plus releases from612 +/// [`read_release_entries`] (one struct pass with the HTML readers).613 +pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<FeedEntry>, String> {614      let mut entries: Vec<FeedEntry> = log615          .iter()616          .map(|c| {617 @@ -108,9 +129,8 @@ pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, Stri618              }619          })620          .collect();621 -    // TODO(m3): releases mixed in newest-first once the reader lands.622 -    let mut releases = read_release_entries(repo);623 -    entries.append(&mut releases);624 +    let mut rels = read_release_entries(repo);625 +    entries.append(&mut rels);626      // Newest-first by RFC3339 `updated` (lexicographic == chronological).627      entries.sort_by(|a, b| b.updated.cmp(&a.updated));628      entries.truncate(MAX_ENTRIES);629 @@ -129,9 +149,6 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri630      out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));631      out.push_str(&format!("<updated>{updated}</updated>\n"));632      out.push_str("<author><name>hu13</name></author>\n");633 -    if !RELEASES_SUPPORTED {634 -        out.push_str("<!-- TODO(m3): releases not yet wired; commits-only feed -->\n");635 -    }636      for e in entries {637          out.push_str("<entry>\n");638          out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));639 @@ -147,9 +164,31 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri640  641  /// Build `feed.atom` in `out` (called from [`crate::build::run_build`]642  /// right after commits). Returns the number of entries written.643 +#[allow(dead_code)]644  pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result<usize, String> {645      let entries = collect_entries(repo, branch)?;646 -    let doc = render_feed(site_name, branch, &entries);647 +    render_entries(out, site_name, branch, &entries)648 +}649 +650 +/// Build `feed.atom` from already-loaded commits (one struct pass).651 +pub fn render_loaded(652 +    repo: &Path,653 +    out: &Path,654 +    branch: &str,655 +    site_name: &str,656 +    log: &[gitx::Commit],657 +) -> Result<usize, String> {658 +    let entries = collect_entries_loaded(repo, log)?;659 +    render_entries(out, site_name, branch, &entries)660 +}661 +662 +fn render_entries(663 +    out: &Path,664 +    site_name: &str,665 +    branch: &str,666 +    entries: &[FeedEntry],667 +) -> Result<usize, String> {668 +    let doc = render_feed(site_name, branch, entries);669      std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;670      std::fs::write(out.join("feed.atom"), doc)671          .map_err(|e| format!("hu13: write feed.atom: {e}"))?;672 @@ -222,7 +261,7 @@ mod tests {673  674      /// Minimal well-formedness check: angle-balance plus a tiny tag-stack675      /// parse over the known Atom shape (feed/entry/title/id/updated/link/676 -    /// summary/author/name + xml prolog + TODO comment).677 +    /// summary/author/name + xml prolog).678      fn check_well_formed(xml: &str) {679          let opens = xml.bytes().filter(|b| *b == b'<').count();680          let closes = xml.bytes().filter(|b| *b == b'>').count();681 @@ -341,9 +380,8 @@ mod tests {682  683      #[test]684      fn releases_required_when_present() {685 -        // Fixture WITH a releases dir: tolerates commits-only now686 -        // (RELEASES_SUPPORTED == false), but REQUIRES release entries687 -        // once the m3 reader lands (flag flips to true).688 +        // Fixture WITH a releases dir: release entries are REQUIRED now689 +        // that the m3 reader has landed.690          let repo = fixture_repo();691          std::fs::create_dir_all(repo.join(".forge").join("releases")).unwrap();692          std::fs::write(693 @@ -354,22 +392,15 @@ mod tests {694          let entries = collect_entries(&repo, "main").expect("collect");695          let xml = render_feed("test", "main", &entries);696          check_well_formed(&xml);697 -        if RELEASES_SUPPORTED {698 -            assert!(699 -                xml.contains("v1.0"),700 -                "release entries required when releases/ dir exists (m3 landed)"701 -            );702 -            assert!(703 -                xml.contains("urn:release:"),704 -                "release urn ids required once m3 lands"705 -            );706 -        } else {707 -            // TODO(m3): commits-only tolerated until the releases reader lands.708 -            assert!(709 -                xml.contains("TODO(m3)"),710 -                "commits-only marker while releases unwired"711 -            );712 -        }713 +        assert!(714 +            xml.contains("v1.0"),715 +            "release entries required when releases/ dir exists"716 +        );717 +        assert!(xml.contains("urn:release:"), "release urn ids required");718 +        assert!(719 +            !xml.contains("TODO(m3)"),720 +            "no m3 TODO marker left in feed output"721 +        );722      }723  724      #[test]725 diff --git a/src/main.rs b/src/main.rs726 index 6d84b9b..c2b1c2c 100644727 --- a/src/main.rs728 +++ b/src/main.rs729 @@ -1,9 +1,12 @@730 +mod agents;731 +mod api;732  mod build;733  mod commits;734  mod feed;735  mod forge;736  mod gitx;737  mod layout;738 +mod releases;739  mod wiki;740  741  const VERSION: &str = env!("CARGO_PKG_VERSION");742 @@ -204,6 +207,9 @@ fn run_serve(args: &[String]) -> i32 {743          print_serve_help();744          return 0;745      }746 +    // TODO(m4): serve `dist/` with `X-Content-Type-Options: nosniff` on every747 +    // response (static files + inbox POSTs); never execute or list beyond748 +    // what the build emitted. Stub for now — prints help and exits 0.749      print_serve_help();750      0751  }752 diff --git a/src/releases.rs b/src/releases.rs753 new file mode 100644754 index 0000000..0abcb92755 --- /dev/null756 +++ b/src/releases.rs757 @@ -0,0 +1,464 @@758 +//! Releases (m3, f0rge issue #8): `.forge/releases/<ver>.md` notes +759 +//! optional `releases/out/<ver>/` artifacts.760 +//!761 +//! Source is the on-disk `<repo>/.forge/releases/` tree (NOT git objects):762 +//! `<version>.md` files carry frontmatter (`version`, `created` required;763 +//! `title`, `sha` optional) plus a markdown notes body. `build/` holds build764 +//! scripts (never rendered); `out/<ver>/` holds built artifacts (gitignored,765 +//! usually absent — fine).766 +//!767 +//! Pages: `releases/index.html` + `releases/<ver>.html`. When768 +//! `releases/out/<ver>/` exists at build time the WHOLE dir is copied to769 +//! `dist/releases/<ver>/` (no size cap — the maintainer shipped it, hu13770 +//! serves it) and linked with sizes plus any sha256 lines found in the771 +//! notes. When absent the page is notes-only with no warning.772 +//!773 +//! SECURITY: every byte from `.forge/` is untrusted. Notes bodies go through774 +//! [`crate::build::markdown_to_html`] (raw HTML escaped); versions, titles,775 +//! shas, filenames, and sha256 lines go through [`crate::layout::esc`].776 +777 +use std::path::{Path, PathBuf};778 +779 +use serde::Deserialize;780 +781 +use crate::build::markdown_to_html;782 +use crate::layout::{esc, page};783 +784 +/// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).785 +pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;786 +787 +#[derive(Debug, Clone, Deserialize)]788 +struct ReleaseFm {789 +    version: String,790 +    created: String,791 +    #[serde(default)]792 +    title: Option<String>,793 +    #[serde(default)]794 +    sha: Option<String>,795 +}796 +797 +/// One file inside `out/<ver>/` (relative path + byte size).798 +#[derive(Debug, Clone, PartialEq, Eq)]799 +pub struct Artifact {800 +    /// `/`-separated path relative to `out/<ver>/`.801 +    pub path: String,802 +    /// File size in bytes.803 +    pub size: u64,804 +}805 +806 +/// One release record: notes + artifacts found at build time.807 +#[derive(Debug, Clone)]808 +pub struct Release {809 +    /// Version string (from frontmatter `version`).810 +    pub version: String,811 +    /// Optional title.812 +    pub title: Option<String>,813 +    /// RFC3339 creation timestamp.814 +    pub created: String,815 +    /// Optional commit sha recorded at create time.816 +    pub sha: Option<String>,817 +    /// Markdown notes body after the frontmatter.818 +    pub body: String,819 +    /// Artifacts present in `out/<ver>/` at build (sorted by path).820 +    pub artifacts: Vec<Artifact>,821 +}822 +823 +impl Release {824 +    /// Display title: `title` when non-empty, else `version`.825 +    #[must_use]826 +    pub fn display_title(&self) -> &str {827 +        match &self.title {828 +            Some(t) if !t.trim().is_empty() => t,829 +            _ => &self.version,830 +        }831 +    }832 +}833 +834 +fn split_frontmatter(text: &str) -> Option<(&str, &str)> {835 +    let rest = text.strip_prefix("---")?;836 +    let rest = rest837 +        .strip_prefix('\n')838 +        .or_else(|| rest.strip_prefix("\r\n"))?;839 +    let mut offset = 0;840 +    for line in rest.split_inclusive('\n') {841 +        let trimmed = line.trim_end_matches(['\n', '\r']);842 +        if trimmed == "---" {843 +            let yaml = &rest[..offset];844 +            let body = &rest[offset + line.len()..];845 +            return Some((yaml, body));846 +        }847 +        offset += line.len();848 +    }849 +    None850 +}851 +852 +fn read_text_capped(path: &Path, label: &str) -> Option<String> {853 +    let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);854 +    if len > RELEASE_MAX_BYTES {855 +        eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");856 +        return None;857 +    }858 +    match std::fs::read(path) {859 +        Ok(bytes) => match String::from_utf8(bytes) {860 +            Ok(text) => Some(text),861 +            Err(_) => {862 +                eprintln!("hu13: releases: skipping {label}: not valid UTF-8");863 +                None864 +            }865 +        },866 +        Err(e) => {867 +            eprintln!("hu13: releases: skipping {label}: cannot read: {e}");868 +            None869 +        }870 +    }871 +}872 +873 +/// Collect every file under `dir` as `(relative /-separated path, size)`,874 +/// sorted by path. No size cap: the maintainer shipped it, hu13 serves it.875 +fn collect_artifacts(dir: &Path) -> Vec<Artifact> {876 +    let mut out = Vec::new();877 +    let mut stack: Vec<PathBuf> = vec![PathBuf::new()];878 +    while let Some(rel) = stack.pop() {879 +        let abs = dir.join(&rel);880 +        let rd = match std::fs::read_dir(&abs) {881 +            Ok(rd) => rd,882 +            Err(e) => {883 +                eprintln!("hu13: releases: cannot list {}: {e}", abs.display());884 +                continue;885 +            }886 +        };887 +        for ent in rd {888 +            let ent = match ent {889 +                Ok(ent) => ent,890 +                Err(e) => {891 +                    eprintln!("hu13: releases: bad dir entry: {e}");892 +                    continue;893 +                }894 +            };895 +            let name = ent.file_name().to_string_lossy().into_owned();896 +            let rel_child = if rel.as_os_str().is_empty() {897 +                PathBuf::from(&name)898 +            } else {899 +                rel.join(&name)900 +            };901 +            let ft = match ent.file_type() {902 +                Ok(ft) => ft,903 +                Err(_) => continue,904 +            };905 +            if ft.is_symlink() {906 +                continue;907 +            }908 +            if ft.is_dir() {909 +                stack.push(rel_child);910 +            } else if ft.is_file() {911 +                let size = std::fs::metadata(ent.path()).map(|m| m.len()).unwrap_or(0);912 +                let rel_str = rel_child913 +                    .components()914 +                    .map(|c| c.as_os_str().to_string_lossy().into_owned())915 +                    .collect::<Vec<_>>()916 +                    .join("/");917 +                out.push(Artifact {918 +                    path: rel_str,919 +                    size,920 +                });921 +            }922 +        }923 +    }924 +    out.sort_by(|a, b| a.path.cmp(&b.path));925 +    out926 +}927 +928 +/// Copy the WHOLE `src_dir` tree to `dest_dir` (no size cap).929 +fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {930 +    let mut stack: Vec<PathBuf> = vec![PathBuf::new()];931 +    while let Some(rel) = stack.pop() {932 +        let src = src_dir.join(&rel);933 +        let dest = dest_dir.join(&rel);934 +        let rd = match std::fs::read_dir(&src) {935 +            Ok(rd) => rd,936 +            Err(e) => {937 +                return Err(format!("hu13: copy releases dir {}: {e}", src.display()));938 +            }939 +        };940 +        std::fs::create_dir_all(&dest)941 +            .map_err(|e| format!("hu13: create releases dir {}: {e}", dest.display()))?;942 +        for ent in rd {943 +            let ent = match ent {944 +                Ok(ent) => ent,945 +                Err(e) => return Err(format!("hu13: list releases dir: {e}")),946 +            };947 +            let name = ent.file_name();948 +            let rel_child = if rel.as_os_str().is_empty() {949 +                PathBuf::from(&name)950 +            } else {951 +                rel.join(&name)952 +            };953 +            let ft = ent954 +                .file_type()955 +                .map_err(|e| format!("hu13: stat releases file: {e}"))?;956 +            if ft.is_symlink() {957 +                continue;958 +            }959 +            if ft.is_dir() {960 +                stack.push(rel_child);961 +            } else if ft.is_file() {962 +                let bytes =963 +                    std::fs::read(ent.path()).map_err(|e| format!("hu13: read artifact: {e}"))?;964 +                std::fs::write(dest_dir.join(&rel_child), &bytes)965 +                    .map_err(|e| format!("hu13: write artifact: {e}"))?;966 +            }967 +        }968 +    }969 +    Ok(())970 +}971 +972 +/// Load every release from on-disk `<repo>/.forge/releases/*.md`.973 +/// Never fails: bad files are skipped with warnings; a missing dir yields974 +/// an empty vec. Order is `(created, version)`.975 +#[must_use]976 +pub fn load(repo: &Path) -> Vec<Release> {977 +    let root = repo.join(".forge").join("releases");978 +    let rd = match std::fs::read_dir(&root) {979 +        Ok(rd) => rd,980 +        Err(_) => return Vec::new(),981 +    };982 +    let mut names: Vec<String> = Vec::new();983 +    for ent in rd {984 +        let ent = match ent {985 +            Ok(ent) => ent,986 +            Err(e) => {987 +                eprintln!("hu13: releases: bad dir entry: {e}");988 +                continue;989 +            }990 +        };991 +        let Ok(ft) = ent.file_type() else {992 +            continue;993 +        };994 +        if ft.is_symlink() || !ft.is_file() {995 +            continue;996 +        }997 +        let name = ent.file_name().to_string_lossy().into_owned();998 +        if name.ends_with(".md") {999 +            names.push(name);1000 +        }1001 +    }1002 +    names.sort();1003 +1004 +    let mut out = Vec::new();1005 +    for name in names {1006 +        let label = format!("releases/{name}");1007 +        let Some(text) = read_text_capped(&root.join(&name), &label) else {1008 +            continue;1009 +        };1010 +        let Some((yaml, body)) = split_frontmatter(&text) else {1011 +            eprintln!("hu13: releases: skipping {label}: missing frontmatter");1012 +            continue;1013 +        };1014 +        let fm: ReleaseFm = match yaml_serde::from_str(yaml) {1015 +            Ok(fm) => fm,1016 +            Err(e) => {1017 +                eprintln!("hu13: releases: skipping {label}: {e}");1018 +                continue;1019 +            }1020 +        };1021 +        // Artifacts: present only when `out/<version>/` exists at build.1022 +        // Absent -> notes-only, no warning.1023 +        let artifacts = if fm.version.contains('/')1024 +            || fm.version.contains('\\')1025 +            || fm.version.split('/').any(|p| p == "..")1026 +        {1027 +            Vec::new()1028 +        } else {1029 +            let out_dir = root.join("out").join(&fm.version);1030 +            if out_dir.is_dir() {1031 +                collect_artifacts(&out_dir)1032 +            } else {1033 +                Vec::new()1034 +            }1035 +        };1036 +        out.push(Release {1037 +            version: fm.version,1038 +            title: fm.title.filter(|t| !t.trim().is_empty()),1039 +            created: fm.created,1040 +            sha: fm.sha.filter(|s| !s.trim().is_empty()),1041 +            body: body.to_string(),1042 +            artifacts,1043 +        });1044 +    }1045 +    out.sort_by(|a, b| {1046 +        (a.created.clone(), a.version.clone()).cmp(&(b.created.clone(), b.version.clone()))1047 +    });1048 +    out1049 +}1050 +1051 +/// Lines in `body` that look like sha256 digest lines: contain 64 hex1052 +/// chars. Returned trimmed, in body order.1053 +#[must_use]1054 +pub fn sha256_lines(body: &str) -> Vec<String> {1055 +    body.lines()1056 +        .filter(|line| {1057 +            let lower = line.trim().to_lowercase();1058 +            let mut run = 0usize;1059 +            let mut best = 0usize;1060 +            for ch in lower.chars() {1061 +                if ch.is_ascii_hexdigit() {1062 +                    run += 1;1063 +                    best = best.max(run);1064 +                } else {1065 +                    run = 0;1066 +                }1067 +            }1068 +            best >= 641069 +        })1070 +        .map(|l| l.trim().to_string())1071 +        .collect()1072 +}1073 +1074 +fn artifact_href(version: &str, path: &str) -> String {1075 +    format!("/releases/{}/{path}", esc(version))1076 +}1077 +1078 +fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {1079 +    let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));1080 +    body.push_str(&format!(1081 +        "<p><span class=\"muted\">version:</span> <code>{}</code></p>\n",1082 +        esc(&rel.version)1083 +    ));1084 +    body.push_str(&format!(1085 +        "<p><span class=\"muted\">created:</span> {}</p>\n",1086 +        esc(&rel.created)1087 +    ));1088 +    if let Some(sha) = &rel.sha {1089 +        body.push_str(&format!(1090 +            "<p><span class=\"muted\">sha:</span> <code>{}</code></p>\n",1091 +            esc(sha)1092 +        ));1093 +    }1094 +    body.push_str(&markdown_to_html(&rel.body));1095 +    if rel.artifacts.is_empty() {1096 +        body.push_str("<p class=\"muted\">No artifacts attached.</p>\n");1097 +    } else {1098 +        body.push_str("<h2>Artifacts</h2>\n<ul class=\"artifacts\">\n");1099 +        for a in &rel.artifacts {1100 +            body.push_str(&format!(1101 +                "<li><a href=\"{}\">{}</a> ({} bytes)</li>\n",1102 +                artifact_href(&rel.version, &a.path),1103 +                esc(&a.path),1104 +                a.size,1105 +            ));1106 +        }1107 +        body.push_str("</ul>\n");1108 +        let hashes = sha256_lines(&rel.body);1109 +        if !hashes.is_empty() {1110 +            body.push_str("<h2>Checksums</h2>\n<pre class=\"log\">");1111 +            for h in &hashes {1112 +                body.push_str(&esc(h));1113 +                body.push('\n');1114 +            }1115 +            body.push_str("</pre>\n");1116 +        }1117 +    }1118 +    body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");1119 +    page(1120 +        &format!("{} — releases — {site_name}", rel.display_title()),1121 +        nav,1122 +        &body,1123 +    )1124 +}1125 +1126 +/// Render `releases/index.html` + `releases/<ver>.html` from already-loaded1127 +/// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.1128 +pub fn render_loaded(1129 +    releases: &[Release],1130 +    repo: &Path,1131 +    out: &Path,1132 +    site_name: &str,1133 +    nav: &str,1134 +) -> Result<(), String> {1135 +    let root = repo.join(".forge").join("releases");1136 +    let mut body = String::from("<h1>Releases</h1>\n");1137 +    if releases.is_empty() {1138 +        body.push_str("<p class=\"muted\">No releases yet.</p>\n");1139 +    } else {1140 +        body.push_str("<table>\n<thead><tr><th>version</th><th>title</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");1141 +        for rel in releases {1142 +            body.push_str(&format!(1143 +                "<tr><td><a href=\"/releases/{v}.html\">{ve}</a></td><td>{t}</td><td>{c}</td><td>{n}</td></tr>\n",1144 +                v = esc(&rel.version),1145 +                ve = esc(&rel.version),1146 +                t = esc(rel.display_title()),1147 +                c = esc(&rel.created),1148 +                n = rel.artifacts.len(),1149 +            ));1150 +        }1151 +        body.push_str("</tbody>\n</table>\n");1152 +    }1153 +    let index = page(&format!("releases — {site_name}"), nav, &body);1154 +    let dest = out.join("releases").join("index.html");1155 +    if let Some(parent) = dest.parent() {1156 +        std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create releases dir: {e}"))?;1157 +    }1158 +    std::fs::write(&dest, index).map_err(|e| format!("hu13: write releases index: {e}"))?;1159 +1160 +    for rel in releases {1161 +        // Copy the whole out dir when present (no size cap).1162 +        if !rel.version.contains('/') && !rel.version.contains('\\') {1163 +            let src_dir = root.join("out").join(&rel.version);1164 +            if src_dir.is_dir() {1165 +                let dest_dir = out.join("releases").join(&rel.version);1166 +                copy_tree(&src_dir, &dest_dir)?;1167 +            }1168 +        }1169 +        let html = render_release_page(rel, site_name, nav);1170 +        let page_dest = out.join("releases").join(format!("{}.html", rel.version));1171 +        if let Some(parent) = page_dest.parent() {1172 +            std::fs::create_dir_all(parent)1173 +                .map_err(|e| format!("hu13: create releases parent: {e}"))?;1174 +        }1175 +        std::fs::write(&page_dest, html)1176 +            .map_err(|e| format!("hu13: write release page {}: {e}", rel.version))?;1177 +    }1178 +    Ok(())1179 +}1180 +1181 +#[cfg(test)]1182 +mod tests {1183 +    use super::*;1184 +1185 +    #[test]1186 +    fn sha256_line_detection() {1187 +        let digest = "a".repeat(64);1188 +        let body = format!("artifact:\n{digest}  app.tar.gz\nplain line\n");1189 +        let lines = sha256_lines(&body);1190 +        assert_eq!(lines.len(), 1);1191 +        assert!(lines[0].contains("app.tar.gz"));1192 +        assert!(sha256_lines("no hashes here\n").is_empty());1193 +    }1194 +1195 +    #[test]1196 +    fn display_title_falls_back_to_version() {1197 +        let r = Release {1198 +            version: "v1.0".to_string(),1199 +            title: None,1200 +            created: "2026-01-01T00:00:00Z".to_string(),1201 +            sha: None,1202 +            body: String::new(),1203 +            artifacts: Vec::new(),1204 +        };1205 +        assert_eq!(r.display_title(), "v1.0");1206 +    }1207 +1208 +    #[test]1209 +    fn missing_dir_is_empty() {1210 +        let dir = std::env::temp_dir().join(format!(1211 +            "hu13-rel-empty-{}-{}",1212 +            std::process::id(),1213 +            std::time::SystemTime::now()1214 +                .duration_since(std::time::UNIX_EPOCH)1215 +                .map(|d| d.as_nanos())1216 +                .unwrap_or(0)1217 +        ));1218 +        std::fs::create_dir_all(&dir).unwrap();1219 +        assert!(load(&dir).is_empty());1220 +    }1221 +}1222 diff --git a/src/wiki.rs b/src/wiki.rs1223 index 899cb1e..345792d 1006441224 --- a/src/wiki.rs1225 +++ b/src/wiki.rs1226 @@ -27,6 +27,8 @@ pub struct WikiPage {1227      pub path: String,1228      /// First `# H1` else the filename.1229      pub title: String,1230 +    /// Byte size of the source file.1231 +    pub size: u64,1232  }1233  1234  fn is_h1_space(c: char) -> bool {1235 @@ -168,8 +170,13 @@ pub fn render(1236          };1237          let filename = rel.rsplit('/').next().unwrap_or(&rel);1238          let title = page_title(&text, filename);1239 +        let size = text.len() as u64;1240          bodies.insert(rel.clone(), text);1241 -        pages.push(WikiPage { path: rel, title });1242 +        pages.push(WikiPage {1243 +            path: rel,1244 +            title,1245 +            size,1246 +        });1247      }1248  1249      let wiki_dir = out.join("wiki");1250 diff --git a/tests/releases_api.rs b/tests/releases_api.rs1251 new file mode 1006441252 index 0000000..57345e31253 --- /dev/null1254 +++ b/tests/releases_api.rs1255 @@ -0,0 +1,239 @@1256 +//! m3 releases + machine surface (f0rge issue #8):1257 +//! 1 release with `out/` artifacts (byte-identical) + 1 notes-only,1258 +//! api counts == html counts, agents.md curl examples hittable,1259 +//! feed includes the release.1260 +1261 +use std::path::{Path, PathBuf};1262 +use std::process::Command;1263 +1264 +fn git(repo: &Path, args: &[&str]) {1265 +    let st = Command::new("git")1266 +        .arg("-C")1267 +        .arg(repo)1268 +        .args(args)1269 +        .status()1270 +        .expect("spawn git");1271 +    assert!(st.success(), "git {args:?} failed");1272 +}1273 +1274 +fn fresh_dir(prefix: &str) -> PathBuf {1275 +    let dir = std::env::temp_dir().join(format!(1276 +        "hu13-relapi-{prefix}-{}-{}",1277 +        std::process::id(),1278 +        std::time::SystemTime::now()1279 +            .duration_since(std::time::UNIX_EPOCH)1280 +            .map(|d| d.as_nanos())1281 +            .unwrap_or(0)1282 +    ));1283 +    std::fs::create_dir_all(&dir).unwrap();1284 +    dir1285 +}1286 +1287 +fn forge_write(repo: &Path, rel: &str, content: &str) {1288 +    let dest = repo.join(".forge").join(rel);1289 +    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();1290 +    std::fs::write(&dest, content).unwrap();1291 +}1292 +1293 +fn json_len(text: &str) -> usize {1294 +    let v: serde_json::Value = serde_json::from_str(text).expect("valid json");1295 +    v.as_array().expect("top-level array").len()1296 +}1297 +1298 +#[test]1299 +fn releases_artifacts_api_agents_feed() {1300 +    let repo = fresh_dir("repo");1301 +    git(&repo, &["init", "-b", "main"]);1302 +    git(&repo, &["config", "user.name", "hu13-test"]);1303 +    git(&repo, &["config", "user.email", "[email protected]"]);1304 +    git(&repo, &["config", "commit.gpgsign", "false"]);1305 +    std::fs::write(repo.join("README.md"), "# relapi fixture\n").unwrap();1306 +1307 +    // One issue + one PR (for api/html count equality).1308 +    forge_write(1309 +        &repo,1310 +        "issues/11111111-1111-4111-8111-111111111111/issue.md",1311 +        "---\nid: 11111111-1111-4111-8111-111111111111\ntitle: API issue\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nIssue body.\n",1312 +    );1313 +    forge_write(1314 +        &repo,1315 +        "prs/44444444-4444-4444-8444-444444444444/pr.md",1316 +        "---\nid: 44444444-4444-4444-8444-444444444444\ntitle: API pr\nstate: open\nbase: main\nhead: feat\ncreated: 2026-09-10T03:30:00Z\n---\nPR body.\n",1317 +    );1318 +    // One wiki page.1319 +    forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");1320 +1321 +    // Release WITH artifacts: notes carry a sha256 line.1322 +    let digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";1323 +    forge_write(1324 +        &repo,1325 +        "releases/v1.0.md",1326 +        &format!(1327 +            "---\nversion: v1.0\ntitle: First release\ncreated: 2026-09-10T02:00:00Z\nsha: abc1234\n---\n# v1.0 notes\n\nShips `app.bin`.\n\n{digest}  app.bin\n"1328 +        ),1329 +    );1330 +    let artifact_bytes: Vec<u8> = (0u8..=255).collect();1331 +    let out_file = repo.join(".forge").join("releases/out/v1.0/app.bin");1332 +    std::fs::create_dir_all(out_file.parent().unwrap()).unwrap();1333 +    std::fs::write(&out_file, &artifact_bytes).unwrap();1334 +    std::fs::write(1335 +        repo.join(".forge").join("releases/out/v1.0/nested.txt"),1336 +        "nested artifact\n",1337 +    )1338 +    .unwrap();1339 +    // Release WITHOUT artifacts: notes-only.1340 +    forge_write(1341 +        &repo,1342 +        "releases/v2.0.md",1343 +        "---\nversion: v2.0\ncreated: 2026-09-10T04:00:00Z\n---\n# v2.0 notes\n\nNo artifacts yet.\n",1344 +    );1345 +1346 +    git(&repo, &["add", "-A"]);1347 +    git(&repo, &["commit", "-m", "relapi fixture"]);1348 +1349 +    let out = fresh_dir("out");1350 +    let bin = env!("CARGO_BIN_EXE_hu13");1351 +    let produced = Command::new(bin)1352 +        .arg("build")1353 +        .arg(&repo)1354 +        .arg("-o")1355 +        .arg(&out)1356 +        .output()1357 +        .expect("spawn hu13 build");1358 +    assert!(1359 +        produced.status.success(),1360 +        "build exits 0; stderr: {}",1361 +        String::from_utf8_lossy(&produced.stderr)1362 +    );1363 +1364 +    let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);1365 +1366 +    // --- releases: index + both detail pages ---1367 +    let index = read("releases/index.html");1368 +    assert_eq!(index.matches("<tr><td>").count(), 2, "two release rows");1369 +    assert!(index.contains("v1.0"), "v1.0 listed");1370 +    assert!(index.contains("v2.0"), "v2.0 listed");1371 +1372 +    let with_art = read("releases/v1.0.html");1373 +    assert!(with_art.contains("First release"), "title shown");1374 +    assert!(with_art.contains("app.bin"), "artifact linked");1375 +    assert!(with_art.contains("nested.txt"), "nested artifact linked");1376 +    assert!(with_art.contains("bytes"), "sizes shown");1377 +    assert!(with_art.contains(digest), "sha256 line from notes shown");1378 +    assert!(1379 +        !with_art.contains("No artifacts attached."),1380 +        "artifacts present"1381 +    );1382 +    // Escaping: no raw passthrough of the version block.1383 +    assert!(!with_art.contains("<script>"));1384 +1385 +    let notes_only = read("releases/v2.0.html");1386 +    assert!(notes_only.contains("v2.0 notes"), "notes rendered");1387 +    assert!(1388 +        notes_only.contains("No artifacts attached."),1389 +        "notes-only state"1390 +    );1391 +1392 +    // Artifacts byte-identical, whole dir copied.1393 +    for rel in ["releases/v1.0/app.bin", "releases/v1.0/nested.txt"] {1394 +        let got = std::fs::read(out.join(rel)).expect(rel);1395 +        let want = std::fs::read(1396 +            repo.join(".forge").join(1397 +                rel.strip_prefix("releases/")1398 +                    .map(|r| format!("releases/out/v1.0/{}", r.rsplit('/').next().unwrap()))1399 +                    .unwrap(),1400 +            ),1401 +        )1402 +        .expect("fixture artifact");1403 +        assert_eq!(got, want, "byte-identical: {rel}");1404 +    }1405 +    assert_eq!(1406 +        std::fs::read(out.join("releases/v1.0/app.bin")).unwrap(),1407 +        artifact_bytes1408 +    );1409 +1410 +    // --- api counts == html counts (same struct pass) ---1411 +    let issues_html = read("issues/index.html").matches("<tr><td>").count();1412 +    let pulls_html = read("pulls/index.html").matches("<tr><td>").count();1413 +    let commits_html = read("commits.html").matches("<tr><td>").count();1414 +    let releases_html = read("releases/index.html").matches("<tr><td>").count();1415 +    let wiki_html = read("wiki/index.html")1416 +        .matches("<li><a href=\"/wiki/")1417 +        .count();1418 +    assert_eq!(1419 +        json_len(&read("api/issues.json")),1420 +        issues_html,1421 +        "issues count"1422 +    );1423 +    assert_eq!(json_len(&read("api/pulls.json")), pulls_html, "pulls count");1424 +    assert_eq!(1425 +        json_len(&read("api/commits.json")),1426 +        commits_html,1427 +        "commits count"1428 +    );1429 +    assert_eq!(1430 +        json_len(&read("api/releases.json")),1431 +        releases_html,1432 +        "releases count"1433 +    );1434 +    assert_eq!(json_len(&read("api/wiki.json")), wiki_html, "wiki count");1435 +    // Compact fixed-order JSON: single line, expected first keys.1436 +    for (rel, key) in [1437 +        ("api/issues.json", "\"number\""),1438 +        ("api/pulls.json", "\"number\""),1439 +        ("api/commits.json", "\"sha\""),1440 +        ("api/releases.json", "\"version\""),1441 +        ("api/wiki.json", "\"path\""),1442 +    ] {1443 +        let text = read(rel);1444 +        assert!(!text.contains('\n'), "{rel} is compact");1445 +        assert!(text.contains(key), "{rel} fixed order starts with {key}");1446 +    }1447 +1448 +    // --- agents.html + agents.md ---1449 +    let agents_html = read("agents.html");1450 +    let agents_md = read("agents.md");1451 +    for needle in [1452 +        "/raw/",1453 +        "/api/issues.json",1454 +        "/repo.tar.gz",1455 +        "/repo.bundle",1456 +        "application/x-www-form-urlencoded",1457 +        "honeypot",1458 +        "<=300",1459 +        "<=20000",1460 +        "<=32KB",1461 +        "By submitting this text",1462 +        "detailed actionable text",1463 +        "ONE prompt not code",1464 +    ] {1465 +        assert!(agents_md.contains(needle), "agents.md has {needle}");1466 +        assert!(1467 +            agents_html.contains(1468 +                &needle1469 +                    .replace('<', "&lt;")1470 +                    .replace('>', "&gt;")1471 +                    .replace('"', "&quot;")1472 +            ) || agents_html.contains(needle),1473 +            "agents.html has {needle}"1474 +        );1475 +    }1476 +    // Curl examples hittable: exact m4 route paths in both files.1477 +    for path in ["/inbox/issue", "/inbox/pr-prompt"] {1478 +        assert!(agents_md.contains(path), "agents.md curl hits {path}");1479 +        assert!(agents_html.contains(path), "agents.html curl hits {path}");1480 +    }1481 +    assert!(agents_md.contains("curl -X POST"), "curl example present");1482 +1483 +    // --- feed includes the release ---1484 +    let feed = read("feed.atom");1485 +    assert!(feed.contains("v1.0"), "feed has release entry");1486 +    assert!(feed.contains("v2.0"), "feed has second release");1487 +    assert!(feed.contains("urn:release:"), "release urn ids");1488 +    assert!(!feed.contains("TODO(m3)"), "no m3 marker left");1489 +1490 +    // --- nav links releases + agents ---1491 +    let home = read("index.html");1492 +    assert!(home.contains("/releases/index.html"), "nav links releases");1493 +    assert!(home.contains("/agents.html"), "nav links agents");1494 +}1495 -- 1496 2.55.01497