Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/workflows/codex-automerge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
name: Codex Safe Automerge

on:
pull_request_target:
types:
- opened
- synchronize
- reopened
- ready_for_review
- labeled

permissions:
contents: write
pull-requests: write
checks: read
statuses: read

jobs:
codex-automerge:
runs-on: ubuntu-latest
steps:
- name: Enforce Codex automerge safety policy
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const repo = context.payload.repository;

if (!pr) {
core.info('No pull_request payload in context.');
return;
}

const labels = Array.from(pr.labels || []).map((label) => label.name);
const hasAutomergeLabel = labels.includes('automerge');
const isNotDraft = pr.draft === false;
const isCodexAuthor = pr.user && pr.user.login === 'chatgpt-codex-connector';
const isCodexBranch = typeof pr.head?.ref === 'string' && pr.head.ref.startsWith('codex/');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict codex branch checks to trusted repositories

The automerge gate treats any PR whose head ref starts with codex/ as trusted (isCodexBranch), but it never verifies that the head branch comes from this repository (for example via pr.head.repo.full_name === repo.full_name). In pull_request_target with write permissions, a fork can use a codex/* branch name and satisfy this check; if a maintainer adds automerge, this workflow can merge untrusted fork code under the elevated token.

Useful? React with 👍 / 👎.


if (!hasAutomergeLabel || !isNotDraft || !(isCodexAuthor || isCodexBranch)) {
core.info(`Skipping automerge. hasAutomergeLabel=${hasAutomergeLabel}, isNotDraft=${isNotDraft}, isCodexAuthor=${isCodexAuthor}, isCodexBranch=${isCodexBranch}`);
return;
}

let minimatchFn = null;
try {
const minimatch = require('minimatch');
minimatchFn = (path, pattern) => minimatch.minimatch(path, pattern, { dot: true });
} catch (error) {
core.warning(`minimatch not available: ${error.message}`);
}

const denyPatterns = [
'**/*.sql',
'**/.github/workflows/**',
'**/*wrangler*.{json,toml}',
'**/src/**/stripe*',
'**/src/**/payments*',
'**/src/**/auth*',
'**/src/**/sessions*',
Comment on lines +57 to +60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match sensitive directory contents in denylist globs

The denylist patterns for stripe*, payments*, auth*, and sessions* only match final path segments, so edits inside those directories (for example src/auth/login.js) are not denied and can still be auto-merged. The fallback regexes on lines 72–75 have the same end-of-path limitation, so both matcher paths miss nested files in these sensitive areas.

Useful? React with 👍 / 👎.

'**/src/**/worker/**/env*',
'**/routing/**',
'**/redirect',
'**/canonical',
'**/migrations/**',
];

const fallbackDeny = (path) => {
if (!path) return false;
return (
/(^|\/)\.github\/workflows\//.test(path) ||
/(^|\/)src\/(.+\/)?stripe[^/]*$/.test(path) ||
/(^|\/)src\/(.+\/)?payments[^/]*$/.test(path) ||
/(^|\/)src\/(.+\/)?auth[^/]*$/.test(path) ||
/(^|\/)src\/(.+\/)?sessions[^/]*$/.test(path) ||
/(^|\/)src\/(.+\/)?worker\/(.+\/)?env[^/]*$/.test(path) ||
/(^|\/)routing(\/|$)/.test(path) ||
/(^|\/)redirect(\/|$)/.test(path) ||
/(^|\/)canonical(\/|$)/.test(path) ||
/(^|\/)migrations(\/|$)/.test(path) ||
/wrangler[^/]*\.(json|toml)$/.test(path) ||
/\.sql$/.test(path)
);
};

const isDenied = (path) => {
if (minimatchFn) {
return denyPatterns.some((pattern) => minimatchFn(path, pattern));
}
return fallbackDeny(path);
};

const files = await github.paginate(github.rest.pulls.listFiles, {
owner: repo.owner.login,
repo: repo.name,
pull_number: pr.number,
per_page: 100,
});

const changedFiles = files.map((file) => file.filename || '');
const denyMatches = [...new Set(changedFiles.filter(isDenied))];
if (denyMatches.length > 0) {
core.info(`Denylist match. Not enabling automerge. Blocked files: ${denyMatches.join(', ')}`);
return;
}

try {
await github.graphql(
`mutation($pullRequestId: ID!) {
enablePullRequestAutoMerge(input: {
pullRequestId: $pullRequestId
mergeMethod: SQUASH
clientMutationId: "codex-automerge"
}) { clientMutationId }
}`,
{
pullRequestId: pr.node_id,
}
);
core.info('Enabled PR auto-merge (squash).');
return;
} catch (error) {
core.warning(`Auto-merge enable failed; attempting fallback merge path. ${error.message}`);
}

const current = await github.rest.pulls.get({
owner: repo.owner.login,
repo: repo.name,
pull_number: pr.number,
});

if (current.data.mergeable === true && current.data.mergeable_state === 'clean') {
const merged = await github.rest.pulls.merge({
owner: repo.owner.login,
repo: repo.name,
pull_number: pr.number,
merge_method: 'squash',
});
core.info(`Fallback merge API result: merged=${merged.data.merged}, sha=${merged.data.sha || 'n/a'}`);
} else {
core.info(`Fallback merge skipped: mergeable=${current.data.mergeable}, state=${current.data.mergeable_state}`);
}
32 changes: 32 additions & 0 deletions docs/codex-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Codex Workflow

## Labels

- `codex-ready`: human-reviewed or accepted by operator (optional)
- `automerge`: safe to automate merge for this PR

## Safety denylist (never automerged)

Do not merge with `automerge` if PR files match any of these patterns:

- `**/*wrangler*.{json,toml}`
- `**/.github/workflows/**`
- `**/src/**/stripe*`
- `**/src/**/payments*`
- `**/src/**/auth*`
- `**/src/**/sessions*`
- `**/src/**/worker/**/env*`
- `**/routing/**`
- `**/redirect`
- `**/canonical`
- `**/migrations/**`
- `**/*.sql`

## PR title format

Use `[codex]` as a prefix for Codex PR titles so they are easy to query.

Examples:

- `[codex] add safe automerge workflow`
- `[codex] tighten search snippet`