How the Hugo site in this repository becomes blowhorn.ai: the build, the GitHub Pages deployment, the custom domain, the DNS records the registrar needs, and how to verify a deployment.
site.yml runs on every pull request and on
every push to master:
- It installs the pinned toolchain: Go from
go.mod(Hugo fetches Docsy as a Hugo module), Node.js from.nvmrc, and Hugo extended and postcss frompackage.jsonwithnpm ci. The checkout has full history so each page's last-modified date (sitemap,llms-full.txt) comes from git. make workflow-checklints the workflows with actionlint, andmake test-scriptsruns the unit tests for the check scripts below. Every step is a make target (make setup-ciinstalls the dependencies);make ciruns the whole job locally in the same order.make site-checkbuilds for production intopublic/once (hugo --minify --gc, which minifies CSS and JavaScript and leaves HTML readable), then checks that output:make validate-html: html-validate on every page;make check-links: every local link, image, font andsrcsetreference resolves (check-site-links.py);make check-third-party: no page, stylesheet or script it loads requests anything from another host (check-third-party.py), because the privacy page promises it. The one exception is the Download section's release lookup onapi.github.com(assets/js/download.js), which the privacy page discloses; it is the only host besides github.com (links) that a script may name;make check-urls: every public URL and every anchor the site has published still exists (check-site-contract.py).
public/is uploaded as the Pages artifact.- On
masteronly (a push or a manual run), thedeployjob publishes that artifact withactions/deploy-pagesunder thegithub-pagesenvironment. The job needspages: writeandid-token: write, which it declares itself.
A pull request never deploys. Its run still uploads the built site as the
github-pages artifact, which you can download from the run to inspect.
gh-axi run list -R layer5io/blowhorn-site --workflow site.ymlThe newest run on master should show both jobs green. The deployment is live
at the Pages URL within a minute of the deploy job finishing:
- https://blowhorn.ai/ once DNS points at GitHub Pages (below).
- https://layer5io.github.io/blowhorn-site/ is the project URL. Because the
custom domain is already set on Pages, GitHub redirects this URL to
https://blowhorn.ai/, so nothing is reachable there until the DNS records below are in place. The artifact itself deploys fine either way.
Pages is configured to publish from GitHub Actions (build_type: workflow),
not from a branch. The one-time setup, done with an account that administers
the repository:
gh-axi api -X POST repos/layer5io/blowhorn-site/pages --field build_type=workflow
# or, if Pages already exists:
gh-axi api -X PUT repos/layer5io/blowhorn-site/pages --field build_type=workflowThis was done on 2026-10-08: Pages exists with build_type: workflow and the
custom domain blowhorn.ai. HTTPS enforcement is set once GitHub issues the
certificate, which needs the DNS records below.
static/CNAME (published as /CNAME) carries blowhorn.ai for completeness. With a workflow
deployment GitHub ignores the file; the custom domain is the one set through
the API or Settings > Pages, below.
The site is published under blowhorn.ai. www.blowhorn.ai is optional:
when its record also points at GitHub, Pages redirects it to the apex.
blowhorn.ai is registered at Porkbun and its DNS is served by Cloudflare. Only the domain's owner can change records. Today the apex resolves to Cloudflare's proxy addresses and answers HTTP 526: the record is proxied, so GitHub cannot verify the origin or issue a certificate. Those records are what the ones below replace.
Pick one of the two forms. Either way the record is DNS only (grey cloud): keep the Cloudflare proxy off at least until GitHub has issued the certificate, because the proxy hides the origin from GitHub's certificate check.
Form A, a flattened CNAME (Cloudflare resolves it to GitHub's addresses itself):
| Type | Name | Target | Proxy |
|---|---|---|---|
| CNAME | @ |
layer5io.github.io |
DNS only |
Form B, GitHub Pages' fixed addresses:
| Type | Name | Value |
|---|---|---|
| A | @ |
185.199.108.153 |
| A | @ |
185.199.109.153 |
| A | @ |
185.199.110.153 |
| A | @ |
185.199.111.153 |
| AAAA | @ |
2606:50c0:8000::153 |
| AAAA | @ |
2606:50c0:8001::153 |
| AAAA | @ |
2606:50c0:8002::153 |
| AAAA | @ |
2606:50c0:8003::153 |
Wait until the apex resolves to GitHub:
dig +short A blowhorn.ai # expect the four 185.199.* addresses
dig +short AAAA blowhorn.ai # expect the four 2606:50c0:* addressesThe Pages custom domain is already blowhorn.ai. Confirm it, watch the
certificate, and enforce HTTPS once GitHub reports it issued (Settings > Pages
shows "Enforce HTTPS" as available):
gh-axi api repos/layer5io/blowhorn-site/pages --jq '.cname, .https_certificate.state'
printf '{"https_enforced":true}' | gh-axi api -X PUT repos/layer5io/blowhorn-site/pages --input -If the custom domain ever has to be set again:
gh-axi api -X PUT repos/layer5io/blowhorn-site/pages --field cname=blowhorn.aiCertificate issuance usually takes a few minutes and can take up to a day. If it stalls, the usual cause is the Cloudflare proxy being on for the record.
| Type | Name | Target | Proxy |
|---|---|---|---|
| CNAME | www |
layer5io.github.io |
DNS only |
With blowhorn.ai as the Pages custom domain and this record in place, GitHub
answers www.blowhorn.ai with a redirect to https://blowhorn.ai/.
dig +short CNAME www.blowhorn.ai # expect: layer5io.github.io.
curl -sI https://www.blowhorn.ai # expect 301 to https://blowhorn.ai/Every page and asset reference is root-relative (/css/site.<hash>.css,
/assets/brand/...), built from baseURL in hugo.toml
(https://blowhorn.ai/). That is what lets 404.html work for any missing
path, nested ones included. A build with a different base URL
(make build-production BASE_URL=... or make build-preview) prefixes every
reference with that URL's path. The project URL
layer5io.github.io/blowhorn-site/ redirects to blowhorn.ai, so nothing is
served under that path in production.
The public URLs are a contract, checked by make site-check:
/, and the Trust Center:/legal/,/legal/privacy/and/legal/terms/(content/en/legal/), with Markdown copies at/legal/index.md,/legal/privacy/index.mdand/legal/terms/index.md.- The old
/privacy.htmland/terms.htmlredirect to their/legal/pages. GitHub Pages cannot send a server redirect, so each is a Hugo alias page (the pages'aliases:front matter, rendered bylayouts/alias.html): a meta refresh plus a canonical link to the new URL./privacy.mdand/terms.mdare static notes pointing at the new Markdown copies. - The home page's anchors:
#platforms,#how,#trust,#download. - The onboarding page
/welcome/(opened by the Chrome extension on install,layouts/onboarding.html), and the short paths/download/,/install/,/signup/,/login/and/early-access/, each a meta refresh rendered bylayouts/redirect.html./signup/,/login/and/early-access/go to Layer5 Cloud (no early-access form: anyone without a plan starts the beta there), whose URL isparams.cloud_urlinhugo.tomlalone. None of these is listed insitemap.xmlorllms.txt. /assets/brand/...(fromstatic/assets/brand/),/favicon.ico,/CNAME.- Generated:
/sitemap.xml,/robots.txt(which names the sitemap),/llms.txt,/llms-full.txtand/index.md.
The canonical URLs, Open Graph tags, sitemap.xml and robots.txt name
https://blowhorn.ai/. A build with HUGO_PREVIEW=true marks every page
noindex, nofollow and its robots.txt disallows everything.
Docsy's own static files (Font Awesome webfonts, its favicons and a few scripts) are published too because Docsy is imported as a module, as in layer5io/docs; no page loads them.
make setup # once: npm install
make check # production build, html-validate, link, third-party and contract checks, check-script tests, actionlint: what CI runs
make site # http://localhost:1313 with live reloadThe user docs under /docs/ live in the private product repository
(leecalcote/blowhorn, docs/{tutorials,how-to,reference,explanation}
and docs/README.md) and are mirrored into content/en/docs/ by
sync-docs.yml. The private
repository runs no moderate or heavy Actions of its own, so the work runs
here, on the public site repository.
- The workflow checks out the product repository at the requested ref
(a tag, branch or SHA;
masterby default) read-only with the existingDMG_SOURCE_TOKENsecret (no new secret), the waypublish-dmg.ymlhandles it.make docs-cli-referencethen regenerates the product's CLI reference pages (docs/reference/cli/*.mdand the command table indocs/reference/cli.md) at that ref with the product's ownrelease/scripts/cli_reference/build.py, which reads its CLI schema, so a page is never published stale. The workflow then runsmake docs-sync REF=..., which is.github/scripts/sync-docs.py(tested bymake test-scripts). The script copies the four public sections and rewrites each page for the site: relative.mdlinks become root-relative site URLs,reference/cli.mdbecomes thereference/cli/section index, a missing title is derived from the first H1, aliases colliding with a published URL are dropped, and headings whose auto id would not begin with a letter get an explicit{#anchor}.docs/internal/,docs/gtm/and any path listed in the product'sinternal-paths.txt(when it exists:release/internal-paths.txtin the product's current layout, elsedocs/or the product root) are never copied. - The product is the only source for
content/en/docs/: the docs landing comes from the product'sdocs/_index.mdand each section landing from that section's_index.md.docs/README.mdis the product's GitHub index and is not copied. Edit docs pages in the product, never here: the next sync overwrites a hand edit. A site_index.mdlanding with no product source is kept as is and reported in the sync warnings; every other synced page is the product's file converted. Pages carryingdraft: truestay unpublished until the product ungates them. - The workflow commits the result signed off (DCO) to the
docs-syncbranch (make docs-sync-basebuilds it on the open sync PR's head, or afresh onmasterwhen none is open, so a branch left by a merged PR is replaced), opens or updates the onedocs: sync from <ref>pull request, and starts the normal site checks on it itself (make docs-sync-start-checks, which runsgh workflow run site.yml --ref docs-syncviaworkflow_dispatch): pushes and pull requests made withgithub.tokennever startpull_requestorpushworkflows, sosite.ymlwould otherwise stay silent on the sync PR, while aworkflow_dispatchrun started with that same token is allowed and attaches to the branch head, showing as the PR's check. Merging the PR once those checks pass deploys. The workflow never pushes tomaster.
A public release starts the sync by itself: publish-dmg.yml's last job
runs make dmg-sync-docs, which starts sync-docs.yml at the product tag
desktop-<version>. A draft release starts nothing; run the sync by hand
once the release is published.
When a synced page breaks a check, fix the converter or the site-side
rendering it drives (layouts/alias.html, layouts/_markup/,
hugo.toml markup settings), never the product docs: the next sync
would overwrite a product-side edit anyway. A product-doc defect the
converter cannot fix (a link outside the published tree, a reference to
the private repository) goes in the sync pull request's description, to
be fixed upstream and re-synced.
Run the same flow locally from a product checkout at ./product
(or pass SOURCE_DIR for a checkout elsewhere):
git clone [email protected]:leecalcote/blowhorn.git product
make docs-sync REF=master # or a tag, branch or SHA
make check # what the sync pull request runsBefore a visual change ships, build for production and serve public/, then
take screenshots at 390, 820 and 1440 px wide in both colour schemes and
confirm there is no horizontal scroll from 320 to 1440 px. With
chrome-devtools-axi:
make build-production && python3 -m http.server 8080 --directory public
chrome-devtools-axi open http://localhost:8080/
chrome-devtools-axi resize 390 844
chrome-devtools-axi emulate --color-scheme dark
chrome-devtools-axi screenshot /path/to/evidence/home-390-dark.png --full-page
chrome-devtools-axi eval 'document.documentElement.scrollWidth > document.documentElement.clientWidth' # must be false/internal/ is the internal Blowhorn site. Its source never enters this
repository, which is public: it lives in the private leecalcote/blowhorn
repository, and only the internal environment mounts it.
| Target | What it does | Output |
|---|---|---|
make build-production |
Public build (hugo, production environment, renderSegments = ["public"]), what CI deploys |
public/, no /internal/ |
make site-internal |
Internal build (hugo --environment internal, renderSegments = ["all"]) with the leecalcote/blowhorn clone mounted |
public-internal/ |
make site / make preview-internal |
Live-reload servers for the two builds (the internal one renders in memory) | http://localhost:1313 |
The internal targets need a leecalcote/blowhorn clone at BLOWHORN_DIR
(default ../blowhorn, beside this clone) and fail with a clear error when it
is missing. leecalcote/blowhorn's Makefile has the same site,
site-internal and preview-internal targets, which run these with
BLOWHORN_SITE_DIR (default ../blowhorn-site).
hugo.toml declares Hugo's default project mounts explicitly
(content/en, data, layouts, i18n, archetypes, assets, static),
since declaring any mount drops the defaults. config/internal/hugo.toml
adds a module import, github.com/leecalcote/blowhorn, with its own mounts:
[[module.imports]]
path = "github.com/leecalcote/blowhorn"
ignoreConfig = true
[[module.imports.mounts]]
source = "marketing/strategy/hugo/strategy"
target = "content/internal/strategy"
[[module.imports.mounts]]
source = "marketing/strategy/hugo/finance"
target = "content/internal/finance"
[[module.imports.mounts]]
source = "marketing/strategy/finance/blowhorn-breakeven-model.xlsx"
target = "content/internal/finance/blowhorn-breakeven-model.xlsx"
[[module.imports.mounts]]
source = "marketing/strategy/finance/blowhorn-breakeven.html"
target = "assets/internal/finance/blowhorn-breakeven.html"
[[module.imports.mounts]]
source = "marketing/strategy/finance/build"
target = "assets/internal/finance/build"
[[module.imports.mounts]]
source = "marketing/capability-comparison/compare-assistants.csv"
target = "assets/internal/compare-assistants.csv"make site-internal points the import at the clone with
HUGO_MODULE_REPLACEMENTS="github.com/leecalcote/blowhorn -> <abs BLOWHORN_DIR>",
so nothing is downloaded, copied or vendored. Because the import lives
only in the internal environment, the public build never reads the private
repository. Hugo replaces module.imports per environment instead of
merging it, so config/internal/hugo.toml repeats the two Docsy imports;
the project's own mounts from hugo.toml carry over (hugo config mounts -e internal lists both). The comparison CSV mounts into assets/, not
data/, because Hugo's data directory does not read CSV; the
internal-compare shortcode unmarshals it. The shortcode sizes each column
from its cells, never its name, so a column the CSV gains needs no template
change: all-status columns stay narrow, a column with any cell over 100
characters gets a reading width, and the table scrolls sideways in its own
region at the page's full width. The break-even report is wrapped
by marketing/strategy/hugo/finance/report/index.md (layout internal-raw),
and the finance page lists the XLSX and the generator scripts as downloads.
The user docs under content/en/docs/ stay on the committed docs-sync copy
(make docs-sync) rather than a mount of leecalcote/blowhorn docs/: a
mount would make every public build, including this repository's public CI,
need a token for the private repository.
public: every page except/internaland/internal/**.internal: only/internaland/internal/**.all: every page. Used by the internal build.
The internal section's committed content/en/internal/_index.md is
build: {list: never} itself and cascades build: {list: local} and
type: docs to every internal page. Internal pages stay out of the
site-wide collections (menus, the search index, llms.txt, the sitemap),
even in the internal build, but stay in their parents' .Pages, so they
render in the docs shell (layouts/docs/baseof.html with
layouts/docs/internal.html) with the docs sidebar, breadcrumb, table of
contents and prev/next built from the /internal/ section tree. Page order
is each page's weight. The public build never renders any of it.
make check-no-internal (part of make site-check, and a step in
site.yml) fails when public/internal exists, a public page links to
/internal/ or names an internal file, or git tracks anything under
content/en/internal/ other than _index.md.
leecalcote/blowhorn's .github/workflows/internal-site.yml checks out both
repositories side by side, runs make site-internal from the blowhorn
clone and uploads public-internal/ as a private workflow artifact. There
is no private host. It never runs in this repository's Actions, whose
artifacts and logs are public.
- A
--renderSegmentsflag on the command line overrides the config.hugo --renderSegments allrenders whatever is mounted. hugo serverhonoursrenderSegments, but without--renderToMemoryit also writes topublic/and serves stale files from there; an internal server run that way leaves/internal/pages inpublic/. Usemake preview-internal.- A segment name that is not defined renders nothing, with no error.
renderSegments = ["public", "internal"]does not render/internal/in Hugo 0.158: thepublicsegment's excludes win. Useall.hugo configwith no-ereports a different environment's mounts thanhugobuilds with; pass-e productionor-e internalwhen checking.