#!/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: "|". 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 ship_phase.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 sync_workflows.py attach_release_asset.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 \"\" [-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=/.gitea --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: " ". 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: [optional(scope)]: " >&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: [optional(scope)]: " >&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