Skip to content

Commit

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.

syntaqxcommitted Parent8b0cf4aBrowse files
14 files+820−470/14 viewed
+0−2
246246
247247 ### Workflow features not supported yet
248248
249−- Reusable workflows from another repository. Ones in the same repository
250− work.
251249 - Actions that upload or download artifacts with the toolkit's artifact
252250 library themselves. The library refuses to run against any server but
253251 github.com. `actions/upload-artifact`, `actions/download-artifact` and
+118−8
2929
3030 | On GitHub | On g1t |
3131 | --- | --- |
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). |
3333 | `jobs`, `needs`, `if`, `outputs`, `env`, `defaults`, `timeout-minutes`, `continue-on-error` | The same. |
3434 | `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. |
3535 | `strategy.matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`, a matrix from `fromJSON(needs.…)` | The same. |
3737 | `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. |
3838 | `${{ }}` expressions: every operator, function and context | The same, including `hashFiles`, `success()`, `failure()`, `always()` and `cancelled()`. |
3939 | `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). |
4141 | 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). |
4343 | `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. |
4444 | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. |
4545 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run, and [masked](#masking-secrets) values stay hidden. |
6767 - **Docker's `type=gha` build cache.** Buildx skips it on g1t, and the
6868 build runs without a cache. Use a registry cache instead; see
6969 [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.
7170 - **Actions that upload artifacts with the toolkit's artifact library
7271 themselves.** The library refuses to run against any server but
7372 github.com. `actions/upload-artifact`, `actions/download-artifact` and
7978 Why each of these is missing, and what to use instead, is on
8079 [What g1t can't do yet](/about/limitations/#actions-and-runners).
8180
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+
82189 ## The runner
83190
84191 Jobs run in a fresh sandbox each: Debian with Node 24, Python 3, Go, Rust,
653760 marks it ready, which on g1t is when it first has code. Each head runs
654761 each workflow once.
655762
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
660769 `demilestoned` too. List them under `types:` to run on them. For
661770 `labeled` and `unlabeled`, `github.event.label` names the label. A pull
662771 request's `branches` filter, `github.base_ref` and
10151124 | `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` |
10161125 | `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` |
10171126 | `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`) |
10181128 | `repository_dispatch` | `POST /repos/{owner}/{repo}/dispatches` with `event_type` and `client_payload` |
10191129 | `list_artifacts` | `GET /repos/{owner}/{repo}/actions/artifacts`, with `name`, `page`, `per_page` |
10201130 | `run_artifacts` | `GET …/actions/runs/{id}/artifacts`, with `name` |
+9−0
5454 git push origin :refs/tags/v1.2.0
5555 ```
5656
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+
5766 ## From the API and MCP
5867
5968 | Route | MCP | What it does |
+28−2
11 import { Form, Link } from "react-router";
22
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";
55
66 import type { Route } from "./+types/settings-actions";
77 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
3737 // Only where the workspace allows it is the box there to send.
3838 if (form.has("pullRequestsShown")) change.canApprovePullRequests = form.get("canApprovePullRequests") === "on";
3939 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;
4042 const saved = await actions.setActionsSettings(user, { namespace: params.owner, name: params.repo }, change);
4143 return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message };
4244 }
155157 </p>
156158 </Section>
157159
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+
158184 <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">
159185 <SubmitButton pending="Saving…">Save settings</SubmitButton>
160186 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
+28−0
273273 /// Whether the workspace lets its repositories turn that on.
274274 #[serde(default)]
275275 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+ }
276300 }
277301
278302 fn write() -> String {
343367 pub approval_policy: Option<String>,
344368 #[serde(default)]
345369 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>,
346374 }
347375
348376 /// `environments`: every environment a repository's workflows, secrets,
+19−0
2626 Ok(response.into_json()?)
2727 }
2828
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+
2948 pub(crate) fn report(&self, report: Value) {
3049 // A report that cannot be sent is tried a few times, then dropped:
3150 // the job goes on, and g1t notices a silent job by itself.
+69−3
11 //! `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
33 //! Docker), `docker://` images, and a few of GitHub's own whose services
44 //! g1t does not have yet.
55
2424 /// Where an action comes from.
2525 enum Source {
2626 Local(PathBuf),
27+ /// Another repository: on g1t when g1t has it and this one may use
28+ /// it, otherwise on GitHub.
2729 GitHub { owner: String, repo: String, path: String, git_ref: String },
2830 }
2931
9799 Some(r) if is_sha(r) => ("HEAD".into(), Some(r.clone()), None),
98100 Some(r) if r.starts_with("refs/") => (r.clone(), None, r.strip_prefix("refs/heads/").map(str::to_owned)),
99101 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),
101105 None if same => (run_ref.clone(), Some(run_sha.clone()), run_ref.strip_prefix("refs/heads/").map(str::to_owned)),
102106 None => ("HEAD".into(), None, None),
103107 };
169173 (true, outputs)
170174 }
171175
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+
172238 /// Fetches an action from GitHub, once per job.
173239 fn fetch_action(&mut self, owner: &str, repo: &str, git_ref: &str) -> Option<PathBuf> {
174240 let dir = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref);
313379 };
314380 let (dir, repository) = match &source {
315381 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) {
317383 Some(root) => (if path.is_empty() { root } else { root.join(path) }, format!("{owner}/{repo}")),
318384 None => return (false, BTreeMap::new()),
319385 },
+35−0
743743 inside other actions), downloads from other repositories, and npm
744744 trusted publishing, which depends on npm accepting g1t's issuer.
745745
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+
746781 ## A repository that maintains itself
747782
748783 > **2026-10-04:** the user asked for Dependabot, GitHub Advanced Security and
+11−0
197197 canApprovePullRequests: boolean;
198198 /** Whether the workspace lets its repositories turn that on. */
199199 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;
200206 };
201207
208+/** Settings, Actions, Access. */
209+export const ACTIONS_ACCESS_LEVELS = ["none", "organization"] as const;
210+export type ActionsAccessLevel = (typeof ACTIONS_ACCESS_LEVELS)[number];
211+
202212 /** What changes a repository's choices; `inherit` unchooses its default. */
203213 export type ActionsSettingsChange = {
204214 defaultPermissions?: "read" | "write" | "inherit";
205215 approvalPolicy?: ApprovalPolicy;
206216 canApprovePullRequests?: boolean;
217+ accessLevel?: ActionsAccessLevel;
207218 };
208219
209220 /** A workspace's policy for its repositories' job tokens. */
+6−0
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;
+2−0
2727 mod payload;
2828 mod plan;
2929 mod protection;
30+mod reach;
3031 mod rename;
3132 pub mod runtime;
3233 mod runners;
237238 "resolve_settings" => reply(&service.resolve_settings(args(body)?).await?),
238239 "job_spec" => reply(&service.job_spec(args(body)?).await?),
239240 "job_auth" => reply(&service.job_auth(args(body)?).await?),
241+ "job_action" => reply(&service.job_action(args(body)?).await?),
240242 "check_runs" => reply(&service.check_runs(args(body)?).await?),
241243 "job_report" => reply(&service.job_report(args(body)?).await?),
242244 // actions/cache, through the API with the job's token.
+36−28
10101010 Ok(())
10111011 }
10121012
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.
10151016 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− };
10211017 let depth = row.call().and_then(|c| c["depth"].as_u64()).unwrap_or(0) + 1;
10221018 if depth > MAX_CALL_DEPTH {
10231019 return self.fail_job(row, &format!("Reusable workflows call each other more than {MAX_CALL_DEPTH} deep.")).await;
10241020 }
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,
10401024 };
10411025 let called = match workflow::parse(&source) {
10421026 Ok(called) => called,
10651049 for (name, value) in given {
10661050 inputs.entry(name).or_insert(value);
10671051 }
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+ }
10681059 let mut statements = Vec::new();
10691060 for called_job in &called.jobs {
10701061 let needs: Vec<String> = called_job.needs.iter().map(|n| format!("{}/{n}", row.key)).collect();
10711062 let call = json!({
10721063 "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,
10741065 });
10751066 statements.push(
10761067 self.db
11481139 }
11491140
11501141 /// 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>> {
11521143 let blob: Outcome<g1t_contracts::repos::BlobView> = g1t_kit::call(
11531144 &self.repos,
11541145 "blob",
18161807 };
18171808 // A run that is not trusted (a pull request from outside the
18181809 // 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+ }
18231831 };
18241832 secrets.insert("G1T_TOKEN".into(), Value::String(token.clone()));
18251833 secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone()));
+21−4
4848 default_permissions: Option<String>,
4949 approval_policy: Option<String>,
5050 can_approve_pulls: Option<u32>,
51+ /// Migration 0008: who may use its actions and reusable workflows.
52+ #[serde(default)]
53+ access_level: Option<String>,
5154 }
5255
5356 #[derive(Deserialize)]
259262 async fn repo_choices(&self, repo_id: &str) -> Result<SettingsRow> {
260263 Ok(self
261264 .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 = ?")
263266 .bind(&[repo_id.into()])?
264267 .first::<SettingsRow>(None)
265268 .await?
310313 approval_policy: row.approval_policy.unwrap_or_else(|| DEFAULT_APPROVAL_POLICY.to_owned()),
311314 can_approve_pull_requests: workspace.can_approve_pull_requests && row.can_approve_pulls == Some(1),
312315 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(),
313317 })
314318 }
315319
328332 Ok(self.repo_choices(repo_id).await?.approval_policy.unwrap_or_else(|| DEFAULT_APPROVAL_POLICY.to_owned()))
329333 }
330334
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+
331341 pub async fn actions_settings(&self, a: ActionsSettingsArgs) -> Result<Outcome<ActionsSettings>> {
332342 let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
333343 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
373383 }
374384 row.can_approve_pulls = Some(u32::from(allow));
375385 }
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+ }
376392 // 0006's artifact retention is kept, or its default for a new row.
377393 self.db
378394 .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)
381397 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",
383399 )
384400 .bind(&[
385401 repo.id.as_str().into(),
389405 now().into(),
390406 a.actor.username.as_str().into(),
391407 g1t_contracts::actions::ARTIFACT_RETENTION_DEFAULT_DAYS.into(),
408+ optional(row.access_level.as_deref()),
392409 ])?
393410 .run()
394411 .await?;
+438−0
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+}