Pin checksummed Hugo download in shared workflow
All checks were successful
Publish Hugo Deployment Image / publish (push) Successful in 54s
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.
This commit is contained in:
parent
7186257987
commit
366b448bb0
4 changed files with 287 additions and 7 deletions
|
|
@ -38,7 +38,9 @@ jobs:
|
|||
run:
|
||||
shell: bash
|
||||
env:
|
||||
PATH: /snap/hugo/current/bin:/usr/local/bin:/usr/bin:/bin:/snap/bin
|
||||
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_PORT: ${{ vars.DEPLOY_SSH_PORT }}
|
||||
DEPLOY_SSH_USER: ${{ inputs.deploy_ssh_user != '' && inputs.deploy_ssh_user || vars.DEPLOY_SSH_USER }}
|
||||
|
|
@ -53,6 +55,55 @@ jobs:
|
|||
with:
|
||||
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
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.deploy_ssh_key }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
FROM debian:bookworm-slim
|
||||
|
||||
ARG HUGO_VERSION=0.160.0
|
||||
ARG HUGO_VERSION=0.165.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -12,20 +12,23 @@ Shared Forgejo deployment workflow for Develton-managed Hugo sites.
|
|||
|
||||
- Repository must be public if other repos call the reusable workflow directly
|
||||
- Caller repository variables and secrets documented below
|
||||
- The runner host must provide `hugo`, `node`, `rsync`, and `ssh` on `PATH`
|
||||
- The runner host must provide `curl`, `tar`, `sha256sum`, `rsync`, and `ssh` on `PATH`; the workflow downloads Hugo itself
|
||||
|
||||
## Sites
|
||||
|
||||
- `neilb.com`
|
||||
- `neilborromeo.com`
|
||||
- `boholtoday.com`
|
||||
- `boholcentral.com`
|
||||
- `cloud.develton.com`
|
||||
- `komsysnet.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
|
||||
|
||||
The legacy publish workflow can still publish the old container image, but normal deployments no longer use it. Hugo deploys run directly on the runner host so they can use the host Hugo binary and avoid nested container deployment paths.
|
||||
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 of Git tags like `v1` publish the matching image tag without moving `latest`
|
||||
|
|
@ -56,7 +59,7 @@ Site repos call the shared workflow with:
|
|||
jobs:
|
||||
deploy:
|
||||
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:
|
||||
deploy_script: ./scripts/deploy.sh
|
||||
secrets:
|
||||
|
|
|
|||
226
tests/test_hugo_version_pins.py
Normal file
226
tests/test_hugo_version_pins.py
Normal 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()
|
||||
Loading…
Reference in a new issue