Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,14 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

# SOPS_AGE_KEY is attached only to steps that actually decrypt
# resume.i18n.json, not at job level, so checkout/mise/install never see it.
- name: Quick verification
run: pnpm check:quick
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}

# Builds and serves the full site (including PDFs) to run these tests -
# this is also the ./build the Lighthouse step below reuses, so it's
# never rebuilt a second time.
- name: E2E tests
run: pnpm test:e2e
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}

# This job runs inside the Playwright container, so Chrome isn't on the
# path Lighthouse's launcher checks. Resolve it via Playwright's own API
Expand All @@ -49,9 +43,8 @@ jobs:
id: chrome
run: echo "path=$(node -e "console.log(require('@playwright/test').chromium.executablePath())")" >> "$GITHUB_OUTPUT"

# No buildCommand/env here: lighthouserc.json's staticDistDir already
# points at ./build from the E2E step above, so this neither rebuilds
# nor needs the decryption key.
# No buildCommand here: lighthouserc.json's staticDistDir already
# points at ./build from the E2E step above, so this doesn't rebuild.
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v12
with:
Expand Down
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,14 @@ The authored resume source is:

There are no generated `resume.de.json`/`resume.en.json` files committed to the repo — they're gitignored and regenerated fresh by `pnpm build:json-resume` (`scripts/generate-resume-source.ts`):

1. Decrypt `resume.i18n.json` (`sops -d`) and mask redacted-client fields (see "Redacted Clients" below), writing the result to `.generated/resume-source.json` (locale-agnostic, never committed).
1. Read `resume.i18n.json` and resolve redacted-client fields to their committed masked values (see "Redacted Clients" below), writing the result to `.generated/resume-source.json` (locale-agnostic, never committed).
2. `deriveResume` (`src/lib/utils/derive-resume.ts`, a pure function) localizes that into a single locale's JSON Resume data, sorts dated sections, and computes skills. Written to `.generated/resume.de.json` / `.generated/resume.en.json`.

This avoids keeping derived copies in sync, and avoids stale generated output (e.g. duration text computed from "now") ever getting committed. `build:json-resume` is the only place `sops`/decryption is ever invoked — everything downstream (`src/lib/data/resume.ts`, `scripts/build-typst-data.ts`, tests) just reads the generated per-locale files as plain data, so none of it needs to be Node-only or server-only.
This avoids keeping derived copies in sync, and avoids stale generated output (e.g. duration text computed from "now") ever getting committed. `build:json-resume` (in `RESUME_MODE=unredacted` only) and `pnpm mask-clients` are the only places `sops`/decryption is ever invoked — everything downstream (`src/lib/data/resume.ts`, `scripts/build-typst-data.ts`, tests) just reads the generated per-locale files as plain data, so none of it needs to be Node-only or server-only.

The pipeline is:

- `resume.i18n.json` -> (`pnpm build:json-resume`, decrypt + mask, one Node script) -> `.generated/resume-source.json`
- `resume.i18n.json` -> (`pnpm build:json-resume`, resolve masked fields, one Node script) -> `.generated/resume-source.json`
- `.generated/resume-source.json` -> (`deriveResume`, pure, once per locale) -> `.generated/resume.de.json` / `.generated/resume.en.json`
- `.generated/resume.<locale>.json` -> web rendering (`src/lib/data/resume.ts` imports both generated files directly)
- `.generated/resume.<locale>.json` -> Typst-ready data (`scripts/build-typst-data.ts` imports the same generated files directly)
Expand All @@ -105,7 +105,7 @@ Fields prefixed `sopsEncrypted` (`sopsEncryptedEntity`, `sopsEncryptedUrl`, `sop

This is a scoped mitigation against low-effort CV/data farming (bots and scrapers reading the current tree or the deployed site), not a guarantee that these identities are unreachable in this public repo altogether. Earlier commits still contain the plaintext values in `resume.i18n.json` blobs from before they were encrypted, and that's an accepted tradeoff, not an oversight — a recruiter who goes digging through Git history is out of scope for this protection. **Do not rewrite Git history to purge old blobs** as a way to "fully" fix this, whether in response to review feedback or otherwise, without being explicitly asked; that's a judgment call for Robert, not something to do preemptively. If plaintext history turns out to be trivially discoverable by an AI agent in a way that matters, that's a reason to revisit later, not a bug to fix now.

Edit with `sops resume.i18n.json` or view with `sops -d resume.i18n.json`; `biome.json` excludes this file so it never fights `sops`'s formatting. `.sops.yaml` lists two age recipients: a personal key for local interactive edits, and a CI-only key (its private half lives in the `SOPS_AGE_KEY` GitHub Actions secret, attached only to the individual CI steps that actually invoke `sops` — never at job level, to keep it out of reach of unrelated steps like checkout or dependency install; the same key is a Netlify build environment variable, which `scripts/netlify-build.sh` removes from the environment before installing tools and passes only to `pnpm build`) so `pnpm build`/`pnpm check:quick` can decrypt in CI without ever exposing the personal key.
Edit with `sops resume.i18n.json` or view with `sops -d resume.i18n.json`; `biome.json` excludes this file so it never fights `sops`'s formatting. After changing an encrypted field, run `pnpm mask-clients`: it writes the masked value as a plain field next to each encrypted one (e.g. `entity: "ta***bH"` next to `sopsEncryptedEntity`) and flags the entry `redacted: true`. Builds read only these committed masked values, so CI and Netlify need no sops key. A build fails if an entry has encrypted fields but no masked ones, which catches a forgotten run for a new client; a changed name of an existing client is not detected, so don't skip the command.

## PDF And Print

Expand Down Expand Up @@ -329,7 +329,7 @@ Every `build:*` script does exactly one job and can be run standalone; `pnpm bui

```bash
pnpm build:paraglide # compile Paraglide UI messages
pnpm build:json-resume # decrypt + mask resume.i18n.json -> .generated/resume.{de,en}.json (gitignored, requires a sops key)
pnpm build:json-resume # resolve masked fields of resume.i18n.json -> .generated/resume.{de,en}.json (gitignored)
pnpm build:typst-data # -> typst/content/{de,en}.typ.json (implies build:json-resume)
pnpm build:pdf # -> static/{de,en}/*.pdf (implies build:json-resume; internally also runs build-typst-data.ts)
pnpm build:vite # vite build -> build/
Expand Down
14 changes: 4 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,16 +142,10 @@ Important deployment behavior:
## PDF Build And Deploy

- Netlify builds the site, including the PDFs, from its own repository checkout.
- `Typst`, `sops` and `age` are not part of Netlify's build image, so `scripts/netlify-build.sh` installs them with mise first. It does that only on Netlify's build servers (`NETLIFY=true`); local builds, e.g. with `netlify deploy`, need the tools on `PATH` already. `mise.toml` sets them to `latest`, so every build uses their newest release.
- Netlify exposes `SOPS_AGE_KEY` to the whole build, including its automatic `pnpm install`. `scripts/netlify-build.sh` keeps the key away from mise and hands it only to `pnpm build`.
- `Typst` is not part of Netlify's build image, so `scripts/netlify-build.sh` installs it with mise first. It does that only on Netlify's build servers (`NETLIFY=true`); local builds, e.g. with `netlify deploy`, need Typst on `PATH` already. `mise.toml` sets it to `latest`, so every build uses its newest release.
- Builds need no secrets: they use the masked client names committed in `resume.i18n.json` (see "Redacted Clients" in `AGENTS.md`).
- Vendored `ttf` or `otf` fonts under `typst/fonts/` are required for the PDF build. See [`typst/README.md`](/home/robert/Development/schaefer-development/roschaefer.de/typst/README.md).

Required Netlify environment variable (mark it secret, scope it to builds):

- `SOPS_AGE_KEY` (a dedicated age private key for decrypting `resume.i18n.json` - see "Redacted Clients" in `AGENTS.md`)

GitHub Actions only runs checks. It needs `SOPS_AGE_KEY` as a repository secret too, but no Netlify credentials.

## SEO / Social Metadata

The site includes:
Expand All @@ -171,5 +165,5 @@ The current social preview image is:
- Print output is optimized primarily for Firefox.
- The web version and print version share the same content source.
- Generated Paraglide files in `src/lib/paraglide` are never committed (the directory has its own nested `.gitignore`) and are recompiled on every `dev`/`build`/`check`/`test` run.
- The decrypted, masked resume source (`.generated/resume-source.json`, `.generated/resume.de.json`, `.generated/resume.en.json`) is likewise gitignored and always regenerated fresh - see `AGENTS.md` for why.
- CI needs a `SOPS_AGE_KEY` repository secret (a dedicated age private key, separate from any personal key) to decrypt `resume.i18n.json` during `pnpm build`/`pnpm check:quick`.
- The masked resume source (`.generated/resume-source.json`, `.generated/resume.de.json`, `.generated/resume.en.json`) is likewise gitignored and always regenerated fresh - see `AGENTS.md` for why.
- After editing the encrypted client fields with `sops resume.i18n.json`, run `pnpm mask-clients` to update the committed masked names.
8 changes: 4 additions & 4 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Typst, sops and age are not part of Netlify's build image, so the build
# installs them with mise before running the site build.
# Typst is not part of Netlify's build image, so the build installs it with
# mise before running the site build.
[build]
command = "scripts/netlify-build.sh"
publish = "build"

# Netlify installs Node and pnpm itself and runs `pnpm install` before the
# build command, so mise only has to provide the remaining tools.
# build command. sops and age are only needed locally (`pnpm mask-clients`).
[build.environment]
NODE_VERSION = "26"
MISE_DISABLE_TOOLS = "node,pnpm"
MISE_DISABLE_TOOLS = "node,pnpm,sops,age"

[[redirects]]
from = "/linkedin"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"build:paraglide": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide --emit-ts-declarations",
"build:json-resume": "node scripts/generate-resume-source.ts",
"mask-clients": "node scripts/mask-clients.ts",
"build:typst-data": "pnpm build:json-resume && node scripts/build-typst-data.ts",
"build:pdf": "pnpm build:json-resume && node scripts/build-pdfs.ts",
"build:vite": "vite build",
Expand Down
Loading
Loading