Restore GitHub target checkout before workflow execution

This commit is contained in:
Scott Werner 2026-09-29 20:57:09 -04:00
parent 5879ebf099
commit 1443a9a997
11 changed files with 531 additions and 10 deletions

View file

@ -3,6 +3,10 @@
slow-timeout = { period = "1s", terminate-after = 3 }
leak-timeout = "500ms"
[[profile.default.overrides]]
filter = "package(fabro-cli) & test(=scenario::petri_docker::a_git_target_reaches_docker_at_its_pinned_revision_and_checkpoints_edits)"
slow-timeout = { period = "30s", terminate-after = 3 }
[[profile.default.overrides]]
filter = "package(fabro-cli)"
slow-timeout = { period = "6s", terminate-after = 4 }
@ -80,4 +84,3 @@ leak-timeout = "2s"
[[profile.ci.overrides]]
filter = "package(fabro-petri)"
slow-timeout = { period = "30s", terminate-after = 4 }

View file

@ -216,7 +216,10 @@ transfer it to a remote server.
`--target-branch` requires `--target-repo` and accepts a working branch name, not a
tag or SHA. Without it, Fabro resolves the repository's default branch. The CLI
only looks up target metadata; the execution sandbox clones the target.
only looks up target metadata. At run creation the server fetches and retains
the selected revision using its GitHub credentials; a failed checkout rejects
creation. The engine populates the execution workspace from that retained
repository before the first command or agent stage.
The shorthand `--target acme/app@release/v2` selects the working branch
`release/v2`; omit `@BRANCH` to use the remote default branch. Target suffixes
accept working branches, while workflow suffixes accept branches, tags, or SHAs.

View file

@ -18,6 +18,7 @@
reason = "these scenarios inspect backend availability and drive the Docker daemon with its CLI"
)]
use std::collections::BTreeMap;
use std::env;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@ -27,6 +28,7 @@ use fabro_static::EnvVars;
use fabro_test::{
TwinScenario, TwinScenarios, TwinToolCall, expect_reqwest_json, test_context, twin_openai,
};
use fabro_types::{WorkflowPath, WorkflowVersion};
use serde_json::json;
use super::petri::{
@ -48,7 +50,15 @@ async fn docker_server() -> RunningServer {
/// `docker_server`, with `settings` appended to the server's settings and
/// `secrets` in its vault.
async fn docker_server_with(settings: &str, secrets: &[(&str, &str)]) -> RunningServer {
let server = RunningServer::start_with(settings, secrets).await;
docker_server_with_env(settings, secrets, &[]).await
}
async fn docker_server_with_env(
settings: &str,
secrets: &[(&str, &str)],
env: &[(&str, &str)],
) -> RunningServer {
let server = RunningServer::start_with_env(settings, secrets, env).await;
let body = json!({
"id": ENVIRONMENT,
"provider": "docker",
@ -75,6 +85,198 @@ async fn docker_server_with(settings: &str, secrets: &[(&str, &str)]) -> Running
server
}
async fn checkout_version(server: &RunningServer, script: &str, settings: &str) -> String {
let path = |name| WorkflowPath::new(name).expect("the workflow path is valid");
let dot = format!(
"digraph Checkout {{ start [shape=Mdiamond]; verify [shape=parallelogram, goal_gate=true, script={}]; exit [shape=Msquare]; start -> verify -> exit; }}",
serde_json::to_string(script).expect("the script serializes"),
);
let version = WorkflowVersion::new(
path("workflow.fabro"),
BTreeMap::from([
(path("workflow.fabro"), dot),
(
path("workflow.toml"),
format!("_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n{settings}"),
),
]),
BTreeMap::new(),
)
.expect("the checkout workflow version is valid");
let response = fabro_test::test_http_client()
.post(format!("{}/api/v1/workflow-versions", server.api_base_url))
.bearer_auth(TEST_DEV_TOKEN)
.json(&version)
.send()
.await
.expect("the checkout request sends");
expect_reqwest_json(
response,
fabro_http::StatusCode::CREATED,
"register checkout workflow",
)
.await["workflow_version_id"]
.as_str()
.expect("the response names its workflow version")
.to_owned()
}
async fn create_checkout_run(
server: &RunningServer,
version: &str,
target: serde_json::Value,
) -> fabro_http::Response {
fabro_test::test_http_client()
.post(format!("{}/api/v1/runs", server.api_base_url))
.bearer_auth(TEST_DEV_TOKEN)
.json(&json!({"workflow_version_id": version, "environment_id": ENVIRONMENT, "target": target, "args": {"auto_approve": true}}))
.send().await.expect("the run creation request sends")
}
async fn start_checkout_run(server: &RunningServer, id: &str) {
let response = fabro_test::test_http_client()
.post(format!("{}/api/v1/runs/{id}/start", server.api_base_url))
.bearer_auth(TEST_DEV_TOKEN)
.json(&json!({}))
.send()
.await
.expect("the checkout request sends");
fabro_test::expect_reqwest_status(response, fabro_http::StatusCode::OK, "start checkout run")
.await;
}
/// The public create/start path must deliver the requested Git revision before
/// stage one. Redirect only this server's Git transport to a local fixture;
/// the run still names a GitHub target and exercises production acquisition.
#[tokio::test(flavor = "multi_thread")]
async fn a_git_target_reaches_docker_at_its_pinned_revision_and_checkpoints_edits() {
if !fabro_test::docker_available() {
return;
}
let context = test_context!();
let upstream = context.temp_dir.join("upstream");
std::fs::create_dir(&upstream).unwrap();
git(&upstream, &["init", "--initial-branch=main"]);
git(&upstream, &["config", "user.name", "Checkout Test"]);
git(&upstream, &["config", "user.email", "checkout@example.com"]);
git(&upstream, &["config", "commit.gpgsign", "false"]);
std::fs::write(upstream.join("README.md"), "existing repository bytes\n").unwrap();
git(&upstream, &["add", "."]);
git(&upstream, &["commit", "-m", "original"]);
let pin = git(&upstream, &["rev-parse", "HEAD"]);
let blob = git(&upstream, &["hash-object", "README.md"]);
std::fs::write(upstream.join("README.md"), "new branch tip\n").unwrap();
git(&upstream, &["commit", "-am", "move main"]);
let config = context.temp_dir.join("checkout.gitconfig");
let remote = "https://github.com/checkout-fixture/source.git";
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n\tinsteadOf = {remote}\n",
upstream.display()
),
)
.unwrap();
let mut server = docker_server_with_env(
"",
&[(EnvVars::GITHUB_TOKEN, "ghu_checkout_fixture")],
&[("GIT_CONFIG_GLOBAL", config.to_str().unwrap())],
)
.await;
let version = checkout_version(&server, &format!(
"set -eu; test $(git hash-object README.md) = {blob}; git merge-base --is-ancestor {pin} HEAD; test $(git remote get-url origin) = {remote}; printf 'edited existing file\\n' >> README.md"
), "").await;
let response = create_checkout_run(
&server,
&version,
json!({"kind": "git", "repo": "checkout-fixture/source", "branch": "main", "sha": pin}),
)
.await;
let created = expect_reqwest_json(
response,
fabro_http::StatusCode::CREATED,
"create Git target run",
)
.await;
let id = created["id"].as_str().unwrap();
let state = super::petri::run_json(&server, &format!("runs/{id}/state")).await;
assert_eq!(state["spec"]["target"]["sha"], pin);
// Neither a moving remote nor a process restart may change the admitted
// source; starting the run must need only the retained source repository.
server.kill();
std::fs::remove_dir_all(upstream).unwrap();
server.launch().await;
start_checkout_run(&server, id).await;
wait_for_success(&server, id).await;
let repository = snapshot_repository(&server, id);
let commits = snapshot_commits(&repository);
let edit = commits
.iter()
.find(|(_, subject, _)| subject.ends_with(": verify (success)"))
.unwrap();
assert_eq!(
git(&repository, &["show", &format!("{}:README.md", edit.0)]),
"existing repository bytes\nedited existing file"
);
assert!(commits.iter().any(|(sha, _, _)| sha == &pin));
cleanup(id);
let empty = checkout_version(&server, "test -z \"$(git ls-files)\"", "").await;
let response = create_checkout_run(&server, &empty, json!({"kind": "none"})).await;
let created = expect_reqwest_json(
response,
fabro_http::StatusCode::CREATED,
"create empty target run",
)
.await;
let id = created["id"].as_str().unwrap();
start_checkout_run(&server, id).await;
wait_for_success(&server, id).await;
cleanup(id);
let disabled = checkout_version(
&server,
"test -z \"$(git ls-files)\"",
"[run.clone]\nenabled = false\n",
)
.await;
let response = create_checkout_run(
&server,
&disabled,
json!({"kind": "git", "repo": "checkout-fixture/source", "branch": "main"}),
)
.await;
let rejected = expect_reqwest_json(
response,
fabro_http::StatusCode::UNPROCESSABLE_ENTITY,
"reject Git target with checkout disabled",
)
.await;
assert_eq!(
rejected["errors"][0]["code"],
"target_environment_unsupported"
);
let response = create_checkout_run(
&server,
&version,
json!({"kind": "git", "repo": "checkout-fixture/source", "branch": "main"}),
)
.await;
let rejected = expect_reqwest_json(
response,
fabro_http::StatusCode::UNPROCESSABLE_ENTITY,
"reject unavailable Git target",
)
.await;
assert_eq!(rejected["errors"][0]["code"], "target_checkout_failed");
assert_eq!(
rejected["errors"][0]["detail"],
"could not check out checkout-fixture/source; check repository access and the requested revision"
);
server.shutdown();
}
/// The run's container on the daemon, by Petri's run label: the one the
/// run's scope lives in.
fn container_of(run_id: &str) -> Option<String> {

View file

@ -20,6 +20,10 @@ const GIT_REV_PARSE_TIMEOUT: Duration = Duration::from_secs(10);
/// Error returned while preparing a checkout from a git source.
#[derive(thiserror::Error, Debug)]
pub(crate) enum GitCheckoutError {
#[error("failed to initialize the run's source repository")]
Initialize(#[source] GitCommandError),
#[error("failed to check out the run's selected revision")]
Checkout(#[source] GitCommandError),
#[error("failed to create Git cache directory {path}")]
CacheDirectory {
path: PathBuf,
@ -61,6 +65,58 @@ pub(crate) enum GitCheckoutError {
},
}
/// Materialize an independent source repository for Petri's checkout step.
/// It lives with the run, so later cache fetches cannot move its HEAD and a
/// delayed start needs neither credentials nor a still-existing remote ref.
/// Authentication is process-local and never written into the repository.
pub(crate) async fn prepare_run_repository(
target: &GitRunTarget,
clone_url: &str,
auth: Option<&GitAuthConfig>,
depth: i32,
directory: &Path,
) -> Result<String, GitCheckoutError> {
for args in [vec!["init", "--quiet"], vec![
"remote", "add", "origin", clone_url,
]] {
run_git_plan(GitCommandPlan::new(args, GIT_REV_PARSE_TIMEOUT).current_dir(directory))
.await
.map_err(GitCheckoutError::Initialize)?;
}
let selector = GitCheckoutSelector::from(target);
let mut args = vec!["fetch".to_string(), "--no-tags".to_string()];
if depth > 0 {
args.push(format!("--depth={depth}"));
}
// Qualify branches, since a branch and a tag may have the same name.
let revision = match selector {
GitCheckoutSelector::Branch(branch) => format!("refs/heads/{branch}"),
_ => selector.selector().into_owned(),
};
args.extend(["origin".to_string(), "--".to_string(), revision]);
run_git_plan(
GitCommandPlan::new(args, GIT_FETCH_TIMEOUT)
.current_dir(directory)
.with_auth(clone_url, auth),
)
.await
.map_err(|source| selector.checkout_error(source))?;
let sha = run_git_plan(build_rev_parse_fetch_head_plan(directory))
.await
.map_err(|source| GitCheckoutError::ResolveCommit { source })?;
let sha = String::from_utf8_lossy(&sha).trim().to_owned();
run_git_plan(
GitCommandPlan::new(
["checkout", "--quiet", "-B", &target.branch, &sha],
GIT_WORKTREE_ADD_TIMEOUT,
)
.current_dir(directory),
)
.await
.map_err(GitCheckoutError::Checkout)?;
Ok(sha)
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum GitCommandError {
#[error("{command} timed out after {timeout_secs}s")]
@ -952,6 +1008,97 @@ mod tests {
files
}
#[tokio::test]
async fn run_repository_pins_the_selected_revision_and_preserves_history_depth() {
let temp = TempDir::new().unwrap();
let upstream = temp.path().join("upstream.git");
let first = seed_upstream(&upstream);
let seed = temp.path().join("seed");
let git = |directory: &Path, args: &[&str]| {
let output = std::process::Command::new("git")
.current_dir(directory)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
};
fs::write(seed.join("README.md"), "moved\n").unwrap();
git(&seed, &["commit", "-am", "move main"]);
let head = git(&seed, &["rev-parse", "HEAD"]);
git(&seed, &["push", upstream.to_str().unwrap(), "main"]);
let url = format!("file://{}", upstream.display());
for (sha, tag, depth, expected, count) in [
(
Some(first.clone()),
Some("missing".to_string()),
1,
&first,
"1",
),
(None, Some("v1".to_string()), 1, &first, "1"),
(None, None, 1, &head, "1"),
(None, None, 0, &head, "2"),
] {
let directory = TempDir::new().unwrap();
let target = GitRunTarget {
repo: "acme/repo".to_owned(),
branch: "main".to_owned(),
tag,
sha,
};
let actual = prepare_run_repository(&target, &url, None, depth, directory.path())
.await
.unwrap();
assert_eq!(&actual, expected);
assert_eq!(git(directory.path(), &["rev-parse", "HEAD"]), actual);
assert_eq!(
git(directory.path(), &["symbolic-ref", "--short", "HEAD"]),
"main"
);
assert_eq!(
git(directory.path(), &["rev-list", "--count", "HEAD"]),
count
);
assert_eq!(git(directory.path(), &["remote", "get-url", "origin"]), url);
assert!(
!fs::read_to_string(directory.path().join(".git/config"))
.unwrap()
.contains("extraheader")
);
}
}
#[tokio::test]
async fn run_repository_refuses_an_unavailable_revision_with_its_cause() {
let temp = TempDir::new().unwrap();
let upstream = temp.path().join("upstream.git");
seed_upstream(&upstream);
let directory = TempDir::new().unwrap();
let target = GitRunTarget {
repo: "acme/repo".to_owned(),
branch: "missing".to_owned(),
tag: None,
sha: None,
};
let error = prepare_run_repository(
&target,
&format!("file://{}", upstream.display()),
None,
1,
directory.path(),
)
.await
.unwrap_err();
assert!(matches!(error, GitCheckoutError::FetchBranch { .. }));
assert!(Error::source(&error).unwrap().is::<GitCommandError>());
assert!(!directory.path().join("README.md").exists());
}
#[tokio::test]
async fn bare_clone_reused_across_calls() {
let temp = TempDir::new().unwrap();

View file

@ -17,6 +17,7 @@ use thiserror::Error;
use tokio::{fs, task};
use crate::run_compiler::{RunCompilerError, settings_layer_with_resolved_dockerfiles};
use crate::server::run_checkout::RunCheckoutError;
#[derive(Debug, Error)]
pub(crate) enum RunIntentAdmissionError {
@ -35,6 +36,8 @@ pub(crate) enum RunIntentAdmissionError {
Environment(#[from] EnvironmentSelectionError),
#[error(transparent)]
Compiler(#[from] RunCompilerError),
#[error(transparent)]
Checkout(#[from] RunCheckoutError),
#[error("run variables could not be loaded")]
VariableSnapshot {
#[source]

View file

@ -169,6 +169,7 @@ mod handler;
pub(crate) mod petri_runs;
mod pull_request_supervisor;
pub(crate) mod resource_sampler;
pub(crate) mod run_checkout;
pub(crate) mod run_records;
mod session_runtime;
pub(crate) mod stream_follower;

View file

@ -65,6 +65,7 @@ use crate::run_intent::{
use crate::run_manifest;
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary};
use crate::server::run_checkout;
#[cfg(any(test, feature = "test-support"))]
use crate::test_support as server_test_support;
@ -727,6 +728,12 @@ async fn finalize_created_run(
explicit_title_supplied: bool,
title_generation_target: ManifestPath,
) -> Response {
let (prepared, run_id) = prepared.resolve_run_id();
let (prepared, source_repository) =
match Box::pin(run_checkout::prepare(&state, prepared, run_id)).await {
Ok(result) => result,
Err(error) => return run_intent_admission_error(error.into()),
};
// Resolve once: we need both the provider IDs (for the run create input
// and ask-fabro-readiness) and the LLM client itself (for the spawned
// title-generation task). `ready_llm_provider_ids` would otherwise call
@ -749,7 +756,15 @@ async fn finalize_created_run(
// Petri compiles the run: the bundle goes to `Runtime::check`, its
// diagnostics come back in Fabro's shape, and the admitted graph is what
// the run executes. Fabro's own settings resolution ran above.
let pinned = match petri_runs::admit(&state, &prepared, &run_materialization_provider_ids).await
let pinned = match petri_runs::admit(
&state,
&prepared,
&run_materialization_provider_ids,
source_repository
.as_ref()
.map(|directory| directory.path().to_path_buf()),
)
.await
{
Ok(admitted) => run_compiler::materialize_admitted(prepared, admitted).await,
Err(error) => Err(error),
@ -776,6 +791,9 @@ async fn finalize_created_run(
}
};
let created_at = created.run_id.created_at();
if let Some(directory) = source_repository {
let _ = directory.keep();
}
// The run's summary row is the projector's: wait for the pass that
// folds the run's first records before reading the run back.
state.petri_projector.settle(created.run_id).await;
@ -851,6 +869,11 @@ fn intent_error(status: StatusCode, detail: impl Into<String>, code: &'static st
fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
match &error {
RunIntentAdmissionError::Checkout(_) => {
// The curated message identifies the target without printing Git's
// raw output or credential-provider error bodies.
tracing::warn!(error = %error, "Run target checkout failed");
}
RunIntentAdmissionError::VersionStore { .. }
| RunIntentAdmissionError::VariableSnapshot { .. }
| RunIntentAdmissionError::WorkerRun { .. }
@ -877,6 +900,11 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
}
match error {
RunIntentAdmissionError::Checkout(error) => intent_error(
StatusCode::UNPROCESSABLE_ENTITY,
error.to_string(),
"target_checkout_failed",
),
RunIntentAdmissionError::VersionStore { .. } => intent_error(
StatusCode::INTERNAL_SERVER_ERROR,
"workflow version store operation failed",

View file

@ -33,6 +33,7 @@
//! failed when it cannot.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
@ -259,11 +260,13 @@ pub(crate) async fn admit(
state: &AppState,
prepared: &PreparedRun,
eligible: &[ProviderId],
git_repository: Option<PathBuf>,
) -> Result<AdmittedRun, RunCompilerError> {
let settings = prepared.settings();
let repository = match prepared.target() {
Some(RunTarget::Folder { path }) => Some(path.into()),
Some(RunTarget::Git(_) | RunTarget::None {}) | None => None,
Some(RunTarget::Git(_)) => git_repository,
Some(RunTarget::None {}) | None => None,
};
let launch = petri_check::launch(
&state.catalog(),

View file

@ -0,0 +1,102 @@
//! Acquire a Git target on the server; Petri owns delivery to the workspace.
//!
//! The source is an independent repository under the run's scratch directory,
//! pinned before admission. Keep it for delayed starts and recovery, and remove
//! it with the run. A guard removes it if compilation or persistence fails.
use fabro_config::Storage;
use fabro_types::settings::run::RunMode;
use fabro_types::{DirtyStatus, GitContext, GitCoordinateValidationError, RunId, RunTarget};
use tempfile::TempDir;
use tokio::fs;
use crate::git_checkout::{self, GitCheckoutError};
use crate::run_compiler::PreparedRun;
use crate::server::AppState;
#[derive(Debug, thiserror::Error)]
pub(crate) enum RunCheckoutError {
#[error("invalid Git target")]
Target(#[source] GitCoordinateValidationError),
#[error("could not resolve GitHub checkout credentials; check the server's GitHub integration")]
Credentials(#[source] anyhow::Error),
#[error("could not create the run's source directory")]
Directory(#[source] std::io::Error),
#[error("could not check out {repo}; check repository access and the requested revision")]
Checkout {
repo: String,
#[source]
source: GitCheckoutError,
},
}
/// Acquire and pin the target, returning its cleanup guard. Folder targets
/// already have a repository; empty, disabled-clone and simulated runs need
/// no remote access. The caller retains the guard only after persisting the
/// run.
pub(crate) async fn prepare(
state: &AppState,
prepared: PreparedRun,
run_id: RunId,
) -> Result<(PreparedRun, Option<TempDir>), RunCheckoutError> {
let settings = &prepared.settings().run;
let Some(RunTarget::Git(target)) = prepared.target() else {
return Ok((prepared, None));
};
if !settings.clone.enabled || settings.execution.mode == RunMode::DryRun {
return Ok((prepared, None));
}
let validated = target
.clone()
.validate()
.map_err(RunCheckoutError::Target)?;
let repo = validated.repository();
let server_settings = state.server_settings();
let credentials = state
.github_credentials(&server_settings.server.integrations.github)
.await
.map_err(RunCheckoutError::Credentials)?;
let auth = git_checkout::resolve_git_read_auth_config(
credentials.as_ref(),
repo,
&state.github_api_base_url,
state.http_client.clone(),
)
.await
.map_err(RunCheckoutError::Credentials)?;
let scratch = Storage::new(state.server_storage_dir()).run_scratch(&run_id);
fs::create_dir_all(scratch.root())
.await
.map_err(RunCheckoutError::Directory)?;
let directory = tempfile::Builder::new()
.prefix("target-")
.tempdir_in(scratch.root())
.map_err(RunCheckoutError::Directory)?;
let sha = git_checkout::prepare_run_repository(
validated.target(),
&git_checkout::github_clone_url(repo),
auth.as_ref(),
settings.clone.depth,
directory.path(),
)
.await
.map_err(|source| RunCheckoutError::Checkout {
repo: repo.to_string(),
source,
})?;
let origin_url = repo.https_url();
let mut target = validated.into_target();
target.sha = Some(sha);
// Keep the durable target and its Git projection in agreement with the
// source Petri receives, including when the caller selected only a ref.
let git = GitContext {
origin_url,
branch: target.branch.clone(),
sha: target.sha.clone(),
dirty: DirtyStatus::Clean,
};
Ok((
prepared.with_target_and_git(RunTarget::Git(target), Some(git)),
Some(directory),
))
}

View file

@ -3402,7 +3402,7 @@ async fn post_runs_run_intent_derives_workflow_slug_from_immutable_entrypoint()
}
#[tokio::test]
async fn post_runs_run_intent_persists_tagged_exact_git_target_without_starting() {
async fn post_runs_dry_run_persists_tagged_exact_git_target_without_starting() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(
@ -3440,6 +3440,7 @@ file = "goal.md"
"sha": submitted_sha
},
"args": {
"dry_run": true,
"inputs": { "ship": true },
"labels": { "team": "platform" }
},
@ -5035,6 +5036,33 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
build_app_state(config).expect("test app state should build")
}
#[tokio::test]
async fn post_runs_git_target_requires_configured_checkout_credentials() {
let state = create_github_token_app_state(None, None);
let app = crate::test_support::build_test_router(Arc::clone(&state));
let version = store_workflow_version(&state, MINIMAL_DOT, None).await;
let response = post_run_intent_response(
&app,
json!({
"workflow_version_id": version,
"target": {"kind": "git", "repo": "acme/private", "branch": "main"},
"args": {},
}),
)
.await;
let body = fabro_test::expect_axum_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"create Git target without configured token",
)
.await;
assert_eq!(body["errors"][0]["code"], "target_checkout_failed");
assert_eq!(
body["errors"][0]["detail"],
"could not resolve GitHub checkout credentials; check the server's GitHub integration"
);
}
#[tokio::test]
async fn github_token_strategy_ignores_process_env_token() {
let state = create_github_token_app_state_with_env_lookup(None, None, |name| match name {

View file

@ -15,10 +15,11 @@
//! below every file layer, `petri.launch_environment` as the environment
//! the run selected over every file layer, `petri.launch_goal` as the goal
//! the run resolved over every file layer and the graph's own, and
//! `petri.repository` as the repository the root `start` stage checks out. A
//! caller with no local repository binds `null`, and the run starts from an
//! empty workspace. The server's run variables (`{{ vars.NAME }}`) are bound as
//! compile variables beside them.
//! `petri.repository` as the repository the root `start` stage checks out.
//! Git targets bind the server's pinned source repository; folder targets bind
//! their local path. A caller requesting no checkout binds `null`, and the run
//! starts from an empty workspace. The server's run variables (`{{ vars.NAME
//! }}`) are bound as compile variables beside them.
use std::collections::BTreeMap;
use std::path::PathBuf;