Skip to content

ci: split the Claude PR-severity and issue-dedupe workflows into two jobs#10955

Open
Roasbeef wants to merge 2 commits into
masterfrom
ci/workflow-job-split
Open

ci: split the Claude PR-severity and issue-dedupe workflows into two jobs#10955
Roasbeef wants to merge 2 commits into
masterfrom
ci/workflow-job-split

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Jul 7, 2026

Copy link
Copy Markdown
Member

In this PR, we clean up the two Claude-driven CI workflows by splitting
each one into a pair of jobs along a clear seam: the part that figures out
what to do, and the part that actually does it. Both workflows had grown
into a single job that mixed the model-driven analysis with the mechanical
GitHub mutation, and pulling those apart makes each job do one thing and
makes the whole flow easier to follow.

PR severity

The pr-severity workflow now has a classify job and an apply job. The
classify job inspects the PR and writes its verdict (the severity level,
whether a comment is warranted, and the comment body) out to a few files.
The apply job reads those files and does the label/comment bookkeeping:
strip any stale severity-* label, set the new one, and post the comment.

The severity the classifier picks is checked against the known set
(critical/high/medium/low) before we touch a label, so a garbled
verdict just no-ops instead of creating junk labels. The comment goes out
via --body-file so the body is always handled as plain data rather than
being spliced into a shell command.

Issue dedupe

The claude-dedupe-issues workflow gets the same shape: a find-duplicates
job and a post-comment job. The find job records the candidate issue
numbers to a file, and the post job hands them to
scripts/comment-on-duplicates.sh, which already validates each number and
renders the comment from a fixed template. This mostly just mirrors how the
script is already factored, so the post job ends up a thin wrapper over it.

Misc

While in here we also drop the unused id-token permission from the dedupe
workflow and turn off checkout credential persistence in both, since none
of these jobs need a git credential sitting on disk.

See each commit message for the per-workflow details.

@github-actions github-actions Bot added the severity-low Best-effort review label Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

🟢 PR Severity: LOW

Auto-classified | 2 files | 317 lines changed

🟢 Low (2 files)
  • .github/workflows/claude-dedupe-issues.yml - CI/CD workflow configuration (.github/*)
  • .github/workflows/pr-severity.yml - CI/CD workflow configuration (.github/*)

Analysis

This PR only touches files under .github/workflows/, which fall under the LOW severity tier (CI/CD configuration). There are no changes to any Go source packages, so no critical/high/medium categories apply.

No severity bump applies: only 2 files changed (well under the 20-file threshold) and 317 lines changed (well under the 500-line threshold), and no critical packages are touched.

No override label or prior classification was found for this PR.


To override, add a severity-override-{critical,high,medium,low} label.

@Roasbeef Roasbeef requested review from a team, bhandras and ellemouton and removed request for a team July 7, 2026 22:38
@Roasbeef

Roasbeef commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

/gateway review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 7, 2026

Copy link
Copy Markdown

✅ Review posted: #10955 (review)

6 finding(s); 6 inline, 0 in body.

🔁 Need a re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR splits both Claude-driven CI workflows into a read-only, model-facing job that records a verdict to files and a model-free, write-scoped job that consumes those files and mutates GitHub. The privilege-separation intent is sound and the mechanics are mostly right — the severity.txt allowlist, --body-file instead of shell interpolation, persist-credentials: false, and dropping id-token are all correct hardening.

Two issues dominate. First, both workflows pass data between jobs via upload-artifact/download-artifact with if-no-files-found: warn, which makes the downstream "no verdict produced" guards dead code: if the model writes no file at all, download-artifact@v4 errors before the guard runs. Second, the separation leaks through comment.md — the read-only job runs the model against fully attacker-controlled fork-PR input and the privileged job posts that content verbatim with no validation, so a prompt injection can dictate the bot's comment body. The remaining findings are smaller consistency and defense-in-depth gaps.

Findings: 🔴 0 Blocker · 🟠 3 Major · 🟡 3 Minor · 🔵 0 Nit

persist-credentials: false

- name: Download duplicate candidates
uses: actions/download-artifact@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Major F1: download-artifact hard-fails when the model writes no file, defeating the no-op guard

find-duplicates uploads duplicates.txt with if-no-files-found: warn (line 95), so when the model produces no file at all — a routine LLM failure mode (timeout, refusal, not following the "write an empty duplicates.txt and stop" instruction) — no dedupe-result artifact is created. actions/download-artifact@v4 here then errors with "Unable to find any artifacts" and fails the job before the downstream if [[ ! -s result/duplicates.txt ]]; then ... exit 0 guard (line 129) can run. That guard was written to handle exactly the missing/empty case but is unreachable for the missing-file case. The empty-file case does work (an empty file still creates the artifact). Make the download tolerant of a missing artifact (continue-on-error: true plus a preceding mkdir -p result, or guarantee the model job always writes the file) so the existing guard is reachable.

pull-requests: write
steps:
- name: Download classification result
uses: actions/download-artifact@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Major F2: download-artifact hard-fails when classify produces no severity.txt

Same artifact-contract cliff as F1. classify uploads with if-no-files-found: warn (line 231), so if the model writes none of severity.txt/should_comment.txt/comment.md (model timeout, refusal, or a future maintainer tightening --allowedTools and dropping Write), no pr-severity-result artifact exists and download-artifact@v4 fails the apply job. The intended fail-safe if [[ ! -f result/severity.txt ]]; then ... exit 0 (line 259) never executes — the job errors instead of degrading quietly. Give the download step tolerance for a missing artifact so the -f guard actually handles the no-verdict path.

Comment thread .github/workflows/pr-severity.yml Outdated
# the body is treated purely as data.
if [[ "$SHOULD_COMMENT" == "true" && -f result/comment.md ]]; then
gh pr comment "$PR_NUMBER" --repo "$REPO" \
--body-file result/comment.md

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Major F3: Unvalidated comment.md lets prompt injection dictate the bot's PR comment

The privilege split leaks through comment.md. The read-only classify job runs the model against fully attacker-controlled input — gh pr view on a fork PR reads the attacker's title/body — and the privileged apply job (holding pull-requests: write) validates severity.txt against critical|high|medium|low and should_comment.txt against true, but performs zero validation on the comment body: gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file result/comment.md. A prompt injection in the PR content can therefore make the model write arbitrary bytes into comment.md and true into should_comment.txt, and an untrusted fork author gets the trusted project bot to post a comment with attacker-chosen content — fake maintainer instructions, phishing links, @mention notification spam — under the bot's identity. --body-file prevents shell/command injection (content is sent as a literal body), which is why this is not a blocker, but the read-only job fully controls a write-scoped action's payload, defeating the stated separation. Constrain the comment: have the model emit only structured fields the apply job renders from a fixed template, or validate/sanitize comment.md (length cap, strip @-mentions and HTML, reject content not matching the expected shape) before posting.

fi

./scripts/comment-on-duplicates.sh \
--base-issue "$BASE_ISSUE" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F4: BASE_ISSUE passed to the script without the numeric validation applied to DUPS

The duplicate ids are filtered through grep -oE '^[0-9]+$', but BASE_ISSUE (${{ github.event.issue.number || inputs.issue_number }}) is passed straight to comment-on-duplicates.sh --base-issue "$BASE_ISSUE" with no validation. For the workflow_dispatch path inputs.issue_number is a free-form string, so a non-numeric dispatch value flows unchecked into the script. It is quoted, so this is not shell injection, but the inconsistency means a bad base issue fails deep in the script rather than being rejected up front. Validate BASE_ISSUE against ^[0-9]+$ alongside the DUPS check.


./scripts/comment-on-duplicates.sh \
--base-issue "$BASE_ISSUE" \
--potential-duplicates "${DUPS[@]}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F5: Dedupe model output selects which issues get cross-referenced (notification spam)

The post-comment job correctly filters duplicates.txt to digits-only and passes the ids as array args, so there is no argument injection. However the selection of issue numbers is model output and thus prompt-injectable: an issue author can inject instructions steering the dedupe bot to reference arbitrary issue numbers, and each reference the bot posts creates a GitHub cross-link that notifies the target issues' participants. This is a low-amplitude notification/cross-reference spam vector (bounded to 3 per opened issue), not a fund or data risk. Defense-in-depth: confirm each candidate is a genuine search hit before referencing it, rather than trusting the model's chosen numbers.

# Allow any user: this job only reads PR metadata via the API with a
# read-only token and writes its verdict to a file. The privileged
# label/comment step runs separately without the model.
allowed_non_write_users: "*"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F6: *allowed_non_write_users: *

allowed_non_write_users: "*" (present in both workflows) disables claude-code-action's author-association gate, feeding untrusted fork/first-time-contributor content to the model unconditionally. For a classifier that must run on every PR this is intentional and is bounded today by the read-only github_token scope and the tool allowlist. But under pull_request_target it is a "trust everyone" default sitting one edit away from danger: a future maintainer who adds a mutating Bash(...) tool or raises the classify job's permissions grants arbitrary fork authors that power without touching this line. Prefer an explicit allowlist, or at minimum a comment marking "*" as safe only while the model job stays read-only.

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 7, 2026

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

@Roasbeef Roasbeef force-pushed the ci/workflow-job-split branch from c60c990 to 3565a16 Compare July 8, 2026 00:06
@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10955 (review)

Prior findings: 4 addressed, 2 still unresolved, 0 withdrawn.
New findings: 5.
Total inline: 7; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid pass on the prior review: F1 and F2 (the download-artifact cliffs) are fixed with continue-on-error: true, F4 (base-issue validation) and F6 (the "*" safety comment) are fully addressed, and F3's core redesign — model writes structured fields, a deterministic template renderer posts via --body-file — is exactly the shape requested and closes the shell/command-injection and raw-markdown-control vectors.

Two priors are only partially resolved. F3's new clean() sanitizer defangs @ and escapes &<> but leaves # cross-references and markdown link/image syntax live, and the files.tsv reason field can still smuggle a newline — so an attacker-steered classification can still spam notifications and embed tracking/phishing links in a bot-authored comment, just less freely than before. F5's candidate-verification loop drops hallucinated issue numbers but a prompt injection can still steer the model to real, unrelated issue numbers that pass verification.

The redesign also introduces one genuinely-new blocker-adjacent defect (F7): the truncating clean() pipeline can abort the apply job under set -euo pipefail on large input — after the labels have already been edited but before the comment posts. Verdict is REQUEST_CHANGES on F3 (partial, major) and F7 (new, major).

Findings: 🔴 0 Blocker · 🟠 2 Major · 🟡 5 Minor · 🔵 0 Nit


Status of prior findings

  • F1 addressed: Fixed in .github/workflows/claude-dedupe-issues.yml — the Download duplicate candidates step now sets continue-on-error: true, so a missing dedupe-result artifact no longer hard-fails the job and the [[ ! -s result/duplicates.txt ]] guard is reachable.
  • F2 addressed: Fixed in .github/workflows/pr-severity.yml — the Download classification result step sets continue-on-error: true, so the [[ ! -f result/severity.txt ]] fail-safe in the apply job now executes on the no-verdict path instead of the download erroring first.
  • F4 addressed: Fixed in .github/workflows/claude-dedupe-issues.ymlBASE_ISSUE is now validated with [[ "$BASE_ISSUE" =~ ^[0-9]+$ ]] (exit 1 otherwise) before being passed to comment-on-duplicates.sh, matching the numeric check on the candidate ids.
  • F6 addressed: Fixed in both workflows — the allowed_non_write_users: "*" lines now carry the requested comment marking "*" safe only while the job stays read-only and warning against adding a write token or mutating tool without replacing it with an allowlist.

Comment thread .github/workflows/pr-severity.yml Outdated
printf '\n</details>\n\n' >> "$OUT"
done

if [[ -f result/analysis.txt ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Major F7: Truncating clean() pipeline can abort apply job under pipefail after labels are edited

clean() is tr … | sed … | head -c "$1". Under set -euo pipefail, when the input exceeds the byte cap, head -c closes the pipe after reading its quota and the upstream sed/tr die on SIGPIPE (exit 141), which pipefail propagates as the pipeline's status. In clean 4000 < result/analysis.txt >> "$OUT" (and the CLEAN_REASON=$(… | clean 300) substitutions), that non-zero status trips set -e and aborts the apply job — which fires precisely on the oversized input the truncation exists to defend against (an attacker-steered analysis.txt larger than the pipe buffer). The abort happens after gh pr edit --add-label has already churned the labels but before gh pr comment, so the PR is left relabeled with no comment and a red job. The authors already guarded the sibling grep -c pipelines with || true but missed these. Append || true to the truncating pipelines (partial stdout is still the desired truncated output) or truncate without a pipe.

- name: Download classification result
uses: actions/download-artifact@v4
# classify uploads with if-no-files-found: warn, so if the model writes
# no verdict at all (timeout, refusal) no artifact exists and

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F8: warn + continue-on-error + exit 0 makes a crashed classifier indistinguishable from a no-op

The F1/F2 fix (if-no-files-found: warn on upload + continue-on-error: true on download + [[ ! -f result/severity.txt ]]; exit 0) makes the pipeline fail open: a classify job that crashed before writing any output produces the same green, no-label, no-comment outcome as a deliberate "nothing to do" decision. Broken model runs are silently laundered into success and PRs go unlabeled with no signal. Consider having classify always write an explicit sentinel (e.g. severity.txt present with should_comment.txt=false) so the apply guard can distinguish "model chose not to act" from "model produced nothing" and surface the latter as a notice/failure.

Comment thread .github/workflows/pr-severity.yml Outdated
# The classify job runs the model on untrusted PR text, so treat every
# field it produced as tainted. This step owns the comment's entire
# structure and sanitizes each interpolated field: control chars are
# stripped, HTML metacharacters are escaped, and @-mentions are

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F9: Unguarded add-label after removing all severity labels can leave PR unlabeled

The apply job removes every severity-* label (each --remove-label … 2>/dev/null || true) and then runs an unguarded gh pr edit … --add-label "severity-$SEVERITY". gh errors if the label does not already exist in the repo (or on a transient API failure); under set -e that aborts the job after the old label was removed, leaving the PR with no severity label and no comment. Add the new label before removing the others, or guard the add.

Comment thread .github/workflows/pr-severity.yml Outdated
}

UPPER_SEV="$(printf '%s' "$SEVERITY" | tr '[:lower:]' '[:upper:]')"
FILE_COUNT="$(grep -c . result/files.tsv || true)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F10: Displayed file count counts unrendered rows

FILE_COUNT="$(grep -c . result/files.tsv || true)" counts every non-empty line, but the renderer only iterates the fixed tiers critical high medium low. A row whose first field is anything else (model typo, stray line, a phantom row forged via a newline in <reason> — see F3) inflates the "%s files" header without appearing in any <details> block, so the stated count can exceed the bullets shown. Compute the header count from the same tier-validated row set the renderer emits.

Comment thread .github/workflows/pr-severity.yml Outdated
for tier in critical high medium low; do
COUNT="$(awk -F'\t' -v t="$tier" '$1==t' result/files.tsv \
| grep -c . || true)"
[[ "$COUNT" -gt 0 ]] || continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F11: Final files.tsv row dropped when the file lacks a trailing newline

while IFS=$'\t' read -r f_tier f_path f_reason; do … done < result/files.tsv skips the last record if the model writes files.tsv without a trailing newline: read returns non-zero at EOF and the loop body is not run for that line, even though the variables were populated. The awk/grep -c counts still include it, so a tier whose only entry is the unterminated last line renders an empty <details> section and the header count won't match the bullets. Guard with while … read -r … || [[ -n "$f_tier" ]].

Comment thread .github/workflows/pr-severity.yml Outdated
critical) printf 'Critical' ;;
high) printf 'High' ;;
medium) printf 'Medium' ;;
low) printf 'Low' ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Major F3 · partially_addressed

The structural fix requested by F3 landed: the classifier now emits only structured fields (severity.txt/should_comment.txt/files.tsv/line_count.txt/analysis.txt), the apply job owns the entire comment structure via a fixed template, and it posts with --body-file. That eliminates command injection and raw-model-markdown control. What remains: the clean() sanitizer defangs @ and HTML-escapes &<>, but does not neutralize (a) #N cross-references — a #-laden analysis.txt (up to 4000 bytes of attacker-influenced text) still generates cross-reference notifications on arbitrary issues/PRs; (b) markdown link/image syntax — [x](https://evil) / ![x](https://evil/beacon.png) render live in the bot-authored comment (phishing + tracking-beacon surface); and (c) a literal newline inside the files.tsv <reason> field, which is split by while IFS=$'\t' read before clean() runs and forges an entire extra file row. Under pull_request_target + allowed_non_write_users:"*" any fork author reaches this. Defang # like @, escape markdown link/image metacharacters, and strip \t/\n/\r from <reason> at write time (or reject rows whose TAB-field-count ≠ 3).

echo "No valid numeric duplicate ids; nothing to post."
exit 0
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F5 · partially_addressed

The new VERIFIED loop confirms each candidate resolves to a real issue via gh issue view and drops unknown ids — good defense-in-depth against hallucinated numbers. But F5's concern was the selection being prompt-injectable: an issue author can still steer the model toward real, unrelated issue numbers (#1, #2, #3), which pass the existence check and get cross-referenced, notifying those issues' participants. Existence-verification closes the "nonexistent number" sub-case only; the bounded (≤3) notification-spam vector on genuine issues survives. To fully address, confirm each candidate was an actual search hit for this issue, not merely that it exists.

@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway re-review

@Roasbeef Roasbeef force-pushed the ci/workflow-job-split branch from 3565a16 to 5336dff Compare July 8, 2026 00:41
@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10955 (review)

Prior findings: 10 addressed, 1 still unresolved, 0 withdrawn.
New findings: 2.
Total inline: 3; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

@github-actions github-actions Bot added severity-medium Focused review required and removed severity-low Best-effort review labels Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

⚠️ Severity changed: LOWMEDIUM (files changed since last classification)

🟡 PR Severity: MEDIUM

Automated classification | 2 files | 547 lines changed

🟢 Low (2 files)
  • .github/workflows/claude-dedupe-issues.yml - CI/CD workflow configuration (+153/-6)
  • .github/workflows/pr-severity.yml - CI/CD workflow configuration (+303/-85)

Analysis

Both changed files fall under .github/*, which base-classifies as LOW severity (CI/CD configuration only, no runtime code affected).

However, the total lines changed (456 additions + 91 deletions = 547 lines) exceeds the 500-line threshold for a severity bump (none of the changed lines belong to test files or auto-generated files, so all lines count toward this total). Per the classification rules, this triggers a one-level bump from LOW to MEDIUM.

No override labels are present, and no critical/high packages are touched, so MEDIUM is the final severity. This warrants a focused review of the workflow logic changes, even though no application code is affected.


To override, add a severity-override-{critical,high,medium,low} label.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This revision resolves nearly all outstanding findings. The two artifact-contract cliffs (F1, F2) are closed with continue-on-error on the downloads; the comment-injection concern (F3) is properly fixed by having the model emit only structured fields that the model-free apply job renders from a fixed template with per-field sanitization; the clean() SIGPIPE-under-pipefail bug (F7) is gone now that truncation is a bash substring rather than a head -c pipe; the label-ordering hazard (F9) is fixed by adding before removing; and the three rendering bugs (F8 signal, F10 count, F11 trailing-record) are all addressed.

One prior finding remains partially addressed: F5's existence check drops hallucinated issue numbers but the selection is still model output, so a prompt injection can steer the bot toward any real issue.

Two new minor, defense-in-depth items surfaced, both on the classify/apply split in pr-severity.yml; neither is a regression (this PR strictly narrows the prior single-job attack surface). Verdict is COMMENT.

Findings: 🔴 0 Blocker · 🟠 0 Major · 🟡 3 Minor · 🔵 0 Nit


Status of prior findings

  • F1 addressed: Fixed — post-comment's Download duplicate candidates step now sets continue-on-error: true, so a missing dedupe-result artifact no longer hard-fails the job and the [[ ! -s result/duplicates.txt ]]; exit 0 guard is reachable.
  • F2 addressed: Fixed — apply's Download classification result step now sets continue-on-error: true; the [[ ! -f result/severity.txt ]] guard now executes and exits 0 with a warning instead of the job erroring on a missing artifact.
  • F3 addressed: Fixed — the classify model now writes only structured fields (severity.txt, should_comment.txt, files.tsv, line_count.txt, analysis.txt) and the model-free apply job owns the comment's entire structure: it validates severity against the critical|high|medium|low enum, tier-validates the file rows, and passes every untrusted field through clean() (C0-control strip, &<> HTML-escape, [] escape, @-defang via zero-width joiner, http(s)://hxxp://, length cap) before templating and posting via --body-file. The read-only job no longer controls the write-scoped payload's shape — exactly the fix requested.
  • F4 addressed: Fixed — post-comment now validates BASE_ISSUE against ^[0-9]+$ up front and exits 1 on a non-numeric value, matching the candidate-id check.
  • F6 addressed: Fixed — allowed_non_write_users: "*" now carries an explicit comment in both workflows marking it safe only while the model job stays read-only, and naming the exact edits (a write token or a mutating tool) that would require replacing "*" with an allowlist.
  • F7 addressed: Fixed — clean() no longer ends in | head -c "$1". It reads the full sanitized input into out and truncates with the bash substring ${out:0:$1}, so an oversized field can no longer SIGPIPE the upstream tr/sed and abort the apply job under pipefail.
  • F8 addressed: Fixed — a missing result/severity.txt now emits ::warning::PR severity classifier produced no verdict; PR left unlabeled. and exits 0, so a no-output run surfaces as a distinct annotation rather than an indistinguishable silent green. The maintainer deliberately warns rather than fails since classification is advisory, which is a valid resolution of the fail-open concern.
  • F9 addressed: Fixed — apply now runs --add-label "severity-$SEVERITY" before the --remove-label loop. A failed add aborts under set -e with the PR still carrying its previous severity label rather than being left with none.
  • F10 addressed: Fixed — FILE_COUNT is now computed from the tier-validated row set (awk filter on critical|high|medium|low), the same set the renderer emits, so a bogus/mistyped tier or a phantom row can no longer inflate the header past the bullets shown.
  • F11 addressed: Fixed — the per-tier render loop is now while IFS=$'\t' read -r f_tier f_path f_reason || [[ -n "$f_tier" ]], so a final files.tsv record without a trailing newline is still emitted and the counts match the bullets.

Comment thread .github/workflows/pr-severity.yml Outdated
fi

if [[ ! -f result/files.tsv ]]; then
echo "should_comment is true but files.tsv is missing; skipping comment." >&2

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F13: cancel-in-progress can interrupt the label swap and leave two severity labels

The F9 fix (add the new label before removing the others) combined with the pre-existing workflow-level concurrency: … cancel-in-progress: true opens a small window: on a rapid synchronize/labeled sequence, if a new run cancels the in-flight apply job between the --add-label and the completion of the --remove-label loop, the PR is left carrying two severity-* labels. It self-heals on the next run (which adds the current and removes the rest), so impact is low, but any downstream consumer assuming exactly one severity label reads an ambiguous state in the interim. Consider excluding apply from cancel-in-progress, or computing the desired label set and applying it as one reconciliation.

@Roasbeef Roasbeef force-pushed the ci/workflow-job-split branch from 5336dff to 3806ebe Compare July 8, 2026 01:05
@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway dismiss F5 F12

F5: accepted defense-in-depth residual. The post-comment job re-validates each candidate is a real issue and drops unknown ids, and cross-references are bounded to 3 per opened issue. Semantic duplicate-verification (confirming a candidate is a genuine search hit) is intentionally out of scope for the model-free posting job.

F12: accepted residual, not a regression — this PR strictly narrows the prior single-job form (which held a write token plus gh pr edit/gh pr comment). The in-process OAuth-token exposure is inherent to running the model on pull_request_target; it is bounded by the minimal read-only allowlist (gh pr view + Write, no network/mutating tool), which is now documented inline at the claude_args allowlist.

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F5 (minor) by @Roasbeef — F12

Open findings on this PR: 🟡 F12 (minor) · 🟡 F13 (minor)

@saubyk

saubyk commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

/gateway dismiss F5 per #10955 (comment)

@saubyk

saubyk commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

/gateway dismiss F12 per #10955 (comment)

@lightninglabs-gateway

Copy link
Copy Markdown

ℹ️ F5 is already dismissed on this PR; nothing to do.

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F12 (minor) by @saubyk — per #10955 (comment)

Open findings on this PR: 🟡 F13 (minor)

@Roasbeef Roasbeef force-pushed the ci/workflow-job-split branch from 3806ebe to e82ddd3 Compare July 8, 2026 01:25
@Roasbeef Roasbeef force-pushed the ci/workflow-job-split branch from 4295334 to 7499871 Compare July 8, 2026 02:30
@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway dismiss F4 F23

F4: redundant. scripts/comment-on-duplicates.sh already validates --base-issue against ^[0-9]+$ and hard-fails on a non-numeric value (and confirms the issue exists) before posting, so a duplicate numeric check in the workflow adds no protection — only a marginally earlier error message. Leaving validation in the one place that actually guards the post.

F23: not applicable. GH-<number> is not a GitHub default autolink — it only autolinks in repos that configure a custom autolink reference for the GH- prefix, which this repo does not. Unlike #N (F18, fixed), there is nothing to defang here. Adding a rule would be dead code for a behavior that does not occur.

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F4 (minor) by @Roasbeef — F23

Open findings on this PR: 🟡 F19 (minor) · 🟡 F20 (minor) · 🟡 F21 (minor) · 🟡 F22 (minor) · 🟡 F23 (minor)

@github-actions github-actions Bot added severity-medium Focused review required and removed severity-low Best-effort review labels Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

⚠️ Severity changed: LOWMEDIUM (files changed since last classification)

🟡 PR Severity: MEDIUM

gh pr view | 4 files | 546 lines changed

🟢 Low (4 files)
  • .github/workflows/claude-dedupe-issues.yml - CI/CD workflow configuration (+148/-7)
  • .github/workflows/pr-severity.yml - CI/CD workflow configuration (+138/-59)
  • scripts/apply-pr-severity.sh - repo automation script (+132/-0)
  • scripts/apply-pr-severity_test.sh - test script for the above (+62/-0)

Analysis

Every changed file falls under .github/* or scripts/*, which are both Low-severity categories on their own. No lnd Go packages, wallet/channel logic, or RPC surfaces are touched — this PR only restructures the Claude-driven CI workflows (pr-severity and issue-dedupe) into separate classify/apply jobs and adds a shell script + test for the label/comment bookkeeping.

However, the total diff is 480 additions + 66 deletions = 546 lines changed. apply-pr-severity_test.sh doesn't match the *_test.go exclusion pattern (it's a shell test, not a Go test), so none of the changed files qualify for exclusion from the line-count calculation. Since 546 > 500, rule 3 bumps the severity one level up: Low → Medium.

No critical/high packages are involved, and the file count (4) is well under the 20-file bump threshold, so this bump is driven solely by line volume.


To override, add a severity-override-{critical,high,medium,low} label.

@saubyk

saubyk commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

/gateway dismiss F23 per #10955 (comment)

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F23 (minor) by @saubyk — per #10955 (comment)

Open findings on this PR: 🟡 F19 (minor) · 🟡 F20 (minor) · 🟡 F21 (minor) · 🟡 F22 (minor)

@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10955 (review)

Prior findings: 17 addressed, 1 still unresolved, 0 withdrawn.
New findings: 5.
Total inline: 6; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This round closes out the last open threads from prior review. The apply-side shell was extracted into scripts/apply-pr-severity.sh with a unit test (closing F21), the comment size cap now measures the sanitized body (F22), the #N defang landed (F18), and the dedupe post job now warns rather than silently no-ops on a missing artifact (F19). The claude-code-action reference is SHA-pinned in both workflows (F20's core ask). The privilege split itself is sound: the model jobs hold read-only tokens, write scope lives only in the model-free apply/post jobs, artifacts are same-run scoped, and both mutating jobs re-validate their inputs.

One prior finding is only partially addressed: F20's supply-chain concern — actions/checkout, actions/upload-artifact, and actions/download-artifact are still on mutable @v4 tags. The genuinely-new items are all minor robustness/consistency gaps: a gh pr view read failure can leave two severity labels despite the single-edit design; the classify step omits the env: GH_TOKEN its sibling dedupe job sets; the apply script guards two of its three documented env vars; neither pr-severity job sets a timeout-minutes its sibling does; and the new test covers only sanitize_comment, not main's branching. Nothing here carries fund, data, or write-scope risk.

Findings: 🔴 0 Blocker · 🟠 0 Major · 🟡 6 Minor · 🔵 0 Nit


Status of prior findings

  • F1 addressed: post-comment now downloads with continue-on-error: true and guards [[ ! -f result/duplicates.txt ]], so the missing-artifact path is reachable.
  • F2 addressed: apply downloads with continue-on-error: true and scripts/apply-pr-severity.sh guards [[ ! -f "$result_dir/severity.txt" ]]; the no-verdict path degrades instead of hard-failing.
  • F6 addressed: allowed_non_write_users: "*" now carries the inline comment marking it safe only while the model job stays read-only, in both workflows.
  • F7 addressed: The inline clean() tr | sed | head -c pipeline is gone; truncation is now a post-sanitization wc -c byte check in apply-pr-severity.sh, so the set -o pipefail SIGPIPE abort no longer exists.
  • F8 addressed: apply-pr-severity.sh:54 emits ::warning::PR severity classifier produced no verdict on a missing severity.txt, distinguishing a broken run from a healthy no-op. The maintainer accepted warn-not-fail as intentional (classification is advisory).
  • F9 addressed: Label reconciliation is now a single gh pr edit combining --add-label with the --remove-label set, so a failed add no longer leaves the PR unlabeled.
  • F10 addressed: The header-count-vs-bullets mismatch is moot: the comment body is now fully model-authored (comment.md), and the shell no longer renders tiers from a files.tsv.
  • F11 addressed: Same as F10 — the while read loop over files.tsv was removed with the shell-side rendering.
  • F13 addressed: The single-edit reconciliation (F9) also closes the cancel-in-progress two-label window: a cancelled run cannot stop between a separate add and remove.
  • F14 addressed: severity is lowercased on read (tr '[:upper:]' '[:lower:]', apply-pr-severity.sh:63), so a stray Low/HIGH matches rather than failing red.
  • F15 addressed: The comment gate now uses ! -s "$result_dir/comment.md" (non-empty), so a 0-byte body no longer reaches gh pr comment --body-file.
  • F16 addressed: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} is restored on the find-duplicates step.
  • F17 addressed: claude-dedupe-issues.yml now declares a concurrency group keyed on the issue number with cancel-in-progress, mirroring pr-severity.
  • F18 addressed: sanitize_comment includes s/#([0-9])/#${z}\1/g, defanging bare #N cross-references; the test asserts #123 and repo#4567 are defanged.
  • F19 addressed: post-comment now emits ::warning::dedupe find-duplicates produced no result when result/duplicates.txt is absent, reserving the silent exit 0 for the present-but-empty case — parity with pr-severity's apply.
  • F21 addressed: The ~100 lines of security-relevant apply-side shell were extracted into the checked-in scripts/apply-pr-severity.sh, invoked as ./scripts/apply-pr-severity.sh result, with scripts/apply-pr-severity_test.sh covering the sanitizer. (See new finding F28 for a coverage gap in that test.)
  • F22 addressed: The size cap is now measured on the sanitized body — sanitize_comment writes comment.sanitized.md, then wc -c < "$result_dir/comment.sanitized.md" is compared to max_bytes, with an inline comment explaining why post-sanitization is the correct measurement point.

@@ -20,6 +23,10 @@ jobs:
classify:
name: Classify PR Severity
runs-on: ubuntu-latest

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F24: pr-severity jobs set no timeout-minutes; sibling dedupe does

Neither classify nor apply sets timeout-minutes, whereas the sibling claude-dedupe-issues.yml caps find-duplicates at 10m and post-comment at 5m. classify runs the model on attacker-controlled PR text and fires on every pull_request_target synchronize; concurrency: cancel-in-progress only serializes within one PR's group, so a fork author pushing across many PRs can spawn parallel model runs bounded only by GitHub's 6h default — cheap input driving paid CLAUDE_CODE_OAUTH_TOKEN and runner-minute consumption. Add a short timeout-minutes to both jobs to match the dedupe workflow.

uses: anthropics/claude-code-action@ba0aafd4308cbba7165f9f2cdb0cfbed5a3c99ce # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_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.

🟡 Minor F25: classify step omits the env: GH_TOKEN its sibling dedupe job sets

The Classify PR with Claude step passes github_token as an action input but sets no env: GH_TOKEN, while the analogous find-duplicates step in the same PR sets both env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} and the identical github_token input. The classify prompt drives gh pr view through the Bash tool, which authenticates from the GH_TOKEN environment. I cannot tell from the diff whether claude-code-action propagates its github_token input into the Bash tool's environment or whether the explicit env is required: if the latter, gh pr view fails, classify writes no severity.txt, and apply warns and no-ops — the severity feature silently stops labeling. Either the dedupe env is redundant or the classify job is missing it; make the two consistent and confirm which.

main() {
local result_dir="${1:-result}"
: "${PR_NUMBER:?PR_NUMBER is required}"
: "${REPO:?REPO is required}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F26: apply script guards PR_NUMBER and REPO but not GH_TOKEN

The script header documents three required env vars (GH_TOKEN, PR_NUMBER, REPO) but only two are enforced with : "${VAR:?...}" fail-fast guards; GH_TOKEN has none. In the workflow it is always set, so this is not a live defect — but a future caller wiring a new job or running locally who omits GH_TOKEN gets an opaque gh auth error deep in main (possibly after label reconciliation has run) rather than the clean up-front failure the other two vars produce, or silently acts with whatever ambient gh credential is present. Add a matching : "${GH_TOKEN:?...}" guard so enforcement matches the documented contract.

# exactly the ones present. (The severity-change banner is authored by the
# model in comment.md, so no previous severity is needed here.)
local cur_labels
cur_labels="$(gh pr view "$PR_NUMBER" --repo "$REPO" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F27: gh pr view read failure can leave the PR with two severity labels

cur_labels="$(gh pr view ... --jq '.labels[].name' || true)" swallows any read failure into an empty string. The reconciliation loop then finds no severity-* labels to remove, but the subsequent gh pr edit --add-label still runs — so a transient read failure (rate limit, 5xx) adds the new label while leaving the old one, exactly the two-label state the single-edit design (and its inline comment) claims to prevent. It self-heals on the next successful run, so impact is low, but the stated guarantee holds only when the label read succeeds. Consider failing closed (drop || true) or distinguishing "no labels" from "read failed" before proceeding to the edit.


DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/apply-pr-severity.sh
source "$DIR/apply-pr-severity.sh"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F28: test exercises only sanitize_comment, not main()'s branches

The new test sources the script and thoroughly covers sanitize_comment, but never invokes main, so its real branching is untested: the invalid-severity return 1, the add/remove label reconciliation (F9/F13's fix and F27's gap), the should_comment/-s comment.md gating, and the max_bytes cutoff (F22's fix) — precisely the paths with gh side effects most likely to regress. Add cases that stub gh via a PATH shim and assert the emitted gh pr edit/gh pr comment argument lists per branch.

@Roasbeef Roasbeef force-pushed the ci/workflow-job-split branch from 7499871 to b5c59b0 Compare July 8, 2026 03:19
@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway dismiss F20

Accepted partial by design. The token-bearing third-party action (anthropics/claude-code-action) is now pinned to a full commit SHA in both workflows — that is the step reachable by fork authors with CLAUDE_CODE_OAUTH_TOKEN in-process, so it is the high-value vector. The first-party actions/checkout|upload-artifact|download-artifact are intentionally left on @v4 tags to stay consistent with the ~30 other lnd workflows; pinning only these two files repo-wide-style would be inconsistent. A blanket first-party action-pinning pass is worth its own dedicated, repo-wide PR rather than being scoped into this job-split refactor.

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F20 (minor) by @Roasbeefno reason given

Open findings on this PR: 🟡 F24 (minor) · 🟡 F25 (minor) · 🟡 F26 (minor) · 🟡 F27 (minor) · 🟡 F28 (minor)

@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10955 (review)

Prior findings: 22 addressed, 0 still unresolved, 0 withdrawn.
New findings: 1.
Total inline: 1; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Every non-dismissed prior finding is now addressed. The five that were still open last cycle are all fixed in the current head: classify/apply now carry timeout-minutes (F24), classify sets env: GH_TOKEN (F25), apply-pr-severity.sh guards all three env vars including GH_TOKEN (F26), the label read is fail-closed with no || true (F27), and the new apply-pr-severity_test.sh exercises main()'s branches via a gh PATH-shim (F28). The extraction of the label/comment logic into a unit-tested script, the single-edit label reconciliation, the post-sanitization size cap, and the SHA-pinned action are all in place and hold up.

One new minor point: the classify step pins no model: while the sibling find-duplicates step pins claude-haiku-4-5-20251001, so a security-classification gate inherits a model that can drift silently across action updates.

One residual I am not raising as a finding: a specialist noted bare commit-SHA strings in comment.md still autolink and were not added to the sanitizer. This is the same notification-amplification class as the already-dismissed F5/F23 (accepted residual, bounded, no fund/data/write-scope risk), and the bare-SHA variant is weaker still (requires a real matching commit oid). Flagging for awareness only.

Findings: 🔴 0 Blocker · 🟠 0 Major · 🟡 1 Minor · 🔵 0 Nit


Status of prior findings

  • F1 addressed: Fixed — post-comment download uses continue-on-error: true and the step guards [[ ! -f result/duplicates.txt ]] before use, so the missing-artifact path is reachable.
  • F2 addressed: Fixed — apply download uses continue-on-error: true and apply-pr-severity.sh guards missing severity.txt with a ::warning:: + return 0.
  • F6 addressed: Fixed — both allowed_non_write_users: "*" sites now carry the inline comment marking "*" safe only while the job stays read-only.
  • F7 addressed: Fixed — the inline truncating-pipeline / SIGPIPE hazard was removed when the logic moved into apply-pr-severity.sh, which caps size with wc -c on a materialized file rather than a head -c pipe under pipefail.
  • F8 addressed: Fixed by design — apply-pr-severity.sh emits ::warning::PR severity classifier produced no verdict on a missing severity.txt, giving the operator a distinguishable signal rather than a fully silent green. Not failing the job is the accepted "classification is advisory" tradeoff.
  • F9 addressed: Fixed — labels are reconciled in a single gh pr edit (add target + remove only present others), so no window exists where the old label is removed before the add.
  • F10 addressed: Fixed — the file-count/tier rendering that produced this mismatch was removed; the header is now authored by the model in comment.md, not recomputed in the apply path.
  • F11 addressed: Fixed — the files.tsv read loop that dropped an unterminated last record no longer exists after the logic moved into the script.
  • F13 addressed: Fixed — the single-edit reconciliation removes the add-then-remove window entirely, so a cancelled run can never leave two severity-* labels.
  • F14 addressed: Fixed — apply-pr-severity.sh lowercases on read (tr '[:upper:]' '[:lower:]') before the critical|high|medium|low case, so a stray HIGH/Low still matches.
  • F15 addressed: Fixed — the comment gate uses ! -s "$result_dir/comment.md" (exists and non-empty), so a 0-byte body never reaches gh pr comment --body-file.
  • F16 addressed: Fixed — the find-duplicates step passes claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} again, so the action can authenticate.
  • F17 addressed: Fixed — claude-dedupe-issues.yml now declares a workflow-level concurrency group keyed on the issue number with cancel-in-progress: true.
  • F18 addressed: Fixed — sanitize_comment() includes s/#([0-9])/#${z}\1/g, defanging #N cross-references while leaving ##/### headings intact.
  • F19 addressed: Fixed — the post-comment step emits ::warning::dedupe find-duplicates produced no result for the absent-file case, reserving the silent exit 0 for the present-but-empty case.
  • F21 addressed: Fixed — the ~100 lines of security-relevant shell were extracted into the checked-in scripts/apply-pr-severity.sh, and the sanitizer/control-flow now have regression coverage in scripts/apply-pr-severity_test.sh.
  • F22 addressed: Fixed — the size cap is measured on comment.sanitized.md after sanitize_comment runs (wc -c < "$result_dir/comment.sanitized.md"), so byte-growing rules can't push a posted body past GitHub's limit.
  • F24 addressed: Fixed — pr-severity.yml now sets timeout-minutes: 10 on classify and timeout-minutes: 5 on apply, matching the dedupe workflow.
  • F25 addressed: Fixed — the Classify PR with Claude step now sets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}, consistent with the dedupe find-duplicates step, so gh pr view in the Bash tool authenticates.
  • F26 addressed: Fixed — apply-pr-severity.sh now guards : "${GH_TOKEN:?GH_TOKEN is required}" alongside PR_NUMBER and REPO, matching the documented contract.
  • F27 addressed: Fixed — the cur_labels read dropped the || true and now fails closed under set -e; the inline comment documents that a read failure aborts before any edit, preventing the two-label state.
  • F28 addressed: Fixed — apply-pr-severity_test.sh adds a # main() section that stubs gh via a PATH shim and asserts the emitted gh pr edit/gh pr comment arg lists across the invalid-severity, missing-verdict, label-only reconciliation, comment-posting, empty-body, and oversized-body branches.

# GITHUB_TOKEN in-process and is reachable by any fork author, so a
# repointed tag would run attacker-controlled action code with those
# secrets present. Bump deliberately when updating.
uses: anthropics/claude-code-action@ba0aafd4308cbba7165f9f2cdb0cfbed5a3c99ce # v1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F29: classify step pins no model; sibling dedupe step does

The Classify PR with Claude step sets no model: input, so it inherits whatever anthropics/claude-code-action defaults to, while the sibling find-duplicates step in claude-dedupe-issues.yml explicitly pins model: claude-haiku-4-5-20251001. Because classify drives an automated write-labeling gate (its severity.txt decides the label the privileged apply job applies), an unpinned model means the classification behavior — and its cost — can drift silently on an action update, and the decision is not reproducible from the workflow file alone. Pin an explicit model: on the classify step so the severity decision is pinned the same way the action version now is, matching the dedupe workflow's convention. Minor and pre-existing rather than introduced here, but in scope for a hardening refactor that otherwise made the two workflows symmetric.

@Roasbeef Roasbeef force-pushed the ci/workflow-job-split branch from b5c59b0 to e07d752 Compare July 8, 2026 04:15
@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway re-review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10955 (review)

Prior findings: 23 addressed, 0 still unresolved, 0 withdrawn.
New findings: 2.
Total inline: 2; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The refactor is in good shape. Every non-dismissed prior finding is now addressed: the apply logic was extracted into the tested scripts/apply-pr-severity.sh (F21), the download steps tolerate a missing artifact (F1/F2), the sanitizer defangs @/#N/links with a post-sanitization size cap (F18/F22), both workflows gained timeouts, SHA-pinned the token-bearing action, restored the OAuth token on the dedupe job (F16), and — new this round — the classify step pins model: claude-sonnet-5, closing the last open finding F29.

Two minor consistency gaps remain in the new script, both around how it handles malformed classifier output: should_comment.txt is not case-normalized the way severity.txt is, and a present-but-empty severity.txt fails the job red rather than degrading like the missing-file path. Neither is a fund/write-scope risk on this advisory labeler.

Findings: 🔴 0 Blocker · 🟠 0 Major · 🟡 2 Minor · 🔵 0 Nit


Status of prior findings

  • F1 addressed: Fixed — post-comment download-artifact uses continue-on-error: true, making the [[ ! -f result/duplicates.txt ]] guard reachable.
  • F2 addressed: Fixed — apply download-artifact uses continue-on-error: true; the script's [[ ! -f "$result_dir/severity.txt" ]] guard now handles the no-verdict path.
  • F6 addressed: allowed_non_write_users: "*" now carries the inline "safe ONLY while this job stays read-only" comment in both workflows — the minimum bar this finding asked for.
  • F7 addressed: The clean()/head -c SIGPIPE pipeline no longer exists; comment rendering moved out of inline shell into apply-pr-severity.sh, which caps size without a truncating pipe.
  • F8 addressed: The script emits ::warning::PR severity classifier produced no verdict for the missing-verdict case rather than laundering it into a silent green.
  • F9 addressed: Label reconciliation is now a single gh pr edit that adds the target and removes only present siblings — no unguarded add after removals.
  • F10 addressed: The files.tsv header/bullet count mismatch is moot; the comment is now the model-authored comment.md, not rendered from a tier table.
  • F11 addressed: Same as F10 — the trailing-newline read loop over files.tsv no longer exists.
  • F13 addressed: The single-edit reconciliation (add + removes in one gh pr edit) closes the two-label cancellation window; documented inline in the script.
  • F14 addressed: severity is lowercased on read (tr '[:upper:]' '[:lower:]'), so Critical/HIGH still match.
  • F15 addressed: The comment guard uses -s (! -s "$result_dir/comment.md"), so a 0-byte body no longer reaches gh pr comment.
  • F16 addressed: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} is restored on the dedupe find-duplicates step.
  • F17 addressed: claude-dedupe-issues.yml now declares a concurrency group keyed on the issue number with cancel-in-progress: true.
  • F18 addressed: The sanitizer defangs #-before-digit (s/#([0-9])/#${z}\1/g); covered by the test's #123/repo#4567 cases.
  • F19 addressed: The dedupe post-comment step emits ::warning::dedupe find-duplicates produced no result for the missing-artifact case, reserving the silent no-op for present-but-empty.
  • F21 addressed: The ~100 lines of apply-time shell were extracted into checked-in scripts/apply-pr-severity.sh with a unit test (apply-pr-severity_test.sh).
  • F22 addressed: The 16 KB cap is measured on the sanitized body (wc -c < "$result_dir/comment.sanitized.md") after sanitize_comment runs.
  • F24 addressed: classify sets timeout-minutes: 10 and apply sets timeout-minutes: 5, matching the dedupe workflow.
  • F25 addressed: The classify step now sets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}, consistent with the dedupe find step; the inline comment documents why.
  • F26 addressed: apply-pr-severity.sh guards all three documented env vars, including : "${GH_TOKEN:?GH_TOKEN is required}".
  • F27 addressed: cur_labels="$(gh pr view ...)" drops the || true, so a read failure aborts under set -e before any edit — failing closed as the finding asked.
  • F28 addressed: apply-pr-severity_test.sh now stubs gh via a PATH shim and asserts the gh pr edit/gh pr comment argument lists across the invalid-severity, missing-verdict, label-reconciliation, comment, empty-comment, and oversized branches.
  • F29 addressed: Fixed at .github/workflows/pr-severity.yml — the Classify PR with Claude step now pins model: claude-sonnet-5, so the classification that gates the applied label is reproducible from the workflow file, matching the dedupe step's convention.

Comment thread scripts/apply-pr-severity.sh Outdated
# privileged, semantically meaningful output. Lowercase on read so a stray
# `Low`/`HIGH` from the model still matches rather than failing the job red.
local severity
severity="$(tr -d '[:space:]' < "$result_dir/severity.txt" | tr '[:upper:]' '[:lower:]')"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F30: Empty severity.txt fails the job red instead of degrading

The two "no usable verdict" cases are handled inconsistently. A missing severity.txt returns 0 with a ::warning:: (advisory degrade), but a present-but-empty or whitespace-only file collapses to severity="", falls through the case to *), and hits return 1, turning the apply job red. An LLM that creates the artifact file but writes nothing into it — a plausible failure mode, and the exact shape if-no-files-found: warn + continue-on-error were tuned to tolerate elsewhere — would spuriously fail the check rather than degrading like the missing-file path. Treat an empty/blank severity the same as a missing verdict (warn + return 0) so the fail-open contract is consistent.

Comment thread scripts/apply-pr-severity.sh Outdated

local should_comment="false"
if [[ -f "$result_dir/should_comment.txt" ]]; then
should_comment="$(tr -d '[:space:]' < "$result_dir/should_comment.txt")"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F31: should_comment not case-normalized unlike severity

should_comment is only stripped of whitespace, while severity is additionally lowercased (line 64). The classifier prompt asks for exactly true/false, but a single model slip (True/TRUE) fails the != "true" check and the comment is silently dropped under a misleading "No comment requested" message — the same class of LLM-output-normalization gap F14 closed for severity, left open on this sibling field. Lowercase on read for parity.

Suggested change
should_comment="$(tr -d '[:space:]' < "$result_dir/should_comment.txt")"
should_comment="$(tr -d '[:space:]' < "$result_dir/should_comment.txt" | tr '[:upper:]' '[:lower:]')"

Roasbeef added 2 commits July 7, 2026 21:24
In this commit, we separate the two concerns in the PR severity workflow:
working out the severity, and applying it. The classify job inspects the
PR and records its verdict (the severity level, whether to comment, and
the comment body) to a few files. A second apply job reads those files
and does the mechanical work of setting the label and posting the comment.

Pulling the classification apart from the application keeps each job doing
one thing and makes the flow easier to follow. The apply job takes the
severity the classifier picked and checks it against the known set before
touching a label, and posts the comment from a file via --body-file so the
body is handled as plain data. We also turn off checkout credential
persistence, since neither job needs a git credential on disk.
In this commit, we give the issue dedupe workflow the same shape: one job
finds the duplicate candidates, another posts the comment. The find job
records the candidate issue numbers to a file, and the post job hands
those numbers to comment-on-duplicates.sh, which already validates each
number and renders the comment from a fixed template.

Keeping detection and posting apart mirrors how the script is already
factored, so the post job ends up a thin wrapper over it. We also drop the
unused id-token permission and turn off checkout credential persistence
while we're in here.
@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway re-review

@Roasbeef Roasbeef force-pushed the ci/workflow-job-split branch from e07d752 to d1ea868 Compare July 8, 2026 04:24
@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10955 (review)

Prior findings: 24 addressed, 0 still unresolved, 0 withdrawn.
New findings: 0.
Total inline: 0; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This round resolves the last two open findings. scripts/apply-pr-severity.sh now treats an empty/whitespace-only severity.txt the same as a missing one (warn + return 0, F30) and lowercases should_comment on read so a True/TRUE slip still posts the comment (F31); both gained regression tests in apply-pr-severity_test.sh (emptysev, truecase). Every other prior finding remains addressed — the classify/apply and find/post split, the token/permission scoping, the artifact-cliff tolerance, the pinned action SHA and model, and the extracted, unit-tested sanitizer are all in place and unregressed.

The dismissed residuals (F3, F4, F5, F12, F20, F23) are dropped per the maintainer's dismissals and are not re-raised — the specialists' re-surfacing of the first-party-action pinning (F20), the GH-/SHA autolink gap (F23), and the BASE_ISSUE validation asymmetry (F4) all fall inside those dismissals. No genuinely-new findings this round.


Status of prior findings

  • F30 addressed: Fixed. Empty/whitespace-only severity.txt now collapses to severity="" via tr -d '[:space:]' and hits the [[ -z "$severity" ]]::warning::…no verdictreturn 0 path (script L54–63), matching the missing-file degrade rather than falling through to return 1. Covered by the emptysev test case ("empty severity.txt returns 0" / "no label edit").
  • F31 addressed: Fixed. should_comment is now read with tr -d '[:space:]' | tr '[:upper:]' '[:lower:]', matching the severity normalization, so a True/TRUE slip no longer silently drops the comment. Covered by the truecase test ("TRUE still posts the comment").
  • F1 addressed: post-comment download uses continue-on-error: true, so the [[ ! -f result/duplicates.txt ]] guard is reachable.
  • F2 addressed: apply download uses continue-on-error: true; the script's missing/empty-verdict guards are reachable.
  • F6 addressed: Both workflows now carry the inline comment marking allowed_non_write_users: "*" safe only while the job stays read-only.
  • F7 addressed: The clean()/head -c SIGPIPE-under-pipefail hazard is gone — the inline block was replaced by apply-pr-severity.sh, which caps size with wc -c on a plain file, not a truncating pipe.
  • F8 addressed: The no-verdict path emits ::warning::PR severity classifier produced no verdict, so a broken model run no longer reads as a healthy no-op.
  • F9 addressed: Label reconciliation is now a single gh pr edit that adds the target and removes only the present others — no unguarded add after a remove.
  • F10 addressed: The files.tsv header-count mismatch is moot; the model no longer emits files.tsv and the header is authored in comment.md.
  • F11 addressed: The trailing-newline read loop over files.tsv no longer exists in the refactored design.
  • F13 addressed: The two-label race is closed by the single-edit reconciliation; a cancelled run cannot stop between a separate add and remove.
  • F14 addressed: severity is lowercased on read (tr '[:upper:]' '[:lower:]'), so Critical/HIGH no longer fall through to the error path.
  • F15 addressed: The comment gate uses ! -s "$result_dir/comment.md" (exists-and-non-empty), so a 0-byte body no longer reaches gh pr comment --body-file. Covered by the empty test.
  • F16 addressed: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} is restored on the find-duplicates step.
  • F17 addressed: claude-dedupe-issues.yml now declares a concurrency group keyed on the issue number with cancel-in-progress: true.
  • F19 addressed: post-comment emits ::warning::dedupe find-duplicates produced no result for the absent-artifact case, reserving the silent exit 0 for present-but-empty.
  • F21 addressed: The apply logic is extracted into checked-in scripts/apply-pr-severity.sh and unit-tested by apply-pr-severity_test.sh.
  • F22 addressed: The size cap is measured on the sanitized body (wc -c < "$result_dir/comment.sanitized.md"), after sanitization expands bytes. Covered by the big test.
  • F24 addressed: Both classify (10m) and apply (5m) now set timeout-minutes, matching the dedupe workflow.
  • F25 addressed: The classify step now sets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}, consistent with find-duplicates.
  • F26 addressed: GH_TOKEN now has a : "${GH_TOKEN:?GH_TOKEN is required}" fail-fast guard matching PR_NUMBER/REPO.
  • F27 addressed: cur_labels is read without || true, so a gh pr view read failure now fails closed under set -e before any edit, upholding the single-edit no-two-label guarantee.
  • F28 addressed: apply-pr-severity_test.sh now stubs gh via a PATH shim and asserts the emitted gh pr edit/gh pr comment argument lists across the invalid/missing/empty/label-only/comment/oversized branches.
  • F29 addressed: The classify step pins model: claude-sonnet-5, so the label-gating classification no longer drifts on an action default.

@Roasbeef

Roasbeef commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

/gateway approve

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Approved — all prior findings addressed.

Findings recap

Findings:

  • F1 (major) — addressed: find-duplicates uploads duplicates.txt with if-no-files-found: warn (line 95), so when the model produces no file at all — a routine L…
  • F2 (major) — addressed: Same artifact-contract cliff as F1. classify uploads with if-no-files-found: warn (line 231), so if the model writes none of `severity.t…
  • F3 (major) — partially_addressed: The privilege split leaks through comment.md. The read-only classify job runs the model against fully attacker-controlled input — `gh pr…
  • F4 (minor) — unresolved: The duplicate ids are filtered through grep -oE '^[0-9]+$', but BASE_ISSUE (${{ github.event.issue.number || inputs.issue_number }}) i…
  • F5 (minor) — partially_addressed: The post-comment job correctly filters duplicates.txt to digits-only and passes the ids as array args, so there is no argument injection…
  • F6 (minor) — addressed: allowed_non_write_users: "*" (present in both workflows) disables claude-code-action's author-association gate, feeding untrusted fork/fir…
  • F7 (major) — addressed: clean() is tr … | sed … | head -c "$1". Under set -euo pipefail, when the input exceeds the byte cap, head -c closes the pipe after …
  • F8 (minor) — addressed: The F1/F2 fix (if-no-files-found: warn on upload + continue-on-error: true on download + [[ ! -f result/severity.txt ]]; exit 0) makes…
  • F9 (minor) — addressed: The apply job removes every severity-* label (each --remove-label … 2>/dev/null || true) and then runs an unguarded `gh pr edit … …
  • F10 (minor) — addressed: FILE_COUNT="$(grep -c . result/files.tsv || true)" counts every non-empty line, but the renderer only iterates the fixed tiers `critical h…
  • F11 (minor) — addressed: while IFS=$'\t' read -r f_tier f_path f_reason; do … done < result/files.tsv skips the last record if the model writes files.tsv without…
  • F12 (minor) — unresolved: The privilege split protects the repo write token but not the secrets already in the classify job's environment. classify runs on `pull_…
  • F13 (minor) — addressed: The F9 fix (add the new label before removing the others) combined with the pre-existing workflow-level `concurrency: … cancel-in-progress: …
  • F14 (minor) — addressed: SEVERITY="$(tr -d '[:space:]' < result/severity.txt)" strips whitespace but does not normalize case, and the case accepts only `critical…
  • F15 (minor) — addressed: The guard [[ "$SHOULD_COMMENT" != "true" || ! -f result/comment.md ]] checks only file existence. If the classifier sets `should_comment=t…
  • F16 (major) — addressed: The split removed the claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} input the pre-split step carried (it is a removed `-…
  • F17 (minor) — addressed: Unlike pr-severity.yml, this workflow declares no concurrency group, and the split widens the check-then-post window: the "already has a…
  • F18 (minor) — addressed: The apply sed sanitizer neutralizes @-mentions, www., http(s)://, and Markdown brackets, but leaves bare #<number> references in…
  • F19 (minor) — addressed: The post-comment guard [[ ! -s result/duplicates.txt ]] && exit 0 (line 146) collapses two distinct states into a silent green: (a) the …
  • F20 (minor) — partially_addressed: Actions are pinned to mutable tags rather than commit SHAs: anthropics/claude-code-action@v1 (pr-severity line 45, dedupe line 44) and `ac…
  • F21 (minor) — addressed: The apply step inlines ~100 lines of security-relevant shell in YAML — severity validation, label reconciliation, size-capping, and the un…
  • F22 (minor) — addressed: The 16 KB cap is measured on the raw comment.md (wc -c, line 360) before sed sanitization runs (line 364). Every sanitizer rule only g…
  • F23 (minor) — unresolved: The sanitizer (line 364) defangs @-mentions, www., http(s)://, #-before-digit, and Markdown brackets, but not GH-<number> referenc…
  • F24 (minor) — addressed: Neither classify nor apply sets timeout-minutes, whereas the sibling claude-dedupe-issues.yml caps find-duplicates at 10m and `pos…
  • F25 (minor) — addressed: The Classify PR with Claude step passes github_token as an action input but sets no env: GH_TOKEN, while the analogous `find-duplicate…
  • F26 (minor) — addressed: The script header documents three required env vars (GH_TOKEN, PR_NUMBER, REPO) but only two are enforced with : "${VAR:?...}" fail-…
  • F27 (minor) — addressed: cur_labels="$(gh pr view ... --jq '.labels[].name' || true)" swallows any read failure into an empty string. The reconciliation loop then …
  • F28 (minor) — addressed: The new test sources the script and thoroughly covers sanitize_comment, but never invokes main, so its real branching is untested: the i…
  • F29 (minor) — addressed: The Classify PR with Claude step sets no model: input, so it inherits whatever anthropics/claude-code-action defaults to, while the si…
  • F30 (minor) — addressed: The two "no usable verdict" cases are handled inconsistently. A missing severity.txt returns 0 with a ::warning:: (advisory degrade), …
  • F31 (minor) — addressed: should_comment is only stripped of whitespace, while severity is additionally lowercased (line 64). The classifier prompt asks for exact…

Dismissed:


Approved by @Roasbeef via /gateway approve. Last reviewed at d1ea868. Skill v0.3.0, model claude-opus-4-8.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway-active severity-medium Focused review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants