Skip to content

The publishing pipeline behind this site

Everything I have built for the last several years belongs to someone else. This is the machinery that publishes what generalises from it, and structurally cannot publish what does not.

Active You are looking at the demo Source: internal GitLab, not public Last reviewed 2026-09-09. Maintained for my own use; no support offered.

Why it exists

The problem was not "I need a portfolio site". It was narrower and more annoying than that: every draft I wrote about my own work either leaked something or said nothing.

Sanitising by hand does not scale and does not hold. You are careful on the page you are thinking about, and then a navigation entry, a file name, or a stale draft in a directory you forgot carries a client's name to a public bucket. That is not a hypothetical — it nearly happened here, and it is why this exists in its current form.

So the actual thesis: the boundary has to be a build-time check, not an act of care. Care fails on the day you are tired. A pipeline that refuses to build does not.

What it does

  • Three tiers with one direction of travel. Raw client and personal material sits in an ignored tier. Sanitised source of truth is tracked but unpublished. Only the third tier reaches the web. Nothing moves leftward, ever.
  • A CI guard that fails the build if the private tier becomes tracked in git, or if a client-identifying term appears anywhere in the published tree — file names included.
  • Specs before content. Anything making a factual claim starts as a specification with an evidence table, so every number on this site traces to a repository count, an audit, or a measurement.
  • Status metadata on every page — machine-readable front matter plus a visible strip — so nothing here can be mistaken for maintained guidance when it is a working note.

The system

Markdown → Zensical → static HTML → S3 behind CloudFront. GitLab is source and CI: a guard stage, then build, then deploy on the default branch only.

The decisions that mattered:

The firewall is a CI stage, not a checklist. Rejected: a documented review step before each publish. Accepted trade-off: the guard is a denylist, so it catches identifiers I have thought of and not ones I have not. It is a floor, not a ceiling — it does not replace reading the page.

Public hosting on AWS rather than the internal GitLab Pages. Pages was simpler and free. It is also reachable only from inside the corporate network, which cannot serve a publicly linked portfolio. Accepted trade-off: an S3 and CloudFront setup to maintain, and credentials to rotate.

Zensical rather than MkDocs Material. The generator is pre-1.0. Accepted trade-off: no PDF export plugin survived the move, so the CV is print-to-PDF for now, and the feature set moves under me. In exchange: one dependency instead of fifteen, and sub-second builds.

Duplication between the source tier and the published tier. The canonical case study and its published rendering are separate files. Accepted trade-off: two copies to keep in step, which is a real drift risk, mitigated only by a header comment naming which one is canonical. A generation step would be better and does not exist yet.

What AI did, and what I decided

AI-assisted: page scaffolding and CSS, the MkDocs-to-Zensical migration, draft prose from my own notes and repository history, and the first cut of the CI guard.

Human-owned: the confidentiality model and every judgement about what may be published; the decision to generalise a staffing figure rather than reuse a sales number; which claims are defensible and which are not; the architecture and hosting choices; and a read of every published line before it shipped.

Two things worth being specific about, because they are the interesting part:

  • The assistant surfaced a page that would have published a client status report — named executives, RAG status on live programmes — and stopped rather than pushing. Catching it was collaborative. Deciding to encode the catch as a CI guard instead of a note to self was the human decision, and it is the only one that persists.
  • A pitch deck claimed a staffing figure that repository history did not support. The number was removed and the case study now says something weaker and true. Judgement calls like that are not delegable, because the person whose name is on the page carries them.

Evidence

  • The guard was negative-tested before being relied on: a deliberately poisoned page was added, the pipeline was confirmed to fail, and the page was removed. An untested guard is a comfort blanket.
  • zensical build --strict passes clean; the denylist scan is clean across the published tree, the source tier and the specs.
  • One real confidentiality incident caught before any push — the origin of the guard.

Not evaluated: whether anyone reads this. There is no analytics, no audience measurement, and no evidence that the structure works better than the CV it replaced. Those are claims I would need data to make, and I do not have it.

Limits

  • The guard is a denylist. It catches the terms it knows. A new client name is invisible to it until someone adds it, so it lowers the floor without raising the ceiling.
  • The denylist itself is not published, for the obvious reason that it is a list of client names.
  • Nothing here is independently verifiable. A reader cannot inspect a client repository to check the case study, and no amount of structure fixes that. The measurable claims come from repository history and audits that are not public. Treat them as I have labelled them.
  • Single author, no review board. The editorial gate is one person deciding, which is exactly the arrangement most likely to publish something it should not.
  • No RSS. The generator ships no feed plugin at this version. Deferred rather than faked.

Reuse

The confidentiality guard generalises, and it is the part worth taking. A sketch, with the actual term list replaced:

confidentiality:
  stage: guard
  image: alpine:3.20
  before_script:
    - apk add --no-cache git
  script:
    # The private tier must never become tracked.
    - |
      if git ls-files --error-unmatch private/ >/dev/null 2>&1; then
        echo "FAIL: private/ is tracked in git. It must stay ignored."
        exit 1
      fi
    # No client-identifying term in the published tree — file names included.
    - |
      if git ls-files docs/ | grep -qiE "$CLIENT_TERMS"; then
        echo "FAIL: client-identifying filename in the published tree."
        exit 1
      fi
    - |
      if git grep -liE "$CLIENT_TERMS" -- docs/; then
        echo "FAIL: client-identifying content in the published tree."
        exit 1
      fi
    - echo "Confidentiality guard passed."

Three notes if you lift it. Check file names as well as content — a leak in a path is still a leak. Negative-test it before you trust it. And keep the guard in the pipeline rather than a pre-commit hook: hooks are local, skippable, and absent on the machine you set up in a hurry.

The rest — the tier layout, the status metadata standard, the case-study contract — is described across Methods and visible in the structure of this site. There is no public repository: the source lives in an internal GitLab.