Building a Public Notes Site with Quartz 5 — Phase 3: Deploy, Domain, and Proof on the Live Site

Phase 3 of my Quartz notes site: I deleted six workflows that never ran, wrote a hardened GitHub Pages deploy with pinned actions, verified my domain against subdomain takeover, found a wildcard DNS record, and proved the privacy filter works on the live site.

Series: Part 3 of 3 (build → privacy → deploy). Put this line at the top of the article, linking both earlier posts:

This is Part 3 of a series. Part 1: Local Build · Part 2: Privacy, Proof, and a Clean Commit

Link “Part 1: Local Build” to https://ny2honolulu.com/quartz-5-notes-site-phase-1-local-build/ and “Part 2: Privacy, Proof, and a Clean Commit” to https://ny2honolulu.com/quartz-5-notes-site-phase-2-privacy-config/. Then add a forward link to this post at the bottom of Phase 2.

Update pending: HTTPS. When GitHub issues the certificate and Enforce HTTPS is ticked, replace the HTTPS note in Step 6 with the result and a screenshot.

What Phase 3 delivered

Phase 3 put my notes site on the internet at notes.ny2honolulu.com, built and deployed by a workflow I wrote and hardened myself. Phase 1 built the site on my desktop and Phase 2 made it private by default; this phase was about publishing it without opening new holes.

By the end of the session (Saturday, October 10, 2026, about two and a half hours):

  1. Six inherited workflows were removed. None of them had ever run on my repo, including the “checks” that showed green in Phase 2.
  2. Two Dependabot pull requests were reviewed and parked. One hid a major version jump inside a “routine” group update.
  3. A hardened deploy workflow went live. Actions pinned to commit hashes, permissions split per job, and an install that refuses to drift from the lockfile.
  4. My domain is verified with GitHub. Nobody else can claim any ny2honolulu.com subdomain on GitHub Pages.
  5. notes.ny2honolulu.com points at GitHub. DNS recon along the way found a wildcard record and two other problems on my main domain.
  6. The v5 branch is protected. It can’t be deleted or force-pushed, by anyone, including me.
  7. The privacy filter is proven on the live site, not just on my desktop.

One item is still waiting: GitHub has to issue an HTTPS certificate for the new name. The site works over plain http:// until then.

The roadmap I followed, in order:

StepWhatWhy in this order
1Inventory the repoKnow what’s there before building on it
2Review Dependabot PRsDecide on dependency changes before the first public build
3Write and rehearse the deploy workflowThe robot that builds and publishes the site
4Turn on GitHub Pages, first deployProve the build works on GitHub’s machines
5Verify the domain with GitHubBlock subdomain takeover before DNS points anywhere
6CNAME record, custom domain, HTTPSConnect the name to the site
7Branch protectionGuard the branch that now publishes on every push
8Privacy proof on the live sitePhase 2 proved it locally; the internet is the real test

Step 1: Inventory the repo

The template shipped six GitHub Actions workflows, and all six were switched off for my repo; I deleted them before writing my own.

My first mistake: the wrong folder

I started with cd ~/notes, which failed because the project lives at ~/projects/notes. The next command, git status, then failed with “not a git repository” because I was still in my home folder. One error caused the other, which is a good reason to run commands one at a time and read each result.

cd ~/projects/notes
git status
git pull
ls -la .github/workflows/
grep -n -i -E "baseUrl|cname" quartz.config.yaml

The results:

  • git status: on branch v5, clean, up to date with GitHub.
  • baseUrl: notes.ny2honolulu.com (line 9): correct, and written without https://, as Quartz expects. The docs warn to set this before deploying, because the RSS feed and sitemap use it.
  • The CNAME plugin (line 149) is enabled. It writes a CNAME file into each build, but GitHub ignores that file when a site is deployed by Actions; the custom domain is set in the repo’s settings instead.
  • .github/workflows/ held six files I had never read.

Reading the six workflows

Every file in .github/workflows/ is code that can run automatically on GitHub, so I read them before deciding anything. Two mattered most:

  • deploy-v5.yaml deploys to Cloudflare Pages using the Quartz author’s secrets, builds the docs folder (Quartz’s own documentation, not my notes), and uses a third-party action pinned only to the tag @v1.
  • dependabot-automerge.yaml merges Dependabot pull requests automatically. It is careful: it matches the exact tested commit, requires every check to pass, refuses major updates, and stops on the first error with set -euo pipefail. It also runs on the workflow_run trigger with write permissions, a pattern security researchers watch closely when a workflow checks out pull request code. This one only reads metadata, so it is safe.

One search showed the rest:

grep -n "github.repository" .github/workflows/*.yaml

Every workflow had a guard like this:

if: ${{ github.repository == 'jackyzha0/quartz' }}

That line means “only run in the original Quartz repo.” On ny2honolulu/notes, every job is skipped.

FileJob guarded?Would have done
build-preview.yamlYesPreview builds for pull requests
ci.yamlYes (both jobs)Tests and builds
dependabot-automerge.yamlYesAuto-merge Dependabot PRs
deploy-preview.yamlYesDeploy PR previews
deploy-v5.yamlYesDeploy Quartz’s docs to Cloudflare
docker-build-push.yamlYesBuild and publish a Docker image

The green checks that never ran

In Phase 2, my first push showed “checks passed.” Since ci.yaml is guarded, nothing was actually tested: GitHub shows a skipped job in a passing state. It’s the same lesson as Phase 1’s “check context, not just output”: a green check proves nothing until you know what ran.

Deleting them

The workflows were dead code with permissions attached, one careless guard edit away from waking up. Deleting them also made one Dependabot pull request pointless, since it only updated actions inside these files.

git rm .github/workflows/*.yaml
git status
git commit -m "Remove upstream Quartz workflows (all guarded to jackyzha0/quartz)"
git push

git status showed exactly six deletions and left .github/dependabot.yml alone, which matters: that file keeps Dependabot watching my libraries. The commit removed 408 lines. Dependabot closed its now-pointless ci-dependencies pull request on its own.

Step 2: Review the Dependabot pull requests

I read both open Dependabot pull requests, found nothing urgent, and parked them so the first public deploy would use the version I had already tested.

Pointing the GitHub CLI at the right repo

gh pr list refused to run: “No default remote repository has been set.” Checking the remotes showed why:

git remote -v
RemoteAddressWhat it is
originny2honolulu/notesMy repo
upstreamjackyzha0/quartzThe original Quartz project, added so I can pull Quartz updates

With two GitHub repos to choose from, gh wouldn’t guess, because a wrong guess would aim pull request commands at the Quartz project. I made two changes:

git remote set-url --push upstream DISABLED
gh repo set-default ny2honolulu/notes

The first replaces only the push address of upstream with a fake one, so I can still fetch Quartz updates but an accidental push there fails immediately. The second tells gh which repo this folder belongs to.

What was waiting

gh pr list
gh pr view 4
gh pr diff 4 --name-only
gh pr diff 1 --name-only
PRTitleFiles changed
#411 production dependency updates, groupedpackage.json, package-lock.json
#1TypeScript 5.9.3 → 7.0.2package.json, package-lock.json

Both touched only the dependency files. A dependency pull request that changes any other file is a red flag worth stopping for.

Three findings hidden inside “11 updates”

  1. A major update inside a routine group. Preact went from 10.29.8 to 11.0.0. Quartz builds every page with Preact, and its release notes point to an upgrade guide. Under semantic versioning (MAJOR.MINOR.PATCH), a major jump may contain breaking changes. Even the automerge workflow I deleted would have refused this one.
  2. A breaking change in a minor version. preact-render-to-string 6.8.0 changed its streaming API on purpose and said so in the changelog. Version numbers are a promise, not a guarantee; release notes are where the truth is.
  3. A maintainer change on yargs. Dependabot flagged that 18.2.0 was published by GitHub Actions, a new releaser. A change in who publishes a package is a classic warning sign of account takeover. Here the commit list included “Set up staged trusted publishing”, which means publishing through a workflow that proves its identity with a short-lived OIDC token instead of a stored password. That is likely an improvement, but likely isn’t verified.

Pull request #1 is a major TypeScript jump to the version 7 compiler, so it gets the same treatment.

The decision: deploy first, update second

None of the updates were security fixes. Deploying the version I had already proven in Phases 1 and 2 gives a known-good live site; testing the updates afterwards means any breakage has one clear cause. Both pull requests are parked.

The baseline audit

npm audit

The result was the same as Phase 1: one vulnerability in braces, counted four times because micromatch, fast-glob and globby depend on it. braces only runs during the build, matching file paths from my own config, and the live site is static HTML, so my Phase 1 risk acceptance still holds. Nothing new had appeared since October 7, and I skipped npm audit fix: it can’t fix braces and could change the lockfile in untested ways. I also ignored the notice offering npm 12, a major version of my build tool, which is not something to change in the middle of a deploy.

Step 3: Write the deploy workflow

I started from the GitHub Pages workflow in the Quartz hosting docs and made four changes for security and reproducibility before pushing anything.

The four changes

ChangeQuartz docsMy workflowWhy
Permissionspages: write and id-token: write for the whole workflowpermissions: {} by default; build gets contents: read, deploy gets the two it needsLeast privilege: the build job can’t publish anything
ActionsTags like @v6Full 40-character commit hashes, with # v6 commentsA tag can be moved to different code; a hash can’t
Node2422Matches the version my desktop builds with
Plugin stepsCache and install Quartz pluginsRemovedAll my plugins are npm packages pinned by package-lock.json (below)

The # v6 comments matter: my dependabot.yml still watches GitHub Actions, and Dependabot reads those comments to propose new hashes later. I get pinning and updates.

Looking up the commit hashes

gh api repos/actions/checkout/commits/v6 --jq .sha
gh api repos/actions/setup-node/commits/v6 --jq .sha
gh api repos/actions/cache/commits/v5 --jq .sha
gh api repos/actions/upload-pages-artifact/commits/v3 --jq .sha
gh api repos/actions/deploy-pages/commits/v4 --jq .sha

Each call asks GitHub which commit a tag points to right now, and --jq .sha pulls out just the hash. All five actions belong to GitHub’s own actions organization, so the risk was low; pinning them anyway builds the habit for third-party actions.

Choosing the Node version

grep -n -A3 '"engines"' package.json
cat .node-version
node --version
SourceSays
package.json enginesNode >=22, npm >=10.9.2
.node-version (from the template)v22.16.0
My desktop (node --version)v22.23.2

mise ignores the .node-version file on my machine, so my builds use 22.23.2. node-version: 22 in the workflow gives GitHub the newest 22.x, the closest match. If a build ever fails, the Node version won’t be a second suspect.

The missing quartz.lock.json

The docs’ workflow caches .quartz/plugins using a hash of quartz.lock.json, and runs npx quartz plugin install, which installs plugins from that lockfile. The file didn’t exist on my machine. Without it, a fresh build server might install nothing, or fetch whatever the latest commit of each plugin happened to be: unpinned third-party code in my build. And ExplicitPublish, my privacy filter, is itself a plugin.

ls .quartz/plugins/
git check-ignore -v quartz.lock.json
ls node_modules/@quartz-community/
grep -n "@quartz-community" package.json
npx quartz plugin install --from-config --dry-run

The answer: .quartz/plugins/ exists but is empty, and every plugin, including explicit-publish, is an ordinary npm package listed in package.json. The dry run reported “All configured plugins are already installed.” quartz.lock.json is only needed for plugins installed from git repos, and I have none.

The ^1.0.0 versions in package.json mean “any 1.x”, so they are not pins. The pin is package-lock.json, which records each exact version and a checksum, and npm ci installs exactly that or fails. So I removed both plugin steps and left a comment in the workflow so I wouldn’t add them back by copying the docs. If I ever add a plugin from a git repo, the install step and a committed quartz.lock.json come back.

The workflow

name: Deploy notes site to GitHub Pages

on:
  push:
    branches:
      - v5
  workflow_dispatch:

# Deny everything by default; each job asks for only what it needs.
permissions: {}

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Check out repository
        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Set up Node
        uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
        with:
          node-version: 22

      - name: Cache npm downloads
        uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      # All Quartz plugins are npm packages pinned by package-lock.json,
      # so npm ci installs them; no separate plugin install is needed.
      - name: Install dependencies exactly as locked
        run: npm ci

      - name: Build site
        run: npx quartz build

      - name: Upload site for Pages
        uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
        with:
          path: public

  deploy:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

What each part does:

  • on: runs on every push to v5; workflow_dispatch adds a manual “Run workflow” button.
  • concurrency: makes a second deploy wait for the first instead of both publishing at once.
  • fetch-depth: 0 fetches the full history, so Quartz can date each note from git.
  • persist-credentials: false leaves no access token on disk after checkout, since nothing later needs it.
  • npm ci installs exactly what the lockfile lists, which keeps my Phase 1 sharp and source-map-js fixes.
  • needs: build means a failed build never reaches the deploy job.
  • id-token: write lets the deploy job prove its identity with a short-lived OIDC token; no stored password exists to steal.

Getting the file onto disk (two tries)

Pasting into Zed failed twice: the first time the file was saved empty, and the second time the shell command itself ended up inside the file, because I pasted it into the editor instead of the terminal. Two quick checks caught it:

wc -l .github/workflows/deploy.yml
grep -c EOF .github/workflows/deploy.yml

The correct file has 66 lines and zero EOF lines. I closed Zed without saving and wrote the file with a heredoc in the terminal, cat > .github/workflows/deploy.yml <<'EOF' through a closing EOF line. The quotes around 'EOF' stop Bash from expanding ${{ runner.os }} as a shell variable.

Rehearsing the build locally

npm ci
npx quartz build

npm ci deleted node_modules and reinstalled 329 packages from the lockfile in 9 seconds, proving nothing my site needs was there by accident. The build found 1 input file, filtered out 0 and emitted 56 files. git status afterwards showed only the new workflow, so npm ci hadn’t changed the lockfile.

Step 4: Turn on GitHub Pages and deploy

The first deploy succeeded in about a minute, and the site went live at ny2honolulu.github.io/notes/ with its styling intact.

Turning Pages on

Pages has to be switched on before the first push, or the deploy job fails. In the repo: Settings → Pages → Build and deployment → Source → GitHub Actions. GitHub replied “GitHub Pages source saved.” I ignored the two suggested workflows (Jekyll and Static HTML) because I had my own, and left Custom domain empty until the domain was verified in Step 5.

Committing and pushing

git status
git add .github/workflows/deploy.yml
git commit -m "Add GitHub Pages deploy workflow (pinned actions, least-privilege permissions)"
git push
gh run watch

The commit (8de9b69) added 66 lines, matching the file. The push woke the workflow up, and gh run watch showed it live:

JobTimeSteps
build22 sCheck out, Node, cache, npm ci, build, upload, cleanup
deploy8 sDeploy to GitHub Pages

The run ended with “completed with ‘success’.” The Post ... steps are automatic cleanup; Post Cache npm downloads, for example, saves the cache for next time.

Two warnings worth noting

Neither stopped the run:

  1. “Node.js 20 is deprecated” for upload-artifact@v4 (used inside upload-pages-artifact) and deploy-pages. This is about the Node version the actions themselves run on, not my site’s Node 22. GitHub is already running them on Node 24. Newer versions exist, and Dependabot should propose new pinned hashes.
  2. “The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026.” ubuntu-latest is a moving label, like a tag. My options are to leave it or pin runs-on: ubuntu-24.04. I’ll decide once everything else is settled, so I’m not changing two things at once.

The first view

The page at https://ny2honolulu.github.io/notes/ showed my homepage, a date of Oct 08, 2026 pulled from git history, working search and theme toggles, an empty Explorer (one note only), a graph with one dot, and my footer links. Settings → Pages confirmed “Your site is live”, deployed by the Deploy notes site to GitHub Pages workflow.

ny2honolulu.github.io/notes/

Step 5: Verify the domain with GitHub

ny2honolulu.com is now verified with GitHub, so no other GitHub account can claim any of its subdomains for Pages; I did this before creating any DNS record that points at GitHub.

Why verify first: subdomain takeover

Once a DNS record sends notes.ny2honolulu.com to GitHub, any GitHub user could claim that name for their own Pages site if my repo ever stopped using it, and serve their content on my subdomain. Bug bounty hunters find this all the time. Verifying the main domain ties it, and every subdomain under it, to my account.

DNS recon on my own domain

Before touching anything, I checked where my DNS lives and what the name already resolved to:

dig NS ny2honolulu.com +short
dig notes.ny2honolulu.com +short
dig ny2honolulu.com +short
dig made-up-name-12345.ny2honolulu.com +short
QueryAnswerMeaning
NS ny2honolulu.comns21.worldnic.com, ns22.worldnic.comNetwork Solutions runs my DNS
notes.ny2honolulu.com209.17.116.165Already resolves, though I never created it
ny2honolulu.com209.17.116.165My blog’s server
made-up-name-12345.ny2honolulu.com209.17.116.165A name I invented still resolves

The last line proves a wildcard record (*.ny2honolulu.com) sends every subdomain to my blog’s server. Testing a made-up name is exactly how subdomain enumeration tools detect wildcards before they start guessing.

The wildcard didn’t block the deploy, because a specific record always beats a wildcard for its own name. It does widen my attack surface: anyone can build a link like login.ny2honolulu.com that really resolves to my server, which is handy for phishing.

What the DNS records list showed

In Network Solutions, the records live under Domain → Advanced Tools → Advanced DNS Records → Manage. Of the three “Manage” links in that box, only the middle one is the records editor; the left one changes nameservers, which would move my whole domain, blog and email included. A warning dialog about advanced users appeared first; I left “Don’t show this again” unticked.

TypeNamePoints toNotes
A*209.17.116.165The wildcard
Acountdown185.158.133.1My Lovable app
A@209.17.116.165My blog
Awww205.178.145.65A different server from my blog
CNAMEautodiscover, imap, mail, pop, smtpNetwork Solutions mailmail is missing a dot: mail.ny2honolulu.comnetsolmail.net
MX@ (×4)mx001–mx004.netsol.xion.oxcs.netEmail delivery
TXT@, _dmarc, _lovable.countdownSPF, DMARC, Lovable verificationDMARC lists my email address publicly

Every real subdomain has its own record, so nothing visible depends on the wildcard; I left it in place to remove later, one change at a time. The same screens showed DNSSEC disabled and, on the domain overview, WHOIS privacy off. The overview also had an upsell box with three extra domains already ticked for checkout, which I scrolled past.

Adding the verification record

On GitHub: profile picture → Settings (account settings, not the repo’s) → Pages → Add a domain → ny2honolulu.com. GitHub showed a TXT record to create:

FieldValue
TypeTXT
Host_github-pages-challenge-ny2honolulu
Value7ffcde4d042281dcdb5c2da85efc43
TTL15 minutes

In Network Solutions, the host box is a dropdown called Refers to. Choosing Other Host reveals a text box; the form adds .ny2honolulu.com itself, so typing the full name would have produced a doubled domain. I set the TTL to the shortest option, so a mistake would clear quickly.

Before clicking Verify, I asked Network Solutions’ own name server directly, skipping any cache:

dig TXT _github-pages-challenge-ny2honolulu.ny2honolulu.com @ns21.worldnic.com +short

It answered with the exact code within a minute or two. GitHub’s Verify button then returned “Successfully verified ny2honolulu.com”.

This TXT record stays forever. GitHub re-checks it, and deleting it during a future DNS cleanup would quietly remove the takeover protection.

Step 6: Connect notes.ny2honolulu.com

notes.ny2honolulu.com now serves my site from GitHub; the HTTPS certificate is the only piece still pending.

Tell GitHub first, then DNS

On the repo’s Pages page (github.com/ny2honolulu/notes/settings/pages), I typed notes.ny2honolulu.com under Custom domain and clicked Save. (The first time, I typed it but didn’t save; the page didn’t change until I did.) GitHub switched the site’s address and started a DNS check that would fail until the record existed.

There are two different Pages settings pages, which confused me at first:

PageAddressWhat it’s for
Account Pagesgithub.com/settings/pagesWhich domains I own
Repo Pagesgithub.com/ny2honolulu/notes/settings/pagesWhich site this repo publishes, and where

One caution from this moment: the old github.io address now redirects to the new name, which still led to my blog’s server through the wildcard. The site was briefly unreachable until the CNAME existed, so I added it right away.

The CNAME record

FieldValue
TypeCNAME
Refers toOther Host: notes
Alias tony2honolulu.github.io
TTL15 minutes

The value is a server name, not a page: no https://, no /notes, no trailing slash. GitHub uses the custom domain setting to know which of my repos to serve. Network Solutions first rejected the value with “Alias to contains invalid characters”, because a space had come along with the copied text. Clearing the box with Ctrl + A and typing the name by hand fixed it.

Checking it from the terminal

dig CNAME notes.ny2honolulu.com @ns21.worldnic.com +short
dig notes.ny2honolulu.com +short

The authoritative server answered ny2honolulu.github.io. (the trailing dot marks a complete name). My normal resolver returned the CNAME plus GitHub’s four addresses, 185.199.108.153 through 185.199.111.153. The wildcard still answers for invented names, which shows the specific record overrode it only for notes. GitHub’s page changed to “DNS check successful”.

When dig and curl disagreed

My first curl returned a 404 from Server: openresty, not GitHub:

curl -sI http://notes.ny2honolulu.com/

OpenResty is a web server built on nginx, very likely the one on my blog’s host, which has no site called notes. So curl had reached the old wildcard address while dig showed GitHub.

ToolHow it looks up a name
digAsks the DNS server directly, skipping my computer’s cache
curl, browsersAsk the operating system, which can cache earlier answers

I checked what the operating system itself returned:

getent hosts notes.ny2honolulu.com
getent ahostsv4 notes.ny2honolulu.com

By then both IPv6 (2606:50c0:8000::153 and siblings) and IPv4 answers pointed at GitHub. My guess that a stale IPv4 entry was to blame turned out to be wrong. The likeliest explanation is that a cached answer from my earlier lookups expired in the minutes between the two checks. Running the same curl again returned the right server:

HeaderValueMeaning
StatusHTTP/1.1 200 OKThe page exists
ServerGitHub.comServed by GitHub
Last-Modified21:54:46 GMT11:54 local, the minute my workflow deployed
Cache-Controlmax-age=600New notes can take up to 10 minutes to appear everywhere
Via, X-Served-By, X-Fastly-Request-IDvarnish, cache-phx...Delivered through the Fastly CDN, from a Phoenix edge server
X-CacheMISSThe edge fetched it fresh; the next visitor gets a HIT

The lesson: right after a DNS change, different tools can disagree for a while because each has its own cache. Comparing several tools tells a real problem from one that is just waiting to expire.

HTTPS: waiting on the certificate

GitHub’s Pages page reads “Your site is live at http://notes.ny2honolulu.com/” and Enforce HTTPS is unavailable because “a certificate has not yet been issued for your domain.” GitHub requests one from Let’s Encrypt once DNS checks out, which can take up to an hour. The Strict-Transport-Security header, which tells browsers to always use HTTPS, is missing until then.

One setting can block a certificate forever: a CAA record listing which certificate authorities may issue for a domain.

dig CAA ny2honolulu.com +short

It printed nothing, so no CAA record exists and Let’s Encrypt isn’t blocked. The DNS check briefly showed “in progress” again after a later deploy; GitHub re-runs it after every deployment.

Update pending: when Enforce HTTPS becomes available, I’ll tick it and add the result and a screenshot here. If it is still stuck after a few hours, GitHub’s documented fix is to remove the custom domain and save it again.

Step 7: Protect the v5 branch

A ruleset named protect-v5 now stops anyone, me included, from deleting v5 or force-pushing to it.

Since every push to v5 now publishes the site, two mistakes became much more expensive:

  • A force push rewrites history and can erase commits on GitHub. With a stolen token, it is also how an attacker would hide their changes.
  • Deleting the branch would break every future deploy.

GitHub’s modern method is a ruleset: Settings → Rulesets → New ruleset → New branch ruleset.

SettingValueWhy
Nameprotect-v5
Enforcement statusActive“Disabled” rules exist but do nothing
Bypass listEmptyNo exceptions, which is the point
Target branchesInclude default branchGitHub resolved this to v5
Restrict deletionsOn
Block force pushesOn
Everything elseOff

What I left off, and why:

  • Require a pull request before merging would block my normal git push. I work alone, so it adds friction without protection.
  • Require status checks to pass has nothing to require: my only workflow runs after the push.
  • Require signed commits is a worthwhile upgrade, but it needs a signing key set up first.

GitHub confirmed “Ruleset created” and “Applies to 1 target: v5”. My next ordinary git push went through, which showed the ruleset blocks force pushes and deletion, not regular work.

Step 8: Prove the privacy filter on the live site

GitHub’s build filtered out an unpublished test note, and the live site returned 404 for it while the homepage returned 200.

Phase 2 proved ExplicitPublish on my desktop. The real test is GitHub’s build and the public site. My repo is public, so the test note’s text is visible on GitHub either way; its content is deliberately harmless.

cat > content/privacy-test.md <<'EOF'
---
title: Privacy test
---

This note has no publish: true, so it must never appear on the site.
EOF
git add content/privacy-test.md
git commit -m "Add unpublished privacy test note (should be filtered out)"
git push

By the time I ran gh run watch, the 35-second build had already finished. I listed recent runs and pulled the filter’s line from the newest run’s log without copying any IDs by hand:

gh run list --limit 3
gh run view "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --log | grep -i "filtered out"

The $( ... ) part runs first and supplies the newest run’s ID. (My first attempt passed the literal placeholder RUN_ID, which GitHub answered with a 404.) The result:

build   Build site   2026-10-10T23:05:22Z   Filtered out 1 files in 109μs

Then the live site, asking only for the HTTP status code:

curl -s -o /dev/null -w '%{http_code}\n' http://notes.ny2honolulu.com/privacy-test
curl -s -o /dev/null -w '%{http_code}\n' http://notes.ny2honolulu.com/privacy-test.html
curl -s -o /dev/null -w '%{http_code}\n' http://notes.ny2honolulu.com/
CheckResultWhat it proves
GitHub build logFiltered out 1 filesExplicitPublish runs in GitHub’s build
/privacy-test404The private note isn’t served
/privacy-test.html404Not under the other spelling either
/ (homepage)200The site is up, so the 404s are real refusals

The homepage check is the positive control from Phase 2: a 404 only means something when a page that should exist returns 200. With the proof done, I removed the test note:

git rm content/privacy-test.md
git commit -m "Remove privacy test note (filter proven on live site)"
git push

Lessons learned

  1. A green check proves nothing until you know what ran. My Phase 2 “checks passed” were skipped jobs.
  2. Inherited automation is code you haven’t read. Six workflows came with the template; reading them took minutes, and deleting them removed 408 lines of attack surface.
  3. Read inside the group update. “11 updates” hid a major version and a deliberate breaking change in a minor one.
  4. Pin what can move. Action tags, ^1.0.0 version ranges, ubuntu-latest: each can quietly change underneath a build. Hashes, lockfiles and npm ci don’t.
  5. Give each job only the permissions it needs. My build job can read code and nothing else.
  6. Verify the domain before pointing DNS anywhere. Order matters for subdomain takeover.
  7. Recon your own infrastructure. Four dig commands found a wildcard record, a stale-looking www address, and a broken mail record I didn’t know about.
  8. Different tools, different caches. When dig and curl disagree right after a DNS change, compare them before assuming something is broken.
  9. Prove controls where they matter. A privacy filter tested only on my desktop wasn’t proven for the public site.
  10. Copy-paste is a real source of errors. An empty file, a command saved inside a file, a stray space, a ) in a URL, a literal RUN_ID: each was caught by checking the result.

Connections to what I’m studying

AWS DVA-C02:

In this phaseAWS equivalent
build and deploy jobs, needs: buildCodeBuild and CodeDeploy as CodePipeline stages
deploy.ymlbuildspec.yml
npm ci from a lockfileThe install phase of a repeatable CodeBuild build
id-token: writeOIDC and AssumeRoleWithWebIdentity, CI/CD without stored keys
Per-job permissionsSeparate IAM service roles for build and deploy
secrets.* in the old workflowsSecrets Manager instead of credentials in code
TXT domain verificationACM DNS validation
CNAME to ny2honolulu.github.ioA CNAME or Route 53 alias to a CloudFront distribution
Wildcard and dangling recordsRoute 53 wildcards and takeover of deleted S3 or Elastic Beanstalk names
CAA recordsCAA must allow Amazon or ACM can’t issue
Short TTLs before changesLowering TTLs ahead of a Route 53 migration
Fastly headers, max-age=600CloudFront edge caching and Cache-Control on S3 objects
Node 20 deprecation warningsLambda runtime deprecation
Ruleset blocking deletionCloudFormation termination protection, RDS deletion protection

Black Hat Python:

  • Testing a made-up subdomain is wildcard detection, the first step of any subdomain enumeration tool.
  • Subdomain takeover is a classic recon finding: resolve subdomains, flag any that land on an unclaimed GitHub Pages site.
  • curl -I is a banner grab. Server: openresty versus Server: GitHub.com told me which machine I had really reached.
  • WHOIS and the DMARC record are OSINT sources that expose names and email addresses.
  • A maintainer change on a package is the supply-chain version of account takeover.
  • persist-credentials: false defends against post-exploitation credential hunting on a build machine.
  • Command substitution, $( ... ), is the same mechanism behind command injection.
  • An invisible character breaking input validation is why allowlists of permitted characters matter.

Follow-up list

These came up during Phase 3 and are left for later sessions:

  • Tick Enforce HTTPS once GitHub issues the certificate
  • Remove the wildcard * A record (nothing depends on it)
  • Check why www points to 205.178.145.65 instead of my blog’s 209.17.116.165
  • Fix the mail CNAME’s missing dot
  • Decide on WHOIS privacy
  • Plan turning on DNSSEC
  • Confirm the next Dependabot Updates run is green (one failed while I deleted the old workflows)
  • Test parked PRs #4 (Preact 11) and #1 (TypeScript 7) against the live baseline
  • Handle the Node 20 action warnings and the ubuntu-latest move to Ubuntu 26 on October 19
  • Change my GitHub profile URL from http:// to https://
  • Consider requiring signed commits on v5
  • Add the site’s address to the repo’s About box and tick Deployments

Maintenance notes

  • Publishing a note is now a push: commit to v5, push, and the site updates in about a minute (up to 10 more for caches).
  • Never delete the _github-pages-challenge-ny2honolulu TXT record. It keeps the domain verified.
  • After pulling Quartz updates from upstream, run ls .github/workflows/: the six deleted workflows still exist in Quartz’s repo and could come back.
  • If I add a plugin from a git repo, restore the plugin install step and commit the quartz.lock.json it creates.
  • The filter controls the website, not the repo. Anything committed to this public repo is public.

Command reference

Every command used in Phase 3, in the order I used them. All were run from ~/projects/notes, one at a time.

CommandPurpose
cd ~/projects/notesMove into the project folder
git statusShow the branch and any uncommitted changes
git pullDownload changes from GitHub
ls -la .github/workflows/List the workflow files
grep -n -i -E "baseUrl|cname" quartz.config.yamlFind the site address and CNAME plugin lines
grep -n -A4 "cname" quartz.config.yamlShow the CNAME plugin and the 4 lines after it
cat .github/workflows/<file>.yamlPrint a workflow file to read it
grep -n "github.repository" .github/workflows/*.yamlFind each workflow’s repo guard
git rm .github/workflows/*.yamlDelete the workflows and stage the deletion
git commit -m "<message>"Record staged changes
git pushUpload commits to GitHub
git remote -vList remotes and their addresses
git remote set-url --push upstream DISABLEDMake pushes to upstream impossible
gh repo set-default ny2honolulu/notesTell the GitHub CLI which repo this folder is
gh pr listList open pull requests
gh pr view 4Read a pull request’s description
gh pr diff 4 --name-onlyList the files a pull request changes
npm auditCheck dependencies for known vulnerabilities
grep -n -A3 '"engines"' package.jsonShow the required Node and npm versions
gh api repos/actions/<name>/commits/<tag> --jq .shaGet the commit hash behind an action’s tag
ls -l quartz.lock.jsonCheck whether the plugin lockfile exists
ls .quartz/plugins/Check for git-installed plugins
git check-ignore -v quartz.lock.jsonCheck whether git ignores a file
ls node_modules/@quartz-community/List the npm-installed plugins
grep -n "@quartz-community" package.jsonConfirm plugins are npm dependencies
npx quartz plugin install --from-config --dry-runPreview a plugin install without changing anything
mkdir -p .github/workflowsRecreate the workflows folder
cat > <file> <<'EOF' ... EOFWrite a file from the terminal, exactly as typed
wc -l <file>Count a file’s lines
grep -c EOF <file>Count lines containing EOF
npm ciReinstall exactly what the lockfile lists
npx quartz buildBuild the site into public
cat .node-versionShow the template’s requested Node version
node --versionShow the Node version actually in use
git add .github/workflows/deploy.ymlStage one file by name
gh run watchWatch a workflow run live
dig NS ny2honolulu.com +shortFind which name servers run the domain
dig <name> +shortLook up where a name points
dig TXT <name> @ns21.worldnic.com +shortAsk the authoritative server for a TXT record
dig CNAME <name> @ns21.worldnic.com +shortAsk the authoritative server for a CNAME record
curl -sI <url>Fetch only a page’s response headers
getent hosts <name>Look up a name the way the operating system does
getent ahostsv4 <name>Same, IPv4 only
curl -s -o /dev/null -w '%{remote_ip}\n' <url>Show which IP curl connected to
dig CAA ny2honolulu.com +shortCheck for records restricting certificate issuers
gh run list --limit 3List the 3 newest workflow runs
gh run view "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --log | grep -i "filtered out"Pull the privacy filter’s line from the newest run’s log
curl -s -o /dev/null -w '%{http_code}\n' <url>Print only a page’s HTTP status code
git rm content/privacy-test.mdDelete the test note and stage the deletion

Browser and DNS settings changed

WhereSettingValue
GitHub repo → Settings → PagesSourceGitHub Actions
GitHub repo → Settings → PagesCustom domainnotes.ny2honolulu.com
GitHub account → Settings → PagesVerified domainsny2honolulu.com
GitHub repo → Settings → Rulesetsprotect-v5Active; restrict deletions, block force pushes
Network Solutions → Advanced DNS RecordsTXT _github-pages-challenge-ny2honolulu7ffcde4d042281dcdb5c2da85efc43, 15 min
Network Solutions → Advanced DNS RecordsCNAME notesny2honolulu.github.io, 15 min

Commits in Phase 3

CommitMessage
e669058Remove privacy test note (filter proven on live site)
0b0c75bAdd unpublished privacy test note (should be filtered out)
8de9b69Add GitHub Pages deploy workflow (pinned actions, least-privilege permissions)
7e16232Remove upstream Quartz workflows (all guarded to jackyzha0/quartz)

This is Part 3 of a series. Part 1: Local Build · Part 2: Privacy, Proof, and a Clean Commit