Push run branch to origin after each Daytona checkpoint

When a workflow runs on a Daytona sandbox, commits on the run branch
are lost when the sandbox is deleted. This pushes the run branch to
origin after each checkpoint so the full commit history is preserved.

- Upgrade GitHub App token permission from contents:read to contents:write
- Add refresh_push_credentials to Sandbox trait (default no-op)
- Store origin URL on DaytonaSandbox and configure push credentials after clone
- Implement refresh_push_credentials on DaytonaSandbox to rotate expiring tokens
- Add git_push_remote helper, called after each remote GitCheckpoint
- Add e2e test verifying the branch appears on GitHub after push

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-06 19:38:53 -05:00
parent 4aa5aae691
commit 4e0f990484
5 changed files with 234 additions and 3 deletions

View file

@ -92,6 +92,10 @@ macro_rules! delegate_sandbox {
fn sandbox_info(&self) -> String {
self.$field.sandbox_info()
}
async fn refresh_push_credentials(&self) -> Result<(), String> {
self.$field.refresh_push_credentials().await
}
}
};
}
@ -329,6 +333,12 @@ pub trait Sandbox: Send + Sync {
fn sandbox_info(&self) -> String {
String::new()
}
/// Refresh git push credentials (e.g. rotate an expiring GitHub App token).
/// Default is a no-op; Daytona overrides to update the remote URL with a fresh token.
async fn refresh_push_credentials(&self) -> Result<(), String> {
Ok(())
}
}
#[cfg(test)]

View file

@ -141,6 +141,8 @@ pub struct DaytonaSandbox {
sandbox: tokio::sync::OnceCell<daytona_sdk::Sandbox>,
rg_available: tokio::sync::OnceCell<bool>,
event_callback: Option<SandboxEventCallback>,
/// HTTPS origin URL stored after clone so we can refresh push credentials later.
origin_url: tokio::sync::OnceCell<String>,
}
impl DaytonaSandbox {
@ -157,6 +159,7 @@ impl DaytonaSandbox {
sandbox: tokio::sync::OnceCell::new(),
rg_available: tokio::sync::OnceCell::const_new(),
event_callback: None,
origin_url: tokio::sync::OnceCell::new(),
}
}
@ -521,6 +524,7 @@ impl Sandbox for DaytonaSandbox {
}
};
let clone_token = password.clone();
let clone_result = git_svc
.clone(
&url,
@ -539,9 +543,40 @@ impl Sandbox for DaytonaSandbox {
let clone_duration =
u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::GitCloneCompleted {
url,
url: url.clone(),
duration_ms: clone_duration,
});
// Store origin URL and set push credentials for later pushes
if let Some(token) = clone_token {
let _ = self.origin_url.set(url);
let process_svc = sandbox.process().await.ok();
if let Some(ps) = process_svc {
let origin = self.origin_url.get().expect("just set");
let auth_url = origin.replacen(
"https://",
&format!("https://x-access-token:{token}@"),
1,
);
let cmd = format!(
"git -c maintenance.auto=0 remote set-url origin '{}'",
auth_url.replace('\'', "'\\''"),
);
let opts = daytona_sdk::ExecuteCommandOptions {
cwd: Some(WORKING_DIRECTORY.to_string()),
..Default::default()
};
let wrapped = wrap_bash_command(&cmd);
if let Ok(r) = ps.execute_command(&wrapped, opts).await {
if r.exit_code != 0 {
tracing::warn!(
exit_code = r.exit_code,
"Failed to set push credentials on origin"
);
}
}
}
}
}
Err(e) if self.github_app.is_none() => {
let err = format!(
@ -657,6 +692,42 @@ impl Sandbox for DaytonaSandbox {
.unwrap_or_default()
}
async fn refresh_push_credentials(&self) -> Result<(), String> {
let origin_url = match self.origin_url.get() {
Some(url) => url,
None => return Ok(()), // no authenticated origin — nothing to refresh
};
let creds = match &self.github_app {
Some(c) => c,
None => return Ok(()),
};
let (owner, repo) = crate::github_app::parse_github_owner_repo(origin_url)
.map_err(|e| format!("Failed to parse origin URL for credential refresh: {e}"))?;
let (_username, password) =
crate::github_app::resolve_clone_credentials(creds, &owner, &repo)
.await
.map_err(|e| format!("Failed to refresh GitHub App token: {e}"))?;
if let Some(token) = password {
let auth_url = origin_url.replacen(
"https://",
&format!("https://x-access-token:{token}@"),
1,
);
let cmd = format!(
"git -c maintenance.auto=0 remote set-url origin '{}'",
auth_url.replace('\'', "'\\''"),
);
self.exec_command(&cmd, 10_000, None, None, None)
.await
.map_err(|e| format!("Failed to set refreshed push credentials: {e}"))?;
}
Ok(())
}
async fn read_file(
&self,
path: &str,

View file

@ -646,6 +646,25 @@ pub async fn git_checkpoint_remote(
}
}
/// Push the run branch to origin inside a remote sandbox (best-effort).
async fn git_push_remote(sandbox: &dyn Sandbox, branch: &str) {
if let Err(e) = sandbox.refresh_push_credentials().await {
tracing::warn!(error = %e, "Failed to refresh push credentials");
}
let cmd = format!("{GIT_REMOTE} push origin {branch}");
match sandbox.exec_command(&cmd, 60_000, None, None, None).await {
Ok(r) if r.exit_code == 0 => {
tracing::info!(branch, "Pushed run branch to origin");
}
Ok(r) => {
tracing::warn!(branch, exit_code = r.exit_code, "Failed to push run branch");
}
Err(e) => {
tracing::warn!(branch, error = %e, "Failed to push run branch");
}
}
}
/// Run a git diff inside a remote sandbox.
async fn git_diff_remote(sandbox: &dyn Sandbox, base: &str) -> Option<String> {
let cmd = format!("{GIT_REMOTE} diff {base} HEAD");
@ -1854,6 +1873,13 @@ impl WorkflowRunEngine {
git_commit_sha: sha.clone(),
});
// Push run branch to origin after remote checkpoint
if matches!(mode, GitCheckpointMode::Remote(_)) {
if let Some(ref branch) = config.run_branch {
git_push_remote(&*self.services.sandbox, branch).await;
}
}
// Save diff.patch for this stage
let prev = last_git_sha
.as_deref()

View file

@ -187,7 +187,7 @@ pub async fn create_installation_access_token(
);
let body = serde_json::json!({
"repositories": [repo],
"permissions": { "contents": "read" }
"permissions": { "contents": "write" }
});
let token_resp = client
@ -436,7 +436,7 @@ mod tests {
.mock("POST", "/app/installations/123/access_tokens")
.match_header("Authorization", "Bearer test-jwt")
.match_body(mockito::Matcher::JsonString(
r#"{"repositories":["repo"],"permissions":{"contents":"read"}}"#.to_string(),
r#"{"repositories":["repo"],"permissions":{"contents":"write"}}"#.to_string(),
))
.with_status(201)
.with_body(r#"{"token": "ghs_xxx"}"#)

View file

@ -1312,3 +1312,127 @@ async fn daytona_iat_not_installed_gives_clear_error() {
"error should mention 'not installed', got: {err}"
);
}
// ---------------------------------------------------------------------------
// Push run branch to origin after each checkpoint (Remote mode + GitHub App)
// ---------------------------------------------------------------------------
/// E2E: After each remote checkpoint, the run branch is pushed to origin.
/// Verifies the branch appears on the remote via `git ls-remote`.
#[tokio::test]
#[ignore]
async fn daytona_git_push_run_branch_to_origin() {
let creds = load_github_app_credentials();
let env = create_env_with_github_app(Some(creds)).await;
env.initialize().await.unwrap();
let env: Arc<dyn Sandbox> = Arc::new(env);
// Install git if not available
let git_check = env
.exec_command("git --version", 10_000, None, None, None)
.await;
if git_check.as_ref().map_or(true, |r| r.exit_code != 0) {
let install = env
.exec_command(
"apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1",
120_000,
None,
None,
None,
)
.await
.expect("apt-get install git should not error");
assert_eq!(
install.exit_code, 0,
"git install failed: {}",
install.stderr
);
}
// Set up git in the sandbox
let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;
// Pipeline: start -> work -> exit
let mut graph = Graph::new("DaytonaGitPush");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test push run branch to origin".to_string()),
);
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut work = Node::new("work");
work.attrs
.insert("label".to_string(), AttrValue::String("Work".to_string()));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), env.clone());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: run_id.clone(),
git_checkpoint: Some(GitCheckpointMode::Remote(dir.path().to_path_buf())),
base_sha: Some(base_sha),
run_branch: Some(branch_name.clone()),
meta_branch: None,
labels: std::collections::HashMap::new(),
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Verify the run branch was pushed to origin
let ls_remote_cmd = format!("git ls-remote --heads origin {branch_name}");
let ls_result = env
.exec_command(&ls_remote_cmd, 30_000, None, None, None)
.await
.expect("git ls-remote should succeed");
assert_eq!(
ls_result.exit_code, 0,
"git ls-remote failed: {}",
ls_result.stdout
);
assert!(
ls_result.stdout.contains(&branch_name),
"run branch should exist on origin after push, got: {}",
ls_result.stdout.trim()
);
// Clean up the remote branch
let delete_cmd = format!("git push origin --delete {branch_name}");
let delete_result = env
.exec_command(&delete_cmd, 30_000, None, None, None)
.await;
if let Ok(r) = &delete_result {
if r.exit_code != 0 {
eprintln!(
"Warning: failed to delete remote branch {branch_name}: {}",
r.stdout
);
}
}
env.cleanup().await.unwrap();
}