ci: split issue dedupe into find and post jobs
What changed, and why it matters
This commit is a hardening and refactoring change to a GitHub Actions workflow that detects duplicate issues. It splits the workflow into two jobs: one that only reads issue data and uses an AI model to find duplicates, and a second that only posts comments. The change reduces the privileges available to the AI step, pins the action to a specific version, disables credential persistence, and adds concurrency controls. There is no indication of a security vulnerability being fixed or introduced; it is a defensive improvement.
No action required. Review the workflow change as a normal hardening/refactoring PR. If desired, verify that `scripts/comment-on-duplicates.sh` performs the validation described in the comments.
Security signals we found
Principle of least privilege: AI/model job no longer holds issues:write or id-token:write
Action dependency pinned to full commit SHA instead of mutable tag
persist-credentials: false set on checkout steps
Concurrency group added to prevent race-condition double-posting
Input validation: post-comment job extracts only numeric issue IDs and delegates to comment-on-duplicates.sh for re-validation
Workflow is purely CI/infrastructure; no application code changed
Evidence from the diff
The commit refactors .github/workflows/claude-dedupe-issues.yml to separate duplicate detection from comment posting. The find-duplicates job now runs with read-only permissions (contents: read, issues: read), removes id-token: write, pins anthropics/claude-code-action to a full commit SHA, sets persist-credentials: false, restricts allowed tools to read-only gh subcommands plus Write, and records candidate issue numbers to an artifact. The post-comment job, which has issues: write, downloads the artifact and invokes scripts/comment-on-duplicates.sh to validate and post. Concurrency controls are added to prevent double-posting. No vulnerability is described or evident in the diff.
Changed components
.github/workflows/claude-dedupe-issues.ymlInspect captured patch +148 / −7
diff --git a/.github/workflows/claude-dedupe-issues.yml b/.github/workflows/claude-dedupe-issues.yml
index 5f73298..93c052e 100644
--- a/.github/workflows/claude-dedupe-issues.yml
+++ b/.github/workflows/claude-dedupe-issues.yml
@@ -10,26 +10,167 @@ on:
required: true
type: string
+# Default to read-only. The find-duplicates job reads untrusted issue text with
+# the model, so it must not hold a write token; post-comment takes issues: write
+# but runs no model and only shells out to scripts/comment-on-duplicates.sh,
+# which re-validates every issue number it is handed.
+permissions:
+ contents: read
+
+# Serialize runs for the same issue so an `issues: opened` event and a
+# workflow_dispatch for the same number can't both read "no prior comment" and
+# double-post. Mirrors pr-severity.yml.
+concurrency:
+ group: claude-dedupe-${{ github.event.issue.number || inputs.issue_number }}
+ cancel-in-progress: true
+
jobs:
- claude-dedupe-issues:
+ find-duplicates:
runs-on: ubuntu-latest
timeout-minutes: 10
+ # Read-only: the model inspects the issue and searches for duplicates, then
+ # records the candidate issue numbers to a file.
permissions:
contents: read
- issues: write
- id-token: write
+ issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ with:
+ persist-credentials: false
- - name: Run Claude Code slash command
- uses: anthropics/claude-code-action@v1
+ - name: Find duplicate issues with Claude
+ # Pinned to a full commit SHA rather than the mutable @v1 tag: this step
+ # feeds untrusted issue text to the model with CLAUDE_CODE_OAUTH_TOKEN in
+ # process, so a repointed tag would run attacker-controlled action code
+ # with that secret present. Bump deliberately when updating.
+ uses: anthropics/claude-code-action@ba0aafd4308cbba7165f9f2cdb0cfbed5a3c99ce # v1
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # Accept any issue author: this job holds only read scope and merely
+ # records candidate issue numbers to a file; the comment is posted by
+ # a separate, model-free job. "*" is safe ONLY while this job stays
+ # read-only. Before granting this job a write token or a mutating tool
+ # (a write-capable gh subcommand, a Bash mutation), replace "*" with
+ # an explicit allowlist — otherwise any fork author's issue text would
+ # steer a privileged model.
allowed_non_write_users: "*"
model: claude-haiku-4-5-20251001
- prompt: "/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}"
- claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # Read-only gh tools plus Write to record the result. No comment or
+ # edit tools, and no access to the duplicate-comment script.
+ claude_args: >-
+ --allowedTools
+ "Bash(gh issue view:*)"
+ "Bash(gh search:*)"
+ "Bash(gh issue list:*)"
+ "Write"
+
+ prompt: |
+ Find up to 3 likely duplicate issues for issue
+ #${{ github.event.issue.number || inputs.issue_number }} in the
+ ${{ github.repository }} repository. Follow these steps precisely:
+
+ 1. View the issue and check whether it (a) is closed, (b) does not
+ need deduping (e.g. broad product feedback without a specific
+ solution, or positive feedback), or (c) already has a duplicates
+ comment. If any of these hold, write an empty `duplicates.txt`
+ (create the file with no content) and stop.
+
+ 2. Summarize the issue.
+
+ 3. Search GitHub for duplicates of this issue using several diverse
+ keyword searches and search approaches, based on the summary.
+
+ 4. Filter out false positives that are likely not actually
+ duplicates of the original issue. If no plausible duplicates
+ remain, write an empty `duplicates.txt` and stop.
+
+ 5. Otherwise, write the chosen duplicate issue numbers to a file
+ named `duplicates.txt` in the current working directory: digits
+ only, one issue number per line, at most 3 lines. Do not include
+ `#`, URLs, or any other text.
+
+ Notes:
+ - Use `gh` to interact with GitHub, not web fetch.
+ - Do NOT use any tools beyond `gh issue view`, `gh search`,
+ `gh issue list`, and `Write`. You do NOT post comments; a separate
+ step does that from the file you write.
+ - Make a todo list first.
+
+ - name: Upload duplicate candidates
+ uses: actions/upload-artifact@v4
+ with:
+ name: dedupe-result
+ path: duplicates.txt
+ if-no-files-found: warn
+ retention-days: 1
+
+ post-comment:
+ runs-on: ubuntu-latest
+ needs: find-duplicates
+ timeout-minutes: 5
+ # Write scope lives here, in a job that runs no model. The base issue number
+ # comes from the trusted event payload, and comment-on-duplicates.sh
+ # re-validates every candidate issue number before posting.
+ permissions:
+ contents: read
+ issues: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Download duplicate candidates
+ uses: actions/download-artifact@v4
+ # find-duplicates uploads with if-no-files-found: warn, so when the
+ # model writes no file at all (timeout, refusal) no artifact exists and
+ # download-artifact would otherwise hard-fail the job. Tolerate a
+ # missing artifact so the no-op guard in the next step is reachable.
+ continue-on-error: true
+ with:
+ name: dedupe-result
+ path: result
+
+ - name: Post duplicate comment
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ BASE_ISSUE: ${{ github.event.issue.number || inputs.issue_number }}
+ run: |
+ set -euo pipefail
+
+ # Distinguish a genuine "no duplicates" verdict from a find-duplicates
+ # run that produced no artifact at all (model crash/timeout, or a
+ # tolerated missing-artifact download). The latter gets a warning so a
+ # broken run doesn't read as a healthy no-op, mirroring the pr-severity
+ # apply step; the present-but-empty case stays a silent no-op.
+ if [[ ! -f result/duplicates.txt ]]; then
+ echo "::warning::dedupe find-duplicates produced no result; nothing posted."
+ exit 0
+ fi
+ if [[ ! -s result/duplicates.txt ]]; then
+ echo "No duplicate candidates; nothing to post."
+ exit 0
+ fi
+
+ # Extract up to 3 purely-numeric issue ids. comment-on-duplicates.sh
+ # re-validates these and the base issue (numeric, existing, at most 3)
+ # before posting.
+ mapfile -t DUPS < <(grep -oE '^[0-9]+$' result/duplicates.txt | head -n 3)
+
+ if [[ ${#DUPS[@]} -eq 0 ]]; then
+ echo "No valid numeric duplicate ids; nothing to post."
+ exit 0
+ fi
+
+ ./scripts/comment-on-duplicates.sh \
+ --base-issue "$BASE_ISSUE" \
+ --potential-duplicates "${DUPS[@]}"
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.