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.

Three parity domains converging on one validation gate Container parity, env and secret parity, and onboarding parity each feed a single validation gate that emits a pass or fail result. Consolidated Parity Validation Container Parity • image digests • platform / arch • compose config Env & Secret Parity • env schema • required vars • secret presence Onboarding Parity • lockfiles • runner emulation • bootstrap target make verify-parity pass / fail (exit code) One gate, three domains — fail fast on any divergence

Prerequisites

Before wiring the consolidated check, confirm each domain already emits a comparable signal:

  • Docker Engine 24+ with docker compose v2 and docker buildx.
  • A Dockerfile that 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.json or 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, and act (nektos/act) on PATH for 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.

  1. Capture the expected image digest as a committed baseline file so both contexts compare against the same value.
  2. Resolve the merged Compose configuration and hash it — a stable hash proves file order and overrides match.
  3. Validate .env against the schema and assert required keys are present.
  4. 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/amd64 before generating the baseline so the digest is comparable to CI. WSL2: keep the repo on the Linux filesystem (~/code, not /mnt/c) so git diff --exit-code on lockfiles is not tripped by line-ending rewrites. Apple Silicon (ARM64): install the aarch64 builds of jq, yq, and act; 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.