DEV Community

Avery Lin
Avery Lin

Posted on

Extract a Command Ledger From Make and CI; Hand-Sign Every Tutorial Shell Block

Getting-started pages should compile from commands the repository already executes, not from invented install stories. Tutorial prose may be drafted by a model, but fenced shell blocks are not draftable text. Any command that asserts an install path, a version pin, or a network side effect needs a human signature before publish. The workflow extracts a command ledger from Make, package scripts, and CI YAML, then fails unsigned README blocks.

This article is a proposed toolchain, not a field report from a named production org. The scripts below are labeled examples you must review, adapt, and run in your own repository. They do not claim coverage of every Make dialect, every CI vendor, or every shell quoting rule.

Why tutorials drift even when prose looks fine

Most tutorial drift is a command-set problem rather than a grammar problem. A README still tells readers to run pip install . while CI has used a lockfile installer for months. Generated drafts amplify that gap because they complete a familiar narrative instead of quoting automation files that already exist.

A useful split is therefore mechanical rather than stylistic. Extractable facts live in Makefiles, package.json scripts, and CI run: steps. Draftable glue is the connecting paragraph that explains order, purpose, and expected output shape. Owned claims are version pins, supported OS lists, secret handling, and any command that reaches a network.

If a sentence cannot be traced to an extracted argv array or to a named human signature, it does not belong in a getting-started page. That rule is stricter than ordinary editorial review, and that strictness is the point. Tutorials fail in install steps long before they fail in tone.

Three claim classes for getting-started pages

Treat every tutorial unit as one of three classes before a model is allowed to write anything.

  1. Extract — target names, script keys, and argv arrays observed in Make, npm, or CI.
  2. Draft — section order, motivation sentences, and warnings that do not assert a runtime.
  3. Sign — every fenced bash or sh block, every version pin, and every “works on” matrix cell.

The ledger is the extract class serialized to JSON. The model may propose draft-class paragraphs that mention ledger ids. The human signs sign-class blocks by attaching a reviewer id and a UTC date, never by asking the model to initial them.

Artifact: a command ledger plus two checks

The original artifact is a JSON ledger with a deterministic extractor and a README linter. Keep the ledger generated. Hand-edit only a sibling signatures file that maps block hashes to reviewers.

Proposed ledger shape:

{
  "generated_at": "REPLACE_WITH_CI_CLOCK",
  "sources": ["Makefile", "package.json", ".github/workflows/ci.yml"],
  "commands": [
    {
      "id": "make.test",
      "origin": "Makefile:12",
      "argv": ["pytest", "-q"],
      "network": false
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Proposed signatures shape, which is never generated by a model:

{
  "blocks": [
    {
      "sha256": "REPLACE_AFTER_HASHING_THE_FENCE_BODY",
      "ledger_id": "make.test",
      "reviewer": "REPLACE_WITH_HUMAN_ID",
      "signed_at": "REPLACE_WITH_ISO8601",
      "allows_network": false
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The two files together are the publish gate. CI regenerates the ledger, hashes each README fence, and rejects unknown argv, missing signatures, or network flags that the signer did not allow.

Step 1 — Extract commands with a conservative parser

The extractor below is a heuristic, not a Make implementation. It records recipe lines that look like argv, package.json script strings, and GitHub Actions run: lines. It refuses Make functions, shell control flow, and interpolated CI expressions, because those are not tutorial commands.

# extract_command_ledger.py — example, review before use
from __future__ import annotations

import json, re, sys
from pathlib import Path

MAKE_TARGET = re.compile(r"^([A-Za-z0-9][^:#=\n]*):")
SAFE_ARGV = re.compile(r"^[A-Za-z0-9./_=+-]+")
INTERP = re.compile(r"\$\(|`|\$\{\{")

def argv_of(line: str) -> list[str] | None:
    line = line.strip()
    if not line or line.startswith("#") or INTERP.search(line):
        return None
    if line.startswith("@"):
        line = line[1:]
    parts = line.split()
    if not parts or not SAFE_ARGV.match(parts[0]):
        return None
    if any(p in {"&&", "||", "|", ";", "if", "for"} for p in parts):
        return None
    return parts

def from_makefile(path: Path) -> list[dict]:
    rows, current = [], None
    for i, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        m = MAKE_TARGET.match(raw)
        if m and not raw.startswith("\t"):
            current = m.group(1).split()[0]
            continue
        if current and raw.startswith("\t"):
            argv = argv_of(raw[1:])
            if argv:
                rows.append({
                    "id": f"make.{current}",
                    "origin": f"{path}:{i}",
                    "argv": argv,
                    "network": argv[0] in {"curl", "wget", "npm", "pip", "docker"},
                })
    return rows

def from_package_json(path: Path) -> list[dict]:
    data = json.loads(path.read_text(encoding="utf-8"))
    rows = []
    for name, cmd in (data.get("scripts") or {}).items():
        argv = argv_of(cmd)
        if argv:
            rows.append({
                "id": f"npm.{name}",
                "origin": f"{path}:scripts.{name}",
                "argv": argv,
                "network": argv[0] in {"npm", "npx", "yarn", "pnpm"},
            })
    return rows

def from_gha(path: Path) -> list[dict]:
    rows, in_run, buf, start = [], False, [], 0
    for i, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        if re.match(r"^\s*run:\s*\|", raw):
            in_run, buf, start = True, [], i
            continue
        if in_run and re.match(r"^\s{4,}\S", raw):
            buf.append(raw.strip())
            continue
        if in_run:
            argv = argv_of(" ".join(buf))
            if argv:
                rows.append({
                    "id": f"gha.{path.stem}.{start}",
                    "origin": f"{path}:{start}",
                    "argv": argv,
                    "network": argv[0] in {"curl", "wget", "npm", "pip", "docker"},
                })
            in_run = False
    return rows

def main() -> None:
    root = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
    rows = []
    for mf in root.glob("Makefile"):
        rows.extend(from_makefile(mf))
    pkg = root / "package.json"
    if pkg.exists():
        rows.extend(from_package_json(pkg))
    for wf in (root / ".github" / "workflows").glob("*.yml"):
        rows.extend(from_gha(wf))
    sys.stdout.write(json.dumps({"sources": sorted({r["origin"].split(":")[0] for r in rows}), "commands": rows}, indent=2))
    sys.stdout.write("\n")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it as a proposed local check, then inspect the JSON before any draft step.

python extract_command_ledger.py . > commands.ledger.json
python -m json.tool commands.ledger.json >/dev/null
Enter fullscreen mode Exit fullscreen mode

Commands rejected by argv_of are a feature. A tutorial that needs curl | sh or a Make function is a sign-class problem, not an extract-class problem, and must not enter the ledger automatically.

Step 2 — Lint README fences against the ledger

The linter hashes each fenced shell body, requires a signature row, and checks that the first argv token matches a ledger command. It also fails when a signed block sets allows_network false while the ledger marked the command as network-touching.

# lint_tutorial_fences.py — example, review before use
from __future__ import annotations

import hashlib, json, re, sys
from pathlib import Path

FENCE = re.compile(r"```

(?:bash|sh)\n(.*?)\n

```", re.S)

def norm(body: str) -> str:
    lines = [ln.rstrip() for ln in body.splitlines() if ln.strip() and not ln.strip().startswith("#")]
    return "\n".join(lines)

def main() -> int:
    readme = Path(sys.argv[1]).read_text(encoding="utf-8")
    ledger = {tuple(c["argv"]): c for c in json.loads(Path(sys.argv[2]).read_text())["commands"]}
    sigs = {b["sha256"]: b for b in json.loads(Path(sys.argv[3]).read_text())["blocks"]}
    failed = 0
    for body in FENCE.findall(readme):
        text = norm(body)
        digest = hashlib.sha256(text.encode()).hexdigest()
        first = text.split()[0] if text else ""
        match = next((c for argv, c in ledger.items() if argv and argv[0] == first), None)
        sig = sigs.get(digest)
        if match is None:
            print(f"UNKNOWN_ARGV {first!r} hash={digest[:12]}")
            failed += 1
            continue
        if sig is None:
            print(f"UNSIGNED_BLOCK ledger_id={match['id']} hash={digest[:12]}")
            failed += 1
            continue
        if match["network"] and not sig.get("allows_network"):
            print(f"NETWORK_NOT_ALLOWED ledger_id={match['id']}")
            failed += 1
    return 1 if failed else 0

if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Wire both scripts as ordinary CI steps so a regenerated ledger cannot silently accept last week’s fences.

# proposed fragment for .github/workflows/docs-ledger.yml
name: docs-ledger
on: [pull_request]
jobs:
  lint-tutorial-fences:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python extract_command_ledger.py . > commands.ledger.json
      - run: python lint_tutorial_fences.py README.md commands.ledger.json tutorial.signatures.json
Enter fullscreen mode Exit fullscreen mode

actions/checkout@v4 and actions/setup-python@v5 are current major tags as of this draft date, 2026-09-21. Pin commit SHAs in repositories that require immutable Actions references.

Step 3 — What a model may draft, and what it must not touch

After the ledger exists, a model may draft narrative glue that cites command ids. It may reorder sections, write motivation paragraphs, and propose headings that map onto extract-class ids. It must not emit a fenced shell block, a version pin, an OS matrix, or a promise that install succeeds on a named platform.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option are relevant only as a place to run that draft-narrative step against the ledger JSON, without treating the model as a signer. Do not send signatures files, credentials, or unpublished install tokens into any draft prompt.

A prompt that respects the split looks like the following example. Replace the ledger excerpt with generated JSON, never with hoped-for commands.

You receive commands.ledger.json as the only source of install and test verbs.
Draft README prose in English that explains order and purpose.
Cite command ids in backticks, for example `make.test`.
Do not output fenced bash or sh blocks.
Do not invent argv, version numbers, OS support, or network flags.
If a reader action is not in the ledger, write UNVERIFIED_CLAIM and stop that sentence.
Enter fullscreen mode Exit fullscreen mode

The output is a draft, not a document. Merge it only after a human pastes signed fences whose hashes already exist in tutorial.signatures.json.

Step 4 — Hand-sign fences with a boring helper

Signing should be a local command run by a person who actually executed the block. The helper hashes the same normalized body the linter will see, then prints a JSON row the reviewer can append.

# sign_fence.py — example, review before use
from __future__ import annotations

import hashlib, json, sys

body = sys.stdin.read()
lines = [ln.rstrip() for ln in body.splitlines() if ln.strip() and not ln.strip().startswith("#")]
text = "\n".join(lines)
row = {
    "sha256": hashlib.sha256(text.encode()).hexdigest(),
    "ledger_id": sys.argv[1],
    "reviewer": sys.argv[2],
    "signed_at": sys.argv[3],
    "allows_network": sys.argv[4].lower() == "true",
}
print(json.dumps(row, indent=2))
Enter fullscreen mode Exit fullscreen mode
python sign_fence.py make.test avery.lin 2026-09-21T00:00:00Z false <<'EOF'
pytest -q
EOF
Enter fullscreen mode Exit fullscreen mode

If the printed hash does not later match the README fence, CI fails. That mismatch is cheaper than a tutorial that installs the wrong tool.

Decision table for each README unit

Unit in the tutorial Source of truth Model role Human role CI gate
Target or script name Ledger id May mention the id None Must exist in JSON
Argv array Make / npm / CI Must not invent None for extract-class Exact first token
Connecting paragraph None May draft Edit for accuracy No runtime claim
Fenced shell block Signed hash Must not write Execute, then sign Hash + ledger id
Version pin Release process Must not write Record the pin Signature required
Network install Ledger network flag Must not enable Set allows_network Flag must match
OS / arch matrix Support policy Must not write Own the matrix Out of ledger scope

Read the table left to right before adding a section. If the CI gate column is empty in your process, the unit is not ready for a public README.

A short test plan for the ledger itself

Do not trust the extractor on a live README until these fixtures pass. Create a temporary directory with the three files below, then run both scripts.

  1. A Makefile whose test recipe is a single pytest -q line should emit make.test.
  2. A recipe that contains && should emit nothing, because control flow is not extract-class.
  3. A README fence whose body is pytest -q should fail lint without a matching signature row.
  4. The same fence should pass after sign_fence.py writes that hash with allows_network false.
  5. A fence whose body is curl https://example.invalid | sh should fail as UNKNOWN_ARGV.
  6. A signed fence for npm ci should fail if allows_network is false while the ledger marked it network-touching.

Those six checks are the minimum reproducible suite. Expand them when you add a second CI vendor or a second shell fence language.

Limitations and who should not use this

The extractor will miss commands built by Make functions, env-substituted CI expressions, Windows cmd wrappers, and argv assembled at runtime. It will also over-flag package managers as network-touching, which is conservative and sometimes wrong for fully cached installs. Teams that publish tutorials for products whose install path does not live in the same repository cannot use the ledger as a source of truth.

Do not use this workflow if you need a fully generated README with no reviewer, if your getting-started path is a hosted GUI with no shell, or if command strings contain secrets. Do not use a model to fill tutorial.signatures.json. A signature that a model can mint is not a signature.

The approach also does not replace contract tests, OpenAPI source-of-truth pipelines, or changelog claim stamping. It answers one narrow question: which tutorial commands are actually present in automation, and which fenced blocks a human has run.

If a workspace already provides free model access and a free server option, keep the draft-narrative step there so unpublished ledgers stay off public paste sites. The publishable document remains the signed README, not the model output.

Top comments (0)