382 lines
16 KiB
Bash
382 lines
16 KiB
Bash
|
|
#!/usr/bin/env bash
|
||
|
|
#
|
||
|
|
# push-to-nas.sh — push the working tree to the Synology NAS over SSH and
|
||
|
|
# run the on-NAS deploy, streaming every stage live and
|
||
|
|
# ending with a clear PASS/FAIL result.
|
||
|
|
#
|
||
|
|
# This is the LOCAL-side counterpart to deploy.sh (which runs ON the NAS).
|
||
|
|
# It is what `npm run deploy` invokes.
|
||
|
|
#
|
||
|
|
# What it does (4 stages, each with a banner you can watch):
|
||
|
|
# [0/4] Preflight — verify rsync/ssh exist, the SSH key is present, and a
|
||
|
|
# one-shot SSH connection to the NAS succeeds (BatchMode,
|
||
|
|
# so it fails fast instead of prompting for a password)
|
||
|
|
# [1/4] Test — run the test suite (`npm test` -> vitest run) as a GATE.
|
||
|
|
# Runs BEFORE anything touches the NAS, so a red suite
|
||
|
|
# aborts the deploy fast and locally. CI-safe (vitest run,
|
||
|
|
# not watch). Skip only with SKIP_TESTS=1 (escape hatch).
|
||
|
|
# [2/4] Sync — `rsync` the working tree to $NAS_PATH on the NAS
|
||
|
|
# (delete-extraneous, but excluding build/runtime cruft
|
||
|
|
# and — critically — the NAS-local .env and backups)
|
||
|
|
# [3/4] Deploy — run `scripts/deploy.sh` ON the NAS over SSH, forwarding
|
||
|
|
# any extra args you pass (e.g. --fresh / --pull)
|
||
|
|
#
|
||
|
|
# Why identity is pinned on every SSH hop:
|
||
|
|
# In some environments (notably IDE-spawned shells such as WebStorm's npm
|
||
|
|
# runner) ssh-agent offers MANY identities. The server can hit MaxAuthTries
|
||
|
|
# and reject the connection BEFORE the correct key is tried — "Permission
|
||
|
|
# denied" even though the key is loaded. Passing an explicit `-i $NAS_KEY`
|
||
|
|
# together with `-o IdentitiesOnly=yes` makes ssh offer ONLY that one key,
|
||
|
|
# so auth is deterministic regardless of how many identities the agent has.
|
||
|
|
# This is applied to the preflight ssh, the rsync transport, AND the remote
|
||
|
|
# deploy ssh — every outbound hop.
|
||
|
|
#
|
||
|
|
# Prerequisites:
|
||
|
|
# - rsync and ssh on the local PATH (macOS/Linux ship both).
|
||
|
|
# - An SSH key that can log into the NAS as $NAS_USER (default
|
||
|
|
# ~/.ssh/id_ed25519; override with NAS_KEY). Key-based auth must already
|
||
|
|
# work — this script does not set up keys.
|
||
|
|
# - deploy.sh + docker-compose.yml + .env already present (or about to be
|
||
|
|
# rsynced) under $NAS_PATH on the NAS. NOTE: .env is intentionally NOT
|
||
|
|
# synced (it is NAS-local and gitignored); create it on the NAS once.
|
||
|
|
#
|
||
|
|
# How to run:
|
||
|
|
# ./scripts/push-to-nas.sh # sync + remote deploy
|
||
|
|
# ./scripts/push-to-nas.sh --fresh # extra args pass through to
|
||
|
|
# # deploy.sh on the NAS
|
||
|
|
# ./scripts/push-to-nas.sh --help # show usage
|
||
|
|
#
|
||
|
|
# Or simply: npm run deploy
|
||
|
|
#
|
||
|
|
# Config (override via environment):
|
||
|
|
# NAS_HOST NAS hostname/IP (default: mycloud.dp.ua)
|
||
|
|
# NAS_USER SSH user on the NAS (default: d.tkachenko)
|
||
|
|
# NAS_PORT SSH port (default: 2323)
|
||
|
|
# NAS_PATH Deploy dir on the NAS (default: /volume1/docker/forge)
|
||
|
|
# NAS_KEY SSH private key to authenticate (default: ~/.ssh/id_ed25519)
|
||
|
|
#
|
||
|
|
# Safety:
|
||
|
|
# - rsync excludes .git, node_modules, dist, the local .env, and backups,
|
||
|
|
# so we never clobber NAS-local secrets/data or push local build cruft.
|
||
|
|
# - Read-only locally; the only mutation is on the NAS via deploy.sh, which
|
||
|
|
# is itself non-destructive (no down/prune/volume drops).
|
||
|
|
# - No secrets echoed.
|
||
|
|
#
|
||
|
|
# Exit codes:
|
||
|
|
# 0 push + remote deploy OK
|
||
|
|
# 1 preflight / sync / remote-deploy failure (remote exit code propagated)
|
||
|
|
# 2 usage error (bad flag) / missing prerequisite
|
||
|
|
#
|
||
|
|
|
||
|
|
set -euo pipefail
|
||
|
|
|
||
|
|
# --- Resolve repo root (works regardless of CWD; tolerates spaces) --------
|
||
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||
|
|
cd "$REPO_ROOT"
|
||
|
|
|
||
|
|
# Optional per-machine overrides. If .deploy.env exists (gitignored), load it so
|
||
|
|
# its NAS_HOST/NAS_USER/NAS_PORT/NAS_PATH/NAS_KEY win over the baked defaults
|
||
|
|
# below. Not required — the defaults already target this NAS.
|
||
|
|
if [[ -f .deploy.env ]]; then set -a; . ./.deploy.env; set +a; fi
|
||
|
|
|
||
|
|
# --- Config (baked defaults for this NAS; override via env or .deploy.env) --
|
||
|
|
NAS_HOST="${NAS_HOST:-mycloud.dp.ua}"
|
||
|
|
NAS_USER="${NAS_USER:-d.tkachenko}"
|
||
|
|
NAS_PORT="${NAS_PORT:-2323}"
|
||
|
|
NAS_PATH="${NAS_PATH:-/volume1/docker/forge}"
|
||
|
|
NAS_KEY="${NAS_KEY:-$HOME/.ssh/id_ed25519}"
|
||
|
|
|
||
|
|
# --- Flags ----------------------------------------------------------------
|
||
|
|
# Everything we don't recognise is forwarded verbatim to deploy.sh on the
|
||
|
|
# NAS (so `--fresh` and future deploy.sh flags Just Work). --help is local.
|
||
|
|
DEPLOY_ARGS=()
|
||
|
|
for arg in "$@"; do
|
||
|
|
case "$arg" in
|
||
|
|
--help|-h)
|
||
|
|
sed -n '2,/^set -euo pipefail/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^set -euo pipefail/d'
|
||
|
|
exit 0
|
||
|
|
;;
|
||
|
|
*)
|
||
|
|
DEPLOY_ARGS+=("$arg")
|
||
|
|
;;
|
||
|
|
esac
|
||
|
|
done
|
||
|
|
|
||
|
|
# --- Colour (only on a TTY; degrade to empty strings head-less) -----------
|
||
|
|
if [[ -t 1 ]]; then
|
||
|
|
C_RESET=$'\033[0m'
|
||
|
|
C_RED=$'\033[31m'
|
||
|
|
C_GREEN=$'\033[32m'
|
||
|
|
C_YELLOW=$'\033[33m'
|
||
|
|
C_BOLD=$'\033[1m'
|
||
|
|
else
|
||
|
|
C_RESET='' C_RED='' C_GREEN='' C_YELLOW='' C_BOLD=''
|
||
|
|
fi
|
||
|
|
|
||
|
|
ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||
|
|
|
||
|
|
banner() {
|
||
|
|
# banner "[1/3]" "Syncing files"
|
||
|
|
printf '\n%s==> %s %s%s\n' "$C_BOLD" "$1" "$2" "$C_RESET"
|
||
|
|
}
|
||
|
|
|
||
|
|
log() { printf '[push %s] %s\n' "$(ts)" "$*"; }
|
||
|
|
warn() { printf '%s[push %s] WARN: %s%s\n' "$C_YELLOW" "$(ts)" "$*" "$C_RESET" >&2; }
|
||
|
|
|
||
|
|
# Print a FAILED banner with a stage label, then exit non-zero.
|
||
|
|
fail() {
|
||
|
|
# fail "<stage>" "<message>" [<exit-code>]
|
||
|
|
local code="${3:-1}"
|
||
|
|
printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET"
|
||
|
|
printf '%s%s PUSH FAILED at stage: %s%s\n' "$C_BOLD" "$C_RED" "$1" "$C_RESET"
|
||
|
|
printf '%s%s %s%s\n' "$C_BOLD" "$C_RED" "$2" "$C_RESET"
|
||
|
|
printf '%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET"
|
||
|
|
exit "$code"
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- SSH options shared by every outbound hop -----------------------------
|
||
|
|
# Pin the exact key + IdentitiesOnly so ssh offers ONLY this identity. This is
|
||
|
|
# the whole point of the script: deterministic auth regardless of how many keys
|
||
|
|
# ssh-agent holds (the WebStorm-vs-Terminal discrepancy). Kept as an array so
|
||
|
|
# the args survive quoting/word-splitting cleanly when expanded.
|
||
|
|
SSH_OPTS=(-p "$NAS_PORT" -i "$NAS_KEY" -o IdentitiesOnly=yes)
|
||
|
|
|
||
|
|
# Single STRING form of the same pinned-key ssh command, for RSYNC_RSH only:
|
||
|
|
# a real (samba) rsync wants ONE remote-shell string, not an array. Single-
|
||
|
|
# quote the key path defensively so an odd path can't word-split.
|
||
|
|
RSYNC_RSH_CMD="ssh -p $NAS_PORT -i '$NAS_KEY' -o IdentitiesOnly=yes"
|
||
|
|
|
||
|
|
# Transfer-hop ssh: pinned identity PLUS BatchMode so a password prompt fails
|
||
|
|
# fast instead of hanging mid-deploy. Used by the tar fallback hops.
|
||
|
|
SSH_TRANSFER=(ssh "${SSH_OPTS[@]}" -o BatchMode=yes)
|
||
|
|
|
||
|
|
# --- Stage 0: preflight ----------------------------------------------------
|
||
|
|
preflight() {
|
||
|
|
banner "[0/4]" "Preflight checks"
|
||
|
|
|
||
|
|
if ! command -v rsync >/dev/null 2>&1; then
|
||
|
|
fail "preflight" "rsync not found on PATH. Install rsync (macOS: it ships by default; Linux: your package manager)." 2
|
||
|
|
fi
|
||
|
|
if ! command -v ssh >/dev/null 2>&1; then
|
||
|
|
fail "preflight" "ssh not found on PATH." 2
|
||
|
|
fi
|
||
|
|
|
||
|
|
# The key MUST exist as a file before we attempt any connection — otherwise
|
||
|
|
# ssh silently falls back to agent identities and we lose the determinism
|
||
|
|
# this whole script exists to provide.
|
||
|
|
if [[ ! -f "$NAS_KEY" ]]; then
|
||
|
|
fail "preflight" "SSH key $NAS_KEY not found — set NAS_KEY or generate one (ssh-keygen -t ed25519)." 2
|
||
|
|
fi
|
||
|
|
|
||
|
|
log "target: $NAS_USER@$NAS_HOST:$NAS_PORT"
|
||
|
|
log "path: $NAS_PATH"
|
||
|
|
log "ssh key: $NAS_KEY"
|
||
|
|
|
||
|
|
# Fail-fast connectivity check. BatchMode=yes => never prompt for a password
|
||
|
|
# (so a misconfigured key fails here, loudly, instead of hanging).
|
||
|
|
log "verifying SSH connectivity (pinned key, no password prompt)..."
|
||
|
|
if ! ssh "${SSH_OPTS[@]}" -o BatchMode=yes -o ConnectTimeout=8 \
|
||
|
|
"$NAS_USER@$NAS_HOST" 'true'; then
|
||
|
|
fail "preflight" "SSH connection to $NAS_USER@$NAS_HOST:$NAS_PORT failed with key $NAS_KEY. Confirm the key is authorized on the NAS (ssh-copy-id) and the host/port are correct." 1
|
||
|
|
fi
|
||
|
|
log "SSH connectivity OK"
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Stage 1: test gate ----------------------------------------------------
|
||
|
|
# Run the suite BEFORE any NAS interaction so a red build fails fast and local,
|
||
|
|
# never leaving a half-synced tree or a broken image on the NAS. `npm test`
|
||
|
|
# resolves to `cd client && npm test` -> `vitest run` (CI mode, non-watch — it
|
||
|
|
# exits, it does not hang). Headless-safe: vitest run needs no TTY. The only
|
||
|
|
# escape hatch is SKIP_TESTS=1 for emergencies; it warns loudly when used.
|
||
|
|
# No secrets are involved or echoed here.
|
||
|
|
stage_test() {
|
||
|
|
banner "[1/4]" "Running test gate (npm test)"
|
||
|
|
|
||
|
|
if [[ "${SKIP_TESTS:-0}" == "1" ]]; then
|
||
|
|
warn "SKIP_TESTS=1 set — BYPASSING the test gate. Deploying UNTESTED code."
|
||
|
|
return 0
|
||
|
|
fi
|
||
|
|
|
||
|
|
if ! command -v npm >/dev/null 2>&1; then
|
||
|
|
fail "test" "npm not found on PATH — cannot run the test gate. Install Node/npm or set SKIP_TESTS=1 to bypass (not recommended)." 2
|
||
|
|
fi
|
||
|
|
|
||
|
|
log "running: npm test (vitest run, non-watch)"
|
||
|
|
# Run directly so the real exit code propagates; set -e would also abort, but
|
||
|
|
# this gives a clean stage-labelled failure banner instead of a bare trap.
|
||
|
|
local rc=0
|
||
|
|
npm test || rc=$?
|
||
|
|
if [[ "$rc" -ne 0 ]]; then
|
||
|
|
fail "test" "Test suite failed (npm test exited $rc). Deploy aborted before touching the NAS. Fix the tests (or SKIP_TESTS=1 to force, not recommended)." "$rc"
|
||
|
|
fi
|
||
|
|
log "tests passed — proceeding to sync"
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Transport detection: find a usable (NON-openrsync) rsync --------------
|
||
|
|
# macOS now ships Apple's "openrsync" as /usr/bin/rsync. openrsync IGNORES both
|
||
|
|
# the `-e` remote-shell option AND the RSYNC_RSH env var, so our pinned ssh key
|
||
|
|
# never reaches ssh, auth falls back to (non-existent) password, and the sync
|
||
|
|
# fails. Detect that here: a usable rsync is one whose `--version` does NOT say
|
||
|
|
# "openrsync". We also probe the common Homebrew install paths in case the user
|
||
|
|
# `brew install rsync`d a real (samba) rsync. Sets $RSYNC_BIN if one is found.
|
||
|
|
detect_usable_rsync() {
|
||
|
|
RSYNC_BIN=""
|
||
|
|
local candidates=()
|
||
|
|
local path_rsync
|
||
|
|
if path_rsync="$(command -v rsync 2>/dev/null)"; then
|
||
|
|
candidates+=("$path_rsync")
|
||
|
|
fi
|
||
|
|
candidates+=(/opt/homebrew/bin/rsync /usr/local/bin/rsync)
|
||
|
|
|
||
|
|
local cand
|
||
|
|
for cand in "${candidates[@]}"; do
|
||
|
|
[[ -x "$cand" ]] || continue
|
||
|
|
if ! "$cand" --version 2>/dev/null | head -1 | grep -qi 'openrsync'; then
|
||
|
|
RSYNC_BIN="$cand"
|
||
|
|
return 0
|
||
|
|
fi
|
||
|
|
done
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Stage 2: sync the working tree (rsync if usable, else tar-over-ssh) ---
|
||
|
|
stage_sync() {
|
||
|
|
banner "[2/4]" "Syncing working tree to $NAS_USER@$NAS_HOST:$NAS_PATH"
|
||
|
|
|
||
|
|
if detect_usable_rsync; then
|
||
|
|
sync_via_rsync
|
||
|
|
else
|
||
|
|
sync_via_tar
|
||
|
|
fi
|
||
|
|
log "sync complete"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Preferred path: a real rsync. --delete keeps the NAS tree a mirror of local;
|
||
|
|
# the excludes protect NAS-local state (.env* secrets, backups DB dumps) and
|
||
|
|
# local-only build cruft. Trailing slash on the source copies the CONTENTS of
|
||
|
|
# REPO_ROOT into NAS_PATH. RSYNC_RSH carries our pinned-key ssh (incl. BatchMode
|
||
|
|
# so it fails fast rather than hanging on a password prompt).
|
||
|
|
sync_via_rsync() {
|
||
|
|
log "using rsync at $RSYNC_BIN"
|
||
|
|
if ! RSYNC_RSH="$RSYNC_RSH_CMD -o BatchMode=yes" "$RSYNC_BIN" -av --delete \
|
||
|
|
--exclude '.git/' \
|
||
|
|
--exclude '._*' \
|
||
|
|
--exclude 'node_modules/' \
|
||
|
|
--exclude 'client/node_modules/' \
|
||
|
|
--exclude 'dist/' \
|
||
|
|
--exclude 'client/dist/' \
|
||
|
|
--exclude '.env' \
|
||
|
|
--exclude '.env.*' \
|
||
|
|
--exclude '.deploy.env' \
|
||
|
|
--exclude 'storage-dump.json' \
|
||
|
|
--exclude '.idea/' \
|
||
|
|
--exclude 'backups/' \
|
||
|
|
--exclude 'logs/' \
|
||
|
|
--exclude 'prototype/' \
|
||
|
|
--exclude 'prototype.zip' \
|
||
|
|
--exclude '.claude/' \
|
||
|
|
--exclude 'claude_artifacts/' \
|
||
|
|
"$REPO_ROOT/" \
|
||
|
|
"$NAS_USER@$NAS_HOST:$NAS_PATH/"; then
|
||
|
|
fail "sync" "rsync to $NAS_HOST:$NAS_PATH failed. See rsync output above."
|
||
|
|
fi
|
||
|
|
}
|
||
|
|
|
||
|
|
# Fallback path (e.g. macOS openrsync): stream a tar of the working tree over
|
||
|
|
# the proven pinned-key ssh and extract it on the NAS. No rsync involved, so
|
||
|
|
# the -e/RSYNC_RSH-ignoring openrsync problem is sidestepped entirely.
|
||
|
|
#
|
||
|
|
# Tradeoff: tar extraction is additive — it does NOT delete stale remote files
|
||
|
|
# the way `rsync --delete` does. Documented loudly below.
|
||
|
|
sync_via_tar() {
|
||
|
|
warn "openrsync detected (Apple's rsync ignores -e/RSYNC_RSH); using tar-over-ssh."
|
||
|
|
warn "Note: stale remote files are NOT deleted in this mode — \`brew install rsync\` to enable --delete."
|
||
|
|
|
||
|
|
if ! "${SSH_TRANSFER[@]}" "$NAS_USER@$NAS_HOST" "mkdir -p '$NAS_PATH'"; then
|
||
|
|
fail "sync" "could not create remote dir $NAS_PATH on $NAS_HOST (tar-over-ssh)."
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Pipe local tar -> remote tar extract, capturing the exit code of BOTH sides.
|
||
|
|
# Snapshot ${PIPESTATUS[@]} into an array in the very next command on BOTH
|
||
|
|
# branches so it is read before anything overwrites it (and `set -e` never
|
||
|
|
# aborts mid-stage).
|
||
|
|
local pipe_status=()
|
||
|
|
{ COPYFILE_DISABLE=1 tar --no-mac-metadata -czf - \
|
||
|
|
--exclude='._*' \
|
||
|
|
--exclude='./.git' \
|
||
|
|
--exclude='./node_modules' \
|
||
|
|
--exclude='./client/node_modules' \
|
||
|
|
--exclude='./dist' \
|
||
|
|
--exclude='./client/dist' \
|
||
|
|
--exclude='./.env' \
|
||
|
|
--exclude='./.env.*' \
|
||
|
|
--exclude='./.deploy.env' \
|
||
|
|
--exclude='./storage-dump.json' \
|
||
|
|
--exclude='./.idea' \
|
||
|
|
--exclude='./backups' \
|
||
|
|
--exclude='./logs' \
|
||
|
|
--exclude='./prototype' \
|
||
|
|
--exclude='./prototype.zip' \
|
||
|
|
--exclude='./.claude' \
|
||
|
|
--exclude='./claude_artifacts' \
|
||
|
|
-C "$REPO_ROOT" . \
|
||
|
|
| "${SSH_TRANSFER[@]}" "$NAS_USER@$NAS_HOST" "tar -xzf - -C '$NAS_PATH'" ; } \
|
||
|
|
&& pipe_status=("${PIPESTATUS[@]}") \
|
||
|
|
|| pipe_status=("${PIPESTATUS[@]}")
|
||
|
|
|
||
|
|
local tar_rc="${pipe_status[0]}"
|
||
|
|
local ssh_rc="${pipe_status[1]}"
|
||
|
|
|
||
|
|
if [[ "$tar_rc" -ne 0 ]]; then
|
||
|
|
fail "sync" "local tar failed (exit $tar_rc) streaming working tree to $NAS_HOST."
|
||
|
|
fi
|
||
|
|
if [[ "$ssh_rc" -ne 0 ]]; then
|
||
|
|
fail "sync" "remote tar extract on $NAS_HOST failed (exit $ssh_rc) (tar-over-ssh)."
|
||
|
|
fi
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Stage 3: run deploy.sh on the NAS ------------------------------------
|
||
|
|
stage_deploy() {
|
||
|
|
banner "[3/4]" "Running deploy.sh on the NAS"
|
||
|
|
|
||
|
|
# -t allocates a TTY so deploy.sh's live streaming + colour come through.
|
||
|
|
# printf %q on each forwarded arg makes the remote command robust to args
|
||
|
|
# containing spaces/quotes; the array may be empty, which is fine.
|
||
|
|
local remote_args=""
|
||
|
|
if [[ ${#DEPLOY_ARGS[@]} -gt 0 ]]; then
|
||
|
|
remote_args="$(printf ' %q' "${DEPLOY_ARGS[@]}")"
|
||
|
|
fi
|
||
|
|
|
||
|
|
local remote_cmd
|
||
|
|
remote_cmd="cd $(printf '%q' "$NAS_PATH") && ./scripts/deploy.sh${remote_args}"
|
||
|
|
|
||
|
|
# Run directly (not under `if ! ...`) so we can capture the REAL remote exit
|
||
|
|
# code: after `if ! cmd`, $? is the negation's status (0), not the command's.
|
||
|
|
local rc=0
|
||
|
|
ssh -t "${SSH_OPTS[@]}" "$NAS_USER@$NAS_HOST" "$remote_cmd" || rc=$?
|
||
|
|
if [[ "$rc" -ne 0 ]]; then
|
||
|
|
fail "deploy" "Remote deploy.sh on $NAS_HOST exited non-zero (code $rc). See the deploy output above." "$rc"
|
||
|
|
fi
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Final OK banner -------------------------------------------------------
|
||
|
|
success_banner() {
|
||
|
|
printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||
|
|
printf '%s%s PUSH + DEPLOY OK%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||
|
|
printf '%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||
|
|
log "target: $NAS_USER@$NAS_HOST:$NAS_PATH"
|
||
|
|
log "remote deploy completed successfully"
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Main ------------------------------------------------------------------
|
||
|
|
main() {
|
||
|
|
preflight
|
||
|
|
stage_test
|
||
|
|
stage_sync
|
||
|
|
stage_deploy
|
||
|
|
success_banner
|
||
|
|
}
|
||
|
|
|
||
|
|
main
|