From d968f12fee8c5f576f134e19230c29c1f0945580 Mon Sep 17 00:00:00 2001 From: Son Piaz Date: Mon, 14 Sep 2026 13:38:33 -0700 Subject: [PATCH 1/2] ci: harden auto-merge policy for live mode Co-authored-by: Cursor --- .github/scripts/auto-merge-policy.cjs | 116 ++++++++++++++++++++- .github/scripts/auto-merge-policy.test.cjs | 85 ++++++++++++++- .github/scripts/package-lock.json | 30 ++++++ .github/scripts/package.json | 8 ++ .github/workflows/auto-merge.yml | 16 ++- .github/workflows/ci.yml | 1 + .gitignore | 1 + 7 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/package-lock.json create mode 100644 .github/scripts/package.json diff --git a/.github/scripts/auto-merge-policy.cjs b/.github/scripts/auto-merge-policy.cjs index 1bf73c185..55e2f4d62 100644 --- a/.github/scripts/auto-merge-policy.cjs +++ b/.github/scripts/auto-merge-policy.cjs @@ -1,7 +1,11 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const { parseDocument } = require("yaml") + const MAX_PROGRAM_FILES = 3 const MAX_ADDITIONS_PER_FILE = 150 const MAX_TOTAL_ADDITIONS = 300 const MAX_FILE_BYTES = 20_000 +const FETCH_TIMEOUT_MS = 10_000 const PROGRAM_PATH = /^programs\/[a-z0-9]+(?:-[a-z0-9]+)*\.yaml$/ const BLOCKING_LABELS = new Set([ @@ -10,6 +14,98 @@ const BLOCKING_LABELS = new Set([ "security", ]) +function parseProgramYaml(content, filename, reasons) { + let document + try { + document = parseDocument(content, { + strict: true, + uniqueKeys: true, + }) + } catch { + reasons.push(`invalid YAML: ${filename}`) + return undefined + } + + if (document.errors.length > 0) { + reasons.push(`invalid or duplicate YAML keys: ${filename}`) + return undefined + } + + let program + try { + program = document.toJS({ maxAliasCount: 0 }) + } catch { + reasons.push(`invalid or aliased YAML: ${filename}`) + return undefined + } + + if ( + !program || + typeof program !== "object" || + Array.isArray(program) + ) { + reasons.push(`program YAML must be a mapping: ${filename}`) + return undefined + } + + return program +} + +function isHttpsUrl(value) { + if (typeof value !== "string") return false + try { + const url = new URL(value) + return ( + url.protocol === "https:" && + url.username === "" && + url.password === "" + ) + } catch { + return false + } +} + +async function fetchProgramContent( + rawUrl, + { + fetchImpl = globalThis.fetch, + maxBytes = MAX_FILE_BYTES, + timeoutMs = FETCH_TIMEOUT_MS, + } = {} +) { + const response = await fetchImpl(rawUrl, { + headers: { Accept: "text/plain" }, + signal: AbortSignal.timeout(timeoutMs), + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const contentLength = Number(response.headers.get("content-length")) + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new Error(`response exceeds ${maxBytes} bytes`) + } + if (!response.body) { + throw new Error("response has no body") + } + + const reader = response.body.getReader() + const chunks = [] + let totalBytes = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.byteLength + if (totalBytes > maxBytes) { + await reader.cancel() + throw new Error(`response exceeds ${maxBytes} bytes`) + } + chunks.push(Buffer.from(value)) + } + + return Buffer.concat(chunks, totalBytes).toString("utf8") +} + function evaluateAutoMergePolicy({ pullRequest, files }) { const reasons = [] const labels = new Set( @@ -68,14 +164,24 @@ function evaluateAutoMergePolicy({ pullRequest, files }) { } if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) { reasons.push(`is larger than ${MAX_FILE_BYTES} bytes: ${file.filename}`) + continue + } + const program = parseProgramYaml(content, file.filename, reasons) + if (!program) continue + + const expectedSlug = file.filename + .slice("programs/".length) + .replace(/\.yaml$/, "") + if (program.slug !== expectedSlug) { + reasons.push(`slug must match filename: ${file.filename}`) } - if (!/^verified:\s*false(?:\s+#.*)?$/m.test(content)) { - reasons.push(`must explicitly set verified: false: ${file.filename}`) + if (program.verified !== false) { + reasons.push(`verified must be the boolean false: ${file.filename}`) } - if (!/^url:\s*https:\/\//m.test(content)) { + if (!isHttpsUrl(program.url)) { reasons.push(`must use an HTTPS product URL: ${file.filename}`) } - if (!/^signup_url:\s*https:\/\//m.test(content)) { + if (!isHttpsUrl(program.signup_url)) { reasons.push(`must use an HTTPS signup URL: ${file.filename}`) } if (/<\/?script\b/i.test(content)) { @@ -92,10 +198,12 @@ function evaluateAutoMergePolicy({ pullRequest, files }) { module.exports = { BLOCKING_LABELS, + FETCH_TIMEOUT_MS, MAX_ADDITIONS_PER_FILE, MAX_FILE_BYTES, MAX_PROGRAM_FILES, MAX_TOTAL_ADDITIONS, PROGRAM_PATH, evaluateAutoMergePolicy, + fetchProgramContent, } diff --git a/.github/scripts/auto-merge-policy.test.cjs b/.github/scripts/auto-merge-policy.test.cjs index a0b04759e..301b48053 100644 --- a/.github/scripts/auto-merge-policy.test.cjs +++ b/.github/scripts/auto-merge-policy.test.cjs @@ -1,7 +1,10 @@ /* eslint-disable @typescript-eslint/no-require-imports */ const test = require("node:test") const assert = require("node:assert/strict") -const { evaluateAutoMergePolicy } = require("./auto-merge-policy.cjs") +const { + evaluateAutoMergePolicy, + fetchProgramContent, +} = require("./auto-merge-policy.cjs") const validContent = `name: Example slug: example @@ -107,3 +110,83 @@ test("blocks unsafe trust, URL, and markup content", () => { ) } }) + +test("parses YAML and rejects duplicate or non-boolean verification", () => { + for (const content of [ + `${validContent}verified: true\n`, + validContent.replace("verified: false", 'verified: "false"'), + validContent.replace("name: Example", "name: ["), + ]) { + const result = evaluate({ + files: [ + { + filename: "programs/example.yaml", + status: "added", + additions: 7, + deletions: 0, + content, + }, + ], + }) + assert.equal(result.eligible, false) + } +}) + +test("requires the parsed slug to match the filename", () => { + const result = evaluate({ + files: [ + { + filename: "programs/not-example.yaml", + status: "added", + additions: 5, + deletions: 0, + content: validContent, + }, + ], + }) + assert.equal(result.eligible, false) + assert.match(result.reasons.join("\n"), /slug must match filename/) +}) + +test("fetches program content within byte and time limits", async () => { + const content = await fetchProgramContent("https://example.com/raw", { + fetchImpl: async () => new Response(validContent), + maxBytes: Buffer.byteLength(validContent), + timeoutMs: 100, + }) + assert.equal(content, validContent) + + await assert.rejects( + fetchProgramContent("https://example.com/raw", { + fetchImpl: async () => + new Response("small", { + headers: { "content-length": "100" }, + }), + maxBytes: 10, + timeoutMs: 100, + }), + /exceeds 10 bytes/ + ) + + await assert.rejects( + fetchProgramContent("https://example.com/raw", { + fetchImpl: async () => new Response("eleven-byte"), + maxBytes: 10, + timeoutMs: 100, + }), + /exceeds 10 bytes/ + ) + + await assert.rejects( + fetchProgramContent("https://example.com/raw", { + fetchImpl: (_url, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }) + }), + timeoutMs: 5, + }), + { name: "TimeoutError" } + ) +}) diff --git a/.github/scripts/package-lock.json b/.github/scripts/package-lock.json new file mode 100644 index 000000000..01d2da92a --- /dev/null +++ b/.github/scripts/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "openaffiliate-auto-merge-policy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openaffiliate-auto-merge-policy", + "version": "1.0.0", + "dependencies": { + "yaml": "2.8.3" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 000000000..1742d53c8 --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "openaffiliate-auto-merge-policy", + "private": true, + "version": "1.0.0", + "dependencies": { + "yaml": "2.8.3" + } +} diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 1d9561e48..6d6bba38a 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -28,6 +28,18 @@ jobs: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false + - name: Set up policy runtime + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: .github/scripts/package-lock.json + + # Installs only the pinned YAML parser. The package and lockfile come + # from the trusted base commit; lifecycle scripts are disabled. + - name: Install policy parser + run: npm ci --ignore-scripts --prefix .github/scripts + - name: Classify pull request uses: actions/github-script@v7 env: @@ -37,6 +49,7 @@ jobs: const { PROGRAM_PATH, evaluateAutoMergePolicy, + fetchProgramContent, } = require('./.github/scripts/auto-merge-policy.cjs') const owner = context.repo.owner @@ -61,8 +74,7 @@ jobs: for (const file of files) { if (!PROGRAM_PATH.test(file.filename)) continue try { - const response = await fetch(file.raw_url) - if (response.ok) file.content = await response.text() + file.content = await fetchProgramContent(file.raw_url) } catch (error) { core.warning(`Could not read ${file.filename}: ${error.message}`) } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 032a0cad8..8675ea7f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: # declarations for @openaffiliate/scoring and tsc cannot resolve it. - run: npm run build --workspace @openaffiliate/scoring - run: npm run lint + - run: npm ci --ignore-scripts --prefix .github/scripts - run: node --test .github/scripts/auto-merge-policy.test.cjs - run: npx tsc --noEmit - run: npm run build diff --git a/.gitignore b/.gitignore index 7792a468c..87b22e741 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ apps/*/.next apps/*/dist services/*/node_modules services/*/dist +.github/scripts/node_modules # verification reports (kept local only, re-generated by pipeline) verification-report.json From c47c31c13f7c5f4825a919c83c51b89cbc38a161 Mon Sep 17 00:00:00 2001 From: Son Piaz Date: Mon, 14 Sep 2026 13:58:36 -0700 Subject: [PATCH 2/2] test: keep timeout case alive on Node 22 Co-authored-by: Cursor --- .github/scripts/auto-merge-policy.cjs | 63 ++++++++++++++++----------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/.github/scripts/auto-merge-policy.cjs b/.github/scripts/auto-merge-policy.cjs index 55e2f4d62..53462408c 100644 --- a/.github/scripts/auto-merge-policy.cjs +++ b/.github/scripts/auto-merge-policy.cjs @@ -73,37 +73,48 @@ async function fetchProgramContent( timeoutMs = FETCH_TIMEOUT_MS, } = {} ) { - const response = await fetchImpl(rawUrl, { - headers: { Accept: "text/plain" }, - signal: AbortSignal.timeout(timeoutMs), - }) - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort( + new DOMException(`timed out after ${timeoutMs}ms`, "TimeoutError") + ) + }, timeoutMs) - const contentLength = Number(response.headers.get("content-length")) - if (Number.isFinite(contentLength) && contentLength > maxBytes) { - throw new Error(`response exceeds ${maxBytes} bytes`) - } - if (!response.body) { - throw new Error("response has no body") - } + try { + const response = await fetchImpl(rawUrl, { + headers: { Accept: "text/plain" }, + signal: controller.signal, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } - const reader = response.body.getReader() - const chunks = [] - let totalBytes = 0 - while (true) { - const { done, value } = await reader.read() - if (done) break - totalBytes += value.byteLength - if (totalBytes > maxBytes) { - await reader.cancel() + const contentLength = Number(response.headers.get("content-length")) + if (Number.isFinite(contentLength) && contentLength > maxBytes) { throw new Error(`response exceeds ${maxBytes} bytes`) } - chunks.push(Buffer.from(value)) - } + if (!response.body) { + throw new Error("response has no body") + } - return Buffer.concat(chunks, totalBytes).toString("utf8") + const reader = response.body.getReader() + const chunks = [] + let totalBytes = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.byteLength + if (totalBytes > maxBytes) { + await reader.cancel() + throw new Error(`response exceeds ${maxBytes} bytes`) + } + chunks.push(Buffer.from(value)) + } + + return Buffer.concat(chunks, totalBytes).toString("utf8") + } finally { + clearTimeout(timeout) + } } function evaluateAutoMergePolicy({ pullRequest, files }) {