From 39c018c430a37420482c85f2f6bf35da5c7262c6 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Mon, 31 Aug 2026 13:45:08 -0400 Subject: [PATCH] Harden automation workflow source handling --- .../app/components/automation-form.tsx | 6 +- .../app/routes/automations-new.test.tsx | 6 +- docs/public/api-reference/fabro-api.yaml | 32 +++++ docs/public/execution/automations.mdx | 8 +- .../src/automation_materializer.rs | 71 ++++++++--- lib/apps/fabro-server/src/git_checkout.rs | 5 +- lib/apps/fabro-server/src/run_compiler.rs | 7 +- .../src/server/automation_scheduler.rs | 26 +++- .../src/server/handler/automations.rs | 7 +- lib/apps/fabro-server/src/server/tests.rs | 14 ++- .../fabro-server/tests/it/api/automations.rs | 11 +- lib/components/fabro-automation/src/lib.rs | 7 +- lib/components/fabro-automation/src/model.rs | 24 +--- .../fabro-automation/tests/store.rs | 10 +- lib/components/fabro-github/src/lib.rs | 115 ++++++++++++++++-- lib/components/fabro-store/src/run_state.rs | 7 +- .../fabro-store/src/run_summary_store.rs | 21 ++-- .../fabro-workflow/src/event/convert.rs | 7 +- .../fabro-workflow/src/operations/create.rs | 21 ++-- lib/foundation/fabro-api/build.rs | 2 +- lib/foundation/fabro-api/src/lib.rs | 17 ++- .../fabro-api/tests/run_summary_round_trip.rs | 25 +++- lib/foundation/fabro-types/src/lib.rs | 11 +- lib/foundation/fabro-types/src/repository.rs | 20 +++ lib/foundation/fabro-types/src/run_summary.rs | 23 +++- .../fabro-types/tests/run_event_serde.rs | 21 +++- .../fabro-types/tests/run_spec_serde.rs | 22 +++- .../src/.openapi-generator/FILES | 1 + .../src/models/automation-ref.ts | 4 + .../fabro-api-client/src/models/index.ts | 1 + ...resolved-automation-git-workflow-source.ts | 37 ++++++ 31 files changed, 444 insertions(+), 145 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/resolved-automation-git-workflow-source.ts diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx index 77bc90903..de8db2629 100644 --- a/apps/fabro-web/app/components/automation-form.tsx +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -554,13 +554,13 @@ export function AutomationFormFields({ /> patch({ usesSeparateWorkflowSource })} - label="Use a different workflow repository" + label="Use a separate workflow source" /> {values.usesSeparateWorkflowSource ? ( diff --git a/apps/fabro-web/app/routes/automations-new.test.tsx b/apps/fabro-web/app/routes/automations-new.test.tsx index dffc74f6c..73608521c 100644 --- a/apps/fabro-web/app/routes/automations-new.test.tsx +++ b/apps/fabro-web/app/routes/automations-new.test.tsx @@ -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"); diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index fbeed6873..61474c200 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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 diff --git a/docs/public/execution/automations.mdx b/docs/public/execution/automations.mdx index 2e43dcb32..468e3e0cf 100644 --- a/docs/public/execution/automations.mdx +++ b/docs/public/execution/automations.mdx @@ -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. diff --git a/lib/apps/fabro-server/src/automation_materializer.rs b/lib/apps/fabro-server/src/automation_materializer.rs index dba8ff0ac..9981a8f37 100644 --- a/lib/apps/fabro-server/src/automation_materializer.rs +++ b/lib/apps/fabro-server/src/automation_materializer.rs @@ -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>, } impl AutomationRunMaterialized { @@ -150,7 +151,7 @@ struct ServerGitHubRemoteResolver { #[async_trait] impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver { async fn resolve(&self, repo: &GitHubRepositorySlug) -> anyhow::Result { - 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 { + 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"]); } diff --git a/lib/apps/fabro-server/src/git_checkout.rs b/lib/apps/fabro-server/src/git_checkout.rs index 822cf1b7c..be90f1ca4 100644 --- a/lib/apps/fabro-server/src/git_checkout.rs +++ b/lib/apps/fabro-server/src/git_checkout.rs @@ -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))) } diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs index 7285f4ccd..732d68bbc 100644 --- a/lib/apps/fabro-server/src/run_compiler.rs +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -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(); diff --git a/lib/apps/fabro-server/src/server/automation_scheduler.rs b/lib/apps/fabro-server/src/server/automation_scheduler.rs index 23a7c19eb..72a149745 100644 --- a/lib/apps/fabro-server/src/server/automation_scheduler.rs +++ b/lib/apps/fabro-server/src/server/automation_scheduler.rs @@ -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] diff --git a/lib/apps/fabro-server/src/server/handler/automations.rs b/lib/apps/fabro-server/src/server/handler/automations.rs index 5aa972f8f..ac99cd822 100644 --- a/lib/apps/fabro-server/src/server/handler/automations.rs +++ b/lib/apps/fabro-server/src/server/handler/automations.rs @@ -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( diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 8173c8a26..d2a471fcb 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -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(), diff --git a/lib/apps/fabro-server/tests/it/api/automations.rs b/lib/apps/fabro-server/tests/it/api/automations.rs index 6786de940..c4d61746c 100644 --- a/lib/apps/fabro-server/tests/it/api/automations.rs +++ b/lib/apps/fabro-server/tests/it/api/automations.rs @@ -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] diff --git a/lib/components/fabro-automation/src/lib.rs b/lib/components/fabro-automation/src/lib.rs index d94b5745c..f4bd12ea2 100644 --- a/lib/components/fabro-automation/src/lib.rs +++ b/lib/components/fabro-automation/src/lib.rs @@ -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; diff --git a/lib/components/fabro-automation/src/model.rs b/lib/components/fabro-automation/src/model.rs index 960b36e6c..2f7212189 100644 --- a/lib/components/fabro-automation/src/model.rs +++ b/lib/components/fabro-automation/src/model.rs @@ -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 { diff --git a/lib/components/fabro-automation/tests/store.rs b/lib/components/fabro-automation/tests/store.rs index 1dd695f16..afce7da7a 100644 --- a/lib/components/fabro-automation/tests/store.rs +++ b/lib/components/fabro-automation/tests/store.rs @@ -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(), diff --git a/lib/components/fabro-github/src/lib.rs b/lib/components/fabro-github/src/lib.rs index 94592a59a..7a5e0b080 100644 --- a/lib/components/fabro-github/src/lib.rs +++ b/lib/components/fabro-github/src/lib.rs @@ -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, Option)> { + 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 { 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"); } diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index 079c01fc2..37068764b 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -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, diff --git a/lib/components/fabro-store/src/run_summary_store.rs b/lib/components/fabro-store/src/run_summary_store.rs index ea5e0b134..bf881e313 100644 --- a/lib/components/fabro-store/src/run_summary_store.rs +++ b/lib/components/fabro-store/src/run_summary_store.rs @@ -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, diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index 34db0184b..f89d53bf6 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -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(); diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index 5e76d17e8..d8f0ffc25 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -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(), diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 947fa2f39..fb65cd0bf 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -695,7 +695,7 @@ fn main() { ), ( "AutomationGitWorkflowSourceKind", - "fabro_automation::AutomationGitWorkflowSourceKind", + "fabro_types::AutomationGitWorkflowSourceKind", &[], ), ("AutomationRef", "fabro_types::AutomationRef", &[]), diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index e97de678d..951886ddd 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -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, diff --git a/lib/foundation/fabro-api/tests/run_summary_round_trip.rs b/lib/foundation/fabro-api/tests/run_summary_round_trip.rs index 031cc6a5e..7fbc62793 100644 --- a/lib/foundation/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/foundation/fabro-api/tests/run_summary_round_trip.rs @@ -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", diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index 62396c669..7fa1caefd 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -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, diff --git a/lib/foundation/fabro-types/src/repository.rs b/lib/foundation/fabro-types/src/repository.rs index b0e69f3e5..ba428f3fc 100644 --- a/lib/foundation/fabro-types/src/repository.rs +++ b/lib/foundation/fabro-types/src/repository.rs @@ -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, diff --git a/lib/foundation/fabro-types/src/run_summary.rs b/lib/foundation/fabro-types/src/run_summary.rs index 0966df360..a9e8620e6 100644 --- a/lib/foundation/fabro-types/src/run_summary.rs +++ b/lib/foundation/fabro-types/src/run_summary.rs @@ -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, + pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub trigger_id: Option, + pub trigger_id: Option, + /// 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>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/lib/foundation/fabro-types/tests/run_event_serde.rs b/lib/foundation/fabro-types/tests/run_event_serde.rs index 0e11baca7..1ad85164a 100644 --- a/lib/foundation/fabro-types/tests/run_event_serde.rs +++ b/lib/foundation/fabro-types/tests/run_event_serde.rs @@ -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() diff --git a/lib/foundation/fabro-types/tests/run_spec_serde.rs b/lib/foundation/fabro-types/tests/run_spec_serde.rs index f052acd9a..409a426ca 100644 --- a/lib/foundation/fabro-types/tests/run_spec_serde.rs +++ b/lib/foundation/fabro-types/tests/run_spec_serde.rs @@ -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() diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index b8fa8afc1..f8582b270 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/models/automation-ref.ts b/lib/packages/fabro-api-client/src/models/automation-ref.ts index a22d99199..e73345dc6 100644 --- a/lib/packages/fabro-api-client/src/models/automation-ref.ts +++ b/lib/packages/fabro-api-client/src/models/automation-ref.ts @@ -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; } diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index d057ba931..8bba11042 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/resolved-automation-git-workflow-source.ts b/lib/packages/fabro-api-client/src/models/resolved-automation-git-workflow-source.ts new file mode 100644 index 000000000..7a2767249 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/resolved-automation-git-workflow-source.ts @@ -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; +}