From 0a1ecf12df968c7c16df3b24da54654b4e52451e Mon Sep 17 00:00:00 2001 From: HackingGate Date: Fri, 23 Oct 2026 22:11:37 +0900 Subject: [PATCH] A generated docs/workflow.md maps each way an action reaches uphold to the command it ends in, and lists the bundled rules that fire at each caller tests/workflow_doc.rs renders the page from the binary's own `rules --sets --json` and `rules --effective --json` and fails while the committed file differs; UPHOLD_WORKFLOW_GOLDEN=bless rewrites it. README.md links it. Claude-Session: https://claude.ai/code/session_01YTpdnDJrf2hT6hFyjS7evA --- README.md | 1 + docs/workflow.md | 187 +++++++++++++++++++++++++ tests/workflow_doc.rs | 314 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 docs/workflow.md create mode 100644 tests/workflow_doc.rs 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`" + ); +}