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:

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:

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

What is not claimed

how claims are graded · back to the homepage