Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ jobs:
| `codex-home` | Directory to use as the Codex CLI home (config/cache). Uses the CLI default when empty. | `""` |
| `safety-strategy` | Controls how the action restricts Codex privileges. See [Safety strategy](#safety-strategy). | `drop-sudo` |
| `codex-user` | Username to run Codex as when `safety-strategy` is `unprivileged-user`. | `""` |
| `progress-comment` | When `true`, maintains a sticky progress comment on pull requests. Requires `pull-requests: write`. | `false` |
| `allow-users` | List of GitHub usernames who can trigger the action in addition to those who have write access to the repo. | `""` |
| `allow-bots` | Allow runs triggered by trusted GitHub bot accounts (`github-actions[bot]`) to bypass the write-access check. | `false` |
| `allow-bot-users` | List of GitHub bot usernames that can bypass the write-access check. `*` is not supported; list trusted bots explicitly. | `""` |
Expand Down
21 changes: 21 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ inputs:
description: "If `safety-strategy` is set to `unprivileged-user`, this specifies the UNIX username to run Codex as."
required: false
default: ""
progress-comment:
description: "When true, create and update a sticky progress comment on pull requests. Requires pull-requests: write permission."
required: false
default: "false"
allow-users:
description: "Comma-separated list of GitHub usernames who can run this action, or '*' to allow all users. Note users who have write access to the GitHub repo have access by default and do not need to be listed here."
required: false
Expand Down Expand Up @@ -343,6 +347,14 @@ runs:
fi
echo "Confirmed sudo privilege is disabled."

- name: Start pull request progress
if: ${{ inputs['progress-comment'] == 'true' }}
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
GITHUB_TOKEN: ${{ github.token }}
run: node "$ACTION_PATH/dist/main.js" update-pr-progress --status running

- name: Run codex exec
id: run_codex
if: ${{ inputs.prompt != '' || inputs['prompt-file'] != '' }}
Expand Down Expand Up @@ -380,3 +392,12 @@ runs:
--effort "$CODEX_EFFORT" \
--safety-strategy "$CODEX_SAFETY_STRATEGY" \
--codex-user "$CODEX_USER"

- name: Update pull request progress
if: ${{ always() && inputs['progress-comment'] == 'true' }}
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
GITHUB_TOKEN: ${{ github.token }}
CODEX_PROGRESS_STATUS: ${{ steps.run_codex.outcome == 'success' && 'completed' || 'failed' }}
run: node "$ACTION_PATH/dist/main.js" update-pr-progress --status "$CODEX_PROGRESS_STATUS"
134 changes: 101 additions & 33 deletions dist/main.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ensureActorHasWriteAccess } from "./checkActorPermissions";
import parseArgsStringToArgv from "string-argv";
import { writeProxyConfig } from "./writeProxyConfig";
import { checkOutput } from "./checkOutput";
import { ProgressStatus, updatePullRequestProgress } from "./updatePullRequestProgress";

export async function main() {
const program = new Command();
Expand All @@ -26,6 +27,17 @@ export async function main() {
.version(pkg.version)
.description("Multitool to support openai/codex-action.");

program
.command("update-pr-progress")
.description("Create or update the action's pull request progress comment")
.requiredOption("--status <status>", "One of running, completed, or failed")
.action(async (options: { status: string }) => {
if (!["running", "completed", "failed"].includes(options.status)) {
throw new Error("Progress status must be one of running, completed, or failed.");
}
await updatePullRequestProgress({ status: options.status as ProgressStatus });
});

program
.command("read-server-info")
.description("Read server info from the responses API proxy")
Expand Down
78 changes: 78 additions & 0 deletions src/updatePullRequestProgress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as core from "@actions/core";
import { Octokit } from "@octokit/rest";
import { readFile } from "node:fs/promises";

export type ProgressStatus = "running" | "completed" | "failed";

const MARKER = "<!-- codex-action-progress -->";

export async function updatePullRequestProgress({
status,
token = process.env.GITHUB_TOKEN ?? "",
repository = process.env.GITHUB_REPOSITORY ?? "",
eventPath = process.env.GITHUB_EVENT_PATH ?? "",
octokit,
}: {
status: ProgressStatus;
token?: string;
repository?: string;
eventPath?: string;
octokit?: Octokit;
}): Promise<void> {
if (!eventPath) {
core.info("Skipping progress comment: this is not a pull request event.");
return;
}
if (!repository.includes("/")) {
throw new Error("GITHUB_REPOSITORY must be in the format owner/repo.");
}
if (!token) {
throw new Error("A GitHub token is required to post a progress comment.");
}

const event = JSON.parse(await readFile(eventPath, "utf8")) as {
pull_request?: { number?: number };
};
const issueNumber = event.pull_request?.number;
if (!issueNumber) {
core.info("Skipping progress comment: this is not a pull request event.");
return;
}

const [owner, repo] = repository.split("/", 2);
const client = octokit ?? new Octokit({ auth: token });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor GITHUB_API_URL for progress comments

On GitHub Enterprise Server, this client defaults to https://api.github.com instead of the server URL provided through GITHUB_API_URL. The repository's existing permissions client explicitly forwards that environment variable for the same reason, but an opted-in progress step will instead send the enterprise token to GitHub.com and fail before Codex runs. Construct this client with the configured baseUrl as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reuse the retry-enabled Octokit client

When GitHub returns a transient 5xx response or closes a connection during comment listing, creation, or update, this plain Octokit client does not apply the retry plugin already used by checkActorPermissions.ts. A transient failure in the start step prevents Codex from running, while one in the final step turns an otherwise successful Codex execution into a failed action. Use the repository's retry-enabled Octokit construction for these API calls.

Useful? React with 👍 / 👎.

const body = `${MARKER}\n${progressMessage(status)}`;
const comments = await client.paginate(client.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
const existing = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" &&
comment.body?.includes(MARKER)
Comment on lines +51 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent stale runs from overwriting current progress

When two action runs overlap for the same pull request—for example, a second commit arrives while the first Codex run is active—both runs select the same bot-authored marker comment. The older run can therefore write completed or failed after the newer run has written running, making the sticky comment falsely report that no work remains. Scope ownership to a run identifier or make final updates conditional on the comment still belonging to that run.

Useful? React with 👍 / 👎.

);

if (existing) {
await client.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await client.issues.createComment({ owner, repo, issue_number: issueNumber, body });
}
}

function progressMessage(status: ProgressStatus): string {
switch (status) {
case "running":
return "🤖 Codex is working on this pull request.";
case "completed":
return "✅ Codex completed its work on this pull request.";
case "failed":
return "❌ Codex did not complete successfully on this pull request.";
}
}
11 changes: 11 additions & 0 deletions test/actionHardening.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,17 @@ test("Codex action and its descendants replace inherited Node options", () => {
);
});

test("opt-in pull request progress comments bracket Codex execution", () => {
const start = actionStep("Start pull request progress");
const finish = actionStep("Update pull request progress");

assert.match(start, /inputs\['progress-comment'\] == 'true'/);
assert.match(start, /update-pr-progress --status running/);
assert.match(finish, /always\(\) && inputs\['progress-comment'\] == 'true'/);
assert.match(finish, /steps\.run_codex\.outcome == 'success' && 'completed' \|\| 'failed'/);
assert.match(finish, /update-pr-progress --status "\$CODEX_PROGRESS_STATUS"/);
});

test(
"nested Codex Node launchers inherit SIGUSR1 protection",
{ skip: process.platform === "win32", timeout: 5_000 },
Expand Down
Loading