Actions: reusable workflows and actions from other repositories
`jobs.<id>.uses: owner/repo/.g1t|.github/workflows/x.yml@ref` and a step's `uses: owner/repo[/path]@ref` are looked for on g1t first, as the calling workspace sees them (reach.rs). The same repository and public ones are used; a private one only from a private repository of its workspace, when its Settings, Actions, Access says so (actions/0008, access_level; GET/PUT /repos/{o}/{r}/actions/permissions/access, MCP get_access/set_access). A job fetches such an action with a read-only token for that repository, ending with the job (job_action, the runner's POST /actions/jobs/{job}/action). Otherwise actions come from GitHub as before, and reusable workflows from a public repository there. A ./ call inside a called workflow reads from that workflow's repository. Called workflows get secrets the way GitHub gives them: only what `secrets:` passes by name, or all with `secrets: inherit`, read when each job starts so none is stored; required ones are checked before the call, and a called job's environment's secrets apply over them. Before, a same-repository call read every repository secret. Docs: Actions guide sections on other repositories, releases and deployments; limitations and PLAN updated.
| 246 | 246 | ||
| 247 | 247 | ### Workflow features not supported yet | |
| 248 | 248 | ||
| 249 | − | - Reusable workflows from another repository. Ones in the same repository | |
| 250 | − | work. | |
| 251 | 249 | - Actions that upload or download artifacts with the toolkit's artifact | |
| 252 | 250 | library themselves. The library refuses to run against any server but | |
| 253 | 251 | github.com. `actions/upload-artifact`, `actions/download-artifact` and |
| 29 | 29 | ||
| 30 | 30 | | On GitHub | On g1t | | |
| 31 | 31 | | --- | --- | | |
| 32 | − | | `on:` `push` (branches, tags, paths), `pull_request`, `pull_request_target`, `issues`, `issue_comment`, `pull_request_review`, `schedule`, `workflow_dispatch`, `workflow_run`, `merge_group`, `create`, `repository_dispatch` | The same, from g1t's own pushes, pull requests, issues, comments and [merge queue](/guides/merge-queue/). `create` starts on each new branch or tag; `repository_dispatch` on [a dispatch event](#repository-dispatch). | | |
| 32 | + | | `on:` `push` (branches, tags, paths), `pull_request`, `pull_request_target`, `issues`, `issue_comment`, `pull_request_review`, `schedule`, `workflow_dispatch`, `workflow_run`, `merge_group`, `create`, `repository_dispatch`, `release`, `deployment`, `deployment_status` | The same, from g1t's own pushes, pull requests, issues, comments, [releases](#releases), [deployments](#deployments) and [merge queue](/guides/merge-queue/). `create` starts on each new branch or tag; `repository_dispatch` on [a dispatch event](#repository-dispatch). | | |
| 33 | 33 | | `jobs`, `needs`, `if`, `outputs`, `env`, `defaults`, `timeout-minutes`, `continue-on-error` | The same. | | |
| 34 | 34 | | `timeout-minutes` and `continue-on-error` on a step | The same, for `run:` and `uses:` steps alike. A `uses:` step's action is stopped at its limit, with every process it started; a step inside a composite action stops at its own limit or the `uses:` step's, whichever comes first. A step stopped this way fails, unless `continue-on-error` lets the job go on. | | |
| 35 | 35 | | `strategy.matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`, a matrix from `fromJSON(needs.…)` | The same. | | |
| ⋯ | |||
| 37 | 37 | | `permissions:` for the workflow or for one job, `read-all`, `write-all` | The same: they decide what [the job's token](#the-jobs-token) may do. | | |
| 38 | 38 | | `${{ }}` expressions: every operator, function and context | The same, including `hashFiles`, `success()`, `failure()`, `always()` and `cancelled()`. | | |
| 39 | 39 | | `run:` with `bash`, `sh`, `python` or a custom shell | The same. | | |
| 40 | − | | JavaScript actions (`uses: owner/repo@v7`) | Fetched from GitHub and run as they are, on Node 24, the runtime current actions declare. | | |
| 40 | + | | JavaScript actions (`uses: owner/repo@v7`, `owner/repo/path@v7`) | Fetched from that repository on g1t when g1t has it and your repository may use it, otherwise from GitHub, and run as they are, on Node 24, the runtime current actions declare. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories). | | |
| 41 | 41 | | Composite actions | The same. | | |
| 42 | − | | Reusable workflows in the repository (`jobs.<id>.uses: ./.g1t/workflows/build.yml`) | The same: `with:` inputs, `on.workflow_call` outputs, and nesting up to four deep. `./.github/workflows/…` finds the workflow under `.g1t/` after the move. Their jobs read the repository's secrets and variables. | | |
| 42 | + | | Reusable workflows (`jobs.<id>.uses: ./.g1t/workflows/build.yml`, or `owner/repo/.g1t/workflows/build.yml@v1` in another repository) | The same: `with:` inputs, `secrets:` by name or `secrets: inherit`, `on.workflow_call` outputs, and nesting up to four deep. `.github/workflows/…` finds the workflow under `.g1t/` after the move. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories). | | |
| 43 | 43 | | `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. | | |
| 44 | 44 | | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. | | |
| 45 | 45 | | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run, and [masked](#masking-secrets) values stay hidden. | | |
| ⋯ | |||
| 67 | 67 | - **Docker's `type=gha` build cache.** Buildx skips it on g1t, and the | |
| 68 | 68 | build runs without a cache. Use a registry cache instead; see | |
| 69 | 69 | [caching image builds](#caching-image-builds). | |
| 70 | − | - **Reusable workflows from other repositories** (`uses: owner/repo/.github/workflows/x.yml@v1`); ones in the same repository work. | |
| 71 | 70 | - **Actions that upload artifacts with the toolkit's artifact library | |
| 72 | 71 | themselves.** The library refuses to run against any server but | |
| 73 | 72 | github.com. `actions/upload-artifact`, `actions/download-artifact` and | |
| ⋯ | |||
| 79 | 78 | Why each of these is missing, and what to use instead, is on | |
| 80 | 79 | [What g1t can't do yet](/about/limitations/#actions-and-runners). | |
| 81 | 80 | ||
| 81 | + | ## Actions and workflows from other repositories | |
| 82 | + | ||
| 83 | + | A step's `uses: owner/repo@ref` (or `owner/repo/path@ref`) and a job's | |
| 84 | + | `uses: owner/repo/.g1t/workflows/build.yml@ref` name another repository. | |
| 85 | + | g1t looks for it on g1t first: | |
| 86 | + | ||
| 87 | + | | The repository | What happens | | |
| 88 | + | | --- | --- | | |
| 89 | + | | On g1t and public | Your workflows use it, from any workspace. | | |
| 90 | + | | On g1t, private, in your workspace, with **Access** set to *Accessible from repositories in* the workspace | Your private repositories' workflows use it. A job reads it with a read-only token for that repository alone, which ends with the job. | | |
| 91 | + | | On g1t, private, and not shared that way | The step or job fails, and says why. A private repository's actions are never used by a public repository's workflows, whose logs anyone can read, nor from another workspace. | | |
| 92 | + | | Not on g1t, or private in a workspace you cannot see | An action is fetched from GitHub, as before; a reusable workflow is read from a public repository on GitHub. | | |
| 93 | + | ||
| 94 | + | `ref` is a branch, a tag or a commit. A reusable workflow may be under | |
| 95 | + | `.g1t/workflows/` or `.github/workflows/`; a `.github/workflows/` path | |
| 96 | + | also finds the file under `.g1t/workflows/` in a repository moved to | |
| 97 | + | g1t. A `./.g1t/workflows/…` call inside a workflow from another | |
| 98 | + | repository reads from that repository, at the same ref. | |
| 99 | + | ||
| 100 | + | To share a private repository's actions and workflows with the rest of its | |
| 101 | + | workspace, an admin chooses **Settings → Actions → Access → Accessible from | |
| 102 | + | repositories in** the workspace, or calls | |
| 103 | + | `PUT /repos/{owner}/{repo}/actions/permissions/access` with | |
| 104 | + | `{"access_level": "organization"}` (`none` to stop). | |
| 105 | + | ||
| 106 | + | ### Secrets for a called workflow | |
| 107 | + | ||
| 108 | + | A called workflow gets only the secrets its caller passes, plus | |
| 109 | + | `G1T_TOKEN` (`GITHUB_TOKEN`): | |
| 110 | + | ||
| 111 | + | ```yaml | |
| 112 | + | jobs: | |
| 113 | + | build: | |
| 114 | + | uses: acme/shared/.g1t/workflows/build.yml@v2 | |
| 115 | + | with: | |
| 116 | + | node-version: 24 | |
| 117 | + | secrets: | |
| 118 | + | npm-token: ${{ secrets.NPM_TOKEN }} | |
| 119 | + | ||
| 120 | + | deploy: | |
| 121 | + | uses: ./.g1t/workflows/deploy.yml | |
| 122 | + | secrets: inherit | |
| 123 | + | ``` | |
| 124 | + | ||
| 125 | + | - `secrets:` with names passes each as the called workflow names it, | |
| 126 | + | read from the caller's `secrets`, `needs`, `inputs`, `matrix`, | |
| 127 | + | `github` and `vars`. | |
| 128 | + | - `secrets: inherit` passes every secret the caller has. | |
| 129 | + | - A job in the called workflow with its own `environment:` also reads that | |
| 130 | + | environment's secrets, over what was passed. | |
| 131 | + | - A secret the called workflow marks `required: true` under | |
| 132 | + | `on.workflow_call.secrets` that the caller does not pass fails the | |
| 133 | + | calling job before anything runs. | |
| 134 | + | ||
| 135 | + | `vars` are the calling repository's, and a called workflow's jobs run | |
| 136 | + | with the calling run's `github` context: `actions/checkout` checks out the | |
| 137 | + | calling repository. | |
| 138 | + | ||
| 139 | + | ## Releases | |
| 140 | + | ||
| 141 | + | Workflows with `on: release` start when a release changes, at the commit | |
| 142 | + | its tag names (`GITHUB_REF` is `refs/tags/<tag>`). Each change is one or | |
| 143 | + | more activity types, which `types:` chooses among: | |
| 144 | + | ||
| 145 | + | | Change | Activity types | | |
| 146 | + | | --- | --- | | |
| 147 | + | | A draft made | `created` | | |
| 148 | + | | A release made and published | `created`, `published`, and `released` (or `prereleased` for a prerelease) | | |
| 149 | + | | A draft published | `published`, and `released` or `prereleased` | | |
| 150 | + | | A prerelease made a full release | `edited` and `released` | | |
| 151 | + | | Made a draft again | `unpublished` | | |
| 152 | + | | Title, notes or prerelease changed | `edited`, with `github.event.changes` holding the old title and notes | | |
| 153 | + | | Deleted (the tag stays) | `deleted` | | |
| 154 | + | ||
| 155 | + | ```yaml | |
| 156 | + | on: | |
| 157 | + | release: | |
| 158 | + | types: [published] | |
| 159 | + | ``` | |
| 160 | + | ||
| 161 | + | `github.event.release` has `tag_name`, `name`, `body`, `draft`, | |
| 162 | + | `prerelease`, `target_commitish`, `author` and `html_url`. A release a | |
| 163 | + | job's own token makes or changes starts no workflows. | |
| 164 | + | ||
| 165 | + | ## Deployments | |
| 166 | + | ||
| 167 | + | `on: deployment` starts when a deployment is made, and | |
| 168 | + | `on: deployment_status` when one has a new status: one reported through | |
| 169 | + | the [deployments API](/guides/deployments-api/) or a | |
| 170 | + | [g1t.page](/guides/deployments/) build. The run is at the commit deployed; | |
| 171 | + | `GITHUB_REF` is the branch or tag deployed, and empty for a bare commit. | |
| 172 | + | ||
| 173 | + | ```yaml | |
| 174 | + | on: deployment_status | |
| 175 | + | ||
| 176 | + | jobs: | |
| 177 | + | smoke: | |
| 178 | + | if: github.event.deployment_status.state == 'success' | |
| 179 | + | runs-on: ubuntu-latest | |
| 180 | + | steps: | |
| 181 | + | - run: curl -fsS "${{ github.event.deployment_status.environment_url }}" | |
| 182 | + | ``` | |
| 183 | + | ||
| 184 | + | `github.event.deployment` has `environment`, `ref`, `sha`, `task` and | |
| 185 | + | `payload`; `github.event.deployment_status` has `state`, `environment_url` | |
| 186 | + | and `log_url`. Deployments a workflow makes, with `environment:` or with | |
| 187 | + | its job's token, start no workflows, so a workflow cannot set itself off. | |
| 188 | + | ||
| 82 | 189 | ## The runner | |
| 83 | 190 | ||
| 84 | 191 | Jobs run in a fresh sandbox each: Debian with Node 24, Python 3, Go, Rust, | |
| ⋯ | |||
| 653 | 760 | marks it ready, which on g1t is when it first has code. Each head runs | |
| 654 | 761 | each workflow once. | |
| 655 | 762 | ||
| 656 | − | They also start on the activity types `labeled`, `unlabeled`, | |
| 657 | − | `milestoned`, `demilestoned`, `assigned`, `review_requested` and | |
| 658 | − | `closed`, and `edited` when the branch a pull request merges into | |
| 659 | − | changes; `issues` workflows on `labeled`, `unlabeled`, `milestoned` and | |
| 763 | + | They also start on the activity types `reopened` (also run by | |
| 764 | + | default, as `opened` and `synchronize` are), `converted_to_draft`, | |
| 765 | + | `ready_for_review`, `labeled`, `unlabeled`, `milestoned`, `demilestoned`, | |
| 766 | + | `assigned`, `review_requested` and `closed`, and `edited` when the branch | |
| 767 | + | a pull request merges into changes; `issue_comment` workflows on | |
| 768 | + | `created`, `edited` (with `github.event.changes.body.from`) and `deleted`; `issues` workflows on `labeled`, `unlabeled`, `milestoned` and | |
| 660 | 769 | `demilestoned` too. List them under `types:` to run on them. For | |
| 661 | 770 | `labeled` and `unlabeled`, `github.event.label` names the label. A pull | |
| 662 | 771 | request's `branches` filter, `github.base_ref` and | |
| ⋯ | |||
| 1015 | 1124 | | `get_permissions`, `set_permissions` | `GET` and `PUT /repos/{owner}/{repo}/actions/permissions/workflow`, with `default_workflow_permissions` (`read`, `write` or `inherit`) and `can_approve_pull_request_reviews` | | |
| 1016 | 1125 | | `get_workspace_permissions`, `set_workspace_permissions` | `GET` and `PUT /workspaces/{workspace}/actions/permissions/workflow`, with `default_workflow_permissions`, `max_workflow_permissions` and `can_approve_pull_request_reviews` | | |
| 1017 | 1126 | | `get_approval_policy`, `set_approval_policy` | `GET` and `PUT /repos/{owner}/{repo}/actions/permissions/fork-pr-contributor-approval` | | |
| 1127 | + | | `get_access`, `set_access` | `GET` and `PUT /repos/{owner}/{repo}/actions/permissions/access`, with `access_level` (`none` or `organization`) | | |
| 1018 | 1128 | | `repository_dispatch` | `POST /repos/{owner}/{repo}/dispatches` with `event_type` and `client_payload` | | |
| 1019 | 1129 | | `list_artifacts` | `GET /repos/{owner}/{repo}/actions/artifacts`, with `name`, `page`, `per_page` | | |
| 1020 | 1130 | | `run_artifacts` | `GET …/actions/runs/{id}/artifacts`, with `name` | | |
| 54 | 54 | git push origin :refs/tags/v1.2.0 | |
| 55 | 55 | ``` | |
| 56 | 56 | ||
| 57 | + | ## Workflows and webhooks | |
| 58 | + | ||
| 59 | + | Each change to a release starts the repository's workflows that run | |
| 60 | + | `on: release`, at the commit its tag names, and is sent to webhooks as | |
| 61 | + | `release.created`, `release.published` and the rest. See | |
| 62 | + | [releases in Actions](/guides/actions/#releases) for which change is which | |
| 63 | + | activity type, and [webhooks](/guides/webhooks/) for what each event | |
| 64 | + | carries. A release a workflow job's own token makes starts no workflows. | |
| 65 | + | ||
| 57 | 66 | ## From the API and MCP | |
| 58 | 67 | ||
| 59 | 68 | | Route | MCP | What it does | |
| 1 | 1 | import { Form, Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | − | import { APPROVAL_POLICIES } from "@g1t/contracts"; | |
| 4 | − | import type { ActionsSettingsChange, ApprovalPolicy } from "@g1t/contracts"; | |
| 3 | + | import { ACTIONS_ACCESS_LEVELS, APPROVAL_POLICIES } from "@g1t/contracts"; | |
| 4 | + | import type { ActionsAccessLevel, ActionsSettingsChange, ApprovalPolicy } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/settings-actions"; | |
| 7 | 7 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| ⋯ | |||
| 37 | 37 | // Only where the workspace allows it is the box there to send. | |
| 38 | 38 | if (form.has("pullRequestsShown")) change.canApprovePullRequests = form.get("canApprovePullRequests") === "on"; | |
| 39 | 39 | if ((APPROVAL_POLICIES as readonly string[]).includes(policy)) change.approvalPolicy = policy as ApprovalPolicy; | |
| 40 | + | const access = String(form.get("accessLevel")); | |
| 41 | + | if ((ACTIONS_ACCESS_LEVELS as readonly string[]).includes(access)) change.accessLevel = access as ActionsAccessLevel; | |
| 40 | 42 | const saved = await actions.setActionsSettings(user, { namespace: params.owner, name: params.repo }, change); | |
| 41 | 43 | return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message }; | |
| 42 | 44 | } | |
| ⋯ | |||
| 155 | 157 | </p> | |
| 156 | 158 | </Section> | |
| 157 | 159 | ||
| 160 | + | <Section | |
| 161 | + | title="Access" | |
| 162 | + | about={ | |
| 163 | + | <> | |
| 164 | + | Which other repositories' workflows may use this repository's actions ( | |
| 165 | + | <code className="font-mono text-xs">uses: {params.owner}/{params.repo}@main</code>) and reusable workflows while it | |
| 166 | + | is private. A public repository's actions and workflows are anyone's. | |
| 167 | + | </> | |
| 168 | + | } | |
| 169 | + | > | |
| 170 | + | <RadioGroup name="accessLevel" defaultValue={settings.accessLevel ?? "none"} className="gap-3"> | |
| 171 | + | <RadioOption | |
| 172 | + | value="none" | |
| 173 | + | label="Not accessible" | |
| 174 | + | description="Only this repository's own workflows use them. The default." | |
| 175 | + | /> | |
| 176 | + | <RadioOption | |
| 177 | + | value="organization" | |
| 178 | + | label={`Accessible from repositories in ${params.owner}`} | |
| 179 | + | description={`Workflows in ${params.owner}'s other private repositories may use them. A public repository's workflows never can, since their logs are public.`} | |
| 180 | + | /> | |
| 181 | + | </RadioGroup> | |
| 182 | + | </Section> | |
| 183 | + | ||
| 158 | 184 | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> | |
| 159 | 185 | <SubmitButton pending="Saving…">Save settings</SubmitButton> | |
| 160 | 186 | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 273 | 273 | /// Whether the workspace lets its repositories turn that on. | |
| 274 | 274 | #[serde(default)] | |
| 275 | 275 | pub workspace_allows_pull_requests: bool, | |
| 276 | + | /// Who may use this repository's actions and reusable workflows from | |
| 277 | + | /// their workflows, when it is private: `none` (only itself, the | |
| 278 | + | /// default) or `organization` (private repositories of its workspace). | |
| 279 | + | /// A public repository's are anyone's. See [`ACCESS_LEVELS`]. | |
| 280 | + | #[serde(default = "no_access")] | |
| 281 | + | pub access_level: String, | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | fn no_access() -> String { | |
| 285 | + | "none".to_owned() | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /// The values of `access_level`. `user` is read as `organization`: a | |
| 289 | + | /// personal account's repositories are its own workspace's. | |
| 290 | + | pub const ACCESS_LEVELS: [&str; 2] = ["none", "organization"]; | |
| 291 | + | ||
| 292 | + | /// `access_level` as given, as one of [`ACCESS_LEVELS`]; None when it is | |
| 293 | + | /// not one. | |
| 294 | + | pub fn access_level(given: &str) -> Option<&'static str> { | |
| 295 | + | match given.trim().to_ascii_lowercase().as_str() { | |
| 296 | + | "none" | "" => Some("none"), | |
| 297 | + | "organization" | "user" | "workspace" => Some("organization"), | |
| 298 | + | _ => None, | |
| 299 | + | } | |
| 276 | 300 | } | |
| 277 | 301 | ||
| 278 | 302 | fn write() -> String { | |
| ⋯ | |||
| 343 | 367 | pub approval_policy: Option<String>, | |
| 344 | 368 | #[serde(default)] | |
| 345 | 369 | pub can_approve_pull_requests: Option<bool>, | |
| 370 | + | /// `none`, or `organization` (`user` reads the same). See | |
| 371 | + | /// [`ActionsSettings::access_level`]. | |
| 372 | + | #[serde(default)] | |
| 373 | + | pub access_level: Option<String>, | |
| 346 | 374 | } | |
| 347 | 375 | ||
| 348 | 376 | /// `environments`: every environment a repository's workflows, secrets, | |
| 26 | 26 | Ok(response.into_json()?) | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | /// Where to fetch another repository's action from: `{"source": "g1t", | |
| 30 | + | /// "url", "ref", "token"}` or `{"source": "github"}`. Err holds why g1t | |
| 31 | + | /// refused (`true`: the repository is private and may not be used | |
| 32 | + | /// here), or that it could not be asked (`false`). | |
| 33 | + | pub(crate) fn action(&self, repository: &str, git_ref: &str) -> std::result::Result<Value, (bool, String)> { | |
| 34 | + | let sent = ureq::post(&format!("{}/actions/jobs/{}/action", self.base, self.job)) | |
| 35 | + | .timeout(Duration::from_secs(30)) | |
| 36 | + | .send_json(json!({ "token": self.token, "report": { "repository": repository, "ref": git_ref } })); | |
| 37 | + | match sent { | |
| 38 | + | Ok(response) => response.into_json().map_err(|error| (false, error.to_string())), | |
| 39 | + | Err(ureq::Error::Status(code, response)) => { | |
| 40 | + | let body: Value = response.into_json().unwrap_or(Value::Null); | |
| 41 | + | let message = body["error"]["message"].as_str().unwrap_or("g1t did not answer.").to_owned(); | |
| 42 | + | Err((code == 403, message)) | |
| 43 | + | } | |
| 44 | + | Err(error) => Err((false, error.to_string())), | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 29 | 48 | pub(crate) fn report(&self, report: Value) { | |
| 30 | 49 | // A report that cannot be sent is tried a few times, then dropped: | |
| 31 | 50 | // the job goes on, and g1t notices a silent job by itself. |
| 1 | 1 | //! `uses:` steps: `actions/checkout` done natively against g1t, actions | |
| 2 | − | //! fetched from GitHub and run as they are (JavaScript, composite and | |
| 2 | + | //! fetched from another repository on g1t (or else GitHub) and run as they are (JavaScript, composite and | |
| 3 | 3 | //! Docker), `docker://` images, and a few of GitHub's own whose services | |
| 4 | 4 | //! g1t does not have yet. | |
| 5 | 5 | ||
| ⋯ | |||
| 24 | 24 | /// Where an action comes from. | |
| 25 | 25 | enum Source { | |
| 26 | 26 | Local(PathBuf), | |
| 27 | + | /// Another repository: on g1t when g1t has it and this one may use | |
| 28 | + | /// it, otherwise on GitHub. | |
| 27 | 29 | GitHub { owner: String, repo: String, path: String, git_ref: String }, | |
| 28 | 30 | } | |
| 29 | 31 | ||
| ⋯ | |||
| 97 | 99 | Some(r) if is_sha(r) => ("HEAD".into(), Some(r.clone()), None), | |
| 98 | 100 | Some(r) if r.starts_with("refs/") => (r.clone(), None, r.strip_prefix("refs/heads/").map(str::to_owned)), | |
| 99 | 101 | Some(r) => (r.clone(), None, Some(r.clone())), | |
| 100 | − | None if same && run_ref.starts_with("refs/pull/") => ("HEAD".into(), Some(run_sha.clone()), None), | |
| 102 | + | // A pull request's merge ref, or no ref at all (a deployment of | |
| 103 | + | // a bare commit): the commit itself. | |
| 104 | + | None if same && (run_ref.starts_with("refs/pull/") || run_ref.is_empty()) => ("HEAD".into(), Some(run_sha.clone()), None), | |
| 101 | 105 | None if same => (run_ref.clone(), Some(run_sha.clone()), run_ref.strip_prefix("refs/heads/").map(str::to_owned)), | |
| 102 | 106 | None => ("HEAD".into(), None, None), | |
| 103 | 107 | }; | |
| ⋯ | |||
| 169 | 173 | (true, outputs) | |
| 170 | 174 | } | |
| 171 | 175 | ||
| 176 | + | /// Fetches another repository's action, once per job: from g1t when | |
| 177 | + | /// g1t has the repository and this one may use it, otherwise from | |
| 178 | + | /// GitHub. A private repository on g1t that may not be used here fails | |
| 179 | + | /// the step, saying why, rather than fetching something else by its | |
| 180 | + | /// name. | |
| 181 | + | fn fetch_remote_action(&mut self, owner: &str, repo: &str, git_ref: &str) -> Option<PathBuf> { | |
| 182 | + | let on_g1t = super::paths::under_home(ACTIONS_DIR).join("_g1t").join(owner).join(repo).join(git_ref); | |
| 183 | + | let on_github = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref); | |
| 184 | + | for dir in [&on_g1t, &on_github] { | |
| 185 | + | if dir.join(".g1t-fetched").exists() { | |
| 186 | + | return Some(dir.clone()); | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | if !(safe(owner) && safe(repo) && safe(git_ref)) { | |
| 190 | + | self.log.line(&format!("##[error]`{owner}/{repo}@{git_ref}` is not a name g1t can fetch.")); | |
| 191 | + | return None; | |
| 192 | + | } | |
| 193 | + | match self.log.api.action(&format!("{owner}/{repo}"), git_ref) { | |
| 194 | + | Ok(found) if found["source"] == "g1t" => self.fetch_g1t_action(&on_g1t, owner, repo, git_ref, &found), | |
| 195 | + | Ok(_) => self.fetch_action(owner, repo, git_ref), | |
| 196 | + | Err((true, why)) => { | |
| 197 | + | self.log.line(&format!("##[error]{why}")); | |
| 198 | + | None | |
| 199 | + | } | |
| 200 | + | Err((false, why)) => { | |
| 201 | + | self.log.line(&format!("##[warning]g1t could not say where {owner}/{repo} is ({why}); fetching it from GitHub.")); | |
| 202 | + | self.fetch_action(owner, repo, git_ref) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// Fetches an action from a repository on g1t, at its ref, with the | |
| 208 | + | /// read-only token g1t gave for it when it is private. | |
| 209 | + | fn fetch_g1t_action(&mut self, dir: &Path, owner: &str, repo: &str, git_ref: &str, found: &Value) -> Option<PathBuf> { | |
| 210 | + | let url = found["url"].as_str().unwrap_or_default().to_owned(); | |
| 211 | + | let token = found["token"].as_str().filter(|token| !token.is_empty()).map(str::to_owned); | |
| 212 | + | if let Some(token) = &token { | |
| 213 | + | self.log.add_mask(token); | |
| 214 | + | } | |
| 215 | + | let auth = token.map(|token| format!("AUTHORIZATION: basic {}", STANDARD.encode(format!("x-access-token:{token}")))); | |
| 216 | + | self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}' from g1t")); | |
| 217 | + | let _ = std::fs::remove_dir_all(dir); | |
| 218 | + | if std::fs::create_dir_all(dir).is_err() || !self.git(dir, &["init", "--quiet"], None) || !self.git(dir, &["remote", "add", "origin", &url], None) { | |
| 219 | + | return None; | |
| 220 | + | } | |
| 221 | + | // A branch or tag at its tip; a commit may need the history. | |
| 222 | + | let shallow = self.fetch_retrying(dir, &["fetch", "--depth=1", "--no-tags", "--quiet", "origin", git_ref], auth.as_deref()); | |
| 223 | + | let checked_out = if shallow { | |
| 224 | + | self.git(dir, &["checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"], None) | |
| 225 | + | } else { | |
| 226 | + | self.fetch_retrying(dir, &["fetch", "--quiet", "--tags", "origin", "+refs/heads/*:refs/remotes/origin/*"], auth.as_deref()) | |
| 227 | + | && self.git(dir, &["checkout", "--quiet", "--force", "--detach", git_ref], None) | |
| 228 | + | }; | |
| 229 | + | if !checked_out { | |
| 230 | + | self.log.line(&format!("##[error]Could not fetch {owner}/{repo}@{git_ref} from g1t: is {git_ref} a branch, tag or commit there?")); | |
| 231 | + | let _ = std::fs::remove_dir_all(dir); | |
| 232 | + | return None; | |
| 233 | + | } | |
| 234 | + | let _ = std::fs::write(dir.join(".g1t-fetched"), ""); | |
| 235 | + | Some(dir.to_path_buf()) | |
| 236 | + | } | |
| 237 | + | ||
| 172 | 238 | /// Fetches an action from GitHub, once per job. | |
| 173 | 239 | fn fetch_action(&mut self, owner: &str, repo: &str, git_ref: &str) -> Option<PathBuf> { | |
| 174 | 240 | let dir = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref); | |
| ⋯ | |||
| 313 | 379 | }; | |
| 314 | 380 | let (dir, repository) = match &source { | |
| 315 | 381 | Source::Local(dir) => (dir.clone(), String::new()), | |
| 316 | − | Source::GitHub { owner, repo, path, git_ref } => match self.fetch_action(owner, repo, git_ref) { | |
| 382 | + | Source::GitHub { owner, repo, path, git_ref } => match self.fetch_remote_action(owner, repo, git_ref) { | |
| 317 | 383 | Some(root) => (if path.is_empty() { root } else { root.join(path) }, format!("{owner}/{repo}")), | |
| 318 | 384 | None => return (false, BTreeMap::new()), | |
| 319 | 385 | }, | |
| 743 | 743 | inside other actions), downloads from other repositories, and npm | |
| 744 | 744 | trusted publishing, which depends on npm accepting g1t's issuer. | |
| 745 | 745 | ||
| 746 | + | ### Actions parity: other repositories, triggers, step timeouts (built 2026-10-08) | |
| 747 | + | ||
| 748 | + | - **Other repositories' actions and reusable workflows** | |
| 749 | + | (`services/actions/src/reach.rs`, actions/0008): `uses: owner/repo@ref`, | |
| 750 | + | `owner/repo/path@ref` and `jobs.<id>.uses: owner/repo/.g1t|.github/workflows/x.yml@ref` | |
| 751 | + | are looked for on g1t first, as the calling workspace sees them. Same | |
| 752 | + | repository or public: used. Private: only from a private repository of | |
| 753 | + | the same workspace, when its **Settings → Actions → Access** (`access_level`, | |
| 754 | + | `GET`/`PUT …/actions/permissions/access`, MCP `get_access`/`set_access`) | |
| 755 | + | says `organization`; a job fetches such an action with a read-only token | |
| 756 | + | for that repository, revoked with the job (`job_action`, the runner's | |
| 757 | + | `POST /actions/jobs/{job}/action`). Not on g1t: actions from GitHub as | |
| 758 | + | before, reusable workflows from a public GitHub repository. A `./` call | |
| 759 | + | inside a called workflow reads from that workflow's own repository. | |
| 760 | + | - **Secrets for called workflows** follow GitHub: none but the job token | |
| 761 | + | unless the caller passes `secrets:` by name or `secrets: inherit`; | |
| 762 | + | required secrets are checked before the call; a called job's | |
| 763 | + | `environment:` reads that environment's secrets over what was passed. The | |
| 764 | + | mapping is kept with the called jobs and read when each starts, so no | |
| 765 | + | secret is stored. (Before, same-repository called workflows read every | |
| 766 | + | repository secret.) | |
| 767 | + | - **Triggers:** `release` (created, published, released, prereleased, | |
| 768 | + | edited, unpublished, deleted; repos now publishes `release.*`), | |
| 769 | + | `deployment` and `deployment_status` (from the deployments service's | |
| 770 | + | events; ones an Actions job or a job token made are marked | |
| 771 | + | `causedByJob` and start nothing), `pull_request` `reopened` and | |
| 772 | + | `converted_to_draft`, `issue_comment` `edited` and `deleted` (work | |
| 773 | + | publishes `pull.reopened`, `pull.converted_to_draft`, `comment.edited` | |
| 774 | + | and `comment.deleted`). | |
| 775 | + | - **`timeout-minutes` on every step**, `uses:` included: the runner keeps a | |
| 776 | + | step deadline every process it starts stops by, nested composite steps | |
| 777 | + | taking the nearer one. | |
| 778 | + | - **Not yet:** a deployment's `ref` that is neither a branch nor a commit | |
| 779 | + | is read as a tag; `on: delete`. | |
| 780 | + | ||
| 746 | 781 | ## A repository that maintains itself | |
| 747 | 782 | ||
| 748 | 783 | > **2026-10-04:** the user asked for Dependabot, GitHub Advanced Security and |
| 197 | 197 | canApprovePullRequests: boolean; | |
| 198 | 198 | /** Whether the workspace lets its repositories turn that on. */ | |
| 199 | 199 | workspaceAllowsPullRequests: boolean; | |
| 200 | + | /** | |
| 201 | + | * Who may use the repository's actions and reusable workflows while it is | |
| 202 | + | * private: only itself (`none`), or private repositories of its workspace | |
| 203 | + | * (`organization`). A public repository's are anyone's. | |
| 204 | + | */ | |
| 205 | + | accessLevel: ActionsAccessLevel; | |
| 200 | 206 | }; | |
| 201 | 207 | ||
| 208 | + | /** Settings, Actions, Access. */ | |
| 209 | + | export const ACTIONS_ACCESS_LEVELS = ["none", "organization"] as const; | |
| 210 | + | export type ActionsAccessLevel = (typeof ACTIONS_ACCESS_LEVELS)[number]; | |
| 211 | + | ||
| 202 | 212 | /** What changes a repository's choices; `inherit` unchooses its default. */ | |
| 203 | 213 | export type ActionsSettingsChange = { | |
| 204 | 214 | defaultPermissions?: "read" | "write" | "inherit"; | |
| 205 | 215 | approvalPolicy?: ApprovalPolicy; | |
| 206 | 216 | canApprovePullRequests?: boolean; | |
| 217 | + | accessLevel?: ActionsAccessLevel; | |
| 207 | 218 | }; | |
| 208 | 219 | ||
| 209 | 220 | /** A workspace's policy for its repositories' job tokens. */ |
| 1 | + | -- Who may use a private repository's actions and reusable workflows from | |
| 2 | + | -- their own workflows (Settings, Actions, Access): `none` (the default: | |
| 3 | + | -- only the repository itself) or `organization` (any private repository in | |
| 4 | + | -- the same workspace). Null is `none`. A public repository's are anyone's, | |
| 5 | + | -- whatever this says. See src/reach.rs. | |
| 6 | + | ALTER TABLE repo_settings ADD COLUMN access_level TEXT; |
| 27 | 27 | mod payload; | |
| 28 | 28 | mod plan; | |
| 29 | 29 | mod protection; | |
| 30 | + | mod reach; | |
| 30 | 31 | mod rename; | |
| 31 | 32 | pub mod runtime; | |
| 32 | 33 | mod runners; | |
| ⋯ | |||
| 237 | 238 | "resolve_settings" => reply(&service.resolve_settings(args(body)?).await?), | |
| 238 | 239 | "job_spec" => reply(&service.job_spec(args(body)?).await?), | |
| 239 | 240 | "job_auth" => reply(&service.job_auth(args(body)?).await?), | |
| 241 | + | "job_action" => reply(&service.job_action(args(body)?).await?), | |
| 240 | 242 | "check_runs" => reply(&service.check_runs(args(body)?).await?), | |
| 241 | 243 | "job_report" => reply(&service.job_report(args(body)?).await?), | |
| 242 | 244 | // actions/cache, through the API with the job's token. | |
| 1010 | 1010 | Ok(()) | |
| 1011 | 1011 | } | |
| 1012 | 1012 | ||
| 1013 | − | /// A job that calls a reusable workflow in the repository: that | |
| 1014 | − | /// workflow's jobs join the run under it, with the inputs it passes. | |
| 1013 | + | /// A job that calls a reusable workflow, in the repository or another | |
| 1014 | + | /// (reach.rs): that workflow's jobs join the run under it, with the | |
| 1015 | + | /// inputs and secrets it passes. | |
| 1015 | 1016 | async fn call_workflow(&self, run: &RunRow, job: &workflow::Job, row: &JobRow, uses: &str, scope: &Scope<'_>) -> Result<()> { | |
| 1016 | − | let Some(local) = uses.strip_prefix("./") else { | |
| 1017 | − | return self | |
| 1018 | − | .fail_job(row, "Reusable workflows from other repositories are not called on g1t yet; ones in this repository (`./.g1t/workflows/…`) are.") | |
| 1019 | − | .await; | |
| 1020 | − | }; | |
| 1021 | 1017 | let depth = row.call().and_then(|c| c["depth"].as_u64()).unwrap_or(0) + 1; | |
| 1022 | 1018 | if depth > MAX_CALL_DEPTH { | |
| 1023 | 1019 | return self.fail_job(row, &format!("Reusable workflows call each other more than {MAX_CALL_DEPTH} deep.")).await; | |
| 1024 | 1020 | } | |
| 1025 | − | let local = local.split('@').next().unwrap_or(local).to_owned(); | |
| 1026 | − | let path = repo_path(&run.repo); | |
| 1027 | − | let Some(ws) = self.workspace_actor(&path.namespace).await? else { | |
| 1028 | − | return self.fail_job(row, "The workspace is gone.").await; | |
| 1029 | − | }; | |
| 1030 | − | // A repository moved from GitHub keeps saying `.github/…`. | |
| 1031 | − | let mut found = self.read_file(&path, &ws, &run.sha, &local).await?.map(|text| (local.clone(), text)); | |
| 1032 | − | if found.is_none() | |
| 1033 | − | && let Some(rest) = local.strip_prefix(".github/") | |
| 1034 | − | { | |
| 1035 | − | let moved = format!(".g1t/{rest}"); | |
| 1036 | − | found = self.read_file(&path, &ws, &run.sha, &moved).await?.map(|text| (moved, text)); | |
| 1037 | − | } | |
| 1038 | − | let Some((file, source)) = found else { | |
| 1039 | − | return self.fail_job(row, &format!("`{uses}` is not in the repository at this commit.")).await; | |
| 1021 | + | let (file, source, origin) = match self.called_workflow(run, row, uses).await? { | |
| 1022 | + | Ok(found) => found, | |
| 1023 | + | Err(why) => return self.fail_job(row, &why).await, | |
| 1040 | 1024 | }; | |
| 1041 | 1025 | let called = match workflow::parse(&source) { | |
| 1042 | 1026 | Ok(called) => called, | |
| ⋯ | |||
| 1065 | 1049 | for (name, value) in given { | |
| 1066 | 1050 | inputs.entry(name).or_insert(value); | |
| 1067 | 1051 | } | |
| 1052 | + | // Secrets: none but the job's token unless `secrets:` passes them, | |
| 1053 | + | // by name or with `inherit`; read when each job starts (job_spec). | |
| 1054 | + | let outer = row.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned()); | |
| 1055 | + | let secrets = crate::reach::secrets_plan(&job.raw, scope.contexts, outer); | |
| 1056 | + | if let Some(name) = crate::reach::missing_secrets(&called.raw, &secrets).first() { | |
| 1057 | + | return self.fail_job(row, &format!("`{file}` needs the secret `{name}`: pass it under `secrets:`, or use `secrets: inherit`.")).await; | |
| 1058 | + | } | |
| 1068 | 1059 | let mut statements = Vec::new(); | |
| 1069 | 1060 | for called_job in &called.jobs { | |
| 1070 | 1061 | let needs: Vec<String> = called_job.needs.iter().map(|n| format!("{}/{n}", row.key)).collect(); | |
| 1071 | 1062 | let call = json!({ | |
| 1072 | 1063 | "role": "callee", "parent": row.key, "job": called_job.id, "path": file, | |
| 1073 | − | "source": source, "inputs": inputs, "depth": depth, | |
| 1064 | + | "source": source, "inputs": inputs, "depth": depth, "origin": origin, "secrets": secrets, | |
| 1074 | 1065 | }); | |
| 1075 | 1066 | statements.push( | |
| 1076 | 1067 | self.db | |
| ⋯ | |||
| 1148 | 1139 | } | |
| 1149 | 1140 | ||
| 1150 | 1141 | /// A file's text at a commit, if it is there. | |
| 1151 | − | async fn read_file(&self, path: &RepoPath, ws: &g1t_contracts::User, sha: &str, file: &str) -> Result<Option<String>> { | |
| 1142 | + | pub(crate) async fn read_file(&self, path: &RepoPath, ws: &g1t_contracts::User, sha: &str, file: &str) -> Result<Option<String>> { | |
| 1152 | 1143 | let blob: Outcome<g1t_contracts::repos::BlobView> = g1t_kit::call( | |
| 1153 | 1144 | &self.repos, | |
| 1154 | 1145 | "blob", | |
| ⋯ | |||
| 1816 | 1807 | }; | |
| 1817 | 1808 | // A run that is not trusted (a pull request from outside the | |
| 1818 | 1809 | // workspace) gets no secrets and an empty token. | |
| 1819 | − | let mut secrets = if trusted { | |
| 1820 | − | self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await? | |
| 1821 | − | } else { | |
| 1822 | − | Map::new() | |
| 1810 | + | let passed = job.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned()); | |
| 1811 | + | let mut secrets = match (trusted, passed) { | |
| 1812 | + | (false, _) => Map::new(), | |
| 1813 | + | (true, None) => self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?, | |
| 1814 | + | // A called workflow's job: what its callers passed it (reach.rs), | |
| 1815 | + | // and its own environment's secrets over them. | |
| 1816 | + | (true, Some(plan)) => { | |
| 1817 | + | let base = self.secrets_for(&run.repo_id, &run.repo, None, true).await?; | |
| 1818 | + | let vars = self.variables_for(&run.repo_id, &run.repo, None, true).await?; | |
| 1819 | + | let github = run.info().context(&job.key, "", run.action.as_deref()); | |
| 1820 | + | let mut passed = crate::reach::resolve_secrets(&plan, &base, &github, &vars); | |
| 1821 | + | if let Some(name) = environment.as_deref() { | |
| 1822 | + | let own = self.secrets_for(&run.repo_id, &run.repo, Some(name), true).await?; | |
| 1823 | + | for (key, value) in own { | |
| 1824 | + | if base.get(&key) != Some(&value) { | |
| 1825 | + | passed.insert(key, value); | |
| 1826 | + | } | |
| 1827 | + | } | |
| 1828 | + | } | |
| 1829 | + | passed | |
| 1830 | + | } | |
| 1823 | 1831 | }; | |
| 1824 | 1832 | secrets.insert("G1T_TOKEN".into(), Value::String(token.clone())); | |
| 1825 | 1833 | secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone())); | |
| 48 | 48 | default_permissions: Option<String>, | |
| 49 | 49 | approval_policy: Option<String>, | |
| 50 | 50 | can_approve_pulls: Option<u32>, | |
| 51 | + | /// Migration 0008: who may use its actions and reusable workflows. | |
| 52 | + | #[serde(default)] | |
| 53 | + | access_level: Option<String>, | |
| 51 | 54 | } | |
| 52 | 55 | ||
| 53 | 56 | #[derive(Deserialize)] | |
| ⋯ | |||
| 259 | 262 | async fn repo_choices(&self, repo_id: &str) -> Result<SettingsRow> { | |
| 260 | 263 | Ok(self | |
| 261 | 264 | .db | |
| 262 | − | .prepare("SELECT default_permissions, approval_policy, can_approve_pulls FROM repo_settings WHERE repo_id = ?") | |
| 265 | + | .prepare("SELECT default_permissions, approval_policy, can_approve_pulls, access_level FROM repo_settings WHERE repo_id = ?") | |
| 263 | 266 | .bind(&[repo_id.into()])? | |
| 264 | 267 | .first::<SettingsRow>(None) | |
| 265 | 268 | .await? | |
| ⋯ | |||
| 310 | 313 | approval_policy: row.approval_policy.unwrap_or_else(|| DEFAULT_APPROVAL_POLICY.to_owned()), | |
| 311 | 314 | can_approve_pull_requests: workspace.can_approve_pull_requests && row.can_approve_pulls == Some(1), | |
| 312 | 315 | workspace_allows_pull_requests: workspace.can_approve_pull_requests, | |
| 316 | + | access_level: row.access_level.as_deref().and_then(g1t_contracts::actions::access_level).unwrap_or("none").to_owned(), | |
| 313 | 317 | }) | |
| 314 | 318 | } | |
| 315 | 319 | ||
| ⋯ | |||
| 328 | 332 | Ok(self.repo_choices(repo_id).await?.approval_policy.unwrap_or_else(|| DEFAULT_APPROVAL_POLICY.to_owned())) | |
| 329 | 333 | } | |
| 330 | 334 | ||
| 335 | + | /// Who may use a repository's actions and reusable workflows when it is | |
| 336 | + | /// private: `none` or `organization`. | |
| 337 | + | pub(crate) async fn access_level_of(&self, repo_id: &str) -> Result<&'static str> { | |
| 338 | + | Ok(self.repo_choices(repo_id).await?.access_level.as_deref().and_then(g1t_contracts::actions::access_level).unwrap_or("none")) | |
| 339 | + | } | |
| 340 | + | ||
| 331 | 341 | pub async fn actions_settings(&self, a: ActionsSettingsArgs) -> Result<Outcome<ActionsSettings>> { | |
| 332 | 342 | let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else { | |
| 333 | 343 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); | |
| ⋯ | |||
| 373 | 383 | } | |
| 374 | 384 | row.can_approve_pulls = Some(u32::from(allow)); | |
| 375 | 385 | } | |
| 386 | + | if let Some(level) = &a.access_level { | |
| 387 | + | match g1t_contracts::actions::access_level(level) { | |
| 388 | + | Some(level) => row.access_level = Some(level.to_owned()), | |
| 389 | + | None => return Ok(fail(FailureCode::Invalid, "access_level is none or organization.")), | |
| 390 | + | } | |
| 391 | + | } | |
| 376 | 392 | // 0006's artifact retention is kept, or its default for a new row. | |
| 377 | 393 | self.db | |
| 378 | 394 | .prepare( | |
| 379 | − | "INSERT INTO repo_settings (repo_id, artifact_retention_days, default_permissions, approval_policy, can_approve_pulls, updated_at, updated_by) | |
| 380 | − | VALUES (?1, ?7, ?2, ?3, ?4, ?5, ?6) | |
| 395 | + | "INSERT INTO repo_settings (repo_id, artifact_retention_days, default_permissions, approval_policy, can_approve_pulls, access_level, updated_at, updated_by) | |
| 396 | + | VALUES (?1, ?7, ?2, ?3, ?4, ?8, ?5, ?6) | |
| 381 | 397 | ON CONFLICT (repo_id) DO UPDATE SET default_permissions = ?2, approval_policy = ?3, can_approve_pulls = ?4, | |
| 382 | − | updated_at = ?5, updated_by = ?6", | |
| 398 | + | access_level = ?8, updated_at = ?5, updated_by = ?6", | |
| 383 | 399 | ) | |
| 384 | 400 | .bind(&[ | |
| 385 | 401 | repo.id.as_str().into(), | |
| ⋯ | |||
| 389 | 405 | now().into(), | |
| 390 | 406 | a.actor.username.as_str().into(), | |
| 391 | 407 | g1t_contracts::actions::ARTIFACT_RETENTION_DEFAULT_DAYS.into(), | |
| 408 | + | optional(row.access_level.as_deref()), | |
| 392 | 409 | ])? | |
| 393 | 410 | .run() | |
| 394 | 411 | .await?; | |
| 1 | + | //! Using another repository's actions and reusable workflows: | |
| 2 | + | //! `uses: owner/repo@ref`, `owner/repo/path@ref` and | |
| 3 | + | //! `jobs.<id>.uses: owner/repo/.g1t/workflows/build.yml@ref`. | |
| 4 | + | //! | |
| 5 | + | //! The repository is looked for on g1t first, as the calling repository's | |
| 6 | + | //! workspace sees it. When g1t has it, the calling repository may use it if | |
| 7 | + | //! it is the same repository, if it is public, or if it is private, in the | |
| 8 | + | //! same workspace, allows it (Settings, Actions, Access: `organization`) | |
| 9 | + | //! and the caller is private too (a public repository's logs would show a | |
| 10 | + | //! private one's code). When g1t does not have it (or the workspace cannot | |
| 11 | + | //! see it), the action comes from GitHub, as before, and the reusable | |
| 12 | + | //! workflow from a public repository there. | |
| 13 | + | ||
| 14 | + | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 15 | + | use g1t_contracts::{FailureCode, Outcome, User}; | |
| 16 | + | use serde_json::{Value, json}; | |
| 17 | + | use worker::Result; | |
| 18 | + | ||
| 19 | + | use crate::{Actions, SITE, fail}; | |
| 20 | + | ||
| 21 | + | /// What `uses:` names in another repository. | |
| 22 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 23 | + | pub(crate) struct UsesRef { | |
| 24 | + | pub(crate) owner: String, | |
| 25 | + | pub(crate) repo: String, | |
| 26 | + | /// Inside the repository: an action's folder, or a workflow's file. | |
| 27 | + | /// Empty for an action at its root. | |
| 28 | + | pub(crate) path: String, | |
| 29 | + | pub(crate) git_ref: String, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl UsesRef { | |
| 33 | + | pub(crate) fn full_name(&self) -> String { | |
| 34 | + | format!("{}/{}", self.owner, self.repo) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// `owner/repo[/path]@ref`, if `uses` is that. Local (`./…`) and | |
| 39 | + | /// `docker://` ones are not. | |
| 40 | + | pub(crate) fn parse_uses(uses: &str) -> Option<UsesRef> { | |
| 41 | + | let uses = uses.trim(); | |
| 42 | + | if uses.starts_with("./") || uses.starts_with("docker://") { | |
| 43 | + | return None; | |
| 44 | + | } | |
| 45 | + | let (name, git_ref) = uses.split_once('@')?; | |
| 46 | + | let mut parts = name.splitn(3, '/'); | |
| 47 | + | let (owner, repo) = (parts.next()?, parts.next()?); | |
| 48 | + | let path = parts.next().unwrap_or_default().trim_matches('/'); | |
| 49 | + | let fine = |part: &str| !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && part != ".."; | |
| 50 | + | if !fine(owner) || !fine(repo) || git_ref.trim().is_empty() || path.split('/').any(|part| part == "..") { | |
| 51 | + | return None; | |
| 52 | + | } | |
| 53 | + | Some(UsesRef { owner: owner.to_owned(), repo: repo.to_owned(), path: path.to_owned(), git_ref: git_ref.trim().to_owned() }) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Whether `caller`'s workflows may use `target`'s actions and reusable | |
| 57 | + | /// workflows, given `target`'s access level (`none` or `organization`). | |
| 58 | + | /// Err says why not. | |
| 59 | + | pub(crate) fn may_use(caller: &Repo, target: &Repo, access_level: &str) -> std::result::Result<(), String> { | |
| 60 | + | if caller.id == target.id || !target.is_private { | |
| 61 | + | return Ok(()); | |
| 62 | + | } | |
| 63 | + | let name = format!("{}/{}", target.namespace, target.name); | |
| 64 | + | if !caller.namespace.eq_ignore_ascii_case(&target.namespace) { | |
| 65 | + | return Err(format!("{name} is private, and only repositories in {} may use its actions and workflows.", target.namespace)); | |
| 66 | + | } | |
| 67 | + | if access_level != "organization" { | |
| 68 | + | return Err(format!( | |
| 69 | + | "{name} is private and does not let other repositories use its actions and workflows. An admin of {name} can allow it under Settings, Actions, Access." | |
| 70 | + | )); | |
| 71 | + | } | |
| 72 | + | if !caller.is_private { | |
| 73 | + | return Err(format!("{name} is private, and a public repository's workflows cannot use a private repository's actions or workflows.")); | |
| 74 | + | } | |
| 75 | + | Ok(()) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Where another repository's action or workflow comes from. | |
| 79 | + | pub(crate) enum Found { | |
| 80 | + | /// g1t has it, and the caller may use it. | |
| 81 | + | G1t(Repo), | |
| 82 | + | /// g1t does not have it (that the caller's workspace can see): GitHub. | |
| 83 | + | GitHub, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | impl Actions { | |
| 87 | + | /// Looks for `owner/repo` on g1t, as `caller`'s workspace (`ws`) sees | |
| 88 | + | /// it, and checks the caller may use it. | |
| 89 | + | pub(crate) async fn find_used(&self, caller: &Repo, ws: &User, used: &UsesRef) -> Result<Outcome<Found>> { | |
| 90 | + | let path = RepoPath { namespace: used.owner.clone(), name: used.repo.clone() }; | |
| 91 | + | let Some(target) = self.visible_repo(&path, &Some(ws.clone())).await? else { | |
| 92 | + | return Ok(Outcome::Ok(Found::GitHub)); | |
| 93 | + | }; | |
| 94 | + | let level = if target.is_private && target.id != caller.id { self.access_level_of(&target.id).await? } else { "none" }; | |
| 95 | + | Ok(match may_use(caller, &target, level) { | |
| 96 | + | Ok(()) => Outcome::Ok(Found::G1t(target)), | |
| 97 | + | Err(why) => fail(FailureCode::Forbidden, why), | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// `job_action`: a running job asks where to fetch an action from, by | |
| 102 | + | /// `report.repository` (`owner/repo`) and `report.ref`. Answers | |
| 103 | + | /// `{"source": "g1t", "url", "ref", "token"}` (a read-only token for | |
| 104 | + | /// that repository, ending with the job, when it is private), | |
| 105 | + | /// `{"source": "github"}`, or a refusal saying why it may not be used. | |
| 106 | + | pub async fn job_action(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<Outcome<Value>> { | |
| 107 | + | let job = crate::check!(self.job_for_token(&a).await?); | |
| 108 | + | let Some(run) = self.run_row(&job.run_id).await? else { | |
| 109 | + | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 110 | + | }; | |
| 111 | + | let repository = a.report["repository"].as_str().unwrap_or_default(); | |
| 112 | + | let git_ref = a.report["ref"].as_str().unwrap_or_default(); | |
| 113 | + | let Some(used) = parse_uses(&format!("{repository}@{git_ref}")) else { | |
| 114 | + | return Ok(fail(FailureCode::Invalid, "Name the action's repository as owner/repo, and its ref.")); | |
| 115 | + | }; | |
| 116 | + | let Some((caller, ws)) = self.repo_by_id(&run.repo_id).await? else { | |
| 117 | + | return Ok(fail(FailureCode::NotFound, "The repository is gone.")); | |
| 118 | + | }; | |
| 119 | + | let target = match crate::check!(self.find_used(&caller, &ws, &used).await?) { | |
| 120 | + | Found::GitHub => return Ok(Outcome::Ok(json!({ "source": "github" }))), | |
| 121 | + | Found::G1t(target) => target, | |
| 122 | + | }; | |
| 123 | + | let full_name = format!("{}/{}", target.namespace, target.name); | |
| 124 | + | // A private repository is read with a token of its own: read-only, | |
| 125 | + | // for that repository alone, ending with the job. | |
| 126 | + | let token = if target.is_private { | |
| 127 | + | let created: g1t_contracts::identity::CreatedAccessToken = g1t_kit::call( | |
| 128 | + | &self.identity, | |
| 129 | + | "create_job_token", | |
| 130 | + | &g1t_contracts::identity::CreateJobTokenArgs { | |
| 131 | + | workspace: ws.clone(), | |
| 132 | + | repo: RepoPath { namespace: target.namespace.clone(), name: target.name.clone() }, | |
| 133 | + | run_id: run.id.clone(), | |
| 134 | + | job_id: job.id.clone(), | |
| 135 | + | name: format!("Action {full_name} for {} run {}", run.repo, run.number), | |
| 136 | + | ttl_seconds: u64::from(job.timeout_minutes) * 60 + 600, | |
| 137 | + | scopes: vec!["repo:read".to_owned(), "code:read".to_owned()], | |
| 138 | + | pull_requests: false, | |
| 139 | + | }, | |
| 140 | + | ) | |
| 141 | + | .await?; | |
| 142 | + | Value::String(created.token) | |
| 143 | + | } else { | |
| 144 | + | Value::Null | |
| 145 | + | }; | |
| 146 | + | Ok(Outcome::Ok(json!({ | |
| 147 | + | "source": "g1t", | |
| 148 | + | "repository": full_name, | |
| 149 | + | "url": format!("{SITE}/{full_name}.git"), | |
| 150 | + | "ref": used.git_ref, | |
| 151 | + | "token": token, | |
| 152 | + | }))) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Whether `path` is a workflow file: `.g1t/workflows/…` or | |
| 157 | + | /// `.github/workflows/…`, ending `.yml` or `.yaml`. | |
| 158 | + | pub(crate) fn is_workflow_path(path: &str) -> bool { | |
| 159 | + | (path.starts_with(".g1t/workflows/") || path.starts_with(".github/workflows/")) && (path.ends_with(".yml") || path.ends_with(".yaml")) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// The paths to try for a workflow file: as written, and for `.github/…` | |
| 163 | + | /// also `.g1t/…`, where a repository moved to g1t keeps it. | |
| 164 | + | fn candidates(path: &str) -> Vec<String> { | |
| 165 | + | let mut paths = vec![path.to_owned()]; | |
| 166 | + | if let Some(rest) = path.strip_prefix(".github/") { | |
| 167 | + | paths.push(format!(".g1t/{rest}")); | |
| 168 | + | } | |
| 169 | + | paths | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// What a job's `secrets:` passes to the workflow it calls, kept with the | |
| 173 | + | /// called jobs until they start, when it is read with the secrets | |
| 174 | + | /// themselves (`resolve_secrets`); no secret is stored. `inherit` passes | |
| 175 | + | /// all of the caller's; a mapping passes each name's expression, read with | |
| 176 | + | /// the caller's `secrets` and the contexts it had (`needs`, `inputs`, | |
| 177 | + | /// `matrix`); nothing passes none. `outer` is the caller's own, when the | |
| 178 | + | /// caller is itself a called workflow's job. | |
| 179 | + | pub(crate) fn secrets_plan(job_raw: &Value, contexts: &serde_json::Map<String, Value>, outer: Option<Value>) -> Value { | |
| 180 | + | let mut plan = match job_raw.get("secrets") { | |
| 181 | + | Some(Value::String(text)) if text.trim() == "inherit" => json!({ "inherit": true }), | |
| 182 | + | Some(Value::Object(map)) => json!({ | |
| 183 | + | "map": map, | |
| 184 | + | "scope": { | |
| 185 | + | "needs": contexts.get("needs").cloned().unwrap_or_else(|| json!({})), | |
| 186 | + | "inputs": contexts.get("inputs").cloned().unwrap_or_else(|| json!({})), | |
| 187 | + | "matrix": contexts.get("matrix").cloned().unwrap_or_else(|| json!({})), | |
| 188 | + | }, | |
| 189 | + | }), | |
| 190 | + | _ => json!({ "map": {} }), | |
| 191 | + | }; | |
| 192 | + | if let Some(outer) = outer.filter(Value::is_object) { | |
| 193 | + | plan["outer"] = outer; | |
| 194 | + | } | |
| 195 | + | plan | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// The secrets a called workflow says are required | |
| 199 | + | /// (`on.workflow_call.secrets.<name>.required`) that `plan` does not pass. | |
| 200 | + | /// `inherit` passes whatever the caller has, so it is not checked here. | |
| 201 | + | pub(crate) fn missing_secrets(called_raw: &Value, plan: &Value) -> Vec<String> { | |
| 202 | + | if plan["inherit"] == json!(true) { | |
| 203 | + | return Vec::new(); | |
| 204 | + | } | |
| 205 | + | let on = called_raw.get("on").or_else(|| called_raw.get("true")).cloned().unwrap_or(Value::Null); | |
| 206 | + | let Some(Value::Object(declared)) = on.get("workflow_call").and_then(|call| call.get("secrets")).cloned() else { | |
| 207 | + | return Vec::new(); | |
| 208 | + | }; | |
| 209 | + | let passed = plan["map"].as_object().cloned().unwrap_or_default(); | |
| 210 | + | declared | |
| 211 | + | .iter() | |
| 212 | + | .filter(|(_, spec)| spec.get("required").and_then(Value::as_bool) == Some(true)) | |
| 213 | + | .filter(|(name, _)| !passed.keys().any(|key| key.eq_ignore_ascii_case(name))) | |
| 214 | + | .map(|(name, _)| name.clone()) | |
| 215 | + | .collect() | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// The `secrets` a called workflow's job gets, by `plan` (`secrets_plan`), | |
| 219 | + | /// from `base` (the repository's secrets, as the top caller has them), | |
| 220 | + | /// with `github` and `vars` for the expressions. The job's token is added | |
| 221 | + | /// by the caller of this, as every job's is. | |
| 222 | + | pub(crate) fn resolve_secrets( | |
| 223 | + | plan: &Value, | |
| 224 | + | base: &serde_json::Map<String, Value>, | |
| 225 | + | github: &Value, | |
| 226 | + | vars: &serde_json::Map<String, Value>, | |
| 227 | + | ) -> serde_json::Map<String, Value> { | |
| 228 | + | let outer = match plan.get("outer").filter(|outer| outer.is_object()) { | |
| 229 | + | Some(outer) => resolve_secrets(outer, base, github, vars), | |
| 230 | + | None => base.clone(), | |
| 231 | + | }; | |
| 232 | + | if plan["inherit"] == json!(true) { | |
| 233 | + | return outer; | |
| 234 | + | } | |
| 235 | + | let mut contexts = serde_json::Map::new(); | |
| 236 | + | if let Some(Value::Object(scope)) = plan.get("scope") { | |
| 237 | + | contexts.extend(scope.clone()); | |
| 238 | + | } | |
| 239 | + | contexts.insert("secrets".into(), Value::Object(outer)); | |
| 240 | + | contexts.insert("github".into(), github.clone()); | |
| 241 | + | contexts.insert("vars".into(), Value::Object(vars.clone())); | |
| 242 | + | let scope = g1t_actions::expr::Scope { contexts: &contexts, status: g1t_actions::expr::Status::Success, hash_files: None }; | |
| 243 | + | let mut passed = serde_json::Map::new(); | |
| 244 | + | for (name, expression) in plan["map"].as_object().into_iter().flatten() { | |
| 245 | + | let value = g1t_actions::expr::interpolate_value(expression, &scope).unwrap_or(Value::Null); | |
| 246 | + | let text = g1t_actions::expr::to_text(&value); | |
| 247 | + | if !text.is_empty() { | |
| 248 | + | passed.insert(name.clone(), Value::String(text)); | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | passed | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// A public repository's file on GitHub, if it is there. | |
| 255 | + | async fn github_file(repository: &str, git_ref: &str, path: &str) -> Option<String> { | |
| 256 | + | let url = format!("https://raw.githubusercontent.com/{repository}/{git_ref}/{path}"); | |
| 257 | + | let headers = worker::Headers::new(); | |
| 258 | + | headers.set("user-agent", "g1t-actions").ok()?; | |
| 259 | + | let mut init = worker::RequestInit::new(); | |
| 260 | + | init.with_method(worker::Method::Get).with_headers(headers); | |
| 261 | + | let request = worker::Request::new_with_init(&url, &init).ok()?; | |
| 262 | + | let mut response = worker::Fetch::Request(request).send().await.ok()?; | |
| 263 | + | if response.status_code() != 200 { | |
| 264 | + | return None; | |
| 265 | + | } | |
| 266 | + | response.text().await.ok() | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | impl Actions { | |
| 270 | + | /// The workflow file a job's `uses:` calls, its text, and where it | |
| 271 | + | /// came from (`origin`, kept with its jobs so a `./` call inside it | |
| 272 | + | /// reads from the same place): `{"source": "g1t" | "github", "repo", | |
| 273 | + | /// "ref"}`. Err says why it cannot be called. | |
| 274 | + | pub(crate) async fn called_workflow( | |
| 275 | + | &self, | |
| 276 | + | run: &crate::plan::RunRow, | |
| 277 | + | row: &crate::plan::JobRow, | |
| 278 | + | uses: &str, | |
| 279 | + | ) -> Result<std::result::Result<(String, String, Value), String>> { | |
| 280 | + | let Some(ws) = self.workspace_actor(&crate::repo_path(&run.repo).namespace).await? else { | |
| 281 | + | return Ok(Err("The workspace is gone.".to_owned())); | |
| 282 | + | }; | |
| 283 | + | let (origin, file) = match parse_uses(uses) { | |
| 284 | + | None => { | |
| 285 | + | let Some(local) = uses.trim().strip_prefix("./") else { | |
| 286 | + | return Ok(Err(format!("`{uses}` is not a workflow: name one as ./.g1t/workflows/build.yml or owner/repo/.g1t/workflows/build.yml@ref."))); | |
| 287 | + | }; | |
| 288 | + | // In the same repository and commit as the workflow the | |
| 289 | + | // calling job is in: the run's, or the called workflow's. | |
| 290 | + | let origin = row | |
| 291 | + | .call() | |
| 292 | + | .filter(|call| call["role"] == "callee") | |
| 293 | + | .and_then(|call| call.get("origin").cloned()) | |
| 294 | + | .filter(Value::is_object) | |
| 295 | + | .unwrap_or_else(|| json!({ "source": "g1t", "repo": run.repo, "ref": run.sha })); | |
| 296 | + | (origin, local.split('@').next().unwrap_or(local).to_owned()) | |
| 297 | + | } | |
| 298 | + | Some(used) => { | |
| 299 | + | if !is_workflow_path(&used.path) { | |
| 300 | + | return Ok(Err(format!("`{uses}` is not a workflow file: it is under .g1t/workflows/ or .github/workflows/ and ends .yml or .yaml."))); | |
| 301 | + | } | |
| 302 | + | let Some((caller, _)) = self.repo_by_id(&run.repo_id).await? else { | |
| 303 | + | return Ok(Err("The repository is gone.".to_owned())); | |
| 304 | + | }; | |
| 305 | + | let origin = match self.find_used(&caller, &ws, &used).await? { | |
| 306 | + | Outcome::Fail(refused) => return Ok(Err(refused.message)), | |
| 307 | + | Outcome::Ok(Found::G1t(target)) => { | |
| 308 | + | json!({ "source": "g1t", "repo": format!("{}/{}", target.namespace, target.name), "ref": used.git_ref }) | |
| 309 | + | } | |
| 310 | + | Outcome::Ok(Found::GitHub) => json!({ "source": "github", "repo": used.full_name(), "ref": used.git_ref }), | |
| 311 | + | }; | |
| 312 | + | (origin, used.path) | |
| 313 | + | } | |
| 314 | + | }; | |
| 315 | + | let repository = origin["repo"].as_str().unwrap_or_default().to_owned(); | |
| 316 | + | let git_ref = origin["ref"].as_str().unwrap_or_default().to_owned(); | |
| 317 | + | let on_github = origin["source"] == "github"; | |
| 318 | + | for path in candidates(&file) { | |
| 319 | + | let text = if on_github { | |
| 320 | + | github_file(&repository, &git_ref, &path).await | |
| 321 | + | } else { | |
| 322 | + | self.read_file(&crate::repo_path(&repository), &ws, &git_ref, &path).await? | |
| 323 | + | }; | |
| 324 | + | if let Some(text) = text { | |
| 325 | + | // Shown as the repository names it, when it is another's. | |
| 326 | + | let shown = if repository.eq_ignore_ascii_case(&run.repo) { path } else { format!("{repository}/{path}@{git_ref}") }; | |
| 327 | + | return Ok(Ok((shown, text, origin))); | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | Ok(Err(if repository.eq_ignore_ascii_case(&run.repo) { | |
| 331 | + | format!("`{uses}` is not in the repository at this commit.") | |
| 332 | + | } else if on_github { | |
| 333 | + | format!("`{uses}` was found neither on g1t nor in a public repository on GitHub.") | |
| 334 | + | } else { | |
| 335 | + | format!("`{uses}`: {repository} has no {file} at {git_ref}.") | |
| 336 | + | })) | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[cfg(test)] | |
| 341 | + | mod tests { | |
| 342 | + | use super::*; | |
| 343 | + | ||
| 344 | + | #[test] | |
| 345 | + | fn a_called_workflow_gets_only_the_secrets_passed_to_it() { | |
| 346 | + | let base: serde_json::Map<String, Value> = | |
| 347 | + | serde_json::from_value(json!({ "NPM_TOKEN": "npm-1", "DEPLOY_KEY": "key-2", "OTHER": "x" })).unwrap(); | |
| 348 | + | let github = json!({ "ref": "refs/heads/main" }); | |
| 349 | + | let vars = serde_json::Map::new(); | |
| 350 | + | let contexts: serde_json::Map<String, Value> = serde_json::from_value(json!({ "needs": { "build": { "outputs": { "target": "prod" } } } })).unwrap(); | |
| 351 | + | // Nothing passed: nothing but the job's token, which is added later. | |
| 352 | + | let none = secrets_plan(&json!({ "uses": "acme/shared/.g1t/workflows/x.yml@v1" }), &contexts, None); | |
| 353 | + | assert!(resolve_secrets(&none, &base, &github, &vars).is_empty()); | |
| 354 | + | // inherit: all of them. | |
| 355 | + | let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, None); | |
| 356 | + | assert_eq!(resolve_secrets(&inherit, &base, &github, &vars), base); | |
| 357 | + | // A mapping: each name's expression, read with the caller's secrets | |
| 358 | + | // and contexts. | |
| 359 | + | let mapped = secrets_plan( | |
| 360 | + | &json!({ "secrets": { "token": "${{ secrets.NPM_TOKEN }}", "where": "${{ needs.build.outputs.target }}-${{ secrets.DEPLOY_KEY }}" } }), | |
| 361 | + | &contexts, | |
| 362 | + | None, | |
| 363 | + | ); | |
| 364 | + | let passed = resolve_secrets(&mapped, &base, &github, &vars); | |
| 365 | + | assert_eq!(passed.len(), 2); | |
| 366 | + | assert_eq!(passed["token"], "npm-1"); | |
| 367 | + | assert_eq!(passed["where"], "prod-key-2"); | |
| 368 | + | // Nested: the inner call reads what the outer one was given. | |
| 369 | + | let inner = secrets_plan(&json!({ "secrets": { "NPM": "${{ secrets.token }}", "LEAK": "${{ secrets.OTHER }}" } }), &contexts, Some(mapped.clone())); | |
| 370 | + | let passed = resolve_secrets(&inner, &base, &github, &vars); | |
| 371 | + | assert_eq!(passed.get("NPM"), Some(&json!("npm-1"))); | |
| 372 | + | assert_eq!(passed.get("LEAK"), None); | |
| 373 | + | let inherited = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, Some(mapped)); | |
| 374 | + | assert_eq!(resolve_secrets(&inherited, &base, &github, &vars).len(), 2); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | #[test] | |
| 378 | + | fn required_secrets_must_be_passed() { | |
| 379 | + | let called = g1t_actions::workflow::parse( | |
| 380 | + | "on:\n workflow_call:\n secrets:\n token: { required: true }\n extra: { required: false }\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]", | |
| 381 | + | ) | |
| 382 | + | .unwrap() | |
| 383 | + | .raw; | |
| 384 | + | let none = secrets_plan(&json!({}), &serde_json::Map::new(), None); | |
| 385 | + | assert_eq!(missing_secrets(&called, &none), ["token"]); | |
| 386 | + | let passed = secrets_plan(&json!({ "secrets": { "TOKEN": "${{ secrets.X }}" } }), &serde_json::Map::new(), None); | |
| 387 | + | assert!(missing_secrets(&called, &passed).is_empty()); | |
| 388 | + | let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &serde_json::Map::new(), None); | |
| 389 | + | assert!(missing_secrets(&called, &inherit).is_empty()); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | #[test] | |
| 393 | + | fn only_workflow_files_are_called() { | |
| 394 | + | assert!(is_workflow_path(".g1t/workflows/build.yml")); | |
| 395 | + | assert!(is_workflow_path(".github/workflows/build.yaml")); | |
| 396 | + | assert!(!is_workflow_path("actions/setup/action.yml")); | |
| 397 | + | assert!(!is_workflow_path(".github/workflows/notes.md")); | |
| 398 | + | assert_eq!(candidates(".github/workflows/x.yml"), [".github/workflows/x.yml", ".g1t/workflows/x.yml"]); | |
| 399 | + | assert_eq!(candidates(".g1t/workflows/x.yml"), [".g1t/workflows/x.yml"]); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | fn repo(id: &str, namespace: &str, private: bool) -> Repo { | |
| 403 | + | serde_json::from_value(json!({ | |
| 404 | + | "id": id, "namespace": namespace, "name": id, "description": null, "isPrivate": private, | |
| 405 | + | "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": "" | |
| 406 | + | })) | |
| 407 | + | .unwrap() | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | #[test] | |
| 411 | + | fn uses_names_a_repository_a_path_and_a_ref() { | |
| 412 | + | assert_eq!( | |
| 413 | + | parse_uses("acme/shared/.g1t/workflows/build.yml@v2"), | |
| 414 | + | Some(UsesRef { owner: "acme".into(), repo: "shared".into(), path: ".g1t/workflows/build.yml".into(), git_ref: "v2".into() }) | |
| 415 | + | ); | |
| 416 | + | assert_eq!(parse_uses("acme/setup@main").map(|u| (u.path, u.git_ref)), Some((String::new(), "main".into()))); | |
| 417 | + | assert_eq!(parse_uses("./.g1t/workflows/build.yml"), None); | |
| 418 | + | assert_eq!(parse_uses("docker://alpine:3"), None); | |
| 419 | + | assert_eq!(parse_uses("acme/setup"), None); | |
| 420 | + | assert_eq!(parse_uses("acme/../x@v1"), None); | |
| 421 | + | assert_eq!(parse_uses("acme/shared/../../etc@v1"), None); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | #[test] | |
| 425 | + | fn who_may_use_a_repositorys_actions() { | |
| 426 | + | let web = repo("web", "acme", true); | |
| 427 | + | // Itself, and anything public, always. | |
| 428 | + | assert!(may_use(&web, &web, "none").is_ok()); | |
| 429 | + | assert!(may_use(&web, &repo("lint", "other", false), "none").is_ok()); | |
| 430 | + | // A private one: only when it allows its workspace's repositories. | |
| 431 | + | let shared = repo("shared", "acme", true); | |
| 432 | + | assert!(may_use(&web, &shared, "none").unwrap_err().contains("Settings, Actions, Access")); | |
| 433 | + | assert!(may_use(&web, &shared, "organization").is_ok()); | |
| 434 | + | // Never from another workspace, nor from a public repository. | |
| 435 | + | assert!(may_use(&repo("x", "other", true), &shared, "organization").unwrap_err().contains("only repositories in acme")); | |
| 436 | + | assert!(may_use(&repo("site", "acme", false), &shared, "organization").unwrap_err().contains("public repository")); | |
| 437 | + | } | |
| 438 | + | } |