Internal · Ops
Infrastructure & Publishing Docs
How edwardca.dev is actually built, verified and distributed — kept next to the code it describes so it never drifts from what the pipeline really does.
System overview
edwardca.dev is a static Next.js 14 site (apps/web, App Router, next-intl) deployed on Vercel, backed by a content pipeline that lives in the same repo as plain files — no headless CMS, no database. Every article is a Markdown file with YAML front matter in _posts/. A Python pipeline (scripts/) validates it, decides when it becomes public, builds a JSON manifest the Next.js build consumes, verifies the deployed page is actually live, and then distributes a LinkedIn post — all triggered by one GitHub Actions workflow.
Four properties this was designed around:
- Git is the source of truth. The article, its status, and its publish schedule all live in one committed Markdown file.
- Verification before distribution. LinkedIn is only posted to after the canonical URL is fetched and confirmed to contain the article’s title, content_id and canonical link tag — a deploy alone is not enough.
- Idempotent state.
_data/distribution.jsonrecords what has already been posted percontent_id, so re-runs never double-post. - No paid tooling. LinkedIn distribution uses the free official Posts API directly — Metricool and similar schedulers were removed and should not be reintroduced.
Publishing pipeline
The pipeline is five small, single-purpose Python modules under scripts/:
| File | Role |
|---|---|
| content.py | Shared primitives: reads & validates _posts/*.md front matter, derives canonical_url, banner_image, loads/saves _data/distribution.json. |
| build_content.py | Writes apps/web/content/articles.json (only status=published, or status=scheduled whose publish_at has passed) and stamps each article with a git fingerprint. |
| verify_article.py | Fetches the live canonical URL and asserts the title, content_id and canonical <link> tag are present — proof the deploy actually shipped this content. |
| publish_linkedin.py | Builds the bilingual LinkedIn post payload and calls POST /rest/posts on the LinkedIn API. |
| publish_content.py | Orchestrator: for every due article, verify → publish to LinkedIn (once) → persist state. Idempotent and safe to re-run. |
All of it runs from one workflow, .github/workflows/publish-content.yml:
- Triggers: a 5-minute cron (
*/5 * * * *),workflow_dispatch(optionalcontent_id+dry_runinputs), and pushes tomaintouching content, app or script paths. - concurrency.group: publish-content with
cancel-in-progress: false— runs queue rather than race. - Steps: checkout (full history) →
build_content.py→ commit the manifest if it changed ([skip ci], bot identityedwardca-content-bot) → pnpm install/build → sleep 120s to let Vercel start its own deploy from the new commit →publish_content.py(verify + distribute) → rotate the LinkedIn refresh token secret if one was issued → commit_data/distribution.jsonif it changed.
The 120-second sleep exists because Vercel deploys on its own schedule (vercel.json uses ignoreCommand: npx turbo-ignore @edwardca/web, deciding purely from the file diff). The workflow commits the manifest, waits for that deploy to land, then verifies the live URL — it does not deploy the site itself.
LinkedIn distribution
Distribution targets the personal profile via the official free LinkedIn Posts API (POST https://api.linkedin.com/rest/posts), not a scheduler product. Each article’s front matter carries a linkedin: block with its own publish_at, bilingual teaser (teaser_en / teaser_es) and hashtags; the post text is teaser + — divider + Spanish teaser + canonical link + hashtags.
Auth. This LinkedIn app tier issues a 60-day access token and no refresh token. The workflow reads LINKEDIN_ACCESS_TOKEN directly if set; if LinkedIn ever starts returning a refresh token instead, publish_linkedin.py already supports the client-id/secret/refresh-token flow and self-rotates the secret via REPO_ADMIN_TOKEN.
Re-auth. Run scripts/linkedin_auth.py locally roughly every 60 days (current token minted 2026-09-28 → next due ~2026-11-27), then update the LINKEDIN_ACCESS_TOKEN repo secret with gh secret set.
Required secrets: LINKEDIN_ACCESS_TOKEN (or the CLIENT_ID/CLIENT_SECRET/REFRESH_TOKEN trio), plus optional LINKEDIN_AUTHOR_URN to skip a /v2/userinfo lookup, and REPO_ADMIN_TOKEN (fine-grained PAT, Secrets: write) only if a refresh token ever needs rotating.
Editorial & brand style
Articles belong to the Systems Design Meditation series — numbered editions, one pillar (systems-technical today), a short set of topics used both as on-page tags and LinkedIn hashtags. Every article is bilingual in a single file: the English body, then an ## Español section with a full Spanish rendering — not a translation stub.
Voice: systems-engineering register, not marketing copy. Short declarative sentences, concrete failure scenarios over abstractions (“a chatbot invents a refund policy” vs. “an agent executes the refund”), and each piece ends with sourced references under ## Sources.
Visual brand: a single blue gradient (#2196F3 → #0D47D9) and the rounded-square “ec” mark, used consistently across the site logo, favicon and every generated article banner — see Images below.
Editing articles & fingerprints
There is intentionally no in-browser CMS: the repo is the editor. To change a published or scheduled article:
- Edit the file in
_posts/<slug>.md(front matter and/or body) directly, or use the “edit this article on GitHub” link at the bottom of any article page — it opens GitHub’s web editor on that exact file. - Commit to
main. The push trigger firespublish-content.ymlautomatically. - The pipeline rebuilds
articles.json, Vercel redeploys, and the article page re-verifies.
Every build stamps each article with a fingerprint pulled from git log on its source file: the short commit SHA, author, and timestamp of its last edit (build_content.py:git_fingerprint). That renders on the article page footer as “Last edited <date> by <author> · commit <sha>”, linking to that commit’s history on GitHub — so anyone reading the piece can see exactly what changed, when, by whom, and inspect the diff. This is provenance, not permissions: anyone with repo write access can edit; the fingerprint is what makes that visible after the fact.
Banners, auxiliary images & diagrams
Two local (not CI-run) Python scripts generate on-brand imagery with Pillow, so no article ships without one:
scripts/generate_article_images.py— for every article in_posts/, renders a 1200×630banner.png(article hero + OG/Twitter card image + index-page thumbnail) and a 1080×1080square.png(auxiliary share image), underapps/web/public/articles/<slug>/. Re-run with--forceafter a title changes;content.pywires the default paths into every article’s data automatically.scripts/generate_diagram.py— a template for one-off explanatory diagrams (boxes, arrows, labelled trust boundaries) embedded directly in an article body via.ArticleBodyrenders that Markdown image syntax as a captioned<figure>.
For a new diagram, copy generate_diagram.py, adjust the boxes/arrows for the article’s argument, save the PNG under that article’s public/articles/<slug>/ folder, and reference it from the Markdown body (both the English and Spanish sections, since the parser treats them as one body).
python scripts/generate_article_images.py # backfill missing banners/squares
python scripts/generate_article_images.py --force # regenerate all (e.g. after a rebrand)Runbook & troubleshooting
- Cron hasn’t visibly fired in testing — GitHub Actions cron can lag by several minutes under load;
workflow_dispatch(optionally withcontent_idanddry_run: true) is the reliable way to force a run on demand:gh workflow run publish-content.yml -f content_id=sdm-2026-001 -f dry_run=true gh run list --workflow=publish-content.yml --limit 5 gh run watch <run-id> --exit-status - Article verification hits an HTTP 500 — usually Vercel mid-deploy at the moment of the verify fetch; it doesn’t block LinkedIn distribution once the article is truly live, and the next run re-verifies. The 120s pre-verify sleep exists to minimize this; a durable fix would be running
build_content.pyas part of the Vercel build itself instead of racing two separate deploys. - LinkedIn token expiring — see LinkedIn distribution above; track the ~60-day re-auth date.
- Never commit
.envor paste raw tokens into a commit message; secrets only viagh secret set.
This page is generated from and should stay in sync with scripts/ and .github/workflows/publish-content.yml. See also the Systems Design Meditations index.