From c8d4fbc66217f344fccc8249d713b0b3c61240df Mon Sep 17 00:00:00 2001
From: gl0w
Date: Thu, 10 Sep 2026 05:36:21 +0100
Subject: [PATCH] m3/releases-api: releases+artifacts, api JSON, agents page
(60 tests)
---
src/agents.rs | 143 +++++++++++++
src/api.rs | 170 ++++++++++++++++
src/build.rs | 36 +++-
src/commits.rs | 40 +++-
src/feed.rs | 133 +++++++-----
src/main.rs | 6 +
src/releases.rs | 464 ++++++++++++++++++++++++++++++++++++++++++
src/wiki.rs | 9 +-
tests/releases_api.rs | 239 ++++++++++++++++++++++
9 files changed, 1167 insertions(+), 73 deletions(-)
create mode 100644 src/agents.rs
create mode 100644 src/api.rs
create mode 100644 src/releases.rs
create mode 100644 tests/releases_api.rs
diff --git a/src/agents.rs b/src/agents.rs
new file mode 100644
index 0000000..3de1133
--- /dev/null
+++ b/src/agents.rs
@@ -0,0 +1,143 @@
+//! Agent surface (m3, f0rge issue #8): `agents.html` + `agents.md`.
+//!
+//! Static instructions for agents: read via raw/api/tarball/bundle, submit
+//! via the two inbox POSTs. The markdown source is written verbatim to
+//! `dist/agents.md`; `dist/agents.html` is the same text rendered through
+//! [`crate::build::markdown_to_html`].
+//!
+//! The curl examples POST to the exact m4 route paths `/inbox/issue` and
+//! `/inbox/pr-prompt` (the m4 server implements those paths; keep the
+//! examples in sync). License text is quoted verbatim from `DRAFT.md`.
+//!
+//! SECURITY: all untrusted values (site name, clone URL, branch, sha) go
+//! through [`crate::layout::esc`]; the guide body is static text.
+
+use std::path::Path;
+
+use crate::build::markdown_to_html;
+use crate::layout::{esc, page};
+
+/// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).
+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.";
+
+fn agents_markdown(
+ site_name: &str,
+ clone_url: Option<&str>,
+ branch: &str,
+ short_sha: &str,
+) -> String {
+ let clone_line = match clone_url {
+ Some(u) => format!("Clone: `git clone {u}` (branch `{branch}` @ `{short_sha}`)."),
+ None => format!("No clone URL configured; branch `{branch}` @ `{short_sha}`."),
+ };
+ format!(
+ "# Agents guide — {site_name}\n\n\
+ You are an agent visiting a hu13 mirror of one repo. No accounts, no JS, no auth.\n\
+ Read with curl; suggest with two dumb text POSTs. A human (or their agent) triages later.\n\n\
+ ## Read\n\n\
+ - Site: `{site_name}`, {clone_line}\n\
+ - Raw file bytes: `/raw/` (byte-identical to the repo).\n\
+ - 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\
+ - Snapshots: `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\
+ - Releases + artifacts: `/releases/index.html`, `/releases/.html`, downloads under `/releases//`.\n\
+ - Feed: `/feed.atom` (up to 50 entries, commits + releases newest-first — poll this, not the HTML).\n\
+ - Human pages mirror the same data: `/commits.html`, `/commit/.html`, `/issues/index.html`, `/pulls/index.html`, `/tasks/index.html`, `/ci/index.html`, `/wiki/index.html`.\n\n\
+ ## Submit (the only write path)\n\n\
+ 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\
+ - `POST /inbox/issue` — suggest an issue. Fields: `title` + `body`.\n\
+ - `POST /inbox/pr-prompt` — suggest a change. Fields: `title` + `prompt`.\n\n\
+ Both POSTs are `Content-Type: application/x-www-form-urlencoded` (urlencoded-only; `multipart/*` is rejected), UTF-8 only.\n\n\
+ | field | rule |\n\
+ | ----- | ---- |\n\
+ | `title` | required, plain text, <=300 chars |\n\
+ | `body` / `prompt` | required, plain text, <=20000 chars |\n\
+ | `handle` | optional, plain text, <=40 chars |\n\
+ | `contact` | optional, plain text, <=100 chars |\n\
+ | `license` | required checkbox — must be checked, else `400` |\n\
+ | honeypot | `honeypot` field must be left empty (filled = silently dropped, still says received) |\n\
+ | whole request | <=32KB else `413` |\n\n\
+ 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\
+ ### License (required — check the box only if you agree)\n\n\
+ > {license}\n\n\
+ Stored per submission: license version + checkbox state + body hash + timestamp. No submission without the box checked.\n\n\
+ ## Philosophy\n\n\
+ - Issues = detailed actionable text from your user. Write what a maintainer can act on: context, steps, expected vs actual, links to `/raw/` paths.\n\
+ - 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\
+ - Free textboxes: the forms are free text on purpose — visiting agents are smarter than any template. Keep it dumb, keep it text.\n\n\
+ ## Curl examples\n\n\
+ Suggest an issue:\n\n\
+ ```sh\n\
+ curl -X POST http://localhost:39313/inbox/issue \\\n\
+ --data-urlencode \"title=Crash on empty config\" \\\n\
+ --data-urlencode \"body=Steps to reproduce: ... (detailed actionable text from your user)\" \\\n\
+ --data-urlencode \"handle=my-bot\" \\\n\
+ --data-urlencode \"contact=bot@example.com\" \\\n\
+ --data-urlencode \"license=agree\" \\\n\
+ --data-urlencode \"honeypot=\"\n\
+ ```\n\n\
+ Suggest a PR prompt (ONE prompt, not code):\n\n\
+ ```sh\n\
+ curl -X POST http://localhost:39313/inbox/pr-prompt \\\n\
+ --data-urlencode \"title=Retry with backoff\" \\\n\
+ --data-urlencode \"prompt=In the uploader, retry failed puts with exponential backoff and ... (ONE prompt, not code)\" \\\n\
+ --data-urlencode \"handle=my-bot\" \\\n\
+ --data-urlencode \"contact=bot@example.com\" \\\n\
+ --data-urlencode \"license=agree\" \\\n\
+ --data-urlencode \"honeypot=\"\n\
+ ```\n\n\
+ Caps reminder: `title` <=300 chars, `body`/`prompt` <=20000 chars, whole request <=32KB. Rate ~2/hr per IP; be polite.\n",
+ site_name = site_name,
+ clone_line = clone_line,
+ branch = branch,
+ license = LICENSE_TEXT.replace('\n', "\n> "),
+ )
+}
+
+/// Render `agents.html` (rendered guide) + `agents.md` (raw markdown copy).
+pub fn render(
+ out: &Path,
+ site_name: &str,
+ nav: &str,
+ clone_url: Option<&str>,
+ branch: &str,
+ short_sha: &str,
+) -> Result<(), String> {
+ let site_e = esc(site_name);
+ let branch_e = esc(branch);
+ let sha_e = esc(short_sha);
+ let url_e = clone_url.map(esc);
+ let md = agents_markdown(&site_e, url_e.as_deref(), &branch_e, &sha_e);
+ // NOTE: site_name/branch/sha above are pre-escaped for the markdown
+ // source so the raw `agents.md` and rendered `agents.html` agree. The
+ // markdown renderer escapes raw HTML again, so `&` etc. stay safe.
+ let html_body = markdown_to_html(&md);
+ let html = page(&format!("agents — {site_name}"), nav, &html_body);
+ std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;
+ std::fs::write(out.join("agents.html"), html)
+ .map_err(|e| format!("hu13: write agents.html: {e}"))?;
+ std::fs::write(out.join("agents.md"), &md)
+ .map_err(|e| format!("hu13: write agents.md: {e}"))?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn curl_examples_hit_m4_routes() {
+ let md = agents_markdown("test", None, "main", "abc1234");
+ assert!(md.contains("/inbox/issue"), "issue POST path present");
+ assert!(
+ md.contains("/inbox/pr-prompt"),
+ "pr-prompt POST path present"
+ );
+ assert!(md.contains("application/x-www-form-urlencoded"));
+ assert!(md.contains("<=300"));
+ assert!(md.contains("<=20000"));
+ assert!(md.contains("<=32KB"));
+ assert!(md.contains("ONE prompt not code") || md.contains("ONE prompt"));
+ assert!(md.contains("detailed actionable text"));
+ assert!(md.contains("By submitting this text"));
+ }
+}
diff --git a/src/api.rs b/src/api.rs
new file mode 100644
index 0000000..cf43f6a
--- /dev/null
+++ b/src/api.rs
@@ -0,0 +1,170 @@
+//! Machine JSON (m3, f0rge issue #8): `api/*.json` built from the SAME
+//! structs used for HTML (no divergent logic).
+//!
+//! [`render`] takes the already-loaded readers — [`crate::forge::ForgeData`]
+//! (issues/pulls/tasks/ci), the commit log slice from [`crate::gitx::log_n`]
+//! (the same vec rendered to `commits.html`), the release slice from
+//! [`crate::releases::load`], and the wiki pages from [`crate::wiki::render`]
+//! — and writes compact fixed-order JSON with `serde_json::to_string`
+//! (never pretty). Array lengths therefore equal the HTML list counts by
+//! construction; see `tests/releases_api.rs`.
+//!
+//! SECURITY: bodies are untrusted text carried as JSON strings (never HTML).
+
+use std::path::Path;
+
+use serde::Serialize;
+
+use crate::forge::ForgeData;
+use crate::gitx::Commit;
+use crate::releases::Release;
+use crate::wiki::WikiPage;
+
+/// One issue row (fixed field order).
+#[derive(Debug, Clone, Serialize)]
+pub struct IssueJson {
+ pub number: usize,
+ pub id: String,
+ pub title: String,
+ pub state: String,
+ pub labels: Vec,
+ pub assignees: Vec,
+ pub milestone: Option,
+ pub created: String,
+ pub comments: usize,
+ pub body: String,
+}
+
+/// One pull row (fixed field order).
+#[derive(Debug, Clone, Serialize)]
+pub struct PullJson {
+ pub number: usize,
+ pub id: String,
+ pub title: String,
+ pub state: String,
+ pub base: String,
+ pub head: String,
+ pub draft: bool,
+ pub created: String,
+ pub comments: usize,
+ pub reviews: usize,
+ pub body: String,
+}
+
+/// One commit row (fixed field order).
+#[derive(Debug, Clone, Serialize)]
+pub struct CommitJson {
+ pub sha: String,
+ pub subject: String,
+ pub author: String,
+ pub date: String,
+}
+
+/// One release row (fixed field order).
+#[derive(Debug, Clone, Serialize)]
+pub struct ReleaseJson {
+ pub version: String,
+ pub title: Option,
+ pub created: String,
+ pub sha: Option,
+ pub artifacts: usize,
+ pub body: String,
+}
+
+/// One wiki row (fixed field order).
+#[derive(Debug, Clone, Serialize)]
+pub struct WikiJson {
+ pub path: String,
+ pub title: String,
+ pub size: u64,
+}
+
+fn write_json(out: &Path, rel: &str, value: &impl Serialize) -> Result<(), String> {
+ // Compact fixed-order JSON (serde struct order, no pretty printing).
+ let text = serde_json::to_string(value).map_err(|e| format!("hu13: encode {rel}: {e}"))?;
+ let dest = out.join(rel);
+ if let Some(parent) = dest.parent() {
+ std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create api dir: {e}"))?;
+ }
+ std::fs::write(&dest, text).map_err(|e| format!("hu13: write {rel}: {e}"))
+}
+
+/// Build `api/issues.json`, `api/pulls.json`, `api/commits.json`,
+/// `api/releases.json`, and `api/wiki.json` from the same structs rendered
+/// to HTML. Callers must pass the exact slices/vecs used for the HTML pass.
+pub fn render(
+ out: &Path,
+ forge: &ForgeData,
+ commits: &[Commit],
+ releases: &[Release],
+ wiki: &[WikiPage],
+) -> Result<(), String> {
+ let issues: Vec = forge
+ .issues
+ .iter()
+ .map(|i| IssueJson {
+ number: i.number,
+ id: i.id.clone(),
+ title: i.title.clone(),
+ state: i.state.clone(),
+ labels: i.labels.clone(),
+ assignees: i.assignees.clone(),
+ milestone: i.milestone.clone(),
+ created: i.created.clone(),
+ comments: i.comments.len(),
+ body: i.body.clone(),
+ })
+ .collect();
+ let pulls: Vec = forge
+ .prs
+ .iter()
+ .map(|p| PullJson {
+ number: p.number,
+ id: p.id.clone(),
+ title: p.title.clone(),
+ state: p.state.clone(),
+ base: p.base.clone(),
+ head: p.head.clone(),
+ draft: p.draft,
+ created: p.created.clone(),
+ comments: p.comments.len(),
+ reviews: p.reviews.len(),
+ body: p.body.clone(),
+ })
+ .collect();
+ let commits_json: Vec = commits
+ .iter()
+ .map(|c| CommitJson {
+ sha: c.sha.clone(),
+ subject: c.subject.clone(),
+ author: c.author.clone(),
+ date: c.date.clone(),
+ })
+ .collect();
+ let releases_json: Vec = releases
+ .iter()
+ .map(|r| ReleaseJson {
+ version: r.version.clone(),
+ title: r.title.clone(),
+ created: r.created.clone(),
+ sha: r.sha.clone(),
+ artifacts: r.artifacts.len(),
+ body: r.body.clone(),
+ })
+ .collect();
+ let wiki_json: Vec = wiki
+ .iter()
+ .map(|w| WikiJson {
+ path: w.path.clone(),
+ title: w.title.clone(),
+ size: w.size,
+ })
+ .collect();
+
+ write_json(out, "api/issues.json", &issues)?;
+ write_json(out, "api/pulls.json", &pulls)?;
+ write_json(out, "api/commits.json", &commits_json)?;
+ write_json(out, "api/releases.json", &releases_json)?;
+ write_json(out, "api/wiki.json", &wiki_json)?;
+ Ok(())
+}
diff --git a/src/build.rs b/src/build.rs
index 9920bf8..5acf830 100644
--- a/src/build.rs
+++ b/src/build.rs
@@ -1,21 +1,27 @@
-//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers).
+//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers + releases/api/agents).
//!
//! Reads one branch via [`crate::gitx`], emits `dist/`:
//! `index.html`, `files/.html`, `raw/` (byte-identical),
//! `commits.html`, `commit/.html`, `feed.atom`, `issues/index.html`,
//! `issues/.html`, `pulls/index.html`, `pulls/.html`,
-//! `tasks/index.html`, `ci/index.html`, `wiki/index.html`,
-//! `wiki/.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.
+//! `tasks/index.html`, `ci/index.html`, `releases/index.html`,
+//! `releases/.html`, `releases//` artifacts,
+//! `wiki/index.html`, `wiki/.html`, `api/*.json`, `agents.html` +
+//! `agents.md`, `repo.bundle` + `repo.tar.gz`, and `style.css`.
//!
//! Bodies from the repo are semi-trusted but treated as untrusted:
//! markdown goes through [`markdown_to_html`], which escapes raw HTML;
//! commit messages and diffs are escaped `` (never markdown).
+//!
+//! Machine JSON (`api/*.json`) and HTML are built from one struct pass over
+//! the same loaded readers (no divergent logic): `forge::load`,
+//! `commits::load`, `releases::load`, `wiki::render`.
use std::collections::BTreeMap;
use std::path::Path;
use crate::layout::esc;
-use crate::{commits, feed, forge, gitx, wiki};
+use crate::{agents, api, commits, feed, forge, gitx, releases, wiki};
/// Files at or above this size get a placeholder page + download link.
pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;
@@ -155,7 +161,9 @@ fn nav_bar(issue_count: usize, pr_count: usize) -> String {
pulls ({pr_count}) · \
tasks · \
ci · \
- wiki"
+ releases · \
+ wiki · \
+ agents"
)
}
@@ -349,12 +357,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result Resultissues ({}) · \
pulls ({}) · \
- tasks ({})
\n",
+ tasks ({}) · \
+ releases ({})\n",
forge_data.issues.len(),
forge_data.prs.len(),
forge_data.tasks.len(),
+ release_data.len(),
));
body.push_str(&format!(
"CI: {}
\n",
diff --git a/src/commits.rs b/src/commits.rs
index 834971c..593c791 100644
--- a/src/commits.rs
+++ b/src/commits.rs
@@ -16,26 +16,27 @@ pub fn short_sha(sha: &str) -> &str {
sha.get(..7).unwrap_or(sha)
}
-/// Render `commits.html` plus one `commit/.html` per listed commit.
-///
-/// Lists at most `max_log` newest-first commits (see `--max-log`).
-/// Returns the number of commits listed.
-pub fn render(
+/// Load up to `max_log` newest-first commits (the same vec rendered to
+/// `commits.html` and `api/commits.json` — one struct pass, no divergent logic).
+pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result, String> {
+ gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))
+}
+
+/// Render `commits.html` plus one `commit/.html` per commit from an
+/// already-loaded log (see [`load`]).
+pub fn render_loaded(
repo: &Path,
out: &Path,
- branch: &str,
- max_log: usize,
+ log: &[gitx::Commit],
site_name: &str,
nav: &str,
) -> Result {
- let log = gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))?;
-
let mut body = String::from("Commits
\n");
if log.is_empty() {
body.push_str("No commits yet.
\n");
} else {
body.push_str("\n| sha | subject | author | date |
\n\n");
- for c in &log {
+ for c in log {
body.push_str(&format!(
"{} | {} | {} | {} |
\n",
esc(&c.sha),
@@ -54,7 +55,7 @@ pub fn render(
std::fs::create_dir_all(out.join("commit"))
.map_err(|e| format!("hu13: create commit dir: {e}"))?;
- for c in &log {
+ for c in log {
let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;
let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;
let detail = format!(
@@ -77,6 +78,23 @@ pub fn render(
Ok(log.len())
}
+/// Render `commits.html` plus details, loading the log itself.
+///
+/// Wrapper around [`load`] + [`render_loaded`] for tests and simple callers.
+/// Lists at most `max_log` newest-first commits (see `--max-log`).
+#[allow(dead_code)]
+pub fn render(
+ repo: &Path,
+ out: &Path,
+ branch: &str,
+ max_log: usize,
+ site_name: &str,
+ nav: &str,
+) -> Result {
+ let log = load(repo, branch, max_log)?;
+ render_loaded(repo, out, &log, site_name, nav)
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/feed.rs b/src/feed.rs
index 2fa59f8..437d787 100644
--- a/src/feed.rs
+++ b/src/feed.rs
@@ -1,30 +1,21 @@
-//! `feed.atom` (m2, f0rge issue #6): 50 entries max, commits +
+//! `feed.atom` (m2/m3, f0rge issues #6 + #8): 50 entries max, commits +
//! release notes mixed newest-first, written at build time.
//!
//! No rss/atom crate (std only): hand-rolled XML writer with [`esc`].
//! Each entry carries title, `urn:` id, RFC3339 `updated`, link, and an
//! escaped summary.
-//!
-//! Releases: no `src/releases.rs` reader exists yet, so this is
-//! commits-only for now.
-//! TODO(m3): mix in `.forge/releases/.md` notes newest-first
-//! once the m3 releases reader lands (see [`RELEASES_SUPPORTED`] +
-//! [`read_release_entries`]).
use std::path::Path;
use crate::gitx;
+use crate::releases;
/// Max entries in `feed.atom` (DRAFT: 50, commits + releases mixed).
pub const MAX_ENTRIES: usize = 50;
-/// Test hook for m3: `false` while no release reader exists in `src/`.
-///
-/// When `src/releases.rs` (or equivalent) lands, set this to `true` and
-/// implement [`read_release_entries`]; the `releases_required_when_present`
-/// test flips from "commits-only tolerated" to "release entries required".
-// TODO(m3): set to true once the releases reader exists.
-pub const RELEASES_SUPPORTED: bool = false;
+/// Releases are wired in (m3 releases reader): `true`.
+#[allow(dead_code)]
+pub const RELEASES_SUPPORTED: bool = true;
/// One Atom entry (commit or release note).
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -35,7 +26,7 @@ pub struct FeedEntry {
pub id: String,
/// RFC3339 timestamp (converted from git iso date / release `created`).
pub updated: String,
- /// Site-relative link (`/commit/.html`, `/releases/...` later).
+ /// Site-relative link (`/commit/.html`, `/releases/.html`).
pub link: String,
/// Escaped one-line summary.
pub summary: String,
@@ -80,21 +71,51 @@ pub fn to_rfc3339(iso: &str) -> String {
s.to_string()
}
-/// Read release-note entries for the feed.
-///
-/// Commits-only stub: no release reader exists in `src/` yet, so this
-/// returns an empty vec.
-// TODO(m3): read `.forge/releases/.md` notes (title/body) here
-// and return one `FeedEntry` per release (`urn:release:`).
+/// Read release-note entries for the feed from the same reader used for
+/// HTML ([`releases::load`], no divergent logic): one entry per release
+/// (`urn:release:`, link `/releases/.html`).
#[must_use]
-pub fn read_release_entries(_repo: &Path) -> Vec {
- // TODO(m3): releases not yet wired; commits-only feed.
- Vec::new()
+pub fn read_release_entries(repo: &Path) -> Vec {
+ releases::load(repo)
+ .into_iter()
+ .map(|r| {
+ let first_line = r
+ .body
+ .lines()
+ .map(str::trim)
+ .find(|l| !l.is_empty())
+ .unwrap_or("")
+ .to_string();
+ let summary = if first_line.is_empty() {
+ format!("release {} ({})", r.version, r.created)
+ } else {
+ let mut s: String = first_line.chars().take(200).collect();
+ if s.len() > 200 {
+ s.truncate(200);
+ }
+ s
+ };
+ FeedEntry {
+ title: r.display_title().to_string(),
+ id: format!("urn:release:{}", r.version),
+ updated: r.created.clone(),
+ link: format!("/releases/{}.html", r.version),
+ summary,
+ }
+ })
+ .collect()
}
/// Collect up to [`MAX_ENTRIES`] entries, commits + releases newest-first.
+#[allow(dead_code)]
pub fn collect_entries(repo: &Path, branch: &str) -> Result, String> {
let log = gitx::log_n(repo, branch, MAX_ENTRIES).map_err(|e| format!("hu13: {e}"))?;
+ collect_entries_loaded(repo, &log)
+}
+
+/// Collect entries from an already-loaded commit log plus releases from
+/// [`read_release_entries`] (one struct pass with the HTML readers).
+pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result, String> {
let mut entries: Vec = log
.iter()
.map(|c| {
@@ -108,9 +129,8 @@ pub fn collect_entries(repo: &Path, branch: &str) -> Result, Stri
}
})
.collect();
- // TODO(m3): releases mixed in newest-first once the reader lands.
- let mut releases = read_release_entries(repo);
- entries.append(&mut releases);
+ let mut rels = read_release_entries(repo);
+ entries.append(&mut rels);
// Newest-first by RFC3339 `updated` (lexicographic == chronological).
entries.sort_by(|a, b| b.updated.cmp(&a.updated));
entries.truncate(MAX_ENTRIES);
@@ -129,9 +149,6 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri
out.push_str(&format!("urn:hu13:feed:{}\n", esc(branch)));
out.push_str(&format!("{updated}\n"));
out.push_str("hu13\n");
- if !RELEASES_SUPPORTED {
- out.push_str("\n");
- }
for e in entries {
out.push_str("\n");
out.push_str(&format!("{}\n", esc(&e.title)));
@@ -147,9 +164,31 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri
/// Build `feed.atom` in `out` (called from [`crate::build::run_build`]
/// right after commits). Returns the number of entries written.
+#[allow(dead_code)]
pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result {
let entries = collect_entries(repo, branch)?;
- let doc = render_feed(site_name, branch, &entries);
+ render_entries(out, site_name, branch, &entries)
+}
+
+/// Build `feed.atom` from already-loaded commits (one struct pass).
+pub fn render_loaded(
+ repo: &Path,
+ out: &Path,
+ branch: &str,
+ site_name: &str,
+ log: &[gitx::Commit],
+) -> Result {
+ let entries = collect_entries_loaded(repo, log)?;
+ render_entries(out, site_name, branch, &entries)
+}
+
+fn render_entries(
+ out: &Path,
+ site_name: &str,
+ branch: &str,
+ entries: &[FeedEntry],
+) -> Result {
+ let doc = render_feed(site_name, branch, entries);
std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;
std::fs::write(out.join("feed.atom"), doc)
.map_err(|e| format!("hu13: write feed.atom: {e}"))?;
@@ -222,7 +261,7 @@ mod tests {
/// Minimal well-formedness check: angle-balance plus a tiny tag-stack
/// parse over the known Atom shape (feed/entry/title/id/updated/link/
- /// summary/author/name + xml prolog + TODO comment).
+ /// summary/author/name + xml prolog).
fn check_well_formed(xml: &str) {
let opens = xml.bytes().filter(|b| *b == b'<').count();
let closes = xml.bytes().filter(|b| *b == b'>').count();
@@ -341,9 +380,8 @@ mod tests {
#[test]
fn releases_required_when_present() {
- // Fixture WITH a releases dir: tolerates commits-only now
- // (RELEASES_SUPPORTED == false), but REQUIRES release entries
- // once the m3 reader lands (flag flips to true).
+ // Fixture WITH a releases dir: release entries are REQUIRED now
+ // that the m3 reader has landed.
let repo = fixture_repo();
std::fs::create_dir_all(repo.join(".forge").join("releases")).unwrap();
std::fs::write(
@@ -354,22 +392,15 @@ mod tests {
let entries = collect_entries(&repo, "main").expect("collect");
let xml = render_feed("test", "main", &entries);
check_well_formed(&xml);
- if RELEASES_SUPPORTED {
- assert!(
- xml.contains("v1.0"),
- "release entries required when releases/ dir exists (m3 landed)"
- );
- assert!(
- xml.contains("urn:release:"),
- "release urn ids required once m3 lands"
- );
- } else {
- // TODO(m3): commits-only tolerated until the releases reader lands.
- assert!(
- xml.contains("TODO(m3)"),
- "commits-only marker while releases unwired"
- );
- }
+ assert!(
+ xml.contains("v1.0"),
+ "release entries required when releases/ dir exists"
+ );
+ assert!(xml.contains("urn:release:"), "release urn ids required");
+ assert!(
+ !xml.contains("TODO(m3)"),
+ "no m3 TODO marker left in feed output"
+ );
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 6d84b9b..c2b1c2c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,9 +1,12 @@
+mod agents;
+mod api;
mod build;
mod commits;
mod feed;
mod forge;
mod gitx;
mod layout;
+mod releases;
mod wiki;
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -204,6 +207,9 @@ fn run_serve(args: &[String]) -> i32 {
print_serve_help();
return 0;
}
+ // TODO(m4): serve `dist/` with `X-Content-Type-Options: nosniff` on every
+ // response (static files + inbox POSTs); never execute or list beyond
+ // what the build emitted. Stub for now — prints help and exits 0.
print_serve_help();
0
}
diff --git a/src/releases.rs b/src/releases.rs
new file mode 100644
index 0000000..0abcb92
--- /dev/null
+++ b/src/releases.rs
@@ -0,0 +1,464 @@
+//! Releases (m3, f0rge issue #8): `.forge/releases/.md` notes +
+//! optional `releases/out//` artifacts.
+//!
+//! Source is the on-disk `/.forge/releases/` tree (NOT git objects):
+//! `.md` files carry frontmatter (`version`, `created` required;
+//! `title`, `sha` optional) plus a markdown notes body. `build/` holds build
+//! scripts (never rendered); `out//` holds built artifacts (gitignored,
+//! usually absent — fine).
+//!
+//! Pages: `releases/index.html` + `releases/.html`. When
+//! `releases/out//` exists at build time the WHOLE dir is copied to
+//! `dist/releases//` (no size cap — the maintainer shipped it, hu13
+//! serves it) and linked with sizes plus any sha256 lines found in the
+//! notes. When absent the page is notes-only with no warning.
+//!
+//! SECURITY: every byte from `.forge/` is untrusted. Notes bodies go through
+//! [`crate::build::markdown_to_html`] (raw HTML escaped); versions, titles,
+//! shas, filenames, and sha256 lines go through [`crate::layout::esc`].
+
+use std::path::{Path, PathBuf};
+
+use serde::Deserialize;
+
+use crate::build::markdown_to_html;
+use crate::layout::{esc, page};
+
+/// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).
+pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;
+
+#[derive(Debug, Clone, Deserialize)]
+struct ReleaseFm {
+ version: String,
+ created: String,
+ #[serde(default)]
+ title: Option,
+ #[serde(default)]
+ sha: Option,
+}
+
+/// One file inside `out//` (relative path + byte size).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Artifact {
+ /// `/`-separated path relative to `out//`.
+ pub path: String,
+ /// File size in bytes.
+ pub size: u64,
+}
+
+/// One release record: notes + artifacts found at build time.
+#[derive(Debug, Clone)]
+pub struct Release {
+ /// Version string (from frontmatter `version`).
+ pub version: String,
+ /// Optional title.
+ pub title: Option,
+ /// RFC3339 creation timestamp.
+ pub created: String,
+ /// Optional commit sha recorded at create time.
+ pub sha: Option,
+ /// Markdown notes body after the frontmatter.
+ pub body: String,
+ /// Artifacts present in `out//` at build (sorted by path).
+ pub artifacts: Vec,
+}
+
+impl Release {
+ /// Display title: `title` when non-empty, else `version`.
+ #[must_use]
+ pub fn display_title(&self) -> &str {
+ match &self.title {
+ Some(t) if !t.trim().is_empty() => t,
+ _ => &self.version,
+ }
+ }
+}
+
+fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
+ let rest = text.strip_prefix("---")?;
+ let rest = rest
+ .strip_prefix('\n')
+ .or_else(|| rest.strip_prefix("\r\n"))?;
+ let mut offset = 0;
+ for line in rest.split_inclusive('\n') {
+ let trimmed = line.trim_end_matches(['\n', '\r']);
+ if trimmed == "---" {
+ let yaml = &rest[..offset];
+ let body = &rest[offset + line.len()..];
+ return Some((yaml, body));
+ }
+ offset += line.len();
+ }
+ None
+}
+
+fn read_text_capped(path: &Path, label: &str) -> Option {
+ let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
+ if len > RELEASE_MAX_BYTES {
+ eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");
+ return None;
+ }
+ match std::fs::read(path) {
+ Ok(bytes) => match String::from_utf8(bytes) {
+ Ok(text) => Some(text),
+ Err(_) => {
+ eprintln!("hu13: releases: skipping {label}: not valid UTF-8");
+ None
+ }
+ },
+ Err(e) => {
+ eprintln!("hu13: releases: skipping {label}: cannot read: {e}");
+ None
+ }
+ }
+}
+
+/// Collect every file under `dir` as `(relative /-separated path, size)`,
+/// sorted by path. No size cap: the maintainer shipped it, hu13 serves it.
+fn collect_artifacts(dir: &Path) -> Vec {
+ let mut out = Vec::new();
+ let mut stack: Vec = vec![PathBuf::new()];
+ while let Some(rel) = stack.pop() {
+ let abs = dir.join(&rel);
+ let rd = match std::fs::read_dir(&abs) {
+ Ok(rd) => rd,
+ Err(e) => {
+ eprintln!("hu13: releases: cannot list {}: {e}", abs.display());
+ continue;
+ }
+ };
+ for ent in rd {
+ let ent = match ent {
+ Ok(ent) => ent,
+ Err(e) => {
+ eprintln!("hu13: releases: bad dir entry: {e}");
+ continue;
+ }
+ };
+ let name = ent.file_name().to_string_lossy().into_owned();
+ let rel_child = if rel.as_os_str().is_empty() {
+ PathBuf::from(&name)
+ } else {
+ rel.join(&name)
+ };
+ let ft = match ent.file_type() {
+ Ok(ft) => ft,
+ Err(_) => continue,
+ };
+ if ft.is_symlink() {
+ continue;
+ }
+ if ft.is_dir() {
+ stack.push(rel_child);
+ } else if ft.is_file() {
+ let size = std::fs::metadata(ent.path()).map(|m| m.len()).unwrap_or(0);
+ let rel_str = rel_child
+ .components()
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
+ .collect::>()
+ .join("/");
+ out.push(Artifact {
+ path: rel_str,
+ size,
+ });
+ }
+ }
+ }
+ out.sort_by(|a, b| a.path.cmp(&b.path));
+ out
+}
+
+/// Copy the WHOLE `src_dir` tree to `dest_dir` (no size cap).
+fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {
+ let mut stack: Vec = vec![PathBuf::new()];
+ while let Some(rel) = stack.pop() {
+ let src = src_dir.join(&rel);
+ let dest = dest_dir.join(&rel);
+ let rd = match std::fs::read_dir(&src) {
+ Ok(rd) => rd,
+ Err(e) => {
+ return Err(format!("hu13: copy releases dir {}: {e}", src.display()));
+ }
+ };
+ std::fs::create_dir_all(&dest)
+ .map_err(|e| format!("hu13: create releases dir {}: {e}", dest.display()))?;
+ for ent in rd {
+ let ent = match ent {
+ Ok(ent) => ent,
+ Err(e) => return Err(format!("hu13: list releases dir: {e}")),
+ };
+ let name = ent.file_name();
+ let rel_child = if rel.as_os_str().is_empty() {
+ PathBuf::from(&name)
+ } else {
+ rel.join(&name)
+ };
+ let ft = ent
+ .file_type()
+ .map_err(|e| format!("hu13: stat releases file: {e}"))?;
+ if ft.is_symlink() {
+ continue;
+ }
+ if ft.is_dir() {
+ stack.push(rel_child);
+ } else if ft.is_file() {
+ let bytes =
+ std::fs::read(ent.path()).map_err(|e| format!("hu13: read artifact: {e}"))?;
+ std::fs::write(dest_dir.join(&rel_child), &bytes)
+ .map_err(|e| format!("hu13: write artifact: {e}"))?;
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Load every release from on-disk `/.forge/releases/*.md`.
+/// Never fails: bad files are skipped with warnings; a missing dir yields
+/// an empty vec. Order is `(created, version)`.
+#[must_use]
+pub fn load(repo: &Path) -> Vec {
+ let root = repo.join(".forge").join("releases");
+ let rd = match std::fs::read_dir(&root) {
+ Ok(rd) => rd,
+ Err(_) => return Vec::new(),
+ };
+ let mut names: Vec = Vec::new();
+ for ent in rd {
+ let ent = match ent {
+ Ok(ent) => ent,
+ Err(e) => {
+ eprintln!("hu13: releases: bad dir entry: {e}");
+ continue;
+ }
+ };
+ let Ok(ft) = ent.file_type() else {
+ continue;
+ };
+ if ft.is_symlink() || !ft.is_file() {
+ continue;
+ }
+ let name = ent.file_name().to_string_lossy().into_owned();
+ if name.ends_with(".md") {
+ names.push(name);
+ }
+ }
+ names.sort();
+
+ let mut out = Vec::new();
+ for name in names {
+ let label = format!("releases/{name}");
+ let Some(text) = read_text_capped(&root.join(&name), &label) else {
+ continue;
+ };
+ let Some((yaml, body)) = split_frontmatter(&text) else {
+ eprintln!("hu13: releases: skipping {label}: missing frontmatter");
+ continue;
+ };
+ let fm: ReleaseFm = match yaml_serde::from_str(yaml) {
+ Ok(fm) => fm,
+ Err(e) => {
+ eprintln!("hu13: releases: skipping {label}: {e}");
+ continue;
+ }
+ };
+ // Artifacts: present only when `out//` exists at build.
+ // Absent -> notes-only, no warning.
+ let artifacts = if fm.version.contains('/')
+ || fm.version.contains('\\')
+ || fm.version.split('/').any(|p| p == "..")
+ {
+ Vec::new()
+ } else {
+ let out_dir = root.join("out").join(&fm.version);
+ if out_dir.is_dir() {
+ collect_artifacts(&out_dir)
+ } else {
+ Vec::new()
+ }
+ };
+ out.push(Release {
+ version: fm.version,
+ title: fm.title.filter(|t| !t.trim().is_empty()),
+ created: fm.created,
+ sha: fm.sha.filter(|s| !s.trim().is_empty()),
+ body: body.to_string(),
+ artifacts,
+ });
+ }
+ out.sort_by(|a, b| {
+ (a.created.clone(), a.version.clone()).cmp(&(b.created.clone(), b.version.clone()))
+ });
+ out
+}
+
+/// Lines in `body` that look like sha256 digest lines: contain 64 hex
+/// chars. Returned trimmed, in body order.
+#[must_use]
+pub fn sha256_lines(body: &str) -> Vec {
+ body.lines()
+ .filter(|line| {
+ let lower = line.trim().to_lowercase();
+ let mut run = 0usize;
+ let mut best = 0usize;
+ for ch in lower.chars() {
+ if ch.is_ascii_hexdigit() {
+ run += 1;
+ best = best.max(run);
+ } else {
+ run = 0;
+ }
+ }
+ best >= 64
+ })
+ .map(|l| l.trim().to_string())
+ .collect()
+}
+
+fn artifact_href(version: &str, path: &str) -> String {
+ format!("/releases/{}/{path}", esc(version))
+}
+
+fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {
+ let mut body = format!("{}
\n", esc(rel.display_title()));
+ body.push_str(&format!(
+ "version: {}
\n",
+ esc(&rel.version)
+ ));
+ body.push_str(&format!(
+ "created: {}
\n",
+ esc(&rel.created)
+ ));
+ if let Some(sha) = &rel.sha {
+ body.push_str(&format!(
+ "sha: {}
\n",
+ esc(sha)
+ ));
+ }
+ body.push_str(&markdown_to_html(&rel.body));
+ if rel.artifacts.is_empty() {
+ body.push_str("No artifacts attached.
\n");
+ } else {
+ body.push_str("Artifacts
\n\n");
+ for a in &rel.artifacts {
+ body.push_str(&format!(
+ "- {} ({} bytes)
\n",
+ artifact_href(&rel.version, &a.path),
+ esc(&a.path),
+ a.size,
+ ));
+ }
+ body.push_str("
\n");
+ let hashes = sha256_lines(&rel.body);
+ if !hashes.is_empty() {
+ body.push_str("Checksums
\n");
+ for h in &hashes {
+ body.push_str(&esc(h));
+ body.push('\n');
+ }
+ body.push_str("\n");
+ }
+ }
+ body.push_str("all releases
\n");
+ page(
+ &format!("{} — releases — {site_name}", rel.display_title()),
+ nav,
+ &body,
+ )
+}
+
+/// Render `releases/index.html` + `releases/.html` from already-loaded
+/// data, copying each present `out//` tree to `dist/releases//`.
+pub fn render_loaded(
+ releases: &[Release],
+ repo: &Path,
+ out: &Path,
+ site_name: &str,
+ nav: &str,
+) -> Result<(), String> {
+ let root = repo.join(".forge").join("releases");
+ let mut body = String::from("Releases
\n");
+ if releases.is_empty() {
+ body.push_str("No releases yet.
\n");
+ } else {
+ body.push_str("\n| version | title | created | artifacts |
\n\n");
+ for rel in releases {
+ body.push_str(&format!(
+ "| {ve} | {t} | {c} | {n} |
\n",
+ v = esc(&rel.version),
+ ve = esc(&rel.version),
+ t = esc(rel.display_title()),
+ c = esc(&rel.created),
+ n = rel.artifacts.len(),
+ ));
+ }
+ body.push_str("\n
\n");
+ }
+ let index = page(&format!("releases — {site_name}"), nav, &body);
+ let dest = out.join("releases").join("index.html");
+ if let Some(parent) = dest.parent() {
+ std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create releases dir: {e}"))?;
+ }
+ std::fs::write(&dest, index).map_err(|e| format!("hu13: write releases index: {e}"))?;
+
+ for rel in releases {
+ // Copy the whole out dir when present (no size cap).
+ if !rel.version.contains('/') && !rel.version.contains('\\') {
+ let src_dir = root.join("out").join(&rel.version);
+ if src_dir.is_dir() {
+ let dest_dir = out.join("releases").join(&rel.version);
+ copy_tree(&src_dir, &dest_dir)?;
+ }
+ }
+ let html = render_release_page(rel, site_name, nav);
+ let page_dest = out.join("releases").join(format!("{}.html", rel.version));
+ if let Some(parent) = page_dest.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("hu13: create releases parent: {e}"))?;
+ }
+ std::fs::write(&page_dest, html)
+ .map_err(|e| format!("hu13: write release page {}: {e}", rel.version))?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sha256_line_detection() {
+ let digest = "a".repeat(64);
+ let body = format!("artifact:\n{digest} app.tar.gz\nplain line\n");
+ let lines = sha256_lines(&body);
+ assert_eq!(lines.len(), 1);
+ assert!(lines[0].contains("app.tar.gz"));
+ assert!(sha256_lines("no hashes here\n").is_empty());
+ }
+
+ #[test]
+ fn display_title_falls_back_to_version() {
+ let r = Release {
+ version: "v1.0".to_string(),
+ title: None,
+ created: "2026-01-01T00:00:00Z".to_string(),
+ sha: None,
+ body: String::new(),
+ artifacts: Vec::new(),
+ };
+ assert_eq!(r.display_title(), "v1.0");
+ }
+
+ #[test]
+ fn missing_dir_is_empty() {
+ let dir = std::env::temp_dir().join(format!(
+ "hu13-rel-empty-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ assert!(load(&dir).is_empty());
+ }
+}
diff --git a/src/wiki.rs b/src/wiki.rs
index 899cb1e..345792d 100644
--- a/src/wiki.rs
+++ b/src/wiki.rs
@@ -27,6 +27,8 @@ pub struct WikiPage {
pub path: String,
/// First `# H1` else the filename.
pub title: String,
+ /// Byte size of the source file.
+ pub size: u64,
}
fn is_h1_space(c: char) -> bool {
@@ -168,8 +170,13 @@ pub fn render(
};
let filename = rel.rsplit('/').next().unwrap_or(&rel);
let title = page_title(&text, filename);
+ let size = text.len() as u64;
bodies.insert(rel.clone(), text);
- pages.push(WikiPage { path: rel, title });
+ pages.push(WikiPage {
+ path: rel,
+ title,
+ size,
+ });
}
let wiki_dir = out.join("wiki");
diff --git a/tests/releases_api.rs b/tests/releases_api.rs
new file mode 100644
index 0000000..57345e3
--- /dev/null
+++ b/tests/releases_api.rs
@@ -0,0 +1,239 @@
+//! m3 releases + machine surface (f0rge issue #8):
+//! 1 release with `out/` artifacts (byte-identical) + 1 notes-only,
+//! api counts == html counts, agents.md curl examples hittable,
+//! feed includes the release.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+fn git(repo: &Path, args: &[&str]) {
+ let st = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(args)
+ .status()
+ .expect("spawn git");
+ assert!(st.success(), "git {args:?} failed");
+}
+
+fn fresh_dir(prefix: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!(
+ "hu13-relapi-{prefix}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+fn forge_write(repo: &Path, rel: &str, content: &str) {
+ let dest = repo.join(".forge").join(rel);
+ std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();
+ std::fs::write(&dest, content).unwrap();
+}
+
+fn json_len(text: &str) -> usize {
+ let v: serde_json::Value = serde_json::from_str(text).expect("valid json");
+ v.as_array().expect("top-level array").len()
+}
+
+#[test]
+fn releases_artifacts_api_agents_feed() {
+ let repo = fresh_dir("repo");
+ git(&repo, &["init", "-b", "main"]);
+ git(&repo, &["config", "user.name", "hu13-test"]);
+ git(&repo, &["config", "user.email", "hu13@test.local"]);
+ git(&repo, &["config", "commit.gpgsign", "false"]);
+ std::fs::write(repo.join("README.md"), "# relapi fixture\n").unwrap();
+
+ // One issue + one PR (for api/html count equality).
+ forge_write(
+ &repo,
+ "issues/11111111-1111-4111-8111-111111111111/issue.md",
+ "---\nid: 11111111-1111-4111-8111-111111111111\ntitle: API issue\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nIssue body.\n",
+ );
+ forge_write(
+ &repo,
+ "prs/44444444-4444-4444-8444-444444444444/pr.md",
+ "---\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",
+ );
+ // One wiki page.
+ forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");
+
+ // Release WITH artifacts: notes carry a sha256 line.
+ let digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+ forge_write(
+ &repo,
+ "releases/v1.0.md",
+ &format!(
+ "---\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"
+ ),
+ );
+ let artifact_bytes: Vec = (0u8..=255).collect();
+ let out_file = repo.join(".forge").join("releases/out/v1.0/app.bin");
+ std::fs::create_dir_all(out_file.parent().unwrap()).unwrap();
+ std::fs::write(&out_file, &artifact_bytes).unwrap();
+ std::fs::write(
+ repo.join(".forge").join("releases/out/v1.0/nested.txt"),
+ "nested artifact\n",
+ )
+ .unwrap();
+ // Release WITHOUT artifacts: notes-only.
+ forge_write(
+ &repo,
+ "releases/v2.0.md",
+ "---\nversion: v2.0\ncreated: 2026-09-10T04:00:00Z\n---\n# v2.0 notes\n\nNo artifacts yet.\n",
+ );
+
+ git(&repo, &["add", "-A"]);
+ git(&repo, &["commit", "-m", "relapi fixture"]);
+
+ let out = fresh_dir("out");
+ let bin = env!("CARGO_BIN_EXE_hu13");
+ let produced = Command::new(bin)
+ .arg("build")
+ .arg(&repo)
+ .arg("-o")
+ .arg(&out)
+ .output()
+ .expect("spawn hu13 build");
+ assert!(
+ produced.status.success(),
+ "build exits 0; stderr: {}",
+ String::from_utf8_lossy(&produced.stderr)
+ );
+
+ let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);
+
+ // --- releases: index + both detail pages ---
+ let index = read("releases/index.html");
+ assert_eq!(index.matches("| ").count(), 2, "two release rows");
+ assert!(index.contains("v1.0"), "v1.0 listed");
+ assert!(index.contains("v2.0"), "v2.0 listed");
+
+ let with_art = read("releases/v1.0.html");
+ assert!(with_art.contains("First release"), "title shown");
+ assert!(with_art.contains("app.bin"), "artifact linked");
+ assert!(with_art.contains("nested.txt"), "nested artifact linked");
+ assert!(with_art.contains("bytes"), "sizes shown");
+ assert!(with_art.contains(digest), "sha256 line from notes shown");
+ assert!(
+ !with_art.contains("No artifacts attached."),
+ "artifacts present"
+ );
+ // Escaping: no raw passthrough of the version block.
+ assert!(!with_art.contains(" |