Personal website and blog for Adam Daniel — Freelance AI Engineer.
Built with Jekyll + Decap CMS, deployed to S3 + CloudFront with an AWS Lambda OAuth proxy.
adamdaniel.ai
├── Jekyll site (S3 + CloudFront, custom domain)
├── Decap CMS (/admin/ — headless CMS backed by this repo)
├── AWS OAuth Proxy (Lambda + API Gateway HTTP API — ~$0/month)
├── Analytics (CloudWatch RUM — see ANALYTICS_SETUP.md)
└── GitHub Actions (production deploy + PR preview environments)
Before GitHub Actions can deploy previews or infrastructure, bootstrap the AWS account with an OIDC identity provider, IAM role, and artifacts bucket.
Prerequisites: AWS CLI v2 with credentials configured, Route53 hosted zone
for adamdaniel.ai.
# Deploy the bootstrap CloudFormation stack
bash infrastructure/bootstrap/deploy.sh
# If a GitHub OIDC provider already exists in this account:
CREATE_OIDC_PROVIDER=false bash infrastructure/bootstrap/deploy.shThen add the stack outputs as GitHub Actions secrets (repo → Settings → Secrets → Actions):
AWS_ROLE_ARN— the IAM role ARN for OIDC authPREVIEW_CLOUDFRONT_ID— the CloudFront distribution ID for preview cache invalidation
After verifying OIDC works, remove the old AWS_ACCESS_KEY_ID and
AWS_SECRET_ACCESS_KEY secrets and deactivate the IAM user keys.
The bootstrap CloudFormation template is no longer vendored here — it lives in
cms-platform (infrastructure/bootstrap/template.yaml) as the single source
of truth, and infrastructure/bootstrap/deploy.sh checks the platform out at the
pinned platform_ref (from platform.lock) into .cms-platform/ and deploys
from there. See that template for full details on what is provisioned.
| Collection | Type | Key Fields |
|---|---|---|
| Posts | Entry (folder: _posts/) |
title, body, date, tags, excerpt, featured_image, published, publish_date |
| Tags | Entry (folder: _tags/) |
name, description |
| Projects | Entry (folder: _projects/) |
title, description, images, url_link, technology, featured |
| Pages | Entry (folder: pages/) |
title, body, permalink, published |
reading_time is computed at build time from word count (÷200 wpm + 1) — there's no editor-facing field for it.
Atom feeds. The site exposes a global feed at /feed.xml (via jekyll-feed) plus per-tag feeds at /tags/<slug>/feed.xml (generated by the gem-delivered tag_feeds Jekyll plugin from cms-platform). The two share a single template — the gem-delivered _layouts/atom_feed.xml (cms-platform theme, not vendored in this repo) — so readers parse both identically. The RSS icon partial (_includes/feed-link.html, also gem-delivered) is mounted on the site-wide layout and on each tag page.
Editor's guide: see docs/CONTENT_GUIDE.md for a walkthrough of the CMS UI (sign-in, all four collections, image uploads, scheduling, previews, and the Save → PR → review → publish pipeline).
Decap CMS is configured at /admin/config.yml. To activate:
-
Create a GitHub OAuth App at https://github.com/settings/developers
- Homepage URL:
https://adamdaniel.ai - Callback URL: (set after deploying the OAuth proxy — see below)
- Homepage URL:
-
Deploy the OAuth proxy.
oauth-proxy/deploy.shis a thin wrapper that checks cms-platform out atplatform_refand delegates to the platform's OAuth-proxy deploy (the Lambda + template are owned upstream, not vendored here).export GITHUB_CLIENT_ID=your_id export GITHUB_CLIENT_SECRET=your_secret bash oauth-proxy/deploy.sh
-
Set
cms.oauth_base_urlin_config.ymlto the deployed OAuth-proxy API URL (andcms.repositoryto this repo, if forking):cms: repository: Adam-S-Daniel/adamdaniel.ai oauth_base_url: https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com
The platform's render hook generates
admin/config.yml/admin/config-local.ymlfrom this block at build time — never hand-edit those files directly (they're gitignored generated artifacts). -
Editorial workflow is on by default (
publish_mode: editorial_workflowinadmin/config.yml). Every Save in the CMS opens a PR on its own branch instead of committing straight to main, lighting up thecms/draft→cms/readylabels, the per-PRpreview-pr{N}.adamdaniel.aienvironment, and the visual-regression review at/admin/reviews/.
Config gotcha: every folder collection in
admin/config*.ymlships with explicitcreate: trueANDdelete: true. Decap defaults both totrue, but spelling them out keeps editor capabilities visible in the YAML and survives any future major-version default change.files:collections (a fixed list of named entries) don't expose create or delete in the Decap UI — convert to a folder collection if editors need to add or remove entries.Why Decap, not Sveltia: an earlier iteration of this repo used Sveltia CMS for its UX improvements, but Sveltia ≤ 0.158 silently ignores
publish_mode: editorial_workflow(the upstream feature is on the 1.0 roadmap, not implemented yet). With branch protection onmain, that meant every Save returned "Repository rule violations found / Changes must be made through a pull request." Decap implements the editorial workflow, so each Save creates acms/...branch and opens a PR.
Implements the GitHub OAuth handshake required by Decap/Netlify CMS (AWS Lambda
- API Gateway HTTP API; no database, VPC, or NAT — pure function, well within
free tier). The Lambda + CloudFormation template are owned upstream by
cms-platform and deployed via the delegating wrapper
oauth-proxy/deploy.sh(bash oauth-proxy/deploy.sh), so a platform fix flows to this site on the nextplatform_refbump instead of being forked. The proxy unit tests live upstream in cms-platform.
Real-user monitoring (Core Web Vitals, JS errors, page-load timings) runs via Amazon CloudWatch RUM. Provisioned as a sibling CloudFormation stack adamdaniel-ai-rum so it lives or dies independently of the deploy pipeline:
bash infrastructure/rum/deploy.shThen paste the printed AppMonitorId and IdentityPoolId into _config.yml under analytics.cloudwatch_rum. The snippet in _includes/analytics/cloudwatch-rum.html is a no-op unless both JEKYLL_ENV=production AND a non-empty app_monitor_id are set, so local jekyll serve and PR previews stay silent.
Cost: roughly $0.10/month at this site's traffic. Full setup, sampling, retention, and exit-data instructions: ANALYTICS_SETUP.md.
| Workflow | Trigger | What it does |
|---|---|---|
deploy-production.yml |
Push to main |
Builds Jekyll, syncs to S3, invalidates CloudFront |
deploy-preview.yml |
PR open/update | Builds Jekyll, syncs to preview S3 prefix, posts URL comment |
cms-editorial-workflow.yml |
PR from CMS | Validates front matter, applies cms/draft label; auto-merges on cms/ready |
visual-regression.yml |
PR open/update | Screenshots every page on the PR vs prod, computes the per-page pixel diff (screenshots/regression/diffs.json), generates a 1920×1080 side-by-side video with a prominent VISUALLY DIFFERENT / IDENTICAL / NEW indicator per page, posts both stats to the PR, and gates merge on a human review at /admin/reviews/. The Waiting state on the approve-regression job is GitHub Environment gating — the job declares environment: regression-review and the environment has required reviewers configured in repo Settings → Environments. Time spent waiting for that approval does not count toward Actions minutes — no runner is allocated while the job is queued for review. |
e2e-tests.yml |
PR + push to main | Runs Playwright inside mcr.microsoft.com/playwright:v<version>-noble (browsers + apt deps prebaked, image tag drift-guarded against package-lock.json). On PRs, e2e/select-specs.js picks the spec subset and a dynamic shard count (1, 2, or 4) so small subsets don't pay for full fanout. The finalize job assembles per-test screenshot videos into the per-test-videos artifact. Concurrency cancels superseded PR runs. |
secrets-scan.yml |
PR + push to main + weekly cron | Runs gitleaks on the PR diff (or full history on push/cron). Allowlist for known test fixtures lives in .gitleaks.toml. |
dependabot-auto-merge.yml |
PR opened/updated by dependabot[bot] |
Verifies the diff only touches dependency-manifest paths, then enables GitHub native auto-merge (--squash). Branch protection holds the merge until e2e + visual-regression checks pass. Configured by .github/dependabot.yml. |
dependabot-comment-sync.yml |
PR opened/updated by dependabot[bot] touching workflows |
Pushes a follow-up commit refreshing every drifted # vX.Y.Z (YYYY-MM-DD) action-pin comment to match the new SHA's actual tag and tag-commit date. Requires the CMS_PLATFORM_PAT repo secret (fine-grained PAT with workflows: write); self-skips with a notice when unset. |
Most workflows declare path filters on their triggers so diffs which can't possibly affect a given workflow's output never allocate a runner. See AGENTS.md § Salient paths per workflow for the full per-workflow table; key entries:
| Workflow | Filter | Effect |
|---|---|---|
deploy-production.yml |
paths-ignore (push to main) |
Skips the prod deploy when a push only touches docs, test/CI tooling, non-Jekyll infra, or unrelated workflow files. The workflow's own file is salient. workflow_dispatch ignores paths-ignore. |
visual-regression.yml |
none — fires on every PR | The caller has no paths:/paths-ignore: filter at all; content-only-skip is decided INSIDE the platform's reusable workflow (e2e/visual-regression-salient.js), not by a caller-level path filter — a required-check gate can't be workflow-level path-filtered without recreating the missing-check trap. CMS-managed content is intentionally treated as non-salient by that internal check (_posts/**, _tags/**, _projects/**, pages/**, _e2e/**, assets/images/uploads/**) — content-only PRs (the kind every CMS Save produces) guarantee pixel diffs by design, so the regression video adds runner time without signal. Mixed PRs that also touch a template path still run the heavy build. |
e2e-tests.yml |
paths-ignore (PR + push to main) |
Coarse-skip for docs-only / unrelated-CI-only diffs. e2e/select-specs.js still does the fine-grained "which specs to run" cut at runtime for diffs that do affect anything testable. |
cms-publish-loop-host.yml, cms-publish-loop-prod.yml |
paths (positive, push to main only) |
Workflow-level path filter on their push trigger — neither has a pull_request trigger at all (they run post-merge, not per-PR); these are not in the required-status-checks list, so workflow-level filtering is safe (no missing-check trap). The schedule/dispatch triggers always fire regardless of paths. |
dependabot-comment-sync.yml |
paths (positive) on its pull_request_target arm only (.github/workflows/**) |
Its pull_request_target arm is filtered to .github/workflows/**; its separate push arm (added to suppress GitHub Actions' phantom zero-job push runs) has NO paths filter at all — it fires on every push to every branch and no-ops immediately in the reusable's first step for any non-Dependabot event. (skills-sync.yml runs on schedule/dispatch only and is a no-op here — no local skills mirror.) |
deploy-preview.yml DOES use a paths-ignore list, like the others (7 entries — see the AGENTS.md "Salient paths per workflow" table) — it is not literally unfiltered. The list is deliberately short/narrow, though, because its closed-action teardown step needs to run regardless of what changed in the PR; a longer ignore list would also skip teardown on a docs-only PR close, leaking the per-PR S3 prefix. The build cost is moderate (~2-3 min) and the safety win of always tearing down outweighs the savings of a broader ignore list.
secrets-scan.yml, cms-editorial-workflow.yml, dependabot-auto-merge.yml, and publish-scheduled-posts.yml are all either security-critical, label-driven (cheap), or cron-driven, so they have no path filter.
Why the Deployments page is mostly red ❌ (by design)
The regression-review entries on GitHub's Deployments page show up as red, but none of them are deploy failures. regression-review is not a deploy target — it's a human-approval gate. When visual-regression.yml detects visually-different pages, its approve-regression job declares environment: regression-review to pause for a reviewer (configured in repo Settings → Environments). GitHub models that pause as a "deployment" stuck in waiting. The next push to the same PR supersedes the pending approval and GitHub flips its status from waiting → error, which the Deployments page renders as red. Every iteration on a PR with visual diffs therefore leaves a trail of red entries — all expected.
The github-pages entries are dormant history from before the 2026-03-15 cutover to S3 + CloudFront in deploy-production.yml. One genuinely errored on first attempt; the rest succeeded and were auto-deactivated when superseded.
Real deploy outcomes live in the Actions tab, not the Deployments page.
PR previews are deployed to S3 and served via CloudFront with HTTPS:
- URL pattern:
https://preview-pr{N}.adamdaniel.ai/(paths identical to prod) - Bucket:
adamdaniel-ai-previews(one prefix per PR:/pr-{N}/) - CDN: single CloudFront distribution with wildcard ACM cert
- Host-to-prefix mapping: a viewer-request CloudFront Function prepends
/pr-{N}on every request; a viewer-response Function strips the same prefix fromLocationheaders so S3's trailing-slash redirects (/admin→/admin/) don't leak the internal key space - Teardown: auto-deleted when the PR is closed/merged
Required secrets:
AWS_ROLE_ARN(see AWS Bootstrap)PREVIEW_CLOUDFRONT_ID— CloudFront distribution ID (output from bootstrap stack)
# One-shot setup: installs everything needed to run the full test stack
# locally on Debian/Ubuntu/WSL2 — apt packages (libnspr4, libnss3, ffmpeg,
# ruby-full, python3), Bundler + Gemfile gems, npm deps, and Playwright
# browser binaries. Idempotent.
bash scripts/setup-test-environment.sh
# Build and serve
bundle exec jekyll serve --livereload
# Site: http://localhost:4000
# CMS admin: http://localhost:4000/admin/index-local.html (uses config-local.yml)
# Run e2e tests (full matrix)
npx playwright test
# Run only the specs that the current diff can affect
node e2e/select-specs.js | jq -r '.files[]?' | xargs npx playwright test
# Decap admin smoke test (boots decap-server + a static fileserver)
npx playwright test e2e/cms-smoke.spec.js --project chromium-desktop
The Jekyll plugin unit tests and the OAuth-proxy Lambda tests are owned upstream
by cms-platform (gem theme/spec/ + the platform oauth-proxy/) and run in the
platform's CI, not on this consumer.
For the full test strategy — categories, trigger map, per-spec walkthrough,
known gaps, and the recipe for adding tests when a new content collection
ships — see docs/TESTING.md.
When editing posts via the CMS, two independent dimensions decide whether content reaches the live site. They look similar in the UI but gate two different things:
| Dimension | What it controls | Where it lives |
|---|---|---|
| Status (Draft / In Review / Ready) | Whether the PR gets merged into its base branch | Decap's editorial workflow — translates to cms/draft / cms/ready PR labels in cms-editorial-workflow.yml. Auto-merge fires on cms/ready. |
| Published toggle | Whether the post, once on its base branch, is rendered on the live site | Custom front-matter field. Jekyll filters published: false out of site.posts at build time. |
Status = "is this change ready?" Published = "should this post be visible right now?"
Editor-facing detail: see
docs/CONTENT_GUIDE.md § Status vs. Published
for the full matrix, including what changes when editing on a preview
branch (preview-pr<N>.adamdaniel.ai) instead of production
(adamdaniel.ai).
| Branch / pattern | Created by | Merges into | What it represents |
|---|---|---|---|
main |
the only long-lived branch | — (production) | Single source of truth. Every push triggers deploy-production.yml. |
feature/* (by convention) |
a developer | main |
Code changes (workflows, CSS, plugins, infra). PR previews live at preview-pr<N>.adamdaniel.ai. Historical example of the pattern: restore-decap-cms (PR #48, merged long ago) — there is no currently-open feature branch by that name. |
cms/<collection>/<slug> |
Decap, when editing on adamdaniel.ai/admin/ |
main |
A single CMS edit in editorial workflow. Auto-merges to main once cms/ready is set and checks pass. Production-bound. |
cms/<collection>/<slug> (off a feature branch) |
Decap, when editing on preview-pr<N>.adamdaniel.ai/admin/ |
the feature branch (e.g. restore-decap-cms) |
A CMS edit made on a feature-branch preview to demonstrate / test changes. Stays on the preview tree — content edits here are not meant to reach production. Decap's branch name doesn't encode the parent, so the admin shows the active backend branch in its commit pill (top-right) when it isn't main. PRs from cms/... into a non-main base get the cms/preview-only label automatically. |
cms/draft-<timestamp> |
Decap legacy fallback (older versions / no slug yet) | same rules as above | Same role as cms/<collection>/<slug> — the timestamped form is what Decap used in earlier releases. |
Standing rule: content additions made on a feature branch's preview
admin (the cms/... PRs targeting the feature branch) should be
dropped when the feature branch itself merges into main. They
exist to exercise the preview environment, not to ship.
Folders grouped by purpose. Anything not listed is incidental.
| Path | Holds |
|---|---|
_posts/ |
Blog posts. Filename YYYY-MM-DD-<slug>.md is required by Jekyll; live URL is /blog/<slug>/ (no date prefix, see _config.yml's permalink). |
_tags/ |
Curated tag descriptions. Optional — auto_tag_pages.rb synthesises archive pages for any tag a post uses. The directory currently exists but is empty in this repo checkout (only .gitkeep, no curated tag files committed today). |
_projects/ |
Project case studies. The directory currently does not exist on disk at all in this repo checkout — the Decap collection still defines _projects/ as a folder collection, so only the on-disk directory is currently absent, not the collection itself. |
pages/ |
Standalone pages with their own permalink (e.g. /about/). Currently disabled on the public route. |
assets/images/uploads/ |
Editor-uploaded images (one flat folder). The CMS's media_folder / public_folder both point here so the on-disk path is byte-identical to the served URL. |
| Path | Holds |
|---|---|
_layouts/ |
Gem-delivered (cms-platform theme) — page templates (default.html, post.html, page.html, project.html, preview.html). Not vendored in this repo; a local file here still overrides the gem's where present. |
_includes/ |
Gem-delivered (cms-platform theme) — partial templates (header, footer, head, etc.) reused across layouts. Not vendored in this repo. |
assets/css/ |
Gem-delivered (cms-platform theme) — stylesheets compiled into the site CSS. This directory does not exist in this repo. |
assets/images/ (non-uploads/) |
Static design assets (logo, OG images, decorative SVG). |
admin/ |
Gem-delivered Decap CMS shell (see "Editorial / CMS surface" below) — no site-specific CSS/JS is vendored under assets/. |
| Path | Holds |
|---|---|
admin/ |
Gem-delivered Decap CMS shell (cms-platform-theme, since v0.1.4) — not present in this repo except the site-owned seam. This repo tracks only admin/collections.site.yml.example (the template for the per-site collection list); the real admin/collections.site.yml, index.html, config.yml / config-local.yml, preview-bridge.js, and the reviews/ dashboard are all gem-delivered or generated at build time, not tracked here. The cobalt-thermal theme (custom.css) that used to ship here was retired in cms-platform PR #81. |
preview.md + _layouts/preview.html |
The live-preview shell at /preview/ that the admin's preview bridge feeds. _layouts/preview.html is gem-delivered (see "Appearance" above). |
| Path | Holds |
|---|---|
_config.yml, Gemfile |
Jekyll configuration and Ruby dependencies. |
_data/ |
Build-time data (e.g. reading_times.yml). |
| Path | Holds |
|---|---|
e2e/ |
Gem-/platform-delivered — Playwright specs + helpers now live in cms-platform, not tracked in this repo. See docs/TESTING.md for the per-spec walkthrough of what runs. |
| Playwright runner config | Platform-delivered — playwright.config.js / playwright.regression.config.js ship with the e2e harness from .cms-platform/e2e, not vendored in this repo. |
| Path | Holds |
|---|---|
infrastructure/bootstrap/ |
One-time AWS setup (CloudFormation templates, deploy scripts). |
oauth-proxy/ |
Delegating deploy.sh for the GitHub OAuth proxy (Lambda + API Gateway). The Lambda + template are owned upstream by cms-platform; the wrapper deploys them at platform_ref. |
.github/workflows/ |
CI/CD — 31 workflow files today; see AGENTS.md § Salient paths per workflow for the full, current per-workflow table rather than duplicating a second (and quickly stale) list here. |
scripts/ |
Build-time helpers (setup-test-environment.sh, write-commit-json.sh, generate-showcase.js, etc.). |
docs/ |
Long-form documentation: CONTENT_GUIDE.md, TESTING.md. |
main ← production (deploys automatically)
├─ feature/* ← code changes (e.g. restore-decap-cms)
│ └─ cms/<collection>/<slug> ← CMS edits made on the feature's preview admin
│ (preview-only — don't carry to main)
└─ cms/<collection>/<slug> ← CMS edits made on adamdaniel.ai/admin/
└─ PR opened, cms/draft applied ← preview URL deployed, regression video posted
└─ cms/ready ← auto-merged to main, preview cleaned up