Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,26 @@ echo "Running pro-test bundle freshness check..."
# otherwise able to ship stale /pro bundles because Vercel serves public/pro/
# as committed bytes.
if [ "$RUN_ALL" = true ] || path_matches '^(pro-test|public/pro)/|^convex/config/productCatalog\.ts$|^scripts/generate-product-config\.mjs$'; then
# Per-gate cache (#6765): the whole-tree cache keys this gate on all bytes
# everywhere, so a docs-only amend after a green run re-pays the full
# ~55s cold rebuild for inputs that did not change. This entry hashes the
# gate's INPUTS (sources, lockfile, catalog, generator, vite/ts config)
# AND its VERIFIED OUTPUTS (generated config, tiers, locales, public/pro/)
# straight from HEAD's tree — blob hashes, so it costs path lookups, not
# file reads. Outputs are part of the key on purpose: an amend that swaps
# verified-good bundle bytes for stale ones under identical inputs must not
# ride a cache hit minted against the good bytes.
GATE_CACHE_DIR="$(git rev-parse --git-common-dir)/wm-prepush-gate-cache"
PRO_TEST_GATE_HASH=$(git ls-tree -r HEAD -- \
pro-test/src pro-test/package.json pro-test/package-lock.json \
pro-test/vite.config.ts pro-test/tsconfig.json \
convex/config/productCatalog.ts scripts/generate-product-config.mjs \
src/config/products.generated.ts src/config/product-ids.generated.ts \
public/pro \
| git hash-object --stdin)
if [ "$ATTESTABLE" = true ] && bash "$ATTEST" gate-cache-read "$GATE_CACHE_DIR" pro-test-bundle "$PRO_TEST_GATE_HASH"; then
echo " pro-test bundle gate: identical inputs and verified outputs already passed for this tree (#6765) — skipping the rebuild."
else
npx tsx scripts/generate-product-config.mjs >/dev/null || {
echo "ERROR: product config generation failed"
exit 1
Expand Down Expand Up @@ -767,6 +787,10 @@ if [ "$RUN_ALL" = true ] || path_matches '^(pro-test|public/pro)/|^convex/config
exit 1
fi
echo " pro-test bundle is up to date."
if bash "$ATTEST" gate-cache-write "$GATE_CACHE_DIR" pro-test-bundle "$PRO_TEST_GATE_HASH" "$ATTESTABLE"; then
echo " pro-test gate cached — a re-push whose gate paths did not change skips the rebuild."
fi
fi # end gate-cache miss: the rebuild path above
else
echo " Skipped (no pro-test/, public/pro/, product catalog, or product-config generator changes in branch)."
fi
Expand Down
47 changes: 45 additions & 2 deletions scripts/prepush-attest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
# bash scripts/prepush-attest.sh dirty # NUL list of offenders
# bash scripts/prepush-attest.sh cache-read <file> <tree> <diff-resolved>
# bash scripts/prepush-attest.sh cache-write <file> <tree> <diff-resolved> <attestable>
# bash scripts/prepush-attest.sh gate-cache-read <dir> <gate> <hash>
# bash scripts/prepush-attest.sh gate-cache-write <dir> <gate> <hash> <attestable>
#
# Exit codes are three-valued on purpose. A gate that answers "no" and a gate
# that could not run must never collapse into the same status as "yes":
Expand All @@ -41,9 +43,10 @@

mode="${1:-}"
case "$mode" in
changed | changed-live | drift | dirty | cache-read | cache-write) ;;
changed | changed-live | drift | dirty | cache-read | cache-write \
| gate-cache-read | gate-cache-write) ;;
*)
echo "usage: $0 <changed|changed-live|drift|dirty|cache-read|cache-write> [args]" >&2
echo "usage: $0 <changed|changed-live|drift|dirty|cache-read|cache-write|gate-cache-read|gate-cache-write> [args]" >&2
exit 2
;;
esac
Expand Down Expand Up @@ -195,6 +198,46 @@ case "$mode" in
fi
printf '%s\n' "$tree" > "$cache_file" || exit 1
;;

# Per-gate cache (#6765). The whole-tree cache keys every gate on one hash,
# so a merge, conflict re-resolve, amend, or regenerated-artifact commit —
# each a new HEAD^{tree} — invalidates gates whose inputs did not change.
# A gate entry instead names ONE hash of exactly that gate's inputs AND
# verified outputs, so an unrelated amend leaves it valid. Layout is one
# file per gate under <dir>, holding a single hash line an operator can
# inspect and clear.
gate-cache-read)
cache_dir="${2:-}"
gate="${3:-}"
hash="${4:-}"
[ -n "$cache_dir" ] && [ -n "$gate" ] && [ -n "$hash" ] ||
usage_error "<dir> <gate> <hash>"
# No diff-resolved or attestable term here by design: callers only reach
# for a per-gate read when they already hold ATTESTABLE=true — the hash
# names HEAD-tree bytes, and a drifted worktree would make the key a lie
# about what actually ran. Keeping that decision at the call site keeps
# this primitive honest with a single rule to test.
[ -f "$cache_dir/$gate" ] || exit 3
grep -qxF "$hash" "$cache_dir/$gate" 2>/dev/null || exit 3
;;

gate-cache-write)
cache_dir="${2:-}"
gate="${3:-}"
hash="${4:-}"
attestable="${5:-}"
[ -n "$cache_dir" ] && [ -n "$gate" ] && [ -n "$hash" ] && [ -n "$attestable" ] ||
usage_error "<dir> <gate> <hash> <attestable>"
if [ "$attestable" != true ]; then
# The gate ran against a worktree that is not HEAD. Whatever it proved,
# it did not prove it about the bytes the hash names — same refusal the
# whole-tree cache-write makes, for the same reason.
echo "not gate-caching: the worktree is not byte-identical to HEAD."
exit 3
fi
mkdir -p "$cache_dir" || exit 1
printf '%s\n' "$hash" > "$cache_dir/$gate" || exit 1
;;
esac

exit 0
50 changes: 50 additions & 0 deletions tests/prepush-attest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -490,3 +490,53 @@ describe('pre-push wiring: the hook must consume these decisions', () => {
lacks(/find api\/ -name "\*\.js"/, 'working-tree globs rediscover ignored sidecar bundles');
});
});

describe('per-gate cache entries key one gate on its own inputs (#6765)', () => {
function setup() {
const root = makeRepo({ branchFiles: { 'src/a.ts': 'x\n' } });
return { root, dir: join(root, 'wm-prepush-gate-cache') };
}

test('write-then-read round-trips a single gate hash', () => {
const { root, dir } = setup();
assert.equal(attest(root, ['gate-cache-write', dir, 'pro-test-bundle', 'hash-1', 'true']).status, 0);
assert.equal(attest(root, ['gate-cache-read', dir, 'pro-test-bundle', 'hash-1']).status, 0);
assert.equal(readFileSync(join(dir, 'pro-test-bundle'), 'utf8'), 'hash-1\n');
});

test('a different hash, a different gate, and no file at all are misses', () => {
const { root, dir } = setup();
attest(root, ['gate-cache-write', dir, 'pro-test-bundle', 'hash-1', 'true']);
assert.equal(attest(root, ['gate-cache-read', dir, 'pro-test-bundle', 'hash-2']).status, 3);
assert.equal(attest(root, ['gate-cache-read', dir, 'proto-freshness', 'hash-1']).status, 3);
assert.equal(attest(root, ['gate-cache-read', join(root, 'absent'), 'pro-test-bundle', 'hash-1']).status, 3);
});

test('a new write replaces the previous hash, it does not accumulate', () => {
// One file per gate with one line: a stale entry must never keep a
// retired hash alive alongside the current one.
const { root, dir } = setup();
attest(root, ['gate-cache-write', dir, 'g', 'old', 'true']);
assert.equal(attest(root, ['gate-cache-write', dir, 'g', 'new', 'true']).status, 0);
assert.equal(readFileSync(join(dir, 'g'), 'utf8'), 'new\n');
assert.equal(attest(root, ['gate-cache-read', dir, 'g', 'old']).status, 3);
assert.equal(attest(root, ['gate-cache-read', dir, 'g', 'new']).status, 0);
});

test('refuses to write when the worktree is not byte-identical to HEAD', () => {
// The hash names HEAD-tree bytes; a gate that ran against a drifted
// worktree proved nothing about them. Same refusal, same reason, as the
// whole-tree cache-write.
const { root, dir } = setup();
const { status, stdout } = attest(root, ['gate-cache-write', dir, 'g', 'h', 'false']);
assert.equal(status, 3);
assert.match(stdout, /not byte-identical to HEAD/);
assert.equal(attest(root, ['gate-cache-read', dir, 'g', 'h']).status, 3);
});

test('usage errors stay exit 2', () => {
const { root, dir } = setup();
assert.equal(attest(root, ['gate-cache-read', dir, 'g']).status, 2);
assert.equal(attest(root, ['gate-cache-write', dir, 'g', 'h']).status, 2);
});
});
45 changes: 45 additions & 0 deletions tests/prepush-hook-gate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -644,3 +644,48 @@ describe('the gate does not fail closed on its own edge cases', () => {
assert.equal(fixture.cached(), fixture.tree());
});
});

describe('the pro-test gate carries its own input-keyed cache (#6765)', () => {
const genCalls = (invocations) =>
invocations.split('>scripts/generate-product-config.mjs').length - 1;

test('a docs-only amend skips the rebuild the whole-tree cache cannot', () => {
const fixture = makeFixture({ branchFiles: { 'pro-test/src/launch.ts': 'export const x = 1;\n' } });
fixture.git(['checkout', '-q', '-b', 'feature/pro-work']);

const first = fixture.run();
assert.equal(first.status, 0, first.stdout);
assert.match(first.stdout, /pro-test gate cached/);
assert.equal(genCalls(first.invocations), 1, 'the first run must actually run the generator');

// The #6765 shape: a merge, amend, or docs-only commit after a green run
// produces a NEW HEAD^{tree} — the whole-tree cache misses — while the
// pro-test gate's own inputs and verified outputs are byte-identical.
mkdirSync(join(fixture.root, 'docs'), { recursive: true });
writeFileSync(join(fixture.root, 'docs', 'notes.md'), 'note\n');
fixture.git(['add', '-A']);
fixture.git(['commit', '--quiet', '-m', 'docs only']);

const second = fixture.run();
assert.equal(second.status, 0, second.stdout);
assert.doesNotMatch(second.stdout, /this exact tree already passed/, 'the tree changed, so the whole-tree cache must miss');
assert.match(second.stdout, /identical inputs and verified outputs already passed/);
assert.equal(genCalls(second.invocations), 0, 'the per-gate cache must skip the rebuild entirely');
});

test('changing a gate input re-runs the rebuild', () => {
const fixture = makeFixture({ branchFiles: { 'pro-test/src/launch.ts': 'export const x = 1;\n' } });
fixture.git(['checkout', '-q', '-b', 'feature/pro-work-2']);
const first = fixture.run();
assert.match(first.stdout, /pro-test gate cached/);

writeFileSync(join(fixture.root, 'pro-test', 'src', 'launch.ts'), 'export const x = 2;\n');
fixture.git(['add', '-A']);
fixture.git(['commit', '--quiet', '-m', 'pro change']);

const second = fixture.run();
assert.equal(second.status, 0, second.stdout);
assert.doesNotMatch(second.stdout, /identical inputs and verified outputs already passed/);
assert.equal(genCalls(second.invocations), 1, 'a changed gate input must invalidate the per-gate entry');
});
});
Loading