edwardca.dev
edwardca.dev
Overview

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.json records what has already been posted per content_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/:

FileRole
content.pyShared primitives: reads & validates _posts/*.md front matter, derives canonical_url, banner_image, loads/saves _data/distribution.json.
build_content.pyWrites 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.pyFetches 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.pyBuilds the bilingual LinkedIn post payload and calls POST /rest/posts on the LinkedIn API.
publish_content.pyOrchestrator: 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 (optional content_id + dry_run inputs), and pushes to main touching 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 identity edwardca-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.json if 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:

  1. 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.
  2. Commit to main. The push trigger fires publish-content.yml automatically.
  3. 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×630 banner.png (article hero + OG/Twitter card image + index-page thumbnail) and a 1080×1080 square.png (auxiliary share image), under apps/web/public/articles/<slug>/. Re-run with --force after a title changes; content.py wires 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 ![alt](/articles/<slug>/diagram.png). ArticleBody renders 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 with content_id and dry_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.py as 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 .env or paste raw tokens into a commit message; secrets only via gh 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.