#!/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