From 16bf7280505808447f1286fb381084f53bd5e303 Mon Sep 17 00:00:00 2001
From: opariffazman
Date: Fri, 17 Jul 2026 17:27:56 +0800
Subject: [PATCH 01/42] =?UTF-8?q?chore:=20retire=20starter/workflows=20+?=
=?UTF-8?q?=20release=20machinery=20=E2=80=94=20slides=20are=20source=20of?=
=?UTF-8?q?=20truth,=20learners=20fork=20monorepo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Fable 5
---
CLAUDE.md | 35 +++++---
scripts/release-launchpad.sh | 78 ------------------
scripts/release-launchpad.test.sh | 48 -----------
scripts/verify-fork-sync.sh | 52 ------------
scripts/verify-workflows.sh | 61 --------------
starter/README.learner.md | 53 -------------
starter/workflows/deploy.cicd1.yml | 41 ----------
starter/workflows/deploy.cicd2.yml | 54 -------------
starter/workflows/deploy.cicd3.yml | 83 -------------------
starter/workflows/deploy.cicd4.yml | 123 -----------------------------
10 files changed, 23 insertions(+), 605 deletions(-)
delete mode 100755 scripts/release-launchpad.sh
delete mode 100755 scripts/release-launchpad.test.sh
delete mode 100755 scripts/verify-fork-sync.sh
delete mode 100755 scripts/verify-workflows.sh
delete mode 100644 starter/README.learner.md
delete mode 100644 starter/workflows/deploy.cicd1.yml
delete mode 100644 starter/workflows/deploy.cicd2.yml
delete mode 100644 starter/workflows/deploy.cicd3.yml
delete mode 100644 starter/workflows/deploy.cicd4.yml
diff --git a/CLAUDE.md b/CLAUDE.md
index 824d6f9..03bfc23 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -40,8 +40,8 @@ Distinct from its siblings (do not blur them):
Pages; extensible to CF Pages). **No image** — the ship is static-only. Three.js, like `devops-bootcamp-app`.
- **`shipit-board` is dual-role:** the *shared* Mission Control on instructor EC2, **and** the
artifact each learner **builds + deploys to their own EC2** in the S4 capstone.
-- The freed `shipit-launchpad` name is reused for the **forkable learner repo**
- `Infratify/shipit-launchpad` (see Distribution below), not an image.
+- Learners fork **this monorepo** (`Infratify/devops-bootcamp-shipit`) and work in `launchpad/`
+ (see Distribution below). The planned payload-only `shipit-launchpad` release repo was never used.
## PINNED — the 4-session arc (lean: one concept per session)
@@ -68,7 +68,9 @@ The one integration point. Keep it stable; slides and the reference workflows de
named-palette colour; sets the ship's hue — every saturated texel takes that hue; greys/blacks stay
neutral — and drives the UI accent) and `shipModel` (which of the 4 ships the board renders in
orbit); `shipName` is a cosmetic label, not identity.
-- **Transport:** each workflow stage POSTs one event to the board.
+- **Transport:** a workflow step POSTs one event per stage it reports. The taught form (CI/CD 3
+ slides) is a **single liftoff report** after the Pages deploy; extra beats (pad, abort-on-failure)
+ are optional operator flourishes, never required of learners.
```
POST $BOARD_URL/api/event
@@ -86,6 +88,9 @@ Content-Type: application/json
}
```
+- **Minimal payload:** only `callsign` + a known `stage`/`status` are required — the board defaults
+ `color`/`shipModel` when absent or invalid (`board/src/room.js`). Slides have learners hardcode
+ their own colour in the JSON body (no config-extraction plumbing in the workflow).
- `$BOARD_URL` is a **public** repo/environment **variable**.
- `$SHIPIT_TOKEN` is the **secret** taught in CI/CD 3 — a ship with no/late token can't report to
Mission Control (the "unauthorized" lesson). Do NOT accept unauthenticated events in prod mode.
@@ -112,8 +117,11 @@ Frozen — slides quote these verbatim.
`node scripts/preflight.mjs` validates `ship.config.json` and **exits non-zero (ABORT)** on a bad
config (unparseable, bad hex, unknown emblem, over-long name). Teaches the *exit-code gate* (a
DevOps skill), not test authoring (a developer skill).
-- **Reference workflows** live as the `cicd1..4` **answer-key branches** on `Infratify/shipit-launchpad`
- (each = the full correct state at that session's end); slides lift the `deploy.yml` from them.
+- **The slides are the source of truth for the workflow** — learners build `deploy.yml` from the
+ building blocks on the slides, nothing else. The authored answer keys (`starter/workflows/`) were
+ retired 2026-07-17: learners shipped a simpler file than they prescribed, and the extra plumbing
+ (config extraction via `jq`, pad/abort beats, `env:` indirection) never earned its place. A
+ session's reference state is *derived* by running its amali on a test fork (see Distribution).
- **Per-session commands** (kelas-taip-bersama): fork → author `deploy.yml` step-by-step per session →
`git push` → watch. Full list in the spec §7.
- **Slides drift note:** the bootcamp slides repo (`~/repo/slides-devops-bootcamp`) quotes the two
@@ -122,13 +130,16 @@ Frozen — slides quote these verbatim.
## PINNED — learner distribution (fork, not template)
-- Learners **fork** `Infratify/shipit-launchpad`. Branches: **`main`** = payload only
- (`ship/` + `ship.config.json` + `preflight`, **no workflow, no `board/`**); **`cicd1..4`** = answer
- keys (`board/` enters at `cicd4`).
-- **The learner authors the workflow** — `.github/workflows/deploy.yml` is NOT shipped; they write
- it, it grows one job per session. That is the lesson.
-- **Discipline rule (load-bearing):** upstream `main` must never gain a workflow or re-touch
- `ship.config.json` after release, so learner **sync-fork** stays conflict-free.
+- Learners **fork** `Infratify/devops-bootcamp-shipit` (this monorepo) and work in `launchpad/`
+ (workflow steps use `working-directory: launchpad`). The payload-only `shipit-launchpad` release
+ repo + its build scripts (`scripts/release-launchpad.sh` & co.) were retired 2026-07-17, never used.
+- **The learner authors the workflow** — `.github/workflows/deploy.yml` is NOT shipped on `main`;
+ they write it from the slide building blocks, and it grows each session. That is the lesson.
+- **`cicdN` reference branches** (recovery/diff aid, not a spec): before each session the operator
+ follows that session's amali verbatim on a test fork — proving the slides run green — and pushes
+ the resulting state as branch `cicdN` on `Infratify/devops-bootcamp-shipit`.
+- **Discipline rule (load-bearing):** upstream `main` must never gain `.github/workflows/` or
+ re-touch `launchpad/ship.config.json`, so learner **sync-fork** stays conflict-free.
## Conventions
diff --git a/scripts/release-launchpad.sh b/scripts/release-launchpad.sh
deleted file mode 100755
index 51d5f71..0000000
--- a/scripts/release-launchpad.sh
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/usr/bin/env bash
-# release-launchpad.sh — assemble the forkable Infratify/shipit-launchpad repo from this monorepo.
-#
-# main payload only (ship + config + preflight) — NO workflow, NO board/
-# cicd1..4 answer keys; each = main + that session's deploy.yml (board/ enters at cicd4)
-#
-# Usage: release-launchpad.sh [--out DIR] [--push REMOTE]
-# --out DIR where to build the repo (default: /.launchpad-release)
-# --push REMOTE after building, force-push main + cicd1..4 to REMOTE (a git URL)
-set -euo pipefail
-
-ROOT="$(git rev-parse --show-toplevel)"
-OUT="$ROOT/.launchpad-release"
-PUSH=""
-while [ $# -gt 0 ]; do
- case "$1" in
- --out) OUT="$2"; shift 2 ;;
- --push) PUSH="$2"; shift 2 ;;
- *) echo "unknown arg: $1" >&2; exit 2 ;;
- esac
-done
-
-WF="$ROOT/starter/workflows"
-for n in 1 2 3 4; do [ -f "$WF/deploy.cicd$n.yml" ] || { echo "missing $WF/deploy.cicd$n.yml" >&2; exit 1; }; done
-[ -f "$ROOT/starter/README.learner.md" ] || { echo "missing starter/README.learner.md" >&2; exit 1; }
-
-# --- stage the payload-only main tree ---
-# Uses tar (universally present; no rsync dependency for a tool instructors/CI will run).
-# Copy the ship payload skipping the heavy build dirs, then strip dev/monorepo cruft precisely.
-STAGE="$(mktemp -d)"; trap 'rm -rf "$STAGE"' EXIT
-tar -C "$ROOT/launchpad" --exclude='./node_modules' --exclude='./dist' -cf - . | tar -C "$STAGE" -xf -
-rm -f "$STAGE/pnpm-lock.yaml" "$STAGE/pnpm-workspace.yaml"
-find "$STAGE" -name '*.test.mjs' -delete
-find "$STAGE" -type d -name '__fixtures__' -prune -exec rm -rf {} +
-cp "$ROOT/starter/README.learner.md" "$STAGE/README.md"
-# drop the dev-only test:unit script from package.json (Node, no jq dependency)
-node -e 'const fs=require("fs"),f=process.argv[1],p=JSON.parse(fs.readFileSync(f));delete p.scripts["test:unit"];fs.writeFileSync(f,JSON.stringify(p,null,2)+"\n")' "$STAGE/package.json"
-
-# --- build the git repo ---
-rm -rf "$OUT"; mkdir -p "$OUT"
-git -C "$OUT" init -q
-git -C "$OUT" config user.email "bootcamp@infratify.dev"
-git -C "$OUT" config user.name "Ship It release"
-
-commit_tree() { git -C "$OUT" add -A && git -C "$OUT" commit -q -m "$1"; }
-sync_into() { tar -C "$1" -cf - . | tar -C "$OUT" -xf -; } # copy tree contents into OUT, keeping .git
-
-# main
-git -C "$OUT" checkout -q -b main
-sync_into "$STAGE/"
-commit_tree "shipit-launchpad: ship payload — fork & build on this (no workflow, no board/)"
-
-# cicd1..3: layer the workflow as .github/workflows/deploy.yml
-prev=main
-for n in 1 2 3; do
- git -C "$OUT" checkout -q -b "cicd$n" "$prev"
- mkdir -p "$OUT/.github/workflows"
- cp "$WF/deploy.cicd$n.yml" "$OUT/.github/workflows/deploy.yml"
- commit_tree "cicd$n: session $n answer key"
- prev="cicd$n"
-done
-
-# cicd4: workflow + the board/ payload (the black box they build)
-git -C "$OUT" checkout -q -b cicd4 cicd3
-cp "$WF/deploy.cicd4.yml" "$OUT/.github/workflows/deploy.yml"
-mkdir -p "$OUT/board"
-tar -C "$ROOT/board" --exclude='./node_modules' --exclude='./dist' -cf - . | tar -C "$OUT/board" -xf -
-commit_tree "cicd4: session 4 answer key + board/ (build & ship it)"
-
-git -C "$OUT" checkout -q main
-
-echo "built: main cicd1 cicd2 cicd3 cicd4 -> $OUT"
-
-if [ -n "$PUSH" ]; then
- echo "pushing to $PUSH ..."
- git -C "$OUT" push --force "$PUSH" main cicd1 cicd2 cicd3 cicd4
- echo "pushed."
-fi
diff --git a/scripts/release-launchpad.test.sh b/scripts/release-launchpad.test.sh
deleted file mode 100755
index ddf8a49..0000000
--- a/scripts/release-launchpad.test.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env bash
-# Asserts release-launchpad.sh produces the correct branch trees.
-set -euo pipefail
-ROOT="$(git rev-parse --show-toplevel)"
-OUT="$(mktemp -d)"; trap 'rm -rf "$OUT"' EXIT
-"$ROOT/scripts/release-launchpad.sh" --out "$OUT" >/dev/null
-
-git -C "$OUT" rev-parse --verify main >/dev/null || { echo "FAIL: no main"; exit 1; }
-for b in cicd1 cicd2 cicd3 cicd4; do
- git -C "$OUT" rev-parse --verify "$b" >/dev/null || { echo "FAIL: no $b"; exit 1; }
-done
-
-co() { git -C "$OUT" checkout -q "$1"; }
-absent() { [ ! -e "$OUT/$1" ] || { echo "FAIL: $2 has $1"; exit 1; }; }
-present() { [ -e "$OUT/$1" ] || { echo "FAIL: $2 missing $1"; exit 1; }; }
-
-# main: payload only — no workflow, no board, no monorepo cruft, no dev tests
-co main
-absent ".github/workflows" main
-absent "board" main
-absent "pnpm-lock.yaml" main
-absent "pnpm-workspace.yaml" main
-absent "scripts/__fixtures__" main
-present "ship.config.json" main
-present "scripts/preflight.mjs" main
-present "src/main.js" main
-[ -z "$(find "$OUT/src" -name '*.test.mjs')" ] || { echo "FAIL: main ships dev tests"; exit 1; }
-grep -q '"test:unit"' "$OUT/package.json" && { echo "FAIL: main keeps test:unit"; exit 1; } || true
-grep -qi fork "$OUT/README.md" || { echo "FAIL: main README not the learner one"; exit 1; }
-
-# cicdN: workflow == the Nth end-state; board only at cicd4
-for n in 1 2 3 4; do
- co "cicd$n"
- present ".github/workflows/deploy.yml" "cicd$n"
- diff -q "$OUT/.github/workflows/deploy.yml" "$ROOT/starter/workflows/deploy.cicd$n.yml" >/dev/null \
- || { echo "FAIL: cicd$n workflow != deploy.cicd$n.yml"; exit 1; }
-done
-co cicd1; absent "board" cicd1
-co cicd3; absent "board" cicd3
-co cicd4; present "board/Dockerfile" cicd4; present "board/src/index.js" cicd4
-absent "board/node_modules" cicd4
-
-# ship.config.json frozen == monorepo payload (discipline)
-co main
-diff -q "$OUT/ship.config.json" "$ROOT/launchpad/ship.config.json" >/dev/null \
- || { echo "FAIL: main ship.config.json drifted from payload"; exit 1; }
-
-echo "OK: release produces correct main + cicd1..4 trees"
diff --git a/scripts/verify-fork-sync.sh b/scripts/verify-fork-sync.sh
deleted file mode 100755
index 1b84558..0000000
--- a/scripts/verify-fork-sync.sh
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/env bash
-# verify-fork-sync.sh — prove the load-bearing discipline: a learner fork syncs upstream `main`
-# with ZERO conflicts, because upstream main never gains a workflow and never re-touches
-# ship.config.json. Local git only — merge mechanics are identical to a GitHub fork.
-set -euo pipefail
-ROOT="$(git rev-parse --show-toplevel)"
-W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT
-
-# 1. Build the upstream repo (main + cicd1..4)
-"$ROOT/scripts/release-launchpad.sh" --out "$W/upstream" >/dev/null
-UP="$W/upstream"
-
-# 2. Structural invariants on upstream main
-git -C "$UP" checkout -q main
-[ ! -e "$UP/.github/workflows" ] || { echo "FAIL: upstream main carries a workflow"; exit 1; }
-[ ! -e "$UP/board" ] || { echo "FAIL: upstream main carries board/"; exit 1; }
-diff -q "$UP/ship.config.json" "$ROOT/launchpad/ship.config.json" >/dev/null \
- || { echo "FAIL: upstream main ship.config.json drifted from the frozen payload"; exit 1; }
-
-# 3. Clone main as a learner fork
-git clone -q --branch main "$UP" "$W/fork"
-FORK="$W/fork"
-git -C "$FORK" config user.email learner@example.com
-git -C "$FORK" config user.name "Learner One"
-git -C "$FORK" remote add upstream "$UP"
-
-# 4. Learner customizes: edit ship.config.json + author their own deploy.yml (the S1 file)
-node -e 'const fs=require("fs"),f=process.argv[1],c=JSON.parse(fs.readFileSync(f));c.shipName="Learner One";c.color="#ff8800";fs.writeFileSync(f,JSON.stringify(c,null,2)+"\n")' "$FORK/ship.config.json"
-mkdir -p "$FORK/.github/workflows"
-cp "$ROOT/starter/workflows/deploy.cicd1.yml" "$FORK/.github/workflows/deploy.yml"
-git -C "$FORK" add -A
-git -C "$FORK" commit -q -m "my ship + my pipeline"
-
-# 5. Instructor fix on upstream main — touches src/ ONLY (never config, never a workflow)
-git -C "$UP" checkout -q main
-printf '\n// instructor: tiny scene tweak\n' >> "$UP/src/main.js"
-git -C "$UP" add -A
-git -C "$UP" commit -q -m "instructor: tweak scene"
-
-# 6. Learner syncs upstream main — MUST be conflict-free
-git -C "$FORK" fetch -q upstream main
-if ! git -C "$FORK" merge --no-edit upstream/main >/dev/null 2>&1; then
- echo "FAIL: sync produced a conflict:"; git -C "$FORK" status --short
- exit 1
-fi
-
-# 7. Both the learner's changes and the instructor fix survived
-grep -q "Learner One" "$FORK/ship.config.json" || { echo "FAIL: learner config lost"; exit 1; }
-grep -q "instructor: tiny scene tweak" "$FORK/src/main.js" || { echo "FAIL: instructor fix not merged"; exit 1; }
-[ -f "$FORK/.github/workflows/deploy.yml" ] || { echo "FAIL: learner workflow lost"; exit 1; }
-
-echo "PASS: fresh fork synced an instructor fix with 0 conflicts (discipline holds)"
diff --git a/scripts/verify-workflows.sh b/scripts/verify-workflows.sh
deleted file mode 100755
index fafe87d..0000000
--- a/scripts/verify-workflows.sh
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/usr/bin/env bash
-# verify-workflows.sh — structural + contract checks on the four answer-key workflows.
-# Not a CI gate; a fail-loud sanity check the release relies on. Runs actionlint if present.
-set -euo pipefail
-ROOT="$(git rev-parse --show-toplevel)"
-WF="$ROOT/starter/workflows"
-
-fail() { echo "FAIL: $1" >&2; exit 1; }
-# has() checks existence first so a not-yet-created file gives a clean "missing" message.
-has() { [ -f "$1" ] || fail "missing $1"; grep -Fq -- "$2" "$1" || fail "$(basename "$1"): missing '$2'"; }
-
-# YAML-validity / actionlint over whatever answer keys exist at this stage (glob, not a fixed 1..4
-# list) — so this same script is correct at Task 2 (cicd1..2 present) and Task 3 (cicd1..4 present).
-for f in "$WF"/deploy.cicd*.yml; do
- [ -e "$f" ] || continue
- if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then
- python3 -c "import yaml,sys; yaml.safe_load(open('$f'))" || fail "$(basename "$f"): invalid YAML"
- fi
- if command -v actionlint >/dev/null 2>&1; then
- actionlint "$f" || fail "$(basename "$f"): actionlint"
- fi
-done
-
-# cicd1 — Pages deploy on push, callsign injected
-has "$WF/deploy.cicd1.yml" "branches: [main]"
-has "$WF/deploy.cicd1.yml" "VITE_CALLSIGN: \${{ github.actor }}"
-has "$WF/deploy.cicd1.yml" "actions/deploy-pages@v4"
-has "$WF/deploy.cicd1.yml" "path: launchpad/dist"
-has "$WF/deploy.cicd1.yml" "working-directory: launchpad"
-
-# cicd2 — adds the pre-flight test gate that blocks deploy
-has "$WF/deploy.cicd2.yml" "npm test"
-has "$WF/deploy.cicd2.yml" "needs: test"
-has "$WF/deploy.cicd2.yml" "VITE_CALLSIGN: \${{ github.actor }}"
-
-# cicd3 — board reporting, pinned contract, >=1 pre-liftoff event
-has "$WF/deploy.cicd3.yml" 'Bearer $SHIPIT_TOKEN'
-has "$WF/deploy.cicd3.yml" '${{ vars.BOARD_URL }}' # env: line
-has "$WF/deploy.cicd3.yml" '/api/event' # curl target (BOARD_URL is an env var there)
-has "$WF/deploy.cicd3.yml" '\"stage\":\"pad\",\"status\":\"running\"' # pre-liftoff beat
-has "$WF/deploy.cicd3.yml" '\"stage\":\"liftoff\",\"status\":\"shipped\"'
-has "$WF/deploy.cicd3.yml" '\"stage\":\"test\",\"status\":\"failed\"' # abort report
-has "$WF/deploy.cicd3.yml" "if: failure()"
-has "$WF/deploy.cicd3.yml" "needs: test"
-
-# cicd4 — build board image (GHCR) + SSM deploy to the learner's EC2
-has "$WF/deploy.cicd4.yml" "docker/build-push-action@v6"
-has "$WF/deploy.cicd4.yml" "context: board"
-has "$WF/deploy.cicd4.yml" "ghcr.io/"
-has "$WF/deploy.cicd4.yml" "packages: write"
-has "$WF/deploy.cicd4.yml" "aws-actions/configure-aws-credentials@v4"
-has "$WF/deploy.cicd4.yml" "aws-region: ap-southeast-1"
-has "$WF/deploy.cicd4.yml" "ssm send-command"
-has "$WF/deploy.cicd4.yml" "AWS-RunShellScript"
-has "$WF/deploy.cicd4.yml" "-p 3000:3000"
-has "$WF/deploy.cicd4.yml" "SHIPIT_TOKEN="
-has "$WF/deploy.cicd4.yml" '${{ vars.EC2_INSTANCE_ID }}'
-# cicd4 still reports to the SHARED board (S3 step preserved)
-has "$WF/deploy.cicd4.yml" '\"stage\":\"liftoff\",\"status\":\"shipped\"'
-
-echo "OK: verify-workflows (cicd1..4 present, contract + shape asserted)"
diff --git a/starter/README.learner.md b/starter/README.learner.md
deleted file mode 100644
index 9475eea..0000000
--- a/starter/README.learner.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# shipit-launchpad — your ship
-
-Your personal **ship microsite** for the DevOps bootcamp: one of four low-poly Three.js spaceships
-(Quaternius, CC0) you customize, and the thing your CI/CD pipeline builds, checks, and ships across
-the four sessions. A green pipeline launches your ship into the shared **Mission Control** orbit on
-the projector.
-
-## How this works
-
-You **forked** this repo. Across four sessions you will **author the pipeline yourself** — the file
-`.github/workflows/deploy.yml` does not exist yet; you write it, and it grows one job per session.
-
-1. **Fork** this repo (you've done this).
-2. **Enable Actions** on your fork once: the **Actions** tab → *I understand my workflows, go ahead
- and enable them*.
-3. **Add the upstream remote** once, so you can reach the answer keys:
- `git remote add upstream https://github.com/Infratify/shipit-launchpad && git fetch upstream`.
-4. Each session, edit files and `git push`, then watch the **Actions** tab.
-
-Stuck? The `cicd1`…`cicd4` branches are the answer keys — `git diff upstream/cicd1` to compare, or
-reset to one if you're lost. **Sync fork** to pull instructor fixes.
-
-## Customize it
-
-Edit **`ship.config.json`** — the only file you need to touch:
-
-```json
-{
- "shipName": "Nebula Runner",
- "color": "#22d3ee",
- "shipModel": "fighter",
- "emblem": "comet"
-}
-```
-
-- `shipName` — up to 24 characters.
-- `color` — a hex colour like `#22d3ee` (recolours your ship — sets its hue to `color`).
-- `shipModel` — one of: `fighter`, `interceptor`, `hauler`, `scout`.
-- `emblem` — one of: `comet`, `bolt`, `star`, `ring`, `delta`, `phoenix`.
-
-Your **callsign** is your GitHub username — it's set automatically when the pipeline runs.
-
-## Run it locally
-
-```bash
-npm install
-npm run dev # live preview
-npm test # pre-flight check — fails (ABORT) if ship.config.json is invalid
-npm run build # static site → dist/
-npm run preview # serve the built site on :8080
-```
-
-`npm test` is the pre-flight gate: a bad `ship.config.json` exits non-zero and blocks the launch.
diff --git a/starter/workflows/deploy.cicd1.yml b/starter/workflows/deploy.cicd1.yml
deleted file mode 100644
index 136fba1..0000000
--- a/starter/workflows/deploy.cicd1.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-name: Ship It
-on:
- push:
- branches: [main]
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-# The ship app lives in launchpad/ in the learner fork; run every step there.
-defaults:
- run:
- working-directory: launchpad
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deploy.outputs.page_url }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- cache-dependency-path: launchpad/package-lock.json
- - run: npm ci
- - run: npm run build
- env:
- VITE_CALLSIGN: ${{ github.actor }} # your callsign = your GitHub username
- - uses: actions/upload-pages-artifact@v3
- with:
- path: launchpad/dist
- - id: deploy
- uses: actions/deploy-pages@v4
diff --git a/starter/workflows/deploy.cicd2.yml b/starter/workflows/deploy.cicd2.yml
deleted file mode 100644
index 924ec88..0000000
--- a/starter/workflows/deploy.cicd2.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: Ship It
-on:
- push:
- branches: [main]
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-# The ship app lives in launchpad/ in the learner fork; run every step there.
-defaults:
- run:
- working-directory: launchpad
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- cache-dependency-path: launchpad/package-lock.json
- - run: npm ci
- - run: npm test # node scripts/preflight.mjs — a bad ship.config.json ABORTS here
-
- deploy:
- needs: test # deploy only runs if the pre-flight gate is green
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deploy.outputs.page_url }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- cache-dependency-path: launchpad/package-lock.json
- - run: npm ci
- - run: npm run build
- env:
- VITE_CALLSIGN: ${{ github.actor }}
- - uses: actions/upload-pages-artifact@v3
- with:
- path: launchpad/dist
- - id: deploy
- uses: actions/deploy-pages@v4
diff --git a/starter/workflows/deploy.cicd3.yml b/starter/workflows/deploy.cicd3.yml
deleted file mode 100644
index cea5d3b..0000000
--- a/starter/workflows/deploy.cicd3.yml
+++ /dev/null
@@ -1,83 +0,0 @@
-name: Ship It
-on:
- push:
- branches: [main]
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - id: cfg
- run: |
- echo "color=$(jq -r .color ship.config.json)" >> "$GITHUB_OUTPUT"
- echo "model=$(jq -r .shipModel ship.config.json)" >> "$GITHUB_OUTPUT"
- - name: Report to Mission Control — on the pad
- env:
- BOARD_URL: ${{ vars.BOARD_URL }}
- SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
- run: |
- curl -fsS -X POST "$BOARD_URL/api/event" \
- -H "authorization: Bearer $SHIPIT_TOKEN" \
- -H 'content-type: application/json' \
- -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"pad\",\"status\":\"running\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\"}"
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- - run: npm ci
- - run: npm test
- - name: Report ABORT if the pre-flight failed
- if: failure()
- env:
- BOARD_URL: ${{ vars.BOARD_URL }}
- SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
- run: |
- curl -fsS -X POST "$BOARD_URL/api/event" \
- -H "authorization: Bearer $SHIPIT_TOKEN" \
- -H 'content-type: application/json' \
- -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"test\",\"status\":\"failed\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\"}"
-
- deploy:
- needs: test
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deploy.outputs.page_url }}
- steps:
- - uses: actions/checkout@v4
- - id: cfg
- run: |
- echo "color=$(jq -r .color ship.config.json)" >> "$GITHUB_OUTPUT"
- echo "model=$(jq -r .shipModel ship.config.json)" >> "$GITHUB_OUTPUT"
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- - run: npm ci
- - run: npm run build
- env:
- VITE_CALLSIGN: ${{ github.actor }}
- - uses: actions/upload-pages-artifact@v3
- with:
- path: dist
- - id: deploy
- uses: actions/deploy-pages@v4
- - name: Report to Mission Control — liftoff
- env:
- BOARD_URL: ${{ vars.BOARD_URL }}
- SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
- run: |
- curl -fsS -X POST "$BOARD_URL/api/event" \
- -H "authorization: Bearer $SHIPIT_TOKEN" \
- -H 'content-type: application/json' \
- -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"liftoff\",\"status\":\"shipped\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\",\"version\":\"${{ github.sha }}\",\"siteUrl\":\"${{ steps.deploy.outputs.page_url }}\"}"
diff --git a/starter/workflows/deploy.cicd4.yml b/starter/workflows/deploy.cicd4.yml
deleted file mode 100644
index bb950de..0000000
--- a/starter/workflows/deploy.cicd4.yml
+++ /dev/null
@@ -1,123 +0,0 @@
-name: Ship It
-on:
- push:
- branches: [main]
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - id: cfg
- run: |
- echo "color=$(jq -r .color ship.config.json)" >> "$GITHUB_OUTPUT"
- echo "model=$(jq -r .shipModel ship.config.json)" >> "$GITHUB_OUTPUT"
- - name: Report to Mission Control — on the pad
- env:
- BOARD_URL: ${{ vars.BOARD_URL }}
- SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
- run: |
- curl -fsS -X POST "$BOARD_URL/api/event" \
- -H "authorization: Bearer $SHIPIT_TOKEN" \
- -H 'content-type: application/json' \
- -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"pad\",\"status\":\"running\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\"}"
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- - run: npm ci
- - run: npm test
- - name: Report ABORT if the pre-flight failed
- if: failure()
- env:
- BOARD_URL: ${{ vars.BOARD_URL }}
- SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
- run: |
- curl -fsS -X POST "$BOARD_URL/api/event" \
- -H "authorization: Bearer $SHIPIT_TOKEN" \
- -H 'content-type: application/json' \
- -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"test\",\"status\":\"failed\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\"}"
-
- deploy:
- needs: test
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deploy.outputs.page_url }}
- steps:
- - uses: actions/checkout@v4
- - id: cfg
- run: |
- echo "color=$(jq -r .color ship.config.json)" >> "$GITHUB_OUTPUT"
- echo "model=$(jq -r .shipModel ship.config.json)" >> "$GITHUB_OUTPUT"
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- - run: npm ci
- - run: npm run build
- env:
- VITE_CALLSIGN: ${{ github.actor }}
- - uses: actions/upload-pages-artifact@v3
- with:
- path: dist
- - id: deploy
- uses: actions/deploy-pages@v4
- - name: Report to Mission Control — liftoff
- env:
- BOARD_URL: ${{ vars.BOARD_URL }}
- SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
- run: |
- curl -fsS -X POST "$BOARD_URL/api/event" \
- -H "authorization: Bearer $SHIPIT_TOKEN" \
- -H 'content-type: application/json' \
- -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"liftoff\",\"status\":\"shipped\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\",\"version\":\"${{ github.sha }}\",\"siteUrl\":\"${{ steps.deploy.outputs.page_url }}\"}"
-
- # S4: build the board image and run it on YOUR OWN EC2 (your personal Mission Control)
- ship:
- needs: deploy
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write # push the image to your GHCR
- steps:
- - uses: actions/checkout@v4
- - id: ghcr
- run: echo "owner=${GITHUB_ACTOR,,}" >> "$GITHUB_OUTPUT" # GHCR needs a lowercase owner
- - name: Log in to GHCR
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build and push the board image
- uses: docker/build-push-action@v6
- with:
- context: board
- push: true
- tags: ghcr.io/${{ steps.ghcr.outputs.owner }}/shipit-board:${{ github.sha }}
- - name: Configure AWS credentials
- uses: aws-actions/configure-aws-credentials@v4
- with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- aws-region: ap-southeast-1
- - name: Deploy the board to my EC2 via SSM
- env:
- IMAGE: ghcr.io/${{ steps.ghcr.outputs.owner }}/shipit-board:${{ github.sha }}
- SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
- run: |
- aws ssm send-command \
- --instance-ids "${{ vars.EC2_INSTANCE_ID }}" \
- --document-name "AWS-RunShellScript" \
- --comment "Ship It S4 — deploy $IMAGE" \
- --parameters commands="[\"docker pull $IMAGE\",\"docker rm -f shipit-board || true\",\"docker run -d --name shipit-board --restart unless-stopped -p 3000:3000 -e SHIPIT_TOKEN=$SHIPIT_TOKEN $IMAGE\"]"
From 60232c41c8997a61e8f28693c8355d1049e256bf Mon Sep 17 00:00:00 2001
From: opariffazman
Date: Fri, 17 Jul 2026 17:41:50 +0800
Subject: [PATCH 02/42] =?UTF-8?q?docs:=20reconcile=20contracts=20to=20taug?=
=?UTF-8?q?ht=20reality=20=E2=80=94=20deploy.yaml=20snapshot=20end-of-S3,?=
=?UTF-8?q?=20minimal=20report=20payload,=20no=20VITE=5FCALLSIGN,=20README?=
=?UTF-8?q?=20un-scaffolded?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Fable 5
---
CLAUDE.md | 99 ++++++++++++++++++++++++++++++++++++++++++++-----------
README.md | 28 +++++++---------
2 files changed, 90 insertions(+), 37 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 03bfc23..fa0c6fc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,33 +64,33 @@ already did `docker build` + ECR by hand in the AWS sessions — S4 *automates t
The one integration point. Keep it stable; slides and the reference workflows depend on it.
- **Identity** = the learner's GitHub username (`${{ github.actor }}`), used as `callsign`.
-- **Config** the board needs comes from the learner's `ship.config.json`: `color` (hex **or** a
- named-palette colour; sets the ship's hue — every saturated texel takes that hue; greys/blacks stay
- neutral — and drives the UI accent) and `shipModel` (which of the 4 ships the board renders in
- orbit); `shipName` is a cosmetic label, not identity.
+- **Config:** `ship.config.json` drives the learner's *site*; the *board* only sees what the report
+ step sends. The taught step hardcodes the learner's colour in the JSON body and omits `shipModel`
+ — so taught ships all render the default model, hue-set by `color` (hex or a named-palette colour;
+ board normalizes, greys/blacks stay neutral). `shipName` is cosmetic, never identity.
- **Transport:** a workflow step POSTs one event per stage it reports. The taught form (CI/CD 3
slides) is a **single liftoff report** after the Pages deploy; extra beats (pad, abort-on-failure)
are optional operator flourishes, never required of learners.
+The taught report (CI/CD 3 slide 14, quoted verbatim — the whole wire contract learners meet):
+
```
POST $BOARD_URL/api/event
Authorization: Bearer $SHIPIT_TOKEN
Content-Type: application/json
-{
- "callsign": "octocat", // GitHub username
- "stage": "build", // pad | build | test | clearance | liftoff
- "status": "passed", // running | passed | failed | aborted | shipped
- "color": "#22d3ee", // hex or a colour name (e.g. "red"); board normalizes → hex, sets the ship's hue
- "shipModel":"fighter", // from ship.config.json: fighter · interceptor · hauler · scout
- "version": "v3", // optional; image/site tag (for rollback demo)
- "siteUrl": "https://…" // optional; the live deployed site to link from orbit
-}
+{ "callsign": "octocat", "stage": "liftoff", "status": "shipped", "color": "#22d3ee" }
```
-- **Minimal payload:** only `callsign` + a known `stage`/`status` are required — the board defaults
- `color`/`shipModel` when absent or invalid (`board/src/room.js`). Slides have learners hardcode
- their own colour in the JSON body (no config-extraction plumbing in the workflow).
+- **Board accepts more than the slides teach** (operator flourishes only, never asked of learners):
+ `stage` ∈ `pad | build | test | clearance | liftoff`, `status` ∈ `running | passed | failed |
+ aborted | shipped`, plus optional `shipModel` (`fighter · interceptor · hauler · scout`),
+ `version`, `siteUrl`. Required: just `callsign` + a known `stage`/`status` (see
+ `board/src/room.js`).
+- **The taught `curl` deliberately has NO `-f` flag:** on a 401 the step stays green and the run log
+ prints `{"error":"unauthorized"}` — CI/CD 3 Amali 3 (wrong-token proof) depends on exactly this.
+ Do not "harden" it to `curl -fsS` in slides or reference branches.
+
- `$BOARD_URL` is a **public** repo/environment **variable**.
- `$SHIPIT_TOKEN` is the **secret** taught in CI/CD 3 — a ship with no/late token can't report to
Mission Control (the "unauthorized" lesson). Do NOT accept unauthenticated events in prod mode.
@@ -110,18 +110,77 @@ Frozen — slides quote these verbatim.
(recolours the ship — sets its hue to `color`; every saturated texel takes that hue, greys/blacks
stay neutral — and drives the UI accent) · `shipModel` ∈ `fighter · interceptor · hauler · scout` · `emblem` ∈
`comet · bolt · star · ring · delta · phoenix`. `callsign` is **not** in config — it's the GitHub
- username, injected via `VITE_CALLSIGN` at build.
+ username. On the board it comes from `${{ github.actor }}` in the report step. The site *can*
+ display it via `VITE_CALLSIGN` at build, but **the taught workflow never sets it** — the app
+ falls back to empty (`launchpad/src/main.js`); don't assume the microsite shows a callsign.
- The ship is one of four low-poly spaceships (Quaternius, CC0), hue-set by `color`; the site and
board both render whichever `shipModel` the learner picked.
-- **The S2 fitness gate** is a config **validation** check (not a unit test): `npm test` →
+- **The S2 fitness gate** is a config **validation** check (not a unit test): taught as
+ `npm run test` (in `launchpad/`, via the workflow's `defaults.run.working-directory`) →
`node scripts/preflight.mjs` validates `ship.config.json` and **exits non-zero (ABORT)** on a bad
config (unparseable, bad hex, unknown emblem, over-long name). Teaches the *exit-code gate* (a
DevOps skill), not test authoring (a developer skill).
-- **The slides are the source of truth for the workflow** — learners build `deploy.yml` from the
+- **The slides are the source of truth for the workflow** — learners build `deploy.yaml` from the
building blocks on the slides, nothing else. The authored answer keys (`starter/workflows/`) were
retired 2026-07-17: learners shipped a simpler file than they prescribed, and the extra plumbing
(config extraction via `jq`, pad/abort beats, `env:` indirection) never earned its place. A
session's reference state is *derived* by running its amali on a test fork (see Distribution).
+
+**Taught workflow — end of S3.** Snapshot derived from the delivered decks 2026-07-17, NOT a spec —
+regenerate from the slides if in doubt. Filename is `.github/workflows/deploy.yaml` (set in CI/CD 1:
+`.yaml`, not `.yml`); `permissions` sits at the bottom because that's where CI/CD 1 adds it; S1's
+`workflow_dispatch` was dropped when S3 rewrote `on:`; each learner's `color` is their own value
+(`#22d3ee` = the slide example):
+
+```yaml
+name: deploy
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+defaults:
+ run:
+ working-directory: launchpad
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ - run: npm clean-install
+ - run: npm run test
+
+ deploy:
+ needs: test
+ if: github.event_name == 'push'
+ environment: production
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ - run: npm clean-install
+ - run: npm run build
+ - uses: actions/upload-pages-artifact@v5
+ with:
+ path: launchpad/dist
+ - uses: actions/deploy-pages@v5
+ - name: Lapor ke papan
+ run: >
+ curl -X POST ${{ vars.BOARD_URL }}/api/event
+ -H "Authorization: Bearer ${{ secrets.SHIPIT_TOKEN }}"
+ -H "Content-Type: application/json"
+ -d '{"callsign":"${{ github.actor }}","stage":"liftoff","status":"shipped","color":"#22d3ee"}'
+
+permissions:
+ pages: write
+ id-token: write
+```
+
+No `contents: read` — checkout still works because learner forks are public. The deploy job binds
+`environment: production` (a learner-created environment with Required reviewers), NOT the
+conventional `github-pages` environment. S4 extends this file; it must not restructure it.
- **Per-session commands** (kelas-taip-bersama): fork → author `deploy.yml` step-by-step per session →
`git push` → watch. Full list in the spec §7.
- **Slides drift note:** the bootcamp slides repo (`~/repo/slides-devops-bootcamp`) quotes the two
@@ -151,7 +210,7 @@ Frozen — slides quote these verbatim.
## Bootcamp integration (context; the arc itself lives in the slides repo)
-`~/repo/slides-devops-bootcamp` → `outlines/2026/cicd1..4.md` + `slides/2026/cicd1..4/`. The
+`~/repo/slides-devops-bootcamp` → `outlines/2026/ci-cd1..4.md` + `slides/2026/ci-cd1..4/`. The
`$SHIPIT_TOKEN` is the CI/CD 3 secret; the **S4 deploy has the learner's pipeline build the `board`
image, push it to their GHCR, and deploy it to their own EC2 (from AWS 2) via SSM** — with a
rollback demo (redeploy the previous tag) as stretch. The instructor's shared board runs on
diff --git a/README.md b/README.md
index 06beb9a..8980485 100644
--- a/README.md
+++ b/README.md
@@ -13,29 +13,23 @@ CI/CD-native.
## Status
-🚧 **Scaffold only.** This repo currently holds the brief, not the build.
+**Built and in use.** Learners fork this repo in CI/CD 1 and grow `.github/workflows/deploy.yaml`
+one session at a time, from the building blocks on the slides — the workflow file is deliberately
+NOT shipped here. `CLAUDE.md` holds the design rationale + the pinned contracts, including a
+snapshot of the taught workflow.
-- **`PROMPT.md`** — kickoff brief. Open a fresh Claude session here and start from it.
-- **`CLAUDE.md`** — design rationale + the pinned pipeline↔board contract.
-
-## Layout (target)
+## Layout
```
-board/ Mission Control — ws hub + Three.js projector spectator → ghcr.io/infratify/shipit-board (:3000)
-launchpad/ the learner ship microsite template (build/test/Dockerfile) → ghcr.io/infratify/shipit-launchpad (:8080)
-scripts/ e2e.sh — full loop proof (to be written)
-docs/ specs + plans
+board/ Mission Control — ws hub + Three.js projector spectator → ghcr.io/infratify/shipit-board (:3000)
+launchpad/ the learner ship microsite — static Vite + Three.js, ships to GitHub Pages (no image)
```
## The 4-session arc it serves
| Session | Launch phase | Teaches |
|---|---|---|
-| CI/CD 1 | Pad live on Pages | first workflow · trigger · job/step/runner |
-| CI/CD 2 | Fuelling + systems check | build · test gate (red = ABORT) · matrix · artifact |
-| CI/CD 3 | Launch clearance | secrets · environment · manual approval |
-| CI/CD 4 | LIFTOFF → orbit | image → GHCR → deploy to EC2 · tags · rollback |
-
-## Next step
-
-Read `PROMPT.md`, then brainstorm → spec → plan before writing code.
+| CI/CD 1 | Pad live on Pages | first workflow · trigger · job/step/runner · Pages deploy |
+| CI/CD 2 | Systems check | test gate (red = ABORT) · `needs` · branch protection |
+| CI/CD 3 | First contact + clearance | secret vs variable · report to Mission Control · push/PR trigger split · manual approval |
+| CI/CD 4 | LIFTOFF → orbit | pipeline builds the board image → GHCR → deploy to your EC2 |
From fb7d1838c6d007544bdaa4770238a0292fa1426a Mon Sep 17 00:00:00 2001
From: opariffazman
Date: Fri, 17 Jul 2026 18:15:39 +0800
Subject: [PATCH 03/42] chore: fixed dockerfile
---
board/Dockerfile | 2 ++
1 file changed, 2 insertions(+)
diff --git a/board/Dockerfile b/board/Dockerfile
index d0911fd..1a78b0d 100644
--- a/board/Dockerfile
+++ b/board/Dockerfile
@@ -5,6 +5,8 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY vite.config.js ./
COPY client ./client
+# client/ship-mesh.js imports ../src/ships.js — the client shares it with the server
+COPY src ./src
RUN npm run build # → /app/dist
# --- runtime ---
From 61d4464584a1d79b1981daca376f60ba9c939494 Mon Sep 17 00:00:00 2001
From: opariffazman
Date: Fri, 17 Jul 2026 20:02:29 +0800
Subject: [PATCH 04/42] =?UTF-8?q?feat(launchpad):=20scripts/report.sh=20?=
=?UTF-8?q?=E2=80=94=20prop-owned=20board=20report,=20learner=20workflow?=
=?UTF-8?q?=20calls=20it=20with=20env=20BOARD=5FURL+SHIPIT=5FTOKEN;=20e2e-?=
=?UTF-8?q?tested=20202/401/missing-env?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Fable 5
---
CLAUDE.md | 68 +++++++++++++++++++++++--------------
launchpad/scripts/report.sh | 37 ++++++++++++++++++++
2 files changed, 80 insertions(+), 25 deletions(-)
create mode 100755 launchpad/scripts/report.sh
diff --git a/CLAUDE.md b/CLAUDE.md
index fa0c6fc..bf383a9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,32 +64,47 @@ already did `docker build` + ECR by hand in the AWS sessions — S4 *automates t
The one integration point. Keep it stable; slides and the reference workflows depend on it.
- **Identity** = the learner's GitHub username (`${{ github.actor }}`), used as `callsign`.
-- **Config:** `ship.config.json` drives the learner's *site*; the *board* only sees what the report
- step sends. The taught step hardcodes the learner's colour in the JSON body and omits `shipModel`
- — so taught ships all render the default model, hue-set by `color` (hex or a named-palette colour;
- board normalizes, greys/blacks stay neutral). `shipName` is cosmetic, never identity.
-- **Transport:** a workflow step POSTs one event per stage it reports. The taught form (CI/CD 3
- slides) is a **single liftoff report** after the Pages deploy; extra beats (pad, abort-on-failure)
- are optional operator flourishes, never required of learners.
+- **Config:** the report script reads `color` + `shipModel` from the learner's `ship.config.json`,
+ so the board renders each learner's real colour AND model (hex or a named-palette colour; board
+ normalizes, greys/blacks stay neutral). `shipName` is cosmetic, never identity.
+- **Transport:** `launchpad/scripts/report.sh` (shipped in this repo — learners call it, never write
+ it) POSTs one event per invocation. The learner workflow adds ONE step (CI/CD 3 Amali 2) — a
+ **single liftoff report** after the Pages deploy:
-The taught report (CI/CD 3 slide 14, quoted verbatim — the whole wire contract learners meet):
+```yaml
+- name: Lapor ke papan
+ run: bash scripts/report.sh
+ env:
+ BOARD_URL: ${{ vars.BOARD_URL }}
+ SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
+```
+
+ That `env:` block IS the lesson surface — one `vars` line, one `secrets` line, nothing else. The
+ HTTP mechanics live in the script. Extra beats (`report.sh pad running`, abort-on-failure) are
+ optional operator flourishes, never required of learners.
+
+What the script sends (slides show this as *anatomy* — learners read it, never type it):
```
POST $BOARD_URL/api/event
Authorization: Bearer $SHIPIT_TOKEN
Content-Type: application/json
-{ "callsign": "octocat", "stage": "liftoff", "status": "shipped", "color": "#22d3ee" }
+{ "callsign": "octocat", "stage": "liftoff", "status": "shipped",
+ "color": "#22d3ee", "shipModel": "fighter" }
```
-- **Board accepts more than the slides teach** (operator flourishes only, never asked of learners):
+`callsign` = `GITHUB_ACTOR` (Actions sets it automatically — no templating in the learner file);
+`color`/`shipModel` read from `ship.config.json`; stage/status from the script args (default
+`liftoff shipped`).
+
+- **Board accepts more than the taught report** (operator flourishes only, never asked of learners):
`stage` ∈ `pad | build | test | clearance | liftoff`, `status` ∈ `running | passed | failed |
- aborted | shipped`, plus optional `shipModel` (`fighter · interceptor · hauler · scout`),
- `version`, `siteUrl`. Required: just `callsign` + a known `stage`/`status` (see
- `board/src/room.js`).
-- **The taught `curl` deliberately has NO `-f` flag:** on a 401 the step stays green and the run log
- prints `{"error":"unauthorized"}` — CI/CD 3 Amali 3 (wrong-token proof) depends on exactly this.
- Do not "harden" it to `curl -fsS` in slides or reference branches.
+ aborted | shipped`, plus optional `version`, `siteUrl`. Required: just `callsign` + a known
+ `stage`/`status` — `color`/`shipModel` default when absent or invalid (see `board/src/room.js`).
+- **The script's `curl` deliberately has NO `-f` flag** (pinned in `report.sh`'s header too): on a
+ 401 the step stays green and the run log prints `{"error":"unauthorized"}` — CI/CD 3 Amali 3
+ (wrong-token proof) depends on exactly this. Do not "harden" it to `-fsS`.
- `$BOARD_URL` is a **public** repo/environment **variable**.
- `$SHIPIT_TOKEN` is the **secret** taught in CI/CD 3 — a ship with no/late token can't report to
@@ -123,14 +138,15 @@ Frozen — slides quote these verbatim.
- **The slides are the source of truth for the workflow** — learners build `deploy.yaml` from the
building blocks on the slides, nothing else. The authored answer keys (`starter/workflows/`) were
retired 2026-07-17: learners shipped a simpler file than they prescribed, and the extra plumbing
- (config extraction via `jq`, pad/abort beats, `env:` indirection) never earned its place. A
- session's reference state is *derived* by running its amali on a test fork (see Distribution).
+ (config extraction, pad/abort beats) never earned its place *in the learner's workflow* — it now
+ lives in the prop's `launchpad/scripts/report.sh` instead, behind a two-line `env:` mapping that
+ doubles as the secrets/vars demo surface. A session's reference state is *derived* by running its
+ amali on a test fork (see Distribution).
**Taught workflow — end of S3.** Snapshot derived from the delivered decks 2026-07-17, NOT a spec —
regenerate from the slides if in doubt. Filename is `.github/workflows/deploy.yaml` (set in CI/CD 1:
`.yaml`, not `.yml`); `permissions` sits at the bottom because that's where CI/CD 1 adds it; S1's
-`workflow_dispatch` was dropped when S3 rewrote `on:`; each learner's `color` is their own value
-(`#22d3ee` = the slide example):
+`workflow_dispatch` was dropped when S3 rewrote `on:`:
```yaml
name: deploy
@@ -167,11 +183,10 @@ jobs:
path: launchpad/dist
- uses: actions/deploy-pages@v5
- name: Lapor ke papan
- run: >
- curl -X POST ${{ vars.BOARD_URL }}/api/event
- -H "Authorization: Bearer ${{ secrets.SHIPIT_TOKEN }}"
- -H "Content-Type: application/json"
- -d '{"callsign":"${{ github.actor }}","stage":"liftoff","status":"shipped","color":"#22d3ee"}'
+ run: bash scripts/report.sh
+ env:
+ BOARD_URL: ${{ vars.BOARD_URL }}
+ SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }}
permissions:
pages: write
@@ -197,6 +212,9 @@ conventional `github-pages` environment. S4 extends this file; it must not restr
- **`cicdN` reference branches** (recovery/diff aid, not a spec): before each session the operator
follows that session's amali verbatim on a test fork — proving the slides run green — and pushes
the resulting state as branch `cicdN` on `Infratify/devops-bootcamp-shipit`.
+- **CI/CD 3 operator dep:** push `launchpad/scripts/report.sh` to upstream `main` before class
+ (new file — sync-fork safe); learners fetch it with `gh repo sync` + `git pull` at the start of
+ Amali 2 — the first live use of the fork model's "sync for instructor fixes" promise.
- **Discipline rule (load-bearing):** upstream `main` must never gain `.github/workflows/` or
re-touch `launchpad/ship.config.json`, so learner **sync-fork** stays conflict-free.
diff --git a/launchpad/scripts/report.sh b/launchpad/scripts/report.sh
new file mode 100755
index 0000000..7b3e497
--- /dev/null
+++ b/launchpad/scripts/report.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# report.sh — POST one launch event to the Mission Control board.
+#
+# The learner workflow calls this with just two env values (CI/CD 3 Amali 2):
+# BOARD_URL board address (repository variable -> env)
+# SHIPIT_TOKEN shared token (repository secret -> env)
+# callsign comes from GITHUB_ACTOR (set by Actions automatically); color and
+# shipModel come from ship.config.json. Stage/status are overridable for
+# operator flourishes — report.sh [stage] [status] — default: liftoff shipped.
+#
+# curl has NO -f on purpose: a rejected report (401) must keep the step green
+# and print {"error":"unauthorized"} in the run log — CI/CD 3 Amali 3 depends
+# on exactly that. Do not "harden" this to -fsS.
+set -euo pipefail
+
+: "${BOARD_URL:?BOARD_URL not set — register it as a repository variable}"
+: "${SHIPIT_TOKEN:?SHIPIT_TOKEN not set — register it as a repository secret}"
+: "${GITHUB_ACTOR:?GITHUB_ACTOR not set (GitHub Actions sets this automatically)}"
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+BODY="$(node -e '
+ const fs = require("fs");
+ const cfg = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
+ process.stdout.write(JSON.stringify({
+ callsign: process.env.GITHUB_ACTOR,
+ stage: process.argv[2],
+ status: process.argv[3],
+ color: cfg.color,
+ shipModel: cfg.shipModel,
+ }));
+' "$DIR/../ship.config.json" "${1:-liftoff}" "${2:-shipped}")"
+
+curl -sS -X POST "$BOARD_URL/api/event" \
+ -H "Authorization: Bearer $SHIPIT_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$BODY"
+echo
From c2ca98013fb1341700f8dd87eb568cc4f8190104 Mon Sep 17 00:00:00 2001
From: Ariff Azman
Date: Sat, 18 Jul 2026 18:27:23 +0800
Subject: [PATCH 05/42] =?UTF-8?q?feat:=20typing=20race=20mode=20=E2=80=94?=
=?UTF-8?q?=20teach=20why=20a=20backend=20server=20exists=20(#20)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* docs(spec): typing race mode — board race mode + launchpad READY button
Board gains a multiplayer typing-race mode (Three.js ortho 2D view,
server-authoritative race state, /play cockpit, operator control).
Launchpad stays static, gains a READY button + hostname-derived callsign.
Reuses the S3 report/roster contract; race entry gated on the roster.
Teaches "why a backend server" via the static-vs-server contrast; staged
as cicd3 experience + cicd4 dissect/rebuild. Slides reconciliation tracked
in Infratify/slides-devops-bootcamp#161.
Co-Authored-By: Claude Opus 4.8 (1M context)
* docs(plan): typing race mode implementation plan (7 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(board): authoritative race state machine
* fix(board): keep race total an immutable cap across rounds
* feat(board): session-gated command corpus
* feat(board): cockpit ws + roster-gated join + operator race control
* feat(launchpad): derive callsign from Pages hostname
* feat(launchpad): READY button linking to the board cockpit
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(board): typing cockpit page (/play)
* fix(board): lowercase callsign at ingest + robust cockpit resync
* feat(board): ortho race view + operator view switch + server HUD
* fix(board): share ship disposal cascade; race view frees GPU resources
* fix(board): race-view fallback, session validation, corpus + comment; test readyHref
Co-Authored-By: Claude Opus 4.8 (1M context)
* docs(spec): wire VITE_BOARD_URL at build; note lowercase callsign display
From whole-branch review: the READY button needs VITE_BOARD_URL on the
npm run build step (build-time var, not the S3 report runtime BOARD_URL),
else the race is unreachable — tracked for the slides in #161. Board now
canonicalizes callsigns lowercase at ingest; document the display change.
Co-Authored-By: Claude Opus 4.8 (1M context)
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
board/client/index.html | 1 +
board/client/main.js | 42 +-
board/client/play.css | 10 +
board/client/play.html | 18 +
board/client/play.js | 68 +
board/client/race-fallback.js | 50 +
board/client/race-view.js | 115 ++
board/client/scene.js | 38 +-
board/client/ship-mesh.js | 36 +
board/client/style.css | 15 +
board/client/track.js | 7 +
board/client/track.test.js | 22 +
board/client/typing.js | 8 +
board/client/typing.test.js | 14 +
board/src/app.js | 69 +-
board/src/corpus.js | 46 +
board/src/index.js | 4 +-
board/src/messages.js | 10 +
board/src/race.js | 55 +
board/src/room.js | 4 +-
board/test/corpus.test.js | 34 +
board/test/race.test.js | 72 +
board/test/room.test.js | 4 +
board/test/server.test.js | 54 +
board/vite.config.js | 12 +-
docs/plans/2026-07-18-typing-race-mode.md | 1296 +++++++++++++++++
.../2026-07-18-typing-race-mode-design.md | 275 ++++
launchpad/src/callsign.js | 12 +
launchpad/src/callsign.test.mjs | 16 +
launchpad/src/main.js | 6 +-
launchpad/src/ready.js | 19 +
launchpad/src/ready.test.mjs | 37 +
launchpad/src/style.css | 19 +
33 files changed, 2422 insertions(+), 66 deletions(-)
create mode 100644 board/client/play.css
create mode 100644 board/client/play.html
create mode 100644 board/client/play.js
create mode 100644 board/client/race-fallback.js
create mode 100644 board/client/race-view.js
create mode 100644 board/client/track.js
create mode 100644 board/client/track.test.js
create mode 100644 board/client/typing.js
create mode 100644 board/client/typing.test.js
create mode 100644 board/src/corpus.js
create mode 100644 board/src/race.js
create mode 100644 board/test/corpus.test.js
create mode 100644 board/test/race.test.js
create mode 100644 docs/plans/2026-07-18-typing-race-mode.md
create mode 100644 docs/specs/2026-07-18-typing-race-mode-design.md
create mode 100644 launchpad/src/callsign.js
create mode 100644 launchpad/src/callsign.test.mjs
create mode 100644 launchpad/src/ready.js
create mode 100644 launchpad/src/ready.test.mjs
diff --git a/board/client/index.html b/board/client/index.html
index 8c5a794..c39b2d1 100644
--- a/board/client/index.html
+++ b/board/client/index.html
@@ -17,6 +17,7 @@ Mission Control
+ 0 connected · 20×/sec
+
+ connecting…
+
+
+
+
+
+
diff --git a/board/client/main.js b/board/client/main.js
index a17f19f..7b5c2b8 100644
--- a/board/client/main.js
+++ b/board/client/main.js
@@ -1,19 +1,24 @@
import './style.css';
import { createScene } from './scene.js';
+import { createRaceView } from './race-view.js';
+import { createRaceFallback } from './race-fallback.js';
import { createFallback, detectWebGL, shouldUseFallback } from './fallback.js';
const app = document.getElementById('app');
const count = document.getElementById('count');
const toasts = document.getElementById('toasts');
+const hud = document.getElementById('race-hud');
+const hudClients = document.getElementById('hud-clients');
const gl = detectWebGL();
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
-let lastShips = [];
-let view = makeView(shouldUseFallback({ gl, reducedMotion: mql.matches }));
+let lastShips = []; // roster (orbit)
+let lastRaceShips = []; // race positions
+let mode = 'orbit'; // 'orbit' | 'race'
+let view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches }));
function showLiftoff(callsign) {
if (!toasts) return;
- // Cap the stack so a class-end launch burst can't run toasts off-screen over the scene.
while (toasts.children.length >= 5) toasts.firstChild.remove();
const el = document.createElement('div');
el.className = 'toast';
@@ -23,22 +28,32 @@ function showLiftoff(callsign) {
setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 400); }, 3000);
}
-function makeView(useFallback) {
+function makeOrbit(useFallback) {
const v = useFallback ? createFallback(app) : createScene(app, {
onLiftoff: showLiftoff,
- onPreloadError: () => {
- view.dispose();
- view = createFallback(app);
- view.update(lastShips);
- },
+ onPreloadError: () => { view.dispose(); view = createFallback(app); view.update(lastShips); },
});
v.update(lastShips);
return v;
}
+function makeRace() {
+ const v = shouldUseFallback({ gl, reducedMotion: mql.matches }) ? createRaceFallback(app) : createRaceView(app);
+ v.update(lastRaceShips);
+ return v;
+}
+
+function setMode(next) {
+ if (next === mode) return;
+ view.dispose();
+ mode = next;
+ if (mode === 'race') { view = makeRace(); if (hud) hud.hidden = false; }
+ else { view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches })); if (hud) hud.hidden = true; }
+}
+
mql.addEventListener('change', (e) => {
view.dispose();
- view = makeView(shouldUseFallback({ gl, reducedMotion: e.matches }));
+ view = mode === 'race' ? makeRace() : makeOrbit(shouldUseFallback({ gl, reducedMotion: e.matches }));
});
window.addEventListener('pagehide', () => view.dispose());
@@ -48,8 +63,13 @@ function connect() {
let m; try { m = JSON.parse(e.data); } catch { return; }
if (m.t === 'roster' && Array.isArray(m.ships)) {
lastShips = m.ships;
- view.update(lastShips);
+ if (mode === 'orbit') view.update(lastShips);
if (count) count.textContent = `${lastShips.length} ship${lastShips.length === 1 ? '' : 's'}`;
+ } else if (m.t === 'race') {
+ lastRaceShips = m.ships || [];
+ setMode(m.view === 'race' ? 'race' : 'orbit');
+ if (mode === 'race') view.update(lastRaceShips);
+ if (hudClients) hudClients.textContent = String(m.clients ?? 0);
}
};
ws.onclose = () => setTimeout(connect, 1000);
diff --git a/board/client/play.css b/board/client/play.css
new file mode 100644
index 0000000..57e1137
--- /dev/null
+++ b/board/client/play.css
@@ -0,0 +1,10 @@
+:root { color-scheme: dark; }
+body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospace, Menlo, Consolas, monospace; }
+#cockpit { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; display: flex; flex-direction: column; gap: 1rem; }
+#status { color: #94a3b8; margin: 0; }
+#track { position: relative; height: 10px; background: #1e293b; border-radius: 999px; }
+#me { position: absolute; top: -3px; left: 0; width: 16px; height: 16px; border-radius: 50%; background: #22d3ee; transition: left 0.15s ease; }
+#prompt { font-size: 1.6rem; margin: 0; padding: 0.75rem 1rem; background: #111a2e; border-radius: 8px; min-height: 2rem; }
+#entry { font: inherit; font-size: 1.2rem; padding: 0.75rem 1rem; background: #0f172a; color: #e2e8f0; border: 2px solid #334155; border-radius: 8px; }
+#entry:focus { outline: none; border-color: #22d3ee; }
+#entry:disabled { opacity: 0.5; }
diff --git a/board/client/play.html b/board/client/play.html
new file mode 100644
index 0000000..aeab80f
--- /dev/null
+++ b/board/client/play.html
@@ -0,0 +1,18 @@
+
+
+