From b2b77a81a2aa74eb204b9fdd876808f4c27b6ffd Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 10:47:46 -0500 Subject: [PATCH 01/39] Add CHANGELOG.md modification check and warning comment --- .github/workflows/pr-checks.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2ae0594407..b0e2aa6b12 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -181,6 +181,31 @@ jobs: path: ${{ runner.temp }}/repo-size/ if-no-files-found: error + - name: Check for CHANGELOG.md changes + id: changelog-check + if: steps.fetch-base.outcome == 'success' + env: + BASE_SHA: ${{ steps.fetch-base.outputs.merge_base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if ! git diff --exit-code --quiet "$BASE_SHA" "$HEAD_SHA" -- CHANGELOG.md; then + echo "CHANGELOG.md was modified in this PR." + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Post a warning about modifying CHANGELOG.md + if: steps.changelog-check.outputs.changed == 'true' + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + body: >- + ⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. + Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically + generated from change-notes. For more information, + see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md). + - name: "Backport: Check out base ref" id: checkout-base if: ${{ startsWith(github.head_ref, 'backport-') }} From fbd33ac6fcc84bf24f586f0648d71ac1fde05121 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 10:50:34 -0500 Subject: [PATCH 02/39] Gate comment-post step on successful fetch-base --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index b0e2aa6b12..1f19b62a04 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -196,7 +196,7 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md - if: steps.changelog-check.outputs.changed == 'true' + if: steps.fetch-base.outcome == 'success' && steps.changelog-check.outputs.changed == 'true' uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: issue-number: ${{ github.event.pull_request.number }} From 43ac64c2673106d737b19d3e66ba56c4b54a3b9d Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 11:16:19 -0500 Subject: [PATCH 03/39] Use `gh api` instead of third-party Action --- .github/workflows/pr-checks.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1f19b62a04..911e4583d0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -197,14 +197,15 @@ jobs: - name: Post a warning about modifying CHANGELOG.md if: steps.fetch-base.outcome == 'success' && steps.changelog-check.outputs.changed == 'true' - uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 - with: - issue-number: ${{ github.event.pull_request.number }} - body: >- - ⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. - Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically - generated from change-notes. For more information, - see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md). + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + body="⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. \ + Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically \ + generated from change-notes. For more information, \ + see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" - name: "Backport: Check out base ref" id: checkout-base From f7aa9dc1ba469698a7f5479c75abd35652c913ba Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 11:40:38 -0500 Subject: [PATCH 04/39] Move post-comment step to `post-pr-comments` job --- .github/workflows/pr-checks.yml | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 911e4583d0..37e06d44e6 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -92,6 +92,8 @@ jobs: contents: read runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + changelog-changed: ${{ steps.changelog-check.outputs.changed }} concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' || false }} @@ -195,18 +197,6 @@ jobs: echo "changed=false" >> "$GITHUB_OUTPUT" fi - - name: Post a warning about modifying CHANGELOG.md - if: steps.fetch-base.outcome == 'success' && steps.changelog-check.outputs.changed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - body="⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. \ - Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically \ - generated from change-notes. For more information, \ - see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." - gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" - - name: "Backport: Check out base ref" id: checkout-base if: ${{ startsWith(github.head_ref, 'backport-') }} @@ -227,8 +217,8 @@ jobs: exit 1 fi - post-repo-size-comment: - name: Post repo size comment + post-pr-comments: + name: Post PR comments needs: other-checks # Keep write permissions isolated from the job that checks out and tests PR code. This job only # posts the candidate comment body produced by the read-only `pr-checks` job. @@ -277,3 +267,15 @@ jobs: else echo "Skipping repo size comment because the delta is below the threshold and no sticky comment exists." fi + + - name: Post a warning about modifying CHANGELOG.md + if: needs.other-checks.outputs.changelog-changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + body="⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. \ + Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically \ + generated from change-notes. For more information, \ + see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From 780e947861f737e875cddac65cbc9682db4740f9 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 11:48:09 -0500 Subject: [PATCH 05/39] Skip CHANGELOG.md modification warning for release and backport PRs --- .github/workflows/pr-checks.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 37e06d44e6..6dbb457e3f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -269,7 +269,16 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md - if: needs.other-checks.outputs.changelog-changed == 'true' + # Release and backport PRs created by `update-release-branch.ts` are titled + # `Merge into `, where the target branch is always a + # `releases/v*` branch. These PRs deliberately modify CHANGELOG.md as part of the release + # process, so skip the warning for them. + if: >- + needs.other-checks.outputs.changelog-changed == 'true' && + !( + startsWith(github.event.pull_request.title, 'Merge ') && + contains(github.event.pull_request.title, ' into releases/v') + ) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} From ae75d466cee9de3cf2da4b0c49e9fdff274a62e2 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:06:32 -0500 Subject: [PATCH 06/39] Exclude "Mergeback" and "Update default bundle" PRs from CHANGELOG.md change warning --- .github/workflows/pr-checks.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6dbb457e3f..3c03943289 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -269,15 +269,17 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md - # Release and backport PRs created by `update-release-branch.ts` are titled - # `Merge into `, where the target branch is always a - # `releases/v*` branch. These PRs deliberately modify CHANGELOG.md as part of the release - # process, so skip the warning for them. + # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, + # so skip the warning for these automation flows. if: >- needs.other-checks.outputs.changelog-changed == 'true' && !( startsWith(github.event.pull_request.title, 'Merge ') && - contains(github.event.pull_request.title, ' into releases/v') + contains(github.event.pull_request.title, ' into releases/v') || + startsWith(github.event.pull_request.title, 'Mergeback ') && + startsWith(github.event.pull_request.head.ref, 'mergeback/') || + startsWith(github.event.pull_request.title, 'Update default bundle to ') && + startsWith(github.event.pull_request.head.ref, 'update-bundle/') ) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 6e4d3fb63d9c4d75040616dcdf7c77fadc4ef827 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:31:10 -0500 Subject: [PATCH 07/39] Use a sticky comment to warn about CHANGELOG.md changes --- .github/workflows/pr-checks.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 3c03943289..9121b16e2e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -282,11 +282,25 @@ jobs: startsWith(github.event.pull_request.head.ref, 'update-bundle/') ) env: + COMMENT_MARKER: "" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | - body="⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. \ - Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically \ - generated from change-notes. For more information, \ + comment_id=$( + gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --paginate \ + --jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" \ + | head -n 1 + ) + + if [[ -n "$comment_id" ]]; then + echo "CHANGELOG.md warning comment already exists ($comment_id)." + exit 0 + fi + + body="$COMMENT_MARKER + ⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. + Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically + generated from change-notes. For more information, see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From d97a656f70b91aaa5380a8bf2278eed24c0b6b18 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:41:52 -0500 Subject: [PATCH 08/39] Update CHANGELOG.md warning message to reference change-notes directory --- .github/workflows/pr-checks.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 9121b16e2e..52cb664845 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -299,8 +299,8 @@ jobs: fi body="$COMMENT_MARKER - ⚠️ CHANGELOG.md was modified in this PR. Please do not modify CHANGELOG.md directly. - Instead, describe your change as a change-note. The CHANGELOG.md file will be automatically - generated from change-notes. For more information, - see [CONTRIBUTING.md](https://github.com/github/codeql-action/blob/main/CONTRIBUTING.md)." + ⚠️ CHANGELOG.md has been modified in this PR. Please do not modify CHANGELOG.md directly. + Instead, create a change-note file in `unreleased-change-notes/`. The CHANGELOG.md + file will be automatically generated from those change-notes. + See [unreleased-change-notes/README.md](unreleased-change-notes/README.md) for more information." gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From ccdf69089ab5036850dc8544c4801e1c34e48237 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:55:10 -0500 Subject: [PATCH 09/39] Escape backticks PR comment body Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 52cb664845..39c319e5d0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -300,7 +300,7 @@ jobs: body="$COMMENT_MARKER ⚠️ CHANGELOG.md has been modified in this PR. Please do not modify CHANGELOG.md directly. - Instead, create a change-note file in `unreleased-change-notes/`. The CHANGELOG.md + Instead, create a change-note file in \`unreleased-change-notes/\`. The CHANGELOG.md file will be automatically generated from those change-notes. See [unreleased-change-notes/README.md](unreleased-change-notes/README.md) for more information." gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From 928548a094382019a950465f393317b0b7134ea0 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 12:57:52 -0500 Subject: [PATCH 10/39] Refine exclusion conditions for CHANGELOG-modification comment Refactor conditions for PR title checks in workflow. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 39c319e5d0..db067db460 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -274,8 +274,13 @@ jobs: if: >- needs.other-checks.outputs.changelog-changed == 'true' && !( - startsWith(github.event.pull_request.title, 'Merge ') && - contains(github.event.pull_request.title, ' into releases/v') || + startsWith(github.event.pull_request.title, 'Merge main into releases/v') && + startsWith(github.event.pull_request.head.ref, 'update-v') && + startsWith(github.event.pull_request.base.ref, 'releases/v') || + startsWith(github.event.pull_request.title, 'Merge releases/v') && + contains(github.event.pull_request.title, ' into releases/v') && + startsWith(github.event.pull_request.head.ref, 'backport-v') && + startsWith(github.event.pull_request.base.ref, 'releases/v') || startsWith(github.event.pull_request.title, 'Mergeback ') && startsWith(github.event.pull_request.head.ref, 'mergeback/') || startsWith(github.event.pull_request.title, 'Update default bundle to ') && From 0707124ab8cf9f077c71211fe07ec8161feabf24 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 13:11:34 -0500 Subject: [PATCH 11/39] Delete CHANGELOG.md-modification comment if CHANGELOG.md no longer modified --- .github/workflows/pr-checks.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index db067db460..c5d9d55927 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -270,9 +270,8 @@ jobs: - name: Post a warning about modifying CHANGELOG.md # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, - # so skip the warning for these automation flows. + # so skip comment management entirely for these automation flows. if: >- - needs.other-checks.outputs.changelog-changed == 'true' && !( startsWith(github.event.pull_request.title, 'Merge main into releases/v') && startsWith(github.event.pull_request.head.ref, 'update-v') && @@ -290,6 +289,7 @@ jobs: COMMENT_MARKER: "" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} + CHANGED: ${{ needs.other-checks.outputs.changelog-changed }} run: | comment_id=$( gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ @@ -298,6 +298,20 @@ jobs: | head -n 1 ) + # If CHANGELOG.md is no longer modified (e.g. a later commit reverted the change), + # remove any stale warning comment left over from an earlier push instead of leaving it + # to incorrectly claim the file is still modified. + if [[ "$CHANGED" == "false" ]]; then + if [[ -n "$comment_id" ]]; then + echo "CHANGELOG.md is no longer modified; deleting stale warning comment ($comment_id)." + gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" + else + echo "CHANGELOG.md was not modified and no warning comment exists; nothing to do." + fi + exit 0 + fi + + # If CHANGELOG.md has been modified and a warning comment already exists, do not post a duplicate comment. if [[ -n "$comment_id" ]]; then echo "CHANGELOG.md warning comment already exists ($comment_id)." exit 0 From 27d37d97e2a09dd0a5bdde848365258b3472a1b5 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 15:19:38 -0500 Subject: [PATCH 12/39] Format all file references as inline Markdown code spans Co-authored-by: Michael B. Gale --- .github/workflows/pr-checks.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c5d9d55927..4ae39623c7 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -318,8 +318,8 @@ jobs: fi body="$COMMENT_MARKER - ⚠️ CHANGELOG.md has been modified in this PR. Please do not modify CHANGELOG.md directly. - Instead, create a change-note file in \`unreleased-change-notes/\`. The CHANGELOG.md + ⚠️ \`CHANGELOG.md\` has been modified in this PR. Please do not modify \`CHANGELOG.md\` directly. + Instead, create a change-note file in \`unreleased-change-notes/\`. The \`CHANGELOG.md\` file will be automatically generated from those change-notes. - See [unreleased-change-notes/README.md](unreleased-change-notes/README.md) for more information." + See [\`unreleased-change-notes/README.md\`](unreleased-change-notes/README.md) for more information." gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" From 9fe741e77d850a82a15baa665d9365395d865677 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 15:26:56 -0500 Subject: [PATCH 13/39] Simplify `if` condition of CHANGELOG-comment-post step --- .github/workflows/pr-checks.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 4ae39623c7..6a21611f49 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -272,19 +272,10 @@ jobs: # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, # so skip comment management entirely for these automation flows. if: >- - !( - startsWith(github.event.pull_request.title, 'Merge main into releases/v') && - startsWith(github.event.pull_request.head.ref, 'update-v') && - startsWith(github.event.pull_request.base.ref, 'releases/v') || - startsWith(github.event.pull_request.title, 'Merge releases/v') && - contains(github.event.pull_request.title, ' into releases/v') && - startsWith(github.event.pull_request.head.ref, 'backport-v') && - startsWith(github.event.pull_request.base.ref, 'releases/v') || - startsWith(github.event.pull_request.title, 'Mergeback ') && - startsWith(github.event.pull_request.head.ref, 'mergeback/') || - startsWith(github.event.pull_request.title, 'Update default bundle to ') && - startsWith(github.event.pull_request.head.ref, 'update-bundle/') - ) + !startsWith(github.event.pull_request.head.ref, 'update-v') && + !startsWith(github.event.pull_request.head.ref, 'backport-v') && + !startsWith(github.event.pull_request.head.ref, 'mergeback/') && + !startsWith(github.event.pull_request.head.ref, 'update-bundle/') env: COMMENT_MARKER: "" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 8c8b0827d5a9af4db0ccee26aad2d80104f81d3d Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 23:02:25 -0500 Subject: [PATCH 14/39] Add script and Action for managing PR comments --- .github/actions/post-comment/action.yml | 46 +++++ pr-checks/post-comment.test.ts | 206 +++++++++++++++++++ pr-checks/post-comment.ts | 253 ++++++++++++++++++++++++ 3 files changed, 505 insertions(+) create mode 100644 .github/actions/post-comment/action.yml create mode 100644 pr-checks/post-comment.test.ts create mode 100644 pr-checks/post-comment.ts diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml new file mode 100644 index 0000000000..f2233bc97e --- /dev/null +++ b/.github/actions/post-comment/action.yml @@ -0,0 +1,46 @@ +name: "Post comment" +description: "Create, update, or delete a sticky PR comment identified by an automatically-derived HTML-comment marker. If this action is used more than once within the same job, give each step an explicit 'id:' so the derived marker stays unique." +inputs: + body: + description: "Full comment markdown." + required: true + action-condition: + description: "The business condition ('true' or 'false') that selects which action to perform." + required: true + action-if-true: + description: "Action to perform when action-condition is 'true'. One of: none, insert, upsert, delete." + required: true + action-if-false: + description: "Action to perform when action-condition is 'false'. One of: none, insert, upsert, delete." + required: true + issue-id: + description: "The issue or pull request number to post/update/delete the comment on. Defaults to the current pull request if not provided." + required: false + token: + description: "The GitHub token to authenticate with." + required: true +runs: + using: composite + steps: + - name: Post comment + shell: bash + env: + BODY: ${{ inputs.body }} + # Derived from the workflow name, job id, and step id/sequence-number so that repeated + # runs of the same step find the same comment (for upsert/delete), while distinct steps + # (even within the same job) get distinct markers. + MARKER: + GH_TOKEN: ${{ inputs.token }} + ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }} + ACTION_CONDITION: ${{ inputs.action-condition }} + ACTION_IF_TRUE: ${{ inputs.action-if-true }} + ACTION_IF_FALSE: ${{ inputs.action-if-false }} + run: | + npx tsx ./pr-checks/post-comment.ts \ + --body "$BODY" \ + --marker "$MARKER" \ + --action-condition "$ACTION_CONDITION" \ + --action-if-true "$ACTION_IF_TRUE" \ + --action-if-false "$ACTION_IF_FALSE" \ + --issue-id "$ISSUE_ID" \ + --repository "$GITHUB_REPOSITORY" diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts new file mode 100644 index 0000000000..c6eb76057e --- /dev/null +++ b/pr-checks/post-comment.test.ts @@ -0,0 +1,206 @@ +#!/usr/bin/env npx tsx + +/* +Tests for post-comment.ts. +*/ + +import * as assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { type ApiClient } from "./api-client"; +import { + findExistingComment, + parseOptions, + resolveToken, +} from "./post-comment"; + +/** A baseline set of valid CLI flags, as an array, for use in parseOptions tests. */ +const validFlags = [ + "--body", + "hello", + "--marker", + "", + "--action-condition", + "true", + "--action-if-true", + "insert", + "--action-if-false", + "delete", + "--issue-id", + "42", + "--repository", + "github/codeql-action", +]; + +/** Returns a copy of `validFlags` with the value following `flag` replaced by `value`. */ +function withFlag(flag: string, value: string): string[] { + const flags = [...validFlags]; + const index = flags.indexOf(flag); + flags[index + 1] = value; + return flags; +} + +/** Returns a copy of `validFlags` with `flag` (and its value) removed entirely. */ +function withoutFlag(flag: string): string[] { + const flags = [...validFlags]; + const index = flags.indexOf(flag); + flags.splice(index, 2); + return flags; +} + +describe("parseOptions", async () => { + await it("parses valid flags", () => { + const options = parseOptions(validFlags); + assert.deepEqual(options, { + body: "hello", + marker: "", + actionCondition: true, + actionIfTrue: "insert", + actionIfFalse: "delete", + issueId: 42, + repository: { owner: "github", repo: "codeql-action" }, + }); + }); + + await it("parses action-condition=false", () => { + const options = parseOptions(withFlag("--action-condition", "false")); + assert.equal(options.actionCondition, false); + }); + + await it("rejects a missing --body", () => { + assert.throws( + () => parseOptions(withoutFlag("--body")), + /Missing required flag --body/, + ); + }); + + await it("rejects a missing --marker", () => { + assert.throws( + () => parseOptions(withoutFlag("--marker")), + /Missing required flag --marker/, + ); + }); + + await it("rejects an invalid --action-condition value", () => { + assert.throws( + () => parseOptions(withFlag("--action-condition", "yes")), + /Invalid value 'yes' for --action-condition/, + ); + }); + + await it("rejects an invalid --action-if-true value", () => { + assert.throws( + () => parseOptions(withFlag("--action-if-true", "bogus")), + /Invalid value 'bogus' for --action-if-true/, + ); + }); + + await it("rejects an invalid --action-if-false value", () => { + assert.throws( + () => parseOptions(withFlag("--action-if-false", "bogus")), + /Invalid value 'bogus' for --action-if-false/, + ); + }); + + await it("rejects a missing --issue-id", () => { + assert.throws( + () => parseOptions(withoutFlag("--issue-id")), + /Missing required flag --issue-id/, + ); + }); + + await it("rejects a non-numeric --issue-id", () => { + assert.throws( + () => parseOptions(withFlag("--issue-id", "not-a-number")), + /Invalid value 'not-a-number' for --issue-id/, + ); + }); + + await it("rejects a missing --repository", () => { + assert.throws( + () => parseOptions(withoutFlag("--repository")), + /Missing required flag --repository/, + ); + }); + + await it("rejects a --repository that isn't in the form 'owner/repo'", () => { + assert.throws( + () => parseOptions(withFlag("--repository", "not-a-slug")), + /Invalid value 'not-a-slug' for --repository/, + ); + }); +}); + +describe("resolveToken", async () => { + await it("reads the token from GH_TOKEN", () => { + assert.equal( + resolveToken({ GH_TOKEN: "gh-token", GITHUB_TOKEN: "gha-token" }), + "gh-token", + ); + }); + + await it("falls back to GITHUB_TOKEN", () => { + assert.equal(resolveToken({ GITHUB_TOKEN: "gha-token" }), "gha-token"); + }); + + await it("throws when no token is set", () => { + assert.throws(() => resolveToken({}), /Missing authentication token/); + }); +}); + +/** A minimal fake of the subset of `ApiClient` used by post-comment.ts. */ +function fakeClient(existingComments: Array<{ id: number; body?: string }>) { + const calls: { + createComment: unknown[]; + updateComment: unknown[]; + deleteComment: unknown[]; + } = { createComment: [], updateComment: [], deleteComment: [] }; + + const client = { + paginate: async () => existingComments, + rest: { + issues: { + listComments: () => {}, + createComment: async (params: unknown) => { + calls.createComment.push(params); + }, + updateComment: async (params: unknown) => { + calls.updateComment.push(params); + }, + deleteComment: async (params: unknown) => { + calls.deleteComment.push(params); + }, + }, + }, + }; + + return { client: client as unknown as ApiClient, calls }; +} + +describe("findExistingComment", async () => { + await it("finds the id of the first comment containing the marker", async () => { + const { client } = fakeClient([ + { id: 1, body: "unrelated" }, + { id: 2, body: "\nhello" }, + { id: 3, body: "\nanother" }, + ]); + const id = await findExistingComment( + client, + { owner: "owner", repo: "repo" }, + 1, + "", + ); + assert.equal(id, 2); + }); + + await it("returns undefined when no comment matches", async () => { + const { client } = fakeClient([{ id: 1, body: "unrelated" }]); + const id = await findExistingComment( + client, + { owner: "owner", repo: "repo" }, + 1, + "", + ); + assert.equal(id, undefined); + }); +}); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts new file mode 100644 index 0000000000..f55da65d2b --- /dev/null +++ b/pr-checks/post-comment.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env npx tsx + +/* +Generic CLI for creating, updating, or deleting a "sticky" PR comment identified by a hidden +HTML-comment marker. This centralizes the gh api logic previously duplicated across PR-check +workflow steps that each managed their own marked comment (e.g. a CHANGELOG.md modification +warning). + +The marker is passed explicitly via `--marker` and can be any non-empty string, though an +HTML comment (e.g. ``) is recommended so it doesn't render visibly. It is used +both to find a pre-existing comment on the pull request so that +`upsert`/`delete` actions know which comment to operate on, and is automatically prepended to +`--body` when posting or updating a comment so that future invocations can find it again. Callers +typically derive this value automatically (see `.github/actions/post-comment`) rather than +hand-authoring it. +*/ + +import { parseArgs } from "node:util"; + +import { type ApiClient, getApiClient } from "./api-client"; + +/** The set of valid actions that can be performed against a (possibly pre-existing) marked comment. */ +export const ACTIONS = ["none", "insert", "upsert", "delete"] as const; +/** An action to perform against a (possibly pre-existing) marked comment. */ +export type Action = (typeof ACTIONS)[number]; + +/** Represents the command-line options. */ +export interface Options { + /** The full comment body. */ + body: string; + /** The hidden HTML-comment marker used to find a pre-existing comment. */ + marker: string; + /** The condition that selects which action to perform. */ + actionCondition: boolean; + /** The action to perform when `actionCondition` is `true`. */ + actionIfTrue: Action; + /** The action to perform when `actionCondition` is `false`. */ + actionIfFalse: Action; + /** The issue/pull request number to post/update/delete the comment on. */ + issueId: number; + /** The repository to operate on. */ + repository: { owner: string; repo: string }; +} + +/** + * Validates and parses a required CLI flag's raw string `value`, delegating the actual + * value-specific validation/conversion to `parse`. + */ +function parseFlag( + flag: string, + value: string | undefined, + parse: (value: string) => T, +): T { + if (value === undefined) { + throw new Error(`Missing required flag --${flag}.`); + } + try { + return parse(value); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid value '${value}' for --${flag}. ${reason}`); + } +} + +/** Parses and validates `value` as an `Action`. */ +function parseAction(flag: string, value: string | undefined): Action { + return parseFlag(flag, value, (raw) => { + if (!(ACTIONS as readonly string[]).includes(raw)) { + throw new Error(`Must be one of: ${ACTIONS.join(", ")}.`); + } + return raw as Action; + }); +} + +/** Parses and validates `value` as a boolean. */ +function parseBoolean(flag: string, value: string | undefined): boolean { + return parseFlag(flag, value, (raw) => { + if (raw !== "true" && raw !== "false") { + throw new Error("Must be 'true' or 'false'."); + } + return raw === "true"; + }); +} + +/** Parses and validates `value` as an issue/pull request number. */ +function parseIssueId(value: string | undefined): number { + return parseFlag("issue-id", value, (raw) => { + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) { + throw new Error("Must be an integer."); + } + return parsed; + }); +} + +/** Parses and validates `value` as an `owner/repo` repository reference. */ +function parseRepository(value: string | undefined): { + owner: string; + repo: string; +} { + return parseFlag("repository", value, (raw) => { + const [owner, repo, ...rest] = raw.split("/"); + if (!owner || !repo || rest.length > 0) { + throw new Error("Must be in the form 'owner/repo'."); + } + return { owner, repo }; + }); +} + +/** Parses the command-line arguments into `Options`. */ +export function parseOptions(argv?: string[]): Options { + const { values } = parseArgs({ + args: argv, + options: { + body: { type: "string" }, + marker: { type: "string" }, + "action-condition": { type: "string" }, + "action-if-true": { type: "string" }, + "action-if-false": { type: "string" }, + "issue-id": { type: "string" }, + repository: { type: "string" }, + }, + strict: true, + }); + + if (values.body === undefined) { + throw new Error("Missing required flag --body."); + } + if (values.marker === undefined) { + throw new Error("Missing required flag --marker."); + } + + return { + body: values.body, + marker: values.marker, + actionCondition: parseBoolean( + "action-condition", + values["action-condition"], + ), + actionIfTrue: parseAction("action-if-true", values["action-if-true"]), + actionIfFalse: parseAction("action-if-false", values["action-if-false"]), + issueId: parseIssueId(values["issue-id"]), + repository: parseRepository(values.repository), + }; +} + +/** Resolves the GitHub API token to use, from `GH_TOKEN` or `GITHUB_TOKEN`. */ +export function resolveToken(env: NodeJS.ProcessEnv): string { + const token = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim(); + if (!token) { + throw new Error( + "Missing authentication token. Set GH_TOKEN or GITHUB_TOKEN.", + ); + } + return token; +} + +/** + * Finds the first comment on the given issue/pull request whose body starts with `marker`, if any. + */ +export async function findExistingComment( + client: ApiClient, + repository: { owner: string; repo: string }, + issueNumber: number, + marker: string, +): Promise { + const comments = await client.paginate(client.rest.issues.listComments, { + ...repository, + issue_number: issueNumber, + per_page: 100, + }); + return comments.find((comment) => comment.body?.startsWith(marker))?.id; +} + +async function main(): Promise { + const options = parseOptions(); + const action = options.actionCondition + ? options.actionIfTrue + : options.actionIfFalse; + console.info(`Resolved action: ${action}`); + + const token = resolveToken(process.env); + const { issueId, marker, repository } = options; + const client = getApiClient(token); + const body = `${marker}\n${options.body}`; + + let existingCommentId: number | undefined; + if (action === "upsert" || action === "delete") { + existingCommentId = await findExistingComment( + client, + repository, + issueId, + marker, + ); + } + + switch (action) { + case "none": + console.info("No action needed."); + break; + case "insert": + console.info("Creating a new comment."); + await client.rest.issues.createComment({ + ...repository, + issue_number: issueId, + body, + }); + break; + case "upsert": + if (existingCommentId === undefined) { + console.info("No existing comment found; creating a new comment."); + await client.rest.issues.createComment({ + ...repository, + issue_number: issueId, + body, + }); + } else { + console.info(`Updating existing comment ${existingCommentId}.`); + await client.rest.issues.updateComment({ + ...repository, + comment_id: existingCommentId, + body, + }); + } + break; + case "delete": + if (existingCommentId === undefined) { + console.info("No existing comment found to delete; skipping."); + } else { + console.info(`Deleting existing comment ${existingCommentId}.`); + await client.rest.issues.deleteComment({ + ...repository, + comment_id: existingCommentId, + }); + } + break; + } + + return 0; +} + +async function run(): Promise { + try { + process.exit(await main()); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +} + +if (import.meta.main) { + void run(); +} From 14cefaa03ce3f948a8c839aa160cd1f52100a34c Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 8 Oct 2026 23:03:55 -0500 Subject: [PATCH 15/39] Replace bash/run step with local Action `post-comment` --- .github/workflows/pr-checks.yml | 69 +++++++++++++++------------------ 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6a21611f49..8ebbcd0ba9 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -238,6 +238,25 @@ jobs: group: check-repo-size-${{ github.event.pull_request.number }} steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Check out the base ref, not the PR's head, since this job runs with + # `pull-requests: write` permissions and must not execute untrusted code from the PR. + ref: ${{ github.event.pull_request.base.sha }} + sparse-checkout: | + pr-checks + .github/actions/post-comment + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: "npm" + + - name: Install dependencies + run: npm ci --workspace=pr-checks + - name: Download repo size comment uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -269,6 +288,7 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md + id: post-changelog-warning # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, # so skip comment management entirely for these automation flows. if: >- @@ -276,41 +296,14 @@ jobs: !startsWith(github.event.pull_request.head.ref, 'backport-v') && !startsWith(github.event.pull_request.head.ref, 'mergeback/') && !startsWith(github.event.pull_request.head.ref, 'update-bundle/') - env: - COMMENT_MARKER: "" - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - CHANGED: ${{ needs.other-checks.outputs.changelog-changed }} - run: | - comment_id=$( - gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ - --paginate \ - --jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" \ - | head -n 1 - ) - - # If CHANGELOG.md is no longer modified (e.g. a later commit reverted the change), - # remove any stale warning comment left over from an earlier push instead of leaving it - # to incorrectly claim the file is still modified. - if [[ "$CHANGED" == "false" ]]; then - if [[ -n "$comment_id" ]]; then - echo "CHANGELOG.md is no longer modified; deleting stale warning comment ($comment_id)." - gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" - else - echo "CHANGELOG.md was not modified and no warning comment exists; nothing to do." - fi - exit 0 - fi - - # If CHANGELOG.md has been modified and a warning comment already exists, do not post a duplicate comment. - if [[ -n "$comment_id" ]]; then - echo "CHANGELOG.md warning comment already exists ($comment_id)." - exit 0 - fi - - body="$COMMENT_MARKER - ⚠️ \`CHANGELOG.md\` has been modified in this PR. Please do not modify \`CHANGELOG.md\` directly. - Instead, create a change-note file in \`unreleased-change-notes/\`. The \`CHANGELOG.md\` - file will be automatically generated from those change-notes. - See [\`unreleased-change-notes/README.md\`](unreleased-change-notes/README.md) for more information." - gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field body="$body" + uses: ./.github/actions/post-comment + with: + body: | + ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. + Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` + file will be automatically generated from those change-notes. + See [`unreleased-change-notes/README.md`](unreleased-change-notes/README.md) for more information. + action-condition: ${{ needs.other-checks.outputs.changelog-changed }} + action-if-true: upsert + action-if-false: delete + token: ${{ secrets.GITHUB_TOKEN }} From 4d464b069771bb6541931592879afc5c59cd9823 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 9 Oct 2026 10:35:34 -0500 Subject: [PATCH 16/39] Improve issue ID parsing and validation logic Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pr-checks/post-comment.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index f55da65d2b..28e0657863 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -85,9 +85,9 @@ function parseBoolean(flag: string, value: string | undefined): boolean { /** Parses and validates `value` as an issue/pull request number. */ function parseIssueId(value: string | undefined): number { return parseFlag("issue-id", value, (raw) => { - const parsed = Number.parseInt(raw, 10); - if (Number.isNaN(parsed)) { - throw new Error("Must be an integer."); + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error("Must be a positive integer."); } return parsed; }); From 3c452a182da69a19b99b58783430dfe368591568 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 9 Oct 2026 10:36:48 -0500 Subject: [PATCH 17/39] Use HTTP link to `unreleased-change-notes/README.md` Update link in PR checks workflow to point to the correct README. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 8ebbcd0ba9..1fbab56124 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -302,7 +302,7 @@ jobs: ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` file will be automatically generated from those change-notes. - See [`unreleased-change-notes/README.md`](unreleased-change-notes/README.md) for more information. + See [`unreleased-change-notes/README.md`](https://github.com/github/codeql-action/blob/main/unreleased-change-notes/README.md) for more information. action-condition: ${{ needs.other-checks.outputs.changelog-changed }} action-if-true: upsert action-if-false: delete From f1419b8c766d21454b9ac9283a268e3dab6afdac Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 9 Oct 2026 17:11:18 -0500 Subject: [PATCH 18/39] Reject empty `--marker` --- pr-checks/post-comment.test.ts | 7 +++++++ pr-checks/post-comment.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index c6eb76057e..492c45f341 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -81,6 +81,13 @@ describe("parseOptions", async () => { ); }); + await it("rejects an empty --marker", () => { + assert.throws( + () => parseOptions(withFlag("--marker", "")), + /Missing required flag --marker/, + ); + }); + await it("rejects an invalid --action-condition value", () => { assert.throws( () => parseOptions(withFlag("--action-condition", "yes")), diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 28e0657863..01b36cac7f 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -126,7 +126,7 @@ export function parseOptions(argv?: string[]): Options { if (values.body === undefined) { throw new Error("Missing required flag --body."); } - if (values.marker === undefined) { + if (values.marker === undefined || values.marker.trim() === "") { throw new Error("Missing required flag --marker."); } From e7dadca872e576f50526a5e4fb5583ffba2add03 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Fri, 9 Oct 2026 17:35:09 -0500 Subject: [PATCH 19/39] Refactor comment handling logic into `performAction` for improved testability --- pr-checks/post-comment.test.ts | 86 ++++++++++++++++++++++++++++++++++ pr-checks/post-comment.ts | 32 +++++++++---- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index 492c45f341..db6d455393 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -10,7 +10,9 @@ import { describe, it } from "node:test"; import { type ApiClient } from "./api-client"; import { findExistingComment, + type Options, parseOptions, + performAction, resolveToken, } from "./post-comment"; @@ -211,3 +213,87 @@ describe("findExistingComment", async () => { assert.equal(id, undefined); }); }); + +/** A baseline set of `Options`, for use in `performAction` tests. */ +const baseOptions: Options = { + body: "hello", + marker: "", + actionCondition: true, + actionIfTrue: "insert", + actionIfFalse: "delete", + issueId: 42, + repository: { owner: "owner", repo: "repo" }, +}; + +describe("performAction", async () => { + await it("does nothing for 'none'", async () => { + const { client, calls } = fakeClient([]); + await performAction(client, "none", baseOptions); + assert.deepEqual(calls, { + createComment: [], + updateComment: [], + deleteComment: [], + }); + }); + + await it("creates a new comment for 'insert', with the marker prepended", async () => { + const { client, calls } = fakeClient([]); + await performAction(client, "insert", baseOptions); + assert.deepEqual(calls.createComment, [ + { + owner: "owner", + repo: "repo", + issue_number: 42, + body: "\nhello", + }, + ]); + assert.deepEqual(calls.updateComment, []); + assert.deepEqual(calls.deleteComment, []); + }); + + await it("creates a new comment for 'upsert' when no existing comment is found", async () => { + const { client, calls } = fakeClient([{ id: 1, body: "unrelated" }]); + await performAction(client, "upsert", baseOptions); + assert.deepEqual(calls.createComment, [ + { + owner: "owner", + repo: "repo", + issue_number: 42, + body: "\nhello", + }, + ]); + assert.deepEqual(calls.updateComment, []); + }); + + await it("updates the existing comment for 'upsert' when one is found", async () => { + const { client, calls } = fakeClient([ + { id: 7, body: "\nold" }, + ]); + await performAction(client, "upsert", baseOptions); + assert.deepEqual(calls.updateComment, [ + { + owner: "owner", + repo: "repo", + comment_id: 7, + body: "\nhello", + }, + ]); + assert.deepEqual(calls.createComment, []); + }); + + await it("does nothing for 'delete' when no existing comment is found", async () => { + const { client, calls } = fakeClient([{ id: 1, body: "unrelated" }]); + await performAction(client, "delete", baseOptions); + assert.deepEqual(calls.deleteComment, []); + }); + + await it("deletes the existing comment for 'delete' when one is found", async () => { + const { client, calls } = fakeClient([ + { id: 9, body: "\nold" }, + ]); + await performAction(client, "delete", baseOptions); + assert.deepEqual(calls.deleteComment, [ + { owner: "owner", repo: "repo", comment_id: 9 }, + ]); + }); +}); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 01b36cac7f..6c1a1e5e54 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -172,16 +172,17 @@ export async function findExistingComment( return comments.find((comment) => comment.body?.startsWith(marker))?.id; } -async function main(): Promise { - const options = parseOptions(); - const action = options.actionCondition - ? options.actionIfTrue - : options.actionIfFalse; - console.info(`Resolved action: ${action}`); - - const token = resolveToken(process.env); +/** + * Performs the resolved `action` against a (possibly pre-existing) marked comment, using + * `client` to talk to the GitHub API. This is the core dispatch logic of this script, extracted + * from `main` so it can be unit-tested with an injected (fake) `client`. + */ +export async function performAction( + client: ApiClient, + action: Action, + options: Options, +): Promise { const { issueId, marker, repository } = options; - const client = getApiClient(token); const body = `${marker}\n${options.body}`; let existingCommentId: number | undefined; @@ -235,6 +236,19 @@ async function main(): Promise { } break; } +} + +async function main(): Promise { + const options = parseOptions(); + const action = options.actionCondition + ? options.actionIfTrue + : options.actionIfFalse; + console.info(`Resolved action: ${action}`); + + const token = resolveToken(process.env); + const client = getApiClient(token); + + await performAction(client, action, options); return 0; } From ae9f97bade2e212818622329754bfded9f7073b5 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Wed, 28 Oct 2026 15:30:49 -0500 Subject: [PATCH 20/39] Make post-comment marker configurable Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/post-comment/action.yml | 10 ++++---- .github/workflows/pr-checks.yml | 1 + pr-checks/post-comment.test.ts | 34 +++++++++++++++++++++++++ pr-checks/post-comment.ts | 6 ++--- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml index f2233bc97e..8b3d56ee95 100644 --- a/.github/actions/post-comment/action.yml +++ b/.github/actions/post-comment/action.yml @@ -1,6 +1,9 @@ name: "Post comment" -description: "Create, update, or delete a sticky PR comment identified by an automatically-derived HTML-comment marker. If this action is used more than once within the same job, give each step an explicit 'id:' so the derived marker stays unique." +description: "Create, update, or delete a sticky PR comment identified by a caller-provided marker." inputs: + marker: + description: "Non-empty marker identifying the comment. Use a unique, stable HTML comment for each distinct comment." + required: true body: description: "Full comment markdown." required: true @@ -26,10 +29,7 @@ runs: shell: bash env: BODY: ${{ inputs.body }} - # Derived from the workflow name, job id, and step id/sequence-number so that repeated - # runs of the same step find the same comment (for upsert/delete), while distinct steps - # (even within the same job) get distinct markers. - MARKER: + MARKER: ${{ inputs.marker }} GH_TOKEN: ${{ inputs.token }} ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }} ACTION_CONDITION: ${{ inputs.action-condition }} diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1fbab56124..d8450dc4b7 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -298,6 +298,7 @@ jobs: !startsWith(github.event.pull_request.head.ref, 'update-bundle/') uses: ./.github/actions/post-comment with: + marker: "" body: | ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index db6d455393..ca7f7f1dda 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -90,6 +90,13 @@ describe("parseOptions", async () => { ); }); + await it("rejects a whitespace-only --marker", () => { + assert.throws( + () => parseOptions(withFlag("--marker", " \t\n")), + /Missing required flag --marker/, + ); + }); + await it("rejects an invalid --action-condition value", () => { assert.throws( () => parseOptions(withFlag("--action-condition", "yes")), @@ -226,6 +233,33 @@ const baseOptions: Options = { }; describe("performAction", async () => { + for (const action of ["upsert", "delete"] as const) { + await it(`uses the supplied marker for '${action}' without affecting other comments`, async () => { + const marker = ""; + const { client, calls } = fakeClient([ + { id: 1, body: "\nunrelated warning" }, + { id: 2, body: `${marker}\nold warning` }, + ]); + const options = parseOptions(withFlag("--marker", marker)); + await performAction(client, action, options); + assert.deepEqual(calls, { + createComment: [], + updateComment: + action === "upsert" + ? [ + { + ...options.repository, + comment_id: 2, + body: `${marker}\nhello`, + }, + ] + : [], + deleteComment: + action === "delete" ? [{ ...options.repository, comment_id: 2 }] : [], + }); + }); + } + await it("does nothing for 'none'", async () => { const { client, calls } = fakeClient([]); await performAction(client, "none", baseOptions); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 6c1a1e5e54..13e6f22386 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -10,9 +10,9 @@ The marker is passed explicitly via `--marker` and can be any non-empty string, HTML comment (e.g. ``) is recommended so it doesn't render visibly. It is used both to find a pre-existing comment on the pull request so that `upsert`/`delete` actions know which comment to operate on, and is automatically prepended to -`--body` when posting or updating a comment so that future invocations can find it again. Callers -typically derive this value automatically (see `.github/actions/post-comment`) rather than -hand-authoring it. +`--body` when posting or updating a comment so that future invocations can find it again. +The `.github/actions/post-comment` action requires callers to supply this value via its +`marker` input. */ import { parseArgs } from "node:util"; From 90d1bc983c4d24649d061979e129bdc9069b2293 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Wed, 28 Oct 2026 15:39:43 -0500 Subject: [PATCH 21/39] Remove bespoke post-comment argument validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pr-checks/post-comment.test.ts | 60 +++------------------- pr-checks/post-comment.ts | 93 ++++------------------------------ 2 files changed, 19 insertions(+), 134 deletions(-) diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index ca7f7f1dda..6e7179bf65 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -69,13 +69,6 @@ describe("parseOptions", async () => { assert.equal(options.actionCondition, false); }); - await it("rejects a missing --body", () => { - assert.throws( - () => parseOptions(withoutFlag("--body")), - /Missing required flag --body/, - ); - }); - await it("rejects a missing --marker", () => { assert.throws( () => parseOptions(withoutFlag("--marker")), @@ -97,53 +90,16 @@ describe("parseOptions", async () => { ); }); - await it("rejects an invalid --action-condition value", () => { - assert.throws( - () => parseOptions(withFlag("--action-condition", "yes")), - /Invalid value 'yes' for --action-condition/, - ); - }); - - await it("rejects an invalid --action-if-true value", () => { - assert.throws( - () => parseOptions(withFlag("--action-if-true", "bogus")), - /Invalid value 'bogus' for --action-if-true/, - ); - }); - - await it("rejects an invalid --action-if-false value", () => { - assert.throws( - () => parseOptions(withFlag("--action-if-false", "bogus")), - /Invalid value 'bogus' for --action-if-false/, - ); - }); - - await it("rejects a missing --issue-id", () => { - assert.throws( - () => parseOptions(withoutFlag("--issue-id")), - /Missing required flag --issue-id/, - ); - }); - - await it("rejects a non-numeric --issue-id", () => { - assert.throws( - () => parseOptions(withFlag("--issue-id", "not-a-number")), - /Invalid value 'not-a-number' for --issue-id/, - ); - }); - - await it("rejects a missing --repository", () => { - assert.throws( - () => parseOptions(withoutFlag("--repository")), - /Missing required flag --repository/, - ); + await it("rejects unknown flags using parseArgs", () => { + assert.throws(() => parseOptions([...validFlags, "--unknown", "value"]), { + code: "ERR_PARSE_ARGS_UNKNOWN_OPTION", + }); }); - await it("rejects a --repository that isn't in the form 'owner/repo'", () => { - assert.throws( - () => parseOptions(withFlag("--repository", "not-a-slug")), - /Invalid value 'not-a-slug' for --repository/, - ); + await it("rejects flags without a value using parseArgs", () => { + assert.throws(() => parseOptions([...validFlags, "--body"]), { + code: "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", + }); }); }); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 13e6f22386..1c61c02216 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -15,6 +15,7 @@ The `.github/actions/post-comment` action requires callers to supply this value `marker` input. */ +import assert from "node:assert/strict"; import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; @@ -42,72 +43,7 @@ export interface Options { repository: { owner: string; repo: string }; } -/** - * Validates and parses a required CLI flag's raw string `value`, delegating the actual - * value-specific validation/conversion to `parse`. - */ -function parseFlag( - flag: string, - value: string | undefined, - parse: (value: string) => T, -): T { - if (value === undefined) { - throw new Error(`Missing required flag --${flag}.`); - } - try { - return parse(value); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid value '${value}' for --${flag}. ${reason}`); - } -} - -/** Parses and validates `value` as an `Action`. */ -function parseAction(flag: string, value: string | undefined): Action { - return parseFlag(flag, value, (raw) => { - if (!(ACTIONS as readonly string[]).includes(raw)) { - throw new Error(`Must be one of: ${ACTIONS.join(", ")}.`); - } - return raw as Action; - }); -} - -/** Parses and validates `value` as a boolean. */ -function parseBoolean(flag: string, value: string | undefined): boolean { - return parseFlag(flag, value, (raw) => { - if (raw !== "true" && raw !== "false") { - throw new Error("Must be 'true' or 'false'."); - } - return raw === "true"; - }); -} - -/** Parses and validates `value` as an issue/pull request number. */ -function parseIssueId(value: string | undefined): number { - return parseFlag("issue-id", value, (raw) => { - const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error("Must be a positive integer."); - } - return parsed; - }); -} - -/** Parses and validates `value` as an `owner/repo` repository reference. */ -function parseRepository(value: string | undefined): { - owner: string; - repo: string; -} { - return parseFlag("repository", value, (raw) => { - const [owner, repo, ...rest] = raw.split("/"); - if (!owner || !repo || rest.length > 0) { - throw new Error("Must be in the form 'owner/repo'."); - } - return { owner, repo }; - }); -} - -/** Parses the command-line arguments into `Options`. */ +/** Parses CLI arguments supplied by the composite action into `Options`. */ export function parseOptions(argv?: string[]): Options { const { values } = parseArgs({ args: argv, @@ -123,24 +59,17 @@ export function parseOptions(argv?: string[]): Options { strict: true, }); - if (values.body === undefined) { - throw new Error("Missing required flag --body."); - } - if (values.marker === undefined || values.marker.trim() === "") { - throw new Error("Missing required flag --marker."); - } + assert(values.marker?.trim(), "Missing required flag --marker."); + const [owner, repo] = values.repository!.split("/"); return { - body: values.body, - marker: values.marker, - actionCondition: parseBoolean( - "action-condition", - values["action-condition"], - ), - actionIfTrue: parseAction("action-if-true", values["action-if-true"]), - actionIfFalse: parseAction("action-if-false", values["action-if-false"]), - issueId: parseIssueId(values["issue-id"]), - repository: parseRepository(values.repository), + body: values.body!, + marker: values.marker!, + actionCondition: values["action-condition"] === "true", + actionIfTrue: values["action-if-true"] as Action, + actionIfFalse: values["action-if-false"] as Action, + issueId: Number(values["issue-id"]), + repository: { owner, repo }, }; } From ae238655488f49ed7f28bd85a74333054160d984 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Wed, 28 Oct 2026 15:53:36 -0500 Subject: [PATCH 22/39] Move comment branching into workflow steps Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/post-comment/action.yml | 22 +++++----------- .github/workflows/pr-checks.yml | 19 +++++++++++--- pr-checks/post-comment.test.ts | 34 +++++++++++++------------ pr-checks/post-comment.ts | 29 +++++++-------------- 4 files changed, 49 insertions(+), 55 deletions(-) diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml index 8b3d56ee95..b8bffb3d56 100644 --- a/.github/actions/post-comment/action.yml +++ b/.github/actions/post-comment/action.yml @@ -5,16 +5,10 @@ inputs: description: "Non-empty marker identifying the comment. Use a unique, stable HTML comment for each distinct comment." required: true body: - description: "Full comment markdown." - required: true - action-condition: - description: "The business condition ('true' or 'false') that selects which action to perform." - required: true - action-if-true: - description: "Action to perform when action-condition is 'true'. One of: none, insert, upsert, delete." - required: true - action-if-false: - description: "Action to perform when action-condition is 'false'. One of: none, insert, upsert, delete." + description: "Full comment markdown. Not needed when deleting a comment." + required: false + action: + description: "Action to perform. One of: none, insert, upsert, delete." required: true issue-id: description: "The issue or pull request number to post/update/delete the comment on. Defaults to the current pull request if not provided." @@ -32,15 +26,11 @@ runs: MARKER: ${{ inputs.marker }} GH_TOKEN: ${{ inputs.token }} ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }} - ACTION_CONDITION: ${{ inputs.action-condition }} - ACTION_IF_TRUE: ${{ inputs.action-if-true }} - ACTION_IF_FALSE: ${{ inputs.action-if-false }} + ACTION: ${{ inputs.action }} run: | npx tsx ./pr-checks/post-comment.ts \ --body "$BODY" \ --marker "$MARKER" \ - --action-condition "$ACTION_CONDITION" \ - --action-if-true "$ACTION_IF_TRUE" \ - --action-if-false "$ACTION_IF_FALSE" \ + --action "$ACTION" \ --issue-id "$ISSUE_ID" \ --repository "$GITHUB_REPOSITORY" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d8450dc4b7..1f1c790202 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -292,6 +292,7 @@ jobs: # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, # so skip comment management entirely for these automation flows. if: >- + needs.other-checks.outputs.changelog-changed == 'true' && !startsWith(github.event.pull_request.head.ref, 'update-v') && !startsWith(github.event.pull_request.head.ref, 'backport-v') && !startsWith(github.event.pull_request.head.ref, 'mergeback/') && @@ -304,7 +305,19 @@ jobs: Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` file will be automatically generated from those change-notes. See [`unreleased-change-notes/README.md`](https://github.com/github/codeql-action/blob/main/unreleased-change-notes/README.md) for more information. - action-condition: ${{ needs.other-checks.outputs.changelog-changed }} - action-if-true: upsert - action-if-false: delete + action: upsert + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Delete the warning about modifying CHANGELOG.md + id: delete-changelog-warning + if: >- + needs.other-checks.outputs.changelog-changed != 'true' && + !startsWith(github.event.pull_request.head.ref, 'update-v') && + !startsWith(github.event.pull_request.head.ref, 'backport-v') && + !startsWith(github.event.pull_request.head.ref, 'mergeback/') && + !startsWith(github.event.pull_request.head.ref, 'update-bundle/') + uses: ./.github/actions/post-comment + with: + marker: "" + action: delete token: ${{ secrets.GITHUB_TOKEN }} diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index 6e7179bf65..cda92de9b7 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -22,12 +22,8 @@ const validFlags = [ "hello", "--marker", "", - "--action-condition", - "true", - "--action-if-true", + "--action", "insert", - "--action-if-false", - "delete", "--issue-id", "42", "--repository", @@ -56,17 +52,20 @@ describe("parseOptions", async () => { assert.deepEqual(options, { body: "hello", marker: "", - actionCondition: true, - actionIfTrue: "insert", - actionIfFalse: "delete", + action: "insert", issueId: 42, repository: { owner: "github", repo: "codeql-action" }, }); }); - await it("parses action-condition=false", () => { - const options = parseOptions(withFlag("--action-condition", "false")); - assert.equal(options.actionCondition, false); + await it("parses a delete action without a body", () => { + const options = parseOptions([ + ...withoutFlag("--body"), + "--action", + "delete", + ]); + assert.equal(options.action, "delete"); + assert.equal(options.body, ""); }); await it("rejects a missing --marker", () => { @@ -181,9 +180,7 @@ describe("findExistingComment", async () => { const baseOptions: Options = { body: "hello", marker: "", - actionCondition: true, - actionIfTrue: "insert", - actionIfFalse: "delete", + action: "insert", issueId: 42, repository: { owner: "owner", repo: "repo" }, }; @@ -281,9 +278,14 @@ describe("performAction", async () => { const { client, calls } = fakeClient([ { id: 9, body: "\nold" }, ]); - await performAction(client, "delete", baseOptions); + const options = parseOptions([ + ...withoutFlag("--body"), + "--action", + "delete", + ]); + await performAction(client, options.action, options); assert.deepEqual(calls.deleteComment, [ - { owner: "owner", repo: "repo", comment_id: 9 }, + { owner: "github", repo: "codeql-action", comment_id: 9 }, ]); }); }); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 1c61c02216..602bf2056a 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -31,12 +31,8 @@ export interface Options { body: string; /** The hidden HTML-comment marker used to find a pre-existing comment. */ marker: string; - /** The condition that selects which action to perform. */ - actionCondition: boolean; - /** The action to perform when `actionCondition` is `true`. */ - actionIfTrue: Action; - /** The action to perform when `actionCondition` is `false`. */ - actionIfFalse: Action; + /** The action to perform. */ + action: Action; /** The issue/pull request number to post/update/delete the comment on. */ issueId: number; /** The repository to operate on. */ @@ -48,11 +44,9 @@ export function parseOptions(argv?: string[]): Options { const { values } = parseArgs({ args: argv, options: { - body: { type: "string" }, + body: { type: "string", default: "" }, marker: { type: "string" }, - "action-condition": { type: "string" }, - "action-if-true": { type: "string" }, - "action-if-false": { type: "string" }, + action: { type: "string" }, "issue-id": { type: "string" }, repository: { type: "string" }, }, @@ -63,11 +57,9 @@ export function parseOptions(argv?: string[]): Options { const [owner, repo] = values.repository!.split("/"); return { - body: values.body!, + body: values.body, marker: values.marker!, - actionCondition: values["action-condition"] === "true", - actionIfTrue: values["action-if-true"] as Action, - actionIfFalse: values["action-if-false"] as Action, + action: values.action as Action, issueId: Number(values["issue-id"]), repository: { owner, repo }, }; @@ -102,7 +94,7 @@ export async function findExistingComment( } /** - * Performs the resolved `action` against a (possibly pre-existing) marked comment, using + * Performs the supplied `action` against a (possibly pre-existing) marked comment, using * `client` to talk to the GitHub API. This is the core dispatch logic of this script, extracted * from `main` so it can be unit-tested with an injected (fake) `client`. */ @@ -169,15 +161,12 @@ export async function performAction( async function main(): Promise { const options = parseOptions(); - const action = options.actionCondition - ? options.actionIfTrue - : options.actionIfFalse; - console.info(`Resolved action: ${action}`); + console.info(`Action: ${options.action}`); const token = resolveToken(process.env); const client = getApiClient(token); - await performAction(client, action, options); + await performAction(client, options.action, options); return 0; } From 94488778fdc9296be5ba13464f1784bf439495e1 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Wed, 28 Oct 2026 16:00:37 -0500 Subject: [PATCH 23/39] Simplify post-comment to a single set-comment mode Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/post-comment/action.yml | 11 +-- .github/workflows/pr-checks.yml | 13 ++- pr-checks/post-comment.test.ts | 112 ++++++++---------------- pr-checks/post-comment.ts | 100 ++++++--------------- 4 files changed, 76 insertions(+), 160 deletions(-) diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml index b8bffb3d56..951fd17042 100644 --- a/.github/actions/post-comment/action.yml +++ b/.github/actions/post-comment/action.yml @@ -1,17 +1,14 @@ name: "Post comment" -description: "Create, update, or delete a sticky PR comment identified by a caller-provided marker." +description: "Create or update a sticky PR comment identified by a caller-provided marker." inputs: marker: description: "Non-empty marker identifying the comment. Use a unique, stable HTML comment for each distinct comment." required: true body: - description: "Full comment markdown. Not needed when deleting a comment." - required: false - action: - description: "Action to perform. One of: none, insert, upsert, delete." + description: "Full comment markdown." required: true issue-id: - description: "The issue or pull request number to post/update/delete the comment on. Defaults to the current pull request if not provided." + description: "The issue or pull request number to post/update the comment on. Defaults to the current pull request if not provided." required: false token: description: "The GitHub token to authenticate with." @@ -26,11 +23,9 @@ runs: MARKER: ${{ inputs.marker }} GH_TOKEN: ${{ inputs.token }} ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }} - ACTION: ${{ inputs.action }} run: | npx tsx ./pr-checks/post-comment.ts \ --body "$BODY" \ --marker "$MARKER" \ - --action "$ACTION" \ --issue-id "$ISSUE_ID" \ --repository "$GITHUB_REPOSITORY" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1f1c790202..fb3f6aba2b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -305,11 +305,10 @@ jobs: Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` file will be automatically generated from those change-notes. See [`unreleased-change-notes/README.md`](https://github.com/github/codeql-action/blob/main/unreleased-change-notes/README.md) for more information. - action: upsert token: ${{ secrets.GITHUB_TOKEN }} - - name: Delete the warning about modifying CHANGELOG.md - id: delete-changelog-warning + - name: Mark the CHANGELOG.md concern as resolved + id: resolve-changelog-warning if: >- needs.other-checks.outputs.changelog-changed != 'true' && !startsWith(github.event.pull_request.head.ref, 'update-v') && @@ -319,5 +318,11 @@ jobs: uses: ./.github/actions/post-comment with: marker: "" - action: delete + body: | + ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. + Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` + file will be automatically generated from those change-notes. + See [`unreleased-change-notes/README.md`](https://github.com/github/codeql-action/blob/main/unreleased-change-notes/README.md) for more information. + + **Edit:** `CHANGELOG.md` is no longer modified in this PR. This concern is resolved. token: ${{ secrets.GITHUB_TOKEN }} diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index cda92de9b7..de840427dc 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -12,8 +12,8 @@ import { findExistingComment, type Options, parseOptions, - performAction, resolveToken, + setComment, } from "./post-comment"; /** A baseline set of valid CLI flags, as an array, for use in parseOptions tests. */ @@ -22,8 +22,6 @@ const validFlags = [ "hello", "--marker", "", - "--action", - "insert", "--issue-id", "42", "--repository", @@ -52,22 +50,11 @@ describe("parseOptions", async () => { assert.deepEqual(options, { body: "hello", marker: "", - action: "insert", issueId: 42, repository: { owner: "github", repo: "codeql-action" }, }); }); - await it("parses a delete action without a body", () => { - const options = parseOptions([ - ...withoutFlag("--body"), - "--action", - "delete", - ]); - assert.equal(options.action, "delete"); - assert.equal(options.body, ""); - }); - await it("rejects a missing --marker", () => { assert.throws( () => parseOptions(withoutFlag("--marker")), @@ -124,8 +111,7 @@ function fakeClient(existingComments: Array<{ id: number; body?: string }>) { const calls: { createComment: unknown[]; updateComment: unknown[]; - deleteComment: unknown[]; - } = { createComment: [], updateComment: [], deleteComment: [] }; + } = { createComment: [], updateComment: [] }; const client = { paginate: async () => existingComments, @@ -138,9 +124,6 @@ function fakeClient(existingComments: Array<{ id: number; body?: string }>) { updateComment: async (params: unknown) => { calls.updateComment.push(params); }, - deleteComment: async (params: unknown) => { - calls.deleteComment.push(params); - }, }, }, }; @@ -176,56 +159,38 @@ describe("findExistingComment", async () => { }); }); -/** A baseline set of `Options`, for use in `performAction` tests. */ +/** A baseline set of `Options`, for use in `setComment` tests. */ const baseOptions: Options = { body: "hello", marker: "", - action: "insert", issueId: 42, repository: { owner: "owner", repo: "repo" }, }; -describe("performAction", async () => { - for (const action of ["upsert", "delete"] as const) { - await it(`uses the supplied marker for '${action}' without affecting other comments`, async () => { - const marker = ""; - const { client, calls } = fakeClient([ - { id: 1, body: "\nunrelated warning" }, - { id: 2, body: `${marker}\nold warning` }, - ]); - const options = parseOptions(withFlag("--marker", marker)); - await performAction(client, action, options); - assert.deepEqual(calls, { - createComment: [], - updateComment: - action === "upsert" - ? [ - { - ...options.repository, - comment_id: 2, - body: `${marker}\nhello`, - }, - ] - : [], - deleteComment: - action === "delete" ? [{ ...options.repository, comment_id: 2 }] : [], - }); - }); - } - - await it("does nothing for 'none'", async () => { - const { client, calls } = fakeClient([]); - await performAction(client, "none", baseOptions); +describe("setComment", async () => { + await it("uses the supplied marker without affecting other comments", async () => { + const marker = ""; + const { client, calls } = fakeClient([ + { id: 1, body: "\nunrelated warning" }, + { id: 2, body: `${marker}\nold warning` }, + ]); + const options = parseOptions(withFlag("--marker", marker)); + await setComment(client, options); assert.deepEqual(calls, { createComment: [], - updateComment: [], - deleteComment: [], + updateComment: [ + { + ...options.repository, + comment_id: 2, + body: `${marker}\nhello`, + }, + ], }); }); - await it("creates a new comment for 'insert', with the marker prepended", async () => { + await it("creates a new comment with the marker prepended", async () => { const { client, calls } = fakeClient([]); - await performAction(client, "insert", baseOptions); + await setComment(client, baseOptions); assert.deepEqual(calls.createComment, [ { owner: "owner", @@ -235,12 +200,11 @@ describe("performAction", async () => { }, ]); assert.deepEqual(calls.updateComment, []); - assert.deepEqual(calls.deleteComment, []); }); - await it("creates a new comment for 'upsert' when no existing comment is found", async () => { + await it("creates a new comment when no existing comment matches", async () => { const { client, calls } = fakeClient([{ id: 1, body: "unrelated" }]); - await performAction(client, "upsert", baseOptions); + await setComment(client, baseOptions); assert.deepEqual(calls.createComment, [ { owner: "owner", @@ -252,11 +216,11 @@ describe("performAction", async () => { assert.deepEqual(calls.updateComment, []); }); - await it("updates the existing comment for 'upsert' when one is found", async () => { + await it("updates the existing comment when one is found", async () => { const { client, calls } = fakeClient([ { id: 7, body: "\nold" }, ]); - await performAction(client, "upsert", baseOptions); + await setComment(client, baseOptions); assert.deepEqual(calls.updateComment, [ { owner: "owner", @@ -268,24 +232,20 @@ describe("performAction", async () => { assert.deepEqual(calls.createComment, []); }); - await it("does nothing for 'delete' when no existing comment is found", async () => { - const { client, calls } = fakeClient([{ id: 1, body: "unrelated" }]); - await performAction(client, "delete", baseOptions); - assert.deepEqual(calls.deleteComment, []); - }); - - await it("deletes the existing comment for 'delete' when one is found", async () => { + await it("replaces a warning with the resolved state using the same marker", async () => { const { client, calls } = fakeClient([ { id: 9, body: "\nold" }, ]); - const options = parseOptions([ - ...withoutFlag("--body"), - "--action", - "delete", - ]); - await performAction(client, options.action, options); - assert.deepEqual(calls.deleteComment, [ - { owner: "github", repo: "codeql-action", comment_id: 9 }, + const body = + "`CHANGELOG.md` is not modified in this PR. No changelog action is needed."; + await setComment(client, { ...baseOptions, body }); + assert.deepEqual(calls.updateComment, [ + { + ...baseOptions.repository, + comment_id: 9, + body: `${baseOptions.marker}\n${body}`, + }, ]); + assert.deepEqual(calls.createComment, []); }); }); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 602bf2056a..3f8abe40bd 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -1,15 +1,14 @@ #!/usr/bin/env npx tsx /* -Generic CLI for creating, updating, or deleting a "sticky" PR comment identified by a hidden +Generic CLI for creating or updating a "sticky" PR comment identified by a hidden HTML-comment marker. This centralizes the gh api logic previously duplicated across PR-check workflow steps that each managed their own marked comment (e.g. a CHANGELOG.md modification warning). The marker is passed explicitly via `--marker` and can be any non-empty string, though an HTML comment (e.g. ``) is recommended so it doesn't render visibly. It is used -both to find a pre-existing comment on the pull request so that -`upsert`/`delete` actions know which comment to operate on, and is automatically prepended to +both to find a pre-existing comment on the pull request to update, and is automatically prepended to `--body` when posting or updating a comment so that future invocations can find it again. The `.github/actions/post-comment` action requires callers to supply this value via its `marker` input. @@ -20,20 +19,13 @@ import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; -/** The set of valid actions that can be performed against a (possibly pre-existing) marked comment. */ -export const ACTIONS = ["none", "insert", "upsert", "delete"] as const; -/** An action to perform against a (possibly pre-existing) marked comment. */ -export type Action = (typeof ACTIONS)[number]; - /** Represents the command-line options. */ export interface Options { /** The full comment body. */ body: string; /** The hidden HTML-comment marker used to find a pre-existing comment. */ marker: string; - /** The action to perform. */ - action: Action; - /** The issue/pull request number to post/update/delete the comment on. */ + /** The issue/pull request number to post/update the comment on. */ issueId: number; /** The repository to operate on. */ repository: { owner: string; repo: string }; @@ -44,9 +36,8 @@ export function parseOptions(argv?: string[]): Options { const { values } = parseArgs({ args: argv, options: { - body: { type: "string", default: "" }, + body: { type: "string" }, marker: { type: "string" }, - action: { type: "string" }, "issue-id": { type: "string" }, repository: { type: "string" }, }, @@ -57,9 +48,8 @@ export function parseOptions(argv?: string[]): Options { const [owner, repo] = values.repository!.split("/"); return { - body: values.body, + body: values.body!, marker: values.marker!, - action: values.action as Action, issueId: Number(values["issue-id"]), repository: { owner, repo }, }; @@ -94,79 +84,45 @@ export async function findExistingComment( } /** - * Performs the supplied `action` against a (possibly pre-existing) marked comment, using - * `client` to talk to the GitHub API. This is the core dispatch logic of this script, extracted - * from `main` so it can be unit-tested with an injected (fake) `client`. + * Creates or updates a marked comment using `client` to talk to the GitHub API. */ -export async function performAction( +export async function setComment( client: ApiClient, - action: Action, options: Options, ): Promise { const { issueId, marker, repository } = options; const body = `${marker}\n${options.body}`; - let existingCommentId: number | undefined; - if (action === "upsert" || action === "delete") { - existingCommentId = await findExistingComment( - client, - repository, - issueId, - marker, - ); - } - - switch (action) { - case "none": - console.info("No action needed."); - break; - case "insert": - console.info("Creating a new comment."); - await client.rest.issues.createComment({ - ...repository, - issue_number: issueId, - body, - }); - break; - case "upsert": - if (existingCommentId === undefined) { - console.info("No existing comment found; creating a new comment."); - await client.rest.issues.createComment({ - ...repository, - issue_number: issueId, - body, - }); - } else { - console.info(`Updating existing comment ${existingCommentId}.`); - await client.rest.issues.updateComment({ - ...repository, - comment_id: existingCommentId, - body, - }); - } - break; - case "delete": - if (existingCommentId === undefined) { - console.info("No existing comment found to delete; skipping."); - } else { - console.info(`Deleting existing comment ${existingCommentId}.`); - await client.rest.issues.deleteComment({ - ...repository, - comment_id: existingCommentId, - }); - } - break; + const existingCommentId = await findExistingComment( + client, + repository, + issueId, + marker, + ); + if (existingCommentId === undefined) { + console.info("No existing comment found; creating a new comment."); + await client.rest.issues.createComment({ + ...repository, + issue_number: issueId, + body, + }); + } else { + console.info(`Updating existing comment ${existingCommentId}.`); + await client.rest.issues.updateComment({ + ...repository, + comment_id: existingCommentId, + body, + }); } } async function main(): Promise { const options = parseOptions(); - console.info(`Action: ${options.action}`); const token = resolveToken(process.env); const client = getApiClient(token); - await performAction(client, options.action, options); + await setComment(client, options); return 0; } From 03137fc68db87f6ae87eba8a822f1be601efbe8a Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Wed, 28 Oct 2026 16:06:08 -0500 Subject: [PATCH 24/39] Rename changelog outputs to modified Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index fb3f6aba2b..911dbb7cd3 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -93,7 +93,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 outputs: - changelog-changed: ${{ steps.changelog-check.outputs.changed }} + changelog-modified: ${{ steps.changelog-check.outputs.modified }} concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' || false }} @@ -192,9 +192,9 @@ jobs: run: | if ! git diff --exit-code --quiet "$BASE_SHA" "$HEAD_SHA" -- CHANGELOG.md; then echo "CHANGELOG.md was modified in this PR." - echo "changed=true" >> "$GITHUB_OUTPUT" + echo "modified=true" >> "$GITHUB_OUTPUT" else - echo "changed=false" >> "$GITHUB_OUTPUT" + echo "modified=false" >> "$GITHUB_OUTPUT" fi - name: "Backport: Check out base ref" @@ -292,7 +292,7 @@ jobs: # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, # so skip comment management entirely for these automation flows. if: >- - needs.other-checks.outputs.changelog-changed == 'true' && + needs.other-checks.outputs.changelog-modified == 'true' && !startsWith(github.event.pull_request.head.ref, 'update-v') && !startsWith(github.event.pull_request.head.ref, 'backport-v') && !startsWith(github.event.pull_request.head.ref, 'mergeback/') && @@ -310,7 +310,7 @@ jobs: - name: Mark the CHANGELOG.md concern as resolved id: resolve-changelog-warning if: >- - needs.other-checks.outputs.changelog-changed != 'true' && + needs.other-checks.outputs.changelog-modified != 'true' && !startsWith(github.event.pull_request.head.ref, 'update-v') && !startsWith(github.event.pull_request.head.ref, 'backport-v') && !startsWith(github.event.pull_request.head.ref, 'mergeback/') && From 36f220a96c230f90737a692bd5b44eb00ef5d238 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Wed, 28 Oct 2026 16:08:22 -0500 Subject: [PATCH 25/39] Remove unused post-comment step IDs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 911dbb7cd3..1e8b15ea60 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -288,7 +288,6 @@ jobs: fi - name: Post a warning about modifying CHANGELOG.md - id: post-changelog-warning # Release, backport, mergeback, and bundle-update PRs deliberately modify CHANGELOG.md, # so skip comment management entirely for these automation flows. if: >- @@ -308,7 +307,6 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Mark the CHANGELOG.md concern as resolved - id: resolve-changelog-warning if: >- needs.other-checks.outputs.changelog-modified != 'true' && !startsWith(github.event.pull_request.head.ref, 'update-v') && From c069d49e81684ff52f0b839a095bfb58194720f9 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Wed, 28 Oct 2026 16:46:41 -0500 Subject: [PATCH 26/39] Test sticky comments using observable state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pr-checks/post-comment.test.ts | 167 ++++++++++++++++++--------------- 1 file changed, 93 insertions(+), 74 deletions(-) diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index de840427dc..7babddcff3 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -75,18 +75,6 @@ describe("parseOptions", async () => { /Missing required flag --marker/, ); }); - - await it("rejects unknown flags using parseArgs", () => { - assert.throws(() => parseOptions([...validFlags, "--unknown", "value"]), { - code: "ERR_PARSE_ARGS_UNKNOWN_OPTION", - }); - }); - - await it("rejects flags without a value using parseArgs", () => { - assert.throws(() => parseOptions([...validFlags, "--body"]), { - code: "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", - }); - }); }); describe("resolveToken", async () => { @@ -108,27 +96,34 @@ describe("resolveToken", async () => { /** A minimal fake of the subset of `ApiClient` used by post-comment.ts. */ function fakeClient(existingComments: Array<{ id: number; body?: string }>) { - const calls: { - createComment: unknown[]; - updateComment: unknown[]; - } = { createComment: [], updateComment: [] }; + const comments = existingComments.map((comment) => ({ ...comment })); + let nextId = + comments.reduce((maxId, comment) => Math.max(maxId, comment.id), 0) + 1; const client = { - paginate: async () => existingComments, + paginate: async () => comments, rest: { issues: { listComments: () => {}, - createComment: async (params: unknown) => { - calls.createComment.push(params); + createComment: async ({ body }: { body: string }) => { + comments.push({ id: nextId++, body }); }, - updateComment: async (params: unknown) => { - calls.updateComment.push(params); + updateComment: async ({ + comment_id, + body, + }: { + comment_id: number; + body: string; + }) => { + const comment = comments.find(({ id }) => id === comment_id); + assert.ok(comment, `Comment ${comment_id} does not exist.`); + comment.body = body; }, }, }, }; - return { client: client as unknown as ApiClient, calls }; + return { client: client as unknown as ApiClient, comments }; } describe("findExistingComment", async () => { @@ -167,85 +162,109 @@ const baseOptions: Options = { repository: { owner: "owner", repo: "repo" }, }; +function assertCommentContent( + comment: { body?: string }, + marker: string, + body: string, +): void { + assert.ok(comment.body?.startsWith(marker)); + assert.ok(comment.body?.endsWith(body)); +} + describe("setComment", async () => { await it("uses the supplied marker without affecting other comments", async () => { const marker = ""; - const { client, calls } = fakeClient([ - { id: 1, body: "\nunrelated warning" }, + const { client, comments } = fakeClient([ + { id: 1, body: `${baseOptions.marker}\nunrelated warning` }, { id: 2, body: `${marker}\nold warning` }, ]); const options = parseOptions(withFlag("--marker", marker)); + await setComment(client, options); - assert.deepEqual(calls, { - createComment: [], - updateComment: [ - { - ...options.repository, - comment_id: 2, - body: `${marker}\nhello`, - }, - ], + + // Only the comment with the supplied marker changes. + assert.equal(comments.length, 2); + assert.deepEqual(comments[0], { + id: 1, + body: `${baseOptions.marker}\nunrelated warning`, }); + assert.equal(comments[1].id, 2); + assertCommentContent(comments[1], marker, options.body); }); await it("creates a new comment with the marker prepended", async () => { - const { client, calls } = fakeClient([]); + const { client, comments } = fakeClient([]); + // No comment has the marker before posting. + assert.equal(comments.length, 0); + assert.ok( + !comments.some(({ body }) => body?.startsWith(baseOptions.marker)), + ); + await setComment(client, baseOptions); - assert.deepEqual(calls.createComment, [ - { - owner: "owner", - repo: "repo", - issue_number: 42, - body: "\nhello", - }, - ]); - assert.deepEqual(calls.updateComment, []); + + // Posting adds exactly one comment with the marker before the body. + assert.equal(comments.length, 1); + assertCommentContent(comments[0], baseOptions.marker, baseOptions.body); }); await it("creates a new comment when no existing comment matches", async () => { - const { client, calls } = fakeClient([{ id: 1, body: "unrelated" }]); + const originalComments = [ + { id: 7, body: "unrelated" }, + { id: 2, body: "another unrelated comment" }, + ]; + const { client, comments } = fakeClient(originalComments); + // Existing comments do not match the marker. + assert.ok( + !comments.some(({ body }) => body?.startsWith(baseOptions.marker)), + ); + await setComment(client, baseOptions); - assert.deepEqual(calls.createComment, [ - { - owner: "owner", - repo: "repo", - issue_number: 42, - body: "\nhello", - }, - ]); - assert.deepEqual(calls.updateComment, []); + + // A new comment is added without changing unrelated comments. + assert.equal(comments.length, originalComments.length + 1); + assert.deepEqual( + comments.slice(0, originalComments.length), + originalComments, + ); + const markedComments = comments.filter(({ body }) => + body?.startsWith(baseOptions.marker), + ); + // Exactly one comment contains the expected marker and body. + assert.equal(markedComments.length, 1); + assertCommentContent( + markedComments[0], + baseOptions.marker, + baseOptions.body, + ); + // The new comment's ID does not collide with existing IDs. + assert.equal(new Set(comments.map(({ id }) => id)).size, comments.length); }); await it("updates the existing comment when one is found", async () => { - const { client, calls } = fakeClient([ - { id: 7, body: "\nold" }, + const { client, comments } = fakeClient([ + { id: 7, body: `${baseOptions.marker}\nold` }, ]); + await setComment(client, baseOptions); - assert.deepEqual(calls.updateComment, [ - { - owner: "owner", - repo: "repo", - comment_id: 7, - body: "\nhello", - }, - ]); - assert.deepEqual(calls.createComment, []); + + // Updating preserves the comment's ID without creating a duplicate. + assert.equal(comments.length, 1); + assert.equal(comments[0].id, 7); + assertCommentContent(comments[0], baseOptions.marker, baseOptions.body); }); await it("replaces a warning with the resolved state using the same marker", async () => { - const { client, calls } = fakeClient([ - { id: 9, body: "\nold" }, + const { client, comments } = fakeClient([ + { id: 9, body: `${baseOptions.marker}\nold` }, ]); const body = "`CHANGELOG.md` is not modified in this PR. No changelog action is needed."; + await setComment(client, { ...baseOptions, body }); - assert.deepEqual(calls.updateComment, [ - { - ...baseOptions.repository, - comment_id: 9, - body: `${baseOptions.marker}\n${body}`, - }, - ]); - assert.deepEqual(calls.createComment, []); + + // The resolved state replaces the warning on the same marked comment. + assert.equal(comments.length, 1); + assert.equal(comments[0].id, 9); + assertCommentContent(comments[0], baseOptions.marker, body); }); }); From afaf32464607c0552afd8d45aacd5b8a06c400bb Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 09:49:40 -0500 Subject: [PATCH 27/39] Move `resolveToken` to `pr-checks/api-client.ts` --- pr-checks/api-client.ts | 11 +++++++++++ pr-checks/post-comment.test.ts | 3 +-- pr-checks/post-comment.ts | 13 +------------ 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/pr-checks/api-client.ts b/pr-checks/api-client.ts index b8f914d815..8f990362b9 100644 --- a/pr-checks/api-client.ts +++ b/pr-checks/api-client.ts @@ -8,3 +8,14 @@ export function getApiClient(token: string): ApiClient { const opts = githubUtils.getOctokitOptions(token); return new githubUtils.GitHub(opts); } + +/** Resolves the GitHub API token to use, from `GH_TOKEN` or `GITHUB_TOKEN`. */ +export function resolveToken(env: NodeJS.ProcessEnv): string { + const token = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim(); + if (!token) { + throw new Error( + "Missing authentication token. Set GH_TOKEN or GITHUB_TOKEN.", + ); + } + return token; +} diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index 7babddcff3..8c1ad5d837 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -7,12 +7,11 @@ Tests for post-comment.ts. import * as assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { type ApiClient } from "./api-client"; +import { type ApiClient, resolveToken } from "./api-client"; import { findExistingComment, type Options, parseOptions, - resolveToken, setComment, } from "./post-comment"; diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 3f8abe40bd..81c42f25a0 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -17,7 +17,7 @@ The `.github/actions/post-comment` action requires callers to supply this value import assert from "node:assert/strict"; import { parseArgs } from "node:util"; -import { type ApiClient, getApiClient } from "./api-client"; +import { type ApiClient, getApiClient, resolveToken } from "./api-client"; /** Represents the command-line options. */ export interface Options { @@ -55,17 +55,6 @@ export function parseOptions(argv?: string[]): Options { }; } -/** Resolves the GitHub API token to use, from `GH_TOKEN` or `GITHUB_TOKEN`. */ -export function resolveToken(env: NodeJS.ProcessEnv): string { - const token = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim(); - if (!token) { - throw new Error( - "Missing authentication token. Set GH_TOKEN or GITHUB_TOKEN.", - ); - } - return token; -} - /** * Finds the first comment on the given issue/pull request whose body starts with `marker`, if any. */ From 3fb676f638e291635d5b0104f2f02bdfd9ff8b56 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 09:56:18 -0500 Subject: [PATCH 28/39] Use existing `RepositoryNwo` type --- pr-checks/post-comment.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 81c42f25a0..8c9703357a 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -17,6 +17,8 @@ The `.github/actions/post-comment` action requires callers to supply this value import assert from "node:assert/strict"; import { parseArgs } from "node:util"; +import { RepositoryNwo } from "../src/repository"; + import { type ApiClient, getApiClient, resolveToken } from "./api-client"; /** Represents the command-line options. */ @@ -28,7 +30,7 @@ export interface Options { /** The issue/pull request number to post/update the comment on. */ issueId: number; /** The repository to operate on. */ - repository: { owner: string; repo: string }; + repository: RepositoryNwo; } /** Parses CLI arguments supplied by the composite action into `Options`. */ @@ -60,7 +62,7 @@ export function parseOptions(argv?: string[]): Options { */ export async function findExistingComment( client: ApiClient, - repository: { owner: string; repo: string }, + repository: RepositoryNwo, issueNumber: number, marker: string, ): Promise { From 56fa196def3445e75b6f83e0451bb67e1a6d4753 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 09:58:48 -0500 Subject: [PATCH 29/39] Use YAML multiline string for the Action's inputs' descriptions --- .github/actions/post-comment/action.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml index 951fd17042..a71b57d9fb 100644 --- a/.github/actions/post-comment/action.yml +++ b/.github/actions/post-comment/action.yml @@ -2,13 +2,17 @@ name: "Post comment" description: "Create or update a sticky PR comment identified by a caller-provided marker." inputs: marker: - description: "Non-empty marker identifying the comment. Use a unique, stable HTML comment for each distinct comment." + description: > + Non-empty marker identifying the comment. Use a unique, + stable HTML comment for each distinct comment. required: true body: description: "Full comment markdown." required: true issue-id: - description: "The issue or pull request number to post/update the comment on. Defaults to the current pull request if not provided." + description: > + The issue or pull request number to post/update the comment on. + Defaults to the current pull request if not provided. required: false token: description: "The GitHub token to authenticate with." From 2902eeab4d2bd10d3f6abcb6ac76da08b77366ef Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 10:01:27 -0500 Subject: [PATCH 30/39] Checkout `main` instead of PR's base for the `post-pr-comments` job --- .github/workflows/pr-checks.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1e8b15ea60..fd3582c7de 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -241,9 +241,9 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Check out the base ref, not the PR's head, since this job runs with + # Check out main, not the PR's head, since this job runs with # `pull-requests: write` permissions and must not execute untrusted code from the PR. - ref: ${{ github.event.pull_request.base.sha }} + ref: main sparse-checkout: | pr-checks .github/actions/post-comment From 87cac24cddbd2ba55bff057328cf9c447e9afa95 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 10:03:32 -0500 Subject: [PATCH 31/39] Avoid sparse checkout This codebase is a tad coupled; it could end up biting us. --- .github/workflows/pr-checks.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index fd3582c7de..2193a69c75 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -244,9 +244,6 @@ jobs: # Check out main, not the PR's head, since this job runs with # `pull-requests: write` permissions and must not execute untrusted code from the PR. ref: main - sparse-checkout: | - pr-checks - .github/actions/post-comment - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 From 5bb3554330a44fde8c21454ae017571752c6abd8 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 10:13:06 -0500 Subject: [PATCH 32/39] Make input-validation assertions more granular --- pr-checks/post-comment.test.ts | 4 ++-- pr-checks/post-comment.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index 8c1ad5d837..d82f5d2492 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -64,14 +64,14 @@ describe("parseOptions", async () => { await it("rejects an empty --marker", () => { assert.throws( () => parseOptions(withFlag("--marker", "")), - /Missing required flag --marker/, + /The value for --marker cannot be empty/, ); }); await it("rejects a whitespace-only --marker", () => { assert.throws( () => parseOptions(withFlag("--marker", " \t\n")), - /Missing required flag --marker/, + /The value for --marker cannot be empty/, ); }); }); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 8c9703357a..20dad0cf08 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -46,12 +46,16 @@ export function parseOptions(argv?: string[]): Options { strict: true, }); - assert(values.marker?.trim(), "Missing required flag --marker."); + assert(values.marker !== undefined, "Missing required flag --marker."); + assert( + values.marker.trim() !== "", + "The value for --marker cannot be empty.", + ); const [owner, repo] = values.repository!.split("/"); return { body: values.body!, - marker: values.marker!, + marker: values.marker, issueId: Number(values["issue-id"]), repository: { owner, repo }, }; From f13bcf383fda63325ba85093fdac7a37daa38121 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 10:20:21 -0500 Subject: [PATCH 33/39] Warn if more than one marked comment was found --- pr-checks/post-comment.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 20dad0cf08..6c9542d298 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -75,7 +75,17 @@ export async function findExistingComment( issue_number: issueNumber, per_page: 100, }); - return comments.find((comment) => comment.body?.startsWith(marker))?.id; + const markedComments = comments.filter((comment) => + comment.body?.startsWith(marker), + ); + if (markedComments.length > 1) { + console.warn( + `Found ${markedComments.length} comments with marker "${marker}". ` + + "Updating the first one and leaving the others unchanged." + + "Use a unique marker for each distinct managed comment to avoid this situation.", + ); + } + return markedComments[0]?.id; } /** From 19e1ab5452dea5c862ca7af856b474750afa1dc4 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 10:28:26 -0500 Subject: [PATCH 34/39] Update `pr-checks/post-comment.ts` description comment --- pr-checks/post-comment.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 6c9542d298..1b55c13b20 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -1,17 +1,19 @@ #!/usr/bin/env npx tsx /* -Generic CLI for creating or updating a "sticky" PR comment identified by a hidden -HTML-comment marker. This centralizes the gh api logic previously duplicated across PR-check -workflow steps that each managed their own marked comment (e.g. a CHANGELOG.md modification -warning). - -The marker is passed explicitly via `--marker` and can be any non-empty string, though an -HTML comment (e.g. ``) is recommended so it doesn't render visibly. It is used -both to find a pre-existing comment on the pull request to update, and is automatically prepended to -`--body` when posting or updating a comment so that future invocations can find it again. -The `.github/actions/post-comment` action requires callers to supply this value via its -`marker` input. +Creates or updates a pull request comment identified by a marker. The marker +is supplied via `--marker` (or the `.github/actions/post-comment` action's `marker` input) +and must be non-empty. The marker is prepended to `--body` and used to find +the comment on later runs. + +Example: +```sh +GH_TOKEN= npx tsx ./pr-checks/post-comment.ts \ + --body "The changelog needs updating." \ + --marker "" \ + --issue-id 123 \ + --repository github/codeql-action +``` */ import assert from "node:assert/strict"; From 9359b6b57145a73e9dcf89286a67594cbbf528db Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 10:34:12 -0500 Subject: [PATCH 35/39] Wrap `--marker` argument in HTML comment tags automatically --- .github/actions/post-comment/action.yml | 4 ++-- .github/workflows/pr-checks.yml | 4 ++-- pr-checks/post-comment.test.ts | 30 ++++++++++++------------- pr-checks/post-comment.ts | 19 ++++++++++------ 4 files changed, 31 insertions(+), 26 deletions(-) diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml index a71b57d9fb..8055bf21da 100644 --- a/.github/actions/post-comment/action.yml +++ b/.github/actions/post-comment/action.yml @@ -3,8 +3,8 @@ description: "Create or update a sticky PR comment identified by a caller-provid inputs: marker: description: > - Non-empty marker identifying the comment. Use a unique, - stable HTML comment for each distinct comment. + Non-empty plain-text identifier for the comment. Use a unique, stable value + for each distinct comment; it is automatically wrapped in a hidden HTML comment. required: true body: description: "Full comment markdown." diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2193a69c75..eda0850499 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -295,7 +295,7 @@ jobs: !startsWith(github.event.pull_request.head.ref, 'update-bundle/') uses: ./.github/actions/post-comment with: - marker: "" + marker: "post-comment:CHANGELOG.md" body: | ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` @@ -312,7 +312,7 @@ jobs: !startsWith(github.event.pull_request.head.ref, 'update-bundle/') uses: ./.github/actions/post-comment with: - marker: "" + marker: "post-comment:CHANGELOG.md" body: | ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index d82f5d2492..960e7a0f0b 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -20,7 +20,7 @@ const validFlags = [ "--body", "hello", "--marker", - "", + "marker", "--issue-id", "42", "--repository", @@ -48,7 +48,7 @@ describe("parseOptions", async () => { const options = parseOptions(validFlags); assert.deepEqual(options, { body: "hello", - marker: "", + marker: "marker", issueId: 42, repository: { owner: "github", repo: "codeql-action" }, }); @@ -136,7 +136,7 @@ describe("findExistingComment", async () => { client, { owner: "owner", repo: "repo" }, 1, - "", + "marker", ); assert.equal(id, 2); }); @@ -147,7 +147,7 @@ describe("findExistingComment", async () => { client, { owner: "owner", repo: "repo" }, 1, - "", + "marker", ); assert.equal(id, undefined); }); @@ -156,7 +156,7 @@ describe("findExistingComment", async () => { /** A baseline set of `Options`, for use in `setComment` tests. */ const baseOptions: Options = { body: "hello", - marker: "", + marker: "marker", issueId: 42, repository: { owner: "owner", repo: "repo" }, }; @@ -166,16 +166,16 @@ function assertCommentContent( marker: string, body: string, ): void { - assert.ok(comment.body?.startsWith(marker)); + assert.ok(comment.body?.startsWith(``)); assert.ok(comment.body?.endsWith(body)); } describe("setComment", async () => { await it("uses the supplied marker without affecting other comments", async () => { - const marker = ""; + const marker = "custom-warning"; const { client, comments } = fakeClient([ - { id: 1, body: `${baseOptions.marker}\nunrelated warning` }, - { id: 2, body: `${marker}\nold warning` }, + { id: 1, body: "\nunrelated warning" }, + { id: 2, body: "\nold warning" }, ]); const options = parseOptions(withFlag("--marker", marker)); @@ -185,7 +185,7 @@ describe("setComment", async () => { assert.equal(comments.length, 2); assert.deepEqual(comments[0], { id: 1, - body: `${baseOptions.marker}\nunrelated warning`, + body: "\nunrelated warning", }); assert.equal(comments[1].id, 2); assertCommentContent(comments[1], marker, options.body); @@ -196,7 +196,7 @@ describe("setComment", async () => { // No comment has the marker before posting. assert.equal(comments.length, 0); assert.ok( - !comments.some(({ body }) => body?.startsWith(baseOptions.marker)), + !comments.some(({ body }) => body?.startsWith("")), ); await setComment(client, baseOptions); @@ -214,7 +214,7 @@ describe("setComment", async () => { const { client, comments } = fakeClient(originalComments); // Existing comments do not match the marker. assert.ok( - !comments.some(({ body }) => body?.startsWith(baseOptions.marker)), + !comments.some(({ body }) => body?.startsWith("")), ); await setComment(client, baseOptions); @@ -226,7 +226,7 @@ describe("setComment", async () => { originalComments, ); const markedComments = comments.filter(({ body }) => - body?.startsWith(baseOptions.marker), + body?.startsWith(""), ); // Exactly one comment contains the expected marker and body. assert.equal(markedComments.length, 1); @@ -241,7 +241,7 @@ describe("setComment", async () => { await it("updates the existing comment when one is found", async () => { const { client, comments } = fakeClient([ - { id: 7, body: `${baseOptions.marker}\nold` }, + { id: 7, body: "\nold" }, ]); await setComment(client, baseOptions); @@ -254,7 +254,7 @@ describe("setComment", async () => { await it("replaces a warning with the resolved state using the same marker", async () => { const { client, comments } = fakeClient([ - { id: 9, body: `${baseOptions.marker}\nold` }, + { id: 9, body: "\nold" }, ]); const body = "`CHANGELOG.md` is not modified in this PR. No changelog action is needed."; diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 1b55c13b20..7f805d1ee9 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -3,14 +3,14 @@ /* Creates or updates a pull request comment identified by a marker. The marker is supplied via `--marker` (or the `.github/actions/post-comment` action's `marker` input) -and must be non-empty. The marker is prepended to `--body` and used to find -the comment on later runs. +and must be non-empty. Supply a plain-text identifier; the CLI wraps it in a +hidden HTML comment, prepends it to `--body`, and uses it to find the comment on later runs. Example: ```sh GH_TOKEN= npx tsx ./pr-checks/post-comment.ts \ --body "The changelog needs updating." \ - --marker "" \ + --marker "my-bot" \ --issue-id 123 \ --repository github/codeql-action ``` @@ -27,7 +27,7 @@ import { type ApiClient, getApiClient, resolveToken } from "./api-client"; export interface Options { /** The full comment body. */ body: string; - /** The hidden HTML-comment marker used to find a pre-existing comment. */ + /** Plain-text identifier wrapped in a hidden HTML comment. */ marker: string; /** The issue/pull request number to post/update the comment on. */ issueId: number; @@ -35,6 +35,10 @@ export interface Options { repository: RepositoryNwo; } +function hiddenMarker(marker: string): string { + return ``; +} + /** Parses CLI arguments supplied by the composite action into `Options`. */ export function parseOptions(argv?: string[]): Options { const { values } = parseArgs({ @@ -72,17 +76,18 @@ export async function findExistingComment( issueNumber: number, marker: string, ): Promise { + const formattedMarker = hiddenMarker(marker); const comments = await client.paginate(client.rest.issues.listComments, { ...repository, issue_number: issueNumber, per_page: 100, }); const markedComments = comments.filter((comment) => - comment.body?.startsWith(marker), + comment.body?.startsWith(formattedMarker), ); if (markedComments.length > 1) { console.warn( - `Found ${markedComments.length} comments with marker "${marker}". ` + + `Found ${markedComments.length} comments with marker "${formattedMarker}". ` + "Updating the first one and leaving the others unchanged." + "Use a unique marker for each distinct managed comment to avoid this situation.", ); @@ -98,7 +103,7 @@ export async function setComment( options: Options, ): Promise { const { issueId, marker, repository } = options; - const body = `${marker}\n${options.body}`; + const body = `${hiddenMarker(marker)}\n${options.body}`; const existingCommentId = await findExistingComment( client, From e9651489af039bd0669aee0c8c9069cecfcc5690 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 10:44:10 -0500 Subject: [PATCH 36/39] Avoid non-null assertion operators by asserting arguments --- pr-checks/post-comment.test.ts | 49 ++++++++++++++++++++++++++++++++++ pr-checks/post-comment.ts | 18 +++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index 960e7a0f0b..7e02c4900a 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -54,6 +54,27 @@ describe("parseOptions", async () => { }); }); + await it("rejects a missing --body", () => { + assert.throws( + () => parseOptions(withoutFlag("--body")), + /Missing required flag --body/, + ); + }); + + await it("rejects an empty --body", () => { + assert.throws( + () => parseOptions(withFlag("--body", "")), + /The value for --body cannot be empty/, + ); + }); + + await it("rejects a whitespace-only --body", () => { + assert.throws( + () => parseOptions(withFlag("--body", " \t\n")), + /The value for --body cannot be empty/, + ); + }); + await it("rejects a missing --marker", () => { assert.throws( () => parseOptions(withoutFlag("--marker")), @@ -74,6 +95,34 @@ describe("parseOptions", async () => { /The value for --marker cannot be empty/, ); }); + + await it("rejects a missing --repository", () => { + assert.throws( + () => parseOptions(withoutFlag("--repository")), + /Missing required flag --repository/, + ); + }); + + await it("rejects an empty --repository", () => { + assert.throws( + () => parseOptions(withFlag("--repository", "")), + /The value for --repository cannot be empty/, + ); + }); + + await it("rejects a whitespace-only --repository", () => { + assert.throws( + () => parseOptions(withFlag("--repository", " \t\n")), + /The value for --repository cannot be empty/, + ); + }); + + await it("rejects an invalid --repository value", () => { + assert.throws( + () => parseOptions(withFlag("--repository", "github/codeql/action")), + /Invalid --repository value/, + ); + }); }); describe("resolveToken", async () => { diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index 7f805d1ee9..e254221eb5 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -52,15 +52,29 @@ export function parseOptions(argv?: string[]): Options { strict: true, }); + assert(values.body !== undefined, "Missing required flag --body."); + assert(values.body.trim() !== "", "The value for --body cannot be empty."); assert(values.marker !== undefined, "Missing required flag --marker."); assert( values.marker.trim() !== "", "The value for --marker cannot be empty.", ); - const [owner, repo] = values.repository!.split("/"); + assert( + values.repository !== undefined, + "Missing required flag --repository.", + ); + assert( + values.repository.trim() !== "", + "The value for --repository cannot be empty.", + ); + assert( + /^[\w.-]+\/[\w.-]+$/.test(values.repository), + "Invalid --repository value.", + ); + const [owner, repo] = values.repository.split("/"); return { - body: values.body!, + body: values.body, marker: values.marker, issueId: Number(values["issue-id"]), repository: { owner, repo }, From 94f0998581cb14903318950325bef8c060c04abe Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 11:32:57 -0500 Subject: [PATCH 37/39] Avoid creating resolved comments without an existing warning Use a boolean --create-if-missing flag, supplied by the composite action by default and omitted for resolution updates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/post-comment/action.yml | 6 ++++++ .github/workflows/pr-checks.yml | 1 + pr-checks/post-comment.test.ts | 28 ++++++++++++++++++++++++- pr-checks/post-comment.ts | 10 +++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml index 8055bf21da..bf8a39b481 100644 --- a/.github/actions/post-comment/action.yml +++ b/.github/actions/post-comment/action.yml @@ -9,6 +9,10 @@ inputs: body: description: "Full comment markdown." required: true + create-if-missing: + description: "Create a comment if no matching marker exists. Set to 'false' to update existing comments only." + required: false + default: "true" issue-id: description: > The issue or pull request number to post/update the comment on. @@ -25,11 +29,13 @@ runs: env: BODY: ${{ inputs.body }} MARKER: ${{ inputs.marker }} + CREATE_IF_MISSING: ${{ inputs.create-if-missing != 'false' && '--create-if-missing' || '' }} GH_TOKEN: ${{ inputs.token }} ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }} run: | npx tsx ./pr-checks/post-comment.ts \ --body "$BODY" \ --marker "$MARKER" \ + $CREATE_IF_MISSING \ --issue-id "$ISSUE_ID" \ --repository "$GITHUB_REPOSITORY" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index eda0850499..7398391d3c 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -313,6 +313,7 @@ jobs: uses: ./.github/actions/post-comment with: marker: "post-comment:CHANGELOG.md" + create-if-missing: "false" body: | ⚠️ `CHANGELOG.md` has been modified in this PR. Please do not modify `CHANGELOG.md` directly. Instead, create a change-note file in `unreleased-change-notes/`. The `CHANGELOG.md` diff --git a/pr-checks/post-comment.test.ts b/pr-checks/post-comment.test.ts index 7e02c4900a..63e0f6caad 100644 --- a/pr-checks/post-comment.test.ts +++ b/pr-checks/post-comment.test.ts @@ -49,11 +49,17 @@ describe("parseOptions", async () => { assert.deepEqual(options, { body: "hello", marker: "marker", + createIfMissing: false, issueId: 42, repository: { owner: "github", repo: "codeql-action" }, }); }); + await it("enables creation when --create-if-missing is supplied", () => { + const options = parseOptions([...validFlags, "--create-if-missing"]); + assert.equal(options.createIfMissing, true); + }); + await it("rejects a missing --body", () => { assert.throws( () => parseOptions(withoutFlag("--body")), @@ -206,6 +212,7 @@ describe("findExistingComment", async () => { const baseOptions: Options = { body: "hello", marker: "marker", + createIfMissing: true, issueId: 42, repository: { owner: "owner", repo: "repo" }, }; @@ -220,6 +227,25 @@ function assertCommentContent( } describe("setComment", async () => { + await it("does not create a resolved comment when no comments exist", async () => { + const { client, comments } = fakeClient([]); + + await setComment(client, { ...baseOptions, createIfMissing: false }); + + // Resolution does not introduce a warning that was never posted. + assert.deepEqual(comments, []); + }); + + await it("leaves unrelated comments unchanged when creation is disabled", async () => { + const originalComments = [{ id: 7, body: "unrelated" }]; + const { client, comments } = fakeClient(originalComments); + + await setComment(client, { ...baseOptions, createIfMissing: false }); + + // Unrelated comments neither trigger resolution nor get overwritten. + assert.deepEqual(comments, originalComments); + }); + await it("uses the supplied marker without affecting other comments", async () => { const marker = "custom-warning"; const { client, comments } = fakeClient([ @@ -308,7 +334,7 @@ describe("setComment", async () => { const body = "`CHANGELOG.md` is not modified in this PR. No changelog action is needed."; - await setComment(client, { ...baseOptions, body }); + await setComment(client, { ...baseOptions, body, createIfMissing: false }); // The resolved state replaces the warning on the same marked comment. assert.equal(comments.length, 1); diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index e254221eb5..bcb598872f 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -5,10 +5,12 @@ Creates or updates a pull request comment identified by a marker. The marker is supplied via `--marker` (or the `.github/actions/post-comment` action's `marker` input) and must be non-empty. Supply a plain-text identifier; the CLI wraps it in a hidden HTML comment, prepends it to `--body`, and uses it to find the comment on later runs. +Supply `--create-if-missing` to allow creation; omit it to update existing comments only. Example: ```sh GH_TOKEN= npx tsx ./pr-checks/post-comment.ts \ + --create-if-missing \ --body "The changelog needs updating." \ --marker "my-bot" \ --issue-id 123 \ @@ -29,6 +31,8 @@ export interface Options { body: string; /** Plain-text identifier wrapped in a hidden HTML comment. */ marker: string; + /** Whether to create a comment when no matching marker exists. */ + createIfMissing: boolean; /** The issue/pull request number to post/update the comment on. */ issueId: number; /** The repository to operate on. */ @@ -46,6 +50,7 @@ export function parseOptions(argv?: string[]): Options { options: { body: { type: "string" }, marker: { type: "string" }, + "create-if-missing": { type: "boolean", default: false }, "issue-id": { type: "string" }, repository: { type: "string" }, }, @@ -76,6 +81,7 @@ export function parseOptions(argv?: string[]): Options { return { body: values.body, marker: values.marker, + createIfMissing: values["create-if-missing"], issueId: Number(values["issue-id"]), repository: { owner, repo }, }; @@ -126,6 +132,10 @@ export async function setComment( marker, ); if (existingCommentId === undefined) { + if (!options.createIfMissing) { + console.info("No existing comment found; skipping creation."); + return; + } console.info("No existing comment found; creating a new comment."); await client.rest.issues.createComment({ ...repository, From 1304cc4df1eace8988996316cfd59be1288be6c4 Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 11:37:40 -0500 Subject: [PATCH 38/39] Potential fix for pull request finding 'CodeQL / Missing space in string concatenation' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- pr-checks/post-comment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr-checks/post-comment.ts b/pr-checks/post-comment.ts index bcb598872f..b9081c5a1d 100644 --- a/pr-checks/post-comment.ts +++ b/pr-checks/post-comment.ts @@ -108,7 +108,7 @@ export async function findExistingComment( if (markedComments.length > 1) { console.warn( `Found ${markedComments.length} comments with marker "${formattedMarker}". ` + - "Updating the first one and leaving the others unchanged." + + "Updating the first one and leaving the others unchanged. " + "Use a unique marker for each distinct managed comment to avoid this situation.", ); } From 68a9dcbb350a27ed37657e9060ee307e988fd29c Mon Sep 17 00:00:00 2001 From: Mario Campos Date: Thu, 29 Oct 2026 19:46:00 -0500 Subject: [PATCH 39/39] Prefer `== true` over `!= false` --- .github/actions/post-comment/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/post-comment/action.yml b/.github/actions/post-comment/action.yml index bf8a39b481..56845e2b66 100644 --- a/.github/actions/post-comment/action.yml +++ b/.github/actions/post-comment/action.yml @@ -29,7 +29,7 @@ runs: env: BODY: ${{ inputs.body }} MARKER: ${{ inputs.marker }} - CREATE_IF_MISSING: ${{ inputs.create-if-missing != 'false' && '--create-if-missing' || '' }} + CREATE_IF_MISSING: ${{ inputs.create-if-missing == 'true' && '--create-if-missing' || '' }} GH_TOKEN: ${{ inputs.token }} ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }} run: |