Skip to content

Commit e00a71b

Browse files
wan9chiclaude
andcommitted
feat(cache): authenticate remote cache uploads with GitHub Actions OIDC
Planning resolves `ResolvedRemoteCacheConfig::auth` to `github-oidc` when `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` are set. It holds the request URL, the request token, and the audience, which is the endpoint without a trailing slash. The request token is a `Secret`, which debug output and serialized plans redact. `build_auth` turns it into `vt_remote_cache::auth::GithubOidc`, which adds a GitHub Actions OIDC token to stores only. It requests a token when the first store needs it, reuses it until two minutes before it expires, shares one request between concurrent stores, and remembers a failed request, so later stores fail without another one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fe27dbd commit e00a71b

18 files changed

Lines changed: 930 additions & 69 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
- **Changed** The run summary now says a task that wrote a file it also read was `not cached because it modified its inputs`, and the statistics in `vp run --verbose` and `vp run --last-details` use the singular for a count of one, e.g. `1 task • 1 cache miss` ([#783](https://github.com/voidzero-dev/vite-task/pull/783)).
55
- **Fixed** An invalid glob in `--filter` no longer shows its error message twice ([#763](https://github.com/voidzero-dev/vite-task/pull/763)).
66
- **Changed** The detailed summary from `vp run --verbose` and `vp run --last-details` now shows each underlying cause of an error on its own line ([#761](https://github.com/voidzero-dev/vite-task/pull/761)).
7-
- **Added** Remote caching. Configure an endpoint with the workspace's `cache: { remote: { url } }` or `VP_REMOTE_CACHE_URL`, and choose access with `--remote-cache=off|read|read-write` or `VP_REMOTE_CACHE`. The default is `read` with an endpoint and `off` without one. After a local cache miss, `vp run` looks the task up in the remote cache and, on a hit, restores its outputs and caches it locally. The task output and the run summary show which hits came from the remote cache. A failed read is just a cache miss, with the failure as its reason. In `read-write` mode, `vp run` also uploads the results of successful, cacheable tasks after caching them locally. Uploads run in the background, so tasks that depend on an uploading task don't wait for it; once all tasks are done, `vp run` waits for the uploads still running and says so. A failed upload doesn't fail the task; the run summary shows a warning instead. Ctrl-C, or a failing task, stops remote cache lookups right away, and a task still being looked up doesn't start. Ctrl-C also cancels the uploads, but a failing task doesn't. Tasks can opt out with `cache: { remote: false }`. Requests to an HTTPS endpoint use HTTP/2 if the server supports it. Requests use the proxy environment variables or, on macOS and Windows, the system proxy settings ([#727](https://github.com/voidzero-dev/vite-task/pull/727), [#755](https://github.com/voidzero-dev/vite-task/pull/755), [#756](https://github.com/voidzero-dev/vite-task/pull/756), [#757](https://github.com/voidzero-dev/vite-task/pull/757), [#764](https://github.com/voidzero-dev/vite-task/pull/764), [#770](https://github.com/voidzero-dev/vite-task/pull/770), [#771](https://github.com/voidzero-dev/vite-task/pull/771), [#772](https://github.com/voidzero-dev/vite-task/pull/772), [#786](https://github.com/voidzero-dev/vite-task/pull/786), [#787](https://github.com/voidzero-dev/vite-task/pull/787)).
7+
- **Added** Remote caching. Configure an endpoint with the workspace's `cache: { remote: { url } }` or `VP_REMOTE_CACHE_URL`, and choose access with `--remote-cache=off|read|read-write` or `VP_REMOTE_CACHE`. The default is `read` with an endpoint and `off` without one. After a local cache miss, `vp run` looks the task up in the remote cache and, on a hit, restores its outputs and caches it locally. The task output and the run summary show which hits came from the remote cache. A failed read is just a cache miss, with the failure as its reason. In `read-write` mode, `vp run` also uploads the results of successful, cacheable tasks after caching them locally. Uploads run in the background, so tasks that depend on an uploading task don't wait for it; once all tasks are done, `vp run` waits for the uploads still running and says so. A failed upload doesn't fail the task; the run summary shows a warning instead. Ctrl-C, or a failing task, stops remote cache lookups right away, and a task still being looked up doesn't start. Ctrl-C also cancels the uploads, but a failing task doesn't. Tasks can opt out with `cache: { remote: false }`. Requests to an HTTPS endpoint use HTTP/2 if the server supports it. Requests use the proxy environment variables or, on macOS and Windows, the system proxy settings. In a GitHub Actions job with `permissions: id-token: write`, uploads authenticate with a GitHub Actions OIDC token whose audience is the endpoint without a trailing slash. If no token can be obtained, each upload fails with a warning instead ([#727](https://github.com/voidzero-dev/vite-task/pull/727), [#755](https://github.com/voidzero-dev/vite-task/pull/755), [#756](https://github.com/voidzero-dev/vite-task/pull/756), [#757](https://github.com/voidzero-dev/vite-task/pull/757), [#764](https://github.com/voidzero-dev/vite-task/pull/764), [#770](https://github.com/voidzero-dev/vite-task/pull/770), [#771](https://github.com/voidzero-dev/vite-task/pull/771), [#772](https://github.com/voidzero-dev/vite-task/pull/772), [#786](https://github.com/voidzero-dev/vite-task/pull/786), [#787](https://github.com/voidzero-dev/vite-task/pull/787), [#798](https://github.com/voidzero-dev/vite-task/pull/798)).
88
- **Fixed** On Windows, environment variable names used by `vp run` now match regardless of ASCII letter case. Assignments in task commands override earlier assignments and inherited variables spelled differently, and `FORCE_COLOR`, `VP_RUN_CONCURRENCY_LIMIT`, and variables requested through `@voidzero-dev/vite-task-client` are found under any spelling ([#747](https://github.com/voidzero-dev/vite-task/pull/747)).
99
- **Changed** A task's cache settings now go inside `cache`, e.g. `cache: { env: ["NODE_ENV"], input: ["src/**"] }`; `cache: true` is the same as `cache: {}`. `env`, `untrackedEnv`, `input`, and `output` are no longer supported at the top level of a task ([#749](https://github.com/voidzero-dev/vite-task/pull/749)).
1010
- **Fixed** Cached tasks on macOS no longer intermittently fail with exit 2 and `oils I/O error (main): No such process` when a fast command finishes before the shell gets scheduled. The bundled shell that runs task commands is updated to Oils 0.38.0, which fixes this race ([#702](https://github.com/voidzero-dev/vite-task/issues/702), [#703](https://github.com/voidzero-dev/vite-task/pull/703)).

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/vt/src/session/cache/remote.rs‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use vt_plan::{
3030
};
3131
use vt_remote_cache::{
3232
Client, Download, Fetched,
33-
auth::{Anonymous, Auth},
33+
auth::{Anonymous, Auth, GithubOidc},
3434
};
3535
use vt_str::Str;
3636
use wincode::{
@@ -239,6 +239,9 @@ impl RemoteClients {
239239
fn build_auth(auth: &RemoteCacheAuth) -> Arc<dyn Auth> {
240240
match auth {
241241
RemoteCacheAuth::Anonymous => Arc::new(Anonymous),
242+
RemoteCacheAuth::GithubOidc { request_url, request_token, audience } => {
243+
Arc::new(GithubOidc::new(request_url, request_token.expose(), audience))
244+
}
242245
}
243246
}
244247

@@ -419,7 +422,7 @@ mod tests {
419422
use vt_path::{AbsolutePathBuf, RelativePathBuf};
420423
use vt_plan::{
421424
cache_metadata::{EnvValueHash, SpawnFingerprint},
422-
remote_cache::RemoteCacheAccess,
425+
remote_cache::{RemoteCacheAccess, Secret},
423426
};
424427

425428
use super::*;
@@ -756,11 +759,22 @@ mod tests {
756759
serve_stalled(b"HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\n\r\n");
757760
// Nothing can listen on port 0.
758761
let unreachable = anonymous("http://127.0.0.1:0/projects/test");
762+
let github_oidc_unavailable = ResolvedRemoteCacheConfig {
763+
auth: RemoteCacheAuth::GithubOidc {
764+
request_url: Arc::from("http://127.0.0.1:0/token"),
765+
request_token: Secret::new(Arc::from("request-token")),
766+
audience: Arc::from("http://127.0.0.1:0/projects/test"),
767+
},
768+
..unreachable.clone()
769+
};
759770
let clients = RemoteClients::default();
760771
let uploads = RemoteUploads::default();
761772

762-
for (remote, message) in [(error_status, "HTTP status 500"), (unreachable, "network error")]
763-
{
773+
for (remote, message) in [
774+
(error_status, "HTTP status 500"),
775+
(unreachable, "network error"),
776+
(github_oidc_unavailable, "failed to authenticate"),
777+
] {
764778
let error = Arc::new(OnceLock::new());
765779
uploads.spawn(prepare_upload(&clients, &remote).unwrap(), Arc::clone(&error));
766780
uploads.wait(&CancellationToken::new()).await;

‎crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,42 @@ steps = [
443443
], comment = "The details include the underlying error." },
444444
]
445445

446+
[[e2e]]
447+
name = "github_oidc_unavailable"
448+
steps = [
449+
{ argv = [
450+
"vt",
451+
"run",
452+
"build",
453+
], envs = [
454+
[
455+
"VP_REMOTE_CACHE",
456+
"read-write",
457+
],
458+
[
459+
"VP_REMOTE_CACHE_URL",
460+
"http://127.0.0.1:0/projects/test",
461+
],
462+
[
463+
"ACTIONS_ID_TOKEN_REQUEST_URL",
464+
"http://127.0.0.1:0/token?api-version=2.0",
465+
],
466+
[
467+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
468+
"request-token",
469+
],
470+
[
471+
"VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS",
472+
"1",
473+
],
474+
], comment = "The job can request GitHub Actions OIDC tokens, so the upload needs one first. Nothing can listen on port 0, so the token request fails, and the upload fails without being sent. The task succeeds." },
475+
{ argv = [
476+
"vt",
477+
"run",
478+
"--last-details",
479+
], comment = "The details include why the token request failed." },
480+
]
481+
446482
[[e2e]]
447483
name = "read_invalid_endpoint"
448484
steps = [
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# github_oidc_unavailable
2+
3+
## `VP_REMOTE_CACHE=read-write VP_REMOTE_CACHE_URL=http://127.0.0.1:0/projects/test ACTIONS_ID_TOKEN_REQUEST_URL=http://127.0.0.1:0/token?api-version=2.0 ACTIONS_ID_TOKEN_REQUEST_TOKEN=request-token VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 vt run build`
4+
5+
The job can request GitHub Actions OIDC tokens, so the upload needs one first. Nothing can listen on port 0, so the token request fails, and the upload fails without being sent. The task succeeds.
6+
7+
```
8+
$ vtt write-file dist/output.txt built ○ cache miss: remote cache fetch failed, executing
9+
10+
---
11+
vt run: remote-cache#build not uploaded to the remote cache: failed to authenticate. (Run `vt run --last-details` for full details)
12+
```
13+
14+
## `vt run --last-details`
15+
16+
The details include why the token request failed.
17+
18+
```
19+
20+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21+
Vite+ Task Runner • Execution Summary
22+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
23+
24+
Statistics: 1 task • 0 cache hits • 1 cache miss
25+
Performance: 0% cache hit rate
26+
27+
Task Details:
28+
────────────────────────────────────────────────
29+
[1] remote-cache#build: $ vtt write-file dist/output.txt built ✓
30+
→ Cache miss: remote cache fetch failed
31+
↳ network error
32+
↳ error sending request
33+
↳ client error (Connect)
34+
↳ tcp connect error
35+
↳ <os error>
36+
⚠ Not uploaded to the remote cache: failed to authenticate
37+
↳ GitHub Actions OIDC token request failed
38+
↳ error sending request
39+
↳ client error (Connect)
40+
↳ tcp connect error
41+
↳ <os error>
42+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43+
```

‎crates/vt_graph/run-config.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export type InputBase = "package" | "workspace";
2929
export type RemoteCacheConfig = {
3030
/**
3131
* HTTP or HTTPS namespace endpoint. Overridden by `VP_REMOTE_CACHE_URL`.
32+
*
33+
* In a GitHub Actions job with `permissions: id-token: write`, uploads
34+
* authenticate with a GitHub Actions OIDC token whose audience is the
35+
* endpoint without a trailing slash.
3236
*/
3337
url: string, };
3438

‎crates/vt_graph/src/config/user.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,10 @@ impl ResolvedGlobalCacheConfig {
412412
#[serde(deny_unknown_fields, rename_all = "camelCase")]
413413
pub struct UserRemoteCacheConfig {
414414
/// HTTP or HTTPS namespace endpoint. Overridden by `VP_REMOTE_CACHE_URL`.
415+
///
416+
/// In a GitHub Actions job with `permissions: id-token: write`, uploads
417+
/// authenticate with a GitHub Actions OIDC token whose audience is the
418+
/// endpoint without a trailing slash.
415419
pub url: Arc<str>,
416420
}
417421

‎crates/vt_plan/src/error.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@ pub enum Error {
184184
#[error("Remote caching requires cache.remote.url or VP_REMOTE_CACHE_URL")]
185185
MissingRemoteCacheEndpoint,
186186

187+
/// The value isn't shown, since it can be a credential.
188+
#[error("Invalid value for {0}: not valid UTF-8")]
189+
NonUtf8RemoteCacheAuthEnv(&'static str),
190+
187191
/// A cycle was detected in the task dependency graph during planning.
188192
///
189193
/// This is caught by `AcyclicGraph::try_from_graph`, which validates that the

‎crates/vt_plan/src/remote_cache.rs‎

Lines changed: 132 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
//! Remote cache settings: the mode requested with `--remote-cache` or
22
//! `VP_REMOTE_CACHE`, and the access and auth resolved for each `vp run` level.
33
4-
use std::{ffi::OsStr, sync::Arc};
4+
use std::{ffi::OsStr, fmt, sync::Arc};
55

66
use rustc_hash::FxHashMap;
7-
use serde::Serialize;
7+
use serde::{Serialize, Serializer};
88
use vt_casefold::EnvName;
99

1010
use crate::Error;
1111

1212
pub(crate) const MODE_ENV: &str = "VP_REMOTE_CACHE";
1313
const URL_ENV: &str = "VP_REMOTE_CACHE_URL";
14+
/// Set in GitHub Actions jobs that can request OIDC tokens, which need
15+
/// `permissions: id-token: write`.
16+
const GITHUB_OIDC_REQUEST_URL_ENV: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
17+
const GITHUB_OIDC_REQUEST_TOKEN_ENV: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
1418

1519
/// Remote cache mode requested with `--remote-cache` or `VP_REMOTE_CACHE`.
1620
/// `resolve` combines it with the endpoint into a [`ResolvedRemoteCacheConfig`].
@@ -33,7 +37,8 @@ impl RemoteCacheMode {
3337
}
3438

3539
/// Remote cache access, endpoint, and auth resolved for a `vp run` level from
36-
/// the requested mode, `VP_REMOTE_CACHE_URL`, and `cache.remote.url`.
40+
/// the requested mode, `VP_REMOTE_CACHE_URL`, `cache.remote.url`, and the envs
41+
/// that the auth uses.
3742
#[derive(Debug, Clone, Serialize)]
3843
pub struct ResolvedRemoteCacheConfig {
3944
pub access: RemoteCacheAccess,
@@ -49,6 +54,41 @@ pub struct ResolvedRemoteCacheConfig {
4954
pub enum RemoteCacheAuth {
5055
/// Requests carry no credentials.
5156
Anonymous,
57+
/// Stores carry a GitHub Actions OIDC token for `audience`, requested
58+
/// from `request_url` with `request_token`.
59+
GithubOidc { request_url: Arc<str>, request_token: Secret, audience: Arc<str> },
60+
}
61+
62+
/// A credential. Debug output and serialized plans show a placeholder
63+
/// instead of its value.
64+
#[derive(Clone, PartialEq, Eq, Hash)]
65+
pub struct Secret(Arc<str>);
66+
67+
impl Secret {
68+
const REDACTED: &str = "<redacted>";
69+
70+
#[must_use]
71+
pub const fn new(value: Arc<str>) -> Self {
72+
Self(value)
73+
}
74+
75+
/// The value, for sending it where it's needed.
76+
#[must_use]
77+
pub fn expose(&self) -> &str {
78+
&self.0
79+
}
80+
}
81+
82+
impl fmt::Debug for Secret {
83+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84+
f.write_str(Self::REDACTED)
85+
}
86+
}
87+
88+
impl Serialize for Secret {
89+
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
90+
serializer.serialize_str(Self::REDACTED)
91+
}
5292
}
5393

5494
/// Remote cache access after resolution. `off` resolves to no remote cache.
@@ -98,25 +138,57 @@ pub(crate) fn resolve(
98138
return Err(Error::MissingRemoteCacheEndpoint);
99139
}
100140
};
101-
Ok(Some(ResolvedRemoteCacheConfig { access, url, auth: RemoteCacheAuth::Anonymous }))
141+
let auth = resolve_auth(&url, envs)?;
142+
Ok(Some(ResolvedRemoteCacheConfig { access, url, auth }))
143+
}
144+
145+
/// Resolves how requests to `url` authenticate from the envs visible at the
146+
/// `vp run` level. In a GitHub Actions job that can request OIDC tokens,
147+
/// stores carry a token whose audience is `url` without a trailing slash.
148+
/// Otherwise requests are anonymous.
149+
fn resolve_auth(
150+
url: &str,
151+
envs: &FxHashMap<EnvName<Arc<OsStr>>, Arc<OsStr>>,
152+
) -> Result<RemoteCacheAuth, Error> {
153+
let (Some(request_url), Some(request_token)) = (
154+
env_value(envs, GITHUB_OIDC_REQUEST_URL_ENV),
155+
env_value(envs, GITHUB_OIDC_REQUEST_TOKEN_ENV),
156+
) else {
157+
return Ok(RemoteCacheAuth::Anonymous);
158+
};
159+
let utf8 = |name: &'static str, value: &OsStr| {
160+
value.to_str().map(Arc::from).ok_or(Error::NonUtf8RemoteCacheAuthEnv(name))
161+
};
162+
Ok(RemoteCacheAuth::GithubOidc {
163+
request_url: utf8(GITHUB_OIDC_REQUEST_URL_ENV, request_url)?,
164+
request_token: Secret::new(utf8(GITHUB_OIDC_REQUEST_TOKEN_ENV, request_token)?),
165+
audience: Arc::from(url.trim_end_matches('/')),
166+
})
102167
}
103168

104169
#[cfg(test)]
105170
mod tests {
171+
use std::ffi::OsString;
172+
106173
use super::*;
107174

175+
fn envs<'a>(
176+
pairs: impl IntoIterator<Item = (&'a str, &'a OsStr)>,
177+
) -> FxHashMap<EnvName<Arc<OsStr>>, Arc<OsStr>> {
178+
pairs
179+
.into_iter()
180+
.map(|(name, value)| {
181+
(EnvName::new(Arc::<OsStr>::from(OsStr::new(name))), Arc::<OsStr>::from(value))
182+
})
183+
.collect()
184+
}
185+
108186
#[test]
109187
fn reads_envs_by_platform_name_rules() {
110-
let envs =
111-
[("vp_remote_cache", "read-write"), ("vp_remote_cache_url", "https://cache.example")]
112-
.into_iter()
113-
.map(|(name, value)| {
114-
(
115-
EnvName::new(Arc::<OsStr>::from(OsStr::new(name))),
116-
Arc::<OsStr>::from(OsStr::new(value)),
117-
)
118-
})
119-
.collect();
188+
let envs = envs([
189+
("vp_remote_cache", OsStr::new("read-write")),
190+
("vp_remote_cache_url", OsStr::new("https://cache.example")),
191+
]);
120192
let resolved = resolve(None, &envs).unwrap();
121193
if cfg!(windows) {
122194
let resolved = resolved.unwrap();
@@ -126,4 +198,50 @@ mod tests {
126198
assert!(resolved.is_none());
127199
}
128200
}
201+
#[test]
202+
fn github_oidc_token_is_redacted() {
203+
let envs = envs([
204+
("VP_REMOTE_CACHE_URL", OsStr::new("https://cache.example/projects/test/")),
205+
("ACTIONS_ID_TOKEN_REQUEST_URL", OsStr::new("https://token.example/?api-version=2.0")),
206+
("ACTIONS_ID_TOKEN_REQUEST_TOKEN", OsStr::new("request-token")),
207+
]);
208+
let auth = resolve(None, &envs).unwrap().unwrap().auth;
209+
let RemoteCacheAuth::GithubOidc { request_url, request_token, audience } = &auth else {
210+
panic!("expected GitHub OIDC, got {auth:?}");
211+
};
212+
assert_eq!(&**request_url, "https://token.example/?api-version=2.0");
213+
assert_eq!(request_token.expose(), "request-token");
214+
assert_eq!(&**audience, "https://cache.example/projects/test");
215+
216+
let debug = vt_str::format!("{auth:?}");
217+
let serialized = serde_json::to_string(&auth).unwrap();
218+
for output in [debug.as_str(), &serialized] {
219+
assert!(!output.contains("request-token") && output.contains("<redacted>"), "{output}");
220+
}
221+
}
222+
223+
#[test]
224+
fn non_utf8_auth_env_fails_without_showing_its_value() {
225+
#[cfg(unix)]
226+
let token = {
227+
use std::os::unix::ffi::OsStringExt as _;
228+
OsString::from_vec(b"secret\xff".to_vec())
229+
};
230+
#[cfg(windows)]
231+
let token = {
232+
use std::os::windows::ffi::OsStringExt as _;
233+
// "secret" followed by an unpaired surrogate.
234+
OsString::from_wide(&[0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0xD800])
235+
};
236+
let envs = envs([
237+
("VP_REMOTE_CACHE_URL", OsStr::new("https://cache.example")),
238+
("ACTIONS_ID_TOKEN_REQUEST_URL", OsStr::new("https://token.example")),
239+
("ACTIONS_ID_TOKEN_REQUEST_TOKEN", token.as_os_str()),
240+
]);
241+
let error = resolve(None, &envs).unwrap_err();
242+
assert_eq!(
243+
error.to_string(),
244+
"Invalid value for ACTIONS_ID_TOKEN_REQUEST_TOKEN: not valid UTF-8"
245+
);
246+
}
129247
}

0 commit comments

Comments
 (0)