Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/check-action-pins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/commitlint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/link-backport-prs/src/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
100 changes: 100 additions & 0 deletions .github/actions/resolve-pr-event/README.md
Original file line number Diff line number Diff line change
@@ -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

<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->

| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
|-----------------|--------|----------|------------------------------------------------|----------------------------------------------------------------------------------------|
| event-json | string | false | | Trusted payload passed between jobs through <br>workflow_call. |
| github-token | string | false | `"${{ github.token }}"` | Token with contents and pull requests <br>read access; actions read for workflow_run. |
| source-workflow | string | false | `".github/workflows/pull-request-events.yaml"` | Expected read-only pull request signal workflow <br>path. |

<!-- AUTO-DOC-INPUT:END -->

## Outputs

<!-- AUTO-DOC-OUTPUT:START - Do not remove or modify this section -->

| 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 <br>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 <br>for a job matrix. |
| node-options | string | NODE_OPTIONS preload for Node actions that <br>consume the synthetic event. |
| pr-number | string | Pull request number. |

<!-- AUTO-DOC-OUTPUT:END -->
46 changes: 46 additions & 0 deletions .github/actions/resolve-pr-event/action.yml
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 20 additions & 0 deletions .github/actions/resolve-pr-event/src/event-bootstrap.cjs
Original file line number Diff line number Diff line change
@@ -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');
}
Loading
Loading