From 9bdf8818a55c8b7c2d7c5704ea4ff1c4cee1e54d Mon Sep 17 00:00:00 2001 From: tx tests Date: Thu, 13 Aug 2026 22:31:10 +0100 Subject: [PATCH] fix(hooks): skip pre-push checks for tag-only pushes .husky/pre-push ran scripts/check.sh --all unconditionally, with no inspection of what was being pushed. Pushing a release tag therefore re-ran the full build and integration suite against a commit that had already passed the same hook when its branch was pushed, and passed CI on top of that. During v0.18.0 this added 5m20s to the release and looked like a hang. The hook now reads the ref lines git sends on stdin and exits early only when every ref is a tag. Anything else, including a mixed branch-and-tag push or input it cannot classify, runs the checks exactly as before, so no code can reach the remote unchecked. Tests run the real hook against a stubbed check.sh that records whether it was invoked, so a regression in the guard fails in milliseconds instead of starting a real multi-minute check run inside the suite. --- .husky/pre-push | 24 ++++++ test/integration/hooks.test.ts | 129 +++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/.husky/pre-push b/.husky/pre-push index a10fb621..939645b2 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -13,6 +13,30 @@ unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG GIT_CONFIG_PARAMETERS \ GIT_NO_REPLACE_OBJECTS GIT_REPLACE_REF_BASE GIT_PREFIX \ GIT_SHALLOW_FILE GIT_COMMON_DIR +# Git sends one line per ref being pushed on stdin: +# +# +# A tag push carries no new code. The commit a tag points at was already checked +# when the branch holding it was pushed, and again by CI, so re-running the full +# build + integration suite here costs minutes during a release and can never +# produce a different answer. Skip only when EVERY ref is a tag; a push carrying +# any branch ref, or one we cannot classify, runs the checks as before. +refs_total=0 +tag_refs=0 +while read -r _local_ref _local_oid remote_ref _remote_oid; do + [ -z "$remote_ref" ] && continue + refs_total=$((refs_total + 1)) + case "$remote_ref" in + refs/tags/*) tag_refs=$((tag_refs + 1)) ;; + esac +done + +if [ "$refs_total" -gt 0 ] && [ "$refs_total" -eq "$tag_refs" ]; then + echo "⏭️ Tag-only push ($tag_refs ref(s)): skipping pre-push checks." + echo " The tagged commit was already verified when its branch was pushed." + exit 0 +fi + echo "🔍 Running pre-push checks..." echo "" diff --git a/test/integration/hooks.test.ts b/test/integration/hooks.test.ts index ad6a3507..51c7d4b8 100644 --- a/test/integration/hooks.test.ts +++ b/test/integration/hooks.test.ts @@ -1139,3 +1139,132 @@ describe("hooksStatus command", () => { } }) }) + +// ============================================================================= +// Pre-push Hook: tag-only push guard +// ============================================================================= + +describe("pre-push hook ref filtering", () => { + let testDir: string + + /** + * Run the real .husky/pre-push against a stubbed scripts/check.sh. + * + * The stub records that it ran instead of executing the real build and + * integration suite, so a regression in the ref guard fails in milliseconds + * rather than kicking off a multi-minute check run inside the test. + */ + const runPrePush = (stdin: string): { status: number; checksRan: boolean; output: string } => { + const marker = resolve(testDir, "check-ran") + mkdirSync(resolve(testDir, "scripts"), { recursive: true }) + writeFileSync( + resolve(testDir, "scripts", "check.sh"), + `#!/bin/sh\ntouch "${marker}"\nexit 0\n`, + ) + chmodSync(resolve(testDir, "scripts", "check.sh"), 0o755) + + let status = 0 + let output = "" + try { + output = execSync(`sh "${resolve(testDir, "pre-push")}" origin https://example.invalid/repo.git`, { + cwd: testDir, + input: stdin, + encoding: "utf-8", + timeout: 30000, + }) + } catch (err) { + const e = err as { status?: number; stdout?: string; stderr?: string } + status = e.status ?? 1 + output = `${e.stdout ?? ""}${e.stderr ?? ""}` + } + + return { status, checksRan: existsSync(marker), output } + } + + beforeEach(() => { + testDir = createTestDir("pre-push-refs", false) + mkdirSync(testDir, { recursive: true }) + writeFileSync( + resolve(testDir, "pre-push"), + readFileSync(resolve(__dirname, "../../.husky/pre-push"), "utf-8"), + ) + }) + + afterEach(() => { + cleanupTestDir(testDir) + }) + + const SHA_A = "1111111111111111111111111111111111111111" + const SHA_B = "2222222222222222222222222222222222222222" + + it("skips checks for a tag-only push", () => { + const result = runPrePush(`refs/tags/v1.2.3 ${SHA_A} refs/tags/v1.2.3 ${SHA_B}\n`) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(false) + expect(result.output).toContain("Tag-only push") + }) + + it("skips checks when several tags are pushed at once", () => { + const result = runPrePush( + `refs/tags/v1.2.3 ${SHA_A} refs/tags/v1.2.3 ${SHA_B}\n` + + `refs/tags/v1.2.4 ${SHA_A} refs/tags/v1.2.4 ${SHA_B}\n`, + ) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(false) + }) + + it("skips checks for a tag deletion", () => { + const zero = "0000000000000000000000000000000000000000" + const result = runPrePush(`(delete) ${zero} refs/tags/v1.2.3 ${SHA_B}\n`) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(false) + }) + + it("runs checks for a branch push", () => { + const result = runPrePush(`refs/heads/main ${SHA_A} refs/heads/main ${SHA_B}\n`) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(true) + }) + + it("runs checks when a push mixes a branch and a tag", () => { + const result = runPrePush( + `refs/heads/main ${SHA_A} refs/heads/main ${SHA_B}\n` + + `refs/tags/v1.2.3 ${SHA_A} refs/tags/v1.2.3 ${SHA_B}\n`, + ) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(true) + }) + + it("runs checks when stdin carries no refs", () => { + // Never skip on an input shape we do not understand. + const result = runPrePush("") + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(true) + }) + + it("still blocks the push when checks fail on a branch push", () => { + mkdirSync(resolve(testDir, "scripts"), { recursive: true }) + writeFileSync(resolve(testDir, "scripts", "check.sh"), "#!/bin/sh\nexit 1\n") + chmodSync(resolve(testDir, "scripts", "check.sh"), 0o755) + + let status = 0 + try { + execSync(`sh "${resolve(testDir, "pre-push")}" origin https://example.invalid/repo.git`, { + cwd: testDir, + input: `refs/heads/main ${SHA_A} refs/heads/main ${SHA_B}\n`, + encoding: "utf-8", + timeout: 30000, + }) + } catch (err) { + status = (err as { status?: number }).status ?? 1 + } + + expect(status).toBe(1) + }) +})