The human-gated path that carries a reviewed sandbox change to prod.
- promote-plan.sh: render a manifest (JSON array of write ops with symbolic @refs
instead of ids — portable sandbox->prod) as a human-readable change-set.
- promote-apply.sh <manifest> --target sandbox|prod: replay it, resolving each
@ref to the id actually created during the run (dependent ops wire up). sandbox
rehearses via dol-write.sh; prod via dol-prod-write.sh.
- dol-prod-write.sh: the ONLY prod-write path. Prod key read from the ENVIRONMENT
only (DOLIBARR_PROD_WRITE_KEY, never a stored .env); every write refused unless
ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD.
- create scripts take a DOL_WRITE override so promote-apply reuses them per target.
- bin/arcodange: `promote {plan|apply}` group + example manifest.
- payment-record.sh: fixed supplier payments (payment_mode_id + closepaidinvoices).
Proven live: plan renders; apply --target sandbox replays a 3-op chain with refs
resolved (@tp1->id, invoice socid=@tp1, payment invoice=@inv1); --target prod
without the confirm flag is REFUSED before sending. Supplier payment now works
end-to-end via the script.
Limitation (documented): manifests reference entities they create (@ref);
pre-existing prod entities need business-key resolution (follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
69 lines
2.8 KiB
Bash
Executable File
69 lines
2.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Replay a promote manifest against a target — sandbox (rehearsal) or prod (real).
|
|
#
|
|
# Resolves the manifest's symbolic @refs to the ids actually created during this
|
|
# run, so dependent ops (an invoice -> its just-created thirdparty) wire up on the
|
|
# target. Sandbox uses dol-write.sh; prod uses the gated dol-prod-write.sh.
|
|
#
|
|
# promote-apply.sh <manifest.json> [--target sandbox|prod]
|
|
#
|
|
# --target prod requires, in the environment (never stored):
|
|
# DOLIBARR_PROD_WRITE_KEY=<prod write key>
|
|
# ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD
|
|
set -euo pipefail
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
MANIFEST="${1:?usage: promote-apply.sh <manifest.json> [--target sandbox|prod]}"; shift || true
|
|
TARGET="sandbox"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--target) TARGET="${2:?}"; shift 2 ;;
|
|
*) echo "promote-apply.sh: unknown arg '$1'" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
case "${TARGET}" in
|
|
sandbox) export DOL_WRITE="${SCRIPT_DIR}/dol-write.sh" ;;
|
|
prod) export DOL_WRITE="${SCRIPT_DIR}/dol-prod-write.sh" ;;
|
|
*) echo "promote-apply.sh: --target must be sandbox|prod" >&2; exit 2 ;;
|
|
esac
|
|
echo ">>> promote-apply target=${TARGET} (writes via $(basename "${DOL_WRITE}"))" >&2
|
|
|
|
python3 - "$MANIFEST" "$SCRIPT_DIR" <<'PY'
|
|
import json, sys, subprocess, os
|
|
manifest_path, script_dir = sys.argv[1], sys.argv[2]
|
|
ops = json.load(open(manifest_path))
|
|
OP_SCRIPT = {"thirdparty": "thirdparty-create.sh", "invoice": "invoice-create.sh",
|
|
"creditnote": "creditnote-create.sh", "payment": "payment-record.sh"}
|
|
refmap = {}
|
|
def resolve(v):
|
|
if isinstance(v, str) and v.startswith("@"):
|
|
k = v[1:]
|
|
if k not in refmap:
|
|
sys.exit("promote-apply: unresolved ref @%s (is it created earlier in the manifest?)" % k)
|
|
return refmap[k]
|
|
if isinstance(v, dict):
|
|
return {kk: resolve(vv) for kk, vv in v.items()}
|
|
if isinstance(v, list):
|
|
return [resolve(x) for x in v]
|
|
return v
|
|
for i, op in enumerate(ops, 1):
|
|
t = op["op"]; script = OP_SCRIPT.get(t)
|
|
if not script:
|
|
sys.exit("promote-apply: unknown op '%s'" % t)
|
|
inp = resolve(op.get("input", {}))
|
|
r = subprocess.run([os.path.join(script_dir, script)], input=json.dumps(inp),
|
|
capture_output=True, text=True, env=os.environ)
|
|
if r.returncode != 0:
|
|
sys.stderr.write(r.stdout + r.stderr + "\n")
|
|
sys.exit("promote-apply: op %d (%s) FAILED" % (i, t))
|
|
out = r.stdout.strip()
|
|
try:
|
|
rid = json.loads(out).get("id")
|
|
except Exception:
|
|
rid = out if out.isdigit() else None
|
|
ref = op.get("ref")
|
|
if ref and rid is not None:
|
|
refmap[ref] = int(rid) if str(rid).isdigit() else rid
|
|
print(" [%d/%d] %-11s %-8s -> id=%s" % (i, len(ops), t, ("@" + ref) if ref else "", rid))
|
|
print("OK — promote complete. ref -> id: %s" % json.dumps(refmap))
|
|
PY
|