Skip to content
andrew.dunn.dev

Part 3: The Build System

Part 3 of the digital garden series.

Why a Makefile

A Makefile is documentation that also happens to run. The Makefile in this project is the canonical description of how content flows from authoring through processing to deployment. It opens with a state flow diagram:

┌─────────────────────────────────────────────────────────────────┐
│                        AUTHORING                                │
│                                                                 │
│  ../vault/inbox/           ../vault/drafts/                      │
│  ├── note.md               ├── my-post.md                       │
│  └── url.txt               ├── DSC_12345.HEIC                   │
│                             └── IMG_6789.MOV                    │
└─────────────────────────────┬───────────────────────────────────┘
                              │
                              │  /capture or manual processing
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      PROCESSING (local)                         │
│                                                                 │
│  1. Editorial review                                            │
│  2. Route to content section                                    │
│  3. For each asset:                                             │
│                                                                 │
│     make archive SRC=.../DSC_12345.HEIC OUT=name \              │
│                  DEST=content/links                             │
│       → content/links/name.heic  (EXIF intact, Git LFS)         │
│                                                                 │
│     make webp SRC=content/links/name.heic OUT=name              │
│       → media/name.webp  (stripped, 3840px, q82)                │
│                                                                 │
│     make archive SRC=.../IMG_6789.MOV OUT=name \                │
│                  DEST=content/links                             │
│       → content/links/name.mov  (original, Git LFS)             │
│                                                                 │
│     make mp4 SRC=content/links/name.mov OUT=name                │
│       → media/name.mp4  (H.264, AAC, stripped, faststart)       │
│                                                                 │
│  4. Markdown: ![](/media/name.webp) or <video src="/media/..."> │
│  5. Delete source from ../vault/                                 │
│  6. make validate                                               │
└─────────────────────────────┬───────────────────────────────────┘
                              │
                              │  git commit + push
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     CI PIPELINE                                 │
│                                                                 │
│  GIT_LFS_SKIP_SMUDGE=1                                          │
│                                                                 │
│  make build              make deploy-site   make deploy-media   │
│  ┌────────────────┐      ┌──────────────┐   ┌──────────────┐    │
│  │ quartz build   │      │ wrangler     │   │ lfs pull     │    │
│  │ -d content/    │ pub/ │ deploy       │   │ --include=   │    │
│  │ -o public/     │─────▶│ worker/      │   │ "media/**"   │    │
│  │                │      │              │   │              │    │
│  │ ignorePatterns │      │ HTML,CSS,JS, │   │ r2 put each  │    │
│  │ skips all      │      │ favicon from │   │ media/*.webp │    │
│  │ media exts     │      │ static assets│   │ media/*.mp4  │    │
│  │                │      └──────────────┘   │ media/*.m4a  │    │
│  │ LFS pointers   │                         └──────────────┘    │
│  │ in content/    │                                             │
│  │ are tiny, never│      originals in content/ are              │
│  │ resolved       │      LFS pointers, never fetched            │
│  └────────────────┘                                             │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     CLOUDFLARE                                  │
│                                                                 │
│  Worker (secondbrain)                                           │
│  ├── Static Assets ◄── Quartz output                            │
│  │   (HTML, CSS, JS, favicon, fonts)                            │
│  │                                                              │
│  └── R2 Bucket ◄── media/*.{webp,mp4,m4a}                       │
│      secondbrain.dunn.dev/media/*                               │
└─────────────────────────────────────────────────────────────────┘

This lives in the Makefile comments. When the pipeline changes, the diagram and the targets change in the same commit.

Original preservation

A photo carries EXIF metadata (GPS, camera model, exposure, timestamps) that’s irreplaceable once stripped. The media pipeline preserves originals at full fidelity and produces stripped, resized copies for the web.

The archive target copies the original to a content directory with a normalized filename:

make archive SRC=path/to/DSC_12345.HEIC OUT=18-07-hoover-dam DEST=content/links
# → content/links/18-07-hoover-dam.heic  (original, EXIF intact)

The original lives next to the markdown that references it, tracked by Git LFS so it doesn’t bloat the repository but stays visible in Obsidian alongside the content.

The webp target then produces a web-optimized copy:

make webp SRC=content/links/18-07-hoover-dam.heic OUT=18-07-hoover-dam
# → media/18-07-hoover-dam.webp  (stripped, 3840px max, quality 82)

ImageMagick strips metadata, auto-orients, resizes to 3840px max, and encodes as WebP at quality 82. Typically 90%+ smaller than the original.

Video and audio follow the same pattern. make mp4 produces H.264/AAC with metadata stripped and faststart enabled for progressive playback. make m4a produces AAC at 128kbps. Originals are archived, web copies go to media/, Git LFS tracks both.

make mp4 SRC=content/links/clip.mov OUT=18-07-clip
# → media/18-07-clip.mp4  (H.264, AAC, stripped, faststart)

make m4a SRC=path/to/recording.wav OUT=2024-talk
# → media/2024-talk.m4a  (AAC 128k, stripped, faststart)

Two copies of every media asset: one archival (full fidelity, EXIF intact, Git LFS) and one web (stripped, optimized, deployed to R2).

CI on GitLab

The GitLab CI pipeline:

image: node:22

stages:
  - build
  - deploy

variables:
  GIT_LFS_SKIP_SMUDGE: "1"

GIT_LFS_SKIP_SMUDGE: "1" keeps CI from pulling every LFS object before doing anything. The build job never needs the actual media because Quartz’s ignorePatterns skips all media file extensions.

Build

build:
  stage: build
  script:
    - make build

Clones Quartz v4, copies in two config files, builds static HTML from content/ into public/. The Quartz config’s ignorePatterns skips .obsidian/ and all media file extensions. The vault lives outside the repo as a sibling directory (../vault/), so Quartz never sees it.

Deploy site

deploy-site:
  stage: deploy
  needs:
    - build
  script:
    - npm install -g wrangler
    - make deploy-site

Deploys the public/ artifact via Wrangler as Workers Static Assets, along with the Worker code in worker/.

Deploy media

deploy-media:
  stage: deploy
  rules:
    - if: '$CI_COMMIT_REF_NAME == "main"'
      changes:
        - media/**
  needs: []
  script:
    - apt-get update -qq && apt-get install -y -qq git-lfs
    - git lfs install --force
    - npm install -g wrangler
    - make deploy-media

Only runs when files in media/ change. Pulls just the media via Git LFS and uploads each to R2. Because of needs: [], it runs in parallel with deploy-site.

every pushmedia/** on mainTRIGGERgit pushany branchSTAGE BUILDbuildmake buildSTAGE DEPLOYdeploy-siteneeds: buildSTAGE DEPLOYdeploy-medianeeds: []

deploy-site waits on build because it ships what build produced. deploy-media reads nothing from the build, so needs: [] lets a media-only push deploy alongside it instead of queueing behind it.

cd $(MEDIA_DIR) && \
find . -maxdepth 1 -type f \( -name '*.webp' -o -name '*.mp4' -o -name '*.m4a' \) | while read file; do
    key="${file#./}"
    mime=$(file --mime-type -b "${file}")
    echo "uploading ${key} (${mime})"
    npx wrangler r2 object put "secondbrain/${key}" --file="${file}" --content-type="${mime}" --remote
done

Local vs CI

The same Makefile serves both environments. make help shows everything available:

developer targets:
  check        verify local dependencies
  deps         install missing dependencies via brew
  archive      copy + rename original next to markdown
  webp         strip + resize + convert to webp
  mp4          strip + transcode to H.264/AAC
  m4a          strip + transcode to AAC
  validate     pre-publish checks

ci targets:
  build        quartz build (content/ -> public/)
  deploy-site  wrangler deploy worker
  deploy-media selective LFS pull + R2 upload

The Makefile is visible in both Obsidian and git.

Next: Part 4: AI harness