Getting-started docs fail when a model invents duration, accounts, and copy-paste safety from CI YAML that never stated those promises. Compile a bootstrap command graph from workflows and scripts, then keep every timing and secret claim in a human-signed block. Unsigned phrases such as five minutes or no extra account should fail a linter before the tutorial ships. The rest of this article is a concrete extractor, a facts file, and a claim gate you can run locally.
First-run prose is a contract, not a summary
API reference can tolerate a missing example, but first-run tutorials cannot, because readers execute them as a contract. When that contract is generated from chat, the failure mode is confident setup advice that CI never validated. The damage shows up later as support tickets about missing tokens, slow image pulls, and privileged ports. This workflow treats those tickets as unsigned claims that should have been blocked in review.
Continuous integration already encodes the real bootstrap order, including checkout, toolchain install, service boot, and test invocation. A tutorial that ignores that graph will reorder steps, drop a login, or hide a Docker daemon requirement behind casual language. The useful split is therefore mechanical: the model may draft headings and command text from extracted nodes, while a human owns elapsed time, third-party accounts, and side effects. Nothing in the signed lane should be inferred from fluent prose.
What the model may draft versus what a human must own
The table below is a review gate, not a style guide. If a sentence asserts a fact from the owned column, the facts file needs a matching signed record before publish. Draftable text can still be wrong, so the extractor is an input, not an approval. Owned claims remain blocked even when the draft looks complete.
| Claim class | Model may draft from repo artifacts | Human must own before publish |
|---|---|---|
| Step order | Makefile targets, package.json scripts, workflow run steps |
That a reader can skip CI-only caching or matrix jobs |
| Command text | Literal shell lines and working directories | That the line is copy-paste safe on a laptop |
| Files created | Paths written by scripts or compose files | That those paths are safe to delete after the tutorial |
| Time to first success | Nothing; CI duration is not reader duration | Minutes, hours, and “quick” language |
| Accounts and secrets | Env var names and login subcommands | Which SaaS accounts are required, never secret values |
| Supported OS | Runner labels such as ubuntu-latest
|
Desktop OS support, Docker Desktop, and nested virtualization |
| Network and privileges | Ports in compose files, sudo in recipes |
Bind to 443, inbound firewall, and rootless constraints |
| Side effects |
dropdb, terraform apply, image pushes |
Data loss, cost, and production-adjacent credentials |
Artifact: bootstrap graph, facts file, and claim linter
The following Python module is a proposal you can run against a real repository. It does not execute commands, call a network, or parse a complete YAML grammar. It only builds a conservative inventory from Makefiles, npm scripts, and GitHub Actions run: lines, then fails a tutorial that asserts unsigned duration or account language.
#!/usr/bin/env python3
"""first_run_gate.py — proposal: inventory bootstrap commands, lint unsigned claims."""
from __future__ import annotations
import json, re, sys
from pathlib import Path
ACCOUNT_HINTS = re.compile(
r"aws |gcloud |az |docker login|npm login|gh auth|kubectl |terraform |"
r"ansible |ssh |sudo |vault ",
re.I,
)
UNSIGNED = [
(re.compile(r"\b(\d+)\s*(minute|min|hour|hr)s?\b", re.I), "duration"),
(re.compile(r"\b(quick|simply|just|easy)\b", re.I), "ease"),
(re.compile(r"no extra accounts?|without (an? )?account|no sign-?up", re.I), "account"),
(re.compile(r"copy[- ]paste (and )?(it )?works|works on (any|every) (os|machine)", re.I), "portability"),
(re.compile(r"production[- ]safe|no side effects|will not delete", re.I), "side_effect"),
]
def makefile_nodes(text: str, source: str) -> list[dict]:
nodes = []
target = None
for line in text.splitlines():
if re.match(r"^[A-Za-z0-9_.-]+:", line) and not line.startswith("\t"):
target = line.split(":", 1)[0]
continue
if target and line.startswith("\t"):
cmd = line.strip()
nodes.append({"source": source, "id": target, "cmd": cmd,
"account_hint": bool(ACCOUNT_HINTS.search(cmd))})
return nodes
def npm_nodes(text: str, source: str) -> list[dict]:
try:
scripts = json.loads(text).get("scripts") or {}
except json.JSONDecodeError:
return []
nodes = []
for name, cmd in scripts.items():
nodes.append({"source": source, "id": f"npm:{name}", "cmd": cmd,
"account_hint": bool(ACCOUNT_HINTS.search(str(cmd)))})
return nodes
def workflow_nodes(text: str, source: str) -> list[dict]:
nodes = []
step = "unnamed"
for line in text.splitlines():
m = re.match(r"\s*-\s*name:\s*(.+)", line)
if m:
step = m.group(1).strip().strip('"')
m = re.match(r"\s*run:\s*\|?\s*(.*)$", line)
if m and m.group(1).strip():
cmd = m.group(1).strip()
nodes.append({"source": source, "id": step, "cmd": cmd,
"account_hint": bool(ACCOUNT_HINTS.search(cmd))})
return nodes
def collect(root: Path) -> list[dict]:
nodes = []
mf = root / "Makefile"
if mf.exists():
nodes += makefile_nodes(mf.read_text(encoding="utf-8"), str(mf))
pkg = root / "package.json"
if pkg.exists():
nodes += npm_nodes(pkg.read_text(encoding="utf-8"), str(pkg))
wf = root / ".github" / "workflows"
if wf.is_dir():
for yml in sorted(wf.glob("*.y*ml")):
nodes += workflow_nodes(yml.read_text(encoding="utf-8"), str(yml))
return nodes
def lint(md: str, signed: set[str]) -> list[str]:
hits = []
for i, line in enumerate(md.splitlines(), 1):
if line.strip().startswith("> signed:"):
continue
for rx, kind in UNSIGNED:
if rx.search(line) and kind not in signed:
hits.append(f"L{i} unsigned:{kind}: {line.strip()}")
return hits
def load_signed(path: Path) -> set[str]:
if not path.exists():
return set()
kinds = set()
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("signed_kinds:"):
continue
if line.strip().startswith("- "):
kinds.add(line.strip()[2:].split("#", 1)[0].strip())
return kinds
def main(argv: list[str]) -> int:
root = Path(argv[1] if len(argv) > 1 else ".")
nodes = collect(root)
(root / "first_run_graph.json").write_text(
json.dumps(nodes, indent=2), encoding="utf-8")
print(f"wrote {len(nodes)} bootstrap nodes to first_run_graph.json")
md_path = root / "getting-started.draft.md"
facts = root / "first_run_signed.yml"
if md_path.exists():
hits = lint(md_path.read_text(encoding="utf-8"), load_signed(facts))
for hit in hits:
print(hit)
return 1 if hits else 0
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Pair the graph with a tiny signed facts file that only a maintainer should edit. Secret values never belong in that file, only names, account types, and duration ranges measured on a named machine class. The linter treats missing kinds as failures, which is the point of the gate.
# first_run_signed.yml — human-owned; do not generate the signed_kinds list.
reader_os: ["macos-14", "ubuntu-22.04"]
required_accounts: ["GitHub", "container registry"]
secret_names: ["GH_TOKEN", "REGISTRY_PASSWORD"]
measured_first_success: "18-35 minutes on a warm DSL link after image cache"
side_effects: ["local Postgres volume created", "binds 5432"]
copy_paste_safe: false
signed_kinds:
- duration
- account
- portability
- side_effect
# ease is intentionally omitted until someone deletes "simply" from the draft
A draft tutorial can keep command blocks that match graph nodes, but duration language must sit behind an explicit signed marker or a signed kind. The example below would fail until ease is added, because “simply” is still present.
# Getting started (draft)
> signed: duration, account, portability, side_effect
Clone the repository, then run the `bootstrap` Makefile target from the graph.
First success took 18-35 minutes on a warm DSL link after the image cache filled.
You need a GitHub account and a container registry identity; do not paste tokens here.
Do not simply skip Docker Desktop on macOS; nested virtualization is an owned claim.
Numbered workflow
Extract the graph in a clean worktree. Run
python3 first_run_gate.py .on the commit you intend to document, not on an unsaved editor buffer. Commitfirst_run_graph.jsonbeside the tutorial if reviewers need the same node list. Treat missing Makefiles as a missing inventory, not as permission to improvise steps.Cluster nodes into reader-visible phases. Group toolchain install, service boot, credential prompts, and verification commands, and drop CI-only cache keys. Record skipped nodes in the facts file so a reviewer can see what the tutorial deliberately omitted. Do not let the model reinsert
actions/cacheas a laptop step.Draft structure from nodes only. Headings, fenced commands, and file paths may come from a model that receives the JSON graph and nothing else. Deny the model the right to fill minutes, OS support, or “no account needed” gaps. If a node has
account_hint: true, the draft must leave a placeholder instead of a reassurance.Sign the owned lane on a measured machine. Fill
first_run_signed.ymlafter someone actually ran the reader path, including image pulls and first-time compiler caches. Write ranges, not marketing integers, and name the OS plus network class used for the measurement. Refuse to signcopy_paste_safewhen any command still interpolates a secret.Gate publishing on the linter exit code. Run the script in CI against
getting-started.draft.mdand fail the job on anyunsigned:line. After the facts file is signed, render the public page from the draft plus the signed block, not from a fresh chat transcript. Keep the graph and the signed file in review, because either one can rot independently.
Where free model access belongs in this loop
The drafting step needs a model that can rewrite JSON nodes into Markdown without touching the signed lane. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option are enough to run that drafting pass on the graph file, while the extractor and linter stay local and deterministic. Keep secret values off the server, and send only node identifiers, command strings already in git, and placeholder names from the facts file. If the model adds a minute count or an account waiver, the linter still blocks the merge, which is the control that matters.
Limitations
The parser is line-oriented, so composite GitHub Actions, reusable workflows, and dynamic matrices will be under-counted. Makefile macros, included fragments, and generated scripts will also disappear from the graph until you expand them in a pre-step. CI duration is a poor predictor of laptop duration, which is why the facts file requires a human measurement instead of a copied job timestamp. The unsigned-language list is English-centric and will miss cheerful synonyms unless you extend the patterns. This method does not prove that a command succeeded; it only proves that forbidden claims were either signed or removed.
Who should not use this approach
Skip the gate if your product setup is a GUI installer with no scripted bootstrap, because the graph will be empty and the linter will only nag adjectives. Skip it if legal or security teams already require a dedicated review of every time and data-handling sentence, and you would be duplicating that process with a weaker regex. Skip it for air-gapped readers whose first-run path cannot be measured on a machine that resembles CI. Do not use a drafting model on graphs that still contain production credentials, private registry passwords, or customer identifiers. Teams that want a single generated README with no signed file should not adopt this workflow, because the whole point is the split.
Run the extractor on one getting-started page this week, fail it on purpose with a fake five-minute claim, then sign only the kinds you actually measured. The free model access is optional for the skeleton; the signed facts file is not.
Top comments (0)