Files
acdl/scripts/sync_to_nova.sh
CIAgent Orchestrator 932923ee99
Nova Slides Render / render (push) Failing after 22s
merge(milestone): v1.29 Reposplit + Identity Layer Bring-Live to main (release v1.28.6)
---ci---
project: acdl
phase: 6
milestone: v1.29
status: complete
---/ci---
2026-08-20 05:29:46 +00:00

395 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/sync_to_nova.sh — manual-only "2nd release" of ~/acdl into ~/nova.
#
# ~/nova is a SEPARATE GitLab repo (jonathanchery/nova) with its own history,
# consumer/platform-team audience, and conventional commit standards. It is
# NOT a mirror of ~/acdl (the CIAgent audit trail lives only in ~/acdl).
#
# This script:
# 1. Refuses to run unless --release (or RELEASE_CONFIRMED=1) is set — it can
# NEVER be triggered by CI or accidentally. This is a human-only gate.
# 2. rsyncs the CONSUMER SUBSET of ~/acdl into ~/nova (internal-only paths —
# .ciagent, terraform, demo, internal scripts, runtime metrics — are
# excluded; the destination's .git history is protected untouched).
# 3. Commits changes DOMAIN BY DOMAIN in a fixed order, using one
# conventional-commit message per changed domain passed via repeated -m
# flags. NO kitchen-sink "sync from source mirror" commit. Messages are
# consumed positionally over the changed domains (in the order printed by
# --list-domains / --dry-run).
# 4. Pushes the current branch to its upstream (unless --no-push).
#
# Usage:
# bash scripts/sync_to_nova.sh --release -m "feat(core): add X" -m "docs(contracts): refresh Y"
# bash scripts/sync_to_nova.sh --dry-run --release -m "chore(core): sync"
# bash scripts/sync_to_nova.sh --list-domains
# bash scripts/sync_to_nova.sh --no-push --release -m "fix(schemas): tighten validation"
#
# SRC=~/acdl DST=~/nova bash scripts/sync_to_nova.sh --release -m "..."
#
# Domain order (first match wins; a domain with no staged changes is skipped
# and does NOT consume a -m message — messages map positionally over the
# CHANGED domains only):
# 1 config README.md, pyproject.toml, requirements-test.txt, .gitignore
# 2 core core/**
# 3 adapters adapters/**
# 4 modules modules/**
# 5 contracts contracts/**
# 6 schemas schemas/**
# 7 pipelines pipelines/**
# 8 mcp mcp/**
# 9 skills skills/**
# 10 scripts scripts/** (consumer runbooks only; internal scripts excluded)
# 11 tests tests/**
# 12 docs docs/**
# 13 metrics metrics/README.md, metrics/TRUST_SNAPSHOT.md, metrics/powerbi/**
# 14 workflows .github/**, workflows-src/**
set -euo pipefail
SRC="${SRC:-$HOME/acdl}"
DST="${DST:-$HOME/nova}"
VERBOSE=0
NO_PUSH=0
DRY_RUN=0
RELEASE=0
LIST_DOMAINS=0
NO_VERIFY_FORMAT=0
COMMIT_MSGS=()
usage() { sed -n '2,40p' "$0"; }
while [ $# -gt 0 ]; do
case "$1" in
--release) RELEASE=1; shift ;;
RELEASE_CONFIRMED=1) RELEASE=1; shift ;;
-v|--verbose) VERBOSE=1; shift ;;
--no-push) NO_PUSH=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--list-domains) LIST_DOMAINS=1; shift ;;
--no-verify-format) NO_VERIFY_FORMAT=1; shift ;;
-m|--message) shift; [ $# -gt 0 ] || { echo "FAIL: -m requires a value" >&2; exit 1; }; COMMIT_MSGS+=("$1"); shift ;;
-m=*) COMMIT_MSGS+=("${1#-m=}"); shift ;;
--message=*) COMMIT_MSGS+=("${1#--message=}"); shift ;;
-h|--help) usage; exit 0 ;;
*) echo "FAIL: unknown argument: $1" >&2; exit 1 ;;
esac
done
# --- domains (ordered; first match wins) ------------------------------------
#
# Each entry: "<name>|<pathspec1 pathspec2 ...>". Pathspecs are relative to
# DST and use git pathspec semantics. The order here IS the commit order and
# the order messages are consumed in.
DOMAINS=(
"config|README.md pyproject.toml requirements-test.txt .gitignore"
"core|core"
"adapters|adapters"
"modules|modules"
"contracts|contracts"
"schemas|schemas"
"pipelines|pipelines"
"mcp|mcp"
"skills|skills"
"scripts|scripts"
"tests|tests"
"docs|docs"
"metrics|metrics/README.md metrics/TRUST_SNAPSHOT.md metrics/powerbi"
"workflows|.github workflows-src"
)
# Internal-only scripts that must NEVER be synced to ~/nova. These are
# CIAgent/ops/release plumbing that only makes sense in ~/acdl. Anything in
# scripts/ NOT in this list is a consumer-facing runbook and IS synced.
EXCLUDE_SCRIPTS=(
sync_to_gl.sh
sync_to_nova.sh
update_atelier_vendor.sh
post_stage_comment.sh
rotate_spike_key.sh
run_l2_lifecycle_destroy.sh
run_lifecycle_destroy.sh
run_lifecycle_test.sh
migrate_dynamodb_data.py
migrate_ssm_paths.py
untag_acdl_keys.py
seed_uptime_monitors.py
push_consumer_image.py
check_north_star_diff.sh
render_slides.sh
)
CONV_RE='^(feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert)(\([^)]+\))?: .+'
# --- helpers ---------------------------------------------------------------
fail() { echo "FAIL: $*" >&2; exit 1; }
# Print the domain list (name | paths) for --list-domains.
print_domains() {
printf '%-12s %s\n' "DOMAIN" "PATHS"
for entry in "${DOMAINS[@]}"; do
name="${entry%%|*}"; paths="${entry#*|}"
printf '%-12s %s\n' "$name" "$paths"
done
}
# --- --list-domains (no side effects, no gate) ------------------------------
if [ "$LIST_DOMAINS" = "1" ]; then
print_domains
exit 0
fi
# --- manual-only gate ------------------------------------------------------
if [ "$RELEASE" = "0" ]; then
echo "sync_to_nova: this is a MANUAL-ONLY 2nd release into ~/nova (separate repo," >&2
echo "separate history, consumer-facing). It is never triggered by CI." >&2
echo "" >&2
echo "To confirm intent, re-run with --release (or set RELEASE_CONFIRMED=1):" >&2
echo " bash scripts/sync_to_nova.sh --release -m \"<conventional commit>\" [-m ...]" >&2
echo "" >&2
echo "Use --list-domains to see the domain order, or --dry-run --release to preview." >&2
exit 2
fi
# --- sanity checks ---------------------------------------------------------
[ -d "$SRC" ] || fail "source not found: $SRC"
[ -d "$DST" ] || fail "destination not found: $DST (create it first)"
[ -d "$DST/.git" ] || fail "destination has no .git: $DST/.git (restore it first)"
# Refuse if DST is not inside $HOME or is the same as SRC.
case "$DST" in
"$HOME"/*) : ;;
*) fail "destination must live under \$HOME (got $DST)" ;;
esac
[ "$SRC" != "$DST" ] || fail "source and destination are identical"
# Refuse to run inside a merge/rebase/conflict state in DST.
git_dir_state() {
local f
for f in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do
[ -e "$DST/.git/$f" ] && return 1
done
[ -d "$DST/.git/rebase-merge" ] || [ -d "$DST/.git/rebase-apply" ] && return 1
return 0
}
git_dir_state || fail "destination .git is mid-operation (merge/rebase/etc); resolve it then re-run"
echo "=== sync_to_nova (manual 2nd release) ==="
echo "source: $SRC"
echo "destination: $DST"
[ "$VERBOSE" = "1" ] && echo "mode: verbose"
[ "$NO_PUSH" = "1" ] && echo "mode: sync + commit (no push)"
[ "$DRY_RUN" = "1" ] && echo "mode: dry-run (no changes made)"
echo ""
# --- rsync (consumer subset) -----------------------------------------------
#
# Strategy: explicit excludes for everything internal-only, then a protected
# .git filter, then .gitignore dir-merge semantics so consumer-visible ignored
# files (pyc, .env, etc.) are also dropped. --delete prunes extras in the
# synced tree so removals in ~/acdl propagate to ~/nova. --delete-excluded is
# NOT used so the protected .git survives.
# Hidden dirs/files in SRC that are NOT consumer-facing. .github is kept.
EXCLUDES=(
--exclude=/.ciagent
--exclude=/.env
--exclude=/.env.secrets
--exclude=/.coverage
--exclude=/.pytest_cache
--exclude=/.git
--exclude=/terraform
--exclude=/demo
)
# Runtime metrics artifacts (keep README.md, powerbi/, TRUST_SNAPSHOT.md).
EXCLUDES+=(
--exclude=/metrics/nova_metrics.db
--exclude=/metrics/decision_ledger.db
--exclude=/metrics/events.jsonl
--exclude=/metrics/test-results.xml
--exclude=/metrics/test-report.json
--exclude=/metrics/coverage.json
--exclude=/metrics/runs
--exclude=/metrics/lifecycle
)
# Internal-only scripts (by basename).
for s in "${EXCLUDE_SCRIPTS[@]}"; do
EXCLUDES+=("--exclude=/scripts/$s")
done
# Universal noise.
EXCLUDES+=(
--exclude=**/__pycache__
--exclude=**/*.pyc
--exclude=**/*.pyo
--exclude=**/.DS_Store
)
# .git protection (P = protect from --delete) + per-directory .gitignore merge.
# Each --filter is a single token: "<rule> <pattern>".
FILTERS=(
"--filter=P .git"
"--filter=:- .gitignore"
)
RSYNC_ARGS=(-a --delete)
[ "$VERBOSE" = "1" ] && RSYNC_ARGS+=(-v)
echo "rsync excludes: ${EXCLUDES[*]}"
echo "rsync filters: ${FILTERS[*]}"
echo ""
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] rsync would run:"
printf ' %q ' rsync "${RSYNC_ARGS[@]}" "${FILTERS[@]}" "${EXCLUDES[@]}" "$SRC/" "$DST/"; echo
else
rsync "${RSYNC_ARGS[@]}" "${FILTERS[@]}" "${EXCLUDES[@]}" "$SRC/" "$DST/"
echo "rsync: OK"
fi
echo ""
# --- domain-based commits ---------------------------------------------------
cd "$DST"
branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ -n "$branch" ] || fail "HEAD is detached; checkout a branch first (got $(git rev-parse --short HEAD))"
# Stage everything (including deletions) so we can diff per-domain.
# In dry-run we do NOT run rsync (nothing is written), so per-domain change
# detection against the destination is meaningless — dry-run is a preview
# only (rsync command + message validation). Domain mapping is computed in
# real mode below.
if [ "$DRY_RUN" = "0" ]; then
git add -A
fi
# Determine which domains have changes (positional, in DOMAIN order).
changed_names=()
changed_pathspecs=()
if [ "$DRY_RUN" = "1" ]; then
# Preview: print the fixed domain order so the user can line up -m messages.
echo "domain order (messages map positionally over CHANGED domains only):"
for entry in "${DOMAINS[@]}"; do
name="${entry%%|*}"; paths="${entry#*|}"
printf ' %-12s %s\n' "$name" "$paths"
done
echo ""
echo "provided -m messages (${#COMMIT_MSGS[@]}):"
for i in "${!COMMIT_MSGS[@]}"; do
printf ' %2d %s\n' "$((i+1))" "${COMMIT_MSGS[$i]}"
done
echo ""
echo "(dry-run: rsync not run — actual changed-domain detection happens in real mode;"
echo " re-run without --dry-run, or with --no-push to commit without pushing.)"
else
for entry in "${DOMAINS[@]}"; do
name="${entry%%|*}"; paths="${entry#*|}"
# shellcheck disable=SC2086
if ! git diff --cached --quiet -- $paths 2>/dev/null; then
changed_names+=("$name"); changed_pathspecs+=("$paths")
fi
done
n_changed=${#changed_names[@]}
echo "changed domains (in commit order):"
if [ "$n_changed" = "0" ]; then
echo " (none)"
else
for i in "${!changed_names[@]}"; do
printf ' %2d %-12s %s\n' "$((i+1))" "${changed_names[$i]}" "${changed_pathspecs[$i]}"
done
fi
echo ""
fi
# Validate -m count matches changed-domain count (real mode only).
n_msgs=${#COMMIT_MSGS[@]}
if [ "$DRY_RUN" = "1" ]; then
# In dry-run we can't know how many domains will change, so we only
# validate conventional-commit format. Count check happens in real mode.
if [ "$NO_VERIFY_FORMAT" = "0" ]; then
for i in "${!COMMIT_MSGS[@]}"; do
msg="${COMMIT_MSGS[$i]}"
if ! [[ "$msg" =~ $CONV_RE ]]; then
echo "FAIL: message $((i+1)) is not a conventional commit:" >&2
echo " \"$msg\"" >&2
echo " expected: <type>[optional(scope)]: <subject>" >&2
echo " types: feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert" >&2
echo " (use --no-verify-format to skip this check)" >&2
exit 1
fi
done
fi
echo "git: (dry-run) no commits made"
else
if [ "$n_changed" = "0" ]; then
echo "git: no changes to commit on branch '$branch'"
else
if [ "$n_msgs" -ne "$n_changed" ]; then
echo "FAIL: $n_changed domain(s) changed but $n_msgs -m message(s) provided." >&2
echo " Messages map POSITIONALLY to the changed domains above (in order)." >&2
echo " Re-run with exactly $n_changed -m flag(s), or --list-domains to" >&2
echo " see the order, or --dry-run to preview." >&2
exit 1
fi
# Validate conventional-commit format (unless --no-verify-format).
if [ "$NO_VERIFY_FORMAT" = "0" ]; then
for i in "${!COMMIT_MSGS[@]}"; do
msg="${COMMIT_MSGS[$i]}"
if ! [[ "$msg" =~ $CONV_RE ]]; then
echo "FAIL: message $((i+1)) is not a conventional commit:" >&2
echo " \"$msg\"" >&2
echo " expected: <type>[optional(scope)]: <subject>" >&2
echo " types: feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert" >&2
echo " (use --no-verify-format to skip this check)" >&2
exit 1
fi
done
fi
# Commit per domain in order.
for i in "${!changed_names[@]}"; do
name="${changed_names[$i]}"
paths="${changed_pathspecs[$i]}"
msg="${COMMIT_MSGS[$i]}"
echo "git: committing domain '$name' on branch '$branch'"
[ "$VERBOSE" = "1" ] && { git diff --cached --stat -- $paths 2>/dev/null || true; }
git reset HEAD -- . >/dev/null 2>&1 || true
# shellcheck disable=SC2086
git add -- $paths
git commit -m "$msg" >/dev/null
done
fi
fi
# --- push ------------------------------------------------------------------
if [ "$NO_PUSH" = "1" ]; then
echo "git: --no-push set, skipping push"
else
upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)"
if [ -z "$upstream" ]; then
fail "no upstream configured for branch '$branch'; set one with: git -C $DST branch --set-upstream-to=origin/$branch $branch"
fi
if [ "$DRY_RUN" = "1" ]; then
echo " [dry-run] git push to $upstream"
else
echo "git: pushing '$branch' to $upstream"
git push
echo "git: push OK"
fi
fi
echo ""
echo "=== sync_to_nova OK ==="
echo "copied (consumer subset) $SRC -> $DST"
[ "${n_changed:-0}" -gt 0 ] && echo "$n_changed domain commit(s) on branch '$branch'"
[ "$DRY_RUN" = "1" ] && echo "(dry-run: nothing actually written, committed, or pushed)"
[ "$NO_PUSH" = "1" ] && echo "(no-push: changes committed but not pushed)"
exit 0