name: Reconcile on: push: branches: - main paths: - "state/*.json" jobs: reconcile: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 2 - name: Reconcile changed environments env: TEST_RECONCILER_TOKEN: ${{ secrets.TEST_RECONCILER_TOKEN }} TEST_RECONCILER_URL: ${{ secrets.TEST_RECONCILER_URL }} DEV_RECONCILER_TOKEN: ${{ secrets.DEV_RECONCILER_TOKEN }} DEV_RECONCILER_URL: ${{ secrets.DEV_RECONCILER_URL }} PROD_RECONCILER_TOKEN: ${{ secrets.PROD_RECONCILER_TOKEN }} PROD_RECONCILER_URL: ${{ secrets.PROD_RECONCILER_URL }} run: | python3 <<'SCRIPT' import json, os, subprocess, urllib.request, sys # Detect changed state files diff = subprocess.run( ["git", "diff", "--name-only", "HEAD~1", "HEAD", "--", "state/*.json"], capture_output=True, text=True, check=True, ) envs = [os.path.basename(f).replace(".json", "") for f in diff.stdout.strip().split("\n") if f.strip()] if not envs: print("No state files changed") exit(0) print(f"Changed environments: {envs}") failed = [] for env in envs: key = env.upper().replace("-", "_") token = os.environ.get(f"{key}_RECONCILER_TOKEN", "") url = os.environ.get(f"{key}_RECONCILER_URL", "") if not token or not url: print(f"[{env}] No secrets configured — skipping") continue # Sync events first try: req = urllib.request.Request( f"{url}/sync-events", method="POST", headers={"Authorization": f"Bearer {token}"}, ) urllib.request.urlopen(req) print(f"[{env}] Synced events") except Exception as e: print(f"[{env}] Sync events failed (non-fatal): {e}") # Apply reconcile with open(f"state/{env}.json", "rb") as f: state_data = f.read() req = urllib.request.Request( f"{url}/reconcile", data=state_data, method="POST", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, ) try: resp = urllib.request.urlopen(req) data = json.loads(resp.read()) except Exception as e: print(f"[{env}] Reconcile FAILED: {e}") failed.append(env) continue status = data.get("status", "ok") if status == "error": print(f"[{env}] Reconcile returned error:") print(json.dumps(data, indent=2)) failed.append(env) continue summary = data.get("summary", {}) print(f"[{env}] Reconcile OK: " f"{summary.get('created',0)} created, " f"{summary.get('updated',0)} updated, " f"{summary.get('deleted',0)} deleted") # Log each operation with details for op in data.get("operations", []): t = op["type"] n = op["name"] s = op.get("status", "?") action = "CREATE" if t.startswith("create") else "DELETE" if t.startswith("delete") else "UPDATE" resource = t.split("_", 1)[1] if "_" in t else t prefix = f"[{env}] {action} {resource} '{n}' -> {s}" changes = op.get("changes", []) if changes: def fmt(v): if isinstance(v, bool): return str(v).lower() if isinstance(v, list): return ", ".join(str(x) for x in v) if v else "(empty)" if v is None: return "(none)" return str(v) parts = [f"{c['field']}: {fmt(c['from'])} -> {fmt(c['to'])}" for c in changes] print(f" {prefix} [{'; '.join(parts)}]") else: print(f" {prefix}") keys = data.get("created_keys", {}) if keys: print(f"[{env}] Created setup keys: {list(keys.keys())}") if failed: print(f"\nFailed environments: {failed}") sys.exit(1) SCRIPT