Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/actions/install-hugo/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
name: Install Hugo
description: Install the pinned Hugo extended build, verified by checksum before install.

# The single declaration of which generator this repo builds with.
# It used to live in both validate-task.yml and deploy-site-task.yml, with an instruction to update both and nothing enforcing it.
# A one-sided bump was silent and produced exactly the failure the pin exists to prevent: validation building the site with one generator while the deploy shipped a tree built by another, each verifying its own checksum against its own version and both passing.
# No Dependabot ecosystem tracks Hugo, so both values move by hand and there was no bot to catch the skew either.
#
# Pinned by version and by checksum rather than installed from a floating action, because the site is reproducible only if the generator is, and a minor bump can change rendered output.
# Update both values together, from the checksums file on the Hugo release.

# The pin is hardcoded below rather than exposed as inputs with defaults.
# An overridable input would let two callers pass different values and reintroduce the divergence this action exists to remove, which is the same defect one level up.
# A pin that callers cannot override is correct by construction rather than by everyone agreeing to omit the argument.
# Change it here, in one place, and every caller moves together or none does.

runs:
using: composite
steps:

# A tampered or moved artifact fails at the checksum rather than producing a wrong site.
# The extended build is asserted from the binary rather than inferred from the file name, since the name is the only thing that carried that requirement before.
- name: Install Hugo step
shell: bash
env:
HUGO_VERSION: 0.164.0
HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd
run: |
set -Eeuo pipefail
deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
curl -sSLf -o "$deb" \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}"
echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict
sudo dpkg --install "$deb"
hugo version
hugo version | grep -q extended || {
echo "::error::Hugo reports a non-extended build, which cannot process this site's SCSS."
exit 1
}
43 changes: 29 additions & 14 deletions .github/workflows/deploy-site-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,28 @@ on:
description: The GitHub Environment to deploy to, production or staging.
required: true
type: string
outputs:
# A caller records what shipped, rather than having to read the host to find out.
# The verification below proves this id is the one answering, so it is the value a rollback names.
release-id:
description: The id of the release installed by this run.
value: ${{ jobs.deploy.outputs.release-id }}
site-url:
description: The base URL the deploy was verified against.
value: ${{ jobs.deploy.outputs.site-url }}

# The transport's options are pinned rather than left to the runner's OpenSSH defaults, and declared once so the two transfers cannot drift apart.
# StrictHostKeyChecking=yes refuses an unknown or changed host key outright, where the default asks and a non-interactive runner then resolves that ambiguously.
# UserKnownHostsFile names the file the deploy key step writes, so the check reads the pinned value rather than whatever the runner image carries.
# BatchMode=yes makes every prompt an immediate failure, so a credential problem surfaces as a failed step rather than a job that hangs to its timeout.
# IdentitiesOnly=yes stops the agent offering other keys, so the deploy authenticates as the confined account or not at all.
env:
# Pinned by version and checksum, because the site is reproducible only if the generator is.
# Update both values together.
HUGO_VERSION: 0.164.0
HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd
SSH_TRANSPORT: >-
ssh -i ~/.ssh/deploy
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile=~/.ssh/known_hosts
-o BatchMode=yes

jobs:

Expand All@@ -22,6 +38,8 @@ jobs:
assert-environment:
name: Assert environment name job
runs-on: ubuntu-latest
# Nothing here reads the repository, so it needs no token scope at all.
permissions: {}
steps:
- name: Assert environment is known step
env:
Expand All@@ -44,6 +62,9 @@ jobs:
environment: ${{ inputs.environment }}
permissions:
contents: read
outputs:
release-id: ${{ steps.release.outputs.id }}
site-url: ${{ vars.HUGO_BASEURL }}

steps:

Expand All@@ -53,15 +74,9 @@ jobs:
with:
fetch-depth: 0

# The pin lives in the action, so the deploy and validation cannot install different generators.
- name: Install Hugo step
run: |
set -Eeuo pipefail
deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
curl -sSLf -o "$deb" \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}"
echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict
sudo dpkg --install "$deb"
hugo version
uses: ./.github/actions/install-hugo

# REQUIRE_BROTLI below makes a missing binary fatal, so this keeps the build from failing.
- name: Install brotli step
Expand DownExpand Up@@ -116,7 +131,7 @@ jobs:
set -Eeuo pipefail
rsync -az --mkpath --no-g --chmod=D2755,F644 \
--link-dest="/${ENVIRONMENT}/current/" \
-e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \
-e "$SSH_TRANSPORT" \
"${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}/" \
"${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/releases/${RELEASE_ID}/"

Expand All@@ -130,7 +145,7 @@ jobs:
run: |
set -Eeuo pipefail
rsync -a --no-recursive \
-e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \
-e "$SSH_TRANSPORT" \
"${RUNNER_TEMP}/bundle/current" \
"${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/"

Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/deploy-site.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,15 +20,19 @@ jobs:

# Staging deploys from any ref, since proving a branch before it merges is what staging is for.
# First, so a mis-dispatched production deploy fails before anything is installed or written.
# Compared against the full ref rather than ref_name, because tags and branches are separate namespaces that share a short name.
# A tag named main would satisfy a ref_name comparison while pointing at an arbitrary commit, which is a bypass of the one gate protecting production.
assert-ref:
name: Assert deploy ref job
runs-on: ubuntu-latest
# Nothing here reads the repository, so it needs no token scope at all.
permissions: {}
steps:
- name: Assert ref matches environment step
run: |
set -Eeuo pipefail
if [ "${{ inputs.environment }}" = "production" ] && [ "${{ github.ref_name }}" != "main" ]; then
echo "::error::Deploy production from main; got ${{ github.ref_name }}."
if [ "${{ inputs.environment }}" = "production" ] && [ "${{ github.ref }}" != "refs/heads/main" ]; then
echo "::error::Deploy production from main; got ${{ github.ref }}."
exit 1
fi

Expand Down
25 changes: 6 additions & 19 deletions .github/workflows/validate-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,13 +5,6 @@ name: Validate task
on:
workflow_call:

env:
# Hugo is pinned by version and checksum rather than installed from a floating action.
# The site build is only reproducible if the generator is, and a minor Hugo bump can change output.
# Update both values together, from the checksums file on the Hugo release.
HUGO_VERSION: 0.164.0
HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd

jobs:

# Source-only repo: lint plus a site build gated on the URL contract, using the same configs the editor uses.
Expand All@@ -29,15 +22,16 @@ jobs:

# Doc linters run as pinned action wrappers.
# The editorconfig-checker action is install-only, so it runs via Docker instead.
# The markdown glob excludes the imported archive and the vendored theme.
# The markdown glob excludes the imported archive and the vendored theme trees.
# Neither is authored here, and .markdownlint-cli2.jsonc is carried verbatim so it cannot scope them.
# The theme exclusion reaches inside a theme directory rather than all of themes/, so a file we author about a vendored tree is still linted.
- name: Lint Markdown step
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
with:
globs: |
**/*.md
!content/**
!themes/**
!themes/*/**
!public/**

# The cspell gate covers README + HISTORY only.
Expand DownExpand Up@@ -77,16 +71,9 @@ jobs:
done
python3 -c 'import yaml,sys; yaml.safe_load(open("hugo.yaml"))'

# Pinned by checksum, so a tampered or moved artifact fails here rather than producing a wrong site.
# The pin lives in the action, so validation and the deploy cannot install different generators.
- name: Install Hugo step
run: |
set -Eeuo pipefail
deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
curl -sSLf -o "$deb" \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}"
echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict
sudo dpkg --install "$deb"
hugo version
uses: ./.github/actions/install-hugo

# --panicOnWarning is the real gate.
# Two PaperMod templates are overridden in layouts/ precisely so it can stay on.
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,3 +34,9 @@ __pycache__/
secrets/
**/secrets
.env

# The working copies of the host channel described in OPERATIONS.md.
# They carry server internals, and the host's own backup is what makes them durable.
# A fixed path so the files are found by name rather than in a session directory.
# Anchored, unlike the secrets patterns above, because an unanchored name would also ignore a content directory called comms, and a swallowed page is a URL lost silently.
/comms/
6 changes: 6 additions & 0 deletions HISTORY.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,8 @@

Pieter Viljoen's blog, and the tooling that builds, verifies, and deploys it.

The live blog is hosted at [blog.insanegenius.com][blog-link].

## Release History

- Version 1.0:
Expand All@@ -11,3 +13,7 @@ Pieter Viljoen's blog, and the tooling that builds, verifies, and deploys it.
- CI gates that contract on every pull request, alongside the doc, shell, and workflow linters, with the Hugo version pinned by checksum so a build is reproducible.
- A self-contained release bundle carrying the site, the web-server config, and the redirect maps together, so a rollback reverts the rules and the content they refer to as one unit.
- The site is not yet serving its public address. This release is the source and its pipeline, not the cutover.

<!-- External -->

[blog-link]: https://blog.insanegenius.com
Loading