GitHub Actions validation
The workflows in .github/workflows/ implement Phase 2 step 4 of the
simplification-plan.md.
They run on GitHub. All jobs use runs-on: self-hosted, the label of the
temporary Docker-host runners in
ADR 011.
No custom runner labels are assumed.
The private repository stays on GitHub Free. Checks report failures on
pushes and PRs, but cannot enforce merges or block a direct push to main.
Branch protection and required checks need a later Pro decision. See
GitHub's protected branch availability.
Workflows and triggers
| Workflow | Triggers | Behavior |
|---|---|---|
validate.yaml |
Every PR, including base-branch edits, without path filters | Always checks workflows and renders both environments. Selects manifest, OpenTofu, Ansible, and docs jobs from the whole PR diff. Reports homelab-validation. |
validate-push.yaml |
Every branch push, including main, and manual dispatch |
Runs all validation jobs. Reports homelab-push-validation. |
docs-image.yaml |
PRs and main pushes changing docs, CONTEXT.md, Zensical config, docs requirements, Dockerfile, .dockerignore, public build scripts and asset inventory, or the workflow. Also v* tags and manual dispatch |
Builds and checks the allowlisted public docs image. PRs build without login or publication. Other events publish to ghcr.io/igresc/homelab-docs. |
Fork PRs skip every self-hosted job. Validation runs only for PRs whose head
repository is github.repository.
Image builds always generate a full sha-<commit> tag. PR builds do not
publish it. Current main builds also publish main and latest. A v*
tag build publishes its Git tag and SHA tag, with automatic latest
generation disabled. Manual branch builds publish the branch tag and SHA
tag, with latest reserved for the default branch.
Image jobs serialize per Git ref without canceling a running publication. After publishing the SHA tag, a branch build checks the current branch tip through the GitHub API. A stale run skips all other tags. A failed tip lookup fails the job before it can publish mutable tags.
The validation jobs cover the checks previously run by the Gitea Flux,
Terraform, and Ansible workflows. The obsolete state backup image workflow
was removed on main and has no GitHub equivalent.
| Job | Applicability on PRs | Validation |
|---|---|---|
workflow |
Always | Actionlint, CI regression tests, workflow YAML parsing, and shell syntax. |
gitleaks |
Always | Scans PR commits in base..head with redacted output and default rules plus the stateless GitHub App token rule. Pushes to main scan before..after. Other pushes scan commits outside origin/main. |
render |
Always | Builds staging and production cluster roots and follows reachable literal Flux references. |
manifests |
Kubernetes changes, any YAML or extensionless Kustomization, recovery scripts, the main backend config, or shared CI |
Runs scripts/validate.sh once. |
iac |
Terraform changes, the raw staging FluxInstance input, or shared CI | OpenTofu formatting, backend-disabled initialization, validation, and mocked bootstrap and Talos tests. |
ansible |
Ansible, .ansible-lint, or shared CI |
Installs pinned collections and runs ansible-lint --offline --profile min playbooks roles. |
docs |
Docs, CONTEXT.md, Zensical config, docs requirements, or shared CI |
Checks dependency-lock inputs, builds the full private site, and builds and checks the allowlisted public artifact. |
required |
Always after the PR jobs | Requires success. Accepts a skipped optional job only when changes explicitly marked it inapplicable. Fails on missing results, failures, cancellation, and unexpected skips. |
Shared CI means .github/**, scripts/ci/**, and scripts/validate.sh.
PR applicability uses git diff base...head, including deleted paths and
both sides of renames. Missing history fails validation. A later docs-only
commit cannot hide an earlier IaC change in the PR.
terraform/cilium.tf reads the raw staging FluxInstance manifest, so changes
to that input also select IaC. Push validation checks everything.
Gitleaks fetches full history but scans only new commits. Branch pushes and
manual dispatches on a branch scan commits outside origin/main, so a rebase
or force-push doesn't rescan main's history. On main, a push scans
before..after and a manual dispatch scans the single selected commit. A
force-push that rewrites main therefore scans the whole rewritten range and
fails on any finding in old history. Missing history fails the job. .gitleaks.toml limits exceptions to Grafana query identifiers,
encrypted SealedSecret and SOPS values, and historical B2 field references.
Plaintext secrets remain subject to all default rules. No history baseline
is used.
Rendering and schema coverage
scripts/ci/render-environments.py starts at kubernetes/clusters/staging
and kubernetes/clusters/prod. It recursively builds Flux Kustomization
paths and FluxInstance sync paths, inspects literal ResourceSet resources,
and checks local GitRepository Helm chart directories for Chart.yaml.
A visited set stops cycles. Missing resources, paths outside the repository,
and recognized references containing controller templates fail.
The renderer does not evaluate ResourceSet templates or inputs, fetch separate source artifacts, render Helm charts, or reproduce Flux's automatic Kustomization generation. Kustomize can fetch remote resources in overlays. Live Secret and ConfigMap substitutions remain outside static validation.
scripts/ci/render-known-missing.txt records one obsolete k3s reference for
later removal. Its five fields bind the exception to an exact object and
source file, using Kustomize origin annotations:
Kustomization flux-system flux-system kubernetes/clusters/prod/flux-system/gotk-sync.yaml ./clusters/prod
A different object referencing the same missing path fails. Moving the object, removing it, or creating its target requires removing the stale exception. This does not change the separate live k3s production setup.
scripts/validate.sh builds every Kubernetes overlay named
kustomization.yaml, kustomization.yml, or Kustomization, including dormant
ones. It parses repository YAML and checks rendered resources with
Kubeconform. The Flux schema bundle is pinned to 2.9.0, matching staging's
2.9.x series. Missing schemas and Secrets are reported as skipped. Helm
charts and controller-generated objects are not schema-validated. Legacy
production API versions can lack schemas. A passing job does not establish
complete schema coverage.
Dependencies and temporary storage
External actions use full commit SHAs. scripts/ci/tools.json pins Linux
x86_64 tools and their SHA-256 digests. OpenTofu also requires a valid signed
checksum file, checked with gpgv and the committed public key. Downloads
are verified before extraction or execution. See
OpenTofu integrity verification.
Python dependencies use .github/requirements/*.txt with
pip --require-hashes. Regenerate a lock with:
uv pip compile --generate-hashes --python-version 3.12 --no-emit-index-url --no-emit-find-links .github/requirements/<name>.in -o .github/requirements/<name>.txt
After regenerating the docs lock, refresh its recorded input digests:
python3 - <<'PY'
import hashlib
import json
from pathlib import Path
files = ('.github/requirements/docs.in', 'requirements-docs.txt', '.github/requirements/docs.txt')
Path('.github/requirements/docs-inputs.json').write_text(json.dumps({name: hashlib.sha256(Path(name).read_bytes()).hexdigest() for name in files}, indent=2) + '\n')
PY
The digest check detects changed inputs or a changed lock. It does not prove
that the lock was compiled from those inputs. Ansible collections are pinned
in ansible/requirements.yml. Offline lint prevents a second installation.
The prepare action puts tools, schemas, Python environments, Ansible storage,
and caches under a unique CI_TEMP in RUNNER_TEMP.
Each job removes that storage with always(). OpenTofu uses separate provider
storage per directory and read-only initialization for existing locks. Talos
module tests derive temporary provider locks from the main root, as the
Gitea workflow does. These derived locks are removed on exit. No backend
access, live plans, or applies run.
For local manifest validation, set CI_TEMP to a temporary directory, run
python3 scripts/ci/install-tools.py yq kubeconform kustomize flux-schemas,
add $CI_TEMP/bin to PATH, and run scripts/validate.sh. Remove the directory
afterward. Without CI_TEMP, the script creates and removes its own storage
under RUNNER_TEMP or the workspace's tmp directory.
Cutover prerequisites
The runners need Linux x86_64, Bash, Git, curl, gpgv, and Python 3.12 or
newer with venv and pip. Checkout v5 needs Actions Runner 2.327.1 or newer.
The image job also needs Docker daemon access and Buildx support. Keep runner
credentials and unrelated host files out of job storage.
Four runners for igresc/homelab run on the Docker host from
~/github-runner-homelab/compose.yaml, one job each. They use the
myoung34/github-runner image with a pinned ubuntu-noble tag, because
latest ships Python 3.8. Each runner keeps its registration in
./runner/data*, so a restart does not need a new token. The compose project
uses network_mode: bridge, because the host has no free Docker address
pools. To add a runner, copy a service with a new name, work directory and
data directory, and start it with a fresh registration token in .env. To
remove them, run docker compose down and delete the runners under
Settings → Actions → Runners.
Before enabling the runner, open repository Settings → Actions → General. Under Fork pull request workflows for the private repository, disable Run workflows from fork pull requests and save. Reload the settings and verify that the option remains disabled. Test a collaborator's fork PR and verify that it starts no self-hosted jobs. Keep this setting disabled even with the job guards, because a fork PR can modify workflow files. See GitHub's private fork workflow settings.
Validation needs no repository secrets. Image publication uses the automatic
GITHUB_TOKEN with contents: read and packages: write, and a job-local
Docker configuration. No registry username variable or publishing PAT is
needed. If the GHCR package already exists, grant this repository Actions
write access to it. See
GitHub's image publication guide.
Staging pulls private images with the owner's classic read:packages PAT in
OpenBao at kv/infra/github/ghcr-pull. External Secrets copies it into a
ghcr-pull Secret in every namespace. See
Documentation image. That pull
credential is not an Actions publishing secret.
Keep .gitea/workflows/ until the cutover. With Gitea's default
WORKFLOW_DIRS=.gitea/workflows,.github/workflows, the first existing directory
wins, so Gitea ignores the GitHub workflows while .gitea/workflows/ exists.
See the Gitea configuration reference.
The retained workflows continue on Gitea until retirement.
After registration, test a real push and PR, image publication, and checkout's
post-cleanup step. Local checks do not establish runner readiness or a
successful GitHub run. The image builds only the reviewed pages in
docs/public-pages.json. The public build checks HTML, search, and sitemap against that allowlist before
publication. The full private docs build remains a separate CI check.