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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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) | |||
| 115 | 115 | ||
| 116 | 116 | ## 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 | 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 | 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 | 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 | 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 | 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 | ||
| @@ -115,7 +115,7 @@ A merge request proposes a branch for its target (the default branch unless set) | |||||
| 115 | 115 | ||||
| 116 | ## CI | 116 | ## 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 { | |||
| 516 | 516 | Concurrency *CIConcurrency `protobuf:"bytes,24,opt,name=concurrency,proto3" json:"concurrency,omitempty"` | |
| 517 | 517 | // head_sha: the head of the merge request of a pull_request run, where sha is | |
| 518 | 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 | 523 | unknownFields protoimpl.UnknownFields | |
| 521 | 524 | sizeCache protoimpl.SizeCache | |
| 522 | 525 | } | |
| @@ -712,6 +715,13 @@ func (x *CIRun) GetHeadSha() string { | |||
| 712 | 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 | 725 | // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned | |
| 716 | 726 | // from, kept apart so that listing runs does not decode it. | |
| 717 | 727 | type CIRunSource struct { | |
| @@ -2504,7 +2514,7 @@ var File_ermite_storage_ci_proto protoreflect.FileDescriptor | |||
| 2504 | 2514 | ||
| 2505 | 2515 | const file_ermite_storage_ci_proto_rawDesc = "" + | |
| 2506 | 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 | 2518 | "\x05CIRun\x12\x12\n" + | |
| 2509 | 2519 | "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x0e\n" + | |
| 2510 | 2520 | "\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" + | |
| @@ -2533,7 +2543,9 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 2533 | 2543 | "\x04name\x18\x16 \x01(\tR\x04name\x12\x18\n" + | |
| 2534 | 2544 | "\atrusted\x18\x17 \x01(\bR\atrusted\x12?\n" + | |
| 2535 | 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 | 2549 | "\vCIRunSource\x12\x15\n" + | |
| 2538 | 2550 | "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" + | |
| 2539 | 2551 | "\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 is | 517 | // 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.UnknownFields | 523 | unknownFields protoimpl.UnknownFields | ||
| 521 | sizeCache protoimpl.SizeCache | 524 | 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 planned | 725 | // 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) { | |||
| 278 | 278 | return mustMarshal(&storagepb.CIRun{ | |
| 279 | 279 | Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt, | |
| 280 | 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 | 282 | Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error, | |
| 283 | 283 | CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt), | |
| 284 | 284 | CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt), Trusted: r.Trusted, | |
| @@ -302,7 +302,7 @@ func decodeRun(item kv.Item) (domain.CIRun, error) { | |||
| 302 | 302 | return domain.CIRun{ | |
| 303 | 303 | Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(), | |
| 304 | 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 | 306 | Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(), | |
| 307 | 307 | CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()), | |
| 308 | 308 | 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 | |||
| 34 | 34 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 35 | 35 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 36 | 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 | 39 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 40 | 40 | const FeaturePasskeys = "passkeys" | |
| @@ -34,7 +34,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||||
| 34 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | 34 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 35 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | 35 | // 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 { | |||
| 224 | 224 | Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken, | |
| 225 | 225 | Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted, | |
| 226 | 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 | ||
| @@ -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 { | |||
| 237 | 237 | Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt, | |
| 238 | 238 | } | |
| 239 | 239 | 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. | |
| 242 | 247 | row.Ref, row.RefHref = "!"+strconv.FormatInt(n, 10), mrHref(repo, n) | |
| 243 | 248 | } | |
| 244 | 249 | 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) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 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 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -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) { | |||
| 326 | 326 | ver := decode[versionOut](t, res.stdout) | |
| 327 | 327 | assert.Equal(t, c.url, ver.Server.URL) | |
| 328 | 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 | 330 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 331 | 331 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 332 | 332 | 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 { | |||
| 175 | 175 | // HeadSha is the head of the merge request of a pull_request run, whose Sha | |
| 176 | 176 | // is the test merge commit github.sha shows; empty for the other events. The | |
| 177 | 177 | // 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 | |
| 179 | 182 | Actor string | |
| 180 | 183 | Status CIStatus | |
| 181 | 184 | Conclusion CIConclusion | |
| @@ -175,7 +175,10 @@ type CIRun struct { | |||||
| 175 | // HeadSha is the head of the merge request of a pull_request run, whose Sha | 175 | // 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. The | 176 | // 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 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 | |||
| 179 | Actor string | 182 | Actor string | ||
| 180 | Status CIStatus | 183 | Status CIStatus | ||
| 181 | Conclusion CIConclusion | 184 | Conclusion CIConclusion | ||
M internal/ports/ci.go +11 -0
| @@ -431,6 +431,17 @@ type RunnerJob struct { | |||
| 431 | 431 | // Permissions are the permissions of GithubToken (key to level), after the | |
| 432 | 432 | // trust rule: no write when the run is not trusted. | |
| 433 | 433 | 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 | 447 | // 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 the | 431 | // 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]string | 433 | 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 job | 447 | // 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 ( | |||
| 22 | 22 | eventDispatch = "workflow_dispatch" | |
| 23 | 23 | eventSchedule = "schedule" | |
| 24 | 24 | eventWorkflowRun = "workflow_run" | |
| 25 | + | eventPullTarget = "pull_request_target" | |
| 25 | 26 | ||
| 26 | 27 | // maxDispatchInputs is the number of top-level inputs of a workflow_dispatch | |
| 27 | 28 | // (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_dispatch | 27 | // maxDispatchInputs is the number of top-level inputs of a workflow_dispatch | ||
| 27 | // (docs.github.com, "Events that trigger workflows", workflow_dispatch: up to | 28 | // (docs.github.com, "Events that trigger workflows", workflow_dispatch: up to | ||
M internal/usecase/ci_pull.go +102 -24
| @@ -52,6 +52,16 @@ import ( | |||
| 52 | 52 | // | |
| 53 | 53 | // A head that does not merge cleanly makes no run for that update, as on GitHub; | |
| 54 | 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 | 66 | const ( | |
| 57 | 67 | // DefaultCIPullRefreshes bounds the merge requests (and the pushes) waiting | |
| @@ -87,6 +97,13 @@ type pullEvent struct { | |||
| 87 | 97 | actor string | |
| 88 | 98 | pusher domain.CIPusher | |
| 89 | 99 | 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 | 107 | // merge is the last merge commit the trust of a closing was checked | |
| 91 | 108 | // against, zero when there was none. | |
| 92 | 109 | merge domain.ObjectID | |
| @@ -99,6 +116,30 @@ func (e pullEvent) trigger(at time.Time) ciTrigger { | |||
| 99 | 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 | 143 | type pullKey struct { | |
| 103 | 144 | repo domain.RepoName | |
| 104 | 145 | number int64 | |
| @@ -163,8 +204,9 @@ func (c *CI) OnPullRequest(ctx context.Context, repo domain.RepoName, ev ports.P | |||
| 163 | 204 | e := pullEvent{ | |
| 164 | 205 | repo: repo, number: ev.Number, action: ev.Action, head: ev.Head, base: ev.Base, | |
| 165 | 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 | 210 | e.trusted, e.merge = c.pullTipsTrusted(ctx, repo, ev) | |
| 169 | 211 | } | |
| 170 | 212 | c.queuePullEvent(e) | |
| @@ -370,6 +412,7 @@ func (c *CI) onBranchesMoved(ctx context.Context, repo domain.RepoName, updates | |||
| 370 | 412 | } | |
| 371 | 413 | tips := map[string]domain.ObjectID{} | |
| 372 | 414 | baseTrusted := map[string]bool{} | |
| 415 | + | var target *defaultTip // the default tip of the snapshot, read once | |
| 373 | 416 | for _, n := range numbers { | |
| 374 | 417 | mr, err := c.cfg.MRs.Get(ctx, repo, n) | |
| 375 | 418 | if err != nil || !mr.IsOpen() { | |
| @@ -395,9 +438,13 @@ func (c *CI) onBranchesMoved(ctx context.Context, repo domain.RepoName, updates | |||
| 395 | 438 | baseTrusted[mr.Target] = bt | |
| 396 | 439 | } | |
| 397 | 440 | evTrusted := trusted && bt | |
| 441 | + | if target == nil { | |
| 442 | + | t := c.defaultTipOf(ctx, repo) | |
| 443 | + | target = &t | |
| 444 | + | } | |
| 398 | 445 | c.queuePullEvent(pullEvent{ | |
| 399 | 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 | 703 | // before is the head before a synchronize. | |
| 657 | 704 | before domain.ObjectID | |
| 658 | 705 | 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 | 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 | 742 | return err | |
| 691 | 743 | } | |
| 692 | 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 | 746 | 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 | 753 | case domain.PullClosed: | |
| 698 | 754 | if mr.IsOpen() || !e.run { | |
| 699 | 755 | return nil | |
| @@ -702,10 +758,11 @@ func (c *CI) handlePullEvent(ctx context.Context, e pullEvent) error { | |||
| 702 | 758 | if err != nil || !ok { | |
| 703 | 759 | return err | |
| 704 | 760 | } | |
| 761 | + | pe := e | |
| 705 | 762 | 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 | |
| 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 | 767 | 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 | 927 | // 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 | + | } | |
| 873 | 934 | runners, err := c.cfg.Store.ListRunners(ctx) | |
| 874 | 935 | if err != nil { | |
| 875 | 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 | 945 | t := e.trigger(c.now()) | |
| 885 | 946 | 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 | 957 | 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 | 963 | f := pushFacts{ | |
| 891 | 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 | 985 | if err != nil || len(files) == 0 { | |
| 913 | 986 | return err | |
| 914 | 987 | } | |
| 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 | |
| 917 | 990 | } | |
| 918 | 991 | var errs []error | |
| 919 | 992 | for _, file := range files { | |
| @@ -921,7 +994,7 @@ func (c *CI) startPullRuns(ctx context.Context, e pullEvent, mr domain.MergeRequ | |||
| 921 | 994 | continue // reported by the push of the branch | |
| 922 | 995 | } | |
| 923 | 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 | 998 | continue | |
| 926 | 999 | } | |
| 927 | 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 | 1013 | // 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 { | |
| 947 | 1020 | if wf == nil || wf.On == nil { | |
| 948 | 1021 | return false | |
| 949 | 1022 | } | |
| 950 | - | ev := wf.On.Event("pull_request") | |
| 1023 | + | ev := wf.On.Event(event) | |
| 951 | 1024 | if ev == nil { | |
| 952 | 1025 | return false | |
| 953 | 1026 | } | |
| @@ -969,7 +1042,8 @@ func matchPullRequest(wf *workflow.Workflow, action, base string, files []string | |||
| 969 | 1042 | // pullGithubJSON builds the github context of a pull_request run: github.ref is | |
| 970 | 1043 | // the merge ref, github.sha the merge commit (the head for a closed request | |
| 971 | 1044 | // 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 | 1047 | func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) string { | |
| 974 | 1048 | p := f.pull | |
| 975 | 1049 | mr := p.mr | |
| @@ -1005,8 +1079,8 @@ func (c *CI) pullGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) str | |||
| 1005 | 1079 | case p.merged: | |
| 1006 | 1080 | mergedAt = tm(mr.UpdatedAt) | |
| 1007 | 1081 | 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 | |
| 1010 | 1084 | } | |
| 1011 | 1085 | prID := stableID("pull", u.full+"#"+num) | |
| 1012 | 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 | 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 | 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 | 1112 | headRef: mr.Source, baseRef: mr.Target, payload: event, | |
| 1035 | 1113 | }) | |
| 1036 | 1114 | } | |
| @@ -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) waiting | 67 | // DefaultCIPullRefreshes bounds the merge requests (and the pushes) waiting | ||
| @@ -87,6 +97,13 @@ type pullEvent struct { | |||||
| 87 | actor string | 97 | actor string | ||
| 88 | pusher domain.CIPusher | 98 | pusher domain.CIPusher | ||
| 89 | trusted bool | 99 | 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 checked | 107 | // 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.ObjectID | 109 | 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.RepoName | 144 | repo domain.RepoName | ||
| 104 | number int64 | 145 | 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] = bt | 438 | baseTrusted[mr.Target] = bt | ||
| 396 | } | 439 | } | ||
| 397 | evTrusted := trusted && bt | 440 | 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.ObjectID | 704 | before domain.ObjectID | ||
| 658 | merged bool | 705 | 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 err | 742 | 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 err | 746 | 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 nil | 755 | 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 err | 759 | 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 for | 763 | + | 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 nil | 767 | 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 that | 927 | // 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.merge | 946 | 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.head | 957 | 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 err | 986 | return err | ||
| 914 | } | 987 | } | ||
| 915 | - | if workflow.SkipCI(f.head.Message) { | 988 | + | if !target && workflow.SkipCI(f.head.Message) { |
| 916 | - | return nil | 989 | + | return nil // GitHub's skip directives are for push and pull_request only |
| 917 | } | 990 | } | ||
| 918 | var errs []error | 991 | 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 branch | 994 | 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 | continue | 998 | 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 merge | 1013 | // 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 | 1014 | + | // request into base: the event (pull_request or pull_request_target) is |
| 942 | - | // (opened, synchronize and reopened when it lists none), and the branches and | 1015 | + | // 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 it | 1016 | + | // 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 the | 1017 | + | // 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 false | 1021 | 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 false | 1025 | 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 is | 1042 | // 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 request | 1043 | // 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_request | 1044 | // 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.pull | 1048 | p := f.pull | ||
| 975 | mr := p.mr | 1049 | 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 commit | 1083 | + | 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) { | |||
| 42 | 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 | 66 | func TestMatchPullRequestFilters(t *testing.T) { | |
| 46 | 67 | wf := parsePR(t, " pull_request:\n branches: [main, 'release/**']\n paths: ['src/**', '!src/gen/**']\n") | |
| 47 | 68 | 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 | |||
| 253 | 253 | WorkflowPath: path, WorkflowYAML: yaml, | |
| 254 | 254 | Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted, | |
| 255 | 255 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | |
| 256 | + | Cache: c.cacheScope(rs.run, rs.src.GithubJSON), | |
| 256 | 257 | } | |
| 257 | 258 | } | |
| 258 | 259 | ||
| @@ -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 { | |||
| 183 | 183 | payload *pushPayload | |
| 184 | 184 | // pull is set for a pull_request run: update.Ref is then | |
| 185 | 185 | // 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 | 188 | pull *pullFacts | |
| 188 | 189 | // custom is set for the events that are not a push or a merge request: | |
| 189 | 190 | // workflow_dispatch, schedule and workflow_run (ci_events.go). | |
| @@ -194,6 +195,8 @@ type pushFacts struct { | |||
| 194 | 195 | // show it. | |
| 195 | 196 | func (f pushFacts) eventName() string { | |
| 196 | 197 | switch { | |
| 198 | + | case f.pull != nil && f.pull.target: | |
| 199 | + | return eventPullTarget | |
| 197 | 200 | case f.pull != nil: | |
| 198 | 201 | return "pull_request" | |
| 199 | 202 | case f.custom != nil: | |
| @@ -214,7 +217,16 @@ func (f pushFacts) runTitle() string { | |||
| 214 | 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 | 230 | func (f pushFacts) headSha() string { | |
| 219 | 231 | if f.pull != nil { | |
| 220 | 232 | return f.pull.head.String() | |
| @@ -463,7 +475,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f | |||
| 463 | 475 | draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft { | |
| 464 | 476 | run := domain.CIRun{ | |
| 465 | 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 | 479 | Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError), Trusted: t.trusted, | |
| 468 | 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 | 537 | draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft { | |
| 526 | 538 | run := domain.CIRun{ | |
| 527 | 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 | 541 | Status: domain.CIQueued, Trusted: t.trusted, CreatedAt: now, | |
| 530 | 542 | } | |
| 531 | 543 | githubJSON := c.githubJSON(ctx, repo, run, f) | |
| @@ -183,7 +183,8 @@ type pushFacts struct { | |||||
| 183 | payload *pushPayload | 183 | payload *pushPayload | ||
| 184 | // pull is set for a pull_request run: update.Ref is then | 184 | // 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 the | 185 | // 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 *pullFacts | 188 | 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) | |||
| 64 | 64 | ||
| 65 | 65 | // MatchPullRequest exposes the pull_request filter to the tests. | |
| 66 | 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 | 75 | // 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 { | |||
| 109 | 109 | // head_sha: the head of the merge request of a pull_request run, where sha is | |
| 110 | 110 | // the test merge commit. Empty for the other events. | |
| 111 | 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 | 117 | // 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 is | 109 | // 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 planned | 117 | // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned | ||