Getting-started documentation fails when generated prose quietly asserts environments, secrets, and success times that nobody reviewed. A safer workflow compiles command skeletons from scripts already committed in the repository, then requires a human signature on every environment promise. Models may draft file trees, unlabeled command blocks, and placeholder tables; they must not fill support windows, credential handling, or first-run duration claims. The sections below specify extractors, a two-file schema, a linter, and the teams that should refuse this split.
The leak that makes first-run pages unreviewable
Quickstart pages mix inventory facts with operational warranties, and Markdown conceals the difference until a reader hits a missing secret. Script names, relative paths, and compose service labels can be compiled from manifests with almost no interpretation by a model. Supported operating systems, required credentials, destructive side effects, and elapsed-time claims are promises, and they need a named owner. When one generated transcript writes all four classes, reviewers cannot mark which sentences are extractable and which sentences are contractual.
Invented flags, laptop-local paths, and optional services promoted to required steps survive because the surrounding tutorial voice sounds finished. The lane split does not raise model accuracy; it makes unsigned warranties unmergeable in continuous integration. Treat that merge gate as the product of the workflow, not as optional style advice.
Lane assignment for getting-started claims
Assign every candidate sentence to one lane before any prose is drafted. The table is a review artifact, not a style guide, and empty cells in the promise lane must fail the build.
| Claim class | Source of truth | Model may draft? | Human must own? |
|---|---|---|---|
| Script name, Makefile target, npm script |
Makefile, package.json, pyproject.toml
|
Yes, as unlabeled inventory | Confirm the target is the public entry |
| Relative paths and repo file tree | The working tree at a reviewed revision | Yes, as a skeleton | Confirm generated paths are not internal |
| Compose service names |
compose.yaml / docker-compose.yml
|
Yes, names only | Mark which services are optional |
| Required environment variables | Human threat and ops review | Placeholder rows only | Names, secrecy, and rotation |
| Supported OS / runtime matrix | Release and support policy | Empty matrix only | Every cell, including “unsupported” |
| First-run duration or “works in N minutes” | Measured runbooks | Forbidden in draft | Sign or delete the claim |
| Destructive side effects | Operator knowledge | Warning stub only | Explicit yes/no plus blast radius |
| Production readiness | Policy, not source code | Forbidden | Sign or omit |
Rows in the first three classes belong in a compiled inventory file. Rows in the remaining classes belong in a signed YAML document that CI treats as blocking. If a sentence cannot be placed, it does not ship.
Workflow
Follow the numbered sequence on a clean checkout. Do not start from a chat window that already contains a finished tutorial.
- Freeze the revision you will document, and record the commit SHA in the owned file.
- Run the extractor against manifests only; do not pass README prose into the inventory job.
- Emit
quickstart.inventory.jsonand a draft Markdown file that contains headings, trees, and unlabeled commands. - Refuse any draft sentence that matches duration, support, secret, or production phrasing.
- Fill
quickstart.owned.yamlby hand: OS matrix, secrets, optional services, destructive flags, and timing. - Run the linter in CI so empty owned fields, leaked claim verbs, and unknown commands fail the build.
- Render the published getting-started page from inventory plus owned YAML, never from the raw model transcript.
The draft file is disposable. The inventory is regenerated. The owned YAML is the review surface that survives.
Example extractor (unexecuted sample)
The following program is an example, not a measured production scanner. It reads common script sources and writes JSON; Makefile parsing is intentionally shallow and will miss pattern rules.
#!/usr/bin/env python3
"""Extract public command names for a getting-started inventory."""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(".")
TARGET_RE = re.compile(r"^([A-Za-z0-9_.-]+):", re.M)
def package_scripts() -> list[dict]:
path = ROOT / "package.json"
if not path.is_file():
return []
data = json.loads(path.read_text(encoding="utf-8"))
scripts = data.get("scripts") or {}
return [
{"source": "package.json", "name": name, "command": cmd}
for name, cmd in scripts.items()
if not str(name).startswith("_"))
]
def makefile_targets() -> list[dict]:
path = ROOT / "Makefile"
if not path.is_file():
return []
text = path.read_text(encoding="utf-8")
names = []
for name in TARGET_RE.findall(text):
if name.startswith(".") or name in names:
continue
names.append(name)
return [{"source": "Makefile", "name": n, "command": f"make {n}"} for n in names]
def compose_services() -> list[dict]:
for candidate in ("compose.yaml", "compose.yml", "docker-compose.yml"):
path = ROOT / candidate
if path.is_file():
text = path.read_text(encoding="utf-8")
break
else:
return []
services = re.findall(r"^\s{2}([A-Za-z0-9_.-]+):\s*$", text, re.M)
# Naive YAML scrape; do not treat this as a full Compose parser.
return [{"source": "compose", "name": s, "command": f"docker compose up {s}"} for s in services]
def main() -> None:
items = package_scripts() + makefile_targets() + compose_services()
payload = {
"lane": "inventory",
"item_count": len(items),
"items": items,
"notes": "Commands are names only. Do not infer OS, secrets, or duration.",
}
Path("quickstart.inventory.json").write_text(
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
)
print(f"wrote quickstart.inventory.json with {len(items)} items")
if __name__ == "__main__":
main()
Run it as python3 extract_quickstart.py from the repository root after you have frozen the SHA. The JSON is the only input a later draft step should see. Do not concatenate existing README text into that job, because prior warranties will contaminate the inventory lane.
Draft file versus owned file
Write two files with different owners. The draft may contain structure; the owned file may contain promises.
<!-- quickstart.draft.md: generated structure, no warranties -->
# Getting started (draft)
## Repository tree (unverified completeness)
- `Makefile`
- `package.json`
- `compose.yaml`
## Command skeletons (labels only)
- `make bootstrap` — purpose unsigned
- `npm run dev` — purpose unsigned
- `docker compose up api` — optional-flag unsigned
## Environment table (cells forbidden in this file)
| Variable | Required | Secret | Notes |
|---|---|---|---|
| _fill in owned YAML_ | | | |
## Timing and support
Do not state minutes, operating systems, or production fitness in this file.
# quickstart.owned.yaml — human-signed promises; empty fields fail CI
commit_sha: "REPLACE_WITH_REVIEWED_SHA"
reviewer: "REPLACE_WITH_NAME"
supported_os:
- id: "linux-x86_64"
runtime: "python3.12"
status: "supported"
- id: "windows"
runtime: "n/a"
status: "unsupported"
secrets:
- name: "API_TOKEN"
required: true
rotation: "operator-managed"
never_commit: true
optional_services:
- name: "api"
optional: false
- name: "mailhog"
optional: true
destructive_commands:
- name: "make reset-db"
destructive: true
blast_radius: "local developer database only"
first_run_timing:
claim_allowed: false
measured_minutes: null
measurement_notes: "Omit duration until a reviewer records a timed run."
production_ready: false
The renderer should join inventory commands with owned annotations and drop any draft paragraph that still contains a forbidden verb. Published pages then inherit structure from the draft and warranties from the YAML, which keeps blame on a reviewer rather than on a transcript.
Example linter (unexecuted sample)
This checker is an example gate. Extend the verb list for your vocabulary; do not treat the regular expressions as a complete policy language.
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import yaml # example dependency; pin it in your own image
FORBIDDEN = re.compile(
r"\b(minutes?|supported on|we guarantee|production[- ]ready|"
r"safe to run|works on|sla|secret is)\b",
re.I,
)
PLACEHOLDER = re.compile(r"REPLACE_WITH_|_fill in", re.I)
def fail(msg: str) -> None:
print(f"quickstart lint: {msg}", file=sys.stderr)
raise SystemExit(1)
def main() -> None:
inventory = json.loads(Path("quickstart.inventory.json").read_text(encoding="utf-8"))
draft = Path("quickstart.draft.md").read_text(encoding="utf-8")
owned = yaml.safe_load(Path("quickstart.owned.yaml").read_text(encoding="utf-8"))
if FORBIDDEN.search(draft):
fail("draft contains forbidden support, secret, or timing language")
known = {item["name"] for item in inventory.get("items", [])}
for row in owned.get("destructive_commands") or []:
if row.get("name") and row["name"].split()[-1] not in known and row["name"] not in known:
# Allow full command strings; still require overlap with inventory names.
if not any(name in row["name"] for name in known):
fail(f"destructive command not in inventory: {row.get('name')}")
required_keys = [
"commit_sha",
"reviewer",
"supported_os",
"secrets",
"optional_services",
"first_run_timing",
"production_ready",
]
for key in required_keys:
if owned.get(key) in (None, "", []):
fail(f"owned field empty: {key}")
blob = json.dumps(owned)
if PLACEHOLDER.search(blob):
fail("owned YAML still contains placeholders")
timing = owned["first_run_timing"]
if timing.get("claim_allowed") and not timing.get("measured_minutes"):
fail("timing claim allowed without measured_minutes")
if owned.get("production_ready") is True:
fail("getting-started must not sign production_ready in this workflow")
print("quickstart lint: ok")
if __name__ == "__main__":
main()
Wire both programs before Markdown publish, not after. A typical CI fragment is python3 extract_quickstart.py && python3 lint_quickstart.py. If the linter is only advisory, the lane split collapses and the draft will absorb warranties again.
Where a draft model may participate
The extractor and linter are ordinary Python and belong in the same CI image that already checks Markdown. When a skeleton would save typing, a free-model pass may propose headings and unlabeled command blocks from quickstart.inventory.json alone. MonkeyCode's free model access and free server option can host that draft job without placing it on a production runner. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The owned YAML still has to be filled by a person who can answer for secrets, operating systems, and timing; no hosted model should be allowed to write those fields.
Keep the model off the renderer input. If the transcript cannot be thrown away after the draft file is written, the inventory and the warranty lanes have already been mixed.
Limitations
Shallow Makefile and Compose scraping will miss included fragments, generated targets, and hidden package scripts. Monorepos need a path allowlist, or the inventory will advertise internal packages as first-run commands. Script presence does not prove a command is supported; humans still confirm the public entrypoint set. Duration claims require a timed run on declared hardware, which this pipeline deliberately refuses to invent. Teams that document installer GUIs, licensed binaries, or air-gapped bundles need additional sources beyond the files shown here.
The linter only catches phrases it knows. Synonyms such as “quarter of an hour” or “runs anywhere” will slip through until you extend the pattern list. That gap is a reason to keep the owned file short and structured, not a reason to trust paragraph review alone.
Who should not use this approach
Do not use this split when the getting-started page is itself a contractual support matrix, because a YAML reviewer is not a substitute for legal sign-off. Skip it when onboarding injects customer secrets into shared runners, since the draft server then becomes a leakage surface. Avoid it if the repository has no stable scripts and every first run is a unique operator procedure. In those cases, write the promises first and do not generate a skeleton that looks more complete than the product.
If you already keep inventories separate from signatures, add this linter beside those checks and treat any hosted draft step as optional.
Top comments (0)