CI Parity Validation Reference
A single consolidated parity checklist and runnable validation script that detects drift across containers, env and secrets, and onboarding for local vs CI.
Drift rarely stays in one place. A floating base image, a missing env key, and a stale lockfile each break a build differently, but they all surface as the same symptom: green locally, red in CI. This reference consolidates the parity checks that otherwise live in three separate domains into one checklist and one runnable validation pass, so a developer or a CI job can confirm alignment in a single command. It extends the environment sync, secrets and CI parity baseline and deliberately reaches across all three of this site's domains: containerized local environments, environment sync, secrets and CI parity, and developer onboarding and friction mapping.
Prerequisites
Before wiring the consolidated check, confirm each domain already emits a comparable signal:
- Docker Engine 24+ with
docker composev2 anddocker buildx. - A
Dockerfilethat pins base images by digest, not floating tags — see the digest-pinning workflow in CI/CD pipeline parity checks. - A machine-readable env schema (
env-schema.jsonor a Zod module) as built in environment variable validation. - Committed lockfiles (
package-lock.json,poetry.lock, etc.) and a one-command bootstrap target, as covered by the onboarding work in common local failure points. jq,yq, andact(nektos/act) onPATHfor the runner-emulation step.
The consolidated parity checklist
Treat this as the canonical list. Each row maps a drift source to the command that detects it and the domain that owns the deeper fix.
| Check | Drift source | Detection command | Owning domain |
|---|---|---|---|
| Image digest | Floating base tag | docker inspect --format '{{index .RepoDigests 0}}' |
Containers |
| Compose resolution | Override / file order | docker compose config --no-interpolate |
Containers |
| Platform / arch | ARM64 vs amd64 runner | docker image inspect --format '{{.Architecture}}' |
Containers |
| Env schema | Undeclared / missing keys | ajv validate -s env-schema.json -d .env |
Env / secrets |
| Required vars | Unset at boot | ${VAR:?} guard or entrypoint check |
Env / secrets |
| Secret presence | Vault / file not mounted | test -s on resolved secret |
Env / secrets |
| Lockfile | Transitive resolution | git diff --exit-code <lockfile> |
Onboarding |
| Runner emulation | Action behaves only in CI | act -n (dry run) |
Onboarding |
Implementing the single validation entrypoint
The whole point is one entrypoint. The Makefile below collects the four detection stages and exits non-zero on the first failure, so it works identically as a pre-push hook and as a CI gate.
- Capture the expected image digest as a committed baseline file so both contexts compare against the same value.
- Resolve the merged Compose configuration and hash it — a stable hash proves file order and overrides match.
- Validate
.envagainst the schema and assert required keys are present. - Confirm lockfiles are clean and the workflow parses under the emulated runner.
# Makefile — consolidated parity gate
.PHONY: verify-parity
verify-parity:
@bash scripts/verify-parity.sh
.PHONY: parity-baseline
parity-baseline:
@docker image inspect app:local --format '{{index .RepoDigests 0}}' > .ci/image.digest
@docker compose config --no-interpolate | sha256sum | awk '{print $$1}' > .ci/compose.sha
@echo "Baseline written to .ci/"
#!/usr/bin/env bash
# scripts/verify-parity.sh — runs every domain's check in one pass
set -euo pipefail
fail() { echo "PARITY FAIL: $1" >&2; exit 1; }
# 1. Container: image digest matches committed baseline
EXPECTED_DIGEST="$(cat .ci/image.digest)"
ACTUAL_DIGEST="$(docker image inspect app:local --format '{{index .RepoDigests 0}}')"
[ "$EXPECTED_DIGEST" = "$ACTUAL_DIGEST" ] || fail "image digest drift ($ACTUAL_DIGEST)"
# 2. Container: merged compose config hash matches baseline
EXPECTED_COMPOSE="$(cat .ci/compose.sha)"
ACTUAL_COMPOSE="$(docker compose config --no-interpolate | sha256sum | awk '{print $1}')"
[ "$EXPECTED_COMPOSE" = "$ACTUAL_COMPOSE" ] || fail "compose resolution drift"
# 3. Env/secrets: schema validation + required keys present
ajv validate -s env-schema.json -d .env --strict-types || fail "env schema invalid"
for key in $(jq -r '.required[]' env-schema.json); do
grep -qE "^${key}=" .env || fail "required env var missing: $key"
done
# 4. Onboarding: lockfile clean + runner emulation parses
git diff --exit-code -- package-lock.json >/dev/null 2>&1 || fail "lockfile dirty"
act -n -W .github/workflows/ci.yml >/dev/null || fail "workflow does not parse under act"
echo "PARITY OK — containers, env/secrets, and runner all aligned"
Drift diagnostic
Run the gate in isolation and inspect which stage tripped:
#!/usr/bin/env bash
set -euo pipefail
make verify-parity || echo "Re-run individual checks above to localize the failing domain"
Wiring the gate into CI
Mirror the local gate as a blocking job so a passing local run guarantees a passing CI run.
# .github/workflows/parity.yml
name: Parity Gate
on: [pull_request]
jobs:
parity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build pinned image
run: docker build -t app:local .
- name: Restore baselines
run: make parity-baseline
- name: Run consolidated parity check
run: make verify-parity
macOS (Docker Desktop): image digests differ from Linux runners when an image lacks an arm64 manifest; build with
--platform linux/amd64before generating the baseline so the digest is comparable to CI. WSL2: keep the repo on the Linux filesystem (~/code, not/mnt/c) sogit diff --exit-codeon lockfiles is not tripped by line-ending rewrites. Apple Silicon (ARM64): install theaarch64builds ofjq,yq, andact; x86 binaries under emulation slow the gate enough to mask real failures behind timeouts.
Rollback / recovery
If the gate blocks work and you need to unblock while triaging:
#!/usr/bin/env bash
set -euo pipefail
# Regenerate baselines from the current verified state, then re-run.
make parity-baseline
make verify-parity
If a baseline was committed from a bad state, revert just the baseline files: git checkout HEAD~1 -- .ci/image.digest .ci/compose.sha, rebuild, and regenerate. Never disable the gate in CI to merge — instead scope the failing check out with an explicit, reviewed skip comment so the gap is visible.