diff --git a/README.md b/README.md
index 3b101f5..d47c226 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ command.before = ["git push"]
- [`docs/REFERENCE.md`](docs/REFERENCE.md) — every config field, seam by seam
- [`docs/DESIGN.md`](docs/DESIGN.md) — why it is shaped this way
+- [`docs/workflow.md`](docs/workflow.md) — how an action reaches uphold, and which bundled rules fire there (generated)
- [`docs/COVERAGE.md`](docs/COVERAGE.md) — which rung of checking exists for which language
- [`QUICK_REFERENCE.md`](QUICK_REFERENCE.md) — the catalog, one page
diff --git a/docs/workflow.md b/docs/workflow.md
new file mode 100644
index 0000000..ab45aaa
--- /dev/null
+++ b/docs/workflow.md
@@ -0,0 +1,187 @@
+
+
+# How an action reaches uphold
+
+Every path into uphold ends in one of a few commands. This page maps each way
+in to the command it ends in, then lists the bundled rules that fire there.
+It describes the tool; a repository's own rules are in its policy file, and
+`uphold rules --effective` lists what one resolves to.
+
+```mermaid
+flowchart LR
+ commit[git commit] --> hooks[pre-commit, commit-msg and
pre-merge-commit hooks]
+ push[git push] --> prepush[pre-push hook]
+ install[uphold hooks --install
or lefthook install] -.writes.-> hooks
+ install -.writes.-> prepush
+ hooks --> guard_stage[uphold guard --stage STAGE]
+ prepush --> guard_push[uphold guard --stage pre-push]
+ hooks -. runner config .-> scan
+ cmd[git, gh, ... on PATH] --> link[link named after the command]
+ shiminstall[uphold shim --install
uphold shim --hook] -.makes and
puts on PATH.-> link
+ link --> shim[uphold shim COMMAND]
+ shim --> real[the real command]
+ shim --> refuse[refusal, exit 1 or 2]
+ agent[agent tool call:
Claude Code PreToolUse, Codex] --> hook[uphold hook claude-code or codex]
+ hook --> allow[call proceeds]
+ hook --> deny[call refused]
+ ci[CI or by hand] --> scan[uphold scan]
+ ci --> guard_manual[uphold guard --stage manual]
+ ci --> supply[uphold supply-chain]
+ piped[text piped in] --> text[uphold scan --text
uphold guard --text]
+```
+
+- **Git stages.** `uphold hooks --install` writes `pre-commit`, `commit-msg`,
+ `pre-merge-commit` and `pre-push` as tracked files and points
+ `core.hooksPath` at them; it refuses lefthook, which installs and owns its
+ own hooks from the config `uphold init --lefthook` writes. Either way the
+ stage ends in `uphold guard --stage STAGE`. A runner's config may also run
+ `uphold scan` or `uphold supply-chain --staged` at `pre-commit`; those are
+ the repository's choice, not the hook's.
+- **Shim.** A link named for the command, on PATH ahead of the real one.
+ `uphold shim --install` makes the links and `uphold shim --hook` keeps them
+ on PATH only inside a tree that declares a policy. With no `[[shim]]` for the
+ command, the real command runs untouched.
+- **Agent harness.** `uphold hook` judges a pending tool call read on stdin. It
+ exists for publishing that spawns no process, such as an MCP server, so it
+ is configured for those tools rather than for shell commands.
+- **CI and manual.** `uphold scan` reads the tree, `uphold guard --stage
+ manual` runs the guards that sweep the whole repository, and `uphold
+ supply-chain` runs the dependency and secret scanners.
+- **Text.** `uphold scan --text` and `uphold guard --text` judge text with no
+ path, such as a commit message.
+
+## commit-msg
+
+`uphold guard --stage commit-msg`, after `git commit` writes the message.
+
+| Rule | Set | Message |
+|---|---|---|
+| `prevent-ai-author` | `commit-message-residue` | The `prevent-ai-author` guard writes its own report. |
+| `prevent-unusual-unicode` | `commit-message-residue` | The `prevent-unusual-unicode` guard writes its own report. |
+| `no-private-repo-names` | `private-names` | The `no-private-repo-names` guard writes its own report. |
+| `removed-function-named` | `unnamed-removal` | The `removed-function-named` guard writes its own report. |
+
+## pre-commit
+
+`uphold guard --stage pre-commit`, over what is staged.
+
+| Rule | Set | Message |
+|---|---|---|
+| `no-hand-copied-base-rule` | `process-residue` | A rule a bundled set already ships is written out here by hand, from a set this policy does not inherit. |
+| `no-merge-commit` | `unreviewed-history` | The `no-merge-commit` guard writes its own report. |
+| `prevent-author-mismatch` | `mismatched-author` | The `prevent-author-mismatch` guard writes its own report. |
+| `prevent-unusual-unicode-in-files` | `invisible-characters` | The `prevent-unusual-unicode-in-files` guard writes its own report. |
+| `no-private-repo-names-staged` | `private-names` | The `no-private-repo-names-staged` guard writes its own report. |
+
+## pre-merge-commit
+
+`uphold guard --stage pre-merge-commit`, when a merge is about to commit.
+
+| Rule | Set | Message |
+|---|---|---|
+| `no-local-merge` | `unreviewed-history` | The `no-local-merge` guard writes its own report. |
+| `prevent-author-mismatch` | `mismatched-author` | The `prevent-author-mismatch` guard writes its own report. |
+| `prevent-unusual-unicode-in-files` | `invisible-characters` | The `prevent-unusual-unicode-in-files` guard writes its own report. |
+| `no-private-repo-names-in-files` | `private-names` | The `no-private-repo-names-in-files` guard writes its own report. |
+
+## pre-push
+
+`uphold guard --stage pre-push`, over the range being pushed.
+
+| Rule | Set | Message |
+|---|---|---|
+| `prevent-unusual-unicode-in-files` | `invisible-characters` | The `prevent-unusual-unicode-in-files` guard writes its own report. |
+| `no-stale-visibility` | `stale-visibility` | The `no-stale-visibility` guard writes its own report. |
+| `prevent-public-push` | `unowned-push` | The `prevent-public-push` guard writes its own report. |
+| `no-private-repo-names-in-files` | `private-names` | The `no-private-repo-names-in-files` guard writes its own report. |
+
+## manual
+
+`uphold guard --stage manual`, from CI or by hand.
+
+| Rule | Set | Message |
+|---|---|---|
+| `no-hand-copied-base-rule` | `process-residue` | A rule a bundled set already ships is written out here by hand, from a set this policy does not inherit. |
+| `prevent-unusual-unicode-in-files` | `invisible-characters` | The `prevent-unusual-unicode-in-files` guard writes its own report. |
+| `no-stale-hook-pins` | `stale-pins` | The `no-stale-hook-pins` guard writes its own report. |
+| `no-stale-visibility` | `stale-visibility` | The `no-stale-visibility` guard writes its own report. |
+| `no-private-repo-names-in-files` | `private-names` | The `no-private-repo-names-in-files` guard writes its own report. |
+
+## shim
+
+`uphold shim COMMAND`, in front of a command that publishes.
+
+| Rule | Set | Message |
+|---|---|---|
+| `no-empty-merge-subject` | `published-text` | Give the merge commit a subject, or leave the subject off so the forge writes the pull request's title. |
+| `no-published-host-identity` | `published-text` | Use neutral placeholders -- example-user, example-host, example.test, /srv/example -- instead of values taken from the local machine. |
+| `no-published-markers` | `published-text` | Published text must satisfy the repository's text-capable guards. |
+| `no-published-private-repo-names` | `published-text` | The `no-private-repo-names` guard writes its own report. |
+| `unowned-forge-target` | `published-text` | The `prevent-unowned-target` guard writes its own report. |
+| `no-announcing-sentence` | `prose-shapes` | This sentence says what the next one will say. |
+| `no-empty-hedge` | `prose-shapes` | This hedge admits no uncertainty -- it softens the claim without qualifying it. |
+| `no-restating-clause` | `prose-shapes` | This clause restates the one in front of it. |
+| `no-unproposed-alternative` | `prose-shapes` | This answers an objection nobody made. |
+
+## agent hook
+
+`uphold hook claude-code` and `uphold hook codex`.
+
+| Rule | Set | Message |
+|---|---|---|
+| `no-empty-merge-subject` | `published-text` | Give the merge commit a subject, or leave the subject off so the forge writes the pull request's title. |
+| `no-published-host-identity` | `published-text` | Use neutral placeholders -- example-user, example-host, example.test, /srv/example -- instead of values taken from the local machine. |
+| `no-published-markers` | `published-text` | Published text must satisfy the repository's text-capable guards. |
+| `no-published-private-repo-names` | `published-text` | The `no-private-repo-names` guard writes its own report. |
+| `no-announcing-sentence` | `prose-shapes` | This sentence says what the next one will say. |
+| `no-empty-hedge` | `prose-shapes` | This hedge admits no uncertainty -- it softens the claim without qualifying it. |
+| `no-restating-clause` | `prose-shapes` | This clause restates the one in front of it. |
+| `no-unproposed-alternative` | `prose-shapes` | This answers an objection nobody made. |
+
+## text
+
+`uphold scan --text` and `uphold guard --text`.
+
+| Rule | Set | Message |
+|---|---|---|
+| `no-empty-merge-subject` | `published-text` | Give the merge commit a subject, or leave the subject off so the forge writes the pull request's title. |
+| `no-announcing-sentence` | `prose-shapes` | This sentence says what the next one will say. |
+| `no-empty-hedge` | `prose-shapes` | This hedge admits no uncertainty -- it softens the claim without qualifying it. |
+| `no-restating-clause` | `prose-shapes` | This clause restates the one in front of it. |
+| `no-unproposed-alternative` | `prose-shapes` | This answers an objection nobody made. |
+
+## scan
+
+`uphold scan`, over the tree. A rule that reads files and has no git hook or published-text seam runs only here.
+
+| Rule | Set | Message |
+|---|---|---|
+| `no-committed-logs` | `process-residue` | Keep captured logs in issues or ignored local storage. |
+| `no-dated-source-metadata` | `process-residue` | Do not commit authoring dates in source or documentation. |
+| `no-hardcoded-home-paths` | `process-residue` | Do not commit hardcoded user home paths. |
+| `no-merge-conflict-markers` | `process-residue` | Do not commit Git merge-conflict markers. |
+| `no-process-history-references` | `process-residue` | Do not reference process-history records (issue, pull request, or thread numbers) in final docs. |
+| `no-source-changelog` | `process-residue` | Do not maintain change history in repository files. |
+| `no-status-source-metadata` | `process-residue` | Do not commit workflow-state metadata in source docs. |
+| `no-task-tracker-references` | `process-residue` | Source, configuration, and documentation must not depend on volatile issue or pull request records. |
+| `no-tracked-private-data-paths` | `process-residue` | Do not commit local private data, build artifacts, traces, or HAR captures. |
+| `no-task-tracker-references-in-code` | `code-residue` | Source, configuration, and build files must not depend on volatile issue or pull request records. |
+| `no-browser-profile-artifacts` | `credentials` | Do not commit browser profile or session-store files. |
+| `no-env-secret-values` | `credentials` | Do not commit populated environment files. |
+| `no-pinned-release-download` | `unmanaged-pins` | Do not fetch a pinned release artifact by URL. |
+| `no-pinned-tool-install` | `unmanaged-pins` | Do not pin a tool version in an install command. |
+| `no-pinned-versioned-fetch` | `unmanaged-pins` | Do not curl or wget a versioned URL. |
+| `no-running-os-identity-metadata` | `host-identity` | Do not commit identity metadata from the running OS. |
+| `no-broken-doc-links` | `broken-links` | A documentation link points at a path that does not exist, or escapes the repository. |
+| `no-non-ascii-in-fixtures` | `captured-fixtures` | A test fixture holds non-ASCII content, which is what a capture from a live upstream looks like and is not what a synthetic fixture looks like. |
+| `no-stale-anchored-fact` | `doc-claims` | A document states a fact that has moved. |
+| `workflow-declares-permissions` | `default-token-grant` | A GitHub Actions workflow declares no top-level `permissions:` block, so its GITHUB_TOKEN gets whatever default the repository, organization or enterprise settings grant -- a scope decided outside this file and invisible in it. |
+| `no-hand-rolled-tool-install` | `hand-rolled-toolchain` | Do not download and unpack a tool by hand. |
+| `no-hand-rolled-tool-symlink` | `hand-rolled-toolchain` | Do not symlink a hand-installed tool onto PATH out of `$HOME/.local`. |
+| `no-restated-line` | `comment-facts` | This comment says what the next line already says. |
+| `no-user-data-measurement` | `comment-facts` | This comment states a measurement, and no anchor checks it -- the value was true when it was written and nothing fails when it stops being. |
+| `no-announcing-sentence` | `prose-shapes` | This sentence says what the next one will say. |
+| `no-empty-hedge` | `prose-shapes` | This hedge admits no uncertainty -- it softens the claim without qualifying it. |
+| `no-restating-clause` | `prose-shapes` | This clause restates the one in front of it. |
+| `no-unproposed-alternative` | `prose-shapes` | This answers an objection nobody made. |
diff --git a/tests/workflow_doc.rs b/tests/workflow_doc.rs
new file mode 100644
index 0000000..262932c
--- /dev/null
+++ b/tests/workflow_doc.rs
@@ -0,0 +1,314 @@
+//! `docs/workflow.md`: how an action reaches uphold, and which bundled rules
+//! fire where. Generated here and committed, so a reader on GitHub sees it and
+//! a change to a bundled set cannot leave it describing the old tool.
+//!
+//! The tables come from the binary's own output, never from this file: which
+//! rules exist and what they say from `rules --sets --json`, and where each one
+//! runs from `rules --effective --json` over a scratch repository that
+//! inherits every set. The second is asked rather than re-derived because the
+//! answer depends on the rule's kind, on `seams` and on which built-ins judge
+//! text (`Rule::seams`), and a copy of that logic here would be a reader free
+//! to disagree with the engine.
+//!
+//! The diagram and the prose around it are code structure, so they are
+//! constants below. Nothing in any JSON output names an entry point.
+//!
+//! `UPHOLD_WORKFLOW_GOLDEN=bless cargo test --test workflow_doc` rewrites the
+//! file; without it a difference fails the test.
+
+#![expect(
+ clippy::expect_used,
+ clippy::let_underscore_must_use,
+ clippy::tests_outside_test_module,
+ reason = "A test asserts on the outcome; a panic in the harness IS the failure report, and there is no caller to hand a Result to"
+)]
+
+mod support;
+
+use std::collections::BTreeMap;
+use std::fmt::Write as _;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+
+use serde_json::Value;
+
+const BLESS: &str = "UPHOLD_WORKFLOW_GOLDEN";
+
+/// The diagram and the prose that frame the tables. Entry points are code
+/// structure that no JSON output carries, so they are written here and the
+/// tests that drive each caller are what keep them honest.
+const HEADER: &str = "\
+
+
+# How an action reaches uphold
+
+Every path into uphold ends in one of a few commands. This page maps each way
+in to the command it ends in, then lists the bundled rules that fire there.
+It describes the tool; a repository's own rules are in its policy file, and
+`uphold rules --effective` lists what one resolves to.
+
+```mermaid
+flowchart LR
+ commit[git commit] --> hooks[pre-commit, commit-msg and
pre-merge-commit hooks]
+ push[git push] --> prepush[pre-push hook]
+ install[uphold hooks --install
or lefthook install] -.writes.-> hooks
+ install -.writes.-> prepush
+ hooks --> guard_stage[uphold guard --stage STAGE]
+ prepush --> guard_push[uphold guard --stage pre-push]
+ hooks -. runner config .-> scan
+ cmd[git, gh, ... on PATH] --> link[link named after the command]
+ shiminstall[uphold shim --install
uphold shim --hook] -.makes and
puts on PATH.-> link
+ link --> shim[uphold shim COMMAND]
+ shim --> real[the real command]
+ shim --> refuse[refusal, exit 1 or 2]
+ agent[agent tool call:
Claude Code PreToolUse, Codex] --> hook[uphold hook claude-code or codex]
+ hook --> allow[call proceeds]
+ hook --> deny[call refused]
+ ci[CI or by hand] --> scan[uphold scan]
+ ci --> guard_manual[uphold guard --stage manual]
+ ci --> supply[uphold supply-chain]
+ piped[text piped in] --> text[uphold scan --text
uphold guard --text]
+```
+
+- **Git stages.** `uphold hooks --install` writes `pre-commit`, `commit-msg`,
+ `pre-merge-commit` and `pre-push` as tracked files and points
+ `core.hooksPath` at them; it refuses lefthook, which installs and owns its
+ own hooks from the config `uphold init --lefthook` writes. Either way the
+ stage ends in `uphold guard --stage STAGE`. A runner's config may also run
+ `uphold scan` or `uphold supply-chain --staged` at `pre-commit`; those are
+ the repository's choice, not the hook's.
+- **Shim.** A link named for the command, on PATH ahead of the real one.
+ `uphold shim --install` makes the links and `uphold shim --hook` keeps them
+ on PATH only inside a tree that declares a policy. With no `[[shim]]` for the
+ command, the real command runs untouched.
+- **Agent harness.** `uphold hook` judges a pending tool call read on stdin. It
+ exists for publishing that spawns no process, such as an MCP server, so it
+ is configured for those tools rather than for shell commands.
+- **CI and manual.** `uphold scan` reads the tree, `uphold guard --stage
+ manual` runs the guards that sweep the whole repository, and `uphold
+ supply-chain` runs the dependency and secret scanners.
+- **Text.** `uphold scan --text` and `uphold guard --text` judge text with no
+ path, such as a commit message.
+
+";
+
+/// What each caller is, in the order the tables follow, and the seam name
+/// `rules --effective --json` uses for it. A git stage is a `git_hooks` entry
+/// and not a seam, so it carries no seam name.
+struct Caller {
+ heading: &'static str,
+ note: &'static str,
+ stage: Option<&'static str>,
+ seam: Option<&'static str>,
+}
+
+const CALLERS: [Caller; 9] = [
+ Caller {
+ heading: "commit-msg",
+ note: "`uphold guard --stage commit-msg`, after `git commit` writes the message.",
+ stage: Some("commit-msg"),
+ seam: None,
+ },
+ Caller {
+ heading: "pre-commit",
+ note: "`uphold guard --stage pre-commit`, over what is staged.",
+ stage: Some("pre-commit"),
+ seam: None,
+ },
+ Caller {
+ heading: "pre-merge-commit",
+ note: "`uphold guard --stage pre-merge-commit`, when a merge is about to commit.",
+ stage: Some("pre-merge-commit"),
+ seam: None,
+ },
+ Caller {
+ heading: "pre-push",
+ note: "`uphold guard --stage pre-push`, over the range being pushed.",
+ stage: Some("pre-push"),
+ seam: None,
+ },
+ Caller {
+ heading: "manual",
+ note: "`uphold guard --stage manual`, from CI or by hand.",
+ stage: Some("manual"),
+ seam: None,
+ },
+ Caller {
+ heading: "shim",
+ note: "`uphold shim COMMAND`, in front of a command that publishes.",
+ stage: None,
+ seam: Some("shim"),
+ },
+ Caller {
+ heading: "agent hook",
+ note: "`uphold hook claude-code` and `uphold hook codex`.",
+ stage: None,
+ seam: Some("hook"),
+ },
+ Caller {
+ heading: "text",
+ note: "`uphold scan --text` and `uphold guard --text`.",
+ stage: None,
+ seam: Some("text"),
+ },
+ Caller {
+ heading: "scan",
+ note: "`uphold scan`, over the tree. A rule that reads files and has no \
+ git hook or published-text seam runs only here.",
+ stage: None,
+ seam: Some("scan"),
+ },
+];
+
+fn uphold(directory: &Path, args: &[&str]) -> Value {
+ let output = Command::new(env!("CARGO_BIN_EXE_uphold"))
+ .args(args)
+ .current_dir(directory)
+ .env("XDG_CONFIG_HOME", directory.with_extension("xdg"))
+ .env_remove("UPHOLD_ALLOW")
+ .stdin(Stdio::null())
+ .output()
+ .expect("the binary runs");
+ assert!(
+ output.status.success(),
+ "uphold {args:?} exited {:?}: {}",
+ output.status.code(),
+ String::from_utf8_lossy(&output.stderr),
+ );
+ serde_json::from_slice(&output.stdout).expect("the output is JSON")
+}
+
+/// A repository that inherits every bundled set, for `rules --effective`.
+///
+/// A policy loads only if each command a rule stands in front of has a
+/// `[[shim]]`, so one is declared per leading word the sets name.
+fn every_set_repository(sets: &[Value]) -> PathBuf {
+ let root = support::scratch("workflow-doc");
+ std::fs::create_dir_all(root.join("policy")).expect("fixture directory");
+ support::git(&root, &["init", "-q", "-b", "main"]);
+
+ let names: Vec = sets
+ .iter()
+ .map(|set| format!("{:?}", text(&set["set"])))
+ .collect();
+ let mut commands: Vec<&str> = sets
+ .iter()
+ .flat_map(|set| set["commands"].as_array().into_iter().flatten())
+ .filter_map(|command| text(command).split_whitespace().next())
+ .collect();
+ commands.sort_unstable();
+ commands.dedup();
+
+ let mut policy = format!(
+ "owner = \"example\"\nvisibility = \"public\"\n[inherit]\nsets = [{}]\n",
+ names.join(", ")
+ );
+ for command in commands {
+ let _ = writeln!(policy, "[[shim]]\ncommand = {command:?}");
+ }
+ std::fs::write(root.join("policy/principles.toml"), policy).expect("policy");
+ root
+}
+
+fn text(value: &Value) -> &str {
+ value.as_str().expect("a string")
+}
+
+/// The first sentence of a message, on one line and safe in a table cell.
+fn first_sentence(message: &str) -> String {
+ let flat = message.split_whitespace().collect::>().join(" ");
+ let end = flat
+ .match_indices(". ")
+ .next()
+ .map_or(flat.len(), |(at, _)| at + 1);
+ flat[..end].replace('|', "\\|")
+}
+
+/// What a table cell says for a rule. A built-in guard declares no message: it
+/// composes its report from what it found, so the cell says that instead of
+/// standing empty.
+fn message_cell(rule: &Value) -> String {
+ let builtin = rule["builtin"]
+ .as_str()
+ .map(|name| format!("The `{name}` guard writes its own report."));
+ rule["message"]
+ .as_str()
+ .filter(|message| !message.is_empty())
+ .map(first_sentence)
+ .or(builtin)
+ .unwrap_or_default()
+}
+
+fn render() -> String {
+ let sets_dir = support::scratch("workflow-doc-sets");
+ std::fs::create_dir_all(&sets_dir).expect("fixture directory");
+ let sets = uphold(&sets_dir, &["rules", "--sets", "--json"]);
+ let sets = sets.as_array().expect("an array of sets");
+
+ let root = every_set_repository(sets);
+ let effective = uphold(&root, &["rules", "--effective", "--json"]);
+ let mut resolved: BTreeMap<&str, &Value> = BTreeMap::new();
+ for rule in effective.as_array().expect("an array of rules") {
+ assert!(
+ resolved.insert(text(&rule["id"]), rule).is_none(),
+ "two bundled rules share the id {}; the tables key on it",
+ rule["id"]
+ );
+ }
+
+ let mut out = String::from(HEADER);
+ for caller in &CALLERS {
+ let mut rows = String::new();
+ for set in sets {
+ for rule in set["rules"].as_array().expect("an array of rules") {
+ let id = text(&rule["id"]);
+ let found = resolved
+ .get(id)
+ .expect("every set rule is in the effective list");
+ let hooks = found["git_hooks"].as_array().expect("git_hooks");
+ let seams = found["seams"].as_array().expect("seams");
+ let fires = match (caller.stage, caller.seam) {
+ (Some(stage), _) => hooks.iter().any(|hook| text(hook) == stage),
+ (None, Some(seam)) => seams.iter().any(|name| text(name) == seam),
+ (None, None) => false,
+ };
+ if fires {
+ let _ = writeln!(
+ rows,
+ "| `{id}` | `{}` | {} |",
+ text(&set["set"]),
+ message_cell(rule)
+ );
+ }
+ }
+ }
+ let _ = write!(out, "## {}\n\n{}\n\n", caller.heading, caller.note);
+ if rows.is_empty() {
+ out.push_str("No bundled rule fires here.\n\n");
+ } else {
+ out.push_str("| Rule | Set | Message |\n|---|---|---|\n");
+ out.push_str(&rows);
+ out.push('\n');
+ }
+ }
+ out.truncate(out.trim_end().len());
+ out.push('\n');
+ out
+}
+
+#[test]
+fn the_workflow_doc_is_what_the_binary_says() {
+ let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/workflow.md");
+ let rendered = render();
+
+ if std::env::var(BLESS).is_ok_and(|mode| mode == "bless") {
+ std::fs::write(&path, &rendered).expect("write docs/workflow.md");
+ return;
+ }
+ let committed = std::fs::read_to_string(&path).unwrap_or_default();
+ assert!(
+ committed == rendered,
+ "docs/workflow.md is stale; regenerate it with `{BLESS}=bless cargo test --test workflow_doc`"
+ );
+}