DEV Community

Avery Lin
Avery Lin

Posted on

Map CI Bootstrap Jobs to a Getting-Started Graph, Then Gate Unsigned Duration Claims

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Numbered workflow

  1. 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. Commit first_run_graph.json beside the tutorial if reviewers need the same node list. Treat missing Makefiles as a missing inventory, not as permission to improvise steps.

  2. 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/cache as a laptop step.

  3. 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.

  4. Sign the owned lane on a measured machine. Fill first_run_signed.yml after 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 sign copy_paste_safe when any command still interpolates a secret.

  5. Gate publishing on the linter exit code. Run the script in CI against getting-started.draft.md and fail the job on any unsigned: 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)