Skip to content

feat: operator toolkit for deCDN nodes worldwide #168

feat: operator toolkit for deCDN nodes worldwide

feat: operator toolkit for deCDN nodes worldwide #168

Workflow file for this run

---
name: CI
on:
push:
branches: [main]
pull_request:
# Least privilege by default; jobs widen only what they need.
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Third-party actions are pinned to a full commit SHA (a re-pointed tag can ship
# malicious code — cf. the March 2026 KICS action compromise). The trailing
# comment records the human-readable version; .github/dependabot.yml bumps them.
jobs:
# Detect whether ansible/, the Helm chart or compose/ changed so heavy jobs skip
# unrelated PRs.
changes:
runs-on: ubuntu-latest
timeout-minutes: 5 # ~5s in practice
permissions:
contents: read
pull-requests: read
outputs:
ansible: ${{ steps.filter.outputs.ansible }}
helm: ${{ steps.filter.outputs.helm }}
compose: ${{ steps.filter.outputs.compose }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
# ci.yml is listed because the ansible jobs' cache wiring lives here, so a
# change to it must re-run them — same reason the helm filter claims it.
ansible:
- 'ansible/**'
- '.github/workflows/ci.yml'
# The chart shares the schema-key inventory and checker with molecule.
helm:
- 'charts/**'
- 'ansible/molecule/schema/files/**'
- 'Makefile'
- '.github/workflows/ci.yml'
# lint-compose and security-compose live in the root Makefile.
compose:
- 'compose/**'
- 'Makefile'
- '.github/workflows/ci.yml'
# Ansible style + best-practice + the production-profile SECURITY rules,
# plus a syntax-check of every playbook. Runs only when ansible/ changed.
ansible-lint:
needs: changes
if: needs.changes.outputs.ansible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20 # ~1m15s in practice
defaults:
run:
working-directory: ansible
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# `cache: pip` needs a file to key on and this repo ships no pip manifest, so
# the workflow is the dependency declaration — it is where the package list
# literally lives. The installs stay unpinned `--upgrade`, so pip still queries
# the PyPI index to resolve; the cache saves the download, not the round trip.
# That is the right trade: PyPI is not the flaky dependency here, Galaxy is
# (see the collections cache below). setup-python derives its own key and takes
# no custom suffix, so this job and galaxy-build share one pip cache; their
# package sets overlap heavily (ansible-core, ansible-lint) so whichever
# populates it first covers most of both. To split them, drop `cache:` and use
# actions/cache on ~/.cache/pip with explicit per-job keys.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: pip
cache-dependency-path: .github/workflows/ci.yml
- name: Install Ansible tooling
run: python -m pip install --upgrade ansible ansible-lint yamllint
# A warm collections tree removes the Galaxy round trip outright: with the
# requirements already satisfied on disk, `ansible-galaxy collection install`
# prints "Nothing to do" and never contacts a server. So a hit is IMMUNE to the
# transient `Connection reset by peer` that failed #50 mid-resolve on the
# transitive community.crypto — not merely faster than it.
#
# restore/save are split rather than the all-in-one action on purpose: the
# failure being defended against is Galaxy dying part way through an install,
# and the combined action's post step would persist that half-written tree.
# Saving only after `make deps` returns 0 keeps a poisoned cache out.
#
# No restore-keys — an older cache still satisfies the `>=` ranges in
# requirements.yml, so `make deps` would find nothing to do and the cache-epoch
# bump documented in that file would silently be a no-op.
#
# `path:` and `hashFiles()` resolve against the workspace root, NOT this job's
# `working-directory: ansible`. The key is shared with galaxy-build and with
# molecule.yml; when they miss together they race to save it and the losers log
# a harmless "Cache already exists".
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: galaxy-cache
with:
path: ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('ansible/requirements.yml') }}
- name: Install Galaxy collections
run: make deps # must precede lint/syntax-check
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: steps.galaxy-cache.outputs.cache-hit != 'true'
with:
path: ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('ansible/requirements.yml') }}
- name: Lint (yamllint + ansible-lint)
run: make lint
- name: Syntax-check playbooks
# Dummy inventory: no real hosts.yml is committed upstream (only hosts.yml.example),
# and --syntax-check only parses, it never connects. Every playbook, so a new
# one (backup, decommission, ...) is covered without editing this step.
run: |
for pb in playbooks/*.yml; do
ansible-playbook "$pb" --syntax-check -i localhost,
done
# Build the public `decdn.node` collection and run galaxy-importer's checks —
# the same validation Galaxy runs on upload (metadata, license, README, embedded
# ansible-lint). This is a readiness GATE only: it never publishes and needs no
# token. Runs only when ansible/ changed.
galaxy-build:
needs: changes
if: needs.changes.outputs.ansible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20 # ~1m45s in practice
defaults:
run:
working-directory: ansible
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shares the pip cache with ansible-lint — see that job for why the key is
# the workflow file.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: pip
cache-dependency-path: .github/workflows/ci.yml
- name: Install build + import tooling
run: python -m pip install --upgrade ansible-core ansible-lint galaxy-importer
# Same cache as ansible-lint (same key, same rationale — see that job).
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: galaxy-cache
with:
path: ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('ansible/requirements.yml') }}
- name: Vendor collection dependencies
# So galaxy-importer's embedded ansible-lint can resolve the roles' FQCNs
# (devsec.hardening, ansible.posix). Installed under ansible/collections.
run: make deps
env:
ANSIBLE_COLLECTIONS_PATH: collections
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: steps.galaxy-cache.outputs.cache-hit != 'true'
with:
path: ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('ansible/requirements.yml') }}
- name: Build + validate the decdn.node collection
run: make galaxy-check
env:
ANSIBLE_COLLECTIONS_PATH: collections
- name: Upload collection artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: decdn-node-collection
path: ansible/build/decdn-node-*.tar.gz
if-no-files-found: ignore
# Helm chart: `helm lint --strict`, positive + negative render tests, kubeconform
# (digest-pinned image) and the upstream schema-key check shared with molecule —
# all via `make lint-helm`, the same command developers run locally.
helm:
needs: changes
if: needs.changes.outputs.helm == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20 # pulls the kubeconform image; ~1m in practice
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Helm version is pinned here, in the kics job below and twice in release.yml;
# bump all four.
- uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v4.3.0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Chart lint + render tests
# yq (mikefarah) and Docker ship on ubuntu-latest. No decdn binary here, so
# the real `decdn config validate` step reports SKIPPED; run it locally with
# DECDN_CLI=... before bumping the decdn version.
run: make lint-helm
# Docker Compose path: render compose/compose.yaml with its example env files and
# assert the invariants its README promises (host network, no published ports,
# digest-pinned image, read-only rootfs, no capabilities, graceful stop). Runs
# only when compose/ or the root Makefile changed.
compose:
needs: changes
if: needs.changes.outputs.compose == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5 # docker compose + jq ship on ubuntu-latest; seconds in practice
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Compose invariants
run: make lint-compose
# Render roles/grafana_alloy's templates and validate them with the REAL pinned
# Grafana Alloy binary. The molecule `grafana-cloud` scenario deliberately runs
# against a stub that exits 0 for every subcommand, so it proves plumbing but
# would happily ship a config Alloy cannot load (an unknown component, a block
# in the wrong parent, a CLI flag that does not exist). This job is that gate.
# Runs only when ansible/ changed.
alloy-config:
needs: changes
if: needs.changes.outputs.ansible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15 # ~1m in practice, most of it the first .deb fetch
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: pip
cache-dependency-path: .github/workflows/ci.yml
- name: Install Ansible
run: python -m pip install --upgrade ansible
# Keyed on the role defaults, which is where the version + digest pin
# lives: bumping grafana_alloy_version invalidates the cache by
# construction, so a bump is always validated against the new binary.
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: ansible/.cache/alloy
key: alloy-${{ runner.os }}-${{ hashFiles('ansible/roles/grafana_alloy/defaults/main.yml') }}
- name: Validate the rendered Alloy configuration
run: make lint-alloy
# Dedicated IaC security scan of the Ansible tree, the rendered Helm chart and
# compose/, driven straight from the digest-pinned KICS *engine* image by
# `make security` — the exact command
# developers run locally, so CI and local results cannot drift. KICS severities
# are CRITICAL/HIGH/MEDIUM/LOW/INFO; the engine's own `--fail-on high` exit
# code is the gate.
#
# SUPPLY-CHAIN NOTE: we deliberately do NOT use Checkmarx/kics-github-action.
# Its git tags were hijacked in the March 2026 TeamPCP attack (CISA KEV), and
# beyond that history its entrypoint `apk add`s nodejs/npm at *run* time inside
# its digest-pinned base image and then executes the result — an unpinned fetch
# that defeats the pinning it advertises. That fetch also breaks the action
# outright today: Chainguard's current nodejs wants a newer glibc than the
# pinned base ships, so `node dist/index.js` dies and the step exits non-zero
# no matter what the scan found. Driving the engine image ourselves drops the
# Node layer entirely and leaves one pinned artifact — `KICS_IMAGE` in the
# Makefile, pinned by Docker Hub digest (a different artifact from the
# hijacked action). See CONTRIBUTING.md.
kics:
needs: changes
if: >-
needs.changes.outputs.ansible == 'true' || needs.changes.outputs.helm == 'true'
|| needs.changes.outputs.compose == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20 # pulls the KICS engine image; ~30s in practice
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v4.3.0
- name: KICS security scan of ansible/, the rendered chart and compose/ (fail on HIGH)
run: make security
# Replaces the action's `enable_jobs_summary`.
- name: Summarise KICS findings
if: always()
run: |
for scan in ansible helm compose; do
results=kics-results/results.json
[ "$scan" != ansible ] && results="kics-results/$scan/results.json"
{
echo "### KICS IaC scan ($scan)"
echo
if [ -f "$results" ]; then
jq -r '.severity_counters
| "| CRITICAL | HIGH | MEDIUM | LOW | INFO |",
"|---|---|---|---|---|",
"| \(.CRITICAL) | \(.HIGH) | \(.MEDIUM) | \(.LOW) | \(.INFO) |"' "$results"
echo
jq -r 'if (.total_counter // 0) == 0 then "No findings."
else (.queries[] | .query_name as $q | .severity as $s
| .files[] | "- **\($s)** \($q) — `\(.file_name):\(.line)`")
end' "$results"
else
echo "No results file — the scan did not complete."
fi
echo
} >> "$GITHUB_STEP_SUMMARY"
done
- name: Upload KICS results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: kics-results
path: kics-results/
if-no-files-found: ignore
# To surface KICS findings in the repo's Security tab, enable GitHub code
# scanning (free on public repos) and un-comment the block below (add
# `security-events: write` to this job's permissions):
# - name: Upload SARIF to code scanning
# if: always()
# uses: github/codeql-action/upload-sarif@d77b13a0df3134d64a457ea9003f600b09fa1c8a # v3.36.1
# with:
# sarif_file: kics-results/results.sarif
# Every pre-commit hook on every file: hygiene, shellcheck, markdownlint and
# yamllint. The same gate `make lint` runs locally, so contributors who never
# installed the hook still meet it. The manual-stage ansible-lint hook is skipped
# (the ansible-lint job above covers it). Always runs: it is cheap and covers
# every path.
pre-commit:
runs-on: ubuntu-latest
timeout-minutes: 10 # hook environments are cached; ~1m cold
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install pre-commit
run: python -m pip install --upgrade pre-commit
# Hook environments (node for markdownlint, the shellcheck image, yamllint's
# virtualenv) keyed on the config that pins them.
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run every hook on every file
run: pre-commit run --all-files --show-diff-on-failure
# The repo's own guard rails: ansible/Makefile's scoping guards (dry runs), the
# release gate, and lint-compose's negative cases (tests/scripts-test.sh). Always
# runs: seconds, and it covers files every path filter above would miss.
scripts:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Script and guard tests
run: make test-scripts
# Lint the workflow files themselves.
actionlint:
runs-on: ubuntu-latest
timeout-minutes: 10 # ~15s in practice
permissions:
contents: read
checks: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: reviewdog/action-actionlint@8b682e1e7e512151d73a6b9449cbff173846226e # v1.74.0
with:
reporter: github-check
fail_on_error: true