diff --git a/.github/actions/check-action-pins/README.md b/.github/actions/check-action-pins/README.md index 29ae7d99..9dcb072a 100644 --- a/.github/actions/check-action-pins/README.md +++ b/.github/actions/check-action-pins/README.md @@ -34,7 +34,7 @@ jobs: ``` Fork pull requests need only `contents: read`. The scanner reads repository files; -it does not run their scripts. Do not switch to `pull_request_target` to run this +it does not run their scripts. Do not switch to a privileged PR event to run this check. For a required check, avoid workflow path filters that can leave it pending. ## Runner requirements diff --git a/.github/actions/commitlint/README.md b/.github/actions/commitlint/README.md index 03f370da..63797ee2 100644 --- a/.github/actions/commitlint/README.md +++ b/.github/actions/commitlint/README.md @@ -234,10 +234,10 @@ above, take expressions normally. `contents: read` is enough. The action makes no API calls and needs no token. -Run it on `pull_request`, not `pull_request_target`. On a fork pull request the +Run it on `pull_request`, not a privileged PR event. On a fork pull request the action checks out and installs from the fork's own manifest; `pull_request` grants no secrets and a read-only token, which is what makes that safe. -`pull_request_target` runs with the base repository's secrets and would turn +a privileged PR event runs with the base repository's secrets and would turn the same install into a credential-exfiltration path. ## Testing diff --git a/.github/actions/link-backport-prs/src/main_test.go b/.github/actions/link-backport-prs/src/main_test.go index 4547e93a..65dd8ab5 100644 --- a/.github/actions/link-backport-prs/src/main_test.go +++ b/.github/actions/link-backport-prs/src/main_test.go @@ -360,8 +360,8 @@ func TestWorkflowRunsLinkerAfterAllProducers(t *testing.T) { t.Fatal("link-backports job is missing") } linkJob := workflow[linkStart:] - if !strings.Contains(linkJob, "needs: [backport, legacy-backport]") { - t.Fatal("link-backports must wait for modern and legacy producers") + if !strings.Contains(linkJob, "needs: [resolve-event, backport, legacy-backport]") { + t.Fatal("link-backports must wait for validated event and modern and legacy producers") } if !strings.Contains(linkJob, "/link-backport-prs@") { t.Fatal("link-backports job does not invoke link-backport-prs") diff --git a/.github/actions/resolve-pr-event/README.md b/.github/actions/resolve-pr-event/README.md new file mode 100644 index 00000000..cb0ecc0b --- /dev/null +++ b/.github/actions/resolve-pr-event/README.md @@ -0,0 +1,100 @@ +# Resolve merged pull requests + +Resolve merged pull requests for a default-branch push or a completed read-only pull request signal workflow. The action fetches authoritative PR data from GitHub and only returns closed, merged PRs targeting the default branch. Fork PRs are included after merge. + +For a push, the action walks every commit in the before/after range and finds associated PRs whose merge SHA is in that range. A forced push, deleted branch, or new branch with a zero before SHA is skipped. Comparison and associated PR pagination are bounded to 1,000 entries each; incomplete results fail the run. Commits with no associated PR are retried twice as one batch, after 2 and 4 seconds, because GitHub can list a merge commit's PR shortly after the push. Commits still without a PR, such as direct or bot pushes, do not stop the run: the PRs found in the same push are still returned, and a warning lists those commits after the outputs are written. If a listed commit merged a PR, remove and re-add its `backport-to-` label to start the backport through the signal. + +For `workflow_run`, the action verifies the run ID, attempt, status, conclusion, source workflow identity, repository, event, head branch and SHA against the API. It uses PR associations from the run or, when empty, from the commit API. A re-fetched PR must match the signal head branch and either its head SHA or merge SHA; its merge commit must be an ancestor of the default branch. No signal artifact is read. Since a signal workflow's code can come from a PR, it must be read-only and carry no secrets. Its output is never treated as authoritative. + +The action returns compact identity envelopes in `events-json`, at most 256, for a matrix. When exactly one PR is eligible, `event-json` also contains current `backport-to-` labels. The full re-fetched event is written only to a runner-local file, which can be supplied through the `NODE_OPTIONS` and `RESOLVED_PR_EVENT_PATH` outputs for Node actions. The aggregate job outputs are capped below GitHub's job output limit. The consumer must restore each envelope with this action before using it; restoration verifies its identity and merge SHA, checks that the merge is an ancestor of the default branch, and fetches current labels. On push, restoration also checks that the merge belongs to the original push range. It re-fetches only the selected PR and uses a constant number of API requests without walking the push range again. Both selector and consumer require `contents: read` and `pull-requests: read`; the signal consumer also needs `actions: read`. All consumers must run from the default branch. + +The default-branch push covers merges. Use the signal for labels added after merge; omitting `closed` avoids a second backport run for the same merge. + +Example signal workflow, `.github/workflows/pull-request-events.yaml`: + +```yaml +name: Pull request events +on: + pull_request: + types: [labeled] +permissions: {} +jobs: + signal: + runs-on: ubuntu-latest + permissions: {} + steps: + - run: ':' +``` + +Example selector and fanout for a repository whose default branch is `main`: + +```yaml +name: Backport on merge +on: + push: + branches: [main] + workflow_run: + workflows: [Pull request events] + types: [completed] +permissions: {} +jobs: + select: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + pull-requests: read + outputs: + events: ${{ steps.resolve.outputs.events-json }} + steps: + - id: resolve + uses: loft-sh/github-actions/.github/actions/resolve-pr-event@resolve-pr-event/v1 + with: + source-workflow: .github/workflows/pull-request-events.yaml + backport: + needs: select + if: needs.select.outputs.events != '[]' && needs.select.outputs.events != '' + strategy: + matrix: + event: ${{ fromJSON(needs.select.outputs.events) }} + permissions: + actions: read + contents: read + pull-requests: read + uses: loft-sh/github-actions/.github/workflows/backport.yaml@backport/v2 + with: + pr-event: ${{ toJSON(matrix.event) }} + source-workflow: .github/workflows/pull-request-events.yaml + secrets: + gh-access-token: ${{ secrets.GH_ACCESS_TOKEN }} +``` + +The example uses a published action and does not check out PR code. Coordinate publication of the action and reusable workflow tags with caller rollout. + +## Inputs + + + +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|-----------------|--------|----------|------------------------------------------------|----------------------------------------------------------------------------------------| +| event-json | string | false | | Trusted payload passed between jobs through
workflow_call. | +| github-token | string | false | `"${{ github.token }}"` | Token with contents and pull requests
read access; actions read for workflow_run. | +| source-workflow | string | false | `".github/workflows/pull-request-events.yaml"` | Expected read-only pull request signal workflow
path. | + + + +## Outputs + + + +| OUTPUT | TYPE | DESCRIPTION | +|--------------|--------|-------------------------------------------------------------------------------| +| action | string | Pull request action. | +| eligible | string | Whether a validated event is available. | +| event-json | string | Compact PR identity and backport labels
when exactly one PR is eligible. | +| event-path | string | Temporary synthetic event file for RESOLVED_PR_EVENT_PATH. | +| events-json | string | Array of compact PR identity envelopes
for a job matrix. | +| node-options | string | NODE_OPTIONS preload for Node actions that
consume the synthetic event. | +| pr-number | string | Pull request number. | + + diff --git a/.github/actions/resolve-pr-event/action.yml b/.github/actions/resolve-pr-event/action.yml new file mode 100644 index 00000000..8b7076bb --- /dev/null +++ b/.github/actions/resolve-pr-event/action.yml @@ -0,0 +1,46 @@ +name: Resolve pull request event +description: Resolve merged pull requests from trusted default branch events. +inputs: + github-token: + required: false + description: Token with contents and pull requests read access; actions read for workflow_run. + default: ${{ github.token }} + source-workflow: + required: false + description: Expected read-only pull request signal workflow path. + default: .github/workflows/pull-request-events.yaml + event-json: + description: Trusted payload passed between jobs through workflow_call. + required: false +outputs: + eligible: + description: Whether a validated event is available. + value: ${{ steps.resolve.outputs.eligible }} + events-json: + description: Array of compact PR identity envelopes for a job matrix. + value: ${{ steps.resolve.outputs.events-json }} + event-json: + description: Compact PR identity and backport labels when exactly one PR is eligible. + value: ${{ steps.resolve.outputs.event-json }} + event-path: + description: Temporary synthetic event file for RESOLVED_PR_EVENT_PATH. + value: ${{ steps.resolve.outputs.event-path }} + node-options: + description: NODE_OPTIONS preload for Node actions that consume the synthetic event. + value: ${{ steps.resolve.outputs.node-options }} + pr-number: + description: Pull request number. + value: ${{ steps.resolve.outputs.pr-number }} + action: + description: Pull request action. + value: ${{ steps.resolve.outputs.action }} +runs: + using: composite + steps: + - id: resolve + shell: bash + env: + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_SOURCE_WORKFLOW: ${{ inputs.source-workflow }} + INPUT_EVENT_JSON: ${{ inputs.event-json }} + run: python3 "${{ github.action_path }}/src/resolve.py" diff --git a/.github/actions/resolve-pr-event/src/event-bootstrap.cjs b/.github/actions/resolve-pr-event/src/event-bootstrap.cjs new file mode 100644 index 00000000..fcb3c9f7 --- /dev/null +++ b/.github/actions/resolve-pr-event/src/event-bootstrap.cjs @@ -0,0 +1,20 @@ +'use strict'; + +// Node actions initialize @actions/github before their entry point can adjust +// runner-provided event variables. Validate the resolver's file before loading. +const fs = require('node:fs'); +try { + const path = process.env.RESOLVED_PR_EVENT_PATH; + const repository = process.env.GITHUB_REPOSITORY; + if (!path || !repository) throw new Error(); + const event = JSON.parse(fs.readFileSync(path, 'utf8')); + const number = event?.pull_request?.number; + const eventRepository = event?.repository?.full_name; + if (!Number.isSafeInteger(number) || number <= 0 || + typeof eventRepository !== 'string' || + eventRepository.toLowerCase() !== repository.toLowerCase()) throw new Error(); + process.env.GITHUB_EVENT_PATH = path; + process.env.GITHUB_EVENT_NAME = 'pull_request'; +} catch (_) { + throw new Error('Unable to restore validated pull request event'); +} diff --git a/.github/actions/resolve-pr-event/src/resolve.py b/.github/actions/resolve-pr-event/src/resolve.py new file mode 100644 index 00000000..38d182b6 --- /dev/null +++ b/.github/actions/resolve-pr-event/src/resolve.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Resolve merged pull requests using trusted GitHub events and API data.""" +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + +MAX_BYTES = 4 * 1024 * 1024 +SHA = re.compile(r'^[0-9a-f]{40}$') +# Seconds to wait before each retry of the commits that had no PR association. +RETRY_DELAYS = (2, 4) + +class Reject(Exception): + pass + +def unique_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Reject('duplicate JSON key') + result[key] = value + return result + +def parse_json(data): + try: + return json.loads(data, object_pairs_hook=unique_pairs) + except (ValueError, UnicodeDecodeError, RecursionError) as exc: + raise Reject('invalid JSON') from exc + +def positive(value): + return type(value) is int and 0 < value <= 2**53 - 1 + +def sha(value): + return isinstance(value, str) and bool(SHA.fullmatch(value)) and value != '0' * 40 + +def same(left, right): + return isinstance(left, str) and isinstance(right, str) and left.lower() == right.lower() + +class SafeRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + redirected = super().redirect_request(req, fp, code, msg, headers, newurl) + if redirected and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc: + redirected.remove_header('Authorization') + return redirected + +class API: + def __init__(self, token, repository): + self.root = os.environ.get('GITHUB_API_URL', 'https://api.github.com').rstrip('/') + self.repo = repository + self.token = token + + def get(self, path): + request = urllib.request.Request(self.root + '/repos/' + self.repo + path, + headers={'Authorization': 'Bearer ' + self.token, 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28'}) + try: + with urllib.request.build_opener(SafeRedirect()).open(request, timeout=30) as response: + raw = response.read(MAX_BYTES + 1) + except urllib.error.HTTPError as exc: + raise RuntimeError('GitHub API request failed (HTTP %d)' % exc.code) from exc + except urllib.error.URLError as exc: + raise RuntimeError('GitHub API request failed') from exc + if len(raw) > MAX_BYTES: + raise Reject('API response too large') + return parse_json(raw) + +def paged(api, path, field=None): + items = [] + for page in range(1, 11): + obj = api.get(path + ('&' if '?' in path else '?') + 'per_page=100&page=%d' % page) + batch = obj.get(field) if field and isinstance(obj, dict) else obj + if not isinstance(batch, list) or len(batch) > 100: + raise Reject('invalid paginated API response') + items.extend(batch) + if len(batch) < 100: + return items + raise RuntimeError('pagination limit exceeded; pull request discovery is incomplete') + +def trusted_context(api, payload, event_name): + repo = api.get('') + default = repo.get('default_branch') + if not same(repo.get('full_name'), api.repo) or not isinstance(default, str) or not default: + raise Reject('repository identity mismatch') + if not same((payload.get('repository') or {}).get('full_name'), api.repo): + raise Reject('event repository mismatch') + ref = 'refs/heads/' + default + if os.environ.get('GITHUB_REF') != ref: + raise Reject('workflow is not running on the default branch') + workflow_ref = os.environ.get('GITHUB_WORKFLOW_REF', '') + if not workflow_ref.startswith(api.repo + '/.github/workflows/') or not workflow_ref.endswith('@' + ref): + raise Reject('workflow revision is not trusted') + return repo, default, ref + +def merged_pr(pr, number, repo, default): + base = pr.get('base') or {} + return (positive(number) and pr.get('number') == number and pr.get('merged') is True and + pr.get('state') == 'closed' and sha(pr.get('merge_commit_sha')) and + same((base.get('repo') or {}).get('full_name'), repo) and base.get('ref') == default) + +def associated_numbers(api, commits): + """Return PRs merged by pushed commits, and the commits GitHub lists no PR for. + + GitHub can list a merge commit's PR a few seconds after the push. Empty + lookups are retried as one batch, so the wait does not grow with the number + of direct commits in a push. + """ + numbers, pending = set(), list(commits) + for delay in (0,) + RETRY_DELAYS: + if delay: + time.sleep(delay) + missing = [] + for commit in pending: + associations = paged(api, '/commits/%s/pulls' % commit) + if not associations: + missing.append(commit) + for candidate in associations: + number = candidate.get('number') if isinstance(candidate, dict) else None + if positive(number) and candidate.get('merge_commit_sha') == commit: + numbers.add(number) + pending = missing + if not pending: + break + return numbers, pending + +def unassociated_warning(commits): + shown = ', '.join(commits[:10]) + (', and %d more' % (len(commits) - 10) if len(commits) > 10 else '') + return ('No pull request is associated with %d pushed commit(s) after retries: %s. A direct push needs no backport. ' + 'If one of these commits merged a pull request, remove and re-add its backport-to- label to start the backport.' + % (len(commits), shown)) + +def resolve(api, payload, event_name, source_workflow='', requested_number=None, unassociated=None): + if not isinstance(payload, dict): + raise Reject('invalid event') + repo, default, ref = trusted_context(api, payload, event_name) + if requested_number is not None and not positive(requested_number): + raise Reject('invalid restored pull request number') + commits = [] + if event_name == 'push': + before, head = payload.get('before'), payload.get('after') + if payload.get('ref') != ref or payload.get('deleted') is not False or payload.get('forced') is not False or not sha(before) or not sha(head): + raise Reject('unsupported push ref, range, deletion, or force push') + if requested_number is None: + commits = [item.get('sha') for item in paged(api, '/compare/%s...%s' % (before, head), 'commits') if isinstance(item, dict)] + if not commits or commits[-1] != head or any(not sha(item) for item in commits): + raise RuntimeError('invalid or incomplete push range') + elif event_name == 'workflow_run': + signal = payload.get('workflow_run') or {} + run_id, attempt = signal.get('id'), signal.get('run_attempt') + if (payload.get('action') != 'completed' or not positive(run_id) or not positive(attempt) or + not re.fullmatch(r'\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml', source_workflow)): + raise Reject('invalid signal identity') + run = api.get('/actions/runs/%d/attempts/%d' % (run_id, attempt)) + workflow = api.get('/actions/workflows/' + urllib.parse.quote(source_workflow.rsplit('/', 1)[-1], safe='')) + if (run.get('id') != run_id or run.get('run_attempt') != attempt or + run.get('event') != 'pull_request' or run.get('status') != 'completed' or run.get('conclusion') != 'success' or + run.get('path') != source_workflow or not positive(run.get('workflow_id')) or + workflow.get('id') != run.get('workflow_id') or workflow.get('path') != source_workflow or + not same((run.get('repository') or {}).get('full_name'), api.repo) or + not isinstance(run.get('head_branch'), str) or not run.get('head_branch') or + not sha(run.get('head_sha')) or signal.get('head_sha') != run.get('head_sha')): + raise Reject('signal run is not eligible') + candidates = run.get('pull_requests') + if not isinstance(candidates, list): + raise Reject('invalid signal pull request associations') + if not candidates: + candidates = paged(api, '/commits/%s/pulls' % run['head_sha']) + signal_sha = run['head_sha'] + else: + raise Reject('unsupported event') + # Push restoration rechecks one selected PR and its default-branch ancestry. + # Only selection needs to discover PRs across the full pushed commit range. + numbers = {requested_number} if event_name == 'push' and requested_number is not None else set() + if commits: + # A commit with no PR, such as a direct or bot push, must not drop the + # PRs merged in the same push. The caller reports it after the outputs. + found, missing = associated_numbers(api, commits) + numbers |= found + if unassociated is not None: + unassociated.extend(missing) + if event_name == 'workflow_run': + for candidate in candidates: + number = candidate.get('number') if isinstance(candidate, dict) else None + if positive(number): + numbers.add(number) + events = [] + for number in sorted(numbers): + pr = api.get('/pulls/%d' % number) + if not isinstance(pr, dict) or not merged_pr(pr, number, api.repo, default): + continue + if commits and pr['merge_commit_sha'] not in commits: + continue + if event_name == 'workflow_run' and (signal_sha not in (pr['merge_commit_sha'], (pr.get('head') or {}).get('sha')) or + (pr.get('head') or {}).get('ref') != run['head_branch'] or + not same((run.get('head_repository') or {}).get('full_name'), + ((pr.get('head') or {}).get('repo') or {}).get('full_name'))): + continue + if not commits: + comparison = api.get('/compare/%s...%s' % (pr['merge_commit_sha'], urllib.parse.quote(default, safe=''))) + if comparison.get('status') not in ('ahead', 'identical'): + raise Reject('merged commit is not on the default branch') + if event_name == 'push': + # A sync merge may introduce a side-branch merge that diverges + # from before. It must be newly reachable from after, not before. + previous = api.get('/compare/%s...%s' % (pr['merge_commit_sha'], before)) + current = api.get('/compare/%s...%s' % (pr['merge_commit_sha'], head)) + if previous.get('status') not in ('behind', 'diverged') or current.get('status') not in ('ahead', 'identical'): + raise Reject('merged commit is not in the pushed range') + events.append({'action': 'closed', 'number': number, 'pull_request': pr, 'repository': repo}) + if len(events) > 256: + raise RuntimeError('too many matched pull requests for a job matrix') + return events + +def compact(event): + pr = event['pull_request'] + repo = event['repository'] + labels = [label for label in pr.get('labels', []) if isinstance(label, dict) and + isinstance(label.get('name'), str) and label['name'].startswith('backport-to-')] + return {'action': 'closed', 'number': event['number'], + 'repository': {'full_name': repo['full_name'], 'name': repo.get('name', repo['full_name'].split('/')[-1]), + 'default_branch': repo['default_branch']}, + 'pull_request': {'number': pr['number'], 'state': pr['state'], 'merged': pr['merged'], + 'merge_commit_sha': pr['merge_commit_sha'], 'base': {'ref': pr['base']['ref'], + 'repo': {'full_name': pr['base']['repo']['full_name']}}, + 'labels': [{'name': item['name']} for item in labels]}} + +def write_output_values(outputs): + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: + for key, value in outputs.items(): + delimiter = 'ghadelimiter_' + uuid.uuid4().hex + while delimiter in value: + delimiter = 'ghadelimiter_' + uuid.uuid4().hex + f.write('%s<<%s\n%s\n%s\n' % (key, delimiter, value, delimiter)) + +def write_outputs(events): + encoded_events = json.dumps([compact(event) for event in events], ensure_ascii=True, separators=(',', ':')) + outputs = {'eligible': 'true' if events else 'false', 'events-json': encoded_events} + if len(events) == 1: + event = events[0] + encoded = json.dumps(event, ensure_ascii=True, separators=(',', ':')) + compact_encoded = json.dumps(compact(event), ensure_ascii=True, separators=(',', ':')) + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=os.environ['RUNNER_TEMP'], prefix='pr-event-', suffix='.json', delete=False) as f: + f.write(encoded) + path = f.name + bootstrap = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'event-bootstrap.cjs') + outputs.update({'event-json': compact_encoded, 'event-path': path, + 'node-options': '--require=' + json.dumps(bootstrap, ensure_ascii=False), + 'pr-number': str(event['number']), 'action': event['action']}) + if sum(len(value.encode('utf-16-le')) for value in outputs.values()) > 700 * 1024: + raise RuntimeError('resolved outputs exceed size limit') + write_output_values(outputs) + +def main(): + try: + repository = os.environ['GITHUB_REPOSITORY'] + with open(os.environ['GITHUB_EVENT_PATH'], 'rb') as f: + raw = f.read(MAX_BYTES + 1) + if len(raw) > MAX_BYTES: + raise Reject('event too large') + payload = parse_json(raw) + restored = os.environ.get('INPUT_EVENT_JSON', '') + requested = None + number = None + if restored: + if len(restored.encode('utf-8')) > MAX_BYTES: + raise Reject('restored event too large') + requested = parse_json(restored) + number = requested.get('number') if isinstance(requested, dict) else None + if not positive(number): + raise Reject('invalid restored pull request number') + unassociated = [] + events = resolve(API(os.environ['INPUT_GITHUB_TOKEN'], repository), payload, + os.environ.get('GITHUB_EVENT_NAME', ''), os.environ.get('INPUT_SOURCE_WORKFLOW', ''), + requested_number=number, unassociated=unassociated) + if restored: + matches = [event for event in events if event['number'] == number] + supplied_pr = requested.get('pull_request') if isinstance(requested, dict) else None + if (len(matches) != 1 or not isinstance(supplied_pr, dict) or + requested.get('action') != 'closed' or supplied_pr.get('number') != number or + supplied_pr.get('merged') is not True or supplied_pr.get('state') != 'closed' or + supplied_pr.get('merge_commit_sha') != matches[0]['pull_request']['merge_commit_sha'] or + not same((requested.get('repository') or {}).get('full_name'), repository) or + not same(((supplied_pr.get('base') or {}).get('repo') or {}).get('full_name'), repository) or + (supplied_pr.get('base') or {}).get('ref') != matches[0]['repository']['default_branch']): + raise Reject('restored event does not match authoritative event') + events = matches + write_outputs(events) + if unassociated: + print('::warning::' + unassociated_warning(unassociated)) + except Reject as exc: + print('::warning::' + str(exc)) + write_output_values({'eligible': 'false', 'events-json': '[]'}) + except RuntimeError as exc: + print('::error::Unable to resolve pull request event: ' + str(exc), file=sys.stderr) + return 1 + except (OSError, KeyError, TypeError) as exc: + print('::error::Unable to resolve pull request event: ' + type(exc).__name__, file=sys.stderr) + return 1 + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/actions/resolve-pr-event/test/test_backport_contract.py b/.github/actions/resolve-pr-event/test/test_backport_contract.py new file mode 100644 index 00000000..c9c2c22a --- /dev/null +++ b/.github/actions/resolve-pr-event/test/test_backport_contract.py @@ -0,0 +1,156 @@ +"""Enforce the trust boundary shared by callers, resolver and mutation jobs.""" +from pathlib import Path +import os +import re +import subprocess +import textwrap + +import pytest + +GITHUB = Path(__file__).resolve().parents[3] +WORKFLOWS = GITHUB / 'workflows' + + +def test_signal_example_only_listens_for_labels(): + readme = (GITHUB / 'actions/resolve-pr-event/README.md').read_text() + signal = readme.split('Example signal workflow', 1)[1].split('```yaml', 1)[1].split('```', 1)[0] + assert re.search(r'pull_request:\s*\n\s*types: \[labeled\]', signal) + + +def jobs(name): + text = (WORKFLOWS / name).read_text().split('\njobs:\n', 1)[1] + return dict(re.findall(r'^ ([\w-]+):\n(.*?)(?=^ [\w-]+:\n|\Z)', text, re.M | re.S)) + + +def test_no_privileged_pr_trigger_remains_in_workflows(): + forbidden = b'pull_request' + b'_target' + matches = [str(path.relative_to(GITHUB)) for path in WORKFLOWS.rglob('*') + if path.is_file() and '__pycache__' not in path.parts + and forbidden in path.read_bytes()] + assert matches == ['workflows/claude-code-review.yaml'] + review = (WORKFLOWS / 'claude-code-review.yaml').read_bytes() + assert review.count(forbidden) == 1 + assert b"github.event_name == '" + forbidden + b"'" in review + + +def test_backport_requires_trusted_default_branch_and_api_resolution(): + resolve = jobs('backport.yaml')['resolve-event'] + assert "inputs.pr-event != ''" in resolve + assert '"push","workflow_run"' in resolve + assert "github.ref == format('refs/heads/{0}', github.event.repository.default_branch)" in resolve + assert "endsWith(github.workflow_ref, format('@refs/heads/{0}', github.event.repository.default_branch))" in resolve + assert 'event-json: ${{ inputs.pr-event }}' in resolve + + +def test_every_backport_job_depends_on_authoritative_event(): + entries = jobs('backport.yaml') + assert set(entries) == {'resolve-event', 'backport', 'fix-imports', + 'legacy-classify', 'legacy-backport', 'link-backports'} + for name, body in entries.items(): + assert 'permissions:\n contents: read' in body, name + assert 'contents: write' not in body, name + if name != 'resolve-event': + assert re.search(r'^ needs:.*resolve-event', body, re.M), name + assert 'github.event.pull_request' not in body, name + for name in ('backport', 'legacy-classify', 'link-backports'): + assert "needs.resolve-event.outputs.eligible == 'true'" in entries[name] + assert 'needs: [resolve-event, backport, legacy-backport]' in entries['link-backports'] + assert 'continue-on-error: true' in entries['link-backports'] + + +def test_node_adapter_is_step_scoped_and_checkouts_never_persist_tokens(): + text = (WORKFLOWS / 'backport.yaml').read_text() + assert 'NODE_OPTIONS: ${{ steps.pr-event.outputs.node-options }}' in text + assert 'RESOLVED_PR_EVENT_PATH: ${{ steps.pr-event.outputs.event-path }}' in text + assert 'GITHUB_EVENT_PATH:' not in text + assert text.count('uses: actions/checkout@') == text.count('persist-credentials: false') + assert 'cancel-in-progress: false' in text + body = jobs('backport.yaml')['backport'] + assert body.count("if: steps.pr-event.outputs.eligible == 'true'") == 3 + + +def test_review_rejects_forks_in_caller_and_reusable_before_secrets(): + for filename, job in [('claude-code-review.yaml', 'claude-review'), + ('test-claude-code-review.yaml', 'claude-review')]: + body = jobs(filename)[job] + assert 'github.event.pull_request.head.repo.full_name == github.repository' in body + assert body.index(' if:') < body.index(' permissions:') + assert 'contents: read' in body and 'pull-requests: write' in body + assert 'allow-unsafe-pr-checkout' not in body + assert 'Setup fork as origin' not in body + review = jobs('claude-code-review.yaml')['claude-review'] + assert "github.event_name == 'pull_request'" in review + assert 'persist-credentials: false' in review + + +@pytest.mark.parametrize('event,owner,head,expected', [ + ('pull_request', 'loft-sh', 'loft-sh/repo', True), + ('pull_request' + '_target', 'loft-sh', 'loft-sh/repo', True), + ('pull_request', 'loft-sh', 'fork/repo', False), + ('pull_request' + '_target', 'loft-sh', 'fork/repo', False), + ('push', 'loft-sh', 'loft-sh/repo', False), + ('pull_request', 'other', 'loft-sh/repo', False), +]) +def test_review_event_guard(event, owner, head, expected): + body = jobs('claude-code-review.yaml')['claude-review'] + guard = body.split(' if: >-\n', 1)[1].split(' runs-on:', 1)[0].strip() + # Translate only the expression operators and known context values, so the + # cases exercise the actual guard rather than a separately written policy. + expression = ' '.join(guard.split()).replace('&&', ' and ').replace('||', ' or ') + expression = expression.replace('github.repository_owner', repr(owner)) + expression = expression.replace('github.event_name', repr(event)) + expression = expression.replace('github.event.pull_request.head.repo.full_name', repr(head)) + expression = expression.replace('github.repository', repr('loft-sh/repo')) + assert eval(expression, {'__builtins__': {}}) is expected + + +def test_smoke_verifiers_need_no_token_permissions(): + for filename in ('test-backport.yaml', 'test-claude-code-review.yaml'): + assert 'permissions: {}' in jobs(filename)['verify'] + text = (WORKFLOWS / filename).read_text() + assert filename in text.split('permissions:', 1)[0] + + +@pytest.mark.parametrize('filename', ['claude.yaml', 'claude.yml']) +@pytest.mark.parametrize('association,is_pr,number,answer,exit_code,allowed,queried', [ + ('MEMBER', 'true', '17', 'true', 0, True, True), + ('OWNER', 'true', '17', 'false', 0, False, True), + ('COLLABORATOR', 'true', '17', 'true', 0, True, True), + ('CONTRIBUTOR', 'true', '17', 'true', 0, False, False), + ('MEMBER', 'false', '', 'true', 0, True, False), + ('NONE', 'false', '', 'true', 0, False, False), + ('MEMBER', 'true', '', 'true', 1, False, False), + ('MEMBER', 'true', '$(touch injected)', 'true', 1, False, False), + ('MEMBER', 'true', '17', '', 0, False, True), +]) +def test_comment_authorization_executes_before_any_secret_or_checkout( + tmp_path, filename, association, is_pr, number, answer, exit_code, allowed, queried): + entries = jobs(filename) + authorize, privileged = entries['authorize'], entries['claude'] + assert 'permissions:\n pull-requests: read' in authorize + assert 'secrets.' not in authorize and 'checkout@' not in authorize + assert 'needs: authorize' in privileged + assert "if: needs.authorize.outputs.allowed == 'true'" in privileged + script = textwrap.dedent(authorize.split(' run: |\n', 1)[1]) + mock = tmp_path / 'gh' + mock.write_text('#!/bin/sh\nprintf called > "$CALL_FILE"\nprintf "%s\\n" "$API_ANSWER"\n') + mock.chmod(0o755) + output, calls = tmp_path / 'out', tmp_path / 'calls' + env = {**os.environ, 'PATH': str(tmp_path) + os.pathsep + os.environ['PATH'], + 'ASSOCIATION': association, 'IS_PR': is_pr, 'PR_NUMBER': number, + 'REPOSITORY': 'org/repo', 'API_ANSWER': answer, + 'GITHUB_OUTPUT': str(output), 'CALL_FILE': str(calls)} + result = subprocess.run(['bash', '-euo', 'pipefail', '-c', script], env=env, + cwd=tmp_path, text=True, capture_output=True) + assert result.returncode == exit_code, result.stderr + assert (output.exists() and 'allowed=true' in output.read_text()) == allowed + assert calls.exists() == queried + assert not (tmp_path / 'injected').exists() + + + +def test_claude_smoke_caller_matches_reusable_permissions_and_blocks_forks(): + caller = jobs('test-claude.yaml')['claude'] + assert 'github.event.pull_request.head.repo.full_name == github.repository' in caller + assert 'contents: write' in caller and 'pull-requests: write' in caller + assert 'permissions: {}' in jobs('test-claude.yaml')['verify'] diff --git a/.github/actions/resolve-pr-event/test/test_resolve.py b/.github/actions/resolve-pr-event/test/test_resolve.py new file mode 100644 index 00000000..aa3abebe --- /dev/null +++ b/.github/actions/resolve-pr-event/test/test_resolve.py @@ -0,0 +1,375 @@ +import importlib.util +import json +import os +import pathlib +import subprocess + +import pytest + +SRC = pathlib.Path(__file__).resolve().parents[1] / 'src' +spec = importlib.util.spec_from_file_location('resolver', SRC / 'resolve.py') +r = importlib.util.module_from_spec(spec) +spec.loader.exec_module(r) +A, B, C = ('a' * 40, 'b' * 40, 'c' * 40) +REPO = {'full_name': 'org/repo', 'default_branch': 'main'} + +def pr(number, commit, fork=False): + return {'number': number, 'state': 'closed', 'merged': True, 'merge_commit_sha': commit, + 'base': {'ref': 'main', 'repo': {'full_name': 'org/repo'}}, + 'head': {'repo': {'full_name': 'fork/repo' if fork else 'org/repo'}, 'sha': B, 'ref': 'feature'}, + 'labels': [{'name': 'backport-to-v1'}], 'body': 'hello'} + +class FakeAPI: + repo = 'org/repo' + def __init__(self): + self.pull = {1: pr(1, B, True), 2: pr(2, C)} + self.calls = [] + self.comparisons = {'/compare/%s...%s' % (commit, A): 'behind' for commit in (B, C)} + def get(self, path): + self.calls.append(path) + if path == '': return REPO + if path.startswith('/compare/'): + return {'commits': [{'sha': B}, {'sha': C}], 'status': self.comparisons.get(path, 'ahead')} + if path.startswith('/commits/'): + commit = path.split('/')[2] + return [self.pull[1]] if commit == B else [self.pull[2], self.pull[1]] + if path.startswith('/pulls/'): return self.pull[int(path.split('/')[-1])] + raise AssertionError(path) + +@pytest.fixture(autouse=True) +def context(monkeypatch): + monkeypatch.setenv('GITHUB_REF', 'refs/heads/main') + monkeypatch.setenv('GITHUB_WORKFLOW_REF', 'org/repo/.github/workflows/backport.yml@refs/heads/main') + +def push(): + return {'repository': REPO, 'ref': 'refs/heads/main', 'before': A, 'after': C, 'deleted': False, 'forced': False} + +def test_multi_commit_and_fork_merged_deduplicated(): + api = FakeAPI() + events = r.resolve(api, push(), 'push') + assert [x['number'] for x in events] == [1, 2] + assert all(x['action'] == 'closed' for x in events) + assert '/pulls/1' in api.calls and '/pulls/2' in api.calls + +def test_nonmatching_and_unmerged_pr_skipped(): + api = FakeAPI() + api.pull[1]['merged'] = False + api.pull[2]['base']['repo']['full_name'] = 'other/repo' + assert r.resolve(api, push(), 'push') == [] + +@pytest.mark.parametrize('field,value', [('ref', 'refs/heads/other'), ('forced', True), ('deleted', True), ('before', '0'*40)]) +def test_invalid_push_does_not_query_range(field, value): + api = FakeAPI(); payload = push(); payload[field] = value + with pytest.raises(r.Reject): r.resolve(api, payload, 'push') + assert len(api.calls) == 1 + +def test_untrusted_context_rejected(monkeypatch): + api = FakeAPI(); payload = push(); payload['repository'] = {'full_name': 'other/repo'} + with pytest.raises(r.Reject): r.resolve(api, payload, 'push') + monkeypatch.setenv('GITHUB_REF', 'refs/heads/branch') + with pytest.raises(r.Reject): r.resolve(api, push(), 'push') + monkeypatch.setenv('GITHUB_REF', 'refs/heads/main') + monkeypatch.setenv('GITHUB_WORKFLOW_REF', 'org/repo/.github/workflows/test.yml@refs/heads/branch') + with pytest.raises(r.Reject): r.resolve(api, push(), 'push') + +class SignalAPI(FakeAPI): + def __init__(self): + super().__init__() + self.signal = {'id': 12, 'run_attempt': 1, 'event': 'pull_request', 'status': 'completed', + 'conclusion': 'success', 'path': '.github/workflows/pull-request-events.yaml', + 'workflow_id': 99, 'repository': REPO, 'head_branch': 'feature', 'head_sha': B, + 'head_repository': {'full_name': 'fork/repo'}, + 'pull_requests': [{'number': 1}]} + def get(self, path): + if path.startswith('/actions/runs/'): return self.signal + if path.startswith('/actions/workflows/'): + return {'id': 99, 'path': '.github/workflows/pull-request-events.yaml'} + return super().get(path) + +def signal_payload(): + return {'repository': REPO, 'action': 'completed', 'workflow_run': {'id': 12, 'run_attempt': 1, 'head_sha': B}} + +SOURCE = '.github/workflows/pull-request-events.yaml' + +def test_signal_fork_pr_and_fallback(): + api = SignalAPI() + assert [e['number'] for e in r.resolve(api, signal_payload(), 'workflow_run', SOURCE)] == [1] + api.signal['pull_requests'] = [] + assert [e['number'] for e in r.resolve(api, signal_payload(), 'workflow_run', SOURCE)] == [1] + assert not any('artifacts' in call for call in api.calls) + +@pytest.mark.parametrize('associations', [[], [{'number': 1}]]) +@pytest.mark.parametrize('original_fork', [False, True]) +def test_signal_cannot_replay_merged_pr_from_another_repository(associations, original_fork): + api = SignalAPI() + api.pull[1] = pr(1, B, original_fork) + api.signal['pull_requests'] = associations + api.signal['head_repository'] = {'full_name': 'attacker/repo'} + assert r.resolve(api, signal_payload(), 'workflow_run', SOURCE) == [] + assert not any(call.startswith('/compare/') for call in api.calls) + +@pytest.mark.parametrize('head_repository', [None, {}, {'full_name': ''}]) +def test_signal_without_source_repository_is_skipped(head_repository): + api = SignalAPI() + api.signal['head_repository'] = head_repository + assert r.resolve(api, signal_payload(), 'workflow_run', SOURCE) == [] + +def test_signal_source_repository_comparison_is_case_insensitive(): + api = SignalAPI() + api.signal['pull_requests'] = [] + api.signal['head_repository'] = {'full_name': 'Fork/Repo'} + assert [e['number'] for e in r.resolve(api, signal_payload(), 'workflow_run', SOURCE)] == [1] + +@pytest.mark.parametrize('field,value', [('conclusion', 'failure'), ('path', '.github/workflows/other.yaml'), + ('head_branch', 'other'), ('head_sha', C), ('event', 'push'), + ('repository', {'full_name': 'other/repo'})]) +def test_invalid_signal(field, value): + api = SignalAPI(); api.signal[field] = value + try: + assert r.resolve(api, signal_payload(), 'workflow_run', SOURCE) == [] + except r.Reject: + pass + +def test_unmerged_and_unrelated_signal_skipped(): + api = SignalAPI(); api.pull[1]['merged'] = False + assert r.resolve(api, signal_payload(), 'workflow_run', SOURCE) == [] + api.pull[1] = pr(1, C); api.pull[1]['head']['sha'] = C + assert r.resolve(api, signal_payload(), 'workflow_run', SOURCE) == [] + +def test_other_events_rejected(): + for name in ('pull_request', 'workflow_dispatch', 'schedule'): + with pytest.raises(r.Reject): r.resolve(FakeAPI(), {'repository': REPO}, name) + +def test_incomplete_range_fails(): + api = FakeAPI() + payload = push(); payload['after'] = 'd'*40 + with pytest.raises(RuntimeError, match='incomplete push range'): r.resolve(api, payload, 'push') + +def test_outputs_escape_injection_and_node_preload(tmp_path, monkeypatch): + monkeypatch.setenv('GITHUB_OUTPUT', str(tmp_path / 'out')) + monkeypatch.setenv('RUNNER_TEMP', str(tmp_path)) + event = {'action': 'closed', 'number': 1, 'pull_request': pr(1, B), 'repository': REPO} + event['pull_request']['body'] = 'hello\n::set-output name=x::bad\n' + r.write_outputs([event]) + output = (tmp_path / 'out').read_text() + assert 'events-json<- Enable the pre-monorepo (<= v0.36) split/re-root backport flow. When @@ -55,41 +65,80 @@ on: description: 'Linear API token for linking backport PRs to their matching Linear sub-issue. Optional; linking is skipped when not provided.' required: false +permissions: {} + # Serialize runs for the same source PR so two rapid label events can't both race # past the open-PR check and double-create the same deterministic backport branch # (the second gh pr create would fail "already exists"). Queue, don't cancel, so # an in-flight backport always finishes. concurrency: - group: backport-${{ github.repository }}-${{ github.event.pull_request.number }} + group: backport-${{ github.repository }}-${{ fromJSON(inputs.pr-event || '{}').pull_request.number || github.run_id }} cancel-in-progress: false jobs: + resolve-event: + name: Resolve merged PR on the trusted default branch + if: >- + inputs.pr-event != '' && + contains(fromJSON('["push","workflow_run"]'), github.event_name) && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && + endsWith(github.workflow_ref, format('@refs/heads/{0}', github.event.repository.default_branch)) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + actions: read + outputs: + eligible: ${{ steps.event.outputs.eligible }} + pr-event: ${{ steps.event.outputs.event-json }} + steps: + - name: Re-read merged pull request from GitHub + id: event + uses: loft-sh/github-actions/.github/actions/resolve-pr-event@resolve-pr-event/v1 # zizmor: ignore[unpinned-uses] -- internal versioned action + with: + event-json: ${{ inputs.pr-event }} + source-workflow: ${{ inputs.source-workflow }} + backport: name: Backport PR + needs: resolve-event if: | - github.event.pull_request.merged == true && + needs.resolve-event.outputs.eligible == 'true' && + fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.merged == true && ( contains( - join(github.event.pull_request.labels.*.name, ','), + join(fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.labels.*.name, ','), 'backport-to-' ) ) && - (github.event.action == 'closed' || startsWith(github.event.label.name, 'backport-to-')) + (fromJSON(needs.resolve-event.outputs.pr-event || '{}').action == 'closed' || startsWith(fromJSON(needs.resolve-event.outputs.pr-event || '{}').label.name, 'backport-to-')) runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read + pull-requests: read + actions: read outputs: targets: ${{ steps.targets.outputs.targets }} steps: + - name: Restore API-resolved pull request event + id: pr-event + uses: loft-sh/github-actions/.github/actions/resolve-pr-event@resolve-pr-event/v1 # zizmor: ignore[unpinned-uses] -- internal versioned action + with: + event-json: ${{ needs.resolve-event.outputs.pr-event }} + source-workflow: ${{ inputs.source-workflow }} + # First, so fix-imports gets its targets even when a cherry-pick fails. - name: List backport targets + if: steps.pr-event.outputs.eligible == 'true' id: targets env: - LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + LABELS: ${{ toJSON(fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.labels.*.name) }} run: echo "targets=$(jq -c '[.[] | select(startswith("backport-to-")) | ltrimstr("backport-to-")]' <<< "$LABELS")" >> "$GITHUB_OUTPUT" - name: Checkout repository + if: steps.pr-event.outputs.eligible == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -101,6 +150,10 @@ jobs: # or a job container would need it installed explicitly. - name: Backport Action + if: steps.pr-event.outputs.eligible == 'true' + env: + NODE_OPTIONS: ${{ steps.pr-event.outputs.node-options }} + RESOLVED_PR_EVENT_PATH: ${{ steps.pr-event.outputs.event-path }} uses: sorenlouv/backport-github-action@8a6c0381851f43f9f1fddc7303f0e9015eb57b62 # v12.0.4 with: github_token: ${{ secrets.gh-access-token }} # zizmor: ignore[secrets-outside-env] -- PAT passed via workflow_call, not a repo secret @@ -113,7 +166,7 @@ jobs: auto_backport_label_prefix: ${{ inputs.legacy-split == false && 'backport-to-' || '' }} - name: Info log - if: ${{ success() }} + if: ${{ success() && steps.pr-event.outputs.eligible == 'true' }} run: cat ~/.backport/backport.info.log - name: Debug log @@ -125,7 +178,7 @@ jobs: # filtering here. fix-imports: name: Fix imports for ${{ matrix.target }} - needs: backport + needs: [resolve-event, backport] if: inputs.fix-imports && !cancelled() && needs.backport.outputs.targets != '' && needs.backport.outputs.targets != '[]' runs-on: ubuntu-latest timeout-minutes: 30 @@ -142,12 +195,12 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Fix imports on backport/${{ matrix.target }}/pr-${{ github.event.pull_request.number }} + - name: Fix imports on backport/${{ matrix.target }}/pr-${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.number }} uses: loft-sh/github-actions/.github/actions/backport-fix-imports@backport-fix-imports/v1 # zizmor: ignore[unpinned-uses] -- internal loft-sh/github-actions ref; reusable workflows check out the CALLER repo, so a local ./.github/actions path is not present here. @backport-fix-imports/v1 is the floating coordination tag kept aligned with backport/v1 (see DEVOPS-923 pin-drift class). with: target-branch: ${{ matrix.target }} - backport-branch: backport/${{ matrix.target }}/pr-${{ github.event.pull_request.number }} - source-branch: ${{ github.event.pull_request.base.ref }} + backport-branch: backport/${{ matrix.target }}/pr-${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.number }} + source-branch: ${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.base.ref }} github-token: ${{ secrets.gh-access-token }} # zizmor: ignore[secrets-outside-env] -- PAT passed through workflow_call; pushes the fix commit so the backport PR's CI runs on it # --- pre-monorepo (<= v0.36) split/re-root flow --------------------------- @@ -156,11 +209,13 @@ jobs: # staging/ tree; they are re-rooted and PR'd by backport-legacy-split instead. legacy-classify: name: Classify legacy targets + needs: resolve-event if: | inputs.legacy-split && - github.event.pull_request.merged == true && - contains(join(github.event.pull_request.labels.*.name, ','), 'backport-to-') && - (github.event.action == 'closed' || startsWith(github.event.label.name, 'backport-to-')) + needs.resolve-event.outputs.eligible == 'true' && + fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.merged == true && + contains(join(fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.labels.*.name, ','), 'backport-to-') && + (fromJSON(needs.resolve-event.outputs.pr-event || '{}').action == 'closed' || startsWith(fromJSON(needs.resolve-event.outputs.pr-event || '{}').label.name, 'backport-to-')) runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -189,12 +244,12 @@ jobs: id: allowlist uses: loft-sh/github-actions/.github/actions/backport-legacy-allowlist@backport-legacy-allowlist/v1 # zizmor: ignore[unpinned-uses] -- internal loft-sh/github-actions ref; reusable workflows check out the CALLER repo, so a local ./.github/actions path is not present here. @backport-legacy-allowlist/v1 is the floating coordination tag kept aligned with backport/v1 (see DEVOPS-923 pin-drift class). with: - labels: ${{ toJSON(github.event.pull_request.labels.*.name) }} + labels: ${{ toJSON(fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.labels.*.name) }} max-minor: '36' legacy-backport: name: Backport ${{ matrix.target }} (legacy) - needs: legacy-classify + needs: [resolve-event, legacy-classify] if: needs.legacy-classify.outputs.has-targets == 'true' runs-on: ubuntu-latest # Bound clone + apply + push so a GitHub service stall can't strand the @@ -211,9 +266,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Pin to the merge commit so it AND its parent (the diff base) are - # present locally; on a pull_request_target closed event the default - # checkout ref is the pre-merge base SHA, which would not contain it. - ref: ${{ github.event.pull_request.merge_commit_sha }} + # present locally. The resolver has verified this merge is in the + # trusted default-branch history. + ref: ${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.merge_commit_sha }} fetch-depth: 0 persist-credentials: false @@ -231,11 +286,11 @@ jobs: oss-repo: ${{ inputs.oss-repo }} pro-repo: ${{ inputs.pro-repo }} github-token: ${{ secrets.gh-access-token }} - commit: ${{ github.event.pull_request.merge_commit_sha }} + commit: ${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.merge_commit_sha }} # PR number so the action diffs the PR head (refs/pull/N/head) from its # merge base -- GitHub's "Files changed" -- capturing every commit of a # rebase-merged PR, not just the merge tip. - pr-number: ${{ github.event.pull_request.number }} + pr-number: ${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.number }} create-pr: 'true' docs-url: ${{ inputs.docs-url }} @@ -269,7 +324,7 @@ jobs: with: marker: "" body: ${{ steps.backport.outputs.comment-body }} - pr-number: ${{ github.event.pull_request.number }} + pr-number: ${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.number }} github-token: ${{ secrets.gh-access-token }} expected-author: ${{ steps.pat.outputs.login }} @@ -278,13 +333,14 @@ jobs: # successful targets link even when another target conflicts or is skipped. link-backports: name: Link backport PRs to Linear sub-issues - needs: [backport, legacy-backport] + needs: [resolve-event, backport, legacy-backport] if: | always() && !cancelled() && - github.event.pull_request.merged == true && - contains(join(github.event.pull_request.labels.*.name, ','), 'backport-to-') && - (github.event.action == 'closed' || startsWith(github.event.label.name, 'backport-to-')) + needs.resolve-event.outputs.eligible == 'true' && + fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.merged == true && + contains(join(fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.labels.*.name, ','), 'backport-to-') && + (fromJSON(needs.resolve-event.outputs.pr-event || '{}').action == 'closed' || startsWith(fromJSON(needs.resolve-event.outputs.pr-event || '{}').label.name, 'backport-to-')) runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -294,9 +350,9 @@ jobs: continue-on-error: true uses: loft-sh/github-actions/.github/actions/link-backport-prs@link-backport-prs/v1 # zizmor: ignore[unpinned-uses] -- internal loft-sh/github-actions ref; reusable workflows check out the CALLER repo, so a local ./.github/actions path is not present here. @link-backport-prs/v1 is the floating coordination tag kept aligned with backport/v1 (see DEVOPS-923 pin-drift class). with: - source-pr: ${{ github.event.pull_request.number }} + source-pr: ${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').pull_request.number }} repo-owner: ${{ github.repository_owner }} - repo-name: ${{ github.event.repository.name }} + repo-name: ${{ fromJSON(needs.resolve-event.outputs.pr-event || '{}').repository.name }} additional-repos: ${{ inputs.legacy-split && format('{0},{1}', inputs.oss-repo, inputs.pro-repo) || '' }} github-token: ${{ secrets.gh-access-token }} # zizmor: ignore[secrets-outside-env] -- PAT passed through workflow_call and required for cross-repo PR edits linear-token: ${{ secrets.linear-token }} # zizmor: ignore[secrets-outside-env] -- optional token passed through workflow_call diff --git a/.github/workflows/claude-code-review.yaml b/.github/workflows/claude-code-review.yaml index 4d36d772..57f30689 100644 --- a/.github/workflows/claude-code-review.yaml +++ b/.github/workflows/claude-code-review.yaml @@ -7,16 +7,19 @@ on: description: 'Anthropic API key for Claude Code Review' required: true +permissions: {} + concurrency: group: claude-review-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: claude-review: - # Guards against running in forked orgs. Does NOT gate on fork PRs — callers - # use pull_request_target so secrets are available for fork PRs by design. - # Do not add run/build steps that source checked-out repo files here. - if: github.repository_owner == 'loft-sh' + # Keep the legacy event until callers migrate; review same-repository PRs only. + if: >- + github.repository_owner == 'loft-sh' && + (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -30,9 +33,6 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 persist-credentials: false - # checkout v7 refuses fork PR heads under pull_request_target. Reviewing - # fork PRs is the point of this job, and nothing here executes their code. - allow-unsafe-pr-checkout: true - name: Restore CLAUDE.md from base branch env: @@ -42,17 +42,6 @@ jobs: # Restore the base-branch version; delete if it doesn't exist there. git show "origin/${BASE_REF}:CLAUDE.md" > CLAUDE.md 2>/dev/null || rm -f CLAUDE.md - - name: Setup fork as origin for Claude - if: ${{ github.event.pull_request.head.repo.fork == true }} - env: - PR_HEAD_CLONE_URL: ${{ github.event.pull_request.head.repo.clone_url }} - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: | - git remote rename origin upstream - git remote add origin "${PR_HEAD_CLONE_URL}" - git fetch origin "${PR_HEAD_REF}" - git checkout -B "${PR_HEAD_REF}" "origin/${PR_HEAD_REF}" - - name: Claude Code Review uses: anthropics/claude-code-action@8cf3482550831fb35a4fc3fbf7ca139cf8028b4c # v1 with: diff --git a/.github/workflows/claude.yaml b/.github/workflows/claude.yaml index 05e65675..9bad6d3e 100644 --- a/.github/workflows/claude.yaml +++ b/.github/workflows/claude.yaml @@ -7,13 +7,43 @@ on: description: 'Anthropic API key for Claude Code agent' required: true +permissions: {} + jobs: - claude: + authorize: if: | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + allowed: ${{ steps.origin.outputs.allowed }} + steps: + - name: Verify trusted author and pull request origin + id: origin + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + IS_PR: ${{ github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment' || (github.event_name == 'issue_comment' && github.event.issue.pull_request != null) }} + PR_NUMBER: ${{ github.event.issue.pull_request && github.event.issue.number || github.event.pull_request.number }} + ASSOCIATION: ${{ github.event_name == 'pull_request_review' && github.event.review.author_association || github.event.comment.author_association }} + run: | + case "$ASSOCIATION" in + MEMBER|OWNER|COLLABORATOR) ;; + *) exit 0 ;; + esac + if [ "$IS_PR" = true ]; then + case "$PR_NUMBER" in ''|*[!0-9]*|0) exit 1 ;; esac + [ "$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" --jq '.head.repo.full_name != null and .head.repo.full_name == .base.repo.full_name')" = true ] || exit 0 + fi + echo 'allowed=true' >> "$GITHUB_OUTPUT" + + claude: + needs: authorize + if: needs.authorize.outputs.allowed == 'true' + runs-on: ubuntu-latest permissions: contents: write pull-requests: write diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index dd066809..3f817b43 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,14 +10,44 @@ on: pull_request_review: types: [submitted] +permissions: {} + jobs: - claude: + authorize: if: | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + allowed: ${{ steps.origin.outputs.allowed }} + steps: + - name: Verify trusted author and pull request origin + id: origin + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + IS_PR: ${{ github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment' || (github.event_name == 'issue_comment' && github.event.issue.pull_request != null) }} + PR_NUMBER: ${{ github.event.issue.pull_request && github.event.issue.number || github.event.pull_request.number }} + ASSOCIATION: ${{ github.event_name == 'issues' && github.event.issue.author_association || github.event_name == 'pull_request_review' && github.event.review.author_association || github.event.comment.author_association }} + run: | + case "$ASSOCIATION" in + MEMBER|OWNER|COLLABORATOR) ;; + *) exit 0 ;; + esac + if [ "$IS_PR" = true ]; then + case "$PR_NUMBER" in ''|*[!0-9]*|0) exit 1 ;; esac + [ "$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" --jq '.head.repo.full_name != null and .head.repo.full_name == .base.repo.full_name')" = true ] || exit 0 + fi + echo 'allowed=true' >> "$GITHUB_OUTPUT" + + claude: + needs: authorize + if: needs.authorize.outputs.allowed == 'true' + runs-on: ubuntu-latest permissions: contents: read pull-requests: read diff --git a/.github/workflows/test-backport.yaml b/.github/workflows/test-backport.yaml index a67efba5..c7c40d3b 100644 --- a/.github/workflows/test-backport.yaml +++ b/.github/workflows/test-backport.yaml @@ -4,14 +4,16 @@ on: pull_request: paths: - '.github/workflows/backport.yaml' + - '.github/workflows/test-backport.yaml' -permissions: - contents: read +permissions: {} jobs: backport: permissions: contents: read + pull-requests: read + actions: read uses: ./.github/workflows/backport.yaml secrets: # GITHUB_TOKEN is sufficient here because the test expects skipped. @@ -22,6 +24,7 @@ jobs: needs: [backport] if: always() runs-on: ubuntu-latest + permissions: {} steps: - name: Check backport result env: diff --git a/.github/workflows/test-claude-code-review.yaml b/.github/workflows/test-claude-code-review.yaml index 2e651b13..c413d2bc 100644 --- a/.github/workflows/test-claude-code-review.yaml +++ b/.github/workflows/test-claude-code-review.yaml @@ -4,11 +4,13 @@ on: pull_request: paths: - '.github/workflows/claude-code-review.yaml' + - '.github/workflows/test-claude-code-review.yaml' permissions: {} jobs: claude-review: + if: github.event.pull_request.head.repo.full_name == github.repository permissions: contents: read pull-requests: write @@ -20,12 +22,12 @@ jobs: needs: [claude-review] if: always() runs-on: ubuntu-latest + permissions: {} steps: - name: Check claude-code-review result env: RESULT: ${{ needs.claude-review.result }} - # The workflow checks github.repository_owner == 'loft-sh' so it runs - # in this repo, but skips in forks. Accept success or skipped — failure + # The workflow reviews same-repo PRs and skips fork PRs. Failure # indicates a genuine workflow bug. run: | echo "claude-code-review result=$RESULT" diff --git a/.github/workflows/test-claude.yaml b/.github/workflows/test-claude.yaml index efe75dce..da644f0d 100644 --- a/.github/workflows/test-claude.yaml +++ b/.github/workflows/test-claude.yaml @@ -4,13 +4,16 @@ on: pull_request: paths: - '.github/workflows/claude.yaml' + - '.github/workflows/claude.yml' + - '.github/workflows/test-claude.yaml' permissions: {} jobs: claude: + if: github.event.pull_request.head.repo.full_name == github.repository permissions: - contents: read + contents: write pull-requests: write actions: read uses: ./.github/workflows/claude.yaml @@ -21,6 +24,7 @@ jobs: needs: [claude] if: always() runs-on: ubuntu-latest + permissions: {} steps: - name: Check claude result env: diff --git a/.github/workflows/test-link-backport-prs.yaml b/.github/workflows/test-link-backport-prs.yaml index 5296dfa8..712fd103 100644 --- a/.github/workflows/test-link-backport-prs.yaml +++ b/.github/workflows/test-link-backport-prs.yaml @@ -5,9 +5,13 @@ on: branches: [main] paths: - '.github/actions/link-backport-prs/**' + - '.github/workflows/backport.yaml' + - '.github/workflows/test-link-backport-prs.yaml' pull_request: paths: - '.github/actions/link-backport-prs/**' + - '.github/workflows/backport.yaml' + - '.github/workflows/test-link-backport-prs.yaml' permissions: contents: read diff --git a/.github/workflows/test-resolve-pr-event.yaml b/.github/workflows/test-resolve-pr-event.yaml new file mode 100644 index 00000000..16b37aa5 --- /dev/null +++ b/.github/workflows/test-resolve-pr-event.yaml @@ -0,0 +1,40 @@ +name: Test resolve-pr-event +on: + push: + branches: [main] + paths: + - '.github/actions/resolve-pr-event/**' + - '.github/workflows/test-resolve-pr-event.yaml' + - '.github/workflows/backport.yaml' + - '.github/workflows/claude-code-review.yaml' + - '.github/workflows/test-claude-code-review.yaml' + - '.github/workflows/test-backport.yaml' + - '.github/workflows/claude.yaml' + - '.github/workflows/claude.yml' + - '.github/workflows/test-claude.yaml' + pull_request: + paths: + - '.github/actions/resolve-pr-event/**' + - '.github/workflows/test-resolve-pr-event.yaml' + - '.github/workflows/backport.yaml' + - '.github/workflows/claude-code-review.yaml' + - '.github/workflows/test-claude-code-review.yaml' + - '.github/workflows/test-backport.yaml' + - '.github/workflows/claude.yaml' + - '.github/workflows/claude.yml' + - '.github/workflows/test-claude.yaml' +permissions: + contents: read +jobs: + pytest: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Run tests + run: uv run --with pytest==8.3.3 pytest .github/actions/resolve-pr-event/test/ -q diff --git a/Makefile b/Makefile index 82429828..6a9fb55c 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ ACTIONS_DIR := .github/actions .PHONY: test-check-action-pins +.PHONY: test-resolve-pr-event WORKFLOWS_DIR := .github/workflows SCRIPTS_DIR := .github/scripts @@ -100,6 +101,10 @@ test-retry-approved-pr-ci: ## run approved PR CI retry pytest tests uv run --with pytest==8.3.3 pytest $(ACTIONS_DIR)/retry-approved-pr-ci/test/ -q test: test-check-action-pins +test: test-resolve-pr-event + +test-resolve-pr-event: ## run pull request event handoff tests + uv run --with pytest==8.3.3 pytest $(ACTIONS_DIR)/resolve-pr-event/test/ -q test-ghcr-tag-guard: ## run GHCR publication guard tests uv run --with pytest==8.3.3 pytest $(ACTIONS_DIR)/ghcr-tag-guard/test/ -q diff --git a/README.md b/README.md index d5d98421..6a57314e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ are written, tested, and structured. Join the testing tailnet from trusted GitHub-hosted Linux jobs using OIDC. See [testing-tailnet README](./.github/actions/testing-tailnet/README.md) for trust and cleanup requirements. +### Resolve merged pull requests + +Finds merged PRs from every commit in a trusted default-branch push and reads +current labels from GitHub before a backport runs. A read-only PR signal and a +trusted default-branch consumer support labels added after merge. It reads no PR artifact and +checks out no PR code. + +**Location:** `.github/actions/resolve-pr-event` + +See [resolve-pr-event](./.github/actions/resolve-pr-event/README.md) for the caller +pattern, permissions, inputs, and outputs. + ### GHCR tag guard Checks every version tag before publication. Existing digests and ambiguous diff --git a/docs/workflows/backport.md b/docs/workflows/backport.md index 5ec94462..58a2af1f 100644 --- a/docs/workflows/backport.md +++ b/docs/workflows/backport.md @@ -1,19 +1,58 @@ # Backport -Creates backport PRs when a merged PR is labeled with `backport/`. +Creates backport PRs for merged PRs with `backport-to-` labels. The caller +runs from the trusted default branch on a push or a completed read-only PR signal workflow. The +workflow re-reads the PR from GitHub before any job receives a mutation token. + +This is a new caller contract. Publish the workflow as `backport/v2`; keep +`backport/v1` unchanged until existing callers migrate. Publish the new +`resolve-pr-event/v1` action first. The action has not been released yet. + +See the [resolver example](../../.github/actions/resolve-pr-event/README.md) for +push selection and matrix fanout. A push can merge more than one PR; use the +`events-json` matrix output, rather than the single-event output. + +PR events cannot invoke the mutation jobs. A read-only `pull_request` workflow +with only the `labeled` activity type signals a default-branch `workflow_run` +consumer. It checks out no code and passes no artifact. The resolver reads the +completed run and associated merged PRs from GitHub, so labels added after merge +still trigger backports. Push selection handles merges, including those whose +original PR workflow was skipped. Omitting `closed` avoids a duplicate backport +run on merge. Fork code never runs in the privileged consumer. + +For each selected envelope, restoration verifies the PR identity and merge SHA, +checks default-branch ancestry, and reads current labels. On push, restoration +also checks that the merge belongs to the original push range. It re-fetches only +the selected PR and uses a constant number of API requests without walking the +push range again. + +| Stage | Trigger and reads | Mutations | Output and skip remedy | +|---|---|---|---| +| Select | Default-branch push; all commits in its range and associated PRs from GitHub | None | One event per merged PR. A commit with no PR, such as a direct push, is skipped with a warning that lists it; PRs merged in the same push still run. If a listed commit merged a PR, re-add its backport label to signal another run. | +| Resolve | Trusted default-branch caller; PR identity, merge and current labels from GitHub | None | Canonical merged PR event. Wrong branch or unmerged PR skips; merge first and run from the default branch. | +| Backport | Canonical event with current backport labels | Existing branch, PR and comment writes using the caller PAT | Existing cherry-pick logs and per-target failure comments. Remove an unwanted label or fix the reported conflict. | +| Link | Both modern and legacy producers have finished | Existing advisory Linear and PR links | Existing advisory output; missing Linear token skips linking. | + +The resolver rejects forged identities, incomplete ranges and forced pushes. +Existing backport actions keep duplicate branch/PR checks and conflict comments. +Expected misses produce a warning or an empty selection; API failures fail the +job. Logs and job outputs remain the observability surface. No new service, +metric, alert or production policy is introduced. ## Inputs -| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | -|----------------|---------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| docs-url | string | false | | Optional link to this repo's backport
documentation. Added to the failure comment
so whoever hits it knows where
the process is written down. Omitted
when empty. An input rather than
a constant because this workflow is
shared with public repositories. | -| fix-imports | boolean | false | `false` | After the cherry-picks, run backport-fix-imports on
each backport branch: rewrite the repository's
own Go module paths to the
target line's major, resolve import-only conflicts,
and push a fix commit. For
repositories whose release lines sit on
an older module major. | -| legacy-split | boolean | false | `false` | Enable the pre-monorepo (<= v0.36) split/re-root backport
flow. When true this workflow empties
auto_backport_label_prefix so sorenlouv honors the caller's
.backportrc.json branchLabelMapping regex (which MUST be scoped to the monorepo era, >= v0.37), and a
companion job routes <= v0.36 targets
through backport-legacy-split. Requires subtree-prefix, oss-repo and
pro-repo. | -| oss-repo | string | false | | OSS repo as owner/repo for legacy
OSS-side backport PRs. Required when legacy-split
is true. | -| pro-repo | string | false | | Pro repo as owner/repo for the
pro half of mixed legacy backports.
Required when legacy-split is true. | -| subtree-prefix | string | false | | OSS subtree path in the monorepo,
e.g. staging/github.com/loft-sh/vcluster. Required when legacy-split is
true. | +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|-----------------|---------|----------|------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| docs-url | string | false | | Optional link to this repo's backport
documentation. Added to the failure comment
so whoever hits it knows where
the process is written down. Omitted
when empty. An input rather than
a constant because this workflow is
shared with public repositories. | +| fix-imports | boolean | false | `false` | After the cherry-picks, run backport-fix-imports on
each backport branch: rewrite the repository's
own Go module paths to the
target line's major, resolve import-only conflicts,
and push a fix commit. For
repositories whose release lines sit on
an older module major. | +| legacy-split | boolean | false | `false` | Enable the pre-monorepo (<= v0.36) split/re-root backport
flow. When true this workflow empties
auto_backport_label_prefix so sorenlouv honors the caller's
.backportrc.json branchLabelMapping regex (which MUST be scoped to the monorepo era, >= v0.37), and a
companion job routes <= v0.36 targets
through backport-legacy-split. Requires subtree-prefix, oss-repo and
pro-repo. | +| oss-repo | string | false | | OSS repo as owner/repo for legacy
OSS-side backport PRs. Required when legacy-split
is true. | +| pr-event | string | false | | Merged PR event JSON from resolve-pr-event
on a trusted default-branch push or
workflow_run. | +| pro-repo | string | false | | Pro repo as owner/repo for the
pro half of mixed legacy backports.
Required when legacy-split is true. | +| source-workflow | string | false | `".github/workflows/pull-request-events.yaml"` | Read-only PR signal workflow path for
workflow_run callers. No artifacts are consumed. | +| subtree-prefix | string | false | | OSS subtree path in the monorepo,
e.g. staging/github.com/loft-sh/vcluster. Required when legacy-split is
true. | diff --git a/docs/workflows/claude-code-review.md b/docs/workflows/claude-code-review.md index b41811da..a1d99e1c 100644 --- a/docs/workflows/claude-code-review.md +++ b/docs/workflows/claude-code-review.md @@ -1,6 +1,13 @@ # Claude Code Review -Runs Claude-powered code review on pull requests. +Runs Claude-powered code review on same-repository pull requests. Both the caller +and this reusable workflow must check that the head repository equals the base +repository before passing the API key. Fork PRs skip review. The job grants only +`contents: read` and `pull-requests: write`; it does not build or run PR scripts. + +Callers should use `pull_request`. During the transition, legacy +`pull_request_target` callers are also accepted for same-repository PRs. A +separate inventory tracks caller migration. ## Inputs diff --git a/docs/workflows/claude.md b/docs/workflows/claude.md index af6f43d8..bc7d238e 100644 --- a/docs/workflows/claude.md +++ b/docs/workflows/claude.md @@ -1,6 +1,6 @@ # Claude -Runs the Claude Code agent on pull request comment triggers. +Runs the Claude Code agent for trusted member, owner, or collaborator mentions. Pull request mentions run only when the head and base repositories match. Mentions on fork pull requests are skipped before the agent receives its secret. ## Inputs