Skip to content

Commit

Merge main into the run-protection branch

Builds on main's actions/0006: run protection moves to 0007, adds its columns to repo_settings and keeps the cache's toolkit version and upload in the scoped rebuild. The toolkit cache and actions/cache share one ref scope; OIDC reads the full permissions model. Token defaults the GitHub way: existing repositories keep read and write, new ones take their workspace's default, a workspace can cap repositories at read-only, and "Allow g1t Actions to create and approve pull requests" (repository and workspace, off by default) decides whether a job's token may open or approve pull requests.

syntaqxcommitted Parents2704ef60ac02b5Browse files
127 files+2201−1200/127 viewed
+29−9
1616 # with `deployment: false`, so a dry run or a change that deploys nothing
1717 # makes no deployment.
1818 #
19−# Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row), the
20−# variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the project's
21−# workflow-only domains for deploy.yml in production (Settings, Guardrails),
22−# and registry.cloudflare.com there too, to find the runner's image. See
23−# docs/DEPLOYING.md.
19+# Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row, with
20+# Containers write), the variable CLOUDFLARE_ACCOUNT_ID, and
21+# api.cloudflare.com among the project's workflow-only domains for
22+# deploy.yml in production (Settings, Guardrails), and
23+# registry.cloudflare.com there too, to find, pull and push the runner's
24+# image. A job that must build that image (the `runner-image` group) does
25+# so with its own Docker Engine, on a larger machine. See docs/DEPLOYING.md.
2426 name: Deploy
2527
2628 on:
131133 # Runs when nothing before it failed: a migrate job skipped for having
132134 # nothing to apply is not a failure.
133135 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }}
134− # Rust builds get 4 vCPUs; everything else the standard machine.
135− runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
136+ # Rust builds and the runner's image get 4 vCPUs; everything else the
137+ # standard machine.
138+ runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
136139 environment:
137140 name: production
138141 url: https://g1t.sh
190193 restore-keys: |
191194 cargo-target-${{ runner.os }}-${{ matrix.group }}-
192195 cargo-target-${{ runner.os }}-
196+ # The runner's image: its binary, built natively for musl (the base
197+ # has musl-gcc; the target is added here), with its Cargo target kept
198+ # between runs. The image itself is built and pushed with the job's
199+ # own Docker Engine (scripts/deploy/image.mjs).
200+ - name: Rust for the runner
201+ if: ${{ matrix.image }}
202+ run: rustup target add x86_64-unknown-linux-musl
203+ - name: Cache the runner's build
204+ if: ${{ matrix.image }}
205+ uses: actions/cache@v4
206+ with:
207+ path: |
208+ ~/.cargo/registry/cache
209+ target/x86_64-unknown-linux-musl/release
210+ !target/**/incremental
211+ key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
212+ restore-keys: runner-musl-${{ runner.os }}-
193213 - name: Install
194214 run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
195215 - name: Deploy ${{ matrix.units }}
201221 name: edge (${{ matrix.group }})
202222 needs: [plan, migrate, core]
203223 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }}
204− runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
224+ runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
205225 environment:
206226 name: production
207227 url: https://g1t.sh
216236 name: front (${{ matrix.group }})
217237 needs: [plan, migrate, core, edge]
218238 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }}
219− runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
239+ runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
220240 environment:
221241 name: production
222242 url: https://g1t.sh
+5−3
77 # Weekly, for security updates and new stable toolchains; when the base's
88 # folder changes on main (a change that forgot to rebuild it); and by hand.
99 #
10−# It needs Docker, which g1t's own sandboxes do not have, so it runs on a
11−# self-hosted runner with the `docker` label. Until one is registered, run
12−# the same thing by hand on a machine with Docker:
10+# It runs on a self-hosted runner with the `docker` label. g1t's own
11+# machines have Docker now, but this build's own downloads (Docker's apt
12+# repository over HTTPS) do not yet trust a guarded job's egress
13+# certificate, so it stays where the network is open. Until a runner is
14+# registered, run the same thing by hand on a machine with Docker:
1315 #
1416 # node scripts/deploy.mjs build-base
1517 #
+2−1
7777 image:
7878 name: Container image
7979 needs: binaries
80− # Needs Docker, which g1t's own sandboxes do not have.
80+ # Builds for arm64 as well as amd64, which needs QEMU's emulators
81+ # registered on the machine: a self-hosted runner's, for now.
8182 runs-on: [self-hosted, docker]
8283 environment: production
8384 timeout-minutes: 30
+5−0
923923 name = "g1t-actions-service"
924924 version = "0.1.0"
925925 dependencies = [
926+ "base64 0.22.1",
926927 "g1t-actions",
927928 "g1t-contracts",
928929 "g1t-kit",
929930 "g1t-secrets",
931+ "hex",
930932 "serde",
931933 "serde_json",
932934 "worker",
942944 "g1t-kit",
943945 "serde",
944946 "serde_json",
947+ "sha2 0.10.9",
945948 "worker",
946949 ]
947950
10941097 dependencies = [
10951098 "anyhow",
10961099 "base64 0.22.1",
1100+ "crc32fast",
10971101 "ed25519-dalek",
1102+ "flate2",
10981103 "g1t-actions",
10991104 "g1t-scan",
11001105 "hex",
+1−0
1515 serde_json = { workspace = true, features = ["preserve_order"] }
1616 worker.workspace = true
1717 base64 = "0.22"
18+sha2 = "0.10"
1819 form_urlencoded = "1"
1920
2021 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
+300−0
1+//! Workflow run artifacts over REST and MCP, in GitHub's shapes: listing a
2+//! repository's or a run's, one by id, a link to download it, deleting it,
3+//! and how long a repository keeps them.
4+//!
5+//! The actions service keeps them and decides who may see and change
6+//! them (`g1t_contracts::actions`); their bytes are in R2, downloaded
7+//! through the toolkit's blob endpoint (toolkit.rs) with a link signed for
8+//! a few minutes. `GET …/artifacts/{id}/zip` answers with a redirect to
9+//! that link, as GitHub's does.
10+
11+use g1t_contracts::actions::{Artifact, ArtifactArgs, ArtifactBlob, ArtifactList, ArtifactRetention, ArtifactRetentionArgs, ArtifactsArgs, DeleteArtifactArgs};
12+use g1t_contracts::{FailureCode, Outcome, Viewer};
13+use serde_json::{Value, json};
14+use worker::Result;
15+
16+use crate::operations::{Services, repo_path};
17+
18+/// One operation on artifacts.
19+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20+pub enum ArtifactsOp {
21+ ListArtifacts,
22+ ListRunArtifacts,
23+ GetArtifact,
24+ DownloadArtifact,
25+ DeleteArtifact,
26+ GetArtifactRetention,
27+ SetArtifactRetention,
28+}
29+
30+impl ArtifactsOp {
31+ /// Every one: `Op::ALL` lists each as `Op::Artifacts(…)`, which a test
32+ /// checks against this.
33+ #[cfg(test)]
34+ pub const ALL: [ArtifactsOp; 7] = [
35+ ArtifactsOp::ListArtifacts,
36+ ArtifactsOp::ListRunArtifacts,
37+ ArtifactsOp::GetArtifact,
38+ ArtifactsOp::DownloadArtifact,
39+ ArtifactsOp::DeleteArtifact,
40+ ArtifactsOp::GetArtifactRetention,
41+ ArtifactsOp::SetArtifactRetention,
42+ ];
43+
44+ pub fn name(self) -> &'static str {
45+ match self {
46+ ArtifactsOp::ListArtifacts => "list_artifacts",
47+ ArtifactsOp::ListRunArtifacts => "list_workflow_run_artifacts",
48+ ArtifactsOp::GetArtifact => "get_artifact",
49+ ArtifactsOp::DownloadArtifact => "download_artifact",
50+ ArtifactsOp::DeleteArtifact => "delete_artifact",
51+ ArtifactsOp::GetArtifactRetention => "get_artifact_retention",
52+ ArtifactsOp::SetArtifactRetention => "set_artifact_retention",
53+ }
54+ }
55+
56+ /// For the API reference.
57+ pub fn title(self) -> &'static str {
58+ match self {
59+ ArtifactsOp::ListArtifacts => "List a repository's artifacts",
60+ ArtifactsOp::ListRunArtifacts => "List a workflow run's artifacts",
61+ ArtifactsOp::GetArtifact => "Get an artifact",
62+ ArtifactsOp::DownloadArtifact => "Download an artifact",
63+ ArtifactsOp::DeleteArtifact => "Delete an artifact",
64+ ArtifactsOp::GetArtifactRetention => "Get artifact retention",
65+ ArtifactsOp::SetArtifactRetention => "Set artifact retention",
66+ }
67+ }
68+
69+ pub fn description(self) -> &'static str {
70+ match self {
71+ ArtifactsOp::ListArtifacts => "List a repository's artifacts that have not expired, newest first: each with its id (a number), name, size_in_bytes, digest (sha256:… of its zip), created_at, expires_at, archive_download_url, and workflow_run (its run's id, head_branch and head_sha). Narrow with name; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.",
72+ ArtifactsOp::ListRunArtifacts => "List one workflow run's artifacts that have not expired, oldest first, in the same shape as list_artifacts. Narrow with name. Needs the Read role.",
73+ ArtifactsOp::GetArtifact => "Get one artifact by its id: its name, size_in_bytes, digest, when it was made and when it expires, and its run. Needs the Read role.",
74+ ArtifactsOp::DownloadArtifact => "A link to download an artifact as a zip file (an artifact an older runner kept is a .tar.gz), good for 10 minutes and needing no token. Over REST, GET …/zip answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with expires_at. Needs the Read role.",
75+ ArtifactsOp::DeleteArtifact => "Delete an artifact before it expires: its bytes go at once and its id stops resolving. Needs the Write role.",
76+ ArtifactsOp::GetArtifactRetention => "How many days the repository keeps artifacts (days), and the most it may choose (maximum_allowed_days, 90). A workflow's retention-days can ask for fewer days, never more. Needs the Read role.",
77+ ArtifactsOp::SetArtifactRetention => "Set how many days the repository keeps artifacts by default, and at most: days, from 1 to 90. Artifacts already uploaded keep the expiry they were given. Needs the Maintain role.",
78+ }
79+ }
80+
81+ /// Whether it changes anything.
82+ pub fn writes(self) -> bool {
83+ matches!(self, ArtifactsOp::DeleteArtifact | ArtifactsOp::SetArtifactRetention)
84+ }
85+
86+ pub fn input(self) -> Value {
87+ let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
88+ let id = json!({ "type": ["integer", "string"], "description": "The artifact's id, a number." });
89+ let (properties, required): (Value, &[&str]) = match self {
90+ ArtifactsOp::ListArtifacts => (
91+ json!({
92+ "repo": repo,
93+ "name": { "type": "string", "description": "Only artifacts with exactly this name." },
94+ "page": { "type": "integer", "description": "The page, from 1." },
95+ "per_page": { "type": "integer", "description": "Artifacts a page: 30 unless you say, at most 100." },
96+ }),
97+ &["repo"],
98+ ),
99+ ArtifactsOp::ListRunArtifacts => (
100+ json!({
101+ "repo": repo,
102+ "id": { "type": "string", "description": "The run's id (run_…)." },
103+ "name": { "type": "string", "description": "Only the artifact with exactly this name." },
104+ }),
105+ &["repo", "id"],
106+ ),
107+ ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact => (json!({ "repo": repo, "id": id }), &["repo", "id"]),
108+ ArtifactsOp::GetArtifactRetention => (json!({ "repo": repo }), &["repo"]),
109+ ArtifactsOp::SetArtifactRetention => (
110+ json!({
111+ "repo": repo,
112+ "days": { "type": "integer", "minimum": 1, "maximum": 90, "description": "Days to keep artifacts, by default and at most." },
113+ }),
114+ &["repo", "days"],
115+ ),
116+ };
117+ json!({ "type": "object", "properties": properties, "required": required })
118+ }
119+}
120+
121+/// A number from a path segment or a JSON number.
122+fn number(input: &Value, key: &str) -> Option<u64> {
123+ match &input[key] {
124+ Value::Number(n) => n.as_u64(),
125+ Value::String(s) => s.trim().parse().ok(),
126+ _ => None,
127+ }
128+}
129+
130+/// An outcome's value made into what is sent; its failure as it is.
131+fn mapped<T>(outcome: Outcome<T>, f: impl FnOnce(T) -> Value) -> Outcome<Value> {
132+ match outcome {
133+ Outcome::Ok(value) => Outcome::Ok(f(value)),
134+ Outcome::Fail(refused) => Outcome::Fail(refused),
135+ }
136+}
137+
138+fn text(input: &Value, key: &str) -> Option<String> {
139+ input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned)
140+}
141+
142+/// An artifact as GitHub's REST API shows one.
143+pub fn shown(artifact: &Artifact, api: &str, repository: &str) -> Value {
144+ let url = format!("{api}/repos/{repository}/actions/artifacts/{}", artifact.id);
145+ json!({
146+ "id": artifact.id,
147+ "node_id": format!("artifact_{}", artifact.id),
148+ "name": artifact.name,
149+ "size_in_bytes": artifact.size,
150+ "url": url,
151+ "archive_download_url": format!("{url}/zip"),
152+ "expired": artifact.expired,
153+ "digest": artifact.digest,
154+ "created_at": artifact.created_at,
155+ "updated_at": artifact.updated_at,
156+ "expires_at": artifact.expires_at,
157+ "workflow_run": {
158+ "id": artifact.run_id,
159+ "repository_id": artifact.repo_id,
160+ "head_repository_id": artifact.repo_id,
161+ "head_branch": artifact.head_branch,
162+ "head_sha": artifact.head_sha,
163+ },
164+ })
165+}
166+
167+/// Where a signed blob token downloads from.
168+pub fn blob_url(api: &str, blob: &str) -> String {
169+ format!("{api}/actions/toolkit/blobs/{blob}")
170+}
171+
172+fn list(found: ArtifactList, api: &str, repository: &str) -> Value {
173+ json!({
174+ "total_count": found.total_count,
175+ "artifacts": found.artifacts.iter().map(|a| shown(a, api, repository)).collect::<Vec<_>>(),
176+ })
177+}
178+
179+pub async fn run(op: ArtifactsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
180+ let Some(repo) = repo_path(input) else {
181+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
182+ };
183+ let repository = format!("{}/{}", repo.namespace, repo.name);
184+ let api = services.addresses.api.clone();
185+ let actions = &services.actions;
186+ let id = number(input, "id");
187+ let by_id = || ArtifactArgs { repo: repo.clone(), viewer: viewer.clone(), id, run: None, name: None };
188+ if matches!(op, ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact) && id.is_none() {
189+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the artifact by its id, a number."));
190+ }
191+ Ok(match op {
192+ ArtifactsOp::ListArtifacts | ArtifactsOp::ListRunArtifacts => {
193+ let run = (op == ArtifactsOp::ListRunArtifacts).then(|| text(input, "id")).flatten();
194+ let args = ArtifactsArgs {
195+ repo: repo.clone(),
196+ viewer: viewer.clone(),
197+ run,
198+ name: text(input, "name"),
199+ page: number(input, "page").map(|n| n as u32),
200+ per_page: number(input, "per_page").map(|n| n as u32),
201+ };
202+ let found: Outcome<ArtifactList> = g1t_kit::call(actions, "artifacts", &args).await?;
203+ mapped(found, |mut found| {
204+ // A run's are listed in the order they were made.
205+ if op == ArtifactsOp::ListRunArtifacts {
206+ found.artifacts.reverse();
207+ }
208+ list(found, &api, &repository)
209+ })
210+ }
211+ ArtifactsOp::GetArtifact => {
212+ let found: Outcome<Artifact> = g1t_kit::call(actions, "artifact", &by_id()).await?;
213+ mapped(found, |a| shown(&a, &api, &repository))
214+ }
215+ ArtifactsOp::DownloadArtifact => {
216+ let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "artifact_download", &by_id()).await?;
217+ match found {
218+ Outcome::Ok(found) if found.blob.is_empty() => Outcome::fail(FailureCode::Invalid, "Download links are not set up on this installation."),
219+ Outcome::Ok(found) => Outcome::Ok(json!({
220+ "url": blob_url(&api, &found.blob),
221+ "expires_at": g1t_contracts::time::rfc3339(g1t_kit::now_ms() + 10 * 60 * 1000),
222+ "artifact": shown(&found.artifact, &api, &repository),
223+ })),
224+ Outcome::Fail(refused) => Outcome::Fail(refused),
225+ }
226+ }
227+ ArtifactsOp::DeleteArtifact => {
228+ let Some(actor) = viewer.clone() else {
229+ return Ok(Outcome::fail(FailureCode::Unauthenticated, "Deleting an artifact needs a g1t access token."));
230+ };
231+ let done: Outcome<Artifact> = g1t_kit::call(actions, "delete_artifact", &DeleteArtifactArgs { actor, repo, id: id.unwrap_or_default() }).await?;
232+ mapped(done, |a| json!({ "deleted": true, "id": a.id, "name": a.name }))
233+ }
234+ ArtifactsOp::GetArtifactRetention | ArtifactsOp::SetArtifactRetention => {
235+ let days = if op == ArtifactsOp::SetArtifactRetention {
236+ match number(input, "days") {
237+ Some(days) => Some(days.min(u64::from(u32::MAX)) as u32),
238+ None => return Ok(Outcome::fail(FailureCode::Invalid, "Give days, from 1 to 90.")),
239+ }
240+ } else {
241+ None
242+ };
243+ let found: Outcome<ArtifactRetention> = g1t_kit::call(actions, "artifact_retention", &ArtifactRetentionArgs { repo, viewer: viewer.clone(), days }).await?;
244+ mapped(found, |r| json!({ "days": r.days, "maximum_allowed_days": r.maximum_allowed_days }))
245+ }
246+ })
247+}
248+
249+#[cfg(test)]
250+mod tests {
251+ use super::*;
252+
253+ fn artifact() -> Artifact {
254+ Artifact {
255+ id: 42,
256+ name: "dist".into(),
257+ size: 1024,
258+ digest: Some(format!("sha256:{}", "a".repeat(64))),
259+ format: "zip".into(),
260+ run_id: "run_1".into(),
261+ job_id: "job_1".into(),
262+ repo_id: "repo_1".into(),
263+ expired: false,
264+ created_at: "2026-10-08T12:00:00.000Z".into(),
265+ updated_at: "2026-10-08T12:00:00.000Z".into(),
266+ expires_at: "2026-10-22T12:00:00.000Z".into(),
267+ head_branch: Some("main".into()),
268+ head_sha: Some("abc".into()),
269+ }
270+ }
271+
272+ #[test]
273+ fn an_artifact_is_shown_as_github_shows_one() {
274+ let shown = shown(&artifact(), "https://api.g1t.sh", "acme/web");
275+ assert_eq!(shown["id"], 42);
276+ assert_eq!(shown["size_in_bytes"], 1024);
277+ assert_eq!(shown["url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42");
278+ assert_eq!(shown["archive_download_url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42/zip");
279+ assert_eq!(shown["workflow_run"]["head_branch"], "main");
280+ assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty());
281+ }
282+
283+ #[test]
284+ fn ids_are_read_from_a_path_or_a_number() {
285+ assert_eq!(number(&json!({ "id": "42" }), "id"), Some(42));
286+ assert_eq!(number(&json!({ "id": 42 }), "id"), Some(42));
287+ assert_eq!(number(&json!({ "id": "run_1" }), "id"), None);
288+ }
289+
290+ #[test]
291+ fn each_operation_is_described_with_a_schema() {
292+ for op in ArtifactsOp::ALL {
293+ assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Artifacts(op)), "{}", op.name());
294+ assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
295+ assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
296+ let level = g1t_contracts::scopes::scope_for(op.name()).unwrap().level();
297+ assert_eq!(op.writes(), level == g1t_contracts::scopes::Level::Write, "{}", op.name());
298+ }
299+ }
300+}
+266−74
1−//! Artifacts and the cache of GitHub Actions jobs.
1+//! Artifacts and the cache of GitHub Actions jobs, as g1t's own runner
2+//! reaches them. (Actions built on GitHub's toolkit reach the same entries
3+//! through toolkit.rs.)
24 //!
3−//! Artifacts are kept in Workers KV in chunks, with KV's own expiry: 14
4−//! days with their run. Cache entries are kept in R2 (ACTIONS_CACHE), up
5−//! to 2 GB each, uploaded in parts; the actions service lists them and
6−//! decides what is found, what fits and what is evicted
7−//! (services/actions/src/cache.rs). Entries saved in KV before the cache
8−//! moved are still found there until they expire.
5+//! Artifacts are kept in R2 (ACTIONS_CACHE, under `a/`), uploaded in
6+//! parts; the actions service lists them and decides names, sizes and how
7+//! long each is kept (services/actions/src/artifacts.rs). Artifacts older
8+//! runners kept in Workers KV are still listed and found there until KV
9+//! expires them. Cache entries are kept in R2 too, up to 2 GB each,
10+//! uploaded in parts; the actions service decides what is found, what
11+//! fits and what is evicted (services/actions/src/cache.rs).
912 //!
1013 //! A sandbox reaches these with its job's token:
1114 //!
12−//! - `GET /actions/jobs/{job}/artifacts`, `PUT|GET .../artifacts/{name}`
15+//! - `GET /actions/jobs/{job}/artifacts[?run_id=]`: its run's (or another run's)
16+//! - `POST .../artifacts/uploads?name=&size=&retention_days=&overwrite=&format=`:
17+//! `{ id, upload, part_bytes, retention_days, expires_at }`
18+//! - `PUT .../artifacts/uploads/{id}/{part}?upload=`, `POST …/complete` with
19+//! `{ size, parts, digest }`, `DELETE .../artifacts/uploads/{id}?upload=`
20+//! - `GET .../artifacts/{id}/download[?run_id=]`, `DELETE .../artifacts/{id}`
21+//! - `PUT|GET .../artifacts/{name}`: a whole artifact by name (older runners)
1322 //! - `GET .../cache?key=&restore=`: the entry, streamed, its key in `x-g1t-key`
1423 //! - `POST .../cache/uploads?key=&size=`: `{ id, upload, part_bytes }`
1524 //! - `PUT .../cache/uploads/{id}/{part}?upload=`: one part, `{ part, etag }`
1726 //! - `DELETE .../cache/uploads/{id}?upload=`: gives the upload up
1827 //! - `PUT .../cache?key=`: a whole entry of at most 60 MB at once (older runners)
1928 //!
20−//! People download an artifact at
21−//! `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`.
29+//! People download an artifact through the REST API (artifacts.rs), or by
30+//! name at `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`.
2231
2332 use serde::{Deserialize, Serialize};
2433 use serde_json::{Value, json};
2534 use worker::kv::KvStore;
26−use worker::{Bucket, Env, Request, Response, Result, UploadedPart};
35+use worker::{Bucket, Env, Request, Response, Result, UploadedPart, Url};
2736
2837 use g1t_contracts::actions::{
29− CACHE_PART_BYTES, CacheAbortArgs, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, CacheReserveArgs,
38+ ARTIFACT_PART_BYTES, Artifact, ArtifactArgs, ArtifactBlob, ArtifactCommitArgs, ArtifactReservation, ArtifactReserveArgs, CACHE_PART_BYTES,
39+ CacheAbortArgs, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, CacheReserveArgs, JobArtifactsArgs,
3040 };
3141 use g1t_contracts::{FailureCode, Outcome};
3242
3343 use crate::operations::Services;
3444
35−/// KV's largest value is 25 MiB; chunks stay under it.
36−const CHUNK: usize = 20 * 1024 * 1024;
37−/// The largest artifact or cache entry, kept within a Worker's memory.
45+/// The largest artifact or cache entry an older runner sends at once,
46+/// held in a Worker's memory.
3847 const MAX_BYTES: usize = 60 * 1024 * 1024;
39−const ARTIFACT_TTL: u64 = 14 * 24 * 60 * 60;
4048
4149 #[derive(Serialize, Deserialize)]
4250 struct Meta {
5260 env.kv("BLOBS")
5361 }
5462
55−async fn put(kv: &KvStore, base: &str, name: &str, bytes: &[u8], ttl: u64) -> Result<()> {
56− let chunks: Vec<&[u8]> = if bytes.is_empty() { vec![&[][..]] } else { bytes.chunks(CHUNK).collect() };
57− for (index, chunk) in chunks.iter().enumerate() {
58− kv.put_bytes(&format!("{base}#{index}"), chunk)?.expiration_ttl(ttl).execute().await?;
59− }
60− let meta = Meta { size: bytes.len(), chunks: chunks.len(), at: g1t_kit::now_ms(), name: name.to_owned() };
61− // The metadata travels with the key in listings, so the newest entry
62− // can be found without reading each.
63− kv.put(base, serde_json::to_string(&meta)?)?
64− .metadata(&meta)?
65− .expiration_ttl(ttl)
66− .execute()
67− .await?;
68− Ok(())
69−}
70−
7163 async fn get(kv: &KvStore, base: &str) -> Result<Option<Vec<u8>>> {
7264 let Some(meta) = kv.get(base).json::<Meta>().await? else { return Ok(None) };
7365 let mut out = Vec::with_capacity(meta.size);
110102
111103 fn error(status: u16, message: &str) -> Result<Response> {
112104 Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status))
113−}
114−
115−fn valid_name(name: &str) -> bool {
116− !name.is_empty() && name.len() <= 200 && !name.starts_with('.') && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' '))
117105 }
118106
119107 fn decode(text: &str) -> String {
154142
155143 /// A sandbox storing or fetching an artifact or cache entry. `rest` is
156144 /// the path after `/actions/jobs/`.
157−pub async fn for_job(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> {
145+pub async fn for_job(request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> {
158146 let (job, what) = rest.split_once('/').unwrap_or((rest, ""));
159147 let token = request
160148 .headers()
170158 let repo = owner["repoId"].as_str().unwrap_or_default().to_owned();
171159 let kv = store(env)?;
172160 match (method, what) {
173− ("GET", "artifacts") => {
174− let listed: Vec<Value> = list(&kv, &format!("a/{run}/"))
175− .await?
176− .into_iter()
177− .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size }))
178− .collect();
179− crate::reply(&listed)
180− }
181− (_, what) if what.starts_with("artifacts/") => {
182− let name = decode(&what["artifacts/".len()..]);
183− if !valid_name(&name) {
184− return error(400, "That is not an artifact name.");
185− }
186− let base = format!("a/{run}/{name}");
187− if method == "PUT" {
188− let bytes = request.bytes().await?;
189− if bytes.len() > MAX_BYTES {
190− return error(413, "Artifacts are at most 60 MB.");
191− }
192− put(&kv, &base, &name, &bytes, ARTIFACT_TTL).await?;
193− return crate::reply(&json!({ "name": name, "size": bytes.len() }));
194− }
195− match get(&kv, &base).await? {
196− Some(bytes) => Response::from_bytes(bytes),
197− None => error(404, "No such artifact."),
198− }
161+ (_, what) if what == "artifacts" || what.starts_with("artifacts/") => {
162+ let bucket = env.bucket("ACTIONS_CACHE")?;
163+ artifacts(request, &kv, &bucket, services, method, job, &token, &run, &repo, what).await
199164 }
200165 (_, what) if what == "cache" || what.starts_with("cache/") => {
201166 let bucket = env.bucket("ACTIONS_CACHE")?;
260225 let found: Outcome<Option<CacheHit>> = g1t_kit::call(
261226 &services.actions,
262227 "cache_lookup",
263− &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore, version: version.clone() },
228+ &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore, version: Some(version.clone()).filter(|v| !v.is_empty()) },
264229 )
265230 .await?;
266231 let found = match refused(found) {
292257 let reserved: Outcome<CacheReservation> = g1t_kit::call(
293258 &services.actions,
294259 "cache_reserve",
295− &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: version.clone() },
260+ &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: Some(version.clone()).filter(|v| !v.is_empty()) },
296261 )
297262 .await?;
298263 let reserved = match reserved {
315280 let reserved: Outcome<CacheReservation> = g1t_kit::call(
316281 &services.actions,
317282 "cache_reserve",
318− &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: version.clone() },
283+ &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: Some(version.clone()).filter(|v| !v.is_empty()) },
319284 )
320285 .await?;
321286 let reserved = match refused(reserved) {
372337 format!("c/{repo}/{id}")
373338 }
374339
340+/// Where an artifact is in R2, as the actions service names it.
341+fn artifact_object(repo: &str, id: u64) -> String {
342+ format!("a/{repo}/{id}")
343+}
344+
345+/// An artifact as a job's runner lists it.
346+fn for_runner(artifact: &Artifact) -> Value {
347+ json!({
348+ "id": artifact.id,
349+ "name": artifact.name,
350+ "size": artifact.size,
351+ "digest": artifact.digest,
352+ "format": artifact.format,
353+ "created_at": artifact.created_at,
354+ "expires_at": artifact.expires_at,
355+ })
356+}
357+
358+/// An R2 object streamed back, with what it is.
359+async fn stream(bucket: &Bucket, object: &str, format: &str) -> Result<Response> {
360+ let Some(found) = bucket.get(object).execute().await? else { return error(404, "That artifact is gone: it expired or was deleted.") };
361+ let size = found.size();
362+ let Some(body) = found.body() else { return error(404, "That artifact is gone: it expired or was deleted.") };
363+ let mut response = Response::from_body(body.response_body()?)?;
364+ let headers = response.headers_mut();
365+ headers.set("content-length", &size.to_string())?;
366+ headers.set("content-type", if format == "tgz" { "application/gzip" } else { "application/zip" })?;
367+ headers.set("x-g1t-format", format)?;
368+ Ok(response)
369+}
370+
371+/// A job's artifacts: listing its run's (or another run's of its
372+/// repository), uploading in parts, downloading, deleting; and the whole
373+/// uploads and downloads by name of older runners.
374+#[allow(clippy::too_many_arguments)]
375+async fn artifacts(
376+ mut request: Request,
377+ kv: &KvStore,
378+ bucket: &Bucket,
379+ services: &Services,
380+ method: &str,
381+ job: &str,
382+ token: &str,
383+ run: &str,
384+ repo: &str,
385+ what: &str,
386+) -> Result<Response> {
387+ let parts: Vec<&str> = what.split('/').collect();
388+ let upload_id = query(&request, "upload").unwrap_or_default();
389+ let credential = |id: Option<u64>, name: Option<String>, run_id: Option<String>| JobArtifactsArgs {
390+ job: job.to_owned(),
391+ token: token.to_owned(),
392+ run_id,
393+ name,
394+ id,
395+ };
396+ let actions = &services.actions;
397+ match (method, parts.as_slice()) {
398+ ("GET", ["artifacts"]) => {
399+ let run_id = query(&request, "run_id").filter(|r| !r.is_empty());
400+ let found: Outcome<Vec<Artifact>> = g1t_kit::call(actions, "job_artifacts", &credential(None, None, run_id.clone())).await?;
401+ match refused(found) {
402+ Ok(found) => {
403+ let mut listed: Vec<Value> = found.iter().map(for_runner).collect();
404+ // Artifacts older runners kept in KV, for the days they
405+ // are still there.
406+ let legacy_run = run_id.as_deref().unwrap_or(run);
407+ for (_, meta) in list(kv, &format!("a/{legacy_run}/")).await? {
408+ if !listed.iter().any(|a| a["name"] == meta.name.as_str()) {
409+ listed.push(json!({ "name": meta.name, "size": meta.size, "format": "tgz" }));
410+ }
411+ }
412+ crate::reply(&listed)
413+ }
414+ Err(reply) => reply,
415+ }
416+ }
417+ ("POST", ["artifacts", "uploads"]) => {
418+ let flag = |name: &str| query(&request, name).is_some_and(|v| v == "true");
419+ let args = ArtifactReserveArgs {
420+ job: job.to_owned(),
421+ token: token.to_owned(),
422+ name: query(&request, "name").unwrap_or_default(),
423+ size: query(&request, "size").and_then(|s| s.parse().ok()).unwrap_or(0),
424+ retention_days: query(&request, "retention_days").and_then(|d| d.parse().ok()).unwrap_or(0),
425+ expires_at: None,
426+ overwrite: flag("overwrite"),
427+ format: query(&request, "format"),
428+ };
429+ let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?;
430+ let reserved = match refused(reserved) {
431+ Ok(reserved) => reserved,
432+ Err(reply) => return reply,
433+ };
434+ let upload = bucket.create_multipart_upload(&reserved.object).execute().await?;
435+ crate::reply(&json!({
436+ "id": reserved.id,
437+ "upload": upload.upload_id().await,
438+ "part_bytes": ARTIFACT_PART_BYTES,
439+ "retention_days": reserved.retention_days,
440+ "expires_at": reserved.expires_at,
441+ }))
442+ }
443+ ("PUT", ["artifacts", "uploads", id, part]) => {
444+ let (Ok(id), Ok(part)) = (id.parse::<u64>(), part.parse::<u16>()) else {
445+ return error(400, "A part is numbered from 1, of an artifact named by its number.");
446+ };
447+ if part == 0 || upload_id.is_empty() {
448+ return error(400, "A part is numbered from 1, and names its upload.");
449+ }
450+ let length = request.headers().get("content-length")?.and_then(|l| l.parse::<u64>().ok()).unwrap_or(0);
451+ if length == 0 || length > ARTIFACT_PART_BYTES {
452+ return error(413, &format!("A part is 1 to {} MB, with its length.", ARTIFACT_PART_BYTES / 1_048_576));
453+ }
454+ let Some(body) = request.inner().body() else { return error(400, "The part is empty.") };
455+ let upload = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id)?;
456+ let uploaded = upload.upload_part(part, body).await?;
457+ crate::reply(&json!({ "part": uploaded.part_number(), "etag": uploaded.etag() }))
458+ }
459+ ("POST", ["artifacts", "uploads", id, "complete"]) => {
460+ let Ok(id) = id.parse::<u64>() else { return error(404, "No such upload.") };
461+ let done: Value = request.json().await.unwrap_or(Value::Null);
462+ let mut parts: Vec<Part> = serde_json::from_value(done["parts"].clone()).unwrap_or_default();
463+ if parts.is_empty() {
464+ return error(400, "Send { size, parts: [{ part, etag }], digest }.");
465+ }
466+ parts.sort_by_key(|p| p.part);
467+ let upload = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id)?;
468+ let object = match upload.complete(parts.into_iter().map(|p| UploadedPart::new(p.part, p.etag))).await {
469+ Ok(object) => object,
470+ Err(problem) => {
471+ let _: Outcome<bool> = g1t_kit::call(actions, "artifact_abort", &credential(Some(id), None, None)).await?;
472+ return error(400, &format!("The upload could not be completed: {problem}"));
473+ }
474+ };
475+ let args = ArtifactCommitArgs {
476+ job: job.to_owned(),
477+ token: token.to_owned(),
478+ id: Some(id),
479+ name: None,
480+ // What R2 holds, not what the runner says.
481+ size: object.size(),
482+ digest: done["digest"].as_str().map(str::to_owned),
483+ };
484+ let committed: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?;
485+ match refused(committed) {
486+ Ok(artifact) => crate::reply(&for_runner(&artifact)),
487+ Err(reply) => reply,
488+ }
489+ }
490+ ("DELETE", ["artifacts", "uploads", id]) => {
491+ let Ok(id) = id.parse::<u64>() else { return error(404, "No such upload.") };
492+ if let Ok(upload) = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id) {
493+ let _ = upload.abort().await;
494+ }
495+ let _: Outcome<bool> = g1t_kit::call(actions, "artifact_abort", &credential(Some(id), None, None)).await?;
496+ crate::reply(&json!({ "aborted": true }))
497+ }
498+ ("GET", ["artifacts", id, "download"]) => {
499+ let Ok(id) = id.parse::<u64>() else { return error(404, "No such artifact.") };
500+ // Any run of the job's repository: the service checks.
501+ let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &credential(Some(id), None, query(&request, "run_id"))).await?;
502+ match found {
503+ Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await,
504+ Outcome::Fail(failure) => error(failure.code.http_status(), &failure.message),
505+ }
506+ }
507+ ("DELETE", ["artifacts", id]) => {
508+ let Ok(id) = id.parse::<u64>() else { return error(404, "No such artifact.") };
509+ let done: Outcome<Artifact> = g1t_kit::call(actions, "job_delete_artifact", &credential(Some(id), None, None)).await?;
510+ match refused(done) {
511+ Ok(artifact) => crate::reply(&for_runner(&artifact)),
512+ Err(reply) => reply,
513+ }
514+ }
515+ // Older runners: a whole artifact of at most 60 MB, by name.
516+ ("PUT", ["artifacts", name]) => {
517+ let name = decode(name);
518+ let bytes = request.bytes().await?;
519+ if bytes.len() > MAX_BYTES {
520+ return error(413, "An artifact sent at once is at most 60 MB; newer runners upload it in parts.");
521+ }
522+ let args = ArtifactReserveArgs {
523+ job: job.to_owned(),
524+ token: token.to_owned(),
525+ name: name.clone(),
526+ size: bytes.len() as u64,
527+ format: Some("tgz".to_owned()),
528+ ..ArtifactReserveArgs::default()
529+ };
530+ let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?;
531+ let reserved = match refused(reserved) {
532+ Ok(reserved) => reserved,
533+ Err(reply) => return reply,
534+ };
535+ let size = bytes.len() as u64;
536+ bucket.put(&reserved.object, bytes).execute().await?;
537+ let args = ArtifactCommitArgs { job: job.to_owned(), token: token.to_owned(), id: Some(reserved.id), name: None, size, digest: None };
538+ let committed: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?;
539+ match refused(committed) {
540+ Ok(_) => crate::reply(&json!({ "name": name, "size": size })),
541+ Err(reply) => reply,
542+ }
543+ }
544+ ("GET", ["artifacts", name]) => {
545+ let name = decode(name);
546+ let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &credential(None, Some(name.clone()), None)).await?;
547+ match found {
548+ Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await,
549+ // One an older runner kept in KV.
550+ Outcome::Fail(_) => match get(kv, &format!("a/{run}/{name}")).await? {
551+ Some(bytes) => Response::from_bytes(bytes),
552+ None => error(404, "No such artifact."),
553+ },
554+ }
555+ }
556+ _ => error(404, "No such endpoint."),
557+ }
558+}
559+
375560 /// Marks an uploaded entry ready, and deletes what that evicted.
376561 async fn commit(bucket: &Bucket, services: &Services, job: &str, token: &str, id: &str, size: u64) -> Result<()> {
377562 let committed: Outcome<CacheCommitted> = g1t_kit::call(
412597 return error(status, &refused.message);
413598 }
414599 let name = decode(name);
600+ // Kept in R2: a redirect to a link signed for a few minutes.
601+ let args = ArtifactArgs {
602+ repo: g1t_contracts::repos::RepoPath { namespace: owner.to_owned(), name: repo.to_owned() },
603+ viewer: viewer.clone(),
604+ id: None,
605+ run: Some(run.to_owned()),
606+ name: Some(name.clone()),
607+ };
608+ let found: Outcome<ArtifactBlob> = g1t_kit::call(&services.actions, "artifact_download", &args).await?;
609+ if let Outcome::Ok(found) = found
610+ && !found.blob.is_empty()
611+ {
612+ return Response::redirect_with_status(Url::parse(&crate::artifacts::blob_url(&services.addresses.api, &found.blob))?, 302);
613+ }
614+ // Kept in KV by an older runner.
415615 match get(&store(env)?, &format!("a/{run}/{name}")).await? {
416616 Some(bytes) => {
417617 let mut response = Response::from_bytes(bytes)?;
424624 }
425625 }
426626
427−/// A run's artifacts, for its page.
428−pub async fn of_run(env: &Env, run: &str) -> Result<Vec<Value>> {
429− Ok(list(&store(env)?, &format!("a/{run}/"))
430− .await?
431− .into_iter()
432− .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size, "at": meta.at }))
433− .collect())
434−}
+75−20
55 //! the data. This Worker holds none.
66
77 mod about;
8+mod artifacts;
89 mod addresses;
910 mod alerts;
1011 mod audit;
1516 mod mcp;
1617 mod notifications;
1718 mod oauth;
19+mod oidc;
1820 mod openapi;
1921 mod pins;
2022 mod projects;
2830 mod runners;
2931 mod security;
3032 mod tools;
33+mod toolkit;
3134
3235 use g1t_contracts::billing::{FinishRunArgs, RunTokens};
3336 use g1t_contracts::identity::{
7376 "spec", "workflow", "github", "event", "contexts", "checkout", "permissions",
7477 ];
7578
79+/// Puts the toolkit's variables in a job's spec: its runtime token, where
80+/// the toolkit's services are, and where to ask for an OIDC token when the
81+/// job may have one and this installation issues them. The `runtime` the
82+/// actions service sent goes no further.
83+fn with_runtime(spec: &mut Value, api: &str, oidc: bool) {
84+ let Some(runtime) = spec.as_object_mut().and_then(|s| s.remove("runtime")) else { return };
85+ let Some(token) = runtime["token"].as_str().filter(|t| !t.is_empty()) else { return };
86+ let id_token = oidc && runtime["id_token"].as_bool() == Some(true);
87+ let vars = toolkit::runtime_variables(api, token, id_token);
88+ if let Some(variables) = spec.get_mut("variables").and_then(Value::as_object_mut) {
89+ variables.extend(vars);
90+ }
91+}
92+
7693 /// An error in the shape every endpoint uses.
7794 fn failure(failure: &Failure) -> Result<Response> {
7895 Ok(reply(&json!({ "error": failure }))?.with_status(failure.code.http_status()))
547564 }));
548565 }
549566
567+ // The services GitHub's toolkit calls from inside a job, with its
568+ // runtime token, and the links they hand out (toolkit.rs).
569+ if !on_mcp && method == "POST"
570+ && let Some(rest) = path.strip_prefix("/twirp/")
571+ {
572+ let (service, rpc) = rest.split_once('/').unwrap_or((rest, ""));
573+ let (service, rpc) = (service.to_owned(), rpc.to_owned());
574+ return toolkit::twirp(request, env, &services, &service, &rpc).await;
575+ }
576+ if !on_mcp && let Some(rest) = path.strip_prefix("/actions/toolkit/_apis/artifactcache/") {
577+ let rest = rest.to_owned();
578+ return toolkit::cache_v1(request, env, &services, method, &rest).await;
579+ }
580+ if !on_mcp && let Some(token) = path.strip_prefix("/actions/toolkit/blobs/") {
581+ let token = token.to_owned();
582+ return toolkit::blob(request, env, &services, method, &token).await;
583+ }
584+ // g1t as an OIDC issuer for workflow jobs (oidc.rs).
585+ if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") {
586+ return oidc::handle(&request, env, &services, &path).await;
587+ }
588+
550589 // A sandbox's artifacts and cache, with its job's token, which is not a
551590 // g1t token either.
552591 if !on_mcp
603642 match (method, path.trim_end_matches('/')) {
604643 ("GET", "") => return reply(&index(&services.addresses)),
605644 ("GET", "/openapi.json") => return Response::from_json(&openapi::document()),
606− // A run's artifacts: listed, or one downloaded.
607− ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts") => {
645+ // One of a run's artifacts downloaded by name (the run's artifacts
646+ // are listed by the REST route in rest.rs).
647+ ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => {
608648 let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect();
609− if let [owner, repo, "actions", "runs", run, "artifacts", rest @ ..] = parts.as_slice() {
649+ if let [owner, repo, "actions", "runs", run, "artifacts", name] = parts.as_slice() {
610650 // A workflow job's token reaches its own repository only.
611651 if viewer
612652 .as_ref()
615655 {
616656 return fail(FailureCode::NotFound, "No such run.");
617657 }
618− return match rest {
619− [] => {
620− let seen: Outcome<Value> = g1t_kit::call(
621− &services.actions,
622− "run",
623− &json!({ "repo": { "namespace": owner, "name": repo }, "viewer": viewer, "id": run }),
624− )
625− .await?;
626− match seen {
627− Outcome::Ok(_) => reply(&blobs::of_run(env, run).await?),
628− Outcome::Fail(refused) => failure(&refused),
629− }
630− }
631− [name] => blobs::download(env, &services, &viewer, owner, repo, run, name).await,
632− _ => fail(FailureCode::NotFound, "No such endpoint."),
633− };
658+ return blobs::download(env, &services, &viewer, owner, repo, run, name).await;
634659 }
635660 }
636661 ("POST", "/device/code") => return device_code(&mut request, &services).await,
679704 return match answered {
680705 // A job's spec is the workflow and its contexts as GitHub
681706 // has them; only g1t's own keys around them are converted.
682− Outcome::Ok(value) => Response::from_json(&wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN)),
707+ Outcome::Ok(value) => {
708+ let mut spec = wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN);
709+ with_runtime(&mut spec, &services.addresses.api, oidc::configured(env));
710+ Response::from_json(&spec)
711+ }
683712 Outcome::Fail(refused) => failure(&refused),
684713 };
685714 }
767796 return fail(FailureCode::NotFound, "No such endpoint.");
768797 };
769798 match audit::run(route.op, &services, &viewer, &input).await? {
799+ // A download is a redirect to its signed link, as GitHub's is.
800+ Outcome::Ok(value) if route.op == operations::Op::Artifacts(artifacts::ArtifactsOp::DownloadArtifact) => {
801+ match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) {
802+ Some(url) => Response::redirect_with_status(url, 302),
803+ None => reply(&value),
804+ }
805+ }
770806 Outcome::Ok(value) => reply(&value),
771807 // A token without the scope a call needs is told which one.
772808 Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) {
815851 use serde_json::json;
816852
817853 #[test]
854+ fn a_job_spec_gets_the_toolkits_variables() {
855+ // As the actions service sends it, converted as the API does.
856+ let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } });
857+ let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN);
858+ super::with_runtime(&mut spec, "https://api.g1t.sh", true);
859+ assert!(spec.get("runtime").is_none(), "the runner never sees it");
860+ let vars = &spec["variables"];
861+ assert_eq!(vars["GITHUB_SHA"], "abc");
862+ assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s");
863+ assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/");
864+ assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "h.p.s");
865+ // No OIDC key here: no OIDC variables, whatever the job may do.
866+ let mut spec = g1t_kit::wire::snake_case_keeping(sent, super::JOB_SPEC_AS_GIVEN);
867+ super::with_runtime(&mut spec, "https://api.g1t.sh", false);
868+ assert!(spec["variables"].get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none());
869+ assert_eq!(spec["variables"]["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/");
870+ }
871+
872+ #[test]
818873 fn camel_case_keys_are_accepted() {
819874 assert_eq!(
820875 snake_case_keys(json!({ "countAgentApprovals": false, "title": "x" })),
+397−0
1+//! OIDC tokens for workflow jobs: g1t as an OpenID Connect issuer, so a
2+//! job with `permissions: id-token: write` can trade a short-lived token
3+//! for a cloud provider's credentials instead of keeping a long-lived key
4+//! in a secret.
5+//!
6+//! - The issuer is `{API}/actions/oidc` (`https://api.g1t.sh/actions/oidc`
7+//! hosted): its discovery document at
8+//! `/.well-known/openid-configuration` under it, and its keys at
9+//! `/.well-known/jwks`. No host of its own: the API's.
10+//! - A job asks `GET {issuer}/token?api-version=2.0&audience=…` with its
11+//! runtime token (`ACTIONS_ID_TOKEN_REQUEST_URL` and `…_TOKEN`, as the
12+//! toolkit's `core.getIDToken` reads them) and gets `{ "value": jwt }`.
13+//! - Tokens are RS256, good for five minutes, with GitHub's claims (the
14+//! actions service decides them and whether the job may have one).
15+//! - The signing key is the Worker secret `ACTIONS_OIDC_KEY`, an RSA
16+//! private key in PEM (PKCS#8 or PKCS#1). `ACTIONS_OIDC_KEY_PREVIOUS`,
17+//! while it is set, is published too, so tokens it signed still verify
18+//! while the new key takes over. Each key's `kid` is its RFC 7638
19+//! thumbprint. docs/DEPLOYING.md says how to make and rotate them.
20+
21+use base64::Engine;
22+use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
23+use g1t_contracts::actions::RuntimeAuthArgs;
24+use g1t_contracts::{FailureCode, Outcome};
25+use g1t_kit::js;
26+use serde_json::{Value, json};
27+use sha2::{Digest, Sha256};
28+use worker::js_sys::{self, Uint8Array};
29+use worker::{Env, Error, Request, Response, Result};
30+
31+use crate::operations::Services;
32+
33+/// How long a token is good for.
34+const LIFETIME_SECONDS: u64 = 5 * 60;
35+pub const KEY_SECRET: &str = "ACTIONS_OIDC_KEY";
36+pub const PREVIOUS_KEY_SECRET: &str = "ACTIONS_OIDC_KEY_PREVIOUS";
37+
38+/// The issuer, under the API's address.
39+pub fn issuer(api: &str) -> String {
40+ format!("{api}/actions/oidc")
41+}
42+
43+/// The OpenID Provider configuration, as relying parties fetch it.
44+pub fn discovery(api: &str) -> Value {
45+ let issuer = issuer(api);
46+ json!({
47+ "issuer": issuer,
48+ "jwks_uri": format!("{issuer}/.well-known/jwks"),
49+ "subject_types_supported": ["public", "pairwise"],
50+ "response_types_supported": ["id_token"],
51+ "claims_supported": [
52+ "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner",
53+ "repository_owner_id", "repository_visibility", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow",
54+ "workflow_ref", "workflow_sha", "job_workflow_ref", "job_workflow_sha", "head_ref", "base_ref", "event_name", "ref_type",
55+ "ref_protected", "environment", "runner_environment"
56+ ],
57+ "id_token_signing_alg_values_supported": ["RS256"],
58+ "scopes_supported": ["openid"],
59+ })
60+}
61+
62+// ── Keys ────────────────────────────────────────────────────────────────────
63+
64+/// A DER element: its tag, and its contents; and what follows it.
65+fn der(input: &[u8]) -> Option<(u8, &[u8], &[u8])> {
66+ let (&tag, rest) = input.split_first()?;
67+ let (&first, rest) = rest.split_first()?;
68+ let (length, rest) = if first < 0x80 {
69+ (first as usize, rest)
70+ } else {
71+ let count = (first & 0x7f) as usize;
72+ if count == 0 || count > 4 || rest.len() < count {
73+ return None;
74+ }
75+ let length = rest[..count].iter().fold(0usize, |n, b| (n << 8) | *b as usize);
76+ (length, &rest[count..])
77+ };
78+ if rest.len() < length {
79+ return None;
80+ }
81+ Some((tag, &rest[..length], &rest[length..]))
82+}
83+
84+/// An INTEGER's magnitude, without the sign byte DER may put in front.
85+fn unsigned(bytes: &[u8]) -> &[u8] {
86+ let mut bytes = bytes;
87+ while bytes.len() > 1 && bytes[0] == 0 {
88+ bytes = &bytes[1..];
89+ }
90+ bytes
91+}
92+
93+/// The modulus and public exponent of an RSA private key: PKCS#1
94+/// `RSAPrivateKey`, or PKCS#8 `PrivateKeyInfo` holding one.
95+pub fn public_numbers(key: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
96+ let (0x30, body, _) = der(key)? else { return None };
97+ let (0x02, _version, rest) = der(body)? else { return None };
98+ let rsa = match der(rest)? {
99+ // PKCS#8: the algorithm, then the key in an OCTET STRING.
100+ (0x30, _algorithm, after) => {
101+ let (0x04, inner, _) = der(after)? else { return None };
102+ let (0x30, rsa, _) = der(inner)? else { return None };
103+ let (0x02, _version, rsa) = der(rsa)? else { return None };
104+ rsa
105+ }
106+ // PKCS#1: the modulus is next.
107+ (0x02, _, _) => rest,
108+ _ => return None,
109+ };
110+ let (0x02, n, rsa) = der(rsa)? else { return None };
111+ let (0x02, e, _) = der(rsa)? else { return None };
112+ Some((unsigned(n).to_vec(), unsigned(e).to_vec()))
113+}
114+
115+/// A DER length.
116+fn der_length(length: usize) -> Vec<u8> {
117+ if length < 0x80 {
118+ return vec![length as u8];
119+ }
120+ let bytes: Vec<u8> = length.to_be_bytes().into_iter().skip_while(|byte| *byte == 0).collect();
121+ let mut out = vec![0x80 | bytes.len() as u8];
122+ out.extend(bytes);
123+ out
124+}
125+
126+/// Wraps a PKCS#1 key in PKCS#8, which is all WebCrypto imports.
127+fn pkcs1_to_pkcs8(pkcs1: &[u8]) -> Vec<u8> {
128+ const RSA_ALGORITHM: [u8; 15] = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
129+ let mut octets = vec![0x04];
130+ octets.extend(der_length(pkcs1.len()));
131+ octets.extend_from_slice(pkcs1);
132+ let mut body = vec![0x02, 0x01, 0x00];
133+ body.extend_from_slice(&RSA_ALGORITHM);
134+ body.extend(octets);
135+ let mut out = vec![0x30];
136+ out.extend(der_length(body.len()));
137+ out.extend(body);
138+ out
139+}
140+
141+/// A signing key: its PKCS#8 DER, and its public half as a JWK.
142+pub struct SigningKey {
143+ pub pkcs8: Vec<u8>,
144+ pub jwk: Value,
145+}
146+
147+impl SigningKey {
148+ /// From PEM, either form; line breaks pasted as `\n` are read too.
149+ pub fn from_pem(pem: &str) -> std::result::Result<SigningKey, String> {
150+ let pem = pem.replace("\\n", "\n");
151+ let pkcs1 = pem.contains("BEGIN RSA PRIVATE KEY");
152+ if !pkcs1 && !pem.contains("BEGIN PRIVATE KEY") {
153+ return Err(format!("{KEY_SECRET} is not a PEM RSA private key."));
154+ }
155+ let body: String = pem.lines().filter(|l| !l.starts_with("-----")).flat_map(str::chars).filter(|c| !c.is_whitespace()).collect();
156+ let der = STANDARD.decode(body).map_err(|_| format!("{KEY_SECRET} is not valid base64."))?;
157+ let (n, e) = public_numbers(&der).ok_or_else(|| format!("{KEY_SECRET} is not an RSA key."))?;
158+ let pkcs8 = if pkcs1 { pkcs1_to_pkcs8(&der) } else { der };
159+ Ok(SigningKey { pkcs8, jwk: jwk(&n, &e) })
160+ }
161+
162+ pub fn kid(&self) -> String {
163+ self.jwk["kid"].as_str().unwrap_or_default().to_owned()
164+ }
165+}
166+
167+/// The public JWK of a key, its `kid` the RFC 7638 thumbprint.
168+pub fn jwk(n: &[u8], e: &[u8]) -> Value {
169+ let (n, e) = (URL_SAFE_NO_PAD.encode(n), URL_SAFE_NO_PAD.encode(e));
170+ // RFC 7638: the required members, in lexicographic order, no spaces.
171+ let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#);
172+ let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
173+ json!({ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": kid, "n": n, "e": e })
174+}
175+
176+/// The keys configured: the current one first.
177+pub fn keys(env: &Env) -> (Option<std::result::Result<SigningKey, String>>, Option<SigningKey>) {
178+ let read = |name: &str| env.secret(name).ok().map(|s| s.to_string()).filter(|s| !s.trim().is_empty());
179+ let current = read(KEY_SECRET).map(|pem| SigningKey::from_pem(&pem));
180+ let previous = read(PREVIOUS_KEY_SECRET).and_then(|pem| SigningKey::from_pem(&pem).ok());
181+ (current, previous)
182+}
183+
184+/// Whether this installation can issue OIDC tokens.
185+pub fn configured(env: &Env) -> bool {
186+ matches!(keys(env).0, Some(Ok(_)))
187+}
188+
189+pub fn jwks(current: Option<&SigningKey>, previous: Option<&SigningKey>) -> Value {
190+ json!({ "keys": current.into_iter().chain(previous).map(|k| k.jwk.clone()).collect::<Vec<_>>() })
191+}
192+
193+// ── Tokens ──────────────────────────────────────────────────────────────────
194+
195+/// The token's claims: what the actions service said about the job, and
196+/// who issued it, for whom and when.
197+pub fn full_claims(mut claims: Value, issuer: &str, audience: &str, jti: &str, now: u64) -> Value {
198+ claims["iss"] = json!(issuer);
199+ claims["aud"] = json!(audience);
200+ claims["jti"] = json!(jti);
201+ claims["iat"] = json!(now);
202+ claims["nbf"] = json!(now.saturating_sub(60));
203+ claims["exp"] = json!(now + LIFETIME_SECONDS);
204+ claims
205+}
206+
207+/// The header and claims, base64url-encoded and joined: what is signed.
208+pub fn signing_input(kid: &str, claims: &Value) -> String {
209+ let header = json!({ "typ": "JWT", "alg": "RS256", "kid": kid, "x5t": kid });
210+ format!("{}.{}", URL_SAFE_NO_PAD.encode(header.to_string()), URL_SAFE_NO_PAD.encode(claims.to_string()))
211+}
212+
213+/// RSASSA-PKCS1-v1_5 with SHA-256, by WebCrypto.
214+async fn sign_rs256(pkcs8: &[u8], data: &[u8]) -> Result<Vec<u8>> {
215+ let subtle = js::get(&js::get(&js_sys::global(), "crypto"), "subtle");
216+ let algorithm = js::to_js(&json!({ "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" }))?;
217+ let usages = js::to_js(&json!(["sign"]))?;
218+ let key = js::call(&subtle, "importKey", &["pkcs8".into(), Uint8Array::from(pkcs8).into(), algorithm.clone(), false.into(), usages])
219+ .await
220+ .map_err(|thrown| Error::RustError(format!("{KEY_SECRET} could not be used: {thrown}")))?;
221+ let signature = js::call(&subtle, "sign", &[algorithm, key, Uint8Array::from(data).into()]).await?;
222+ Ok(Uint8Array::new(&signature).to_vec())
223+}
224+
225+fn error(status: u16, message: &str) -> Result<Response> {
226+ Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status))
227+}
228+
229+/// `/actions/oidc/…`: discovery, keys, and a job's token.
230+pub async fn handle(request: &Request, env: &Env, services: &Services, path: &str) -> Result<Response> {
231+ let api = services.addresses.api.clone();
232+ let (current, previous) = keys(env);
233+ let current = match current {
234+ Some(Ok(key)) => key,
235+ Some(Err(problem)) => {
236+ worker::console_error!("oidc: {problem}");
237+ return error(503, "OIDC tokens are not set up on this installation.");
238+ }
239+ None => return error(404, "OIDC tokens are not set up on this installation."),
240+ };
241+ let cached = |value: &Value| -> Result<Response> {
242+ let mut response = Response::from_json(value)?;
243+ response.headers_mut().set("cache-control", "public, max-age=300")?;
244+ Ok(response)
245+ };
246+ match path {
247+ "/actions/oidc/.well-known/openid-configuration" => cached(&discovery(&api)),
248+ "/actions/oidc/.well-known/jwks" => cached(&jwks(Some(&current), previous.as_ref())),
249+ "/actions/oidc/token" => {
250+ let token = crate::toolkit::bearer(request);
251+ let Some(job) = crate::toolkit::runtime_job(&token) else {
252+ return error(401, "Send the job's ACTIONS_ID_TOKEN_REQUEST_TOKEN as a bearer token.");
253+ };
254+ let claims: Outcome<Value> = g1t_kit::call(&services.actions, "oidc_claims", &RuntimeAuthArgs { job, token }).await?;
255+ let claims = match claims {
256+ Outcome::Ok(claims) => claims,
257+ Outcome::Fail(refused) => {
258+ let status = match refused.code {
259+ FailureCode::Unauthenticated => 401,
260+ FailureCode::Forbidden => 403,
261+ _ => 404,
262+ };
263+ return error(status, &refused.message);
264+ }
265+ };
266+ let url = request.url()?;
267+ let owner = claims["repository_owner"].as_str().unwrap_or_default().to_owned();
268+ let audience = url
269+ .query_pairs()
270+ .find(|(k, _)| k == "audience")
271+ .map(|(_, v)| v.into_owned())
272+ .filter(|a| !a.trim().is_empty())
273+ // GitHub's default: the owner's address.
274+ .unwrap_or_else(|| format!("{}/{owner}", services.addresses.site));
275+ let now = g1t_kit::now_ms() / 1000;
276+ let jti = g1t_contracts::new_id("oidc", g1t_kit::now_ms());
277+ let claims = full_claims(claims, &issuer(&api), &audience, &jti, now);
278+ let input = signing_input(&current.kid(), &claims);
279+ let signature = sign_rs256(&current.pkcs8, input.as_bytes()).await?;
280+ let value = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature));
281+ Response::from_json(&json!({ "count": value.len(), "value": value }))
282+ }
283+ _ => error(404, "No such endpoint."),
284+ }
285+}
286+
287+#[cfg(test)]
288+mod tests {
289+ use super::*;
290+ use std::process::Command;
291+
292+ #[test]
293+ fn discovery_names_the_issuer_and_its_keys() {
294+ let doc = discovery("https://api.g1t.sh");
295+ assert_eq!(doc["issuer"], "https://api.g1t.sh/actions/oidc");
296+ assert_eq!(doc["jwks_uri"], "https://api.g1t.sh/actions/oidc/.well-known/jwks");
297+ assert_eq!(doc["id_token_signing_alg_values_supported"], json!(["RS256"]));
298+ assert!(doc["claims_supported"].as_array().unwrap().contains(&json!("job_workflow_ref")));
299+ }
300+
301+ #[test]
302+ fn a_thumbprint_is_rfc_7638s() {
303+ // RFC 7638, section 3.1: the example key and its thumbprint.
304+ let n = URL_SAFE_NO_PAD
305+ .decode("0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw")
306+ .unwrap();
307+ let key = jwk(&n, &[1, 0, 1]);
308+ assert_eq!(key["e"], "AQAB");
309+ assert_eq!(key["kid"], "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs");
310+ }
311+
312+ #[test]
313+ fn claims_get_who_issued_them_and_a_short_life() {
314+ let claims = full_claims(json!({ "sub": "repo:acme/web:ref:refs/heads/main" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "oidc_1", 1_700_000_000);
315+ assert_eq!(claims["iss"], "https://api.g1t.sh/actions/oidc");
316+ assert_eq!(claims["aud"], "sts.amazonaws.com");
317+ assert_eq!(claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(), LIFETIME_SECONDS);
318+ assert!(claims["nbf"].as_u64().unwrap() <= claims["iat"].as_u64().unwrap());
319+ let input = signing_input("kid1", &claims);
320+ let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(input.split('.').next().unwrap()).unwrap()).unwrap();
321+ assert_eq!((header["alg"].as_str(), header["kid"].as_str()), (Some("RS256"), Some("kid1")));
322+ }
323+
324+ #[test]
325+ fn a_key_that_is_not_one_is_refused() {
326+ assert!(SigningKey::from_pem("hello").is_err());
327+ let pem = format!("{}\nAAAA\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----"));
328+ assert!(SigningKey::from_pem(&pem).is_err());
329+ }
330+
331+ fn openssl(args: &[&str]) -> Option<std::process::Output> {
332+ Command::new("openssl").args(args).output().ok().filter(|o| o.status.success())
333+ }
334+
335+ /// With openssl on the machine: a key made here, read in both PEM
336+ /// forms, gives the modulus openssl gives, and a token signed with it
337+ /// (by openssl, as WebCrypto is not here) verifies against the public
338+ /// key built from the JWKS.
339+ #[test]
340+ fn a_real_key_signs_tokens_its_jwks_verifies() {
341+ let dir = std::env::temp_dir().join(format!("g1t-oidc-test-{}", std::process::id()));
342+ let _ = std::fs::create_dir_all(&dir);
343+ let path = |name: &str| dir.join(name).display().to_string();
344+ if openssl(&["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", &path("key.pem")]).is_none() {
345+ eprintln!("openssl is not here; skipped");
346+ return;
347+ }
348+ let pem = std::fs::read_to_string(path("key.pem")).unwrap();
349+ let key = SigningKey::from_pem(&pem).unwrap();
350+ // The modulus is openssl's.
351+ let modulus = openssl(&["rsa", "-in", &path("key.pem"), "-noout", "-modulus"]).unwrap();
352+ let modulus = String::from_utf8_lossy(&modulus.stdout).trim().trim_start_matches("Modulus=").to_lowercase();
353+ let n = URL_SAFE_NO_PAD.decode(key.jwk["n"].as_str().unwrap()).unwrap();
354+ assert_eq!(n.iter().map(|b| format!("{b:02x}")).collect::<String>(), modulus);
355+ assert_eq!(key.jwk["e"], "AQAB");
356+ // The traditional form reads to the same key.
357+ if openssl(&["rsa", "-in", &path("key.pem"), "-traditional", "-out", &path("key1.pem")]).is_some() {
358+ let pkcs1 = std::fs::read_to_string(path("key1.pem")).unwrap();
359+ if pkcs1.contains("BEGIN RSA PRIVATE KEY") {
360+ let again = SigningKey::from_pem(&pkcs1).unwrap();
361+ assert_eq!(again.kid(), key.kid());
362+ assert_eq!(again.pkcs8, key.pkcs8, "PKCS#1 is wrapped as openssl writes PKCS#8");
363+ }
364+ }
365+ // Sign the token's input with openssl, verify with the JWKS's key.
366+ let claims = full_claims(json!({ "sub": "repo:acme/web:environment:prod" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "j", 1_700_000_000);
367+ let input = signing_input(&key.kid(), &claims);
368+ std::fs::write(path("input"), &input).unwrap();
369+ openssl(&["dgst", "-sha256", "-sign", &path("key.pem"), "-out", &path("sig"), &path("input")]).unwrap();
370+ // The public key from n and e alone: RSAPublicKey DER.
371+ let integer = |bytes: &[u8]| {
372+ let mut value = bytes.to_vec();
373+ if value[0] & 0x80 != 0 {
374+ value.insert(0, 0);
375+ }
376+ let mut out = vec![0x02];
377+ out.extend(der_length(value.len()));
378+ out.extend(value);
379+ out
380+ };
381+ let mut body = integer(&n);
382+ body.extend(integer(&URL_SAFE_NO_PAD.decode(key.jwk["e"].as_str().unwrap()).unwrap()));
383+ let mut public = vec![0x30];
384+ public.extend(der_length(body.len()));
385+ public.extend(body);
386+ std::fs::write(path("public.der"), &public).unwrap();
387+ let checked = openssl(&["rsa", "-RSAPublicKey_in", "-inform", "DER", "-in", &path("public.der"), "-pubout", "-out", &path("public.pem")]);
388+ assert!(checked.is_some(), "openssl reads the public key built from the JWKS");
389+ let verified = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]);
390+ assert!(verified.is_some(), "the JWKS key verifies the token's signature");
391+ // And not a token whose claims were changed.
392+ std::fs::write(path("input"), format!("{input}A")).unwrap();
393+ let forged = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]);
394+ assert!(forged.is_none());
395+ let _ = std::fs::remove_dir_all(&dir);
396+ }
397+}
+11−0
88 use serde_json::{Map, Value, json};
99
1010 use crate::about::AboutOp;
11+use crate::artifacts::ArtifactsOp;
1112 use crate::deployments::DeploymentsOp;
1213 use crate::protection::ProtectionOp;
1314 use crate::operations::Op;
367368 Op::CancelWorkflowRun,
368369 Op::RerunWorkflowRun,
369370 Op::UpdateWorkflow,
371+ Op::Artifacts(ArtifactsOp::ListArtifacts),
372+ Op::Artifacts(ArtifactsOp::ListRunArtifacts),
373+ Op::Artifacts(ArtifactsOp::GetArtifact),
374+ Op::Artifacts(ArtifactsOp::DownloadArtifact),
375+ Op::Artifacts(ArtifactsOp::DeleteArtifact),
376+ Op::Artifacts(ArtifactsOp::GetArtifactRetention),
377+ Op::Artifacts(ArtifactsOp::SetArtifactRetention),
370378 ],
371379 ),
372380 (
396404 Op::Protection(ProtectionOp::GetForkPrApproval),
397405 Op::Protection(ProtectionOp::SetForkPrApproval),
398406 Op::Protection(ProtectionOp::CreateRepositoryDispatch),
407+ Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
408+ Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
399409 ],
400410 ),
401411 (
651661 Op::About(op) => op.title(),
652662 Op::Deployments(op) => op.title(),
653663 Op::Protection(op) => op.title(),
664+ Op::Artifacts(op) => op.title(),
654665 }
655666 }
656667
+24−1
2828 use crate::alerts::{AlertKind, SecurityAlert};
2929 use crate::checks::ChecksOp;
3030 use crate::about::AboutOp;
31+use crate::artifacts::ArtifactsOp;
3132 use crate::deployments::DeploymentsOp;
3233 use crate::protection::ProtectionOp;
3334 use crate::rules::RulesOp;
287288 /// Environments' protection rules, approving runs, the token's default
288289 /// permissions and repository dispatch: protection.rs.
289290 Protection(ProtectionOp),
291+ /// Workflow run artifacts, and how long they are kept: artifacts.rs.
292+ Artifacts(ArtifactsOp),
290293 }
291294
292295 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
649652 }
650653
651654 impl Op {
652− pub const ALL: [Op; 267] = [
655+ pub const ALL: [Op; 276] = [
653656 Op::Whoami,
654657 Op::GetWorkspace,
655658 Op::CreateWorkspace,
907910 Op::Deployments(DeploymentsOp::CreateDeploymentStatus),
908911 Op::Deployments(DeploymentsOp::ListEnvironments),
909912 Op::Deployments(DeploymentsOp::GetEnvironment),
913+ Op::Artifacts(ArtifactsOp::ListArtifacts),
914+ Op::Artifacts(ArtifactsOp::ListRunArtifacts),
915+ Op::Artifacts(ArtifactsOp::GetArtifact),
916+ Op::Artifacts(ArtifactsOp::DownloadArtifact),
917+ Op::Artifacts(ArtifactsOp::DeleteArtifact),
918+ Op::Artifacts(ArtifactsOp::GetArtifactRetention),
919+ Op::Artifacts(ArtifactsOp::SetArtifactRetention),
910920 Op::Protection(ProtectionOp::UpdateEnvironment),
911921 Op::Protection(ProtectionOp::DeleteEnvironment),
912922 Op::Protection(ProtectionOp::GetPendingDeployments),
917927 Op::Protection(ProtectionOp::GetForkPrApproval),
918928 Op::Protection(ProtectionOp::SetForkPrApproval),
919929 Op::Protection(ProtectionOp::CreateRepositoryDispatch),
930+ Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
931+ Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
920932 ];
921933
922934 pub fn by_name(name: &str) -> Option<Op> {
11111123 Op::About(op) => op.name(),
11121124 Op::Deployments(op) => op.name(),
11131125 Op::Protection(op) => op.name(),
1126+ Op::Artifacts(op) => op.name(),
11141127 }
11151128 }
11161129
16251638 Op::About(op) => op.description(),
16261639 Op::Deployments(op) => op.description(),
16271640 Op::Protection(op) => op.description(),
1641+ Op::Artifacts(op) => op.description(),
16281642 }
16291643 }
16301644
29973011 Op::About(op) => op.input(),
29983012 Op::Deployments(op) => op.input(),
29993013 Op::Protection(op) => op.input(),
3014+ Op::Artifacts(op) => op.input(),
30003015 }
30013016 }
30023017
30093024 if let Op::About(op) = self {
30103025 return !op.anonymous();
30113026 }
3027+ // A public repository's artifacts are anyone's to read.
3028+ if let Op::Artifacts(op) = self {
3029+ return op.writes();
3030+ }
30123031 !matches!(
30133032 self,
30143033 Op::ListRepos
30613080 if let Op::Security(op) = self {
30623081 return op.needs_repo();
30633082 }
3083+ if let Op::Protection(op) = self {
3084+ return op.needs_repo();
3085+ }
30643086 !matches!(
30653087 self,
30663088 Op::Whoami
50605082 Op::About(op) => crate::about::run(op, services, viewer, input).await,
50615083 Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await,
50625084 Op::Protection(op) => crate::protection::run(op, services, viewer, input).await,
5085+ Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await,
50635086 Op::ReopenSecurityAlert => {
50645087 let changed: Outcome<AlertChange> = call(
50655088 &services.security,
+94−12
2525 GetForkPrApproval,
2626 SetForkPrApproval,
2727 CreateRepositoryDispatch,
28+ GetWorkspaceWorkflowPermissions,
29+ SetWorkspaceWorkflowPermissions,
2830 }
2931
3032 impl ProtectionOp {
3133 /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test
3234 /// checks against this.
3335 #[cfg(test)]
34− pub const ALL: [ProtectionOp; 10] = [
36+ pub const ALL: [ProtectionOp; 12] = [
3537 ProtectionOp::UpdateEnvironment,
3638 ProtectionOp::DeleteEnvironment,
3739 ProtectionOp::GetPendingDeployments,
4244 ProtectionOp::GetForkPrApproval,
4345 ProtectionOp::SetForkPrApproval,
4446 ProtectionOp::CreateRepositoryDispatch,
47+ ProtectionOp::GetWorkspaceWorkflowPermissions,
48+ ProtectionOp::SetWorkspaceWorkflowPermissions,
4549 ];
4650
4751 pub fn name(self) -> &'static str {
5660 ProtectionOp::GetForkPrApproval => "get_fork_pr_approval",
5761 ProtectionOp::SetForkPrApproval => "set_fork_pr_approval",
5862 ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch",
63+ ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions",
64+ ProtectionOp::SetWorkspaceWorkflowPermissions => "set_workspace_workflow_permissions",
5965 }
6066 }
6167
68+ /// Whether it is about one repository, named by `repo`; the rest are a
69+ /// workspace's.
70+ pub fn needs_repo(self) -> bool {
71+ !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions)
72+ }
73+
6274 pub fn title(self) -> &'static str {
6375 match self {
6476 ProtectionOp::UpdateEnvironment => "Create or update an environment's protection rules",
7183 ProtectionOp::GetForkPrApproval => "Get the approval policy for outside pull requests",
7284 ProtectionOp::SetForkPrApproval => "Set the approval policy for outside pull requests",
7385 ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event",
86+ ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions",
87+ ProtectionOp::SetWorkspaceWorkflowPermissions => "Set a workspace's default workflow permissions",
7488 }
7589 }
7690
8195 ProtectionOp::GetPendingDeployments => "The environments whose protection rules hold a run's jobs, this attempt: each with the environment's name, state (waiting, approved or rejected), wait_timer and wait_until (when its timer lets its jobs start), its reviewers, the jobs it holds, who reviewed it and their comment, and current_user_can_approve. Needs the Read role.",
8296 ProtectionOp::ReviewPendingDeployments => "Approve or reject the jobs a run's environments hold. environment_names names them (every waiting one if left out; environment_ids is read as names too); state is approved or rejected; comment is kept with the review. Only one of the environment's reviewers may, or an admin when can_admins_bypass is on, which also skips the wait timer; with prevent_self_review, not whoever started the run. A rejected environment's jobs fail. A workflow job's own token cannot review. Returns the pending deployments as they stand.",
8397 ProtectionOp::ApproveWorkflowRun => "Let a run of a pull request from outside start: it waits as action_required, by the repository's approval policy (get_fork_pr_approval), until someone with the Write role approves it. A workflow job's own token cannot approve. Returns the run.",
84− ProtectionOp::GetWorkflowPermissions => "What a job's G1T_TOKEN (GITHUB_TOKEN) may do when its workflow and job write no `permissions:`: default_workflow_permissions is read (contents and packages read; the default) or write (every permission). can_approve_pull_request_reviews is always false: a job's token never approves pull requests. Needs the Read role.",
85− ProtectionOp::SetWorkflowPermissions => "Set default_workflow_permissions to read or write. Workflows that write `permissions:` get what they write either way, and a pull request's run from outside gets read-only. Needs the Admin role.",
98+ ProtectionOp::GetWorkflowPermissions => "What a job's G1T_TOKEN (GITHUB_TOKEN) may do when its workflow and job write no `permissions:`: default_workflow_permissions is read (contents and packages read) or write (every permission). Unless the repository chose (default_chosen), a repository made before restricted tokens has write and a newer one its workspace's default; it is never more than the workspace's max_workflow_permissions. can_approve_pull_request_reviews says whether its jobs may open and approve pull requests (off unless chosen, and only where the workspace allows it). Needs the Read role.",
99+ ProtectionOp::SetWorkflowPermissions => "Set default_workflow_permissions to read, write (refused where the workspace's maximum is read) or inherit (back to the workspace's default, or write for a repository made before restricted tokens), and can_approve_pull_request_reviews, \"Allow g1t Actions to create and approve pull requests\" (refused where the workspace does not allow it). Workflows that write `permissions:` get what they write either way, and a pull request's run from outside gets read-only. Needs the Admin role.",
86100 ProtectionOp::GetForkPrApproval => "Which pull requests' runs wait for someone with the Write role to approve them before anything runs (approve_workflow_run): approval_policy is first_time_contributors (a pull request from someone outside the workspace who has not had one merged here), outside_contributors (the default: also everyone outside who cannot push here) or all_external_contributors (everyone outside the workspace, outside collaborators included). Members never wait, nor does g1t's own work. Needs the Read role.",
87101 ProtectionOp::SetForkPrApproval => "Set approval_policy: first_time_contributors, outside_contributors or all_external_contributors. Needs the Admin role.",
102+ ProtectionOp::GetWorkspaceWorkflowPermissions => "A workspace's policy for its repositories' job tokens: default_workflow_permissions (read, the default, or write) is what a repository made from now on gets until it chooses; max_workflow_permissions (write, the default, or read) is the most any repository's default may be, so read holds every repository to read-only; can_approve_pull_request_reviews (off by default) lets its repositories allow jobs to open and approve pull requests. Members only.",
103+ ProtectionOp::SetWorkspaceWorkflowPermissions => "Change a workspace's default_workflow_permissions, max_workflow_permissions and can_approve_pull_request_reviews; fields left out stay as they are. A maximum of read makes the default read too. Owners only.",
88104 ProtectionOp::CreateRepositoryDispatch => "Start the default branch's workflows that run `on: repository_dispatch` for event_type (those listing it under types, or with none). client_payload, a JSON object of at most 10 properties and 64 KB, is github.event.client_payload; github.event.action is event_type. A workflow job's own token may send one: with workflow_dispatch, it is how one workflow starts another. Needs the Write role (code:write). Returns how many runs started.",
89105 }
90106 }
91107
92108 /// Whether it changes anything (the caller is its actor).
93109 pub fn writes(self) -> bool {
94− !matches!(self, ProtectionOp::GetPendingDeployments | ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval)
110+ !matches!(
111+ self,
112+ ProtectionOp::GetPendingDeployments
113+ | ProtectionOp::GetWorkflowPermissions
114+ | ProtectionOp::GetForkPrApproval
115+ | ProtectionOp::GetWorkspaceWorkflowPermissions
116+ )
95117 }
96118
97119 pub fn input(self) -> Value {
98120 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
99121 let run = json!({ "type": "string", "description": "The run's id, run_…." });
122+ let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." });
100123 let environment = json!({ "type": "string", "description": "The environment's name, such as production." });
101124 let (properties, required): (Value, &[&str]) = match self {
102125 ProtectionOp::UpdateEnvironment => (
155178 ProtectionOp::SetWorkflowPermissions => (
156179 json!({
157180 "repo": repo,
158− "default_workflow_permissions": { "type": "string", "enum": ["read", "write"] },
181+ "default_workflow_permissions": { "type": "string", "enum": ["read", "write", "inherit"] },
182+ "can_approve_pull_request_reviews": { "type": "boolean", "description": "Allow g1t Actions to create and approve pull requests." },
183+ }),
184+ &["repo"],
185+ ),
186+ ProtectionOp::GetWorkspaceWorkflowPermissions => (json!({ "workspace": workspace }), &["workspace"]),
187+ ProtectionOp::SetWorkspaceWorkflowPermissions => (
188+ json!({
189+ "workspace": workspace,
190+ "default_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "What new repositories get." },
191+ "max_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "The most any repository's default may be." },
192+ "can_approve_pull_request_reviews": { "type": "boolean", "description": "Let repositories allow jobs to open and approve pull requests." },
159193 }),
160− &["repo", "default_workflow_permissions"],
194+ &["workspace"],
161195 ),
162196 ProtectionOp::SetForkPrApproval => (
163197 json!({
384418 })
385419 }
386420
421+/// A workspace's policy, in the standard shape.
422+fn workspace_view(settings: &Value) -> Value {
423+ json!({
424+ "default_workflow_permissions": settings["defaultPermissions"],
425+ "max_workflow_permissions": settings["maxPermissions"],
426+ "can_approve_pull_request_reviews": settings["canApprovePullRequests"],
427+ })
428+}
429+
387430 pub async fn run(op: ProtectionOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
388− let Some(repo) = repo_path(input) else {
389− return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
390− };
391431 if op.writes() && viewer.is_none() {
392432 return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
393433 }
394434 let actor = || viewer.clone().unwrap_or_default();
395435 let actions = &services.actions;
436+ if !op.needs_repo() {
437+ let Some(workspace) = text(input, "workspace") else {
438+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
439+ };
440+ let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions {
441+ g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await?
442+ } else {
443+ g1t_kit::call(
444+ actions,
445+ "set_workspace_actions_settings",
446+ &json!({
447+ "actor": actor(),
448+ "workspace": workspace,
449+ "defaultPermissions": text(input, "default_workflow_permissions"),
450+ "maxPermissions": text(input, "max_workflow_permissions"),
451+ "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"),
452+ }),
453+ )
454+ .await?
455+ };
456+ return Ok(map(settings, |s| workspace_view(&s)));
457+ }
458+ let Some(repo) = repo_path(input) else {
459+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
460+ };
396461 let id = text(input, "id").unwrap_or_default();
397462 let environment = text(input, "environment").unwrap_or_default();
398463 Ok(match op {
445510 g1t_kit::call(
446511 actions,
447512 "set_actions_settings",
448− &json!({ "actor": actor(), "repo": repo, "defaultPermissions": text(input, "default_workflow_permissions").unwrap_or_default() }),
513+ &json!({
514+ "actor": actor(),
515+ "repo": repo,
516+ "defaultPermissions": text(input, "default_workflow_permissions"),
517+ "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"),
518+ }),
449519 )
450520 .await?
451521 };
452− map(settings, |s| json!({ "default_workflow_permissions": s["defaultPermissions"], "can_approve_pull_request_reviews": false }))
522+ map(settings, |s| {
523+ json!({
524+ "default_workflow_permissions": s["defaultPermissions"],
525+ "default_chosen": s["defaultChosen"],
526+ "max_workflow_permissions": s["maxPermissions"],
527+ "can_approve_pull_request_reviews": s["canApprovePullRequests"],
528+ })
529+ })
453530 }
454531 ProtectionOp::GetForkPrApproval | ProtectionOp::SetForkPrApproval => {
455532 let settings: Outcome<Value> = if op == ProtectionOp::GetForkPrApproval {
478555 .await?;
479556 map(started, |runs| json!({ "runs": runs }))
480557 }
558+ // Answered above, before a repository is read.
559+ ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions => {
560+ Outcome::fail(FailureCode::Invalid, "Name the workspace.")
561+ }
481562 })
482563 }
483564
525606 fn each_operation_is_described_with_a_schema() {
526607 for op in ProtectionOp::ALL {
527608 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
528− assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
609+ let needs = if op.needs_repo() { "repo" } else { "workspace" };
610+ assert!(op.input()["required"].as_array().unwrap().contains(&json!(needs)), "{}", op.name());
529611 }
530612 }
531613 }
+211−0
1011210112 },
1011310113 "response": {
1011410114 "default_workflow_permissions": "read",
10115+ "default_chosen": true,
10116+ "max_workflow_permissions": "write",
1011510117 "can_approve_pull_request_reviews": false
1011610118 }
1011710119 },
1012510127 },
1012610128 "response": {
1012710129 "default_workflow_permissions": "write",
10130+ "default_chosen": true,
10131+ "max_workflow_permissions": "write",
1012810132 "can_approve_pull_request_reviews": false
1012910133 }
1013010134 },
1016410168 "runs": 1
1016510169 }
1016610170 },
10171+ "get_workspace_workflow_permissions": {
10172+ "params": {
10173+ "workspace": "flagon-io"
10174+ },
10175+ "response": {
10176+ "default_workflow_permissions": "read",
10177+ "max_workflow_permissions": "write",
10178+ "can_approve_pull_request_reviews": false
10179+ }
10180+ },
10181+ "set_workspace_workflow_permissions": {
10182+ "params": {
10183+ "workspace": "flagon-io"
10184+ },
10185+ "request": {
10186+ "max_workflow_permissions": "read"
10187+ },
10188+ "response": {
10189+ "default_workflow_permissions": "read",
10190+ "max_workflow_permissions": "read",
10191+ "can_approve_pull_request_reviews": false
10192+ },
10193+ "notes": "A maximum of read holds every repository's default to read-only, and makes the workspace's default read too."
10194+ },
1016710195 "get_languages": {
1016810196 "response": {
1016910197 "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
1087010898 "response": {
1087110899 "rerequested": true
1087210900 }
10901+ },
10902+ "list_artifacts": {
10903+ "params": {
10904+ "owner": "flagon-io",
10905+ "name": "g1t"
10906+ },
10907+ "query": {
10908+ "name": "web-dist",
10909+ "per_page": "1"
10910+ },
10911+ "response": {
10912+ "total_count": 9,
10913+ "artifacts": [
10914+ {
10915+ "id": 4182,
10916+ "node_id": "artifact_4182",
10917+ "name": "web-dist",
10918+ "size_in_bytes": 18734120,
10919+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182",
10920+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip",
10921+ "expired": false,
10922+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
10923+ "created_at": "2026-10-08T14:03:51.204Z",
10924+ "updated_at": "2026-10-08T14:03:52.880Z",
10925+ "expires_at": "2026-10-22T14:03:51.204Z",
10926+ "workflow_run": {
10927+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
10928+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10929+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10930+ "head_branch": "main",
10931+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
10932+ }
10933+ }
10934+ ]
10935+ },
10936+ "notes": "Expired and deleted artifacts are not listed. `workflow_run.id` is the run's id, which `get_workflow_run` takes."
10937+ },
10938+ "list_workflow_run_artifacts": {
10939+ "params": {
10940+ "owner": "flagon-io",
10941+ "name": "g1t",
10942+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z"
10943+ },
10944+ "response": {
10945+ "total_count": 2,
10946+ "artifacts": [
10947+ {
10948+ "id": 4181,
10949+ "node_id": "artifact_4181",
10950+ "name": "coverage",
10951+ "size_in_bytes": 412870,
10952+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4181",
10953+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4181/zip",
10954+ "expired": false,
10955+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
10956+ "created_at": "2026-10-08T14:03:51.204Z",
10957+ "updated_at": "2026-10-08T14:03:52.880Z",
10958+ "expires_at": "2026-10-22T14:03:51.204Z",
10959+ "workflow_run": {
10960+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
10961+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10962+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10963+ "head_branch": "main",
10964+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
10965+ }
10966+ },
10967+ {
10968+ "id": 4182,
10969+ "node_id": "artifact_4182",
10970+ "name": "web-dist",
10971+ "size_in_bytes": 18734120,
10972+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182",
10973+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip",
10974+ "expired": false,
10975+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
10976+ "created_at": "2026-10-08T14:03:51.204Z",
10977+ "updated_at": "2026-10-08T14:03:52.880Z",
10978+ "expires_at": "2026-10-22T14:03:51.204Z",
10979+ "workflow_run": {
10980+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
10981+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10982+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10983+ "head_branch": "main",
10984+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
10985+ }
10986+ }
10987+ ]
10988+ }
10989+ },
10990+ "get_artifact": {
10991+ "params": {
10992+ "owner": "flagon-io",
10993+ "name": "g1t",
10994+ "id": "4182"
10995+ },
10996+ "response": {
10997+ "id": 4182,
10998+ "node_id": "artifact_4182",
10999+ "name": "web-dist",
11000+ "size_in_bytes": 18734120,
11001+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182",
11002+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip",
11003+ "expired": false,
11004+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
11005+ "created_at": "2026-10-08T14:03:51.204Z",
11006+ "updated_at": "2026-10-08T14:03:52.880Z",
11007+ "expires_at": "2026-10-22T14:03:51.204Z",
11008+ "workflow_run": {
11009+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
11010+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11011+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11012+ "head_branch": "main",
11013+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
11014+ }
11015+ }
11016+ },
11017+ "download_artifact": {
11018+ "params": {
11019+ "owner": "flagon-io",
11020+ "name": "g1t",
11021+ "id": "4182"
11022+ },
11023+ "response": {
11024+ "url": "https://api.g1t.sh/actions/toolkit/blobs/eyJrIjoiYXJ0aWZhY3QiLCJpIjoiNDE4MiJ9.q3VbS1x9",
11025+ "expires_at": "2026-10-08T15:13:00.000Z",
11026+ "artifact": {
11027+ "id": 4182,
11028+ "node_id": "artifact_4182",
11029+ "name": "web-dist",
11030+ "size_in_bytes": 18734120,
11031+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182",
11032+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip",
11033+ "expired": false,
11034+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
11035+ "created_at": "2026-10-08T14:03:51.204Z",
11036+ "updated_at": "2026-10-08T14:03:52.880Z",
11037+ "expires_at": "2026-10-22T14:03:51.204Z",
11038+ "workflow_run": {
11039+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
11040+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11041+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11042+ "head_branch": "main",
11043+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
11044+ }
11045+ }
11046+ },
11047+ "notes": "Over REST the answer is `302 Found` with the link in `Location`, as GitHub's is, so `curl -L -o web-dist.zip …/zip` saves the file; the body above is what the MCP `workflow` tool's `download_artifact` returns. The link needs no token and stops working after 10 minutes."
11048+ },
11049+ "delete_artifact": {
11050+ "params": {
11051+ "owner": "flagon-io",
11052+ "name": "g1t",
11053+ "id": "4181"
11054+ },
11055+ "response": {
11056+ "deleted": true,
11057+ "id": 4181,
11058+ "name": "coverage"
11059+ }
11060+ },
11061+ "get_artifact_retention": {
11062+ "params": {
11063+ "owner": "flagon-io",
11064+ "name": "g1t"
11065+ },
11066+ "response": {
11067+ "days": 14,
11068+ "maximum_allowed_days": 90
11069+ }
11070+ },
11071+ "set_artifact_retention": {
11072+ "params": {
11073+ "owner": "flagon-io",
11074+ "name": "g1t"
11075+ },
11076+ "request": {
11077+ "days": 30
11078+ },
11079+ "response": {
11080+ "days": 30,
11081+ "maximum_allowed_days": 90
11082+ },
11083+ "notes": "Only artifacts uploaded afterwards are kept the new number of days. A workflow's `retention-days` asks for fewer, never more."
1087311084 }
1087411085 }
+2−0
112112 Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is,
113113 // Deployments travel in `snake_case` between services too.
114114 Op::Deployments(_) => return as_is,
115+ // Artifacts are shaped by the API itself, in `snake_case`.
116+ Op::Artifacts(_) => return as_is,
115117 // Built by the API itself, in `snake_case`.
116118 Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is),
117119 Op::DismissSecurityAlert | Op::ReopenSecurityAlert => {
+22−0
33 use serde_json::{Map, Value};
44
55 use crate::about::AboutOp;
6+use crate::artifacts::ArtifactsOp;
67 use crate::deployments::DeploymentsOp;
78 use crate::protection::ProtectionOp;
89 use crate::operations::Op;
355356 &[],
356357 ),
357358 route("POST", "/repos/:owner/:name/dispatches", Op::Protection(ProtectionOp::CreateRepositoryDispatch), &[]),
359+ route(
360+ "GET",
361+ "/workspaces/:workspace/actions/permissions/workflow",
362+ Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
363+ &[],
364+ ),
365+ route(
366+ "PUT",
367+ "/workspaces/:workspace/actions/permissions/workflow",
368+ Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
369+ &[],
370+ ),
358371 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
359372 route(
360373 "POST",
667680 Op::GetJobLogs,
668681 &[("after", "after")],
669682 ),
683+ // Artifacts, at GitHub's addresses. `…/zip` answers with a redirect to
684+ // a signed link (lib.rs).
685+ route("GET", "/repos/:owner/:name/actions/artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), &[("name", "name"), ("page", "page"), ("per_page", "per_page")]),
686+ route("GET", "/repos/:owner/:name/actions/runs/:id/artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), &[("name", "name")]),
687+ route("GET", "/repos/:owner/:name/actions/artifacts/:id", Op::Artifacts(ArtifactsOp::GetArtifact), &[]),
688+ route("GET", "/repos/:owner/:name/actions/artifacts/:id/zip", Op::Artifacts(ArtifactsOp::DownloadArtifact), &[]),
689+ route("DELETE", "/repos/:owner/:name/actions/artifacts/:id", Op::Artifacts(ArtifactsOp::DeleteArtifact), &[]),
690+ route("GET", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::GetArtifactRetention), &[]),
691+ route("PUT", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), &[]),
670692 route(
671693 "GET",
672694 "/repos/:owner/:name/actions/secrets",
+757−0
1+//! The services GitHub's Actions toolkit calls from inside a job, so that
2+//! actions built on `@actions/cache` and `@actions/artifact` (such as
3+//! `actions/setup-node` with `cache:`, or `Swatinem/rust-cache`) work on
4+//! g1t unchanged. A job is told where they are in its variables
5+//! (`ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`,
6+//! `ACTIONS_CACHE_SERVICE_V2`; see `runtime_variables`).
7+//!
8+//! - Twirp, at `/twirp/github.actions.results.api.v1.CacheService/…` and
9+//! `…ArtifactService/…`: the cache's newer protocol (`CreateCacheEntry`,
10+//! `FinalizeCacheEntryUpload`, `GetCacheEntryDownloadURL`) and the
11+//! artifacts' (`CreateArtifact`, `FinalizeArtifact`, `ListArtifacts`,
12+//! `GetSignedArtifactURL`, `DeleteArtifact`). JSON, the toolkit's field
13+//! names.
14+//! - The cache's older protocol, at `{ACTIONS_CACHE_URL}_apis/artifactcache/…`,
15+//! which the toolkit's client uses whenever the server it runs against is
16+//! not github.com: on g1t, that is the one it uses.
17+//! - Blobs, at `/actions/toolkit/blobs/{token}`: the signed links those
18+//! hand out. Downloads are a plain GET. Uploads speak the part of Azure
19+//! Blob Storage's protocol the toolkit's client uses (Put Blob, Put
20+//! Block, Put Block List), mapped onto an R2 multipart upload: a block's
21+//! id ends in its index, which is its part's number.
22+//!
23+//! Every call carries the job's runtime token; the actions service checks
24+//! it and keeps the entries (cache.rs, artifacts.rs, runtime.rs there).
25+
26+use base64::Engine;
27+use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
28+use g1t_contracts::actions::{
29+ ARTIFACT_MAX_BYTES, Artifact, ArtifactBlob, ArtifactCommitArgs, ArtifactReservation, ArtifactReserveArgs, BlobArgs, BlobGrant, BlobPart,
30+ BlobSignArgs, CACHE_MAX_ENTRY_BYTES, CACHE_PART_BYTES, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation,
31+ CacheReserveArgs, CacheUploadArgs, JobArtifactsArgs,
32+};
33+use g1t_contracts::{Failure, FailureCode, Outcome};
34+use serde_json::{Map, Value, json};
35+use worker::{Bucket, Env, Request, Response, Result, UploadedPart};
36+
37+use crate::artifacts::blob_url;
38+use crate::operations::Services;
39+
40+/// The Twirp services, by name.
41+pub const CACHE_SERVICE: &str = "github.actions.results.api.v1.CacheService";
42+pub const ARTIFACT_SERVICE: &str = "github.actions.results.api.v1.ArtifactService";
43+/// Where the cache's older protocol is: `ACTIONS_CACHE_URL`.
44+pub const CACHE_PATH: &str = "/actions/toolkit/";
45+/// The largest single block or blob a request may carry.
46+const MAX_BLOCK_BYTES: u64 = 256 * 1024 * 1024;
47+
48+/// The bearer token of a request.
49+pub fn bearer(request: &Request) -> String {
50+ request
51+ .headers()
52+ .get("authorization")
53+ .ok()
54+ .flatten()
55+ .and_then(|h| h.split_once(' ').map(|(_, t)| t.trim().to_owned()))
56+ .unwrap_or_default()
57+}
58+
59+fn claims(token: &str) -> Option<Value> {
60+ serde_json::from_slice(&URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?).ok()
61+}
62+
63+/// The job a runtime token names, unchecked: the actions service checks it.
64+pub fn runtime_job(token: &str) -> Option<String> {
65+ claims(token)?["job"].as_str().map(str::to_owned)
66+}
67+
68+/// The variables a job gets for the toolkit: its runtime token and where
69+/// the services are, and where to ask for an OIDC token when it may.
70+pub fn runtime_variables(api: &str, token: &str, id_token: bool) -> Map<String, Value> {
71+ let mut vars = Map::new();
72+ let mut set = |k: &str, v: String| {
73+ vars.insert(k.to_owned(), Value::String(v));
74+ };
75+ set("ACTIONS_RUNTIME_TOKEN", token.to_owned());
76+ set("ACTIONS_RESULTS_URL", format!("{api}/"));
77+ set("ACTIONS_CACHE_URL", format!("{api}{CACHE_PATH}"));
78+ set("ACTIONS_CACHE_SERVICE_V2", "True".to_owned());
79+ if id_token {
80+ set("ACTIONS_ID_TOKEN_REQUEST_URL", format!("{}/token?api-version=2.0", crate::oidc::issuer(api)));
81+ set("ACTIONS_ID_TOKEN_REQUEST_TOKEN", token.to_owned());
82+ }
83+ vars
84+}
85+
86+// ── Twirp ───────────────────────────────────────────────────────────────────
87+
88+/// A Twirp error: its code and message, at the status Twirp gives it.
89+fn twirp_error(code: &str, message: &str) -> Result<Response> {
90+ let status = match code {
91+ "unauthenticated" => 401,
92+ "permission_denied" => 403,
93+ "not_found" => 404,
94+ "already_exists" => 409,
95+ "invalid_argument" => 400,
96+ "failed_precondition" => 412,
97+ "resource_exhausted" => 429,
98+ _ => 500,
99+ };
100+ Ok(Response::from_json(&json!({ "code": code, "msg": message }))?.with_status(status))
101+}
102+
103+fn twirp_failure(failure: &Failure) -> Result<Response> {
104+ let code = match failure.code {
105+ FailureCode::Unauthenticated => "unauthenticated",
106+ FailureCode::Forbidden => "permission_denied",
107+ FailureCode::NotFound => "not_found",
108+ FailureCode::Conflict => "already_exists",
109+ FailureCode::Invalid => "invalid_argument",
110+ _ => "failed_precondition",
111+ };
112+ twirp_error(code, &failure.message)
113+}
114+
115+/// A field in the toolkit's spelling (`snake_case`), or its JSON name.
116+fn field<'a>(body: &'a Value, name: &str) -> &'a Value {
117+ if !body[name].is_null() {
118+ return &body[name];
119+ }
120+ let camel: String = name.split('_').enumerate().map(|(i, p)| if i == 0 { p.to_owned() } else { p[..1].to_uppercase() + &p[1..] }).collect();
121+ &body[camel]
122+}
123+
124+fn text(body: &Value, name: &str) -> String {
125+ match field(body, name) {
126+ Value::String(s) => s.clone(),
127+ Value::Number(n) => n.to_string(),
128+ // A wrapper written as an object, `{ "value": … }`.
129+ Value::Object(o) => o.get("value").map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_owned)).unwrap_or_default(),
130+ _ => String::new(),
131+ }
132+}
133+
134+fn number(body: &Value, name: &str) -> Option<u64> {
135+ text(body, name).trim().parse().ok()
136+}
137+
138+/// The run and job a runtime token names, which a request's backend ids
139+/// must match.
140+fn backend_ids(token: &str) -> (String, String) {
141+ let c = claims(token).unwrap_or_default();
142+ (c["run"].as_str().unwrap_or_default().to_owned(), c["job"].as_str().unwrap_or_default().to_owned())
143+}
144+
145+/// What the toolkit's artifact client lists.
146+fn listed(artifact: &Artifact) -> Value {
147+ json!({
148+ "workflow_run_backend_id": artifact.run_id,
149+ "workflow_job_run_backend_id": artifact.job_id,
150+ "database_id": artifact.id.to_string(),
151+ "name": artifact.name,
152+ "size": artifact.size.to_string(),
153+ "created_at": artifact.created_at,
154+ "digest": artifact.digest,
155+ })
156+}
157+
158+/// Starts an R2 upload for an entry the service reserved, and the signed
159+/// link the toolkit sends it to.
160+async fn start_upload(bucket: &Bucket, services: &Services, job: &str, token: &str, kind: &str, id: &str, object: &str) -> Result<Outcome<String>> {
161+ let upload = bucket.create_multipart_upload(object).execute().await?;
162+ let upload = upload.upload_id().await;
163+ let signed: Outcome<String> = g1t_kit::call(
164+ &services.actions,
165+ "blob_sign",
166+ &BlobSignArgs { job: job.to_owned(), token: token.to_owned(), kind: kind.to_owned(), id: id.to_owned(), upload },
167+ )
168+ .await?;
169+ Ok(match signed {
170+ Outcome::Ok(blob) => Outcome::Ok(blob_url(&services.addresses.api, &blob)),
171+ Outcome::Fail(refused) => Outcome::Fail(refused),
172+ })
173+}
174+
175+/// `POST /twirp/{service}/{method}`.
176+pub async fn twirp(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> {
177+ let token = bearer(&request);
178+ let Some(job) = runtime_job(&token) else {
179+ return twirp_error("unauthenticated", "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token.");
180+ };
181+ let body: Value = request.json().await.unwrap_or(Value::Null);
182+ let bucket = env.bucket("ACTIONS_CACHE")?;
183+ let actions = &services.actions;
184+ let (run, own_job) = backend_ids(&token);
185+ // An artifact call names its run, and for an upload its job: the
186+ // token's.
187+ if service == ARTIFACT_SERVICE {
188+ let asked_run = text(&body, "workflow_run_backend_id");
189+ if !asked_run.is_empty() && asked_run != run {
190+ return twirp_error("permission_denied", "The runtime token is for another run.");
191+ }
192+ let asked_job = text(&body, "workflow_job_run_backend_id");
193+ if matches!(method, "CreateArtifact" | "FinalizeArtifact") && !asked_job.is_empty() && asked_job != own_job {
194+ return twirp_error("permission_denied", "The runtime token is for another job.");
195+ }
196+ }
197+ match (service, method) {
198+ (CACHE_SERVICE, "GetCacheEntryDownloadURL") => {
199+ let restore: Vec<String> = field(&body, "restore_keys").as_array().map(|k| k.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default();
200+ let args = CacheLookupArgs { job, token, key: text(&body, "key"), restore, version: Some(text(&body, "version")) };
201+ let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?;
202+ match found {
203+ Outcome::Ok(Some(CacheHit { key, blob: Some(blob), .. })) => {
204+ Response::from_json(&json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key }))
205+ }
206+ Outcome::Ok(_) => Response::from_json(&json!({ "ok": false, "signed_download_url": "", "matched_key": "" })),
207+ Outcome::Fail(refused) => twirp_failure(&refused),
208+ }
209+ }
210+ (CACHE_SERVICE, "CreateCacheEntry") => {
211+ let args = CacheReserveArgs { job: job.clone(), token: token.clone(), key: text(&body, "key"), size: 0, version: Some(text(&body, "version")) };
212+ let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?;
213+ let reserved = match reserved {
214+ Outcome::Ok(reserved) => reserved,
215+ // The client warns with this and goes on, as for a key
216+ // another job is saving.
217+ Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
218+ };
219+ match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? {
220+ Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })),
221+ Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
222+ }
223+ }
224+ (CACHE_SERVICE, "FinalizeCacheEntryUpload") => {
225+ let args = CacheUploadArgs { job: job.clone(), token: token.clone(), number: None, key: Some(text(&body, "key")), version: Some(text(&body, "version")) };
226+ let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?;
227+ let pending = match pending {
228+ Outcome::Ok(pending) => pending,
229+ Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })),
230+ };
231+ let Some(object) = bucket.head(&pending.object).await? else {
232+ return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." }));
233+ };
234+ match commit_cache(&bucket, services, &job, &token, &pending.id, object.size()).await? {
235+ Outcome::Ok(()) => Response::from_json(&json!({ "ok": true, "entry_id": pending.number.to_string() })),
236+ Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })),
237+ }
238+ }
239+ (ARTIFACT_SERVICE, "CreateArtifact") => {
240+ let expires_at = Some(text(&body, "expires_at")).filter(|e| !e.is_empty());
241+ let args = ArtifactReserveArgs { job: job.clone(), token: token.clone(), name: text(&body, "name"), expires_at, ..ArtifactReserveArgs::default() };
242+ let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?;
243+ let reserved = match reserved {
244+ Outcome::Ok(reserved) => reserved,
245+ Outcome::Fail(refused) => return twirp_failure(&refused),
246+ };
247+ match start_upload(&bucket, services, &job, &token, "artifact", &reserved.id.to_string(), &reserved.object).await? {
248+ Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })),
249+ Outcome::Fail(refused) => twirp_failure(&refused),
250+ }
251+ }
252+ (ARTIFACT_SERVICE, "FinalizeArtifact") => {
253+ let digest = Some(text(&body, "hash")).filter(|h| !h.is_empty());
254+ // The size it was measured at as it was stored, not the one it
255+ // says.
256+ let args = ArtifactCommitArgs { job, token, id: None, name: Some(text(&body, "name")), size: 0, digest };
257+ let done: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?;
258+ match done {
259+ Outcome::Ok(artifact) => Response::from_json(&json!({ "ok": true, "artifact_id": artifact.id.to_string() })),
260+ Outcome::Fail(refused) => twirp_failure(&refused),
261+ }
262+ }
263+ (ARTIFACT_SERVICE, "ListArtifacts") => {
264+ let name = Some(text(&body, "name_filter")).filter(|n| !n.is_empty());
265+ let id = number(&body, "id_filter");
266+ let args = JobArtifactsArgs { job, token, run_id: None, name, id };
267+ let found: Outcome<Vec<Artifact>> = g1t_kit::call(actions, "job_artifacts", &args).await?;
268+ match found {
269+ Outcome::Ok(found) => Response::from_json(&json!({ "artifacts": found.iter().map(listed).collect::<Vec<_>>() })),
270+ Outcome::Fail(refused) => twirp_failure(&refused),
271+ }
272+ }
273+ (ARTIFACT_SERVICE, "GetSignedArtifactURL") => {
274+ let args = JobArtifactsArgs { job, token, run_id: None, name: Some(text(&body, "name")), id: None };
275+ let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &args).await?;
276+ match found {
277+ Outcome::Ok(found) if !found.blob.is_empty() => Response::from_json(&json!({ "signed_url": blob_url(&services.addresses.api, &found.blob) })),
278+ Outcome::Ok(_) => twirp_error("failed_precondition", "Download links are not set up on this installation."),
279+ Outcome::Fail(refused) => twirp_failure(&refused),
280+ }
281+ }
282+ (ARTIFACT_SERVICE, "DeleteArtifact") => {
283+ let args = JobArtifactsArgs { job, token, run_id: None, name: Some(text(&body, "name")), id: None };
284+ let done: Outcome<Artifact> = g1t_kit::call(actions, "job_delete_artifact", &args).await?;
285+ match done {
286+ Outcome::Ok(artifact) => Response::from_json(&json!({ "ok": true, "artifact_id": artifact.id.to_string() })),
287+ Outcome::Fail(refused) => twirp_failure(&refused),
288+ }
289+ }
290+ _ => twirp_error("bad_route", &format!("No method {method} on {service}.")),
291+ }
292+}
293+
294+/// Marks an uploaded cache entry ready, and deletes what that evicted.
295+async fn commit_cache(bucket: &Bucket, services: &Services, job: &str, token: &str, id: &str, size: u64) -> Result<Outcome<()>> {
296+ let committed: Outcome<CacheCommitted> = g1t_kit::call(
297+ &services.actions,
298+ "cache_commit",
299+ &CacheCommitArgs { job: job.to_owned(), token: token.to_owned(), id: id.to_owned(), size },
300+ )
301+ .await?;
302+ Ok(match committed {
303+ Outcome::Ok(committed) => {
304+ if !committed.evicted.is_empty() {
305+ bucket.delete_multiple(committed.evicted.iter().map(String::as_str).collect()).await?;
306+ }
307+ Outcome::Ok(())
308+ }
309+ Outcome::Fail(refused) => Outcome::Fail(refused),
310+ })
311+}
312+
313+// ── The cache's older protocol ──────────────────────────────────────────────
314+
315+fn plain_error(status: u16, message: &str) -> Result<Response> {
316+ Ok(Response::from_json(&json!({ "message": message, "error": { "message": message } }))?.with_status(status))
317+}
318+
319+fn query(request: &Request, name: &str) -> Option<String> {
320+ request.url().ok()?.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned())
321+}
322+
323+/// The part a chunk of the older protocol is, from its `Content-Range`:
324+/// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them.
325+pub fn chunk_part(range: &str) -> Option<(u16, u64)> {
326+ let range = range.trim().strip_prefix("bytes ")?;
327+ let (span, _) = range.split_once('/')?;
328+ let (start, end) = span.split_once('-')?;
329+ let (start, end): (u64, u64) = (start.trim().parse().ok()?, end.trim().parse().ok()?);
330+ if end < start || start % CACHE_PART_BYTES != 0 || end - start + 1 > CACHE_PART_BYTES {
331+ return None;
332+ }
333+ Some(((start / CACHE_PART_BYTES + 1) as u16, end - start + 1))
334+}
335+
336+/// `{ACTIONS_CACHE_URL}_apis/artifactcache/…`. `rest` is the path after it.
337+pub async fn cache_v1(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> {
338+ let token = bearer(&request);
339+ let Some(job) = runtime_job(&token) else {
340+ return plain_error(401, "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token.");
341+ };
342+ let bucket = env.bucket("ACTIONS_CACHE")?;
343+ let actions = &services.actions;
344+ let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
345+ match (method, parts.as_slice()) {
346+ ("GET", ["cache"]) => {
347+ let keys: Vec<String> = query(&request, "keys").unwrap_or_default().split(',').map(|k| k.trim().to_owned()).filter(|k| !k.is_empty()).collect();
348+ let Some((key, restore)) = keys.split_first() else {
349+ return plain_error(400, "Give keys.");
350+ };
351+ let version = query(&request, "version").unwrap_or_default();
352+ let args = CacheLookupArgs { job, token, key: key.clone(), restore: restore.to_vec(), version: Some(version.clone()) };
353+ let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?;
354+ match found {
355+ Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => Response::from_json(&json!({
356+ "cacheKey": key,
357+ "cacheVersion": version,
358+ "scope": "",
359+ "creationTime": created_at,
360+ "archiveLocation": blob_url(&services.addresses.api, &blob),
361+ })),
362+ Outcome::Ok(_) => Ok(Response::empty()?.with_status(204)),
363+ Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message),
364+ }
365+ }
366+ ("POST", ["caches"]) => {
367+ let body: Value = request.json().await.unwrap_or(Value::Null);
368+ let size = body["cacheSize"].as_u64().unwrap_or(0);
369+ let key = body["key"].as_str().unwrap_or_default().to_owned();
370+ let version = body["version"].as_str().unwrap_or_default().to_owned();
371+ let args = CacheReserveArgs { job: job.clone(), token: token.clone(), key, size, version: Some(version) };
372+ let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?;
373+ let reserved = match reserved {
374+ Outcome::Ok(reserved) => reserved,
375+ Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message),
376+ };
377+ match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? {
378+ Outcome::Ok(_) => Ok(Response::from_json(&json!({ "cacheId": reserved.number }))?.with_status(201)),
379+ Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message),
380+ }
381+ }
382+ (_, ["caches", number]) => {
383+ let Ok(number) = number.parse::<u64>() else {
384+ return plain_error(404, "No such cache entry.");
385+ };
386+ let args = CacheUploadArgs { job: job.clone(), token: token.clone(), number: Some(number), key: None, version: None };
387+ let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?;
388+ let pending = match pending {
389+ Outcome::Ok(pending) => pending,
390+ Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message),
391+ };
392+ let (Some(blob), Some(upload_id)) = (pending.blob.clone(), pending.upload.clone()) else {
393+ return plain_error(409, "That entry's upload was not started.");
394+ };
395+ match method {
396+ "PATCH" => {
397+ let range = request.headers().get("content-range")?.unwrap_or_default();
398+ let Some((part, length)) = chunk_part(&range) else {
399+ return plain_error(400, &format!("Send the entry in chunks of {} MB, each with its Content-Range.", CACHE_PART_BYTES / 1_048_576));
400+ };
401+ let Some(body) = request.inner().body() else { return plain_error(400, "The chunk is empty.") };
402+ let upload = bucket.resume_multipart_upload(&pending.object, &upload_id)?;
403+ let uploaded = upload.upload_part(part, body).await?;
404+ let recorded = BlobArgs { blob, part: u32::from(part), etag: uploaded.etag(), size: length };
405+ let _: Outcome<bool> = g1t_kit::call(actions, "blob_part", &recorded).await?;
406+ Ok(Response::empty()?.with_status(204))
407+ }
408+ "POST" => {
409+ let parts: Outcome<Vec<BlobPart>> = g1t_kit::call(actions, "blob_parts", &BlobArgs { blob: blob.clone(), ..BlobArgs::default() }).await?;
410+ let parts = match parts {
411+ Outcome::Ok(parts) => parts,
412+ Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message),
413+ };
414+ let size = match finish(&bucket, &pending.object, &upload_id, &parts).await {
415+ Ok(size) => size,
416+ Err(problem) => return plain_error(400, &format!("The entry could not be completed: {problem}")),
417+ };
418+ let _: Outcome<bool> = g1t_kit::call(actions, "blob_done", &BlobArgs { blob, size, ..BlobArgs::default() }).await?;
419+ match commit_cache(&bucket, services, &job, &token, &pending.id, size).await? {
420+ Outcome::Ok(()) => Ok(Response::empty()?.with_status(204)),
421+ Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message),
422+ }
423+ }
424+ _ => plain_error(405, "PATCH a chunk, or POST to commit."),
425+ }
426+ }
427+ _ => plain_error(404, "No such endpoint."),
428+ }
429+}
430+
431+/// Completes an R2 upload from its recorded parts, in order; the size it
432+/// came to. An upload with no parts is an empty object.
433+async fn finish(bucket: &Bucket, object: &str, upload_id: &str, parts: &[BlobPart]) -> std::result::Result<u64, String> {
434+ let upload = bucket.resume_multipart_upload(object, upload_id).map_err(|e| e.to_string())?;
435+ if parts.is_empty() {
436+ let _ = upload.abort().await;
437+ bucket.put(object, Vec::<u8>::new()).execute().await.map_err(|e| e.to_string())?;
438+ return Ok(0);
439+ }
440+ let done = upload
441+ .complete(parts.iter().map(|p| UploadedPart::new(p.part as u16, p.etag.clone())))
442+ .await
443+ .map_err(|e| e.to_string())?;
444+ Ok(done.size())
445+}
446+
447+// ── Blobs ───────────────────────────────────────────────────────────────────
448+
449+/// A block's index, from its id: the toolkit's client (Azure's SDK) makes
450+/// block ids as base64 of a prefix and the index padded with zeros.
451+pub fn block_index(id: &str) -> Option<u32> {
452+ let decoded = STANDARD.decode(id.trim()).ok()?;
453+ let text = String::from_utf8(decoded).ok()?;
454+ let digits: String = text.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
455+ if digits.is_empty() {
456+ return None;
457+ }
458+ digits.parse().ok()
459+}
460+
461+/// The block ids of a Put Block List body, in order.
462+pub fn block_list(xml: &str) -> Vec<String> {
463+ let mut ids = Vec::new();
464+ let mut rest = xml;
465+ while let Some(open) = rest.find('<') {
466+ rest = &rest[open + 1..];
467+ let Some(close) = rest.find('>') else { break };
468+ let tag = &rest[..close];
469+ rest = &rest[close + 1..];
470+ if matches!(tag, "Latest" | "Committed" | "Uncommitted") {
471+ let Some(end) = rest.find("</") else { break };
472+ ids.push(rest[..end].trim().to_owned());
473+ rest = &rest[end..];
474+ }
475+ }
476+ ids
477+}
478+
479+/// The parts a block list names, as recorded: each block must have been
480+/// sent, as the part its index says, and they must run from the first.
481+pub fn parts_for(ids: &[String], recorded: &[BlobPart]) -> std::result::Result<Vec<BlobPart>, String> {
482+ let mut out = Vec::with_capacity(ids.len());
483+ for (position, id) in ids.iter().enumerate() {
484+ let index = block_index(id).ok_or_else(|| format!("The block id {id} does not end in its index."))?;
485+ let part = index + 1;
486+ if part as usize != position + 1 {
487+ return Err("The blocks must be listed in the order they were numbered.".to_owned());
488+ }
489+ let found = recorded.iter().find(|p| p.part == part).ok_or_else(|| format!("Block {id} was never sent."))?;
490+ out.push(found.clone());
491+ }
492+ Ok(out)
493+}
494+
495+fn azure(status: u16) -> Result<Response> {
496+ let mut response = Response::empty()?.with_status(status);
497+ let headers = response.headers_mut();
498+ headers.set("x-ms-request-id", &g1t_contracts::new_id("req", g1t_kit::now_ms()))?;
499+ headers.set("x-ms-version", "2024-11-04")?;
500+ headers.set("x-ms-request-server-encrypted", "true")?;
501+ Ok(response)
502+}
503+
504+fn azure_error(status: u16, code: &str, message: &str) -> Result<Response> {
505+ let body = format!("<?xml version=\"1.0\" encoding=\"utf-8\"?><Error><Code>{code}</Code><Message>{message}</Message></Error>");
506+ let mut response = Response::ok(body)?.with_status(status);
507+ response.headers_mut().set("content-type", "application/xml")?;
508+ response.headers_mut().set("x-ms-error-code", code)?;
509+ Ok(response)
510+}
511+
512+/// `/actions/toolkit/blobs/{token}`: GET or HEAD a download, PUT an upload.
513+pub async fn blob(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> {
514+ let opened: Outcome<BlobGrant> = g1t_kit::call(&services.actions, "blob_open", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?;
515+ let grant = match opened {
516+ Outcome::Ok(grant) => grant,
517+ Outcome::Fail(refused) => {
518+ let status = if refused.code == FailureCode::NotFound { 404 } else { 403 };
519+ return azure_error(status, if status == 404 { "BlobNotFound" } else { "AuthenticationFailed" }, &refused.message);
520+ }
521+ };
522+ let bucket = env.bucket("ACTIONS_CACHE")?;
523+ match (method, grant.upload.as_deref()) {
524+ ("GET" | "HEAD", None) => {
525+ let headers = |response: &mut Response, size: u64| -> Result<()> {
526+ let headers = response.headers_mut();
527+ headers.set("content-length", &size.to_string())?;
528+ headers.set("content-type", grant.content_type.as_deref().unwrap_or("application/octet-stream"))?;
529+ headers.set("x-ms-blob-type", "BlockBlob")?;
530+ if let Some(name) = &grant.filename {
531+ headers.set("content-disposition", &format!("attachment; filename=\"{}\"", name.replace('"', "")))?;
532+ }
533+ Ok(())
534+ };
535+ if method == "HEAD" {
536+ let Some(object) = bucket.head(&grant.object).await? else { return azure_error(404, "BlobNotFound", "It is gone.") };
537+ let mut response = Response::empty()?;
538+ headers(&mut response, object.size())?;
539+ return Ok(response);
540+ }
541+ let Some(object) = bucket.get(&grant.object).execute().await? else { return azure_error(404, "BlobNotFound", "It is gone.") };
542+ let size = object.size();
543+ let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") };
544+ let mut response = Response::from_body(body.response_body()?)?;
545+ headers(&mut response, size)?;
546+ Ok(response)
547+ }
548+ ("PUT", Some(upload_id)) => {
549+ let comp = query(&request, "comp").unwrap_or_default();
550+ let limit = if grant.kind == "cache" { CACHE_MAX_ENTRY_BYTES } else { ARTIFACT_MAX_BYTES };
551+ match comp.as_str() {
552+ // Put Block, or Put Blob: one part.
553+ "block" | "" => {
554+ let part = if comp == "block" {
555+ match query(&request, "blockid").as_deref().and_then(block_index) {
556+ Some(index) if index < 10_000 => index + 1,
557+ _ => return azure_error(400, "InvalidQueryParameterValue", "A block id ends in its index, from 0."),
558+ }
559+ } else {
560+ 1
561+ };
562+ let length = request.headers().get("content-length")?.and_then(|l| l.parse::<u64>().ok()).unwrap_or(0);
563+ if length > MAX_BLOCK_BYTES || length > limit {
564+ return azure_error(413, "RequestBodyTooLarge", "That block is larger than g1t takes at once.");
565+ }
566+ let upload = bucket.resume_multipart_upload(&grant.object, upload_id)?;
567+ let uploaded = match request.inner().body() {
568+ Some(body) if length > 0 => upload.upload_part(part as u16, body).await?,
569+ _ => upload.upload_part(part as u16, Vec::<u8>::new()).await?,
570+ };
571+ let recorded = BlobArgs { blob: token.to_owned(), part, etag: uploaded.etag(), size: length };
572+ let _: Outcome<bool> = g1t_kit::call(&services.actions, "blob_part", &recorded).await?;
573+ if comp == "block" {
574+ return azure(201);
575+ }
576+ // Put Blob is the whole thing: finish it now.
577+ complete_blob(&bucket, services, token, &grant, upload_id, &[], limit, true).await
578+ }
579+ "blocklist" => {
580+ let xml = request.text().await.unwrap_or_default();
581+ let ids = block_list(&xml);
582+ complete_blob(&bucket, services, token, &grant, upload_id, &ids, limit, false).await
583+ }
584+ _ => azure_error(400, "InvalidQueryParameterValue", "g1t takes Put Blob, Put Block and Put Block List."),
585+ }
586+ }
587+ _ => azure_error(405, "UnsupportedHttpVerb", "That link is not for this."),
588+ }
589+}
590+
591+/// Finishes an upload from its parts: the blocks a list names, or the one
592+/// part of a Put Blob.
593+#[allow(clippy::too_many_arguments)]
594+async fn complete_blob(
595+ bucket: &Bucket,
596+ services: &Services,
597+ token: &str,
598+ grant: &BlobGrant,
599+ upload_id: &str,
600+ ids: &[String],
601+ limit: u64,
602+ whole: bool,
603+) -> Result<Response> {
604+ let recorded: Outcome<Vec<BlobPart>> = g1t_kit::call(&services.actions, "blob_parts", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?;
605+ let recorded = match recorded {
606+ Outcome::Ok(recorded) => recorded,
607+ Outcome::Fail(refused) => return azure_error(403, "AuthenticationFailed", &refused.message),
608+ };
609+ let parts = if whole {
610+ recorded.into_iter().filter(|p| p.part == 1).collect()
611+ } else {
612+ match parts_for(ids, &recorded) {
613+ Ok(parts) => parts,
614+ Err(problem) => return azure_error(400, "InvalidBlockList", &problem),
615+ }
616+ };
617+ let size = match finish(bucket, &grant.object, upload_id, &parts).await {
618+ Ok(size) => size,
619+ Err(problem) => return azure_error(400, "InvalidBlockList", &format!("The upload could not be completed: {problem}")),
620+ };
621+ if size > limit {
622+ bucket.delete(&grant.object).await?;
623+ return azure_error(413, "RequestBodyTooLarge", &format!("It is {} MB, more than g1t keeps ({} MB).", size / 1_048_576, limit / 1_048_576));
624+ }
625+ let _: Outcome<bool> = g1t_kit::call(&services.actions, "blob_done", &BlobArgs { blob: token.to_owned(), size, ..BlobArgs::default() }).await?;
626+ azure(201)
627+}
628+
629+#[cfg(test)]
630+mod tests {
631+ use super::*;
632+
633+ /// What Azure's SDK sends as block ids: base64 of a 36-character uuid
634+ /// prefix and the index padded to 48 characters in all.
635+ fn azure_block_id(index: u32) -> String {
636+ let prefix = "4a2f0d2e-8a44-4f1b-9d55-6f1a2b3c4d5e";
637+ let padded = format!("{prefix}{index:0>width$}", width = 48 - prefix.len());
638+ STANDARD.encode(padded)
639+ }
640+
641+ #[test]
642+ fn block_ids_give_their_index() {
643+ assert_eq!(block_index(&azure_block_id(0)), Some(0));
644+ assert_eq!(block_index(&azure_block_id(17)), Some(17));
645+ assert_eq!(block_index(&STANDARD.encode("no-digits")), None);
646+ assert_eq!(block_index("not base64!"), None);
647+ }
648+
649+ #[test]
650+ fn a_block_list_is_read_in_order_and_matched_to_parts() {
651+ // As the SDK's commitBlockList sends it.
652+ let xml = format!(
653+ "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><BlockList><Latest>{}</Latest><Latest>{}</Latest></BlockList>",
654+ azure_block_id(0),
655+ azure_block_id(1)
656+ );
657+ let ids = block_list(&xml);
658+ assert_eq!(ids, [azure_block_id(0), azure_block_id(1)]);
659+ // Sent out of order, as they are concurrently.
660+ let recorded = vec![
661+ BlobPart { part: 2, etag: "b".into(), size: 3 },
662+ BlobPart { part: 1, etag: "a".into(), size: 8 },
663+ ];
664+ let parts = parts_for(&ids, &recorded).unwrap();
665+ assert_eq!(parts.iter().map(|p| p.etag.as_str()).collect::<Vec<_>>(), ["a", "b"]);
666+ // A block never sent, or listed out of order.
667+ assert!(parts_for(&[azure_block_id(0), azure_block_id(2)], &recorded).is_err());
668+ assert!(parts_for(&[azure_block_id(1), azure_block_id(0)], &recorded).is_err());
669+ assert!(block_list("<BlockList></BlockList>").is_empty());
670+ }
671+
672+ #[test]
673+ fn older_protocol_chunks_are_parts() {
674+ let mb32 = CACHE_PART_BYTES;
675+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32 - 1)), Some((1, mb32)));
676+ assert_eq!(chunk_part(&format!("bytes {}-{}/*", mb32 * 2, mb32 * 2 + 99)), Some((3, 100)));
677+ // Not on a chunk's boundary, too long, or not a range.
678+ assert_eq!(chunk_part("bytes 5-10/*"), None);
679+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), None);
680+ assert_eq!(chunk_part("0-10"), None);
681+ }
682+
683+ /// The toolkit's requests, as `@actions/cache` 4 and `@actions/artifact`
684+ /// 2 send them (protobuf-ts, proto field names, no defaults).
685+ #[test]
686+ fn twirp_requests_read_in_the_toolkits_spelling() {
687+ let create_cache = json!({ "key": "node-cache-Linux-x64-npm-abc", "version": "a7f2c1e0" });
688+ assert_eq!(text(&create_cache, "key"), "node-cache-Linux-x64-npm-abc");
689+ let lookup = json!({ "key": "k", "restore_keys": ["k-", "x-"], "version": "v" });
690+ assert_eq!(field(&lookup, "restore_keys").as_array().unwrap().len(), 2);
691+ let finalize = json!({ "key": "k", "size_bytes": "1048576", "version": "v" });
692+ assert_eq!(number(&finalize, "size_bytes"), Some(1_048_576));
693+ let create_artifact = json!({
694+ "workflow_run_backend_id": "run_1",
695+ "workflow_job_run_backend_id": "job_1",
696+ "name": "dist",
697+ "expires_at": "2026-10-13T00:00:00Z",
698+ "version": 4
699+ });
700+ assert_eq!(text(&create_artifact, "workflow_job_run_backend_id"), "job_1");
701+ let list = json!({ "workflow_run_backend_id": "run_1", "workflow_job_run_backend_id": "job_1", "id_filter": "42", "name_filter": "dist" });
702+ assert_eq!(number(&list, "id_filter"), Some(42));
703+ assert_eq!(text(&list, "name_filter"), "dist");
704+ // A client writing JSON names instead reads the same.
705+ let camel = json!({ "workflowRunBackendId": "run_1", "sizeBytes": 3 });
706+ assert_eq!(text(&camel, "workflow_run_backend_id"), "run_1");
707+ assert_eq!(number(&camel, "size_bytes"), Some(3));
708+ }
709+
710+ #[test]
711+ fn the_runtime_token_names_its_run_and_job() {
712+ let payload = URL_SAFE_NO_PAD.encode(json!({ "job": "job_1", "run": "run_1" }).to_string());
713+ let token = format!("h.{payload}.s");
714+ assert_eq!(runtime_job(&token).as_deref(), Some("job_1"));
715+ assert_eq!(backend_ids(&token), ("run_1".to_owned(), "job_1".to_owned()));
716+ assert_eq!(runtime_job("deadbeef"), None);
717+ }
718+
719+ #[test]
720+ fn a_job_is_told_where_the_toolkit_s_services_are() {
721+ let vars = runtime_variables("https://api.g1t.sh", "tok", false);
722+ // Twirp paths are resolved against the root of ACTIONS_RESULTS_URL.
723+ assert_eq!(vars["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/");
724+ // The older protocol appends `_apis/artifactcache/…`.
725+ assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/");
726+ assert_eq!(vars["ACTIONS_CACHE_SERVICE_V2"], "True");
727+ assert!(vars.get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none());
728+ let vars = runtime_variables("https://api.g1t.sh", "tok", true);
729+ // core.getIDToken appends `&audience=…`.
730+ assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_URL"], "https://api.g1t.sh/actions/oidc/token?api-version=2.0");
731+ assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "tok");
732+ }
733+
734+ #[test]
735+ fn artifacts_are_listed_as_the_toolkit_reads_them() {
736+ let artifact = Artifact {
737+ id: 7,
738+ name: "dist".into(),
739+ size: 10,
740+ digest: None,
741+ format: "zip".into(),
742+ run_id: "run_1".into(),
743+ job_id: "job_1".into(),
744+ repo_id: "repo_1".into(),
745+ expired: false,
746+ created_at: "2026-10-08T12:00:00.000Z".into(),
747+ updated_at: "2026-10-08T12:00:00.000Z".into(),
748+ expires_at: "2026-10-22T12:00:00.000Z".into(),
749+ head_branch: None,
750+ head_sha: None,
751+ };
752+ let shown = listed(&artifact);
753+ assert_eq!(shown["database_id"], "7");
754+ assert_eq!(shown["size"], "10");
755+ assert_eq!(shown["workflow_job_run_backend_id"], "job_1");
756+ }
757+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.