diff --git a/src/config/env.ts b/src/config/env.ts index 6888222..02ecf97 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -42,10 +42,12 @@ export function parseEnv(input: NodeJS.ProcessEnv): AppEnv { ]); } + const normalizedKey = normalizePrivateKey(privateKey); + return { ...parsed, - GITHUB_PRIVATE_KEY: normalizePrivateKey(privateKey), - GITHUB_PRIVATE_KEY_PEM: normalizePrivateKey(privateKey), + GITHUB_PRIVATE_KEY: normalizedKey, + GITHUB_PRIVATE_KEY_PEM: normalizedKey, }; } diff --git a/src/github/client.ts b/src/github/client.ts index 3e260c1..23dc67b 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -219,22 +219,22 @@ function getGitHubStatusCode(error: unknown) { return undefined; } +function getRetryAfterHeader(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) return undefined; + const response = (error as Record)["response"]; + if (typeof response !== "object" || response === null) return undefined; + const headers = (response as Record)["headers"]; + if (typeof headers !== "object" || headers === null) return undefined; + const value = (headers as Record)["retry-after"]; + return value !== undefined ? String(value) : undefined; +} + function getRetryDelayMs(error: unknown, attempt: number) { - const headerValue = - typeof error === "object" && - error !== null && - "response" in error && - typeof error.response === "object" && - error.response !== null && - "headers" in error.response && - typeof error.response.headers === "object" && - error.response.headers !== null && - "retry-after" in error.response.headers - ? Number(error.response.headers["retry-after"]) - : undefined; - - if (typeof headerValue === "number" && Number.isFinite(headerValue) && headerValue >= 0) { - return Math.min(headerValue * 1000, 5_000); + const headerValue = getRetryAfterHeader(error); + const delaySeconds = headerValue !== undefined ? Number(headerValue) : undefined; + + if (typeof delaySeconds === "number" && Number.isFinite(delaySeconds) && delaySeconds >= 0) { + return Math.min(delaySeconds * 1000, 5_000); } return Math.min(250 * 2 ** attempt, 1_000); diff --git a/src/lib/replayStore.ts b/src/lib/replayStore.ts index 3031b1c..c80fe11 100644 --- a/src/lib/replayStore.ts +++ b/src/lib/replayStore.ts @@ -1,9 +1,12 @@ export class ReplayStore { private readonly ttlMs: number; + private readonly pruneIntervalMs: number; private readonly values = new Map(); + private lastPruneTime = 0; - constructor(ttlMs = 15 * 60 * 1000) { + constructor(ttlMs = 15 * 60 * 1000, pruneIntervalMs = 60_000) { this.ttlMs = ttlMs; + this.pruneIntervalMs = pruneIntervalMs; } getStatus(key: string, now = Date.now()) { @@ -32,6 +35,10 @@ export class ReplayStore { } prune(now = Date.now()) { + if (now - this.lastPruneTime < this.pruneIntervalMs) { + return; + } + this.lastPruneTime = now; for (const [key, value] of this.values.entries()) { if (value.expiresAt <= now) { this.values.delete(key); diff --git a/src/webhooks/github.ts b/src/webhooks/github.ts index 9dff74e..5bebddd 100644 --- a/src/webhooks/github.ts +++ b/src/webhooks/github.ts @@ -382,6 +382,8 @@ async function buildReleaseJob( }); } +const COMMIT_SHA_RE = /^[a-f0-9]{7,64}$/i; + function looksLikeCommitSha(value: string) { - return /^[a-f0-9]{7,64}$/i.test(value); + return COMMIT_SHA_RE.test(value); } diff --git a/tests/replayStore.test.ts b/tests/replayStore.test.ts new file mode 100644 index 0000000..8e3bfb3 --- /dev/null +++ b/tests/replayStore.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { ReplayStore } from "../src/lib/replayStore"; + +describe("ReplayStore", () => { + it("returns null for an unknown key", () => { + const store = new ReplayStore(); + expect(store.getStatus("unknown")).toBeNull(); + }); + + it("begin() returns 'started' for a new key", () => { + const store = new ReplayStore(); + expect(store.begin("key-1")).toBe("started"); + }); + + it("begin() returns 'in_flight' for a key already in flight", () => { + const store = new ReplayStore(); + store.begin("key-1"); + expect(store.begin("key-1")).toBe("in_flight"); + }); + + it("begin() returns 'completed' for a completed key within TTL", () => { + const store = new ReplayStore(); + const now = Date.now(); + store.begin("key-1", now); + store.complete("key-1", now); + expect(store.begin("key-1", now + 1_000)).toBe("completed"); + }); + + it("release() removes a key so it can be re-started", () => { + const store = new ReplayStore(); + store.begin("key-1"); + store.release("key-1"); + expect(store.getStatus("key-1")).toBeNull(); + expect(store.begin("key-1")).toBe("started"); + }); + + describe("prune() throttling", () => { + it("does not scan entries before the prune interval elapses", () => { + const pruneIntervalMs = 60_000; + const store = new ReplayStore(15 * 60 * 1000, pruneIntervalMs); + const now = 1_000_000; + const farPast = now - 20 * 60 * 1000; // 20 minutes before now + + // Insert an entry that has already expired + store.begin("expired-key", farPast); + // Manually force expiry by completing with a past time + store.complete("expired-key", farPast); + + // The first prune call at `now` runs (lastPruneTime is 0, so interval has elapsed). + store.prune(now); + + // Add a fresh entry + store.begin("live-key", now); + + // A prune call just before the interval elapses should NOT scan (lastPruneTime = now) + const justBeforeInterval = now + pruneIntervalMs - 1; + store.prune(justBeforeInterval); + + // live-key must still be accessible + expect(store.getStatus("live-key", justBeforeInterval)).toBe("in_flight"); + }); + + it("scans entries once the prune interval has elapsed", () => { + const pruneIntervalMs = 60_000; + const ttlMs = 5 * 60 * 1000; + const store = new ReplayStore(ttlMs, pruneIntervalMs); + const now = 2_000_000; + + // Insert a key and immediately expire it by completing it with an old timestamp + store.begin("old-key", now - ttlMs - 1); + store.complete("old-key", now - ttlMs - 1); + + // First prune triggers the initial scan (lastPruneTime starts at 0) + store.prune(now); + + // Insert another expired entry after the first prune + store.begin("another-old-key", now); + // Make it expire by the time the next prune fires + store.complete("another-old-key", now); + + // Advance past the prune interval — this should trigger a scan + const nextPrune = now + pruneIntervalMs + 1; + store.prune(nextPrune); + + // Entry with expiresAt = now + ttlMs should still be valid at nextPrune + // (it expires at now + ttlMs, and nextPrune = now + 60_001 < now + ttlMs = now + 300_000) + expect(store.getStatus("another-old-key", nextPrune)).toBe("completed"); + }); + + it("expired entries are invisible after the prune interval fires", () => { + const pruneIntervalMs = 60_000; + const ttlMs = 1_000; // very short TTL for test + const store = new ReplayStore(ttlMs, pruneIntervalMs); + const now = 3_000_000; + + store.begin("expiring-key", now); + // The entry expires at now + 1_000 + + // Advance past both TTL and prune interval + const future = now + ttlMs + pruneIntervalMs + 1; + // Calling getStatus triggers prune() which should run and remove the expired entry + expect(store.getStatus("expiring-key", future)).toBeNull(); + }); + }); +});