Skip to content
Merged
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
127 changes: 123 additions & 4 deletions .github/scripts/auto-merge-policy.cjs
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -10,6 +14,109 @@ 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 controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort(
new DOMException(`timed out after ${timeoutMs}ms`, "TimeoutError")
)
}, timeoutMs)

try {
const response = await fetchImpl(rawUrl, {
headers: { Accept: "text/plain" },
signal: controller.signal,
})
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")
} finally {
clearTimeout(timeout)
}
}

function evaluateAutoMergePolicy({ pullRequest, files }) {
const reasons = []
const labels = new Set(
Expand Down Expand Up @@ -68,14 +175,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)) {
Expand All @@ -92,10 +209,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,
}
85 changes: 84 additions & 1 deletion .github/scripts/auto-merge-policy.test.cjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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" }
)
})
30 changes: 30 additions & 0 deletions .github/scripts/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .github/scripts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "openaffiliate-auto-merge-policy",
"private": true,
"version": "1.0.0",
"dependencies": {
"yaml": "2.8.3"
}
}
16 changes: 14 additions & 2 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -37,6 +49,7 @@ jobs:
const {
PROGRAM_PATH,
evaluateAutoMergePolicy,
fetchProgramContent,
} = require('./.github/scripts/auto-merge-policy.cjs')

const owner = context.repo.owner
Expand All @@ -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}`)
}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading