Skip to content

Latest commit

 

History

History
403 lines (334 loc) · 19.4 KB

deploy.md

File metadata and controls

403 lines (334 loc) · 19.4 KB

Deploying the site

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.

What deploys, and when

site.yml runs on every pull request and on every push to master:

  1. 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 from package.json with npm ci. The checkout has full history so each page's last-modified date (sitemap, llms-full.txt) comes from git.
  2. make workflow-check lints the workflows with actionlint, and make test-scripts runs the unit tests for the check scripts below. Every step is a make target (make setup-ci installs the dependencies); make ci runs the whole job locally in the same order.
  3. make site-check builds for production into public/ 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 and srcset reference 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 on api.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).
  4. public/ is uploaded as the Pages artifact.
  5. On master only (a push or a manual run), the deploy job publishes that artifact with actions/deploy-pages under the github-pages environment. The job needs pages: write and id-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.

Verify a deployment

gh-axi run list -R layer5io/blowhorn-site --workflow site.yml

The 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 configuration

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=workflow

This 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.

Custom domain: blowhorn.ai (the apex)

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.

Step 1: the apex records (do this first)

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:* addresses

The 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.ai

Certificate 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.

Step 2: the www record (optional)

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/

URLs

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.md and /legal/terms/index.md.
  • The old /privacy.html and /terms.html redirect to their /legal/ pages. GitHub Pages cannot send a server redirect, so each is a Hugo alias page (the pages' aliases: front matter, rendered by layouts/alias.html): a meta refresh plus a canonical link to the new URL. /privacy.md and /terms.md are 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 by layouts/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 is params.cloud_url in hugo.toml alone. None of these is listed in sitemap.xml or llms.txt.
  • /assets/brand/... (from static/assets/brand/), /favicon.ico, /CNAME.
  • Generated: /sitemap.xml, /robots.txt (which names the sitemap), /llms.txt, /llms-full.txt and /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.

Local checks and evidence

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 reload

Product docs sync

The 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.

  1. The workflow checks out the product repository at the requested ref (a tag, branch or SHA; master by default) read-only with the existing DMG_SOURCE_TOKEN secret (no new secret), the way publish-dmg.yml handles it. make docs-cli-reference then regenerates the product's CLI reference pages (docs/reference/cli/*.md and the command table in docs/reference/cli.md) at that ref with the product's own release/scripts/cli_reference/build.py, which reads its CLI schema, so a page is never published stale. The workflow then runs make docs-sync REF=..., which is .github/scripts/sync-docs.py (tested by make test-scripts). The script copies the four public sections and rewrites each page for the site: relative .md links become root-relative site URLs, reference/cli.md becomes the reference/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's internal-paths.txt (when it exists: release/internal-paths.txt in the product's current layout, else docs/ or the product root) are never copied.
  2. The product is the only source for content/en/docs/: the docs landing comes from the product's docs/_index.md and each section landing from that section's _index.md. docs/README.md is 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.md landing 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 carrying draft: true stay unpublished until the product ungates them.
  3. The workflow commits the result signed off (DCO) to the docs-sync branch (make docs-sync-base builds it on the open sync PR's head, or afresh on master when none is open, so a branch left by a merged PR is replaced), opens or updates the one docs: sync from <ref> pull request, and starts the normal site checks on it itself (make docs-sync-start-checks, which runs gh workflow run site.yml --ref docs-sync via workflow_dispatch): pushes and pull requests made with github.token never start pull_request or push workflows, so site.yml would otherwise stay silent on the sync PR, while a workflow_dispatch run 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 to master.

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 runs

Before 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 section (Hugo segments and module mounts)

/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.

Builds and local servers

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).

Mounts

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.

Segments and checks

  • public: every page except /internal and /internal/**.
  • internal: only /internal and /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.

Where the internal build runs

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.

Footguns

  • A --renderSegments flag on the command line overrides the config. hugo --renderSegments all renders whatever is mounted.
  • hugo server honours renderSegments, but without --renderToMemory it also writes to public/ and serves stale files from there; an internal server run that way leaves /internal/ pages in public/. Use make 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: the public segment's excludes win. Use all.
  • hugo config with no -e reports a different environment's mounts than hugo builds with; pass -e production or -e internal when checking.