Skip to content

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.py treats it as EXAMS_CONFIGread-only; it writes current_state.json.
  • The workflow only stages git add site/, so scripts/exams.json is 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 retiringretired 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:

  1. cert-tracker/scripts/exams.json"status": "retired" for each past-dated entry. Pure CRLF file — read/write with newline="" and verify the diff is only "status" lines.
  2. aguidetocloud-revamp/content/cert-tracker/<slug>.mdexam_status: "retired". Confirm each file has manual: true, or the bot will revert it.
  3. Trigger the pipeline instead of waiting for Sunday: gh workflow run weekly-cert-tracker.yml --repo susanthgit/cert-tracker
  4. 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 surfaceRule #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-revamp was 4 commits behind and cert-tracker 21 behind, while the target files were byte-identical. Use a throwaway worktree off origin/main (git worktree add --detach), never a stale local HEAD, and tear down with git worktree remove --force.
  • Do not hand-edit static/data/cert-tracker/latest.json — it is regenerated wholesale each run. Fix exams.json. (One narrow exception, below: landing a regenerated copy after fixing the generator.)
  • The single-page template (layouts/cert-tracker/single.html L49-68) emits the #cert-countdown div only on the retiring branch; retired renders "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.py L26 STUDY_GUIDE_URL was 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: all active, all 404. Fixed; latest.json now ships 55.
    • The asymmetry that turns this into a trap: fetch_exams.py handles statuses differently. Beta/upcoming (L246-288) results.append(record) unconditionally. Active (L290-315) fetches the study guide and on failure does errors.append(code) + continue — only retired gets a stub fallback (L294). ⇒ Flipping any exam betaactive while 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 status banner is rendered by the TEMPLATE — never hand-write one in the .md body. single.html L48-69 emits a banner from exam_status using zt-cert-status--* classes, with branches for retiring/retired/beta/upcoming and no branch for active (by design — active pages show nothing). Several .md bodies 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 on ai-901. Correct fix is to delete the hand-written div, not to invent a .cert-status-active CSS class (none exists; cert-tracker.css has only -retiring L1306, -retired L1311, -beta L1316). Reference pattern: az-104, sc-900, dp-600 carry 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.json at runtime. On 14 Sep every server-side check passed while the live index still showed a Beta badge, because latest.json was stale — the checks structurally could not see it. Verify the index with a headless browser, and curl the deployed latest.json separately. (Playwright is installed locally in aguidetocloud-revamp/node_modules, so the script must live inside that repo to resolve the import.)
  • latest.json is 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 fixing exams.json + fetch_exams.py gets silently reverted on the next run.
  • Retired exams still render "Typical prep: X weeks" (single.html L360 excludes only beta/upcoming). Pre-existing, cosmetic.
  • The grep tool failed repeatedly with "Search paths do not exist" on paths that demonstrably exist in these repos; Select-String worked every time.