Files
hyperframes/scripts/check-large-files.sh
Miguel Ángel bb9e507e84 perf(packages): ship npm packages without source maps and guard their size in CI (#5403)
* perf(packages): ship npm packages without source maps and guard their size in CI

No package or consumer reads the published source maps, so they are no
longer built. The CLI copies only the studio app it serves instead of the
whole studio dist. verify:packed-manifests now holds every published package
to a size budget that ratchets down, and CI rejects pull requests that add a
large binary outside LFS.

* fix(ci): large-binary check reads every filename and fails on git errors

* ci: run only Build when a skill bundled into the CLI changes

* fix(ci): size guards also catch type changes, odd file names, unreadable blobs and bad budgets

* fix(ci): large-binary check reads blobs by id and skips submodules

* fix(ci): exempt only exact registry copies under docs/public/catalog

* test(ci): cover the staged catalog-copy exemption; drop the local-only override hint in CI

* fix(ci): producer budget follows its slimmed package; size checks name their real cause
2026-10-10 09:04:47 +00:00

178 lines
7.2 KiB
Bash
Executable File

#!/bin/sh
# Reject large binaries committed straight into the git pack instead of LFS.
#
# Why this exists: the repo's history carries hundreds of MB of binaries that
# should have been LFS — a 31 MB ONNX model, nested HDR-regression MP4s that
# dodged non-recursive .gitattributes globs, demo clips, scratch renders. Each
# was "noticed later and deleted," but a raw commit lives in history forever and
# every clone pays for it. This hook stops the next one at commit time.
#
# Rule: any staged file larger than $MAX_KB that is NOT routed through Git LFS
# fails the commit. Fix by either adding an LFS pattern in .gitattributes for
# that path/extension, or not committing the file (assets/, gitignore, etc.).
#
# Usage:
# check-large-files.sh # default: check the staged file set
# check-large-files.sh <file> [<file>] # explicit files (handy for testing)
# check-large-files.sh --range <base> <head> # files a commit range adds or changes (CI)
#
# The staged and range modes size the blob git stores, not the file on disk, so
# a file matching an LFS pattern but added without git-lfs installed is caught.
#
# We read the staged set ourselves rather than taking lefthook's {staged_files}
# expansion, which splits paths containing spaces into separate args.
set -u
MAX_KB="${HF_MAX_NONLFS_KB:-500}"
# staged (default): the index; range: what <head> adds over <base>; files: paths on disk.
MODE=staged
RANGE_BASE=""
RANGE_HEAD=""
if [ "${1:-}" = "--range" ]; then
[ "$#" -eq 3 ] || { echo "usage: $0 --range <base> <head>" >&2; exit 2; }
MODE=range
RANGE_BASE="$2"
RANGE_HEAD="$3"
shift 3
elif [ "$#" -gt 0 ]; then
MODE=files
fi
# Git modes name paths from the repository root; files mode keeps the caller's paths.
if [ "$MODE" != files ]; then
cd "$(git rev-parse --show-toplevel)" || { echo "ERROR: not inside a git repository." >&2; exit 2; }
fi
# Emit a "<raw diff header>" record then a path record per entry, NUL-separated and
# unquoted. The header carries the new mode and blob id, so blobs are read by id,
# never by a path git could parse as revision syntax. Renames count as additions.
list_files() {
case "$MODE" in
range) git diff -z --raw --no-abbrev --no-renames --diff-filter=ACMT "$RANGE_BASE" "$RANGE_HEAD" ;;
staged) git diff -z --raw --no-abbrev --no-renames --diff-filter=ACMT --cached ;;
files) for f in "$@"; do printf 'file\0%s\0' "$f"; done ;;
esac
}
# Blob ids under registry/ in the tree being checked, listed on first need.
list_registry_ids() {
[ -s "$regids" ] && return
if [ "$MODE" = range ]; then
git ls-tree -r "$RANGE_HEAD" -- registry | awk '{print $3}'
else
git ls-files -s -- registry | awk '{print $2}'
fi > "$regids"
}
read_bytes() {
if [ "$MODE" = files ]; then cat -- "$1"; else git cat-file blob "$2"; fi
}
violations="$(mktemp)"
errors="$(mktemp)"
paths="$(mktemp)"
blob="$(mktemp)"
regids="$(mktemp)"
trap 'rm -f "$violations" "$errors" "$paths" "$blob" "$regids"' EXIT INT TERM
# A git error (bad ref, not a repo) must fail the check, not pass an empty list.
list_files "$@" > "$paths" || { echo "ERROR: could not list the files to check." >&2; exit 2; }
# Records are NUL-separated, so a newline byte can only come from a filename; sh cannot
# keep such a name whole, so refuse it rather than check the wrong files.
if [ "$(tr -cd '\n' < "$paths" | wc -c)" -gt 0 ]; then
echo "ERROR: a file name contains a newline; rename it." >&2
exit 2
fi
tr '\0' '\n' < "$paths" | while IFS= read -r header && IFS= read -r f; do
[ -n "$f" ] || continue
id=""
if [ "$MODE" = files ]; then
# Skip symlinks: `wc -c` would measure the link *target's* bytes, so a symlink
# to a large LFS-tracked asset could be flagged even though the real blob is a
# tiny pointer.
[ -L "$f" ] && continue
[ -f "$f" ] || continue
else
# ":<old mode> <new mode> <old id> <new id> <status>"
set -- $header
# A submodule (160000) records another repository's commit: nothing stored here.
[ "$2" = 160000 ] && continue
id="$4"
fi
# registry/ intentionally ships raw binary assets (block backgrounds, avatar
# PNGs, .glb models, audio) so installed blocks stay portable without an LFS
# round-trip. Those are the product, not accidental bloat — skip them here.
case "$f" in registry/*) continue ;; esac
# docs/public/catalog/ mirrors those assets for the docs site: exempt only an exact copy.
case "$f" in docs/public/catalog/*)
[ -n "$id" ] || id="$(git hash-object -- "$f")"
list_registry_ids
grep -qxF "$id" "$regids" && continue ;;
esac
# Text is exempt, whatever its size, because the cost this hook exists to stop
# is a binary one. Git delta-compresses text, so a file that grows by a few KB
# per commit adds a few KB to the pack. A binary of the same size re-enters the
# pack whole on every edit, which is exactly how the history got its hundreds
# of megabytes. `docs/changelog.mdx` is the case that forced this: half a
# megabyte of release notes, a little larger every release, tripping a check
# whose own error message says "large binaries".
#
# `grep -I` treats a file containing NUL bytes as binary, the same heuristic
# git uses to print "Binary files differ". A generated blob of text is still
# caught by review, not here.
# Read once into a file so a failed read is an error, never an empty pass.
read_bytes "$f" "$id" > "$blob" || { printf '%s\n' "$f" >> "$errors"; continue; }
grep -qI . "$blob" && continue
bytes="$(wc -c < "$blob" | tr -d ' ')"
# Ceiling division: a sub-1024-byte file must report >=1 KB, never 0, so it
# can't slip past a strict threshold (e.g. HF_MAX_NONLFS_KB=0). Plain
# `bytes / 1024` would round a 512-byte binary down to 0 and pass it.
kb=$(( (bytes + 1023) / 1024 ))
[ "$kb" -le "$MAX_KB" ] && continue
# A stored blob over the limit is raw bytes even when .gitattributes routes
# the path through LFS (git-lfs was not installed). A file on disk under an
# LFS pattern is the smudged copy of a pointer, so it passes.
filter="$(git check-attr filter -- "$f" | sed 's/.*: //')"
note=""
if [ "$filter" = "lfs" ]; then
[ "$MODE" = files ] && continue
note=" — matches an LFS pattern but was stored raw; run \`git lfs install\` and re-add it"
fi
printf '%s\t%s\n' "$kb" "$f$note" >> "$violations"
done
if [ -s "$errors" ]; then
echo "ERROR: could not read these files from git:" >&2
sed 's/^/ • /' "$errors" >&2
exit 2
fi
# `while` ran in a pipeline subshell, so it couldn't set a parent-shell flag —
# the violations file is the durable signal.
if [ -s "$violations" ]; then
echo "ERROR: large binaries are being committed to git instead of LFS." >&2
if [ "$MODE" = range ]; then
echo " (limit: ${MAX_KB} KB)" >&2
else
echo " (limit: ${MAX_KB} KB — override per-commit with HF_MAX_NONLFS_KB)" >&2
fi
echo >&2
while IFS=' ' read -r kb f; do
printf ' • %s (%s KB)\n' "$f" "$kb" >&2
done < "$violations"
echo >&2
echo "Fix: add an LFS pattern for it in .gitattributes, e.g." >&2
echo " path/to/**/*.ext filter=lfs diff=lfs merge=lfs -text" >&2
echo " then re-stage the file. Or, if it should not be committed at all," >&2
echo " add it to .gitignore." >&2
exit 1
fi