Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
81 changes: 81 additions & 0 deletions .github/workflows/issue-triage-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Preview issue triage prompt changes without modifying issues.
name: "Issue triage preview"

on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- ".github/workflows/issue-triage-preview.yml"
- ".github/workflows/issue-triage.yml"
- ".github/workflows/reproduce-bug.yml"
- "agents/codex/**"
- "agents/prompts/reproduce-bug.md"
- "agents/prompts/triage-issue.md"
- "agents/schemas/issue-triage-bug.json"
- "agents/schemas/issue-triage.json"
- "agents/scripts/issue-triage-preview.py"
- "agents/scripts/issue-triage-preview.py.lock"

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
collect:
if: github.repository == 'astral-sh/uv' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
outputs:
issues: ${{ steps.issues.outputs.issues }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: "Install uv"
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.28"
- name: "Find recent issues"
id: issues
run: uv run --locked --no-project --script agents/scripts/issue-triage-preview.py collect
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

triage:
name: "Triage issue ${{ matrix.issue }}"
needs: collect
permissions:
contents: read
issues: read
strategy:
fail-fast: false
matrix:
issue: ${{ fromJSON(needs.collect.outputs.issues) }}
uses: ./.github/workflows/issue-triage.yml # zizmor: ignore[secrets-inherit] Required for environment secrets in reusable workflows.
with:
issue: ${{ matrix.issue }}
secrets: inherit
Comment on lines +52 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Do not run PR-controlled workflows with environment secrets

This local reusable workflow is resolved from the pull request revision, and its triage job enters the unprotected automations environment and receives OPENAI_API_KEY. Consequently, anyone able to push a same-repository branch can modify the called workflow or its Codex configuration and expose or misuse that credential before review. The same-repository check only excludes forks; it does not establish that the executed revision is trusted. Keep this preview secretless, or move the credentialed stage to protected default-branch workflow code behind an exact-SHA-bound approval and environment protection.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this is legitimate?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

same repo branch == a maintainer's branch, so, likely not?

Comment on lines +52 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Prevent PR-controlled workflows from receiving automation secrets

This pull_request workflow invokes a repository-local reusable workflow from the pull request revision and passes secrets: inherit; that called workflow enters the automations environment and consumes OPENAI_API_KEY. A same-repository branch writer can therefore change any listed prompt/schema to trigger the run while also changing .github/workflows/issue-triage.yml, causing unreviewed code to receive or exfiltrate the environment credential before merge. The head-repository check blocks forks but does not establish that the branch author is entitled to environment secrets. Keep the PR stage secretless, or move API-backed processing behind an explicitly authorized stage that runs trusted default-branch code and treats PR content only as data.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should probably add some notes to the threat-model about this.


report:
needs: triage
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: "Install uv"
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.28"
- name: "Create or update preview comment"
run: uv run --locked --no-project --script agents/scripts/issue-triage-preview.py comment --pull-request "$PULL_REQUEST_NUMBER"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
15 changes: 13 additions & 2 deletions .github/workflows/issue-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ on:
description: "An issue URL or number"
required: true
type: string
workflow_call:
inputs:
issue:
description: "An issue URL or number"
required: true
type: string

permissions: {}

Expand Down Expand Up @@ -62,7 +68,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: codex-thread-issue-triage-${{ github.run_id }}-${{ github.run_attempt }}
name: codex-thread-issue-triage-${{ inputs.issue || github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
path: agents/codex/sessions
if-no-files-found: warn

Expand All @@ -85,6 +91,11 @@ jobs:
permissions: {}
steps:
- name: "Write triage summary"
run: printf '```json\n%s\n```\n' "$TRIAGE_RESULT" >> "$GITHUB_STEP_SUMMARY"
run: |
{
printf '%s\n' '```json'
printf '%s\n' "$TRIAGE_RESULT" | jq .
printf '%s\n' '```'
} >> "$GITHUB_STEP_SUMMARY"
env:
TRIAGE_RESULT: ${{ needs.triage.outputs.result }}
9 changes: 7 additions & 2 deletions .github/workflows/reproduce-bug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: codex-thread-reproduce-bug-${{ github.run_id }}-${{ github.run_attempt }}
name: codex-thread-reproduce-bug-${{ inputs.issue }}-${{ github.run_id }}-${{ github.run_attempt }}
path: agents/codex/sessions
if-no-files-found: warn

Expand All @@ -101,6 +101,11 @@ jobs:
permissions: {}
steps:
- name: "Write reproduction summary"
run: printf '```json\n%s\n```\n' "$REPRODUCTION_RESULT" >> "$GITHUB_STEP_SUMMARY"
run: |
{
printf '%s\n' '```json'
printf '%s\n' "$REPRODUCTION_RESULT" | jq .
printf '%s\n' '```'
} >> "$GITHUB_STEP_SUMMARY"
env:
REPRODUCTION_RESULT: ${{ needs.reproduce.outputs.result }}
4 changes: 2 additions & 2 deletions agents/prompts/triage-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,5 @@ maintainer action requested.

Set `summary` to a concise overview of the closest items, or state that none were found.

Clearly distinguish source-backed findings from hypotheses. Do not draft a public reply or claim a
root cause that you have not confirmed from the repository.
Clearly distinguish source-backed findings from hypotheses in the summary and item reasons. Do not
draft a public reply or claim a root cause that you have not confirmed from the repository.
115 changes: 115 additions & 0 deletions agents/scripts/issue-triage-preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx"]
# ///

"""Collect recent issues and update the issue-triage preview comment."""

import argparse
import json
import os
from pathlib import Path
from typing import Any

import httpx

MARKER = "<!-- issue-triage-preview -->"


def request(
method: str,
path: str,
*,
query: dict[str, str | int] | None = None,
body: dict[str, str] | None = None,
) -> Any:
response = httpx.request(
method,
f"{os.environ['GITHUB_API_URL']}/{path}",
params=query,
json=body,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {os.environ['GH_TOKEN']}",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=30,
)
response.raise_for_status()
return response.json()


def collect(limit: int) -> None:
repository = os.environ["GITHUB_REPOSITORY"]
result = request(
"GET",
"search/issues",
query={
"q": f"repo:{repository} is:issue",
"sort": "created",
"order": "desc",
"per_page": limit,
},
)
issues = [str(issue["number"]) for issue in result["items"]]

with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
output.write(f"issues={json.dumps(issues, separators=(',', ':'))}\n")


def comment(pull_request: int) -> None:
repository = os.environ["GITHUB_REPOSITORY"]
comments_path = f"repos/{repository}/issues/{pull_request}/comments"
comment_id = None
page = 1

while True:
comments = request("GET", comments_path, query={"per_page": 100, "page": page})
for issue_comment in comments:
if (
issue_comment["user"]["login"] == "github-actions[bot]"
and MARKER in issue_comment["body"]
):
comment_id = issue_comment["id"]
if len(comments) < 100:
break
page += 1

run_url = (
f"{os.environ['GITHUB_SERVER_URL']}/{repository}/actions/runs/"
f"{os.environ['GITHUB_RUN_ID']}"
)
body = {
"body": (
f"{MARKER}\n\n"
"## Issue triage preview\n\n"
"The issue triage preview is available in the "
f"[workflow run summary]({run_url})."
)
}

if comment_id is None:
request("POST", comments_path, body=body)
else:
request("PATCH", f"repos/{repository}/issues/comments/{comment_id}", body=body)


def main() -> None:
parser = argparse.ArgumentParser()
commands = parser.add_subparsers(dest="command", required=True)

collect_parser = commands.add_parser("collect")
collect_parser.add_argument("--limit", type=int, default=5)

comment_parser = commands.add_parser("comment")
comment_parser.add_argument("--pull-request", type=int, required=True)

args = parser.parse_args()
if args.command == "collect":
collect(args.limit)
else:
comment(args.pull_request)


if __name__ == "__main__":
main()
83 changes: 83 additions & 0 deletions agents/scripts/issue-triage-preview.py.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
version = 1
revision = 3
requires-python = ">=3.12"

[manifest]
requirements = [{ name = "httpx" }]

[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]

[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]

[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]

[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]

[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]

[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]

[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]