From 521e77c4ce6030ef083c5d76983d7b86893d6dd5 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 14:55:28 +1300 Subject: [PATCH 01/12] ci: label pull requests and validate changesets --- .github/pull_request_template.md | 1 + .github/workflows/pr-metadata.yml | 37 +++ .../__tests__/pr-metadata.test.ts | 122 ++++++++++ scripts/pr-metadata-helpers.ts | 176 ++++++++++++++ scripts/pr-metadata.ts | 220 ++++++++++++++++++ 5 files changed, 556 insertions(+) create mode 100644 .github/workflows/pr-metadata.yml create mode 100644 packages/opencode-usage-limits/__tests__/pr-metadata.test.ts create mode 100644 scripts/pr-metadata-helpers.ts create mode 100644 scripts/pr-metadata.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8d53bf3..71e3bf4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,4 +31,5 @@ ## Release - [ ] A root Changeset is included when the change affects a published package. +- [ ] If a package change intentionally needs no release, the `skip-changeset` label is applied with reviewer agreement. - [ ] No package-specific release workflow or prerelease metadata was added. diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml new file mode 100644 index 0000000..630e058 --- /dev/null +++ b/.github/workflows/pr-metadata.yml @@ -0,0 +1,37 @@ +name: PR metadata + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] + +concurrency: + group: pr-metadata-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + metadata: + if: >- + (github.event.action != 'labeled' && github.event.action != 'unlabeled') || github.event.label.name == 'skip-changeset' + runs-on: ubuntu-latest + steps: + - name: Checkout trusted default-branch automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Label pull request and validate Changeset coverage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun scripts/pr-metadata.ts diff --git a/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts new file mode 100644 index 0000000..2d4ee9d --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { + getComponentLabels, + getMissingChangesets, + getRequiredChangesets, + getSizeLabel, + parseChangesetEntries, + reconcileLabels, +} from "../../../scripts/pr-metadata-helpers.ts"; + +const forceInput = "@mynameistito/opencode-force-input"; +const usageLimits = "@mynameistito/opencode-usage-limits"; +const docs = "@mynameistito/opencode-plugins-docs"; +const changeset = (entries: string): string => + `---\n${entries}\n---\nSummary\n`; + +describe("PR metadata helpers", () => { + it("maps package and documentation paths to component labels", () => { + expect( + getComponentLabels( + [ + "packages/opencode-force-input/src/index.ts", + "packages/opencode-usage-limits/src/index.ts", + "apps/web/docs/index.mdx", + ], + [] + ) + ).toStrictEqual(new Set(["force-input", "usage-limits", "docs"])); + }); + + it("parses one or multiple valid Changeset package entries", () => { + expect( + parseChangesetEntries(changeset(`"${usageLimits}": patch`)) + ).toStrictEqual([usageLimits]); + expect( + parseChangesetEntries( + changeset(`"${forceInput}": minor\n"${usageLimits}": patch`) + ) + ).toStrictEqual([forceInput, usageLimits]); + }); + + it("ignores malformed or unsupported Changeset entries", () => { + expect( + parseChangesetEntries(`---\n${usageLimits}: sideways\n---`) + ).toStrictEqual([]); + expect(parseChangesetEntries("not frontmatter")).toStrictEqual([]); + }); + + it("unions path and Changeset component signals", () => { + expect( + getComponentLabels(["package.json"], [usageLimits, forceInput]) + ).toStrictEqual(new Set(["usage-limits", "force-input"])); + expect( + getComponentLabels(["packages/opencode-force-input/src/index.ts"], []) + ).toStrictEqual(new Set(["force-input"])); + }); + + it("requires Changesets for meaningful plugin and docs changes only", () => { + expect( + getRequiredChangesets([ + "packages/opencode-force-input/src/index.ts", + "packages/opencode-usage-limits/README.md", + "apps/web/docs/usage-limits.mdx", + ]) + ).toStrictEqual(new Set([forceInput, usageLimits, docs])); + expect( + getRequiredChangesets([ + "packages/opencode-force-input/__tests__/plugin.test.ts", + "packages/opencode-usage-limits/scripts/test-package.ts", + "packages/opencode-force-input/tsconfig.json", + "packages/opencode-force-input/CONTRIBUTING.md", + ]) + ).toStrictEqual(new Set()); + }); + + it("reports changed packages without a matching Changeset entry", () => { + expect( + getMissingChangesets( + new Set([forceInput, usageLimits]), + new Set([forceInput]) + ) + ).toStrictEqual([usageLimits]); + expect( + getMissingChangesets(new Set([forceInput]), new Set([forceInput])) + ).toStrictEqual([]); + }); + + it("removes stale managed labels but preserves unrelated labels", () => { + expect( + reconcileLabels( + ["force-input", "size/l", "triaged"], + new Set(["usage-limits", "size/s"]), + new Set(["force-input", "usage-limits", "size/l", "size/s"]) + ) + ).toStrictEqual({ + add: ["usage-limits", "size/s"], + remove: ["force-input", "size/l"], + }); + }); + + it("ignores lockfiles and generated output when measuring PR size", () => { + expect( + getSizeLabel([ + { additions: 50_000, deletions: 50_000, filename: "bun.lock" }, + { + additions: 5000, + deletions: 0, + filename: "apps/web/.blume/generated.ts", + }, + { + additions: 4, + deletions: 4, + filename: "packages/plugin/src/index.ts", + }, + ]) + ).toBe("size/xs"); + expect( + getSizeLabel([{ additions: 1001, deletions: 0, filename: "src/a.ts" }]) + ).toBe("size/xl"); + }); +}); diff --git a/scripts/pr-metadata-helpers.ts b/scripts/pr-metadata-helpers.ts new file mode 100644 index 0000000..374870d --- /dev/null +++ b/scripts/pr-metadata-helpers.ts @@ -0,0 +1,176 @@ +export const packageLabels = new Map([ + ["@mynameistito/opencode-force-input", "force-input"], + ["@mynameistito/opencode-usage-limits", "usage-limits"], + ["@mynameistito/opencode-plugins-docs", "docs"], +]); + +const pathLabels = new Map([ + ["packages/opencode-force-input/", "force-input"], + ["packages/opencode-usage-limits/", "usage-limits"], + ["apps/web/", "docs"], +]); + +const meaningfulPackageFile = (filename: string): boolean => { + const segments = filename.split("/"); + const excludedDirectory = segments.some((segment) => + ["__tests__", "scripts"].includes(segment) + ); + const excludedFile = [ + "AGENTS.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + "tsconfig.json", + "vitest.config.ts", + "vitest.setup.ts", + ].includes(segments.at(-1) ?? ""); + return !excludedDirectory && !excludedFile; +}; + +export const parseChangesetEntries = (content: string): string[] => { + const lines = content.split(/\r?\n/u); + const opening = lines.indexOf("---"); + const closing = lines.indexOf("---", opening + 1); + if (opening !== 0 || closing === -1) { + return []; + } + const names: string[] = []; + for (const line of lines.slice(opening + 1, closing)) { + const entry = line.match( + /^\s*["'](?[^"']+)["']\s*:\s*(?patch|minor|major)\s*(?:#.*)?$/u + ); + const name = entry?.groups?.name; + if (name && packageLabels.has(name)) { + names.push(name); + } + } + return names; +}; + +export const getComponentLabels = ( + filenames: string[], + changesetNames: Iterable +): Set => { + const labels = new Set(); + for (const filename of filenames) { + for (const [path, label] of pathLabels) { + if (filename.startsWith(path)) { + labels.add(label); + } + } + } + for (const name of changesetNames) { + const label = packageLabels.get(name); + if (label) { + labels.add(label); + } + } + return labels; +}; + +interface ChangedFile { + filename: string; + additions: number; + deletions: number; +} + +export const getSizeLabel = (files: ChangedFile[]): string => { + const ignoredFilenames = new Set([ + "bun.lock", + "bun.lockb", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "CHANGELOG.md", + ]); + const ignoredDirectories = new Set([ + "coverage", + "dist", + ".blume", + ".blume-verify", + ".alchemy", + "node_modules", + ]); + let changedLines = 0; + for (const file of files) { + const segments = file.filename.split("/"); + const basename = segments.at(-1) ?? ""; + const ignored = + ignoredFilenames.has(basename) || + segments.some((segment) => ignoredDirectories.has(segment)) || + basename.endsWith(".snap") || + basename.endsWith(".tsbuildinfo"); + if (!ignored) { + changedLines += file.additions + file.deletions; + } + } + if (changedLines <= 10) { + return "size/xs"; + } + if (changedLines <= 100) { + return "size/s"; + } + if (changedLines <= 500) { + return "size/m"; + } + if (changedLines <= 1000) { + return "size/l"; + } + return "size/xl"; +}; + +export const getRequiredChangesets = (filenames: string[]): Set => { + const required = new Set(); + for (const filename of filenames) { + if ( + filename.startsWith("packages/opencode-force-input/") && + meaningfulPackageFile(filename) + ) { + required.add("@mynameistito/opencode-force-input"); + } + if ( + filename.startsWith("packages/opencode-usage-limits/") && + meaningfulPackageFile(filename) + ) { + required.add("@mynameistito/opencode-usage-limits"); + } + if ( + filename.startsWith("apps/web/docs/") || + [ + "apps/web/alchemy.run.ts", + "apps/web/blume.config.ts", + "apps/web/theme.css", + ].includes(filename) + ) { + required.add("@mynameistito/opencode-plugins-docs"); + } + } + return required; +}; + +export const getMissingChangesets = ( + requiredPackages: Iterable, + changesetPackages: Set +): string[] => + [...requiredPackages].filter((name) => !changesetPackages.has(name)); + +interface LabelReconciliation { + add: string[]; + remove: string[]; +} + +export const reconcileLabels = ( + currentLabels: string[], + desiredLabels: Set, + managedLabels: Set +): LabelReconciliation => { + const add = [...desiredLabels].filter( + (label) => !currentLabels.includes(label) + ); + const remove = currentLabels.filter( + (label) => managedLabels.has(label) && !desiredLabels.has(label) + ); + return { add, remove }; +}; diff --git a/scripts/pr-metadata.ts b/scripts/pr-metadata.ts new file mode 100644 index 0000000..7cbee64 --- /dev/null +++ b/scripts/pr-metadata.ts @@ -0,0 +1,220 @@ +import { + getComponentLabels, + getMissingChangesets, + getRequiredChangesets, + getSizeLabel, + packageLabels, + parseChangesetEntries, + reconcileLabels, +} from "./pr-metadata-helpers.ts"; + +const owner = process.env.GITHUB_REPOSITORY?.split("/")[0]; +const repository = process.env.GITHUB_REPOSITORY?.split("/")[1]; +const token = process.env.GITHUB_TOKEN; +const pullRequestNumber = Number(process.env.PR_NUMBER); +const headSha = process.env.PR_HEAD_SHA; + +if (!owner || !repository || !token || !Number.isInteger(pullRequestNumber)) { + throw new Error( + "Missing GitHub repository, token, or pull request metadata." + ); +} + +const api = async (endpoint: string, init?: RequestInit): Promise => { + const response = await fetch(`https://api.github.com${endpoint}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...init?.headers, + }, + }); + if (!response.ok) { + throw new Error(`GitHub API ${response.status}: ${await response.text()}`); + } + // SAFETY: Each caller pairs this response with the schema for the requested GitHub API endpoint. + return (await response.json()) as T; +}; + +interface PullRequestFile { + filename: string; + additions: number; + deletions: number; +} + +interface PullRequest { + head: { sha: string; repo?: { full_name?: string } | null }; + labels: { name: string }[]; + changed_files: number; +} + +const pullRequest = await api( + `/repos/${owner}/${repository}/pulls/${pullRequestNumber}` +); +if (headSha && pullRequest.head.sha !== headSha) { + console.log( + `Skipping stale event for ${headSha}; pull request now points to ${pullRequest.head.sha}.` + ); + process.exit(0); +} +const pageCount = Math.ceil(Math.min(pullRequest.changed_files, 300) / 100); +const filePages = await Promise.all( + Array.from({ length: pageCount }, (_, index) => + api( + `/repos/${owner}/${repository}/pulls/${pullRequestNumber}/files?per_page=100&page=${index + 1}` + ) + ) +); +const files = filePages.flat(); + +const componentLabels = new Set(packageLabels.values()); +const managedLabels = new Set([ + ...componentLabels, + "release", + "dependencies", + "github-actions", + "size/xs", + "size/s", + "size/m", + "size/l", + "size/xl", +]); + +const changesetFiles = files.filter(({ filename }) => + /^\.changeset\/(?!README\.md$)[^/]+\.md$/u.test(filename) +); +const headRepository = + pullRequest.head.repo?.full_name ?? `${owner}/${repository}`; +const changesetContents = await Promise.all( + changesetFiles.map(async ({ filename }) => { + try { + const encodedPath = filename.split("/").map(encodeURIComponent).join("/"); + const file = await api<{ content?: string; encoding?: string }>( + `/repos/${headRepository}/contents/${encodedPath}?ref=${encodeURIComponent(headSha ?? pullRequest.head.sha)}` + ); + if (file.encoding !== "base64" || !file.content) { + return []; + } + const content = Buffer.from(file.content, "base64").toString("utf-8"); + return parseChangesetEntries(content); + } catch (error) { + console.warn( + `Could not read Changeset ${filename}; ignoring it: ${String(error)}` + ); + return []; + } + }) +); +const changesetNames = new Set(changesetContents.flat()); +const components = getComponentLabels( + files.map(({ filename }) => filename), + changesetNames +); + +const labelSet = new Set(components); +if (files.some(({ filename }) => filename.startsWith(".changeset/"))) { + labelSet.add("release"); +} +if ( + files.some( + ({ filename }) => + filename === "package.json" || + filename.endsWith("/package.json") || + ["bun.lock", "bun.lockb"].includes(filename) + ) +) { + labelSet.add("dependencies"); +} +if (files.some(({ filename }) => filename.startsWith(".github/"))) { + labelSet.add("github-actions"); +} + +const sizeLabel = getSizeLabel(files); +labelSet.add(sizeLabel); + +const currentLabels = pullRequest.labels.map(({ name }) => name); +const skipChangesetLabel = "skip-changeset"; +const ensureLabels = new Set([ + ...labelSet, + ...(currentLabels.includes(skipChangesetLabel) ? [] : [skipChangesetLabel]), +]); +const labelsEndpoint = `/repos/${owner}/${repository}/labels`; +const getLabelColor = (name: string): string => { + if (name === skipChangesetLabel) { + return "d4c5f9"; + } + if (name.startsWith("size/")) { + return "ededed"; + } + return "1d76db"; +}; +await Promise.all( + [...ensureLabels].map(async (name) => { + try { + await api(labelsEndpoint, { + body: JSON.stringify({ + color: getLabelColor(name), + description: + name === skipChangesetLabel + ? "Use only for justified changes that do not require a release." + : "Automatically managed pull request metadata", + name, + }), + method: "POST", + }); + } catch (error) { + if (!String(error).includes("422")) { + throw error; + } + } + }) +); + +const { add: labelsToAdd, remove: labelsToRemove } = reconcileLabels( + currentLabels, + labelSet, + managedLabels +); +if (labelsToAdd.length > 0) { + await api( + `/repos/${owner}/${repository}/issues/${pullRequestNumber}/labels`, + { + body: JSON.stringify({ labels: labelsToAdd }), + method: "POST", + } + ); +} +if (labelsToRemove.length > 0) { + await Promise.all( + labelsToRemove.map((name) => + api( + `/repos/${owner}/${repository}/issues/${pullRequestNumber}/labels/${encodeURIComponent(name)}`, + { method: "DELETE" } + ) + ) + ); +} + +const skipChangeset = currentLabels.includes(skipChangesetLabel); +const requiredPackages = skipChangeset + ? new Set() + : getRequiredChangesets(files.map(({ filename }) => filename)); + +const missingPackages = getMissingChangesets(requiredPackages, changesetNames); +if (missingPackages.length > 0) { + throw new Error( + `Changeset required for changed published/deployed package(s):\n${missingPackages + .map( + (name) => + `- ${name} (missing a valid entry in a changed .changeset/*.md file)` + ) + .join( + "\n" + )}\nAdd a Changeset or apply the skip-changeset label for a justified non-release change.` + ); +} + +console.log( + `PR metadata reconciled: ${[...labelSet].join(", ")}; Changeset coverage passed${skipChangeset ? " (skip-changeset label)" : ""}.` +); From 1668adee0bb8b389aa4b0250b81c40e4594d33db Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 14:56:00 +1300 Subject: [PATCH 02/12] test: validate npm package contents --- .../scripts/test-package.ts | 3 + .../scripts/test-package.ts | 3 + scripts/check-package-tarball.ts | 103 ++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 scripts/check-package-tarball.ts diff --git a/packages/opencode-force-input/scripts/test-package.ts b/packages/opencode-force-input/scripts/test-package.ts index a71a41d..9eb6d2d 100644 --- a/packages/opencode-force-input/scripts/test-package.ts +++ b/packages/opencode-force-input/scripts/test-package.ts @@ -1,3 +1,5 @@ +import { checkPackageTarball } from "../../../scripts/check-package-tarball.ts"; + const tuiEntrypoint = new URL("../dist/index.mjs", import.meta.url); const tuiModule = await import(tuiEntrypoint.href); const tuiPlugin = tuiModule.default; @@ -13,3 +15,4 @@ if (!hasValidTuiPlugin) { } console.log(`Package smoke test passed: ${tuiPlugin.id}`); +await checkPackageTarball("packages/opencode-force-input"); diff --git a/packages/opencode-usage-limits/scripts/test-package.ts b/packages/opencode-usage-limits/scripts/test-package.ts index 1dc40cd..2eb488b 100644 --- a/packages/opencode-usage-limits/scripts/test-package.ts +++ b/packages/opencode-usage-limits/scripts/test-package.ts @@ -1,5 +1,7 @@ import { createRequire } from "node:module"; +import { checkPackageTarball } from "../../../scripts/check-package-tarball.ts"; + const expectedId = "mynameistito.usage-limits"; const entrypoint = new URL("../dist/index.mjs", import.meta.url); @@ -55,3 +57,4 @@ if (!isPlugin) { } console.log(`Package smoke test passed: ${expectedId}`); +await checkPackageTarball("packages/opencode-usage-limits"); diff --git a/scripts/check-package-tarball.ts b/scripts/check-package-tarball.ts new file mode 100644 index 0000000..9828e3e --- /dev/null +++ b/scripts/check-package-tarball.ts @@ -0,0 +1,103 @@ +import { appendFileSync, readFileSync } from "node:fs"; +import path from "node:path"; + +interface PackageManifest { + name: string; + files?: string[]; +} + +interface PackedFile { + path: string; + size: number; +} + +interface PackResult { + files: PackedFile[]; + size: number; + unpackedSize: number; +} + +const npmPath = Bun.which("npm"); +if (!npmPath) { + throw new Error("npm is required to validate package tarballs."); +} + +export const checkPackageTarball = async ( + packageDirectory: string +): Promise => { + const absolutePackageDirectory = path.resolve( + import.meta.dir, + "..", + packageDirectory + ); + const manifestPath = path.join(absolutePackageDirectory, "package.json"); + // SAFETY: Package-specific manifest and required-file checks verify this structure. + const manifest = JSON.parse( + readFileSync(manifestPath, "utf-8") + ) as PackageManifest; + const childProcess = Bun.spawn( + [npmPath, "pack", "--dry-run", "--json", "--ignore-scripts"], + { cwd: absolutePackageDirectory, stderr: "inherit", stdout: "pipe" } + ); + const output = await new Response(childProcess.stdout).text(); + if ((await childProcess.exited) !== 0) { + throw new Error(`npm pack failed for ${manifest.name}.`); + } + // SAFETY: npm pack --json returns either a result array or a workspace map with the fields declared here. + const parsed = JSON.parse(output) as + | PackResult[] + | Record; + let pack: PackResult | undefined; + if (Array.isArray(parsed)) { + [pack] = parsed; + } else { + pack = parsed[manifest.name]; + } + if (!pack || !Array.isArray(pack.files)) { + throw new Error(`npm pack returned no result for ${manifest.name}`); + } + const packedPaths = new Set(pack.files.map(({ path: filePath }) => filePath)); + const requiredFiles = + manifest.name === "@mynameistito/opencode-force-input" + ? ["README.md", "LICENSE", "dist/index.mjs", "dist/index.d.mts"] + : [ + "README.md", + "LICENSE", + "dist/index.mjs", + "dist/index.d.mts", + "usage-limits.schema.json", + "examples/usage-limits.jsonc", + ]; + const missingFiles = requiredFiles.filter((file) => !packedPaths.has(file)); + const unexpectedFiles = [...packedPaths].filter((file) => + /^(?:src|__tests__|scripts|coverage|node_modules)\//u.test(file) + ); + if (missingFiles.length > 0 || unexpectedFiles.length > 0) { + throw new Error( + [ + `npm pack validation failed for ${manifest.name}`, + ...(missingFiles.length > 0 + ? [`Missing required files: ${missingFiles.join(", ")}`] + : []), + ...(unexpectedFiles.length > 0 + ? [`Unexpected files: ${unexpectedFiles.join(", ")}`] + : []), + ].join("\n") + ); + } + + const summary = `| \`${manifest.name}\` | ${pack.files.length} | ${(pack.size / 1024).toFixed(1)} KB | ${(pack.unpackedSize / 1024).toFixed(1)} KB |`; + console.log( + `npm pack validation passed: ${manifest.name}, ${pack.files.length} files, ${(pack.size / 1024).toFixed(1)} KB packed.` + ); + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) { + if (readFileSync(summaryPath, "utf-8").length === 0) { + appendFileSync( + summaryPath, + "| Package | Files | Packed | Unpacked |\n| --- | ---: | ---: | ---: |\n" + ); + } + appendFileSync(summaryPath, `${summary}\n`); + } +}; From 0fd47837bca7a3e98f30838c0dc7c0e5cc1533af Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 14:56:12 +1300 Subject: [PATCH 03/12] ci: add workflow and documentation checks --- .github/workflows/link-check.yml | 31 +++++++++++++ .github/workflows/workflow-security.yml | 58 +++++++++++++++++++++++++ .github/zizmor.yml | 16 +++++++ 3 files changed, 105 insertions(+) create mode 100644 .github/workflows/link-check.yml create mode 100644 .github/workflows/workflow-security.yml create mode 100644 .github/zizmor.yml diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 0000000..03198ef --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,31 @@ +name: Documentation links + +on: + pull_request: + paths: + - README.md + - apps/web/** + - packages/*/README.md + - .lycheeignore + schedule: + - cron: "23 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + links: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check documentation links + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 + with: + args: >- + --verbose --no-progress --max-retries 3 --retry-wait-time 2 --timeout 20 --exclude-mail --accept 200,206,301,302,307,308,403,429 README.md 'apps/web/**/*.md' 'apps/web/**/*.mdx' 'packages/*/README.md' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml new file mode 100644 index 0000000..f26d4f6 --- /dev/null +++ b/.github/workflows/workflow-security.yml @@ -0,0 +1,58 @@ +name: Workflow security + +on: + pull_request: + paths: + - .github/workflows/** + - .github/actions/** + - .github/dependabot.yml + - .github/zizmor.yml + push: + branches: [main] + paths: + - .github/workflows/** + - .github/actions/** + - .github/dependabot.yml + - .github/zizmor.yml + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: actionlint + uses: reviewdog/action-actionlint@086cc5300a077f608b81d2b03801bc382e417264 # v1.65.2 + with: + fail_level: error + reporter: github-annotations + + zizmor: + runs-on: ubuntu-latest + steps: + - name: Checkout trusted security configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + path: trusted + persist-credentials: false + - name: Checkout workflow changes for analysis + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: source + persist-credentials: false + - name: zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + advanced-security: false + annotations: true + config: trusted/.github/zizmor.yml + inputs: source/.github + version: 1.30.1 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000..08ed5a3 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,16 @@ +# These findings are limited to existing, intentionally reviewed workflow +# patterns. Keep the ignores line-specific so new findings remain visible. +rules: + dangerous-triggers: + ignore: + - deploy-preview.yml:3 + - deploy.yml:3 + # Required for fork-safe labeling; this workflow checks out only the trusted default branch. + - pr-metadata.yml:3 + excessive-permissions: + ignore: + - deploy-preview.yml:20 + - deploy-preview.yml:21 + adhoc-packages: + ignore: + - release.yml:35 From 29ae7963e1739a511e47e776ad6227cb9c5794f1 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 14:56:26 +1300 Subject: [PATCH 04/12] docs: document repository automation --- AGENTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b4a0175..f7075d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,11 @@ ## Changesets -Create Changesets at the root with `bun run changeset-add -- force-input|usage-limits patch|minor|major "summary"`. +Create Changesets at the root with `bun run changeset-add -- force-input|usage-limits patch|minor|major "summary"`. Meaningful plugin changes and documentation-site content changes require a matching Changeset. Tests, package-local scripts, and listed development-only metadata/configuration changes are exempt. Apply `skip-changeset` only for a justified non-release change and with maintainer agreement. + +## Pull request automation + +PR metadata automation labels changed components from file paths and package names in changed Changesets, and reconciles size labels on every update. Documentation link checks run for relevant edits and weekly. Workflow security scans run when Actions or Dependabot configuration changes. ## Testing From ce6d23c0500d15c3d8fe04898d4efca0dd5c9287 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 15:05:49 +1300 Subject: [PATCH 05/12] fix(ci): make workflow security checks portable --- .github/workflows/workflow-security.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index f26d4f6..0df5aa7 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -30,7 +30,7 @@ jobs: uses: reviewdog/action-actionlint@086cc5300a077f608b81d2b03801bc382e417264 # v1.65.2 with: fail_level: error - reporter: github-annotations + reporter: local zizmor: runs-on: ubuntu-latest @@ -48,11 +48,21 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} path: source persist-credentials: false + - name: Select trusted zizmor configuration + id: zizmor-config + shell: bash + run: | + if [[ -f trusted/.github/zizmor.yml ]]; then + echo "path=trusted/.github/zizmor.yml" >> "$GITHUB_OUTPUT" + else + echo "::warning::The trusted branch has no zizmor configuration yet; using the PR config for this bootstrap run." + echo "path=source/.github/zizmor.yml" >> "$GITHUB_OUTPUT" + fi - name: zizmor uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 with: advanced-security: false annotations: true - config: trusted/.github/zizmor.yml + config: ${{ steps.zizmor-config.outputs.path }} inputs: source/.github version: 1.30.1 From aa9097be6fede9cce2935cbc02c477244bc1b100 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 15:16:13 +1300 Subject: [PATCH 06/12] fix(ci): correct workflow security reporters --- .github/workflows/workflow-security.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index 0df5aa7..943643e 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -21,16 +21,20 @@ permissions: jobs: actionlint: runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + checks: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: actionlint - uses: reviewdog/action-actionlint@086cc5300a077f608b81d2b03801bc382e417264 # v1.65.2 + uses: reviewdog/action-actionlint@a5524e1c19e62881d79c1f1b9b6f09f16356e281 # v1.65.2 with: fail_level: error - reporter: local + reporter: github-pr-check zizmor: runs-on: ubuntu-latest From 125d7497f88014200cec56ace4a587e72ef918b6 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 15:20:14 +1300 Subject: [PATCH 07/12] chore(deps): set update cooldowns --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d4a636f..3414399 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 labels: - dependencies ignore: @@ -12,6 +14,8 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 labels: - dependencies - github-actions From b541d1fc4814a85ce549e8908cc45927dafc7215 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 15:35:38 +1300 Subject: [PATCH 08/12] fix(ci): address review findings --- .github/pull_request_template.md | 2 +- .github/workflows/link-check.yml | 2 +- .github/workflows/pr-metadata.yml | 4 +- .github/workflows/workflow-security.yml | 10 +++- AGENTS.md | 2 +- .../__tests__/pr-metadata.test.ts | 39 +++++++++++++ scripts/pr-metadata-helpers.ts | 51 ++++++++++++++++ scripts/pr-metadata.ts | 58 +++++++++++++++---- 8 files changed, 148 insertions(+), 20 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 71e3bf4..6721e1e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,5 +31,5 @@ ## Release - [ ] A root Changeset is included when the change affects a published package. -- [ ] If a package change intentionally needs no release, the `skip-changeset` label is applied with reviewer agreement. +- [ ] If a package change intentionally needs no release, the `skip-changeset` label is applied with maintainer agreement. - [ ] No package-specific release workflow or prerelease metadata was added. diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 03198ef..ec7c6ac 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -26,6 +26,6 @@ jobs: uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 with: args: >- - --verbose --no-progress --max-retries 3 --retry-wait-time 2 --timeout 20 --exclude-mail --accept 200,206,301,302,307,308,403,429 README.md 'apps/web/**/*.md' 'apps/web/**/*.mdx' 'packages/*/README.md' + --verbose --no-progress --max-retries 3 --retry-wait-time 2 --timeout 20 --exclude-mail --exclude '^/' --accept 200,206,301,302,307,308,429 README.md 'apps/web/**/*.md' 'apps/web/**/*.mdx' 'packages/*/README.md' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 630e058..a4f89ed 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -5,8 +5,8 @@ on: types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] concurrency: - group: pr-metadata-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: pr-metadata-${{ github.event.pull_request.number }}-${{ github.event.action }}-${{ github.event.label.name || 'none' }} + cancel-in-progress: ${{ github.event.action == 'synchronize' }} permissions: contents: read diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index 943643e..7458930 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -21,7 +21,6 @@ permissions: jobs: actionlint: runs-on: ubuntu-latest - if: github.event_name == 'pull_request' permissions: contents: read checks: write @@ -34,7 +33,7 @@ jobs: uses: reviewdog/action-actionlint@a5524e1c19e62881d79c1f1b9b6f09f16356e281 # v1.65.2 with: fail_level: error - reporter: github-pr-check + reporter: ${{ github.event_name == 'pull_request' && 'github-pr-check' || 'github-check' }} zizmor: runs-on: ubuntu-latest @@ -55,11 +54,16 @@ jobs: - name: Select trusted zizmor configuration id: zizmor-config shell: bash + env: + IS_FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} run: | if [[ -f trusted/.github/zizmor.yml ]]; then echo "path=trusted/.github/zizmor.yml" >> "$GITHUB_OUTPUT" + elif [[ "$IS_FORK_PR" == "true" ]]; then + echo "::error::The trusted default branch has no zizmor configuration; refusing to use fork-provided configuration." + exit 1 else - echo "::warning::The trusted branch has no zizmor configuration yet; using the PR config for this bootstrap run." + echo "::warning::The trusted branch has no zizmor configuration yet; using same-repository configuration for this bootstrap run." echo "path=source/.github/zizmor.yml" >> "$GITHUB_OUTPUT" fi - name: zizmor diff --git a/AGENTS.md b/AGENTS.md index f7075d7..70e0578 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ ## Changesets -Create Changesets at the root with `bun run changeset-add -- force-input|usage-limits patch|minor|major "summary"`. Meaningful plugin changes and documentation-site content changes require a matching Changeset. Tests, package-local scripts, and listed development-only metadata/configuration changes are exempt. Apply `skip-changeset` only for a justified non-release change and with maintainer agreement. +Create Changesets at the root with `bun run changeset-add -- docs|force-input|usage-limits patch|minor|major "summary"`. Meaningful plugin changes and documentation-site content changes require a matching Changeset. Tests, package-local scripts, and listed development-only metadata/configuration changes are exempt. Apply `skip-changeset` only for a justified non-release change and with maintainer agreement. ## Pull request automation diff --git a/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts index 2d4ee9d..51d4df9 100644 --- a/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts +++ b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest"; import { getComponentLabels, getMissingChangesets, + getPullRequestFilePageCount, getRequiredChangesets, getSizeLabel, + isChangesetReleasePR, + isDuplicateLabelError, parseChangesetEntries, reconcileLabels, } from "../../../scripts/pr-metadata-helpers.ts"; @@ -86,6 +89,42 @@ describe("PR metadata helpers", () => { ).toStrictEqual([]); }); + it("paginates all pull request files up to the API limit", () => { + expect(getPullRequestFilePageCount(0)).toBe(0); + expect(getPullRequestFilePageCount(301)).toBe(4); + expect(getPullRequestFilePageCount(3000)).toBe(30); + expect(() => getPullRequestFilePageCount(3001)).toThrow( + "more than 3000 changed files" + ); + }); + + it("exempts generated Changesets release pull requests", () => { + expect(isChangesetReleasePR("changeset-release/main")).toBeTruthy(); + expect(isChangesetReleasePR("feature/update-plugin")).toBeFalsy(); + }); + + it("recognizes only GitHub's duplicate-label validation response", () => { + expect( + isDuplicateLabelError( + 422, + JSON.stringify({ errors: [{ code: "already_exists" }] }) + ) + ).toBeTruthy(); + expect( + isDuplicateLabelError( + 422, + JSON.stringify({ errors: [{ code: "invalid" }] }) + ) + ).toBeFalsy(); + expect(isDuplicateLabelError(422, "not JSON")).toBeFalsy(); + expect( + isDuplicateLabelError( + 500, + JSON.stringify({ errors: [{ code: "already_exists" }] }) + ) + ).toBeFalsy(); + }); + it("removes stale managed labels but preserves unrelated labels", () => { expect( reconcileLabels( diff --git a/scripts/pr-metadata-helpers.ts b/scripts/pr-metadata-helpers.ts index 374870d..6f61030 100644 --- a/scripts/pr-metadata-helpers.ts +++ b/scripts/pr-metadata-helpers.ts @@ -69,6 +69,57 @@ export const getComponentLabels = ( return labels; }; +const pullRequestFilesPerPage = 100; +const pullRequestFilesLimit = 3000; + +/** + * Get the number of GitHub API pages needed to inspect all changed files. + * + * @param changedFiles - The changed-file count reported by the pull request. + * @returns The number of pages at 100 files per page. + */ +export const getPullRequestFilePageCount = (changedFiles: number): number => { + if (changedFiles > pullRequestFilesLimit) { + throw new Error( + `Pull requests with more than ${pullRequestFilesLimit} changed files cannot be fully inspected by the GitHub API.` + ); + } + return Math.ceil(changedFiles / pullRequestFilesPerPage); +}; + +/** + * Identify the release PR branch created by Changesets. + * + * @param headRef - The pull request head branch name. + * @returns Whether the branch uses Changesets' release PR prefix. + */ +export const isChangesetReleasePR = (headRef: string): boolean => + headRef.startsWith("changeset-release/"); + +/** + * Check whether a GitHub API validation response is specifically a duplicate label. + * + * @param status - The HTTP status returned by GitHub. + * @param responseBody - The response body returned by GitHub. + * @returns Whether the response identifies an existing label by name. + */ +export const isDuplicateLabelError = ( + status: number, + responseBody: string +): boolean => { + if (status !== 422) { + return false; + } + try { + const payload: { errors?: { code?: string }[] } = JSON.parse(responseBody); + return ( + payload.errors?.some(({ code }) => code === "already_exists") ?? false + ); + } catch { + return false; + } +}; + interface ChangedFile { filename: string; additions: number; diff --git a/scripts/pr-metadata.ts b/scripts/pr-metadata.ts index 7cbee64..6d2021c 100644 --- a/scripts/pr-metadata.ts +++ b/scripts/pr-metadata.ts @@ -1,8 +1,11 @@ import { getComponentLabels, getMissingChangesets, + getPullRequestFilePageCount, getRequiredChangesets, getSizeLabel, + isChangesetReleasePR, + isDuplicateLabelError, packageLabels, parseChangesetEntries, reconcileLabels, @@ -20,18 +23,35 @@ if (!owner || !repository || !token || !Number.isInteger(pullRequestNumber)) { ); } +class GitHubApiError extends Error { + override readonly name = "GitHubApiError"; + readonly status: number; + readonly responseBody: string; + + constructor(status: number, responseBody: string) { + super(`GitHub API ${status}: ${responseBody}`); + this.status = status; + this.responseBody = responseBody; + } +} + const api = async (endpoint: string, init?: RequestInit): Promise => { const response = await fetch(`https://api.github.com${endpoint}`, { ...init, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, + "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", ...init?.headers, }, }); if (!response.ok) { - throw new Error(`GitHub API ${response.status}: ${await response.text()}`); + throw new GitHubApiError(response.status, await response.text()); + } + if (response.status === 204) { + // SAFETY: The DELETE label endpoint returns no response body. + return undefined as T; } // SAFETY: Each caller pairs this response with the schema for the requested GitHub API endpoint. return (await response.json()) as T; @@ -44,7 +64,11 @@ interface PullRequestFile { } interface PullRequest { - head: { sha: string; repo?: { full_name?: string } | null }; + head: { + ref: string; + sha: string; + repo?: { full_name?: string } | null; + }; labels: { name: string }[]; changed_files: number; } @@ -58,7 +82,7 @@ if (headSha && pullRequest.head.sha !== headSha) { ); process.exit(0); } -const pageCount = Math.ceil(Math.min(pullRequest.changed_files, 300) / 100); +const pageCount = getPullRequestFilePageCount(pullRequest.changed_files); const filePages = await Promise.all( Array.from({ length: pageCount }, (_, index) => api( @@ -151,22 +175,31 @@ const getLabelColor = (name: string): string => { }; await Promise.all( [...ensureLabels].map(async (name) => { + const color = getLabelColor(name); + const description = + name === skipChangesetLabel + ? "Use only for justified changes that do not require a release." + : "Automatically managed pull request metadata"; try { await api(labelsEndpoint, { body: JSON.stringify({ - color: getLabelColor(name), - description: - name === skipChangesetLabel - ? "Use only for justified changes that do not require a release." - : "Automatically managed pull request metadata", + color, + description, name, }), method: "POST", }); } catch (error) { - if (!String(error).includes("422")) { + if ( + !(error instanceof GitHubApiError) || + !isDuplicateLabelError(error.status, error.responseBody) + ) { throw error; } + await api(`${labelsEndpoint}/${encodeURIComponent(name)}`, { + body: JSON.stringify({ color, description, name }), + method: "PATCH", + }); } }) ); @@ -197,9 +230,10 @@ if (labelsToRemove.length > 0) { } const skipChangeset = currentLabels.includes(skipChangesetLabel); -const requiredPackages = skipChangeset - ? new Set() - : getRequiredChangesets(files.map(({ filename }) => filename)); +const requiredPackages = + skipChangeset || isChangesetReleasePR(pullRequest.head.ref) + ? new Set() + : getRequiredChangesets(files.map(({ filename }) => filename)); const missingPackages = getMissingChangesets(requiredPackages, changesetNames); if (missingPackages.length > 0) { From 803652f7c88e4162547e15b8a766565de080a236 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 15:45:50 +1300 Subject: [PATCH 09/12] fix(package): reject root test and script files --- .../__tests__/package-tarball.test.ts | 23 +++++++++++++++++++ scripts/check-package-tarball.ts | 6 ++--- scripts/package-tarball-helpers.ts | 22 ++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 packages/opencode-usage-limits/__tests__/package-tarball.test.ts create mode 100644 scripts/package-tarball-helpers.ts diff --git a/packages/opencode-usage-limits/__tests__/package-tarball.test.ts b/packages/opencode-usage-limits/__tests__/package-tarball.test.ts new file mode 100644 index 0000000..658c0d4 --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/package-tarball.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isUnexpectedPackagePath } from "../../../scripts/package-tarball-helpers.ts"; + +describe("package tarball paths", () => { + it("rejects package source, test, and script directories", () => { + expect(isUnexpectedPackagePath("src/index.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("__tests__/plugin.test.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("scripts/test-package.ts")).toBeTruthy(); + }); + + it("rejects root-level test and build scripts", () => { + expect(isUnexpectedPackagePath("test-package.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("build.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("plugin.test.ts")).toBeTruthy(); + }); + + it("allows intended root package files and build output", () => { + expect(isUnexpectedPackagePath("README.md")).toBeFalsy(); + expect(isUnexpectedPackagePath("dist/index.mjs")).toBeFalsy(); + expect(isUnexpectedPackagePath("usage-limits.schema.json")).toBeFalsy(); + }); +}); diff --git a/scripts/check-package-tarball.ts b/scripts/check-package-tarball.ts index 9828e3e..11a3e2f 100644 --- a/scripts/check-package-tarball.ts +++ b/scripts/check-package-tarball.ts @@ -1,6 +1,8 @@ import { appendFileSync, readFileSync } from "node:fs"; import path from "node:path"; +import { isUnexpectedPackagePath } from "./package-tarball-helpers.ts"; + interface PackageManifest { name: string; files?: string[]; @@ -69,9 +71,7 @@ export const checkPackageTarball = async ( "examples/usage-limits.jsonc", ]; const missingFiles = requiredFiles.filter((file) => !packedPaths.has(file)); - const unexpectedFiles = [...packedPaths].filter((file) => - /^(?:src|__tests__|scripts|coverage|node_modules)\//u.test(file) - ); + const unexpectedFiles = [...packedPaths].filter(isUnexpectedPackagePath); if (missingFiles.length > 0 || unexpectedFiles.length > 0) { throw new Error( [ diff --git a/scripts/package-tarball-helpers.ts b/scripts/package-tarball-helpers.ts new file mode 100644 index 0000000..cdd2312 --- /dev/null +++ b/scripts/package-tarball-helpers.ts @@ -0,0 +1,22 @@ +const isUnexpectedRootFile = (filePath: string): boolean => { + if (filePath.includes("/")) { + return false; + } + return ( + ["build", "script", "scripts", "test"].some( + (prefix) => filePath === prefix || filePath.startsWith(`${prefix}.`) + ) || + filePath.startsWith("test-") || + /\.(?:test|spec)\.[^/.]+$/u.test(filePath) + ); +}; + +/** + * Identify package paths that should not be included in the published tarball. + * + * @param filePath - A path reported by `npm pack --dry-run`. + * @returns Whether the path belongs to an excluded source directory or is a root-level test/script file. + */ +export const isUnexpectedPackagePath = (filePath: string): boolean => + /^(?:src|__tests__|scripts|coverage|node_modules)\//u.test(filePath) || + isUnexpectedRootFile(filePath); From 2264687c007d7e672f8540f1b7a1010f88ab32f2 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 15:51:55 +1300 Subject: [PATCH 10/12] fix(ci): harden fork checks and release exemptions --- .github/workflows/workflow-security.yml | 11 ++++----- .../__tests__/pr-metadata.test.ts | 23 +++++++++++++++++-- scripts/pr-metadata-helpers.ts | 12 +++++++--- scripts/pr-metadata.ts | 7 +++++- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index 7458930..800043f 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -21,19 +21,18 @@ permissions: jobs: actionlint: runs-on: ubuntu-latest - permissions: - contents: read - checks: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: actionlint - uses: reviewdog/action-actionlint@a5524e1c19e62881d79c1f1b9b6f09f16356e281 # v1.65.2 + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2 with: - fail_level: error - reporter: ${{ github.event_name == 'pull_request' && 'github-pr-check' || 'github-check' }} + version: 1.7.11 + cache: false + shellcheck: false + pyflakes: false zizmor: runs-on: ubuntu-latest diff --git a/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts index 51d4df9..aee9902 100644 --- a/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts +++ b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts @@ -99,8 +99,27 @@ describe("PR metadata helpers", () => { }); it("exempts generated Changesets release pull requests", () => { - expect(isChangesetReleasePR("changeset-release/main")).toBeTruthy(); - expect(isChangesetReleasePR("feature/update-plugin")).toBeFalsy(); + expect( + isChangesetReleasePR( + "changeset-release/main", + "mynameistito/opencode-plugins", + "mynameistito/opencode-plugins" + ) + ).toBeTruthy(); + expect( + isChangesetReleasePR( + "changeset-release/main", + "fork/opencode-plugins", + "mynameistito/opencode-plugins" + ) + ).toBeFalsy(); + expect( + isChangesetReleasePR( + "feature/update-plugin", + "mynameistito/opencode-plugins", + "mynameistito/opencode-plugins" + ) + ).toBeFalsy(); }); it("recognizes only GitHub's duplicate-label validation response", () => { diff --git a/scripts/pr-metadata-helpers.ts b/scripts/pr-metadata-helpers.ts index 6f61030..3f354f2 100644 --- a/scripts/pr-metadata-helpers.ts +++ b/scripts/pr-metadata-helpers.ts @@ -91,10 +91,16 @@ export const getPullRequestFilePageCount = (changedFiles: number): number => { * Identify the release PR branch created by Changesets. * * @param headRef - The pull request head branch name. - * @returns Whether the branch uses Changesets' release PR prefix. + * @param headRepository - The repository containing the pull request head. + * @param baseRepository - The repository receiving the pull request. + * @returns Whether a same-repository branch uses Changesets' release PR prefix. */ -export const isChangesetReleasePR = (headRef: string): boolean => - headRef.startsWith("changeset-release/"); +export const isChangesetReleasePR = ( + headRef: string, + headRepository: string | null | undefined, + baseRepository: string +): boolean => + headRepository === baseRepository && headRef.startsWith("changeset-release/"); /** * Check whether a GitHub API validation response is specifically a duplicate label. diff --git a/scripts/pr-metadata.ts b/scripts/pr-metadata.ts index 6d2021c..d5107a8 100644 --- a/scripts/pr-metadata.ts +++ b/scripts/pr-metadata.ts @@ -231,7 +231,12 @@ if (labelsToRemove.length > 0) { const skipChangeset = currentLabels.includes(skipChangesetLabel); const requiredPackages = - skipChangeset || isChangesetReleasePR(pullRequest.head.ref) + skipChangeset || + isChangesetReleasePR( + pullRequest.head.ref, + pullRequest.head.repo?.full_name, + `${owner}/${repository}` + ) ? new Set() : getRequiredChangesets(files.map(({ filename }) => filename)); From 326c2a9c6135b91c24236734c135fb81d56ceea3 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 15:54:43 +1300 Subject: [PATCH 11/12] fix(package): reject test and build directories --- .../__tests__/package-tarball.test.ts | 7 +++++++ scripts/package-tarball-helpers.ts | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode-usage-limits/__tests__/package-tarball.test.ts b/packages/opencode-usage-limits/__tests__/package-tarball.test.ts index 658c0d4..513ebed 100644 --- a/packages/opencode-usage-limits/__tests__/package-tarball.test.ts +++ b/packages/opencode-usage-limits/__tests__/package-tarball.test.ts @@ -9,6 +9,13 @@ describe("package tarball paths", () => { expect(isUnexpectedPackagePath("scripts/test-package.ts")).toBeTruthy(); }); + it("rejects root-level test and build directories", () => { + expect(isUnexpectedPackagePath("test/fixtures/sample.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("tests/fixtures/sample.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("spec/plugin.spec.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("build/output.js")).toBeTruthy(); + }); + it("rejects root-level test and build scripts", () => { expect(isUnexpectedPackagePath("test-package.ts")).toBeTruthy(); expect(isUnexpectedPackagePath("build.ts")).toBeTruthy(); diff --git a/scripts/package-tarball-helpers.ts b/scripts/package-tarball-helpers.ts index cdd2312..d3a89ad 100644 --- a/scripts/package-tarball-helpers.ts +++ b/scripts/package-tarball-helpers.ts @@ -18,5 +18,6 @@ const isUnexpectedRootFile = (filePath: string): boolean => { * @returns Whether the path belongs to an excluded source directory or is a root-level test/script file. */ export const isUnexpectedPackagePath = (filePath: string): boolean => - /^(?:src|__tests__|scripts|coverage|node_modules)\//u.test(filePath) || - isUnexpectedRootFile(filePath); + /^(?:src|__tests__|scripts|coverage|node_modules|test|tests|spec|build)\//u.test( + filePath + ) || isUnexpectedRootFile(filePath); From 2b043f37c8710d886672d23722a863f96f26f5b5 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 15:58:30 +1300 Subject: [PATCH 12/12] fix(ci): enable shellcheck for workflows --- .github/workflows/workflow-security.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index 800043f..a4378d8 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -31,7 +31,7 @@ jobs: with: version: 1.7.11 cache: false - shellcheck: false + shellcheck: true pyflakes: false zizmor: