From db02cb0358e2eaf064805572bc93b586364e5d38 Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Thu, 20 Aug 2026 11:43:55 -0500 Subject: [PATCH] fix: don't abort semver set when a post_version hook's file is missing A post_version hook (replace/patch/regexp) whose target `file` doesn't exist threw an uncaught Deno.errors.NotFound out of postVersionHook, which aborted the whole `semver set`/`semver inc` run even though the VERSION file write and any earlier hooks had already succeeded. Treat a missing hook file as a recoverable warning instead: log it, skip that hook, and keep running the rest. Warnings are collected and surfaced via the existing GITHUB_OUTPUT mechanism as `hook_warnings` so callers can react to them. Other hook error types (unknown kind, bad config, etc.) still fail fast as before. Fixes #171 --- src/commands/inc/major.ts | 4 +- src/commands/inc/minor.ts | 4 +- src/commands/inc/none.ts | 4 +- src/commands/inc/patch.ts | 4 +- src/commands/set.ts | 4 +- src/hooks/hooks.interfaces.ts | 6 ++ src/hooks/post.test.ts | 110 +++++++++++++++++++++++++++++++++- src/hooks/post.ts | 64 +++++++++++++------- src/util/version.ts | 5 ++ 9 files changed, 171 insertions(+), 34 deletions(-) diff --git a/src/commands/inc/major.ts b/src/commands/inc/major.ts index eb3ec83..2b3ff56 100644 --- a/src/commands/inc/major.ts +++ b/src/commands/inc/major.ts @@ -21,11 +21,11 @@ export const major = { build, }); await writeVersionFile(current); - await postVersionHook( + const hookWarnings = await postVersionHook( args, previous, current, ); - await printVersion(args, current, args.json); + await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/minor.ts b/src/commands/inc/minor.ts index 8181567..cb5df7c 100644 --- a/src/commands/inc/minor.ts +++ b/src/commands/inc/minor.ts @@ -21,11 +21,11 @@ export const minor = { build, }); await writeVersionFile(current); - await postVersionHook( + const hookWarnings = await postVersionHook( args, previous, current, ); - await printVersion(args, current, args.json); + await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/none.ts b/src/commands/inc/none.ts index 4f8d24a..3a85c9b 100644 --- a/src/commands/inc/none.ts +++ b/src/commands/inc/none.ts @@ -21,11 +21,11 @@ export const none = { build, }); await writeVersionFile(current); - await postVersionHook( + const hookWarnings = await postVersionHook( args, previous, current, ); - await printVersion(args, current, args.json); + await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/patch.ts b/src/commands/inc/patch.ts index 7c321df..73ee5a2 100644 --- a/src/commands/inc/patch.ts +++ b/src/commands/inc/patch.ts @@ -21,11 +21,11 @@ export const patch = { build, }); await writeVersionFile(current); - await postVersionHook( + const hookWarnings = await postVersionHook( args, previous, current, ); - await printVersion(args, current, args.json); + await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/set.ts b/src/commands/set.ts index af869a2..5788444 100644 --- a/src/commands/set.ts +++ b/src/commands/set.ts @@ -26,11 +26,11 @@ export const set = { const previous = await readVersionFile(); const version = value ? parse(value) : previous ? previous : parse("0.1.0"); await writeVersionFile(version); - await postVersionHook( + const hookWarnings = await postVersionHook( args, previous, version, ); - await printVersion(args, version, args.json); + await printVersion(args, version, args.json, hookWarnings); }, }; diff --git a/src/hooks/hooks.interfaces.ts b/src/hooks/hooks.interfaces.ts index ea444ec..499b7fe 100644 --- a/src/hooks/hooks.interfaces.ts +++ b/src/hooks/hooks.interfaces.ts @@ -37,3 +37,9 @@ export type VersionConfig = { post?: PostHook[]; }; }; + +export type HookWarning = { + kind: PostHookKind; + file: string; + reason: string; +}; diff --git a/src/hooks/post.test.ts b/src/hooks/post.test.ts index 933280c..42a6c88 100644 --- a/src/hooks/post.test.ts +++ b/src/hooks/post.test.ts @@ -1,6 +1,6 @@ import { parse } from "semver"; import { assertEquals } from "@std/assert"; -import { resolvesNext, stub } from "testing/mock"; +import { assertSpyCalls, resolvesNext, stub } from "testing/mock"; import * as YAML from "yaml"; import { IContext } from "../context.ts"; import { postVersionHook } from "./post.ts"; @@ -45,6 +45,114 @@ Deno.test("yml or yaml", async () => { } }); +Deno.test("hook target file missing is a warning, not a failure", async () => { + const notFound = new Deno.errors.NotFound( + "No such file or directory (os error 2): readfile '.github/README.md'", + ); + const context: IContext = { + githubDir: ".github", + hooks: { + patch: async () => await undefined, + replace: async () => await undefined, + regexp: () => { + throw notFound; + }, + }, + }; + const stubs = [ + stub( + Deno, + "stat", + resolvesNext([ + { isFile: true } as Deno.FileInfo, // version.yml + ]), + ), + stub( + Deno, + "readTextFile", + resolvesNext([ + YAML.stringify({ + on: { + post: [{ + kind: "regexp", + file: ".github/README.md", + pattern: "\\d+\\.\\d+\\.\\d+", + }], + }, + }), + ]), + ), + stub(context.hooks, "patch"), + stub(context.hooks, "replace"), + ]; + try { + const warnings = await postVersionHook( + context, + parse("1.0.0"), + parse("1.2.3"), + ); + assertEquals(warnings, [{ + kind: "regexp", + file: ".github/README.md", + reason: notFound.message, + }]); + } finally { + stubs.forEach((s) => s.restore()); + } +}); + +Deno.test("remaining hooks still run after one hook's file is missing", async () => { + const notFound = new Deno.errors.NotFound("not found"); + const context: IContext = { + githubDir: ".github", + hooks: { + patch: async () => await undefined, + replace: () => { + throw notFound; + }, + regexp: async () => await undefined, + }, + }; + const stubs = [ + stub( + Deno, + "stat", + resolvesNext([ + { isFile: true } as Deno.FileInfo, // version.yml + ]), + ), + stub( + Deno, + "readTextFile", + resolvesNext([ + YAML.stringify({ + on: { + post: [ + { kind: "replace", file: "missing.txt" }, + { kind: "patch", file: "test/example.csproj" }, + ], + }, + }), + ]), + ), + stub(context.hooks, "regexp"), + ]; + const patchStub = stub(context.hooks, "patch"); + try { + const warnings = await postVersionHook( + context, + parse("1.0.0"), + parse("1.2.3"), + ); + assertEquals(warnings.length, 1); + assertEquals(warnings[0].file, "missing.txt"); + assertSpyCalls(patchStub, 1); + } finally { + patchStub.restore(); + stubs.forEach((s) => s.restore()); + } +}); + Deno.test("custom config", async () => { const context: IContext = { config: ".github/version-test.yml", diff --git a/src/hooks/post.ts b/src/hooks/post.ts index 87f0c95..84c9c1c 100644 --- a/src/hooks/post.ts +++ b/src/hooks/post.ts @@ -1,7 +1,11 @@ import * as YAML from "yaml"; import { HookError } from "../errors/mod.ts"; import { exists } from "../util/exists.ts"; -import { PostHookKind, VersionConfig } from "./hooks.interfaces.ts"; +import { + HookWarning, + PostHookKind, + VersionConfig, +} from "./hooks.interfaces.ts"; import { IContext } from "../context.ts"; import { SemVer } from "semver"; @@ -15,7 +19,8 @@ export async function postVersionHook( context: IContext, previous: SemVer, current: SemVer, -) { +): Promise { + const warnings: HookWarning[] = []; const versionConfig = await getVersionConfig(context); if (versionConfig) { console.log(`Invoking post_version hook...`); @@ -29,31 +34,44 @@ export async function postVersionHook( for (const hook of postHooks) { const { kind } = hook; - switch (kind) { - case PostHookKind.Replace: - await context.hooks.replace(hook.file, previous, current); - break; - case PostHookKind.Patch: - await context.hooks.patch(hook.file, current, hook.format); - break; - case PostHookKind.RegExp: - await context.hooks.regexp( - hook.file, - current, - hook.pattern, - hook.flags, - hook.format, - hook.prefix, - ); - break; - default: - throw new HookError( - "post_hook", - `unknown hook kind ${kind}`, + try { + switch (kind) { + case PostHookKind.Replace: + await context.hooks.replace(hook.file, previous, current); + break; + case PostHookKind.Patch: + await context.hooks.patch(hook.file, current, hook.format); + break; + case PostHookKind.RegExp: + await context.hooks.regexp( + hook.file, + current, + hook.pattern, + hook.flags, + hook.format, + hook.prefix, + ); + break; + default: + throw new HookError( + "post_hook", + `unknown hook kind ${kind}`, + ); + } + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + const reason = err instanceof Error ? err.message : String(err); + console.warn( + `warning: post_version hook (${kind}) skipped, file not found: ${hook.file}`, ); + warnings.push({ kind, file: hook.file, reason }); + } else { + throw err; + } } } } + return warnings; } async function getVersionConfig(context: IContext) { diff --git a/src/util/version.ts b/src/util/version.ts index c43ef0f..b60a811 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -1,6 +1,7 @@ import * as path from "path"; import { format, parse, SemVer } from "semver"; import { IContext } from "../context.ts"; +import { HookWarning } from "../hooks/hooks.interfaces.ts"; import { semverFormats } from "./variant.ts"; export const DEFAULT_VERSION = parse("0.1.0"); @@ -25,6 +26,7 @@ export async function printVersion( context: IContext, semver: SemVer, forceJson = false, + hookWarnings: HookWarning[] = [], ) { const formatted = format(semver); const { major, minor, patch, prerelease = [], build = [] } = semver; @@ -40,6 +42,9 @@ export async function printVersion( build: b, dotnet, docker, + ...hookWarnings.length > 0 + ? { hook_warnings: JSON.stringify(hookWarnings) } + : {}, // Adding these for backwards compatibility, do not remove or add more // todo: remove on next major version