This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 6a71d204a fix(ci): handle fork-PR triage commands via workflow_run
handoff (#3268)
6a71d204a is described below
commit 6a71d204ab2b723393b81cbaf55392556fbad4c4
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Fri May 15 12:37:17 2026 +0200
fix(ci): handle fork-PR triage commands via workflow_run handoff (#3268)
---
.../{pr-triage.yml => pr-triage-apply.yml} | 380 ++++++++++++++-------
.github/workflows/pr-triage-collect.yml | 59 ++++
CONTRIBUTING.md | 32 +-
3 files changed, 334 insertions(+), 137 deletions(-)
diff --git a/.github/workflows/pr-triage.yml
b/.github/workflows/pr-triage-apply.yml
similarity index 60%
rename from .github/workflows/pr-triage.yml
rename to .github/workflows/pr-triage-apply.yml
index 2ee0f9df8..c250b53ee 100644
--- a/.github/workflows/pr-triage.yml
+++ b/.github/workflows/pr-triage-apply.yml
@@ -15,11 +15,20 @@
# specific language governing permissions and limitations
# under the License.
-name: PR Triage
+name: PR Triage Apply
# Comment-driven PR triage and lifecycle labels. Inspired by
# rust-lang/triagebot UX.
#
+# Two-stage design to defeat the fork-PR token restriction:
+# pr-triage-collect.yml runs on issue_comment / pull_request_review with
+# a read-only GITHUB_TOKEN (forced for cross-fork events) and uploads
+# the raw event payload as artifact `triage-event`. This workflow then
+# fires via workflow_run on the base ref, where the GITHUB_TOKEN has
+# write perms, downloads the artifact, and applies the labels.
+# pull_request_target lifecycle is handled directly here without going
+# through the collector — it already gets a write token by design.
+#
# Comment commands (parsed line-by-line in PR comments and review bodies):
# /request-review @u [@u2 ...] — request review from one or more
# @users or @org/teams; the command may
@@ -29,7 +38,12 @@ name: PR Triage
#
# Auth gate:
# /request-review, /ready -> org MEMBER / repo COLLABORATOR/OWNER, or PR
author
-# /author -> org MEMBER / repo COLLABORATOR/OWNER only
+# /author -> CONTRIBUTOR / MEMBER / COLLABORATOR / OWNER
+# (anyone with author_association=CONTRIBUTOR has
+# at least one merged commit and is trusted to
+# flip any PR back to the author queue, matching
+# the implicit /author from a changes_requested
+# review)
# changes_requested review -> CONTRIBUTOR / MEMBER / COLLABORATOR / OWNER
#
# Review state (pull_request_review.submitted):
@@ -54,31 +68,33 @@ name: PR Triage
# closed (merged or not) -> remove both S-* labels
#
# SECURITY:
-# - issue_comment.created, pull_request_review.submitted and
-# pull_request_target are the ONLY triggers.
+# - workflow_run, pull_request_target are the ONLY triggers here.
# - No actions/checkout of any ref. PR-controlled code is never executed.
# - The default GITHUB_TOKEN is never written to outputs, env files, or
# passed to user-supplied programs. The workflow only calls the GitHub
# REST API via actions/github-script.
-# - pull_request_target and pull_request_review run the base-repo workflow
-# with a write token so fork-PR labels can be applied. This is safe
-# because we never run fork code: no checkout, no exec of PR contents,
-# no token export. The review body is treated as untrusted text, parsed
-# only by the command regex. See
+# - pull_request_target runs the base-repo workflow with a write token so
+# fork-PR lifecycle labels can be applied. workflow_run also runs against
+# base ref. Both are safe because we never run fork code: no checkout,
+# no exec of PR contents, no token export. The review/comment body
+# reaches us via the artifact uploaded by pr-triage-collect.yml; it is
+# treated as untrusted text, parsed only by the command regex. See
#
https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
on:
- issue_comment:
- types: [created]
- pull_request_review:
- types: [submitted]
pull_request_target:
types: [opened, ready_for_review, converted_to_draft, closed]
+ workflow_run:
+ workflows: ["PR Triage Collect"]
+ types: [completed]
permissions:
pull-requests: write
issues: write
contents: read
+ # Required for actions/download-artifact@v8 to fetch the triage-event
+ # artifact uploaded by the upstream PR Triage Collect run via run-id.
+ actions: read
concurrency:
# cancel-in-progress: false keeps the active run, but GH still replaces
@@ -90,26 +106,85 @@ concurrency:
# /request-review burst in <1s lands at most 2 reviewers, larger bursts
# lose proportionally more. Pending-queue retention key (queue:) is not
# yet in the GH Actions schema; revisit when available.
- group: pr-triage-${{ github.event.pull_request.number ||
github.event.issue.number }}
+ #
+ # workflow_run leg cannot key on PR number until after artifact parse,
+ # so it falls back to the upstream run id. Each collect run is unique,
+ # so this widens the group to one-run-per-upstream-collect and same-PR
+ # bursts on this leg do NOT serialize. Correctness does not depend on
+ # serialization: the setLabels helper performs an atomic replace-all
+ # (list current labels, drop the S-* sibling, push target, single
+ # setLabels call), so two interleaved label flips converge on the
+ # last-finishing run's target instead of fabricating a both-labels
+ # state. Per-PR serialization on this leg is a noise/cost
+ # optimization, blocked on GH adding job-level concurrency so the
+ # parsed PR number can re-key.
+ group: pr-triage-apply-${{ github.event.pull_request.number ||
github.event.workflow_run.id }}
cancel-in-progress: false
jobs:
- triage:
+ apply:
if: |
- (github.event_name == 'issue_comment' && github.event.issue.pull_request
!= null)
- || github.event_name == 'pull_request_review'
- || github.event_name == 'pull_request_target'
+ github.event_name == 'pull_request_target'
+ || (github.event_name == 'workflow_run'
+ && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
+ - name: Download triage event artifact
+ if: github.event_name == 'workflow_run'
+ uses: actions/download-artifact@v8
+ with:
+ name: triage-event
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ path: payload/
+
+ - name: Parse triage event payload
+ if: github.event_name == 'workflow_run'
+ env:
+ UPSTREAM_EVENT: ${{ github.event.workflow_run.event }}
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ # The artifact contains exactly one file: the JSON event payload
+ # uploaded as `${{ github.event_path }}`. Its on-disk name is
+ # whatever the runner used (e.g. event.json); resolve it dynamically.
+ payload=( payload/*.json payload/event.json )
+ f=""
+ for candidate in "${payload[@]}"; do
+ [[ -f "$candidate" ]] && f="$candidate" && break
+ done
+ if [[ -z "$f" ]]; then
+ echo "triage payload not found under payload/" >&2
+ ls -la payload/ >&2 || true
+ exit 1
+ fi
+ # Body is attacker-controlled. Route it via a file rather than
+ # $GITHUB_ENV: heredoc terminators can be forged inside the body
+ # to close the block early and inject arbitrary env vars (e.g.
+ # NODE_OPTIONS, GITHUB_PATH) into the subsequent github-script
+ # step, which holds a write GITHUB_TOKEN. The other fields below
+ # are constrained single-line values (numeric ids, enum states,
+ # GH login charset) and can stay in $GITHUB_ENV.
+ jq -r '.comment.body // .review.body // ""' "$f" > payload/body.txt
+ {
+ echo "COMMENT_AUTHOR=$(jq -r '.comment.user.login //
.review.user.login // ""' "$f")"
+ echo "COMMENT_ASSOC=$(jq -r '.comment.author_association //
.review.author_association // ""' "$f")"
+ echo "COMMENT_ID=$(jq -r '.comment.id // ""' "$f")"
+ echo "PR_NUMBER=$(jq -r '.pull_request.number // .issue.number //
""' "$f")"
+ echo "REVIEW_STATE=$(jq -r '.review.state // ""' "$f")"
+ echo "TRIAGE_EVENT_NAME=${UPSTREAM_EVENT}"
+ } >> "$GITHUB_ENV"
+
- name: Dispatch
uses: actions/github-script@v9
env:
- COMMENT_BODY: ${{ github.event.comment.body ||
github.event.review.body }}
- COMMENT_AUTHOR: ${{ github.event.comment.user.login ||
github.event.review.user.login }}
- COMMENT_ASSOC: ${{ github.event.comment.author_association ||
github.event.review.author_association }}
- COMMENT_ID: ${{ github.event.comment.id }}
- PR_NUMBER: ${{ github.event.pull_request.number ||
github.event.issue.number }}
+ COMMENT_AUTHOR: ${{ env.COMMENT_AUTHOR }}
+ COMMENT_ASSOC: ${{ env.COMMENT_ASSOC }}
+ COMMENT_ID: ${{ env.COMMENT_ID }}
+ PR_NUMBER: ${{ env.PR_NUMBER || github.event.pull_request.number }}
+ REVIEW_STATE: ${{ env.REVIEW_STATE }}
+ TRIAGE_EVENT_NAME: ${{ env.TRIAGE_EVENT_NAME || github.event_name }}
with:
script: |
const LABEL_REVIEW = 'S-waiting-on-review';
@@ -122,6 +197,23 @@ jobs:
// the two cannot drift. Bots are excluded separately.
const REVIEW_AUTHOR_ASSOCS = new Set(['CONTRIBUTOR',
...COMMITTER_ASSOCS]);
const prNumber = Number(process.env.PR_NUMBER);
+ // Number('') === 0 and an unknown jq path also yields ''.
+ // Without this gate the run would silently issue API calls
+ // against issue_number: 0, get a 404 swallowed by the
+ // pulls.get catch below, and complete green with no labels
+ // applied. Fail loudly instead so the misconfiguration shows
+ // up as a red Actions run.
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
+ core.setFailed(`invalid PR_NUMBER: ${process.env.PR_NUMBER}`);
+ return;
+ }
+
+ // Effective event name. On the workflow_run leg the artifact
+ // payload was produced by issue_comment / pull_request_review,
+ // and the upstream event name was preserved into the env in
+ // the parse step. On the pull_request_target leg we read the
+ // GH-provided context.eventName directly.
+ const eventName = process.env.TRIAGE_EVENT_NAME ||
context.eventName;
// Welcome comment posted once when a non-draft PR is opened.
// The leading HTML marker is invisible in rendered Markdown
@@ -134,20 +226,18 @@ jobs:
'You can update that label as the review goes back and forth,
with slash commands - each on its own line, in a regular PR comment (not an
inline review reply):',
'',
'- `/ready` - mark it `S-waiting-on-review` again, after
addressing feedback',
- '- `/author` - mark it `S-waiting-on-author` (maintainers only)',
+ '- `/author` - mark it `S-waiting-on-author` (maintainers, or
anyone who has had a PR merged before)',
'- `/request-review @user ...` - request reviewers (`@user` or
`@org/team`)',
'',
+ 'Commands take up to ~90s to apply. If no reaction (👍 or 😕)
appears on your comment, the apply step likely failed - check the repo\'s
Actions tab for the `PR Triage Apply` run. Commands posted inside a review body
(rather than a normal comment) cannot be reacted to, so they stay log-only.',
+ '',
'See
[CONTRIBUTING.md](https://github.com/apache/iggy/blob/master/CONTRIBUTING.md#pr-triage-commands)
for details.',
].join('\n');
// Retry transient GitHub API failures so a single 502 cannot
- // abort a command. This matters most inside setLabels: a
- // remove that throws skips the paired add, and an add that
- // throws after the remove leaves the PR label-less — both
- // are states the lifecycle hooks never re-enter. Transient =
- // 5xx, 429, or a network error with no HTTP status. Anything
- // else (404, 403, 422) is not retried and surfaces to the
- // caller unchanged.
+ // abort a command. Transient = 5xx, 429, or a network error
+ // with no HTTP status. Anything else (404, 403, 422) is not
+ // retried and surfaces to the caller unchanged.
const withRetry = async (fn, label) => {
const delays = [500, 1500, 4000];
for (let attempt = 0; ; attempt++) {
@@ -166,61 +256,50 @@ jobs:
}
};
- // Returns true if a label was actually deleted, false if the
- // label was already absent (404). Callers use this to decide
- // whether a rollback re-add is warranted.
- const removeLabelIfPresent = async (name) => {
- try {
- await withRetry(() => github.rest.issues.removeLabel({
+ // Atomic replace-all on the S-* slot: list current labels,
+ // drop both S-* siblings, optionally push the target, single
+ // setLabels PUT. Used by both the command leg (add = the
+ // target state) and the lifecycle close/converted_to_draft
+ // path (add = null -> just clear both S-*). One primitive
+ // for both legs so the lifecycle DELETE pair cannot race the
+ // command-leg PUT and resurrect a cleared S-* on a closed PR.
+ //
+ // Per-PR concurrency is NOT guaranteed on the workflow_run
+ // leg (see concurrency: comment above), so two command runs
+ // can land in parallel. The PUT-style setLabels makes
+ // interleaved flips converge on the last-finishing run's
+ // target instead of fabricating a both-labels state (which
+ // the lifecycle hasState gate would then treat as terminal
+ // and never clean up).
+ //
+ // The list+setLabels pair is not atomic against unrelated
+ // mutators, so a racing /label add by another workflow
+ // between our list and our setLabels would be overwritten.
+ // Acceptable: iggy's other workflows do not touch labels.
+ //
+ // Pagination is mandatory: an unpaginated per_page: 100 fetch
+ // on a PR carrying more than 100 labels would silently drop
+ // every label beyond page 1 on the setLabels PUT.
+ const replaceStateLabel = async (add) => {
+ const live = await withRetry(() => github.paginate(
+ github.rest.issues.listLabelsOnIssue,
+ {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
- name,
- }), `removeLabel ${name}`);
- return true;
- } catch (e) {
- if (e.status !== 404) throw e;
- return false;
- }
- };
-
- const setLabels = async (add, remove) => {
- // Invariant: "neither S-* label" is recoverable (next command
- // sets the correct one); "both S-* labels" is NOT (lifecycle
- // hasState gate skips when any S-* is present, so a stuck
- // both-labels state never gets cleaned up). Remove-first is
- // strictly safer than add-first under crash mid-op.
- //
- // Both calls go through withRetry, so only a non-transient
- // error or an outage outlasting the retry budget reaches
- // these catch blocks. On add failure re-add the removed
- // label to restore the prior single-label state -- but only
- // when the remove actually deleted one. If `remove` was
- // already absent (PR had neither S-* label), re-adding it
- // would fabricate a label the PR never carried.
- const removed = await removeLabelIfPresent(remove);
- try {
- await withRetry(() => github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- labels: [add],
- }), `addLabels ${add}`);
- } catch (e) {
- if (removed) {
- try {
- await withRetry(() => github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- labels: [remove],
- }), `addLabels rollback ${remove}`);
- } catch (rollbackErr) {
- core.warning(`setLabels rollback failed:
${rollbackErr.message}`);
- }
- }
- throw e;
- }
+ per_page: 100,
+ },
+ ), 'listLabelsOnIssue (replaceStateLabel)');
+ const next = live
+ .map(l => l.name)
+ .filter(n => n !== LABEL_REVIEW && n !== LABEL_AUTHOR);
+ if (add) next.push(add);
+ await withRetry(() => github.rest.issues.setLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ labels: next,
+ }), `setLabels ${add ?? '(clear S-*)'}`);
};
// Best-effort commenter feedback. A failed reaction or reply
@@ -285,13 +364,19 @@ jobs:
// not double-post.
const postWelcomeOnce = async () => {
try {
- const existing = await withRetry(() =>
github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- per_page: 100,
- }), 'listComments');
- if (existing.data.some(c => c.body &&
c.body.includes(WELCOME_MARKER))) {
+ // Paginated so the marker is still detected if an Actions
+ // re-run fires after the PR has accumulated more than 100
+ // comments. Without it the welcome would double-post.
+ const existing = await withRetry(() => github.paginate(
+ github.rest.issues.listComments,
+ {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ per_page: 100,
+ },
+ ), 'listComments');
+ if (existing.some(c => c.body &&
c.body.includes(WELCOME_MARKER))) {
core.info('welcome: already posted, skipping');
return;
}
@@ -308,20 +393,21 @@ jobs:
};
// ----- pull_request_target lifecycle -----
- if (context.eventName === 'pull_request_target') {
+ if (eventName === 'pull_request_target') {
const action = context.payload.action;
const pr = context.payload.pull_request;
core.info(`lifecycle event=${action} draft=${pr.draft}`);
if (action === 'closed' || action === 'converted_to_draft') {
- // Always attempt the DELETE: pr.labels is the webhook
- // snapshot and can disagree with current state via
- // out-of-band edits or in-flight comment-handler races.
- // removeLabelIfPresent swallows 404 so two no-op calls on
- // a label-less PR are cheap.
- await removeLabelIfPresent(LABEL_REVIEW);
- await removeLabelIfPresent(LABEL_AUTHOR);
+ // Atomic clear via replaceStateLabel(null): a single
+ // setLabels PUT that drops both S-* in one call. Using
+ // the same primitive as the command leg closes the
+ // cross-leg TOCTOU where two independent removeLabel
+ // calls here could be interleaved with a command-leg
+ // list+PUT and let S-waiting-on-review resurrect on a
+ // closed PR.
+ await replaceStateLabel(null);
core.info(`lifecycle: cleared S-* labels (${action})`);
return;
}
@@ -337,23 +423,38 @@ jobs:
// comment is contributor onboarding. ready_for_review is
// excluded: a PR opened as a draft and later marked ready
// gets no welcome, keeping scope at "non-draft open".
+ // Bots (dependabot, renovate, ...) are skipped: the
+ // welcome targets human contributor onboarding and bots
+ // cannot drive triage commands anyway (gated by isBot).
if (action === 'opened') {
- if (await hasWriteAccess(pr.user.login)) {
- core.info(`welcome: skipped, ${pr.user.login} has write
access`);
+ const author = pr.user && pr.user.login;
+ const authorIsBot = (pr.user && pr.user.type === 'Bot')
+ || (author && /\[bot\]$/.test(author));
+ if (!author) {
+ core.info('welcome: skipped, PR has no author');
+ } else if (authorIsBot) {
+ core.info(`welcome: skipped, ${author} is a bot`);
+ } else if (await hasWriteAccess(author)) {
+ core.info(`welcome: skipped, ${author} has write access`);
} else {
await postWelcomeOnce();
}
}
// Read live labels rather than the webhook-frozen
// pr.labels — a comment-driven /author or /ready may have
- // landed between the lifecycle event and this run.
- const live = await withRetry(() =>
github.rest.issues.listLabelsOnIssue({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- per_page: 100,
- }), 'listLabelsOnIssue');
- const liveNames = live.data.map(l => l.name);
+ // landed between the lifecycle event and this run. Paginated
+ // so a PR with more than 100 labels still reports hasState
+ // correctly.
+ const live = await withRetry(() => github.paginate(
+ github.rest.issues.listLabelsOnIssue,
+ {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ per_page: 100,
+ },
+ ), 'listLabelsOnIssue');
+ const liveNames = live.map(l => l.name);
const hasState = liveNames.includes(LABEL_REVIEW)
|| liveNames.includes(LABEL_AUTHOR);
core.info(`lifecycle: has_state=${hasState}`);
@@ -376,17 +477,25 @@ jobs:
}
// ----- issue_comment / pull_request_review commands -----
- // Both event types feed the same path: COMMENT_BODY/_AUTHOR/
- // _ASSOC resolve from github.event.comment or .review in the
- // step env.
- const body = process.env.COMMENT_BODY || '';
+ // Both event types feed the same path: COMMENT_AUTHOR/_ASSOC/
+ // _ID, PR_NUMBER and REVIEW_STATE resolve from the artifact
+ // uploaded by pr-triage-collect.yml via the parse step above.
+ // body is routed through a file (payload/body.txt) rather
+ // than $GITHUB_ENV -- see the parse step's comment for the
+ // injection rationale. The file is absent on the
+ // pull_request_target leg (no parse step), and lifecycle
+ // returned above, so falling back to '' is safe.
+ const fs = require('fs');
+ const body = fs.existsSync('payload/body.txt')
+ ? fs.readFileSync('payload/body.txt', 'utf8')
+ : '';
// A changes_requested review is an implicit /author: the ball
// is back with the author. Gated by REVIEW_AUTHOR_ASSOCS
// (checked below) -- wider than the /author command. An
// explicit command in the review body still takes precedence.
- // payload.review is absent for issue_comment.
- const reviewState = context.payload.review?.state;
+ // REVIEW_STATE is empty for issue_comment.
+ const reviewState = process.env.REVIEW_STATE || '';
const reviewWantsAuthor = reviewState === 'changes_requested';
// Skip everything if there is neither a command line nor an
@@ -414,11 +523,13 @@ jobs:
// see REVIEW_AUTHOR_ASSOCS.
const isReviewFlipper = REVIEW_AUTHOR_ASSOCS.has(commentAssoc) &&
!isBot;
- // Live state read. payload.issue.state is the webhook-frozen
- // value at delivery; comment-while-open + close-arriving-later
- // would otherwise re-label a now-closed PR. Run on both
- // committer and non-committer paths — correctness over the
- // single API call saved on the committer fast-path.
+ // Live state read. The artifact carries the webhook-frozen
+ // payload at delivery time, plus we now have ~30s of
+ // artifact-handoff latency on top; comment-while-open +
+ // close-arriving-later would otherwise re-label a now-closed
+ // PR. Run on both committer and non-committer paths --
+ // correctness over the single API call saved on the
+ // committer fast-path.
let prData;
try {
prData = await withRetry(() => github.rest.pulls.get({
@@ -438,9 +549,20 @@ jobs:
core.info('PR is closed, skipping');
return;
}
+ // Reject command runs against a PR that turned draft after
+ // the comment was posted. The pull_request_target lifecycle
+ // clears S-* on converted_to_draft; without this gate the
+ // ~30-90s artifact-handoff window lets a stale command
+ // re-add S-waiting-on-review onto a now-draft PR.
+ if (prData.data.draft) {
+ core.info('PR is draft, skipping');
+ return;
+ }
- const prAuthor = prData.data.user.login;
- const isPrAuthor = commentAuthor === prAuthor && !isBot;
+ // pr.user can be null for deleted GitHub accounts; treat the
+ // PR as having no recognized author rather than crashing.
+ const prAuthor = (prData.data.user && prData.data.user.login) ||
null;
+ const isPrAuthor = !!prAuthor && commentAuthor === prAuthor &&
!isBot;
core.info(`comment_author=${commentAuthor} assoc=${commentAssoc} `
+
`committer=${isCommitter} pr_author=${isPrAuthor}
bot=${isBot}`);
@@ -508,7 +630,7 @@ jobs:
core.info(`/ready: ignored, ${commentAuthor} lacks
permission`);
denied = true;
} else {
- await setLabels(LABEL_REVIEW, LABEL_AUTHOR);
+ await replaceStateLabel(LABEL_REVIEW);
core.info(`/ready: ${LABEL_REVIEW} <- ${LABEL_AUTHOR}`);
applied = true;
}
@@ -516,11 +638,15 @@ jobs:
}
if (!sawAuthor && /^\/author(?:\s|$)/.test(line)) {
sawAuthor = true;
- if (!isCommitter) {
- core.info(`/author: ignored, ${commentAuthor} not a
committer`);
+ // Wider than /ready: anyone GitHub recognizes as a returning
+ // contributor (>=1 merged commit) can flip any PR to the
+ // author queue. Same gate as the implicit /author from a
+ // changes_requested review.
+ if (!isReviewFlipper) {
+ core.info(`/author: ignored, ${commentAuthor} not a
committer or returning contributor`);
denied = true;
} else {
- await setLabels(LABEL_AUTHOR, LABEL_REVIEW);
+ await replaceStateLabel(LABEL_AUTHOR);
core.info(`/author: ${LABEL_AUTHOR} <- ${LABEL_REVIEW}`);
applied = true;
}
@@ -555,7 +681,7 @@ jobs:
const why = e.status === 422
? 'a handle is not a repo collaborator, or the team is
unknown'
: `GitHub API error (${e.status ?? 'network'})`;
- await reply(`\`/request-review\`: could not request
${requested} — ${why}.`);
+ await reply(`\`/request-review\`: could not request
${requested} - ${why}.`);
}
}
@@ -569,7 +695,7 @@ jobs:
core.info(`review changes_requested: ignored, ` +
`${commentAuthor} (${commentAssoc}) not permitted`);
} else {
- await setLabels(LABEL_AUTHOR, LABEL_REVIEW);
+ await replaceStateLabel(LABEL_AUTHOR);
core.info(`review changes_requested: ${LABEL_AUTHOR} <-
${LABEL_REVIEW}`);
applied = true;
}
diff --git a/.github/workflows/pr-triage-collect.yml
b/.github/workflows/pr-triage-collect.yml
new file mode 100644
index 000000000..6e85f2eb2
--- /dev/null
+++ b/.github/workflows/pr-triage-collect.yml
@@ -0,0 +1,59 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: PR Triage Collect
+
+# Read-only collector for fork-restricted events. issue_comment and
+# pull_request_review on cross-fork PRs deliver a read-only GITHUB_TOKEN
+# regardless of the workflow's permissions: block, so the label mutations
+# cannot run here. This workflow uploads the raw event payload as an
+# artifact; pr-triage-apply.yml picks it up via workflow_run and applies
+# the labels with a write token from the base ref.
+#
+# pull_request_target is NOT routed through this collector — it already
+# runs against base ref with a write token and is handled directly in
+# pr-triage-apply.yml.
+#
+# SECURITY: no actions/checkout, no exec of PR contents, no token export.
+# The artifact contents come from the GitHub-runner-written event_path,
+# not from PR code.
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review:
+ types: [submitted]
+
+permissions:
+ contents: read
+
+jobs:
+ collect:
+ if: |
+ github.event_name == 'pull_request_review'
+ || (github.event_name == 'issue_comment'
+ && github.event.issue.pull_request != null)
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - name: Upload event payload
+ uses: actions/upload-artifact@v7
+ with:
+ name: triage-event
+ path: ${{ github.event_path }}
+ retention-days: 1
+ if-no-files-found: error
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 08aeffb29..bf1928937 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -106,8 +106,11 @@ Keep subject under 72 chars. Use body for details if
needed.
You can move a PR around the review queue by posting a slash command in the
PR conversation. The pattern is similar to `rust-lang/triagebot`. The
-machinery lives in
[`./.github/workflows/pr-triage.yml`](./.github/workflows/pr-triage.yml)
-if you want to peek.
+machinery lives in
+[`./.github/workflows/pr-triage-apply.yml`](./.github/workflows/pr-triage-apply.yml)
+(with a read-only collector in
+[`./.github/workflows/pr-triage-collect.yml`](./.github/workflows/pr-triage-collect.yml)
+that hops the token-permission gap for fork PRs) if you want to peek.
### Commands
@@ -115,7 +118,7 @@ if you want to peek.
| --- | --- | --- |
| `/request-review @user-or-team ...` | the PR author or a maintainer |
Requests review from one or more `@user` or `@org/team` handles |
| `/ready` | the PR author or a maintainer | Marks the PR
`S-waiting-on-review` |
-| `/author` | a maintainer | Marks the PR `S-waiting-on-author` |
+| `/author` | a maintainer, or any returning contributor (>=1 merged PR) |
Marks the PR `S-waiting-on-author` |
A "maintainer" here means someone with write access to apache/iggy
(in practice, the `@apache/iggy-committers` team). Automated comments from
@@ -164,10 +167,9 @@ for review".
Submitting a review with "Request changes" is treated as an implicit
`/author`: the PR moves to `S-waiting-on-author`. Anyone GitHub recognizes
-as a repo contributor or above can trigger this - a wider set than the
-`/author` command, which stays maintainer-only, since a formal "Request
-changes" review is a deliberate, attributable action. If your review body
-also contains an explicit `/ready` or `/author`, that command wins.
+as a repo contributor or above can trigger this - the same set that can
+issue an explicit `/author`. If your review body also contains an explicit
+`/ready` or `/author`, that command wins.
### Tips
@@ -185,6 +187,11 @@ also contains an explicit `/ready` or `/author`, that
command wins.
### When something goes wrong
+Commands take up to ~90s to apply: the read-only collector hands the
+event off to the write-capable apply workflow via an artifact, which
+adds latency on top of GitHub Actions scheduling. Wait for a reaction
+before re-issuing - duplicate commands can interleave.
+
The workflow reacts on your comment so you get quick feedback: a 👍 means
a command was applied, a 😕 means a command was recognized but you lacked
permission to run it. A failed `/request-review` also posts a one-line
@@ -192,9 +199,14 @@ reply naming the handles GitHub rejected. Commands posted
in a review body
(rather than a normal comment) cannot be reacted to, so they stay
log-only.
-For the full story, open the PR's "Checks" or "Actions" tab and look at
-the `PR Triage` run for the comment you posted. The run log says exactly
-what it saw and why (no permission, unknown user, etc.).
+If no reaction appears within a couple of minutes, the apply run likely
+failed. Open the repo's "Actions" tab and look at the `PR Triage Apply`
+run for the comment you posted - the run log says exactly what it saw
+and why (no permission, unknown user, transient API error, etc.). The
+`PR Triage Collect` run that appears on the PR's "Checks" tab is just
+the read-only event collector; the actual labelling happens in
+`PR Triage Apply`, which is triggered via `workflow_run` and is not
+attached to the PR's checks.
### Examples