- What Phase 3 delivered
- Step 1: Inventory the repo
- Step 2: Review the Dependabot pull requests
- Step 3: Write the deploy workflow
- Step 4: Turn on GitHub Pages and deploy
- Step 5: Verify the domain with GitHub
- Step 6: Connect notes.ny2honolulu.com
- Step 7: Protect the v5 branch
- Step 8: Prove the privacy filter on the live site
- Lessons learned
- Connections to what I’m studying
- Follow-up list
- Maintenance notes
- Command reference
- Browser and DNS settings changed
- Commits in Phase 3
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):
- Six inherited workflows were removed. None of them had ever run on my repo, including the “checks” that showed green in Phase 2.
- Two Dependabot pull requests were reviewed and parked. One hid a major version jump inside a “routine” group update.
- 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.
- My domain is verified with GitHub. Nobody else can claim any
ny2honolulu.comsubdomain on GitHub Pages. notes.ny2honolulu.compoints at GitHub. DNS recon along the way found a wildcard record and two other problems on my main domain.- The
v5branch is protected. It can’t be deleted or force-pushed, by anyone, including me. - 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:
| Step | What | Why in this order |
|---|---|---|
| 1 | Inventory the repo | Know what’s there before building on it |
| 2 | Review Dependabot PRs | Decide on dependency changes before the first public build |
| 3 | Write and rehearse the deploy workflow | The robot that builds and publishes the site |
| 4 | Turn on GitHub Pages, first deploy | Prove the build works on GitHub’s machines |
| 5 | Verify the domain with GitHub | Block subdomain takeover before DNS points anywhere |
| 6 | CNAME record, custom domain, HTTPS | Connect the name to the site |
| 7 | Branch protection | Guard the branch that now publishes on every push |
| 8 | Privacy proof on the live site | Phase 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 branchv5, clean, up to date with GitHub.baseUrl: notes.ny2honolulu.com(line 9): correct, and written withouthttps://, 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
CNAMEfile 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.yamldeploys to Cloudflare Pages using the Quartz author’s secrets, builds thedocsfolder (Quartz’s own documentation, not my notes), and uses a third-party action pinned only to the tag@v1.dependabot-automerge.yamlmerges 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 withset -euo pipefail. It also runs on theworkflow_runtrigger 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.
| File | Job guarded? | Would have done |
|---|---|---|
build-preview.yaml | Yes | Preview builds for pull requests |
ci.yaml | Yes (both jobs) | Tests and builds |
dependabot-automerge.yaml | Yes | Auto-merge Dependabot PRs |
deploy-preview.yaml | Yes | Deploy PR previews |
deploy-v5.yaml | Yes | Deploy Quartz’s docs to Cloudflare |
docker-build-push.yaml | Yes | Build 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
| Remote | Address | What it is |
|---|---|---|
origin | ny2honolulu/notes | My repo |
upstream | jackyzha0/quartz | The 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
| PR | Title | Files changed |
|---|---|---|
| #4 | 11 production dependency updates, grouped | package.json, package-lock.json |
| #1 | TypeScript 5.9.3 → 7.0.2 | package.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”
- 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. - A breaking change in a minor version.
preact-render-to-string6.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. - 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
| Change | Quartz docs | My workflow | Why |
|---|---|---|---|
| Permissions | pages: write and id-token: write for the whole workflow | permissions: {} by default; build gets contents: read, deploy gets the two it needs | Least privilege: the build job can’t publish anything |
| Actions | Tags like @v6 | Full 40-character commit hashes, with # v6 comments | A tag can be moved to different code; a hash can’t |
| Node | 24 | 22 | Matches the version my desktop builds with |
| Plugin steps | Cache and install Quartz plugins | Removed | All 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
| Source | Says |
|---|---|
package.json engines | Node >=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 tov5;workflow_dispatchadds a manual “Run workflow” button.concurrency:makes a second deploy wait for the first instead of both publishing at once.fetch-depth: 0fetches the full history, so Quartz can date each note from git.persist-credentials: falseleaves no access token on disk after checkout, since nothing later needs it.npm ciinstalls exactly what the lockfile lists, which keeps my Phase 1sharpandsource-map-jsfixes.needs: buildmeans a failed build never reaches the deploy job.id-token: writelets 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:
| Job | Time | Steps |
|---|---|---|
| build | 22 s | Check out, Node, cache, npm ci, build, upload, cleanup |
| deploy | 8 s | Deploy 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:
- “Node.js 20 is deprecated” for
upload-artifact@v4(used insideupload-pages-artifact) anddeploy-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. - “The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026.”
ubuntu-latestis a moving label, like a tag. My options are to leave it or pinruns-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.
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
| Query | Answer | Meaning |
|---|---|---|
NS ny2honolulu.com | ns21.worldnic.com, ns22.worldnic.com | Network Solutions runs my DNS |
notes.ny2honolulu.com | 209.17.116.165 | Already resolves, though I never created it |
ny2honolulu.com | 209.17.116.165 | My blog’s server |
made-up-name-12345.ny2honolulu.com | 209.17.116.165 | A 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.
| Type | Name | Points to | Notes |
|---|---|---|---|
| A | * | 209.17.116.165 | The wildcard |
| A | countdown | 185.158.133.1 | My Lovable app |
| A | @ | 209.17.116.165 | My blog |
| A | www | 205.178.145.65 | A different server from my blog |
| CNAME | autodiscover, imap, mail, pop, smtp | Network Solutions mail | mail is missing a dot: mail.ny2honolulu.comnetsolmail.net |
| MX | @ (×4) | mx001–mx004.netsol.xion.oxcs.net | Email delivery |
| TXT | @, _dmarc, _lovable.countdown | SPF, DMARC, Lovable verification | DMARC 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:
| Field | Value |
|---|---|
| Type | TXT |
| Host | _github-pages-challenge-ny2honolulu |
| Value | 7ffcde4d042281dcdb5c2da85efc43 |
| TTL | 15 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:
| Page | Address | What it’s for |
|---|---|---|
| Account Pages | github.com/settings/pages | Which domains I own |
| Repo Pages | github.com/ny2honolulu/notes/settings/pages | Which 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
| Field | Value |
|---|---|
| Type | CNAME |
| Refers to | Other Host: notes |
| Alias to | ny2honolulu.github.io |
| TTL | 15 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.
| Tool | How it looks up a name |
|---|---|
dig | Asks the DNS server directly, skipping my computer’s cache |
curl, browsers | Ask 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:
| Header | Value | Meaning |
|---|---|---|
| Status | HTTP/1.1 200 OK | The page exists |
Server | GitHub.com | Served by GitHub |
Last-Modified | 21:54:46 GMT | 11:54 local, the minute my workflow deployed |
Cache-Control | max-age=600 | New notes can take up to 10 minutes to appear everywhere |
Via, X-Served-By, X-Fastly-Request-ID | varnish, cache-phx... | Delivered through the Fastly CDN, from a Phoenix edge server |
X-Cache | MISS | The 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.
| Setting | Value | Why |
|---|---|---|
| Name | protect-v5 | |
| Enforcement status | Active | “Disabled” rules exist but do nothing |
| Bypass list | Empty | No exceptions, which is the point |
| Target branches | Include default branch | GitHub resolved this to v5 |
| Restrict deletions | On | |
| Block force pushes | On | |
| Everything else | Off |
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/
| Check | Result | What it proves |
|---|---|---|
| GitHub build log | Filtered out 1 files | ExplicitPublish runs in GitHub’s build |
/privacy-test | 404 | The private note isn’t served |
/privacy-test.html | 404 | Not under the other spelling either |
/ (homepage) | 200 | The 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
- A green check proves nothing until you know what ran. My Phase 2 “checks passed” were skipped jobs.
- 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.
- Read inside the group update. “11 updates” hid a major version and a deliberate breaking change in a minor one.
- Pin what can move. Action tags,
^1.0.0version ranges,ubuntu-latest: each can quietly change underneath a build. Hashes, lockfiles andnpm cidon’t. - Give each job only the permissions it needs. My build job can read code and nothing else.
- Verify the domain before pointing DNS anywhere. Order matters for subdomain takeover.
- Recon your own infrastructure. Four
digcommands found a wildcard record, a stale-lookingwwwaddress, and a broken mail record I didn’t know about. - Different tools, different caches. When
digandcurldisagree right after a DNS change, compare them before assuming something is broken. - Prove controls where they matter. A privacy filter tested only on my desktop wasn’t proven for the public site.
- 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 literalRUN_ID: each was caught by checking the result.
Connections to what I’m studying
AWS DVA-C02:
| In this phase | AWS equivalent |
|---|---|
build and deploy jobs, needs: build | CodeBuild and CodeDeploy as CodePipeline stages |
deploy.yml | buildspec.yml |
npm ci from a lockfile | The install phase of a repeatable CodeBuild build |
id-token: write | OIDC and AssumeRoleWithWebIdentity, CI/CD without stored keys |
| Per-job permissions | Separate IAM service roles for build and deploy |
secrets.* in the old workflows | Secrets Manager instead of credentials in code |
| TXT domain verification | ACM DNS validation |
CNAME to ny2honolulu.github.io | A CNAME or Route 53 alias to a CloudFront distribution |
| Wildcard and dangling records | Route 53 wildcards and takeover of deleted S3 or Elastic Beanstalk names |
| CAA records | CAA must allow Amazon or ACM can’t issue |
| Short TTLs before changes | Lowering TTLs ahead of a Route 53 migration |
Fastly headers, max-age=600 | CloudFront edge caching and Cache-Control on S3 objects |
| Node 20 deprecation warnings | Lambda runtime deprecation |
| Ruleset blocking deletion | CloudFormation 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 -Iis a banner grab.Server: openrestyversusServer: GitHub.comtold 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: falsedefends 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
wwwpoints to205.178.145.65instead of my blog’s209.17.116.165 - Fix the
mailCNAME’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-latestmove to Ubuntu 26 on October 19 - Change my GitHub profile URL from
http://tohttps:// - 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-ny2honoluluTXT record. It keeps the domain verified. - After pulling Quartz updates from
upstream, runls .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.jsonit 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.
| Command | Purpose |
|---|---|
cd ~/projects/notes | Move into the project folder |
git status | Show the branch and any uncommitted changes |
git pull | Download changes from GitHub |
ls -la .github/workflows/ | List the workflow files |
grep -n -i -E "baseUrl|cname" quartz.config.yaml | Find the site address and CNAME plugin lines |
grep -n -A4 "cname" quartz.config.yaml | Show the CNAME plugin and the 4 lines after it |
cat .github/workflows/<file>.yaml | Print a workflow file to read it |
grep -n "github.repository" .github/workflows/*.yaml | Find each workflow’s repo guard |
git rm .github/workflows/*.yaml | Delete the workflows and stage the deletion |
git commit -m "<message>" | Record staged changes |
git push | Upload commits to GitHub |
git remote -v | List remotes and their addresses |
git remote set-url --push upstream DISABLED | Make pushes to upstream impossible |
gh repo set-default ny2honolulu/notes | Tell the GitHub CLI which repo this folder is |
gh pr list | List open pull requests |
gh pr view 4 | Read a pull request’s description |
gh pr diff 4 --name-only | List the files a pull request changes |
npm audit | Check dependencies for known vulnerabilities |
grep -n -A3 '"engines"' package.json | Show the required Node and npm versions |
gh api repos/actions/<name>/commits/<tag> --jq .sha | Get the commit hash behind an action’s tag |
ls -l quartz.lock.json | Check whether the plugin lockfile exists |
ls .quartz/plugins/ | Check for git-installed plugins |
git check-ignore -v quartz.lock.json | Check whether git ignores a file |
ls node_modules/@quartz-community/ | List the npm-installed plugins |
grep -n "@quartz-community" package.json | Confirm plugins are npm dependencies |
npx quartz plugin install --from-config --dry-run | Preview a plugin install without changing anything |
mkdir -p .github/workflows | Recreate the workflows folder |
cat > <file> <<'EOF' ... EOF | Write 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 ci | Reinstall exactly what the lockfile lists |
npx quartz build | Build the site into public |
cat .node-version | Show the template’s requested Node version |
node --version | Show the Node version actually in use |
git add .github/workflows/deploy.yml | Stage one file by name |
gh run watch | Watch a workflow run live |
dig NS ny2honolulu.com +short | Find which name servers run the domain |
dig <name> +short | Look up where a name points |
dig TXT <name> @ns21.worldnic.com +short | Ask the authoritative server for a TXT record |
dig CNAME <name> @ns21.worldnic.com +short | Ask 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 +short | Check for records restricting certificate issuers |
gh run list --limit 3 | List 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.md | Delete the test note and stage the deletion |
Browser and DNS settings changed
| Where | Setting | Value |
|---|---|---|
| GitHub repo → Settings → Pages | Source | GitHub Actions |
| GitHub repo → Settings → Pages | Custom domain | notes.ny2honolulu.com |
| GitHub account → Settings → Pages | Verified domains | ny2honolulu.com |
| GitHub repo → Settings → Rulesets | protect-v5 | Active; restrict deletions, block force pushes |
| Network Solutions → Advanced DNS Records | TXT _github-pages-challenge-ny2honolulu | 7ffcde4d042281dcdb5c2da85efc43, 15 min |
| Network Solutions → Advanced DNS Records | CNAME notes | ny2honolulu.github.io, 15 min |
Commits in Phase 3
| Commit | Message |
|---|---|
e669058 | Remove privacy test note (filter proven on live site) |
0b0c75b | Add unpublished privacy test note (should be filtered out) |
8de9b69 | Add GitHub Pages deploy workflow (pinned actions, least-privilege permissions) |
7e16232 | Remove 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