Harden automation workflow source handling

This commit is contained in:
Scott Werner 2026-08-31 13:45:08 -04:00
parent 305381e2c2
commit 39c018c430
31 changed files with 444 additions and 145 deletions

View file

@ -554,13 +554,13 @@ export function AutomationFormFields({
/>
</Row>
<Row
title="Different repository"
help="Load workflow files from another saved GitHub coordinate while keeping the run target independent."
title="Separate workflow source"
help="Resolve workflow files from an explicit repository and ref, independent of the run target. The repository may be the same as the target."
>
<ToggleSwitch
checked={values.usesSeparateWorkflowSource}
onChange={(usesSeparateWorkflowSource) => patch({ usesSeparateWorkflowSource })}
label="Use a different workflow repository"
label="Use a separate workflow source"
/>
</Row>
{values.usesSeparateWorkflowSource ? (

View file

@ -310,7 +310,7 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Automation environment")).toBe("");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
expect(switchChecked(renderer, "Use a different workflow repository")).toBe(false);
expect(switchChecked(renderer, "Use a separate workflow source")).toBe(false);
expect(renderer.root.findAllByProps({ "aria-label": "Workflow source repository" })).toHaveLength(0);
});
@ -382,7 +382,7 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Automation environment")).toBe("default");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
expect(switchChecked(renderer, "Use a different workflow repository")).toBe(false);
expect(switchChecked(renderer, "Use a separate workflow source")).toBe(false);
expect(
renderer.root.findAllByProps({ "aria-label": "Cron expression" }),
).toHaveLength(0);
@ -451,7 +451,7 @@ describe("AutomationsNew", () => {
changeField(renderer, "Automation environment", "daytona-smoke");
changeField(renderer, "Workflow slug", "release");
act(() => {
byLabel(renderer, "Use a different workflow repository").props.onChange(true);
byLabel(renderer, "Use a separate workflow source").props.onChange(true);
});
changeField(renderer, "Workflow source repository", " fabro-sh/workflows ");
changeField(renderer, "Workflow source ref", "ABCDEF0123456789ABCDEF0123456789ABCDEF01");

View file

@ -6765,6 +6765,33 @@ components:
are not accepted.
example: main
ResolvedAutomationGitWorkflowSource:
description: >-
Workflow source coordinate and exact commit captured when an
automation run was created. The requested ref remains available for
audit context while `resolved_sha` identifies the immutable source
revision that supplied the workflow bytes.
type: object
additionalProperties: false
required:
- repo
- kind
- ref
- resolved_sha
properties:
repo:
type: string
description: GitHub repository slug in `owner/name` form.
kind:
$ref: "#/components/schemas/AutomationGitWorkflowSourceKind"
ref:
type: string
description: Branch, tag, or commit requested by the automation.
resolved_sha:
type: string
pattern: "^[0-9a-f]{40}$"
description: Exact lowercase Git commit that supplied the workflow bytes.
Automation:
description: Public automation definition.
type: object
@ -12127,6 +12154,11 @@ components:
type: ["string", "null"]
trigger_id:
type: ["string", "null"]
workflow_source:
description: Resolved workflow source for automation runs that declare one.
oneOf:
- $ref: "#/components/schemas/ResolvedAutomationGitWorkflowSource"
- type: "null"
RunOrigin:
type: object

View file

@ -45,7 +45,7 @@ An extensionless workflow such as `"release"` resolves directly to `.fabro/workf
Automation admission does not read `.fabro/project.toml`. Put settings needed by the run in the workflow configuration or the selected server environment. Fabro packages the workflow and its runnable dependencies into immutable workflow versions before creating the run.
### Using a separate workflow repository
### Using a separate workflow source
Omit `workflow_source` to resolve the `workflow` selector in the run-target checkout, as in the request above. This is the compatibility default for existing definitions.
@ -53,7 +53,9 @@ To keep reusable workflow files in another repository, provide an explicit sourc
```json title="Create automation with a separate workflow source"
{
"id": "nightly-release",
"name": "Nightly release",
"environment_id": "default",
"target": {
"kind": "git",
"repo": "acme/orders-api",
@ -71,9 +73,9 @@ To keep reusable workflow files in another repository, provide an explicit sourc
}
```
`kind` may be `branch`, `tag`, or `commit`. Branch and tag refs are bare names and are resolved again on every firing. A commit ref is exactly 40 hexadecimal characters and always selects that commit. The server uses its configured GitHub credentials independently for the target and workflow-source repositories; automation requests never carry credentials.
`kind` may be `branch`, `tag`, or `commit`. Branch and tag refs are bare names and are resolved again on every firing. A commit ref is exactly 40 hexadecimal characters and always selects that commit. The server uses its configured GitHub credentials independently for the target and workflow-source repositories, with read-only repository access for workflow materialization; automation requests never carry credentials.
Fabro resolves the run target to an exact commit and checks out the selected workflow source before it creates a run. It packages the workflow and its dependencies into immutable, content-addressed workflow versions, then admits the run with the root workflow-version ID and the independently exact run target. The mutable source coordinate is therefore resolved per firing, while the bytes used by that run remain pinned. If an explicit source names the same repository and effective selector as the target, Fabro reuses the checkout without removing the explicit saved source.
Fabro resolves the run target to an exact commit and checks out the selected workflow source before it creates a run. It packages the workflow and its dependencies into immutable, content-addressed workflow versions, then admits the run with the root workflow-version ID and the independently exact run target. The run's automation metadata records both the requested workflow-source coordinate and its resolved commit, so the mutable source can be audited after its branch or tag moves. If an explicit source names the same repository and effective selector as the target, Fabro reuses the checkout without removing the explicit saved source.
If target or source authentication, checkout, workflow discovery, packaging, or workflow-version storage fails, Fabro creates no run and sends no start request.

View file

@ -5,15 +5,15 @@ use async_trait::async_trait;
use fabro_automation::{AutomationGitWorkflowSource, AutomationId, AutomationValidationError};
use fabro_manifest::WorkflowVersionCollectError;
use fabro_types::{
GitHubRepositorySlug, GitRunTarget, RunId, RunIntent, RunIntentArgs, RunTarget,
TargetValidationError, WorkflowVersionId,
GitHubRepositorySlug, GitRunTarget, ResolvedAutomationGitWorkflowSource, RunId, RunIntent,
RunIntentArgs, RunTarget, TargetValidationError, WorkflowVersionId,
};
use fabro_workflow_version::{WorkflowVersionStore, WorkflowVersionStoreError};
use tokio::{fs, task};
use crate::git_checkout::{
GitAuthConfig, GitCheckoutError, GitCheckoutSelector, GitRepoCache, WorktreePrepareInput,
github_clone_url, resolve_git_auth_config,
github_clone_url, resolve_git_read_auth_config,
};
#[derive(Debug, Clone, PartialEq, Eq)]
@ -30,6 +30,7 @@ pub(crate) struct AutomationRunMaterializeInput {
pub(crate) struct AutomationRunMaterialized {
pub workflow_version_id: WorkflowVersionId,
pub target: GitRunTarget,
pub workflow_source: Option<Box<ResolvedAutomationGitWorkflowSource>>,
}
impl AutomationRunMaterialized {
@ -150,7 +151,7 @@ struct ServerGitHubRemoteResolver {
#[async_trait]
impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver {
async fn resolve(&self, repo: &GitHubRepositorySlug) -> anyhow::Result<GitRemote> {
let auth = resolve_git_auth_config(
let auth = resolve_git_read_auth_config(
self.credentials.as_ref(),
repo,
&self.api_base_url,
@ -299,8 +300,8 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
&target_checkout_dir,
)
.await?;
let workflow_checkout_dir = match separate_source {
None => target_checkout_dir,
let (workflow_checkout_dir, workflow_checkout_sha) = match separate_source {
None => (target_checkout_dir, checked_out_sha.clone()),
Some((repo, source)) => {
let remote = if repo == target_repo {
target_remote
@ -309,18 +310,27 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
.await?
};
let source_checkout_dir = temp_dir.path().join("workflow-source");
self.prepare_checkout(
CheckoutRole::WorkflowSource,
&repo,
&remote,
GitCheckoutSelector::from(source),
&source_checkout_dir,
)
.await?;
source_checkout_dir
let source_sha = self
.prepare_checkout(
CheckoutRole::WorkflowSource,
&repo,
&remote,
GitCheckoutSelector::from(source),
&source_checkout_dir,
)
.await?;
(source_checkout_dir, source_sha)
}
};
exact_target.sha = Some(checked_out_sha);
let resolved_workflow_source = workflow_source.map(|source| {
Box::new(ResolvedAutomationGitWorkflowSource {
repo: source.repo,
kind: source.kind,
reference: source.reference,
resolved_sha: workflow_checkout_sha,
})
});
let workflow = PathBuf::from(input.workflow);
let closure = task::spawn_blocking(move || {
@ -346,6 +356,7 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
Ok(AutomationRunMaterialized {
workflow_version_id: closure.root_id(),
target: exact_target,
workflow_source: resolved_workflow_source,
})
}
}
@ -494,6 +505,14 @@ impl AutomationRunMaterializer for TestAutomationRunMaterializer {
&self,
input: AutomationRunMaterializeInput,
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
let workflow_source = input.workflow_source.as_ref().map(|source| {
Box::new(ResolvedAutomationGitWorkflowSource {
repo: source.repo.clone(),
kind: source.kind,
reference: source.reference.clone(),
resolved_sha: "ffffffffffffffffffffffffffffffffffffffff".to_string(),
})
});
let response = {
let mut guard = self
.inner
@ -522,6 +541,7 @@ impl AutomationRunMaterializer for TestAutomationRunMaterializer {
Ok(AutomationRunMaterialized {
workflow_version_id,
target: materialized.target,
workflow_source,
})
}
}
@ -860,6 +880,7 @@ mod tests {
materialized.target.sha.as_deref(),
Some(target_fixture.initial_sha.as_str())
);
assert_eq!(materialized.workflow_source, None);
let version = store
.get(&materialized.workflow_version_id)
.await
@ -917,6 +938,15 @@ mod tests {
materialized.target.sha.as_deref(),
Some(target_fixture.initial_sha.as_str())
);
assert_eq!(
materialized.workflow_source,
Some(Box::new(ResolvedAutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind: AutomationGitWorkflowSourceKind::Branch,
reference: "main".to_string(),
resolved_sha: source_fixture.initial_sha.clone(),
}))
);
let version = store
.get(&materialized.workflow_version_id)
.await
@ -947,7 +977,7 @@ mod tests {
HashMap::from([(repo, fixture.bare.to_string_lossy().into_owned())]),
);
materializer
let materialized = materializer
.materialize(input(
"Fabro-Sh/Shared",
Some(source(
@ -960,6 +990,15 @@ mod tests {
.await
.unwrap();
assert_eq!(
materialized.workflow_source,
Some(Box::new(ResolvedAutomationGitWorkflowSource {
repo: "fabro-sh/shared".to_string(),
kind: AutomationGitWorkflowSourceKind::Branch,
reference: "main".to_string(),
resolved_sha: fixture.initial_sha,
}))
);
assert_eq!(resolver.repositories(), vec!["Fabro-Sh/Shared"]);
}

View file

@ -323,7 +323,7 @@ impl GitAuthConfig {
}
}
pub(crate) async fn resolve_git_auth_config(
pub(crate) async fn resolve_git_read_auth_config(
credentials: Option<&fabro_github::GitHubCredentials>,
repo: &GitHubRepositorySlug,
github_api_base_url: &str,
@ -339,7 +339,8 @@ pub(crate) async fn resolve_git_auth_config(
None => fabro_github::GitHubContext::new(credentials, github_api_base_url),
};
let (username, password) =
fabro_github::resolve_clone_credentials(&context, repo.owner(), repo.repo()).await?;
fabro_github::resolve_read_only_clone_credentials(&context, repo.owner(), repo.repo())
.await?;
Ok(Some(GitAuthConfig::new(username, password)))
}

View file

@ -1025,9 +1025,10 @@ include = ["reports/{{ vars.path }}/*.json"]
let run_id = RunId::new();
let parent_id = RunId::new();
let automation = AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule".to_string()),
workflow_source: None,
};
let submitted = b"submitted manifest".to_vec();
let workflow_version_id = fabro_types::test_support::test_workflow_version_id();

View file

@ -287,9 +287,10 @@ async fn fire_scheduled_automation_run(
system_kind: SystemActorKind::Engine,
};
let automation_ref = AutomationRef {
id: automation_id.to_string(),
name: Some(automation.name.clone()),
trigger_id: Some(trigger_id.to_string()),
id: automation_id.to_string(),
name: Some(automation.name.clone()),
trigger_id: Some(trigger_id.to_string()),
workflow_source: materialized.workflow_source.clone(),
};
// RunIntent admission produces a large future; box it to keep our
// stack frame small (matches handler/automations.rs).
@ -395,7 +396,7 @@ mod tests {
};
use fabro_static::EnvVars;
use fabro_store::ListRunsQuery;
use fabro_types::{GitRunTarget, RunStatus, RunTarget};
use fabro_types::{GitRunTarget, ResolvedAutomationGitWorkflowSource, RunStatus, RunTarget};
use super::*;
use crate::test_support::{TestAppStateBuilder, TestAutomationRunMaterializer};
@ -741,8 +742,21 @@ mod tests {
let captured = materializer.captured_inputs();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].workflow_source, Some(workflow_source));
assert_eq!(cached_runs(state.as_ref()).await.len(), 1);
assert_eq!(captured[0].workflow_source, Some(workflow_source.clone()));
let runs = cached_runs(state.as_ref()).await;
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0]
.automation
.as_ref()
.and_then(|automation| automation.workflow_source.clone()),
Some(Box::new(ResolvedAutomationGitWorkflowSource {
repo: workflow_source.repo,
kind: workflow_source.kind,
reference: workflow_source.reference,
resolved_sha: "ffffffffffffffffffffffffffffffffffffffff".to_string(),
}))
);
}
#[tokio::test]

View file

@ -154,9 +154,10 @@ async fn create_automation_run(
}
};
let automation_ref = AutomationRef {
id: automation.id.to_string(),
name: Some(automation.name.clone()),
trigger_id: Some(api_trigger_id),
id: automation.id.to_string(),
name: Some(automation.name.clone()),
trigger_id: Some(api_trigger_id),
workflow_source: materialized.workflow_source.clone(),
};
let response = Box::pin(runs::create_run_from_intent(

View file

@ -4452,9 +4452,10 @@ async fn create_run_from_manifest_helper_persists_automation_metadata_and_exact_
let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap();
let run_id = RunId::new();
let automation = fabro_types::AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule".to_string()),
workflow_source: None,
};
let target = RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
@ -4511,9 +4512,10 @@ async fn create_run_from_intent_helper_persists_automation_version_and_exact_tar
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let run_id = RunId::new();
let automation = fabro_types::AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule".to_string()),
workflow_source: None,
};
let target = RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),

View file

@ -1052,7 +1052,7 @@ async fn api_triggered_run_passes_saved_workflow_source_to_materialization() {
});
create_automation_with_body(&app, &body).await;
create_automation_run(&app, "nightly", StatusCode::CREATED).await;
let created = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
let captured = materializer.captured_workflow_sources();
assert_eq!(captured.len(), 1);
@ -1064,6 +1064,15 @@ async fn api_triggered_run_passes_saved_workflow_source_to_materialization() {
reference: "release-v1".to_string(),
})
);
assert_eq!(
created["automation"]["workflow_source"],
json!({
"repo": "fabro-sh/workflows",
"kind": "tag",
"ref": "release-v1",
"resolved_sha": "ffffffffffffffffffffffffffffffffffffffff"
})
);
}
#[tokio::test]

View file

@ -5,15 +5,14 @@ mod model;
mod store;
pub use error::{AutomationStoreError, AutomationValidationError};
pub use fabro_types::GitHubRepositorySlug;
pub use fabro_types::{AutomationGitWorkflowSourceKind, GitHubRepositorySlug};
pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId};
pub use migrations::{
EnvironmentSelectorBackfillReport, ImportReport, backfill_environment_selectors,
import_legacy_directory_once,
};
pub use model::{
ApiTrigger, Automation, AutomationDraft, AutomationGitWorkflowSource,
AutomationGitWorkflowSourceKind, AutomationReplace, AutomationTrigger, ScheduleTrigger,
parse_schedule_expression,
ApiTrigger, Automation, AutomationDraft, AutomationGitWorkflowSource, AutomationReplace,
AutomationTrigger, ScheduleTrigger, parse_schedule_expression,
};
pub use store::AutomationStore;

View file

@ -5,8 +5,8 @@ use croner::Cron;
use croner::errors::CronError;
use croner::parser::{CronParser, Seconds, Year};
use fabro_types::{
GitHubRepositorySlug, GitRunTarget, RunTarget, is_valid_git_branch_name, is_valid_git_tag_name,
normalize_git_commit_sha,
AutomationGitWorkflowSourceKind, GitHubRepositorySlug, GitRunTarget, RunTarget,
is_valid_git_branch_name, is_valid_git_tag_name, normalize_git_commit_sha,
};
use serde::{Deserialize, Serialize};
@ -151,26 +151,6 @@ impl Automation {
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AutomationGitWorkflowSourceKind {
Branch,
Tag,
Commit,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationGitWorkflowSource {

View file

@ -353,19 +353,20 @@ async fn corrupt_workflow_source_rows_are_rejected_as_stored_shape_errors() {
let partial = store.create(draft("partial", true)).await.unwrap();
let unknown = store.create(draft("unknown", true)).await.unwrap();
let mut connection = database.pool().acquire().await.unwrap();
sqlx::query("DROP TRIGGER automation_workflow_source_all_or_none_update")
.execute(database.pool())
.execute(&mut *connection)
.await
.unwrap();
sqlx::query("PRAGMA ignore_check_constraints = ON")
.execute(database.pool())
.execute(&mut *connection)
.await
.unwrap();
sqlx::query(
"UPDATE automations SET workflow_source_repository = 'fabro-sh/workflows' WHERE id = ?",
)
.bind(partial.id.as_str())
.execute(database.pool())
.execute(&mut *connection)
.await
.unwrap();
sqlx::query(
@ -373,9 +374,10 @@ async fn corrupt_workflow_source_rows_are_rejected_as_stored_shape_errors() {
workflow_source_kind = 'unknown', workflow_source_ref = 'main' WHERE id = ?",
)
.bind(unknown.id.as_str())
.execute(database.pool())
.execute(&mut *connection)
.await
.unwrap();
drop(connection);
assert!(matches!(
store.get(&partial.id).await.unwrap_err(),

View file

@ -1281,27 +1281,57 @@ pub async fn resolve_clone_credentials(
GitHubCredentials::Installation(token) => token.valid_token()?.to_string(),
GitHubCredentials::App(_) => {
let client = ctx.http_client()?;
mint_git_contents_write_token(&client, ctx, owner, repo).await?
mint_git_token(
&client,
ctx,
owner,
repo,
serde_json::json!({ "contents": "write" }),
)
.await?
}
};
Ok((Some("x-access-token".to_string()), Some(token)))
}
/// Mint an installation token scoped to repository contents writes.
async fn mint_git_contents_write_token(
/// Resolve credentials for fetching repository contents without granting a
/// GitHub App token permission to push.
///
/// Static PATs and pre-minted installation tokens retain their configured
/// permissions. App credentials mint a repository-scoped token with
/// `contents: read`.
pub async fn resolve_read_only_clone_credentials(
ctx: &GitHubContext<'_>,
owner: &str,
repo: &str,
) -> anyhow::Result<(Option<String>, Option<String>)> {
let token = match ctx.creds {
GitHubCredentials::Pat(token) => token.clone(),
GitHubCredentials::Installation(token) => token.valid_token()?.to_string(),
GitHubCredentials::App(_) => {
let client = ctx.http_client()?;
mint_git_token(
&client,
ctx,
owner,
repo,
serde_json::json!({ "contents": "read" }),
)
.await?
}
};
Ok((Some("x-access-token".to_string()), Some(token)))
}
async fn mint_git_token(
client: &impl HttpClient,
ctx: &GitHubContext<'_>,
owner: &str,
repo: &str,
permissions: serde_json::Value,
) -> anyhow::Result<String> {
ctx.creds
.resolve_bearer_token(
client,
owner,
repo,
ctx.base_url,
serde_json::json!({ "contents": "write" }),
)
.resolve_bearer_token(client, owner, repo, ctx.base_url, permissions)
.await
}
@ -2547,6 +2577,24 @@ mod tests {
);
}
#[tokio::test]
async fn resolve_read_only_clone_credentials_returns_static_token_unchanged() {
let creds = GitHubCredentials::Pat("ghu_test".to_string());
let credentials =
resolve_read_only_clone_credentials(&GitHubContext::new(&creds, ""), "owner", "repo")
.await
.unwrap();
assert_eq!(
credentials,
(
Some("x-access-token".to_string()),
Some("ghu_test".to_string())
)
);
}
#[tokio::test]
async fn clone_token_requests_only_contents_write() {
let mock = MockHttpClient::new()
@ -2569,9 +2617,50 @@ mod tests {
slug: None,
});
let context = GitHubContext::new(&credentials, "");
let token = mint_git_contents_write_token(&mock, &context, "owner", "repo")
.await
.unwrap();
let token = mint_git_token(
&mock,
&context,
"owner",
"repo",
serde_json::json!({ "contents": "write" }),
)
.await
.unwrap();
assert_eq!(token, "ghs_xxx");
}
#[tokio::test]
async fn read_only_clone_token_requests_only_contents_read() {
let mock = MockHttpClient::new()
.on(
HttpMethod::Get,
"/repos/owner/repo/installation",
200,
r#"{"id": 123}"#,
)
.on(
HttpMethod::Post,
"/app/installations/123/access_tokens",
201,
r#"{"token": "ghs_xxx", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.with_req_body(r#"{"permissions":{"contents":"read"},"repositories":["repo"]}"#);
let credentials = GitHubCredentials::App(GitHubAppCredentials {
app_id: "test".to_string(),
private_key_pem: test_rsa_key().to_string(),
slug: None,
});
let context = GitHubContext::new(&credentials, "");
let token = mint_git_token(
&mock,
&context,
"owner",
"repo",
serde_json::json!({ "contents": "read" }),
)
.await
.unwrap();
assert_eq!(token, "ghs_xxx");
}

View file

@ -2473,9 +2473,10 @@ mod tests {
#[test]
fn run_created_projects_automation_into_spec_and_summary() {
let automation = AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: None,
};
let event = test_raw_event(
1,

View file

@ -1898,15 +1898,17 @@ mod tests {
let mut first = projection(first_id, "bravo", created_at);
first.spec.automation = Some(AutomationRef {
id: "nightly".to_string(),
name: None,
trigger_id: None,
id: "nightly".to_string(),
name: None,
trigger_id: None,
workflow_source: None,
});
let mut second = projection(second_id, "alpha", created_at);
second.spec.automation = Some(AutomationRef {
id: "nightly".to_string(),
name: None,
trigger_id: None,
id: "nightly".to_string(),
name: None,
trigger_id: None,
workflow_source: None,
});
let mut archived = projection(archived_id, "charlie", created_at);
archived.archived_at = Some(created_at);
@ -1955,9 +1957,10 @@ mod tests {
let run_id = run_id(created_at.timestamp_millis().cast_unsigned(), 1);
let mut projection = projection(run_id, "billed", created_at);
projection.spec.automation = Some(AutomationRef {
id: "nightly".to_string(),
name: None,
trigger_id: None,
id: "nightly".to_string(),
name: None,
trigger_id: None,
workflow_source: None,
});
projection.status = RunStatus::Succeeded {
reason: SuccessReason::Completed,

View file

@ -2850,9 +2850,10 @@ mod tests {
subject: user_principal("alice"),
};
let automation = AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: None,
};
let workflow_version_id = test_support::test_workflow_version_id();

View file

@ -1660,9 +1660,10 @@ reasoning = false
let dir = tempfile::tempdir().unwrap();
let storage_root = dir.path().join("storage");
let automation = AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: None,
};
let request = CreateRunInput {
workflow: WorkflowInput::DotSource {
@ -1806,9 +1807,10 @@ reasoning = false
let compiled_source = MINIMAL_DOT.replace("Build feature", "Compiled goal");
std::fs::write(&dot_path, &compiled_source).unwrap();
let automation = AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: None,
};
let request = CreateRunInput {
workflow: WorkflowInput::Path(dot_path.clone()),
@ -2509,9 +2511,10 @@ reasoning = false
None,
));
let automation = fabro_types::AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: None,
};
let created = create(
store.as_ref(),

View file

@ -695,7 +695,7 @@ fn main() {
),
(
"AutomationGitWorkflowSourceKind",
"fabro_automation::AutomationGitWorkflowSourceKind",
"fabro_types::AutomationGitWorkflowSourceKind",
&[],
),
("AutomationRef", "fabro_types::AutomationRef", &[]),

View file

@ -16,8 +16,7 @@ mod generated {
pub mod types {
pub use fabro_automation::{
Automation, AutomationDraft as CreateAutomationRequest, AutomationGitWorkflowSource,
AutomationGitWorkflowSourceKind, AutomationReplace as ReplaceAutomationRequest,
AutomationTrigger,
AutomationReplace as ReplaceAutomationRequest, AutomationTrigger,
};
pub use fabro_environment::Environment;
pub use fabro_model::{
@ -46,13 +45,13 @@ pub mod types {
pub use fabro_types::{
ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource,
AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary,
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
FailureSignature, GitContext, GitRunTarget, IdpIdentity, IntegrationConnectionKind,
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind,
McpServerDraft as CreateMcpServerRequest, McpServerProjection,
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationGitWorkflowSourceKind,
AutomationRef, BilledTokenCounts, BlobHash, CommandTermination, Conclusion, ContentPart,
CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail,
FailureCategory, FailureDetail, FailureSignature, GitContext, GitRunTarget, IdpIdentity,
IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus,
IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord,
LlmOutputKind, McpServerDraft as CreateMcpServerRequest, McpServerProjection,
McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer,
McpTransportView, Message, PairId, PairMessageId, PairMessageRecord, PairMessageRequest,
PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry,

View file

@ -9,8 +9,9 @@ use fabro_api::types::{
};
use fabro_types::status::{RunStatus, SuccessReason};
use fabro_types::{
AskFabro, AskFabroUnavailableReason, AutomationRef, DiffSummary, PullRequestLink,
RepositoryProvider, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary,
AskFabro, AskFabroUnavailableReason, AutomationGitWorkflowSourceKind, AutomationRef,
DiffSummary, PullRequestLink, RepositoryProvider, RepositoryRef,
ResolvedAutomationGitWorkflowSource, Run, RunApproval, RunApprovalState, RunBillingSummary,
RunId, RunLifecycle, RunLinks, RunOrigin, RunRunnableSource, RunSize, RunTimestamps, RunTiming,
WorkflowRef, fixtures, test_support,
};
@ -79,9 +80,15 @@ fn run_summary_json_matches_openapi_shape() {
edge_count: 9,
},
automation: Some(AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: Some(Box::new(ResolvedAutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind: AutomationGitWorkflowSourceKind::Commit,
reference: "0123456789abcdef0123456789abcdef01234567".to_string(),
resolved_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
})),
}),
repository: Some(RepositoryRef {
name: "fabro".to_string(),
@ -154,7 +161,13 @@ fn run_summary_json_matches_openapi_shape() {
"automation": {
"id": "nightly",
"name": "Nightly",
"trigger_id": "schedule_1"
"trigger_id": "schedule_1",
"workflow_source": {
"repo": "fabro-sh/workflows",
"kind": "commit",
"ref": "0123456789abcdef0123456789abcdef01234567",
"resolved_sha": "0123456789abcdef0123456789abcdef01234567"
}
},
"repository": {
"name": "fabro",

View file

@ -114,8 +114,9 @@ pub use pull_request::{
};
pub use reasoning::ReasoningOutput;
pub use repository::{
GitHubRepositorySlug, GitHubRepositorySlugError, RepositoryProvider, RepositoryRef,
is_valid_git_branch_name, is_valid_git_tag_name, normalize_git_commit_sha,
AutomationGitWorkflowSourceKind, GitHubRepositorySlug, GitHubRepositorySlugError,
RepositoryProvider, RepositoryRef, is_valid_git_branch_name, is_valid_git_tag_name,
normalize_git_commit_sha,
};
pub use run::{
DirtyStatus, ForkSourceRef, GitContext, RunClientProvenance, RunProvenance,
@ -147,9 +148,9 @@ pub use run_sandbox::{
RunSandboxRuntime,
};
pub use run_summary::{
AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunApproval, RunApprovalState,
RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind,
RunSize, RunTimestamps, WorkflowRef,
AskFabro, AskFabroUnavailableReason, AutomationRef, ResolvedAutomationGitWorkflowSource, Run,
RunApproval, RunApprovalState, RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel,
RunOrigin, RunOriginKind, RunSize, RunTimestamps, WorkflowRef,
};
pub use run_title::{
MAX_RUN_TITLE_CHARS, RunTitleError, infer_run_title, normalize_explicit_run_title,

View file

@ -5,6 +5,26 @@ use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AutomationGitWorkflowSourceKind {
Branch,
Tag,
Commit,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryRef {
pub name: String,

View file

@ -3,6 +3,7 @@ use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::repository::AutomationGitWorkflowSourceKind;
use crate::{
DiffSummary, InterviewQuestionRecord, Principal, PullRequestLink, RepositoryRef,
RunControlAction, RunId, RunSandbox, RunStatus, RunTiming,
@ -113,13 +114,29 @@ impl WorkflowRef {
}
}
/// Requested workflow-source coordinate and the immutable commit selected for
/// one automation run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResolvedAutomationGitWorkflowSource {
pub repo: String,
pub kind: AutomationGitWorkflowSourceKind,
#[serde(rename = "ref")]
pub reference: String,
pub resolved_sha: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutomationRef {
pub id: String,
pub id: String,
#[serde(default)]
pub name: Option<String>,
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_id: Option<String>,
pub trigger_id: Option<String>,
/// Boxed because this metadata is uncommon and run specs cross many async
/// server boundaries where their inline size matters.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_source: Option<Box<ResolvedAutomationGitWorkflowSource>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -8,7 +8,8 @@ use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
use fabro_types::{
AutomationRef, EventBody, GitRunTarget, RunTarget, TurnId, WorkflowSettings, fixtures,
AutomationGitWorkflowSourceKind, AutomationRef, EventBody, GitRunTarget,
ResolvedAutomationGitWorkflowSource, RunTarget, TurnId, WorkflowSettings, fixtures,
};
fn templated_settings() -> WorkflowSettings {
@ -35,9 +36,15 @@ fn run_created_props_round_trip_templated_settings() {
sha: None,
})),
automation: Some(AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: Some(Box::new(ResolvedAutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind: AutomationGitWorkflowSourceKind::Tag,
reference: "v1".to_string(),
resolved_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
})),
}),
provenance: test_run_provenance(),
manifest_blob: None,
@ -78,6 +85,12 @@ fn run_created_props_round_trip_templated_settings() {
assert_eq!(json["parent_id"], fixtures::RUN_2.to_string());
assert_eq!(json["automation"]["id"], "nightly");
assert_eq!(json["automation"]["trigger_id"], "schedule_1");
assert_eq!(json["automation"]["workflow_source"]["kind"], "tag");
assert_eq!(json["automation"]["workflow_source"]["ref"], "v1");
assert_eq!(
json["automation"]["workflow_source"]["resolved_sha"],
"0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(
json["workflow_version_id"],
test_workflow_version_id().to_string()

View file

@ -5,7 +5,10 @@ use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, RunSpec};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
use fabro_types::{AutomationRef, GitRunTarget, RunTarget, WorkflowSettings, fixtures};
use fabro_types::{
AutomationGitWorkflowSourceKind, AutomationRef, GitRunTarget,
ResolvedAutomationGitWorkflowSource, RunTarget, WorkflowSettings, fixtures,
};
fn templated_settings() -> WorkflowSettings {
let mut settings = WorkflowSettings::default();
@ -29,9 +32,15 @@ fn run_spec_round_trips_templated_settings() {
sha: Some("abc123".to_string()),
})),
automation: Some(AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: Some(Box::new(ResolvedAutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind: AutomationGitWorkflowSourceKind::Branch,
reference: "main".to_string(),
resolved_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
})),
}),
source_directory: Some("/Users/client/project".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
@ -66,6 +75,11 @@ fn run_spec_round_trips_templated_settings() {
assert_eq!(json["fork_source_ref"]["checkpoint_sha"], "def456");
assert_eq!(json["automation"]["id"], "nightly");
assert_eq!(json["automation"]["trigger_id"], "schedule_1");
assert_eq!(json["automation"]["workflow_source"]["ref"], "main");
assert_eq!(
json["automation"]["workflow_source"]["resolved_sha"],
"0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(
json["workflow_version_id"],
test_workflow_version_id().to_string()

View file

@ -337,6 +337,7 @@ models/replace-mcp-server-request.ts
models/repo-check-response-permissions.ts
models/repo-check-response.ts
models/repository-ref.ts
models/resolved-automation-git-workflow-source.ts
models/review-target-kind.ts
models/review-target.ts
models/rewind-request.ts

View file

@ -13,9 +13,13 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ResolvedAutomationGitWorkflowSource } from './resolved-automation-git-workflow-source';
export interface AutomationRef {
'id': string;
'name': string | null;
'trigger_id'?: string | null;
'workflow_source'?: ResolvedAutomationGitWorkflowSource | null;
}

View file

@ -306,6 +306,7 @@ export * from './replace-mcp-server-request';
export * from './repo-check-response';
export * from './repo-check-response-permissions';
export * from './repository-ref';
export * from './resolved-automation-git-workflow-source';
export * from './review-target';
export * from './review-target-kind';
export * from './rewind-request';

View file

@ -0,0 +1,37 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationGitWorkflowSourceKind } from './automation-git-workflow-source-kind';
/**
* Workflow source coordinate and exact commit captured when an automation run was created. The requested ref remains available for audit context while `resolved_sha` identifies the immutable source revision that supplied the workflow bytes.
*/
export interface ResolvedAutomationGitWorkflowSource {
/**
* GitHub repository slug in `owner/name` form.
*/
'repo': string;
'kind': AutomationGitWorkflowSourceKind;
/**
* Branch, tag, or commit requested by the automation.
*/
'ref': string;
/**
* Exact lowercase Git commit that supplied the workflow bytes.
*/
'resolved_sha': string;
}