Skip to content

Commit

Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2

Jobs with id-token: write get OIDC tokens from g1t's issuer (the API, /actions/oidc) with GitHub's claims; every job gets the toolkit's runtime token and addresses, so actions built on @actions/cache work unmodified; artifacts move to R2 with retention, overwrite, compression, patterns, merging, cross-run downloads, the REST artifacts API, MCP actions, and a list with size, expiry, download and delete on the run's page.

syntaqxcommitted Parent859f150Browse files
49 files+2353−1080/49 viewed
+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")?;
258223 let found: Outcome<Option<CacheHit>> = g1t_kit::call(
259224 &services.actions,
260225 "cache_lookup",
261− &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore: restore.clone() },
226+ &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore: restore.clone(), version: None },
262227 )
263228 .await?;
264229 let found = match refused(found) {
289254 let reserved: Outcome<CacheReservation> = g1t_kit::call(
290255 &services.actions,
291256 "cache_reserve",
292− &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64 },
257+ &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: None },
293258 )
294259 .await?;
295260 let reserved = match reserved {
312277 let reserved: Outcome<CacheReservation> = g1t_kit::call(
313278 &services.actions,
314279 "cache_reserve",
315− &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size },
280+ &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: None },
316281 )
317282 .await?;
318283 let reserved = match refused(reserved) {
369334 format!("c/{repo}/{id}")
370335 }
371336
337+/// Where an artifact is in R2, as the actions service names it.
338+fn artifact_object(repo: &str, id: u64) -> String {
339+ format!("a/{repo}/{id}")
340+}
341+
342+/// An artifact as a job's runner lists it.
343+fn for_runner(artifact: &Artifact) -> Value {
344+ json!({
345+ "id": artifact.id,
346+ "name": artifact.name,
347+ "size": artifact.size,
348+ "digest": artifact.digest,
349+ "format": artifact.format,
350+ "created_at": artifact.created_at,
351+ "expires_at": artifact.expires_at,
352+ })
353+}
354+
355+/// An R2 object streamed back, with what it is.
356+async fn stream(bucket: &Bucket, object: &str, format: &str) -> Result<Response> {
357+ let Some(found) = bucket.get(object).execute().await? else { return error(404, "That artifact is gone: it expired or was deleted.") };
358+ let size = found.size();
359+ let Some(body) = found.body() else { return error(404, "That artifact is gone: it expired or was deleted.") };
360+ let mut response = Response::from_body(body.response_body()?)?;
361+ let headers = response.headers_mut();
362+ headers.set("content-length", &size.to_string())?;
363+ headers.set("content-type", if format == "tgz" { "application/gzip" } else { "application/zip" })?;
364+ headers.set("x-g1t-format", format)?;
365+ Ok(response)
366+}
367+
368+/// A job's artifacts: listing its run's (or another run's of its
369+/// repository), uploading in parts, downloading, deleting; and the whole
370+/// uploads and downloads by name of older runners.
371+#[allow(clippy::too_many_arguments)]
372+async fn artifacts(
373+ mut request: Request,
374+ kv: &KvStore,
375+ bucket: &Bucket,
376+ services: &Services,
377+ method: &str,
378+ job: &str,
379+ token: &str,
380+ run: &str,
381+ repo: &str,
382+ what: &str,
383+) -> Result<Response> {
384+ let parts: Vec<&str> = what.split('/').collect();
385+ let upload_id = query(&request, "upload").unwrap_or_default();
386+ let credential = |id: Option<u64>, name: Option<String>, run_id: Option<String>| JobArtifactsArgs {
387+ job: job.to_owned(),
388+ token: token.to_owned(),
389+ run_id,
390+ name,
391+ id,
392+ };
393+ let actions = &services.actions;
394+ match (method, parts.as_slice()) {
395+ ("GET", ["artifacts"]) => {
396+ let run_id = query(&request, "run_id").filter(|r| !r.is_empty());
397+ let found: Outcome<Vec<Artifact>> = g1t_kit::call(actions, "job_artifacts", &credential(None, None, run_id.clone())).await?;
398+ match refused(found) {
399+ Ok(found) => {
400+ let mut listed: Vec<Value> = found.iter().map(for_runner).collect();
401+ // Artifacts older runners kept in KV, for the days they
402+ // are still there.
403+ let legacy_run = run_id.as_deref().unwrap_or(run);
404+ for (_, meta) in list(kv, &format!("a/{legacy_run}/")).await? {
405+ if !listed.iter().any(|a| a["name"] == meta.name.as_str()) {
406+ listed.push(json!({ "name": meta.name, "size": meta.size, "format": "tgz" }));
407+ }
408+ }
409+ crate::reply(&listed)
410+ }
411+ Err(reply) => reply,
412+ }
413+ }
414+ ("POST", ["artifacts", "uploads"]) => {
415+ let flag = |name: &str| query(&request, name).is_some_and(|v| v == "true");
416+ let args = ArtifactReserveArgs {
417+ job: job.to_owned(),
418+ token: token.to_owned(),
419+ name: query(&request, "name").unwrap_or_default(),
420+ size: query(&request, "size").and_then(|s| s.parse().ok()).unwrap_or(0),
421+ retention_days: query(&request, "retention_days").and_then(|d| d.parse().ok()).unwrap_or(0),
422+ expires_at: None,
423+ overwrite: flag("overwrite"),
424+ format: query(&request, "format"),
425+ };
426+ let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?;
427+ let reserved = match refused(reserved) {
428+ Ok(reserved) => reserved,
429+ Err(reply) => return reply,
430+ };
431+ let upload = bucket.create_multipart_upload(&reserved.object).execute().await?;
432+ crate::reply(&json!({
433+ "id": reserved.id,
434+ "upload": upload.upload_id().await,
435+ "part_bytes": ARTIFACT_PART_BYTES,
436+ "retention_days": reserved.retention_days,
437+ "expires_at": reserved.expires_at,
438+ }))
439+ }
440+ ("PUT", ["artifacts", "uploads", id, part]) => {
441+ let (Ok(id), Ok(part)) = (id.parse::<u64>(), part.parse::<u16>()) else {
442+ return error(400, "A part is numbered from 1, of an artifact named by its number.");
443+ };
444+ if part == 0 || upload_id.is_empty() {
445+ return error(400, "A part is numbered from 1, and names its upload.");
446+ }
447+ let length = request.headers().get("content-length")?.and_then(|l| l.parse::<u64>().ok()).unwrap_or(0);
448+ if length == 0 || length > ARTIFACT_PART_BYTES {
449+ return error(413, &format!("A part is 1 to {} MB, with its length.", ARTIFACT_PART_BYTES / 1_048_576));
450+ }
451+ let Some(body) = request.inner().body() else { return error(400, "The part is empty.") };
452+ let upload = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id)?;
453+ let uploaded = upload.upload_part(part, body).await?;
454+ crate::reply(&json!({ "part": uploaded.part_number(), "etag": uploaded.etag() }))
455+ }
456+ ("POST", ["artifacts", "uploads", id, "complete"]) => {
457+ let Ok(id) = id.parse::<u64>() else { return error(404, "No such upload.") };
458+ let done: Value = request.json().await.unwrap_or(Value::Null);
459+ let mut parts: Vec<Part> = serde_json::from_value(done["parts"].clone()).unwrap_or_default();
460+ if parts.is_empty() {
461+ return error(400, "Send { size, parts: [{ part, etag }], digest }.");
462+ }
463+ parts.sort_by_key(|p| p.part);
464+ let upload = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id)?;
465+ let object = match upload.complete(parts.into_iter().map(|p| UploadedPart::new(p.part, p.etag))).await {
466+ Ok(object) => object,
467+ Err(problem) => {
468+ let _: Outcome<bool> = g1t_kit::call(actions, "artifact_abort", &credential(Some(id), None, None)).await?;
469+ return error(400, &format!("The upload could not be completed: {problem}"));
470+ }
471+ };
472+ let args = ArtifactCommitArgs {
473+ job: job.to_owned(),
474+ token: token.to_owned(),
475+ id: Some(id),
476+ name: None,
477+ // What R2 holds, not what the runner says.
478+ size: object.size(),
479+ digest: done["digest"].as_str().map(str::to_owned),
480+ };
481+ let committed: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?;
482+ match refused(committed) {
483+ Ok(artifact) => crate::reply(&for_runner(&artifact)),
484+ Err(reply) => reply,
485+ }
486+ }
487+ ("DELETE", ["artifacts", "uploads", id]) => {
488+ let Ok(id) = id.parse::<u64>() else { return error(404, "No such upload.") };
489+ if let Ok(upload) = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id) {
490+ let _ = upload.abort().await;
491+ }
492+ let _: Outcome<bool> = g1t_kit::call(actions, "artifact_abort", &credential(Some(id), None, None)).await?;
493+ crate::reply(&json!({ "aborted": true }))
494+ }
495+ ("GET", ["artifacts", id, "download"]) => {
496+ let Ok(id) = id.parse::<u64>() else { return error(404, "No such artifact.") };
497+ // Any run of the job's repository: the service checks.
498+ let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &credential(Some(id), None, query(&request, "run_id"))).await?;
499+ match found {
500+ Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await,
501+ Outcome::Fail(failure) => error(failure.code.http_status(), &failure.message),
502+ }
503+ }
504+ ("DELETE", ["artifacts", id]) => {
505+ let Ok(id) = id.parse::<u64>() else { return error(404, "No such artifact.") };
506+ let done: Outcome<Artifact> = g1t_kit::call(actions, "job_delete_artifact", &credential(Some(id), None, None)).await?;
507+ match refused(done) {
508+ Ok(artifact) => crate::reply(&for_runner(&artifact)),
509+ Err(reply) => reply,
510+ }
511+ }
512+ // Older runners: a whole artifact of at most 60 MB, by name.
513+ ("PUT", ["artifacts", name]) => {
514+ let name = decode(name);
515+ let bytes = request.bytes().await?;
516+ if bytes.len() > MAX_BYTES {
517+ return error(413, "An artifact sent at once is at most 60 MB; newer runners upload it in parts.");
518+ }
519+ let args = ArtifactReserveArgs {
520+ job: job.to_owned(),
521+ token: token.to_owned(),
522+ name: name.clone(),
523+ size: bytes.len() as u64,
524+ format: Some("tgz".to_owned()),
525+ ..ArtifactReserveArgs::default()
526+ };
527+ let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?;
528+ let reserved = match refused(reserved) {
529+ Ok(reserved) => reserved,
530+ Err(reply) => return reply,
531+ };
532+ let size = bytes.len() as u64;
533+ bucket.put(&reserved.object, bytes).execute().await?;
534+ let args = ArtifactCommitArgs { job: job.to_owned(), token: token.to_owned(), id: Some(reserved.id), name: None, size, digest: None };
535+ let committed: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?;
536+ match refused(committed) {
537+ Ok(_) => crate::reply(&json!({ "name": name, "size": size })),
538+ Err(reply) => reply,
539+ }
540+ }
541+ ("GET", ["artifacts", name]) => {
542+ let name = decode(name);
543+ let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &credential(None, Some(name.clone()), None)).await?;
544+ match found {
545+ Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await,
546+ // One an older runner kept in KV.
547+ Outcome::Fail(_) => match get(kv, &format!("a/{run}/{name}")).await? {
548+ Some(bytes) => Response::from_bytes(bytes),
549+ None => error(404, "No such artifact."),
550+ },
551+ }
552+ }
553+ _ => error(404, "No such endpoint."),
554+ }
555+}
556+
372557 /// Marks an uploaded entry ready, and deletes what that evicted.
373558 async fn commit(bucket: &Bucket, services: &Services, job: &str, token: &str, id: &str, size: u64) -> Result<()> {
374559 let committed: Outcome<CacheCommitted> = g1t_kit::call(
428613 return error(status, &refused.message);
429614 }
430615 let name = decode(name);
616+ // Kept in R2: a redirect to a link signed for a few minutes.
617+ let args = ArtifactArgs {
618+ repo: g1t_contracts::repos::RepoPath { namespace: owner.to_owned(), name: repo.to_owned() },
619+ viewer: viewer.clone(),
620+ id: None,
621+ run: Some(run.to_owned()),
622+ name: Some(name.clone()),
623+ };
624+ let found: Outcome<ArtifactBlob> = g1t_kit::call(&services.actions, "artifact_download", &args).await?;
625+ if let Outcome::Ok(found) = found
626+ && !found.blob.is_empty()
627+ {
628+ return Response::redirect_with_status(Url::parse(&crate::artifacts::blob_url(&services.addresses.api, &found.blob))?, 302);
629+ }
630+ // Kept in KV by an older runner.
431631 match get(&store(env)?, &format!("a/{run}/{name}")).await? {
432632 Some(bytes) => {
433633 let mut response = Response::from_bytes(bytes)?;
440640 }
441641 }
442642
443−/// A run's artifacts, for its page.
444−pub async fn of_run(env: &Env, run: &str) -> Result<Vec<Value>> {
445− Ok(list(&store(env)?, &format!("a/{run}/"))
446− .await?
447− .into_iter()
448− .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size, "at": meta.at }))
449− .collect())
450−}
+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;
2729 mod runners;
2830 mod security;
2931 mod tools;
32+mod toolkit;
3033
3134 use g1t_contracts::billing::{FinishRunArgs, RunTokens};
3235 use g1t_contracts::identity::{
7275 "spec", "workflow", "github", "event", "contexts", "checkout",
7376 ];
7477
78+/// Puts the toolkit's variables in a job's spec: its runtime token, where
79+/// the toolkit's services are, and where to ask for an OIDC token when the
80+/// job may have one and this installation issues them. The `runtime` the
81+/// actions service sent goes no further.
82+fn with_runtime(spec: &mut Value, api: &str, oidc: bool) {
83+ let Some(runtime) = spec.as_object_mut().and_then(|s| s.remove("runtime")) else { return };
84+ let Some(token) = runtime["token"].as_str().filter(|t| !t.is_empty()) else { return };
85+ let id_token = oidc && runtime["id_token"].as_bool() == Some(true);
86+ let vars = toolkit::runtime_variables(api, token, id_token);
87+ if let Some(variables) = spec.get_mut("variables").and_then(Value::as_object_mut) {
88+ variables.extend(vars);
89+ }
90+}
91+
7592 /// An error in the shape every endpoint uses.
7693 fn failure(failure: &Failure) -> Result<Response> {
7794 Ok(reply(&json!({ "error": failure }))?.with_status(failure.code.http_status()))
546563 }));
547564 }
548565
566+ // The services GitHub's toolkit calls from inside a job, with its
567+ // runtime token, and the links they hand out (toolkit.rs).
568+ if !on_mcp && method == "POST"
569+ && let Some(rest) = path.strip_prefix("/twirp/")
570+ {
571+ let (service, rpc) = rest.split_once('/').unwrap_or((rest, ""));
572+ let (service, rpc) = (service.to_owned(), rpc.to_owned());
573+ return toolkit::twirp(request, env, &services, &service, &rpc).await;
574+ }
575+ if !on_mcp && let Some(rest) = path.strip_prefix("/actions/toolkit/_apis/artifactcache/") {
576+ let rest = rest.to_owned();
577+ return toolkit::cache_v1(request, env, &services, method, &rest).await;
578+ }
579+ if !on_mcp && let Some(token) = path.strip_prefix("/actions/toolkit/blobs/") {
580+ let token = token.to_owned();
581+ return toolkit::blob(request, env, &services, method, &token).await;
582+ }
583+ // g1t as an OIDC issuer for workflow jobs (oidc.rs).
584+ if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") {
585+ return oidc::handle(&request, env, &services, &path).await;
586+ }
587+
549588 // A sandbox's artifacts and cache, with its job's token, which is not a
550589 // g1t token either.
551590 if !on_mcp
602641 match (method, path.trim_end_matches('/')) {
603642 ("GET", "") => return reply(&index(&services.addresses)),
604643 ("GET", "/openapi.json") => return Response::from_json(&openapi::document()),
605− // A run's artifacts: listed, or one downloaded.
606− ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts") => {
644+ // One of a run's artifacts downloaded by name (the run's artifacts
645+ // are listed by the REST route in rest.rs).
646+ ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => {
607647 let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect();
608− if let [owner, repo, "actions", "runs", run, "artifacts", rest @ ..] = parts.as_slice() {
609− return match rest {
610− [] => {
611− let seen: Outcome<Value> = g1t_kit::call(
612− &services.actions,
613− "run",
614− &json!({ "repo": { "namespace": owner, "name": repo }, "viewer": viewer, "id": run }),
615− )
616− .await?;
617− match seen {
618− Outcome::Ok(_) => reply(&blobs::of_run(env, run).await?),
619− Outcome::Fail(refused) => failure(&refused),
620− }
621− }
622− [name] => blobs::download(env, &services, &viewer, owner, repo, run, name).await,
623− _ => fail(FailureCode::NotFound, "No such endpoint."),
624− };
648+ if let [owner, repo, "actions", "runs", run, "artifacts", name] = parts.as_slice() {
649+ return blobs::download(env, &services, &viewer, owner, repo, run, name).await;
625650 }
626651 }
627652 ("POST", "/device/code") => return device_code(&mut request, &services).await,
670695 return match answered {
671696 // A job's spec is the workflow and its contexts as GitHub
672697 // has them; only g1t's own keys around them are converted.
673− Outcome::Ok(value) => Response::from_json(&wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN)),
698+ Outcome::Ok(value) => {
699+ let mut spec = wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN);
700+ with_runtime(&mut spec, &services.addresses.api, oidc::configured(env));
701+ Response::from_json(&spec)
702+ }
674703 Outcome::Fail(refused) => failure(&refused),
675704 };
676705 }
758787 return fail(FailureCode::NotFound, "No such endpoint.");
759788 };
760789 match audit::run(route.op, &services, &viewer, &input).await? {
790+ // A download is a redirect to its signed link, as GitHub's is.
791+ Outcome::Ok(value) if route.op == operations::Op::Artifacts(artifacts::ArtifactsOp::DownloadArtifact) => {
792+ match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) {
793+ Some(url) => Response::redirect_with_status(url, 302),
794+ None => reply(&value),
795+ }
796+ }
761797 Outcome::Ok(value) => reply(&value),
762798 // A token without the scope a call needs is told which one.
763799 Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) {
806842 use serde_json::json;
807843
808844 #[test]
845+ fn a_job_spec_gets_the_toolkits_variables() {
846+ // As the actions service sends it, converted as the API does.
847+ let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } });
848+ let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN);
849+ super::with_runtime(&mut spec, "https://api.g1t.sh", true);
850+ assert!(spec.get("runtime").is_none(), "the runner never sees it");
851+ let vars = &spec["variables"];
852+ assert_eq!(vars["GITHUB_SHA"], "abc");
853+ assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s");
854+ assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/");
855+ assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "h.p.s");
856+ // No OIDC key here: no OIDC variables, whatever the job may do.
857+ let mut spec = g1t_kit::wire::snake_case_keeping(sent, super::JOB_SPEC_AS_GIVEN);
858+ super::with_runtime(&mut spec, "https://api.g1t.sh", false);
859+ assert!(spec["variables"].get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none());
860+ assert_eq!(spec["variables"]["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/");
861+ }
862+
863+ #[test]
809864 fn camel_case_keys_are_accepted() {
810865 assert_eq!(
811866 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+}
+9−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::operations::Op;
1314 use crate::checks::ChecksOp;
366367 Op::CancelWorkflowRun,
367368 Op::RerunWorkflowRun,
368369 Op::UpdateWorkflow,
370+ Op::Artifacts(ArtifactsOp::ListArtifacts),
371+ Op::Artifacts(ArtifactsOp::ListRunArtifacts),
372+ Op::Artifacts(ArtifactsOp::GetArtifact),
373+ Op::Artifacts(ArtifactsOp::DownloadArtifact),
374+ Op::Artifacts(ArtifactsOp::DeleteArtifact),
375+ Op::Artifacts(ArtifactsOp::GetArtifactRetention),
376+ Op::Artifacts(ArtifactsOp::SetArtifactRetention),
369377 ],
370378 ),
371379 (
633641 Op::Checks(op) => op.title(),
634642 Op::About(op) => op.title(),
635643 Op::Deployments(op) => op.title(),
644+ Op::Artifacts(op) => op.title(),
636645 }
637646 }
638647
+19−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::rules::RulesOp;
3334 use crate::security::SecurityOp;
283284 About(AboutOp),
284285 /// Deployments wherever they run, and environments: deployments.rs.
285286 Deployments(DeploymentsOp),
287+ /// Workflow run artifacts, and how long they are kept: artifacts.rs.
288+ Artifacts(ArtifactsOp),
286289 }
287290
288291 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
645648 }
646649
647650 impl Op {
648− pub const ALL: [Op; 257] = [
651+ pub const ALL: [Op; 264] = [
649652 Op::Whoami,
650653 Op::GetWorkspace,
651654 Op::CreateWorkspace,
903906 Op::Deployments(DeploymentsOp::CreateDeploymentStatus),
904907 Op::Deployments(DeploymentsOp::ListEnvironments),
905908 Op::Deployments(DeploymentsOp::GetEnvironment),
909+ Op::Artifacts(ArtifactsOp::ListArtifacts),
910+ Op::Artifacts(ArtifactsOp::ListRunArtifacts),
911+ Op::Artifacts(ArtifactsOp::GetArtifact),
912+ Op::Artifacts(ArtifactsOp::DownloadArtifact),
913+ Op::Artifacts(ArtifactsOp::DeleteArtifact),
914+ Op::Artifacts(ArtifactsOp::GetArtifactRetention),
915+ Op::Artifacts(ArtifactsOp::SetArtifactRetention),
906916 ];
907917
908918 pub fn by_name(name: &str) -> Option<Op> {
10961106 Op::Checks(op) => op.name(),
10971107 Op::About(op) => op.name(),
10981108 Op::Deployments(op) => op.name(),
1109+ Op::Artifacts(op) => op.name(),
10991110 }
11001111 }
11011112
16091620 Op::Checks(op) => op.description(),
16101621 Op::About(op) => op.description(),
16111622 Op::Deployments(op) => op.description(),
1623+ Op::Artifacts(op) => op.description(),
16121624 }
16131625 }
16141626
29802992 Op::Checks(op) => op.input(),
29812993 Op::About(op) => op.input(),
29822994 Op::Deployments(op) => op.input(),
2995+ Op::Artifacts(op) => op.input(),
29832996 }
29842997 }
29852998
29923005 if let Op::About(op) = self {
29933006 return !op.anonymous();
29943007 }
3008+ // A public repository's artifacts are anyone's to read.
3009+ if let Op::Artifacts(op) = self {
3010+ return op.writes();
3011+ }
29953012 !matches!(
29963013 self,
29973014 Op::ListRepos
50395056 Op::Checks(op) => crate::checks::run(op, services, viewer, input).await,
50405057 Op::About(op) => crate::about::run(op, services, viewer, input).await,
50415058 Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await,
5059+ Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await,
50425060 Op::ReopenSecurityAlert => {
50435061 let changed: Outcome<AlertChange> = call(
50445062 &services.security,
+183−0
1062510625 "response": {
1062610626 "rerequested": true
1062710627 }
10628+ },
10629+ "list_artifacts": {
10630+ "params": {
10631+ "owner": "flagon-io",
10632+ "name": "g1t"
10633+ },
10634+ "query": {
10635+ "name": "web-dist",
10636+ "per_page": "1"
10637+ },
10638+ "response": {
10639+ "total_count": 9,
10640+ "artifacts": [
10641+ {
10642+ "id": 4182,
10643+ "node_id": "artifact_4182",
10644+ "name": "web-dist",
10645+ "size_in_bytes": 18734120,
10646+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182",
10647+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip",
10648+ "expired": false,
10649+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
10650+ "created_at": "2026-10-08T14:03:51.204Z",
10651+ "updated_at": "2026-10-08T14:03:52.880Z",
10652+ "expires_at": "2026-10-22T14:03:51.204Z",
10653+ "workflow_run": {
10654+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
10655+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10656+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10657+ "head_branch": "main",
10658+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
10659+ }
10660+ }
10661+ ]
10662+ },
10663+ "notes": "Expired and deleted artifacts are not listed. `workflow_run.id` is the run's id, which `get_workflow_run` takes."
10664+ },
10665+ "list_workflow_run_artifacts": {
10666+ "params": {
10667+ "owner": "flagon-io",
10668+ "name": "g1t",
10669+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z"
10670+ },
10671+ "response": {
10672+ "total_count": 2,
10673+ "artifacts": [
10674+ {
10675+ "id": 4181,
10676+ "node_id": "artifact_4181",
10677+ "name": "coverage",
10678+ "size_in_bytes": 412870,
10679+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4181",
10680+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4181/zip",
10681+ "expired": false,
10682+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
10683+ "created_at": "2026-10-08T14:03:51.204Z",
10684+ "updated_at": "2026-10-08T14:03:52.880Z",
10685+ "expires_at": "2026-10-22T14:03:51.204Z",
10686+ "workflow_run": {
10687+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
10688+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10689+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10690+ "head_branch": "main",
10691+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
10692+ }
10693+ },
10694+ {
10695+ "id": 4182,
10696+ "node_id": "artifact_4182",
10697+ "name": "web-dist",
10698+ "size_in_bytes": 18734120,
10699+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182",
10700+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip",
10701+ "expired": false,
10702+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
10703+ "created_at": "2026-10-08T14:03:51.204Z",
10704+ "updated_at": "2026-10-08T14:03:52.880Z",
10705+ "expires_at": "2026-10-22T14:03:51.204Z",
10706+ "workflow_run": {
10707+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
10708+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10709+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10710+ "head_branch": "main",
10711+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
10712+ }
10713+ }
10714+ ]
10715+ }
10716+ },
10717+ "get_artifact": {
10718+ "params": {
10719+ "owner": "flagon-io",
10720+ "name": "g1t",
10721+ "id": "4182"
10722+ },
10723+ "response": {
10724+ "id": 4182,
10725+ "node_id": "artifact_4182",
10726+ "name": "web-dist",
10727+ "size_in_bytes": 18734120,
10728+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182",
10729+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip",
10730+ "expired": false,
10731+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
10732+ "created_at": "2026-10-08T14:03:51.204Z",
10733+ "updated_at": "2026-10-08T14:03:52.880Z",
10734+ "expires_at": "2026-10-22T14:03:51.204Z",
10735+ "workflow_run": {
10736+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
10737+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10738+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10739+ "head_branch": "main",
10740+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
10741+ }
10742+ }
10743+ },
10744+ "download_artifact": {
10745+ "params": {
10746+ "owner": "flagon-io",
10747+ "name": "g1t",
10748+ "id": "4182"
10749+ },
10750+ "response": {
10751+ "url": "https://api.g1t.sh/actions/toolkit/blobs/eyJrIjoiYXJ0aWZhY3QiLCJpIjoiNDE4MiJ9.q3VbS1x9",
10752+ "expires_at": "2026-10-08T15:13:00.000Z",
10753+ "artifact": {
10754+ "id": 4182,
10755+ "node_id": "artifact_4182",
10756+ "name": "web-dist",
10757+ "size_in_bytes": 18734120,
10758+ "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182",
10759+ "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip",
10760+ "expired": false,
10761+ "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
10762+ "created_at": "2026-10-08T14:03:51.204Z",
10763+ "updated_at": "2026-10-08T14:03:52.880Z",
10764+ "expires_at": "2026-10-22T14:03:51.204Z",
10765+ "workflow_run": {
10766+ "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z",
10767+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10768+ "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
10769+ "head_branch": "main",
10770+ "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7"
10771+ }
10772+ }
10773+ },
10774+ "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."
10775+ },
10776+ "delete_artifact": {
10777+ "params": {
10778+ "owner": "flagon-io",
10779+ "name": "g1t",
10780+ "id": "4181"
10781+ },
10782+ "response": {
10783+ "deleted": true,
10784+ "id": 4181,
10785+ "name": "coverage"
10786+ }
10787+ },
10788+ "get_artifact_retention": {
10789+ "params": {
10790+ "owner": "flagon-io",
10791+ "name": "g1t"
10792+ },
10793+ "response": {
10794+ "days": 14,
10795+ "maximum_allowed_days": 90
10796+ }
10797+ },
10798+ "set_artifact_retention": {
10799+ "params": {
10800+ "owner": "flagon-io",
10801+ "name": "g1t"
10802+ },
10803+ "request": {
10804+ "days": 30
10805+ },
10806+ "response": {
10807+ "days": 30,
10808+ "maximum_allowed_days": 90
10809+ },
10810+ "notes": "Only artifacts uploaded afterwards are kept the new number of days. A workflow's `retention-days` asks for fewer, never more."
1062810811 }
1062910812 }
+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 => {
+10−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::operations::Op;
89 use crate::checks::ChecksOp;
635636 Op::GetJobLogs,
636637 &[("after", "after")],
637638 ),
639+ // Artifacts, at GitHub's addresses. `…/zip` answers with a redirect to
640+ // a signed link (lib.rs).
641+ route("GET", "/repos/:owner/:name/actions/artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), &[("name", "name"), ("page", "page"), ("per_page", "per_page")]),
642+ route("GET", "/repos/:owner/:name/actions/runs/:id/artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), &[("name", "name")]),
643+ route("GET", "/repos/:owner/:name/actions/artifacts/:id", Op::Artifacts(ArtifactsOp::GetArtifact), &[]),
644+ route("GET", "/repos/:owner/:name/actions/artifacts/:id/zip", Op::Artifacts(ArtifactsOp::DownloadArtifact), &[]),
645+ route("DELETE", "/repos/:owner/:name/actions/artifacts/:id", Op::Artifacts(ArtifactsOp::DeleteArtifact), &[]),
646+ route("GET", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::GetArtifactRetention), &[]),
647+ route("PUT", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), &[]),
638648 route(
639649 "GET",
640650 "/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+}
+9−1
1919 use serde_json::{Map, Value, json};
2020
2121 use crate::about::AboutOp;
22+use crate::artifacts::ArtifactsOp;
2223 use crate::deployments::DeploymentsOp;
2324 use crate::operations::Op;
2425 use crate::checks::ChecksOp;
200201 Tool {
201202 name: "workflow",
202203 title: "Workflows",
203− description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.",
204+ description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.",
204205 default_action: None,
205206 actions: &[
206207 a("list", Op::ListWorkflows, "Workflows on the default branch"),
211212 a("cancel", Op::CancelWorkflowRun, "Cancel a run"),
212213 a("rerun", Op::RerunWorkflowRun, "Run a finished run again"),
213214 a("update", Op::UpdateWorkflow, "Turn a workflow on or off"),
215+ a("list_artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), "A repository's artifacts, newest first; or a run's with run_artifacts"),
216+ a("run_artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), "One run's artifacts"),
217+ a("get_artifact", Op::Artifacts(ArtifactsOp::GetArtifact), "One artifact: size, digest, expiry, run"),
218+ a("download_artifact", Op::Artifacts(ArtifactsOp::DownloadArtifact), "A 10-minute link to an artifact's zip"),
219+ a("delete_artifact", Op::Artifacts(ArtifactsOp::DeleteArtifact), "Delete an artifact before it expires"),
220+ a("artifact_retention", Op::Artifacts(ArtifactsOp::GetArtifactRetention), "Days the repository keeps artifacts"),
221+ a("set_artifact_retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), "Change the days the repository keeps artifacts"),
214222 a("combined_status", Op::Checks(ChecksOp::GetCombinedStatus), "A commit's statuses and the state they add up to"),
215223 a("list_statuses", Op::Checks(ChecksOp::ListCommitStatuses), "A commit's statuses, newest first"),
216224 a("set_status", Op::Checks(ChecksOp::CreateCommitStatus), "Set a status on a commit"),
+3−2
3333 // A person's pinned projects: list_pinned_projects and changing them.
3434 { "binding": "PROJECTS", "service": "g1t-projects" }
3535 ],
36− // GitHub Actions artifacts, in chunks, with KV's own expiry (and cache
36+ // GitHub Actions artifacts older runners kept, in chunks, with KV's own
37+ // expiry, read until it passes (and cache
3738 // entries saved before the cache moved to R2, until they expire).
3839 "kv_namespaces": [{ "binding": "BLOBS", "id": "16a4232cb746418db53782aa068be693" }],
39− // actions/cache entries, up to 2 GB each, uploaded in parts. The actions
40+ // actions/cache entries (`c/`) and artifacts (`a/`), uploaded in parts. The actions
4041 // service lists them and decides what is kept (services/actions/src/cache.rs).
4142 "r2_buckets": [{ "binding": "ACTIONS_CACHE", "bucket_name": "g1t-actions-cache" }],
4243 "observability": { "enabled": true }
+24−3
220220 | One job on g1t's machines | 60 minutes. On a self-hosted runner, 24 hours. |
221221 | One cache entry | 2 GiB, compressed. A larger one is not saved. |
222222 | A repository's caches | 10 GiB together. Past it, the entries restored longest ago are removed. |
223−| One artifact | 60 MB, kept for 14 days |
223+| One artifact | 5 GiB, zipped. Kept 14 days unless the repository says otherwise, at most 90. |
224+| A run's artifacts | 10 GiB together. |
224225
225226 The machine sizes are Cloudflare Containers' instance sizes. For more, use a
226227 [self-hosted runner](/guides/self-hosted-runners/). See
230231
231232 - Reusable workflows from another repository. Ones in the same repository
232233 work.
233−- Actions that cache through the hosted toolkit's own cache service, such as
234− `setup-node` with `cache: npm`. They run without it; use `actions/cache`.
234+- Actions that upload or download artifacts with the toolkit's artifact
235+ library themselves. The library refuses to run against any server but
236+ github.com. `actions/upload-artifact`, `actions/download-artifact` and
237+ `actions/upload-artifact/merge` work, as g1t runs them itself.
238+- A cache entry between 100 and 128 MB saved by an action built on the
239+ toolkit, such as `setup-node` with `cache: npm`. The toolkit sends an
240+ entry under 128 MB in one request, and g1t takes at most 100 MB in one
241+ request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and
242+ the job goes on; smaller and larger entries are saved.
235243 - Environments' protection rules: required reviewers, wait timers and branch
236244 limits. A job with `environment:` gets that environment's values and runs
237245 without waiting.
238246
239247 See [Not yet](/guides/actions/#not-yet). **Status.** Planned.
240248
249+### No npm trusted publishing or provenance
250+
251+A workflow on g1t can't publish to npm with trusted publishing, or with
252+`--provenance`.
253+
254+- **Why.** Both trade the job's OIDC token with npm and Sigstore, which
255+ accept tokens only from the CI services they list. g1t's
256+ [OIDC tokens](/guides/actions/#oidc-tokens) work with any cloud that
257+ lets you add an issuer, and npm does not.
258+- **Instead.** Publish with a granular access token in a secret
259+ (`NODE_AUTH_TOKEN`); see [npm](/guides/actions/#npm).
260+- **Status.** Depends on npm.
261+
241262 ## Deployments
242263
243264 ### Static sites and Workers only
+293−7
4343 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. |
4444 | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is the workspace's own token for the run; `GITHUB_TOKEN` is its alias. |
4545 | `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work, and the run records a [deployment](/guides/deployments-api/#deployments-from-g1t-actions) to it. `url` gives the deployment its address; `deployment: false` reads the environment's values without making one. |
46−| `actions/upload-artifact`, `actions/download-artifact` | Kept with the run for 14 days, passed between its jobs, and downloadable from the run's page. Up to 60 MB each. |
46+| `actions/upload-artifact`, `actions/download-artifact`, `actions/upload-artifact/merge` | The same inputs and outputs as version 4: `retention-days`, `overwrite`, `compression-level`, `include-hidden-files`, `!` exclusions, download by `pattern` with `merge-multiple`, and from another run with `run-id` and `github-token`. Up to 5 GiB each; see [artifacts](#artifacts). |
4747 | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). |
48+| Actions that cache through the toolkit, such as `actions/setup-node` with `cache: npm` or `Swatinem/rust-cache` | The same: they save to and restore from the repository's cache. See [actions built on the toolkit](#actions-built-on-the-toolkit). |
49+| `permissions: id-token: write` | The job can ask for an OIDC token, and trade it for a cloud provider's credentials. See [OIDC tokens](#oidc-tokens). |
4850
4951 The **Actions** page of a workflow says, under *How this runs on g1t*,
5052 anything in it that runs differently.
6062 g1t's image; a [self-hosted runner](/guides/self-hosted-runners/#what-a-job-gets)
6163 that runs jobs in Docker uses it.
6264 - **Reusable workflows from other repositories** (`uses: owner/repo/.github/workflows/x.yml@v1`); ones in the same repository work.
63−- **The toolkit's own cache.** Actions that cache through GitHub's service
64− themselves, such as `actions/setup-node` with `cache: npm`, run without
65− it. Use `actions/cache` for the same effect.
65+- **Actions that upload artifacts with the toolkit's artifact library
66+ themselves.** The library refuses to run against any server but
67+ github.com. `actions/upload-artifact`, `actions/download-artifact` and
68+ `actions/upload-artifact/merge` work, because g1t runs them itself. See
69+ [actions built on the toolkit](#actions-built-on-the-toolkit).
6670 - **Environments' protection rules** (required reviewers, wait timers,
6771 branch limits). A job with `environment:` gets that environment's
6872 [values](/guides/secrets-and-variables/#a-value-per-environment), and runs
164168 ```
165169
166170 Each restore and save says on the job's log how large the entry was and
167−how long it took. A workspace on the plan pays for what its caches hold
168−(`Actions cache storage` on its statement), at R2's price plus the margin;
169−see [usage and billing](/guides/usage-and-billing/#actions-cache).
171+how long it took. A workspace on the plan pays for what its caches and
172+[artifacts](#artifacts) hold (`Actions cache storage` on its statement),
173+at R2's price plus the margin; see
174+[usage and billing](/guides/usage-and-billing/#actions-cache).
175+
176+## Artifacts
177+
178+`actions/upload-artifact` keeps files a job made with its run, for later
179+jobs, other runs and people:
180+
181+| | |
182+| --- | --- |
183+| One artifact | Up to 5 GiB, zipped. |
184+| A run's artifacts | Up to 10 GiB together. |
185+| How long | The repository's setting: 14 days unless someone with the Maintain role changes it under **Settings → Repository → Artifacts**, from 1 to 90 days. `retention-days` asks for fewer days, never more. |
186+| Names | One artifact per name in a run. Uploading a name again fails, unless the upload says `overwrite: true`, which replaces it. A name is up to 256 characters, none of `" : < > \| * ? \ /`. |
187+| `path` | Files, folders and globs, `**` included; a line starting with `!` leaves matching paths out. Files and folders whose names start with `.` are left out unless `include-hidden-files: true`. |
188+| Compression | `compression-level` 0 (stored) to 9; 6 unless you say. |
189+| Outputs | `artifact-id` (a number), `artifact-url` (its run's page) and `artifact-digest` (the SHA-256 of its zip). |
190+
191+```yaml
192+- uses: actions/upload-artifact@v4
193+ with:
194+ name: web-dist
195+ path: |
196+ dist/
197+ !dist/**/*.map
198+ retention-days: 5
199+ compression-level: 9
200+```
201+
202+`actions/download-artifact` downloads one by `name` into `path`, or every
203+artifact of the run, each into a folder of its name; `pattern` picks them
204+by name, and `merge-multiple: true` puts them all in one folder.
205+`artifact-ids` picks them by number. With `github-token` and `run-id`, it
206+downloads from another run of the same repository, such as the one a
207+`workflow_run` workflow follows:
208+
209+```yaml
210+- uses: actions/download-artifact@v4
211+ with:
212+ name: web-dist
213+ github-token: ${{ secrets.GITHUB_TOKEN }}
214+ run-id: ${{ github.event.workflow_run.id }}
215+```
216+
217+`actions/upload-artifact/merge` downloads the run's artifacts that match
218+`pattern`, uploads them as one artifact (`name`, `merged-artifacts` unless
219+you say), and deletes them with `delete-merged: true`.
220+
221+A run's page lists its artifacts with their size and when they expire.
222+Anyone who can see the run downloads them there; someone with the Write
223+role can delete one before it expires.
224+
225+## Actions built on the toolkit
226+
227+Many actions save to the cache with GitHub's toolkit, `@actions/cache`,
228+rather than through `actions/cache`: `actions/setup-node`,
229+`actions/setup-python`, `actions/setup-go` and `actions/setup-java` with
230+`cache:`, `Swatinem/rust-cache`, and others. They work on g1t as they
231+are: every job gets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_CACHE_URL` and
232+`ACTIONS_RESULTS_URL`, and g1t answers the toolkit's requests from the
233+repository's cache.
234+
235+- Their entries are the repository's, under [the cache's](#the-cache)
236+ limits, and are deleted the same way.
237+- An entry is restored only by the same kind of save: the toolkit names a
238+ version for each entry, from its paths and compression. An entry
239+ `setup-node` saved is not restored by `actions/cache`, and the other way
240+ round.
241+- An entry the toolkit sends whole, which it does below 128 MB, is not
242+ saved when it is over 100 MB, the most g1t takes in one request. The
243+ step warns and the job goes on.
244+- The toolkit's artifact library refuses to run against any server but
245+ github.com, so an action that uploads artifacts with it directly fails
246+ with its own message. `actions/upload-artifact`,
247+ `actions/download-artifact` and `actions/upload-artifact/merge` work:
248+ g1t runs those itself.
249+
250+## OIDC tokens
251+
252+A job can prove which repository, branch and environment it runs for with
253+a short-lived OpenID Connect token signed by g1t, and trade it for a cloud
254+provider's credentials. Nothing long-lived needs to sit in a secret.
255+
256+1. Give the job, or the workflow, `permissions: id-token: write`. A job
257+ without it gets no token, and neither does a run of a pull request from
258+ outside the repository.
259+2. Tell your cloud to trust g1t's issuer for your repository (below).
260+3. Use the provider's own login action, which asks for the token.
261+
262+```yaml
263+permissions:
264+ id-token: write
265+ contents: read
266+
267+jobs:
268+ deploy:
269+ runs-on: ubuntu-latest
270+ environment: production
271+ steps:
272+ - uses: aws-actions/configure-aws-credentials@v4
273+ with:
274+ role-to-assume: arn:aws:iam::123456789012:role/acme-web-deploy
275+ aws-region: us-east-1
276+```
277+
278+The job gets `ACTIONS_ID_TOKEN_REQUEST_URL` and
279+`ACTIONS_ID_TOKEN_REQUEST_TOKEN`, which `core.getIDToken()` reads. To ask
280+for a token yourself, name its audience:
281+
282+```sh
283+curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
284+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.example.com" | jq -r .value
285+```
286+
287+| | |
288+| --- | --- |
289+| Issuer | `https://api.g1t.sh/actions/oidc` |
290+| Discovery | `https://api.g1t.sh/actions/oidc/.well-known/openid-configuration` |
291+| Keys | `https://api.g1t.sh/actions/oidc/.well-known/jwks`, RS256, each with its `kid` |
292+| Lifetime | 5 minutes |
293+| Audience | What the job asks for; `https://g1t.sh/<owner>` when it asks for none |
294+
295+### Claims
296+
297+Each token carries the claims GitHub's do, so trust policies written for
298+those read g1t's the same way.
299+
300+| Claim | Example |
301+| --- | --- |
302+| `sub` | `repo:acme/web:environment:production` for a job with an `environment:`; `repo:acme/web:pull_request` for a pull request's run; otherwise `repo:acme/web:ref:refs/heads/main` (or `refs/tags/v1.2.0`) |
303+| `repository`, `repository_owner` | `acme/web`, `acme` |
304+| `repository_id`, `repository_owner_id` | g1t's ids for them, such as `rep_01kpw0…` |
305+| `repository_visibility` | `public` or `private` |
306+| `ref`, `ref_type`, `ref_protected`, `sha` | `refs/heads/main`, `branch`, `"true"`, the commit |
307+| `head_ref`, `base_ref` | A pull request's branches |
308+| `environment` | The job's environment, when it has one |
309+| `event_name` | `push`, `pull_request`, `workflow_dispatch`, … |
310+| `workflow`, `workflow_ref`, `workflow_sha` | `Deploy`, `acme/web/.g1t/workflows/deploy.yml@refs/heads/main`, the commit |
311+| `job_workflow_ref`, `job_workflow_sha` | The workflow that defines the job: a called workflow's own file |
312+| `run_id`, `run_number`, `run_attempt` | `run_01kq9c…`, `"12"`, `"1"` |
313+| `actor`, `actor_id` | Who started the run |
314+| `runner_environment` | `github-hosted` on g1t's machines, `self-hosted` on yours |
315+| `iss`, `aud`, `jti`, `iat`, `nbf`, `exp` | As in any OIDC token |
316+
317+### AWS
318+
319+1. Add g1t as an identity provider, under **IAM → Identity providers**:
320+ provider type **OpenID Connect**, provider URL
321+ `https://api.g1t.sh/actions/oidc`, audience `sts.amazonaws.com`. Or:
322+
323+ ```sh
324+ aws iam create-open-id-connect-provider \
325+ --url https://api.g1t.sh/actions/oidc \
326+ --client-id-list sts.amazonaws.com
327+ ```
328+
329+2. Give the role a trust policy for your repository:
330+
331+ ```json
332+ {
333+ "Version": "2012-10-17",
334+ "Statement": [{
335+ "Effect": "Allow",
336+ "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/api.g1t.sh/actions/oidc" },
337+ "Action": "sts:AssumeRoleWithWebIdentity",
338+ "Condition": {
339+ "StringEquals": {
340+ "api.g1t.sh/actions/oidc:aud": "sts.amazonaws.com",
341+ "api.g1t.sh/actions/oidc:sub": "repo:acme/web:environment:production"
342+ }
343+ }
344+ }]
345+ }
346+ ```
347+
348+3. Use `aws-actions/configure-aws-credentials@v4` with `role-to-assume`,
349+ as above.
350+
351+### Google Cloud
352+
353+1. Make a workload identity pool and a provider for g1t:
354+
355+ ```sh
356+ gcloud iam workload-identity-pools create g1t --location=global
357+ gcloud iam workload-identity-pools providers create-oidc g1t \
358+ --location=global --workload-identity-pool=g1t \
359+ --issuer-uri=https://api.g1t.sh/actions/oidc \
360+ --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
361+ --attribute-condition="assertion.repository_owner == 'acme'"
362+ ```
363+
364+2. Let the repository act as a service account:
365+
366+ ```sh
367+ gcloud iam service-accounts add-iam-policy-binding deploy@acme-prod.iam.gserviceaccount.com \
368+ --role=roles/iam.workloadIdentityUser \
369+ --member="principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/g1t/attribute.repository/acme/web"
370+ ```
371+
372+3. Use `google-github-actions/auth@v2` with
373+ `workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/g1t/providers/g1t`
374+ and `service_account`. It asks for the provider's own name as the
375+ audience, which the provider accepts unless you change its allowed
376+ audiences.
377+
378+### Azure
379+
380+1. On the app registration or user-assigned managed identity, add a
381+ federated credential with the scenario **Other issuer**: issuer
382+ `https://api.g1t.sh/actions/oidc`, subject identifier
383+ `repo:acme/web:environment:production`, audience
384+ `api://AzureADTokenExchange`. Or:
385+
386+ ```sh
387+ az ad app federated-credential create --id <application id> --parameters '{
388+ "name": "g1t-acme-web-production",
389+ "issuer": "https://api.g1t.sh/actions/oidc",
390+ "subject": "repo:acme/web:environment:production",
391+ "audiences": ["api://AzureADTokenExchange"]
392+ }'
393+ ```
394+
395+2. Give it a role on what it deploys to, as for any identity.
396+3. Use `azure/login@v2` with `client-id`, `tenant-id` and
397+ `subscription-id`, and no secret.
398+
399+A federated credential matches the subject exactly: add one per
400+environment or branch that deploys.
401+
402+### Cloudflare
403+
404+Cloudflare's API takes API tokens, not OIDC tokens. Keep a token scoped
405+to what the workflow deploys in a secret, available to that workflow only
406+(see [workflow-only domains](/guides/guardrails/#workflow-only-domains)
407+for limiting where it can be sent).
170408
409+A Worker of your own can trust g1t's jobs directly, by checking the token
410+a job sends it against g1t's keys:
411+
412+```ts
413+import { createRemoteJWKSet, jwtVerify } from "jose";
414+
415+const keys = createRemoteJWKSet(new URL("https://api.g1t.sh/actions/oidc/.well-known/jwks"));
416+
417+export async function fromG1tJob(request: Request): Promise<boolean> {
418+ const token = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
419+ const { payload } = await jwtVerify(token, keys, {
420+ issuer: "https://api.g1t.sh/actions/oidc",
421+ audience: "https://deploy.example.com",
422+ });
423+ return payload.sub === "repo:acme/web:environment:production";
424+}
425+```
426+
427+### npm
428+
429+npm's trusted publishing and provenance accept OIDC tokens only from the
430+CI services npm lists, and g1t is not one of them yet. Publish with a
431+granular access token in a secret instead:
432+
433+```yaml
434+- uses: actions/setup-node@v4
435+ with:
436+ node-version: 24
437+ registry-url: https://registry.npmjs.org
438+- run: npm publish
439+ env:
440+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
441+```
442+
443+See [What g1t can't do yet](/about/limitations/#no-npm-trusted-publishing-or-provenance).
444+
171445 ## Runs and logs
172446
173447 Open a repository's **Actions** page, in its sidebar. Pick a workflow to
331605 | `cancel` | `POST /repos/{owner}/{repo}/actions/runs/{id}/cancel` |
332606 | `rerun` | `POST …/runs/{id}/rerun`, or `…/rerun-failed-jobs` |
333607 | `update` | `PUT …/workflows/{workflow}/enable` and `…/disable` |
608+| `list_artifacts` | `GET /repos/{owner}/{repo}/actions/artifacts`, with `name`, `page`, `per_page` |
609+| `run_artifacts` | `GET …/actions/runs/{id}/artifacts`, with `name` |
610+| `get_artifact` | `GET …/actions/artifacts/{artifact_id}` |
611+| `download_artifact` | `GET …/actions/artifacts/{artifact_id}/zip`: a `302` to a link good for 10 minutes |
612+| `delete_artifact` | `DELETE …/actions/artifacts/{artifact_id}` |
613+| `artifact_retention`, `set_artifact_retention` | `GET` and `PUT …/actions/permissions/artifact-and-log-retention` with `days` (it sets artifacts' days only; logs are kept with their run) |
334614
335615 Secrets and variables have a tool of their own, `secret`:
336616
350630 -d '{"ref": "main", "inputs": {"environment": "staging"}}'
351631 ```
352632
633+To save an artifact from a script, follow the redirect:
634+
635+```sh
636+curl -L -o web-dist.zip -H "Authorization: Bearer $G1T_TOKEN" \
637+ https://api.g1t.sh/repos/acme/web/actions/artifacts/4182/zip
638+```
+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.