Skip to content

Authoring guide

How this workshop is built. Read before authoring a section, a lab, or a module. Contribution workflow (issues, PRs, the small-fix fast path) lives in CONTRIBUTING; this page is the authoring contract itself.

Non-negotiable guardrails

  1. Vendor-neutral. No employer, customer, or corporate names anywhere — slides, labs, code, assets, commits, docs. Product names (OpenTofu, Terraform, LocalStack, Spacelift, …) are fine when technically relevant.
  2. No tooling attribution. Never name editors, generators, or AI assistants in commits, code, or docs. No Co-Authored-By trailers.
  3. Label AI imagery. Every AI-generated image carries a visible AI generated footer (the section-cover layout does this automatically).
  4. OpenTofu-first. Teach the tofu CLI. HCL's top-level block is still terraform {}, but prose, commands, and output say tofu. Note Terraform compatibility; don't run a parallel Terraform track.
  5. Stay current. Track current OpenTofu behaviour and versions. Verify version claims against upstream release notes at authoring time.

Published site (GitHub Pages)

The live site is MkDocs Material at / plus hash-routed Slidev under /deck/ (superset), /deck/3day/, and /deck/templates/. Build/preview locally with task pages:build / task pages:preview (needs python3 -m pip install -r docs/requirements-docs.txt). Contract tests: pnpm test:pages / task test:pages.

Repository map

  • versions.env — toolchain pin SSoT (OpenTofu, Go, LocalStack, Terramate). Bump versions here; scripts/verify.sh section 10 catches consumer skew (Taskfile, docker-compose, Terratest Dockerfile, CI workflow literals, bootstrap).
  • scripts/deck-manifest.mjs — section metadata SSoT (id, slug, title, tier, day, canonical cut, status, timings, Day-1 fit-plan markers). Syllabus and runbook tables are validated against it in CI.
  • scripts/generate-decks.mjs — emits generated root decks from the manifest. Run pnpm decks:generate after manifest edits; pnpm decks:check fails on drift.
  • Facilitator launcher: pnpm deck -- --day 1, --section S05, or --range S05-S09 (task deck -- …) starts a deterministic selection. pnpm deck -- --list discovers choices. With a TTY, gum adds a menu; without it, explicit flags work everywhere. A noninteractive invocation with no selector fails instead of opening the superset. --dry-run writes gitignored .deck-selection.md without starting Slidev.
  • slides.md / slides-3day.md / slides-day-1.md / slides-day-2.md / slides-day-3.md — generated root decks (header: Generated by scripts/generate-decks.mjs … Do not edit.). Mostly frontmatter + src: import blocks. Do not hand-edit tier tokens or hide flags here — change scripts/deck-manifest.mjs and regenerate.
  • slides-templates.md — hand-authored templates gallery (not manifest-generated).
  • pages/SNN-topic/index.md — one self-contained section: a section-cover divider + content slides. Never reference another section's slide numbers, and never embed a lab body — reference labs by path.
  • labs/day-N/NN-topic.md — standalone labs (see contract below).
  • modules/ + examples/ — the runnable OpenTofu (see the module DoD below).
  • variant/ovh/ — the OVHcloud Public Cloud variant track, a self-contained lane outside the base discovery globs; its own entry is variant/ovh/README.md.
  • theme/ — the local Slidev theme: layouts/, components/ (IacIcon, KwCard, KwChip, CodeNote, CodeCallout, ArchBox), styles/theme.css.
  • components/ — animated Vue teaching diagrams (step prop bound to $clicks; clamp out-of-range step — never throw or blank a slide). Auto-imported into every deck from the repo root. Current set:
  • PlanApplyFlow — the core workflow lit stage-by-stage: config → plan → apply → state. Props: step?: number (0–4, bind :step="$clicks"; clamped). step 0 lights nothing, step 4 lights all four.
  • TestPyramid — the testing pyramid built bottom-up (static → unit → integration → e2e). Props: step?: number (0–4, base-first, clamped) plus per-layer label lists staticTools / unitTools / integrationTools / e2eTools (string[], default [] → bare bands). Reused by S12 and S18 with their own tool sets.
  • StateEncryptionFlow — client-side state encryption lit stage-by-stage: plaintext state → PBKDF2 key provider → AES-GCM method → ciphertext. Props: step?: number (0–4, bind :step="$clicks"; clamped). step 0 lights nothing, step 4 lights all four. S05's headline visual.
  • DependencyGraph — a resource/module DAG revealed in dependency (topological) order: random_pet → module.svc_a / module.svc_b (one module consumed twice) → local_file. Props: step?: number (0–8, bind :step="$clicks"; clamped) — a source node, then its edge, then the dependent node, per click. step 0 lights nothing, step 8 lights all. The S02 (references between blocks) / S07 (module composition) visual.
  • StateReconcile — the desired / state / actual reconciliation model lit stage-by-stage: desired → state → actual → refresh → reconcile. Props: step?: number (0–5, bind :step="$clicks"; clamped). step 0 lights nothing, step 5 lights all five. S04's headline visual; reused in S09 when reading a plan diff.
  • MockProviderFlow — converting an apply-shaped test into a mocked plan contract lit stage-by-stage: apply run → mock_provider → plan run → mock_resource → override_resource. Props: step?: number (0–5, bind :step="$clicks"; clamped). step 0 lights nothing, step 5 lights all five. S17's headline visual.
  • TerramateOrchestration — the Day-3 Terramate pipeline lit phase-by-phase: discover → generate → order → filter. Props: step?: number (0–4, bind :step="$clicks"; clamped), optional phase?: 'discover' | 'generate' | 'order' | 'filter' or highlight?: number (0-based) to emphasise one stage without suppressing others. step 0 lights nothing, step 4 lights all four. S20 shows the full loop; S21–S24 pass phase to highlight one verb.
  • scripts/ — the gates and generators beyond the SSoT trio above: verify.sh (drift/fmt/validate/test), lab-contract.mjs, lab-inventory.mjs, link-check.mjs, deck/pages/quiz/supply-chain test suites, export tooling.
  • tests/shell/ — bats suites for the shell plane (lab.bats, ci-contract.bats, stubs) covering setup/*.sh and the CI contract.
  • setup/ — learner/facilitator environment: bootstrap.sh (toolchain install), lab.sh + docker-compose.yml glue, LocalStack notes (localstack.md, localstack-k8s.yaml), and the Terratest container image (setup/terratest/Dockerfile).
  • docs/_generated/lab-inventory.json — generated lab-inventory snapshot; node scripts/lab-inventory.mjs --check fails on drift, --write refreshes.
  • quiz/ — portable participant question bank (questions.json + questions.schema.json; pnpm quiz:validate, pnpm test:quiz).
  • supply-chain/exceptions.json — dependency-audit exception list consumed by the npm-audit gate (pnpm dep-audit, pnpm test:supply-chain).
  • public/ — static deck assets served at the site root: branding/, covers/ (AI-labelled section art), icons/, favicon.
  • docs/ — MkDocs site sources (syllabus, setup, runbook, this guide) plus tracked references that are not published (e.g. decisions/).
  • docs/decisions/ — tracked ADRs.
  • docs/facilitator-runbook.md — delivery guide for facilitators (timing cuts, morning checklist, known traps).
  • .github/ — CI workflows (ci.yml, release.yml, …), PR template, issue templates.

Gate-name legend (US-*)

Gate and check names like US-F-TIERS or US-P-PINS in Taskfile.yaml, scripts/verify.sh, and .github/workflows/ci.yml are the IDs of the internal backlog stories that introduced them — kept as stable labels so a gate can be traced to its origin. The backlog itself is untracked planning material; treat the IDs as opaque gate names.

Design system

Reuse the layouts; never invent a per-slide layout. Available: cover, section-cover, agenda, statement, code-walkthrough, code-annotated, comparison, two-cols-code, topology, lab, recap. Patterns:

  • magic-move — grow an HCL manifest field-by-field, or morph HCL → plan → shell.
  • CodeNote — click-synced side rail explaining highlighted lines ({none|1-2|...} line steps sync with at="N").
  • CodeCallout — floating overlay that labels a risky line in place.
  • IacIcon — one badge per HCL construct (kind="resource|module|state|test|…"). Use a glyph where a slide names a specific construct; keep emoji for conceptual/decorative cards. Over-conversion is a defect.

Section headers & tiers

Every section sets section: 'NN', day: Day N, tier: core|recommended|optional in its pages/SNN-*/index.md frontmatter. Tier, day, title, and cut membership live in scripts/deck-manifest.mjs; generated decks import sections under # SNN · Title · tier · Day comments produced by pnpm decks:generate. Tiers must match frontmatter and obey hide:true ⟺ optional in slides-3day.md — pnpm decks:check and task verify (US-F-TIERS) enforce both. Rationale: tier meanings in ADR 0008; manifest model in ADR 0014.

Lab authoring contract

Flat file labs/day-N/NN-topic.md, one per section. Every lab:

  • Opens with a header table: Section, Environment (localstack ✓ / mock ✓ / real-aws (optional)), Estimated time.
  • Has Objective, Prerequisites, Files used, numbered Steps, Expected observations, Cleanup / panic reset, optional Stretch.
  • Idiot-proof: every task and question ships a <details><summary> spoiler with the exact command / expected output.
  • Break → fix: show the failure, then the fix (e.g. enforced-plaintext error → add fallback).
  • Single source of truth: the HCL a slide teaches is the file the lab applies. The next lab continues the same project — by carrying its spine addresses forward, not by being a file superset (see below).
  • Panic reset is always safe: task lab:down + tofu destroy leaves no residue.

The evolving project: service-manifest

Every hands-on stage grows one project, service-manifest — the child module already at labs/day-1/07-modules/modules/service-manifest/ is the name's source of truth (svc-manifest is informal shorthand). The published stage→section map is canonical in docs/syllabus.md · The evolving project — edit it there first; the rule below is the authoring-side copy.

A shared mutating directory is impossible here for two physical reasons: every lab must run standalone from its own tracked workdir (task lab:validate DIR=labs/day-N/NN-topic, below), and scripts/verify.sh byte-compares an annotated block against the whole source file, so a directory that mutates between sections has no stable snapshot to cite. Continuity is therefore carried by addresses:

  • Project spine — carried forward, never renamed, never silently dropped: local_file.manifest, variable "service", variable "environment", output "manifest_path". Once introduced, every later Day-1 stage declares it — except stage 8, where S07 extracts the spine into ./modules/service-manifest and the root reaches it through the instances (module.checkout.local_file.manifest, each instance's manifest_path, service/environment passed as module arguments). Names unchanged, prefix moved; the extraction is the lesson, so stage 8 is framing only — never a structural edit.
  • Auxiliary demonstration resources (e.g. local_file.summary, which only gives the dependency-graph beat a second node) may be retired — but only explicitly, with the lab preamble naming what was retired and why. A silent disappearance is a defect.
  • A stage conforms when its diff from the previous stage reads as spine + an explicit auxiliary delta.
  • Where the spine's first variable lands — decided (US-C-STAGE-D1a). The spine arrives in two instalments: local_file.manifest and output "manifest_path" at stage 1, variable "service" and variable "environment" at stage 4 (labs/day-1/06-variables/), where S06 teaches typed inputs. Stage 2 teaches the variable block type with variable "owner" — auxiliary, non-spine name, retired explicitly at stage 3, which still declares no variables. Do not "fix" that by adding spine inputs to stage 3: the alternative was rejected because it forces S06 to re-type a spine address from string to object({…}). Rationale and revert live in the lane's decision note; the canonical statement is in docs/syllabus.md.

Do not author to a file superset. "Stage N is stage N−1 plus a delta" is contradicted by the tree at six of the seven Day-1 transitions — 2→3 drops variable "owner", locals, data.local_file.motd and module "greeting"; 3→4 drops random_pet.env and local_file.summary; 4→5 drops variable "api_token" and the outputs effective_environment and api_token (and brings random_pet.env back); 5→6 drops both guard variables and the precondition/postcondition/check blocks they fed; 6→7 drops random_pet.env, output "db_password" and the explicit backend "local" block, introducing variable "state_passphrase"; 7→8 drops variable "state_passphrase" and random_password.session, moving the spine into ./modules/service-manifest. Only 1→2 retires nothing. S04 and S05 also teach deliberately against a small config. Judge a stage by the spine rule, never by counting files. Every one of those drops is named in the receiving lab's ### Continuity preamble — that is where a learner's "where did it go?" is answered, and an unnamed drop is a defect.

Showing the transition on a slide: use the drift-checked pattern the repo already ships — a code-walkthrough whose magic-move container holds consecutive annotated fences, each byte-checked against its own whole file. Reference: slides-templates.md:123-172 over labs/fixtures/templates-demo/naming-step-{1,2,3}.tf. Step snapshots belong under labs/fixtures/ (carve-out below) — never as an excerpt of a lab workdir file, which the whole-file gate rejects.

Lab workdir & drift contract

A lab's runnable HCL lives as tracked files in a sibling workdir, not as heredocs the learner pastes into $HOME. For a lab labs/day-N/NN-topic.md, put its config under labs/day-N/NN-topic/ (e.g. labs/day-1/09-best-practices/main.tf) and reference those files by path from the prose. The heredoc-into-$HOME pattern is never the primary flow — a learner should be able to task lab:validate DIR=labs/day-N/NN-topic against real, tofu fmt-clean files. The lab:* tasks all take DIR=labs/day-N/NN-topic (see Taskfile.yaml).

Carve-out: labs/fixtures/ is reserved for scripts/verify.sh drift fixtures (e.g. labs/fixtures/drift-demo/, labs/fixtures/templates-demo/). It is an intentional exception to the labs/day-N/NN-topic convention and is not a workshop section — never number it into the section namespace.

To make "slide↔lab single source of truth" CI-verifiable, tie a fenced hcl block in labs/** or pages/** to its source file with an HTML-comment marker on the line immediately above the fence (shown indented so the inner fences render):

<!-- source: labs/fixtures/drift-demo/main.tf -->
```hcl
terraform {
  required_version = ">= 1.9"
}
```

scripts/verify.sh scans labs/**/*.md, pages/**/*.md, and slides-templates.md, diffs each annotated block body against its source file, and fails the build, naming the file, on any drift (or if the file is missing). Rules:

  • Annotated block → diffed against its source; drift is a build failure.
  • Unannotated hcl block → ignored (scratch/inline teaching HCL, or a partially-authored lab). A lab with only unannotated blocks warns, never fails, so in-flight work never blocks unrelated lanes.
  • Line endings must be LF. Lab .md and .tf files are enforced to LF by the repo-root .gitattributes; the drift check also strips \r upstream so a stray CRLF cannot silently disarm detection. Only the block-body comparison additionally normalises a lone trailing newline — it is not a licence to author CRLF.
  • The marker <!-- source: … --> and the opening fence are expected at column 0 (top level). Bare ```hcl and magic-move fences with metadata (```hcl {none|…}) are both accepted — only the hcl language tag matters; the {…} info string is ignored. A block written inside a list/indented context keeps its indentation in the body, so it will diff against the raw file only if the file is indented identically — author drift-checked blocks at top level.
  • The block body must be byte-identical to the file. Generate the block from the file — never hand-sync the two. labs/fixtures/drift-demo/ is the reference example.

Definition of Done (per section)

  1. Slides authored in pages/SNN-topic/index.md, matching the outline beats.
  2. Lab authored, idiot-proof with spoilers, break→fix.
  3. Manifest single-source-of-truth: slide HCL ↔ lab HCL identical.
  4. Components reused, not re-invented.
  5. pnpm decks:check green; generated root decks build (pnpm build, build:day1–day3, build:3day, build:templates).
  6. PDF/PNG export clean (magic-move + components render without overflow).
  7. task verify green — tofu fmt -check, validate, and tofu test pass (plan/mock lane needs no cloud; integration lane uses LocalStack). The fmt gate scans git-tracked *.tf (minus the deliberate S13 messy fixture), so it ignores sibling worktrees and caches — but equally, a new .tf you have not git added yet is not checked. Stage it before trusting a green gate. CI checkouts are fully tracked, so CI always sees it.
  8. Presenter notes on every content slide (see convention below) so anyone can deliver the deck, not just its author.
  9. Facilitator runbook row in docs/facilitator-runbook.md for the section (timing cues from README fit plan / lab duration:, one checkpoint question, known failure modes, and the live cut-order note). Stub sections must not claim a shipped row until this lands with the authored content.
  10. Cleanup safe; no guardrail violations.
  11. Conventional Commit + gitmoji.

Presenter-notes convention

Every content slide carries Slidev presenter notes so any facilitator can deliver it. Slidev treats the last HTML comment in a slide as its presenter notes: place it at the very end of the slide's markdown, after all content (including any ::notes:: slot / CodeNote rail — that slot is a layout region, not presenter notes) and immediately before the --- separator. Notes show in presenter mode and never render on the slide.

Each note contains three things: what to say (the beat's teaching point in 2–4 sentences), a timing cue (e.g. ~3 min; on lab slides match the duration: frontmatter), and the transition line into the next slide (on recap slides echo the next: frontmatter). Anchor claims to what the slide actually teaches — don't invent facts. Pure cover/divider slides with no teaching content may be skipped; a divider with a spoken framing line is a beat and gets a note.

# Some slide title

- point one
- point two

<!--
Say: what this beat teaches, in 2-4 sentences. (~3 min)
Then: one transition line into the next slide.
-->

Commits

<emoji> <type>(<scope>): <subject> — Conventional Commits + gitmoji. Types: feat ✨ · fix 🐛 · docs 📝 · refactor ♻️ · test ✅ · chore 🔧 · ci 👷. Scopes: deck, labs, theme, modules, repo, ci. Imperative, lowercase, no trailing period.

Build & verify

task verify / scripts/verify.sh require Bash ≥4 (shopt globstar from US-X-DRIFT2). macOS /bin/bash 3.2 fails if it is first on PATH; Homebrew bash 5 and CI Ubuntu are fine.

pnpm install
pnpm deck -- --list                              # discover day / section / range choices
pnpm deck -- --day 1                             # facilitator launcher (or task deck -- …)
pnpm decks:generate && pnpm decks:check   # after manifest / section metadata edits
pnpm build && pnpm build:3day && pnpm build:templates   # decks
pnpm lint                                                # markdownlint (labs + variant/ovh docs)
task verify                                              # tofu fmt/validate/test