Skip to content

ci: add pull request validation gate - #457

Open
anatolinicolae wants to merge 6 commits into
mainfrom
anatoli/ci-validation-gate
Open

anatolinicolae wants to merge 6 commits into
mainfrom
anatoli/ci-validation-gate

Conversation

@anatolinicolae

@anatolinicolae anatolinicolae commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

What

Adds static validation on pull_request. Every workflow until now deployed to production, so a malformed playbook was first discovered by running it against a live host.

Five checks, all green:

Check Tool
YAML yamllint
Ansible ansible-lint (production)
OpenTofu tofu validate / tofu fmt
Actions zizmor

ci/validate.sh runs the same set locally.

Four latent bugs fixed

  • community.docker / community.general used but undeclared in every host's requirements.yml (34 and 3 call sites). Resolved only via a transitive dependency, so a clean runner would fail syntax-check. Now pinned.
  • Duplicate become key in mx1's Nginx block. YAML kept the last, so the block ran with no privilege escalation.
  • CrowdSec config.yaml template set no file mode. Pinned to 0644.
  • Commands that change state didn't declare it. changed_when: true is already the default, so these are declarations, not behaviour changes.

The remaining ~99 files are mechanical: 574 yamllint violations and 151 fqcn fixes, in a separate commit.

Notes

  • The container security contract in CLAUDE.md is not enforced — nothing checks a compose file. A new service omitting cap_drop will pass CI and rely on review.
  • All four checks are required status checks on main. Retiring or renaming one needs a matching change to required_status_checks.contexts, or the merge blocks.

Your Name added 3 commits September 25, 2026 23:08
Found by the new PR validation gate; all four are latent rather than
currently-failing problems.

community.docker (34 call sites) and community.general (3) are used by the
role tree but appeared in no requirements.yml. They resolved only as a
transitive dependency of geerlingguy.docker, which declares no dependencies
of its own, so a clean runner would have failed ansible-lint's syntax check.
Both are now pinned explicitly in every host's requirements.yml.

mx1's Nginx Configuration task set `become: true` twice in the same mapping.
YAML keeps the last occurrence, so the block ran with no privilege escalation
at all. The block-level key is now the single source of truth.

The CrowdSec config.yaml template set no file mode, leaving permissions to
the umask. Pinned to 0644, matching the role's own keyring.

Commands that genuinely change state now say so. `changed_when: true` is
already the default for command/shell, so these are declarations of existing
behaviour rather than changes, and they clear the no-changed-when rule
without lying about idempotency. The apt role's `== true` comparisons are
dropped; stat.exists is already a boolean.
Required for the validation gate to be green, and mostly invisible in
review: yes/no to true/false, a document start on every file, trailing
newlines, and 151 fqcn fixes bringing the last unqualified actions in line
with the rest of the tree.

Every rewritten file was re-parsed and compared against HEAD to confirm the
parsed structure was unchanged; long lines were folded into YAML folded
scalars and verified to resolve byte-identical. Two bugs in that conversion
were caught this way and reverted: a regex that rewrote the interior of a
{{ }} Jinja expression, and a pass that touched 122 files instead of the 88
that actually needed it.

The one mixed-style file (mx1 system/config) was left alone apart from the
block-level `become` fix in the previous commit; indenting sequences the way
yamllint defaults to would have reformatted every task file in the repo.
Every workflow until now was deploy-to-production, so a malformed playbook
was first discovered by running it against a live host. Adds static
validation on pull_request, plus a local runner so the same failures surface
before pushing.

Seven checks, all green at this commit:

  - workflow secret guard, asserting validate.yml declares no credentials
  - compose policy, enforcing the container contract from CLAUDE.md
  - yamllint, ansible-lint, tofu validate/fmt, zizmor

The compose check is the point of the exercise. The cap_drop and
no-new-privileges rules were prose in CLAUDE.md, reviewable but not
enforced. Services that genuinely cannot satisfy them (data stores needing
CAP_SETUID for gosu, the authentik worker needing the Docker socket) are now
listed explicitly with a written reason, keyed by (compose path, service)
because service names are ambiguous across stacks. An exempted service that
also sets cap_drop fails, so a stale exemption cannot linger.

validate.yml runs on pull_request and so executes code from the pull request.
It is therefore configured to hold no credentials at all, so its behaviour
does not depend on who opened the PR. ci/check_workflow_secrets.sh re-checks
that on every run, and zizmor audits the workflow independently.

Both checkers were verified to fail on the conditions they claim to catch
rather than passing vacuously. The tofu job uses -backend=false so it needs
no OCI credentials.

Known scope limit: the eight deploy workflows still produce 42 zizmor
findings (mostly secrets interpolation and missing persist-credentials).
They legitimately hold credentials and are never triggered by pull_request,
so auditing them is a separate hardening pass. Noted in the workflow.
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 129 files, which is 29 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: e6567e43-797e-4414-9405-eb68ee4e279d

📥 Commits

Reviewing files that changed from the base of the PR and between 2bcf26a and 638d50a.

📒 Files selected for processing (129)
  • .ansible-lint
  • .github/actions/setup-lint-env/action.yml
  • .github/workflows/mirror-ansible-deploy.yml
  • .github/workflows/mirror-tofu-apply.yml
  • .github/workflows/mx1-ansible-deploy.yml
  • .github/workflows/tower-ansible-deploy.yml
  • .github/workflows/tower-tofu-apply.yml
  • .github/workflows/validate.yml
  • .github/workflows/web1-ansible-deploy.yml
  • .github/workflows/web2-ansible-deploy.yml
  • .github/workflows/web3-ansible-deploy.yml
  • .yamllint
  • CLAUDE.md
  • README.md
  • ci/validate.sh
  • mirror/ansible/defaults/main.yml
  • mirror/ansible/playbook.yml
  • mirror/ansible/requirements.yml
  • mirror/ansible/roles/system/apt/tasks/main.yml
  • mirror/ansible/roles/system/config/handlers/main.yml
  • mirror/ansible/roles/system/config/tasks/main.yml
  • mirror/ansible/roles/system/containers/tasks/deploy_stack.yml
  • mirror/ansible/roles/system/containers/tasks/main.yml
  • mirror/ansible/roles/system/migrations/defaults/main.yml
  • mirror/ansible/roles/system/migrations/tasks/main.yml
  • mx1/ansible/defaults/main.yml
  • mx1/ansible/playbook.yml
  • mx1/ansible/requirements.yml
  • mx1/ansible/roles/system/apt/tasks/main.yml
  • mx1/ansible/roles/system/config/handlers/main.yml
  • mx1/ansible/roles/system/config/tasks/main.yml
  • mx1/ansible/roles/system/containers/tasks/deploy_stack.yml
  • mx1/ansible/roles/system/containers/tasks/main.yml
  • mx1/ansible/roles/system/migrations/defaults/main.yml
  • mx1/ansible/roles/system/migrations/tasks/main.yml
  • mx1/ansible/roles/system/monit/tasks/main.yml
  • tower/ansible/defaults/main.yml
  • tower/ansible/playbook.yml
  • tower/ansible/requirements.yml
  • tower/ansible/roles/system/apt/tasks/main.yml
  • tower/ansible/roles/system/config/tasks/main.yml
  • tower/ansible/roles/system/containers/tasks/deploy_stack.yml
  • tower/ansible/roles/system/containers/tasks/main.yml
  • tower/ansible/roles/system/migrations/defaults/main.yml
  • tower/ansible/roles/system/migrations/tasks/main.yml
  • tower/containers/cloudflared/docker-compose.yml
  • tower/containers/cobalt/docker-compose.yml
  • tower/containers/headscale/docker-compose.yml
  • tower/terraform/.terraform.lock.hcl
  • tower/terraform/network.tf
  • web1/ansible/defaults/main.yml
  • web1/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml
  • web1/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml
  • web1/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml
  • web1/ansible/playbook.yml
  • web1/ansible/requirements.yml
  • web1/ansible/roles/system/apt/tasks/main.yml
  • web1/ansible/roles/system/backup/tasks/main.yml
  • web1/ansible/roles/system/config/handlers/main.yml
  • web1/ansible/roles/system/config/tasks/main.yml
  • web1/ansible/roles/system/containers/tasks/authentik.yml
  • web1/ansible/roles/system/containers/tasks/main.yml
  • web1/ansible/roles/system/containers/tasks/opencloud.yml
  • web1/ansible/roles/system/crowdsec/handlers/main.yml
  • web1/ansible/roles/system/crowdsec/tasks/main.yml
  • web1/ansible/roles/system/migrations/defaults/main.yml
  • web1/ansible/roles/system/migrations/tasks/main.yml
  • web1/ansible/roles/system/monit/tasks/main.yml
  • web1/ansible/roles/system/php/tasks/_build_php_extension.yml
  • web1/ansible/roles/system/php/tasks/main.yml
  • web1/containers/1min-relay/docker-compose.yml
  • web1/containers/calcom/docker-compose.yml
  • web1/containers/hermes/docker-compose.yml
  • web1/containers/open-webui/docker-compose.yml
  • web1/containers/opencloud/csp.yaml
  • web1/containers/opencloud/docker-compose.yml
  • web1/containers/roundcube/docker-compose.yml
  • web1/containers/twenty/docker-compose.yml
  • web1/containers/webmail/docker-compose.yml
  • web2/ansible/defaults/main.yml
  • web2/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml
  • web2/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml
  • web2/ansible/playbook.yml
  • web2/ansible/requirements.yml
  • web2/ansible/roles/system/apt/tasks/main.yml
  • web2/ansible/roles/system/backup/tasks/main.yml
  • web2/ansible/roles/system/config/handlers/main.yml
  • web2/ansible/roles/system/config/tasks/main.yml
  • web2/ansible/roles/system/containers/tasks/deploy_stack.yml
  • web2/ansible/roles/system/containers/tasks/main.yml
  • web2/ansible/roles/system/crowdsec/handlers/main.yml
  • web2/ansible/roles/system/crowdsec/tasks/main.yml
  • web2/ansible/roles/system/migrations/defaults/main.yml
  • web2/ansible/roles/system/migrations/tasks/main.yml
  • web2/ansible/roles/system/monit/tasks/main.yml
  • web2/containers/cloudflared/docker-compose.yml
  • web2/containers/cobalt/docker-compose.yml
  • web2/containers/hypebun-web/docker-compose.yml
  • web3/ansible/defaults/main.yml
  • web3/ansible/migrations/20260121_0001_relocate_media_for_authentik.yml
  • web3/ansible/migrations/20260127_0002_switch_crowdsec_bouncer_to_nftables.yml
  • web3/ansible/migrations/20260204_0003_fix_crowdsec_nftables_priority.yml
  • web3/ansible/playbook.yml
  • web3/ansible/requirements.yml
  • web3/ansible/roles/system/apt/tasks/main.yml
  • web3/ansible/roles/system/backup/tasks/main.yml
  • web3/ansible/roles/system/config/handlers/main.yml
  • web3/ansible/roles/system/config/tasks/main.yml
  • web3/ansible/roles/system/containers/tasks/authentik.yml
  • web3/ansible/roles/system/containers/tasks/deploy_stack.yml
  • web3/ansible/roles/system/containers/tasks/fleet.yml
  • web3/ansible/roles/system/containers/tasks/main.yml
  • web3/ansible/roles/system/crowdsec/handlers/main.yml
  • web3/ansible/roles/system/crowdsec/tasks/main.yml
  • web3/ansible/roles/system/migrations/defaults/main.yml
  • web3/ansible/roles/system/migrations/tasks/main.yml
  • web3/ansible/roles/system/monit/tasks/main.yml
  • web3/ansible/roles/system/php/tasks/_build_php_extension.yml
  • web3/ansible/roles/system/php/tasks/main.yml
  • web3/containers/1min-relay/docker-compose.yml
  • web3/containers/calcom/docker-compose.yml
  • web3/containers/cloudflared/docker-compose.yml
  • web3/containers/cobalt/docker-compose.yml
  • web3/containers/fleet/docker-compose.yml
  • web3/containers/gpt-load/docker-compose.yml
  • web3/containers/hermes/docker-compose.yml
  • web3/containers/n8n/docker-compose.yml
  • web3/containers/open-webui/docker-compose.yml
  • web3/containers/twenty/docker-compose.yml

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Your Name added 3 commits September 25, 2026 23:20
Neither survived scrutiny, and keeping them was worse than not having them.

check_workflow_secrets.sh asserted that validate.yml declares no
credentials. It ran as a job in the same workflow it was checking, on the
pull request's own copy of itself — so a pull request could delete the script
or the job and pass. main is not a protected branch either, so no check is
required. It was documentation wearing a test's clothes.

Testing also showed it was largely redundant with the zizmor job beside it:
of its seven rules, zizmor independently catches secrets references,
pull_request_target, workflow_run and write permissions. Its one unique rule
was the environment: key, which zizmor passes. And it had a real bug — the
persist-credentials check was file-global rather than per-step, so dropping
the setting from any one of five checkouts still passed, which is the exact
case the rule exists for.

check_compose.py enforced the container contract from CLAUDE.md. That
contract is prose, and expressing it in code created a second copy to keep
in sync. The script was also the only thing forcing a hand-maintained
exemption list into the repo, keyed by (compose path, service) because
service names are ambiguous across stacks.

Both were review aids dressed as gates. The underlying rules stay where they
were read before: CLAUDE.md, with the reasoning about why they are easy to
miss now written next to the rules.

Leaves yamllint, ansible-lint, tofu validate/fmt and zizmor, all green.
main is now a protected branch with all four checks required, so the wording
that described them as unenforced is stale. Replaced with what a maintainer
actually needs to know: the checks block a merge, and retiring one requires
removing it from required_status_checks.contexts in the same pull request.
The validate jobs pip-installed each tool in isolation, so ansible-lint
brought its own ansible-core. That happened to match the deploys'
ansible==14.4.0 (-> ansible-core~=2.21.4), but nothing tied them: bump
ansible in requirements.txt and the deploys would move while validation
checked a different version.

Installing requirements.txt and ansible-lint into one uv environment makes the
match structural. If they ever conflict the install fails loudly instead of
quietly adding a second copy. Verified: all five packages co-resolve, and
ansible-lint reports ansible-core 2.21.4 — the pinned one.

Lint tool pins stay out of requirements.txt on purpose; that file is what the
deploys install and a production playbook should not carry a linter.

Local action referenced as $/.github/actions/... rather than ./. The $/ form
is the current syntax and resolves to the exact running commit, so it does not
depend on the workspace checkout or a hardcoded version. The tofu job needs no
Python and is left alone.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant