Files
timebank-cc-public/scripts/publish-public-snapshot.sh

160 lines
6.0 KiB
Bash
Executable File

#!/bin/bash
#
# Publish a history-free snapshot of a branch to the public repository.
#
# The public repo never receives the private history: each publish is ONE new
# commit whose tree is the chosen ref minus the paths in .public-exclude. Its
# only parent is the previous public snapshot, so the public history is just
# the list of snapshots.
#
# Before anything is pushed, the snapshot is scanned for secrets: the values of
# PASSWORD/SECRET/KEY/TOKEN variables in the local .env, private keys, service
# account files and common token formats. Any hit aborts the publish.
#
# Usage (from the repository root):
# scripts/publish-public-snapshot.sh [ref] # dry run: build + scan only
# scripts/publish-public-snapshot.sh [ref] --push # build, scan and push
#
# ref defaults to main. Plain `git push public` is disabled on purpose
# (remote.public.pushurl), so this script is the only way to publish.
set -euo pipefail
PUBLIC_URL=https://gitea.thesesidesup.nl/ronald/timebank-cc-public.git
PUBLIC_BRANCH=main
REF=main
PUSH=0
for arg in "$@"; do
case "$arg" in
--push) PUSH=1 ;;
-*) echo "Unknown option: $arg" >&2; exit 2 ;;
*) REF=$arg ;;
esac
done
cd "$(git rev-parse --show-toplevel)"
COMMIT=$(git rev-parse --verify "$REF^{commit}")
SHORT=$(git rev-parse --short "$COMMIT")
echo "Source: $REF ($SHORT)"
# --- Build the tree without the excluded paths ------------------------------
INDEX=$(mktemp)
trap 'rm -f "$INDEX"' EXIT
export GIT_INDEX_FILE=$INDEX
git read-tree "$COMMIT"
EXCLUDES=()
ALLOWED=() # '!path' lines: reviewed, exempt from the file-name check
if git cat-file -e "$COMMIT:.public-exclude" 2>/dev/null; then
while IFS= read -r line; do
line=${line%%#*}
line=$(printf '%s' "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
case "$line" in
'') ;;
'!'*) ALLOWED+=("${line#!}") ;;
*) EXCLUDES+=("$line") ;;
esac
done < <(git show "$COMMIT:.public-exclude")
fi
for p in "${EXCLUDES[@]}"; do
git rm -r -q --cached --ignore-unmatch -- "$p" >/dev/null
done
TREE=$(git write-tree)
unset GIT_INDEX_FILE
echo "Excluded: ${EXCLUDES[*]:-(nothing)}"
echo "Files in snapshot: $(git ls-tree -r --name-only "$TREE" | wc -l)"
# --- Secret scan --------------------------------------------------------------
hits=0
report() { echo " SECRET: $1"; hits=$((hits + 1)); }
# 1. Values of secret-like variables in the local .env (never printed). In a
# linked worktree without its own .env, use the main checkout's.
ENV_FILE=.env
[ -f "$ENV_FILE" ] || ENV_FILE="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")/.env"
if [ -f "$ENV_FILE" ]; then
echo "Scanning for values from: $ENV_FILE"
else
echo "WARNING: no .env found, skipping the value scan"
fi
if [ -f "$ENV_FILE" ]; then
while IFS= read -r line; do
key=${line%%=*}
val=$(printf '%s' "${line#*=}" | sed -E 's/[[:space:]]+#.*//; s/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/')
[ ${#val} -ge 8 ] || continue
case "$val" in *'${'*|null|true|false) continue ;; esac
# Client-side keys are public by design (they ship in the JS bundle).
case "$key" in VITE_*|MIX_*|PUSHER_APP_KEY|REVERB_APP_KEY) continue ;; esac
while IFS= read -r f; do
report "value of $key from .env in ${f#*:}"
done < <(git grep -l -F -e "$val" "$TREE" -- || true)
done < <(grep -E '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|KEY|TOKEN|DSN)[A-Z0-9_]*=' "$ENV_FILE" || true)
fi
# 2. Files that should never be public.
while IFS= read -r f; do
for a in "${ALLOWED[@]}"; do [ "$f" = "$a" ] && continue 2; done
report "sensitive file name: $f"
done < <(git ls-tree -r --name-only "$TREE" \
| grep -E '(^|/)\.env$|(^|/)\.env\.(local|production|prod|backup)|\.pem$|\.p12$|\.pfx$|(^|/)id_(rsa|ed25519|ecdsa)$|firebase-adminsdk|\.htpasswd|\.sql(\.gz)?$' || true)
# 3. Common secret formats. Placeholders (your_..., ..._here, example) are skipped.
PATTERNS=(
-e "-----BEGIN [A-Z ]*PRIVATE KEY-----"
-e "-p'[^'\$ <]{6,}'"
-e "MYSQL_PWD=[^\$\" <]{6,}"
-e "IDENTIFIED BY '[^'<\$]{6,}'"
-e "AKIA[0-9A-Z]{16}"
-e "sk_live_[0-9a-zA-Z]{10,}"
-e "gh[pousr]_[A-Za-z0-9]{30,}"
-e "glpat-[A-Za-z0-9_-]{20}"
-e "xox[abprs]-[A-Za-z0-9-]{10,}"
-e "AIza[0-9A-Za-z_-]{35}"
-e "://[^/ :@\"'<]+:[^/ @\"'\$<]{6,}@"
)
while IFS= read -r m; do
m=${m#*:} # strip the tree id
report "pattern match in ${m%%:*}:$(cut -d: -f2 <<<"$m") (line not shown)"
done < <(git grep -n -I -E "${PATTERNS[@]}" "$TREE" -- ':!*.lock' ':!package-lock.json' \
| grep -v -i -E 'your[_-]|_here|example|placeholder|changeme|xxxx|\.\.\.' || true)
if [ $hits -ne 0 ]; then
echo
echo "ABORTED: $hits possible secret(s) in the snapshot. Fix them in $REF, or add the"
echo "path to .public-exclude, then run again. Nothing was pushed."
exit 1
fi
echo "Secret scan: clean"
# --- Commit on top of the previous public snapshot ----------------------------
PARENT=$(git ls-remote "$PUBLIC_URL" "refs/heads/$PUBLIC_BRANCH" | awk '{print $1}')
PARENT_ARGS=()
if [ -n "$PARENT" ]; then
git fetch -q "$PUBLIC_URL" "$PUBLIC_BRANCH"
if [ "$(git rev-parse "$PARENT^{tree}")" = "$TREE" ]; then
echo "Public repo already has exactly this content. Nothing to do."
exit 0
fi
PARENT_ARGS=(-p "$PARENT")
echo "Previous public snapshot: $(git log -1 --format='%h %s' "$PARENT")"
else
echo "Public repo is empty: this becomes the first commit."
fi
MSG="Snapshot of $REF at $SHORT ($(git log -1 --format=%cd --date=short "$COMMIT"))"
SNAP=$(git commit-tree "$TREE" "${PARENT_ARGS[@]}" -m "$MSG")
echo "Snapshot commit: $(git rev-parse --short "$SNAP") \"$MSG\""
if [ $PUSH -eq 1 ]; then
git push "$PUBLIC_URL" "$SNAP:refs/heads/$PUBLIC_BRANCH"
echo "Published to $PUBLIC_URL ($PUBLIC_BRANCH)"
else
echo
echo "Dry run, nothing pushed. Inspect with: git ls-tree -r --name-only $SNAP | less"
echo "Publish with: $0 $REF --push"
fi