Cert Tracker Playbook¶
Read this before touching cert retirement status, cert page content, or the /cert-tracker/ index.
Born 30 Aug 2026, after six cert pages were found telling visitors an exam was "Retiring" when it had already retired 1–2 months earlier.
🔴 The one thing that bites: status lives in TWO independent sources¶
Fixing one is a silent half-fix that looks verified. This is the mistake to avoid.
| Surface | Reads from | Source of truth | Written by |
|---|---|---|---|
/cert-tracker/<slug>/ single page |
Hugo front matter exam_status |
aguidetocloud-revamp/content/cert-tracker/<slug>.md |
Hand-curated |
/cert-tracker/ index |
/data/cert-tracker/latest.json (client-side fetch) |
cert-tracker/scripts/exams.json |
Weekly bot |
/study-guides/, cert-nav |
Hugo front matter | same as single page | Hand-curated |
On 30 Aug the six .md files were fixed and verified live — and the index still said "Retiring", because static/js/cert-tracker.js:15 fetches latest.json, not the front matter.
The index is client-rendered — fetched HTML proves nothing
cert-tracker.js builds the cards in JS. To verify what a visitor actually sees you need a headless browser, not curl. Same trap as the window.__guidedCerts cert listings.
The pipeline¶
cert-tracker repo (susanthgit/cert-tracker)
scripts/exams.json ← hand-maintained CONFIG (the real source)
│ fetch_exams.py → current_state.json (scrapes MS Learn)
│ diff_engine.py
│ generate_data.py → site/latest.json, site/exams/*.json
│ generate_pages.py → /tmp/cert-pages/*.md
▼
.github/workflows/weekly-cert-tracker.yml cron '0 5 * * 0' (Sun 05:00 UTC = 6 PM NZT)
│ commits site/ in cert-tracker
│ clones aguidetocloud-revamp with secrets.PERSONAL_PAT
▼
aguidetocloud-revamp
static/data/cert-tracker/** ← copied UNCONDITIONALLY
content/cert-tracker/*.md ← copied ONLY IF the file lacks `manual: true`
│
▼ git push → Cloudflare Pages auto-build
workflow_dispatch is enabled — the run can be triggered manually.
🛡️ manual: true is what protects hand-edited pages¶
if [ -f "$target" ] && grep -q "manual: true" "$target"; then
echo "Skipping manual page: $basename"
else
cp "$f" "$target" # ← silently reverts your edit
fi
Any hand-edit to content/cert-tracker/*.md without manual: true is reverted on the next Sunday run. Verified in practice 30 Aug 2026: bot commit 143d181e touched only static/data/cert-tracker/** and zero content pages.
Why edits to exams.json are durable¶
fetch_exams.pytreats it asEXAMS_CONFIG— read-only; it writescurrent_state.json.- The workflow only stages
git add site/, soscripts/exams.jsonis never machine-written. sync-cert-data.js:436(if (fs.existsSync(mdPath)) { skipped++; continue; }) never overwrites an existing.md.
🔁 The recurring check — "has any retiring cert already retired?"¶
Root-cause class (Rule #11): a status label that is a point-in-time snapshot, never re-evaluated against its own date. Nothing auto-flips retiring → retired when retirement_date passes, in either source. Stale entries are the inevitable result of time passing, not a data-entry error.
Run on the 1st of each month (catches anything that retired during the previous month):
# 1. Any 'retiring' cert whose date has already passed?
$j = Invoke-RestMethod "https://www.aguidetocloud.com/data/cert-tracker/latest.json?cb=$(Get-Random)"
$j.exams | Where-Object { $_.status -eq 'retiring' -and [datetime]$_.retirement_date -lt (Get-Date) } |
ForEach-Object { "STALE: {0} retired {1}" -f $_.code, $_.retirement_date }
# 2. Front matter must agree (the other source)
Select-String -Path C:\ssClawy\aguidetocloud-revamp\content\cert-tracker\*.md `
-Pattern '^exam_status:\s*"retiring"' | ForEach-Object { $_.Filename }
If anything is stale, fix both sources:
cert-tracker/scripts/exams.json→"status": "retired"for each past-dated entry. Pure CRLF file — read/write withnewline=""and verify the diff is only"status"lines.aguidetocloud-revamp/content/cert-tracker/<slug>.md→exam_status: "retired". Confirm each file hasmanual: true, or the bot will revert it.- Trigger the pipeline instead of waiting for Sunday:
gh workflow run weekly-cert-tracker.yml --repo susanthgit/cert-tracker - Verify live (Rule #14 — the index is client-rendered, so use a browser):
expect
Retired → <successor>, no countdown, and the Retiring stat box to drop.
Safety proof, so nobody hesitates: generate_data.py sorts by status_order but never filters, and MS-900 / MB-910 / MB-920 were already retired end-to-end. Flipping a cert to retired cannot make it vanish from the index.
📌 Pending actions¶
| Due | Action | Notes |
|---|---|---|
| 2026-10-01 | AZ-500, AZ-800, AZ-801 retire 2026-09-30 — run the check above; all three will need flipping in both sources. | The only retiring entries left after the 30 Aug cleanup. This is the next guaranteed recurrence. |
| Open — needs Sush | Build the auto-transition so this stops recurring. Options: date-transition in generate_data.py, or date-aware derivation in static/js/cert-tracker.js. |
Modifies unattended automation on a public surface ⇒ Rule #16 Gate A required first. Deliberately not built. Trigger: "fix the cert status auto-transition". |
| Open — needs Sush | cert-tracker's git remote has an expired embedded PAT (push fails Invalid username or token, fetch works). |
Same class as the deferred "remove PAT from URLs" item. Working push today: git -c credential.helper="!gh auth git-credential" push "https://github.com/susanthgit/cert-tracker.git" HEAD:main |
| Low priority | 4 of the 6 certs fixed on 30 Aug (ai-102, mb-240, pl-500, pl-600) have no aliases: front matter. |
Trips the standup's redirect/SEO rule. A redirect call, not a bug. |
| Open — found 14 Sep | FAQs render TWICE on every cert page that has them. single.html L182 calls the FAQ partial, then L230 renders a second legacy block. Confirmed live on ai-901, az-104, sc-900 (and 0× on ai-102, which has no FAQs). |
Template-wide and pre-existing — deliberately NOT bundled into the AI-901 fix. Affects every cert page, so it deserves its own change + verification. |
| Open — found 14 Sep | Three-way status conflict on AI-103 / AI-200 / AI-300. data/all_certs.toml and ai-200.md front matter say active; the cert-tracker pipeline and latest.json say beta. |
Unresolved — a Gate B reviewer flagged the TOML as proof my labels were wrong, but the freshly regenerated pipeline data disagrees. I kept labels matching the pipeline (authoritative for the tracker). Needs its own investigation; do not assume either source is right. |
Gotchas¶
- Both local clones are routinely stale — on 30 Aug
aguidetocloud-revampwas 4 commits behind andcert-tracker21 behind, while the target files were byte-identical. Use a throwaway worktree offorigin/main(git worktree add --detach), never a stale localHEAD, and tear down withgit worktree remove --force. - Do not hand-edit
static/data/cert-tracker/latest.json— it is regenerated wholesale each run. Fixexams.json. (One narrow exception, below: landing a regenerated copy after fixing the generator.) - The single-page template (
layouts/cert-tracker/single.htmlL49-68) emits the#cert-countdowndiv only on theretiringbranch;retiredrenders "This exam was retired on …" with no countdown. - 🔴 SOLVED 14 Sep 2026 — the "55 configured, 52 shipped" dropout was a silent data-loss bug, and it made the obvious fix dangerous.
fetch_exams.pyL26STUDY_GUIDE_URLwas missing/credentials/. Microsoft kept redirects for study-guide URLs that predate the move, so the legacy path returned 200 for older exams and 404 for newer ones — no error, just absence. The three dropouts were DP-600, DP-700, MB-820: allactive, all 404. Fixed;latest.jsonnow ships 55.- The asymmetry that turns this into a trap:
fetch_exams.pyhandles statuses differently. Beta/upcoming (L246-288)results.append(record)unconditionally. Active (L290-315) fetches the study guide and on failure doeserrors.append(code)+continue— onlyretiredgets a stub fallback (L294). ⇒ Flipping any exambeta→activewhile its study-guide URL 404s silently DELETES it from the index. Fixing the reported "AI-901 shows Beta" bug without the URL fix would have made AI-901 vanish entirely — worse than the bug being reported. - Before flipping any status to
active, probe that exam's study-guide URL for a 200. All 55 codes were probed against the corrected URL on 14 Sep → 55/55 = 200.
- The asymmetry that turns this into a trap:
- 🔴 The status banner is rendered by the TEMPLATE — never hand-write one in the
.mdbody.single.htmlL48-69 emits a banner fromexam_statususingzt-cert-status--*classes, with branches for retiring/retired/beta/upcoming and no branch foractive(by design — active pages show nothing). Several.mdbodies also carried hand-written banners using different classes (cert-status-banner cert-status-beta), so those pages rendered two banners — which is why "80% discount" appeared twice onai-901. Correct fix is to delete the hand-written div, not to invent a.cert-status-activeCSS class (none exists;cert-tracker.csshas only-retiringL1306,-retiredL1311,-betaL1316). Reference pattern:az-104,sc-900,dp-600carry zero banner divs. - 🔴 Greps over Hugo build output CANNOT verify the cert-tracker index — it is rendered client-side. The single cert page comes from Hugo front matter (server-rendered, greppable); the index and roadmap are drawn by JS from
static/data/cert-tracker/latest.jsonat runtime. On 14 Sep every server-side check passed while the live index still showed a Beta badge, becauselatest.jsonwas stale — the checks structurally could not see it. Verify the index with a headless browser, andcurlthe deployedlatest.jsonseparately. (Playwright is installed locally inaguidetocloud-revamp/node_modules, so the script must live inside that repo to resolve the import.) latest.jsonis the one exception to "don't hand-edit generated files" — but only after fixing the upstream generator. Landing a regenerated copy is then idempotent: the weekly bot reproduces the same bytes. Hand-editing it without fixingexams.json+fetch_exams.pygets silently reverted on the next run.- Retired exams still render "Typical prep: X weeks" (
single.htmlL360 excludes onlybeta/upcoming). Pre-existing, cosmetic. - The
greptool failed repeatedly with "Search paths do not exist" on paths that demonstrably exist in these repos;Select-Stringworked every time.
Related¶
- Deployment Playbook — pre-push discipline
- Incident Log — guardrail origin stories
- Parallel Git Rules — worktree pattern, never
git stash