detached df1fe87

feat(ci): pull_request_target (#41)

commit
df1fe87c8cdd83cc958bb414787579d9836fbd64
author
brice · · 2026-10-16 08:23 UTC
committer
bnema · · 2026-10-16 08:23 UTC
parent
626ef13
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
19 files · +714 -43
M README.md +7 -3
@@ -115,7 +115,7 @@ A merge request proposes a branch for its target (the default branch unless set)
115115
116116 ## CI
117117
118-ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request), on `pull_request` (the merge requests, below), `workflow_dispatch`, `schedule` and `workflow_run` (below) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.
118+ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request), on `pull_request` and `pull_request_target` (the merge requests, below), `workflow_dispatch`, `schedule` and `workflow_run` (below) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.
119119
120120 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
121121
@@ -123,7 +123,11 @@ Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a ru
123123
124124 An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.
125125
126-**`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner, like a push; a job token starts no run.
126+**`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner **and** the head and the base it runs were pushed by the owner: its workflow comes from the merge commit, so from both branches. Each is checked against the record of who set the branch to exactly that commit, taken when the event happens (see `schedule`); a branch with no record, or whose record names another commit, is untrusted. The owner opening, pushing on or closing the request of a branch an agent pushed (or one targeting an agent's branch) gets an untrusted run. A closing runs the last test merge commit, whose head and base are checked; when it is the merge of an older head than the closing's, it is untrusted. The closing a merge makes has the merger's trust when the merger set the target to the merge result and the head is the one merged, even after the source branch is deleted. A job token starts no run.
127+
128+**`pull_request_target`.** The same activities, `types:` (default `opened`, `synchronize`, `reopened`), `branches` and `paths` filters and payload as `pull_request`, with `github.event_name` `pull_request_target`, but the workflow is read from the **default branch** and runs on it: `github.ref` is `refs/heads/<default>` and `github.sha` its tip as it was when the activity happened, whatever the head contains (a head that edits the workflow does not change what runs). It also runs when the head conflicts with the target. Its checks are on the head commit, like those of `pull_request`, so they show on the merge request and not on the default branch. The run is trusted (secrets, write token) only when **both** whoever caused the activity is the owner **and** the default branch tip was pushed by the owner (see `schedule`); the head's pusher does not matter, since none of its files run. This is stricter than GitHub, which gives the secrets to every pull request of the repository: here an agent that opens or pushes a merge request gets the run without secrets and with a read-only token. Re-runs and `workflow_run` keep or lower the trust as for any run. Its cache is read-only (the runner sets `ACTIONS_CACHE_MODE=read`, so `actions/cache` skips the save) and restores only the default branch's entries, never the merge ref's, which the `pull_request` runs of the request save from the head's code. Every other run restores from its own ref, then the target branch for a `pull_request`, then the default branch, and saves under its own ref. An entry keeps the trust of the run that saved it: a trusted run restores only entries trusted runs saved (the owner pushing on top of an agent's branch or merge request never restores what the agent's runs cached), an untrusted run restores both, and an untrusted run on the default branch (an agent's `workflow_run` or dispatch on it) saves nothing. Entries saved before ermite sent the cache scope (ermite-runner then used the run's ref whatever its trust) are not told apart: clear the runners' caches once after upgrading.
129+
130+> **Warning** (GitHub's, and it applies here): running untrusted code on `pull_request_target` can leak the secrets and the write token, and poison caches. A trusted run that checks out the head (`actions/checkout` with `ref: ${{ github.event.pull_request.head.sha }}`, `git fetch` of `refs/pull/<n>/head`) and then builds, tests or installs it runs the head's code with them: a "pwn request". Use `pull_request` when the workflow builds the request's code; with `pull_request_target`, treat the checked-out head as data only and never execute it (build scripts, `npm install`, configuration files and tools it brings all count). See GitHub's [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target) and [Preventing pwn requests](https://securitylab.github.com/research/github-actions-preventing-pwn-requests).
127131
128132 **`workflow_dispatch`.** `ermite workflow run <file> --repo <repo> [--ref <branch or tag>] [-f name=value …]` starts a run of a workflow that declares `workflow_dispatch` (the API is `RunService.DispatchWorkflow`, feature `ci.dispatch`; it needs `ci:write`). The file is its path or its name under `.github/workflows`, and it is read at the ref (the default branch when `--ref` is left out). The inputs are checked against the workflow: an undeclared input, a missing `required` input without a default, a `boolean` other than `true` or `false`, a `number` that is not a decimal number, a `choice` outside its `options`, and an `environment` that is not a valid environment name are refused; a `choice` without default takes its first option. A workflow has at most 25 inputs and their values at most 65,535 characters. In the run, `github.event.inputs` holds the values as strings and the `inputs` context as their declared types (`boolean`, `number`, else string). The actor is the caller. The run is trusted (secrets, write token) only when **both** the caller is the owner **and** the ref it runs on was moved to its current commit by the owner (see `schedule`): the owner dispatching an agent's branch gets an untrusted run, like the owner re-running an agent's run, and a ref with no record (pushed before ermite recorded pushers) is untrusted too. This holds for a tag as for a branch, and for a job token as for the owner. A token limited to branch prefixes can dispatch only on a branch under them and its run is never trusted. A job can dispatch with its repository token when its `permissions:` give `actions: write` (GitHub's exception to "events made with `GITHUB_TOKEN` start no run"), even without `contents: read`; a token that does not hold the permission is refused.
129133
@@ -131,7 +135,7 @@ An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same
131135
132136 **`workflow_run`.** A workflow that lists `workflow_run: workflows: [<name>]` runs when a run of a workflow with that `name` is `requested` (created) or `completed` (`types:` picks one; `in_progress` never happens). `branches` and `branches-ignore` apply to the head branch of the triggering run (the source branch of a merge request). The workflow is read from the **default branch** and runs on its latest commit; the payload has `workflow_run` (`id`, `name`, `head_branch`, `head_sha`, `event`, `status`, `conclusion`, `run_number`, `run_attempt`, `path`, `actor`) and `workflow`. The run inherits the actor and the trust of the triggering run and never gains any: a workflow_run of an untrusted run has no secrets. A chain stops after three levels of `workflow_run`.
133137
134-The event of a run is `push`, `pull_request`, `workflow_dispatch`, `schedule` or `workflow_run`: `ermite run list` and `view` and the Actions pages show it.
138+The event of a run is `push`, `pull_request`, `pull_request_target`, `workflow_dispatch`, `schedule` or `workflow_run`: `ermite run list` and `view` and the Actions pages show it.
135139
136140 The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm`, `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive) and `GET /repos/{owner}/{repo}/pulls/{number}/files` (what `dorny/paths-filter` reads on a `pull_request`: up to 3000 files, `page` and `per_page` of at most 100, 30 by default, with the `Link` header; a job token needs `pull-requests: read`, else `403 Resource not accessible by integration`). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
137141
@@ -115,7 +115,7 @@ A merge request proposes a branch for its target (the default branch unless set)
115 115
116 ## CI116 ## CI
117 117
118-ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request), on `pull_request` (the merge requests, below), `workflow_dispatch`, `schedule` and `workflow_run` (below) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.118+ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request), on `pull_request` and `pull_request_target` (the merge requests, below), `workflow_dispatch`, `schedule` and `workflow_run` (below) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.
119 119
120 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.120 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
121 121
@@ -123,7 +123,11 @@ Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a ru
123 123
124 An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.124 An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.
125 125
126-**`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner, like a push; a job token starts no run.126+**`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner **and** the head and the base it runs were pushed by the owner: its workflow comes from the merge commit, so from both branches. Each is checked against the record of who set the branch to exactly that commit, taken when the event happens (see `schedule`); a branch with no record, or whose record names another commit, is untrusted. The owner opening, pushing on or closing the request of a branch an agent pushed (or one targeting an agent's branch) gets an untrusted run. A closing runs the last test merge commit, whose head and base are checked; when it is the merge of an older head than the closing's, it is untrusted. The closing a merge makes has the merger's trust when the merger set the target to the merge result and the head is the one merged, even after the source branch is deleted. A job token starts no run.
127+
128+**`pull_request_target`.** The same activities, `types:` (default `opened`, `synchronize`, `reopened`), `branches` and `paths` filters and payload as `pull_request`, with `github.event_name` `pull_request_target`, but the workflow is read from the **default branch** and runs on it: `github.ref` is `refs/heads/<default>` and `github.sha` its tip as it was when the activity happened, whatever the head contains (a head that edits the workflow does not change what runs). It also runs when the head conflicts with the target. Its checks are on the head commit, like those of `pull_request`, so they show on the merge request and not on the default branch. The run is trusted (secrets, write token) only when **both** whoever caused the activity is the owner **and** the default branch tip was pushed by the owner (see `schedule`); the head's pusher does not matter, since none of its files run. This is stricter than GitHub, which gives the secrets to every pull request of the repository: here an agent that opens or pushes a merge request gets the run without secrets and with a read-only token. Re-runs and `workflow_run` keep or lower the trust as for any run. Its cache is read-only (the runner sets `ACTIONS_CACHE_MODE=read`, so `actions/cache` skips the save) and restores only the default branch's entries, never the merge ref's, which the `pull_request` runs of the request save from the head's code. Every other run restores from its own ref, then the target branch for a `pull_request`, then the default branch, and saves under its own ref. An entry keeps the trust of the run that saved it: a trusted run restores only entries trusted runs saved (the owner pushing on top of an agent's branch or merge request never restores what the agent's runs cached), an untrusted run restores both, and an untrusted run on the default branch (an agent's `workflow_run` or dispatch on it) saves nothing. Entries saved before ermite sent the cache scope (ermite-runner then used the run's ref whatever its trust) are not told apart: clear the runners' caches once after upgrading.
129+
130+> **Warning** (GitHub's, and it applies here): running untrusted code on `pull_request_target` can leak the secrets and the write token, and poison caches. A trusted run that checks out the head (`actions/checkout` with `ref: ${{ github.event.pull_request.head.sha }}`, `git fetch` of `refs/pull/<n>/head`) and then builds, tests or installs it runs the head's code with them: a "pwn request". Use `pull_request` when the workflow builds the request's code; with `pull_request_target`, treat the checked-out head as data only and never execute it (build scripts, `npm install`, configuration files and tools it brings all count). See GitHub's [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target) and [Preventing pwn requests](https://securitylab.github.com/research/github-actions-preventing-pwn-requests).
127 131
128 **`workflow_dispatch`.** `ermite workflow run <file> --repo <repo> [--ref <branch or tag>] [-f name=value …]` starts a run of a workflow that declares `workflow_dispatch` (the API is `RunService.DispatchWorkflow`, feature `ci.dispatch`; it needs `ci:write`). The file is its path or its name under `.github/workflows`, and it is read at the ref (the default branch when `--ref` is left out). The inputs are checked against the workflow: an undeclared input, a missing `required` input without a default, a `boolean` other than `true` or `false`, a `number` that is not a decimal number, a `choice` outside its `options`, and an `environment` that is not a valid environment name are refused; a `choice` without default takes its first option. A workflow has at most 25 inputs and their values at most 65,535 characters. In the run, `github.event.inputs` holds the values as strings and the `inputs` context as their declared types (`boolean`, `number`, else string). The actor is the caller. The run is trusted (secrets, write token) only when **both** the caller is the owner **and** the ref it runs on was moved to its current commit by the owner (see `schedule`): the owner dispatching an agent's branch gets an untrusted run, like the owner re-running an agent's run, and a ref with no record (pushed before ermite recorded pushers) is untrusted too. This holds for a tag as for a branch, and for a job token as for the owner. A token limited to branch prefixes can dispatch only on a branch under them and its run is never trusted. A job can dispatch with its repository token when its `permissions:` give `actions: write` (GitHub's exception to "events made with `GITHUB_TOKEN` start no run"), even without `contents: read`; a token that does not hold the permission is refused.132 **`workflow_dispatch`.** `ermite workflow run <file> --repo <repo> [--ref <branch or tag>] [-f name=value …]` starts a run of a workflow that declares `workflow_dispatch` (the API is `RunService.DispatchWorkflow`, feature `ci.dispatch`; it needs `ci:write`). The file is its path or its name under `.github/workflows`, and it is read at the ref (the default branch when `--ref` is left out). The inputs are checked against the workflow: an undeclared input, a missing `required` input without a default, a `boolean` other than `true` or `false`, a `number` that is not a decimal number, a `choice` outside its `options`, and an `environment` that is not a valid environment name are refused; a `choice` without default takes its first option. A workflow has at most 25 inputs and their values at most 65,535 characters. In the run, `github.event.inputs` holds the values as strings and the `inputs` context as their declared types (`boolean`, `number`, else string). The actor is the caller. The run is trusted (secrets, write token) only when **both** the caller is the owner **and** the ref it runs on was moved to its current commit by the owner (see `schedule`): the owner dispatching an agent's branch gets an untrusted run, like the owner re-running an agent's run, and a ref with no record (pushed before ermite recorded pushers) is untrusted too. This holds for a tag as for a branch, and for a job token as for the owner. A token limited to branch prefixes can dispatch only on a branch under them and its run is never trusted. A job can dispatch with its repository token when its `permissions:` give `actions: write` (GitHub's exception to "events made with `GITHUB_TOKEN` start no run"), even without `contents: read`; a token that does not hold the permission is refused.
129 133
@@ -131,7 +135,7 @@ An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same
131 135
132 **`workflow_run`.** A workflow that lists `workflow_run: workflows: [<name>]` runs when a run of a workflow with that `name` is `requested` (created) or `completed` (`types:` picks one; `in_progress` never happens). `branches` and `branches-ignore` apply to the head branch of the triggering run (the source branch of a merge request). The workflow is read from the **default branch** and runs on its latest commit; the payload has `workflow_run` (`id`, `name`, `head_branch`, `head_sha`, `event`, `status`, `conclusion`, `run_number`, `run_attempt`, `path`, `actor`) and `workflow`. The run inherits the actor and the trust of the triggering run and never gains any: a workflow_run of an untrusted run has no secrets. A chain stops after three levels of `workflow_run`.136 **`workflow_run`.** A workflow that lists `workflow_run: workflows: [<name>]` runs when a run of a workflow with that `name` is `requested` (created) or `completed` (`types:` picks one; `in_progress` never happens). `branches` and `branches-ignore` apply to the head branch of the triggering run (the source branch of a merge request). The workflow is read from the **default branch** and runs on its latest commit; the payload has `workflow_run` (`id`, `name`, `head_branch`, `head_sha`, `event`, `status`, `conclusion`, `run_number`, `run_attempt`, `path`, `actor`) and `workflow`. The run inherits the actor and the trust of the triggering run and never gains any: a workflow_run of an untrusted run has no secrets. A chain stops after three levels of `workflow_run`.
133 137
134-The event of a run is `push`, `pull_request`, `workflow_dispatch`, `schedule` or `workflow_run`: `ermite run list` and `view` and the Actions pages show it.138+The event of a run is `push`, `pull_request`, `pull_request_target`, `workflow_dispatch`, `schedule` or `workflow_run`: `ermite run list` and `view` and the Actions pages show it.
135 139
136 The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm`, `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive) and `GET /repos/{owner}/{repo}/pulls/{number}/files` (what `dorny/paths-filter` reads on a `pull_request`: up to 3000 files, `page` and `per_page` of at most 100, 30 by default, with the `Link` header; a job token needs `pull-requests: read`, else `403 Resource not accessible by integration`). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.140 The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm`, `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive) and `GET /repos/{owner}/{repo}/pulls/{number}/files` (what `dorny/paths-filter` reads on a `pull_request`: up to 3000 files, `page` and `per_page` of at most 100, 30 by default, with the `Link` header; a job token needs `pull-requests: read`, else `403 Resource not accessible by integration`). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
137 141
M gen/ermite/storage/ci.pb.go +15 -3
@@ -516,7 +516,10 @@ type CIRun struct {
516516 Concurrency *CIConcurrency `protobuf:"bytes,24,opt,name=concurrency,proto3" json:"concurrency,omitempty"`
517517 // head_sha: the head of the merge request of a pull_request run, where sha is
518518 // the test merge commit. Empty for the other events.
519- HeadSha string `protobuf:"bytes,25,opt,name=head_sha,json=headSha,proto3" json:"head_sha,omitempty"`
519+ HeadSha string `protobuf:"bytes,25,opt,name=head_sha,json=headSha,proto3" json:"head_sha,omitempty"`
520+ // pull_number: the merge request of a pull_request or pull_request_target
521+ // run, 0 for the other events and in runs stored before it existed.
522+ PullNumber int64 `protobuf:"varint,26,opt,name=pull_number,json=pullNumber,proto3" json:"pull_number,omitempty"`
520523 unknownFields protoimpl.UnknownFields
521524 sizeCache protoimpl.SizeCache
522525 }
@@ -712,6 +715,13 @@ func (x *CIRun) GetHeadSha() string {
712715 return ""
713716 }
714717
718+func (x *CIRun) GetPullNumber() int64 {
719+ if x != nil {
720+ return x.PullNumber
721+ }
722+ return 0
723+}
724+
715725 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned
716726 // from, kept apart so that listing runs does not decode it.
717727 type CIRunSource struct {
@@ -2504,7 +2514,7 @@ var File_ermite_storage_ci_proto protoreflect.FileDescriptor
25042514
25052515 const file_ermite_storage_ci_proto_rawDesc = "" +
25062516 "\n" +
2507- "\x17ermite/storage/ci.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd5\x06\n" +
2517+ "\x17ermite/storage/ci.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf6\x06\n" +
25082518 "\x05CIRun\x12\x12\n" +
25092519 "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x0e\n" +
25102520 "\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" +
@@ -2533,7 +2543,9 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
25332543 "\x04name\x18\x16 \x01(\tR\x04name\x12\x18\n" +
25342544 "\atrusted\x18\x17 \x01(\bR\atrusted\x12?\n" +
25352545 "\vconcurrency\x18\x18 \x01(\v2\x1d.ermite.storage.CIConcurrencyR\vconcurrency\x12\x19\n" +
2536- "\bhead_sha\x18\x19 \x01(\tR\aheadShaJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"\xc7\x01\n" +
2546+ "\bhead_sha\x18\x19 \x01(\tR\aheadSha\x12\x1f\n" +
2547+ "\vpull_number\x18\x1a \x01(\x03R\n" +
2548+ "pullNumberJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"\xc7\x01\n" +
25372549 "\vCIRunSource\x12\x15\n" +
25382550 "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" +
25392551 "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" +
@@ -516,7 +516,10 @@ type CIRun struct {
516 Concurrency *CIConcurrency `protobuf:"bytes,24,opt,name=concurrency,proto3" json:"concurrency,omitempty"`516 Concurrency *CIConcurrency `protobuf:"bytes,24,opt,name=concurrency,proto3" json:"concurrency,omitempty"`
517 // head_sha: the head of the merge request of a pull_request run, where sha is517 // head_sha: the head of the merge request of a pull_request run, where sha is
518 // the test merge commit. Empty for the other events.518 // the test merge commit. Empty for the other events.
519- HeadSha string `protobuf:"bytes,25,opt,name=head_sha,json=headSha,proto3" json:"head_sha,omitempty"`519+ HeadSha string `protobuf:"bytes,25,opt,name=head_sha,json=headSha,proto3" json:"head_sha,omitempty"`
520+ // pull_number: the merge request of a pull_request or pull_request_target
521+ // run, 0 for the other events and in runs stored before it existed.
522+ PullNumber int64 `protobuf:"varint,26,opt,name=pull_number,json=pullNumber,proto3" json:"pull_number,omitempty"`
520 unknownFields protoimpl.UnknownFields523 unknownFields protoimpl.UnknownFields
521 sizeCache protoimpl.SizeCache524 sizeCache protoimpl.SizeCache
522 }525 }
@@ -712,6 +715,13 @@ func (x *CIRun) GetHeadSha() string {
712 return ""715 return ""
713 }716 }
714 717
718+func (x *CIRun) GetPullNumber() int64 {
719+ if x != nil {
720+ return x.PullNumber
721+ }
722+ return 0
723+}
724+
715 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned725 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned
716 // from, kept apart so that listing runs does not decode it.726 // from, kept apart so that listing runs does not decode it.
717 type CIRunSource struct {727 type CIRunSource struct {
@@ -2504,7 +2514,7 @@ var File_ermite_storage_ci_proto protoreflect.FileDescriptor
2504 2514
2505 const file_ermite_storage_ci_proto_rawDesc = "" +2515 const file_ermite_storage_ci_proto_rawDesc = "" +
2506 "\n" +2516 "\n" +
2507- "\x17ermite/storage/ci.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd5\x06\n" +2517+ "\x17ermite/storage/ci.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf6\x06\n" +
2508 "\x05CIRun\x12\x12\n" +2518 "\x05CIRun\x12\x12\n" +
2509 "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x0e\n" +2519 "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x0e\n" +
2510 "\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" +2520 "\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" +
@@ -2533,7 +2543,9 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
2533 "\x04name\x18\x16 \x01(\tR\x04name\x12\x18\n" +2543 "\x04name\x18\x16 \x01(\tR\x04name\x12\x18\n" +
2534 "\atrusted\x18\x17 \x01(\bR\atrusted\x12?\n" +2544 "\atrusted\x18\x17 \x01(\bR\atrusted\x12?\n" +
2535 "\vconcurrency\x18\x18 \x01(\v2\x1d.ermite.storage.CIConcurrencyR\vconcurrency\x12\x19\n" +2545 "\vconcurrency\x18\x18 \x01(\v2\x1d.ermite.storage.CIConcurrencyR\vconcurrency\x12\x19\n" +
2536- "\bhead_sha\x18\x19 \x01(\tR\aheadShaJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"\xc7\x01\n" +2546+ "\bhead_sha\x18\x19 \x01(\tR\aheadSha\x12\x1f\n" +
2547+ "\vpull_number\x18\x1a \x01(\x03R\n" +
2548+ "pullNumberJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"\xc7\x01\n" +
2537 "\vCIRunSource\x12\x15\n" +2549 "\vCIRunSource\x12\x15\n" +
2538 "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" +2550 "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" +
2539 "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" +2551 "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" +
M internal/adapters/kvstore/ci.go +2 -2
@@ -278,7 +278,7 @@ func encodeRun(r domain.CIRun) ([]byte, error) {
278278 return mustMarshal(&storagepb.CIRun{
279279 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
280280 WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: r.Name, Title: r.Title,
281- Event: r.Event, Ref: r.Ref, Sha: r.Sha, HeadSha: r.HeadSha, Actor: r.Actor,
281+ Event: r.Event, Ref: r.Ref, Sha: r.Sha, HeadSha: r.HeadSha, PullNumber: r.PullNumber, Actor: r.Actor,
282282 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
283283 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
284284 CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt), Trusted: r.Trusted,
@@ -302,7 +302,7 @@ func decodeRun(item kv.Item) (domain.CIRun, error) {
302302 return domain.CIRun{
303303 Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),
304304 WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Name: name, Title: pb.GetTitle(),
305- Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), HeadSha: pb.GetHeadSha(), Actor: pb.GetActor(),
305+ Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), HeadSha: pb.GetHeadSha(), PullNumber: pb.GetPullNumber(), Actor: pb.GetActor(),
306306 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
307307 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
308308 CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
@@ -278,7 +278,7 @@ func encodeRun(r domain.CIRun) ([]byte, error) {
278 return mustMarshal(&storagepb.CIRun{278 return mustMarshal(&storagepb.CIRun{
279 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,279 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
280 WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: r.Name, Title: r.Title,280 WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: r.Name, Title: r.Title,
281- Event: r.Event, Ref: r.Ref, Sha: r.Sha, HeadSha: r.HeadSha, Actor: r.Actor,281+ Event: r.Event, Ref: r.Ref, Sha: r.Sha, HeadSha: r.HeadSha, PullNumber: r.PullNumber, Actor: r.Actor,
282 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,282 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
283 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),283 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
284 CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt), Trusted: r.Trusted,284 CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt), Trusted: r.Trusted,
@@ -302,7 +302,7 @@ func decodeRun(item kv.Item) (domain.CIRun, error) {
302 return domain.CIRun{302 return domain.CIRun{
303 Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),303 Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),
304 WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Name: name, Title: pb.GetTitle(),304 WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Name: name, Title: pb.GetTitle(),
305- Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), HeadSha: pb.GetHeadSha(), Actor: pb.GetActor(),305+ Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), HeadSha: pb.GetHeadSha(), PullNumber: pb.GetPullNumber(), Actor: pb.GetActor(),
306 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),306 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
307 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),307 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
308 CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),308 CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
M internal/adapters/rpc/api.go +1 -1
@@ -34,7 +34,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3434 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3535 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3636 // are.
37-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch"}
37+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache"}
3838
3939 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4040 const FeaturePasskeys = "passkeys"
@@ -34,7 +34,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
34 // always implements, as GetVersion lists them. FeaturePasskeys comes on top34 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
35 // when Config.Passkeys is set, and FeatureCISecrets when the settings services35 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
36 // are.36 // are.
37-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch"}37+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache"}
38 38
39 // FeaturePasskeys is listed by GetVersion when passkeys are available.39 // FeaturePasskeys is listed by GetVersion when passkeys are available.
40 const FeaturePasskeys = "passkeys"40 const FeaturePasskeys = "passkeys"
M internal/adapters/rpc/ci_convert.go +1 -0
@@ -224,6 +224,7 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
224224 Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,
225225 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,
226226 Permissions: &pb.JobPermissions{Levels: j.Permissions},
227+ Cache: &pb.JobCache{Repository: j.Cache.Repository, WriteRef: j.Cache.WriteRef, ReadRefs: j.Cache.ReadRefs},
227228 }
228229 }
229230
@@ -224,6 +224,7 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
224 Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,224 Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,
225 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,225 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,
226 Permissions: &pb.JobPermissions{Levels: j.Permissions},226 Permissions: &pb.JobPermissions{Levels: j.Permissions},
227+ Cache: &pb.JobCache{Repository: j.Cache.Repository, WriteRef: j.Cache.WriteRef, ReadRefs: j.Cache.ReadRefs},
227 }228 }
228 }229 }
229 230
M internal/adapters/web/actions_pages.go +7 -2
@@ -237,8 +237,13 @@ func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
237237 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
238238 }
239239 row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
240- if n, ok := pullNumber(run.Ref); ok {
241- // A pull_request run is for refs/pull/<n>/merge: it shows the merge request.
240+ n, ok := run.PullNumber, run.PullNumber > 0
241+ if !ok {
242+ n, ok = pullNumber(run.Ref) // a run stored before PullNumber existed
243+ }
244+ if ok {
245+ // A pull_request run is for refs/pull/<n>/merge, a pull_request_target
246+ // run for the default branch: both show the merge request.
242247 row.Ref, row.RefHref = "!"+strconv.FormatInt(n, 10), mrHref(repo, n)
243248 }
244249 row.When, row.WhenAt = whenOf(now, run.CreatedAt)
@@ -237,8 +237,13 @@ func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
237 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,237 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
238 }238 }
239 row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))239 row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
240- if n, ok := pullNumber(run.Ref); ok {240+ n, ok := run.PullNumber, run.PullNumber > 0
241- // A pull_request run is for refs/pull/<n>/merge: it shows the merge request.241+ if !ok {
242+ n, ok = pullNumber(run.Ref) // a run stored before PullNumber existed
243+ }
244+ if ok {
245+ // A pull_request run is for refs/pull/<n>/merge, a pull_request_target
246+ // run for the default branch: both show the merge request.
242 row.Ref, row.RefHref = "!"+strconv.FormatInt(n, 10), mrHref(repo, n)247 row.Ref, row.RefHref = "!"+strconv.FormatInt(n, 10), mrHref(repo, n)
243 }248 }
244 row.When, row.WhenAt = whenOf(now, run.CreatedAt)249 row.When, row.WhenAt = whenOf(now, run.CreatedAt)
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.secrets"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.secrets"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
A internal/app/ci_pull_target_test.go +406 -0
@@ -0,0 +1,406 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+// CI-4 D4: the pull_request_target event runs the workflows of the default
20+// branch's tip for the activities of a merge request.
21+
22+const targetWorkflow = `name: prt
23+on:
24+ pull_request_target:
25+%s
26+jobs:
27+ j:
28+ runs-on: ubuntu-latest
29+ permissions:
30+ contents: write
31+ steps:
32+ - run: echo ${{ secrets.API_TOKEN }} %s
33+`
34+
35+func targetWF(on, mark string) map[string]string {
36+ return map[string]string{".github/workflows/prt.yml": fmt.Sprintf(targetWorkflow, on, mark)}
37+}
38+
39+// newTargetHarness is a harness with a repository secret and the
40+// pull_request_target workflow of on on main.
41+func newTargetHarness(t *testing.T, on string) (*ciHarness, *fakeRunner, string) {
42+ t.Helper()
43+ c := newSecretsHarness(t, secretsKeyA)
44+ runner := c.registerRunner("alpha", "ubuntu-latest")
45+ c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
46+ files := targetWF(on, "from-main")
47+ files["a.txt"] = "base\n"
48+ return c, runner, c.push("main", files, "")
49+}
50+
51+func TestPullRequestTargetRunsTheDefaultBranchWorkflow(t *testing.T) {
52+ c, runner, main := newTargetHarness(t, "")
53+ // The head changes the workflow: that version is not the one that runs.
54+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
55+ files := targetWF("", "from-head")
56+ files["b.txt"] = "b\n"
57+ feat1 := c.push("feat", files, "")
58+
59+ n := c.openMR(c.root, "feat")
60+ job := c.finish(runner)
61+ assert.Contains(t, job.GetWorkflowYaml(), "from-main")
62+ assert.NotContains(t, job.GetWorkflowYaml(), "from-head")
63+ assert.Equal(t, main, job.GetSha())
64+ assert.Equal(t, "refs/heads/main", job.GetRef())
65+
66+ // Trusted: the owner opened it and pushed main.
67+ assert.True(t, job.GetTrusted())
68+ assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
69+ assert.Equal(t, "write", job.GetPermissions().GetLevels()["contents"])
70+
71+ runs := c.runs()
72+ require.Len(t, runs, 1)
73+ assert.Equal(t, "pull_request_target", runs[0].GetEvent())
74+ assert.Equal(t, "refs/heads/main", runs[0].GetRef())
75+ assert.Equal(t, main, runs[0].GetSha())
76+ assert.Equal(t, "Title of feat", runs[0].GetTitle())
77+ assert.True(t, runs[0].GetTrusted())
78+
79+ // The checks attach to the head, as those of pull_request, not to main.
80+ require.Len(t, c.checks(feat1), 1)
81+ assert.Empty(t, c.checks(main))
82+
83+ gh := c.ghContext(job)
84+ assert.Equal(t, "pull_request_target", gh["event_name"])
85+ assert.Equal(t, "refs/heads/main", gh["ref"])
86+ assert.Equal(t, "main", gh["ref_name"])
87+ assert.Equal(t, main, gh["sha"])
88+ assert.Equal(t, "feat", gh["head_ref"])
89+ assert.Equal(t, "main", gh["base_ref"])
90+ ev := gh["event"].(map[string]any)
91+ assert.Equal(t, "opened", ev["action"])
92+ assert.EqualValues(t, n, ev["number"])
93+ pr := ev["pull_request"].(map[string]any)
94+ assert.Equal(t, feat1, pr["head"].(map[string]any)["sha"])
95+ assert.Equal(t, main, pr["base"].(map[string]any)["sha"])
96+ assert.Equal(t, c.ref(domain.PullMergeRef(n)), pr["merge_commit_sha"], "the test merge, as for pull_request")
97+
98+ // The cache: read-only, restores main's entries only, even trusted.
99+ cache := job.GetCache()
100+ require.NotNil(t, cache)
101+ assert.Equal(t, "owner/proj", cache.GetRepository())
102+ assert.Empty(t, cache.GetWriteRef())
103+ assert.Equal(t, []string{"refs/heads/main"}, cache.GetReadRefs())
104+
105+ // A synchronize runs main's workflow again.
106+ feat2 := c.push("feat", map[string]string{"c.txt": "c\n"}, "")
107+ job = c.finish(runner)
108+ gh = c.ghContext(job)
109+ assert.Equal(t, "synchronize", gh["event"].(map[string]any)["action"])
110+ assert.Equal(t, main, gh["sha"])
111+ assert.Equal(t, feat2, gh["event"].(map[string]any)["after"])
112+ assert.True(t, job.GetTrusted())
113+}
114+
115+// GitHub runs pull_request_target even when the request conflicts, while
116+// pull_request does not run.
117+func TestPullRequestTargetRunsOnAConflict(t *testing.T) {
118+ c, runner, _ := newTargetHarness(t, "")
119+ c.push("main", pullWF("", ""), "")
120+ c.mustGit(c.wt, "checkout", "-q", "-b", "conflict")
121+ c.push("conflict", map[string]string{"a.txt": "feature\n"}, "")
122+ c.mustGit(c.wt, "checkout", "-q", "main")
123+ main := c.push("main", map[string]string{"a.txt": "main\n"}, "")
124+
125+ n := c.openMR(c.root, "conflict")
126+ job := c.finish(runner)
127+ gh := c.ghContext(job)
128+ assert.Equal(t, "pull_request_target", gh["event_name"])
129+ assert.Equal(t, main, gh["sha"])
130+ assert.Nil(t, gh["event"].(map[string]any)["pull_request"].(map[string]any)["merge_commit_sha"], "no test merge")
131+ assert.Empty(t, c.ref(domain.PullMergeRef(n)))
132+ require.Len(t, c.runs(), 1, "the pull_request workflow does not run")
133+}
134+
135+// An agent's activity runs the default branch's workflow without secrets and
136+// with a read-only token, unlike GitHub (T1). The head's provenance does not
137+// matter: its files do not run.
138+func TestPullRequestTargetTrustFollowsTheActor(t *testing.T) {
139+ c, runner, _ := newTargetHarness(t, " types: [opened, synchronize, closed]")
140+ secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
141+ c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
142+ c.writeFile(c.wt, "x.txt", []byte("x\n"))
143+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
144+
145+ // Opened by the agent: not trusted.
146+ n := c.openMR(c.api(secret), "agent/x")
147+ job := c.finish(runner)
148+ assert.Equal(t, "pull_request_target", c.ghContext(job)["event_name"])
149+ assert.False(t, job.GetTrusted())
150+ assert.Empty(t, job.GetSecrets())
151+ assert.NotEqual(t, "write", job.GetPermissions().GetLevels()["contents"])
152+ assert.Equal(t, "claude", c.runs()[0].GetActor())
153+ agentRun := c.runs()[0].GetId()
154+
155+ // The owner re-running it does not unlock the secrets.
156+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: agentRun}))
157+ require.NoError(t, err)
158+ job = c.finish(runner)
159+ assert.False(t, job.GetTrusted())
160+ assert.Empty(t, job.GetSecrets())
161+
162+ // The owner pushes on the agent's branch: trusted, as main's code runs.
163+ c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
164+ job = c.finish(runner)
165+ assert.Equal(t, "synchronize", c.ghContext(job)["event"].(map[string]any)["action"])
166+ assert.True(t, job.GetTrusted())
167+
168+ // The agent pushes: not trusted.
169+ c.writeFile(c.wt, "z.txt", []byte("z\n"))
170+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
171+ assert.False(t, c.finish(runner).GetTrusted())
172+
173+ // The owner closes it, the head being the agent's: trusted.
174+ _, err = c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
175+ require.NoError(t, err)
176+ job = c.finish(runner)
177+ assert.Equal(t, "closed", c.ghContext(job)["event"].(map[string]any)["action"])
178+ assert.True(t, job.GetTrusted())
179+
180+ // The owner opens a request of a branch the agent pushed: trusted too.
181+ c.mustGit(c.wt, "checkout", "-q", "-b", "agent/y", "main")
182+ c.writeFile(c.wt, "w.txt", []byte("w\n"))
183+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/y")
184+ c.openMR(c.root, "agent/y")
185+ job = c.finish(runner)
186+ assert.True(t, job.GetTrusted())
187+ assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
188+}
189+
190+// The default tip runs: it must have been pushed by a trusted principal, as
191+// taken when the activity happens.
192+func TestPullRequestTargetTrustFollowsTheDefaultTip(t *testing.T) {
193+ c, hook := newHookedHarness(t)
194+ runner := c.registerRunner("alpha", "ubuntu-latest")
195+ main := c.push("main", targetWF("", "from-main"), "")
196+ c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
197+ c.push("f1", map[string]string{"1.txt": "1\n"}, "")
198+
199+ // main's record says an untrusted principal set it there.
200+ require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
201+ Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main, Actor: "claude", Trusted: false,
202+ }))
203+ c.openMR(c.root, "f1")
204+ job := c.finish(runner)
205+ assert.Equal(t, main, job.GetSha())
206+ assert.False(t, job.GetTrusted(), "main was not pushed by a trusted principal")
207+
208+ // The owner pushes main: trusted again.
209+ c.mustGit(c.wt, "checkout", "-q", "main")
210+ main2 := c.push("main", map[string]string{"m.txt": "m\n"}, "")
211+ c.mustGit(c.wt, "checkout", "-q", "-b", "f2", "main")
212+ c.push("f2", map[string]string{"2.txt": "2\n"}, "")
213+ c.openMR(c.root, "f2")
214+ job = c.finish(runner)
215+ assert.Equal(t, main2, job.GetSha())
216+ assert.True(t, job.GetTrusted())
217+
218+ // The tip is the one of the activity: main moving before the event is
219+ // handled changes neither the code nor the trust.
220+ c.mustGit(c.wt, "checkout", "-q", "-b", "f3", "main")
221+ c.push("f3", map[string]string{"3.txt": "3\n"}, "")
222+ reached, release := hook.block(t, "event")
223+ c.openMR(c.root, "f3")
224+ await(t, reached, "the opening event")
225+ c.mustGit(c.wt, "checkout", "-q", "main")
226+ main3 := c.push("main", targetWF("", "moved"), "")
227+ require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
228+ Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main3, Actor: "claude", Trusted: false,
229+ }))
230+ release()
231+ job = c.finish(runner)
232+ assert.Equal(t, main2, job.GetSha())
233+ assert.Contains(t, job.GetWorkflowYaml(), "from-main")
234+ assert.True(t, job.GetTrusted())
235+}
236+
237+// Types, branches and paths filter as for pull_request: the base branch and the
238+// files between the merge base and the head.
239+func TestPullRequestTargetTypesAndFilters(t *testing.T) {
240+ c, runner, _ := newTargetHarness(t, " types: [closed]\n branches: [main]\n paths: ['docs/**']")
241+ c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
242+ c.push("f1", map[string]string{"src/x.go": "x\n"}, "")
243+ n1 := c.openMR(c.root, "f1")
244+ c.mustGit(c.wt, "checkout", "-q", "-b", "f2", "main")
245+ f2 := c.push("f2", map[string]string{"docs/a.md": "a\n"}, "")
246+ n2 := c.openMR(c.root, "f2")
247+ c.waitFor("the head refs", func() bool { return c.ref(domain.PullHeadRef(n1)) != "" && c.ref(domain.PullHeadRef(n2)) == f2 })
248+
249+ for _, n := range []int64{n1, n2} {
250+ _, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
251+ require.NoError(t, err)
252+ }
253+ job := c.finish(runner)
254+ ev := c.ghContext(job)["event"].(map[string]any)
255+ assert.Equal(t, "closed", ev["action"])
256+ assert.EqualValues(t, n2, ev["number"])
257+ require.Len(t, c.runs(), 1, "neither opened nor the request without docs")
258+ assert.True(t, strings.HasPrefix(c.runs()[0].GetRef(), "refs/heads/"))
259+}
260+
261+// Job.cache of the other events (CI-3 D8): save under the run's ref, restore
262+// from it, the base branch of a merge request, then the default branch.
263+func TestJobCacheScopes(t *testing.T) {
264+ c := newCIHarness(t, nil)
265+ runner := c.registerRunner("alpha", "ubuntu-latest")
266+ files := pullWF("", "")
267+ files[".github/workflows/push.yml"] = "on: push\njobs:\n p:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
268+ c.push("main", files, "")
269+ job := c.finish(runner)
270+ assert.Equal(t, "refs/heads/main", job.GetCache().GetWriteRef())
271+ assert.Equal(t, []string{"refs/heads/main"}, job.GetCache().GetReadRefs())
272+
273+ c.mustGit(c.wt, "checkout", "-q", "-b", "dev")
274+ c.push("dev", map[string]string{"d.txt": "d\n"}, "")
275+ job = c.finish(runner)
276+ assert.Equal(t, "refs/heads/dev", job.GetCache().GetWriteRef())
277+ assert.Equal(t, []string{"refs/heads/dev", "refs/heads/main"}, job.GetCache().GetReadRefs())
278+
279+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
280+ c.push("feat", map[string]string{"f.txt": "f\n"}, "")
281+ c.finish(runner) // its push run
282+ c.openMRInto("feat", "dev")
283+ job = c.finish(runner)
284+ assert.Equal(t, "pull_request", c.ghContext(job)["event_name"])
285+ assert.Equal(t, "owner/proj", job.GetCache().GetRepository())
286+ assert.Equal(t, "refs/pull/1/merge", job.GetCache().GetWriteRef())
287+ assert.Equal(t, []string{"refs/pull/1/merge", "refs/heads/dev", "refs/heads/main"}, job.GetCache().GetReadRefs())
288+}
289+
290+// An untrusted run never saves under the default branch: the cache main's
291+// trusted runs restore is written by trusted runs only.
292+func TestJobCacheOfUntrustedRuns(t *testing.T) {
293+ c := newCIHarness(t, nil)
294+ runner := c.registerRunner("alpha", "ubuntu-latest")
295+ files := merge(map[string]string{".github/workflows/push.yml": "name: push\non: [push, workflow_dispatch]\njobs:\n p:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"},
296+ followWorkflow("follow", " workflows: [push]\n types: [requested]\n branches: ['agent/**']"))
297+ main := c.push("main", files, "")
298+ job := c.finish(runner)
299+ assert.True(t, job.GetTrusted())
300+ assert.Equal(t, "refs/heads/main", job.GetCache().GetWriteRef(), "the owner's push of main")
301+
302+ // The agent's push of its branch: untrusted, saves under that branch only.
303+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/"}, "proj")
304+ c.pushAs(agent, "agent/x", map[string]string{"x.txt": "x\n"})
305+ jobs := map[string]*pb.Job{}
306+ for range 2 {
307+ j := c.finish(runner)
308+ jobs[c.ghContext(j)["event_name"].(string)] = j
309+ }
310+ job = jobs["push"]
311+ require.NotNil(t, job)
312+ assert.False(t, job.GetTrusted())
313+ assert.Equal(t, "refs/untrusted/heads/agent/x", job.GetCache().GetWriteRef(), "in the namespace of untrusted entries")
314+ assert.Equal(t, []string{"refs/untrusted/heads/agent/x", "refs/heads/agent/x", "refs/heads/main"}, job.GetCache().GetReadRefs())
315+
316+ // Its workflow_run runs on main and inherits the trust: read-only.
317+ job = jobs["workflow_run"]
318+ require.NotNil(t, job)
319+ assert.Equal(t, "refs/heads/main", job.GetRef())
320+ assert.False(t, job.GetTrusted())
321+ assert.Empty(t, job.GetCache().GetWriteRef())
322+ assert.Equal(t, []string{"refs/heads/main"}, job.GetCache().GetReadRefs())
323+
324+ // A dispatch on main whose tip an untrusted principal set: read-only.
325+ require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
326+ Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main, Actor: "claude", Trusted: false,
327+ }))
328+ _, err := c.dispatch(c.root, "push.yml", "main", nil)
329+ require.NoError(t, err)
330+ job = c.finish(runner)
331+ assert.Equal(t, "workflow_dispatch", c.ghContext(job)["event_name"])
332+ assert.False(t, job.GetTrusted())
333+ assert.Empty(t, job.GetCache().GetWriteRef())
334+}
335+
336+// The run pages show the merge request of a pull_request_target run, whose
337+// ref is the default branch.
338+func TestWebShowsTheMergeRequestOfAPullRequestTargetRun(t *testing.T) {
339+ c := newCIHarness(t, nil)
340+ b := browser{c.harness}
341+ runner := c.registerRunner("alpha", "ubuntu-latest")
342+ _, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
343+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
344+ }))
345+ require.NoError(t, err)
346+ c.push("main", targetWF("", "x"), "")
347+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
348+ c.push("feat", map[string]string{"f.txt": "f\n"}, "")
349+ n := c.openMR(c.root, "feat")
350+ c.finish(runner)
351+ run := c.runs()[0]
352+ require.Equal(t, "pull_request_target", run.GetEvent())
353+
354+ for _, page := range []string{"/proj/actions", fmt.Sprintf("/proj/actions/runs/%d", run.GetId())} {
355+ res := b.get(page, nil)
356+ require.Equal(t, 200, res.status, res.body)
357+ assert.Contains(t, res.body, fmt.Sprintf(`href="/proj/merge-requests/%d"`, n), page)
358+ assert.Contains(t, res.body, fmt.Sprintf("!%d", n), page)
359+ }
360+}
361+
362+// A trusted run never restores what an untrusted run saved: the owner pushing
363+// on top of an agent's branch, or on the merge request the agent opened, gets
364+// the trusted entries only.
365+func TestJobCacheTrustedRunsNeverReadUntrustedEntries(t *testing.T) {
366+ c := newCIHarness(t, nil)
367+ runner := c.registerRunner("alpha", "ubuntu-latest")
368+ files := pullWF("", "")
369+ files[".github/workflows/push.yml"] = "on: push\njobs:\n p:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
370+ c.push("main", files, "")
371+ c.finish(runner)
372+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
373+ c.pushAs(agent, "agent/x", map[string]string{"x.txt": "x\n"})
374+ assert.Equal(t, "refs/untrusted/heads/agent/x", c.finish(runner).GetCache().GetWriteRef())
375+
376+ // The agent opens the request: an untrusted pull_request run.
377+ c.openMR(c.api(agent), "agent/x")
378+ job := c.finish(runner)
379+ require.Equal(t, "pull_request", c.ghContext(job)["event_name"])
380+ assert.False(t, job.GetTrusted())
381+ assert.Equal(t, "refs/untrusted/pull/1/merge", job.GetCache().GetWriteRef())
382+ assert.Equal(t, []string{"refs/untrusted/pull/1/merge", "refs/pull/1/merge", "refs/heads/main"}, job.GetCache().GetReadRefs())
383+
384+ // The owner pushes on top: the push run and the synchronize are trusted
385+ // and see no untrusted entry, at any level.
386+ c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
387+ jobs := map[string]*pb.Job{}
388+ for range 2 {
389+ j := c.finish(runner)
390+ jobs[c.ghContext(j)["event_name"].(string)] = j
391+ }
392+ push, pull := jobs["push"], jobs["pull_request"]
393+ require.NotNil(t, push)
394+ require.NotNil(t, pull)
395+ assert.True(t, push.GetTrusted())
396+ assert.Equal(t, "refs/heads/agent/x", push.GetCache().GetWriteRef())
397+ assert.Equal(t, []string{"refs/heads/agent/x", "refs/heads/main"}, push.GetCache().GetReadRefs())
398+ assert.True(t, pull.GetTrusted())
399+ assert.Equal(t, "refs/pull/1/merge", pull.GetCache().GetWriteRef())
400+ assert.Equal(t, []string{"refs/pull/1/merge", "refs/heads/main"}, pull.GetCache().GetReadRefs())
401+ for _, j := range jobs {
402+ for _, r := range j.GetCache().GetReadRefs() {
403+ assert.False(t, strings.HasPrefix(r, "refs/untrusted/"), r)
404+ }
405+ }
406+}
@@ -0,0 +1,406 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+// CI-4 D4: the pull_request_target event runs the workflows of the default
20+// branch's tip for the activities of a merge request.
21+
22+const targetWorkflow = `name: prt
23+on:
24+ pull_request_target:
25+%s
26+jobs:
27+ j:
28+ runs-on: ubuntu-latest
29+ permissions:
30+ contents: write
31+ steps:
32+ - run: echo ${{ secrets.API_TOKEN }} %s
33+`
34+
35+func targetWF(on, mark string) map[string]string {
36+ return map[string]string{".github/workflows/prt.yml": fmt.Sprintf(targetWorkflow, on, mark)}
37+}
38+
39+// newTargetHarness is a harness with a repository secret and the
40+// pull_request_target workflow of on on main.
41+func newTargetHarness(t *testing.T, on string) (*ciHarness, *fakeRunner, string) {
42+ t.Helper()
43+ c := newSecretsHarness(t, secretsKeyA)
44+ runner := c.registerRunner("alpha", "ubuntu-latest")
45+ c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
46+ files := targetWF(on, "from-main")
47+ files["a.txt"] = "base\n"
48+ return c, runner, c.push("main", files, "")
49+}
50+
51+func TestPullRequestTargetRunsTheDefaultBranchWorkflow(t *testing.T) {
52+ c, runner, main := newTargetHarness(t, "")
53+ // The head changes the workflow: that version is not the one that runs.
54+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
55+ files := targetWF("", "from-head")
56+ files["b.txt"] = "b\n"
57+ feat1 := c.push("feat", files, "")
58+
59+ n := c.openMR(c.root, "feat")
60+ job := c.finish(runner)
61+ assert.Contains(t, job.GetWorkflowYaml(), "from-main")
62+ assert.NotContains(t, job.GetWorkflowYaml(), "from-head")
63+ assert.Equal(t, main, job.GetSha())
64+ assert.Equal(t, "refs/heads/main", job.GetRef())
65+
66+ // Trusted: the owner opened it and pushed main.
67+ assert.True(t, job.GetTrusted())
68+ assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
69+ assert.Equal(t, "write", job.GetPermissions().GetLevels()["contents"])
70+
71+ runs := c.runs()
72+ require.Len(t, runs, 1)
73+ assert.Equal(t, "pull_request_target", runs[0].GetEvent())
74+ assert.Equal(t, "refs/heads/main", runs[0].GetRef())
75+ assert.Equal(t, main, runs[0].GetSha())
76+ assert.Equal(t, "Title of feat", runs[0].GetTitle())
77+ assert.True(t, runs[0].GetTrusted())
78+
79+ // The checks attach to the head, as those of pull_request, not to main.
80+ require.Len(t, c.checks(feat1), 1)
81+ assert.Empty(t, c.checks(main))
82+
83+ gh := c.ghContext(job)
84+ assert.Equal(t, "pull_request_target", gh["event_name"])
85+ assert.Equal(t, "refs/heads/main", gh["ref"])
86+ assert.Equal(t, "main", gh["ref_name"])
87+ assert.Equal(t, main, gh["sha"])
88+ assert.Equal(t, "feat", gh["head_ref"])
89+ assert.Equal(t, "main", gh["base_ref"])
90+ ev := gh["event"].(map[string]any)
91+ assert.Equal(t, "opened", ev["action"])
92+ assert.EqualValues(t, n, ev["number"])
93+ pr := ev["pull_request"].(map[string]any)
94+ assert.Equal(t, feat1, pr["head"].(map[string]any)["sha"])
95+ assert.Equal(t, main, pr["base"].(map[string]any)["sha"])
96+ assert.Equal(t, c.ref(domain.PullMergeRef(n)), pr["merge_commit_sha"], "the test merge, as for pull_request")
97+
98+ // The cache: read-only, restores main's entries only, even trusted.
99+ cache := job.GetCache()
100+ require.NotNil(t, cache)
101+ assert.Equal(t, "owner/proj", cache.GetRepository())
102+ assert.Empty(t, cache.GetWriteRef())
103+ assert.Equal(t, []string{"refs/heads/main"}, cache.GetReadRefs())
104+
105+ // A synchronize runs main's workflow again.
106+ feat2 := c.push("feat", map[string]string{"c.txt": "c\n"}, "")
107+ job = c.finish(runner)
108+ gh = c.ghContext(job)
109+ assert.Equal(t, "synchronize", gh["event"].(map[string]any)["action"])
110+ assert.Equal(t, main, gh["sha"])
111+ assert.Equal(t, feat2, gh["event"].(map[string]any)["after"])
112+ assert.True(t, job.GetTrusted())
113+}
114+
115+// GitHub runs pull_request_target even when the request conflicts, while
116+// pull_request does not run.
117+func TestPullRequestTargetRunsOnAConflict(t *testing.T) {
118+ c, runner, _ := newTargetHarness(t, "")
119+ c.push("main", pullWF("", ""), "")
120+ c.mustGit(c.wt, "checkout", "-q", "-b", "conflict")
121+ c.push("conflict", map[string]string{"a.txt": "feature\n"}, "")
122+ c.mustGit(c.wt, "checkout", "-q", "main")
123+ main := c.push("main", map[string]string{"a.txt": "main\n"}, "")
124+
125+ n := c.openMR(c.root, "conflict")
126+ job := c.finish(runner)
127+ gh := c.ghContext(job)
128+ assert.Equal(t, "pull_request_target", gh["event_name"])
129+ assert.Equal(t, main, gh["sha"])
130+ assert.Nil(t, gh["event"].(map[string]any)["pull_request"].(map[string]any)["merge_commit_sha"], "no test merge")
131+ assert.Empty(t, c.ref(domain.PullMergeRef(n)))
132+ require.Len(t, c.runs(), 1, "the pull_request workflow does not run")
133+}
134+
135+// An agent's activity runs the default branch's workflow without secrets and
136+// with a read-only token, unlike GitHub (T1). The head's provenance does not
137+// matter: its files do not run.
138+func TestPullRequestTargetTrustFollowsTheActor(t *testing.T) {
139+ c, runner, _ := newTargetHarness(t, " types: [opened, synchronize, closed]")
140+ secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
141+ c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
142+ c.writeFile(c.wt, "x.txt", []byte("x\n"))
143+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
144+
145+ // Opened by the agent: not trusted.
146+ n := c.openMR(c.api(secret), "agent/x")
147+ job := c.finish(runner)
148+ assert.Equal(t, "pull_request_target", c.ghContext(job)["event_name"])
149+ assert.False(t, job.GetTrusted())
150+ assert.Empty(t, job.GetSecrets())
151+ assert.NotEqual(t, "write", job.GetPermissions().GetLevels()["contents"])
152+ assert.Equal(t, "claude", c.runs()[0].GetActor())
153+ agentRun := c.runs()[0].GetId()
154+
155+ // The owner re-running it does not unlock the secrets.
156+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: agentRun}))
157+ require.NoError(t, err)
158+ job = c.finish(runner)
159+ assert.False(t, job.GetTrusted())
160+ assert.Empty(t, job.GetSecrets())
161+
162+ // The owner pushes on the agent's branch: trusted, as main's code runs.
163+ c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
164+ job = c.finish(runner)
165+ assert.Equal(t, "synchronize", c.ghContext(job)["event"].(map[string]any)["action"])
166+ assert.True(t, job.GetTrusted())
167+
168+ // The agent pushes: not trusted.
169+ c.writeFile(c.wt, "z.txt", []byte("z\n"))
170+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
171+ assert.False(t, c.finish(runner).GetTrusted())
172+
173+ // The owner closes it, the head being the agent's: trusted.
174+ _, err = c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
175+ require.NoError(t, err)
176+ job = c.finish(runner)
177+ assert.Equal(t, "closed", c.ghContext(job)["event"].(map[string]any)["action"])
178+ assert.True(t, job.GetTrusted())
179+
180+ // The owner opens a request of a branch the agent pushed: trusted too.
181+ c.mustGit(c.wt, "checkout", "-q", "-b", "agent/y", "main")
182+ c.writeFile(c.wt, "w.txt", []byte("w\n"))
183+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/y")
184+ c.openMR(c.root, "agent/y")
185+ job = c.finish(runner)
186+ assert.True(t, job.GetTrusted())
187+ assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
188+}
189+
190+// The default tip runs: it must have been pushed by a trusted principal, as
191+// taken when the activity happens.
192+func TestPullRequestTargetTrustFollowsTheDefaultTip(t *testing.T) {
193+ c, hook := newHookedHarness(t)
194+ runner := c.registerRunner("alpha", "ubuntu-latest")
195+ main := c.push("main", targetWF("", "from-main"), "")
196+ c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
197+ c.push("f1", map[string]string{"1.txt": "1\n"}, "")
198+
199+ // main's record says an untrusted principal set it there.
200+ require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
201+ Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main, Actor: "claude", Trusted: false,
202+ }))
203+ c.openMR(c.root, "f1")
204+ job := c.finish(runner)
205+ assert.Equal(t, main, job.GetSha())
206+ assert.False(t, job.GetTrusted(), "main was not pushed by a trusted principal")
207+
208+ // The owner pushes main: trusted again.
209+ c.mustGit(c.wt, "checkout", "-q", "main")
210+ main2 := c.push("main", map[string]string{"m.txt": "m\n"}, "")
211+ c.mustGit(c.wt, "checkout", "-q", "-b", "f2", "main")
212+ c.push("f2", map[string]string{"2.txt": "2\n"}, "")
213+ c.openMR(c.root, "f2")
214+ job = c.finish(runner)
215+ assert.Equal(t, main2, job.GetSha())
216+ assert.True(t, job.GetTrusted())
217+
218+ // The tip is the one of the activity: main moving before the event is
219+ // handled changes neither the code nor the trust.
220+ c.mustGit(c.wt, "checkout", "-q", "-b", "f3", "main")
221+ c.push("f3", map[string]string{"3.txt": "3\n"}, "")
222+ reached, release := hook.block(t, "event")
223+ c.openMR(c.root, "f3")
224+ await(t, reached, "the opening event")
225+ c.mustGit(c.wt, "checkout", "-q", "main")
226+ main3 := c.push("main", targetWF("", "moved"), "")
227+ require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
228+ Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main3, Actor: "claude", Trusted: false,
229+ }))
230+ release()
231+ job = c.finish(runner)
232+ assert.Equal(t, main2, job.GetSha())
233+ assert.Contains(t, job.GetWorkflowYaml(), "from-main")
234+ assert.True(t, job.GetTrusted())
235+}
236+
237+// Types, branches and paths filter as for pull_request: the base branch and the
238+// files between the merge base and the head.
239+func TestPullRequestTargetTypesAndFilters(t *testing.T) {
240+ c, runner, _ := newTargetHarness(t, " types: [closed]\n branches: [main]\n paths: ['docs/**']")
241+ c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
242+ c.push("f1", map[string]string{"src/x.go": "x\n"}, "")
243+ n1 := c.openMR(c.root, "f1")
244+ c.mustGit(c.wt, "checkout", "-q", "-b", "f2", "main")
245+ f2 := c.push("f2", map[string]string{"docs/a.md": "a\n"}, "")
246+ n2 := c.openMR(c.root, "f2")
247+ c.waitFor("the head refs", func() bool { return c.ref(domain.PullHeadRef(n1)) != "" && c.ref(domain.PullHeadRef(n2)) == f2 })
248+
249+ for _, n := range []int64{n1, n2} {
250+ _, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
251+ require.NoError(t, err)
252+ }
253+ job := c.finish(runner)
254+ ev := c.ghContext(job)["event"].(map[string]any)
255+ assert.Equal(t, "closed", ev["action"])
256+ assert.EqualValues(t, n2, ev["number"])
257+ require.Len(t, c.runs(), 1, "neither opened nor the request without docs")
258+ assert.True(t, strings.HasPrefix(c.runs()[0].GetRef(), "refs/heads/"))
259+}
260+
261+// Job.cache of the other events (CI-3 D8): save under the run's ref, restore
262+// from it, the base branch of a merge request, then the default branch.
263+func TestJobCacheScopes(t *testing.T) {
264+ c := newCIHarness(t, nil)
265+ runner := c.registerRunner("alpha", "ubuntu-latest")
266+ files := pullWF("", "")
267+ files[".github/workflows/push.yml"] = "on: push\njobs:\n p:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
268+ c.push("main", files, "")
269+ job := c.finish(runner)
270+ assert.Equal(t, "refs/heads/main", job.GetCache().GetWriteRef())
271+ assert.Equal(t, []string{"refs/heads/main"}, job.GetCache().GetReadRefs())
272+
273+ c.mustGit(c.wt, "checkout", "-q", "-b", "dev")
274+ c.push("dev", map[string]string{"d.txt": "d\n"}, "")
275+ job = c.finish(runner)
276+ assert.Equal(t, "refs/heads/dev", job.GetCache().GetWriteRef())
277+ assert.Equal(t, []string{"refs/heads/dev", "refs/heads/main"}, job.GetCache().GetReadRefs())
278+
279+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
280+ c.push("feat", map[string]string{"f.txt": "f\n"}, "")
281+ c.finish(runner) // its push run
282+ c.openMRInto("feat", "dev")
283+ job = c.finish(runner)
284+ assert.Equal(t, "pull_request", c.ghContext(job)["event_name"])
285+ assert.Equal(t, "owner/proj", job.GetCache().GetRepository())
286+ assert.Equal(t, "refs/pull/1/merge", job.GetCache().GetWriteRef())
287+ assert.Equal(t, []string{"refs/pull/1/merge", "refs/heads/dev", "refs/heads/main"}, job.GetCache().GetReadRefs())
288+}
289+
290+// An untrusted run never saves under the default branch: the cache main's
291+// trusted runs restore is written by trusted runs only.
292+func TestJobCacheOfUntrustedRuns(t *testing.T) {
293+ c := newCIHarness(t, nil)
294+ runner := c.registerRunner("alpha", "ubuntu-latest")
295+ files := merge(map[string]string{".github/workflows/push.yml": "name: push\non: [push, workflow_dispatch]\njobs:\n p:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"},
296+ followWorkflow("follow", " workflows: [push]\n types: [requested]\n branches: ['agent/**']"))
297+ main := c.push("main", files, "")
298+ job := c.finish(runner)
299+ assert.True(t, job.GetTrusted())
300+ assert.Equal(t, "refs/heads/main", job.GetCache().GetWriteRef(), "the owner's push of main")
301+
302+ // The agent's push of its branch: untrusted, saves under that branch only.
303+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/"}, "proj")
304+ c.pushAs(agent, "agent/x", map[string]string{"x.txt": "x\n"})
305+ jobs := map[string]*pb.Job{}
306+ for range 2 {
307+ j := c.finish(runner)
308+ jobs[c.ghContext(j)["event_name"].(string)] = j
309+ }
310+ job = jobs["push"]
311+ require.NotNil(t, job)
312+ assert.False(t, job.GetTrusted())
313+ assert.Equal(t, "refs/untrusted/heads/agent/x", job.GetCache().GetWriteRef(), "in the namespace of untrusted entries")
314+ assert.Equal(t, []string{"refs/untrusted/heads/agent/x", "refs/heads/agent/x", "refs/heads/main"}, job.GetCache().GetReadRefs())
315+
316+ // Its workflow_run runs on main and inherits the trust: read-only.
317+ job = jobs["workflow_run"]
318+ require.NotNil(t, job)
319+ assert.Equal(t, "refs/heads/main", job.GetRef())
320+ assert.False(t, job.GetTrusted())
321+ assert.Empty(t, job.GetCache().GetWriteRef())
322+ assert.Equal(t, []string{"refs/heads/main"}, job.GetCache().GetReadRefs())
323+
324+ // A dispatch on main whose tip an untrusted principal set: read-only.
325+ require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
326+ Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main, Actor: "claude", Trusted: false,
327+ }))
328+ _, err := c.dispatch(c.root, "push.yml", "main", nil)
329+ require.NoError(t, err)
330+ job = c.finish(runner)
331+ assert.Equal(t, "workflow_dispatch", c.ghContext(job)["event_name"])
332+ assert.False(t, job.GetTrusted())
333+ assert.Empty(t, job.GetCache().GetWriteRef())
334+}
335+
336+// The run pages show the merge request of a pull_request_target run, whose
337+// ref is the default branch.
338+func TestWebShowsTheMergeRequestOfAPullRequestTargetRun(t *testing.T) {
339+ c := newCIHarness(t, nil)
340+ b := browser{c.harness}
341+ runner := c.registerRunner("alpha", "ubuntu-latest")
342+ _, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
343+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
344+ }))
345+ require.NoError(t, err)
346+ c.push("main", targetWF("", "x"), "")
347+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
348+ c.push("feat", map[string]string{"f.txt": "f\n"}, "")
349+ n := c.openMR(c.root, "feat")
350+ c.finish(runner)
351+ run := c.runs()[0]
352+ require.Equal(t, "pull_request_target", run.GetEvent())
353+
354+ for _, page := range []string{"/proj/actions", fmt.Sprintf("/proj/actions/runs/%d", run.GetId())} {
355+ res := b.get(page, nil)
356+ require.Equal(t, 200, res.status, res.body)
357+ assert.Contains(t, res.body, fmt.Sprintf(`href="/proj/merge-requests/%d"`, n), page)
358+ assert.Contains(t, res.body, fmt.Sprintf("!%d", n), page)
359+ }
360+}
361+
362+// A trusted run never restores what an untrusted run saved: the owner pushing
363+// on top of an agent's branch, or on the merge request the agent opened, gets
364+// the trusted entries only.
365+func TestJobCacheTrustedRunsNeverReadUntrustedEntries(t *testing.T) {
366+ c := newCIHarness(t, nil)
367+ runner := c.registerRunner("alpha", "ubuntu-latest")
368+ files := pullWF("", "")
369+ files[".github/workflows/push.yml"] = "on: push\njobs:\n p:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
370+ c.push("main", files, "")
371+ c.finish(runner)
372+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
373+ c.pushAs(agent, "agent/x", map[string]string{"x.txt": "x\n"})
374+ assert.Equal(t, "refs/untrusted/heads/agent/x", c.finish(runner).GetCache().GetWriteRef())
375+
376+ // The agent opens the request: an untrusted pull_request run.
377+ c.openMR(c.api(agent), "agent/x")
378+ job := c.finish(runner)
379+ require.Equal(t, "pull_request", c.ghContext(job)["event_name"])
380+ assert.False(t, job.GetTrusted())
381+ assert.Equal(t, "refs/untrusted/pull/1/merge", job.GetCache().GetWriteRef())
382+ assert.Equal(t, []string{"refs/untrusted/pull/1/merge", "refs/pull/1/merge", "refs/heads/main"}, job.GetCache().GetReadRefs())
383+
384+ // The owner pushes on top: the push run and the synchronize are trusted
385+ // and see no untrusted entry, at any level.
386+ c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
387+ jobs := map[string]*pb.Job{}
388+ for range 2 {
389+ j := c.finish(runner)
390+ jobs[c.ghContext(j)["event_name"].(string)] = j
391+ }
392+ push, pull := jobs["push"], jobs["pull_request"]
393+ require.NotNil(t, push)
394+ require.NotNil(t, pull)
395+ assert.True(t, push.GetTrusted())
396+ assert.Equal(t, "refs/heads/agent/x", push.GetCache().GetWriteRef())
397+ assert.Equal(t, []string{"refs/heads/agent/x", "refs/heads/main"}, push.GetCache().GetReadRefs())
398+ assert.True(t, pull.GetTrusted())
399+ assert.Equal(t, "refs/pull/1/merge", pull.GetCache().GetWriteRef())
400+ assert.Equal(t, []string{"refs/pull/1/merge", "refs/heads/main"}, pull.GetCache().GetReadRefs())
401+ for _, j := range jobs {
402+ for _, r := range j.GetCache().GetReadRefs() {
403+ assert.False(t, strings.HasPrefix(r, "refs/untrusted/"), r)
404+ }
405+ }
406+}
M internal/app/cli_e2e_test.go +1 -1
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326326 ver := decode[versionOut](t, res.stdout)
327327 assert.Equal(t, c.url, ver.Server.URL)
328328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.secrets"}, ver.Server.Features)
329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets"}, ver.Server.Features)
330330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331331 stdout, stderr, code = c.bin(t, secret, "version")
332332 require.Equal(t, 0, code, stderr)
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326 ver := decode[versionOut](t, res.stdout)326 ver := decode[versionOut](t, res.stdout)
327 assert.Equal(t, c.url, ver.Server.URL)327 assert.Equal(t, c.url, ver.Server.URL)
328 assert.NotEmpty(t, ver.Server.Version)328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.secrets"}, ver.Server.Features)329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets"}, ver.Server.Features)
330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331 stdout, stderr, code = c.bin(t, secret, "version")331 stdout, stderr, code = c.bin(t, secret, "version")
332 require.Equal(t, 0, code, stderr)332 require.Equal(t, 0, code, stderr)
M internal/domain/ci.go +4 -1
@@ -175,7 +175,10 @@ type CIRun struct {
175175 // HeadSha is the head of the merge request of a pull_request run, whose Sha
176176 // is the test merge commit github.sha shows; empty for the other events. The
177177 // checks of the run attach to it (CheckSha).
178- HeadSha string
178+ HeadSha string
179+ // PullNumber is the merge request of a pull_request or pull_request_target
180+ // run, 0 for the other events (and for runs stored before it existed).
181+ PullNumber int64
179182 Actor string
180183 Status CIStatus
181184 Conclusion CIConclusion
@@ -175,7 +175,10 @@ type CIRun struct {
175 // HeadSha is the head of the merge request of a pull_request run, whose Sha175 // HeadSha is the head of the merge request of a pull_request run, whose Sha
176 // is the test merge commit github.sha shows; empty for the other events. The176 // is the test merge commit github.sha shows; empty for the other events. The
177 // checks of the run attach to it (CheckSha).177 // checks of the run attach to it (CheckSha).
178- HeadSha string178+ HeadSha string
179+ // PullNumber is the merge request of a pull_request or pull_request_target
180+ // run, 0 for the other events (and for runs stored before it existed).
181+ PullNumber int64
179 Actor string182 Actor string
180 Status CIStatus183 Status CIStatus
181 Conclusion CIConclusion184 Conclusion CIConclusion
M internal/ports/ci.go +11 -0
@@ -431,6 +431,17 @@ type RunnerJob struct {
431431 // Permissions are the permissions of GithubToken (key to level), after the
432432 // trust rule: no write when the run is not trusted.
433433 Permissions map[string]string
434+ // Cache is the scope of the job in the runner's cache (Job.cache).
435+ Cache JobCache
436+}
437+
438+// JobCache is what a job may do in the cache the runner serves: save entries
439+// under WriteRef only ("" for none) and restore those of ReadRefs, most
440+// specific first. Repository is "owner/name".
441+type JobCache struct {
442+ Repository string
443+ WriteRef string
444+ ReadRefs []string
434445 }
435446
436447 // String describes the job without its secrets and tokens, so that a job
@@ -431,6 +431,17 @@ type RunnerJob struct {
431 // Permissions are the permissions of GithubToken (key to level), after the431 // Permissions are the permissions of GithubToken (key to level), after the
432 // trust rule: no write when the run is not trusted.432 // trust rule: no write when the run is not trusted.
433 Permissions map[string]string433 Permissions map[string]string
434+ // Cache is the scope of the job in the runner's cache (Job.cache).
435+ Cache JobCache
436+}
437+
438+// JobCache is what a job may do in the cache the runner serves: save entries
439+// under WriteRef only ("" for none) and restore those of ReadRefs, most
440+// specific first. Repository is "owner/name".
441+type JobCache struct {
442+ Repository string
443+ WriteRef string
444+ ReadRefs []string
434 }445 }
435 446
436 // String describes the job without its secrets and tokens, so that a job447 // String describes the job without its secrets and tokens, so that a job
A internal/usecase/ci_cache.go +108 -0
@@ -0,0 +1,108 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "encoding/json"
7+ "slices"
8+ "strings"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// The scope of a job in the cache the runner serves (CI-3 D8, CI-4 D4), sent as
15+// Job.cache. Entries are keyed by repository and ref, as on GitHub: a job
16+// restores from the ref of its run, then the base branch of a merge request,
17+// then the default branch, and saves under the ref of its run.
18+//
19+// An entry carries the trust of the run that saved it, because restoring it
20+// runs what it holds (node_modules, a build cache): a trusted run (secrets,
21+// write token) must never restore what an untrusted one saved. The runner
22+// treats the refs of Job.cache as names (it checks "refs/..." only), so the
23+// server keeps two namespaces:
24+//
25+// - a trusted run saves under its ref, refs/heads/x, and restores trusted
26+// entries only;
27+// - an untrusted run saves under refs/untrusted/heads/x, never under the
28+// default branch (not even in its own namespace: nothing saved there would
29+// ever be restored by a run that should), and restores both kinds, its own
30+// namespace first at each level.
31+//
32+// A pull_request_target run is on the default branch but runs for a merge
33+// request: it saves nothing (the runner then sets ACTIONS_CACHE_MODE=read) and
34+// restores the default branch alone, never the merge ref, whose entries the
35+// pull_request runs of the request save from the head's code. GitHub makes
36+// these runs read-only on the default branch too.
37+//
38+// Every runner that serves a cache (ermite-runner v0.3.0 and later) honours
39+// Job.cache, which the server always sends; entries saved before it did were
40+// saved under the plain refs whatever the trust of the run.
41+
42+// untrustedPrefix starts the refs the entries of untrusted runs are saved
43+// under. No run is ever on such a ref: runs are on refs/heads, refs/tags and
44+// refs/pull.
45+const untrustedPrefix = "refs/untrusted/"
46+
47+// untrustedRef is the ref an untrusted run saves ref's entries under.
48+func untrustedRef(ref string) string { return untrustedPrefix + strings.TrimPrefix(ref, "refs/") }
49+
50+// cacheLinks are the facts of the github context of a run that its cache scope
51+// needs.
52+type cacheLinks struct {
53+ BaseRef string `json:"base_ref"`
54+ Event struct {
55+ Repository struct {
56+ DefaultBranch string `json:"default_branch"`
57+ } `json:"repository"`
58+ } `json:"event"`
59+}
60+
61+// cacheScope is the scope of the jobs of a run (an attempt: a re-run that
62+// lowers the trust lowers the scope); githubJSON is the github context the run
63+// was created with (domain.CIRunSource), so the default branch is the one of
64+// that moment.
65+func (c *CI) cacheScope(run domain.CIRun, githubJSON string) ports.JobCache {
66+ var l cacheLinks
67+ _ = json.Unmarshal([]byte(githubJSON), &l) // a context that does not read: no default branch known
68+ sc := ports.JobCache{Repository: c.cfg.Owner + "/" + run.Repo.String()}
69+ def := ""
70+ if l.Event.Repository.DefaultBranch != "" {
71+ def = "refs/heads/" + l.Event.Repository.DefaultBranch
72+ }
73+ read := func(ref string) {
74+ if ref == "" {
75+ return
76+ }
77+ if !run.Trusted && ref != def { // never saved under the default branch
78+ sc.ReadRefs = appendNew(sc.ReadRefs, untrustedRef(ref))
79+ }
80+ sc.ReadRefs = appendNew(sc.ReadRefs, ref)
81+ }
82+ switch {
83+ case run.Event == eventPullTarget:
84+ // Read-only, the default branch only.
85+ if def != "" {
86+ sc.ReadRefs = []string{def}
87+ }
88+ return sc
89+ case run.Trusted:
90+ sc.WriteRef = run.Ref
91+ case def != "" && run.Ref != def:
92+ sc.WriteRef = untrustedRef(run.Ref)
93+ }
94+ read(run.Ref)
95+ if l.BaseRef != "" {
96+ read("refs/heads/" + l.BaseRef)
97+ }
98+ read(def)
99+ return sc
100+}
101+
102+// appendNew appends ref unless refs has it.
103+func appendNew(refs []string, ref string) []string {
104+ if slices.Contains(refs, ref) {
105+ return refs
106+ }
107+ return append(refs, ref)
108+}
@@ -0,0 +1,108 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "encoding/json"
7+ "slices"
8+ "strings"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// The scope of a job in the cache the runner serves (CI-3 D8, CI-4 D4), sent as
15+// Job.cache. Entries are keyed by repository and ref, as on GitHub: a job
16+// restores from the ref of its run, then the base branch of a merge request,
17+// then the default branch, and saves under the ref of its run.
18+//
19+// An entry carries the trust of the run that saved it, because restoring it
20+// runs what it holds (node_modules, a build cache): a trusted run (secrets,
21+// write token) must never restore what an untrusted one saved. The runner
22+// treats the refs of Job.cache as names (it checks "refs/..." only), so the
23+// server keeps two namespaces:
24+//
25+// - a trusted run saves under its ref, refs/heads/x, and restores trusted
26+// entries only;
27+// - an untrusted run saves under refs/untrusted/heads/x, never under the
28+// default branch (not even in its own namespace: nothing saved there would
29+// ever be restored by a run that should), and restores both kinds, its own
30+// namespace first at each level.
31+//
32+// A pull_request_target run is on the default branch but runs for a merge
33+// request: it saves nothing (the runner then sets ACTIONS_CACHE_MODE=read) and
34+// restores the default branch alone, never the merge ref, whose entries the
35+// pull_request runs of the request save from the head's code. GitHub makes
36+// these runs read-only on the default branch too.
37+//
38+// Every runner that serves a cache (ermite-runner v0.3.0 and later) honours
39+// Job.cache, which the server always sends; entries saved before it did were
40+// saved under the plain refs whatever the trust of the run.
41+
42+// untrustedPrefix starts the refs the entries of untrusted runs are saved
43+// under. No run is ever on such a ref: runs are on refs/heads, refs/tags and
44+// refs/pull.
45+const untrustedPrefix = "refs/untrusted/"
46+
47+// untrustedRef is the ref an untrusted run saves ref's entries under.
48+func untrustedRef(ref string) string { return untrustedPrefix + strings.TrimPrefix(ref, "refs/") }
49+
50+// cacheLinks are the facts of the github context of a run that its cache scope
51+// needs.
52+type cacheLinks struct {
53+ BaseRef string `json:"base_ref"`
54+ Event struct {
55+ Repository struct {
56+ DefaultBranch string `json:"default_branch"`
57+ } `json:"repository"`
58+ } `json:"event"`
59+}
60+
61+// cacheScope is the scope of the jobs of a run (an attempt: a re-run that
62+// lowers the trust lowers the scope); githubJSON is the github context the run
63+// was created with (domain.CIRunSource), so the default branch is the one of
64+// that moment.
65+func (c *CI) cacheScope(run domain.CIRun, githubJSON string) ports.JobCache {
66+ var l cacheLinks
67+ _ = json.Unmarshal([]byte(githubJSON), &l) // a context that does not read: no default branch known
68+ sc := ports.JobCache{Repository: c.cfg.Owner + "/" + run.Repo.String()}
69+ def := ""
70+ if l.Event.Repository.DefaultBranch != "" {
71+ def = "refs/heads/" + l.Event.Repository.DefaultBranch
72+ }
73+ read := func(ref string) {
74+ if ref == "" {
75+ return
76+ }
77+ if !run.Trusted && ref != def { // never saved under the default branch
78+ sc.ReadRefs = appendNew(sc.ReadRefs, untrustedRef(ref))
79+ }
80+ sc.ReadRefs = appendNew(sc.ReadRefs, ref)
81+ }
82+ switch {
83+ case run.Event == eventPullTarget:
84+ // Read-only, the default branch only.
85+ if def != "" {
86+ sc.ReadRefs = []string{def}
87+ }
88+ return sc
89+ case run.Trusted:
90+ sc.WriteRef = run.Ref
91+ case def != "" && run.Ref != def:
92+ sc.WriteRef = untrustedRef(run.Ref)
93+ }
94+ read(run.Ref)
95+ if l.BaseRef != "" {
96+ read("refs/heads/" + l.BaseRef)
97+ }
98+ read(def)
99+ return sc
100+}
101+
102+// appendNew appends ref unless refs has it.
103+func appendNew(refs []string, ref string) []string {
104+ if slices.Contains(refs, ref) {
105+ return refs
106+ }
107+ return append(refs, ref)
108+}
M internal/usecase/ci_events.go +1 -0
@@ -22,6 +22,7 @@ const (
2222 eventDispatch = "workflow_dispatch"
2323 eventSchedule = "schedule"
2424 eventWorkflowRun = "workflow_run"
25+ eventPullTarget = "pull_request_target"
2526
2627 // maxDispatchInputs is the number of top-level inputs of a workflow_dispatch
2728 // (docs.github.com, "Events that trigger workflows", workflow_dispatch: up to
@@ -22,6 +22,7 @@ const (
22 eventDispatch = "workflow_dispatch"22 eventDispatch = "workflow_dispatch"
23 eventSchedule = "schedule"23 eventSchedule = "schedule"
24 eventWorkflowRun = "workflow_run"24 eventWorkflowRun = "workflow_run"
25+ eventPullTarget = "pull_request_target"
25 26
26 // maxDispatchInputs is the number of top-level inputs of a workflow_dispatch27 // maxDispatchInputs is the number of top-level inputs of a workflow_dispatch
27 // (docs.github.com, "Events that trigger workflows", workflow_dispatch: up to28 // (docs.github.com, "Events that trigger workflows", workflow_dispatch: up to
M internal/usecase/ci_pull.go +102 -24
@@ -52,6 +52,16 @@ import (
5252 //
5353 // A head that does not merge cleanly makes no run for that update, as on GitHub;
5454 // the merge ref then keeps the last commit that merged, as GitHub's does.
55+//
56+// The pull_request_target event (CI-4, D4) has the same activities, payload
57+// and filters, but runs the workflows of the default branch's tip, as taken
58+// when the activity happened, conflict or not: github.ref is the default
59+// branch, github.sha that tip. Its checks attach to the head too. It is
60+// trusted when the cause of the activity is trusted and the default tip was
61+// pushed by a trusted principal: the head and the base do not matter, as none
62+// of their files run unless a workflow checks them out (GitHub's "pwn
63+// request"). It is stricter than GitHub, which gives the secrets to any pull
64+// request of the repository: an agent's activity runs it without secrets.
5565
5666 const (
5767 // DefaultCIPullRefreshes bounds the merge requests (and the pushes) waiting
@@ -87,6 +97,13 @@ type pullEvent struct {
8797 actor string
8898 pusher domain.CIPusher
8999 trusted bool
100+ // actorTrusted is the trust of the cause alone (the pusher of a
101+ // synchronize, whoever opened or closed): trusted also requires the head
102+ // and the base to be, pull_request_target the default tip.
103+ actorTrusted bool
104+ // target is the default branch's tip when the activity happened: what its
105+ // pull_request_target runs execute.
106+ target defaultTip
90107 // merge is the last merge commit the trust of a closing was checked
91108 // against, zero when there was none.
92109 merge domain.ObjectID
@@ -99,6 +116,30 @@ func (e pullEvent) trigger(at time.Time) ciTrigger {
99116 return ciTrigger{repo: e.repo, actor: e.actor, pusher: e.pusher, trusted: e.trusted, at: at, log: e.log}
100117 }
101118
119+// defaultTip is the tip of the default branch at one moment (CI-4, D4).
120+type defaultTip struct {
121+ branch string
122+ commit domain.ObjectID
123+ // trusted: a trusted principal set the branch to exactly that commit.
124+ trusted bool
125+}
126+
127+// defaultTipOf takes the tip of the default branch of a repository now, and
128+// whether a trusted principal pushed it. The zero value when the repository
129+// has no default branch (no pull_request_target run), and not trusted on any
130+// error or without a record (refTrusted).
131+func (c *CI) defaultTipOf(ctx context.Context, repo domain.RepoName) defaultTip {
132+ def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
133+ if err != nil || !exists {
134+ return defaultTip{}
135+ }
136+ tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
137+ if err != nil {
138+ return defaultTip{}
139+ }
140+ return defaultTip{branch: def, commit: tip, trusted: c.refTrusted(ctx, repo, pinnedRef{ref: "refs/heads/" + def, commit: tip, object: tip})}
141+}
142+
102143 type pullKey struct {
103144 repo domain.RepoName
104145 number int64
@@ -163,8 +204,9 @@ func (c *CI) OnPullRequest(ctx context.Context, repo domain.RepoName, ev ports.P
163204 e := pullEvent{
164205 repo: repo, number: ev.Number, action: ev.Action, head: ev.Head, base: ev.Base,
165206 actor: authorOf(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),
207+ actorTrusted: domain.TrustedActor(p), target: c.defaultTipOf(ctx, repo),
166208 }
167- if domain.TrustedActor(p) {
209+ if e.actorTrusted {
168210 e.trusted, e.merge = c.pullTipsTrusted(ctx, repo, ev)
169211 }
170212 c.queuePullEvent(e)
@@ -370,6 +412,7 @@ func (c *CI) onBranchesMoved(ctx context.Context, repo domain.RepoName, updates
370412 }
371413 tips := map[string]domain.ObjectID{}
372414 baseTrusted := map[string]bool{}
415+ var target *defaultTip // the default tip of the snapshot, read once
373416 for _, n := range numbers {
374417 mr, err := c.cfg.MRs.Get(ctx, repo, n)
375418 if err != nil || !mr.IsOpen() {
@@ -395,9 +438,13 @@ func (c *CI) onBranchesMoved(ctx context.Context, repo domain.RepoName, updates
395438 baseTrusted[mr.Target] = bt
396439 }
397440 evTrusted := trusted && bt
441+ if target == nil {
442+ t := c.defaultTipOf(ctx, repo)
443+ target = &t
444+ }
398445 c.queuePullEvent(pullEvent{
399446 repo: repo, number: mr.Number, action: domain.PullSynchronize, head: head, base: base, before: before,
400- actor: actor, pusher: pusher, trusted: evTrusted, run: true, log: log,
447+ actor: actor, pusher: pusher, trusted: evTrusted, actorTrusted: trusted, target: *target, run: true, log: log,
401448 })
402449 }
403450 }
@@ -656,6 +703,11 @@ type pullFacts struct {
656703 // before is the head before a synchronize.
657704 before domain.ObjectID
658705 merged bool
706+ // merge is the test merge commit of head and base, zero when there is
707+ // none (a conflict, or a closed request that never merged cleanly).
708+ merge domain.ObjectID
709+ // target is set for a pull_request_target run.
710+ target bool
659711 }
660712
661713 // pullState is the merge of a head into a base.
@@ -690,10 +742,14 @@ func (c *CI) handlePullEvent(ctx context.Context, e pullEvent) error {
690742 return err
691743 }
692744 c.stage(pullStageComputed)
693- if err := c.publishPull(ctx, mr, st); err != nil || !st.clean || !e.run {
745+ if err := c.publishPull(ctx, mr, st); err != nil || !e.run {
694746 return err
695747 }
696- return c.startPullRuns(ctx, e, mr, st)
748+ var err1 error
749+ if st.clean { // a conflict makes no pull_request run, as on GitHub
750+ err1 = c.startPullRuns(ctx, e, mr, st, false)
751+ }
752+ return errors.Join(err1, c.startPullRuns(ctx, e, mr, st, true))
697753 case domain.PullClosed:
698754 if mr.IsOpen() || !e.run {
699755 return nil
@@ -702,10 +758,11 @@ func (c *CI) handlePullEvent(ctx context.Context, e pullEvent) error {
702758 if err != nil || !ok {
703759 return err
704760 }
761+ pe := e
705762 if st.otherHead || st.merge != e.merge {
706- e.trusted = false // not the code the trust was checked for
763+ pe.trusted = false // not the code the trust was checked for
707764 }
708- return c.startPullRuns(ctx, e, mr, st)
765+ return errors.Join(c.startPullRuns(ctx, pe, mr, st, false), c.startPullRuns(ctx, e, mr, st, true))
709766 }
710767 return nil
711768 }
@@ -868,8 +925,12 @@ func (c *CI) closedPull(ctx context.Context, e pullEvent, mr domain.MergeRequest
868925 }
869926
870927 // startPullRuns starts the pull_request workflows of the merge request that
871-// match the activity and the files it changes.
872-func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequest, st pullState) error {
928+// match the activity and the files it changes, or with target its
929+// pull_request_target workflows: those of the default tip of the event.
930+func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequest, st pullState, target bool) error {
931+ if target && (e.target.commit.IsZero() || st.head.IsZero()) {
932+ return nil // no default branch, or no head to run for
933+ }
873934 runners, err := c.cfg.Store.ListRunners(ctx)
874935 if err != nil {
875936 return fmt.Errorf("list the runners: %w", err)
@@ -883,10 +944,22 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ
883944 }
884945 t := e.trigger(c.now())
885946 ref, sha := domain.PullMergeRef(mr.Number), st.merge
886- if sha.IsZero() {
947+ switch {
948+ case target:
949+ // The code is the default tip's: the cause and that tip decide.
950+ ref, sha = "refs/heads/"+e.target.branch, e.target.commit
951+ t.trusted = e.actorTrusted && e.target.trusted
952+ if e.actorTrusted && !e.target.trusted {
953+ ctxLog(ctx).Info().Str("repo", e.repo.String()).Int64("number", mr.Number).Str("action", e.action).Str("tip", sha.String()).
954+ Msg("ci pull_request_target runs are not trusted: the default branch tip was not pushed by a trusted principal")
955+ }
956+ case sha.IsZero():
887957 ref, sha = domain.PullHeadRef(mr.Number), st.head
888958 }
889- pf := &pullFacts{mr: mr, action: e.action, head: st.head, base: st.base, before: e.before, merged: mr.State == domain.MRStateMerged}
959+ pf := &pullFacts{
960+ mr: mr, action: e.action, head: st.head, base: st.base, before: e.before, merged: mr.State == domain.MRStateMerged,
961+ merge: st.merge, target: target,
962+ }
890963 f := pushFacts{
891964 update: domain.RefUpdate{Ref: ref, Old: domain.ZeroOID, New: sha.String()}, commit: sha, pull: pf,
892965 }
@@ -912,8 +985,8 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ
912985 if err != nil || len(files) == 0 {
913986 return err
914987 }
915- if workflow.SkipCI(f.head.Message) {
916- return nil
988+ if !target && workflow.SkipCI(f.head.Message) {
989+ return nil // GitHub's skip directives are for push and pull_request only
917990 }
918991 var errs []error
919992 for _, file := range files {
@@ -921,7 +994,7 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ
921994 continue // reported by the push of the branch
922995 }
923996 wf, perrs := workflow.Parse(file.path, file.content)
924- if len(perrs) > 0 || !matchPullRequest(wf, e.action, mr.Target, f.files, f.filesTruncated) {
997+ if len(perrs) > 0 || !matchPullRequest(wf, f.eventName(), e.action, mr.Target, f.files, f.filesTruncated) {
925998 continue
926999 }
9271000 if problem := c.matrixProblem(ctx, repo, t, f, file.path, wf); problem.text != "" {
@@ -938,16 +1011,16 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ
9381011 }
9391012
9401013 // matchPullRequest reports whether the workflow runs for the activity of a merge
941-// request into base: the event is listed, the activity is one of its types
942-// (opened, synchronize and reopened when it lists none), and the branches and
943-// paths filters, which apply to the base branch and the changed files, let it
944-// through. The filters are those of the push event (ermite-actions), run on the
945-// pull_request event under another name.
946-func matchPullRequest(wf *workflow.Workflow, action, base string, files []string, truncated bool) bool {
1014+// request into base: the event (pull_request or pull_request_target) is
1015+// listed, the activity is one of its types (opened, synchronize and reopened
1016+// when it lists none), and the branches and paths filters, which apply to the
1017+// base branch and the changed files, let it through. The filters are those of
1018+// the push event (ermite-actions), run on the event under another name.
1019+func matchPullRequest(wf *workflow.Workflow, event, action, base string, files []string, truncated bool) bool {
9471020 if wf == nil || wf.On == nil {
9481021 return false
9491022 }
950- ev := wf.On.Event("pull_request")
1023+ ev := wf.On.Event(event)
9511024 if ev == nil {
9521025 return false
9531026 }
@@ -969,7 +1042,8 @@ func matchPullRequest(wf *workflow.Workflow, action, base string, files []string
9691042 // pullGithubJSON builds the github context of a pull_request run: github.ref is
9701043 // the merge ref, github.sha the merge commit (the head for a closed request
9711044 // that never merged), and the event is shaped like GitHub's pull_request
972-// webhook payload with the fields ermite can fill.
1045+// webhook payload with the fields ermite can fill. A pull_request_target run
1046+// has the same payload under its own event name, on the default branch.
9731047 func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) string {
9741048 p := f.pull
9751049 mr := p.mr
@@ -1005,8 +1079,8 @@ func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) str
10051079 case p.merged:
10061080 mergedAt = tm(mr.UpdatedAt)
10071081 mergeSha = mr.MergedCommitID.String()
1008- case f.commit.String() != p.head.String():
1009- mergeSha = f.commit.String() // the test merge commit
1082+ case !p.merge.IsZero() && p.merge != p.head:
1083+ mergeSha = p.merge.String() // the test merge commit
10101084 }
10111085 prID := stableID("pull", u.full+"#"+num)
10121086 pr := map[string]any{
@@ -1029,8 +1103,12 @@ func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) str
10291103 }
10301104 event["before"], event["after"] = before, p.head.String()
10311105 }
1106+ refName := strings.TrimPrefix(run.Ref, domain.PullRefPrefix)
1107+ if p.target {
1108+ refName = strings.TrimPrefix(run.Ref, "refs/heads/")
1109+ }
10321110 return c.githubContext(repo, run, ghEvent{
1033- name: "pull_request", refName: strings.TrimPrefix(run.Ref, domain.PullRefPrefix), refType: "branch",
1111+ name: f.eventName(), refName: refName, refType: "branch",
10341112 headRef: mr.Source, baseRef: mr.Target, payload: event,
10351113 })
10361114 }
@@ -52,6 +52,16 @@ import (
52 //52 //
53 // A head that does not merge cleanly makes no run for that update, as on GitHub;53 // A head that does not merge cleanly makes no run for that update, as on GitHub;
54 // the merge ref then keeps the last commit that merged, as GitHub's does.54 // the merge ref then keeps the last commit that merged, as GitHub's does.
55+//
56+// The pull_request_target event (CI-4, D4) has the same activities, payload
57+// and filters, but runs the workflows of the default branch's tip, as taken
58+// when the activity happened, conflict or not: github.ref is the default
59+// branch, github.sha that tip. Its checks attach to the head too. It is
60+// trusted when the cause of the activity is trusted and the default tip was
61+// pushed by a trusted principal: the head and the base do not matter, as none
62+// of their files run unless a workflow checks them out (GitHub's "pwn
63+// request"). It is stricter than GitHub, which gives the secrets to any pull
64+// request of the repository: an agent's activity runs it without secrets.
55 65
56 const (66 const (
57 // DefaultCIPullRefreshes bounds the merge requests (and the pushes) waiting67 // DefaultCIPullRefreshes bounds the merge requests (and the pushes) waiting
@@ -87,6 +97,13 @@ type pullEvent struct {
87 actor string97 actor string
88 pusher domain.CIPusher98 pusher domain.CIPusher
89 trusted bool99 trusted bool
100+ // actorTrusted is the trust of the cause alone (the pusher of a
101+ // synchronize, whoever opened or closed): trusted also requires the head
102+ // and the base to be, pull_request_target the default tip.
103+ actorTrusted bool
104+ // target is the default branch's tip when the activity happened: what its
105+ // pull_request_target runs execute.
106+ target defaultTip
90 // merge is the last merge commit the trust of a closing was checked107 // merge is the last merge commit the trust of a closing was checked
91 // against, zero when there was none.108 // against, zero when there was none.
92 merge domain.ObjectID109 merge domain.ObjectID
@@ -99,6 +116,30 @@ func (e pullEvent) trigger(at time.Time) ciTrigger {
99 return ciTrigger{repo: e.repo, actor: e.actor, pusher: e.pusher, trusted: e.trusted, at: at, log: e.log}116 return ciTrigger{repo: e.repo, actor: e.actor, pusher: e.pusher, trusted: e.trusted, at: at, log: e.log}
100 }117 }
101 118
119+// defaultTip is the tip of the default branch at one moment (CI-4, D4).
120+type defaultTip struct {
121+ branch string
122+ commit domain.ObjectID
123+ // trusted: a trusted principal set the branch to exactly that commit.
124+ trusted bool
125+}
126+
127+// defaultTipOf takes the tip of the default branch of a repository now, and
128+// whether a trusted principal pushed it. The zero value when the repository
129+// has no default branch (no pull_request_target run), and not trusted on any
130+// error or without a record (refTrusted).
131+func (c *CI) defaultTipOf(ctx context.Context, repo domain.RepoName) defaultTip {
132+ def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
133+ if err != nil || !exists {
134+ return defaultTip{}
135+ }
136+ tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
137+ if err != nil {
138+ return defaultTip{}
139+ }
140+ return defaultTip{branch: def, commit: tip, trusted: c.refTrusted(ctx, repo, pinnedRef{ref: "refs/heads/" + def, commit: tip, object: tip})}
141+}
142+
102 type pullKey struct {143 type pullKey struct {
103 repo domain.RepoName144 repo domain.RepoName
104 number int64145 number int64
@@ -163,8 +204,9 @@ func (c *CI) OnPullRequest(ctx context.Context, repo domain.RepoName, ev ports.P
163 e := pullEvent{204 e := pullEvent{
164 repo: repo, number: ev.Number, action: ev.Action, head: ev.Head, base: ev.Base,205 repo: repo, number: ev.Number, action: ev.Action, head: ev.Head, base: ev.Base,
165 actor: authorOf(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),206 actor: authorOf(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),
207+ actorTrusted: domain.TrustedActor(p), target: c.defaultTipOf(ctx, repo),
166 }208 }
167- if domain.TrustedActor(p) {209+ if e.actorTrusted {
168 e.trusted, e.merge = c.pullTipsTrusted(ctx, repo, ev)210 e.trusted, e.merge = c.pullTipsTrusted(ctx, repo, ev)
169 }211 }
170 c.queuePullEvent(e)212 c.queuePullEvent(e)
@@ -370,6 +412,7 @@ func (c *CI) onBranchesMoved(ctx context.Context, repo domain.RepoName, updates
370 }412 }
371 tips := map[string]domain.ObjectID{}413 tips := map[string]domain.ObjectID{}
372 baseTrusted := map[string]bool{}414 baseTrusted := map[string]bool{}
415+ var target *defaultTip // the default tip of the snapshot, read once
373 for _, n := range numbers {416 for _, n := range numbers {
374 mr, err := c.cfg.MRs.Get(ctx, repo, n)417 mr, err := c.cfg.MRs.Get(ctx, repo, n)
375 if err != nil || !mr.IsOpen() {418 if err != nil || !mr.IsOpen() {
@@ -395,9 +438,13 @@ func (c *CI) onBranchesMoved(ctx context.Context, repo domain.RepoName, updates
395 baseTrusted[mr.Target] = bt438 baseTrusted[mr.Target] = bt
396 }439 }
397 evTrusted := trusted && bt440 evTrusted := trusted && bt
441+ if target == nil {
442+ t := c.defaultTipOf(ctx, repo)
443+ target = &t
444+ }
398 c.queuePullEvent(pullEvent{445 c.queuePullEvent(pullEvent{
399 repo: repo, number: mr.Number, action: domain.PullSynchronize, head: head, base: base, before: before,446 repo: repo, number: mr.Number, action: domain.PullSynchronize, head: head, base: base, before: before,
400- actor: actor, pusher: pusher, trusted: evTrusted, run: true, log: log,447+ actor: actor, pusher: pusher, trusted: evTrusted, actorTrusted: trusted, target: *target, run: true, log: log,
401 })448 })
402 }449 }
403 }450 }
@@ -656,6 +703,11 @@ type pullFacts struct {
656 // before is the head before a synchronize.703 // before is the head before a synchronize.
657 before domain.ObjectID704 before domain.ObjectID
658 merged bool705 merged bool
706+ // merge is the test merge commit of head and base, zero when there is
707+ // none (a conflict, or a closed request that never merged cleanly).
708+ merge domain.ObjectID
709+ // target is set for a pull_request_target run.
710+ target bool
659 }711 }
660 712
661 // pullState is the merge of a head into a base.713 // pullState is the merge of a head into a base.
@@ -690,10 +742,14 @@ func (c *CI) handlePullEvent(ctx context.Context, e pullEvent) error {
690 return err742 return err
691 }743 }
692 c.stage(pullStageComputed)744 c.stage(pullStageComputed)
693- if err := c.publishPull(ctx, mr, st); err != nil || !st.clean || !e.run {745+ if err := c.publishPull(ctx, mr, st); err != nil || !e.run {
694 return err746 return err
695 }747 }
696- return c.startPullRuns(ctx, e, mr, st)748+ var err1 error
749+ if st.clean { // a conflict makes no pull_request run, as on GitHub
750+ err1 = c.startPullRuns(ctx, e, mr, st, false)
751+ }
752+ return errors.Join(err1, c.startPullRuns(ctx, e, mr, st, true))
697 case domain.PullClosed:753 case domain.PullClosed:
698 if mr.IsOpen() || !e.run {754 if mr.IsOpen() || !e.run {
699 return nil755 return nil
@@ -702,10 +758,11 @@ func (c *CI) handlePullEvent(ctx context.Context, e pullEvent) error {
702 if err != nil || !ok {758 if err != nil || !ok {
703 return err759 return err
704 }760 }
761+ pe := e
705 if st.otherHead || st.merge != e.merge {762 if st.otherHead || st.merge != e.merge {
706- e.trusted = false // not the code the trust was checked for763+ pe.trusted = false // not the code the trust was checked for
707 }764 }
708- return c.startPullRuns(ctx, e, mr, st)765+ return errors.Join(c.startPullRuns(ctx, pe, mr, st, false), c.startPullRuns(ctx, e, mr, st, true))
709 }766 }
710 return nil767 return nil
711 }768 }
@@ -868,8 +925,12 @@ func (c *CI) closedPull(ctx context.Context, e pullEvent, mr domain.MergeRequest
868 }925 }
869 926
870 // startPullRuns starts the pull_request workflows of the merge request that927 // startPullRuns starts the pull_request workflows of the merge request that
871-// match the activity and the files it changes.928+// match the activity and the files it changes, or with target its
872-func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequest, st pullState) error {929+// pull_request_target workflows: those of the default tip of the event.
930+func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequest, st pullState, target bool) error {
931+ if target && (e.target.commit.IsZero() || st.head.IsZero()) {
932+ return nil // no default branch, or no head to run for
933+ }
873 runners, err := c.cfg.Store.ListRunners(ctx)934 runners, err := c.cfg.Store.ListRunners(ctx)
874 if err != nil {935 if err != nil {
875 return fmt.Errorf("list the runners: %w", err)936 return fmt.Errorf("list the runners: %w", err)
@@ -883,10 +944,22 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ
883 }944 }
884 t := e.trigger(c.now())945 t := e.trigger(c.now())
885 ref, sha := domain.PullMergeRef(mr.Number), st.merge946 ref, sha := domain.PullMergeRef(mr.Number), st.merge
886- if sha.IsZero() {947+ switch {
948+ case target:
949+ // The code is the default tip's: the cause and that tip decide.
950+ ref, sha = "refs/heads/"+e.target.branch, e.target.commit
951+ t.trusted = e.actorTrusted && e.target.trusted
952+ if e.actorTrusted && !e.target.trusted {
953+ ctxLog(ctx).Info().Str("repo", e.repo.String()).Int64("number", mr.Number).Str("action", e.action).Str("tip", sha.String()).
954+ Msg("ci pull_request_target runs are not trusted: the default branch tip was not pushed by a trusted principal")
955+ }
956+ case sha.IsZero():
887 ref, sha = domain.PullHeadRef(mr.Number), st.head957 ref, sha = domain.PullHeadRef(mr.Number), st.head
888 }958 }
889- pf := &pullFacts{mr: mr, action: e.action, head: st.head, base: st.base, before: e.before, merged: mr.State == domain.MRStateMerged}959+ pf := &pullFacts{
960+ mr: mr, action: e.action, head: st.head, base: st.base, before: e.before, merged: mr.State == domain.MRStateMerged,
961+ merge: st.merge, target: target,
962+ }
890 f := pushFacts{963 f := pushFacts{
891 update: domain.RefUpdate{Ref: ref, Old: domain.ZeroOID, New: sha.String()}, commit: sha, pull: pf,964 update: domain.RefUpdate{Ref: ref, Old: domain.ZeroOID, New: sha.String()}, commit: sha, pull: pf,
892 }965 }
@@ -912,8 +985,8 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ
912 if err != nil || len(files) == 0 {985 if err != nil || len(files) == 0 {
913 return err986 return err
914 }987 }
915- if workflow.SkipCI(f.head.Message) {988+ if !target && workflow.SkipCI(f.head.Message) {
916- return nil989+ return nil // GitHub's skip directives are for push and pull_request only
917 }990 }
918 var errs []error991 var errs []error
919 for _, file := range files {992 for _, file := range files {
@@ -921,7 +994,7 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ
921 continue // reported by the push of the branch994 continue // reported by the push of the branch
922 }995 }
923 wf, perrs := workflow.Parse(file.path, file.content)996 wf, perrs := workflow.Parse(file.path, file.content)
924- if len(perrs) > 0 || !matchPullRequest(wf, e.action, mr.Target, f.files, f.filesTruncated) {997+ if len(perrs) > 0 || !matchPullRequest(wf, f.eventName(), e.action, mr.Target, f.files, f.filesTruncated) {
925 continue998 continue
926 }999 }
927 if problem := c.matrixProblem(ctx, repo, t, f, file.path, wf); problem.text != "" {1000 if problem := c.matrixProblem(ctx, repo, t, f, file.path, wf); problem.text != "" {
@@ -938,16 +1011,16 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ
938 }1011 }
939 1012
940 // matchPullRequest reports whether the workflow runs for the activity of a merge1013 // matchPullRequest reports whether the workflow runs for the activity of a merge
941-// request into base: the event is listed, the activity is one of its types1014+// request into base: the event (pull_request or pull_request_target) is
942-// (opened, synchronize and reopened when it lists none), and the branches and1015+// listed, the activity is one of its types (opened, synchronize and reopened
943-// paths filters, which apply to the base branch and the changed files, let it1016+// when it lists none), and the branches and paths filters, which apply to the
944-// through. The filters are those of the push event (ermite-actions), run on the1017+// base branch and the changed files, let it through. The filters are those of
945-// pull_request event under another name.1018+// the push event (ermite-actions), run on the event under another name.
946-func matchPullRequest(wf *workflow.Workflow, action, base string, files []string, truncated bool) bool {1019+func matchPullRequest(wf *workflow.Workflow, event, action, base string, files []string, truncated bool) bool {
947 if wf == nil || wf.On == nil {1020 if wf == nil || wf.On == nil {
948 return false1021 return false
949 }1022 }
950- ev := wf.On.Event("pull_request")1023+ ev := wf.On.Event(event)
951 if ev == nil {1024 if ev == nil {
952 return false1025 return false
953 }1026 }
@@ -969,7 +1042,8 @@ func matchPullRequest(wf *workflow.Workflow, action, base string, files []string
969 // pullGithubJSON builds the github context of a pull_request run: github.ref is1042 // pullGithubJSON builds the github context of a pull_request run: github.ref is
970 // the merge ref, github.sha the merge commit (the head for a closed request1043 // the merge ref, github.sha the merge commit (the head for a closed request
971 // that never merged), and the event is shaped like GitHub's pull_request1044 // that never merged), and the event is shaped like GitHub's pull_request
972-// webhook payload with the fields ermite can fill.1045+// webhook payload with the fields ermite can fill. A pull_request_target run
1046+// has the same payload under its own event name, on the default branch.
973 func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) string {1047 func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) string {
974 p := f.pull1048 p := f.pull
975 mr := p.mr1049 mr := p.mr
@@ -1005,8 +1079,8 @@ func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) str
1005 case p.merged:1079 case p.merged:
1006 mergedAt = tm(mr.UpdatedAt)1080 mergedAt = tm(mr.UpdatedAt)
1007 mergeSha = mr.MergedCommitID.String()1081 mergeSha = mr.MergedCommitID.String()
1008- case f.commit.String() != p.head.String():1082+ case !p.merge.IsZero() && p.merge != p.head:
1009- mergeSha = f.commit.String() // the test merge commit1083+ mergeSha = p.merge.String() // the test merge commit
1010 }1084 }
1011 prID := stableID("pull", u.full+"#"+num)1085 prID := stableID("pull", u.full+"#"+num)
1012 pr := map[string]any{1086 pr := map[string]any{
@@ -1029,8 +1103,12 @@ func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) str
1029 }1103 }
1030 event["before"], event["after"] = before, p.head.String()1104 event["before"], event["after"] = before, p.head.String()
1031 }1105 }
1106+ refName := strings.TrimPrefix(run.Ref, domain.PullRefPrefix)
1107+ if p.target {
1108+ refName = strings.TrimPrefix(run.Ref, "refs/heads/")
1109+ }
1032 return c.githubContext(repo, run, ghEvent{1110 return c.githubContext(repo, run, ghEvent{
1033- name: "pull_request", refName: strings.TrimPrefix(run.Ref, domain.PullRefPrefix), refType: "branch",1111+ name: f.eventName(), refName: refName, refType: "branch",
1034 headRef: mr.Source, baseRef: mr.Target, payload: event,1112 headRef: mr.Source, baseRef: mr.Target, payload: event,
1035 })1113 })
1036 }1114 }
M internal/usecase/ci_pull_test.go +21 -0
@@ -42,6 +42,27 @@ func TestMatchPullRequestTypes(t *testing.T) {
4242 assert.True(t, usecase.MatchPullRequest(parsePR(t, " [push, pull_request]\n"), "opened", "main", nil, false))
4343 }
4444
45+// CI-4 D4: pull_request_target has the types and the filters of pull_request,
46+// under its own name; types ermite never emits parse and never match.
47+func TestMatchPullRequestTarget(t *testing.T) {
48+ def := parsePR(t, " pull_request_target:\n")
49+ for action, want := range map[string]bool{"opened": true, "synchronize": true, "reopened": true, "closed": false} {
50+ assert.Equal(t, want, usecase.MatchPullRequestTarget(def, action, "main", nil, false), "default types, "+action)
51+ }
52+ assert.False(t, usecase.MatchPullRequest(def, "opened", "main", nil, false), "not a pull_request workflow")
53+ assert.False(t, usecase.MatchPullRequestTarget(parsePR(t, " pull_request:\n"), "opened", "main", nil, false))
54+ labeled := parsePR(t, " pull_request_target:\n types: [labeled, closed]\n")
55+ assert.False(t, usecase.MatchPullRequestTarget(labeled, "opened", "main", nil, false))
56+ assert.True(t, usecase.MatchPullRequestTarget(labeled, "closed", "main", nil, false))
57+
58+ wf := parsePR(t, " pull_request_target:\n branches: [main]\n paths: ['src/**']\n")
59+ assert.True(t, usecase.MatchPullRequestTarget(wf, "opened", "main", []string{"src/a.go"}, false))
60+ assert.False(t, usecase.MatchPullRequestTarget(wf, "opened", "dev", []string{"src/a.go"}, false), "the base branch is filtered")
61+ assert.False(t, usecase.MatchPullRequestTarget(wf, "opened", "main", []string{"README.md"}, false), "the changed files are filtered")
62+ ignore := parsePR(t, " pull_request_target:\n branches-ignore: [wip]\n")
63+ assert.False(t, usecase.MatchPullRequestTarget(ignore, "opened", "wip", nil, false))
64+}
65+
4566 func TestMatchPullRequestFilters(t *testing.T) {
4667 wf := parsePR(t, " pull_request:\n branches: [main, 'release/**']\n paths: ['src/**', '!src/gen/**']\n")
4768 files := func(f ...string) []string { return f }
@@ -42,6 +42,27 @@ func TestMatchPullRequestTypes(t *testing.T) {
42 assert.True(t, usecase.MatchPullRequest(parsePR(t, " [push, pull_request]\n"), "opened", "main", nil, false))42 assert.True(t, usecase.MatchPullRequest(parsePR(t, " [push, pull_request]\n"), "opened", "main", nil, false))
43 }43 }
44 44
45+// CI-4 D4: pull_request_target has the types and the filters of pull_request,
46+// under its own name; types ermite never emits parse and never match.
47+func TestMatchPullRequestTarget(t *testing.T) {
48+ def := parsePR(t, " pull_request_target:\n")
49+ for action, want := range map[string]bool{"opened": true, "synchronize": true, "reopened": true, "closed": false} {
50+ assert.Equal(t, want, usecase.MatchPullRequestTarget(def, action, "main", nil, false), "default types, "+action)
51+ }
52+ assert.False(t, usecase.MatchPullRequest(def, "opened", "main", nil, false), "not a pull_request workflow")
53+ assert.False(t, usecase.MatchPullRequestTarget(parsePR(t, " pull_request:\n"), "opened", "main", nil, false))
54+ labeled := parsePR(t, " pull_request_target:\n types: [labeled, closed]\n")
55+ assert.False(t, usecase.MatchPullRequestTarget(labeled, "opened", "main", nil, false))
56+ assert.True(t, usecase.MatchPullRequestTarget(labeled, "closed", "main", nil, false))
57+
58+ wf := parsePR(t, " pull_request_target:\n branches: [main]\n paths: ['src/**']\n")
59+ assert.True(t, usecase.MatchPullRequestTarget(wf, "opened", "main", []string{"src/a.go"}, false))
60+ assert.False(t, usecase.MatchPullRequestTarget(wf, "opened", "dev", []string{"src/a.go"}, false), "the base branch is filtered")
61+ assert.False(t, usecase.MatchPullRequestTarget(wf, "opened", "main", []string{"README.md"}, false), "the changed files are filtered")
62+ ignore := parsePR(t, " pull_request_target:\n branches-ignore: [wip]\n")
63+ assert.False(t, usecase.MatchPullRequestTarget(ignore, "opened", "wip", nil, false))
64+}
65+
45 func TestMatchPullRequestFilters(t *testing.T) {66 func TestMatchPullRequestFilters(t *testing.T) {
46 wf := parsePR(t, " pull_request:\n branches: [main, 'release/**']\n paths: ['src/**', '!src/gen/**']\n")67 wf := parsePR(t, " pull_request:\n branches: [main, 'release/**']\n paths: ['src/**', '!src/gen/**']\n")
47 files := func(f ...string) []string { return f }68 files := func(f ...string) []string { return f }
M internal/usecase/ci_runner.go +1 -0
@@ -253,6 +253,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, sett
253253 WorkflowPath: path, WorkflowYAML: yaml,
254254 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,
255255 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
256+ Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
256257 }
257258 }
258259
@@ -253,6 +253,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, sett
253 WorkflowPath: path, WorkflowYAML: yaml,253 WorkflowPath: path, WorkflowYAML: yaml,
254 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,254 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,
255 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),255 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
256+ Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
256 }257 }
257 }258 }
258 259
M internal/usecase/ci_trigger.go +16 -4
@@ -183,7 +183,8 @@ type pushFacts struct {
183183 payload *pushPayload
184184 // pull is set for a pull_request run: update.Ref is then
185185 // refs/pull/<n>/merge (or /head), commit the test merge commit (or the
186- // head) and head the head commit.
186+ // head) and head the head commit. For a pull_request_target run
187+ // (pull.target) update.Ref is the default branch and commit its tip.
187188 pull *pullFacts
188189 // custom is set for the events that are not a push or a merge request:
189190 // workflow_dispatch, schedule and workflow_run (ci_events.go).
@@ -194,6 +195,8 @@ type pushFacts struct {
194195 // show it.
195196 func (f pushFacts) eventName() string {
196197 switch {
198+ case f.pull != nil && f.pull.target:
199+ return eventPullTarget
197200 case f.pull != nil:
198201 return "pull_request"
199202 case f.custom != nil:
@@ -214,7 +217,16 @@ func (f pushFacts) runTitle() string {
214217 return firstLine(f.head.Message)
215218 }
216219
217-// headSha is CIRun.HeadSha: the head of the merge request, "" for a push.
220+// pullNumber is CIRun.PullNumber: the merge request, 0 for a push.
221+func (f pushFacts) pullNumber() int64 {
222+ if f.pull != nil {
223+ return f.pull.mr.Number
224+ }
225+ return 0
226+}
227+
228+// headSha is CIRun.HeadSha: the head of the merge request (pull_request and
229+// pull_request_target), "" for a push.
218230 func (f pushFacts) headSha() string {
219231 if f.pull != nil {
220232 return f.pull.head.String()
@@ -463,7 +475,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
463475 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
464476 run := domain.CIRun{
465477 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
466- Title: f.runTitle(), Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), Actor: t.actor,
478+ Title: f.runTitle(), Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), PullNumber: f.pullNumber(), Actor: t.actor,
467479 Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError), Trusted: t.trusted,
468480 CreatedAt: now, StartedAt: now, CompletedAt: now,
469481 }
@@ -525,7 +537,7 @@ func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pus
525537 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
526538 run := domain.CIRun{
527539 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
528- Title: title, Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), Actor: t.actor,
540+ Title: title, Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), PullNumber: f.pullNumber(), Actor: t.actor,
529541 Status: domain.CIQueued, Trusted: t.trusted, CreatedAt: now,
530542 }
531543 githubJSON := c.githubJSON(ctx, repo, run, f)
@@ -183,7 +183,8 @@ type pushFacts struct {
183 payload *pushPayload183 payload *pushPayload
184 // pull is set for a pull_request run: update.Ref is then184 // pull is set for a pull_request run: update.Ref is then
185 // refs/pull/<n>/merge (or /head), commit the test merge commit (or the185 // refs/pull/<n>/merge (or /head), commit the test merge commit (or the
186- // head) and head the head commit.186+ // head) and head the head commit. For a pull_request_target run
187+ // (pull.target) update.Ref is the default branch and commit its tip.
187 pull *pullFacts188 pull *pullFacts
188 // custom is set for the events that are not a push or a merge request:189 // custom is set for the events that are not a push or a merge request:
189 // workflow_dispatch, schedule and workflow_run (ci_events.go).190 // workflow_dispatch, schedule and workflow_run (ci_events.go).
@@ -194,6 +195,8 @@ type pushFacts struct {
194 // show it.195 // show it.
195 func (f pushFacts) eventName() string {196 func (f pushFacts) eventName() string {
196 switch {197 switch {
198+ case f.pull != nil && f.pull.target:
199+ return eventPullTarget
197 case f.pull != nil:200 case f.pull != nil:
198 return "pull_request"201 return "pull_request"
199 case f.custom != nil:202 case f.custom != nil:
@@ -214,7 +217,16 @@ func (f pushFacts) runTitle() string {
214 return firstLine(f.head.Message)217 return firstLine(f.head.Message)
215 }218 }
216 219
217-// headSha is CIRun.HeadSha: the head of the merge request, "" for a push.220+// pullNumber is CIRun.PullNumber: the merge request, 0 for a push.
221+func (f pushFacts) pullNumber() int64 {
222+ if f.pull != nil {
223+ return f.pull.mr.Number
224+ }
225+ return 0
226+}
227+
228+// headSha is CIRun.HeadSha: the head of the merge request (pull_request and
229+// pull_request_target), "" for a push.
218 func (f pushFacts) headSha() string {230 func (f pushFacts) headSha() string {
219 if f.pull != nil {231 if f.pull != nil {
220 return f.pull.head.String()232 return f.pull.head.String()
@@ -463,7 +475,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
463 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {475 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
464 run := domain.CIRun{476 run := domain.CIRun{
465 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,477 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
466- Title: f.runTitle(), Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), Actor: t.actor,478+ Title: f.runTitle(), Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), PullNumber: f.pullNumber(), Actor: t.actor,
467 Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError), Trusted: t.trusted,479 Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError), Trusted: t.trusted,
468 CreatedAt: now, StartedAt: now, CompletedAt: now,480 CreatedAt: now, StartedAt: now, CompletedAt: now,
469 }481 }
@@ -525,7 +537,7 @@ func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pus
525 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {537 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
526 run := domain.CIRun{538 run := domain.CIRun{
527 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,539 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
528- Title: title, Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), Actor: t.actor,540+ Title: title, Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), PullNumber: f.pullNumber(), Actor: t.actor,
529 Status: domain.CIQueued, Trusted: t.trusted, CreatedAt: now,541 Status: domain.CIQueued, Trusted: t.trusted, CreatedAt: now,
530 }542 }
531 githubJSON := c.githubJSON(ctx, repo, run, f)543 githubJSON := c.githubJSON(ctx, repo, run, f)
M internal/usecase/export_test.go +6 -1
@@ -64,7 +64,12 @@ func EvalConcurrency(ctx context.Context, tok template.Token, env template.Env)
6464
6565 // MatchPullRequest exposes the pull_request filter to the tests.
6666 func MatchPullRequest(wf *workflow.Workflow, action, base string, files []string, truncated bool) bool {
67- return matchPullRequest(wf, action, base, files, truncated)
67+ return matchPullRequest(wf, "pull_request", action, base, files, truncated)
68+}
69+
70+// MatchPullRequestTarget exposes the pull_request_target filter to the tests.
71+func MatchPullRequestTarget(wf *workflow.Workflow, action, base string, files []string, truncated bool) bool {
72+ return matchPullRequest(wf, "pull_request_target", action, base, files, truncated)
6873 }
6974
7075 // MatchWorkflowRun exposes the workflow_run filter to the tests.
@@ -64,7 +64,12 @@ func EvalConcurrency(ctx context.Context, tok template.Token, env template.Env)
64 64
65 // MatchPullRequest exposes the pull_request filter to the tests.65 // MatchPullRequest exposes the pull_request filter to the tests.
66 func MatchPullRequest(wf *workflow.Workflow, action, base string, files []string, truncated bool) bool {66 func MatchPullRequest(wf *workflow.Workflow, action, base string, files []string, truncated bool) bool {
67- return matchPullRequest(wf, action, base, files, truncated)67+ return matchPullRequest(wf, "pull_request", action, base, files, truncated)
68+}
69+
70+// MatchPullRequestTarget exposes the pull_request_target filter to the tests.
71+func MatchPullRequestTarget(wf *workflow.Workflow, action, base string, files []string, truncated bool) bool {
72+ return matchPullRequest(wf, "pull_request_target", action, base, files, truncated)
68 }73 }
69 74
70 // MatchWorkflowRun exposes the workflow_run filter to the tests.75 // MatchWorkflowRun exposes the workflow_run filter to the tests.
M proto/ermite/storage/ci.proto +3 -0
@@ -109,6 +109,9 @@ message CIRun {
109109 // head_sha: the head of the merge request of a pull_request run, where sha is
110110 // the test merge commit. Empty for the other events.
111111 string head_sha = 25;
112+ // pull_number: the merge request of a pull_request or pull_request_target
113+ // run, 0 for the other events and in runs stored before it existed.
114+ int64 pull_number = 26;
112115 }
113116
114117 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned
@@ -109,6 +109,9 @@ message CIRun {
109 // head_sha: the head of the merge request of a pull_request run, where sha is109 // head_sha: the head of the merge request of a pull_request run, where sha is
110 // the test merge commit. Empty for the other events.110 // the test merge commit. Empty for the other events.
111 string head_sha = 25;111 string head_sha = 25;
112+ // pull_number: the merge request of a pull_request or pull_request_target
113+ // run, 0 for the other events and in runs stored before it existed.
114+ int64 pull_number = 26;
112 }115 }
113 116
114 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned117 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close