Compare commits

...
Author SHA1 Message Date
Neil Borromeo
366b448bb0 Pin checksummed Hugo download in shared workflow
All checks were successful
Publish Hugo Deployment Image / publish (push) Successful in 54s
The deploy job downloads Hugo Extended 0.165.0 from the GitHub release,
verifies its SHA-256 and runs it from RUNNER_TEMP instead of the runner's
auto-refreshing snap. Deploys fail before rsync if hugo is a different
version, not Extended, or not the pinned binary.
2026-10-19 15:48:34 +08:00
Neil Borromeo
7186257987 Trust resolved deploy host addresses
All checks were successful
Publish Hugo Deployment Image / publish (push) Successful in 30s
2026-04-25 22:05:30 +08:00
Neil Borromeo
5c437bae74 Run Hugo deployments on host runner
All checks were successful
Publish Hugo Deployment Image / publish (push) Successful in 1m3s
2026-04-25 17:47:45 +08:00
4 changed files with 304 additions and 22 deletions

View file

@ -33,13 +33,14 @@ jobs:
deploy: deploy:
name: ${{ inputs.workflow_name }} name: ${{ inputs.workflow_name }}
timeout-minutes: 20 timeout-minutes: 20
runs-on: docker-desktop runs-on: develton-hugo
container:
image: registry.develton.com/cliper/hugo-deployment:v1
defaults: defaults:
run: run:
shell: bash shell: bash
env: env:
PATH: /usr/local/bin:/usr/bin:/bin:/snap/bin
HUGO_VERSION: "0.165.0"
HUGO_SHA256: "f43494894cdf4a8630a201d5c828051c77f523cc66bb3938b30806835470ac20"
DEPLOY_SSH_HOST: ${{ inputs.deploy_ssh_host != '' && inputs.deploy_ssh_host || vars.DEPLOY_SSH_HOST }} DEPLOY_SSH_HOST: ${{ inputs.deploy_ssh_host != '' && inputs.deploy_ssh_host || vars.DEPLOY_SSH_HOST }}
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }} DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
DEPLOY_SSH_USER: ${{ inputs.deploy_ssh_user != '' && inputs.deploy_ssh_user || vars.DEPLOY_SSH_USER }} DEPLOY_SSH_USER: ${{ inputs.deploy_ssh_user != '' && inputs.deploy_ssh_user || vars.DEPLOY_SSH_USER }}
@ -54,6 +55,55 @@ jobs:
with: with:
submodules: ${{ inputs.checkout_submodules && 'recursive' || 'false' }} submodules: ${{ inputs.checkout_submodules && 'recursive' || 'false' }}
- name: Install pinned Hugo Extended
run: |
set -euo pipefail
arch="$(uname -m)"
if [[ "$arch" != "x86_64" ]]; then
printf 'Unsupported runner architecture: %s\n' "$arch" >&2
exit 1
fi
hugo_dir="${RUNNER_TEMP:?RUNNER_TEMP is not set}/hugo-${HUGO_VERSION}"
archive="hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz"
rm -rf "$hugo_dir"
mkdir -p "$hugo_dir"
curl --fail --silent --show-error --location --retry 3 \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${archive}" \
--output "$hugo_dir/$archive"
printf '%s %s\n' "$HUGO_SHA256" "$hugo_dir/$archive" | sha256sum --check --strict
tar -xzf "$hugo_dir/$archive" -C "$hugo_dir" hugo
rm -f "$hugo_dir/$archive"
printf '%s\n' "$hugo_dir" >> "$GITHUB_PATH"
- name: Validate Hugo version
run: |
set -euo pipefail
expected_hugo_path="${RUNNER_TEMP:?RUNNER_TEMP is not set}/hugo-${HUGO_VERSION}/hugo"
actual_hugo_path="$(command -v hugo || true)"
if [[ "$actual_hugo_path" != "$expected_hugo_path" ]]; then
printf 'Expected hugo at %s, got: %s\n' "$expected_hugo_path" "${actual_hugo_path:-<none>}" >&2
exit 1
fi
if ! actual_hugo_version="$(hugo version 2>&1)"; then
printf 'Unable to determine Hugo version: %s\n' "$actual_hugo_version" >&2
exit 1
fi
read -r hugo_product hugo_version_token _ <<< "$actual_hugo_version"
if [[ "$hugo_product" != "hugo" || "$hugo_version_token" != v* ]]; then
printf 'Unable to parse Hugo version output: %s\n' "$actual_hugo_version" >&2
exit 1
fi
reported_hugo_version="${hugo_version_token#v}"
reported_hugo_version="${reported_hugo_version%%[-+]*}"
if [[ ! "$reported_hugo_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "$reported_hugo_version" != "$HUGO_VERSION" ]]; then
printf 'Expected Hugo v%s, got: %s\n' "$HUGO_VERSION" "$hugo_version_token" >&2
exit 1
fi
if [[ "$hugo_version_token" != *"+extended" ]]; then
printf 'Expected Hugo Extended, got: %s\n' "$hugo_version_token" >&2
exit 1
fi
- name: Validate deploy configuration - name: Validate deploy configuration
env: env:
DEPLOY_SSH_KEY: ${{ secrets.deploy_ssh_key }} DEPLOY_SSH_KEY: ${{ secrets.deploy_ssh_key }}
@ -85,7 +135,13 @@ jobs:
if [[ -n "${DEPLOY_KNOWN_HOSTS:-}" ]]; then if [[ -n "${DEPLOY_KNOWN_HOSTS:-}" ]]; then
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
else else
ssh-keyscan -H -p "${DEPLOY_SSH_PORT:-22}" "${DEPLOY_SSH_HOST}" > "$HOME/.ssh/known_hosts" {
ssh-keyscan -H -p "${DEPLOY_SSH_PORT:-22}" "${DEPLOY_SSH_HOST}"
getent ahosts "${DEPLOY_SSH_HOST}" \
| awk '{ print $1 }' \
| sort -u \
| xargs -r -n1 ssh-keyscan -H -p "${DEPLOY_SSH_PORT:-22}"
} > "$HOME/.ssh/known_hosts"
fi fi
chmod 0600 "$HOME/.ssh/known_hosts" chmod 0600 "$HOME/.ssh/known_hosts"

View file

@ -1,6 +1,6 @@
FROM debian:bookworm-slim FROM debian:bookworm-slim
ARG HUGO_VERSION=0.160.0 ARG HUGO_VERSION=0.165.0
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \

View file

@ -1,32 +1,34 @@
# Hugo Deployment # Hugo Deployment
Shared Forgejo deployment image scaffold for Develton-managed Hugo sites. Shared Forgejo deployment workflow for Develton-managed Hugo sites.
## Contents ## Contents
- Prebuilt deployment image with Hugo, rsync, and OpenSSH client tools - Reusable Forgejo workflow for Hugo static-site deploys
- Forgejo workflow that publishes versioned and `latest` image tags - Host-mode execution through the `develton-hugo` runner label
- Image publish path intended for `registry.develton.com` - Legacy image publishing workflow retained for rollback/reference
## Prerequisites ## Prerequisites
- Repository must be public if other repos call the reusable workflow directly - Repository must be public if other repos call the reusable workflow directly
- Forgejo variables: `REGISTRY_HOST`, `REGISTRY_USERNAME` - Caller repository variables and secrets documented below
- Forgejo secrets: `REGISTRY_PASSWORD` - The runner host must provide `curl`, `tar`, `sha256sum`, `rsync`, and `ssh` on `PATH`; the workflow downloads Hugo itself
- The image was smoke-tested locally, and `hugo version`, `rsync --version`, and `ssh -V` were confirmed successfully.
## Sites ## Sites
- `neilb.com` - `neilborromeo.com`
- `boholtoday.com` - `boholtoday.com`
- `boholcentral.com` - `boholcentral.com`
- `cloud.develton.com`
- `komsysnet.com` - `komsysnet.com`
- `thefreelancemastery.com` - `thefreelancemastery.com`
All five sites deploy through the shared workflow, which downloads Hugo Extended 0.165.0 from the GitHub release, verifies its pinned SHA-256, and runs it from `$RUNNER_TEMP`. A checksum mismatch, a different version, a non-Extended build, or any other `hugo` first on `PATH` fails before deployment.
To change the version, update `HUGO_VERSION` and `HUGO_SHA256` together (hash from `hugo_<version>_checksums.txt`), plus the Dockerfile ARG and the tests.
## Image Publishing ## Image Publishing
The publish workflow runs on `docker-desktop` and publishes the versioned tag on every run. The legacy publish workflow can still publish the old container image, but normal deployments no longer use it. The legacy image is also pinned to Hugo Extended 0.165.0. Hugo deploys run directly on the runner host so they can use the pinned Hugo download and avoid nested container deployment paths.
- Pushes to `main` publish `registry.develton.com/cliper/hugo-deployment:<commit-sha>` and also move `latest` - Pushes to `main` publish `registry.develton.com/cliper/hugo-deployment:<commit-sha>` and also move `latest`
- Pushes of Git tags like `v1` publish the matching image tag without moving `latest` - Pushes of Git tags like `v1` publish the matching image tag without moving `latest`
@ -56,8 +58,8 @@ Site repos call the shared workflow with:
```yaml ```yaml
jobs: jobs:
deploy: deploy:
runs-on: docker-desktop runs-on: develton-hugo
uses: https://git.develton.com/cliper/hugo_deployment/.forgejo/workflows/deploy-hugo-site.yml@v1 uses: https://git.develton.com/cliper/hugo_deployment/.forgejo/workflows/deploy-hugo-site.yml@main
with: with:
deploy_script: ./scripts/deploy.sh deploy_script: ./scripts/deploy.sh
secrets: secrets:
@ -69,10 +71,8 @@ Notes:
- For the current caller pattern, `DEPLOY_SSH_HOST`, `DEPLOY_SSH_USER`, and `DEPLOY_PATH` are required repo variables unless the caller explicitly passes host and user as workflow inputs - For the current caller pattern, `DEPLOY_SSH_HOST`, `DEPLOY_SSH_USER`, and `DEPLOY_PATH` are required repo variables unless the caller explicitly passes host and user as workflow inputs
- Caller repos pass `DEPLOY_SSH_KEY` through `jobs.<job_id>.secrets`; Forgejo v11 accepts caller-provided reusable workflow secrets even though `on.workflow_call` itself only documents `inputs` and `outputs` - Caller repos pass `DEPLOY_SSH_KEY` through `jobs.<job_id>.secrets`; Forgejo v11 accepts caller-provided reusable workflow secrets even though `on.workflow_call` itself only documents `inputs` and `outputs`
- Caller workflows should use the fully qualified workflow URL because shorthand cross-repo `owner/repo/...@ref` resolution on the current Develton Forgejo instance is producing `https:///...` clone URLs for reusable workflows - Caller workflows should use the fully qualified workflow URL because shorthand cross-repo `owner/repo/...@ref` resolution on the current Develton Forgejo instance is producing `https:///...` clone URLs for reusable workflows
- Caller workflows should keep `runs-on: docker-desktop` on the job that uses this reusable workflow when running against the current Develton Forgejo instance, which avoids buggy workflow expansion on the server side - Caller workflows should keep `runs-on: develton-hugo` on the job that uses this reusable workflow.
- Caller repos can set `DEPLOY_KNOWN_HOSTS` in repo variables with a pinned `known_hosts` entry for stronger host verification - Caller repos can set `DEPLOY_KNOWN_HOSTS` in repo variables with a pinned `known_hosts` entry for stronger host verification
- If `DEPLOY_KNOWN_HOSTS` is not set, the workflow falls back to `ssh-keyscan` so current repos keep working - If `DEPLOY_KNOWN_HOSTS` is not set, the workflow falls back to `ssh-keyscan` so current repos keep working
- The reusable workflow itself always runs on `docker-desktop` - The reusable workflow itself runs on `develton-hugo`.
- The reusable workflow resolves its container image as `registry.develton.com/cliper/hugo-deployment:v1`, so caller repos do not need `REGISTRY_HOST` just to start the job - The reusable workflow intentionally does not set `jobs.<job_id>.container`.
- Because the reusable workflow job container is resolved before checkout, callers must not switch over until `registry.develton.com/cliper/hugo-deployment:v1` has been published and verified pullable by the runner
- The reusable workflow is currently pinned to `registry.develton.com/cliper/hugo-deployment:v1`

View file

@ -0,0 +1,226 @@
import os
import re
import subprocess
import tempfile
import textwrap
import unittest
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
INFRASTRUCTURE_ROOT = REPOSITORY_ROOT.parent / "hetzner.develton.com"
EXPECTED_HUGO_VERSION = "0.165.0"
EXPECTED_HUGO_SHA256 = "f43494894cdf4a8630a201d5c828051c77f523cc66bb3938b30806835470ac20"
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def workflow_run_block(workflow: str, step_name: str) -> str:
match = re.search(
rf"^ - name: {re.escape(step_name)}\n"
r" run: \|\n"
r"(?P<body>(?: .*\n)+)",
workflow,
re.MULTILINE,
)
if match is None:
raise AssertionError(f"Workflow step not found: {step_name}")
return textwrap.dedent(match.group("body"))
class HugoVersionPinTests(unittest.TestCase):
def test_legacy_image_uses_expected_hugo_version(self) -> None:
dockerfile = read(REPOSITORY_ROOT / "Dockerfile")
match = re.search(r"^ARG HUGO_VERSION=(\S+)$", dockerfile, re.MULTILINE)
self.assertIsNotNone(match, "Dockerfile must declare HUGO_VERSION")
self.assertEqual(EXPECTED_HUGO_VERSION, match.group(1))
self.assertIn("hugo_extended_${HUGO_VERSION}_", dockerfile)
def test_shared_workflow_pins_and_validates_extended_hugo(self) -> None:
workflow = read(
REPOSITORY_ROOT / ".forgejo" / "workflows" / "deploy-hugo-site.yml"
)
self.assertRegex(
workflow,
re.compile(
rf'^ HUGO_VERSION: ["\']{re.escape(EXPECTED_HUGO_VERSION)}["\']$',
re.MULTILINE,
),
)
self.assertIn(f' HUGO_SHA256: "{EXPECTED_HUGO_SHA256}"', workflow)
path = re.search(r"^ PATH: (.+)$", workflow, re.MULTILINE)
self.assertIsNotNone(path)
self.assertNotIn("/snap/hugo", path.group(1))
checkout_position = workflow.find("- name: Checkout repository")
install_position = workflow.find("- name: Install pinned Hugo Extended")
self.assertGreaterEqual(checkout_position, 0)
self.assertGreater(install_position, checkout_position)
installation = workflow_run_block(workflow, "Install pinned Hugo Extended")
self.assertIn("sha256sum --check --strict", installation)
self.assertIn("GITHUB_PATH", installation)
validation_position = workflow.find("- name: Validate Hugo version")
deploy_position = workflow.find("- name: Run deploy script")
self.assertGreater(validation_position, install_position)
self.assertGreater(deploy_position, validation_position)
validation = workflow_run_block(workflow, "Validate Hugo version")
self.assertIn("hugo version", validation)
self.assertIn("exit 1", validation)
def test_shared_workflow_version_guard_fails_closed(self) -> None:
workflow = read(
REPOSITORY_ROOT / ".forgejo" / "workflows" / "deploy-hugo-site.yml"
)
validation = workflow_run_block(workflow, "Validate Hugo version")
with tempfile.TemporaryDirectory() as temporary_directory:
hugo_directory = Path(temporary_directory) / f"hugo-{EXPECTED_HUGO_VERSION}"
hugo_directory.mkdir()
fake_hugo = hugo_directory / "hugo"
fake_hugo.write_text(
"#!/bin/sh\nprintf '%s\\n' \"$FAKE_HUGO_VERSION_OUTPUT\"\n",
encoding="utf-8",
)
fake_hugo.chmod(0o755)
environment = os.environ.copy()
environment["PATH"] = f"{hugo_directory}:{environment['PATH']}"
environment["RUNNER_TEMP"] = temporary_directory
environment["HUGO_VERSION"] = EXPECTED_HUGO_VERSION
accepted_outputs = (
(
"hugo v0.165.0+extended linux/amd64 "
"BuildDate=unknown VendorInfo=gohugoio"
),
(
"hugo v0.165.0-67fb390c6309dc2b8361e714fc5d71e3e4d625cf"
"+extended linux/amd64 BuildDate=unknown VendorInfo=snap:0.165.0"
),
)
for accepted_output in accepted_outputs:
with self.subTest(accepted_output=accepted_output):
environment["FAKE_HUGO_VERSION_OUTPUT"] = accepted_output
accepted = subprocess.run(
["bash", "-c", validation],
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(0, accepted.returncode, accepted.stderr)
rejected_outputs = (
"hugo v0.164.0+extended linux/amd64 BuildDate=unknown",
"hugo v0.165.1+extended linux/amd64 BuildDate=unknown",
"hugo v0.165.1-abcdef+extended linux/amd64 BuildDate=unknown",
"hugo v0.165.0 linux/amd64 BuildDate=unknown",
"hugo v0.165.0-abcdef linux/amd64 BuildDate=unknown",
)
for rejected_output in rejected_outputs:
with self.subTest(rejected_output=rejected_output):
environment["FAKE_HUGO_VERSION_OUTPUT"] = rejected_output
rejected = subprocess.run(
["bash", "-c", validation],
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(0, rejected.returncode)
other_directory = Path(temporary_directory) / "other"
other_directory.mkdir()
other_hugo = other_directory / "hugo"
other_hugo.write_text(fake_hugo.read_text(), encoding="utf-8")
other_hugo.chmod(0o755)
environment["FAKE_HUGO_VERSION_OUTPUT"] = accepted_outputs[0]
environment["PATH"] = f"{other_directory}:{environment['PATH']}"
rejected = subprocess.run(
["bash", "-c", validation],
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(0, rejected.returncode)
self.assertIn("Expected hugo at", rejected.stderr)
def test_install_step_rejects_checksum_mismatch(self) -> None:
workflow = read(
REPOSITORY_ROOT / ".forgejo" / "workflows" / "deploy-hugo-site.yml"
)
installation = workflow_run_block(workflow, "Install pinned Hugo Extended")
with tempfile.TemporaryDirectory() as temporary_directory:
temporary_path = Path(temporary_directory)
fake_bin = temporary_path / "bin"
fake_bin.mkdir()
fake_curl = fake_bin / "curl"
fake_curl.write_text(
'#!/bin/sh\n'
'while [ "$#" -gt 0 ]; do\n'
' if [ "$1" = "--output" ]; then\n'
' printf junk > "$2"\n'
' exit 0\n'
' fi\n'
' shift\n'
'done\n'
'exit 1\n',
encoding="utf-8",
)
fake_curl.chmod(0o755)
github_path = temporary_path / "github_path"
github_path.touch()
environment = os.environ.copy()
environment.update(
PATH=f"{fake_bin}:{environment['PATH']}",
RUNNER_TEMP=temporary_directory,
GITHUB_PATH=str(github_path),
HUGO_VERSION=EXPECTED_HUGO_VERSION,
HUGO_SHA256=EXPECTED_HUGO_SHA256,
)
rejected = subprocess.run(
["bash", "-c", installation],
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(0, rejected.returncode)
self.assertIn("FAILED", rejected.stdout)
self.assertEqual("", github_path.read_text())
self.assertFalse((temporary_path / f"hugo-{EXPECTED_HUGO_VERSION}" / "hugo").exists())
def test_infrastructure_scaffold_uses_expected_hugo_version(self) -> None:
bootstrap = read(
INFRASTRUCTURE_ROOT
/ "ansible"
/ "tasks"
/ "bootstrap-hugo-site-from-deployment.yml"
)
self.assertIn(f'HUGO_VERSION: "{EXPECTED_HUGO_VERSION}"', bootstrap)
self.assertNotRegex(bootstrap, r'HUGO_VERSION: "(?!0\.165\.0)[^"]+"')
def test_readme_documents_supported_sites_and_version_guarantee(self) -> None:
readme = read(REPOSITORY_ROOT / "README.md")
expected_sites = (
"neilborromeo.com",
"boholtoday.com",
"boholcentral.com",
"komsysnet.com",
"thefreelancemastery.com",
)
documented_sites = re.findall(r"^- `([^`]+)`$", readme, re.MULTILINE)
self.assertEqual(list(expected_sites), documented_sites[: len(expected_sites)])
self.assertIn("Hugo Extended 0.165.0", readme)
self.assertIn("fails before deployment", readme)
if __name__ == "__main__":
unittest.main()