Portfolio pipeline
The delivery path that builds this site: a private source repository, two fail-closed character gates, a Hugo build in GitHub Actions, and a one-way publish to GitHub Pages.
Evidence tier: Verified | code (private) for the pipeline described below. The workflow source is in a private repository, so there is no source link here. What is public is the thing it produces: this page, and every byte of the site it is served from, which you can read in your browser’s viewer without logging in anywhere. That asymmetry is deliberate and is the subject of the page.
What it is
The CI/CD path that builds this site. A commit to main in a private repository, when it touches the
site directory, is fetched by GitHub Actions, scanned for forbidden characters twice (once in the source,
once in the built artifact), rendered by Hugo into eleven files, and published to the gh-pages branch of
a separate public repository, from which GitHub Pages serves it. Six such runs have completed, all green,
and the site you are reading is the output of the sixth.
It is the least glamorous thing on this page and the one I would defend hardest in an interview, because it is where the rules that make the rest of the site trustworthy stop being intentions.
Architecture
private source repository (Markdown + one Python checker + one workflow)
|
| git push to main, paths: public-site/** or the workflow file
v
GitHub Actions, deploy.yml (the only workflow; ubuntu-latest)
1 checkout permissions: contents: read, so the job cannot write here
2 checker self-test 4 planted violations must all be caught
3 whole-tree source scan 16 extensions, fail closed, 9 allowlisted paths
4 Hugo 0.168.0 extended hugo --minify --gc --baseURL
5 homepage assertion the build must have produced an index.html that is HTML
6 built-artifact scan the same checker, pointed at the 11 files about to ship
7 publish personal access token -> gh-pages of the public repo, force_orphan
|
v
public repository, branch gh-pages (one commit, always: it is an artifact, not history)
|
v
GitHub Pages -> https://sternnick.github.io/portfolio/
The reference run: 8 steps, all success, job wall clock 11 seconds. Its log carries the two scan
results, scanned 62 file(s) against the source and scanned 11 file(s) against the artifact, with
CLEAN after each.
Key decisions
Private source, public output. The site is authored in a repository nobody outside can read, and only the rendered result crosses into the public one. Three things this buys, all of them real:
- Draft safety. A half-written case study can be committed, pushed and reworded without it ever being public. Nothing is exposed by the act of working on it.
- Data privacy in the authoring material. The private repository holds the fact ledger, the counterfactual drafts and the source documents the site summarises. The public repository holds HTML.
- A one-way door by construction. The workflow writes
gh-pagesand nevermain, and the public repository holds no workflow files at all. An edit made on the public side is overwritten by the next deploy, so the only durable way to change the site is to change the source.
The cost is honest and worth stating: the public repository has no pull requests and no issues (they are disabled on purpose), so there is no public trail of why the output changed. The trail lives in private commit messages, which is why the commit message is the place the reasoning has to go.
Hugo, and specifically static Hugo. No Node.js, no package manager, no lockfile, no node_modules,
no dependency to expire between visits. The build is one binary at one pinned version, it finishes in
15 milliseconds, and its entire output is 11 files I can enumerate. A local build with that pinned
version reproduces the same 11 paths, which is the check I run before pushing anything. The cost: no
dynamic content, no server-side anything, and a theme written by hand rather than picked from a gallery.
That cost is the point of the choice.
The sanitizer, and what makes it a gate rather than a lint. One Python file, no dependencies, run twice per deploy. Three properties were added specifically because their absence had already caused a failure:
- It names characters by codepoint instead of embedding them in its own source, because a checker whose source contains the characters it forbids cannot be scanned by itself.
- It runs a self-test before it is allowed to pass anything: four planted violations, one per rule, must all be caught. A gate that cannot fail is not a gate.
- It refuses to report
CLEANwhen it has scanned zero files, because an empty file list once looked like a passing check.
Two scans rather than one is also deliberate. The source scan runs before Hugo is installed, so a bad character costs seconds and can never publish first. The artifact scan reads what actually ships, so it also sees a character a theme partial, a shortcode or Hugo itself introduced. The artifact scan is the authoritative one.
What broke
Sub-paths, twice. The site is published under /portfolio/, a repository project page, not at a
domain root. A link written as a root-absolute path resolves above that prefix and 404s on every page but
the homepage, while looking perfectly correct in the source. Every internal URL is therefore generated by
Hugo’s relURL, or by RelPermalink in the card partial, both of which know the base path. The same trap
returned later in a different shape: a minify build strips attribute quotes, so the artifact contains
href=/portfolio/... and a link checker written against href=" matches nothing at all and cheerfully
reports a site with no links in it. Two ways to be wrong about the same three characters.
The gate that was too weak to notice. Until 2026-10-26 the artifact step checked for typographic
dashes with two grep calls and nothing else. It printed a helpful remediation message, it ran on every
deploy, and it passed. Measured that day against the live artifact: four typographic quotation marks were
sitting in the published HTML, on a page that had shipped through that very step. The step had been
green the entire time, because it had never been asked about quotes. The fix was not a better grep but
the whole checker above, adopted as a step, with the self-test that would have caught the original
weakness in the first run. The quotes are gone, verified by fetching every published page.
A public repository that carried more than it should. A separate public repository in the same
family, a documentation mirror, had an internal network address and an operator mailbox in its early
commits. No credential was ever in it: the exposure was topology and identity, not access. It was closed
by rewriting main to a single squashed commit, after verifying that the tree was byte-identical to the
one before, that the repository had no forks and no tags, and that a full backup existed off-site. The
part worth remembering is what the force-push could not reach: a forge keeps its own refs for closed pull
requests, and a rewritten default branch does not remove them. The residue was accepted and written down,
with the condition that would make it unacceptable.
Known limitations
- Static only. No server-side code, no dynamic content, no personalisation, no search. Everything is decided at build time.
- The sync is one-way, and it is a force-push.
gh-pagesis published withforce_orphan, so it holds exactly one commit. Nothing can be authored on the public side, and there is no rollback history in that branch: the artifact is the last build, full stop. - The homepage assertion is the only content guard. It fails a build that produced no
index.html. A build that produced a wrong but well-formed page passes, correctly, because no automated check can tell a bad sentence from a good one. - A character policy is not an exfiltration policy. The scans forbid specific punctuation. They do not look for hostnames, addresses or paths, so what keeps those out of the generated pages is the export boundary in the source repository plus a human reading the diff.
- No link check in CI. Nothing follows the links in the artifact. They are correct today because
relURLmakes them so and because aminifybuild removes the quotes a hand-written checker would key on. - A docs-only change runs nothing. The workflow’s push trigger is filtered to the site directory and the workflow file itself, so a commit that only touches documentation starts no run and gets no scan from CI. That is the same boundary as the previous point, seen from the other side: the automated guard is strongest exactly where the artifact is built.
- Green build is not green publish. Publishing authenticates with a personal access token, because the job’s own token cannot write to a different repository. If that token expires, every step before the last one succeeds and the publish fails with a 403.
What is not claimed
- No claim about scale. Six deploys, one publisher, one maintainer, no staging environment, no blue or green anything, and no traffic to speak of.
- No container or self-hosted runner story. The build runs on a hosted runner with Hugo installed as a step. Nothing here was tuned for cost or concurrency.
- No secret-scanning claim. The pipeline holds one secret, a token, and its handling is unremarkable: it is a repository secret, read once, by the step that needs it.
- No public source for the pipeline itself. The workflow file lives in a private repository. The evidence here is the artifact it produced, which is the page you are reading.