mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add tag support to Git run targets
This commit is contained in:
parent
34014e6dce
commit
fbba98defd
32 changed files with 692 additions and 112 deletions
|
|
@ -9318,7 +9318,10 @@ components:
|
||||||
folder: "#/components/schemas/FolderRunTarget"
|
folder: "#/components/schemas/FolderRunTarget"
|
||||||
|
|
||||||
GitRunTarget:
|
GitRunTarget:
|
||||||
description: Public github.com repository target.
|
description: >-
|
||||||
|
Public github.com repository target. The branch names the attached
|
||||||
|
working branch. An optional tag selects a release at worker start, and
|
||||||
|
an optional exact SHA is authoritative when both are present.
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required:
|
required:
|
||||||
|
|
@ -9335,14 +9338,23 @@ components:
|
||||||
example: acme/my-app
|
example: acme/my-app
|
||||||
branch:
|
branch:
|
||||||
type: string
|
type: string
|
||||||
description: Required branch name, preserved exactly.
|
description: Required attached working branch name, preserved exactly.
|
||||||
example: feature/foo
|
example: feature/foo
|
||||||
|
tag:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: >-
|
||||||
|
Optional bare tag name. Prefixes such as `refs/tags/` and `tags/`
|
||||||
|
are rejected. Without `sha`, the worker resolves this tag when the
|
||||||
|
sandbox starts and fails if it is unavailable.
|
||||||
|
example: v1.2.3
|
||||||
sha:
|
sha:
|
||||||
type: string
|
type: string
|
||||||
pattern: "^[0-9A-Fa-f]{40}$"
|
pattern: "^[0-9A-Fa-f]{40}$"
|
||||||
description: >-
|
description: >-
|
||||||
Optional exact commit. The server lowercase-normalizes its syntax
|
Optional exact commit. The server lowercase-normalizes its syntax
|
||||||
but does not resolve it or prove branch ancestry.
|
but does not resolve it, prove branch ancestry, or prove that it
|
||||||
|
matches an accompanying tag. When present, this exact commit wins.
|
||||||
|
|
||||||
NoneRunTarget:
|
NoneRunTarget:
|
||||||
description: >-
|
description: >-
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ memory = "4GB"
|
||||||
mode = "block"
|
mode = "block"
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
|
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
|
||||||
|
|
||||||
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.
|
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,18 +227,29 @@ When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the c
|
||||||
|
|
||||||
For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints.
|
For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints.
|
||||||
|
|
||||||
#### Exact commits for run intents
|
#### Git targets for run intents
|
||||||
|
|
||||||
The `RunIntent` create body names a required Git branch and may also pin a full
|
The `RunIntent` create body always names a GitHub repository and a working
|
||||||
40-character commit SHA. Creating the run validates and lowercase-normalizes
|
branch. It may also select a bare tag, pin a full 40-character commit SHA, or
|
||||||
the SHA, but it does not contact GitHub, resolve the commit, or prove that the
|
include both:
|
||||||
commit belongs to the submitted branch.
|
|
||||||
|
|
||||||
At sandbox setup, Docker fetches the submitted commit directly and Daytona
|
| Target fields | Revision selected when the worker starts |
|
||||||
receives it as `commit_id`; the submitted branch remains the working branch.
|
|---|---|
|
||||||
If the exact commit is unavailable, setup fails. Fabro never substitutes the
|
| `branch` | The branch HEAD |
|
||||||
branch's newer HEAD. When the request omits `sha`, the sandbox resolves the
|
| `branch` + `sha` | The exact commit |
|
||||||
branch at materialization time instead.
|
| `branch` + `tag` | The tag's peeled commit |
|
||||||
|
| `branch` + `tag` + `sha` | The exact commit; the tag remains part of the run's identity |
|
||||||
|
|
||||||
|
`branch` is always the attached branch inside the sandbox. `tag` is a bare tag
|
||||||
|
name such as `v1.2.3`; `refs/tags/v1.2.3` and `tags/v1.2.3` are rejected. An
|
||||||
|
unpinned tag is resolved when the worker starts, so moving a tag before that
|
||||||
|
point changes the selected commit.
|
||||||
|
|
||||||
|
Creating the run validates the selectors and lowercase-normalizes `sha`, but
|
||||||
|
does not contact GitHub or prove ancestry. An exact SHA is authoritative:
|
||||||
|
Fabro does not prove it belongs to the branch or matches the accompanying tag.
|
||||||
|
If a requested tag or exact commit is unavailable, sandbox setup fails without
|
||||||
|
falling back to a same-named branch or the branch's newer HEAD.
|
||||||
|
|
||||||
### GITHUB_TOKEN injection
|
### GITHUB_TOKEN injection
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -935,6 +935,7 @@ fn preflight_sandbox_spec(
|
||||||
run_id: None,
|
run_id: None,
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
|
clone_tag: None,
|
||||||
clone_commit_sha: None,
|
clone_commit_sha: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -947,6 +948,7 @@ fn preflight_sandbox_spec(
|
||||||
run_id: None,
|
run_id: None,
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
|
clone_tag: None,
|
||||||
clone_commit_sha: None,
|
clone_commit_sha: None,
|
||||||
api_key: daytona_api_key,
|
api_key: daytona_api_key,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -809,12 +809,12 @@ async fn batch_archive_runs(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(request): Json<BatchRunLifecycleRequest>,
|
Json(request): Json<BatchRunLifecycleRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
batch_run_archive_action(
|
Box::pin(batch_run_archive_action(
|
||||||
state,
|
state,
|
||||||
Principal::User(user),
|
Principal::User(user),
|
||||||
request,
|
request,
|
||||||
ArchiveAction::Archive,
|
ArchiveAction::Archive,
|
||||||
)
|
))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -823,12 +823,12 @@ async fn batch_unarchive_runs(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(request): Json<BatchRunLifecycleRequest>,
|
Json(request): Json<BatchRunLifecycleRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
batch_run_archive_action(
|
Box::pin(batch_run_archive_action(
|
||||||
state,
|
state,
|
||||||
Principal::User(user),
|
Principal::User(user),
|
||||||
request,
|
request,
|
||||||
ArchiveAction::Unarchive,
|
ArchiveAction::Unarchive,
|
||||||
)
|
))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1091,7 +1091,7 @@ async fn validate_intent_environment(
|
||||||
SandboxProviderKind::Daytona => image.docker.is_some(),
|
SandboxProviderKind::Daytona => image.docker.is_some(),
|
||||||
};
|
};
|
||||||
let (target_incompatible, detail) = match target {
|
let (target_incompatible, detail) = match target {
|
||||||
RunTarget::Git { .. } => (
|
RunTarget::Git(_) => (
|
||||||
provider == SandboxProviderKind::Local || !settings.run.clone.enabled,
|
provider == SandboxProviderKind::Local || !settings.run.clone.enabled,
|
||||||
"Git targets require a compatible clone-enabled Docker or Daytona environment",
|
"Git targets require a compatible clone-enabled Docker or Daytona environment",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1169,7 +1169,10 @@ async fn drive_agent_session(
|
||||||
result = &mut process => {
|
result = &mut process => {
|
||||||
while let Ok(event) = receiver.try_recv() {
|
while let Ok(event) = receiver.try_recv() {
|
||||||
record_turn_output(output, &event);
|
record_turn_output(output, &event);
|
||||||
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
|
Box::pin(persist_agent_event(
|
||||||
|
run_store, run_id, session_id, turn_id, event, sender,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
@ -1177,7 +1180,10 @@ async fn drive_agent_session(
|
||||||
match event {
|
match event {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
record_turn_output(output, &event);
|
record_turn_output(output, &event);
|
||||||
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
|
Box::pin(persist_agent_event(
|
||||||
|
run_store, run_id, session_id, turn_id, event, sender,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Err(RecvError::Lagged(_) | RecvError::Closed) => {}
|
Err(RecvError::Lagged(_) | RecvError::Closed) => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3598,7 +3598,7 @@ async fn store_workflow_version(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_runs_run_intent_creates_submitted_version_backed_git_target_without_starting() {
|
async fn post_runs_run_intent_persists_tagged_exact_git_target_without_starting() {
|
||||||
let state = test_app_state();
|
let state = test_app_state();
|
||||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||||
let workflow_version_id = store_workflow_version(
|
let workflow_version_id = store_workflow_version(
|
||||||
|
|
@ -3638,6 +3638,7 @@ docker = "workflow-owned:latest"
|
||||||
"kind": "git",
|
"kind": "git",
|
||||||
"repo": "fabro-sh/fabro",
|
"repo": "fabro-sh/fabro",
|
||||||
"branch": "feature/run-intent",
|
"branch": "feature/run-intent",
|
||||||
|
"tag": "v1.2.3",
|
||||||
"sha": submitted_sha
|
"sha": submitted_sha
|
||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
|
|
@ -3671,11 +3672,12 @@ docker = "workflow-owned:latest"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projection.spec.target,
|
projection.spec.target,
|
||||||
Some(fabro_types::RunTarget::Git {
|
Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
tag: Some("v1.2.3".to_string()),
|
||||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||||
})
|
}))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projection
|
projection
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ async fn shell_reports_real_docker_process_outcome() {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
) else {
|
) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ pub(crate) enum CloneDecision {
|
||||||
GitHub {
|
GitHub {
|
||||||
origin_url: String,
|
origin_url: String,
|
||||||
branch: Option<String>,
|
branch: Option<String>,
|
||||||
|
tag: Option<String>,
|
||||||
commit_sha: Option<String>,
|
commit_sha: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +106,25 @@ pub(crate) fn exact_fetch_command(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch one fully-qualified tag without consulting a same-named branch.
|
||||||
|
#[cfg(any(feature = "docker", test))]
|
||||||
|
pub(crate) fn tag_fetch_command(
|
||||||
|
checkout_path: &str,
|
||||||
|
fetch_source: &str,
|
||||||
|
tag: &str,
|
||||||
|
depth: Option<usize>,
|
||||||
|
) -> String {
|
||||||
|
let depth_arg = depth_argument(depth);
|
||||||
|
let tag_ref = format!("refs/tags/{tag}");
|
||||||
|
format!(
|
||||||
|
"{git} -C {} fetch{depth_arg} --no-tags {} -- {}",
|
||||||
|
sandbox::shell_quote(checkout_path),
|
||||||
|
sandbox::shell_quote(fetch_source),
|
||||||
|
sandbox::shell_quote(&tag_ref),
|
||||||
|
git = sandbox::GIT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Leading-space ` --depth N` fragment for a Git command, or empty when
|
/// Leading-space ` --depth N` fragment for a Git command, or empty when
|
||||||
/// `depth` is `None` to fetch full history.
|
/// `depth` is `None` to fetch full history.
|
||||||
#[cfg(any(feature = "docker", test))]
|
#[cfg(any(feature = "docker", test))]
|
||||||
|
|
@ -155,6 +175,13 @@ pub(crate) fn exact_checkout_verify_command(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Peel the fetched tag to a commit, attach it to the working branch, and
|
||||||
|
/// print the resolved commit ID.
|
||||||
|
#[cfg(any(feature = "docker", test))]
|
||||||
|
pub(crate) fn tag_checkout_verify_command(checkout_path: &str, branch: &str) -> String {
|
||||||
|
exact_checkout_verify_command(checkout_path, branch, "FETCH_HEAD^{commit}")
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn verify_exact_head(output: &str, expected_sha: &str) -> crate::Result<()> {
|
pub(crate) fn verify_exact_head(output: &str, expected_sha: &str) -> crate::Result<()> {
|
||||||
let actual_sha = output.trim();
|
let actual_sha = output.trim();
|
||||||
let actual_sha = normalize_exact_commit_sha(actual_sha).map_err(|err| {
|
let actual_sha = normalize_exact_commit_sha(actual_sha).map_err(|err| {
|
||||||
|
|
@ -168,6 +195,12 @@ pub(crate) fn verify_exact_head(output: &str, expected_sha: &str) -> crate::Resu
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn verify_resolved_head(output: &str) -> crate::Result<String> {
|
||||||
|
normalize_exact_commit_sha(output.trim()).map_err(|err| {
|
||||||
|
crate::Error::context("Tag checkout produced an invalid HEAD commit ID", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn trim_root(root: &str) -> &str {
|
fn trim_root(root: &str) -> &str {
|
||||||
let trimmed = root.trim_end_matches('/');
|
let trimmed = root.trim_end_matches('/');
|
||||||
if trimmed.is_empty() { "/" } else { trimmed }
|
if trimmed.is_empty() { "/" } else { trimmed }
|
||||||
|
|
@ -194,31 +227,48 @@ pub(crate) fn decide_clone(
|
||||||
skip_clone: bool,
|
skip_clone: bool,
|
||||||
clone_origin_url: Option<&str>,
|
clone_origin_url: Option<&str>,
|
||||||
clone_branch: Option<&str>,
|
clone_branch: Option<&str>,
|
||||||
|
clone_tag: Option<&str>,
|
||||||
clone_commit_sha: Option<&str>,
|
clone_commit_sha: Option<&str>,
|
||||||
) -> crate::Result<CloneDecision> {
|
) -> crate::Result<CloneDecision> {
|
||||||
|
let tag = clone_tag
|
||||||
|
.map(|tag| {
|
||||||
|
if tag.trim().is_empty() {
|
||||||
|
Err(crate::Error::message(
|
||||||
|
"Tag checkout requires a non-empty tag",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(tag.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
let commit_sha = clone_commit_sha
|
let commit_sha = clone_commit_sha
|
||||||
.map(normalize_exact_commit_sha)
|
.map(normalize_exact_commit_sha)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
|
||||||
if commit_sha.is_some() {
|
if tag.is_some() || commit_sha.is_some() {
|
||||||
|
let selector = if commit_sha.is_some() {
|
||||||
|
"Exact commit checkout"
|
||||||
|
} else {
|
||||||
|
"Tag checkout"
|
||||||
|
};
|
||||||
if skip_clone {
|
if skip_clone {
|
||||||
return Err(crate::Error::message(
|
return Err(crate::Error::message(format!(
|
||||||
"Exact commit checkout requires cloning to be enabled",
|
"{selector} requires cloning to be enabled"
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
if clone_origin_url.is_none_or(|url| url.trim().is_empty()) {
|
if clone_origin_url.is_none_or(|url| url.trim().is_empty()) {
|
||||||
return Err(crate::Error::message(
|
return Err(crate::Error::message(format!(
|
||||||
"Exact commit checkout requires a repository origin",
|
"{selector} requires a repository origin"
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
// The branch names the checkout the run works on; it is not used to
|
// The branch names the checkout the run works on; it is not used to
|
||||||
// constrain which commits may be fetched. No layer proves branch/SHA
|
// constrain which commits may be fetched. No layer proves branch/SHA
|
||||||
// ancestry, and an unavailable exact commit fails without falling back
|
// ancestry, and an unavailable exact commit fails without falling back
|
||||||
// to branch HEAD.
|
// to branch HEAD.
|
||||||
if clone_branch.is_none_or(|branch| branch.trim().is_empty()) {
|
if clone_branch.is_none_or(|branch| branch.trim().is_empty()) {
|
||||||
return Err(crate::Error::message(
|
return Err(crate::Error::message(format!(
|
||||||
"Exact commit checkout requires a repository branch",
|
"{selector} requires a repository branch"
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,6 +296,7 @@ pub(crate) fn decide_clone(
|
||||||
branch: clone_branch
|
branch: clone_branch
|
||||||
.filter(|branch| !branch.trim().is_empty())
|
.filter(|branch| !branch.trim().is_empty())
|
||||||
.map(str::to_string),
|
.map(str::to_string),
|
||||||
|
tag,
|
||||||
commit_sha,
|
commit_sha,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +318,7 @@ pub(crate) fn repo_cloned_for_record(
|
||||||
clone_origin_url: Option<&str>,
|
clone_origin_url: Option<&str>,
|
||||||
) -> Option<bool> {
|
) -> Option<bool> {
|
||||||
Some(matches!(
|
Some(matches!(
|
||||||
decide_clone(skip_clone, clone_origin_url, None, None).ok()?,
|
decide_clone(skip_clone, clone_origin_url, None, None, None).ok()?,
|
||||||
CloneDecision::GitHub { .. }
|
CloneDecision::GitHub { .. }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
@ -307,17 +358,8 @@ mod tests {
|
||||||
String::from_utf8(output.stdout).expect("git output should be UTF-8")
|
String::from_utf8(output.stdout).expect("git output should be UTF-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
|
|
||||||
)]
|
|
||||||
fn run_shell(cwd: &Path, command: &str) -> String {
|
fn run_shell(cwd: &Path, command: &str) -> String {
|
||||||
let output = isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
|
let output = run_shell_output(cwd, command);
|
||||||
"--noprofile",
|
|
||||||
"--norc",
|
|
||||||
"-c",
|
|
||||||
command,
|
|
||||||
]));
|
|
||||||
assert!(
|
assert!(
|
||||||
output.status.success(),
|
output.status.success(),
|
||||||
"command failed: {}",
|
"command failed: {}",
|
||||||
|
|
@ -326,6 +368,19 @@ mod tests {
|
||||||
String::from_utf8(output.stdout).expect("command output should be UTF-8")
|
String::from_utf8(output.stdout).expect("command output should be UTF-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::disallowed_methods,
|
||||||
|
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
|
||||||
|
)]
|
||||||
|
fn run_shell_output(cwd: &Path, command: &str) -> Output {
|
||||||
|
isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
|
||||||
|
"--noprofile",
|
||||||
|
"--norc",
|
||||||
|
"-c",
|
||||||
|
command,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skip_clone_overrides_present_origin() {
|
fn skip_clone_overrides_present_origin() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -334,6 +389,7 @@ mod tests {
|
||||||
Some("https://gitlab.com/acme/widgets.git"),
|
Some("https://gitlab.com/acme/widgets.git"),
|
||||||
Some("main"),
|
Some("main"),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
CloneDecision::EmptyWorkspace {
|
CloneDecision::EmptyWorkspace {
|
||||||
|
|
@ -345,7 +401,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_origin_creates_empty_workspace() {
|
fn missing_origin_creates_empty_workspace() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decide_clone(false, None, None, None).unwrap(),
|
decide_clone(false, None, None, None, None).unwrap(),
|
||||||
CloneDecision::EmptyWorkspace {
|
CloneDecision::EmptyWorkspace {
|
||||||
reason: EmptyWorkspaceReason::MissingOrigin,
|
reason: EmptyWorkspaceReason::MissingOrigin,
|
||||||
}
|
}
|
||||||
|
|
@ -360,16 +416,50 @@ mod tests {
|
||||||
Some("git@github.com:acme/widgets.git"),
|
Some("git@github.com:acme/widgets.git"),
|
||||||
Some("feature/work"),
|
Some("feature/work"),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
CloneDecision::GitHub {
|
CloneDecision::GitHub {
|
||||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||||
branch: Some("feature/work".to_string()),
|
branch: Some("feature/work".to_string()),
|
||||||
|
tag: None,
|
||||||
commit_sha: None,
|
commit_sha: None,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_clone_keeps_working_branch_and_bare_tag_distinct() {
|
||||||
|
assert_eq!(
|
||||||
|
decide_clone(
|
||||||
|
false,
|
||||||
|
Some("https://github.com/acme/widgets"),
|
||||||
|
Some("release"),
|
||||||
|
Some("v1.2.3"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
CloneDecision::GitHub {
|
||||||
|
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||||
|
branch: Some("release".to_string()),
|
||||||
|
tag: Some("v1.2.3".to_string()),
|
||||||
|
commit_sha: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_fetch_and_checkout_commands_use_the_fully_qualified_tag() {
|
||||||
|
assert_eq!(
|
||||||
|
tag_fetch_command("/repos/acme/widgets", "origin", "release/v1", Some(10)),
|
||||||
|
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets fetch --depth 10 --no-tags origin -- refs/tags/release/v1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tag_checkout_verify_command("/repos/acme/widgets", "release"),
|
||||||
|
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets checkout -B release FETCH_HEAD'^{commit}' && git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets rev-parse HEAD"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_github_origin_fails_without_skip_clone() {
|
fn non_github_origin_fails_without_skip_clone() {
|
||||||
let error = decide_clone(
|
let error = decide_clone(
|
||||||
|
|
@ -377,6 +467,7 @@ mod tests {
|
||||||
Some("https://gitlab.com/acme/widgets.git"),
|
Some("https://gitlab.com/acme/widgets.git"),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect_err("non-GitHub origins should fail");
|
.expect_err("non-GitHub origins should fail");
|
||||||
assert!(error.to_string().contains("GitHub repository origins only"));
|
assert!(error.to_string().contains("GitHub repository origins only"));
|
||||||
|
|
@ -392,12 +483,14 @@ mod tests {
|
||||||
false,
|
false,
|
||||||
Some("https://github.com/acme/widgets"),
|
Some("https://github.com/acme/widgets"),
|
||||||
Some("moving-branch"),
|
Some("moving-branch"),
|
||||||
|
Some("release"),
|
||||||
Some(lowercase),
|
Some(lowercase),
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
CloneDecision::GitHub {
|
CloneDecision::GitHub {
|
||||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||||
branch: Some("moving-branch".to_string()),
|
branch: Some("moving-branch".to_string()),
|
||||||
|
tag: Some("release".to_string()),
|
||||||
commit_sha: Some(lowercase.to_string()),
|
commit_sha: Some(lowercase.to_string()),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -406,12 +499,14 @@ mod tests {
|
||||||
false,
|
false,
|
||||||
Some("https://github.com/acme/widgets"),
|
Some("https://github.com/acme/widgets"),
|
||||||
Some("main"),
|
Some("main"),
|
||||||
|
None,
|
||||||
Some(uppercase),
|
Some(uppercase),
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
CloneDecision::GitHub {
|
CloneDecision::GitHub {
|
||||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||||
branch: Some("main".to_string()),
|
branch: Some("main".to_string()),
|
||||||
|
tag: None,
|
||||||
commit_sha: Some(uppercase.to_ascii_lowercase()),
|
commit_sha: Some(uppercase.to_ascii_lowercase()),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -432,6 +527,7 @@ mod tests {
|
||||||
false,
|
false,
|
||||||
Some("https://github.com/acme/widgets"),
|
Some("https://github.com/acme/widgets"),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some(sha),
|
Some(sha),
|
||||||
)
|
)
|
||||||
.expect_err("invalid exact commit SHA should fail");
|
.expect_err("invalid exact commit SHA should fail");
|
||||||
|
|
@ -449,13 +545,14 @@ mod tests {
|
||||||
true,
|
true,
|
||||||
Some("https://github.com/acme/widgets"),
|
Some("https://github.com/acme/widgets"),
|
||||||
Some("main"),
|
Some("main"),
|
||||||
|
None,
|
||||||
Some(sha),
|
Some(sha),
|
||||||
)
|
)
|
||||||
.expect_err("exact checkout with skip-clone should fail");
|
.expect_err("exact checkout with skip-clone should fail");
|
||||||
assert!(skip_error.to_string().contains("requires cloning"));
|
assert!(skip_error.to_string().contains("requires cloning"));
|
||||||
|
|
||||||
for origin in [None, Some(""), Some(" ")] {
|
for origin in [None, Some(""), Some(" ")] {
|
||||||
let error = decide_clone(false, origin, Some("main"), Some(sha))
|
let error = decide_clone(false, origin, Some("main"), None, Some(sha))
|
||||||
.expect_err("exact checkout without an origin should fail");
|
.expect_err("exact checkout without an origin should fail");
|
||||||
assert!(error.to_string().contains("requires a repository origin"));
|
assert!(error.to_string().contains("requires a repository origin"));
|
||||||
}
|
}
|
||||||
|
|
@ -465,6 +562,7 @@ mod tests {
|
||||||
false,
|
false,
|
||||||
Some("https://github.com/acme/widgets"),
|
Some("https://github.com/acme/widgets"),
|
||||||
branch,
|
branch,
|
||||||
|
None,
|
||||||
Some(sha),
|
Some(sha),
|
||||||
)
|
)
|
||||||
.expect_err("exact checkout without a branch should fail");
|
.expect_err("exact checkout without a branch should fail");
|
||||||
|
|
@ -472,6 +570,47 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_checkout_requires_nonempty_tag_clone_origin_and_branch() {
|
||||||
|
let empty_tag = decide_clone(
|
||||||
|
false,
|
||||||
|
Some("https://github.com/acme/widgets"),
|
||||||
|
Some("main"),
|
||||||
|
Some(""),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect_err("empty tags should fail");
|
||||||
|
assert!(empty_tag.to_string().contains("non-empty tag"));
|
||||||
|
|
||||||
|
let skip_error = decide_clone(
|
||||||
|
true,
|
||||||
|
Some("https://github.com/acme/widgets"),
|
||||||
|
Some("main"),
|
||||||
|
Some("v1"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect_err("tag checkout with skip-clone should fail");
|
||||||
|
assert!(skip_error.to_string().contains("requires cloning"));
|
||||||
|
|
||||||
|
for origin in [None, Some(""), Some(" ")] {
|
||||||
|
let error = decide_clone(false, origin, Some("main"), Some("v1"), None)
|
||||||
|
.expect_err("tag checkout without an origin should fail");
|
||||||
|
assert!(error.to_string().contains("requires a repository origin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for branch in [None, Some(""), Some(" ")] {
|
||||||
|
let error = decide_clone(
|
||||||
|
false,
|
||||||
|
Some("https://github.com/acme/widgets"),
|
||||||
|
branch,
|
||||||
|
Some("v1"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect_err("tag checkout without a branch should fail");
|
||||||
|
assert!(error.to_string().contains("requires a repository branch"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn docker_exact_checkout_commands_quote_inputs() {
|
fn docker_exact_checkout_commands_quote_inputs() {
|
||||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||||
|
|
@ -536,7 +675,7 @@ mod tests {
|
||||||
clippy::disallowed_methods,
|
clippy::disallowed_methods,
|
||||||
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
|
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
|
||||||
)]
|
)]
|
||||||
fn exact_checkout_fetches_admitted_commit_after_branch_advances() {
|
fn exact_checkout_fetches_admitted_commit_after_branch_and_tag_advance() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
let remote = temp.path().join("remote.git");
|
let remote = temp.path().join("remote.git");
|
||||||
let source = temp.path().join("source");
|
let source = temp.path().join("source");
|
||||||
|
|
@ -561,10 +700,14 @@ mod tests {
|
||||||
]);
|
]);
|
||||||
run_git(&source, &["push", "-u", "origin", "main"]);
|
run_git(&source, &["push", "-u", "origin", "main"]);
|
||||||
let admitted_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
|
let admitted_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
|
||||||
|
run_git(&source, &["tag", "release"]);
|
||||||
|
run_git(&source, &["push", "origin", "refs/tags/release"]);
|
||||||
|
|
||||||
fs::write(source.join("revision.txt"), "B\n").expect("write commit B");
|
fs::write(source.join("revision.txt"), "B\n").expect("write commit B");
|
||||||
run_git(&source, &["commit", "-am", "commit B"]);
|
run_git(&source, &["commit", "-am", "commit B"]);
|
||||||
run_git(&source, &["push", "origin", "main"]);
|
run_git(&source, &["push", "origin", "main"]);
|
||||||
|
run_git(&source, &["tag", "-f", "release"]);
|
||||||
|
run_git(&source, &["push", "--force", "origin", "refs/tags/release"]);
|
||||||
let advanced_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
|
let advanced_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
|
||||||
assert_ne!(admitted_sha, advanced_sha);
|
assert_ne!(admitted_sha, advanced_sha);
|
||||||
|
|
||||||
|
|
@ -609,6 +752,79 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[expect(
|
||||||
|
clippy::disallowed_methods,
|
||||||
|
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
|
||||||
|
)]
|
||||||
|
fn tag_checkout_peels_lightweight_and_annotated_tags_without_branch_fallback() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let remote = temp.path().join("remote.git");
|
||||||
|
let source = temp.path().join("source");
|
||||||
|
fs::create_dir(&source).expect("source directory");
|
||||||
|
|
||||||
|
run_git(temp.path(), &[
|
||||||
|
"init",
|
||||||
|
"--bare",
|
||||||
|
remote.to_str().expect("UTF-8 remote path"),
|
||||||
|
]);
|
||||||
|
run_git(&source, &["init"]);
|
||||||
|
fs::write(source.join("revision.txt"), "release\n").expect("write release commit");
|
||||||
|
run_git(&source, &["add", "revision.txt"]);
|
||||||
|
run_git(&source, &["commit", "-m", "release"]);
|
||||||
|
run_git(&source, &["branch", "-M", "main"]);
|
||||||
|
let release_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
|
||||||
|
run_git(&source, &["tag", "lightweight"]);
|
||||||
|
run_git(&source, &["tag", "-a", "annotated", "-m", "annotated"]);
|
||||||
|
run_git(&source, &[
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"origin",
|
||||||
|
remote.to_str().expect("UTF-8 remote path"),
|
||||||
|
]);
|
||||||
|
run_git(&source, &["push", "origin", "main", "--tags"]);
|
||||||
|
|
||||||
|
let remote_path = remote.to_str().expect("UTF-8 remote path");
|
||||||
|
for tag in ["lightweight", "annotated"] {
|
||||||
|
let checkout = temp.path().join(format!("checkout-{tag}"));
|
||||||
|
let checkout_path = checkout.to_str().expect("UTF-8 checkout path");
|
||||||
|
run_shell(
|
||||||
|
temp.path(),
|
||||||
|
&exact_repository_init_command(remote_path, checkout_path),
|
||||||
|
);
|
||||||
|
run_shell(
|
||||||
|
temp.path(),
|
||||||
|
&tag_fetch_command(checkout_path, "origin", tag, Some(10)),
|
||||||
|
);
|
||||||
|
let head = run_shell(
|
||||||
|
temp.path(),
|
||||||
|
&tag_checkout_verify_command(checkout_path, "release-work"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(verify_resolved_head(&head).unwrap(), release_sha);
|
||||||
|
assert_eq!(
|
||||||
|
run_git(&checkout, &["symbolic-ref", "HEAD"]).trim(),
|
||||||
|
"refs/heads/release-work"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let missing = temp.path().join("missing");
|
||||||
|
let missing_path = missing.to_str().expect("UTF-8 checkout path");
|
||||||
|
run_shell(
|
||||||
|
temp.path(),
|
||||||
|
&exact_repository_init_command(remote_path, missing_path),
|
||||||
|
);
|
||||||
|
let output = run_shell_output(
|
||||||
|
temp.path(),
|
||||||
|
&tag_fetch_command(missing_path, "origin", "main", Some(10)),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"a branch must not satisfy a tag fetch"
|
||||||
|
);
|
||||||
|
assert!(!missing.join("revision.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn github_layout_maps_ssh_origin_to_repos_checkout_and_workspace_link() {
|
fn github_layout_maps_ssh_origin_to_repos_checkout_and_workspace_link() {
|
||||||
let layout = github_repo_layout(
|
let layout = github_repo_layout(
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,20 @@ const DAYTONA_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
/// leaked by a dead worker. An explicit `0` disables auto-stop entirely.
|
/// leaked by a dead worker. An explicit `0` disables auto-stop entirely.
|
||||||
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120;
|
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120;
|
||||||
|
|
||||||
|
fn git_clone_selector(
|
||||||
|
branch: Option<&str>,
|
||||||
|
tag: Option<&str>,
|
||||||
|
commit_sha: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if commit_sha.is_some() {
|
||||||
|
branch.map(str::to_string)
|
||||||
|
} else if let Some(tag) = tag {
|
||||||
|
Some(format!("refs/tags/{tag}"))
|
||||||
|
} else {
|
||||||
|
branch.map(str::to_string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn daytona_not_found(err: &DaytonaError) -> bool {
|
pub(crate) fn daytona_not_found(err: &DaytonaError) -> bool {
|
||||||
matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404)
|
matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404)
|
||||||
}
|
}
|
||||||
|
|
@ -464,6 +478,7 @@ pub struct DaytonaSandbox {
|
||||||
/// Explicit branch to clone. When set, overrides the branch detected by
|
/// Explicit branch to clone. When set, overrides the branch detected by
|
||||||
/// the submitted run spec.
|
/// the submitted run spec.
|
||||||
clone_branch: Option<String>,
|
clone_branch: Option<String>,
|
||||||
|
clone_tag: Option<String>,
|
||||||
clone_commit_sha: Option<String>,
|
clone_commit_sha: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,14 +493,16 @@ impl DaytonaSandbox {
|
||||||
run_id: Option<RunId>,
|
run_id: Option<RunId>,
|
||||||
clone_origin_url: Option<String>,
|
clone_origin_url: Option<String>,
|
||||||
clone_branch: Option<String>,
|
clone_branch: Option<String>,
|
||||||
|
clone_tag: Option<String>,
|
||||||
clone_commit_sha: Option<String>,
|
clone_commit_sha: Option<String>,
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
) -> crate::Result<Self> {
|
) -> crate::Result<Self> {
|
||||||
if clone_commit_sha.is_some() {
|
if clone_tag.is_some() || clone_commit_sha.is_some() {
|
||||||
clone_source::decide_clone(
|
clone_source::decide_clone(
|
||||||
config.skip_clone,
|
config.skip_clone,
|
||||||
clone_origin_url.as_deref(),
|
clone_origin_url.as_deref(),
|
||||||
clone_branch.as_deref(),
|
clone_branch.as_deref(),
|
||||||
|
clone_tag.as_deref(),
|
||||||
clone_commit_sha.as_deref(),
|
clone_commit_sha.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
@ -512,6 +529,7 @@ impl DaytonaSandbox {
|
||||||
run_id,
|
run_id,
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
|
clone_tag,
|
||||||
clone_commit_sha,
|
clone_commit_sha,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -565,6 +583,7 @@ impl DaytonaSandbox {
|
||||||
run_id: None,
|
run_id: None,
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
|
clone_tag: None,
|
||||||
clone_commit_sha: None,
|
clone_commit_sha: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -683,6 +702,33 @@ impl DaytonaSandbox {
|
||||||
clone_source::verify_exact_head(&head, expected_sha)
|
clone_source::verify_exact_head(&head, expected_sha)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach a tag clone's peeled commit to the requested working branch.
|
||||||
|
async fn attach_tag_branch(
|
||||||
|
process_svc: &daytona_sdk::ProcessService,
|
||||||
|
checkout_path: &str,
|
||||||
|
branch: &str,
|
||||||
|
deadline: time::Instant,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
Self::run_required_post_clone_command(
|
||||||
|
process_svc,
|
||||||
|
&clone_source::exact_branch_checkout_command(checkout_path, branch, "HEAD^{commit}"),
|
||||||
|
"/",
|
||||||
|
"git checkout tag commit",
|
||||||
|
deadline,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let head = Self::run_required_post_clone_command(
|
||||||
|
process_svc,
|
||||||
|
&clone_source::exact_head_revision_command(checkout_path),
|
||||||
|
"/",
|
||||||
|
"git rev-parse HEAD after tag checkout",
|
||||||
|
deadline,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
clone_source::verify_resolved_head(&head)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute one post-clone command under the shared setup deadline.
|
/// Execute one post-clone command under the shared setup deadline.
|
||||||
///
|
///
|
||||||
/// The SDK timeout asks Daytona to terminate the remote process. The outer
|
/// The SDK timeout asks Daytona to terminate the remote process. The outer
|
||||||
|
|
@ -1521,6 +1567,7 @@ impl Sandbox for DaytonaSandbox {
|
||||||
self.config.skip_clone,
|
self.config.skip_clone,
|
||||||
self.clone_origin_url.as_deref(),
|
self.clone_origin_url.as_deref(),
|
||||||
self.clone_branch.as_deref(),
|
self.clone_branch.as_deref(),
|
||||||
|
self.clone_tag.as_deref(),
|
||||||
self.clone_commit_sha.as_deref(),
|
self.clone_commit_sha.as_deref(),
|
||||||
)
|
)
|
||||||
.map_err(|e| self.fail_init(init_start, e))?;
|
.map_err(|e| self.fail_init(init_start, e))?;
|
||||||
|
|
@ -1549,6 +1596,7 @@ impl Sandbox for DaytonaSandbox {
|
||||||
CloneDecision::GitHub {
|
CloneDecision::GitHub {
|
||||||
origin_url,
|
origin_url,
|
||||||
branch,
|
branch,
|
||||||
|
tag,
|
||||||
commit_sha,
|
commit_sha,
|
||||||
} => {
|
} => {
|
||||||
let layout =
|
let layout =
|
||||||
|
|
@ -1647,6 +1695,8 @@ impl Sandbox for DaytonaSandbox {
|
||||||
self.fail_init(init_start, err)
|
self.fail_init(init_start, err)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let clone_selector =
|
||||||
|
git_clone_selector(branch.as_deref(), tag.as_deref(), commit_sha.as_deref());
|
||||||
let clone_plan = git_retry::RetryPlan::clone_default(None);
|
let clone_plan = git_retry::RetryPlan::clone_default(None);
|
||||||
let clone_result = git_retry::retry_git_operation(
|
let clone_result = git_retry::retry_git_operation(
|
||||||
SandboxProviderKind::Daytona,
|
SandboxProviderKind::Daytona,
|
||||||
|
|
@ -1657,7 +1707,7 @@ impl Sandbox for DaytonaSandbox {
|
||||||
let origin = origin_url.as_str();
|
let origin = origin_url.as_str();
|
||||||
let target = layout.primary_repo_path.as_str();
|
let target = layout.primary_repo_path.as_str();
|
||||||
let options = GitCloneOptions {
|
let options = GitCloneOptions {
|
||||||
branch: branch.clone(),
|
branch: clone_selector.clone(),
|
||||||
commit_id: commit_sha.clone(),
|
commit_id: commit_sha.clone(),
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
password: password.clone(),
|
password: password.clone(),
|
||||||
|
|
@ -1725,6 +1775,27 @@ impl Sandbox for DaytonaSandbox {
|
||||||
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
|
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
|
} else if tag.is_some() {
|
||||||
|
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty())
|
||||||
|
else {
|
||||||
|
let err =
|
||||||
|
crate::Error::message("Tag checkout requires a repository branch");
|
||||||
|
return Err(self
|
||||||
|
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
|
||||||
|
.await);
|
||||||
|
};
|
||||||
|
if let Err(err) = Self::attach_tag_branch(
|
||||||
|
&process_svc,
|
||||||
|
&layout.primary_repo_path,
|
||||||
|
branch,
|
||||||
|
post_clone_deadline,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(self
|
||||||
|
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
|
||||||
|
.await);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let symlink_cmd = clone_source::repo_symlink_command(&layout);
|
let symlink_cmd = clone_source::repo_symlink_command(&layout);
|
||||||
|
|
@ -3193,6 +3264,23 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::sandbox::BASH_PROBE_MARKER;
|
use crate::sandbox::BASH_PROBE_MARKER;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daytona_clone_selector_uses_fully_qualified_tag_unless_sha_is_exact() {
|
||||||
|
assert_eq!(
|
||||||
|
git_clone_selector(Some("release-work"), Some("v1.2.3"), None).as_deref(),
|
||||||
|
Some("refs/tags/v1.2.3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
git_clone_selector(
|
||||||
|
Some("release-work"),
|
||||||
|
Some("v1.2.3"),
|
||||||
|
Some("0123456789abcdef0123456789abcdef01234567"),
|
||||||
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some("release-work")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invalid_exact_sha_fails_before_daytona_client_construction() {
|
async fn invalid_exact_sha_fails_before_daytona_client_construction() {
|
||||||
let error = DaytonaSandbox::new(
|
let error = DaytonaSandbox::new(
|
||||||
|
|
@ -3201,6 +3289,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
Some("https://github.com/acme/widgets".to_string()),
|
Some("https://github.com/acme/widgets".to_string()),
|
||||||
Some("main".to_string()),
|
Some("main".to_string()),
|
||||||
|
None,
|
||||||
Some("not-a-sha".to_string()),
|
Some("not-a-sha".to_string()),
|
||||||
Some("dtn_not_used".to_string()),
|
Some("dtn_not_used".to_string()),
|
||||||
)
|
)
|
||||||
|
|
@ -3220,6 +3309,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
Some("https://github.com/acme/widgets".to_string()),
|
Some("https://github.com/acme/widgets".to_string()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||||
Some("dtn_not_used".to_string()),
|
Some("dtn_not_used".to_string()),
|
||||||
)
|
)
|
||||||
|
|
@ -3547,6 +3637,7 @@ mod tests {
|
||||||
run_id: None,
|
run_id: None,
|
||||||
clone_origin_url: None,
|
clone_origin_url: None,
|
||||||
clone_branch: None,
|
clone_branch: None,
|
||||||
|
clone_tag: None,
|
||||||
clone_commit_sha: None,
|
clone_commit_sha: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3726,6 +3817,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some("dtn_test".to_string()),
|
Some("dtn_test".to_string()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -3765,6 +3857,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some("dtn_test".to_string()),
|
Some("dtn_test".to_string()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -4093,6 +4186,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some("dtn_test".to_string()),
|
Some("dtn_test".to_string()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ pub struct DockerSandbox {
|
||||||
run_id: Option<RunId>,
|
run_id: Option<RunId>,
|
||||||
clone_origin_url: Option<String>,
|
clone_origin_url: Option<String>,
|
||||||
clone_branch: Option<String>,
|
clone_branch: Option<String>,
|
||||||
|
clone_tag: Option<String>,
|
||||||
clone_commit_sha: Option<String>,
|
clone_commit_sha: Option<String>,
|
||||||
container_id: OnceCell<String>,
|
container_id: OnceCell<String>,
|
||||||
repo_cloned: OnceCell<bool>,
|
repo_cloned: OnceCell<bool>,
|
||||||
|
|
@ -187,13 +188,15 @@ impl DockerSandbox {
|
||||||
run_id: Option<RunId>,
|
run_id: Option<RunId>,
|
||||||
clone_origin_url: Option<String>,
|
clone_origin_url: Option<String>,
|
||||||
clone_branch: Option<String>,
|
clone_branch: Option<String>,
|
||||||
|
clone_tag: Option<String>,
|
||||||
clone_commit_sha: Option<String>,
|
clone_commit_sha: Option<String>,
|
||||||
) -> crate::Result<Self> {
|
) -> crate::Result<Self> {
|
||||||
if clone_commit_sha.is_some() {
|
if clone_tag.is_some() || clone_commit_sha.is_some() {
|
||||||
clone_source::decide_clone(
|
clone_source::decide_clone(
|
||||||
config.skip_clone,
|
config.skip_clone,
|
||||||
clone_origin_url.as_deref(),
|
clone_origin_url.as_deref(),
|
||||||
clone_branch.as_deref(),
|
clone_branch.as_deref(),
|
||||||
|
clone_tag.as_deref(),
|
||||||
clone_commit_sha.as_deref(),
|
clone_commit_sha.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +208,7 @@ impl DockerSandbox {
|
||||||
run_id,
|
run_id,
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
|
clone_tag,
|
||||||
clone_commit_sha,
|
clone_commit_sha,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -216,6 +220,7 @@ impl DockerSandbox {
|
||||||
run_id: Option<RunId>,
|
run_id: Option<RunId>,
|
||||||
clone_origin_url: Option<String>,
|
clone_origin_url: Option<String>,
|
||||||
clone_branch: Option<String>,
|
clone_branch: Option<String>,
|
||||||
|
clone_tag: Option<String>,
|
||||||
clone_commit_sha: Option<String>,
|
clone_commit_sha: Option<String>,
|
||||||
) -> crate::Result<Self> {
|
) -> crate::Result<Self> {
|
||||||
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
|
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
|
||||||
|
|
@ -229,6 +234,7 @@ impl DockerSandbox {
|
||||||
run_id,
|
run_id,
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
|
clone_tag,
|
||||||
clone_commit_sha,
|
clone_commit_sha,
|
||||||
container_id: OnceCell::new(),
|
container_id: OnceCell::new(),
|
||||||
repo_cloned: OnceCell::new(),
|
repo_cloned: OnceCell::new(),
|
||||||
|
|
@ -256,6 +262,7 @@ impl DockerSandbox {
|
||||||
clone_origin_url.clone(),
|
clone_origin_url.clone(),
|
||||||
clone_branch,
|
clone_branch,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
sandbox.validate_managed_container(container_id).await?;
|
sandbox.validate_managed_container(container_id).await?;
|
||||||
sandbox
|
sandbox
|
||||||
|
|
@ -903,6 +910,7 @@ impl DockerSandbox {
|
||||||
&self,
|
&self,
|
||||||
origin_url: String,
|
origin_url: String,
|
||||||
branch: Option<String>,
|
branch: Option<String>,
|
||||||
|
tag: Option<String>,
|
||||||
commit_sha: Option<String>,
|
commit_sha: Option<String>,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
self.verify_git_available().await?;
|
self.verify_git_available().await?;
|
||||||
|
|
@ -1030,6 +1038,64 @@ impl DockerSandbox {
|
||||||
if let Err(error) = clone_source::verify_exact_head(&head.stdout, expected_sha) {
|
if let Err(error) = clone_source::verify_exact_head(&head.stdout, expected_sha) {
|
||||||
return Err(self.report_clone_failure(&origin_url, error));
|
return Err(self.report_clone_failure(&origin_url, error));
|
||||||
}
|
}
|
||||||
|
} else if let Some(tag) = tag.as_deref() {
|
||||||
|
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty()) else {
|
||||||
|
let error = crate::Error::message("Tag checkout requires a repository branch");
|
||||||
|
return Err(self.report_clone_failure(&origin_url, error));
|
||||||
|
};
|
||||||
|
|
||||||
|
let init_command =
|
||||||
|
clone_source::exact_repository_init_command(clone_url, &layout.primary_repo_path);
|
||||||
|
if let Err(error) = self
|
||||||
|
.run_exact_local_git_command(
|
||||||
|
&init_command,
|
||||||
|
"initialize Docker tag repository checkout",
|
||||||
|
clone_deadline,
|
||||||
|
auth_url.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(self.report_clone_failure(&origin_url, error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetch_command = clone_source::tag_fetch_command(
|
||||||
|
&layout.primary_repo_path,
|
||||||
|
"origin",
|
||||||
|
tag,
|
||||||
|
self.config.clone_depth,
|
||||||
|
);
|
||||||
|
if let Err(failure) = self
|
||||||
|
.retry_git_transfer(
|
||||||
|
&fetch_command,
|
||||||
|
"fetch",
|
||||||
|
"Docker tag fetch",
|
||||||
|
"git fetch tag",
|
||||||
|
clone_deadline,
|
||||||
|
clone_credential_context,
|
||||||
|
auth_url.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(self.report_clone_failure(&origin_url, failure.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let checkout_command =
|
||||||
|
clone_source::tag_checkout_verify_command(&layout.primary_repo_path, branch);
|
||||||
|
let head = match self
|
||||||
|
.run_exact_local_git_command(
|
||||||
|
&checkout_command,
|
||||||
|
"git checkout tag",
|
||||||
|
clone_deadline,
|
||||||
|
auth_url.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => return Err(self.report_clone_failure(&origin_url, error)),
|
||||||
|
};
|
||||||
|
if let Err(error) = clone_source::verify_resolved_head(&head.stdout) {
|
||||||
|
return Err(self.report_clone_failure(&origin_url, error));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let command = git_clone_command(
|
let command = git_clone_command(
|
||||||
clone_url,
|
clone_url,
|
||||||
|
|
@ -1826,6 +1892,7 @@ impl Sandbox for DockerSandbox {
|
||||||
self.config.skip_clone,
|
self.config.skip_clone,
|
||||||
self.clone_origin_url.as_deref(),
|
self.clone_origin_url.as_deref(),
|
||||||
self.clone_branch.as_deref(),
|
self.clone_branch.as_deref(),
|
||||||
|
self.clone_tag.as_deref(),
|
||||||
self.clone_commit_sha.as_deref(),
|
self.clone_commit_sha.as_deref(),
|
||||||
)
|
)
|
||||||
.map_err(|e| self.fail_init(init_start, e))?;
|
.map_err(|e| self.fail_init(init_start, e))?;
|
||||||
|
|
@ -1847,9 +1914,13 @@ impl Sandbox for DockerSandbox {
|
||||||
CloneDecision::GitHub {
|
CloneDecision::GitHub {
|
||||||
origin_url,
|
origin_url,
|
||||||
branch,
|
branch,
|
||||||
|
tag,
|
||||||
commit_sha,
|
commit_sha,
|
||||||
} => {
|
} => {
|
||||||
if let Err(e) = self.clone_github_repo(origin_url, branch, commit_sha).await {
|
if let Err(e) = self
|
||||||
|
.clone_github_repo(origin_url, branch, tag, commit_sha)
|
||||||
|
.await
|
||||||
|
{
|
||||||
return Err(self.fail_init(init_start, e));
|
return Err(self.fail_init(init_start, e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2668,6 +2739,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
Some("https://github.com/acme/widgets".to_string()),
|
Some("https://github.com/acme/widgets".to_string()),
|
||||||
Some("main".to_string()),
|
Some("main".to_string()),
|
||||||
|
None,
|
||||||
Some("not-a-sha".to_string()),
|
Some("not-a-sha".to_string()),
|
||||||
)
|
)
|
||||||
.err()
|
.err()
|
||||||
|
|
@ -2685,6 +2757,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
Some("https://github.com/acme/widgets".to_string()),
|
Some("https://github.com/acme/widgets".to_string()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||||
)
|
)
|
||||||
.err()
|
.err()
|
||||||
|
|
@ -3072,6 +3145,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("test sandbox should build");
|
.expect("test sandbox should build");
|
||||||
sandbox
|
sandbox
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ impl SandboxProvider for DaytonaSandboxProvider {
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some(api_key),
|
Some(api_key),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ impl SandboxProvider for DockerSandboxProvider {
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
sandbox.initialize().await?;
|
sandbox.initialize().await?;
|
||||||
let container_id = sandbox.container_identifier()?.to_string();
|
let container_id = sandbox.container_identifier()?.to_string();
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ pub enum SandboxSpec {
|
||||||
run_id: Option<RunId>,
|
run_id: Option<RunId>,
|
||||||
clone_origin_url: Option<String>,
|
clone_origin_url: Option<String>,
|
||||||
clone_branch: Option<String>,
|
clone_branch: Option<String>,
|
||||||
|
clone_tag: Option<String>,
|
||||||
clone_commit_sha: Option<String>,
|
clone_commit_sha: Option<String>,
|
||||||
},
|
},
|
||||||
#[cfg(feature = "daytona")]
|
#[cfg(feature = "daytona")]
|
||||||
|
|
@ -41,6 +42,7 @@ pub enum SandboxSpec {
|
||||||
run_id: Option<RunId>,
|
run_id: Option<RunId>,
|
||||||
clone_origin_url: Option<String>,
|
clone_origin_url: Option<String>,
|
||||||
clone_branch: Option<String>,
|
clone_branch: Option<String>,
|
||||||
|
clone_tag: Option<String>,
|
||||||
clone_commit_sha: Option<String>,
|
clone_commit_sha: Option<String>,
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
@ -204,6 +206,7 @@ impl SandboxSpec {
|
||||||
run_id,
|
run_id,
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
|
clone_tag,
|
||||||
clone_commit_sha,
|
clone_commit_sha,
|
||||||
} => {
|
} => {
|
||||||
let mut sandbox = DockerSandbox::new(
|
let mut sandbox = DockerSandbox::new(
|
||||||
|
|
@ -212,6 +215,7 @@ impl SandboxSpec {
|
||||||
*run_id,
|
*run_id,
|
||||||
clone_origin_url.clone(),
|
clone_origin_url.clone(),
|
||||||
clone_branch.clone(),
|
clone_branch.clone(),
|
||||||
|
clone_tag.clone(),
|
||||||
clone_commit_sha.clone(),
|
clone_commit_sha.clone(),
|
||||||
)
|
)
|
||||||
.context("Failed to create Docker sandbox")?;
|
.context("Failed to create Docker sandbox")?;
|
||||||
|
|
@ -227,6 +231,7 @@ impl SandboxSpec {
|
||||||
run_id,
|
run_id,
|
||||||
clone_origin_url,
|
clone_origin_url,
|
||||||
clone_branch,
|
clone_branch,
|
||||||
|
clone_tag,
|
||||||
clone_commit_sha,
|
clone_commit_sha,
|
||||||
api_key,
|
api_key,
|
||||||
} => {
|
} => {
|
||||||
|
|
@ -236,6 +241,7 @@ impl SandboxSpec {
|
||||||
*run_id,
|
*run_id,
|
||||||
clone_origin_url.clone(),
|
clone_origin_url.clone(),
|
||||||
clone_branch.clone(),
|
clone_branch.clone(),
|
||||||
|
clone_tag.clone(),
|
||||||
clone_commit_sha.clone(),
|
clone_commit_sha.clone(),
|
||||||
api_key.clone(),
|
api_key.clone(),
|
||||||
)
|
)
|
||||||
|
|
@ -282,6 +288,7 @@ mod tests {
|
||||||
run_id: None,
|
run_id: None,
|
||||||
clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()),
|
clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()),
|
||||||
clone_branch: Some("main".to_string()),
|
clone_branch: Some("main".to_string()),
|
||||||
|
clone_tag: None,
|
||||||
clone_commit_sha: None,
|
clone_commit_sha: None,
|
||||||
};
|
};
|
||||||
let mut sandbox = MockSandbox::linux();
|
let mut sandbox = MockSandbox::linux();
|
||||||
|
|
@ -320,6 +327,7 @@ mod tests {
|
||||||
run_id: None,
|
run_id: None,
|
||||||
clone_origin_url: Some("https://github.com/acme/widgets".to_string()),
|
clone_origin_url: Some("https://github.com/acme/widgets".to_string()),
|
||||||
clone_branch: Some("main".to_string()),
|
clone_branch: Some("main".to_string()),
|
||||||
|
clone_tag: None,
|
||||||
clone_commit_sha: Some("not-a-sha".to_string()),
|
clone_commit_sha: Some("not-a-sha".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -349,6 +357,7 @@ mod tests {
|
||||||
run_id: None,
|
run_id: None,
|
||||||
clone_origin_url: Some("https://gitlab.com/acme/widgets".to_string()),
|
clone_origin_url: Some("https://gitlab.com/acme/widgets".to_string()),
|
||||||
clone_branch: None,
|
clone_branch: None,
|
||||||
|
clone_tag: None,
|
||||||
clone_commit_sha: None,
|
clone_commit_sha: None,
|
||||||
};
|
};
|
||||||
let mut sandbox = MockSandbox::linux();
|
let mut sandbox = MockSandbox::linux();
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ mod daytona_streaming_live {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
|
|
@ -76,6 +77,7 @@ mod daytona_streaming_live {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
sandbox.initialize().await?;
|
sandbox.initialize().await?;
|
||||||
|
|
@ -182,6 +184,7 @@ mod daytona_streaming_live {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -232,6 +235,7 @@ mod daytona_streaming_live {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -300,6 +304,7 @@ mod daytona_streaming_live {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("docker sandbox should construct");
|
.expect("docker sandbox should construct");
|
||||||
sandbox
|
sandbox
|
||||||
|
|
@ -114,6 +115,7 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("docker sandbox should construct");
|
.expect("docker sandbox should construct");
|
||||||
sandbox
|
sandbox
|
||||||
|
|
@ -176,6 +178,7 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
|
||||||
Some("https://github.com/brynary/rack-test".to_string()),
|
Some("https://github.com/brynary/rack-test".to_string()),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("docker sandbox should construct");
|
.expect("docker sandbox should construct");
|
||||||
sandbox
|
sandbox
|
||||||
|
|
@ -243,6 +246,7 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("docker sandbox should construct");
|
.expect("docker sandbox should construct");
|
||||||
sandbox
|
sandbox
|
||||||
|
|
@ -337,6 +341,7 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("docker sandbox should construct");
|
.expect("docker sandbox should construct");
|
||||||
sandbox
|
sandbox
|
||||||
|
|
@ -422,6 +427,7 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("docker sandbox should construct");
|
.expect("docker sandbox should construct");
|
||||||
sandbox
|
sandbox
|
||||||
|
|
|
||||||
|
|
@ -478,7 +478,7 @@ pub async fn persist_create_run(
|
||||||
let (source_directory, git) = match target.as_ref() {
|
let (source_directory, git) = match target.as_ref() {
|
||||||
Some(RunTarget::None {}) => (None, None),
|
Some(RunTarget::None {}) => (None, None),
|
||||||
Some(RunTarget::Folder { path }) => (Some(path.clone()), git),
|
Some(RunTarget::Folder { path }) => (Some(path.clone()), git),
|
||||||
Some(RunTarget::Git { .. }) | None => (Some(source_directory), git),
|
Some(RunTarget::Git(_)) | None => (Some(source_directory), git),
|
||||||
};
|
};
|
||||||
let persisted_run_dir = run_dir.clone();
|
let persisted_run_dir = run_dir.clone();
|
||||||
let persisted = spawn_blocking(move || {
|
let persisted = spawn_blocking(move || {
|
||||||
|
|
|
||||||
|
|
@ -407,11 +407,12 @@ mod tests {
|
||||||
source_directory: Some("/client/source".to_string()),
|
source_directory: Some("/client/source".to_string()),
|
||||||
workflow_slug: Some("fork-source".to_string()),
|
workflow_slug: Some("fork-source".to_string()),
|
||||||
workflow_version_id: Some(workflow_version_id),
|
workflow_version_id: Some(workflow_version_id),
|
||||||
target: Some(fabro_types::RunTarget::Git {
|
target: Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
|
||||||
repo: "example/repo".to_string(),
|
repo: "example/repo".to_string(),
|
||||||
branch: "main".to_string(),
|
branch: "main".to_string(),
|
||||||
|
tag: None,
|
||||||
sha: None,
|
sha: None,
|
||||||
}),
|
})),
|
||||||
automation: None,
|
automation: None,
|
||||||
provenance: test_support::test_run_provenance(),
|
provenance: test_support::test_run_provenance(),
|
||||||
manifest_blob: None,
|
manifest_blob: None,
|
||||||
|
|
@ -500,11 +501,12 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
forked_state.spec.target,
|
forked_state.spec.target,
|
||||||
Some(fabro_types::RunTarget::Git {
|
Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
|
||||||
repo: "example/repo".to_string(),
|
repo: "example/repo".to_string(),
|
||||||
branch: "main".to_string(),
|
branch: "main".to_string(),
|
||||||
|
tag: None,
|
||||||
sha: None,
|
sha: None,
|
||||||
})
|
}))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
forked_state.spec.fork_source_ref.unwrap().source_run_id,
|
forked_state.spec.fork_source_ref.unwrap().source_run_id,
|
||||||
|
|
|
||||||
|
|
@ -170,11 +170,12 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_target() -> RunTarget {
|
fn run_target() -> RunTarget {
|
||||||
RunTarget::Git {
|
RunTarget::Git(fabro_types::GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "main".to_string(),
|
branch: "main".to_string(),
|
||||||
|
tag: None,
|
||||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn append_created(
|
async fn append_created(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ use fabro_sandbox::from_environment::{
|
||||||
};
|
};
|
||||||
use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};
|
use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};
|
||||||
use fabro_static::EnvVars;
|
use fabro_static::EnvVars;
|
||||||
|
#[cfg(test)]
|
||||||
|
use fabro_types::GitRunTarget;
|
||||||
use fabro_types::settings::run::{
|
use fabro_types::settings::run::{
|
||||||
ApprovalMode, McpServerSettings as ResolvedMcpServerSettings, PullRequestSettings,
|
ApprovalMode, McpServerSettings as ResolvedMcpServerSettings, PullRequestSettings,
|
||||||
ResolvedGithubIntegration, ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings,
|
ResolvedGithubIntegration, ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings,
|
||||||
|
|
@ -470,7 +472,7 @@ impl RunSession {
|
||||||
}
|
}
|
||||||
let sandbox = match sandbox_provider {
|
let sandbox = match sandbox_provider {
|
||||||
SandboxProviderKind::Local => match record.target.as_ref() {
|
SandboxProviderKind::Local => match record.target.as_ref() {
|
||||||
Some(target @ (RunTarget::Git { .. } | RunTarget::None {})) => {
|
Some(target @ (RunTarget::Git(_) | RunTarget::None {})) => {
|
||||||
return Err(Error::engine(format!(
|
return Err(Error::engine(format!(
|
||||||
"persisted {} run targets require a clone-based sandbox provider",
|
"persisted {} run targets require a clone-based sandbox provider",
|
||||||
target.kind_name()
|
target.kind_name()
|
||||||
|
|
@ -502,6 +504,7 @@ impl RunSession {
|
||||||
run_id: Some(record.run_id),
|
run_id: Some(record.run_id),
|
||||||
clone_origin_url: clone_source.origin_url,
|
clone_origin_url: clone_source.origin_url,
|
||||||
clone_branch: clone_source.branch,
|
clone_branch: clone_source.branch,
|
||||||
|
clone_tag: clone_source.tag,
|
||||||
clone_commit_sha: clone_source.commit_sha,
|
clone_commit_sha: clone_source.commit_sha,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -517,6 +520,7 @@ impl RunSession {
|
||||||
run_id: Some(record.run_id),
|
run_id: Some(record.run_id),
|
||||||
clone_origin_url: clone_source.origin_url,
|
clone_origin_url: clone_source.origin_url,
|
||||||
clone_branch: clone_source.branch,
|
clone_branch: clone_source.branch,
|
||||||
|
clone_tag: clone_source.tag,
|
||||||
clone_commit_sha: clone_source.commit_sha,
|
clone_commit_sha: clone_source.commit_sha,
|
||||||
api_key,
|
api_key,
|
||||||
}
|
}
|
||||||
|
|
@ -599,6 +603,7 @@ impl RunSession {
|
||||||
struct CloneSourceForRun {
|
struct CloneSourceForRun {
|
||||||
origin_url: Option<String>,
|
origin_url: Option<String>,
|
||||||
branch: Option<String>,
|
branch: Option<String>,
|
||||||
|
tag: Option<String>,
|
||||||
commit_sha: Option<String>,
|
commit_sha: Option<String>,
|
||||||
/// The target asked for an empty workspace, so the provider must not
|
/// The target asked for an empty workspace, so the provider must not
|
||||||
/// clone even when it would otherwise inherit an origin.
|
/// clone even when it would otherwise inherit an origin.
|
||||||
|
|
@ -652,6 +657,7 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
|
||||||
return Ok(CloneSourceForRun {
|
return Ok(CloneSourceForRun {
|
||||||
origin_url: record.repo_origin_url().map(str::to_string),
|
origin_url: record.repo_origin_url().map(str::to_string),
|
||||||
branch: record.base_branch().map(str::to_string),
|
branch: record.base_branch().map(str::to_string),
|
||||||
|
tag: None,
|
||||||
commit_sha: None,
|
commit_sha: None,
|
||||||
skip_clone: false,
|
skip_clone: false,
|
||||||
});
|
});
|
||||||
|
|
@ -668,9 +674,14 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
|
||||||
"persisted Git run target has an invalid repository slug"
|
"persisted Git run target has an invalid repository slug"
|
||||||
}
|
}
|
||||||
TargetValidationError::Branch => "persisted Git run target has an invalid branch",
|
TargetValidationError::Branch => "persisted Git run target has an invalid branch",
|
||||||
|
TargetValidationError::Tag => "persisted Git run target has an invalid tag",
|
||||||
TargetValidationError::Sha => "persisted Git run target has an invalid SHA",
|
TargetValidationError::Sha => "persisted Git run target has an invalid SHA",
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
let tag = match &validated.target {
|
||||||
|
RunTarget::Git(target) => target.tag.clone(),
|
||||||
|
RunTarget::None {} | RunTarget::Folder { .. } => None,
|
||||||
|
};
|
||||||
// A target with no Git projection (`none` or `folder`) supplies no clone
|
// A target with no Git projection (`none` or `folder`) supplies no clone
|
||||||
// source. Folder targets only reach the Local provider, where `skip_clone`
|
// source. Folder targets only reach the Local provider, where `skip_clone`
|
||||||
// is unused.
|
// is unused.
|
||||||
|
|
@ -678,12 +689,14 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
|
||||||
Some(git) => CloneSourceForRun {
|
Some(git) => CloneSourceForRun {
|
||||||
origin_url: Some(git.origin_url),
|
origin_url: Some(git.origin_url),
|
||||||
branch: Some(git.branch),
|
branch: Some(git.branch),
|
||||||
|
tag,
|
||||||
commit_sha: git.sha,
|
commit_sha: git.sha,
|
||||||
skip_clone: false,
|
skip_clone: false,
|
||||||
},
|
},
|
||||||
None => CloneSourceForRun {
|
None => CloneSourceForRun {
|
||||||
origin_url: None,
|
origin_url: None,
|
||||||
branch: None,
|
branch: None,
|
||||||
|
tag: None,
|
||||||
commit_sha: None,
|
commit_sha: None,
|
||||||
skip_clone: true,
|
skip_clone: true,
|
||||||
},
|
},
|
||||||
|
|
@ -3175,11 +3188,12 @@ reasoning = false
|
||||||
let mut spec = test_support::test_run_spec();
|
let mut spec = test_support::test_run_spec();
|
||||||
let submitted_sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
let submitted_sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||||
let normalized_sha = "abcdef0123456789abcdef0123456789abcdef01";
|
let normalized_sha = "abcdef0123456789abcdef0123456789abcdef01";
|
||||||
spec.target = Some(RunTarget::Git {
|
spec.target = Some(RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
tag: Some("v1.2.3".to_string()),
|
||||||
sha: Some(submitted_sha.to_string()),
|
sha: Some(submitted_sha.to_string()),
|
||||||
});
|
}));
|
||||||
spec.git = Some(fabro_types::GitContext {
|
spec.git = Some(fabro_types::GitContext {
|
||||||
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
|
@ -3194,17 +3208,19 @@ reasoning = false
|
||||||
Some("https://github.com/fabro-sh/fabro")
|
Some("https://github.com/fabro-sh/fabro")
|
||||||
);
|
);
|
||||||
assert_eq!(source.branch.as_deref(), Some("feature/run-intent"));
|
assert_eq!(source.branch.as_deref(), Some("feature/run-intent"));
|
||||||
|
assert_eq!(source.tag.as_deref(), Some("v1.2.3"));
|
||||||
assert_eq!(source.commit_sha.as_deref(), Some(normalized_sha));
|
assert_eq!(source.commit_sha.as_deref(), Some(normalized_sha));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clone_commit_persisted_git_target_without_sha_keeps_branch_unpinned() {
|
fn clone_commit_persisted_git_target_without_sha_keeps_branch_unpinned() {
|
||||||
let mut spec = test_support::test_run_spec();
|
let mut spec = test_support::test_run_spec();
|
||||||
spec.target = Some(RunTarget::Git {
|
spec.target = Some(RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
tag: None,
|
||||||
sha: None,
|
sha: None,
|
||||||
});
|
}));
|
||||||
spec.git = Some(fabro_types::GitContext {
|
spec.git = Some(fabro_types::GitContext {
|
||||||
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
|
@ -3218,14 +3234,32 @@ reasoning = false
|
||||||
assert_eq!(source.commit_sha, None);
|
assert_eq!(source.commit_sha, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_source_preserves_unpinned_tag_separately_from_working_branch() {
|
||||||
|
let mut spec = test_support::test_run_spec();
|
||||||
|
spec.target = Some(RunTarget::Git(GitRunTarget {
|
||||||
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
|
branch: "release-work".to_string(),
|
||||||
|
tag: Some("v1.2.3".to_string()),
|
||||||
|
sha: None,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let source = clone_source_for_run(&spec).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(source.branch.as_deref(), Some("release-work"));
|
||||||
|
assert_eq!(source.tag.as_deref(), Some("v1.2.3"));
|
||||||
|
assert_eq!(source.commit_sha, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clone_commit_persisted_git_target_is_authoritative_over_projection() {
|
fn clone_commit_persisted_git_target_is_authoritative_over_projection() {
|
||||||
let mut spec = test_support::test_run_spec();
|
let mut spec = test_support::test_run_spec();
|
||||||
spec.target = Some(RunTarget::Git {
|
spec.target = Some(RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "main".to_string(),
|
branch: "main".to_string(),
|
||||||
|
tag: None,
|
||||||
sha: None,
|
sha: None,
|
||||||
});
|
}));
|
||||||
// A drifted (or absent) projection never feeds the clone source: the
|
// A drifted (or absent) projection never feeds the clone source: the
|
||||||
// validated target alone does.
|
// validated target alone does.
|
||||||
spec.git = Some(fabro_types::GitContext {
|
spec.git = Some(fabro_types::GitContext {
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ async fn create_env_with_github_app(
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?")
|
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?")
|
||||||
|
|
@ -413,7 +414,7 @@ async fn daytona_snapshot_sandbox() {
|
||||||
};
|
};
|
||||||
|
|
||||||
let creds = load_github_app_credentials();
|
let creds = load_github_app_credentials();
|
||||||
let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None, None)
|
let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None, None, None)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?");
|
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?");
|
||||||
env.initialize().await.unwrap();
|
env.initialize().await.unwrap();
|
||||||
|
|
@ -1618,7 +1619,7 @@ async fn daytona_computer_use_browser_screenshot() {
|
||||||
skip_clone: true,
|
skip_clone: true,
|
||||||
..DaytonaConfig::default()
|
..DaytonaConfig::default()
|
||||||
};
|
};
|
||||||
let env = DaytonaSandbox::new(config, None, None, None, None, None, None)
|
let env = DaytonaSandbox::new(config, None, None, None, None, None, None, None)
|
||||||
.await
|
.await
|
||||||
.expect("DAYTONA_API_KEY must be set");
|
.expect("DAYTONA_API_KEY must be set");
|
||||||
env.initialize().await.unwrap();
|
env.initialize().await.unwrap();
|
||||||
|
|
@ -1766,7 +1767,7 @@ async fn daytona_playwright_mcp_sandbox_transport() {
|
||||||
skip_clone: true,
|
skip_clone: true,
|
||||||
..DaytonaConfig::default()
|
..DaytonaConfig::default()
|
||||||
};
|
};
|
||||||
let sandbox = DaytonaSandbox::new(config, None, None, None, None, None, None)
|
let sandbox = DaytonaSandbox::new(config, None, None, None, None, None, None, None)
|
||||||
.await
|
.await
|
||||||
.expect("DAYTONA_API_KEY must be set");
|
.expect("DAYTONA_API_KEY must be set");
|
||||||
sandbox.initialize().await.unwrap();
|
sandbox.initialize().await.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -13737,7 +13737,7 @@ async fn asset_collection_docker_sandbox() {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
|
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
|
||||||
fabro_agent::DockerSandbox::new(config, None, None, None, None, None)
|
fabro_agent::DockerSandbox::new(config, None, None, None, None, None, None)
|
||||||
.expect("Docker not available"),
|
.expect("Docker not available"),
|
||||||
);
|
);
|
||||||
sandbox.initialize().await.expect("Docker init failed");
|
sandbox.initialize().await.expect("Docker init failed");
|
||||||
|
|
|
||||||
|
|
@ -739,6 +739,7 @@ fn main() {
|
||||||
("RunIntent", "fabro_types::RunIntent", &[]),
|
("RunIntent", "fabro_types::RunIntent", &[]),
|
||||||
("RunIntentArgs", "fabro_types::RunIntentArgs", &[]),
|
("RunIntentArgs", "fabro_types::RunIntentArgs", &[]),
|
||||||
("RunTarget", "fabro_types::RunTarget", &[]),
|
("RunTarget", "fabro_types::RunTarget", &[]),
|
||||||
|
("GitRunTarget", "fabro_types::GitRunTarget", &[]),
|
||||||
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
|
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
|
||||||
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
|
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
|
||||||
("BlobHash", "fabro_types::BlobHash", &[]),
|
("BlobHash", "fabro_types::BlobHash", &[]),
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ pub mod types {
|
||||||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
|
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
|
||||||
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
|
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
|
||||||
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
||||||
FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
|
FailureSignature, GitContext, GitRunTarget, IdpIdentity, IntegrationConnectionKind,
|
||||||
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
|
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
|
||||||
IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind,
|
IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind,
|
||||||
McpServerDraft as CreateMcpServerRequest, McpServerProjection,
|
McpServerDraft as CreateMcpServerRequest, McpServerProjection,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::any::{TypeId, type_name};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use fabro_api::types::{RunIntent as ApiRunIntent, RunIntentArgs as ApiRunIntentArgs};
|
use fabro_api::types::{RunIntent as ApiRunIntent, RunIntentArgs as ApiRunIntentArgs};
|
||||||
use fabro_types::{RunIntent, RunIntentArgs, RunTarget, test_support};
|
use fabro_types::{GitRunTarget, RunIntent, RunIntentArgs, RunTarget, test_support};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -10,17 +10,19 @@ fn run_intent_schemas_reuse_canonical_types() {
|
||||||
assert_same_type::<ApiRunIntent, RunIntent>();
|
assert_same_type::<ApiRunIntent, RunIntent>();
|
||||||
assert_same_type::<ApiRunIntentArgs, RunIntentArgs>();
|
assert_same_type::<ApiRunIntentArgs, RunIntentArgs>();
|
||||||
assert_same_type::<fabro_api::types::RunTarget, RunTarget>();
|
assert_same_type::<fabro_api::types::RunTarget, RunTarget>();
|
||||||
|
assert_same_type::<fabro_api::types::GitRunTarget, GitRunTarget>();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_intent_round_trips_the_openapi_shape() {
|
fn run_intent_round_trips_the_openapi_shape() {
|
||||||
let intent = RunIntent {
|
let intent = RunIntent {
|
||||||
workflow_version_id: test_support::test_workflow_version_id(),
|
workflow_version_id: test_support::test_workflow_version_id(),
|
||||||
target: RunTarget::Git {
|
target: RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
tag: Some("v1.2.3".to_string()),
|
||||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||||
},
|
}),
|
||||||
args: RunIntentArgs {
|
args: RunIntentArgs {
|
||||||
model: Some("gpt-5.6-sol".to_string()),
|
model: Some("gpt-5.6-sol".to_string()),
|
||||||
provider: Some("openai".to_string()),
|
provider: Some("openai".to_string()),
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ pub use run_event::{
|
||||||
pub use run_failure::RunFailure;
|
pub use run_failure::RunFailure;
|
||||||
pub use run_id::{RunId, fixtures};
|
pub use run_id::{RunId, fixtures};
|
||||||
pub use run_intent::{
|
pub use run_intent::{
|
||||||
RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedRunTarget,
|
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedRunTarget,
|
||||||
};
|
};
|
||||||
pub use run_projection::{
|
pub use run_projection::{
|
||||||
ActivatedSkill, AgentControlState, CheckpointRecord, McpServerProjection, McpServerStatus,
|
ActivatedSkill, AgentControlState, CheckpointRecord, McpServerProjection, McpServerStatus,
|
||||||
|
|
|
||||||
|
|
@ -45,16 +45,25 @@ pub struct RunIntentArgs {
|
||||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
#[strum(serialize_all = "snake_case")]
|
#[strum(serialize_all = "snake_case")]
|
||||||
pub enum RunTarget {
|
pub enum RunTarget {
|
||||||
Git {
|
Git(GitRunTarget),
|
||||||
repo: String,
|
|
||||||
branch: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
sha: Option<String>,
|
|
||||||
},
|
|
||||||
None {},
|
None {},
|
||||||
Folder {
|
Folder { path: String },
|
||||||
path: String,
|
}
|
||||||
},
|
|
||||||
|
/// A Git-backed run target.
|
||||||
|
///
|
||||||
|
/// `branch` is always the attached working branch. When present, `tag` names
|
||||||
|
/// the requested release identity. An exact `sha` is authoritative over both
|
||||||
|
/// selectors while preserving the tag in durable state.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct GitRunTarget {
|
||||||
|
pub repo: String,
|
||||||
|
pub branch: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tag: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sha: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RunTarget {
|
impl RunTarget {
|
||||||
|
|
@ -71,7 +80,12 @@ impl RunTarget {
|
||||||
/// filesystem validation and canonicalization during provider admission.
|
/// filesystem validation and canonicalization during provider admission.
|
||||||
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
|
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
|
||||||
match self {
|
match self {
|
||||||
Self::Git { repo, branch, sha } => {
|
Self::Git(GitRunTarget {
|
||||||
|
repo,
|
||||||
|
branch,
|
||||||
|
tag,
|
||||||
|
sha,
|
||||||
|
}) => {
|
||||||
let slug = GitHubRepositorySlug::try_new(&repo)
|
let slug = GitHubRepositorySlug::try_new(&repo)
|
||||||
.ok_or(TargetValidationError::Repository)?;
|
.ok_or(TargetValidationError::Repository)?;
|
||||||
// The selector grammar is checked on the bare branch name so
|
// The selector grammar is checked on the bare branch name so
|
||||||
|
|
@ -86,6 +100,15 @@ impl RunTarget {
|
||||||
{
|
{
|
||||||
return Err(TargetValidationError::Branch);
|
return Err(TargetValidationError::Branch);
|
||||||
}
|
}
|
||||||
|
if tag.as_deref().is_some_and(|tag| {
|
||||||
|
tag == "HEAD"
|
||||||
|
|| tag.starts_with("tags/")
|
||||||
|
|| tag.starts_with("refs/")
|
||||||
|
|| repository::normalize_git_commit_sha(tag).is_some()
|
||||||
|
|| !repository::is_valid_github_ref_selector(tag)
|
||||||
|
}) {
|
||||||
|
return Err(TargetValidationError::Tag);
|
||||||
|
}
|
||||||
let sha = sha
|
let sha = sha
|
||||||
.map(|sha| {
|
.map(|sha| {
|
||||||
repository::normalize_git_commit_sha(&sha).ok_or(TargetValidationError::Sha)
|
repository::normalize_git_commit_sha(&sha).ok_or(TargetValidationError::Sha)
|
||||||
|
|
@ -98,7 +121,12 @@ impl RunTarget {
|
||||||
dirty: DirtyStatus::Clean,
|
dirty: DirtyStatus::Clean,
|
||||||
};
|
};
|
||||||
Ok(ValidatedRunTarget {
|
Ok(ValidatedRunTarget {
|
||||||
target: Self::Git { repo, branch, sha },
|
target: Self::Git(GitRunTarget {
|
||||||
|
repo,
|
||||||
|
branch,
|
||||||
|
tag,
|
||||||
|
sha,
|
||||||
|
}),
|
||||||
git: Some(git),
|
git: Some(git),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +157,8 @@ pub enum TargetValidationError {
|
||||||
Repository,
|
Repository,
|
||||||
#[error("target branch must be a non-empty branch name, not a ref or commit selector")]
|
#[error("target branch must be a non-empty branch name, not a ref or commit selector")]
|
||||||
Branch,
|
Branch,
|
||||||
|
#[error("target tag must be a non-empty bare tag name, not a ref or commit selector")]
|
||||||
|
Tag,
|
||||||
#[error("target SHA must be exactly 40 ASCII hexadecimal characters")]
|
#[error("target SHA must be exactly 40 ASCII hexadecimal characters")]
|
||||||
Sha,
|
Sha,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ use fabro_types::run_event::{RunSessionTurnFailedCode, RunSessionTurnFailedProps
|
||||||
use fabro_types::settings::InterpString;
|
use fabro_types::settings::InterpString;
|
||||||
use fabro_types::settings::run::RunGoal;
|
use fabro_types::settings::run::RunGoal;
|
||||||
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
||||||
use fabro_types::{AutomationRef, EventBody, RunTarget, TurnId, WorkflowSettings, fixtures};
|
use fabro_types::{
|
||||||
|
AutomationRef, EventBody, GitRunTarget, RunTarget, TurnId, WorkflowSettings, fixtures,
|
||||||
|
};
|
||||||
|
|
||||||
fn templated_settings() -> WorkflowSettings {
|
fn templated_settings() -> WorkflowSettings {
|
||||||
let mut settings = WorkflowSettings::default();
|
let mut settings = WorkflowSettings::default();
|
||||||
|
|
@ -26,11 +28,12 @@ fn run_created_props_round_trip_templated_settings() {
|
||||||
source_directory: Some("/Users/client/project".to_string()),
|
source_directory: Some("/Users/client/project".to_string()),
|
||||||
workflow_slug: Some("demo".to_string()),
|
workflow_slug: Some("demo".to_string()),
|
||||||
workflow_version_id: Some(test_workflow_version_id()),
|
workflow_version_id: Some(test_workflow_version_id()),
|
||||||
target: Some(RunTarget::Git {
|
target: Some(RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "main".to_string(),
|
branch: "main".to_string(),
|
||||||
|
tag: None,
|
||||||
sha: None,
|
sha: None,
|
||||||
}),
|
})),
|
||||||
automation: Some(AutomationRef {
|
automation: Some(AutomationRef {
|
||||||
id: "nightly".to_string(),
|
id: "nightly".to_string(),
|
||||||
name: Some("Nightly".to_string()),
|
name: Some("Nightly".to_string()),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use fabro_types::{
|
use fabro_types::{
|
||||||
RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha,
|
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
|
@ -14,11 +14,12 @@ fn version_id() -> WorkflowVersionId {
|
||||||
fn intent() -> RunIntent {
|
fn intent() -> RunIntent {
|
||||||
RunIntent {
|
RunIntent {
|
||||||
workflow_version_id: version_id(),
|
workflow_version_id: version_id(),
|
||||||
target: RunTarget::Git {
|
target: RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
tag: Some("v1.2.3".to_string()),
|
||||||
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
||||||
},
|
}),
|
||||||
args: RunIntentArgs {
|
args: RunIntentArgs {
|
||||||
model: Some("gpt-5.6".to_string()),
|
model: Some("gpt-5.6".to_string()),
|
||||||
provider: Some("openai".to_string()),
|
provider: Some("openai".to_string()),
|
||||||
|
|
@ -43,6 +44,7 @@ fn run_intent_round_trips_the_strict_git_shape() {
|
||||||
assert_eq!(value["target"]["kind"], "git");
|
assert_eq!(value["target"]["kind"], "git");
|
||||||
assert_eq!(value["target"]["repo"], "fabro-sh/fabro");
|
assert_eq!(value["target"]["repo"], "fabro-sh/fabro");
|
||||||
assert_eq!(value["target"]["branch"], "feature/run-intent");
|
assert_eq!(value["target"]["branch"], "feature/run-intent");
|
||||||
|
assert_eq!(value["target"]["tag"], "v1.2.3");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::from_value::<RunIntent>(value).expect("intent should deserialize"),
|
serde_json::from_value::<RunIntent>(value).expect("intent should deserialize"),
|
||||||
intent
|
intent
|
||||||
|
|
@ -158,11 +160,12 @@ fn git_commit_sha_normalization_is_exact_and_pure() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn target_validation_normalizes_sha_without_network_resolution() {
|
fn target_validation_normalizes_sha_without_network_resolution() {
|
||||||
let validated = RunTarget::Git {
|
let validated = RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
tag: Some("release/v1".to_string()),
|
||||||
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
||||||
}
|
})
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let git = validated
|
let git = validated
|
||||||
|
|
@ -174,11 +177,15 @@ fn target_validation_normalizes_sha_without_network_resolution() {
|
||||||
Some("abcdef0123456789abcdef0123456789abcdef01")
|
Some("abcdef0123456789abcdef0123456789abcdef01")
|
||||||
);
|
);
|
||||||
assert_eq!(git.origin_url, "https://github.com/fabro-sh/fabro");
|
assert_eq!(git.origin_url, "https://github.com/fabro-sh/fabro");
|
||||||
assert_eq!(validated.target, RunTarget::Git {
|
assert_eq!(
|
||||||
|
validated.target,
|
||||||
|
RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "feature/run-intent".to_string(),
|
branch: "feature/run-intent".to_string(),
|
||||||
|
tag: Some("release/v1".to_string()),
|
||||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||||
});
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -204,17 +211,18 @@ fn run_intent_folder_target_is_preserved_for_provider_admission() {
|
||||||
fn target_validation_rejects_invalid_grammar() {
|
fn target_validation_rejects_invalid_grammar() {
|
||||||
use fabro_types::TargetValidationError;
|
use fabro_types::TargetValidationError;
|
||||||
|
|
||||||
let validate = |repo: &str, branch: &str, sha: Option<&str>| {
|
let validate = |repo: &str, branch: &str, tag: Option<&str>, sha: Option<&str>| {
|
||||||
RunTarget::Git {
|
RunTarget::Git(GitRunTarget {
|
||||||
repo: repo.to_string(),
|
repo: repo.to_string(),
|
||||||
branch: branch.to_string(),
|
branch: branch.to_string(),
|
||||||
|
tag: tag.map(str::to_string),
|
||||||
sha: sha.map(str::to_string),
|
sha: sha.map(str::to_string),
|
||||||
}
|
})
|
||||||
.validate()
|
.validate()
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validate("not-a-slug", "main", None).unwrap_err(),
|
validate("not-a-slug", "main", None, None).unwrap_err(),
|
||||||
TargetValidationError::Repository
|
TargetValidationError::Repository
|
||||||
);
|
);
|
||||||
for branch in [
|
for branch in [
|
||||||
|
|
@ -229,13 +237,66 @@ fn target_validation_rejects_invalid_grammar() {
|
||||||
"bad..branch",
|
"bad..branch",
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validate("fabro-sh/fabro", branch, None).unwrap_err(),
|
validate("fabro-sh/fabro", branch, None, None).unwrap_err(),
|
||||||
TargetValidationError::Branch,
|
TargetValidationError::Branch,
|
||||||
"{branch:?}"
|
"{branch:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for tag in [
|
||||||
|
"",
|
||||||
|
"HEAD",
|
||||||
|
"-v1",
|
||||||
|
".v1",
|
||||||
|
"tags/v1",
|
||||||
|
"refs/tags/v1",
|
||||||
|
"abcdef0123456789abcdef0123456789abcdef01",
|
||||||
|
"bad..tag",
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validate("fabro-sh/fabro", "main", Some("short")).unwrap_err(),
|
validate("fabro-sh/fabro", "main", Some(tag), None).unwrap_err(),
|
||||||
|
TargetValidationError::Tag,
|
||||||
|
"{tag:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
validate("fabro-sh/fabro", "main", None, Some("short")).unwrap_err(),
|
||||||
TargetValidationError::Sha
|
TargetValidationError::Sha
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_target_round_trips_all_branch_tag_sha_states() {
|
||||||
|
for target in [
|
||||||
|
GitRunTarget {
|
||||||
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
tag: None,
|
||||||
|
sha: None,
|
||||||
|
},
|
||||||
|
GitRunTarget {
|
||||||
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
tag: None,
|
||||||
|
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||||
|
},
|
||||||
|
GitRunTarget {
|
||||||
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
|
branch: "release".to_string(),
|
||||||
|
tag: Some("v1.2.3".to_string()),
|
||||||
|
sha: None,
|
||||||
|
},
|
||||||
|
GitRunTarget {
|
||||||
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
|
branch: "release".to_string(),
|
||||||
|
tag: Some("v1.2.3".to_string()),
|
||||||
|
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let run_target = RunTarget::Git(target);
|
||||||
|
let value = serde_json::to_value(&run_target).expect("target should serialize");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<RunTarget>(value).expect("target should deserialize"),
|
||||||
|
run_target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, RunSpec};
|
||||||
use fabro_types::settings::InterpString;
|
use fabro_types::settings::InterpString;
|
||||||
use fabro_types::settings::run::RunGoal;
|
use fabro_types::settings::run::RunGoal;
|
||||||
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
||||||
use fabro_types::{AutomationRef, RunTarget, WorkflowSettings, fixtures};
|
use fabro_types::{AutomationRef, GitRunTarget, RunTarget, WorkflowSettings, fixtures};
|
||||||
|
|
||||||
fn templated_settings() -> WorkflowSettings {
|
fn templated_settings() -> WorkflowSettings {
|
||||||
let mut settings = WorkflowSettings::default();
|
let mut settings = WorkflowSettings::default();
|
||||||
|
|
@ -22,11 +22,12 @@ fn run_spec_round_trips_templated_settings() {
|
||||||
graph_source: None,
|
graph_source: None,
|
||||||
workflow_slug: Some("demo".to_string()),
|
workflow_slug: Some("demo".to_string()),
|
||||||
workflow_version_id: Some(test_workflow_version_id()),
|
workflow_version_id: Some(test_workflow_version_id()),
|
||||||
target: Some(RunTarget::Git {
|
target: Some(RunTarget::Git(GitRunTarget {
|
||||||
repo: "fabro-sh/fabro".to_string(),
|
repo: "fabro-sh/fabro".to_string(),
|
||||||
branch: "main".to_string(),
|
branch: "main".to_string(),
|
||||||
|
tag: None,
|
||||||
sha: Some("abc123".to_string()),
|
sha: Some("abc123".to_string()),
|
||||||
}),
|
})),
|
||||||
automation: Some(AutomationRef {
|
automation: Some(AutomationRef {
|
||||||
id: "nightly".to_string(),
|
id: "nightly".to_string(),
|
||||||
name: Some("Nightly".to_string()),
|
name: Some("Nightly".to_string()),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public github.com repository target.
|
* Public github.com repository target. The branch names the attached working branch. An optional tag selects a release at worker start, and an optional exact SHA is authoritative when both are present.
|
||||||
*/
|
*/
|
||||||
export interface GitRunTarget {
|
export interface GitRunTarget {
|
||||||
'kind': GitRunTargetKindEnum;
|
'kind': GitRunTargetKindEnum;
|
||||||
|
|
@ -24,11 +24,15 @@ export interface GitRunTarget {
|
||||||
*/
|
*/
|
||||||
'repo': string;
|
'repo': string;
|
||||||
/**
|
/**
|
||||||
* Required branch name, preserved exactly.
|
* Required attached working branch name, preserved exactly.
|
||||||
*/
|
*/
|
||||||
'branch': string;
|
'branch': string;
|
||||||
/**
|
/**
|
||||||
* Optional exact commit. The server lowercase-normalizes its syntax but does not resolve it or prove branch ancestry.
|
* Optional bare tag name. Prefixes such as `refs/tags/` and `tags/` are rejected. Without `sha`, the worker resolves this tag when the sandbox starts and fails if it is unavailable.
|
||||||
|
*/
|
||||||
|
'tag'?: string;
|
||||||
|
/**
|
||||||
|
* Optional exact commit. The server lowercase-normalizes its syntax but does not resolve it, prove branch ancestry, or prove that it matches an accompanying tag. When present, this exact commit wins.
|
||||||
*/
|
*/
|
||||||
'sha'?: string;
|
'sha'?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue