Skip to content

ci: post the cli-interface label and comment from a workflow_run job - #151

Open
napetrov wants to merge 9 commits into
intel:mainfrom
napetrov:cli-surface-poster
Open

napetrov wants to merge 9 commits into
intel:mainfrom
napetrov:cli-surface-poster

Conversation

@napetrov

Copy link
Copy Markdown
Contributor

Summary

Fork PRs get a read-only token, so #88's cli-interface workflow can only write a job summary there. This adds cli-interface-comment.yml: a workflow_run job that runs in the base repo's context with a write token and applies the cli-interface-change label and the sticky comment from the result cli-interface uploads.

fork PR -> cli-interface (read-only) -> artifact cli-interface-result
                                          |  workflow_run
                                          v
                 cli-interface-comment (write token, no checkout) -> label + sticky comment

Not stacked: this branch is based on main and touches only new files. The artifact upload it consumes is one extra commit on #88's branch (e4ef0b0), where the workflow lives. Until #88 merges, this workflow never fires (no cli-interface run to trigger on).

Trust boundary: no checkout, no PR code executed. The artifact is untrusted data: size-capped (zip 256 KiB, JSON 200 KiB), parsed as JSON only, pr_number must be a positive integer, head_sha must equal the run's head, the PR must belong to this repo and still have that head (stale results are skipped), the artifact must belong to the triggering run. Diff text goes only into an API body, with @ mentions neutralised and the marker stripped. Permissions: actions: read, pull-requests: write, issues: write; actions SHA-pinned.

Evidence

  • dokimi/tests/test_cli_interface_comment_workflow.py: 25 pass. Static checks (trigger, no checkout, permissions, pins) plus the embedded script run under node against a fake GitHub client with good and hostile artifacts (bad pr_number types, wrong sha, bad changed, oversized diff/artifact, other run's artifact, other repo, stale head, forged marker, mentions).
  • Mutation check: removing the head-sha and base-repo checks makes 3 tests fail.
  • ruff check and format clean. ci: report user-facing CLI surface changes on every PR #88 with the extra commit: test_cli_surface.py 63 pass.
  • Not exercised live: workflow_run only runs from the default branch, so the end-to-end run can happen only after both PRs merge.

Merge Danger

🤖 Generated with Claude Code

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@codecov-commenter

codecov-commenter commented Oct 21, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.67%. Comparing base (c9e66a7) to head (c11ab76).
⚠️ Report is 20 commits behind head on main.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #151      +/-   ##
==========================================
+ Coverage   91.40%   91.67%   +0.27%     
==========================================
  Files         216      223       +7     
  Lines       26738    27505     +767     
  Branches     4376     4525     +149     
==========================================
+ Hits        24439    25216     +777     
+ Misses       1635     1620      -15     
- Partials      664      669       +5     

see 40 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…workflow_run poster

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@vchamarthi

Copy link
Copy Markdown
Contributor

@exai-bot /review

@exai-bot exai-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.

Note

AI-generated review of f780f1827ae4, requested by @vchamarthi, using the default review skill. It can be wrong: verify a finding before acting on it. React with a thumbs up or down on a finding to tell the bot whether it helped.
Model us.anthropic.claude-sonnet-5-5, effort default, 4 of 96 turns, 0.8 of 48 minutes, 72k in / 5k out tokens.

This adds a workflow_run job that reads the cli-interface-result artifact and applies the cli-interface-change label and sticky comment with a write token. The trust boundary is well built: no checkout, size caps, strict type checks, head-SHA and base-repo checks, and unzip called with argv only. The tests run the embedded script under node against hostile inputs, which is a good approach. I found only small robustness gaps, and none of them is a security hole.

4 findings are on the changed lines, as comments below.

For a human reviewer

  • The workflow depends on #88's artifact result.json having pr_number, head_sha, changed and diff, and on head_sha being the PR head rather than the merge commit. That workflow is not in this tree, so I could not check it.
  • Whether unzip is installed on the intel-ubuntu-latest runner image. The job fails closed if it is missing.

Not verified

I could not run the tests or the workflow. I did not confirm that intel-ubuntu-latest has unzip. I did not confirm that #88 sets head_sha to the PR head commit; the cli-interface workflow is not on main.

if (!['0', '1', 'error'].includes(res.changed)) return bad('changed is not one of 0, 1, error');
if (typeof res.diff !== 'string' || res.diff.length > MAX_DIFF_CHARS) return bad('diff is missing or too long');

// The number is only a claim of the PR's code: check it is a PR of this

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 (confirmed, correctness): Mention neutralisation can push the comment body past GitHub's 65,536-character limit

  • The diff is capped at 60,000 characters before sanitising, but .replace(/@/g, '@\u200b') adds a character per @.
  • A diff with about 6,000 or more @ characters, for example from a PR that adds many decorators, can exceed 65,536 once the marker and footer are added.
  • createComment or updateComment then returns 422 and the job fails with no comment posted.
  • A hostile PR can trigger this deliberately. The failure is noisy but harmless.

Suggested fix:

Sanitise first, then truncate the result to a safe length (about 60,000 characters) with a "truncated" note. Or apply the cap after the replacement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a176a67: the 60,000-character cap is now applied after the mention rewrite (and the text is truncated with a note), so the body cannot pass 65,536. Test: test_many_mentions_cannot_push_the_body_past_githubs_limit (fails on the old code).

if (!['0', '1', 'error'].includes(res.changed)) return bad('changed is not one of 0, 1, error');
if (typeof res.diff !== 'string' || res.diff.length > MAX_DIFF_CHARS) return bad('diff is missing or too long');

// The number is only a claim of the PR's code: check it is a PR of this

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 (plausible, security): Only @ is neutralised; links, images and #N references from PR-controlled text are posted as github-actions[bot]

  • The diff text is PR-controlled and is posted unfenced.
  • A fork author can make the bot comment contain clickable phishing links, tracking images, or #123 or org/repo#N cross-reference spam.
  • The comment appears under the bot identity on the maintainers' PR. The author can only write to their own PR, so impact is limited, but the description frames the sanitising as complete.

Suggested fix:

Render the diff inside a fenced code block, choosing a fence longer than any backtick run in the text. Or escape [, < and # as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a176a67: the diff is now posted inside a code fence longer than any backtick run in it, so links, images, HTML and #N references do not render; the @ and marker neutralising stays. Test: test_diff_is_fenced_so_links_and_references_do_not_render (fails on the old code).


permissions: {}

concurrency:

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 (plausible, correctness): Concurrency is keyed by head SHA, so two runs for the same PR can race and create duplicate sticky comments

  • Runs for different pushes to one PR use different groups and can execute in parallel.
  • Both can list comments, find no existing marker, and each call createComment.
  • Later runs then update only the first match, so a stale duplicate is left behind.
  • The same race exists between this job and ci: report user-facing CLI surface changes on every PR #88's same-repo job, which the description acknowledges. The stale-head check narrows the window but does not remove it, because it runs before the comment lookup and write.

Suggested fix:

Key the group on the PR. workflow_run.pull_requests[0].number is empty for forks, so this needs a different key, for example head_repository.full_name plus head_branch. Alternatively, accept the race and have the job delete duplicate marker comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a176a67: the group is now workflow + head_repository.full_name + head_branch (pull_requests[] is empty for forks), so pushes to one PR post one at a time; the stale-head check covers an older run that runs later. The race with #88's same-repo job remains (acknowledged in the description). Test: test_concurrency_is_per_pr_branch_not_per_commit.

assert "createComment" not in _names(calls)


def test_error_result_comments_and_leaves_the_label(tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit (confirmed, tests): The 'unchanged' path with an existing sticky comment is untested

test_unchanged_result_removes_the_label covers only the case with no existing comment. The branch that rewrites an existing comment to "The CLI surface now matches the base branch" has no test. Neither does the tolerated failure of removeLabel/createLabel. Both are cheap to add with the existing harness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in a176a67: test_unchanged_result_rewrites_an_existing_comment_even_if_removing_the_label_fails covers the existing-comment rewrite and a throwing removeLabel.

@vchamarthi

Copy link
Copy Markdown
Contributor

@exai-bot /re-review

@exai-bot exai-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.

Note

AI-generated review of a176a67e2c1c, requested by @vchamarthi, using the default review skill. It can be wrong: verify a finding before acting on it. React with a thumbs up or down on a finding to tell the bot whether it helped.
Model us.anthropic.claude-sonnet-5-5, effort default, 3 of 93 turns, 0.6 of 48 minutes, 44k in / 5k out tokens.

Re-review of a176a67e2c1c: 1 new finding.

1 finding is on the changed lines, as comments below.

Comment thread .github/workflows/cli-interface-comment.yml Outdated
@napetrov

Copy link
Copy Markdown
Contributor Author

@exai-bot @vchamarthi on the "For a human reviewer" points of the first review: (1) #88's cli-interface.yml (head e4ef0b0) writes result.json with pr_number, head_sha, changed and diff, and head_sha is github.event.pull_request.head.sha, i.e. the PR head, not the merge commit; the poster compares it with the run's head_sha. (2) I could not verify that unzip exists on the intel-ubuntu-latest image: no other workflow here uses it. If it is missing the job fails closed (a warning, no comment, no label). Ready for another look.

napetrov and others added 3 commits October 21, 2026 20:42
…ot one marker pass

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The fence was one longer than the longest backtick run, so a diff of
backticks under the 60,000-character cap produced a body of up to
180,000 characters, which GitHub rejects after the label is applied.
Split runs of 8+ backticks before the cap so the fence is at most 8.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
Only the bot-login check stops the write-token job from editing a
comment another user posted with the marker; removing it left every
test green. Cover all three results.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
@xaleryb

xaleryb commented Oct 22, 2026

Copy link
Copy Markdown
Contributor
  • Ran the new test file and the full suite with the embedded script under node, using hostile artifacts and real cli_surface.py diff --markdown output from ci: report user-facing CLI surface changes on every PR #88.
  • Producer/consumer format: feeding ci: report user-facing CLI surface changes on every PR #88's real diff --markdown output (changed=1, and also its error text with changed=error) gives a body of diff\n## CLI interface change detected\n...```diff...```\n. On fork PRs the heading, intro and inner fence show up as literal code. Expected: the same rendered comment ci: report user-facing CLI surface changes on every PR #88's own job posts. One option is for the artifact to carry plain diff lines, with the poster writing the fixed heading outside the fence and its own error text.
  • Pushed:
    • 7bcb4c9: splits runs of 8+ backticks so the fence stays at most 8 long. Before, diff=''*30000` gave a 90,129-char body, over the 65,536 limit, and that write happened after the label was already added.
    • 0043665: pins the bot-login guard. Without it, a marker comment by another user was rewritten and every test stayed green.
  • Left for the author: the format mismatch above.

@antonwolfy antonwolfy left a comment

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.

Review at 0043665. The script holds up: I could not break the fence, the 65,536 limit, the marker or the mention rewrite, and the validation of the artifact is right. Two things are worth fixing before merge.

What I ran, on a Linux review host at 0043665:

  • make ci-local: 25 passed, 1 failed, 6 skipped; 6,709 tests. The one failure is pip-audit on virtualenv 21.14.7 (CVE-2026-113722) from the existing locks, fixed on main by #152; it is not caused by the PR.
  • The new test file: 34 passed. Reproductions for the findings, run under node with a fake GitHub client.
  • A mutation run on the workflow: 6 mutants applied, all 6 pass the full test file (see the inline comment).

Checked, no issues

  • Body size and fence: about 150 random and edge inputs (all @, all <!--, all backticks, runs of 7, 8, 9 and 20 backticks, CRLF, empty diff, nested marker, a cut inside an emoji). The largest body was 60,159 characters, with one marker and one fence pair every time.
  • Validation: pr_number, head_sha, changed, diff type and length, base-repo and stale-head checks each reject their bad input.
  • Lint: zizmor on the workflow reports no findings; the action is SHA-pinned and permissions are minimal.

@xaleryb's question on the artifact format (the diff --markdown heading and inner fence land as literal code on fork PRs) waits for the author.

Comment thread .github/workflows/cli-interface-comment.yml Outdated
Comment thread dokimi/tests/test_cli_interface_comment_workflow.py
@napetrov

Copy link
Copy Markdown
Contributor Author

On the artifact format (xaleryb): confirmed. #88's diff --markdown output carries its own '## CLI interface change detected' heading, intro and ```diff fence, and the poster fenced it a second time, so on fork PRs those showed as literal code. Fixed in 52b0518: the poster unwraps that producer wrapper (test_the_producers_markdown_is_unwrapped_and_the_heading_is_written_outside_the_fence), writes the heading and the single fence itself, and for changed: error posts its own fixed text instead of the artifact's (test_the_error_comment_is_the_jobs_own_text_not_the_artifacts). I did this on the poster side so it works with the current cli-interface.yml unchanged; a plain-lines artifact is also accepted as is.

… the producer's diff once

An over-cap diff is truncated instead of dropped, an artifact or result.json
over the size caps replaces the sticky comment with a too-large note (label
untouched), the producer's heading and fence are unwrapped so the poster
writes them once, and the error text is the job's own. Tests pin the guards
a mutation pass found unpinned.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>

@antonwolfy antonwolfy left a comment

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.

Review at 52b0518. Both earlier points are fixed in code and tests: an over-cap diff is truncated, an over-cap artifact or result.json rewrites the sticky comment, and the mutants from the last round now fail. Three things are worth fixing before merge, all in the new code's tests: the mutants below pass the full 46-test file.

What I ran, on a Linux review host at 52b0518:

  • make ci-local: 25 passed, 1 failed, 6 skipped. The one failure is pip-audit on virtualenv 21.14.7 from the existing locks; the PR does not touch dependencies.
  • The new test file: 46 passed. Reproductions run under node with a fake GitHub client.
  • A mutation run on the workflow: 71 mutants, 25 pass the full test file (6 of those are equivalent). The ones that matter are in the inline comments.

Checked, no issues

  • Body size: @, backtick and emoji-cut inputs, 60k-line inputs and the unwrap regex on 60k+ characters all gave a body of at most 60,270 characters, in under 0.4 s (no backtracking).
  • Wrapper smuggling: a spoofed heading, a link or an inner fence after an unwrapped diff stays inside the longer fence.
  • result.json cap: 204,740 bytes is read; 204,810 bytes takes the too-large path through ENOBUFS.
  • Lint: zizmor reports no findings (one justified ignore); ruff check passes; action is SHA-pinned, permissions minimal.

@xaleryb's point on the artifact format is fixed in the poster by the unwrap; whether the regex matches the producer's real bytes cannot be checked from this tree.

Comment on lines +74 to +78
const prs = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit,
{ owner, repo, commit_sha: run.head_sha });
for (const p of prs) {
if (p.state !== 'open' || p.base?.repo?.full_name !== `${owner}/${repo}` ||
p.head?.sha !== run.head_sha) continue;

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.

The tooLarge lookup is not pinned: the commit it queries and the loop over several PRs can both break with every test green.

The fake paginate returns the configured PR list whatever arguments it gets, and every test passes a single PR. Mutation check on the workflow alone, full test file run:

  • commit_sha: run.head_sha changed to commit_sha: 'x': 46 passed.
  • continue at line 78 changed to break: 46 passed. With a closed or fork PR listed before the valid open one, the valid PR keeps its stale diff comment.

By reading the code, an empty lookup result also ends with only a warning and a green job, so the stale comment stays. No test covers that.

Suggestion: assert commit_sha in the recorded call, and add a case where the first associated PR is filtered out (closed, wrong head, other repo) and the second is valid, and one with an empty list.

// ```diff fence. Fenced again, those would show as literal code: take the diff
// lines out of it and write the heading and the one fence here, as that job does.
const wrapped = res.diff.match(/^## CLI interface change detected\n\n[^\n]*\n\n```diff\n([\s\S]*)\n```\n?$/);
let text = (wrapped ? wrapped[1] : res.diff).replace(/<!--/g, '<!\u200b--').replace(/@/g, '@\u200b')

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.

The mention and comment-opener rewrites can be weakened and every test still passes.

Mutation check on this line, full test file run:

  • .replace(/@/g, ...) changed to .replace(/@/, ...): 46 passed. Only the first @ is broken, so every later @user in a PR-controlled diff would ping.
  • '<!\u200b--' changed to '<!--\u200b': 46 passed. The text is still a comment opener.
  • {8,} changed to {9,} on line 140: 46 passed. An 8-backtick run is not split and the fence grows to 9.

The 60,000-@ test only checks the body length, and the mention test uses one @.

Suggestion: assert on a diff with several mentions (@a @b @c) that no @ is followed by a word character, that the body contains no <!-- other than the marker, and that an 8-backtick run gives a fence of at most 8.

// The producer's `diff --markdown` output carries its own heading, intro and
// ```diff fence. Fenced again, those would show as literal code: take the diff
// lines out of it and write the heading and the one fence here, as that job does.
const wrapped = res.diff.match(/^## CLI interface change detected\n\n[^\n]*\n\n```diff\n([\s\S]*)\n```\n?$/);

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.

The producer-wrapper regex is tested with one hand-written input, and it falls back to double fencing on any variation.

Mutation check, full test file run: dropping the leading ^, dropping the trailing $, making ([\s\S]*) lazy, loosening the intro line [^\n]*, and making the trailing \n required all give 46 passed. Only removing the unwrap altogether fails.

The regex also accepts only \n endings and one optional trailing newline. In a run with the fake client, a wrapper with CRLF, or one ending in a blank line after the closing fence, is not unwrapped: the body shows the producer's ## CLI interface change detected heading and its diff fence as literal code under the job's own heading, with no warning.

Suggestion: add cases for a diff that merely contains the heading text in the middle, a CRLF wrapper, a wrapper with a trailing blank line and one with no trailing newline, and decide which of them the unwrap should accept (for example \r?\n and \s*$).

The commit-to-PR lookup on the base repository returns no PR for a
fork's head commit, so on fork PRs an over-cap result still left the
stale comment. List the open PRs from the run's head branch instead.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
@xaleryb

xaleryb commented Oct 23, 2026

Copy link
Copy Markdown
Contributor
  • Re-ran the earlier inputs at 52b0518 under node: ci: report user-facing CLI surface changes on every PR #88's real render(..., markdown=True) output (normal, over the cap, and the error text), diff=''*30000`, and a marker comment by another user. All three now pass. The full suite, ruff, mypy, bandit and zizmor are clean.
  • Too-large note on fork PRs: input is an artifact or result.json over the cap on a fork PR. Observed: repos.listPullRequestsAssociatedWithCommit on the base repository returns [] for a fork's head commit (checked against the live API for every open fork PR), so the stale comment stays. Expected: the comment is replaced, as it already is for same-repo PRs.
  • Pushed:
    • c11ab76: looks the PR up with pulls.list on the base repo, using head=<head owner>:<head_branch> and state=open. The existing head-sha and base-repo filters stay. The harness now returns [] for the commit lookup, as GitHub does for a fork commit, and a new test pins the query.
  • Nothing is left for the author.

@xaleryb xaleryb left a comment

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.

Approved at c11ab76. Every earlier point re-run at this head passes and no must-fix or mid item is open; full suite, ruff and mypy clean on Linux; CI green.

@antonwolfy antonwolfy left a comment

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.

Review at c11ab76. The new fork lookup is right: pulls.list with head=<owner>:<branch> replaces the commit lookup, and the new test pins the query. The three test gaps from my last review are still open, and each one lets a real regression through.

What I ran, on a Linux review host at c11ab76:

  • make ci-local: 25 passed, 1 failed, 6 skipped; 6,722 tests. The one failure is pip-audit on the existing lock files, which this PR does not touch.
  • The new test file: 47 passed. Mutants on the workflow alone, full test file run: the ones listed below all pass.
  • A mutant that changes the new head filter to <repo owner>:<branch> fails the new test, so the query is pinned.

Still open from my earlier review:

  • .github/workflows/cli-interface-comment.yml:77: continue changed to break still gives 47 passed. No test lists two PRs with the first one filtered out, and none returns an empty list. (commit_sha is gone with the commit lookup; the head and state arguments are now asserted.)
  • .github/workflows/cli-interface-comment.yml:140: .replace(/@/g, ...) changed to .replace(/@/, ...), '<!\u200b--' changed to '<!--\u200b', and {8,} changed to {9,} (line 141) all still give 47 passed.
  • .github/workflows/cli-interface-comment.yml:139: dropping ^, dropping $, a lazy ([\s\S]*), a loosened [^\n]* and a required trailing \n all still give 47 passed. A CRLF wrapper and a wrapper with a blank line after the closing fence are still double-fenced, and no test covers either.

Checked, no issues

  • Fork lookup: head as <owner>:<branch> is the documented filter form; the head-sha, state and base-repo filters stay client-side. Mutating the head template, state or the sha check each fails a test.
  • Unwrap: the LF wrapper with and without a trailing newline gives one heading and one diff fence; a diff that merely contains the heading mid-way is not unwrapped.
  • Lint: ruff, mypy, bandit, deptry, gitleaks and the docs build pass in ci-local.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants