Repository navigation
docs: front doors for node operators and publishers #310
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| 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, compose/ or cloud-init/ 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 }} | |
| cloud-init: ${{ steps.filter.outputs.cloud-init }} | |
| 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, | |
| # and renders monitoring/decdn-node/ through a symlink; lint-helm also | |
| # checks monitoring/sponsord/ and monitoring/iroh-relay/. | |
| helm: | |
| - 'charts/**' | |
| - 'monitoring/**' | |
| - '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' | |
| # lint-cloud-init lives in the root Makefile and reads | |
| # ansible/requirements.yml (the collection lock must cover it). | |
| cloud-init: | |
| - 'cloud-init/**' | |
| - 'ansible/requirements.yml' | |
| - '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 the | |
| # hosts-*.yml.example files), 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` and `decdn.publisher` collections and run | |
| # galaxy-importer's checks on each — 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 and decdn.publisher collections | |
| run: make galaxy-check | |
| env: | |
| ANSIBLE_COLLECTIONS_PATH: collections | |
| - name: Upload collection artifacts | |
| if: always() | |
| uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 | |
| with: | |
| name: decdn-collections | |
| path: | | |
| ansible/build/decdn-node-*.tar.gz | |
| ansible/build/decdn-publisher-*.tar.gz | |
| if-no-files-found: ignore | |
| # Helm chart: `helm lint --strict`, positive + negative render tests, kubeconform | |
| # and promtool (digest-pinned images) 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 and promtool images; ~1m in practice | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Helm version is pinned here, in the kics and scripts jobs below and twice in | |
| # release-chart.yml; bump all five. | |
| - 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 | |
| # cloud-init path: `cloud-init schema` plus the invariants cloud-init/README.md | |
| # promises (no secrets, release installs, host-generated node wallet, localhost in | |
| # decdn_nodes and/or sponsord_hosts, a keyed admin account, shellcheck-clean stage 1, | |
| # a collection lock covering ansible/requirements.yml), for both templates. The boots | |
| # themselves are the molecule `cloud-init` and `cloud-init-publisher` scenarios | |
| # (molecule.yml). Runs only when cloud-init/, ansible/requirements.yml, the root | |
| # Makefile or ci.yml changed. | |
| cloud-init: | |
| needs: changes | |
| if: needs.changes.outputs.cloud-init == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 # an apt install at most; seconds otherwise | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # yq and shellcheck ship on ubuntu-latest; cloud-init may not. | |
| - name: Install cloud-init | |
| run: command -v cloud-init || { sudo apt-get update -qq && sudo apt-get install -y -qq cloud-init; } | |
| - name: cloud-init invariants | |
| run: make lint-cloud-init | |
| # Docker Compose path: render compose/compose.yaml with every profile on (with its | |
| # example env files, without env files, with an empty .env) and assert the | |
| # invariants its README promises (compose/tests/*.jq: host network, nothing | |
| # published, images by digest, read-only rootfs, no capabilities, exact mounts, | |
| # loopback listeners, no secret inline, graceful stop, unset variables that fail | |
| # closed). 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 | |
| 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.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 the lint-compose and lint-cloud-init 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: 10 # an apt install at most; seconds otherwise | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Without cloud-init on PATH the harness SKIPS the lint-cloud-init cases. | |
| - name: Install cloud-init | |
| run: command -v cloud-init || { sudo apt-get update -qq && sudo apt-get install -y -qq cloud-init; } | |
| # Without ansible-core on PATH the harness FAILS the firewall-holes case in CI. | |
| - name: Install ansible-core | |
| run: command -v ansible || pipx install ansible-core | |
| # The release's Helm, for the test that packages the chart as release-chart.yml does. | |
| - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 | |
| with: | |
| version: v4.3.0 | |
| # For the scripts/release.sh cases: the release tarball, pinned by sha256. | |
| # release.sh needs git-cliff 2.9 or later; RELEASING.md names this version as | |
| # the one CI tests with, so bump the two together. | |
| - name: Install git-cliff | |
| env: | |
| GIT_CLIFF_VERSION: 2.14.1 | |
| GIT_CLIFF_SHA256: dfe9bef0c7a00d05fafe78e6e591a1a280d39dd495b8066764db0a559acde67f | |
| run: | | |
| f="git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | |
| curl -fsSLo "$RUNNER_TEMP/$f" "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/$f" | |
| echo "${GIT_CLIFF_SHA256} $RUNNER_TEMP/$f" | sha256sum -c - | |
| tar -xzf "$RUNNER_TEMP/$f" -C "$RUNNER_TEMP" | |
| echo "$RUNNER_TEMP/git-cliff-${GIT_CLIFF_VERSION}" >> "$GITHUB_PATH" | |
| - 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@1758727be30241166609dad4c97588b6ad411f32 # v1.79.1 | |
| with: | |
| reporter: github-check | |
| fail_on_error: true |