1use std::{
8 collections::{BTreeMap, BTreeSet},
9 error::Error,
10 ffi::OsString,
11 fmt, fs,
12 io::{self, Read, Seek, Write},
13 path::{Component, Path, PathBuf},
14 process::{Child, Command, ExitStatus, Stdio},
15 sync::{
16 Mutex,
17 atomic::{AtomicU64, Ordering},
18 mpsc::{self, Receiver, RecvTimeoutError},
19 },
20 thread,
21 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
22};
23
24#[cfg(any(not(windows), test))]
25#[cfg(test)]
26use std::ffi::OsStr;
27
28use rustferry_remote::{
29 ArtifactDownloadRequest, ArtifactDownloadResult, ArtifactKind, ArtifactListRequest,
30 ArtifactManifest, BuildProvider, CURRENT_PROTOCOL_VERSION, CancellationAck,
31 CancellationRequest, CancellationToken, CleanupConfirmation, CleanupRequest, CleanupStatus,
32 CompilePhaseEvidence, EventPage, EventRequest, GitSnapshotDescriptor, HandshakeRequest,
33 HandshakeResponse, IosArtifactType, IosDeviceBuildRequest, IosDeviceBuildResult, JobHandle,
34 JobState, ProviderCapabilities, ProviderCheck, ProviderCheckStatus, ProviderDoctorReport,
35 ProviderDoctorRequest, ProviderFeature, ProviderFuture, RemoteBuildError, RemoteBuildEvent,
36 RemoteBuildEventKind, RemoteBuildResult, RemoteErrorInfo, SecretReference, SecretReferenceKind,
37 SigningMode, SigningStatus, SourceArchive, SourceBundleDescriptor, SourceMode,
38 canonical_git_snapshot_descriptor_bytes, canonical_request_sha256,
39};
40use semver::Version;
41use serde::{Deserialize, Serialize};
42use sha2::{Digest, Sha256};
43
44use crate::{
45 artifact::GithubArtifactExpectation,
46 git_endpoint::{GitRemoteName, GithubGitEndpoint, GithubRemoteSnapshot},
47 git_process::GitNetworkPolicy,
48 job_logs::GithubDurableRunAttemptLogIdentity,
49 snapshot::{
50 GitSha1ObjectId, GitSnapshotError, GitSnapshotImportInputs, GitSnapshotKeepaliveRef,
51 GitSnapshotObjectGraphV1, GitSnapshotObjectKind, GitSnapshotPrecomputeInputs,
52 GitSnapshotSourceRef, GitSnapshotStageDirectory, GitSnapshotStageLocatorV1,
53 GitSnapshotStageV1, complete_git_snapshot_object_graph,
54 discover_complete_git_snapshot_stages, require_sha1_object_format,
55 },
56 transport::{
57 AuthenticatedPrincipal, BranchName, CommitSha, GhExecutionError, GhRunner, GithubTransport,
58 GithubWorkflowDispatchHttpClient, PollPolicy, Repository, RunConclusion, RunEvent,
59 RunHandle, RunId, RunSnapshot, RunStatus, TemporaryGitRef, TransportError,
60 WorkflowDispatchReceipt, WorkflowDispatchRequest, WorkflowId, WorkflowRegistration,
61 },
62 workflow::{
63 GeneratedWorkflow, MAX_SIGNING_PROFILES, TemporaryBranchNamespace, TrustedSourceRef,
64 WorkflowConfig, WorkflowRunTrigger, generate_workflow,
65 },
66};
67
68#[cfg(feature = "secure-job-log-http")]
69use crate::{
70 job_logs::{GithubJobLogError, GithubJobLogFetcher, GithubJobLogLimits, GithubJobLogPoll},
71 transport::GhProcessRunner,
72};
73
74#[cfg(unix)]
75use crate::git_endpoint::GithubGitTransport;
76
77pub const GITHUB_PROVIDER_ID: &str = "github-actions";
79pub const DISPATCH_MANIFEST_PATH: &str = ".rustferry/goal3/request.json";
81
82const DISPATCH_MANIFEST_SCHEMA_VERSION: u32 = 2;
83const GITHUB_JOB_RESUME_SCHEMA_VERSION: u32 = 1;
84const GITHUB_WORKFLOW_DISPATCH_RESUME_SCHEMA_VERSION: u32 = 1;
85const GITHUB_GIT_SNAPSHOT_RESUME_SCHEMA_VERSION: u32 = 1;
86pub const GITHUB_SIGNED_CLEANUP_EVIDENCE_SCHEMA_VERSION: u32 = 1;
88const MAX_JOB_RECORDS: usize = 1_024;
89const MAX_JOB_EVENTS: usize = 64;
90const MAX_JOB_MANIFESTS: usize = 64;
91pub const GITHUB_JOB_RESUME_MAX_BYTES: usize = 16 * 1024 * 1024;
93const MAX_DISPATCH_FILE_BYTES: usize = 2 * 1024 * 1024;
94const MAX_GIT_OUTPUT_BYTES: usize = 64 * 1024;
95const MAX_GIT_STDERR_BYTES: usize = 64 * 1024;
96const MAX_GIT_TIMEOUT: Duration = Duration::from_mins(10);
97const GIT_PROCESS_CLEANUP_GRACE: Duration = Duration::from_secs(1);
98const MAX_CALLER_GIT_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
99const MAX_CALLER_GIT_CONTROL_BYTES: u64 = 1024 * 1024;
100static INDEX_RESERVATION_COUNTER: AtomicU64 = AtomicU64::new(0);
101pub const GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS: u64 = 75 * 60 * 1_000;
105
106#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
108pub struct WorkflowFingerprint(String);
109
110impl WorkflowFingerprint {
111 pub fn new(value: impl Into<String>) -> Result<Self, GithubProviderConfigError> {
117 let value = value.into();
118 if !is_lower_sha256(&value) {
119 return Err(GithubProviderConfigError::InvalidWorkflowFingerprint);
120 }
121 Ok(Self(value))
122 }
123
124 pub fn as_str(&self) -> &str {
126 &self.0
127 }
128
129 pub fn for_workflow(workflow: &GeneratedWorkflow) -> Self {
131 Self(hex_sha256(workflow.yaml().as_bytes()))
132 }
133}
134
135#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
137#[allow(
138 clippy::struct_excessive_bools,
139 reason = "independent least-privilege mutation authorities must remain separately configurable"
140)]
141pub struct GithubMutationAuthorization {
142 pub publish_temporary_ref: bool,
144 pub cancel_run: bool,
146 pub delete_temporary_ref: bool,
148 pub publish_git_snapshot_source: bool,
150 pub delete_git_snapshot_source: bool,
152 pub manage_git_snapshot_keepalive: bool,
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub struct GithubPollingPolicy {
159 transport: PollPolicy,
160 discovery_attempts: u16,
161 discovery_window_ms: u64,
162}
163
164impl GithubPollingPolicy {
165 pub fn new(
172 attempts: u16,
173 interval: Duration,
174 ) -> Result<Self, crate::transport::TransportConfigError> {
175 let transport = PollPolicy::new(attempts, interval)?;
176 let interval_ms = u64::try_from(interval.as_millis()).unwrap_or(u64::MAX);
177 let discovery_window_ms = interval_ms.saturating_mul(u64::from(attempts));
178 Ok(Self {
179 transport,
180 discovery_attempts: attempts,
181 discovery_window_ms,
182 })
183 }
184
185 pub fn secure_defaults() -> Self {
191 Self::new(1_440, Duration::from_secs(5)).expect("constant polling policy is valid")
192 }
193
194 pub const fn transport(self) -> PollPolicy {
196 self.transport
197 }
198
199 pub const fn discovery_attempts(self) -> u16 {
201 self.discovery_attempts
202 }
203
204 pub const fn discovery_window_ms(self) -> u64 {
206 self.discovery_window_ms
207 }
208}
209
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub enum GithubProviderConfigError {
213 InvalidWorkflowFingerprint,
215 WorkflowFingerprintMismatch,
217 InvalidSourceRepository,
219 InvalidJobLimit,
221 InvalidWorkerVersion,
223 WorkerVersionMismatch,
225 InvalidGitEndpoints,
227 UnsupportedExecutionTransport,
229}
230
231impl fmt::Display for GithubProviderConfigError {
232 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233 match self {
234 Self::InvalidWorkflowFingerprint => {
235 formatter.write_str("workflow fingerprint must be lowercase SHA-256")
236 }
237 Self::WorkflowFingerprintMismatch => {
238 formatter.write_str("generated workflow does not match its approved fingerprint")
239 }
240 Self::InvalidSourceRepository => {
241 formatter.write_str("source repository does not match the workflow public source")
242 }
243 Self::InvalidJobLimit => formatter.write_str("job limit must be between 1 and 1024"),
244 Self::InvalidWorkerVersion => {
245 formatter.write_str("workflow worker version must be semantic-version syntax")
246 }
247 Self::WorkerVersionMismatch => {
248 formatter.write_str("handshake worker version does not match workflow distribution")
249 }
250 Self::InvalidGitEndpoints => {
251 formatter.write_str("Git endpoints do not match provider repositories")
252 }
253 Self::UnsupportedExecutionTransport => formatter
254 .write_str("execution Git fetch and push must use SSH on this secure platform"),
255 }
256 }
257}
258
259impl Error for GithubProviderConfigError {}
260
261#[derive(Clone, Debug, Eq, PartialEq)]
263pub struct GithubProviderConfig {
264 repository: Repository,
265 source_repository: String,
266 workflow: WorkflowConfig,
267 workflow_fingerprint: WorkflowFingerprint,
268 poll_policy: GithubPollingPolicy,
269 mutation_authorization: GithubMutationAuthorization,
270 worker_version: Version,
271 max_jobs: usize,
272 git_endpoints: Option<GithubProviderGitEndpoints>,
273}
274
275#[derive(Clone, Debug, Eq, PartialEq)]
276struct GithubProviderGitEndpoints {
277 source: GithubRemoteSnapshot,
278 execution: GithubRemoteSnapshot,
279}
280
281impl GithubProviderConfig {
282 #[allow(clippy::too_many_arguments)]
290 pub fn new(
291 repository: Repository,
292 source_repository: impl Into<String>,
293 workflow: WorkflowConfig,
294 workflow_fingerprint: WorkflowFingerprint,
295 poll_policy: GithubPollingPolicy,
296 mutation_authorization: GithubMutationAuthorization,
297 worker_version: &Version,
298 max_jobs: usize,
299 ) -> Result<Self, GithubProviderConfigError> {
300 if !(1..=MAX_JOB_RECORDS).contains(&max_jobs) {
301 return Err(GithubProviderConfigError::InvalidJobLimit);
302 }
303 let source_repository = source_repository.into();
304 if source_repository != workflow.public_source_repository().url() {
305 return Err(GithubProviderConfigError::InvalidSourceRepository);
306 }
307 let generated = generate_workflow(&workflow);
308 if WorkflowFingerprint::for_workflow(&generated) != workflow_fingerprint {
309 return Err(GithubProviderConfigError::WorkflowFingerprintMismatch);
310 }
311 let distribution_worker_version = Version::parse(workflow.worker().version())
312 .map_err(|_| GithubProviderConfigError::InvalidWorkerVersion)?;
313 if *worker_version != distribution_worker_version {
314 return Err(GithubProviderConfigError::WorkerVersionMismatch);
315 }
316 Ok(Self {
317 repository,
318 source_repository,
319 workflow,
320 workflow_fingerprint,
321 poll_policy,
322 mutation_authorization,
323 worker_version: distribution_worker_version,
324 max_jobs,
325 git_endpoints: None,
326 })
327 }
328
329 pub fn repository(&self) -> &Repository {
331 &self.repository
332 }
333
334 pub fn source_repository(&self) -> &str {
336 &self.source_repository
337 }
338
339 pub fn workflow(&self) -> &WorkflowConfig {
341 &self.workflow
342 }
343
344 pub fn workflow_fingerprint(&self) -> &WorkflowFingerprint {
346 &self.workflow_fingerprint
347 }
348
349 pub const fn poll_policy(&self) -> PollPolicy {
351 self.poll_policy.transport()
352 }
353
354 pub const fn mutation_authorization(&self) -> GithubMutationAuthorization {
356 self.mutation_authorization
357 }
358
359 pub fn bind_git_endpoints(
366 mut self,
367 source: GithubRemoteSnapshot,
368 execution: GithubRemoteSnapshot,
369 ) -> Result<Self, GithubProviderConfigError> {
370 if !github_remote_matches(source.fetch().canonical_url(), &self.source_repository)
371 || !github_remote_matches(source.push().canonical_url(), &self.source_repository)
372 || !github_remote_matches(
373 execution.fetch().canonical_url(),
374 &repository_url(&self.repository),
375 )
376 || !github_remote_matches(
377 execution.push().canonical_url(),
378 &repository_url(&self.repository),
379 )
380 {
381 return Err(GithubProviderConfigError::InvalidGitEndpoints);
382 }
383 #[cfg(unix)]
384 if execution.fetch().transport() != GithubGitTransport::Ssh
385 || execution.push().transport() != GithubGitTransport::Ssh
386 {
387 return Err(GithubProviderConfigError::UnsupportedExecutionTransport);
388 }
389 self.git_endpoints = Some(GithubProviderGitEndpoints { source, execution });
390 Ok(self)
391 }
392}
393
394#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
396#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
397pub enum GithubPrincipalIdentityV1 {
398 User {
400 id: u64,
402 login: String,
404 },
405 RepositoryCredential,
411}
412
413impl GithubPrincipalIdentityV1 {
414 fn from_authenticated(
415 principal: &AuthenticatedPrincipal,
416 run_trigger: WorkflowRunTrigger,
417 execution_repository_id: u64,
418 ) -> RemoteBuildResult<Self> {
419 match principal {
420 AuthenticatedPrincipal::User(user) => Ok(Self::User {
421 id: user.id(),
422 login: user.login().to_owned(),
423 }),
424 AuthenticatedPrincipal::RepositoryCredential { .. }
425 if run_trigger != WorkflowRunTrigger::WorkflowDispatch =>
426 {
427 Err(resume_failure(
428 "resume_repository_credential_requires_workflow_dispatch",
429 ))
430 }
431 AuthenticatedPrincipal::RepositoryCredential { repository_id }
432 if *repository_id != execution_repository_id =>
433 {
434 Err(resume_failure(
435 "resume_repository_credential_repository_mismatch",
436 ))
437 }
438 AuthenticatedPrincipal::RepositoryCredential { .. } => Ok(Self::RepositoryCredential),
439 }
440 }
441}
442
443#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
445#[serde(deny_unknown_fields)]
446pub struct GithubDurableIdentityV1 {
447 pub provider: String,
449 pub provider_config_sha256: String,
451 pub principal: GithubPrincipalIdentityV1,
453 pub execution_repository: String,
455 pub execution_repository_id: u64,
457}
458
459#[derive(Clone, Debug)]
460enum GithubGitSnapshotStageAuthorityV1 {
461 SameInvocation {
462 locator: GitSnapshotStageLocatorV1,
463 stage: GitSnapshotStageV1,
464 },
465 ExactRetryLineage {
466 locator: GitSnapshotStageLocatorV1,
467 stage: GitSnapshotStageV1,
468 },
469 FreshReconsentAdoption,
470}
471
472#[derive(Clone, Debug)]
480pub struct GithubGitSnapshotSubmissionV1 {
481 durable_identity: GithubDurableIdentityV1,
482 request: IosDeviceBuildRequest,
483 approved_consent_sha256: String,
484 stage_authority: GithubGitSnapshotStageAuthorityV1,
485}
486
487#[derive(Clone, Debug, Eq, PartialEq)]
493pub struct GithubGitSnapshotKeepaliveReleaseAuthorizationV1 {
494 job_id: String,
495 complete_lineage_authorization_sha256: String,
496}
497
498impl GithubGitSnapshotKeepaliveReleaseAuthorizationV1 {
499 pub fn new(
505 job_id: impl Into<String>,
506 complete_lineage_authorization_sha256: impl Into<String>,
507 ) -> RemoteBuildResult<Self> {
508 let job_id = job_id.into();
509 let complete_lineage_authorization_sha256 = complete_lineage_authorization_sha256.into();
510 GitSnapshotSourceRef::for_operation(&job_id)
511 .map_err(|_| resume_failure("snapshot_prune_job_invalid"))?;
512 if !is_lower_sha256(&complete_lineage_authorization_sha256) {
513 return Err(resume_failure("snapshot_prune_authorization_invalid"));
514 }
515 Ok(Self {
516 job_id,
517 complete_lineage_authorization_sha256,
518 })
519 }
520
521 pub fn job_id(&self) -> &str {
523 &self.job_id
524 }
525
526 pub fn complete_lineage_authorization_sha256(&self) -> &str {
528 &self.complete_lineage_authorization_sha256
529 }
530}
531
532#[derive(Clone, Debug, Eq, PartialEq)]
534pub struct GithubGitSnapshotExactRetryAuthorizationV1 {
535 parent_job_id: String,
536 child_operation_id: String,
537 child_source_created_at_ms: u64,
538 retry_lineage_authorization_sha256: String,
539}
540
541impl GithubGitSnapshotExactRetryAuthorizationV1 {
542 pub fn new(
549 parent_job_id: impl Into<String>,
550 child_operation_id: impl Into<String>,
551 child_source_created_at_ms: u64,
552 retry_lineage_authorization_sha256: impl Into<String>,
553 ) -> RemoteBuildResult<Self> {
554 let parent_job_id = parent_job_id.into();
555 let child_operation_id = child_operation_id.into();
556 let retry_lineage_authorization_sha256 = retry_lineage_authorization_sha256.into();
557 if parent_job_id == child_operation_id
558 || child_source_created_at_ms == 0
559 || child_source_created_at_ms / 1_000 > i64::MAX as u64
560 || GitSnapshotSourceRef::for_operation(&parent_job_id).is_err()
561 || GitSnapshotSourceRef::for_operation(&child_operation_id).is_err()
562 || !is_lower_sha256(&retry_lineage_authorization_sha256)
563 {
564 return Err(resume_failure("snapshot_exact_retry_authorization_invalid"));
565 }
566 Ok(Self {
567 parent_job_id,
568 child_operation_id,
569 child_source_created_at_ms,
570 retry_lineage_authorization_sha256,
571 })
572 }
573}
574
575#[derive(Clone, Debug, PartialEq)]
581pub struct GithubGitSnapshotExactRetryPlanV1 {
582 pub request: IosDeviceBuildRequest,
584 pub source_created_at_ms: u64,
586 pub request_template_sha256: String,
588 pub manifest_sha256: String,
590 pub archive: SourceArchive,
592 pub descriptor_sha256: String,
594 pub graph: GitSnapshotObjectGraphV1,
596}
597
598#[derive(Clone, Debug, PartialEq)]
600pub struct GithubGitSnapshotExactRetryStageV1 {
601 pub request: IosDeviceBuildRequest,
603 pub stage_locator: GitSnapshotStageLocatorV1,
605 pub stage: GitSnapshotStageV1,
607}
608
609#[derive(Clone, Debug, PartialEq)]
611pub struct GithubGitSnapshotRecoveryCandidateV1 {
612 pub stage_locator: GitSnapshotStageLocatorV1,
614 pub stage: GitSnapshotStageV1,
616 pub descriptor: GitSnapshotDescriptor,
618 pub final_request: IosDeviceBuildRequest,
620}
621
622impl GithubGitSnapshotSubmissionV1 {
623 pub fn same_invocation(
629 durable_identity: GithubDurableIdentityV1,
630 request: IosDeviceBuildRequest,
631 approved_consent_sha256: String,
632 locator: GitSnapshotStageLocatorV1,
633 stage: GitSnapshotStageV1,
634 ) -> RemoteBuildResult<Self> {
635 if !is_lower_sha256(&approved_consent_sha256) {
636 return Err(provider_failure(
637 "snapshot_consent_invalid",
638 "Git snapshot consent identity is invalid",
639 false,
640 ));
641 }
642 Ok(Self {
643 durable_identity,
644 request,
645 approved_consent_sha256,
646 stage_authority: GithubGitSnapshotStageAuthorityV1::SameInvocation { locator, stage },
647 })
648 }
649
650 pub fn exact_retry_lineage(
660 durable_identity: GithubDurableIdentityV1,
661 authorization: &GithubGitSnapshotExactRetryAuthorizationV1,
662 staged: GithubGitSnapshotExactRetryStageV1,
663 ) -> RemoteBuildResult<Self> {
664 if staged.request.operation_id != authorization.child_operation_id
665 || staged.stage.operation_id != authorization.child_operation_id
666 || staged.stage_locator.operation_id != authorization.child_operation_id
667 || staged.stage.source_created_at_ms != authorization.child_source_created_at_ms
668 || staged.stage.consent_sha256 != authorization.retry_lineage_authorization_sha256
669 || staged.stage.final_request != staged.request
670 {
671 return Err(provider_failure(
672 "snapshot_exact_retry_stage_authorization_mismatch",
673 "Git snapshot retry stage differs from durable lineage authority",
674 false,
675 ));
676 }
677 staged
678 .stage
679 .validate_for_request(
680 &GitSnapshotDescriptor::from_request(
681 &staged.request,
682 SourceBundleDescriptor::new(
683 staged.stage.archive.clone(),
684 staged.request.source.clone(),
685 ),
686 )
687 .map_err(|_| resume_failure("snapshot_exact_retry_stage_invalid"))?,
688 &staged.request,
689 )
690 .map_err(|_| resume_failure("snapshot_exact_retry_stage_invalid"))?;
691 Ok(Self {
692 durable_identity,
693 request: staged.request,
694 approved_consent_sha256: authorization.retry_lineage_authorization_sha256.clone(),
695 stage_authority: GithubGitSnapshotStageAuthorityV1::ExactRetryLineage {
696 locator: staged.stage_locator,
697 stage: staged.stage,
698 },
699 })
700 }
701
702 pub fn after_fresh_reconsent(
708 durable_identity: GithubDurableIdentityV1,
709 request: IosDeviceBuildRequest,
710 approved_consent_sha256: String,
711 ) -> RemoteBuildResult<Self> {
712 if !is_lower_sha256(&approved_consent_sha256) {
713 return Err(provider_failure(
714 "snapshot_consent_invalid",
715 "Git snapshot consent identity is invalid",
716 false,
717 ));
718 }
719 Ok(Self {
720 durable_identity,
721 request,
722 approved_consent_sha256,
723 stage_authority: GithubGitSnapshotStageAuthorityV1::FreshReconsentAdoption,
724 })
725 }
726}
727
728#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
730#[serde(rename_all = "snake_case")]
731pub enum GithubRunEventV1 {
732 Push,
734 WorkflowDispatch,
736}
737
738impl From<RunEvent> for GithubRunEventV1 {
739 fn from(value: RunEvent) -> Self {
740 match value {
741 RunEvent::Push => Self::Push,
742 RunEvent::WorkflowDispatch => Self::WorkflowDispatch,
743 }
744 }
745}
746
747impl From<GithubRunEventV1> for RunEvent {
748 fn from(value: GithubRunEventV1) -> Self {
749 match value {
750 GithubRunEventV1::Push => Self::Push,
751 GithubRunEventV1::WorkflowDispatch => Self::WorkflowDispatch,
752 }
753 }
754}
755
756#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
758#[serde(rename_all = "snake_case")]
759pub enum GithubRunStatusV1 {
760 Requested,
762 Queued,
764 Pending,
766 Waiting,
768 InProgress,
770 Completed,
772}
773
774impl From<RunStatus> for GithubRunStatusV1 {
775 fn from(value: RunStatus) -> Self {
776 match value {
777 RunStatus::Requested => Self::Requested,
778 RunStatus::Queued => Self::Queued,
779 RunStatus::Pending => Self::Pending,
780 RunStatus::Waiting => Self::Waiting,
781 RunStatus::InProgress => Self::InProgress,
782 RunStatus::Completed => Self::Completed,
783 }
784 }
785}
786
787impl From<GithubRunStatusV1> for RunStatus {
788 fn from(value: GithubRunStatusV1) -> Self {
789 match value {
790 GithubRunStatusV1::Requested => Self::Requested,
791 GithubRunStatusV1::Queued => Self::Queued,
792 GithubRunStatusV1::Pending => Self::Pending,
793 GithubRunStatusV1::Waiting => Self::Waiting,
794 GithubRunStatusV1::InProgress => Self::InProgress,
795 GithubRunStatusV1::Completed => Self::Completed,
796 }
797 }
798}
799
800#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
802#[serde(rename_all = "snake_case")]
803pub enum GithubRunConclusionV1 {
804 Success,
806 Failure,
808 Cancelled,
810 TimedOut,
812 Neutral,
814 Skipped,
816 Stale,
818 ActionRequired,
820 StartupFailure,
822}
823
824impl From<RunConclusion> for GithubRunConclusionV1 {
825 fn from(value: RunConclusion) -> Self {
826 match value {
827 RunConclusion::Success => Self::Success,
828 RunConclusion::Failure => Self::Failure,
829 RunConclusion::Cancelled => Self::Cancelled,
830 RunConclusion::TimedOut => Self::TimedOut,
831 RunConclusion::Neutral => Self::Neutral,
832 RunConclusion::Skipped => Self::Skipped,
833 RunConclusion::Stale => Self::Stale,
834 RunConclusion::ActionRequired => Self::ActionRequired,
835 RunConclusion::StartupFailure => Self::StartupFailure,
836 }
837 }
838}
839
840impl From<GithubRunConclusionV1> for RunConclusion {
841 fn from(value: GithubRunConclusionV1) -> Self {
842 match value {
843 GithubRunConclusionV1::Success => Self::Success,
844 GithubRunConclusionV1::Failure => Self::Failure,
845 GithubRunConclusionV1::Cancelled => Self::Cancelled,
846 GithubRunConclusionV1::TimedOut => Self::TimedOut,
847 GithubRunConclusionV1::Neutral => Self::Neutral,
848 GithubRunConclusionV1::Skipped => Self::Skipped,
849 GithubRunConclusionV1::Stale => Self::Stale,
850 GithubRunConclusionV1::ActionRequired => Self::ActionRequired,
851 GithubRunConclusionV1::StartupFailure => Self::StartupFailure,
852 }
853 }
854}
855
856#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
858#[serde(deny_unknown_fields)]
859pub struct GithubRunIdentityV1 {
860 pub run_id: u64,
862 pub workflow_id: u64,
864 pub workflow_path: String,
866 pub head_sha: String,
868 pub branch: String,
870 pub event: GithubRunEventV1,
872 pub run_number: u64,
874 pub run_attempt: u64,
876 pub status: GithubRunStatusV1,
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub conclusion: Option<GithubRunConclusionV1>,
881}
882
883#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
890#[serde(deny_unknown_fields)]
891pub struct GithubSignedCleanupEvidenceV1 {
892 pub schema_version: u32,
894 pub provider: String,
896 pub operation_id: String,
898 pub job_id: String,
900 pub execution_repository: String,
902 pub execution_repository_id: u64,
904 pub request_sha256: String,
906 pub source_repository: String,
908 pub source_revision: String,
910 pub source_sha256: String,
912 pub dispatch_revision: String,
914 pub run: GithubRunIdentityV1,
916 pub compile_evidence_sha256: String,
918 pub manifest_sha256: String,
920 pub signing_report_sha256: String,
922 pub validation_report_sha256: String,
924 pub github_artifact_id: u64,
926 pub github_artifact_api_sha256: String,
928}
929
930#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
932#[serde(rename_all = "snake_case")]
933pub enum GithubGitSnapshotPhaseV1 {
934 Prepared,
936 KeepaliveIntent,
938 KeepaliveExact,
940 StageDeleteIntent,
942 StageDeleted,
944 SourcePublishIntent,
946 SourceExact,
948 SourceAbsent,
950 SourceConflict,
952 SourceCleanupIntent,
954 SourceDeleted,
956 KeepaliveReleaseIntent,
958 KeepaliveReleased,
960}
961
962#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
964#[serde(deny_unknown_fields)]
965pub struct GithubGitSnapshotResumeV1 {
966 pub schema_version: u32,
968 pub stage_locator: GitSnapshotStageLocatorV1,
970 pub stage: GitSnapshotStageV1,
972 pub phase: GithubGitSnapshotPhaseV1,
974 pub source_publication_attempts: u8,
976 pub source_publication_started_at_ms: u64,
978 pub source_publication_quiescence_deadline_ms: u64,
980 pub source_publication_process_fenced: bool,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub source_publication_lease_scope_sha256: Option<String>,
985 pub source_publication_absence_observations: u8,
987 pub source_publication_absence_first_observed_at_ms: u64,
989 pub source_publication_absence_last_observed_at_ms: u64,
991 #[serde(default, skip_serializing_if = "Option::is_none")]
993 pub keepalive_release_authorization_sha256: Option<String>,
994}
995
996#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
998#[serde(deny_unknown_fields)]
999pub struct GithubWorkflowDispatchReceiptV1 {
1000 pub run_id: u64,
1002 pub workflow_id: u64,
1004 pub workflow_path: String,
1006 pub branch: String,
1008 pub dispatch_revision: String,
1010 pub run_name: String,
1012}
1013
1014#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1016#[serde(deny_unknown_fields)]
1017pub struct GithubWorkflowDispatchResumeV1 {
1018 pub schema_version: u32,
1020 pub workflow_id: u64,
1022 pub workflow_path: String,
1024 pub branch: String,
1026 pub operation_id: String,
1028 pub request_sha256: String,
1030 pub source_revision: String,
1032 pub dispatch_revision: String,
1034 pub body_sha256: String,
1036 pub run_name: String,
1038 pub uncertain: bool,
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub receipt: Option<GithubWorkflowDispatchReceiptV1>,
1043}
1044
1045#[allow(clippy::struct_excessive_bools)]
1047#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1048#[serde(deny_unknown_fields)]
1049pub struct GithubJobResumeV1 {
1050 pub schema_version: u32,
1052 pub provider: String,
1054 pub provider_config_sha256: String,
1056 pub principal: GithubPrincipalIdentityV1,
1058 pub execution_repository: String,
1060 pub execution_repository_id: u64,
1062 pub source_repository: String,
1064 pub trusted_source_ref: String,
1066 pub workflow_path: String,
1068 pub workflow_sha256: String,
1070 pub temporary_ref: String,
1072 pub operation_id: String,
1074 pub job_id: String,
1076 pub request: IosDeviceBuildRequest,
1078 pub request_sha256: String,
1080 pub source_revision: String,
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub git_snapshot: Option<GithubGitSnapshotResumeV1>,
1085 #[serde(skip_serializing_if = "Option::is_none")]
1087 pub prepared_dispatch_commit: Option<String>,
1088 #[serde(skip_serializing_if = "Option::is_none")]
1090 pub dispatch_commit: Option<String>,
1091 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub workflow_dispatch: Option<Box<GithubWorkflowDispatchResumeV1>>,
1096 #[serde(skip_serializing_if = "Option::is_none")]
1098 pub run: Option<GithubRunIdentityV1>,
1099 pub created_at_ms: u64,
1101 pub publication_started_at_ms: u64,
1103 pub publication_quiescence_deadline_ms: u64,
1105 pub state: JobState,
1107 pub publication_intent: bool,
1109 pub publication_uncertain: bool,
1111 pub publication_absent: bool,
1113 pub publication_not_attempted: bool,
1117 pub publication_process_fenced: bool,
1119 #[serde(skip_serializing_if = "Option::is_none")]
1121 pub publication_lease_scope_sha256: Option<String>,
1122 pub publication_absence_observations: u8,
1124 pub publication_absence_first_observed_at_ms: u64,
1126 pub cancellation_requested: bool,
1128 pub cancellation_dispatched: bool,
1130 pub cleanup_requested: bool,
1132 pub remove_artifacts_requested: bool,
1134 pub artifacts_removed: bool,
1136 pub temporary_ref_deleted: bool,
1138 pub verification_pending_event: bool,
1140 pub run_discovery_attempts: u16,
1142 pub run_discovery_deadline_ms: u64,
1144 pub manifests: Vec<ArtifactManifest>,
1146 #[serde(skip_serializing_if = "Option::is_none")]
1148 pub compile_evidence: Option<CompilePhaseEvidence>,
1149 #[serde(skip_serializing_if = "Option::is_none")]
1151 pub signed_cleanup_evidence: Option<GithubSignedCleanupEvidenceV1>,
1152 pub events: Vec<RemoteBuildEvent>,
1154}
1155
1156pub trait GithubJobCheckpointSink: Send {
1165 fn checkpoint(&mut self, resume: &GithubJobResumeV1) -> RemoteBuildResult<()>;
1171}
1172
1173#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1175#[serde(deny_unknown_fields)]
1176pub struct GithubDispatchManifest {
1177 pub schema_version: u32,
1179 pub provider: String,
1181 pub execution_repository: String,
1183 pub source_repository: String,
1185 pub trusted_source_ref: String,
1187 pub temporary_ref: String,
1189 pub workflow_path: String,
1191 pub workflow_sha256: String,
1193 pub request: IosDeviceBuildRequest,
1195}
1196
1197impl GithubDispatchManifest {
1198 fn new(
1199 config: &GithubProviderConfig,
1200 temporary_ref: &TemporaryGitRef,
1201 request: IosDeviceBuildRequest,
1202 ) -> Self {
1203 Self {
1204 schema_version: DISPATCH_MANIFEST_SCHEMA_VERSION,
1205 provider: GITHUB_PROVIDER_ID.to_owned(),
1206 execution_repository: repository_url(&config.repository),
1207 source_repository: config.source_repository.clone(),
1208 trusted_source_ref: config.workflow.trusted_source_ref().as_str().to_owned(),
1209 temporary_ref: format!("refs/heads/{}", temporary_ref.branch().as_str()),
1210 workflow_path: config.workflow.filename().repository_path(),
1211 workflow_sha256: config.workflow_fingerprint.as_str().to_owned(),
1212 request,
1213 }
1214 }
1215
1216 pub fn validate_for(&self, config: &GithubProviderConfig) -> RemoteBuildResult<()> {
1222 if self.schema_version != DISPATCH_MANIFEST_SCHEMA_VERSION
1223 || self.provider != GITHUB_PROVIDER_ID
1224 || !github_remote_matches(
1225 &self.execution_repository,
1226 &repository_url(&config.repository),
1227 )
1228 || self.source_repository != config.source_repository
1229 || self.trusted_source_ref != config.workflow.trusted_source_ref().as_str()
1230 || self.workflow_path != config.workflow.filename().repository_path()
1231 || self.workflow_sha256 != config.workflow_fingerprint.as_str()
1232 || self.temporary_ref
1233 != format!(
1234 "refs/heads/{}/{}",
1235 config.workflow.temporary_branch_namespace().as_str(),
1236 self.request.operation_id
1237 )
1238 {
1239 return Err(provider_failure(
1240 "dispatch_manifest_identity_mismatch",
1241 "dispatch manifest identity does not match provider configuration",
1242 false,
1243 ));
1244 }
1245 validate_provider_request(config, &self.request)
1246 }
1247
1248 fn encode(&self) -> RemoteBuildResult<Vec<u8>> {
1249 let mut bytes =
1250 serde_json::to_vec_pretty(self).map_err(|error| RemoteBuildError::Serialization {
1251 message: bounded_message(&error.to_string()),
1252 })?;
1253 bytes.push(b'\n');
1254 if bytes.len() > MAX_DISPATCH_FILE_BYTES {
1255 return Err(provider_failure(
1256 "dispatch_manifest_too_large",
1257 "dispatch manifest exceeds provider size policy",
1258 false,
1259 ));
1260 }
1261 Ok(bytes)
1262 }
1263}
1264
1265pub struct GitSnapshotImportRequest<'a> {
1267 pub locator: &'a GitSnapshotStageLocatorV1,
1269 pub expected_stage: &'a GitSnapshotStageV1,
1271 pub request: &'a IosDeviceBuildRequest,
1273}
1274
1275pub struct GitSnapshotExactRetryPrecomputeRequest<'a> {
1277 pub parent_request: &'a IosDeviceBuildRequest,
1279 pub parent_stage: &'a GitSnapshotStageV1,
1281 pub child_operation_id: &'a str,
1283 pub child_source_created_at_ms: u64,
1285}
1286
1287pub struct GitSnapshotExactRetryStageRequest<'a> {
1289 pub parent_request: &'a IosDeviceBuildRequest,
1291 pub parent_stage: &'a GitSnapshotStageV1,
1293 pub expected_plan: &'a GithubGitSnapshotExactRetryPlanV1,
1295 pub retry_lineage_authorization_sha256: &'a str,
1297}
1298
1299pub struct GitSnapshotKeepaliveRequest<'a> {
1301 pub operation_id: &'a str,
1303 pub keepalive_ref: &'a GitSnapshotKeepaliveRef,
1305 pub commit: &'a GitSha1ObjectId,
1307}
1308
1309pub struct GitSnapshotSourcePublishRequest<'a> {
1311 pub source_repository: &'a str,
1313 pub source_ref: &'a GitSnapshotSourceRef,
1315 pub commit: &'a GitSha1ObjectId,
1317 pub authorized: bool,
1319}
1320
1321pub struct GitSnapshotSourceDeleteRequest<'a> {
1323 pub source_repository: &'a str,
1325 pub source_ref: &'a GitSnapshotSourceRef,
1327 pub expected_commit: &'a GitSha1ObjectId,
1329 pub authorized: bool,
1331}
1332
1333#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1335pub enum GitSnapshotRefReconciliation {
1336 Absent,
1338 Exact,
1340 Conflict,
1342}
1343
1344#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1346pub enum GitSnapshotSourcePublication {
1347 Exact,
1349 Uncertain,
1351 Conflict,
1353}
1354
1355pub struct TemporaryRefPublishRequest<'a> {
1357 pub repository: &'a Repository,
1359 pub source_repository: &'a str,
1361 pub trusted_source_ref: &'a TrustedSourceRef,
1363 pub source_revision: &'a CommitSha,
1365 pub snapshot_source_ref: Option<&'a GitSnapshotSourceRef>,
1367 pub temporary_ref: &'a TemporaryGitRef,
1369 pub workflow_path: &'a str,
1371 pub workflow_bytes: &'a [u8],
1373 pub workflow_fingerprint: &'a WorkflowFingerprint,
1375 pub manifest_bytes: &'a [u8],
1377 pub operation_id: &'a str,
1379 pub created_at_ms: u64,
1381 pub authorized: bool,
1383}
1384
1385#[derive(Clone, Debug, Eq, PartialEq)]
1387pub struct TemporaryRefPublisherReadiness {
1388 pub trusted_ref_tip: CommitSha,
1390}
1391
1392pub struct TemporaryRefDoctorRequest<'a> {
1394 pub repository: &'a Repository,
1396 pub source_repository: &'a str,
1398 pub trusted_source_ref: &'a TrustedSourceRef,
1400 pub temporary_branch_namespace: &'a TemporaryBranchNamespace,
1402 pub workflow_path: &'a str,
1404 pub workflow_fingerprint: &'a WorkflowFingerprint,
1406}
1407
1408pub struct ExecutionWorkflowDoctorRequest<'a> {
1410 pub repository: &'a Repository,
1412 pub default_branch_ref: &'a TrustedSourceRef,
1414 pub workflow_path: &'a str,
1416 pub workflow_fingerprint: &'a WorkflowFingerprint,
1418}
1419
1420pub struct TemporaryRefDeleteRequest<'a> {
1422 pub repository: &'a Repository,
1424 pub source_repository: &'a str,
1426 pub temporary_ref: &'a TemporaryGitRef,
1428 pub expected_commit: &'a CommitSha,
1430 pub authorized: bool,
1432}
1433
1434pub struct TemporaryRefReconcileRequest<'a> {
1436 pub repository: &'a Repository,
1438 pub source_repository: &'a str,
1440 pub trusted_source_ref: &'a TrustedSourceRef,
1442 pub source_revision: &'a CommitSha,
1444 pub snapshot_source_ref: Option<&'a GitSnapshotSourceRef>,
1446 pub temporary_ref: &'a TemporaryGitRef,
1448 pub workflow_path: &'a str,
1450 pub workflow_bytes: &'a [u8],
1452 pub workflow_fingerprint: &'a WorkflowFingerprint,
1454 pub manifest_bytes: &'a [u8],
1456 pub operation_id: &'a str,
1458 pub created_at_ms: u64,
1460 pub expected_prepared_commit: &'a CommitSha,
1462}
1463
1464pub struct TemporaryRefPublicationLeaseRequest<'a> {
1466 pub repository: &'a Repository,
1468 pub operation_id: &'a str,
1470 pub temporary_ref: &'a TemporaryGitRef,
1472}
1473
1474pub struct GitSnapshotSourcePublicationLeaseRequest<'a> {
1476 pub source_repository: &'a str,
1478 pub source_ref: &'a GitSnapshotSourceRef,
1480}
1481
1482#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1484pub enum TemporaryRefPublicationLease {
1485 Acquired,
1488 AlreadyOwned,
1490 HeldByOther,
1492 Unsupported,
1494}
1495
1496#[derive(Clone, Debug, Eq, PartialEq)]
1498pub enum TemporaryRefReconciliation {
1499 Missing,
1501 Exact(PublishedTemporaryRef),
1503 Conflict,
1505}
1506
1507#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1509pub enum GithubJobReconciliation {
1510 NotStarted,
1512 AlreadyMapped,
1514 Missing,
1516 Recovered,
1518 Conflict,
1520 ConflictResolved,
1523}
1524
1525#[derive(Clone, Debug, Eq, PartialEq)]
1527pub struct PublishedTemporaryRef {
1528 temporary_ref: TemporaryGitRef,
1529 commit: CommitSha,
1530}
1531
1532impl PublishedTemporaryRef {
1533 pub fn new(temporary_ref: TemporaryGitRef, commit: CommitSha) -> Self {
1535 Self {
1536 temporary_ref,
1537 commit,
1538 }
1539 }
1540
1541 pub fn temporary_ref(&self) -> &TemporaryGitRef {
1543 &self.temporary_ref
1544 }
1545
1546 pub fn commit(&self) -> &CommitSha {
1548 &self.commit
1549 }
1550}
1551
1552#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1554pub enum TemporaryRefPublishError {
1555 Unauthorized,
1557 RemoteMismatch,
1559 TrustedRefUnavailable,
1561 SourceRevisionNotTrusted,
1563 TemporaryRefExists,
1565 WorkflowFingerprintMismatch,
1567 LocalIsolationFailed,
1569 Git(GitExecutionError),
1571 MalformedGitOutput,
1573 PublicationVerificationFailed,
1575 TemporaryRefLeaseRejected,
1577 DeletionVerificationFailed,
1579 GitSnapshotUnsupported,
1581 ExecutionWorkflowVerificationUnsupported,
1583 GitSnapshotInvalid,
1585 GitSnapshotDiscoveryChanged,
1587 GitSnapshotRefConflict,
1589}
1590
1591impl fmt::Display for TemporaryRefPublishError {
1592 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1593 match self {
1594 Self::Unauthorized => formatter.write_str("temporary-ref mutation is not authorized"),
1595 Self::RemoteMismatch => formatter.write_str("Git remote does not match configuration"),
1596 Self::TrustedRefUnavailable => formatter.write_str("trusted source ref is unavailable"),
1597 Self::SourceRevisionNotTrusted => {
1598 formatter.write_str("source revision is not reachable from trusted source ref")
1599 }
1600 Self::TemporaryRefExists => formatter.write_str("temporary ref already exists"),
1601 Self::WorkflowFingerprintMismatch => {
1602 formatter.write_str("workflow bytes do not match approved fingerprint")
1603 }
1604 Self::LocalIsolationFailed => formatter.write_str("local Git isolation failed"),
1605 Self::Git(error) => error.fmt(formatter),
1606 Self::MalformedGitOutput => formatter.write_str("Git returned malformed metadata"),
1607 Self::PublicationVerificationFailed => {
1608 formatter.write_str("published ref failed exact verification")
1609 }
1610 Self::TemporaryRefLeaseRejected => {
1611 formatter.write_str("temporary ref changed before lease-protected deletion")
1612 }
1613 Self::DeletionVerificationFailed => {
1614 formatter.write_str("deleted ref failed exact absence verification")
1615 }
1616 Self::GitSnapshotUnsupported => {
1617 formatter.write_str("Git snapshot publication is unsupported")
1618 }
1619 Self::ExecutionWorkflowVerificationUnsupported => {
1620 formatter.write_str("execution workflow verification is unsupported")
1621 }
1622 Self::GitSnapshotInvalid => {
1623 formatter.write_str("Git snapshot stage or object graph is invalid")
1624 }
1625 Self::GitSnapshotDiscoveryChanged => {
1626 formatter.write_str("Git snapshot recovery discovery changed during inspection")
1627 }
1628 Self::GitSnapshotRefConflict => {
1629 formatter.write_str("Git snapshot ref conflicts with another commit")
1630 }
1631 }
1632 }
1633}
1634
1635impl Error for TemporaryRefPublishError {}
1636
1637impl From<GitExecutionError> for TemporaryRefPublishError {
1638 fn from(value: GitExecutionError) -> Self {
1639 Self::Git(value)
1640 }
1641}
1642
1643impl From<GitSnapshotError> for TemporaryRefPublishError {
1644 fn from(value: GitSnapshotError) -> Self {
1645 if value == GitSnapshotError::DiscoveryChanged {
1646 Self::GitSnapshotDiscoveryChanged
1647 } else {
1648 Self::GitSnapshotInvalid
1649 }
1650 }
1651}
1652
1653#[derive(Clone, Debug, Eq, PartialEq)]
1655pub struct TemporaryRefPublishFailure {
1656 error: TemporaryRefPublishError,
1657 possible_publication: Option<PublishedTemporaryRef>,
1658}
1659
1660impl TemporaryRefPublishFailure {
1661 fn uncertain(error: TemporaryRefPublishError, publication: PublishedTemporaryRef) -> Self {
1662 Self {
1663 error,
1664 possible_publication: Some(publication),
1665 }
1666 }
1667
1668 pub const fn error(&self) -> TemporaryRefPublishError {
1670 self.error
1671 }
1672
1673 pub fn possible_publication(&self) -> Option<&PublishedTemporaryRef> {
1675 self.possible_publication.as_ref()
1676 }
1677}
1678
1679impl From<TemporaryRefPublishError> for TemporaryRefPublishFailure {
1680 fn from(error: TemporaryRefPublishError) -> Self {
1681 Self {
1682 error,
1683 possible_publication: None,
1684 }
1685 }
1686}
1687
1688pub trait TemporaryRefPublisher: Send {
1693 fn supports_git_snapshot(&self) -> bool {
1695 false
1696 }
1697
1698 fn doctor(
1705 &mut self,
1706 request: &TemporaryRefDoctorRequest<'_>,
1707 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError>;
1708
1709 fn verify_execution_workflow(
1716 &mut self,
1717 _request: &ExecutionWorkflowDoctorRequest<'_>,
1718 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError> {
1719 Err(TemporaryRefPublishError::ExecutionWorkflowVerificationUnsupported)
1720 }
1721
1722 fn precompute_git_snapshot(
1728 &mut self,
1729 _inputs: &mut GitSnapshotPrecomputeInputs,
1730 _operation_id: &str,
1731 _created_at_ms: u64,
1732 ) -> Result<GitSnapshotObjectGraphV1, TemporaryRefPublishError> {
1733 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1734 }
1735
1736 fn adopt_git_snapshot_stage(
1743 &mut self,
1744 _request: &IosDeviceBuildRequest,
1745 ) -> Result<(GitSnapshotStageLocatorV1, GitSnapshotStageV1), TemporaryRefPublishError> {
1746 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1747 }
1748
1749 fn discover_git_snapshot_stages(
1758 &mut self,
1759 ) -> Result<Vec<GithubGitSnapshotRecoveryCandidateV1>, TemporaryRefPublishError> {
1760 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1761 }
1762
1763 fn import_git_snapshot(
1773 &mut self,
1774 _request: &GitSnapshotImportRequest<'_>,
1775 ) -> Result<GitSnapshotObjectGraphV1, TemporaryRefPublishError> {
1776 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1777 }
1778
1779 fn precompute_exact_git_snapshot_retry(
1788 &mut self,
1789 _request: &GitSnapshotExactRetryPrecomputeRequest<'_>,
1790 ) -> Result<GithubGitSnapshotExactRetryPlanV1, TemporaryRefPublishError> {
1791 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1792 }
1793
1794 fn stage_exact_git_snapshot_retry(
1805 &mut self,
1806 _request: &GitSnapshotExactRetryStageRequest<'_>,
1807 ) -> Result<GithubGitSnapshotExactRetryStageV1, TemporaryRefPublishError> {
1808 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1809 }
1810
1811 fn delete_git_snapshot_stage(
1820 &mut self,
1821 _request: &GitSnapshotImportRequest<'_>,
1822 ) -> Result<(), TemporaryRefPublishError> {
1823 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1824 }
1825
1826 fn reconcile_git_snapshot_keepalive(
1832 &mut self,
1833 _request: &GitSnapshotKeepaliveRequest<'_>,
1834 ) -> Result<GitSnapshotRefReconciliation, TemporaryRefPublishError> {
1835 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1836 }
1837
1838 fn create_git_snapshot_keepalive(
1847 &mut self,
1848 _request: &GitSnapshotKeepaliveRequest<'_>,
1849 ) -> Result<(), TemporaryRefPublishError> {
1850 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1851 }
1852
1853 fn publish_git_snapshot_source(
1859 &mut self,
1860 _request: &GitSnapshotSourcePublishRequest<'_>,
1861 ) -> Result<GitSnapshotSourcePublication, TemporaryRefPublishError> {
1862 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1863 }
1864
1865 fn reconcile_git_snapshot_source(
1871 &mut self,
1872 _request: &GitSnapshotSourcePublishRequest<'_>,
1873 ) -> Result<GitSnapshotRefReconciliation, TemporaryRefPublishError> {
1874 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1875 }
1876
1877 fn delete_git_snapshot_source(
1883 &mut self,
1884 _request: &GitSnapshotSourceDeleteRequest<'_>,
1885 ) -> Result<(), TemporaryRefPublishError> {
1886 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1887 }
1888
1889 fn release_git_snapshot_keepalive(
1895 &mut self,
1896 _request: &GitSnapshotKeepaliveRequest<'_>,
1897 ) -> Result<(), TemporaryRefPublishError> {
1898 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
1899 }
1900
1901 fn prepare(
1912 &mut self,
1913 _request: &TemporaryRefPublishRequest<'_>,
1914 ) -> Result<Option<PublishedTemporaryRef>, TemporaryRefPublishError> {
1915 Ok(None)
1916 }
1917
1918 fn publication_attempt_is_process_fenced(&self) -> bool {
1922 false
1923 }
1924
1925 fn acquire_git_snapshot_source_lease(
1931 &mut self,
1932 _request: &GitSnapshotSourcePublicationLeaseRequest<'_>,
1933 ) -> Result<TemporaryRefPublicationLease, TemporaryRefPublishError> {
1934 Ok(TemporaryRefPublicationLease::Unsupported)
1935 }
1936
1937 fn release_git_snapshot_source_lease(
1939 &mut self,
1940 _request: &GitSnapshotSourcePublicationLeaseRequest<'_>,
1941 ) {
1942 }
1943
1944 fn publication_lease_scope_sha256(&self) -> Option<&str> {
1946 None
1947 }
1948
1949 fn acquire_publication_lease(
1958 &mut self,
1959 _request: &TemporaryRefPublicationLeaseRequest<'_>,
1960 ) -> Result<TemporaryRefPublicationLease, TemporaryRefPublishError> {
1961 Ok(TemporaryRefPublicationLease::Unsupported)
1962 }
1963
1964 fn release_publication_lease(&mut self, _request: &TemporaryRefPublicationLeaseRequest<'_>) {}
1966
1967 fn publish(
1973 &mut self,
1974 request: &TemporaryRefPublishRequest<'_>,
1975 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure>;
1976
1977 fn publish_prepared(
1985 &mut self,
1986 request: &TemporaryRefPublishRequest<'_>,
1987 prepared: Option<&PublishedTemporaryRef>,
1988 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
1989 if prepared.is_some() {
1990 return Err(TemporaryRefPublishError::PublicationVerificationFailed.into());
1991 }
1992 self.publish(request)
1993 }
1994
1995 fn delete_temporary_ref(
2002 &mut self,
2003 request: &TemporaryRefDeleteRequest<'_>,
2004 ) -> Result<(), TemporaryRefPublishError>;
2005}
2006
2007pub trait TemporaryRefReconciler: Send {
2012 fn reconcile_temporary_ref(
2023 &mut self,
2024 request: &TemporaryRefReconcileRequest<'_>,
2025 ) -> Result<TemporaryRefReconciliation, TemporaryRefPublishError>;
2026}
2027
2028pub struct GitInvocation {
2030 arguments: Vec<OsString>,
2031 stdin: Vec<u8>,
2032 environment: Vec<(GitEnvironmentVariable, OsString)>,
2033 timeout: Duration,
2034 stdout_limit: usize,
2035 network: GitNetworkPolicy,
2036}
2037
2038#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2040pub enum GitEnvironmentVariable {
2041 IndexFile,
2043 AuthorName,
2045 AuthorEmail,
2047 CommitterName,
2049 CommitterEmail,
2051 AuthorDate,
2053 CommitterDate,
2055}
2056
2057impl GitEnvironmentVariable {
2058 const fn as_str(self) -> &'static str {
2059 match self {
2060 Self::IndexFile => "GIT_INDEX_FILE",
2061 Self::AuthorName => "GIT_AUTHOR_NAME",
2062 Self::AuthorEmail => "GIT_AUTHOR_EMAIL",
2063 Self::CommitterName => "GIT_COMMITTER_NAME",
2064 Self::CommitterEmail => "GIT_COMMITTER_EMAIL",
2065 Self::AuthorDate => "GIT_AUTHOR_DATE",
2066 Self::CommitterDate => "GIT_COMMITTER_DATE",
2067 }
2068 }
2069}
2070
2071impl GitInvocation {
2072 fn new(arguments: impl IntoIterator<Item = impl Into<OsString>>, timeout: Duration) -> Self {
2073 Self {
2074 arguments: arguments.into_iter().map(Into::into).collect(),
2075 stdin: Vec::new(),
2076 environment: Vec::new(),
2077 timeout,
2078 stdout_limit: MAX_GIT_OUTPUT_BYTES,
2079 network: GitNetworkPolicy::Offline,
2080 }
2081 }
2082
2083 fn with_stdin(mut self, stdin: &[u8]) -> Self {
2084 self.stdin.extend_from_slice(stdin);
2085 self
2086 }
2087
2088 fn with_environment(
2089 mut self,
2090 variable: GitEnvironmentVariable,
2091 value: impl Into<OsString>,
2092 ) -> Self {
2093 self.environment.push((variable, value.into()));
2094 self
2095 }
2096
2097 fn with_network(mut self, endpoint: &GithubGitEndpoint) -> Self {
2098 self.network = endpoint.transport().into();
2099 self
2100 }
2101
2102 fn with_stdout_limit(mut self, limit: usize) -> Self {
2103 self.stdout_limit = limit;
2104 self
2105 }
2106
2107 pub fn arguments(&self) -> &[OsString] {
2109 &self.arguments
2110 }
2111
2112 pub const fn stdin_len(&self) -> usize {
2114 self.stdin.len()
2115 }
2116
2117 pub fn environment(&self) -> &[(GitEnvironmentVariable, OsString)] {
2119 &self.environment
2120 }
2121
2122 pub const fn timeout(&self) -> Duration {
2124 self.timeout
2125 }
2126
2127 pub const fn stdout_limit(&self) -> usize {
2129 self.stdout_limit
2130 }
2131}
2132
2133impl fmt::Debug for GitInvocation {
2134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2135 formatter
2136 .debug_struct("GitInvocation")
2137 .field("arguments", &self.arguments)
2138 .field("stdin_len", &self.stdin.len())
2139 .field(
2140 "environment_names",
2141 &self
2142 .environment
2143 .iter()
2144 .map(|pair| pair.0.as_str())
2145 .collect::<Vec<_>>(),
2146 )
2147 .field("network", &self.network)
2148 .field("timeout", &self.timeout)
2149 .field("stdout_limit", &self.stdout_limit)
2150 .finish()
2151 }
2152}
2153
2154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2156pub enum GitExecutionError {
2157 SpawnFailed,
2159 TimedOut,
2161 ProcessIo,
2163 OutputLimitExceeded,
2165 CommandFailed {
2167 exit_code: Option<i32>,
2169 },
2170}
2171
2172impl fmt::Display for GitExecutionError {
2173 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2174 match self {
2175 Self::SpawnFailed => formatter.write_str("failed to start Git"),
2176 Self::TimedOut => formatter.write_str("Git operation timed out"),
2177 Self::ProcessIo => formatter.write_str("Git process I/O failed"),
2178 Self::OutputLimitExceeded => formatter.write_str("Git output exceeded its limit"),
2179 Self::CommandFailed {
2180 exit_code: Some(code),
2181 } => {
2182 write!(formatter, "Git operation failed with status {code}")
2183 }
2184 Self::CommandFailed { exit_code: None } => formatter.write_str("Git operation failed"),
2185 }
2186 }
2187}
2188
2189impl Error for GitExecutionError {}
2190
2191pub trait GitRunner: Send {
2193 fn execute(&mut self, invocation: &GitInvocation) -> Result<Vec<u8>, GitExecutionError>;
2199
2200 fn execute_file(
2209 &mut self,
2210 _invocation: &GitInvocation,
2211 _input: fs::File,
2212 _expected_len: u64,
2213 ) -> Result<Vec<u8>, GitExecutionError> {
2214 Err(GitExecutionError::ProcessIo)
2215 }
2216
2217 fn process_tree_is_parent_fenced(&self) -> bool {
2222 false
2223 }
2224}
2225
2226#[derive(Debug)]
2228pub struct GitProcessRunner {
2229 #[cfg(windows)]
2230 toolchain: crate::git_process::WindowsGitToolchain,
2231 #[cfg(windows)]
2232 repository: crate::git_repository::PrivateBareGitRepository,
2233 #[cfg(unix)]
2234 toolchain: crate::git_process::UnixGitToolchain,
2235 #[cfg(unix)]
2236 repository: crate::git_repository::PrivateBareGitRepository,
2237}
2238
2239impl GitProcessRunner {
2240 pub fn new(
2246 executable: impl AsRef<Path>,
2247 isolation_root: impl AsRef<Path>,
2248 ) -> Result<Self, GitPublisherConfigError> {
2249 #[cfg(windows)]
2250 {
2251 let toolchain = crate::git_process::WindowsGitToolchain::new(executable)
2252 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2253 let preparation =
2254 crate::git_repository::PrivateGitRepositoryPreparation::prepare(isolation_root)
2255 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2256 if preparation.needs_initialization() {
2257 let spec = preparation
2258 .initialization_spec(&toolchain)
2259 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2260 let mut command = spec
2261 .command()
2262 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2263 command
2264 .stdin(Stdio::piped())
2265 .stdout(Stdio::piped())
2266 .stderr(Stdio::piped());
2267 run_git_process(command, &[], MAX_GIT_TIMEOUT, MAX_GIT_OUTPUT_BYTES)
2268 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2269 }
2270 let repository = preparation
2271 .finish()
2272 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2273 Ok(Self {
2274 toolchain,
2275 repository,
2276 })
2277 }
2278
2279 #[cfg(unix)]
2280 {
2281 let toolchain = crate::git_process::UnixGitToolchain::new(executable)
2282 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2283 let preparation =
2284 crate::git_repository::PrivateGitRepositoryPreparation::prepare(isolation_root)
2285 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2286 if preparation.needs_initialization() {
2287 let spec = preparation
2288 .initialization_spec(&toolchain)
2289 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2290 let mut command = spec
2291 .command()
2292 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2293 command
2294 .stdin(Stdio::piped())
2295 .stdout(Stdio::piped())
2296 .stderr(Stdio::piped());
2297 run_git_process(command, &[], MAX_GIT_TIMEOUT, MAX_GIT_OUTPUT_BYTES)
2298 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2299 }
2300 let repository = preparation
2301 .finish()
2302 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2303 Ok(Self {
2304 toolchain,
2305 repository,
2306 })
2307 }
2308
2309 #[cfg(not(any(unix, windows)))]
2310 {
2311 let _ = (executable.as_ref(), isolation_root.as_ref());
2312 Err(GitPublisherConfigError::UnsupportedSecurePlatform)
2313 }
2314 }
2315}
2316
2317#[derive(Debug)]
2324pub struct CallerGitRepository {
2325 #[cfg(windows)]
2326 toolchain: crate::git_process::WindowsGitToolchain,
2327 #[cfg(unix)]
2328 toolchain: crate::git_process::UnixGitToolchain,
2329 root: PathBuf,
2330 git_dir: Option<PathBuf>,
2331 common_dir: Option<PathBuf>,
2332 root_retained: Option<rustferry_core::RetainedDirectoryIdentity>,
2333 git_dir_retained: Option<rustferry_core::RetainedDirectoryIdentity>,
2334 common_dir_retained: Option<rustferry_core::RetainedDirectoryIdentity>,
2335 control_files: Vec<CallerGitControlFile>,
2336 home: PathBuf,
2337 xdg: PathBuf,
2338 temp: PathBuf,
2339 private_guards: Vec<std::fs::File>,
2340 _private_state: tempfile::TempDir,
2341}
2342
2343#[derive(Debug)]
2345pub struct CallerGitOutput {
2346 status: ExitStatus,
2347 stdout: Vec<u8>,
2348}
2349
2350#[derive(Debug)]
2351struct CallerGitControlFile {
2352 path: PathBuf,
2353 retained: rustferry_core::RetainedRegularFileIdentity,
2354 sha256: String,
2355}
2356
2357impl CallerGitControlFile {
2358 fn capture(path: PathBuf) -> Result<Self, GitPublisherConfigError> {
2359 let retained = rustferry_core::RetainedRegularFileIdentity::open(&path)
2360 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2361 let sha256 = caller_git_control_sha256(&path)?;
2362 retained
2363 .verify_path(&path)
2364 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2365 Ok(Self {
2366 path,
2367 retained,
2368 sha256,
2369 })
2370 }
2371
2372 fn verify(&self) -> Result<(), GitPublisherConfigError> {
2373 self.retained
2374 .verify_path(&self.path)
2375 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2376 if caller_git_control_sha256(&self.path)? != self.sha256 {
2377 return Err(GitPublisherConfigError::InvalidDirectory);
2378 }
2379 self.retained
2380 .verify_path(&self.path)
2381 .map_err(|_| GitPublisherConfigError::InvalidDirectory)
2382 }
2383}
2384
2385impl CallerGitOutput {
2386 pub fn success(&self) -> bool {
2388 self.status.success()
2389 }
2390
2391 pub fn exit_code(&self) -> Option<i32> {
2393 self.status.code()
2394 }
2395
2396 pub fn stdout(&self) -> &[u8] {
2398 &self.stdout
2399 }
2400}
2401
2402impl CallerGitRepository {
2403 #[allow(
2410 clippy::too_many_lines,
2411 reason = "the security binding is intentionally one linear pre-spawn validation sequence"
2412 )]
2413 pub fn open(start: impl AsRef<Path>) -> Result<Self, GitPublisherConfigError> {
2414 #[cfg(not(any(unix, windows)))]
2415 {
2416 let _ = start.as_ref();
2417 return Err(GitPublisherConfigError::UnsupportedSecurePlatform);
2418 }
2419 #[cfg(any(unix, windows))]
2420 {
2421 let start = canonical_directory(start.as_ref())?;
2422 let executable = crate::git_process::trusted_git_executable()
2423 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2424 #[cfg(windows)]
2425 let toolchain = crate::git_process::WindowsGitToolchain::new(&executable)
2426 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2427 #[cfg(unix)]
2428 let toolchain = crate::git_process::UnixGitToolchain::new(&executable)
2429 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2430 let private_state =
2431 tempfile::tempdir().map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2432 let state_root = private_state.path().join("state");
2433 let home = state_root.join("home");
2434 let xdg = state_root.join("xdg");
2435 let temp = state_root.join("tmp");
2436 let private_guards =
2437 create_private_git_process_directories([&state_root, &home, &xdg, &temp])?;
2438 let mut repository = Self {
2439 toolchain,
2440 root: start.clone(),
2441 git_dir: None,
2442 common_dir: None,
2443 root_retained: None,
2444 git_dir_retained: None,
2445 common_dir_retained: None,
2446 control_files: Vec::new(),
2447 home,
2448 xdg,
2449 temp,
2450 private_guards,
2451 _private_state: private_state,
2452 };
2453 repository.validate_local_config(&start)?;
2454 let root = repository
2455 .execute_at(
2456 &start,
2457 ["rev-parse", "--show-toplevel"],
2458 &[],
2459 MAX_GIT_OUTPUT_BYTES,
2460 )
2461 .map_err(|_| GitPublisherConfigError::CallerRepositoryReadFailed)?;
2462 if !root.status.success() {
2463 return Err(GitPublisherConfigError::CallerRepositoryReadFailed);
2464 }
2465 let root = parse_caller_git_directory(&root.stdout)?;
2466 if !start.starts_with(&root) {
2467 return Err(GitPublisherConfigError::InvalidDirectory);
2468 }
2469 repository.root = root;
2470 let git_dir = repository
2471 .execute_at(
2472 &repository.root,
2473 ["rev-parse", "--absolute-git-dir"],
2474 &[],
2475 MAX_GIT_OUTPUT_BYTES,
2476 )
2477 .map_err(|_| GitPublisherConfigError::CallerRepositoryReadFailed)?;
2478 if !git_dir.status.success() {
2479 return Err(GitPublisherConfigError::CallerRepositoryReadFailed);
2480 }
2481 let git_dir = parse_caller_git_directory(&git_dir.stdout)?;
2482 let root_retained = rustferry_core::RetainedDirectoryIdentity::open(&repository.root)
2483 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2484 let git_dir_retained = rustferry_core::RetainedDirectoryIdentity::open(&git_dir)
2485 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2486 repository.git_dir = Some(git_dir);
2487 repository.root_retained = Some(root_retained);
2488 repository.git_dir_retained = Some(git_dir_retained);
2489 repository.verify_repository_paths()?;
2490 let common_dir = repository
2491 .execute(["rev-parse", "--git-common-dir"], &[], MAX_GIT_OUTPUT_BYTES)
2492 .map_err(|_| GitPublisherConfigError::CallerRepositoryReadFailed)?;
2493 if !common_dir.status.success() {
2494 return Err(GitPublisherConfigError::CallerRepositoryReadFailed);
2495 }
2496 let common_dir = parse_caller_git_directory_relative(
2497 &common_dir.stdout,
2498 repository.git_dir.as_deref().unwrap_or(&repository.root),
2499 )?;
2500 let common_dir_retained = rustferry_core::RetainedDirectoryIdentity::open(&common_dir)
2501 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2502 repository.common_dir = Some(common_dir.clone());
2503 repository.common_dir_retained = Some(common_dir_retained);
2504 let mut control_paths = BTreeSet::from([common_dir.join("config")]);
2505 let dot_git = repository.root.join(".git");
2506 if fs::symlink_metadata(&dot_git).is_ok_and(|metadata| metadata.is_file()) {
2507 control_paths.insert(dot_git);
2508 }
2509 let common_pointer = repository
2510 .git_dir
2511 .as_ref()
2512 .ok_or(GitPublisherConfigError::InvalidDirectory)?
2513 .join("commondir");
2514 if fs::symlink_metadata(&common_pointer).is_ok_and(|metadata| metadata.is_file()) {
2515 control_paths.insert(common_pointer);
2516 }
2517 repository.control_files = control_paths
2518 .into_iter()
2519 .map(CallerGitControlFile::capture)
2520 .collect::<Result<Vec<_>, _>>()?;
2521 repository.verify_repository_paths()?;
2522 repository.validate_local_config(&repository.root)?;
2523 let rebound = repository
2524 .execute(
2525 ["rev-parse", "--absolute-git-dir"],
2526 &[],
2527 MAX_GIT_OUTPUT_BYTES,
2528 )
2529 .map_err(|_| GitPublisherConfigError::CallerRepositoryReadFailed)?;
2530 if !rebound.status.success()
2531 || parse_caller_git_directory(&rebound.stdout)?
2532 != *repository
2533 .git_dir
2534 .as_ref()
2535 .ok_or(GitPublisherConfigError::InvalidDirectory)?
2536 {
2537 return Err(GitPublisherConfigError::InvalidDirectory);
2538 }
2539 let rebound_common = repository
2540 .execute(["rev-parse", "--git-common-dir"], &[], MAX_GIT_OUTPUT_BYTES)
2541 .map_err(|_| GitPublisherConfigError::CallerRepositoryReadFailed)?;
2542 if !rebound_common.status.success()
2543 || parse_caller_git_directory_relative(
2544 &rebound_common.stdout,
2545 repository
2546 .git_dir
2547 .as_deref()
2548 .ok_or(GitPublisherConfigError::InvalidDirectory)?,
2549 )? != common_dir
2550 {
2551 return Err(GitPublisherConfigError::InvalidDirectory);
2552 }
2553 Ok(repository)
2554 }
2555 }
2556
2557 pub fn root(&self) -> &Path {
2559 &self.root
2560 }
2561
2562 pub fn head_revision(&self) -> Result<CallerGitOutput, GitExecutionError> {
2568 self.execute(["rev-parse", "--verify", "HEAD"], &[], MAX_GIT_OUTPUT_BYTES)
2569 }
2570
2571 pub fn working_tree_status(&self) -> Result<CallerGitOutput, GitExecutionError> {
2577 self.execute(
2578 [
2579 "status",
2580 "--porcelain=v1",
2581 "--untracked-files=all",
2582 "--ignore-submodules=all",
2583 ],
2584 &[],
2585 MAX_CALLER_GIT_OUTPUT_BYTES,
2586 )
2587 }
2588
2589 pub fn tree_entries(
2595 &self,
2596 revision: &str,
2597 paths: &[String],
2598 ) -> Result<CallerGitOutput, GitExecutionError> {
2599 if !is_lower_object_id(revision)
2600 || paths.is_empty()
2601 || paths.len() > 128
2602 || paths.iter().any(|path| !is_safe_caller_git_path(path))
2603 || paths
2604 .iter()
2605 .map(|path| path.len().saturating_add(1))
2606 .sum::<usize>()
2607 > 8 * 1024
2608 {
2609 return Err(GitExecutionError::ProcessIo);
2610 }
2611 let mut arguments = vec![
2612 OsString::from("--literal-pathspecs"),
2613 OsString::from("ls-tree"),
2614 OsString::from("-l"),
2615 OsString::from("-z"),
2616 OsString::from(revision),
2617 OsString::from("--"),
2618 ];
2619 arguments.extend(paths.iter().map(OsString::from));
2620 self.execute(arguments, &[], MAX_CALLER_GIT_OUTPUT_BYTES)
2621 }
2622
2623 pub fn blob_batch(
2629 &self,
2630 object_ids: &[u8],
2631 output_limit: usize,
2632 ) -> Result<CallerGitOutput, GitExecutionError> {
2633 if object_ids.is_empty()
2634 || object_ids.len() > 128 * 41
2635 || output_limit == 0
2636 || output_limit > MAX_CALLER_GIT_OUTPUT_BYTES
2637 || !object_ids
2638 .split(|byte| *byte == b'\n')
2639 .filter(|value| !value.is_empty())
2640 .all(|value| value.len() == 40 && value.iter().copied().all(is_lower_hex_byte))
2641 {
2642 return Err(GitExecutionError::ProcessIo);
2643 }
2644 self.execute(["cat-file", "--batch"], object_ids, output_limit)
2645 }
2646
2647 pub fn current_branch(&self) -> Result<CallerGitOutput, GitExecutionError> {
2653 self.execute(
2654 ["symbolic-ref", "--quiet", "--short", "HEAD"],
2655 &[],
2656 MAX_GIT_OUTPUT_BYTES,
2657 )
2658 }
2659
2660 pub fn check_ignore(&self, path: &str) -> Result<CallerGitOutput, GitExecutionError> {
2666 if !is_safe_caller_git_path(path) {
2667 return Err(GitExecutionError::ProcessIo);
2668 }
2669 self.execute(
2670 ["check-ignore", "--quiet", "--", path],
2671 &[],
2672 MAX_GIT_OUTPUT_BYTES,
2673 )
2674 }
2675
2676 pub fn discover_remote(
2682 &self,
2683 remote_name: &str,
2684 ) -> Result<GithubRemoteSnapshot, GitPublisherConfigError> {
2685 let remote = GitRemoteName::new(remote_name)
2686 .map_err(|_| GitPublisherConfigError::InvalidRemoteName)?;
2687 let output = self
2688 .execute(remote.discovery_arguments(), &[], MAX_GIT_OUTPUT_BYTES)
2689 .map_err(|_| GitPublisherConfigError::RemoteDiscoveryFailed)?;
2690 if !output.status.success() {
2691 return Err(GitPublisherConfigError::RemoteDiscoveryFailed);
2692 }
2693 GithubRemoteSnapshot::from_local_config_output(&remote, &output.stdout)
2694 .map_err(|_| GitPublisherConfigError::RemoteDiscoveryFailed)
2695 }
2696
2697 fn validate_local_config(&self, directory: &Path) -> Result<(), GitPublisherConfigError> {
2698 let output = self
2699 .execute_at(
2700 directory,
2701 [
2702 "config",
2703 "--local",
2704 "--no-includes",
2705 "--null",
2706 "--name-only",
2707 "--list",
2708 ],
2709 &[],
2710 MAX_GIT_OUTPUT_BYTES,
2711 )
2712 .map_err(|_| GitPublisherConfigError::CallerRepositoryReadFailed)?;
2713 if !output.status.success() {
2714 return Err(GitPublisherConfigError::CallerRepositoryReadFailed);
2715 }
2716 validate_caller_git_config_names(&output.stdout)
2717 }
2718
2719 fn execute(
2720 &self,
2721 arguments: impl IntoIterator<Item = impl Into<OsString>>,
2722 stdin: &[u8],
2723 output_limit: usize,
2724 ) -> Result<CallerGitOutput, GitExecutionError> {
2725 self.execute_at(&self.root, arguments, stdin, output_limit)
2726 }
2727
2728 fn execute_at(
2729 &self,
2730 directory: &Path,
2731 arguments: impl IntoIterator<Item = impl Into<OsString>>,
2732 stdin: &[u8],
2733 output_limit: usize,
2734 ) -> Result<CallerGitOutput, GitExecutionError> {
2735 self.execute_at_with_pre_spawn_hook(directory, arguments, stdin, output_limit, || {})
2736 }
2737
2738 fn execute_at_with_pre_spawn_hook(
2739 &self,
2740 directory: &Path,
2741 arguments: impl IntoIterator<Item = impl Into<OsString>>,
2742 stdin: &[u8],
2743 output_limit: usize,
2744 pre_spawn_hook: impl FnOnce(),
2745 ) -> Result<CallerGitOutput, GitExecutionError> {
2746 if stdin.len() > 128 * 41 || output_limit == 0 || output_limit > MAX_CALLER_GIT_OUTPUT_BYTES
2747 {
2748 return Err(GitExecutionError::ProcessIo);
2749 }
2750 #[cfg(not(any(unix, windows)))]
2751 {
2752 let _ = (directory, arguments, stdin, output_limit);
2753 Err(GitExecutionError::ProcessIo)
2754 }
2755 #[cfg(any(unix, windows))]
2756 {
2757 verify_private_git_process_directories(&self.private_guards)
2758 .map_err(|_| GitExecutionError::ProcessIo)?;
2759 self.verify_repository_paths()
2760 .map_err(|_| GitExecutionError::ProcessIo)?;
2761 let context = crate::git_process::GitProcessContext::new(
2762 directory, None, &self.home, &self.xdg, &self.temp,
2763 )
2764 .map_err(|_| GitExecutionError::ProcessIo)?;
2765 let mut arguments = arguments.into_iter().map(Into::into).collect::<Vec<_>>();
2766 if let Some(git_dir) = &self.git_dir {
2767 let mut bound = vec![
2768 OsString::from("--git-dir"),
2769 caller_git_argument_path(git_dir),
2770 OsString::from("--work-tree"),
2771 caller_git_argument_path(&self.root),
2772 ];
2773 bound.append(&mut arguments);
2774 arguments = bound;
2775 }
2776 let spec = self
2777 .toolchain
2778 .process_spec(&context, GitNetworkPolicy::Offline, arguments)
2779 .map_err(|_| GitExecutionError::ProcessIo)?;
2780 let mut command = spec.command().map_err(|_| GitExecutionError::ProcessIo)?;
2781 command
2782 .stdin(Stdio::piped())
2783 .stdout(Stdio::piped())
2784 .stderr(Stdio::piped());
2785 pre_spawn_hook();
2786 verify_private_git_process_directories(&self.private_guards)
2787 .map_err(|_| GitExecutionError::ProcessIo)?;
2788 self.verify_repository_paths()
2789 .map_err(|_| GitExecutionError::ProcessIo)?;
2790 let output =
2791 run_git_process_allow_status(command, stdin, MAX_GIT_TIMEOUT, output_limit)?;
2792 verify_private_git_process_directories(&self.private_guards)
2793 .map_err(|_| GitExecutionError::ProcessIo)?;
2794 self.verify_repository_paths()
2795 .map_err(|_| GitExecutionError::ProcessIo)?;
2796 Ok(CallerGitOutput {
2797 status: output.status,
2798 stdout: output.stdout,
2799 })
2800 }
2801 }
2802
2803 fn verify_repository_paths(&self) -> Result<(), GitPublisherConfigError> {
2804 match (
2805 self.git_dir.as_ref(),
2806 self.root_retained.as_ref(),
2807 self.git_dir_retained.as_ref(),
2808 ) {
2809 (None, None, None) => Ok(()),
2810 (Some(git_dir), Some(root), Some(retained_git_dir)) => {
2811 root.verify_path(&self.root)
2812 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2813 retained_git_dir
2814 .verify_path(git_dir)
2815 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2816 match (&self.common_dir, &self.common_dir_retained) {
2817 (Some(common_dir), Some(retained)) => retained
2818 .verify_path(common_dir)
2819 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?,
2820 (None, None) => {}
2821 _ => return Err(GitPublisherConfigError::InvalidDirectory),
2822 }
2823 for control in &self.control_files {
2824 control.verify()?;
2825 }
2826 Ok(())
2827 }
2828 _ => Err(GitPublisherConfigError::InvalidDirectory),
2829 }
2830 }
2831}
2832
2833fn create_private_git_process_directories<'a>(
2834 paths: impl IntoIterator<Item = &'a PathBuf>,
2835) -> Result<Vec<std::fs::File>, GitPublisherConfigError> {
2836 #[cfg(windows)]
2837 let mut guards = Vec::new();
2838 #[cfg(not(windows))]
2839 let guards = Vec::new();
2840 #[cfg(windows)]
2841 for path in paths {
2842 guards.push(
2843 rustferry_core::windows_private_directory::create_private_directory(path)
2844 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?,
2845 );
2846 }
2847 #[cfg(unix)]
2848 for path in paths {
2849 use std::os::unix::fs::DirBuilderExt as _;
2850
2851 let mut builder = fs::DirBuilder::new();
2852 builder.mode(0o700);
2853 builder
2854 .create(path)
2855 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2856 }
2857 #[cfg(not(any(unix, windows)))]
2858 {
2859 let _ = paths;
2860 return Err(GitPublisherConfigError::UnsupportedSecurePlatform);
2861 }
2862 Ok(guards)
2863}
2864
2865fn verify_private_git_process_directories(
2866 guards: &[std::fs::File],
2867) -> Result<(), GitPublisherConfigError> {
2868 #[cfg(windows)]
2869 {
2870 use std::os::windows::io::AsHandle as _;
2871
2872 for guard in guards {
2873 rustferry_core::windows_private_directory::verify_private_directory_handle(
2874 guard.as_handle(),
2875 )
2876 .map_err(|_| GitPublisherConfigError::SecureIsolationFailed)?;
2877 }
2878 }
2879 #[cfg(not(windows))]
2880 if !guards.is_empty() {
2881 return Err(GitPublisherConfigError::SecureIsolationFailed);
2882 }
2883 Ok(())
2884}
2885
2886fn parse_caller_git_directory(output: &[u8]) -> Result<PathBuf, GitPublisherConfigError> {
2887 let value = std::str::from_utf8(output)
2888 .map_err(|_| GitPublisherConfigError::CallerRepositoryReadFailed)?
2889 .trim();
2890 if value.is_empty() || value.contains(['\r', '\n']) {
2891 return Err(GitPublisherConfigError::CallerRepositoryReadFailed);
2892 }
2893 canonical_directory(Path::new(value))
2894}
2895
2896fn caller_git_argument_path(path: &Path) -> OsString {
2897 #[cfg(windows)]
2898 {
2899 crate::git_process::external_path(path)
2900 .as_os_str()
2901 .to_owned()
2902 }
2903 #[cfg(not(windows))]
2904 path.as_os_str().to_owned()
2905}
2906
2907fn parse_caller_git_directory_relative(
2908 output: &[u8],
2909 relative_to: &Path,
2910) -> Result<PathBuf, GitPublisherConfigError> {
2911 let value = std::str::from_utf8(output)
2912 .map_err(|_| GitPublisherConfigError::CallerRepositoryReadFailed)?
2913 .trim();
2914 if value.is_empty() || value.contains(['\r', '\n']) {
2915 return Err(GitPublisherConfigError::CallerRepositoryReadFailed);
2916 }
2917 let path = Path::new(value);
2918 let resolved = if path.is_absolute() {
2919 path.to_path_buf()
2920 } else {
2921 relative_to.join(path)
2922 };
2923 canonical_directory(&resolved)
2924}
2925
2926fn caller_git_control_sha256(path: &Path) -> Result<String, GitPublisherConfigError> {
2927 let metadata =
2928 fs::symlink_metadata(path).map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2929 if !metadata.is_file()
2930 || metadata.file_type().is_symlink()
2931 || metadata.len() > MAX_CALLER_GIT_CONTROL_BYTES
2932 {
2933 return Err(GitPublisherConfigError::InvalidDirectory);
2934 }
2935 let bytes = fs::read(path).map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
2936 if u64::try_from(bytes.len()).ok() != Some(metadata.len()) {
2937 return Err(GitPublisherConfigError::InvalidDirectory);
2938 }
2939 Ok(hex::encode(Sha256::digest(bytes)))
2940}
2941
2942#[allow(
2943 clippy::case_sensitive_file_extension_comparisons,
2944 reason = "the suffix is a Git config-key component, not a file extension"
2945)]
2946fn validate_caller_git_config_names(output: &[u8]) -> Result<(), GitPublisherConfigError> {
2947 if output.len() > MAX_GIT_OUTPUT_BYTES {
2948 return Err(GitPublisherConfigError::UnsafeCallerRepositoryConfig);
2949 }
2950 if output.is_empty() {
2951 return Ok(());
2952 }
2953 let Some(output) = output.strip_suffix(&[0]) else {
2954 return Err(GitPublisherConfigError::UnsafeCallerRepositoryConfig);
2955 };
2956 for raw in output.split(|byte| *byte == 0) {
2957 let key = std::str::from_utf8(raw)
2958 .map_err(|_| GitPublisherConfigError::UnsafeCallerRepositoryConfig)?;
2959 if key.is_empty() || key.len() > 512 || key.chars().any(char::is_control) {
2960 return Err(GitPublisherConfigError::UnsafeCallerRepositoryConfig);
2961 }
2962 let key = key.to_ascii_lowercase();
2963 let external_diff = key == "diff.external"
2964 || key.starts_with("diff.")
2965 && (key.ends_with(".command") || key.ends_with(".textconv"));
2966 let partial_clone_remote = key.starts_with("remote.")
2967 && (key.ends_with(".promisor") || key.ends_with(".partialclonefilter"));
2968 if key == "include.path"
2969 || key.starts_with("includeif.") && key.ends_with(".path")
2970 || key == "extensions.worktreeconfig"
2971 || key == "extensions.partialclone"
2972 || key == "core.worktree"
2973 || key.starts_with("filter.")
2974 || key.starts_with("protocol.")
2975 || partial_clone_remote
2976 || external_diff
2977 {
2978 return Err(GitPublisherConfigError::UnsafeCallerRepositoryConfig);
2979 }
2980 }
2981 Ok(())
2982}
2983
2984fn is_lower_object_id(value: &str) -> bool {
2985 value.len() == 40 && value.bytes().all(is_lower_hex_byte)
2986}
2987
2988const fn is_lower_hex_byte(byte: u8) -> bool {
2989 byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
2990}
2991
2992fn is_safe_caller_git_path(value: &str) -> bool {
2993 !value.is_empty()
2994 && value.len() <= 4_096
2995 && !value.starts_with(['/', '\\', '-'])
2996 && !value.contains(['\0', '\r', '\n', '\\'])
2997 && value
2998 .split('/')
2999 .all(|component| !component.is_empty() && !matches!(component, "." | ".."))
3000}
3001
3002impl GitRunner for GitProcessRunner {
3003 fn execute(&mut self, invocation: &GitInvocation) -> Result<Vec<u8>, GitExecutionError> {
3004 #[cfg(not(any(unix, windows)))]
3005 {
3006 let _ = invocation;
3007 return Err(GitExecutionError::ProcessIo);
3008 }
3009 #[cfg(any(unix, windows))]
3010 {
3011 let context = self
3012 .repository
3013 .process_context()
3014 .map_err(|_| GitExecutionError::ProcessIo)?;
3015 let spec = self
3016 .toolchain
3017 .process_spec(
3018 &context,
3019 invocation.network,
3020 invocation.arguments.iter().cloned(),
3021 )
3022 .map_err(|_| GitExecutionError::ProcessIo)?;
3023 let mut command = spec.command().map_err(|_| GitExecutionError::ProcessIo)?;
3024 command
3025 .stdin(Stdio::piped())
3026 .stdout(Stdio::piped())
3027 .stderr(Stdio::piped());
3028 for (variable, value) in &invocation.environment {
3029 command.env(variable.as_str(), value);
3030 }
3031 run_git_process(
3032 command,
3033 &invocation.stdin,
3034 invocation.timeout,
3035 invocation.stdout_limit,
3036 )
3037 }
3038 }
3039
3040 fn execute_file(
3041 &mut self,
3042 invocation: &GitInvocation,
3043 input: fs::File,
3044 expected_len: u64,
3045 ) -> Result<Vec<u8>, GitExecutionError> {
3046 #[cfg(not(any(unix, windows)))]
3047 {
3048 let _ = (invocation, input, expected_len);
3049 return Err(GitExecutionError::ProcessIo);
3050 }
3051 #[cfg(any(unix, windows))]
3052 {
3053 if !invocation.stdin.is_empty() {
3054 return Err(GitExecutionError::ProcessIo);
3055 }
3056 let context = self
3057 .repository
3058 .process_context()
3059 .map_err(|_| GitExecutionError::ProcessIo)?;
3060 let spec = self
3061 .toolchain
3062 .process_spec(
3063 &context,
3064 invocation.network,
3065 invocation.arguments.iter().cloned(),
3066 )
3067 .map_err(|_| GitExecutionError::ProcessIo)?;
3068 let mut command = spec.command().map_err(|_| GitExecutionError::ProcessIo)?;
3069 command
3070 .stdin(Stdio::piped())
3071 .stdout(Stdio::piped())
3072 .stderr(Stdio::piped());
3073 for (variable, value) in &invocation.environment {
3074 command.env(variable.as_str(), value);
3075 }
3076 run_git_process_file(
3077 command,
3078 input,
3079 expected_len,
3080 invocation.timeout,
3081 invocation.stdout_limit,
3082 )
3083 }
3084 }
3085
3086 fn process_tree_is_parent_fenced(&self) -> bool {
3087 cfg!(windows)
3088 }
3089}
3090
3091#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3093pub enum GitPublisherConfigError {
3094 InvalidGitExecutable,
3096 InvalidDirectory,
3098 InvalidRemoteName,
3100 InvalidTimeout,
3102 SecureIsolationFailed,
3104 RemoteDiscoveryFailed,
3106 CallerRepositoryReadFailed,
3108 UnsafeCallerRepositoryConfig,
3110 UnsupportedSecurePlatform,
3112}
3113
3114impl fmt::Display for GitPublisherConfigError {
3115 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3116 match self {
3117 Self::InvalidGitExecutable => formatter.write_str("Git executable is invalid"),
3118 Self::InvalidDirectory => formatter.write_str("Git publisher directory is invalid"),
3119 Self::InvalidRemoteName => formatter.write_str("Git remote name is invalid"),
3120 Self::InvalidTimeout => formatter.write_str("Git operation timeout is invalid"),
3121 Self::SecureIsolationFailed => {
3122 formatter.write_str("secure Git publisher isolation could not be established")
3123 }
3124 Self::RemoteDiscoveryFailed => {
3125 formatter.write_str("Git remote could not be captured safely")
3126 }
3127 Self::CallerRepositoryReadFailed => {
3128 formatter.write_str("caller Git repository could not be read safely")
3129 }
3130 Self::UnsafeCallerRepositoryConfig => {
3131 formatter.write_str("caller Git repository config is unsafe")
3132 }
3133 Self::UnsupportedSecurePlatform => {
3134 formatter.write_str("secure Git publication is unsupported on this platform")
3135 }
3136 }
3137 }
3138}
3139
3140impl Error for GitPublisherConfigError {}
3141
3142#[derive(Debug)]
3148pub struct GitTemporaryRefPublisher<R> {
3149 runner: R,
3150 snapshot_imports: BTreeMap<String, GitSnapshotImportInputs>,
3151 isolation_directory: PathBuf,
3152 #[cfg(windows)]
3153 _isolation_directory_guard: std::fs::File,
3154 isolation_directory_identity: rustferry_core::DirectoryFilesystemIdentity,
3155 publication_lease_scope_sha256: String,
3156 publication_leases: BTreeMap<String, rustferry_core::process_control::ProcessFileLease>,
3157 #[cfg(test)]
3158 source_remote_name: String,
3159 #[cfg(test)]
3160 execution_remote_name: String,
3161 frozen_remotes: Option<VerifiedPublisherRemotes>,
3162 timeout: Duration,
3163}
3164
3165#[derive(Clone, Debug, Eq, PartialEq)]
3166struct VerifiedGitRemote {
3167 fetch: GithubGitEndpoint,
3168 push: GithubGitEndpoint,
3169}
3170
3171#[derive(Clone, Debug, Eq, PartialEq)]
3172struct VerifiedPublisherRemotes {
3173 source: VerifiedGitRemote,
3174 execution: VerifiedGitRemote,
3175}
3176
3177fn execution_readiness_ref(
3178 remote: &VerifiedGitRemote,
3179 trusted_tip: &CommitSha,
3180 namespace: &TemporaryBranchNamespace,
3181) -> String {
3182 let counter = INDEX_RESERVATION_COUNTER.fetch_add(1, Ordering::Relaxed);
3183 let now = SystemTime::now()
3184 .duration_since(UNIX_EPOCH)
3185 .unwrap_or_default()
3186 .as_nanos();
3187 let mut digest = Sha256::new();
3188 digest.update(b"rustferry-github-execution-readiness-ref-v1\0");
3189 digest.update(remote.fetch.canonical_url().as_bytes());
3190 digest.update(b"\0");
3191 digest.update(remote.push.canonical_url().as_bytes());
3192 digest.update(b"\0");
3193 digest.update(trusted_tip.as_str().as_bytes());
3194 digest.update(b"\0");
3195 digest.update(namespace.as_str().as_bytes());
3196 digest.update(b"\0");
3197 digest.update(std::process::id().to_le_bytes());
3198 digest.update(counter.to_le_bytes());
3199 digest.update(now.to_le_bytes());
3200 let encoded = hex::encode(digest.finalize());
3201 format!(
3202 "refs/heads/{}/readiness-{}",
3203 namespace.as_str(),
3204 &encoded[..16]
3205 )
3206}
3207
3208fn temporary_namespace_for_publication(
3209 temporary_ref: &TemporaryGitRef,
3210 operation_id: &str,
3211) -> Result<TemporaryBranchNamespace, TemporaryRefPublishError> {
3212 let suffix = format!("/{operation_id}");
3213 let namespace = temporary_ref
3214 .branch()
3215 .as_str()
3216 .strip_suffix(&suffix)
3217 .filter(|value| !value.is_empty())
3218 .ok_or(TemporaryRefPublishError::MalformedGitOutput)?;
3219 TemporaryBranchNamespace::new(namespace.to_owned())
3220 .map_err(|_| TemporaryRefPublishError::MalformedGitOutput)
3221}
3222
3223impl<R> GitTemporaryRefPublisher<R> {
3224 fn initialize(
3225 runner: R,
3226 isolation_directory: impl AsRef<Path>,
3227 timeout: Duration,
3228 ) -> Result<Self, GitPublisherConfigError> {
3229 let isolation_directory = canonical_directory(isolation_directory.as_ref())?;
3230 if timeout.is_zero() || timeout > MAX_GIT_TIMEOUT {
3231 return Err(GitPublisherConfigError::InvalidTimeout);
3232 }
3233 #[cfg(windows)]
3234 let isolation_directory_guard =
3235 rustferry_core::windows_private_directory::open_private_directory_read_guard(
3236 &isolation_directory,
3237 )
3238 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
3239 let isolation_directory_identity =
3240 rustferry_core::DirectoryFilesystemIdentity::capture(&isolation_directory)
3241 .map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
3242 Ok(Self {
3243 runner,
3244 snapshot_imports: BTreeMap::new(),
3245 publication_lease_scope_sha256: publication_lease_scope_sha256(
3246 &isolation_directory,
3247 &isolation_directory_identity,
3248 ),
3249 isolation_directory,
3250 #[cfg(windows)]
3251 _isolation_directory_guard: isolation_directory_guard,
3252 isolation_directory_identity,
3253 publication_leases: BTreeMap::new(),
3254 #[cfg(test)]
3255 source_remote_name: String::new(),
3256 #[cfg(test)]
3257 execution_remote_name: String::new(),
3258 frozen_remotes: None,
3259 timeout,
3260 })
3261 }
3262
3263 #[cfg(test)]
3269 pub fn new(
3270 runner: R,
3271 isolation_directory: impl AsRef<Path>,
3272 source_remote_name: impl Into<String>,
3273 execution_remote_name: impl Into<String>,
3274 timeout: Duration,
3275 ) -> Result<Self, GitPublisherConfigError> {
3276 let source_remote_name = source_remote_name.into();
3277 let execution_remote_name = execution_remote_name.into();
3278 if !is_safe_remote_name(&source_remote_name) || !is_safe_remote_name(&execution_remote_name)
3279 {
3280 return Err(GitPublisherConfigError::InvalidRemoteName);
3281 }
3282 let mut publisher = Self::initialize(runner, isolation_directory, timeout)?;
3283 publisher.source_remote_name = source_remote_name;
3284 publisher.execution_remote_name = execution_remote_name;
3285 Ok(publisher)
3286 }
3287
3288 pub fn new_with_endpoints(
3297 runner: R,
3298 isolation_directory: impl AsRef<Path>,
3299 source: &GithubRemoteSnapshot,
3300 execution: &GithubRemoteSnapshot,
3301 timeout: Duration,
3302 ) -> Result<Self, GitPublisherConfigError> {
3303 let mut publisher = Self::initialize(runner, isolation_directory, timeout)?;
3304 publisher.frozen_remotes = Some(VerifiedPublisherRemotes {
3305 source: VerifiedGitRemote {
3306 fetch: source.fetch().clone(),
3307 push: source.push().clone(),
3308 },
3309 execution: VerifiedGitRemote {
3310 fetch: execution.fetch().clone(),
3311 push: execution.push().clone(),
3312 },
3313 });
3314 Ok(publisher)
3315 }
3316
3317 pub fn into_runner(self) -> R {
3319 self.runner
3320 }
3321}
3322
3323impl<R: GitRunner> GitTemporaryRefPublisher<R> {
3324 #[cfg(test)]
3325 fn execute(
3326 &mut self,
3327 arguments: impl IntoIterator<Item = impl Into<OsString>>,
3328 ) -> Result<Vec<u8>, TemporaryRefPublishError> {
3329 self.runner
3330 .execute(&GitInvocation::new(arguments, self.timeout))
3331 .map_err(Into::into)
3332 }
3333
3334 fn execute_invocation(
3335 &mut self,
3336 invocation: &GitInvocation,
3337 ) -> Result<Vec<u8>, TemporaryRefPublishError> {
3338 self.runner.execute(invocation).map_err(Into::into)
3339 }
3340
3341 fn execute_without_replacements(
3342 &mut self,
3343 arguments: impl IntoIterator<Item = impl Into<OsString>>,
3344 ) -> Result<Vec<u8>, TemporaryRefPublishError> {
3345 self.execute_invocation(&GitInvocation::new(arguments, self.timeout))
3346 }
3347
3348 #[cfg(test)]
3349 fn verify_remote(
3350 &mut self,
3351 remote_name: &str,
3352 expected_repository: &str,
3353 ) -> Result<VerifiedGitRemote, TemporaryRefPublishError> {
3354 let fetch_output = self.execute(["remote", "get-url", remote_name])?;
3355 let fetch = GithubGitEndpoint::parse(parse_single_line(&fetch_output)?)
3356 .map_err(|_| TemporaryRefPublishError::RemoteMismatch)?;
3357 let push_output = self.execute(["remote", "get-url", "--push", remote_name])?;
3358 let push = GithubGitEndpoint::parse(parse_single_line(&push_output)?)
3359 .map_err(|_| TemporaryRefPublishError::RemoteMismatch)?;
3360 if !github_remote_matches(fetch.canonical_url(), expected_repository)
3361 || !github_remote_matches(push.canonical_url(), expected_repository)
3362 || fetch.repository() != push.repository()
3363 {
3364 return Err(TemporaryRefPublishError::RemoteMismatch);
3365 }
3366 Ok(VerifiedGitRemote { fetch, push })
3367 }
3368
3369 fn remote_ref(
3370 &mut self,
3371 endpoint: &GithubGitEndpoint,
3372 full_ref: &str,
3373 ) -> Result<Option<CommitSha>, TemporaryRefPublishError> {
3374 let invocation = GitInvocation::new(
3375 ["ls-remote", "--refs", endpoint.canonical_url(), full_ref],
3376 self.timeout,
3377 )
3378 .with_network(endpoint);
3379 let output = self.execute_invocation(&invocation)?;
3380 parse_ls_remote(&output, full_ref)
3381 }
3382
3383 fn execution_remote(
3384 &mut self,
3385 repository: &Repository,
3386 ) -> Result<VerifiedGitRemote, TemporaryRefPublishError> {
3387 let expected = repository_url(repository);
3388 if let Some(remotes) = &self.frozen_remotes {
3389 if !github_remote_matches(remotes.execution.fetch.canonical_url(), &expected)
3390 || !github_remote_matches(remotes.execution.push.canonical_url(), &expected)
3391 {
3392 return Err(TemporaryRefPublishError::RemoteMismatch);
3393 }
3394 return Ok(remotes.execution.clone());
3395 }
3396 #[cfg(test)]
3397 {
3398 let execution_remote_name = self.execution_remote_name.clone();
3399 self.verify_remote(&execution_remote_name, &expected)
3400 }
3401 #[cfg(not(test))]
3402 {
3403 Err(TemporaryRefPublishError::RemoteMismatch)
3404 }
3405 }
3406
3407 fn verify_execution_workflow_bytes(
3408 &mut self,
3409 request: &ExecutionWorkflowDoctorRequest<'_>,
3410 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError> {
3411 validate_repository_path(request.workflow_path)
3412 .map_err(|()| TemporaryRefPublishError::MalformedGitOutput)?;
3413 let remote = self.execution_remote(request.repository)?;
3414 let tip = self
3415 .remote_ref(&remote.fetch, request.default_branch_ref.as_str())?
3416 .ok_or(TemporaryRefPublishError::TrustedRefUnavailable)?;
3417 let fetch = GitInvocation::new(
3418 [
3419 "fetch",
3420 "--quiet",
3421 "--no-tags",
3422 "--no-write-fetch-head",
3423 remote.fetch.canonical_url(),
3424 request.default_branch_ref.as_str(),
3425 ],
3426 self.timeout,
3427 )
3428 .with_network(&remote.fetch);
3429 self.execute_invocation(&fetch)?;
3430 if self
3431 .remote_ref(&remote.fetch, request.default_branch_ref.as_str())?
3432 .as_ref()
3433 != Some(&tip)
3434 {
3435 return Err(TemporaryRefPublishError::TrustedRefUnavailable);
3436 }
3437 let workflow = self
3438 .execute_without_replacements([
3439 "show",
3440 &format!("{}:{}", tip.as_str(), request.workflow_path),
3441 ])
3442 .map_err(|_| TemporaryRefPublishError::WorkflowFingerprintMismatch)?;
3443 if workflow.len() > MAX_DISPATCH_FILE_BYTES
3444 || WorkflowFingerprint::for_workflow_bytes(&workflow) != *request.workflow_fingerprint
3445 {
3446 return Err(TemporaryRefPublishError::WorkflowFingerprintMismatch);
3447 }
3448 Ok(TemporaryRefPublisherReadiness {
3449 trusted_ref_tip: tip,
3450 })
3451 }
3452
3453 fn doctor_with_remote(
3454 &mut self,
3455 request: &TemporaryRefDoctorRequest<'_>,
3456 ) -> Result<(TemporaryRefPublisherReadiness, VerifiedPublisherRemotes), TemporaryRefPublishError>
3457 {
3458 validate_repository_path(request.workflow_path)
3459 .map_err(|()| TemporaryRefPublishError::MalformedGitOutput)?;
3460 let execution_repository = repository_url(request.repository);
3461 let remotes = if let Some(remotes) = &self.frozen_remotes {
3462 if !github_remote_matches(
3463 remotes.source.fetch.canonical_url(),
3464 request.source_repository,
3465 ) || !github_remote_matches(
3466 remotes.source.push.canonical_url(),
3467 request.source_repository,
3468 ) || !github_remote_matches(
3469 remotes.execution.fetch.canonical_url(),
3470 &execution_repository,
3471 ) || !github_remote_matches(
3472 remotes.execution.push.canonical_url(),
3473 &execution_repository,
3474 ) {
3475 return Err(TemporaryRefPublishError::RemoteMismatch);
3476 }
3477 remotes.clone()
3478 } else {
3479 #[cfg(test)]
3480 {
3481 let source_remote_name = self.source_remote_name.clone();
3482 let execution_remote_name = self.execution_remote_name.clone();
3483 let source = self.verify_remote(&source_remote_name, request.source_repository)?;
3484 let execution = if source_remote_name == execution_remote_name
3485 && github_remote_matches(request.source_repository, &execution_repository)
3486 {
3487 source.clone()
3488 } else {
3489 self.verify_remote(&execution_remote_name, &execution_repository)?
3490 };
3491 VerifiedPublisherRemotes { source, execution }
3492 }
3493 #[cfg(not(test))]
3494 {
3495 return Err(TemporaryRefPublishError::RemoteMismatch);
3496 }
3497 };
3498 let tip = self
3499 .remote_ref(&remotes.source.fetch, request.trusted_source_ref.as_str())?
3500 .ok_or(TemporaryRefPublishError::TrustedRefUnavailable)?;
3501 let fetch = GitInvocation::new(
3502 [
3503 "fetch",
3504 "--quiet",
3505 "--no-tags",
3506 "--no-write-fetch-head",
3507 remotes.source.fetch.canonical_url(),
3508 request.trusted_source_ref.as_str(),
3509 ],
3510 self.timeout,
3511 )
3512 .with_network(&remotes.source.fetch);
3513 self.execute_invocation(&fetch)?;
3514 let trusted_workflow = self
3515 .execute_without_replacements([
3516 "show",
3517 &format!("{}:{}", tip.as_str(), request.workflow_path),
3518 ])
3519 .map_err(|_| TemporaryRefPublishError::WorkflowFingerprintMismatch)?;
3520 if WorkflowFingerprint::for_workflow_bytes(&trusted_workflow)
3521 != *request.workflow_fingerprint
3522 {
3523 return Err(TemporaryRefPublishError::WorkflowFingerprintMismatch);
3524 }
3525 if self.frozen_remotes.is_some() {
3526 self.prove_execution_readiness(
3527 &remotes.execution,
3528 &tip,
3529 request.temporary_branch_namespace,
3530 )?;
3531 }
3532 Ok((
3533 TemporaryRefPublisherReadiness {
3534 trusted_ref_tip: tip,
3535 },
3536 remotes,
3537 ))
3538 }
3539
3540 fn prove_execution_readiness(
3541 &mut self,
3542 execution: &VerifiedGitRemote,
3543 trusted_tip: &CommitSha,
3544 namespace: &TemporaryBranchNamespace,
3545 ) -> Result<(), TemporaryRefPublishError> {
3546 let readiness_ref = execution_readiness_ref(execution, trusted_tip, namespace);
3547 if self.remote_ref(&execution.fetch, &readiness_ref)?.is_some()
3548 || self.remote_ref(&execution.push, &readiness_ref)?.is_some()
3549 {
3550 return Err(TemporaryRefPublishError::PublicationVerificationFailed);
3551 }
3552 let lease = format!("--force-with-lease={readiness_ref}:");
3553 let refspec = format!("{}:{readiness_ref}", trusted_tip.as_str());
3554 let dry_run = GitInvocation::new(
3555 [
3556 "push",
3557 "--dry-run",
3558 "--porcelain",
3559 "--no-verify",
3560 &lease,
3561 execution.push.canonical_url(),
3562 &refspec,
3563 ],
3564 self.timeout,
3565 )
3566 .with_network(&execution.push);
3567 self.execute_invocation(&dry_run)?;
3568 if self.remote_ref(&execution.push, &readiness_ref)?.is_some() {
3569 return Err(TemporaryRefPublishError::PublicationVerificationFailed);
3570 }
3571 Ok(())
3572 }
3573
3574 fn index_invocation(
3575 &self,
3576 arguments: impl IntoIterator<Item = impl Into<OsString>>,
3577 index_path: &Path,
3578 ) -> GitInvocation {
3579 GitInvocation::new(arguments, self.timeout)
3580 .with_environment(GitEnvironmentVariable::IndexFile, index_path.as_os_str())
3581 }
3582
3583 fn hash_blob(&mut self, bytes: &[u8]) -> Result<String, TemporaryRefPublishError> {
3584 let invocation =
3585 GitInvocation::new(["hash-object", "-w", "--stdin"], self.timeout).with_stdin(bytes);
3586 parse_object_id(&self.execute_invocation(&invocation)?)
3587 }
3588
3589 fn require_snapshot_sha1(&mut self) -> Result<(), TemporaryRefPublishError> {
3590 let output = self.execute_without_replacements(["rev-parse", "--show-object-format"])?;
3591 require_sha1_object_format(&output).map_err(Into::into)
3592 }
3593
3594 fn hash_snapshot_bytes(
3595 &mut self,
3596 kind: GitSnapshotObjectKind,
3597 bytes: &[u8],
3598 write: bool,
3599 ) -> Result<GitSha1ObjectId, TemporaryRefPublishError> {
3600 let mut arguments = vec![OsString::from("hash-object")];
3601 if write {
3602 arguments.push(OsString::from("-w"));
3603 }
3604 if kind != GitSnapshotObjectKind::Blob {
3605 arguments.push(OsString::from("-t"));
3606 arguments.push(OsString::from(kind.git_type()));
3607 }
3608 arguments.push(OsString::from("--stdin"));
3609 let invocation = GitInvocation::new(arguments, self.timeout).with_stdin(bytes);
3610 let object = parse_object_id(&self.execute_invocation(&invocation)?)?;
3611 GitSha1ObjectId::new(object).map_err(Into::into)
3612 }
3613
3614 fn hash_snapshot_file(
3615 &mut self,
3616 mut file: fs::File,
3617 expected_len: u64,
3618 write: bool,
3619 ) -> Result<GitSha1ObjectId, TemporaryRefPublishError> {
3620 file.seek(io::SeekFrom::Start(0))
3621 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3622 let arguments = if write {
3623 ["hash-object", "-w", "--stdin"].as_slice()
3624 } else {
3625 ["hash-object", "--stdin"].as_slice()
3626 };
3627 let invocation = GitInvocation::new(arguments, self.timeout);
3628 let output = self
3629 .runner
3630 .execute_file(&invocation, file, expected_len)
3631 .map_err(TemporaryRefPublishError::from)?;
3632 GitSha1ObjectId::new(parse_object_id(&output)?).map_err(Into::into)
3633 }
3634
3635 fn precompute_snapshot_graph(
3636 &mut self,
3637 inputs: &mut GitSnapshotPrecomputeInputs,
3638 operation_id: &str,
3639 created_at_ms: u64,
3640 ) -> Result<GitSnapshotObjectGraphV1, TemporaryRefPublishError> {
3641 self.require_snapshot_sha1()?;
3642 let archive_size = inputs.expected_archive().size;
3643 let archive_file = inputs
3644 .archive_reader()?
3645 .try_clone()
3646 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3647 let archive_blob = self.hash_snapshot_file(archive_file, archive_size, false)?;
3648 inputs.verify_contents()?;
3649 let descriptor_bytes = inputs.descriptor_bytes().to_vec();
3650 let descriptor_blob =
3651 self.hash_snapshot_bytes(GitSnapshotObjectKind::Blob, &descriptor_bytes, false)?;
3652 inputs.verify_contents()?;
3653 let graph = complete_git_snapshot_object_graph(
3654 archive_blob,
3655 descriptor_blob,
3656 operation_id,
3657 created_at_ms,
3658 |kind, bytes| {
3659 let object = self.hash_snapshot_bytes(kind, bytes, false)?;
3660 inputs.verify_contents()?;
3661 Ok::<_, TemporaryRefPublishError>(object)
3662 },
3663 )?;
3664 inputs.verify_contents()?;
3665 Ok(graph)
3666 }
3667
3668 fn import_snapshot_graph(
3669 &mut self,
3670 request: &GitSnapshotImportRequest<'_>,
3671 ) -> Result<GitSnapshotObjectGraphV1, TemporaryRefPublishError> {
3672 if let Some(inputs) = self.snapshot_imports.get_mut(&request.request.operation_id) {
3673 if inputs.locator() != request.locator || inputs.stage() != request.expected_stage {
3674 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
3675 }
3676 inputs.verify_bindings()?;
3677 return Ok(inputs.stage().graph.clone());
3678 }
3679 self.require_snapshot_sha1()?;
3680 let mut inputs = GitSnapshotImportInputs::load(
3681 &self.isolation_directory,
3682 request.locator,
3683 request.request,
3684 )?;
3685 if inputs.stage() != request.expected_stage {
3686 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
3687 }
3688 let graph = inputs.stage().graph.clone();
3689 let created_at_ms = inputs.stage().source_created_at_ms;
3690 let archive_size = inputs.stage().archive.size;
3691 let archive_file = inputs
3692 .archive_reader()?
3693 .try_clone()
3694 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3695 let archive_blob = self.hash_snapshot_file(archive_file, archive_size, true)?;
3696 inputs.verify_imported_archive_blob(&archive_blob)?;
3697 let descriptor_bytes = inputs.descriptor_bytes().to_vec();
3698 let descriptor_blob =
3699 self.hash_snapshot_bytes(GitSnapshotObjectKind::Blob, &descriptor_bytes, true)?;
3700 inputs.verify_imported_descriptor_blob(&descriptor_blob)?;
3701 graph.verify_rehashed(
3702 archive_blob,
3703 descriptor_blob,
3704 &request.request.operation_id,
3705 created_at_ms,
3706 |kind, bytes| {
3707 let object = self.hash_snapshot_bytes(kind, bytes, true)?;
3708 inputs.verify_bindings()?;
3709 Ok::<_, TemporaryRefPublishError>(object)
3710 },
3711 )?;
3712 inputs.verify_bindings()?;
3713 self.snapshot_imports
3714 .insert(request.request.operation_id.clone(), inputs);
3715 Ok(graph)
3716 }
3717
3718 fn precompute_exact_snapshot_retry(
3719 &mut self,
3720 request: &GitSnapshotExactRetryPrecomputeRequest<'_>,
3721 ) -> Result<(GithubGitSnapshotExactRetryPlanV1, Vec<u8>), TemporaryRefPublishError> {
3722 if request.parent_request.operation_id == request.child_operation_id
3723 || request.child_source_created_at_ms == 0
3724 || request.child_source_created_at_ms / 1_000 > i64::MAX as u64
3725 || GitSnapshotSourceRef::for_operation(request.child_operation_id).is_err()
3726 {
3727 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
3728 }
3729 let parent_descriptor = GitSnapshotDescriptor::from_request(
3730 request.parent_request,
3731 SourceBundleDescriptor::new(
3732 request.parent_stage.archive.clone(),
3733 request.parent_request.source.clone(),
3734 ),
3735 )
3736 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3737 request
3738 .parent_stage
3739 .validate_for_request(&parent_descriptor, request.parent_request)?;
3740 self.require_snapshot_sha1()?;
3741 let stdout_limit = usize::try_from(
3742 request
3743 .parent_stage
3744 .archive
3745 .size
3746 .checked_add(1)
3747 .ok_or(TemporaryRefPublishError::GitSnapshotInvalid)?,
3748 )
3749 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3750 let archive_bytes = self.execute_invocation(
3751 &GitInvocation::new(
3752 [
3753 "cat-file",
3754 "blob",
3755 request.parent_stage.graph.archive_blob.as_str(),
3756 ],
3757 self.timeout,
3758 )
3759 .with_stdout_limit(stdout_limit),
3760 )?;
3761 if archive_bytes.len() as u64 != request.parent_stage.archive.size
3762 || hex_sha256(&archive_bytes) != request.parent_stage.archive.sha256
3763 {
3764 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
3765 }
3766 let archive_blob =
3767 self.hash_snapshot_bytes(GitSnapshotObjectKind::Blob, &archive_bytes, false)?;
3768 if archive_blob != request.parent_stage.graph.archive_blob {
3769 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
3770 }
3771 let mut child_request = request.parent_request.clone();
3772 request
3773 .child_operation_id
3774 .clone_into(&mut child_request.operation_id);
3775 child_request.source_revision = None;
3776 let descriptor = GitSnapshotDescriptor::from_request(
3777 &child_request,
3778 SourceBundleDescriptor::new(
3779 request.parent_stage.archive.clone(),
3780 child_request.source.clone(),
3781 ),
3782 )
3783 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3784 let descriptor_bytes = canonical_git_snapshot_descriptor_bytes(&descriptor)
3785 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3786 let descriptor_blob =
3787 self.hash_snapshot_bytes(GitSnapshotObjectKind::Blob, &descriptor_bytes, false)?;
3788 let graph = complete_git_snapshot_object_graph(
3789 archive_blob,
3790 descriptor_blob,
3791 request.child_operation_id,
3792 request.child_source_created_at_ms,
3793 |kind, bytes| self.hash_snapshot_bytes(kind, bytes, false),
3794 )?;
3795 child_request.source_revision = Some(graph.commit.as_str().to_owned());
3796 child_request
3797 .validate()
3798 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3799 let plan = GithubGitSnapshotExactRetryPlanV1 {
3800 request: child_request,
3801 source_created_at_ms: request.child_source_created_at_ms,
3802 request_template_sha256: descriptor.request_template_sha256,
3803 manifest_sha256: request.parent_stage.manifest_sha256.clone(),
3804 archive: request.parent_stage.archive.clone(),
3805 descriptor_sha256: hex_sha256(&descriptor_bytes),
3806 graph,
3807 };
3808 Ok((plan, archive_bytes))
3809 }
3810
3811 #[allow(
3812 clippy::too_many_lines,
3813 reason = "the exact retry reproduces the durable plan before its bounded replayable stage publication"
3814 )]
3815 fn stage_exact_snapshot_retry(
3816 &mut self,
3817 request: &GitSnapshotExactRetryStageRequest<'_>,
3818 ) -> Result<GithubGitSnapshotExactRetryStageV1, TemporaryRefPublishError> {
3819 if !is_lower_sha256(request.retry_lineage_authorization_sha256) {
3820 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
3821 }
3822 let precompute_request = GitSnapshotExactRetryPrecomputeRequest {
3823 parent_request: request.parent_request,
3824 parent_stage: request.parent_stage,
3825 child_operation_id: &request.expected_plan.request.operation_id,
3826 child_source_created_at_ms: request.expected_plan.source_created_at_ms,
3827 };
3828 let (actual_plan, archive_bytes) =
3829 self.precompute_exact_snapshot_retry(&precompute_request)?;
3830 if &actual_plan != request.expected_plan {
3831 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
3832 }
3833 let descriptor = GitSnapshotDescriptor::from_request(
3834 &actual_plan.request,
3835 SourceBundleDescriptor::new(
3836 actual_plan.archive.clone(),
3837 actual_plan.request.source.clone(),
3838 ),
3839 )
3840 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3841 let descriptor_bytes = canonical_git_snapshot_descriptor_bytes(&descriptor)
3842 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3843 if actual_plan.request_template_sha256 != descriptor.request_template_sha256
3844 || actual_plan.manifest_sha256 != descriptor.bundle.manifest.sha256
3845 || actual_plan.descriptor_sha256 != hex_sha256(&descriptor_bytes)
3846 {
3847 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
3848 }
3849 let operation_id = &actual_plan.request.operation_id;
3850 let stage_directory = GitSnapshotStageDirectory::open_or_create_exact_retry(
3851 &self.isolation_directory,
3852 operation_id,
3853 )?;
3854 let archive_identity = stage_directory
3855 .write_or_verify_archive_bytes_exact_retry(&archive_bytes, &actual_plan.archive)?;
3856 let descriptor_identity =
3857 stage_directory.write_or_verify_descriptor_exact_retry(&descriptor)?;
3858 let stage = GitSnapshotStageV1 {
3859 schema_version: crate::snapshot::GIT_SNAPSHOT_STAGE_SCHEMA_VERSION,
3860 operation_id: operation_id.clone(),
3861 isolation_root_identity: stage_directory.isolation_root_identity().to_owned(),
3862 snapshots_store_identity: stage_directory.snapshots_store_identity().to_owned(),
3863 stage_directory_identity: stage_directory.stage_directory_identity().to_owned(),
3864 source_repository: request.parent_stage.source_repository.clone(),
3865 source_ref: GitSnapshotSourceRef::for_operation(operation_id)?,
3866 keepalive_ref: GitSnapshotKeepaliveRef::for_operation(operation_id)?,
3867 source_created_at_ms: actual_plan.source_created_at_ms,
3868 consent_sha256: request.retry_lineage_authorization_sha256.to_owned(),
3869 request_template_sha256: actual_plan.request_template_sha256.clone(),
3870 manifest_sha256: actual_plan.manifest_sha256.clone(),
3871 archive: actual_plan.archive.clone(),
3872 descriptor_sha256: actual_plan.descriptor_sha256.clone(),
3873 final_request: actual_plan.request.clone(),
3874 archive_file_identity: archive_identity.to_string(),
3875 descriptor_file_identity: descriptor_identity.to_string(),
3876 graph: actual_plan.graph.clone(),
3877 };
3878 let stage_locator = stage_directory.publish_or_verify_metadata_exact_retry(
3879 &stage,
3880 &descriptor,
3881 &actual_plan.request,
3882 )?;
3883 Ok(GithubGitSnapshotExactRetryStageV1 {
3884 request: actual_plan.request,
3885 stage_locator,
3886 stage,
3887 })
3888 }
3889
3890 fn local_snapshot_ref(
3891 &mut self,
3892 full_ref: &str,
3893 ) -> Result<Option<GitSha1ObjectId>, TemporaryRefPublishError> {
3894 let format = "--format=%(refname)%00%(objectname)";
3895 let output = self.execute_without_replacements(["for-each-ref", format, full_ref])?;
3896 if output.is_empty() {
3897 return Ok(None);
3898 }
3899 let mut lines = output
3900 .split(|byte| *byte == b'\n')
3901 .filter(|line| !line.is_empty());
3902 let line = lines
3903 .next()
3904 .ok_or(TemporaryRefPublishError::MalformedGitOutput)?;
3905 if lines.next().is_some() {
3906 return Err(TemporaryRefPublishError::MalformedGitOutput);
3907 }
3908 let Some(separator) = line.iter().position(|byte| *byte == 0) else {
3909 return Err(TemporaryRefPublishError::MalformedGitOutput);
3910 };
3911 if &line[..separator] != full_ref.as_bytes() {
3912 return Err(TemporaryRefPublishError::MalformedGitOutput);
3913 }
3914 let object = std::str::from_utf8(&line[separator + 1..])
3915 .map_err(|_| TemporaryRefPublishError::MalformedGitOutput)?;
3916 GitSha1ObjectId::new(object.to_owned())
3917 .map(Some)
3918 .map_err(Into::into)
3919 }
3920
3921 fn source_snapshot_remote(
3922 &mut self,
3923 expected_repository: &str,
3924 ) -> Result<VerifiedGitRemote, TemporaryRefPublishError> {
3925 if let Some(remotes) = &self.frozen_remotes {
3926 if !github_remote_matches(remotes.source.fetch.canonical_url(), expected_repository)
3927 || !github_remote_matches(remotes.source.push.canonical_url(), expected_repository)
3928 {
3929 return Err(TemporaryRefPublishError::RemoteMismatch);
3930 }
3931 return Ok(remotes.source.clone());
3932 }
3933 #[cfg(test)]
3934 {
3935 let source_remote_name = self.source_remote_name.clone();
3936 self.verify_remote(&source_remote_name, expected_repository)
3937 }
3938 #[cfg(not(test))]
3939 {
3940 Err(TemporaryRefPublishError::RemoteMismatch)
3941 }
3942 }
3943
3944 #[allow(clippy::too_many_lines)]
3945 fn prepare_publication_with_ref_policy(
3946 &mut self,
3947 request: &TemporaryRefPublishRequest<'_>,
3948 require_temporary_ref_absent: bool,
3949 ) -> Result<(PublishedTemporaryRef, VerifiedGitRemote), TemporaryRefPublishError> {
3950 if !request.authorized {
3951 return Err(TemporaryRefPublishError::Unauthorized);
3952 }
3953 if request.workflow_bytes.len() > MAX_DISPATCH_FILE_BYTES
3954 || request.manifest_bytes.len() > MAX_DISPATCH_FILE_BYTES
3955 || WorkflowFingerprint::for_workflow_bytes(request.workflow_bytes)
3956 != *request.workflow_fingerprint
3957 {
3958 return Err(TemporaryRefPublishError::WorkflowFingerprintMismatch);
3959 }
3960 validate_repository_path(request.workflow_path)
3961 .map_err(|()| TemporaryRefPublishError::MalformedGitOutput)?;
3962 let temporary_branch_namespace =
3963 temporary_namespace_for_publication(request.temporary_ref, request.operation_id)?;
3964 let (readiness, remotes) = self.doctor_with_remote(&TemporaryRefDoctorRequest {
3965 repository: request.repository,
3966 source_repository: request.source_repository,
3967 trusted_source_ref: request.trusted_source_ref,
3968 temporary_branch_namespace: &temporary_branch_namespace,
3969 workflow_path: request.workflow_path,
3970 workflow_fingerprint: request.workflow_fingerprint,
3971 })?;
3972 if let Some(snapshot_ref) = request.snapshot_source_ref {
3973 if self
3974 .remote_ref(&remotes.source.fetch, snapshot_ref.as_str())?
3975 .as_ref()
3976 != Some(request.source_revision)
3977 {
3978 return Err(TemporaryRefPublishError::GitSnapshotRefConflict);
3979 }
3980 let fetch = GitInvocation::new(
3981 [
3982 "fetch",
3983 "--quiet",
3984 "--no-tags",
3985 "--no-write-fetch-head",
3986 remotes.source.fetch.canonical_url(),
3987 snapshot_ref.as_str(),
3988 ],
3989 self.timeout,
3990 )
3991 .with_network(&remotes.source.fetch);
3992 self.execute_invocation(&fetch)?;
3993 self.execute_without_replacements([
3994 "cat-file",
3995 "-e",
3996 &format!("{}^{{commit}}", request.source_revision.as_str()),
3997 ])
3998 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
3999 if self
4000 .remote_ref(&remotes.source.fetch, snapshot_ref.as_str())?
4001 .as_ref()
4002 != Some(request.source_revision)
4003 {
4004 return Err(TemporaryRefPublishError::GitSnapshotRefConflict);
4005 }
4006 } else {
4007 self.execute_without_replacements([
4008 "cat-file",
4009 "-e",
4010 &format!("{}^{{commit}}", request.source_revision.as_str()),
4011 ])
4012 .map_err(|_| TemporaryRefPublishError::SourceRevisionNotTrusted)?;
4013 }
4014 self.execute_without_replacements([
4015 "cat-file",
4016 "-e",
4017 &format!("{}^{{commit}}", readiness.trusted_ref_tip.as_str()),
4018 ])
4019 .map_err(|_| TemporaryRefPublishError::TrustedRefUnavailable)?;
4020 if request.snapshot_source_ref.is_none() {
4021 self.execute_without_replacements([
4022 "merge-base",
4023 "--is-ancestor",
4024 request.source_revision.as_str(),
4025 readiness.trusted_ref_tip.as_str(),
4026 ])
4027 .map_err(|_| TemporaryRefPublishError::SourceRevisionNotTrusted)?;
4028 }
4029
4030 let full_temporary_ref = format!("refs/heads/{}", request.temporary_ref.branch().as_str());
4031 if require_temporary_ref_absent
4032 && self
4033 .remote_ref(&remotes.execution.fetch, &full_temporary_ref)?
4034 .is_some()
4035 {
4036 return Err(TemporaryRefPublishError::TemporaryRefExists);
4037 }
4038
4039 let mut reservation =
4040 IndexReservation::create(&self.isolation_directory, request.operation_id)?;
4041 let index_path = reservation.index_path().to_path_buf();
4042 let workflow_blob = self.hash_blob(request.workflow_bytes)?;
4043 let manifest_blob = self.hash_blob(request.manifest_bytes)?;
4044 self.execute_invocation(&self.index_invocation(
4045 [
4046 "update-index",
4047 "--add",
4048 "--cacheinfo",
4049 "100644",
4050 &workflow_blob,
4051 request.workflow_path,
4052 ],
4053 &index_path,
4054 ))?;
4055 self.execute_invocation(&self.index_invocation(
4056 [
4057 "update-index",
4058 "--add",
4059 "--cacheinfo",
4060 "100644",
4061 &manifest_blob,
4062 DISPATCH_MANIFEST_PATH,
4063 ],
4064 &index_path,
4065 ))?;
4066 let tree = parse_object_id(
4067 &self.execute_invocation(&self.index_invocation(["write-tree"], &index_path))?,
4068 )?;
4069 let commit_message = format!(
4070 "RustFerry temporary iPhone build {}\n",
4071 request.operation_id
4072 );
4073 let git_date = format!("@{} +0000", request.created_at_ms / 1_000);
4074 let commit_invocation = GitInvocation::new(["commit-tree", &tree], self.timeout)
4075 .with_stdin(commit_message.as_bytes())
4076 .with_environment(GitEnvironmentVariable::AuthorName, "ShiroKSH")
4077 .with_environment(
4078 GitEnvironmentVariable::AuthorEmail,
4079 "kushidashiro@gmail.com",
4080 )
4081 .with_environment(GitEnvironmentVariable::CommitterName, "ShiroKSH")
4082 .with_environment(
4083 GitEnvironmentVariable::CommitterEmail,
4084 "kushidashiro@gmail.com",
4085 )
4086 .with_environment(GitEnvironmentVariable::AuthorDate, &git_date)
4087 .with_environment(GitEnvironmentVariable::CommitterDate, &git_date);
4088 let commit = CommitSha::new(parse_object_id(
4089 &self.execute_invocation(&commit_invocation)?,
4090 )?)
4091 .map_err(|_| TemporaryRefPublishError::MalformedGitOutput)?;
4092 reservation.finish()?;
4093 Ok((
4094 PublishedTemporaryRef::new(request.temporary_ref.clone(), commit),
4095 remotes.execution,
4096 ))
4097 }
4098
4099 fn prepare_publication(
4100 &mut self,
4101 request: &TemporaryRefPublishRequest<'_>,
4102 ) -> Result<(PublishedTemporaryRef, VerifiedGitRemote), TemporaryRefPublishError> {
4103 self.prepare_publication_with_ref_policy(request, true)
4104 }
4105
4106 fn push_publication(
4107 &mut self,
4108 request: &TemporaryRefPublishRequest<'_>,
4109 publication: PublishedTemporaryRef,
4110 remote: &VerifiedGitRemote,
4111 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
4112 let full_temporary_ref = format!("refs/heads/{}", request.temporary_ref.branch().as_str());
4113 let lease = format!("--force-with-lease={full_temporary_ref}:");
4114 let refspec = format!("{}:{full_temporary_ref}", publication.commit().as_str());
4115 let push = GitInvocation::new(
4116 [
4117 "push",
4118 "--porcelain",
4119 "--no-verify",
4120 "--atomic",
4121 &lease,
4122 remote.push.canonical_url(),
4123 &refspec,
4124 ],
4125 self.timeout,
4126 )
4127 .with_network(&remote.push);
4128 if let Err(error) = self.execute_invocation(&push) {
4129 return Err(TemporaryRefPublishFailure::uncertain(error, publication));
4130 }
4131 let confirmed = self
4132 .remote_ref(&remote.fetch, &full_temporary_ref)
4133 .map_err(|error| TemporaryRefPublishFailure::uncertain(error, publication.clone()))?;
4134 if confirmed.as_ref() != Some(publication.commit()) {
4135 return Err(TemporaryRefPublishFailure::uncertain(
4136 TemporaryRefPublishError::PublicationVerificationFailed,
4137 publication,
4138 ));
4139 }
4140 Ok(publication)
4141 }
4142}
4143
4144impl<R: GitRunner> TemporaryRefPublisher for GitTemporaryRefPublisher<R> {
4145 fn supports_git_snapshot(&self) -> bool {
4146 cfg!(any(unix, windows))
4147 }
4148
4149 fn precompute_git_snapshot(
4150 &mut self,
4151 inputs: &mut GitSnapshotPrecomputeInputs,
4152 operation_id: &str,
4153 created_at_ms: u64,
4154 ) -> Result<GitSnapshotObjectGraphV1, TemporaryRefPublishError> {
4155 self.precompute_snapshot_graph(inputs, operation_id, created_at_ms)
4156 }
4157
4158 fn adopt_git_snapshot_stage(
4159 &mut self,
4160 request: &IosDeviceBuildRequest,
4161 ) -> Result<(GitSnapshotStageLocatorV1, GitSnapshotStageV1), TemporaryRefPublishError> {
4162 let (locator, inputs) =
4163 GitSnapshotImportInputs::adopt_without_locator(&self.isolation_directory, request)?;
4164 let stage = inputs.stage().clone();
4165 drop(inputs);
4166 Ok((locator, stage))
4167 }
4168
4169 fn discover_git_snapshot_stages(
4170 &mut self,
4171 ) -> Result<Vec<GithubGitSnapshotRecoveryCandidateV1>, TemporaryRefPublishError> {
4172 discover_complete_git_snapshot_stages(&self.isolation_directory)?
4173 .into_iter()
4174 .map(|candidate| {
4175 let final_request = candidate.stage.final_request.clone();
4176 Ok(GithubGitSnapshotRecoveryCandidateV1 {
4177 stage_locator: candidate.locator,
4178 stage: candidate.stage,
4179 descriptor: candidate.descriptor,
4180 final_request,
4181 })
4182 })
4183 .collect()
4184 }
4185
4186 fn import_git_snapshot(
4187 &mut self,
4188 request: &GitSnapshotImportRequest<'_>,
4189 ) -> Result<GitSnapshotObjectGraphV1, TemporaryRefPublishError> {
4190 self.import_snapshot_graph(request)
4191 }
4192
4193 fn precompute_exact_git_snapshot_retry(
4194 &mut self,
4195 request: &GitSnapshotExactRetryPrecomputeRequest<'_>,
4196 ) -> Result<GithubGitSnapshotExactRetryPlanV1, TemporaryRefPublishError> {
4197 self.precompute_exact_snapshot_retry(request)
4198 .map(|(plan, _archive_bytes)| plan)
4199 }
4200
4201 fn stage_exact_git_snapshot_retry(
4202 &mut self,
4203 request: &GitSnapshotExactRetryStageRequest<'_>,
4204 ) -> Result<GithubGitSnapshotExactRetryStageV1, TemporaryRefPublishError> {
4205 self.stage_exact_snapshot_retry(request)
4206 }
4207
4208 fn delete_git_snapshot_stage(
4209 &mut self,
4210 request: &GitSnapshotImportRequest<'_>,
4211 ) -> Result<(), TemporaryRefPublishError> {
4212 GitSnapshotImportInputs::delete_stage_exact(
4213 &self.isolation_directory,
4214 request.locator,
4215 request.expected_stage,
4216 request.request,
4217 )
4218 .map_err(Into::into)
4219 }
4220
4221 fn reconcile_git_snapshot_keepalive(
4222 &mut self,
4223 request: &GitSnapshotKeepaliveRequest<'_>,
4224 ) -> Result<GitSnapshotRefReconciliation, TemporaryRefPublishError> {
4225 if GitSnapshotKeepaliveRef::for_operation(request.operation_id)? != *request.keepalive_ref {
4226 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
4227 }
4228 Ok(
4229 match self.local_snapshot_ref(request.keepalive_ref.as_str())? {
4230 None => GitSnapshotRefReconciliation::Absent,
4231 Some(commit) if commit == *request.commit => GitSnapshotRefReconciliation::Exact,
4232 Some(_) => GitSnapshotRefReconciliation::Conflict,
4233 },
4234 )
4235 }
4236
4237 fn create_git_snapshot_keepalive(
4238 &mut self,
4239 request: &GitSnapshotKeepaliveRequest<'_>,
4240 ) -> Result<(), TemporaryRefPublishError> {
4241 if GitSnapshotKeepaliveRef::for_operation(request.operation_id)? != *request.keepalive_ref {
4242 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
4243 }
4244 let mut inputs = self
4245 .snapshot_imports
4246 .remove(request.operation_id)
4247 .ok_or(TemporaryRefPublishError::GitSnapshotInvalid)?;
4248 if inputs.stage().graph.commit != *request.commit {
4249 self.snapshot_imports
4250 .insert(request.operation_id.to_owned(), inputs);
4251 return Err(TemporaryRefPublishError::GitSnapshotInvalid);
4252 }
4253 inputs.verify_bindings()?;
4254 let invocation = GitInvocation::new(
4255 [
4256 "update-ref",
4257 request.keepalive_ref.as_str(),
4258 request.commit.as_str(),
4259 "0000000000000000000000000000000000000000",
4260 ],
4261 self.timeout,
4262 );
4263 let mutation = self.execute_invocation(&invocation);
4264 inputs.verify_bindings()?;
4265 let reconciliation = self.reconcile_git_snapshot_keepalive(request)?;
4266 match (mutation, reconciliation) {
4267 (_, GitSnapshotRefReconciliation::Exact) => Ok(()),
4268 (_, GitSnapshotRefReconciliation::Conflict) => {
4269 Err(TemporaryRefPublishError::GitSnapshotRefConflict)
4270 }
4271 (Err(error), GitSnapshotRefReconciliation::Absent) => {
4272 self.snapshot_imports
4273 .insert(request.operation_id.to_owned(), inputs);
4274 Err(error)
4275 }
4276 (Ok(_), GitSnapshotRefReconciliation::Absent) => {
4277 self.snapshot_imports
4278 .insert(request.operation_id.to_owned(), inputs);
4279 Err(TemporaryRefPublishError::PublicationVerificationFailed)
4280 }
4281 }
4282 }
4283
4284 fn publish_git_snapshot_source(
4285 &mut self,
4286 request: &GitSnapshotSourcePublishRequest<'_>,
4287 ) -> Result<GitSnapshotSourcePublication, TemporaryRefPublishError> {
4288 if !request.authorized {
4289 return Err(TemporaryRefPublishError::Unauthorized);
4290 }
4291 let remote = self.source_snapshot_remote(request.source_repository)?;
4292 let full_ref = request.source_ref.as_str();
4293 let lease = format!("--force-with-lease={full_ref}:");
4294 let refspec = format!("{}:{full_ref}", request.commit.as_str());
4295 let invocation = GitInvocation::new(
4296 [
4297 "push",
4298 "--porcelain",
4299 "--no-verify",
4300 "--atomic",
4301 &lease,
4302 remote.push.canonical_url(),
4303 &refspec,
4304 ],
4305 self.timeout,
4306 )
4307 .with_network(&remote.push);
4308 let mutation = self.execute_invocation(&invocation);
4309 let reconciliation = self.reconcile_git_snapshot_source(request)?;
4310 Ok(match reconciliation {
4311 GitSnapshotRefReconciliation::Exact => GitSnapshotSourcePublication::Exact,
4312 GitSnapshotRefReconciliation::Conflict => GitSnapshotSourcePublication::Conflict,
4313 GitSnapshotRefReconciliation::Absent if mutation.is_err() => {
4314 GitSnapshotSourcePublication::Uncertain
4315 }
4316 GitSnapshotRefReconciliation::Absent => GitSnapshotSourcePublication::Uncertain,
4317 })
4318 }
4319
4320 fn reconcile_git_snapshot_source(
4321 &mut self,
4322 request: &GitSnapshotSourcePublishRequest<'_>,
4323 ) -> Result<GitSnapshotRefReconciliation, TemporaryRefPublishError> {
4324 let remote = self.source_snapshot_remote(request.source_repository)?;
4325 let expected = CommitSha::new(request.commit.as_str().to_owned())
4326 .map_err(|_| TemporaryRefPublishError::GitSnapshotInvalid)?;
4327 Ok(
4328 match self.remote_ref(&remote.fetch, request.source_ref.as_str())? {
4329 None => GitSnapshotRefReconciliation::Absent,
4330 Some(commit) if commit == expected => GitSnapshotRefReconciliation::Exact,
4331 Some(_) => GitSnapshotRefReconciliation::Conflict,
4332 },
4333 )
4334 }
4335
4336 fn delete_git_snapshot_source(
4337 &mut self,
4338 request: &GitSnapshotSourceDeleteRequest<'_>,
4339 ) -> Result<(), TemporaryRefPublishError> {
4340 if !request.authorized {
4341 return Err(TemporaryRefPublishError::Unauthorized);
4342 }
4343 let remote = self.source_snapshot_remote(request.source_repository)?;
4344 let full_ref = request.source_ref.as_str();
4345 let lease = format!(
4346 "--force-with-lease={full_ref}:{}",
4347 request.expected_commit.as_str()
4348 );
4349 let refspec = format!(":{full_ref}");
4350 let invocation = GitInvocation::new(
4351 [
4352 "push",
4353 "--porcelain",
4354 "--no-verify",
4355 "--atomic",
4356 &lease,
4357 remote.push.canonical_url(),
4358 &refspec,
4359 ],
4360 self.timeout,
4361 )
4362 .with_network(&remote.push);
4363 let mutation = self.execute_invocation(&invocation);
4364 let reconciliation =
4365 self.reconcile_git_snapshot_source(&GitSnapshotSourcePublishRequest {
4366 source_repository: request.source_repository,
4367 source_ref: request.source_ref,
4368 commit: request.expected_commit,
4369 authorized: true,
4370 })?;
4371 match (mutation, reconciliation) {
4372 (_, GitSnapshotRefReconciliation::Absent) => Ok(()),
4373 (_, GitSnapshotRefReconciliation::Conflict) => {
4374 Err(TemporaryRefPublishError::GitSnapshotRefConflict)
4375 }
4376 (Err(error), GitSnapshotRefReconciliation::Exact) => Err(error),
4377 (Ok(_), GitSnapshotRefReconciliation::Exact) => {
4378 Err(TemporaryRefPublishError::DeletionVerificationFailed)
4379 }
4380 }
4381 }
4382
4383 fn release_git_snapshot_keepalive(
4384 &mut self,
4385 request: &GitSnapshotKeepaliveRequest<'_>,
4386 ) -> Result<(), TemporaryRefPublishError> {
4387 match self.reconcile_git_snapshot_keepalive(request)? {
4388 GitSnapshotRefReconciliation::Absent => return Ok(()),
4389 GitSnapshotRefReconciliation::Conflict => {
4390 return Err(TemporaryRefPublishError::GitSnapshotRefConflict);
4391 }
4392 GitSnapshotRefReconciliation::Exact => {}
4393 }
4394 let invocation = GitInvocation::new(
4395 [
4396 "update-ref",
4397 "-d",
4398 request.keepalive_ref.as_str(),
4399 request.commit.as_str(),
4400 ],
4401 self.timeout,
4402 );
4403 let mutation = self.execute_invocation(&invocation);
4404 match (mutation, self.reconcile_git_snapshot_keepalive(request)?) {
4405 (_, GitSnapshotRefReconciliation::Absent) => Ok(()),
4406 (_, GitSnapshotRefReconciliation::Conflict) => {
4407 Err(TemporaryRefPublishError::GitSnapshotRefConflict)
4408 }
4409 (Err(error), GitSnapshotRefReconciliation::Exact) => Err(error),
4410 (Ok(_), GitSnapshotRefReconciliation::Exact) => {
4411 Err(TemporaryRefPublishError::DeletionVerificationFailed)
4412 }
4413 }
4414 }
4415
4416 fn doctor(
4417 &mut self,
4418 request: &TemporaryRefDoctorRequest<'_>,
4419 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError> {
4420 self.doctor_with_remote(request)
4421 .map(|(readiness, _)| readiness)
4422 }
4423
4424 fn verify_execution_workflow(
4425 &mut self,
4426 request: &ExecutionWorkflowDoctorRequest<'_>,
4427 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError> {
4428 self.verify_execution_workflow_bytes(request)
4429 }
4430
4431 fn prepare(
4432 &mut self,
4433 request: &TemporaryRefPublishRequest<'_>,
4434 ) -> Result<Option<PublishedTemporaryRef>, TemporaryRefPublishError> {
4435 let (publication, _) = self.prepare_publication(request)?;
4436 Ok(Some(publication))
4437 }
4438
4439 fn publication_attempt_is_process_fenced(&self) -> bool {
4440 self.runner.process_tree_is_parent_fenced()
4441 }
4442
4443 fn publication_lease_scope_sha256(&self) -> Option<&str> {
4444 Some(&self.publication_lease_scope_sha256)
4445 }
4446
4447 fn acquire_publication_lease(
4448 &mut self,
4449 request: &TemporaryRefPublicationLeaseRequest<'_>,
4450 ) -> Result<TemporaryRefPublicationLease, TemporaryRefPublishError> {
4451 if !cfg!(any(unix, windows)) {
4452 return Ok(TemporaryRefPublicationLease::Unsupported);
4453 }
4454 let key = publication_lease_key(request);
4455 if self.publication_leases.contains_key(&key) {
4456 return Ok(TemporaryRefPublicationLease::AlreadyOwned);
4457 }
4458 if self.publication_leases.len() >= MAX_JOB_RECORDS {
4459 return Err(TemporaryRefPublishError::LocalIsolationFailed);
4460 }
4461 rustferry_core::verify_directory_identity(
4462 &self.isolation_directory,
4463 &self.isolation_directory_identity,
4464 )
4465 .map_err(|_| TemporaryRefPublishError::LocalIsolationFailed)?;
4466 let path = self
4467 .isolation_directory
4468 .join(format!(".rustferry-publication-{key}.lock"));
4469 let lease = rustferry_core::process_control::try_acquire_process_file_lease(&path)
4470 .map_err(|_| TemporaryRefPublishError::LocalIsolationFailed)?;
4471 let Some(lease) = lease else {
4472 return Ok(TemporaryRefPublicationLease::HeldByOther);
4473 };
4474 self.publication_leases.insert(key, lease);
4475 Ok(TemporaryRefPublicationLease::Acquired)
4476 }
4477
4478 fn acquire_git_snapshot_source_lease(
4479 &mut self,
4480 request: &GitSnapshotSourcePublicationLeaseRequest<'_>,
4481 ) -> Result<TemporaryRefPublicationLease, TemporaryRefPublishError> {
4482 if !cfg!(any(unix, windows)) {
4483 return Ok(TemporaryRefPublicationLease::Unsupported);
4484 }
4485 if repository_from_url(request.source_repository).is_none() {
4486 return Err(TemporaryRefPublishError::RemoteMismatch);
4487 }
4488 let key = git_snapshot_source_lease_key(request);
4489 if self.publication_leases.contains_key(&key) {
4490 return Ok(TemporaryRefPublicationLease::AlreadyOwned);
4491 }
4492 if self.publication_leases.len() >= MAX_JOB_RECORDS {
4493 return Err(TemporaryRefPublishError::LocalIsolationFailed);
4494 }
4495 rustferry_core::verify_directory_identity(
4496 &self.isolation_directory,
4497 &self.isolation_directory_identity,
4498 )
4499 .map_err(|_| TemporaryRefPublishError::LocalIsolationFailed)?;
4500 let path = self
4501 .isolation_directory
4502 .join(format!(".rustferry-snapshot-source-{key}.lock"));
4503 let lease = rustferry_core::process_control::try_acquire_process_file_lease(&path)
4504 .map_err(|_| TemporaryRefPublishError::LocalIsolationFailed)?;
4505 let Some(lease) = lease else {
4506 return Ok(TemporaryRefPublicationLease::HeldByOther);
4507 };
4508 self.publication_leases.insert(key, lease);
4509 Ok(TemporaryRefPublicationLease::Acquired)
4510 }
4511
4512 fn release_publication_lease(&mut self, request: &TemporaryRefPublicationLeaseRequest<'_>) {
4513 self.publication_leases
4514 .remove(&publication_lease_key(request));
4515 }
4516
4517 fn release_git_snapshot_source_lease(
4518 &mut self,
4519 request: &GitSnapshotSourcePublicationLeaseRequest<'_>,
4520 ) {
4521 self.publication_leases
4522 .remove(&git_snapshot_source_lease_key(request));
4523 }
4524
4525 fn publish(
4526 &mut self,
4527 request: &TemporaryRefPublishRequest<'_>,
4528 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
4529 let (publication, remote) = self.prepare_publication(request)?;
4530 self.push_publication(request, publication, &remote)
4531 }
4532
4533 fn publish_prepared(
4534 &mut self,
4535 request: &TemporaryRefPublishRequest<'_>,
4536 prepared: Option<&PublishedTemporaryRef>,
4537 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
4538 let Some(expected) = prepared else {
4539 return self.publish(request);
4540 };
4541 let (publication, remote) = self.prepare_publication(request)?;
4542 if expected != &publication || expected.temporary_ref() != request.temporary_ref {
4543 return Err(TemporaryRefPublishError::PublicationVerificationFailed.into());
4544 }
4545 self.push_publication(request, publication, &remote)
4546 }
4547
4548 fn delete_temporary_ref(
4549 &mut self,
4550 request: &TemporaryRefDeleteRequest<'_>,
4551 ) -> Result<(), TemporaryRefPublishError> {
4552 if !request.authorized {
4553 return Err(TemporaryRefPublishError::Unauthorized);
4554 }
4555 let remote = self.execution_remote(request.repository)?;
4556 let full_ref = format!("refs/heads/{}", request.temporary_ref.branch().as_str());
4557 let lease = format!(
4558 "--force-with-lease={full_ref}:{}",
4559 request.expected_commit.as_str()
4560 );
4561 let delete_refspec = format!(":{full_ref}");
4562 let deletion_invocation = GitInvocation::new(
4563 [
4564 "push",
4565 "--porcelain",
4566 "--no-verify",
4567 "--atomic",
4568 &lease,
4569 remote.push.canonical_url(),
4570 &delete_refspec,
4571 ],
4572 self.timeout,
4573 )
4574 .with_network(&remote.push);
4575 let deletion = self.execute_invocation(&deletion_invocation);
4576 match deletion {
4577 Ok(_) => match self.remote_ref(&remote.fetch, &full_ref)? {
4578 None => Ok(()),
4579 Some(_) => Err(TemporaryRefPublishError::DeletionVerificationFailed),
4580 },
4581 Err(push_error) => match self.remote_ref(&remote.fetch, &full_ref) {
4582 Ok(None) => Ok(()),
4583 Ok(Some(current)) if current != *request.expected_commit => {
4584 Err(TemporaryRefPublishError::TemporaryRefLeaseRejected)
4585 }
4586 Ok(Some(_)) | Err(_) => Err(push_error),
4587 },
4588 }
4589 }
4590}
4591
4592impl<R: GitRunner> TemporaryRefReconciler for GitTemporaryRefPublisher<R> {
4593 fn reconcile_temporary_ref(
4594 &mut self,
4595 request: &TemporaryRefReconcileRequest<'_>,
4596 ) -> Result<TemporaryRefReconciliation, TemporaryRefPublishError> {
4597 let reconstruction = TemporaryRefPublishRequest {
4598 repository: request.repository,
4599 source_repository: request.source_repository,
4600 trusted_source_ref: request.trusted_source_ref,
4601 source_revision: request.source_revision,
4602 snapshot_source_ref: request.snapshot_source_ref,
4603 temporary_ref: request.temporary_ref,
4604 workflow_path: request.workflow_path,
4605 workflow_bytes: request.workflow_bytes,
4606 workflow_fingerprint: request.workflow_fingerprint,
4607 manifest_bytes: request.manifest_bytes,
4608 operation_id: request.operation_id,
4609 created_at_ms: request.created_at_ms,
4610 authorized: true,
4611 };
4612 let (recomputed, remote) =
4613 self.prepare_publication_with_ref_policy(&reconstruction, false)?;
4614 if recomputed.temporary_ref() != request.temporary_ref
4615 || recomputed.commit() != request.expected_prepared_commit
4616 {
4617 return Ok(TemporaryRefReconciliation::Conflict);
4618 }
4619 let full_ref = format!("refs/heads/{}", request.temporary_ref.branch().as_str());
4620 let Some(tip) = self.remote_ref(&remote.fetch, &full_ref)? else {
4621 return Ok(TemporaryRefReconciliation::Missing);
4622 };
4623 if tip != *request.expected_prepared_commit {
4624 return Ok(TemporaryRefReconciliation::Conflict);
4625 }
4626 Ok(TemporaryRefReconciliation::Exact(
4627 PublishedTemporaryRef::new(request.temporary_ref.clone(), tip),
4628 ))
4629 }
4630}
4631
4632impl WorkflowFingerprint {
4633 fn for_workflow_bytes(bytes: &[u8]) -> Self {
4634 Self(hex_sha256(bytes))
4635 }
4636}
4637
4638struct IndexReservation {
4639 index_path: PathBuf,
4640 #[cfg(windows)]
4641 directory_guard: Option<std::fs::File>,
4642 finished: bool,
4643}
4644
4645impl IndexReservation {
4646 fn create(directory: &Path, operation_id: &str) -> Result<Self, TemporaryRefPublishError> {
4647 validate_ref_segment(operation_id)
4648 .map_err(|()| TemporaryRefPublishError::LocalIsolationFailed)?;
4649 let created = create_unique_index_directory(directory, operation_id)?;
4650 let directory = created.path;
4651 let index_path = directory.join("index");
4652 Ok(Self {
4653 index_path,
4654 #[cfg(windows)]
4655 directory_guard: Some(created.guard),
4656 finished: false,
4657 })
4658 }
4659
4660 fn index_path(&self) -> &Path {
4661 &self.index_path
4662 }
4663
4664 fn finish(&mut self) -> Result<(), TemporaryRefPublishError> {
4665 self.cleanup()?;
4666 self.finished = true;
4667 Ok(())
4668 }
4669
4670 fn cleanup(&mut self) -> Result<(), TemporaryRefPublishError> {
4671 remove_exact_file_if_present(&self.index_path)?;
4672 remove_exact_file_if_present(&self.index_path.with_extension("lock"))?;
4673 #[cfg(windows)]
4674 {
4675 let guard = self
4676 .directory_guard
4677 .take()
4678 .ok_or(TemporaryRefPublishError::LocalIsolationFailed)?;
4679 rustferry_core::windows_private_directory::remove_private_directory_handle(guard)
4680 .map_err(|_| TemporaryRefPublishError::LocalIsolationFailed)
4681 }
4682 #[cfg(not(windows))]
4683 {
4684 let directory = self
4685 .index_path
4686 .parent()
4687 .ok_or(TemporaryRefPublishError::LocalIsolationFailed)?;
4688 fs::remove_dir(directory).map_err(|_| TemporaryRefPublishError::LocalIsolationFailed)
4689 }
4690 }
4691}
4692
4693impl Drop for IndexReservation {
4694 fn drop(&mut self) {
4695 if !self.finished {
4696 let _ = self.cleanup();
4697 }
4698 }
4699}
4700
4701struct UniqueIndexDirectory {
4702 path: PathBuf,
4703 #[cfg(windows)]
4704 guard: std::fs::File,
4705}
4706
4707fn create_unique_index_directory(
4708 parent: &Path,
4709 operation_id: &str,
4710) -> Result<UniqueIndexDirectory, TemporaryRefPublishError> {
4711 for _ in 0..16 {
4712 let counter = INDEX_RESERVATION_COUNTER.fetch_add(1, Ordering::Relaxed);
4713 let mut digest = Sha256::new();
4714 digest.update(b"rustferry-index-reservation-v1\0");
4715 digest.update(operation_id.as_bytes());
4716 digest.update(std::process::id().to_be_bytes());
4717 digest.update(counter.to_be_bytes());
4718 digest.update(
4719 SystemTime::now()
4720 .duration_since(UNIX_EPOCH)
4721 .unwrap_or_default()
4722 .as_nanos()
4723 .to_be_bytes(),
4724 );
4725 let name = format!(".rustferry-index-{}", hex::encode(digest.finalize()));
4726 let path = parent.join(name);
4727 #[cfg(windows)]
4728 match rustferry_core::windows_private_directory::create_private_directory(&path) {
4729 Ok(guard) => return Ok(UniqueIndexDirectory { path, guard }),
4730 Err(_) if path.exists() => {}
4731 Err(_) => return Err(TemporaryRefPublishError::LocalIsolationFailed),
4732 }
4733 #[cfg(unix)]
4734 {
4735 use std::os::unix::fs::DirBuilderExt as _;
4736
4737 let mut builder = fs::DirBuilder::new();
4738 builder.mode(0o700);
4739 match builder.create(&path) {
4740 Ok(()) => return Ok(UniqueIndexDirectory { path }),
4741 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
4742 Err(_) => return Err(TemporaryRefPublishError::LocalIsolationFailed),
4743 }
4744 }
4745 }
4746 Err(TemporaryRefPublishError::LocalIsolationFailed)
4747}
4748
4749#[derive(Clone, Debug, PartialEq)]
4751pub struct GithubArtifactContext {
4752 pub job_id: String,
4754 pub operation_id: String,
4756 pub repository: Repository,
4758 pub execution_repository_id: Option<u64>,
4761 pub run: RunSnapshot,
4763 pub source_repository: String,
4765 pub source_revision: CommitSha,
4767 pub dispatch_revision: CommitSha,
4769 pub request: IosDeviceBuildRequest,
4771 pub request_sha256: String,
4773}
4774
4775#[derive(Clone, Debug, Eq, PartialEq)]
4781pub struct GithubVerifiedRunEvidence {
4782 compile_evidence: CompilePhaseEvidence,
4783 signed_cleanup_evidence: Option<GithubSignedCleanupEvidenceV1>,
4784}
4785
4786impl GithubVerifiedRunEvidence {
4787 pub(crate) fn new(
4788 compile_evidence: CompilePhaseEvidence,
4789 signed_cleanup_evidence: Option<GithubSignedCleanupEvidenceV1>,
4790 ) -> Self {
4791 Self {
4792 compile_evidence,
4793 signed_cleanup_evidence,
4794 }
4795 }
4796
4797 pub fn compile_evidence(&self) -> &CompilePhaseEvidence {
4799 &self.compile_evidence
4800 }
4801
4802 pub fn signed_cleanup_evidence(&self) -> Option<&GithubSignedCleanupEvidenceV1> {
4804 self.signed_cleanup_evidence.as_ref()
4805 }
4806}
4807
4808pub trait VerifiedArtifactStore: Send {
4813 fn supports_listing(&self) -> bool;
4815 fn supports_download(&self) -> bool;
4817 fn supports_removal(&self) -> bool;
4819 fn supports_signed_cleanup_evidence(&self) -> bool {
4821 false
4822 }
4823
4824 fn list_verified(
4830 &mut self,
4831 context: &GithubArtifactContext,
4832 ) -> RemoteBuildResult<Vec<ArtifactManifest>>;
4833
4834 fn verified_run_evidence(
4843 &mut self,
4844 _context: &GithubArtifactContext,
4845 ) -> RemoteBuildResult<Option<GithubVerifiedRunEvidence>> {
4846 Ok(None)
4847 }
4848
4849 fn download_verified(
4855 &mut self,
4856 context: &GithubArtifactContext,
4857 request: &ArtifactDownloadRequest,
4858 ) -> RemoteBuildResult<ArtifactDownloadResult>;
4859
4860 fn remove_artifacts(&mut self, context: &GithubArtifactContext) -> RemoteBuildResult<()>;
4870}
4871
4872#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
4874pub struct NoVerifiedArtifactStore;
4875
4876impl VerifiedArtifactStore for NoVerifiedArtifactStore {
4877 fn supports_listing(&self) -> bool {
4878 false
4879 }
4880
4881 fn supports_download(&self) -> bool {
4882 false
4883 }
4884
4885 fn supports_removal(&self) -> bool {
4886 false
4887 }
4888
4889 fn list_verified(
4890 &mut self,
4891 _context: &GithubArtifactContext,
4892 ) -> RemoteBuildResult<Vec<ArtifactManifest>> {
4893 Err(unsupported(ProviderFeature::ArtifactListing))
4894 }
4895
4896 fn download_verified(
4897 &mut self,
4898 _context: &GithubArtifactContext,
4899 _request: &ArtifactDownloadRequest,
4900 ) -> RemoteBuildResult<ArtifactDownloadResult> {
4901 Err(unsupported(ProviderFeature::ArtifactDownload))
4902 }
4903
4904 fn remove_artifacts(&mut self, _context: &GithubArtifactContext) -> RemoteBuildResult<()> {
4905 Err(provider_failure(
4906 "artifact_removal_unsupported",
4907 "verified artifact removal is not configured",
4908 false,
4909 ))
4910 }
4911}
4912
4913pub trait ProviderClock: Send + Sync {
4915 fn now_ms(&self) -> u64;
4917}
4918
4919#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
4921pub struct SystemProviderClock;
4922
4923impl ProviderClock for SystemProviderClock {
4924 fn now_ms(&self) -> u64 {
4925 SystemTime::now()
4926 .duration_since(UNIX_EPOCH)
4927 .unwrap_or_default()
4928 .as_millis()
4929 .try_into()
4930 .unwrap_or(u64::MAX)
4931 }
4932}
4933
4934#[allow(clippy::struct_excessive_bools)]
4935#[derive(Clone, Debug)]
4936struct JobRecord {
4937 principal: Option<GithubPrincipalIdentityV1>,
4938 execution_repository_id: Option<u64>,
4939 operation_id: String,
4940 job_id: String,
4941 source_revision: CommitSha,
4942 git_snapshot: Option<GithubGitSnapshotResumeV1>,
4943 request: IosDeviceBuildRequest,
4944 request_sha256: String,
4945 temporary_ref: TemporaryGitRef,
4946 prepared_dispatch_commit: Option<CommitSha>,
4947 dispatch_commit: Option<CommitSha>,
4948 workflow_dispatch: Option<GithubWorkflowDispatchResumeV1>,
4949 created_at_ms: u64,
4950 publication_started_at_ms: u64,
4951 publication_quiescence_deadline_ms: u64,
4952 state: JobState,
4953 run: Option<RunHandle>,
4954 run_snapshot: Option<RunSnapshot>,
4955 publication_intent: bool,
4956 publication_absent: bool,
4957 publication_not_attempted: bool,
4958 publication_process_fenced: bool,
4959 publication_lease_scope_sha256: Option<String>,
4960 publication_takeover_lease_owned: bool,
4961 snapshot_source_lease_owned: bool,
4962 publication_absence_observations: u8,
4963 publication_absence_first_observed_at_ms: u64,
4964 cancellation_requested: bool,
4965 cancellation_dispatched: bool,
4966 cleanup_requested: bool,
4967 remove_artifacts_requested: bool,
4968 artifacts_removed: bool,
4969 temporary_ref_deleted: bool,
4970 verification_pending_event: bool,
4971 publication_uncertain: bool,
4972 run_discovery_attempts: u16,
4973 run_discovery_deadline_ms: u64,
4974 manifests: Vec<ArtifactManifest>,
4975 compile_evidence: Option<CompilePhaseEvidence>,
4976 signed_cleanup_evidence: Option<GithubSignedCleanupEvidenceV1>,
4977 events: Vec<RemoteBuildEvent>,
4978}
4979
4980#[derive(Debug)]
4981struct WorkflowDispatchPostAuthority {
4982 job_id: String,
4983 request: WorkflowDispatchRequest,
4984}
4985
4986impl GithubWorkflowDispatchResumeV1 {
4987 fn from_request(request: &WorkflowDispatchRequest) -> Self {
4988 Self {
4989 schema_version: GITHUB_WORKFLOW_DISPATCH_RESUME_SCHEMA_VERSION,
4990 workflow_id: request.workflow().id().get(),
4991 workflow_path: request.workflow().path().to_owned(),
4992 branch: request.temporary_ref().branch().as_str().to_owned(),
4993 operation_id: request.operation_id().to_owned(),
4994 request_sha256: request.request_sha256().to_owned(),
4995 source_revision: request.source_revision().as_str().to_owned(),
4996 dispatch_revision: request.dispatch_revision().as_str().to_owned(),
4997 body_sha256: request.body_sha256().to_owned(),
4998 run_name: request.run_name().to_owned(),
4999 uncertain: true,
5000 receipt: None,
5001 }
5002 }
5003
5004 fn request_for_record(
5005 &self,
5006 config: &GithubProviderConfig,
5007 record: &JobRecord,
5008 ) -> RemoteBuildResult<WorkflowDispatchRequest> {
5009 let dispatch_revision = record
5010 .dispatch_commit
5011 .clone()
5012 .ok_or_else(|| resume_failure("resume_workflow_dispatch_commit_missing"))?;
5013 if self.schema_version != GITHUB_WORKFLOW_DISPATCH_RESUME_SCHEMA_VERSION
5014 || config.workflow.run_trigger() != WorkflowRunTrigger::WorkflowDispatch
5015 || self.workflow_path != config.workflow.filename().repository_path()
5016 || self.branch != record.temporary_ref.branch().as_str()
5017 || self.operation_id != record.operation_id
5018 || self.request_sha256 != record.request_sha256
5019 || self.source_revision != record.source_revision.as_str()
5020 || self.dispatch_revision != dispatch_revision.as_str()
5021 || !is_lower_sha256(&self.body_sha256)
5022 || self.run_name.is_empty()
5023 || self.uncertain != self.receipt.is_none()
5024 {
5025 return Err(resume_failure("resume_workflow_dispatch_identity_mismatch"));
5026 }
5027 let workflow_id = WorkflowId::new(self.workflow_id)
5028 .map_err(|_| resume_failure("resume_workflow_dispatch_identity_invalid"))?;
5029 let registration = WorkflowRegistration::restore(
5030 config.repository.clone(),
5031 workflow_id,
5032 self.workflow_path.clone(),
5033 )
5034 .map_err(|_| resume_failure("resume_workflow_dispatch_identity_invalid"))?;
5035 let request = WorkflowDispatchRequest::new(
5036 config.repository.clone(),
5037 registration,
5038 record.temporary_ref.clone(),
5039 record.operation_id.clone(),
5040 record.request_sha256.clone(),
5041 record.source_revision.clone(),
5042 dispatch_revision,
5043 )
5044 .map_err(|_| resume_failure("resume_workflow_dispatch_identity_invalid"))?;
5045 if request.body_sha256() != self.body_sha256 || request.run_name() != self.run_name {
5046 return Err(resume_failure("resume_workflow_dispatch_body_mismatch"));
5047 }
5048 Ok(request)
5049 }
5050
5051 fn bind_receipt(&mut self, receipt: &WorkflowDispatchReceipt) -> RemoteBuildResult<()> {
5052 let candidate = GithubWorkflowDispatchReceiptV1 {
5053 run_id: receipt.run_id().get(),
5054 workflow_id: receipt.workflow_id().get(),
5055 workflow_path: receipt.workflow_path().to_owned(),
5056 branch: receipt.branch().as_str().to_owned(),
5057 dispatch_revision: receipt.dispatch_revision().as_str().to_owned(),
5058 run_name: receipt.run_name().to_owned(),
5059 };
5060 if candidate.workflow_id != self.workflow_id
5061 || candidate.workflow_path != self.workflow_path
5062 || candidate.branch != self.branch
5063 || candidate.dispatch_revision != self.dispatch_revision
5064 || candidate.run_name != self.run_name
5065 || self
5066 .receipt
5067 .as_ref()
5068 .is_some_and(|existing| existing != &candidate)
5069 {
5070 return Err(resume_failure("resume_workflow_dispatch_receipt_mismatch"));
5071 }
5072 self.receipt = Some(candidate);
5073 self.uncertain = false;
5074 Ok(())
5075 }
5076
5077 fn restored_receipt(
5078 &self,
5079 config: &GithubProviderConfig,
5080 record: &JobRecord,
5081 ) -> RemoteBuildResult<Option<WorkflowDispatchReceipt>> {
5082 self.request_for_record(config, record)?;
5083 let Some(receipt) = self.receipt.as_ref() else {
5084 return Ok(None);
5085 };
5086 if receipt.workflow_id != self.workflow_id
5087 || receipt.workflow_path != self.workflow_path
5088 || receipt.branch != self.branch
5089 || receipt.dispatch_revision != self.dispatch_revision
5090 || receipt.run_name != self.run_name
5091 {
5092 return Err(resume_failure("resume_workflow_dispatch_receipt_mismatch"));
5093 }
5094 let restored = WorkflowDispatchReceipt::restore(
5095 config.repository.clone(),
5096 RunId::new(receipt.run_id)
5097 .map_err(|_| resume_failure("resume_workflow_dispatch_receipt_invalid"))?,
5098 WorkflowId::new(receipt.workflow_id)
5099 .map_err(|_| resume_failure("resume_workflow_dispatch_receipt_invalid"))?,
5100 receipt.workflow_path.clone(),
5101 CommitSha::new(receipt.dispatch_revision.clone())
5102 .map_err(|_| resume_failure("resume_workflow_dispatch_receipt_invalid"))?,
5103 BranchName::new(receipt.branch.clone())
5104 .map_err(|_| resume_failure("resume_workflow_dispatch_receipt_invalid"))?,
5105 receipt.run_name.clone(),
5106 )
5107 .map_err(|_| resume_failure("resume_workflow_dispatch_receipt_invalid"))?;
5108 Ok(Some(restored))
5109 }
5110}
5111
5112const fn workflow_run_event(trigger: WorkflowRunTrigger) -> RunEvent {
5113 match trigger {
5114 WorkflowRunTrigger::Push => RunEvent::Push,
5115 WorkflowRunTrigger::WorkflowDispatch => RunEvent::WorkflowDispatch,
5116 }
5117}
5118
5119fn validate_workflow_dispatch_record(
5120 config: &GithubProviderConfig,
5121 record: &JobRecord,
5122) -> RemoteBuildResult<()> {
5123 let expected_event = workflow_run_event(config.workflow.run_trigger());
5124 if record
5125 .run
5126 .as_ref()
5127 .is_some_and(|run| run.event() != expected_event)
5128 {
5129 return Err(resume_failure("resume_run_trigger_mismatch"));
5130 }
5131 match config.workflow.run_trigger() {
5132 WorkflowRunTrigger::Push => {
5133 if record.workflow_dispatch.is_some()
5134 || matches!(
5135 record.principal,
5136 Some(GithubPrincipalIdentityV1::RepositoryCredential)
5137 )
5138 {
5139 return Err(resume_failure("resume_workflow_dispatch_unexpected"));
5140 }
5141 }
5142 WorkflowRunTrigger::WorkflowDispatch => {
5143 let Some(dispatch) = record.workflow_dispatch.as_ref() else {
5144 if record.run.is_some() {
5145 return Err(resume_failure("resume_workflow_dispatch_intent_missing"));
5146 }
5147 return Ok(());
5148 };
5149 dispatch.request_for_record(config, record)?;
5150 let receipt = dispatch.restored_receipt(config, record)?;
5151 if record.publication_uncertain
5152 || record.dispatch_commit.is_none()
5153 || record.run.as_ref().is_some_and(|run| {
5154 receipt
5155 .as_ref()
5156 .is_none_or(|receipt| receipt.run_id() != run.id())
5157 })
5158 {
5159 return Err(resume_failure(
5160 "resume_workflow_dispatch_state_inconsistent",
5161 ));
5162 }
5163 }
5164 }
5165 Ok(())
5166}
5167
5168impl GithubRunIdentityV1 {
5169 fn from_snapshot(snapshot: &RunSnapshot) -> Self {
5170 let handle = snapshot.handle();
5171 Self {
5172 run_id: handle.id().get(),
5173 workflow_id: handle.workflow_id().get(),
5174 workflow_path: handle.workflow_path().to_owned(),
5175 head_sha: handle.head_sha().as_str().to_owned(),
5176 branch: handle.branch().as_str().to_owned(),
5177 event: handle.event().into(),
5178 run_number: snapshot.run_number(),
5179 run_attempt: snapshot.run_attempt(),
5180 status: snapshot.status().into(),
5181 conclusion: snapshot.conclusion().map(Into::into),
5182 }
5183 }
5184}
5185
5186pub fn validate_github_compile_phase_evidence(
5196 evidence: &CompilePhaseEvidence,
5197 request: &IosDeviceBuildRequest,
5198 job_id: &str,
5199) -> RemoteBuildResult<()> {
5200 GithubArtifactExpectation::new(
5201 job_id.to_owned(),
5202 GITHUB_PROVIDER_ID,
5203 request.clone(),
5204 evidence.clone(),
5205 )
5206 .map(|_| ())
5207 .map_err(|_| {
5208 provider_failure(
5209 "compile_evidence_invalid",
5210 "compile evidence does not match the exact GitHub request",
5211 false,
5212 )
5213 })
5214}
5215
5216fn compile_evidence_sha256(evidence: &CompilePhaseEvidence) -> RemoteBuildResult<String> {
5217 let bytes = serde_json::to_vec(evidence).map_err(|error| RemoteBuildError::Serialization {
5218 message: bounded_message(&error.to_string()),
5219 })?;
5220 Ok(hex_sha256(&bytes))
5221}
5222
5223impl GithubSignedCleanupEvidenceV1 {
5224 #[allow(clippy::too_many_arguments)]
5225 pub(crate) fn from_verified_artifact(
5226 context: &GithubArtifactContext,
5227 compile_evidence: &CompilePhaseEvidence,
5228 manifest: &ArtifactManifest,
5229 manifest_sha256: &str,
5230 github_artifact_id: u64,
5231 github_artifact_api_sha256: &str,
5232 ) -> RemoteBuildResult<Self> {
5233 validate_github_compile_phase_evidence(
5234 compile_evidence,
5235 &context.request,
5236 &context.job_id,
5237 )?;
5238 validate_manifests_for_context(context, std::slice::from_ref(manifest))?;
5239 let execution_repository_id = context
5240 .execution_repository_id
5241 .filter(|identity| *identity != 0)
5242 .ok_or_else(|| resume_failure("signed_cleanup_repository_identity_missing"))?;
5243 if context.operation_id != context.job_id
5244 || context.operation_id != context.request.operation_id
5245 || context.request.signing.mode != SigningMode::ManualDevelopment
5246 || context.request_sha256 != canonical_request_sha256(&context.request)?
5247 || !run_snapshot_is_successful(&context.run)
5248 || manifest.cleanup_status != CleanupStatus::Confirmed
5249 || manifest.signing.mode != SigningMode::ManualDevelopment
5250 || manifest.signing.status != SigningStatus::ArtifactValidated
5251 || github_artifact_id == 0
5252 || !is_lower_sha256(manifest_sha256)
5253 || !is_lower_sha256(github_artifact_api_sha256)
5254 {
5255 return Err(resume_failure("signed_cleanup_evidence_context_invalid"));
5256 }
5257 let signing_report = manifest
5258 .one_artifact(ArtifactKind::SigningReport)
5259 .map_err(|_| resume_failure("signed_cleanup_evidence_reports_invalid"))?;
5260 let validation_report = manifest
5261 .one_artifact(ArtifactKind::ValidationReport)
5262 .map_err(|_| resume_failure("signed_cleanup_evidence_reports_invalid"))?;
5263 if !is_lower_sha256(&signing_report.sha256) || !is_lower_sha256(&validation_report.sha256) {
5264 return Err(resume_failure("signed_cleanup_evidence_reports_invalid"));
5265 }
5266 Ok(Self {
5267 schema_version: GITHUB_SIGNED_CLEANUP_EVIDENCE_SCHEMA_VERSION,
5268 provider: GITHUB_PROVIDER_ID.to_owned(),
5269 operation_id: context.operation_id.clone(),
5270 job_id: context.job_id.clone(),
5271 execution_repository: repository_url(&context.repository),
5272 execution_repository_id,
5273 request_sha256: context.request_sha256.clone(),
5274 source_repository: context.source_repository.clone(),
5275 source_revision: context.source_revision.as_str().to_owned(),
5276 source_sha256: context.request.source.sha256.clone(),
5277 dispatch_revision: context.dispatch_revision.as_str().to_owned(),
5278 run: GithubRunIdentityV1::from_snapshot(&context.run),
5279 compile_evidence_sha256: compile_evidence_sha256(compile_evidence)?,
5280 manifest_sha256: manifest_sha256.to_owned(),
5281 signing_report_sha256: signing_report.sha256.clone(),
5282 validation_report_sha256: validation_report.sha256.clone(),
5283 github_artifact_id,
5284 github_artifact_api_sha256: github_artifact_api_sha256.to_owned(),
5285 })
5286 }
5287
5288 pub fn validate_for_resume(&self, resume: &GithubJobResumeV1) -> RemoteBuildResult<()> {
5295 let compile_evidence = resume
5296 .compile_evidence
5297 .as_ref()
5298 .ok_or_else(|| resume_failure("signed_cleanup_compile_evidence_missing"))?;
5299 validate_github_compile_phase_evidence(compile_evidence, &resume.request, &resume.job_id)?;
5300 let run = resume
5301 .run
5302 .as_ref()
5303 .ok_or_else(|| resume_failure("signed_cleanup_run_identity_missing"))?;
5304 let [manifest] = resume.manifests.as_slice() else {
5305 return Err(resume_failure("signed_cleanup_manifest_set_invalid"));
5306 };
5307 let manifest_record = manifest
5308 .one_artifact(ArtifactKind::Manifest)
5309 .map_err(|_| resume_failure("signed_cleanup_manifest_digest_missing"))?;
5310 let signing_report = manifest
5311 .one_artifact(ArtifactKind::SigningReport)
5312 .map_err(|_| resume_failure("signed_cleanup_report_digest_missing"))?;
5313 let validation_report = manifest
5314 .one_artifact(ArtifactKind::ValidationReport)
5315 .map_err(|_| resume_failure("signed_cleanup_report_digest_missing"))?;
5316 if self.schema_version != GITHUB_SIGNED_CLEANUP_EVIDENCE_SCHEMA_VERSION
5317 || self.provider != GITHUB_PROVIDER_ID
5318 || self.operation_id != resume.operation_id
5319 || self.job_id != resume.job_id
5320 || self.execution_repository != resume.execution_repository
5321 || self.execution_repository_id == 0
5322 || self.execution_repository_id != resume.execution_repository_id
5323 || self.request_sha256 != resume.request_sha256
5324 || self.source_repository != resume.source_repository
5325 || self.source_revision != resume.source_revision
5326 || self.source_sha256 != resume.request.source.sha256
5327 || resume.dispatch_commit.as_deref() != Some(self.dispatch_revision.as_str())
5328 || &self.run != run
5329 || self.run.status != GithubRunStatusV1::Completed
5330 || self.run.conclusion != Some(GithubRunConclusionV1::Success)
5331 || self.compile_evidence_sha256 != compile_evidence_sha256(compile_evidence)?
5332 || self.manifest_sha256 != manifest_record.sha256
5333 || self.signing_report_sha256 != signing_report.sha256
5334 || self.validation_report_sha256 != validation_report.sha256
5335 || self.github_artifact_id == 0
5336 || !is_lower_sha256(&self.compile_evidence_sha256)
5337 || !is_lower_sha256(&self.manifest_sha256)
5338 || !is_lower_sha256(&self.signing_report_sha256)
5339 || !is_lower_sha256(&self.validation_report_sha256)
5340 || !is_lower_sha256(&self.github_artifact_api_sha256)
5341 || resume.request.signing.mode != SigningMode::ManualDevelopment
5342 || manifest.operation_id != resume.operation_id
5343 || manifest.job_id != resume.job_id
5344 || manifest.provider != GITHUB_PROVIDER_ID
5345 || manifest.source_repository.as_deref() != Some(resume.source_repository.as_str())
5346 || manifest.source_revision.as_deref() != Some(resume.source_revision.as_str())
5347 || manifest.source_sha256 != compile_evidence.source_sha256
5348 || manifest.cargo_lock_sha256 != compile_evidence.cargo_lock_sha256
5349 || manifest.config_sha256 != compile_evidence.config_sha256
5350 || manifest.cleanup_status != CleanupStatus::Confirmed
5351 || manifest.signing.mode != SigningMode::ManualDevelopment
5352 || manifest.signing.status != SigningStatus::ArtifactValidated
5353 {
5354 return Err(resume_failure("signed_cleanup_evidence_mismatch"));
5355 }
5356 Ok(())
5357 }
5358}
5359
5360impl GithubGitSnapshotResumeV1 {
5361 #[allow(
5362 clippy::too_many_lines,
5363 reason = "one validator keeps every snapshot phase field invariant adjacent"
5364 )]
5365 fn validate_for_request(&self, request: &IosDeviceBuildRequest) -> RemoteBuildResult<()> {
5366 if self.schema_version != GITHUB_GIT_SNAPSHOT_RESUME_SCHEMA_VERSION
5367 || request.source_mode != SourceMode::GitSnapshot
5368 || request.signing.mode != SigningMode::UnsignedCompileOnly
5369 {
5370 return Err(resume_failure("resume_git_snapshot_mode_invalid"));
5371 }
5372 self.stage_locator
5373 .validate_for_operation(&request.operation_id)
5374 .map_err(|_| resume_failure("resume_git_snapshot_locator_invalid"))?;
5375 let descriptor = GitSnapshotDescriptor::from_request(
5376 request,
5377 SourceBundleDescriptor::new(self.stage.archive.clone(), request.source.clone()),
5378 )
5379 .map_err(|_| resume_failure("resume_git_snapshot_descriptor_invalid"))?;
5380 self.stage
5381 .validate_for_request(&descriptor, request)
5382 .map_err(|_| resume_failure("resume_git_snapshot_stage_invalid"))?;
5383 if self.stage_locator.operation_id != self.stage.operation_id
5384 || self.stage_locator.isolation_root_identity != self.stage.isolation_root_identity
5385 || self.stage_locator.snapshots_store_identity != self.stage.snapshots_store_identity
5386 || self.stage_locator.stage_directory_identity != self.stage.stage_directory_identity
5387 || self.source_publication_attempts > 3
5388 || self.source_publication_absence_observations > 2
5389 || (self.source_publication_absence_observations == 0)
5390 != (self.source_publication_absence_first_observed_at_ms == 0)
5391 || (self.source_publication_absence_observations == 0)
5392 != (self.source_publication_absence_last_observed_at_ms == 0)
5393 || self.source_publication_absence_last_observed_at_ms
5394 < self.source_publication_absence_first_observed_at_ms
5395 || self.source_publication_process_fenced
5396 != self.source_publication_lease_scope_sha256.is_some()
5397 || self
5398 .source_publication_lease_scope_sha256
5399 .as_ref()
5400 .is_some_and(|scope| !is_lower_sha256(scope))
5401 || self
5402 .keepalive_release_authorization_sha256
5403 .as_ref()
5404 .is_some_and(|digest| !is_lower_sha256(digest))
5405 {
5406 return Err(resume_failure("resume_git_snapshot_binding_invalid"));
5407 }
5408 let before_source = matches!(
5409 self.phase,
5410 GithubGitSnapshotPhaseV1::Prepared
5411 | GithubGitSnapshotPhaseV1::KeepaliveIntent
5412 | GithubGitSnapshotPhaseV1::KeepaliveExact
5413 | GithubGitSnapshotPhaseV1::StageDeleteIntent
5414 | GithubGitSnapshotPhaseV1::StageDeleted
5415 );
5416 if before_source
5417 && (self.source_publication_attempts != 0
5418 || self.source_publication_started_at_ms != 0
5419 || self.source_publication_quiescence_deadline_ms != 0
5420 || self.source_publication_process_fenced
5421 || self.source_publication_absence_observations != 0)
5422 {
5423 return Err(resume_failure("resume_git_snapshot_source_started_early"));
5424 }
5425 let source_phase = !before_source;
5426 if source_phase && self.source_publication_attempts == 0 {
5427 return Err(resume_failure("resume_git_snapshot_source_intent_missing"));
5428 }
5429 if source_phase && !self.source_publication_process_fenced {
5430 return Err(resume_failure("resume_git_snapshot_source_fence_missing"));
5431 }
5432 if self.source_publication_started_at_ms == 0
5433 && source_phase
5434 && self.source_publication_quiescence_deadline_ms != u64::MAX
5435 {
5436 return Err(resume_failure(
5437 "resume_git_snapshot_source_boundary_invalid",
5438 ));
5439 }
5440 if self.source_publication_started_at_ms != 0
5441 && self.source_publication_quiescence_deadline_ms
5442 < self.source_publication_started_at_ms
5443 {
5444 return Err(resume_failure(
5445 "resume_git_snapshot_source_boundary_invalid",
5446 ));
5447 }
5448 if matches!(
5449 self.phase,
5450 GithubGitSnapshotPhaseV1::SourceExact
5451 | GithubGitSnapshotPhaseV1::SourceConflict
5452 | GithubGitSnapshotPhaseV1::SourceCleanupIntent
5453 | GithubGitSnapshotPhaseV1::SourceDeleted
5454 | GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
5455 | GithubGitSnapshotPhaseV1::KeepaliveReleased
5456 ) && self.source_publication_started_at_ms == 0
5457 {
5458 return Err(resume_failure(
5459 "resume_git_snapshot_source_ownership_unarmed",
5460 ));
5461 }
5462 if self.phase == GithubGitSnapshotPhaseV1::SourceAbsent
5463 && (!self.source_publication_process_fenced
5464 || self.source_publication_absence_observations < 2
5465 || self.source_publication_started_at_ms == 0
5466 || self.source_publication_absence_last_observed_at_ms
5467 < self.source_publication_quiescence_deadline_ms)
5468 {
5469 return Err(resume_failure("resume_git_snapshot_absence_unproven"));
5470 }
5471 let keepalive_release_phase = matches!(
5472 self.phase,
5473 GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
5474 | GithubGitSnapshotPhaseV1::KeepaliveReleased
5475 );
5476 if keepalive_release_phase != self.keepalive_release_authorization_sha256.is_some() {
5477 return Err(resume_failure(
5478 "resume_git_snapshot_keepalive_release_authority_invalid",
5479 ));
5480 }
5481 Ok(())
5482 }
5483
5484 #[allow(
5485 clippy::too_many_lines,
5486 reason = "the snapshot reducer keeps every durable phase and retry edge in one audit boundary"
5487 )]
5488 fn validate_successor(&self, next: &Self) -> RemoteBuildResult<()> {
5489 if self.schema_version != next.schema_version
5490 || self.stage_locator != next.stage_locator
5491 || self.stage != next.stage
5492 || self.source_publication_process_fenced && !next.source_publication_process_fenced
5493 || !option_can_bind_once(
5494 self.source_publication_lease_scope_sha256.as_deref(),
5495 next.source_publication_lease_scope_sha256.as_deref(),
5496 )
5497 || !option_can_bind_once(
5498 self.keepalive_release_authorization_sha256.as_deref(),
5499 next.keepalive_release_authorization_sha256.as_deref(),
5500 )
5501 {
5502 return Err(resume_failure("resume_git_snapshot_identity_changed"));
5503 }
5504 let allowed_phase = self.phase == next.phase
5505 || matches!(
5506 (self.phase, next.phase),
5507 (
5508 GithubGitSnapshotPhaseV1::Prepared,
5509 GithubGitSnapshotPhaseV1::KeepaliveIntent
5510 ) | (
5511 GithubGitSnapshotPhaseV1::KeepaliveIntent,
5512 GithubGitSnapshotPhaseV1::KeepaliveExact
5513 ) | (
5514 GithubGitSnapshotPhaseV1::KeepaliveExact,
5515 GithubGitSnapshotPhaseV1::StageDeleteIntent
5516 ) | (
5517 GithubGitSnapshotPhaseV1::StageDeleteIntent,
5518 GithubGitSnapshotPhaseV1::StageDeleted
5519 ) | (
5520 GithubGitSnapshotPhaseV1::StageDeleted | GithubGitSnapshotPhaseV1::SourceAbsent,
5521 GithubGitSnapshotPhaseV1::SourcePublishIntent
5522 ) | (
5523 GithubGitSnapshotPhaseV1::SourcePublishIntent,
5524 GithubGitSnapshotPhaseV1::SourceExact
5525 | GithubGitSnapshotPhaseV1::SourceAbsent
5526 | GithubGitSnapshotPhaseV1::SourceConflict
5527 ) | (
5528 GithubGitSnapshotPhaseV1::SourceExact,
5529 GithubGitSnapshotPhaseV1::SourceCleanupIntent
5530 | GithubGitSnapshotPhaseV1::SourceConflict
5531 ) | (
5532 GithubGitSnapshotPhaseV1::SourceCleanupIntent,
5533 GithubGitSnapshotPhaseV1::SourceDeleted
5534 ) | (
5535 GithubGitSnapshotPhaseV1::SourceDeleted
5536 | GithubGitSnapshotPhaseV1::SourceAbsent
5537 | GithubGitSnapshotPhaseV1::SourceConflict,
5538 GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
5539 ) | (
5540 GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent,
5541 GithubGitSnapshotPhaseV1::KeepaliveReleased
5542 )
5543 );
5544 if !allowed_phase {
5545 return Err(resume_failure("resume_git_snapshot_phase_regressed"));
5546 }
5547 let retrying_absent = self.phase == GithubGitSnapshotPhaseV1::SourceAbsent
5548 && next.phase == GithubGitSnapshotPhaseV1::SourcePublishIntent;
5549 let first_source_intent = self.phase == GithubGitSnapshotPhaseV1::StageDeleted
5550 && next.phase == GithubGitSnapshotPhaseV1::SourcePublishIntent;
5551 let expected_attempts = if retrying_absent || first_source_intent {
5552 self.source_publication_attempts
5553 .checked_add(1)
5554 .ok_or_else(|| resume_failure("resume_git_snapshot_attempt_overflow"))?
5555 } else {
5556 self.source_publication_attempts
5557 };
5558 if next.source_publication_attempts != expected_attempts {
5559 return Err(resume_failure("resume_git_snapshot_attempt_invalid"));
5560 }
5561 if retrying_absent {
5562 if next.source_publication_started_at_ms != 0
5563 || next.source_publication_quiescence_deadline_ms != u64::MAX
5564 || next.source_publication_absence_observations != 0
5565 || next.source_publication_absence_first_observed_at_ms != 0
5566 || next.source_publication_absence_last_observed_at_ms != 0
5567 {
5568 return Err(resume_failure("resume_git_snapshot_retry_not_rearmed"));
5569 }
5570 } else {
5571 if self.source_publication_started_at_ms != 0
5572 && self.source_publication_started_at_ms != next.source_publication_started_at_ms
5573 {
5574 return Err(resume_failure("resume_git_snapshot_start_changed"));
5575 }
5576 if self.source_publication_quiescence_deadline_ms != 0
5577 && self.source_publication_started_at_ms != 0
5578 && self.source_publication_quiescence_deadline_ms
5579 != next.source_publication_quiescence_deadline_ms
5580 {
5581 return Err(resume_failure("resume_git_snapshot_deadline_changed"));
5582 }
5583 if self.source_publication_absence_observations
5584 > next.source_publication_absence_observations
5585 || self.source_publication_absence_first_observed_at_ms != 0
5586 && self.source_publication_absence_first_observed_at_ms
5587 != next.source_publication_absence_first_observed_at_ms
5588 || self.source_publication_absence_last_observed_at_ms
5589 > next.source_publication_absence_last_observed_at_ms
5590 {
5591 return Err(resume_failure("resume_git_snapshot_absence_regressed"));
5592 }
5593 }
5594 Ok(())
5595 }
5596}
5597
5598impl GithubJobResumeV1 {
5599 #[allow(clippy::too_many_lines)]
5606 pub fn validate_trigger_binding(&self) -> RemoteBuildResult<()> {
5607 let Some(dispatch) = self.workflow_dispatch.as_deref() else {
5608 if self.run.as_ref().is_some_and(|run| {
5609 matches!(
5610 self.principal,
5611 GithubPrincipalIdentityV1::RepositoryCredential
5612 ) || run.event != GithubRunEventV1::Push
5613 }) {
5614 return Err(resume_failure("resume_run_trigger_mismatch"));
5615 }
5616 return Ok(());
5617 };
5618 if !self.publication_intent
5619 || self.publication_uncertain
5620 || self.dispatch_commit.as_deref() != Some(dispatch.dispatch_revision.as_str())
5621 || dispatch.schema_version != GITHUB_WORKFLOW_DISPATCH_RESUME_SCHEMA_VERSION
5622 || dispatch.workflow_path != self.workflow_path
5623 || dispatch.operation_id != self.operation_id
5624 || dispatch.operation_id != self.request.operation_id
5625 || dispatch.request_sha256 != self.request_sha256
5626 || dispatch.source_revision != self.source_revision
5627 || dispatch.uncertain != dispatch.receipt.is_none()
5628 {
5629 return Err(resume_failure("resume_workflow_dispatch_identity_mismatch"));
5630 }
5631 self.request.validate()?;
5632 if canonical_request_sha256(&self.request)? != self.request_sha256 {
5633 return Err(resume_failure("resume_workflow_dispatch_request_mismatch"));
5634 }
5635 let repository = repository_from_url(&self.execution_repository)
5636 .ok_or_else(|| resume_failure("resume_execution_repository_invalid"))?;
5637 let branch = self
5638 .temporary_ref
5639 .strip_prefix("refs/heads/")
5640 .ok_or_else(|| resume_failure("resume_temporary_ref_invalid"))?;
5641 let suffix = format!("/{}", self.operation_id);
5642 let namespace = branch
5643 .strip_suffix(&suffix)
5644 .ok_or_else(|| resume_failure("resume_workflow_dispatch_branch_mismatch"))?;
5645 let namespace = TemporaryBranchNamespace::new(namespace)
5646 .map_err(|_| resume_failure("resume_temporary_ref_invalid"))?;
5647 let temporary_ref = TemporaryGitRef::new(
5648 &namespace,
5649 BranchName::new(branch).map_err(|_| resume_failure("resume_temporary_ref_invalid"))?,
5650 )
5651 .map_err(|_| resume_failure("resume_temporary_ref_invalid"))?;
5652 if dispatch.branch != temporary_ref.branch().as_str() {
5653 return Err(resume_failure("resume_workflow_dispatch_branch_mismatch"));
5654 }
5655 let registration = WorkflowRegistration::restore(
5656 repository.clone(),
5657 WorkflowId::new(dispatch.workflow_id)
5658 .map_err(|_| resume_failure("resume_workflow_dispatch_identity_invalid"))?,
5659 dispatch.workflow_path.clone(),
5660 )
5661 .map_err(|_| resume_failure("resume_workflow_dispatch_identity_invalid"))?;
5662 let request = WorkflowDispatchRequest::new(
5663 repository,
5664 registration,
5665 temporary_ref,
5666 self.operation_id.clone(),
5667 self.request_sha256.clone(),
5668 CommitSha::new(self.source_revision.clone())
5669 .map_err(|_| resume_failure("resume_source_revision_invalid"))?,
5670 CommitSha::new(dispatch.dispatch_revision.clone())
5671 .map_err(|_| resume_failure("resume_dispatch_commit_invalid"))?,
5672 )
5673 .map_err(|_| resume_failure("resume_workflow_dispatch_identity_invalid"))?;
5674 if request.body_sha256() != dispatch.body_sha256 || request.run_name() != dispatch.run_name
5675 {
5676 return Err(resume_failure("resume_workflow_dispatch_body_mismatch"));
5677 }
5678 if dispatch.receipt.as_ref().is_some_and(|receipt| {
5679 receipt.run_id == 0
5680 || receipt.workflow_id != dispatch.workflow_id
5681 || receipt.workflow_path != dispatch.workflow_path
5682 || receipt.branch != dispatch.branch
5683 || receipt.dispatch_revision != dispatch.dispatch_revision
5684 || receipt.run_name != dispatch.run_name
5685 }) {
5686 return Err(resume_failure("resume_workflow_dispatch_receipt_mismatch"));
5687 }
5688 match (&dispatch.receipt, &self.run) {
5689 (None, Some(_)) => Err(resume_failure("resume_workflow_dispatch_receipt_missing")),
5690 (None | Some(_), None) => Ok(()),
5691 (Some(receipt), Some(run))
5692 if receipt.run_id == run.run_id
5693 && receipt.workflow_id == dispatch.workflow_id
5694 && receipt.workflow_path == dispatch.workflow_path
5695 && receipt.branch == dispatch.branch
5696 && receipt.dispatch_revision == dispatch.dispatch_revision
5697 && receipt.run_name == dispatch.run_name
5698 && run.workflow_id == receipt.workflow_id
5699 && run.workflow_path == receipt.workflow_path
5700 && run.head_sha == receipt.dispatch_revision
5701 && run.branch == receipt.branch
5702 && run.event == GithubRunEventV1::WorkflowDispatch
5703 && run.run_attempt == 1 =>
5704 {
5705 Ok(())
5706 }
5707 (Some(_), Some(_)) => Err(resume_failure("resume_workflow_dispatch_run_mismatch")),
5708 }
5709 }
5710
5711 pub fn encode(&self) -> RemoteBuildResult<Vec<u8>> {
5717 let mut bytes =
5718 serde_json::to_vec(self).map_err(|error| RemoteBuildError::Serialization {
5719 message: bounded_message(&error.to_string()),
5720 })?;
5721 bytes.push(b'\n');
5722 if bytes.len() > GITHUB_JOB_RESUME_MAX_BYTES {
5723 return Err(resume_failure("resume_record_too_large"));
5724 }
5725 Ok(bytes)
5726 }
5727
5728 pub fn decode(bytes: &[u8]) -> RemoteBuildResult<Self> {
5735 let raw: serde_json::Value = crate::strict_json::decode(bytes, GITHUB_JOB_RESUME_MAX_BYTES)
5736 .map_err(|_| resume_failure("resume_record_json_invalid"))?;
5737 let resume: Self = serde_json::from_value(raw.clone())
5738 .map_err(|_| resume_failure("resume_record_json_invalid"))?;
5739 let normalized = serde_json::to_value(&resume)
5740 .map_err(|_| resume_failure("resume_record_json_invalid"))?;
5741 if normalized != raw {
5742 return Err(resume_failure("resume_record_json_invalid"));
5743 }
5744 Ok(resume)
5745 }
5746
5747 #[allow(clippy::too_many_lines)]
5757 pub fn validate_successor(&self, next: &Self) -> RemoteBuildResult<()> {
5758 if self.schema_version != next.schema_version
5759 || self.provider != next.provider
5760 || self.provider_config_sha256 != next.provider_config_sha256
5761 || self.principal != next.principal
5762 || self.execution_repository != next.execution_repository
5763 || self.execution_repository_id != next.execution_repository_id
5764 || self.source_repository != next.source_repository
5765 || self.trusted_source_ref != next.trusted_source_ref
5766 || self.workflow_path != next.workflow_path
5767 || self.workflow_sha256 != next.workflow_sha256
5768 || self.temporary_ref != next.temporary_ref
5769 || self.operation_id != next.operation_id
5770 || self.job_id != next.job_id
5771 || self.request != next.request
5772 || self.request_sha256 != next.request_sha256
5773 || self.source_revision != next.source_revision
5774 || self.created_at_ms != next.created_at_ms
5775 || self.publication_process_fenced && !next.publication_process_fenced
5776 || !option_can_bind_once(
5777 self.publication_lease_scope_sha256.as_deref(),
5778 next.publication_lease_scope_sha256.as_deref(),
5779 )
5780 || next
5781 .publication_lease_scope_sha256
5782 .as_ref()
5783 .is_some_and(|scope| !is_lower_sha256(scope))
5784 || self.run_discovery_deadline_ms != next.run_discovery_deadline_ms
5785 {
5786 return Err(resume_failure("resume_successor_identity_changed"));
5787 }
5788 match (&self.git_snapshot, &next.git_snapshot) {
5789 (None, None) => {}
5790 (Some(previous), Some(successor)) => {
5791 previous.validate_for_request(&self.request)?;
5792 successor.validate_for_request(&next.request)?;
5793 previous.validate_successor(successor)?;
5794 }
5795 _ => return Err(resume_failure("resume_successor_git_snapshot_replaced")),
5796 }
5797 if next.publication_intent
5798 && next.git_snapshot.as_ref().is_some_and(|snapshot| {
5799 !matches!(
5800 snapshot.phase,
5801 GithubGitSnapshotPhaseV1::SourceExact
5802 | GithubGitSnapshotPhaseV1::SourceCleanupIntent
5803 | GithubGitSnapshotPhaseV1::SourceDeleted
5804 | GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
5805 | GithubGitSnapshotPhaseV1::KeepaliveReleased
5806 )
5807 })
5808 {
5809 return Err(resume_failure(
5810 "resume_successor_dispatch_before_snapshot_source",
5811 ));
5812 }
5813 if !publication_boundary_can_advance(self, next) {
5814 return Err(resume_failure(
5815 "resume_successor_publication_boundary_changed",
5816 ));
5817 }
5818 if !job_state_can_reach(self.state, next.state) {
5819 return Err(resume_failure("resume_successor_state_regressed"));
5820 }
5821 if !option_can_bind_once(
5822 self.compile_evidence.as_ref(),
5823 next.compile_evidence.as_ref(),
5824 ) {
5825 return Err(resume_failure("resume_successor_compile_evidence_changed"));
5826 }
5827 if !option_can_bind_once(
5828 self.signed_cleanup_evidence.as_ref(),
5829 next.signed_cleanup_evidence.as_ref(),
5830 ) {
5831 return Err(resume_failure(
5832 "resume_successor_signed_cleanup_evidence_changed",
5833 ));
5834 }
5835 if let Some(compile_evidence) = next.compile_evidence.as_ref() {
5836 validate_github_compile_phase_evidence(compile_evidence, &next.request, &next.job_id)?;
5837 }
5838 if let Some(evidence) = next.signed_cleanup_evidence.as_ref() {
5839 evidence.validate_for_resume(next)?;
5840 }
5841 if next.state == JobState::Cleaned {
5842 let cleanup_proven = match next.request.signing.mode {
5843 SigningMode::UnsignedCompileOnly => next
5844 .run
5845 .as_ref()
5846 .is_some_and(|run| run.status == GithubRunStatusV1::Completed),
5847 SigningMode::ManualDevelopment => next.signed_cleanup_evidence.is_some(),
5848 _ => false,
5849 };
5850 if !cleanup_proven {
5851 return Err(resume_failure(
5852 "resume_successor_signing_cleanup_evidence_missing",
5853 ));
5854 }
5855 }
5856 if matches!(
5857 next.state,
5858 JobState::Cleaning | JobState::Cleaned | JobState::CleanupFailed
5859 ) && next.git_snapshot.as_ref().is_some_and(|snapshot| {
5860 !matches!(
5861 snapshot.phase,
5862 GithubGitSnapshotPhaseV1::SourceDeleted
5863 | GithubGitSnapshotPhaseV1::SourceAbsent
5864 | GithubGitSnapshotPhaseV1::SourceConflict
5865 | GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
5866 | GithubGitSnapshotPhaseV1::KeepaliveReleased
5867 )
5868 }) {
5869 return Err(resume_failure(
5870 "resume_successor_snapshot_source_cleanup_missing",
5871 ));
5872 }
5873 if !option_can_bind_once(
5874 self.prepared_dispatch_commit.as_deref(),
5875 next.prepared_dispatch_commit.as_deref(),
5876 ) {
5877 return Err(resume_failure("resume_successor_prepared_dispatch_changed"));
5878 }
5879 if !option_can_bind_once(
5880 self.dispatch_commit.as_deref(),
5881 next.dispatch_commit.as_deref(),
5882 ) {
5883 return Err(resume_failure("resume_successor_dispatch_changed"));
5884 }
5885 if matches!(
5886 (
5887 next.prepared_dispatch_commit.as_deref(),
5888 next.dispatch_commit.as_deref(),
5889 ),
5890 (Some(prepared), Some(dispatch)) if prepared != dispatch
5891 ) {
5892 return Err(resume_failure("resume_successor_dispatch_binding_mismatch"));
5893 }
5894 match (&self.workflow_dispatch, &next.workflow_dispatch) {
5895 (None, _) => {}
5896 (Some(_), None) => {
5897 return Err(resume_failure(
5898 "resume_successor_workflow_dispatch_intent_removed",
5899 ));
5900 }
5901 (Some(previous), Some(candidate)) => {
5902 if previous.schema_version != candidate.schema_version
5903 || previous.workflow_id != candidate.workflow_id
5904 || previous.workflow_path != candidate.workflow_path
5905 || previous.branch != candidate.branch
5906 || previous.operation_id != candidate.operation_id
5907 || previous.request_sha256 != candidate.request_sha256
5908 || previous.source_revision != candidate.source_revision
5909 || previous.dispatch_revision != candidate.dispatch_revision
5910 || previous.body_sha256 != candidate.body_sha256
5911 || previous.run_name != candidate.run_name
5912 || previous.receipt.is_some() && previous.receipt != candidate.receipt
5913 || previous.receipt.is_none()
5914 && candidate.receipt.is_none()
5915 && previous.uncertain != candidate.uncertain
5916 || candidate.uncertain != candidate.receipt.is_none()
5917 {
5918 return Err(resume_failure("resume_successor_workflow_dispatch_changed"));
5919 }
5920 }
5921 }
5922 match (&self.run, &next.run) {
5923 (None, _) => {}
5924 (Some(_), None) => return Err(resume_failure("resume_successor_run_removed")),
5925 (Some(previous), Some(candidate)) => {
5926 if previous.run_id != candidate.run_id
5927 || previous.workflow_id != candidate.workflow_id
5928 || previous.workflow_path != candidate.workflow_path
5929 || previous.head_sha != candidate.head_sha
5930 || previous.branch != candidate.branch
5931 || previous.event != candidate.event
5932 || previous.run_number != candidate.run_number
5933 || previous.run_attempt != candidate.run_attempt
5934 {
5935 return Err(resume_failure("resume_successor_run_identity_changed"));
5936 }
5937 if run_status_phase(candidate.status) < run_status_phase(previous.status)
5938 || previous.conclusion.is_some() && previous.conclusion != candidate.conclusion
5939 {
5940 return Err(resume_failure("resume_successor_run_regressed"));
5941 }
5942 }
5943 }
5944 if !bool_is_monotonic(self.publication_intent, next.publication_intent)
5945 || !bool_is_monotonic(self.publication_absent, next.publication_absent)
5946 || !bool_is_monotonic(
5947 self.publication_not_attempted,
5948 next.publication_not_attempted,
5949 )
5950 || !bool_is_monotonic(self.cancellation_requested, next.cancellation_requested)
5951 || !bool_is_monotonic(self.cancellation_dispatched, next.cancellation_dispatched)
5952 || !bool_is_monotonic(self.cleanup_requested, next.cleanup_requested)
5953 || !bool_is_monotonic(
5954 self.remove_artifacts_requested,
5955 next.remove_artifacts_requested,
5956 )
5957 || !bool_is_monotonic(self.artifacts_removed, next.artifacts_removed)
5958 || !bool_is_monotonic(self.temporary_ref_deleted, next.temporary_ref_deleted)
5959 || !bool_is_monotonic(
5960 self.verification_pending_event,
5961 next.verification_pending_event,
5962 )
5963 {
5964 return Err(resume_failure("resume_successor_flag_regressed"));
5965 }
5966 if self.publication_uncertain
5967 && !next.publication_uncertain
5968 && next.dispatch_commit.is_none()
5969 && !next.publication_absent
5970 {
5971 return Err(resume_failure(
5972 "resume_successor_publication_evidence_removed",
5973 ));
5974 }
5975 if next.run_discovery_attempts < self.run_discovery_attempts {
5976 return Err(resume_failure("resume_successor_discovery_regressed"));
5977 }
5978 if next.publication_absence_observations < self.publication_absence_observations {
5979 return Err(resume_failure(
5980 "resume_successor_publication_observations_regressed",
5981 ));
5982 }
5983 if self.publication_absence_first_observed_at_ms != 0
5984 && self.publication_absence_first_observed_at_ms
5985 != next.publication_absence_first_observed_at_ms
5986 {
5987 return Err(resume_failure(
5988 "resume_successor_publication_first_observation_changed",
5989 ));
5990 }
5991 if self.events.len() > next.events.len()
5992 || self.events.as_slice() != &next.events[..self.events.len()]
5993 {
5994 return Err(resume_failure("resume_successor_events_regressed"));
5995 }
5996 if !self.manifests.is_empty() && self.manifests != next.manifests {
5997 return Err(resume_failure("resume_successor_manifests_changed"));
5998 }
5999 Ok(())
6000 }
6001
6002 fn from_record(
6003 config: &GithubProviderConfig,
6004 principal: GithubPrincipalIdentityV1,
6005 execution_repository_id: u64,
6006 record: &JobRecord,
6007 ) -> RemoteBuildResult<Self> {
6008 if execution_repository_id == 0 {
6009 return Err(resume_failure("resume_repository_identity_invalid"));
6010 }
6011 validate_workflow_dispatch_record(config, record)?;
6012 let run = match (&record.run, &record.run_snapshot) {
6013 (None, None) => None,
6014 (Some(handle), Some(snapshot)) if snapshot.handle() == handle => {
6015 Some(GithubRunIdentityV1::from_snapshot(snapshot))
6016 }
6017 _ => return Err(resume_failure("resume_run_identity_incomplete")),
6018 };
6019 Ok(Self {
6020 schema_version: GITHUB_JOB_RESUME_SCHEMA_VERSION,
6021 provider: GITHUB_PROVIDER_ID.to_owned(),
6022 provider_config_sha256: provider_config_sha256(config)?,
6023 principal,
6024 execution_repository: repository_url(&config.repository),
6025 execution_repository_id,
6026 source_repository: config.source_repository.clone(),
6027 trusted_source_ref: config.workflow.trusted_source_ref().as_str().to_owned(),
6028 workflow_path: config.workflow.filename().repository_path(),
6029 workflow_sha256: config.workflow_fingerprint.as_str().to_owned(),
6030 temporary_ref: format!("refs/heads/{}", record.temporary_ref.branch().as_str()),
6031 operation_id: record.operation_id.clone(),
6032 job_id: record.job_id.clone(),
6033 request: record.request.clone(),
6034 request_sha256: record.request_sha256.clone(),
6035 source_revision: record.source_revision.as_str().to_owned(),
6036 git_snapshot: record.git_snapshot.clone(),
6037 prepared_dispatch_commit: record
6038 .prepared_dispatch_commit
6039 .as_ref()
6040 .map(|commit| commit.as_str().to_owned()),
6041 dispatch_commit: record
6042 .dispatch_commit
6043 .as_ref()
6044 .map(|commit| commit.as_str().to_owned()),
6045 workflow_dispatch: record.workflow_dispatch.clone().map(Box::new),
6046 run,
6047 created_at_ms: record.created_at_ms,
6048 publication_started_at_ms: record.publication_started_at_ms,
6049 publication_quiescence_deadline_ms: record.publication_quiescence_deadline_ms,
6050 state: record.state,
6051 publication_intent: record.publication_intent,
6052 publication_uncertain: record.publication_uncertain,
6053 publication_absent: record.publication_absent,
6054 publication_not_attempted: record.publication_not_attempted,
6055 publication_process_fenced: record.publication_process_fenced,
6056 publication_lease_scope_sha256: record.publication_lease_scope_sha256.clone(),
6057 publication_absence_observations: record.publication_absence_observations,
6058 publication_absence_first_observed_at_ms: record
6059 .publication_absence_first_observed_at_ms,
6060 cancellation_requested: record.cancellation_requested,
6061 cancellation_dispatched: record.cancellation_dispatched,
6062 cleanup_requested: record.cleanup_requested,
6063 remove_artifacts_requested: record.remove_artifacts_requested,
6064 artifacts_removed: record.artifacts_removed,
6065 temporary_ref_deleted: record.temporary_ref_deleted,
6066 verification_pending_event: record.verification_pending_event,
6067 run_discovery_attempts: record.run_discovery_attempts,
6068 run_discovery_deadline_ms: record.run_discovery_deadline_ms,
6069 manifests: record.manifests.clone(),
6070 compile_evidence: record.compile_evidence.clone(),
6071 signed_cleanup_evidence: record.signed_cleanup_evidence.clone(),
6072 events: record.events.clone(),
6073 })
6074 }
6075
6076 #[allow(clippy::too_many_lines)]
6077 fn into_record(
6078 self,
6079 config: &GithubProviderConfig,
6080 principal: &GithubPrincipalIdentityV1,
6081 execution_repository_id: u64,
6082 ) -> RemoteBuildResult<JobRecord> {
6083 if self.schema_version != GITHUB_JOB_RESUME_SCHEMA_VERSION
6084 || self.provider != GITHUB_PROVIDER_ID
6085 || self.provider_config_sha256 != provider_config_sha256(config)?
6086 || self.principal != *principal
6087 || !github_remote_matches(
6088 &self.execution_repository,
6089 &repository_url(&config.repository),
6090 )
6091 || self.execution_repository_id == 0
6092 || self.execution_repository_id != execution_repository_id
6093 || self.source_repository != config.source_repository
6094 || self.trusted_source_ref != config.workflow.trusted_source_ref().as_str()
6095 || self.workflow_path != config.workflow.filename().repository_path()
6096 || self.workflow_sha256 != config.workflow_fingerprint.as_str()
6097 {
6098 return Err(resume_failure("resume_provider_identity_mismatch"));
6099 }
6100 validate_provider_request(config, &self.request)?;
6101 if self.operation_id != self.job_id
6102 || self.operation_id != self.request.operation_id
6103 || self.request_sha256 != canonical_request_sha256(&self.request)?
6104 || self.request.source_revision.as_deref() != Some(self.source_revision.as_str())
6105 {
6106 return Err(resume_failure("resume_request_identity_mismatch"));
6107 }
6108 if let Some(compile_evidence) = self.compile_evidence.as_ref() {
6109 validate_github_compile_phase_evidence(compile_evidence, &self.request, &self.job_id)?;
6110 }
6111 if let Some(evidence) = self.signed_cleanup_evidence.as_ref() {
6112 evidence.validate_for_resume(&self)?;
6113 }
6114 let branch = BranchName::new(format!(
6115 "{}/{}",
6116 config.workflow.temporary_branch_namespace().as_str(),
6117 self.operation_id
6118 ))
6119 .map_err(|_| resume_failure("resume_temporary_ref_invalid"))?;
6120 let temporary_ref =
6121 TemporaryGitRef::new(config.workflow.temporary_branch_namespace(), branch)
6122 .map_err(|_| resume_failure("resume_temporary_ref_invalid"))?;
6123 if self.temporary_ref != format!("refs/heads/{}", temporary_ref.branch().as_str()) {
6124 return Err(resume_failure("resume_temporary_ref_mismatch"));
6125 }
6126 let dispatch_manifest =
6127 GithubDispatchManifest::new(config, &temporary_ref, self.request.clone());
6128 dispatch_manifest
6129 .validate_for(config)
6130 .and_then(|()| dispatch_manifest.encode().map(|_| ()))
6131 .map_err(|_| resume_failure("resume_dispatch_manifest_invalid"))?;
6132 let source_revision = CommitSha::new(self.source_revision.clone())
6133 .map_err(|_| resume_failure("resume_source_revision_invalid"))?;
6134 match (&self.request.source_mode, &self.git_snapshot) {
6135 (SourceMode::Git, None) => {}
6136 (SourceMode::GitSnapshot, Some(snapshot)) => {
6137 snapshot.validate_for_request(&self.request)?;
6138 }
6139 _ => return Err(resume_failure("resume_git_snapshot_binding_missing")),
6140 }
6141 let dispatch_commit = self
6142 .dispatch_commit
6143 .as_deref()
6144 .map(CommitSha::new)
6145 .transpose()
6146 .map_err(|_| resume_failure("resume_dispatch_revision_invalid"))?;
6147 let prepared_dispatch_commit = self
6148 .prepared_dispatch_commit
6149 .as_deref()
6150 .map(CommitSha::new)
6151 .transpose()
6152 .map_err(|_| resume_failure("resume_prepared_dispatch_revision_invalid"))?;
6153 if matches!(
6154 (&prepared_dispatch_commit, &dispatch_commit),
6155 (Some(prepared), Some(dispatch)) if prepared != dispatch
6156 ) {
6157 return Err(resume_failure("resume_prepared_dispatch_revision_mismatch"));
6158 }
6159 let (run, run_snapshot) = match self.run.as_ref() {
6160 None => (None, None),
6161 Some(identity) => {
6162 let dispatch = dispatch_commit
6163 .as_ref()
6164 .ok_or_else(|| resume_failure("resume_run_without_dispatch"))?;
6165 if identity.workflow_path != self.workflow_path
6166 || identity.head_sha != dispatch.as_str()
6167 || identity.branch != temporary_ref.branch().as_str()
6168 || RunEvent::from(identity.event)
6169 != workflow_run_event(config.workflow.run_trigger())
6170 {
6171 return Err(resume_failure("resume_run_identity_mismatch"));
6172 }
6173 let handle = RunHandle::restore(
6174 identity.run_id,
6175 identity.workflow_id,
6176 identity.workflow_path.clone(),
6177 identity.head_sha.clone(),
6178 identity.branch.clone(),
6179 identity.event.into(),
6180 )
6181 .map_err(|_| resume_failure("resume_run_identity_invalid"))?;
6182 let snapshot = RunSnapshot::restore(
6183 handle.clone(),
6184 identity.run_number,
6185 identity.run_attempt,
6186 identity.status.into(),
6187 identity.conclusion.map(Into::into),
6188 )
6189 .map_err(|_| resume_failure("resume_run_snapshot_invalid"))?;
6190 (Some(handle), Some(snapshot))
6191 }
6192 };
6193 if self.events.len() > MAX_JOB_EVENTS || self.manifests.len() > MAX_JOB_MANIFESTS {
6194 return Err(resume_failure("resume_record_capacity_exceeded"));
6195 }
6196 for (index, event) in self.events.iter().enumerate() {
6197 event.validate()?;
6198 if event.operation_id != self.operation_id
6199 || event.job_id != self.job_id
6200 || event.provider != GITHUB_PROVIDER_ID
6201 || event.sequence != u64::try_from(index).unwrap_or(u64::MAX) + 1
6202 || matches!(&event.kind, RemoteBuildEventKind::Unknown)
6203 {
6204 return Err(resume_failure("resume_event_identity_mismatch"));
6205 }
6206 }
6207 let snapshot_pre_dispatch = self.git_snapshot.as_ref().is_some_and(|snapshot| {
6208 matches!(
6209 snapshot.phase,
6210 GithubGitSnapshotPhaseV1::Prepared
6211 | GithubGitSnapshotPhaseV1::KeepaliveIntent
6212 | GithubGitSnapshotPhaseV1::KeepaliveExact
6213 | GithubGitSnapshotPhaseV1::StageDeleteIntent
6214 | GithubGitSnapshotPhaseV1::StageDeleted
6215 | GithubGitSnapshotPhaseV1::SourcePublishIntent
6216 | GithubGitSnapshotPhaseV1::SourceExact
6217 | GithubGitSnapshotPhaseV1::SourceAbsent
6218 | GithubGitSnapshotPhaseV1::SourceConflict
6219 | GithubGitSnapshotPhaseV1::SourceCleanupIntent
6220 | GithubGitSnapshotPhaseV1::SourceDeleted
6221 )
6222 }) && !self.publication_intent
6223 && prepared_dispatch_commit.is_none()
6224 && dispatch_commit.is_none()
6225 && run.is_none()
6226 && self.publication_started_at_ms == 0
6227 && self.publication_quiescence_deadline_ms == u64::MAX
6228 && !self.publication_uncertain
6229 && !self.publication_absent
6230 && !self.publication_not_attempted
6231 && !self.publication_process_fenced
6232 && self.publication_lease_scope_sha256.is_none()
6233 && self.publication_absence_observations == 0
6234 && self.publication_absence_first_observed_at_ms == 0;
6235 let snapshot_terminal_pre_dispatch = self.git_snapshot.as_ref().is_some_and(|snapshot| {
6236 snapshot.phase == GithubGitSnapshotPhaseV1::SourceConflict
6237 || snapshot.phase == GithubGitSnapshotPhaseV1::SourceAbsent
6238 && snapshot.source_publication_attempts >= 3
6239 });
6240 let snapshot_cancel_cleanup_proven = self.git_snapshot.as_ref().is_some_and(|snapshot| {
6241 snapshot.phase == GithubGitSnapshotPhaseV1::StageDeleted
6242 && snapshot.source_publication_attempts == 0
6243 || snapshot.phase == GithubGitSnapshotPhaseV1::SourcePublishIntent
6244 && snapshot.source_publication_started_at_ms == 0
6245 || matches!(
6246 snapshot.phase,
6247 GithubGitSnapshotPhaseV1::SourceAbsent
6248 | GithubGitSnapshotPhaseV1::SourceConflict
6249 | GithubGitSnapshotPhaseV1::SourceDeleted
6250 )
6251 });
6252 let snapshot_source_cleanup_proven = self.git_snapshot.as_ref().is_none_or(|snapshot| {
6253 matches!(
6254 snapshot.phase,
6255 GithubGitSnapshotPhaseV1::SourceDeleted
6256 | GithubGitSnapshotPhaseV1::SourceAbsent
6257 | GithubGitSnapshotPhaseV1::SourceConflict
6258 | GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
6259 | GithubGitSnapshotPhaseV1::KeepaliveReleased
6260 )
6261 });
6262 let no_dispatch_state_valid = match self.state {
6263 JobState::Created => !self.publication_absent,
6264 JobState::Cancelling => {
6265 self.cancellation_requested
6266 && !self.publication_absent
6267 && (self.publication_intent || snapshot_pre_dispatch)
6268 }
6269 JobState::Cancelled => {
6270 self.cancellation_requested
6271 && (self.publication_absent
6272 || snapshot_pre_dispatch && snapshot_cancel_cleanup_proven)
6273 }
6274 JobState::Failed => {
6275 self.publication_uncertain
6276 || self.publication_absent
6277 || self.publication_not_attempted
6278 || snapshot_pre_dispatch && snapshot_terminal_pre_dispatch
6279 }
6280 JobState::Cleaning | JobState::CleanupFailed => {
6281 (self.publication_absent || self.publication_not_attempted)
6282 && self.cleanup_requested
6283 || snapshot_pre_dispatch
6284 && snapshot_terminal_pre_dispatch
6285 && self.cleanup_requested
6286 }
6287 JobState::Queued | JobState::Running | JobState::Succeeded | JobState::Cleaned => false,
6288 };
6289 let expected_discovery_deadline = self
6290 .created_at_ms
6291 .saturating_add(config.poll_policy.discovery_window_ms());
6292 let publication_boundary_armed = publication_boundary_is_armed(&self);
6293 let publication_boundary_unarmed = publication_boundary_is_unarmed(&self);
6294 let successful_run = run_snapshot
6295 .as_ref()
6296 .is_some_and(run_snapshot_is_successful);
6297 let cleanup_success_run = match self.request.signing.mode {
6298 SigningMode::UnsignedCompileOnly => run_snapshot
6299 .as_ref()
6300 .is_some_and(|snapshot| snapshot.status() == RunStatus::Completed),
6301 SigningMode::ManualDevelopment => self.signed_cleanup_evidence.is_some(),
6302 _ => false,
6303 };
6304 let completed_run = run_snapshot
6305 .as_ref()
6306 .is_some_and(|snapshot| snapshot.status() == RunStatus::Completed);
6307 let cancelled_run = run_snapshot.as_ref().is_some_and(|snapshot| {
6308 snapshot.status() == RunStatus::Completed
6309 && snapshot.conclusion() == Some(RunConclusion::Cancelled)
6310 });
6311 let manifest_state_valid = matches!(
6312 self.state,
6313 JobState::Succeeded | JobState::Cleaning | JobState::Cleaned | JobState::CleanupFailed
6314 );
6315 if self.run_discovery_attempts > config.poll_policy.discovery_attempts().max(2)
6316 || self.publication_absence_observations > 2
6317 || self
6318 .publication_lease_scope_sha256
6319 .as_ref()
6320 .is_some_and(|scope| !is_lower_sha256(scope))
6321 || self.publication_process_fenced && self.publication_lease_scope_sha256.is_none()
6322 || (self.publication_absence_observations == 0)
6323 != (self.publication_absence_first_observed_at_ms == 0)
6324 || self.run_discovery_deadline_ms != expected_discovery_deadline
6325 || !(publication_boundary_armed || publication_boundary_unarmed)
6326 || !publication_boundary_armed
6327 && (dispatch_commit.is_some()
6328 || self.publication_absent
6329 || self.publication_absence_observations != 0)
6330 || !self.publication_intent && !snapshot_pre_dispatch
6331 || self.publication_absent
6332 && (!self.publication_intent
6333 || prepared_dispatch_commit.is_none()
6334 || !self.publication_process_fenced
6335 || self.publication_absence_observations < 2
6336 || dispatch_commit.is_some()
6337 || !self.temporary_ref_deleted
6338 || self.publication_uncertain)
6339 || self.publication_not_attempted
6340 && (!self.publication_intent
6341 || self.publication_absent
6342 || self.publication_uncertain
6343 || self.publication_absence_observations != 0
6344 || dispatch_commit.is_some()
6345 || run.is_some()
6346 || !matches!(
6347 self.state,
6348 JobState::Failed | JobState::Cleaning | JobState::CleanupFailed
6349 ))
6350 || dispatch_commit.is_none()
6351 && (run.is_some()
6352 || self.cancellation_dispatched
6353 || self.artifacts_removed
6354 || !no_dispatch_state_valid)
6355 || self.cancellation_dispatched && run.is_none()
6356 || self.cancellation_dispatched && !self.cancellation_requested
6357 || self.state == JobState::Cancelling && !self.cancellation_requested
6358 || self.cancellation_requested
6359 && !matches!(
6360 self.state,
6361 JobState::Cancelling
6362 | JobState::Cancelled
6363 | JobState::Failed
6364 | JobState::Cleaning
6365 | JobState::Cleaned
6366 | JobState::CleanupFailed
6367 )
6368 || self.remove_artifacts_requested && !self.cleanup_requested
6369 || self.artifacts_removed && !self.remove_artifacts_requested
6370 || self.temporary_ref_deleted && !self.cleanup_requested && !self.publication_absent
6371 || matches!(
6372 self.state,
6373 JobState::Cleaning | JobState::Cleaned | JobState::CleanupFailed
6374 ) && (!self.cleanup_requested || !snapshot_source_cleanup_proven)
6375 || self.state == JobState::Succeeded && (self.manifests.is_empty() || !successful_run)
6376 || self.manifests.is_empty() != self.compile_evidence.is_none()
6377 || !self.manifests.is_empty() && (!successful_run || !manifest_state_valid)
6378 || self.signed_cleanup_evidence.is_some()
6379 && (self.request.signing.mode != SigningMode::ManualDevelopment
6380 || !successful_run
6381 || !manifest_state_valid)
6382 || self.request.signing.mode == SigningMode::ManualDevelopment
6383 && !self.manifests.is_empty()
6384 && self.signed_cleanup_evidence.is_none()
6385 || matches!(
6386 self.state,
6387 JobState::Succeeded
6388 | JobState::Failed
6389 | JobState::Cancelled
6390 | JobState::Cleaning
6391 | JobState::Cleaned
6392 | JobState::CleanupFailed
6393 ) && run_snapshot.is_some()
6394 && !completed_run
6395 || self.state == JobState::Cancelled
6396 && !(self.publication_absent && run_snapshot.is_none() || cancelled_run)
6397 || self.state == JobState::Cleaned && !cleanup_success_run
6398 || matches!(self.state, JobState::Cleaned | JobState::CleanupFailed)
6399 && (!(self.temporary_ref_deleted
6400 || self.publication_not_attempted
6401 || snapshot_terminal_pre_dispatch)
6402 || self.remove_artifacts_requested && !self.artifacts_removed)
6403 {
6404 return Err(resume_failure("resume_state_inconsistent"));
6405 }
6406 let record = JobRecord {
6407 principal: Some(principal.clone()),
6408 execution_repository_id: Some(execution_repository_id),
6409 operation_id: self.operation_id,
6410 job_id: self.job_id,
6411 source_revision,
6412 git_snapshot: self.git_snapshot,
6413 request: self.request,
6414 request_sha256: self.request_sha256,
6415 temporary_ref,
6416 prepared_dispatch_commit,
6417 dispatch_commit,
6418 workflow_dispatch: self.workflow_dispatch.map(|dispatch| *dispatch),
6419 created_at_ms: self.created_at_ms,
6420 publication_started_at_ms: self.publication_started_at_ms,
6421 publication_quiescence_deadline_ms: self.publication_quiescence_deadline_ms,
6422 state: self.state,
6423 run,
6424 run_snapshot,
6425 publication_intent: self.publication_intent,
6426 publication_absent: self.publication_absent,
6427 publication_not_attempted: self.publication_not_attempted,
6428 publication_process_fenced: self.publication_process_fenced,
6429 publication_lease_scope_sha256: self.publication_lease_scope_sha256,
6430 publication_takeover_lease_owned: false,
6431 snapshot_source_lease_owned: false,
6432 publication_absence_observations: self.publication_absence_observations,
6433 publication_absence_first_observed_at_ms: self.publication_absence_first_observed_at_ms,
6434 cancellation_requested: self.cancellation_requested,
6435 cancellation_dispatched: self.cancellation_dispatched,
6436 cleanup_requested: self.cleanup_requested,
6437 remove_artifacts_requested: self.remove_artifacts_requested,
6438 artifacts_removed: self.artifacts_removed,
6439 temporary_ref_deleted: self.temporary_ref_deleted,
6440 verification_pending_event: self.verification_pending_event,
6441 publication_uncertain: self.publication_uncertain,
6442 run_discovery_attempts: self.run_discovery_attempts,
6443 run_discovery_deadline_ms: self.run_discovery_deadline_ms,
6444 manifests: self.manifests,
6445 compile_evidence: self.compile_evidence,
6446 signed_cleanup_evidence: self.signed_cleanup_evidence,
6447 events: self.events,
6448 };
6449 validate_workflow_dispatch_record(config, &record)?;
6450 if !record.manifests.is_empty() {
6451 validate_manifests(&record, config, &record.manifests)?;
6452 }
6453 Ok(record)
6454 }
6455}
6456
6457#[derive(Debug, Default)]
6458struct ProviderState {
6459 jobs: BTreeMap<String, JobRecord>,
6460 reservations: BTreeSet<String>,
6461 cancellation_reservations: BTreeSet<String>,
6462}
6463
6464struct JobReservation<'a> {
6465 state: &'a Mutex<ProviderState>,
6466 job_id: String,
6467 active: bool,
6468}
6469
6470impl<'a> JobReservation<'a> {
6471 fn acquire(
6472 state: &'a Mutex<ProviderState>,
6473 job_id: String,
6474 maximum: usize,
6475 allow_recycling: bool,
6476 ) -> RemoteBuildResult<Self> {
6477 let mut locked = lock_provider_state(state)?;
6478 if locked.reservations.contains(&job_id)
6479 || locked
6480 .jobs
6481 .get(&job_id)
6482 .is_some_and(|record| !allow_recycling || !record_is_recyclable(record))
6483 {
6484 return Err(provider_failure(
6485 "duplicate_job_id",
6486 "operation already has a GitHub provider job",
6487 false,
6488 ));
6489 }
6490 if allow_recycling && locked.jobs.get(&job_id).is_some_and(record_is_recyclable) {
6491 locked.jobs.remove(&job_id);
6492 }
6493 if locked.jobs.len() + locked.reservations.len() >= maximum {
6494 if !allow_recycling {
6495 return Err(provider_failure(
6496 "job_capacity_reached",
6497 "GitHub provider reached its bounded durable job capacity",
6498 true,
6499 ));
6500 }
6501 let recyclable = locked
6502 .jobs
6503 .iter()
6504 .filter(|(_, record)| record_is_recyclable(record))
6505 .min_by(|(left_id, left), (right_id, right)| {
6506 left.created_at_ms
6507 .cmp(&right.created_at_ms)
6508 .then_with(|| left_id.cmp(right_id))
6509 })
6510 .map(|(candidate_id, _)| candidate_id.clone());
6511 if let Some(recyclable) = recyclable {
6512 locked.jobs.remove(&recyclable);
6513 } else {
6514 return Err(provider_failure(
6515 "job_capacity_reached",
6516 "GitHub provider reached its bounded in-memory job capacity",
6517 true,
6518 ));
6519 }
6520 }
6521 locked.reservations.insert(job_id.clone());
6522 drop(locked);
6523 Ok(Self {
6524 state,
6525 job_id,
6526 active: true,
6527 })
6528 }
6529
6530 fn insert_pending(mut self, record: JobRecord) -> RemoteBuildResult<PendingJobRecord<'a>> {
6531 let mut state = lock_provider_state(self.state)?;
6532 state.reservations.remove(&self.job_id);
6533 state.jobs.insert(self.job_id.clone(), record);
6534 self.active = false;
6535 drop(state);
6536 Ok(PendingJobRecord {
6537 state: self.state,
6538 job_id: self.job_id.clone(),
6539 retain: false,
6540 })
6541 }
6542}
6543
6544impl Drop for JobReservation<'_> {
6545 fn drop(&mut self) {
6546 if self.active
6547 && let Ok(mut state) = self.state.lock()
6548 {
6549 state.reservations.remove(&self.job_id);
6550 }
6551 }
6552}
6553
6554struct PendingJobRecord<'a> {
6555 state: &'a Mutex<ProviderState>,
6556 job_id: String,
6557 retain: bool,
6558}
6559
6560impl PendingJobRecord<'_> {
6561 fn retain(&mut self) {
6562 self.retain = true;
6563 }
6564}
6565
6566impl Drop for PendingJobRecord<'_> {
6567 fn drop(&mut self) {
6568 if !self.retain
6569 && let Ok(mut state) = self.state.lock()
6570 {
6571 state.jobs.remove(&self.job_id);
6572 }
6573 }
6574}
6575
6576pub struct GithubBuildProvider<R, P, A = NoVerifiedArtifactStore, C = SystemProviderClock> {
6578 config: GithubProviderConfig,
6579 capabilities: ProviderCapabilities,
6580 transport: Mutex<GithubTransport<R>>,
6581 publisher: Mutex<P>,
6582 artifacts: Mutex<A>,
6583 clock: C,
6584 state: Mutex<ProviderState>,
6585 checkpoint_sink: Option<Mutex<Box<dyn GithubJobCheckpointSink>>>,
6586}
6587
6588impl<R, P> GithubBuildProvider<R, P, NoVerifiedArtifactStore, SystemProviderClock>
6589where
6590 P: TemporaryRefPublisher,
6591{
6592 pub fn new(config: GithubProviderConfig, transport: GithubTransport<R>, publisher: P) -> Self {
6597 Self::with_artifact_store_and_clock(
6598 config,
6599 transport,
6600 publisher,
6601 NoVerifiedArtifactStore,
6602 SystemProviderClock,
6603 )
6604 }
6605}
6606
6607impl<R, P, A, C> GithubBuildProvider<R, P, A, C>
6608where
6609 P: TemporaryRefPublisher,
6610 A: VerifiedArtifactStore,
6611 C: ProviderClock,
6612{
6613 pub fn with_artifact_store_and_clock(
6615 config: GithubProviderConfig,
6616 transport: GithubTransport<R>,
6617 publisher: P,
6618 artifacts: A,
6619 clock: C,
6620 ) -> Self {
6621 let capabilities =
6622 provider_capabilities(&config, &artifacts, publisher.supports_git_snapshot());
6623 Self {
6624 config,
6625 capabilities,
6626 transport: Mutex::new(transport),
6627 publisher: Mutex::new(publisher),
6628 artifacts: Mutex::new(artifacts),
6629 clock,
6630 state: Mutex::new(ProviderState::default()),
6631 checkpoint_sink: None,
6632 }
6633 }
6634
6635 #[must_use]
6637 pub fn with_checkpoint_sink(mut self, sink: impl GithubJobCheckpointSink + 'static) -> Self {
6638 self.checkpoint_sink = Some(Mutex::new(Box::new(sink)));
6639 self
6640 }
6641
6642 pub const fn workflow_run_trigger(&self) -> WorkflowRunTrigger {
6644 self.config.workflow.run_trigger()
6645 }
6646
6647 pub fn wait_for_github_run(
6656 &self,
6657 job_id: &str,
6658 cancellation: &CancellationToken,
6659 ) -> RemoteBuildResult<RunSnapshot>
6660 where
6661 R: GhRunner,
6662 {
6663 cancellation.check()?;
6664 let handle = {
6665 let state = self.lock_state()?;
6666 state
6667 .jobs
6668 .get(job_id)
6669 .and_then(|record| record.run.clone())
6670 .ok_or_else(|| job_not_mapped(job_id))?
6671 };
6672 let snapshot = self
6673 .lock_transport()?
6674 .wait_for_run(
6675 &self.config.repository,
6676 &handle,
6677 self.config.poll_policy.transport(),
6678 )
6679 .map_err(transport_failure)?;
6680 cancellation.check()?;
6681 Ok(snapshot)
6682 }
6683
6684 fn lock_state(&self) -> RemoteBuildResult<std::sync::MutexGuard<'_, ProviderState>> {
6685 lock_provider_state(&self.state)
6686 }
6687
6688 fn lock_transport(&self) -> RemoteBuildResult<std::sync::MutexGuard<'_, GithubTransport<R>>> {
6689 self.transport.lock().map_err(|_| {
6690 provider_failure(
6691 "transport_unavailable",
6692 "GitHub transport is unavailable",
6693 true,
6694 )
6695 })
6696 }
6697
6698 fn lock_publisher(&self) -> RemoteBuildResult<std::sync::MutexGuard<'_, P>> {
6699 self.publisher.lock().map_err(|_| {
6700 provider_failure(
6701 "publisher_unavailable",
6702 "temporary-ref publisher is unavailable",
6703 true,
6704 )
6705 })
6706 }
6707
6708 fn lock_artifacts(&self) -> RemoteBuildResult<std::sync::MutexGuard<'_, A>> {
6709 self.artifacts.lock().map_err(|_| {
6710 provider_failure(
6711 "artifact_store_unavailable",
6712 "verified artifact store is unavailable",
6713 true,
6714 )
6715 })
6716 }
6717
6718 fn current_resume_identity(&self) -> RemoteBuildResult<(GithubPrincipalIdentityV1, u64)>
6719 where
6720 R: GhRunner,
6721 {
6722 let mut transport = self.lock_transport()?;
6723 let principal = transport
6724 .authenticate(&self.config.repository)
6725 .map_err(transport_failure)?;
6726 let repository = transport
6727 .repository(&self.config.repository)
6728 .map_err(transport_failure)?;
6729 let principal = GithubPrincipalIdentityV1::from_authenticated(
6730 &principal,
6731 self.config.workflow.run_trigger(),
6732 repository.id(),
6733 )?;
6734 Ok((principal, repository.id()))
6735 }
6736
6737 pub fn durable_identity(&self) -> RemoteBuildResult<GithubDurableIdentityV1>
6747 where
6748 R: GhRunner,
6749 {
6750 let (principal, execution_repository_id) = self.current_resume_identity()?;
6751 Ok(GithubDurableIdentityV1 {
6752 provider: GITHUB_PROVIDER_ID.to_owned(),
6753 provider_config_sha256: provider_config_sha256(&self.config)?,
6754 principal,
6755 execution_repository: repository_url(&self.config.repository),
6756 execution_repository_id,
6757 })
6758 }
6759
6760 fn validate_durable_identity_binding(
6761 &self,
6762 identity: &GithubDurableIdentityV1,
6763 ) -> RemoteBuildResult<()> {
6764 if identity.provider != GITHUB_PROVIDER_ID
6765 || identity.provider_config_sha256 != provider_config_sha256(&self.config)?
6766 || !github_remote_matches(
6767 &identity.execution_repository,
6768 &repository_url(&self.config.repository),
6769 )
6770 || identity.execution_repository_id == 0
6771 || matches!(
6772 identity.principal,
6773 GithubPrincipalIdentityV1::RepositoryCredential
6774 ) && self.config.workflow.run_trigger() != WorkflowRunTrigger::WorkflowDispatch
6775 {
6776 return Err(resume_failure("resume_provider_identity_mismatch"));
6777 }
6778 Ok(())
6779 }
6780
6781 pub fn precompute_git_snapshot_graph(
6792 &self,
6793 inputs: &mut GitSnapshotPrecomputeInputs,
6794 operation_id: &str,
6795 source_created_at_ms: u64,
6796 cancellation: &CancellationToken,
6797 ) -> RemoteBuildResult<GitSnapshotObjectGraphV1> {
6798 cancellation.check()?;
6799 if !self
6800 .capabilities
6801 .source_modes
6802 .contains(&SourceMode::GitSnapshot)
6803 {
6804 return Err(unsupported(ProviderFeature::SourceMode(
6805 SourceMode::GitSnapshot,
6806 )));
6807 }
6808 GitSnapshotSourceRef::for_operation(operation_id)
6809 .map_err(TemporaryRefPublishError::from)
6810 .map_err(publisher_failure)?;
6811 let graph = {
6812 let mut publisher = self.lock_publisher()?;
6813 publisher
6814 .precompute_git_snapshot(inputs, operation_id, source_created_at_ms)
6815 .map_err(publisher_failure)?
6816 };
6817 cancellation.check()?;
6818 Ok(graph)
6819 }
6820
6821 pub fn discover_uncheckpointed_git_snapshot_stages(
6833 &self,
6834 ) -> RemoteBuildResult<Vec<GithubGitSnapshotRecoveryCandidateV1>> {
6835 if !self
6836 .capabilities
6837 .source_modes
6838 .contains(&SourceMode::GitSnapshot)
6839 {
6840 return Err(unsupported(ProviderFeature::SourceMode(
6841 SourceMode::GitSnapshot,
6842 )));
6843 }
6844 let mut candidates = self
6845 .lock_publisher()?
6846 .discover_git_snapshot_stages()
6847 .map_err(publisher_failure)?;
6848 let occupied = {
6849 let state = self.lock_state()?;
6850 state
6851 .jobs
6852 .keys()
6853 .chain(state.reservations.iter())
6854 .cloned()
6855 .collect::<BTreeSet<_>>()
6856 };
6857 candidates.retain(|candidate| !occupied.contains(&candidate.final_request.operation_id));
6858 candidates.sort_by(|left, right| {
6859 left.final_request
6860 .operation_id
6861 .cmp(&right.final_request.operation_id)
6862 });
6863 let mut previous = None;
6864 for candidate in &candidates {
6865 let operation_id = candidate.final_request.operation_id.as_str();
6866 if previous == Some(operation_id)
6867 || candidate.stage.final_request != candidate.final_request
6868 || candidate.stage.operation_id != operation_id
6869 || candidate.stage_locator.operation_id != operation_id
6870 || candidate.stage.source_repository != self.config.source_repository
6871 {
6872 return Err(provider_failure(
6873 "snapshot_stage_discovery_invalid",
6874 "Git snapshot recovery discovery found an invalid candidate",
6875 false,
6876 ));
6877 }
6878 candidate
6879 .stage
6880 .validate_for_request(&candidate.descriptor, &candidate.final_request)
6881 .map_err(|_| {
6882 provider_failure(
6883 "snapshot_stage_discovery_invalid",
6884 "Git snapshot recovery discovery found an invalid candidate",
6885 false,
6886 )
6887 })?;
6888 validate_git_snapshot_submission(&self.config, &candidate.final_request)?;
6889 previous = Some(operation_id);
6890 }
6891 Ok(candidates)
6892 }
6893
6894 #[allow(
6906 clippy::too_many_lines,
6907 reason = "snapshot reservation, consent, immutable record construction, and first checkpoint form one security boundary"
6908 )]
6909 pub fn submit_git_snapshot(
6910 &self,
6911 submission: GithubGitSnapshotSubmissionV1,
6912 cancellation: &CancellationToken,
6913 ) -> RemoteBuildResult<JobHandle>
6914 where
6915 R: GhRunner,
6916 P: TemporaryRefReconciler,
6917 {
6918 cancellation.check()?;
6919 if self.checkpoint_sink.is_none() {
6920 return Err(provider_failure(
6921 "snapshot_checkpoint_required",
6922 "Git snapshot submission requires durable provider checkpoints",
6923 false,
6924 ));
6925 }
6926 validate_git_snapshot_submission(&self.config, &submission.request)?;
6927 if !self
6928 .capabilities
6929 .source_modes
6930 .contains(&SourceMode::GitSnapshot)
6931 {
6932 return Err(unsupported(ProviderFeature::SourceMode(
6933 SourceMode::GitSnapshot,
6934 )));
6935 }
6936 self.validate_durable_identity_binding(&submission.durable_identity)?;
6937 let mut workflow_dispatch_client =
6938 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
6939 self.preflight_workflow_dispatch_registration()?;
6940 Some(self.prepare_workflow_dispatch_client()?)
6941 } else {
6942 None
6943 };
6944 let job_id = submission.request.operation_id.clone();
6945 let reservation =
6946 JobReservation::acquire(&self.state, job_id.clone(), self.config.max_jobs, false)?;
6947 let (stage_locator, stage) = match submission.stage_authority {
6948 GithubGitSnapshotStageAuthorityV1::SameInvocation { locator, stage }
6949 | GithubGitSnapshotStageAuthorityV1::ExactRetryLineage { locator, stage } => {
6950 (locator, stage)
6951 }
6952 GithubGitSnapshotStageAuthorityV1::FreshReconsentAdoption => self
6953 .lock_publisher()?
6954 .adopt_git_snapshot_stage(&submission.request)
6955 .map_err(publisher_failure)?,
6956 };
6957 if stage.consent_sha256 != submission.approved_consent_sha256 {
6958 return Err(provider_failure(
6959 "snapshot_consent_changed",
6960 "Git snapshot stage differs from the explicitly accepted upload plan",
6961 false,
6962 ));
6963 }
6964 let snapshot = GithubGitSnapshotResumeV1 {
6965 schema_version: GITHUB_GIT_SNAPSHOT_RESUME_SCHEMA_VERSION,
6966 stage_locator,
6967 stage,
6968 phase: GithubGitSnapshotPhaseV1::Prepared,
6969 source_publication_attempts: 0,
6970 source_publication_started_at_ms: 0,
6971 source_publication_quiescence_deadline_ms: 0,
6972 source_publication_process_fenced: false,
6973 source_publication_lease_scope_sha256: None,
6974 source_publication_absence_observations: 0,
6975 source_publication_absence_first_observed_at_ms: 0,
6976 source_publication_absence_last_observed_at_ms: 0,
6977 keepalive_release_authorization_sha256: None,
6978 };
6979 snapshot.validate_for_request(&submission.request)?;
6980 let branch = BranchName::new(format!(
6981 "{}/{}",
6982 self.config.workflow.temporary_branch_namespace().as_str(),
6983 submission.request.operation_id
6984 ))
6985 .map_err(|_| {
6986 provider_failure(
6987 "operation_id_not_ref_safe",
6988 "operation ID cannot form a safe temporary Git ref",
6989 false,
6990 )
6991 })?;
6992 let temporary_ref =
6993 TemporaryGitRef::new(self.config.workflow.temporary_branch_namespace(), branch)
6994 .map_err(|_| {
6995 provider_failure(
6996 "operation_id_not_ref_safe",
6997 "operation ID cannot form a safe temporary Git ref",
6998 false,
6999 )
7000 })?;
7001 let source_revision = CommitSha::new(
7002 submission
7003 .request
7004 .source_revision
7005 .clone()
7006 .ok_or_else(|| resume_failure("resume_source_revision_invalid"))?,
7007 )
7008 .map_err(|_| resume_failure("resume_source_revision_invalid"))?;
7009 let created_at_ms = snapshot.stage.source_created_at_ms;
7010 let manifest =
7011 GithubDispatchManifest::new(&self.config, &temporary_ref, submission.request.clone());
7012 manifest.validate_for(&self.config)?;
7013 manifest.encode()?;
7014 let request_sha256 = canonical_request_sha256(&submission.request)?;
7015 let discovery_deadline_ms =
7016 created_at_ms.saturating_add(self.config.poll_policy.discovery_window_ms());
7017 let mut record = JobRecord {
7018 principal: Some(submission.durable_identity.principal),
7019 execution_repository_id: Some(submission.durable_identity.execution_repository_id),
7020 operation_id: job_id.clone(),
7021 job_id: job_id.clone(),
7022 source_revision,
7023 git_snapshot: Some(snapshot),
7024 request: submission.request,
7025 request_sha256,
7026 temporary_ref,
7027 prepared_dispatch_commit: None,
7028 dispatch_commit: None,
7029 workflow_dispatch: None,
7030 created_at_ms,
7031 publication_started_at_ms: 0,
7032 publication_quiescence_deadline_ms: u64::MAX,
7033 state: JobState::Created,
7034 run: None,
7035 run_snapshot: None,
7036 publication_intent: false,
7037 publication_absent: false,
7038 publication_not_attempted: false,
7039 publication_process_fenced: false,
7040 publication_lease_scope_sha256: None,
7041 publication_takeover_lease_owned: false,
7042 snapshot_source_lease_owned: false,
7043 publication_absence_observations: 0,
7044 publication_absence_first_observed_at_ms: 0,
7045 cancellation_requested: false,
7046 cancellation_dispatched: false,
7047 cleanup_requested: false,
7048 remove_artifacts_requested: false,
7049 artifacts_removed: false,
7050 temporary_ref_deleted: false,
7051 verification_pending_event: false,
7052 publication_uncertain: false,
7053 run_discovery_attempts: 0,
7054 run_discovery_deadline_ms: discovery_deadline_ms,
7055 manifests: Vec::new(),
7056 compile_evidence: None,
7057 signed_cleanup_evidence: None,
7058 events: Vec::new(),
7059 };
7060 append_event(
7061 &mut record,
7062 created_at_ms,
7063 "submit",
7064 RemoteBuildEventKind::OperationStarted {
7065 command: "build-iphone --snapshot".to_owned(),
7066 },
7067 )?;
7068 append_event(
7069 &mut record,
7070 created_at_ms,
7071 "submit",
7072 RemoteBuildEventKind::JobCreated {
7073 state: JobState::Created,
7074 },
7075 )?;
7076 let mut pending = reservation.insert_pending(record)?;
7077 {
7078 let mut provider_state = self.lock_state()?;
7079 let record = provider_state
7080 .jobs
7081 .get_mut(&job_id)
7082 .ok_or_else(|| resume_failure("snapshot_pending_job_lost"))?;
7083 self.checkpoint_record(record)?;
7084 }
7085 pending.retain();
7086 self.continue_git_snapshot_submission(&job_id, cancellation)?;
7087 let start_dispatch = {
7088 let provider_state = self.lock_state()?;
7089 let record = provider_state
7090 .jobs
7091 .get(&job_id)
7092 .ok_or_else(|| job_not_found(&job_id))?;
7093 self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch
7094 && record.dispatch_commit.is_some()
7095 && !record.publication_uncertain
7096 && !record.state.is_terminal()
7097 && !record.state.is_build_terminal()
7098 };
7099 if start_dispatch {
7100 let client = workflow_dispatch_client.as_deref_mut().ok_or_else(|| {
7101 provider_failure(
7102 "workflow_dispatch_client_unavailable",
7103 "Preflighted workflow dispatch client is unavailable",
7104 true,
7105 )
7106 })?;
7107 self.start_workflow_dispatch_with_client(&job_id, client, cancellation)?;
7108 }
7109 let provider_state = self.lock_state()?;
7110 let record = provider_state
7111 .jobs
7112 .get(&job_id)
7113 .ok_or_else(|| job_not_found(&job_id))?;
7114 Ok(JobHandle {
7115 job_id,
7116 state: record.state,
7117 created_at_ms,
7118 })
7119 }
7120
7121 #[allow(
7132 clippy::too_many_lines,
7133 reason = "parent terminal, keepalive, archive, and no-write child-plan checks are one read-only authority boundary"
7134 )]
7135 pub fn precompute_exact_git_snapshot_retry(
7136 &self,
7137 parent_job_id: &str,
7138 child_operation_id: &str,
7139 child_source_created_at_ms: u64,
7140 cancellation: &CancellationToken,
7141 ) -> RemoteBuildResult<GithubGitSnapshotExactRetryPlanV1> {
7142 cancellation.check()?;
7143 if !self
7144 .capabilities
7145 .source_modes
7146 .contains(&SourceMode::GitSnapshot)
7147 {
7148 return Err(unsupported(ProviderFeature::SourceMode(
7149 SourceMode::GitSnapshot,
7150 )));
7151 }
7152 if parent_job_id == child_operation_id
7153 || child_source_created_at_ms == 0
7154 || child_source_created_at_ms / 1_000 > i64::MAX as u64
7155 || GitSnapshotSourceRef::for_operation(child_operation_id).is_err()
7156 {
7157 return Err(provider_failure(
7158 "snapshot_exact_retry_child_invalid",
7159 "Git snapshot exact retry child identity is invalid",
7160 false,
7161 ));
7162 }
7163 let state = self.lock_state()?;
7164 if state.jobs.contains_key(child_operation_id)
7165 || state.reservations.contains(child_operation_id)
7166 {
7167 return Err(provider_failure(
7168 "snapshot_exact_retry_child_exists",
7169 "Git snapshot retry child operation already exists",
7170 false,
7171 ));
7172 }
7173 let parent = state
7174 .jobs
7175 .get(parent_job_id)
7176 .ok_or_else(|| job_not_found(parent_job_id))?;
7177 if parent.operation_id != parent_job_id
7178 || !parent.cleanup_requested
7179 || parent.state != JobState::Cleaned
7180 {
7181 return Err(provider_failure(
7182 "snapshot_exact_retry_parent_not_terminal",
7183 "Git snapshot exact retry requires completed parent cleanup",
7184 false,
7185 ));
7186 }
7187 let snapshot = parent
7188 .git_snapshot
7189 .as_ref()
7190 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?;
7191 if !matches!(
7192 snapshot.phase,
7193 GithubGitSnapshotPhaseV1::SourceDeleted
7194 | GithubGitSnapshotPhaseV1::SourceAbsent
7195 | GithubGitSnapshotPhaseV1::SourceConflict
7196 ) || snapshot.keepalive_release_authorization_sha256.is_some()
7197 {
7198 return Err(provider_failure(
7199 "snapshot_exact_retry_parent_releasing",
7200 "Git snapshot parent keepalive is already releasing or unavailable",
7201 false,
7202 ));
7203 }
7204 let keepalive_request = GitSnapshotKeepaliveRequest {
7205 operation_id: &parent.operation_id,
7206 keepalive_ref: &snapshot.stage.keepalive_ref,
7207 commit: &snapshot.stage.graph.commit,
7208 };
7209 let precompute_request = GitSnapshotExactRetryPrecomputeRequest {
7210 parent_request: &parent.request,
7211 parent_stage: &snapshot.stage,
7212 child_operation_id,
7213 child_source_created_at_ms,
7214 };
7215 let mut publisher = self.lock_publisher()?;
7216 match publisher
7217 .reconcile_git_snapshot_keepalive(&keepalive_request)
7218 .map_err(publisher_failure)?
7219 {
7220 GitSnapshotRefReconciliation::Exact => {}
7221 GitSnapshotRefReconciliation::Absent => {
7222 return Err(provider_failure(
7223 "snapshot_exact_retry_parent_keepalive_missing",
7224 "Git snapshot parent keepalive is absent",
7225 false,
7226 ));
7227 }
7228 GitSnapshotRefReconciliation::Conflict => {
7229 return Err(provider_failure(
7230 "snapshot_exact_retry_parent_keepalive_conflict",
7231 "Git snapshot parent keepalive changed",
7232 false,
7233 ));
7234 }
7235 }
7236 let plan = publisher
7237 .precompute_exact_git_snapshot_retry(&precompute_request)
7238 .map_err(publisher_failure)?;
7239 match publisher
7240 .reconcile_git_snapshot_keepalive(&keepalive_request)
7241 .map_err(publisher_failure)?
7242 {
7243 GitSnapshotRefReconciliation::Exact => {}
7244 GitSnapshotRefReconciliation::Absent => {
7245 return Err(provider_failure(
7246 "snapshot_exact_retry_parent_keepalive_lost",
7247 "Git snapshot parent keepalive disappeared during retry planning",
7248 false,
7249 ));
7250 }
7251 GitSnapshotRefReconciliation::Conflict => {
7252 return Err(provider_failure(
7253 "snapshot_exact_retry_parent_keepalive_changed",
7254 "Git snapshot parent keepalive changed during retry planning",
7255 false,
7256 ));
7257 }
7258 }
7259 drop(publisher);
7260 cancellation.check()?;
7261 Ok(plan)
7262 }
7263
7264 #[allow(
7276 clippy::too_many_lines,
7277 reason = "lineage authorization, child lease, replayable stage, and parent recheck must stay visibly ordered"
7278 )]
7279 pub fn stage_exact_git_snapshot_retry(
7280 &self,
7281 authorization: &GithubGitSnapshotExactRetryAuthorizationV1,
7282 expected_plan: &GithubGitSnapshotExactRetryPlanV1,
7283 cancellation: &CancellationToken,
7284 ) -> RemoteBuildResult<GithubGitSnapshotExactRetryStageV1> {
7285 cancellation.check()?;
7286 if !self
7287 .capabilities
7288 .source_modes
7289 .contains(&SourceMode::GitSnapshot)
7290 {
7291 return Err(unsupported(ProviderFeature::SourceMode(
7292 SourceMode::GitSnapshot,
7293 )));
7294 }
7295 if expected_plan.request.operation_id != authorization.child_operation_id
7296 || expected_plan.source_created_at_ms != authorization.child_source_created_at_ms
7297 || expected_plan.request.source_revision.as_deref()
7298 != Some(expected_plan.graph.commit.as_str())
7299 {
7300 return Err(provider_failure(
7301 "snapshot_exact_retry_plan_mismatch",
7302 "Git snapshot retry plan differs from durable lineage authority",
7303 false,
7304 ));
7305 }
7306 let state = self.lock_state()?;
7307 if state.jobs.contains_key(&authorization.child_operation_id)
7308 || state
7309 .reservations
7310 .contains(&authorization.child_operation_id)
7311 {
7312 return Err(provider_failure(
7313 "snapshot_exact_retry_child_exists",
7314 "Git snapshot retry child operation already exists",
7315 false,
7316 ));
7317 }
7318 let parent = state
7319 .jobs
7320 .get(&authorization.parent_job_id)
7321 .ok_or_else(|| job_not_found(&authorization.parent_job_id))?;
7322 if parent.operation_id != authorization.parent_job_id
7323 || !parent.cleanup_requested
7324 || parent.state != JobState::Cleaned
7325 {
7326 return Err(provider_failure(
7327 "snapshot_exact_retry_parent_not_terminal",
7328 "Git snapshot exact retry requires completed parent cleanup",
7329 false,
7330 ));
7331 }
7332 let snapshot = parent
7333 .git_snapshot
7334 .as_ref()
7335 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?;
7336 if !matches!(
7337 snapshot.phase,
7338 GithubGitSnapshotPhaseV1::SourceDeleted
7339 | GithubGitSnapshotPhaseV1::SourceAbsent
7340 | GithubGitSnapshotPhaseV1::SourceConflict
7341 ) || snapshot.keepalive_release_authorization_sha256.is_some()
7342 {
7343 return Err(provider_failure(
7344 "snapshot_exact_retry_parent_releasing",
7345 "Git snapshot parent keepalive is already releasing or unavailable",
7346 false,
7347 ));
7348 }
7349 let keepalive_request = GitSnapshotKeepaliveRequest {
7350 operation_id: &parent.operation_id,
7351 keepalive_ref: &snapshot.stage.keepalive_ref,
7352 commit: &snapshot.stage.graph.commit,
7353 };
7354 let stage_request = GitSnapshotExactRetryStageRequest {
7355 parent_request: &parent.request,
7356 parent_stage: &snapshot.stage,
7357 expected_plan,
7358 retry_lineage_authorization_sha256: &authorization.retry_lineage_authorization_sha256,
7359 };
7360 let mut publisher = self.lock_publisher()?;
7361 if !publisher.publication_attempt_is_process_fenced() {
7362 return Err(provider_failure(
7363 "snapshot_exact_retry_process_unfenced",
7364 "Git snapshot retry staging requires a cross-process child lease",
7365 false,
7366 ));
7367 }
7368 let child_source_ref =
7369 GitSnapshotSourceRef::for_operation(&authorization.child_operation_id)
7370 .map_err(|_| resume_failure("snapshot_exact_retry_authorization_invalid"))?;
7371 let child_lease = GitSnapshotSourcePublicationLeaseRequest {
7372 source_repository: &self.config.source_repository,
7373 source_ref: &child_source_ref,
7374 };
7375 if publisher
7376 .acquire_git_snapshot_source_lease(&child_lease)
7377 .map_err(publisher_failure)?
7378 != TemporaryRefPublicationLease::Acquired
7379 {
7380 return Err(provider_failure(
7381 "snapshot_exact_retry_child_active",
7382 "Another live provider owns this Git snapshot retry child",
7383 true,
7384 ));
7385 }
7386 let result = (|| {
7387 match publisher
7388 .reconcile_git_snapshot_keepalive(&keepalive_request)
7389 .map_err(publisher_failure)?
7390 {
7391 GitSnapshotRefReconciliation::Exact => {}
7392 GitSnapshotRefReconciliation::Absent => {
7393 return Err(provider_failure(
7394 "snapshot_exact_retry_parent_keepalive_missing",
7395 "Git snapshot parent keepalive is absent",
7396 false,
7397 ));
7398 }
7399 GitSnapshotRefReconciliation::Conflict => {
7400 return Err(provider_failure(
7401 "snapshot_exact_retry_parent_keepalive_conflict",
7402 "Git snapshot parent keepalive changed",
7403 false,
7404 ));
7405 }
7406 }
7407 let child = publisher
7408 .stage_exact_git_snapshot_retry(&stage_request)
7409 .map_err(publisher_failure)?;
7410 match publisher
7411 .reconcile_git_snapshot_keepalive(&keepalive_request)
7412 .map_err(publisher_failure)?
7413 {
7414 GitSnapshotRefReconciliation::Exact => {}
7415 GitSnapshotRefReconciliation::Absent => {
7416 return Err(provider_failure(
7417 "snapshot_exact_retry_parent_keepalive_lost",
7418 "Git snapshot parent keepalive disappeared during retry staging",
7419 false,
7420 ));
7421 }
7422 GitSnapshotRefReconciliation::Conflict => {
7423 return Err(provider_failure(
7424 "snapshot_exact_retry_parent_keepalive_changed",
7425 "Git snapshot parent keepalive changed during retry staging",
7426 false,
7427 ));
7428 }
7429 }
7430 cancellation.check()?;
7431 Ok(child)
7432 })();
7433 publisher.release_git_snapshot_source_lease(&child_lease);
7434 drop(publisher);
7435 result
7436 }
7437
7438 #[allow(
7451 clippy::too_many_lines,
7452 reason = "prune authorization, durable intent, exact release, and absence checkpoint are one crash-safe primitive"
7453 )]
7454 pub fn release_git_snapshot_keepalive_for_prune(
7455 &self,
7456 authorization: &GithubGitSnapshotKeepaliveReleaseAuthorizationV1,
7457 cancellation: &CancellationToken,
7458 ) -> RemoteBuildResult<()> {
7459 cancellation.check()?;
7460 if self.checkpoint_sink.is_none() {
7461 return Err(provider_failure(
7462 "snapshot_checkpoint_required",
7463 "Git snapshot keepalive release requires durable provider checkpoints",
7464 false,
7465 ));
7466 }
7467 if !self
7468 .config
7469 .mutation_authorization
7470 .manage_git_snapshot_keepalive
7471 {
7472 return Err(provider_failure(
7473 "snapshot_keepalive_unauthorized",
7474 "Git snapshot keepalive mutation is not authorized",
7475 false,
7476 ));
7477 }
7478 let mut state = self.lock_state()?;
7479 let record = state
7480 .jobs
7481 .get_mut(authorization.job_id())
7482 .ok_or_else(|| job_not_found(authorization.job_id()))?;
7483 if record.operation_id != authorization.job_id
7484 || !record.cleanup_requested
7485 || record.state != JobState::Cleaned
7486 {
7487 return Err(provider_failure(
7488 "snapshot_keepalive_prune_not_terminal",
7489 "Git snapshot keepalive release requires completed terminal cleanup",
7490 false,
7491 ));
7492 }
7493 let snapshot = record
7494 .git_snapshot
7495 .as_mut()
7496 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?;
7497 let reacknowledge_release_intent =
7498 snapshot.phase == GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent;
7499 match snapshot.phase {
7500 GithubGitSnapshotPhaseV1::SourceDeleted
7501 | GithubGitSnapshotPhaseV1::SourceAbsent
7502 | GithubGitSnapshotPhaseV1::SourceConflict => {
7503 snapshot.keepalive_release_authorization_sha256 =
7504 Some(authorization.complete_lineage_authorization_sha256.clone());
7505 snapshot.phase = GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent;
7506 self.checkpoint_record(record)?;
7507 }
7508 GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
7509 | GithubGitSnapshotPhaseV1::KeepaliveReleased => {
7510 if snapshot.keepalive_release_authorization_sha256.as_deref()
7511 != Some(authorization.complete_lineage_authorization_sha256())
7512 {
7513 return Err(provider_failure(
7514 "snapshot_prune_authorization_changed",
7515 "Git snapshot complete-lineage authorization changed after intent",
7516 false,
7517 ));
7518 }
7519 if snapshot.phase == GithubGitSnapshotPhaseV1::KeepaliveReleased {
7520 self.checkpoint_record(record)?;
7521 return Ok(());
7522 }
7523 }
7524 _ => {
7525 return Err(provider_failure(
7526 "snapshot_keepalive_prune_source_not_clean",
7527 "Git snapshot source cleanup is incomplete",
7528 false,
7529 ));
7530 }
7531 }
7532 if reacknowledge_release_intent {
7533 self.checkpoint_record(record)?;
7534 }
7535 cancellation.check()?;
7536 let snapshot = record
7537 .git_snapshot
7538 .as_ref()
7539 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?;
7540 let request = GitSnapshotKeepaliveRequest {
7541 operation_id: &record.operation_id,
7542 keepalive_ref: &snapshot.stage.keepalive_ref,
7543 commit: &snapshot.stage.graph.commit,
7544 };
7545 let reconciliation = {
7546 let mut publisher = self.lock_publisher()?;
7547 publisher
7548 .reconcile_git_snapshot_keepalive(&request)
7549 .map_err(publisher_failure)?
7550 };
7551 match reconciliation {
7552 GitSnapshotRefReconciliation::Absent => {}
7553 GitSnapshotRefReconciliation::Exact => {
7554 self.lock_publisher()?
7555 .release_git_snapshot_keepalive(&request)
7556 .map_err(publisher_failure)?;
7557 }
7558 GitSnapshotRefReconciliation::Conflict => {
7559 return Err(provider_failure(
7560 "snapshot_keepalive_conflict",
7561 "Git snapshot keepalive changed before lineage prune",
7562 false,
7563 ));
7564 }
7565 }
7566 let final_reconciliation = {
7567 let mut publisher = self.lock_publisher()?;
7568 publisher
7569 .reconcile_git_snapshot_keepalive(&request)
7570 .map_err(publisher_failure)?
7571 };
7572 if final_reconciliation != GitSnapshotRefReconciliation::Absent {
7573 return Err(provider_failure(
7574 "snapshot_keepalive_release_unconfirmed",
7575 "Git snapshot keepalive absence was not confirmed",
7576 false,
7577 ));
7578 }
7579 record
7580 .git_snapshot
7581 .as_mut()
7582 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?
7583 .phase = GithubGitSnapshotPhaseV1::KeepaliveReleased;
7584 self.checkpoint_record(record)
7585 }
7586
7587 fn current_public_snapshot_source_identity(
7588 &self,
7589 record: &JobRecord,
7590 ) -> RemoteBuildResult<(GithubPrincipalIdentityV1, u64)>
7591 where
7592 R: GhRunner,
7593 {
7594 let identity = self.current_resume_identity()?;
7595 Self::validate_record_identity(record, &identity)?;
7596 let source_repository =
7597 repository_from_url(&self.config.source_repository).ok_or_else(|| {
7598 provider_failure(
7599 "source_repository_invalid",
7600 "configured source repository identity is invalid",
7601 false,
7602 )
7603 })?;
7604 let source = self
7605 .lock_transport()?
7606 .repository(&source_repository)
7607 .map_err(transport_failure)?;
7608 if source.is_private() || source.is_archived() || source.is_disabled() {
7609 return Err(provider_failure(
7610 "snapshot_source_repository_not_public_writable",
7611 "Git snapshots require the exact configured public writable source repository",
7612 false,
7613 ));
7614 }
7615 Ok(identity)
7616 }
7617
7618 fn revalidate_public_snapshot_source(&self, record: &JobRecord) -> RemoteBuildResult<()>
7619 where
7620 R: GhRunner,
7621 {
7622 self.current_public_snapshot_source_identity(record)
7623 .map(drop)
7624 }
7625
7626 fn acquire_snapshot_source_lease(
7627 &self,
7628 record: &mut JobRecord,
7629 ) -> RemoteBuildResult<(bool, String)> {
7630 let expected_scope = record
7631 .git_snapshot
7632 .as_ref()
7633 .and_then(|snapshot| snapshot.source_publication_lease_scope_sha256.as_deref());
7634 let source_ref = record
7635 .git_snapshot
7636 .as_ref()
7637 .map(|snapshot| snapshot.stage.source_ref.clone())
7638 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?;
7639 let mut publisher = self.lock_publisher()?;
7640 let actual_scope = publisher
7641 .publication_lease_scope_sha256()
7642 .filter(|scope| is_lower_sha256(scope))
7643 .ok_or_else(|| {
7644 provider_failure(
7645 "snapshot_publication_lease_unavailable",
7646 "Git snapshot source publication has no exact process lease scope",
7647 true,
7648 )
7649 })?
7650 .to_owned();
7651 if expected_scope.is_some_and(|expected| expected != actual_scope) {
7652 return Err(provider_failure(
7653 "snapshot_publication_lease_scope_changed",
7654 "Git snapshot source publication lease scope changed",
7655 true,
7656 ));
7657 }
7658 if !publisher.publication_attempt_is_process_fenced() {
7659 return Err(provider_failure(
7660 "snapshot_publication_process_unfenced",
7661 "Git snapshot source publication process cannot be fenced across restart",
7662 false,
7663 ));
7664 }
7665 let lease_request = GitSnapshotSourcePublicationLeaseRequest {
7666 source_repository: &self.config.source_repository,
7667 source_ref: &source_ref,
7668 };
7669 match publisher
7670 .acquire_git_snapshot_source_lease(&lease_request)
7671 .map_err(publisher_failure)?
7672 {
7673 TemporaryRefPublicationLease::Acquired => {
7674 record.snapshot_source_lease_owned = true;
7675 }
7676 TemporaryRefPublicationLease::AlreadyOwned if record.snapshot_source_lease_owned => {}
7677 TemporaryRefPublicationLease::AlreadyOwned
7678 | TemporaryRefPublicationLease::HeldByOther => {
7679 return Err(provider_failure(
7680 "snapshot_publication_owner_active",
7681 "Another live provider owns Git snapshot source publication",
7682 true,
7683 ));
7684 }
7685 TemporaryRefPublicationLease::Unsupported => {
7686 return Err(provider_failure(
7687 "snapshot_publication_lease_unavailable",
7688 "Git snapshot source publication requires a cross-process lease",
7689 false,
7690 ));
7691 }
7692 }
7693 Ok((true, actual_scope))
7694 }
7695
7696 fn release_snapshot_source_lease(&self, record: &mut JobRecord) -> RemoteBuildResult<()> {
7697 if !record.snapshot_source_lease_owned {
7698 return Ok(());
7699 }
7700 let source_ref = record
7701 .git_snapshot
7702 .as_ref()
7703 .map(|snapshot| snapshot.stage.source_ref.clone())
7704 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?;
7705 self.lock_publisher()?.release_git_snapshot_source_lease(
7706 &GitSnapshotSourcePublicationLeaseRequest {
7707 source_repository: &self.config.source_repository,
7708 source_ref: &source_ref,
7709 },
7710 );
7711 record.snapshot_source_lease_owned = false;
7712 Ok(())
7713 }
7714
7715 fn fail_snapshot_before_dispatch(
7716 &self,
7717 record: &mut JobRecord,
7718 code: &'static str,
7719 message: &'static str,
7720 ) -> RemoteBuildResult<()> {
7721 if record.state == JobState::Created {
7722 transition_record(record, JobState::Failed)?;
7723 append_failed(record, self.clock.now_ms(), code, message, false)?;
7724 }
7725 self.checkpoint_record(record)
7726 }
7727
7728 fn cleanup_git_snapshot_source(&self, record: &mut JobRecord) -> RemoteBuildResult<()>
7729 where
7730 R: GhRunner,
7731 {
7732 let Some(snapshot) = record.git_snapshot.clone() else {
7733 return Ok(());
7734 };
7735 if matches!(
7736 snapshot.phase,
7737 GithubGitSnapshotPhaseV1::SourceExact | GithubGitSnapshotPhaseV1::SourceCleanupIntent
7738 ) && !self
7739 .config
7740 .mutation_authorization
7741 .delete_git_snapshot_source
7742 {
7743 return Err(provider_failure(
7744 "snapshot_source_cleanup_unauthorized",
7745 "Git snapshot source cleanup is not authorized",
7746 false,
7747 ));
7748 }
7749 match snapshot.phase {
7750 GithubGitSnapshotPhaseV1::SourceExact => {
7751 record
7752 .git_snapshot
7753 .as_mut()
7754 .expect("checked snapshot")
7755 .phase = GithubGitSnapshotPhaseV1::SourceCleanupIntent;
7756 self.checkpoint_record(record)?;
7757 }
7758 GithubGitSnapshotPhaseV1::SourceCleanupIntent => {
7759 self.checkpoint_record(record)?;
7760 }
7761 GithubGitSnapshotPhaseV1::SourceDeleted => {
7762 if record.snapshot_source_lease_owned {
7763 self.checkpoint_record(record)?;
7764 self.release_snapshot_source_lease(record)?;
7765 }
7766 return Ok(());
7767 }
7768 GithubGitSnapshotPhaseV1::SourceAbsent
7769 | GithubGitSnapshotPhaseV1::SourceConflict
7770 | GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
7771 | GithubGitSnapshotPhaseV1::KeepaliveReleased => return Ok(()),
7772 _ => {
7773 return Err(provider_failure(
7774 "snapshot_source_cleanup_not_owned",
7775 "Git snapshot source cleanup lacks exact publication ownership",
7776 false,
7777 ));
7778 }
7779 }
7780 let snapshot = record.git_snapshot.clone().expect("checked snapshot");
7781 self.acquire_snapshot_source_lease(record)?;
7782 self.revalidate_record_identity(record)?;
7783 self.lock_publisher()?
7784 .delete_git_snapshot_source(&GitSnapshotSourceDeleteRequest {
7785 source_repository: &self.config.source_repository,
7786 source_ref: &snapshot.stage.source_ref,
7787 expected_commit: &snapshot.stage.graph.commit,
7788 authorized: self
7789 .config
7790 .mutation_authorization
7791 .delete_git_snapshot_source,
7792 })
7793 .map_err(publisher_failure)?;
7794 record
7795 .git_snapshot
7796 .as_mut()
7797 .expect("checked snapshot")
7798 .phase = GithubGitSnapshotPhaseV1::SourceDeleted;
7799 self.checkpoint_record(record)?;
7800 self.release_snapshot_source_lease(record)
7801 }
7802
7803 #[allow(
7804 clippy::too_many_lines,
7805 reason = "each snapshot crash boundary is kept adjacent to its durable checkpoint"
7806 )]
7807 fn continue_git_snapshot_submission(
7808 &self,
7809 job_id: &str,
7810 cancellation: &CancellationToken,
7811 ) -> RemoteBuildResult<()>
7812 where
7813 R: GhRunner,
7814 P: TemporaryRefReconciler,
7815 {
7816 let mut acknowledged_phase = None;
7817 loop {
7818 cancellation.check()?;
7819 let workflow_dispatch_snapshot_identity =
7820 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
7821 let working = {
7822 let state = self.lock_state()?;
7823 state
7824 .jobs
7825 .get(job_id)
7826 .cloned()
7827 .ok_or_else(|| job_not_found(job_id))?
7828 };
7829 let phase = working
7830 .git_snapshot
7831 .as_ref()
7832 .map(|snapshot| snapshot.phase)
7833 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?;
7834 if matches!(
7835 phase,
7836 GithubGitSnapshotPhaseV1::Prepared | GithubGitSnapshotPhaseV1::SourceExact
7837 ) {
7838 self.verify_execution_workflow_default()?;
7839 }
7840 matches!(
7841 phase,
7842 GithubGitSnapshotPhaseV1::Prepared
7843 | GithubGitSnapshotPhaseV1::SourcePublishIntent
7844 | GithubGitSnapshotPhaseV1::SourceExact
7845 )
7846 .then(|| self.current_public_snapshot_source_identity(&working))
7847 .transpose()?
7848 } else {
7849 None
7850 };
7851 let mut state = self.lock_state()?;
7852 let record = state
7853 .jobs
7854 .get_mut(job_id)
7855 .ok_or_else(|| job_not_found(job_id))?;
7856 let snapshot = record
7857 .git_snapshot
7858 .clone()
7859 .ok_or_else(|| resume_failure("resume_git_snapshot_binding_missing"))?;
7860 let phase_was_acknowledged = acknowledged_phase.take() == Some(snapshot.phase);
7861 if snapshot.phase != GithubGitSnapshotPhaseV1::Prepared && !phase_was_acknowledged {
7862 self.checkpoint_record(record)?;
7863 }
7864 match snapshot.phase {
7865 GithubGitSnapshotPhaseV1::Prepared => {
7866 if let Some(identity) = &workflow_dispatch_snapshot_identity {
7867 Self::validate_record_identity(record, identity)?;
7868 } else {
7869 self.revalidate_public_snapshot_source(record)?;
7870 }
7871 record
7872 .git_snapshot
7873 .as_mut()
7874 .expect("checked snapshot")
7875 .phase = GithubGitSnapshotPhaseV1::KeepaliveIntent;
7876 self.checkpoint_record(record)?;
7877 acknowledged_phase = Some(GithubGitSnapshotPhaseV1::KeepaliveIntent);
7878 }
7879 GithubGitSnapshotPhaseV1::KeepaliveIntent => {
7880 if !self
7881 .config
7882 .mutation_authorization
7883 .manage_git_snapshot_keepalive
7884 {
7885 return Err(provider_failure(
7886 "snapshot_keepalive_unauthorized",
7887 "Git snapshot keepalive mutation is not authorized",
7888 false,
7889 ));
7890 }
7891 let keepalive_request = GitSnapshotKeepaliveRequest {
7892 operation_id: &record.operation_id,
7893 keepalive_ref: &snapshot.stage.keepalive_ref,
7894 commit: &snapshot.stage.graph.commit,
7895 };
7896 let reconciliation = self
7897 .lock_publisher()?
7898 .reconcile_git_snapshot_keepalive(&keepalive_request)
7899 .map_err(publisher_failure)?;
7900 match reconciliation {
7901 GitSnapshotRefReconciliation::Exact => {}
7902 GitSnapshotRefReconciliation::Conflict => {
7903 return Err(provider_failure(
7904 "snapshot_keepalive_conflict",
7905 "Git snapshot keepalive ref is occupied by another commit",
7906 false,
7907 ));
7908 }
7909 GitSnapshotRefReconciliation::Absent => {
7910 let import_request = GitSnapshotImportRequest {
7911 locator: &snapshot.stage_locator,
7912 expected_stage: &snapshot.stage,
7913 request: &record.request,
7914 };
7915 let mut publisher = self.lock_publisher()?;
7916 let imported = publisher
7917 .import_git_snapshot(&import_request)
7918 .map_err(publisher_failure)?;
7919 if imported != snapshot.stage.graph {
7920 return Err(provider_failure(
7921 "snapshot_import_graph_mismatch",
7922 "Git snapshot import differs from the durable object graph",
7923 false,
7924 ));
7925 }
7926 publisher
7927 .create_git_snapshot_keepalive(&keepalive_request)
7928 .map_err(publisher_failure)?;
7929 }
7930 }
7931 record
7932 .git_snapshot
7933 .as_mut()
7934 .expect("checked snapshot")
7935 .phase = GithubGitSnapshotPhaseV1::KeepaliveExact;
7936 self.checkpoint_record(record)?;
7937 acknowledged_phase = Some(GithubGitSnapshotPhaseV1::KeepaliveExact);
7938 }
7939 GithubGitSnapshotPhaseV1::KeepaliveExact => {
7940 record
7941 .git_snapshot
7942 .as_mut()
7943 .expect("checked snapshot")
7944 .phase = GithubGitSnapshotPhaseV1::StageDeleteIntent;
7945 self.checkpoint_record(record)?;
7946 acknowledged_phase = Some(GithubGitSnapshotPhaseV1::StageDeleteIntent);
7947 }
7948 GithubGitSnapshotPhaseV1::StageDeleteIntent => {
7949 let keepalive_request = GitSnapshotKeepaliveRequest {
7950 operation_id: &record.operation_id,
7951 keepalive_ref: &snapshot.stage.keepalive_ref,
7952 commit: &snapshot.stage.graph.commit,
7953 };
7954 match self
7955 .lock_publisher()?
7956 .reconcile_git_snapshot_keepalive(&keepalive_request)
7957 .map_err(publisher_failure)?
7958 {
7959 GitSnapshotRefReconciliation::Exact => {}
7960 GitSnapshotRefReconciliation::Absent => {
7961 return Err(provider_failure(
7962 "snapshot_keepalive_missing",
7963 "Git snapshot keepalive disappeared before stage deletion",
7964 false,
7965 ));
7966 }
7967 GitSnapshotRefReconciliation::Conflict => {
7968 return Err(provider_failure(
7969 "snapshot_keepalive_conflict",
7970 "Git snapshot keepalive changed before stage deletion",
7971 false,
7972 ));
7973 }
7974 }
7975 self.lock_publisher()?
7976 .delete_git_snapshot_stage(&GitSnapshotImportRequest {
7977 locator: &snapshot.stage_locator,
7978 expected_stage: &snapshot.stage,
7979 request: &record.request,
7980 })
7981 .map_err(publisher_failure)?;
7982 record
7983 .git_snapshot
7984 .as_mut()
7985 .expect("checked snapshot")
7986 .phase = GithubGitSnapshotPhaseV1::StageDeleted;
7987 self.checkpoint_record(record)?;
7988 acknowledged_phase = Some(GithubGitSnapshotPhaseV1::StageDeleted);
7989 }
7990 GithubGitSnapshotPhaseV1::StageDeleted => {
7991 let (process_fenced, scope) = self.acquire_snapshot_source_lease(record)?;
7992 let snapshot = record.git_snapshot.as_mut().expect("checked snapshot");
7993 snapshot.phase = GithubGitSnapshotPhaseV1::SourcePublishIntent;
7994 snapshot.source_publication_attempts = 1;
7995 snapshot.source_publication_started_at_ms = 0;
7996 snapshot.source_publication_quiescence_deadline_ms = u64::MAX;
7997 snapshot.source_publication_process_fenced = process_fenced;
7998 snapshot.source_publication_lease_scope_sha256 = Some(scope);
7999 snapshot.source_publication_absence_observations = 0;
8000 snapshot.source_publication_absence_first_observed_at_ms = 0;
8001 snapshot.source_publication_absence_last_observed_at_ms = 0;
8002 self.checkpoint_record(record)?;
8003 acknowledged_phase = Some(GithubGitSnapshotPhaseV1::SourcePublishIntent);
8004 }
8005 GithubGitSnapshotPhaseV1::SourcePublishIntent => {
8006 self.acquire_snapshot_source_lease(record)?;
8007 if snapshot.source_publication_started_at_ms == 0 {
8008 if let Some(identity) = &workflow_dispatch_snapshot_identity {
8009 Self::validate_record_identity(record, identity)?;
8010 } else {
8011 self.revalidate_public_snapshot_source(record)?;
8012 }
8013 let started_at_ms = self.clock.now_ms();
8014 {
8015 let snapshot = record.git_snapshot.as_mut().expect("checked snapshot");
8016 snapshot.source_publication_started_at_ms = started_at_ms;
8017 snapshot.source_publication_quiescence_deadline_ms =
8018 publication_quiescence_deadline(started_at_ms, 0);
8019 }
8020 self.checkpoint_record(record)?;
8021 let result = self
8022 .lock_publisher()?
8023 .publish_git_snapshot_source(&GitSnapshotSourcePublishRequest {
8024 source_repository: &self.config.source_repository,
8025 source_ref: &snapshot.stage.source_ref,
8026 commit: &snapshot.stage.graph.commit,
8027 authorized: self
8028 .config
8029 .mutation_authorization
8030 .publish_git_snapshot_source,
8031 })
8032 .map_err(publisher_failure)?;
8033 match result {
8034 GitSnapshotSourcePublication::Exact => {
8035 record
8036 .git_snapshot
8037 .as_mut()
8038 .expect("checked snapshot")
8039 .phase = GithubGitSnapshotPhaseV1::SourceExact;
8040 self.checkpoint_record(record)?;
8041 acknowledged_phase = Some(GithubGitSnapshotPhaseV1::SourceExact);
8042 }
8043 GitSnapshotSourcePublication::Conflict => {
8044 record
8045 .git_snapshot
8046 .as_mut()
8047 .expect("checked snapshot")
8048 .phase = GithubGitSnapshotPhaseV1::SourceConflict;
8049 self.fail_snapshot_before_dispatch(
8050 record,
8051 "snapshot_source_ref_conflict",
8052 "Git snapshot source ref is occupied by another commit",
8053 )?;
8054 self.release_snapshot_source_lease(record)?;
8055 return Ok(());
8056 }
8057 GitSnapshotSourcePublication::Uncertain => {
8058 let observed_at_ms = self.clock.now_ms();
8059 let snapshot =
8060 record.git_snapshot.as_mut().expect("checked snapshot");
8061 snapshot.source_publication_absence_observations = 1;
8062 snapshot.source_publication_absence_first_observed_at_ms =
8063 observed_at_ms;
8064 snapshot.source_publication_absence_last_observed_at_ms =
8065 observed_at_ms;
8066 snapshot.source_publication_quiescence_deadline_ms =
8067 publication_quiescence_deadline(
8068 snapshot.source_publication_started_at_ms,
8069 observed_at_ms,
8070 );
8071 self.checkpoint_record(record)?;
8072 return Err(provider_failure(
8073 "snapshot_source_publication_uncertain",
8074 "Git snapshot source publication requires exact reconciliation",
8075 true,
8076 ));
8077 }
8078 }
8079 } else {
8080 if let Some(identity) = &workflow_dispatch_snapshot_identity {
8081 Self::validate_record_identity(record, identity)?;
8082 } else {
8083 self.revalidate_public_snapshot_source(record)?;
8084 }
8085 let reconciliation = self
8086 .lock_publisher()?
8087 .reconcile_git_snapshot_source(&GitSnapshotSourcePublishRequest {
8088 source_repository: &self.config.source_repository,
8089 source_ref: &snapshot.stage.source_ref,
8090 commit: &snapshot.stage.graph.commit,
8091 authorized: true,
8092 })
8093 .map_err(publisher_failure)?;
8094 match reconciliation {
8095 GitSnapshotRefReconciliation::Exact => {
8096 record
8097 .git_snapshot
8098 .as_mut()
8099 .expect("checked snapshot")
8100 .phase = GithubGitSnapshotPhaseV1::SourceExact;
8101 self.checkpoint_record(record)?;
8102 acknowledged_phase = Some(GithubGitSnapshotPhaseV1::SourceExact);
8103 }
8104 GitSnapshotRefReconciliation::Conflict => {
8105 record
8106 .git_snapshot
8107 .as_mut()
8108 .expect("checked snapshot")
8109 .phase = GithubGitSnapshotPhaseV1::SourceConflict;
8110 self.fail_snapshot_before_dispatch(
8111 record,
8112 "snapshot_source_ref_conflict",
8113 "Git snapshot source ref is occupied by another commit",
8114 )?;
8115 self.release_snapshot_source_lease(record)?;
8116 return Ok(());
8117 }
8118 GitSnapshotRefReconciliation::Absent => {
8119 let observed_at_ms = self.clock.now_ms();
8120 let snapshot =
8121 record.git_snapshot.as_mut().expect("checked snapshot");
8122 if snapshot.source_publication_absence_observations == 0 {
8123 snapshot.source_publication_absence_first_observed_at_ms =
8124 observed_at_ms;
8125 snapshot.source_publication_quiescence_deadline_ms =
8126 publication_quiescence_deadline(
8127 snapshot.source_publication_started_at_ms,
8128 observed_at_ms,
8129 );
8130 }
8131 snapshot.source_publication_absence_observations = snapshot
8132 .source_publication_absence_observations
8133 .saturating_add(1)
8134 .min(2);
8135 snapshot.source_publication_absence_last_observed_at_ms =
8136 observed_at_ms;
8137 let absent = snapshot.source_publication_absence_observations >= 2
8138 && snapshot.source_publication_process_fenced
8139 && observed_at_ms
8140 >= snapshot.source_publication_quiescence_deadline_ms;
8141 if absent {
8142 snapshot.phase = GithubGitSnapshotPhaseV1::SourceAbsent;
8143 }
8144 self.checkpoint_record(record)?;
8145 acknowledged_phase = Some(
8146 record
8147 .git_snapshot
8148 .as_ref()
8149 .expect("checked snapshot")
8150 .phase,
8151 );
8152 if !absent {
8153 return Err(provider_failure(
8154 "snapshot_source_reconciliation_pending",
8155 "Git snapshot source absence is not yet strongly proven",
8156 true,
8157 ));
8158 }
8159 }
8160 }
8161 }
8162 }
8163 GithubGitSnapshotPhaseV1::SourceAbsent => {
8164 if snapshot.source_publication_attempts >= 3 {
8165 self.fail_snapshot_before_dispatch(
8166 record,
8167 "snapshot_source_publication_absent",
8168 "Git snapshot source publication remained absent after bounded retries",
8169 )?;
8170 self.release_snapshot_source_lease(record)?;
8171 return Ok(());
8172 }
8173 self.acquire_snapshot_source_lease(record)?;
8174 let snapshot = record.git_snapshot.as_mut().expect("checked snapshot");
8175 snapshot.phase = GithubGitSnapshotPhaseV1::SourcePublishIntent;
8176 snapshot.source_publication_attempts += 1;
8177 snapshot.source_publication_started_at_ms = 0;
8178 snapshot.source_publication_quiescence_deadline_ms = u64::MAX;
8179 snapshot.source_publication_absence_observations = 0;
8180 snapshot.source_publication_absence_first_observed_at_ms = 0;
8181 snapshot.source_publication_absence_last_observed_at_ms = 0;
8182 self.checkpoint_record(record)?;
8183 acknowledged_phase = Some(GithubGitSnapshotPhaseV1::SourcePublishIntent);
8184 }
8185 GithubGitSnapshotPhaseV1::SourceConflict => {
8186 self.release_snapshot_source_lease(record)?;
8187 return Ok(());
8188 }
8189 GithubGitSnapshotPhaseV1::SourceExact => {
8190 self.acquire_snapshot_source_lease(record)?;
8191 if let Some(identity) = &workflow_dispatch_snapshot_identity {
8192 Self::validate_record_identity(record, identity)?;
8193 } else {
8194 self.revalidate_public_snapshot_source(record)?;
8195 }
8196 let reconciliation = self
8197 .lock_publisher()?
8198 .reconcile_git_snapshot_source(&GitSnapshotSourcePublishRequest {
8199 source_repository: &self.config.source_repository,
8200 source_ref: &snapshot.stage.source_ref,
8201 commit: &snapshot.stage.graph.commit,
8202 authorized: true,
8203 })
8204 .map_err(publisher_failure)?;
8205 if reconciliation != GitSnapshotRefReconciliation::Exact {
8206 record
8207 .git_snapshot
8208 .as_mut()
8209 .expect("checked snapshot")
8210 .phase = GithubGitSnapshotPhaseV1::SourceConflict;
8211 self.fail_snapshot_before_dispatch(
8212 record,
8213 "snapshot_source_ownership_lost",
8214 "Git snapshot source ref changed before dispatch publication",
8215 )?;
8216 self.release_snapshot_source_lease(record)?;
8217 return Ok(());
8218 }
8219 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
8220 drop(state);
8221 return self
8222 .publish_git_snapshot_dispatch_workflow_dispatch(job_id, cancellation);
8223 }
8224 let result = self.publish_git_snapshot_dispatch(record, cancellation);
8225 if !record.publication_intent {
8226 self.release_snapshot_source_lease(record)?;
8227 }
8228 return result;
8229 }
8230 GithubGitSnapshotPhaseV1::SourceCleanupIntent
8231 | GithubGitSnapshotPhaseV1::SourceDeleted
8232 | GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
8233 | GithubGitSnapshotPhaseV1::KeepaliveReleased => return Ok(()),
8234 }
8235 }
8236 }
8237
8238 #[allow(
8239 clippy::too_many_lines,
8240 reason = "the snapshot dispatch intent, fresh identity barrier, ref mutation, and lease release are one ordered boundary"
8241 )]
8242 fn publish_git_snapshot_dispatch_workflow_dispatch(
8243 &self,
8244 job_id: &str,
8245 cancellation: &CancellationToken,
8246 ) -> RemoteBuildResult<()>
8247 where
8248 R: GhRunner,
8249 {
8250 cancellation.check()?;
8251 let mut working = {
8252 let state = self.lock_state()?;
8253 state
8254 .jobs
8255 .get(job_id)
8256 .cloned()
8257 .ok_or_else(|| job_not_found(job_id))?
8258 };
8259 if working.publication_intent {
8260 return Ok(());
8261 }
8262 let snapshot = working
8263 .git_snapshot
8264 .clone()
8265 .filter(|snapshot| snapshot.phase == GithubGitSnapshotPhaseV1::SourceExact)
8266 .ok_or_else(|| resume_failure("resume_git_snapshot_source_not_exact"))?;
8267 let initial_identity = self.current_public_snapshot_source_identity(&working)?;
8268 let workflow = generate_workflow(&self.config.workflow);
8269 let manifest = GithubDispatchManifest::new(
8270 &self.config,
8271 &working.temporary_ref,
8272 working.request.clone(),
8273 );
8274 manifest.validate_for(&self.config)?;
8275 let manifest_bytes = manifest.encode()?;
8276 let source_revision = working.source_revision.clone();
8277 let snapshot_source_ref = snapshot.stage.source_ref.clone();
8278 let temporary_ref = working.temporary_ref.clone();
8279 let operation_id = working.operation_id.clone();
8280 let created_at_ms = working.created_at_ms;
8281 let publish_request = TemporaryRefPublishRequest {
8282 repository: &self.config.repository,
8283 source_repository: &self.config.source_repository,
8284 trusted_source_ref: self.config.workflow.trusted_source_ref(),
8285 source_revision: &source_revision,
8286 snapshot_source_ref: Some(&snapshot_source_ref),
8287 temporary_ref: &temporary_ref,
8288 workflow_path: workflow.path(),
8289 workflow_bytes: workflow.yaml().as_bytes(),
8290 workflow_fingerprint: &self.config.workflow_fingerprint,
8291 manifest_bytes: &manifest_bytes,
8292 operation_id: &operation_id,
8293 created_at_ms,
8294 authorized: self.config.mutation_authorization.publish_temporary_ref,
8295 };
8296 let prepared = self
8297 .lock_publisher()?
8298 .prepare(&publish_request)
8299 .map_err(publisher_failure)?;
8300 if prepared
8301 .as_ref()
8302 .is_some_and(|publication| publication.temporary_ref() != &temporary_ref)
8303 {
8304 return Err(publisher_failure(
8305 TemporaryRefPublishError::PublicationVerificationFailed,
8306 ));
8307 }
8308 let (publication_process_fenced, publication_lease_scope_sha256) =
8309 self.acquire_new_snapshot_dispatch_lease(&mut working)?;
8310 working.prepared_dispatch_commit = prepared
8311 .as_ref()
8312 .map(|publication| publication.commit().clone());
8313 working.publication_started_at_ms = 0;
8314 working.publication_quiescence_deadline_ms = u64::MAX;
8315 working.publication_intent = true;
8316 working.publication_process_fenced = publication_process_fenced;
8317 working.publication_lease_scope_sha256 = Some(publication_lease_scope_sha256.clone());
8318 {
8319 let mut state = self.lock_state()?;
8320 let record = state
8321 .jobs
8322 .get_mut(job_id)
8323 .ok_or_else(|| job_not_found(job_id))?;
8324 if record.git_snapshot.as_ref() != Some(&snapshot)
8325 || record.publication_intent
8326 || record.prepared_dispatch_commit.is_some()
8327 || record.dispatch_commit.is_some()
8328 {
8329 return Err(resume_failure("snapshot_dispatch_identity_changed"));
8330 }
8331 Self::validate_record_identity(record, &initial_identity)?;
8332 record
8333 .prepared_dispatch_commit
8334 .clone_from(&working.prepared_dispatch_commit);
8335 record.publication_started_at_ms = 0;
8336 record.publication_quiescence_deadline_ms = u64::MAX;
8337 record.publication_intent = true;
8338 record.publication_process_fenced = publication_process_fenced;
8339 record.publication_lease_scope_sha256 = Some(publication_lease_scope_sha256);
8340 record.publication_takeover_lease_owned = true;
8341 self.checkpoint_record(record)?;
8342 }
8343
8344 let publication_identity = self.current_public_snapshot_source_identity(&working);
8345 let boundary_cancellation = cancellation.check();
8346 let boundary_result = (|| {
8347 let mut state = self.lock_state()?;
8348 let record = state
8349 .jobs
8350 .get_mut(job_id)
8351 .ok_or_else(|| job_not_found(job_id))?;
8352 let identity = publication_identity?;
8353 boundary_cancellation?;
8354 Self::validate_record_identity(record, &identity)?;
8355 if record.prepared_dispatch_commit != working.prepared_dispatch_commit
8356 || !record.publication_intent
8357 || record.publication_uncertain
8358 || record.dispatch_commit.is_some()
8359 || record.git_snapshot.as_ref() != Some(&snapshot)
8360 {
8361 return Err(resume_failure("snapshot_dispatch_identity_changed"));
8362 }
8363 if record.cancellation_requested {
8364 return Err(provider_failure(
8365 "publication_cancelled_before_mutation",
8366 "GitHub snapshot dispatch was cancelled before any remote ref mutation",
8367 false,
8368 ));
8369 }
8370 record.publication_started_at_ms = self.clock.now_ms();
8371 record.publication_quiescence_deadline_ms =
8372 publication_quiescence_deadline(record.publication_started_at_ms, 0);
8373 working.publication_started_at_ms = record.publication_started_at_ms;
8374 working.publication_quiescence_deadline_ms = record.publication_quiescence_deadline_ms;
8375 self.checkpoint_record(record)
8376 })();
8377 if let Err(error) = boundary_result {
8378 let mut state = self.lock_state()?;
8379 let record = state
8380 .jobs
8381 .get_mut(job_id)
8382 .ok_or_else(|| job_not_found(job_id))?;
8383 mark_publication_not_attempted(
8384 record,
8385 self.clock.now_ms(),
8386 "snapshot_dispatch_identity_revalidation_failed",
8387 "GitHub identity or public snapshot source changed before dispatch publication",
8388 true,
8389 )?;
8390 record.publication_takeover_lease_owned = false;
8391 record.snapshot_source_lease_owned = false;
8392 self.checkpoint_record(record)?;
8393 drop(state);
8394 let _ = self.release_record_publication_lease(&mut working);
8395 let _ = self.release_snapshot_source_lease(&mut working);
8396 return Err(error);
8397 }
8398
8399 let publish_result = self
8400 .lock_publisher()?
8401 .publish_prepared(&publish_request, prepared.as_ref());
8402 let (published, publication_uncertain) = match publish_result {
8403 Ok(published) => (published, false),
8404 Err(failure) => {
8405 if let Some(publication) = failure.possible_publication().cloned() {
8406 (publication, true)
8407 } else {
8408 let error = publisher_failure(failure.error());
8409 let mut state = self.lock_state()?;
8410 let record = state
8411 .jobs
8412 .get_mut(job_id)
8413 .ok_or_else(|| job_not_found(job_id))?;
8414 mark_publication_not_attempted(
8415 record,
8416 self.clock.now_ms(),
8417 "snapshot_dispatch_publication_not_attempted",
8418 "Snapshot dispatch publication failed before any remote mutation",
8419 false,
8420 )?;
8421 record.publication_takeover_lease_owned = false;
8422 record.snapshot_source_lease_owned = false;
8423 self.checkpoint_record(record)?;
8424 drop(state);
8425 let _ = self.release_record_publication_lease(&mut working);
8426 let _ = self.release_snapshot_source_lease(&mut working);
8427 return Err(error);
8428 }
8429 }
8430 };
8431 let publisher_binding_mismatch = published.temporary_ref() != &temporary_ref
8432 || prepared
8433 .as_ref()
8434 .is_some_and(|expected| expected.commit() != published.commit());
8435 let release_publication = !publication_uncertain || publisher_binding_mismatch;
8436 {
8437 let mut state = self.lock_state()?;
8438 let record = state
8439 .jobs
8440 .get_mut(job_id)
8441 .ok_or_else(|| job_not_found(job_id))?;
8442 if record.prepared_dispatch_commit != working.prepared_dispatch_commit
8443 || record.publication_started_at_ms != working.publication_started_at_ms
8444 || record.dispatch_commit.is_some()
8445 || record.git_snapshot.as_ref() != Some(&snapshot)
8446 {
8447 return Err(resume_failure("snapshot_dispatch_identity_changed"));
8448 }
8449 record.dispatch_commit = (!publisher_binding_mismatch && !publication_uncertain)
8450 .then(|| published.commit().clone());
8451 record.publication_uncertain = publication_uncertain || publisher_binding_mismatch;
8452 record.cancellation_requested |= cancellation.is_cancelled();
8453 if record.cancellation_requested && record.state != JobState::Cancelling {
8454 transition_record(record, JobState::Cancelling)?;
8455 }
8456 if publisher_binding_mismatch {
8457 transition_record(record, JobState::Failed)?;
8458 append_event(
8459 record,
8460 self.clock.now_ms(),
8461 "cleanup",
8462 RemoteBuildEventKind::Warning {
8463 code: "github.cleanup_required".to_owned(),
8464 message: "Snapshot dispatch publisher violated its prepared ref/commit binding; automatic adoption was refused".to_owned(),
8465 help: Some("Inspect the alternate publisher and exact remote ref".to_owned()),
8466 },
8467 )?;
8468 append_failed(
8469 record,
8470 self.clock.now_ms(),
8471 "snapshot_dispatch_binding_mismatch",
8472 "Snapshot dispatch publisher returned a different ref or commit after possible mutation",
8473 false,
8474 )?;
8475 } else if publication_uncertain {
8476 append_event(
8477 record,
8478 self.clock.now_ms(),
8479 "submit",
8480 RemoteBuildEventKind::Warning {
8481 code: "github.snapshot_dispatch_publication_uncertain".to_owned(),
8482 message: "Snapshot dispatch ref push may have succeeded; cleanup ownership was retained".to_owned(),
8483 help: Some("Continue polling or reconcile this exact job".to_owned()),
8484 },
8485 )?;
8486 }
8487 record.snapshot_source_lease_owned = false;
8488 if release_publication {
8489 record.publication_takeover_lease_owned = false;
8490 }
8491 self.checkpoint_record(record)?;
8492 }
8493 self.release_snapshot_source_lease(&mut working)?;
8494 if release_publication {
8495 self.release_record_publication_lease(&mut working)?;
8496 }
8497 Ok(())
8498 }
8499
8500 #[allow(
8501 clippy::too_many_lines,
8502 reason = "source-ref revalidation and dispatch publication checkpoints form one ordered mutation boundary"
8503 )]
8504 fn publish_git_snapshot_dispatch(
8505 &self,
8506 record: &mut JobRecord,
8507 cancellation: &CancellationToken,
8508 ) -> RemoteBuildResult<()>
8509 where
8510 R: GhRunner,
8511 {
8512 if record.publication_intent {
8513 return Ok(());
8514 }
8515 cancellation.check()?;
8516 let snapshot = record
8517 .git_snapshot
8518 .clone()
8519 .filter(|snapshot| snapshot.phase == GithubGitSnapshotPhaseV1::SourceExact)
8520 .ok_or_else(|| resume_failure("resume_git_snapshot_source_not_exact"))?;
8521 self.revalidate_public_snapshot_source(record)?;
8522 let workflow = generate_workflow(&self.config.workflow);
8523 let manifest = GithubDispatchManifest::new(
8524 &self.config,
8525 &record.temporary_ref,
8526 record.request.clone(),
8527 );
8528 manifest.validate_for(&self.config)?;
8529 let manifest_bytes = manifest.encode()?;
8530 let source_revision = record.source_revision.clone();
8531 let snapshot_source_ref = snapshot.stage.source_ref.clone();
8532 let temporary_ref = record.temporary_ref.clone();
8533 let operation_id = record.operation_id.clone();
8534 let created_at_ms = record.created_at_ms;
8535 let publish_request = TemporaryRefPublishRequest {
8536 repository: &self.config.repository,
8537 source_repository: &self.config.source_repository,
8538 trusted_source_ref: self.config.workflow.trusted_source_ref(),
8539 source_revision: &source_revision,
8540 snapshot_source_ref: Some(&snapshot_source_ref),
8541 temporary_ref: &temporary_ref,
8542 workflow_path: workflow.path(),
8543 workflow_bytes: workflow.yaml().as_bytes(),
8544 workflow_fingerprint: &self.config.workflow_fingerprint,
8545 manifest_bytes: &manifest_bytes,
8546 operation_id: &operation_id,
8547 created_at_ms,
8548 authorized: self.config.mutation_authorization.publish_temporary_ref,
8549 };
8550 let prepared = self
8551 .lock_publisher()?
8552 .prepare(&publish_request)
8553 .map_err(publisher_failure)?;
8554 if prepared
8555 .as_ref()
8556 .is_some_and(|publication| publication.temporary_ref() != &record.temporary_ref)
8557 {
8558 return Err(publisher_failure(
8559 TemporaryRefPublishError::PublicationVerificationFailed,
8560 ));
8561 }
8562 let (publication_process_fenced, publication_lease_scope_sha256) =
8563 self.acquire_new_snapshot_dispatch_lease(record)?;
8564 record.prepared_dispatch_commit = prepared
8565 .as_ref()
8566 .map(|publication| publication.commit().clone());
8567 record.publication_started_at_ms = 0;
8568 record.publication_quiescence_deadline_ms = u64::MAX;
8569 record.publication_intent = true;
8570 record.publication_process_fenced = publication_process_fenced;
8571 record.publication_lease_scope_sha256 = Some(publication_lease_scope_sha256);
8572 self.checkpoint_record(record)?;
8573 if let Err(error) = self.revalidate_public_snapshot_source(record) {
8574 mark_publication_not_attempted(
8575 record,
8576 self.clock.now_ms(),
8577 "snapshot_dispatch_identity_revalidation_failed",
8578 "GitHub identity or public snapshot source changed before dispatch publication",
8579 true,
8580 )?;
8581 self.checkpoint_record(record)?;
8582 self.release_record_publication_lease(record)?;
8583 self.release_snapshot_source_lease(record)?;
8584 return Err(error);
8585 }
8586 record.publication_started_at_ms = self.clock.now_ms();
8587 record.publication_quiescence_deadline_ms =
8588 publication_quiescence_deadline(record.publication_started_at_ms, 0);
8589 self.checkpoint_record(record)?;
8590 cancellation.check()?;
8591 let publish_result = self
8592 .lock_publisher()?
8593 .publish_prepared(&publish_request, prepared.as_ref());
8594 let (published, publication_uncertain) = match publish_result {
8595 Ok(published) => (published, false),
8596 Err(failure) => {
8597 if let Some(publication) = failure.possible_publication().cloned() {
8598 (publication, true)
8599 } else {
8600 let error = publisher_failure(failure.error());
8601 mark_publication_not_attempted(
8602 record,
8603 self.clock.now_ms(),
8604 "snapshot_dispatch_publication_not_attempted",
8605 "Snapshot dispatch publication failed before any remote mutation",
8606 false,
8607 )?;
8608 self.checkpoint_record(record)?;
8609 self.release_record_publication_lease(record)?;
8610 self.release_snapshot_source_lease(record)?;
8611 return Err(error);
8612 }
8613 }
8614 };
8615 let publisher_binding_mismatch = published.temporary_ref() != &record.temporary_ref
8616 || prepared
8617 .as_ref()
8618 .is_some_and(|expected| expected.commit() != published.commit());
8619 record.dispatch_commit = (!publisher_binding_mismatch && !publication_uncertain)
8620 .then(|| published.commit().clone());
8621 record.publication_uncertain = publication_uncertain || publisher_binding_mismatch;
8622 self.checkpoint_record(record)?;
8623 if publisher_binding_mismatch {
8624 transition_record(record, JobState::Failed)?;
8625 append_event(
8626 record,
8627 self.clock.now_ms(),
8628 "cleanup",
8629 RemoteBuildEventKind::Warning {
8630 code: "github.cleanup_required".to_owned(),
8631 message: "Snapshot dispatch publisher violated its prepared ref/commit binding; automatic adoption was refused".to_owned(),
8632 help: Some("Inspect the alternate publisher and exact remote ref".to_owned()),
8633 },
8634 )?;
8635 append_failed(
8636 record,
8637 self.clock.now_ms(),
8638 "snapshot_dispatch_binding_mismatch",
8639 "Snapshot dispatch publisher returned a different ref or commit after possible mutation",
8640 false,
8641 )?;
8642 self.checkpoint_record(record)?;
8643 self.release_record_publication_lease(record)?;
8644 self.release_snapshot_source_lease(record)?;
8645 return Ok(());
8646 }
8647 if publication_uncertain {
8648 append_event(
8649 record,
8650 self.clock.now_ms(),
8651 "submit",
8652 RemoteBuildEventKind::Warning {
8653 code: "github.snapshot_dispatch_publication_uncertain".to_owned(),
8654 message: "Snapshot dispatch ref push may have succeeded; cleanup ownership was retained".to_owned(),
8655 help: Some("Continue polling or reconcile this exact job".to_owned()),
8656 },
8657 )?;
8658 } else if self.config.workflow.run_trigger() == WorkflowRunTrigger::Push {
8659 if record.state == JobState::Created {
8660 transition_record(record, JobState::Queued)?;
8661 }
8662 append_event(
8663 record,
8664 self.clock.now_ms(),
8665 "queue",
8666 RemoteBuildEventKind::JobQueued { position: None },
8667 )?;
8668 }
8669 record.cancellation_requested |= cancellation.is_cancelled();
8670 if record.cancellation_requested && record.state != JobState::Cancelling {
8671 transition_record(record, JobState::Cancelling)?;
8672 }
8673 self.checkpoint_record(record)?;
8674 self.release_snapshot_source_lease(record)?;
8675 if !record.publication_uncertain {
8676 self.release_record_publication_lease(record)?;
8677 }
8678 Ok(())
8679 }
8680
8681 fn revalidate_record_identity(&self, record: &JobRecord) -> RemoteBuildResult<()>
8682 where
8683 R: GhRunner,
8684 {
8685 if record.principal.is_none() || record.execution_repository_id.is_none() {
8686 return Ok(());
8687 }
8688 let identity = self.current_resume_identity()?;
8689 Self::validate_record_identity(record, &identity)
8690 }
8691
8692 fn validate_record_identity(
8693 record: &JobRecord,
8694 identity: &(GithubPrincipalIdentityV1, u64),
8695 ) -> RemoteBuildResult<()> {
8696 let (Some(expected_principal), Some(expected_repository_id)) =
8697 (record.principal.as_ref(), record.execution_repository_id)
8698 else {
8699 return Ok(());
8700 };
8701 if &identity.0 != expected_principal || identity.1 != expected_repository_id {
8702 return Err(resume_failure("resume_live_identity_changed"));
8703 }
8704 Ok(())
8705 }
8706
8707 fn checkpoint_record(&self, record: &JobRecord) -> RemoteBuildResult<()> {
8708 let Some(sink) = &self.checkpoint_sink else {
8709 return Ok(());
8710 };
8711 let principal = record
8712 .principal
8713 .clone()
8714 .ok_or_else(|| resume_failure("checkpoint_principal_unavailable"))?;
8715 let repository_id = record
8716 .execution_repository_id
8717 .ok_or_else(|| resume_failure("checkpoint_repository_identity_unavailable"))?;
8718 let resume =
8719 GithubJobResumeV1::from_record(&self.config, principal, repository_id, record)?;
8720 sink.lock()
8721 .map_err(|_| {
8722 provider_failure(
8723 "checkpoint_sink_unavailable",
8724 "GitHub job checkpoint sink is unavailable",
8725 true,
8726 )
8727 })?
8728 .checkpoint(&resume)
8729 }
8730
8731 fn verify_execution_workflow_default(&self) -> RemoteBuildResult<()>
8732 where
8733 P: TemporaryRefPublisher,
8734 {
8735 let workflow_path = self.config.workflow.filename().repository_path();
8736 self.lock_publisher()?
8737 .verify_execution_workflow(&ExecutionWorkflowDoctorRequest {
8738 repository: &self.config.repository,
8739 default_branch_ref: self.config.workflow.trusted_source_ref(),
8740 workflow_path: &workflow_path,
8741 workflow_fingerprint: &self.config.workflow_fingerprint,
8742 })
8743 .map_err(publisher_failure)?;
8744 Ok(())
8745 }
8746
8747 fn preflight_workflow_dispatch_registration(&self) -> RemoteBuildResult<WorkflowRegistration>
8748 where
8749 R: GhRunner,
8750 {
8751 if self.config.workflow.run_trigger() != WorkflowRunTrigger::WorkflowDispatch {
8752 return Err(resume_failure("workflow_dispatch_trigger_mismatch"));
8753 }
8754 let mut transport = self.lock_transport()?;
8755 let repository = transport
8756 .repository(&self.config.repository)
8757 .map_err(transport_failure)?;
8758 let expected_trusted_ref = format!("refs/heads/{}", repository.default_branch().as_str());
8759 if self.config.workflow.trusted_source_ref().as_str() != expected_trusted_ref {
8760 return Err(provider_failure(
8761 "workflow_dispatch_default_branch_required",
8762 "Workflow dispatch requires its exact workflow on the execution repository default branch",
8763 false,
8764 ));
8765 }
8766 let registration = transport
8767 .workflow_registration(
8768 &self.config.repository,
8769 &self.config.workflow.filename().repository_path(),
8770 )
8771 .map_err(transport_failure)?;
8772 drop(transport);
8773 self.verify_execution_workflow_default()?;
8774 Ok(registration)
8775 }
8776
8777 fn prepare_workflow_dispatch_client(
8778 &self,
8779 ) -> RemoteBuildResult<Box<dyn GithubWorkflowDispatchHttpClient + Send>>
8780 where
8781 R: GhRunner,
8782 {
8783 let factory = {
8784 let transport = self.lock_transport()?;
8785 transport.runner().workflow_dispatch_client_factory()
8786 }
8787 .map_err(workflow_dispatch_client_failure)?;
8788 factory.create().map_err(workflow_dispatch_client_failure)
8789 }
8790
8791 fn ensure_workflow_dispatch_intent(
8792 &self,
8793 job_id: &str,
8794 ) -> RemoteBuildResult<Option<WorkflowDispatchPostAuthority>>
8795 where
8796 R: GhRunner,
8797 {
8798 if self.config.workflow.run_trigger() != WorkflowRunTrigger::WorkflowDispatch {
8799 return Err(resume_failure("workflow_dispatch_trigger_mismatch"));
8800 }
8801 if self.checkpoint_sink.is_none() {
8802 return Err(provider_failure(
8803 "workflow_dispatch_checkpoint_required",
8804 "Workflow dispatch requires a durable provider checkpoint sink",
8805 false,
8806 ));
8807 }
8808 {
8809 let state = self.lock_state()?;
8810 let record = state
8811 .jobs
8812 .get(job_id)
8813 .ok_or_else(|| job_not_found(job_id))?;
8814 if let Some(dispatch) = record.workflow_dispatch.as_ref() {
8815 dispatch.request_for_record(&self.config, record)?;
8816 return Ok(None);
8817 }
8818 if !record.publication_intent
8819 || record.publication_uncertain
8820 || record.dispatch_commit.is_none()
8821 || record.state.is_build_terminal()
8822 || record.state.is_terminal()
8823 {
8824 return Err(provider_failure(
8825 "workflow_dispatch_not_ready",
8826 "Workflow dispatch lacks an exact active published ref",
8827 true,
8828 ));
8829 }
8830 }
8831
8832 let identity = self.current_resume_identity()?;
8833 let registration = self.preflight_workflow_dispatch_registration()?;
8834 let mut state = self.lock_state()?;
8835 let record = state
8836 .jobs
8837 .get_mut(job_id)
8838 .ok_or_else(|| job_not_found(job_id))?;
8839 if let Some(dispatch) = record.workflow_dispatch.as_ref() {
8840 dispatch.request_for_record(&self.config, record)?;
8841 return Ok(None);
8842 }
8843 if !record.publication_intent
8844 || record.publication_uncertain
8845 || record.dispatch_commit.is_none()
8846 || record.state.is_build_terminal()
8847 || record.state.is_terminal()
8848 {
8849 return Err(provider_failure(
8850 "workflow_dispatch_not_ready",
8851 "Workflow dispatch lacks an exact active published ref",
8852 true,
8853 ));
8854 }
8855 Self::validate_record_identity(record, &identity)?;
8856 let request = WorkflowDispatchRequest::new(
8857 self.config.repository.clone(),
8858 registration,
8859 record.temporary_ref.clone(),
8860 record.operation_id.clone(),
8861 record.request_sha256.clone(),
8862 record.source_revision.clone(),
8863 record
8864 .dispatch_commit
8865 .clone()
8866 .expect("checked dispatch commit"),
8867 )
8868 .map_err(|_| resume_failure("workflow_dispatch_request_invalid"))?;
8869 record.workflow_dispatch = Some(GithubWorkflowDispatchResumeV1::from_request(&request));
8870 self.checkpoint_record(record)?;
8871 Ok(Some(WorkflowDispatchPostAuthority {
8872 job_id: job_id.to_owned(),
8873 request,
8874 }))
8875 }
8876
8877 fn dispatch_workflow_once(
8878 &self,
8879 authority: WorkflowDispatchPostAuthority,
8880 client: &mut (dyn GithubWorkflowDispatchHttpClient + Send),
8881 ) -> RemoteBuildResult<()>
8882 where
8883 R: GhRunner,
8884 {
8885 let WorkflowDispatchPostAuthority { job_id, request } = authority;
8886 {
8887 let state = self.lock_state()?;
8888 let record = state
8889 .jobs
8890 .get(&job_id)
8891 .ok_or_else(|| job_not_found(&job_id))?;
8892 let dispatch = record
8893 .workflow_dispatch
8894 .as_ref()
8895 .ok_or_else(|| resume_failure("workflow_dispatch_intent_lost"))?;
8896 let current = dispatch.request_for_record(&self.config, record)?;
8897 if current != request || dispatch.receipt.is_some() || record.run.is_some() {
8898 return Err(provider_failure(
8899 "workflow_dispatch_post_forbidden",
8900 "Workflow dispatch no longer permits a first POST",
8901 false,
8902 ));
8903 }
8904 }
8905 let attempt = {
8906 let transport = self.lock_transport()?;
8907 transport.workflow_dispatch_attempt(&request)
8908 };
8909 let receipt = attempt.send(client).map_err(transport_failure)?;
8910 let mut state = self.lock_state()?;
8911 let record = state
8912 .jobs
8913 .get_mut(&job_id)
8914 .ok_or_else(|| job_not_found(&job_id))?;
8915 let dispatch = record
8916 .workflow_dispatch
8917 .as_ref()
8918 .ok_or_else(|| resume_failure("workflow_dispatch_intent_lost"))?;
8919 if dispatch.request_for_record(&self.config, record)? != request {
8920 return Err(resume_failure("workflow_dispatch_intent_changed"));
8921 }
8922 if let Some(existing) = dispatch.restored_receipt(&self.config, record)? {
8923 if existing != receipt {
8924 return Err(resume_failure("workflow_dispatch_receipt_changed"));
8925 }
8926 } else {
8927 record
8928 .workflow_dispatch
8929 .as_mut()
8930 .expect("checked workflow dispatch")
8931 .bind_receipt(&receipt)?;
8932 }
8933 self.checkpoint_record(record)
8934 }
8935
8936 fn workflow_dispatch_receipt_for_reconciliation(
8937 &self,
8938 job_id: &str,
8939 request: &WorkflowDispatchRequest,
8940 existing_receipt: Option<WorkflowDispatchReceipt>,
8941 ) -> RemoteBuildResult<WorkflowDispatchReceipt>
8942 where
8943 R: GhRunner,
8944 {
8945 if let Some(receipt) = existing_receipt {
8946 return Ok(receipt);
8947 }
8948 let handle = self
8949 .lock_transport()?
8950 .find_workflow_dispatch_run(request)
8951 .map_err(workflow_dispatch_reconciliation_failure)?;
8952 if handle.workflow_id() != request.workflow().id() {
8953 return Err(resume_failure("workflow_dispatch_workflow_id_mismatch"));
8954 }
8955 let receipt = WorkflowDispatchReceipt::restore(
8956 self.config.repository.clone(),
8957 handle.id(),
8958 handle.workflow_id(),
8959 handle.workflow_path().to_owned(),
8960 handle.head_sha().clone(),
8961 handle.branch().clone(),
8962 request.run_name().to_owned(),
8963 )
8964 .map_err(|_| resume_failure("workflow_dispatch_receipt_invalid"))?;
8965 let mut state = self.lock_state()?;
8966 let record = state
8967 .jobs
8968 .get_mut(job_id)
8969 .ok_or_else(|| job_not_found(job_id))?;
8970 let receipt_was_missing = record
8971 .workflow_dispatch
8972 .as_ref()
8973 .ok_or_else(|| resume_failure("workflow_dispatch_intent_lost"))?
8974 .receipt
8975 .is_none();
8976 if receipt_was_missing {
8977 record
8978 .workflow_dispatch
8979 .as_mut()
8980 .expect("checked workflow dispatch")
8981 .bind_receipt(&receipt)?;
8982 self.checkpoint_record(record)?;
8983 } else if record
8984 .workflow_dispatch
8985 .as_ref()
8986 .expect("checked workflow dispatch")
8987 .restored_receipt(&self.config, record)?
8988 .as_ref()
8989 != Some(&receipt)
8990 {
8991 return Err(resume_failure("workflow_dispatch_receipt_changed"));
8992 }
8993 Ok(receipt)
8994 }
8995
8996 fn reconcile_workflow_dispatch_run(
8997 &self,
8998 job_id: &str,
8999 cancellation: &CancellationToken,
9000 ) -> RemoteBuildResult<()>
9001 where
9002 R: GhRunner,
9003 {
9004 cancellation.check()?;
9005 let (request, existing_receipt, existing_run) = {
9006 let state = self.lock_state()?;
9007 let record = state
9008 .jobs
9009 .get(job_id)
9010 .ok_or_else(|| job_not_found(job_id))?;
9011 let dispatch = record
9012 .workflow_dispatch
9013 .as_ref()
9014 .ok_or_else(|| resume_failure("workflow_dispatch_intent_missing"))?;
9015 (
9016 dispatch.request_for_record(&self.config, record)?,
9017 dispatch.restored_receipt(&self.config, record)?,
9018 record.run.clone(),
9019 )
9020 };
9021 if existing_run.is_some() {
9022 return Ok(());
9023 }
9024
9025 let receipt =
9026 self.workflow_dispatch_receipt_for_reconciliation(job_id, &request, existing_receipt)?;
9027
9028 let snapshot = self
9029 .lock_transport()?
9030 .run_by_id(&receipt)
9031 .map_err(workflow_dispatch_reconciliation_failure)?;
9032 if snapshot.run_attempt() != 1 {
9033 return Err(provider_failure(
9034 "run_initial_attempt_mismatch",
9035 "Initial GitHub run mapping observed an external rerun attempt",
9036 false,
9037 ));
9038 }
9039 let identity = self.current_resume_identity()?;
9040 let mut state = self.lock_state()?;
9041 let record = state
9042 .jobs
9043 .get_mut(job_id)
9044 .ok_or_else(|| job_not_found(job_id))?;
9045 let durable_receipt = record
9046 .workflow_dispatch
9047 .as_ref()
9048 .ok_or_else(|| resume_failure("workflow_dispatch_intent_lost"))?
9049 .restored_receipt(&self.config, record)?
9050 .ok_or_else(|| resume_failure("workflow_dispatch_receipt_lost"))?;
9051 if durable_receipt != receipt {
9052 return Err(resume_failure("workflow_dispatch_receipt_changed"));
9053 }
9054 Self::validate_record_identity(record, &identity)?;
9055 record.run = Some(snapshot.handle().clone());
9056 record.run_snapshot = Some(snapshot);
9057 if record.state == JobState::Created {
9058 transition_record(record, JobState::Queued)?;
9059 append_event(
9060 record,
9061 self.clock.now_ms(),
9062 "queue",
9063 RemoteBuildEventKind::JobQueued { position: None },
9064 )?;
9065 }
9066 append_event(
9067 record,
9068 self.clock.now_ms(),
9069 "queue",
9070 RemoteBuildEventKind::Progress {
9071 message: "Exact workflow-dispatch run mapped to this job".to_owned(),
9072 current: None,
9073 total: None,
9074 },
9075 )?;
9076 self.checkpoint_record(record)
9077 }
9078
9079 fn start_or_reconcile_workflow_dispatch(
9080 &self,
9081 job_id: &str,
9082 cancellation: &CancellationToken,
9083 ) -> RemoteBuildResult<()>
9084 where
9085 R: GhRunner,
9086 {
9087 let start = self.start_or_reconcile_workflow_dispatch_inner(job_id, cancellation);
9088 self.checkpoint_workflow_dispatch_cancellation(job_id, cancellation)?;
9089 start
9090 }
9091
9092 fn start_or_reconcile_workflow_dispatch_inner(
9093 &self,
9094 job_id: &str,
9095 cancellation: &CancellationToken,
9096 ) -> RemoteBuildResult<()>
9097 where
9098 R: GhRunner,
9099 {
9100 let intent_exists = {
9101 let state = self.lock_state()?;
9102 state
9103 .jobs
9104 .get(job_id)
9105 .ok_or_else(|| job_not_found(job_id))?
9106 .workflow_dispatch
9107 .is_some()
9108 };
9109 if intent_exists {
9110 return self
9111 .reconcile_workflow_dispatch_with_cancellation_checkpoint(job_id, cancellation);
9112 }
9113 let mut client = self.prepare_workflow_dispatch_client()?;
9114 self.start_workflow_dispatch_with_client(job_id, &mut *client, cancellation)
9115 }
9116
9117 fn reconcile_workflow_dispatch_with_cancellation_checkpoint(
9118 &self,
9119 job_id: &str,
9120 cancellation: &CancellationToken,
9121 ) -> RemoteBuildResult<()>
9122 where
9123 R: GhRunner,
9124 {
9125 let reconciliation = self.reconcile_workflow_dispatch_run(job_id, cancellation);
9126 self.checkpoint_workflow_dispatch_cancellation(job_id, cancellation)?;
9127 reconciliation
9128 }
9129
9130 fn checkpoint_workflow_dispatch_cancellation(
9131 &self,
9132 job_id: &str,
9133 cancellation: &CancellationToken,
9134 ) -> RemoteBuildResult<bool> {
9135 let mut state = self.lock_state()?;
9136 let record = state
9137 .jobs
9138 .get_mut(job_id)
9139 .ok_or_else(|| job_not_found(job_id))?;
9140 if (record.publication_intent || record.git_snapshot.is_none())
9141 && !record.state.is_build_terminal()
9142 && !record.state.is_terminal()
9143 && !record.cancellation_requested
9144 && cancellation.is_cancelled()
9145 {
9146 record.cancellation_requested = true;
9147 if record.state != JobState::Cancelling {
9148 transition_record(record, JobState::Cancelling)?;
9149 }
9150 self.checkpoint_record(record)?;
9151 }
9152 Ok(record.cancellation_requested)
9153 }
9154
9155 fn start_workflow_dispatch_with_client(
9156 &self,
9157 job_id: &str,
9158 client: &mut (dyn GithubWorkflowDispatchHttpClient + Send),
9159 cancellation: &CancellationToken,
9160 ) -> RemoteBuildResult<()>
9161 where
9162 R: GhRunner,
9163 {
9164 let start = self.start_workflow_dispatch_with_client_inner(job_id, client, cancellation);
9165 self.checkpoint_workflow_dispatch_cancellation(job_id, cancellation)?;
9166 start
9167 }
9168
9169 fn start_workflow_dispatch_with_client_inner(
9170 &self,
9171 job_id: &str,
9172 client: &mut (dyn GithubWorkflowDispatchHttpClient + Send),
9173 cancellation: &CancellationToken,
9174 ) -> RemoteBuildResult<()>
9175 where
9176 R: GhRunner,
9177 {
9178 if let Some(authority) = self.ensure_workflow_dispatch_intent(job_id)? {
9179 let dispatch_result = self.dispatch_workflow_once(authority, client);
9180 let cancellation_result =
9181 self.checkpoint_workflow_dispatch_cancellation(job_id, cancellation);
9182 cancellation_result?;
9183 dispatch_result?;
9184 let mapping_cancellation = CancellationToken::new();
9185 let mapping_result =
9186 self.reconcile_workflow_dispatch_run(job_id, &mapping_cancellation);
9187 let cancellation_requested =
9188 self.checkpoint_workflow_dispatch_cancellation(job_id, cancellation)?;
9189 mapping_result?;
9190 if cancellation_requested {
9191 self.sync_job_with_cancel_policy(job_id, &mapping_cancellation, true)?;
9192 }
9193 return Ok(());
9194 }
9195 self.reconcile_workflow_dispatch_with_cancellation_checkpoint(job_id, cancellation)
9196 }
9197
9198 fn acquire_restored_publication_lease(&self, record: &mut JobRecord) -> RemoteBuildResult<()>
9199 where
9200 P: TemporaryRefPublisher,
9201 {
9202 if record.publication_takeover_lease_owned {
9203 return Ok(());
9204 }
9205 let expected_scope = record
9206 .publication_lease_scope_sha256
9207 .as_deref()
9208 .ok_or_else(|| {
9209 provider_failure(
9210 "publication_lease_unavailable",
9211 "Durable publication has no cross-process ownership lease",
9212 true,
9213 )
9214 })?;
9215 let mut publisher = self.lock_publisher()?;
9216 if publisher.publication_lease_scope_sha256() != Some(expected_scope) {
9217 return Err(provider_failure(
9218 "publication_lease_scope_changed",
9219 "Temporary-ref publication lease scope changed",
9220 true,
9221 ));
9222 }
9223 let lease = publisher
9224 .acquire_publication_lease(&TemporaryRefPublicationLeaseRequest {
9225 repository: &self.config.repository,
9226 operation_id: &record.operation_id,
9227 temporary_ref: &record.temporary_ref,
9228 })
9229 .map_err(publisher_failure)?;
9230 match lease {
9231 TemporaryRefPublicationLease::Acquired => {
9232 record.publication_takeover_lease_owned = true;
9233 Ok(())
9234 }
9235 TemporaryRefPublicationLease::AlreadyOwned
9236 | TemporaryRefPublicationLease::HeldByOther => Err(provider_failure(
9237 "publication_owner_active",
9238 "Another live provider owns temporary-ref publication",
9239 true,
9240 )),
9241 TemporaryRefPublicationLease::Unsupported => Err(provider_failure(
9242 "publication_lease_unavailable",
9243 "Temporary-ref publisher cannot provide cross-process ownership",
9244 true,
9245 )),
9246 }
9247 }
9248
9249 fn acquire_new_snapshot_dispatch_lease(
9250 &self,
9251 record: &mut JobRecord,
9252 ) -> RemoteBuildResult<(bool, String)> {
9253 let mut publisher = self.lock_publisher()?;
9254 let scope = publisher
9255 .publication_lease_scope_sha256()
9256 .filter(|scope| is_lower_sha256(scope))
9257 .ok_or_else(|| {
9258 provider_failure(
9259 "publication_lease_unavailable",
9260 "Snapshot dispatch publication has no cross-process ownership scope",
9261 false,
9262 )
9263 })?
9264 .to_owned();
9265 if !publisher.publication_attempt_is_process_fenced() {
9266 return Err(provider_failure(
9267 "publication_process_unfenced",
9268 "Snapshot dispatch publication process cannot be fenced across restart",
9269 false,
9270 ));
9271 }
9272 let request = TemporaryRefPublicationLeaseRequest {
9273 repository: &self.config.repository,
9274 operation_id: &record.operation_id,
9275 temporary_ref: &record.temporary_ref,
9276 };
9277 match publisher
9278 .acquire_publication_lease(&request)
9279 .map_err(publisher_failure)?
9280 {
9281 TemporaryRefPublicationLease::Acquired => {
9282 record.publication_takeover_lease_owned = true;
9283 Ok((true, scope))
9284 }
9285 TemporaryRefPublicationLease::AlreadyOwned
9286 if record.publication_takeover_lease_owned =>
9287 {
9288 Ok((true, scope))
9289 }
9290 TemporaryRefPublicationLease::AlreadyOwned
9291 | TemporaryRefPublicationLease::HeldByOther => Err(provider_failure(
9292 "publication_owner_active",
9293 "Another live provider owns snapshot dispatch publication",
9294 true,
9295 )),
9296 TemporaryRefPublicationLease::Unsupported => Err(provider_failure(
9297 "publication_lease_unavailable",
9298 "Snapshot dispatch publication requires a cross-process lease",
9299 false,
9300 )),
9301 }
9302 }
9303
9304 fn release_record_publication_lease(&self, record: &mut JobRecord) -> RemoteBuildResult<()>
9305 where
9306 P: TemporaryRefPublisher,
9307 {
9308 if !record.publication_takeover_lease_owned {
9309 return Ok(());
9310 }
9311 self.lock_publisher()?
9312 .release_publication_lease(&TemporaryRefPublicationLeaseRequest {
9313 repository: &self.config.repository,
9314 operation_id: &record.operation_id,
9315 temporary_ref: &record.temporary_ref,
9316 });
9317 record.publication_takeover_lease_owned = false;
9318 Ok(())
9319 }
9320
9321 fn revalidate_restored_run(&self, record: &mut JobRecord) -> RemoteBuildResult<()>
9322 where
9323 R: GhRunner,
9324 {
9325 self.revalidate_restored_run_with_policy(record, false)
9326 }
9327
9328 fn revalidate_restored_run_with_policy(
9329 &self,
9330 record: &mut JobRecord,
9331 include_terminal: bool,
9332 ) -> RemoteBuildResult<()>
9333 where
9334 R: GhRunner,
9335 {
9336 if record.state.is_terminal() && !include_terminal {
9337 return Ok(());
9338 }
9339 let Some(expected) = record.run.clone() else {
9340 return Ok(());
9341 };
9342 let snapshot = self
9343 .lock_transport()?
9344 .run(&self.config.repository, &expected)
9345 .map_err(transport_failure)?;
9346 if record
9347 .run_snapshot
9348 .as_ref()
9349 .is_some_and(|previous| github_run_is_later_attempt(previous, &snapshot))
9350 && record.signed_cleanup_evidence.is_some()
9351 {
9352 return Ok(());
9355 }
9356 ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot)?;
9357 if (record.state == JobState::Succeeded || !record.manifests.is_empty())
9358 && !run_snapshot_is_successful(&snapshot)
9359 {
9360 return Err(resume_failure("resume_live_success_state_mismatch"));
9361 }
9362 record.run_snapshot = Some(snapshot);
9363 Ok(())
9364 }
9365
9366 fn records_from_offline_resumes(
9367 &self,
9368 resumes: Vec<GithubJobResumeV1>,
9369 expected: &GithubDurableIdentityV1,
9370 ) -> RemoteBuildResult<BTreeMap<String, JobRecord>> {
9371 if resumes.len() > self.config.max_jobs {
9372 return Err(resume_failure("resume_job_capacity_exceeded"));
9373 }
9374 if expected.provider != GITHUB_PROVIDER_ID
9375 || expected.provider_config_sha256 != provider_config_sha256(&self.config)?
9376 || !github_remote_matches(
9377 &expected.execution_repository,
9378 &repository_url(&self.config.repository),
9379 )
9380 || expected.execution_repository_id == 0
9381 {
9382 return Err(resume_failure("resume_provider_identity_mismatch"));
9383 }
9384 let mut restored = BTreeMap::new();
9385 for resume in resumes {
9386 let record = resume.into_record(
9387 &self.config,
9388 &expected.principal,
9389 expected.execution_repository_id,
9390 )?;
9391 if restored.insert(record.job_id.clone(), record).is_some() {
9392 return Err(resume_failure("resume_duplicate_job_id"));
9393 }
9394 }
9395 Ok(restored)
9396 }
9397
9398 fn insert_restored_records(
9399 &self,
9400 restored: BTreeMap<String, JobRecord>,
9401 ) -> RemoteBuildResult<()> {
9402 let mut state = self.lock_state()?;
9403 if !state.reservations.is_empty()
9404 || state.jobs.len().saturating_add(restored.len()) > self.config.max_jobs
9405 || restored
9406 .keys()
9407 .any(|job_id| state.jobs.contains_key(job_id))
9408 {
9409 return Err(resume_failure("resume_state_conflict"));
9410 }
9411 state.jobs.extend(restored);
9412 Ok(())
9413 }
9414
9415 pub fn restore_job_resumes_offline(
9426 &self,
9427 resumes: Vec<GithubJobResumeV1>,
9428 expected: &GithubDurableIdentityV1,
9429 ) -> RemoteBuildResult<()> {
9430 let restored = self.records_from_offline_resumes(resumes, expected)?;
9431 self.insert_restored_records(restored)
9432 }
9433
9434 pub fn revalidate_restored_job_live(
9443 &self,
9444 job_id: &str,
9445 cancellation: &CancellationToken,
9446 ) -> RemoteBuildResult<()>
9447 where
9448 R: GhRunner,
9449 {
9450 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
9451 let live_validation = (|| {
9452 cancellation.check()?;
9453 if self
9454 .observe_workflow_dispatch_run(job_id, true, cancellation)?
9455 .is_none()
9456 {
9457 let identity = self.current_resume_identity()?;
9458 cancellation.check()?;
9459 let mut state = self.lock_state()?;
9460 let record = state
9461 .jobs
9462 .get_mut(job_id)
9463 .ok_or_else(|| job_not_found(job_id))?;
9464 Self::validate_record_identity(record, &identity)?;
9465 self.checkpoint_record(record)?;
9466 }
9467 Ok(())
9468 })();
9469 self.checkpoint_workflow_dispatch_cancellation(job_id, cancellation)?;
9470 live_validation?;
9471 return cancellation.check();
9472 }
9473 cancellation.check()?;
9474 let mut state = self.lock_state()?;
9475 let record = state
9476 .jobs
9477 .get_mut(job_id)
9478 .ok_or_else(|| job_not_found(job_id))?;
9479 self.revalidate_record_identity(record)?;
9480 self.revalidate_restored_run_with_policy(record, true)?;
9481 self.checkpoint_record(record)?;
9482 cancellation.check()
9483 }
9484
9485 pub fn restored_terminal_job_log_identity(
9494 &self,
9495 job_id: &str,
9496 ) -> RemoteBuildResult<Option<GithubDurableRunAttemptLogIdentity>> {
9497 let state = self.lock_state()?;
9498 let record = state
9499 .jobs
9500 .get(job_id)
9501 .ok_or_else(|| job_not_found(job_id))?;
9502 let Some(snapshot) = record.run_snapshot.as_ref() else {
9503 return Ok(None);
9504 };
9505 if !snapshot.status().is_terminal() || snapshot.conclusion().is_none() {
9506 return Ok(None);
9507 }
9508 GithubDurableRunAttemptLogIdentity::new(
9509 self.config.repository.clone(),
9510 snapshot.handle().id(),
9511 snapshot.run_attempt(),
9512 snapshot.handle().head_sha().clone(),
9513 snapshot.handle().workflow_id(),
9514 )
9515 .map(Some)
9516 .map_err(|_| job_log_identity_failure())
9517 }
9518
9519 pub fn export_job_resumes(&self) -> RemoteBuildResult<Vec<GithubJobResumeV1>>
9528 where
9529 R: GhRunner,
9530 {
9531 let records = {
9532 let state = self.lock_state()?;
9533 state.jobs.values().cloned().collect::<Vec<_>>()
9534 };
9535 if records
9536 .iter()
9537 .any(|record| !record.publication_intent && record.git_snapshot.is_none())
9538 {
9539 return Err(resume_failure("resume_submission_not_checkpointable"));
9540 }
9541 if records
9542 .iter()
9543 .any(|record| record.principal.is_none() || record.execution_repository_id.is_none())
9544 {
9545 return Err(resume_failure("resume_creation_identity_unbound"));
9546 }
9547 let (principal, repository_id) = self.current_resume_identity()?;
9548 records
9549 .iter()
9550 .map(|record| {
9551 if record.principal.as_ref() != Some(&principal)
9552 || record.execution_repository_id != Some(repository_id)
9553 {
9554 return Err(resume_failure("resume_live_identity_changed"));
9555 }
9556 GithubJobResumeV1::from_record(
9557 &self.config,
9558 principal.clone(),
9559 repository_id,
9560 record,
9561 )
9562 })
9563 .collect()
9564 }
9565
9566 pub fn restore_job_resumes(&self, resumes: Vec<GithubJobResumeV1>) -> RemoteBuildResult<()>
9575 where
9576 R: GhRunner,
9577 {
9578 let (principal, repository_id) = self.current_resume_identity()?;
9579 let expected = GithubDurableIdentityV1 {
9580 provider: GITHUB_PROVIDER_ID.to_owned(),
9581 provider_config_sha256: provider_config_sha256(&self.config)?,
9582 principal,
9583 execution_repository: repository_url(&self.config.repository),
9584 execution_repository_id: repository_id,
9585 };
9586 let mut restored = self.records_from_offline_resumes(resumes, &expected)?;
9587 for record in restored.values_mut() {
9588 self.revalidate_restored_run(record)?;
9589 }
9590 self.insert_restored_records(restored)
9591 }
9592
9593 pub fn reconcile_restored_job(
9600 &self,
9601 job_id: &str,
9602 cancellation: &CancellationToken,
9603 ) -> RemoteBuildResult<GithubJobReconciliation>
9604 where
9605 R: GhRunner,
9606 P: TemporaryRefPublisher + TemporaryRefReconciler,
9607 {
9608 self.reconcile_restored_job_with_cancel_policy(job_id, cancellation, true)
9609 }
9610
9611 pub fn reconcile_restored_job_get_only(
9621 &self,
9622 job_id: &str,
9623 cancellation: &CancellationToken,
9624 ) -> RemoteBuildResult<GithubJobReconciliation>
9625 where
9626 R: GhRunner,
9627 P: TemporaryRefPublisher + TemporaryRefReconciler,
9628 {
9629 self.reconcile_restored_job_with_cancel_policy(job_id, cancellation, false)
9630 }
9631
9632 #[allow(
9633 clippy::too_many_lines,
9634 reason = "the dispatch restart boundary keeps external reconciliation and exact state rebinding adjacent"
9635 )]
9636 fn reconcile_restored_workflow_dispatch_publication(
9637 &self,
9638 job_id: &str,
9639 cancellation: &CancellationToken,
9640 allow_cancel_dispatch: bool,
9641 ) -> RemoteBuildResult<GithubJobReconciliation>
9642 where
9643 R: GhRunner,
9644 P: TemporaryRefPublisher + TemporaryRefReconciler,
9645 {
9646 cancellation.check()?;
9647 let mut working = {
9648 let state = self.lock_state()?;
9649 state
9650 .jobs
9651 .get(job_id)
9652 .cloned()
9653 .ok_or_else(|| job_not_found(job_id))?
9654 };
9655 if working.dispatch_commit.is_some() && !working.publication_uncertain {
9656 {
9657 let mut state = self.lock_state()?;
9658 let record = state
9659 .jobs
9660 .get_mut(job_id)
9661 .ok_or_else(|| job_not_found(job_id))?;
9662 self.checkpoint_record(record)?;
9663 }
9664 if working.workflow_dispatch.is_some() {
9665 self.reconcile_workflow_dispatch_with_cancellation_checkpoint(
9666 job_id,
9667 cancellation,
9668 )?;
9669 } else if allow_cancel_dispatch {
9670 self.start_or_reconcile_workflow_dispatch(job_id, cancellation)?;
9671 }
9672 self.sync_job_with_cancel_policy(job_id, cancellation, allow_cancel_dispatch)?;
9673 return Ok(GithubJobReconciliation::AlreadyMapped);
9674 }
9675 if working.publication_absent {
9676 let mut state = self.lock_state()?;
9677 let record = state
9678 .jobs
9679 .get_mut(job_id)
9680 .ok_or_else(|| job_not_found(job_id))?;
9681 self.checkpoint_record(record)?;
9682 return Ok(GithubJobReconciliation::Missing);
9683 }
9684 if working.publication_not_attempted || !working.publication_intent {
9685 let mut state = self.lock_state()?;
9686 let record = state
9687 .jobs
9688 .get_mut(job_id)
9689 .ok_or_else(|| job_not_found(job_id))?;
9690 self.checkpoint_record(record)?;
9691 return Ok(GithubJobReconciliation::NotStarted);
9692 }
9693 self.acquire_restored_publication_lease(&mut working)?;
9694 if publication_record_boundary_is_unarmed(&working) {
9695 let apply_result = (|| {
9696 let mut state = self.lock_state()?;
9697 let record = state
9698 .jobs
9699 .get_mut(job_id)
9700 .ok_or_else(|| job_not_found(job_id))?;
9701 if record.operation_id != working.operation_id
9702 || record.request_sha256 != working.request_sha256
9703 || record.temporary_ref != working.temporary_ref
9704 || record.prepared_dispatch_commit != working.prepared_dispatch_commit
9705 || !publication_record_boundary_is_unarmed(record)
9706 {
9707 return Err(resume_failure(
9708 "workflow_dispatch_publication_identity_changed",
9709 ));
9710 }
9711 mark_publication_not_attempted(
9712 record,
9713 self.clock.now_ms(),
9714 "publication_not_armed",
9715 "Prepared publication never reached its durable push boundary",
9716 false,
9717 )?;
9718 self.checkpoint_record(record)
9719 })();
9720 let release_source = self.release_snapshot_source_lease(&mut working);
9721 let release_publication = self.release_record_publication_lease(&mut working);
9722 apply_result?;
9723 release_source?;
9724 release_publication?;
9725 return Ok(GithubJobReconciliation::NotStarted);
9726 }
9727 let external_result = (|| {
9728 if let Some(snapshot) = working.git_snapshot.clone() {
9729 if snapshot.phase != GithubGitSnapshotPhaseV1::SourceExact {
9730 return Err(resume_failure("resume_git_snapshot_source_not_exact"));
9731 }
9732 self.acquire_snapshot_source_lease(&mut working)?;
9733 self.revalidate_public_snapshot_source(&working)?;
9734 let source = self
9735 .lock_publisher()?
9736 .reconcile_git_snapshot_source(&GitSnapshotSourcePublishRequest {
9737 source_repository: &self.config.source_repository,
9738 source_ref: &snapshot.stage.source_ref,
9739 commit: &snapshot.stage.graph.commit,
9740 authorized: true,
9741 })
9742 .map_err(publisher_failure)?;
9743 if source != GitSnapshotRefReconciliation::Exact {
9744 return Err(provider_failure(
9745 "snapshot_source_ownership_lost",
9746 "Git snapshot source ref changed before dispatch reconciliation",
9747 false,
9748 ));
9749 }
9750 } else {
9751 self.revalidate_record_identity(&working)?;
9752 }
9753 let workflow = generate_workflow(&self.config.workflow);
9754 let manifest = GithubDispatchManifest::new(
9755 &self.config,
9756 &working.temporary_ref,
9757 working.request.clone(),
9758 );
9759 manifest.validate_for(&self.config)?;
9760 let manifest_bytes = manifest.encode()?;
9761 let expected_prepared_commit =
9762 working.prepared_dispatch_commit.as_ref().ok_or_else(|| {
9763 provider_failure(
9764 "publication_reconciliation_unavailable",
9765 "Durable publication does not contain an exact prepared commit",
9766 true,
9767 )
9768 })?;
9769 self.lock_publisher()?
9770 .reconcile_temporary_ref(&TemporaryRefReconcileRequest {
9771 repository: &self.config.repository,
9772 source_repository: &self.config.source_repository,
9773 trusted_source_ref: self.config.workflow.trusted_source_ref(),
9774 source_revision: &working.source_revision,
9775 snapshot_source_ref: working
9776 .git_snapshot
9777 .as_ref()
9778 .map(|snapshot| &snapshot.stage.source_ref),
9779 temporary_ref: &working.temporary_ref,
9780 workflow_path: workflow.path(),
9781 workflow_bytes: workflow.yaml().as_bytes(),
9782 workflow_fingerprint: &self.config.workflow_fingerprint,
9783 manifest_bytes: &manifest_bytes,
9784 operation_id: &working.operation_id,
9785 created_at_ms: working.created_at_ms,
9786 expected_prepared_commit,
9787 })
9788 .map_err(publisher_failure)
9789 })();
9790 let reconciliation = match external_result {
9791 Ok(reconciliation) => reconciliation,
9792 Err(error) => {
9793 let _ = self.release_snapshot_source_lease(&mut working);
9794 let _ = self.release_record_publication_lease(&mut working);
9795 return Err(error);
9796 }
9797 };
9798 let expected_prepared_commit = working
9799 .prepared_dispatch_commit
9800 .clone()
9801 .ok_or_else(|| resume_failure("resume_prepared_dispatch_commit_missing"))?;
9802 let recovered = matches!(
9803 &reconciliation,
9804 TemporaryRefReconciliation::Exact(published)
9805 if published.temporary_ref() == &working.temporary_ref
9806 && published.commit() == &expected_prepared_commit
9807 );
9808 let outcome = if recovered {
9809 GithubJobReconciliation::Recovered
9810 } else if matches!(reconciliation, TemporaryRefReconciliation::Missing) {
9811 GithubJobReconciliation::Missing
9812 } else {
9813 GithubJobReconciliation::Conflict
9814 };
9815 let apply_result = (|| {
9816 let mut state = self.lock_state()?;
9817 let record = state
9818 .jobs
9819 .get_mut(job_id)
9820 .ok_or_else(|| job_not_found(job_id))?;
9821 if record.principal != working.principal
9822 || record.execution_repository_id != working.execution_repository_id
9823 || record.operation_id != working.operation_id
9824 || record.source_revision != working.source_revision
9825 || record.git_snapshot != working.git_snapshot
9826 || record.request_sha256 != working.request_sha256
9827 || record.temporary_ref != working.temporary_ref
9828 || record.prepared_dispatch_commit != working.prepared_dispatch_commit
9829 || record.dispatch_commit != working.dispatch_commit
9830 || record.publication_intent != working.publication_intent
9831 || record.publication_uncertain != working.publication_uncertain
9832 || record.workflow_dispatch != working.workflow_dispatch
9833 {
9834 return Err(resume_failure(
9835 "workflow_dispatch_publication_identity_changed",
9836 ));
9837 }
9838 if recovered {
9839 record.dispatch_commit = Some(expected_prepared_commit);
9840 record.publication_uncertain = false;
9841 record.publication_absent = false;
9842 } else {
9843 record.publication_uncertain = true;
9844 record.publication_absent = false;
9845 record.publication_absence_observations = record
9846 .publication_absence_observations
9847 .saturating_add(1)
9848 .min(2);
9849 if record.publication_absence_first_observed_at_ms == 0 {
9850 record.publication_absence_first_observed_at_ms = self.clock.now_ms();
9851 record.publication_quiescence_deadline_ms = publication_quiescence_deadline(
9852 record.publication_started_at_ms,
9853 record.publication_absence_first_observed_at_ms,
9854 );
9855 }
9856 }
9857 self.checkpoint_record(record)
9858 })();
9859 let release_source = self.release_snapshot_source_lease(&mut working);
9860 let release_publication = self.release_record_publication_lease(&mut working);
9861 apply_result?;
9862 release_source?;
9863 release_publication?;
9864 if recovered {
9865 let has_dispatch_intent = {
9866 let state = self.lock_state()?;
9867 state
9868 .jobs
9869 .get(job_id)
9870 .ok_or_else(|| job_not_found(job_id))?
9871 .workflow_dispatch
9872 .is_some()
9873 };
9874 if has_dispatch_intent {
9875 self.reconcile_workflow_dispatch_with_cancellation_checkpoint(
9876 job_id,
9877 cancellation,
9878 )?;
9879 } else if allow_cancel_dispatch {
9880 self.start_or_reconcile_workflow_dispatch(job_id, cancellation)?;
9881 } else {
9882 return Err(provider_failure(
9883 "workflow_dispatch_mutation_required",
9884 "Exact publication was recovered, but starting its workflow requires mutation authorization",
9885 true,
9886 ));
9887 }
9888 self.sync_job_with_cancel_policy(job_id, cancellation, allow_cancel_dispatch)?;
9889 }
9890 Ok(outcome)
9891 }
9892
9893 fn reconcile_restored_job_with_cancel_policy(
9894 &self,
9895 job_id: &str,
9896 cancellation: &CancellationToken,
9897 allow_cancel_dispatch: bool,
9898 ) -> RemoteBuildResult<GithubJobReconciliation>
9899 where
9900 R: GhRunner,
9901 P: TemporaryRefPublisher + TemporaryRefReconciler,
9902 {
9903 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
9904 let reconciliation = self.reconcile_restored_job_with_cancel_policy_inner(
9905 job_id,
9906 cancellation,
9907 allow_cancel_dispatch,
9908 );
9909 self.checkpoint_workflow_dispatch_cancellation(job_id, cancellation)?;
9910 return reconciliation;
9911 }
9912 self.reconcile_restored_job_with_cancel_policy_inner(
9913 job_id,
9914 cancellation,
9915 allow_cancel_dispatch,
9916 )
9917 }
9918
9919 #[allow(clippy::too_many_lines)]
9920 fn reconcile_restored_job_with_cancel_policy_inner(
9921 &self,
9922 job_id: &str,
9923 cancellation: &CancellationToken,
9924 allow_cancel_dispatch: bool,
9925 ) -> RemoteBuildResult<GithubJobReconciliation>
9926 where
9927 R: GhRunner,
9928 P: TemporaryRefPublisher + TemporaryRefReconciler,
9929 {
9930 cancellation.check()?;
9931 let snapshot_pending_dispatch = {
9932 let state = self.lock_state()?;
9933 let record = state
9934 .jobs
9935 .get(job_id)
9936 .ok_or_else(|| job_not_found(job_id))?;
9937 record.git_snapshot.is_some() && !record.publication_intent
9938 };
9939 if snapshot_pending_dispatch {
9940 self.continue_git_snapshot_submission(job_id, cancellation)?;
9941 let state = self.lock_state()?;
9942 let record = state
9943 .jobs
9944 .get(job_id)
9945 .ok_or_else(|| job_not_found(job_id))?;
9946 if !record.publication_intent {
9947 return Ok(
9948 match record.git_snapshot.as_ref().map(|snapshot| snapshot.phase) {
9949 Some(GithubGitSnapshotPhaseV1::SourceConflict) => {
9950 GithubJobReconciliation::Conflict
9951 }
9952 Some(GithubGitSnapshotPhaseV1::SourceAbsent) => {
9953 GithubJobReconciliation::Missing
9954 }
9955 _ => GithubJobReconciliation::NotStarted,
9956 },
9957 );
9958 }
9959 }
9960 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
9961 return self.reconcile_restored_workflow_dispatch_publication(
9962 job_id,
9963 cancellation,
9964 allow_cancel_dispatch,
9965 );
9966 }
9967 let mut state = self.lock_state()?;
9968 let record = state
9969 .jobs
9970 .get_mut(job_id)
9971 .ok_or_else(|| job_not_found(job_id))?;
9972 if record.dispatch_commit.is_some() && !record.publication_uncertain {
9973 if self.config.workflow.run_trigger() == WorkflowRunTrigger::Push {
9974 normalize_published_record(record, self.clock.now_ms())?;
9975 }
9976 let has_dispatch_intent = record.workflow_dispatch.is_some();
9977 self.checkpoint_record(record)?;
9978 self.release_record_publication_lease(record)?;
9979 self.release_snapshot_source_lease(record)?;
9980 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch
9981 && (has_dispatch_intent || allow_cancel_dispatch)
9982 {
9983 drop(state);
9984 if has_dispatch_intent {
9985 self.reconcile_workflow_dispatch_with_cancellation_checkpoint(
9986 job_id,
9987 cancellation,
9988 )?;
9989 } else {
9990 self.start_or_reconcile_workflow_dispatch(job_id, cancellation)?;
9991 }
9992 self.sync_job_with_cancel_policy(job_id, cancellation, allow_cancel_dispatch)?;
9993 }
9994 return Ok(GithubJobReconciliation::AlreadyMapped);
9995 }
9996 if record.publication_absent {
9997 self.checkpoint_record(record)?;
9998 self.release_record_publication_lease(record)?;
9999 self.release_snapshot_source_lease(record)?;
10000 return Ok(GithubJobReconciliation::Missing);
10001 }
10002 if record.publication_not_attempted {
10003 self.checkpoint_record(record)?;
10004 self.release_record_publication_lease(record)?;
10005 self.release_snapshot_source_lease(record)?;
10006 return Ok(GithubJobReconciliation::NotStarted);
10007 }
10008 self.acquire_restored_publication_lease(record)?;
10009 if publication_record_boundary_is_unarmed(record) {
10010 mark_publication_not_attempted(
10011 record,
10012 self.clock.now_ms(),
10013 "publication_not_armed",
10014 "Prepared publication never reached its durable push boundary",
10015 false,
10016 )?;
10017 self.checkpoint_record(record)?;
10018 self.release_record_publication_lease(record)?;
10019 self.release_snapshot_source_lease(record)?;
10020 return Ok(GithubJobReconciliation::NotStarted);
10021 }
10022 if !record.publication_intent {
10023 self.release_snapshot_source_lease(record)?;
10024 return Ok(GithubJobReconciliation::NotStarted);
10025 }
10026 let snapshot = record
10027 .git_snapshot
10028 .clone()
10029 .filter(|snapshot| snapshot.phase == GithubGitSnapshotPhaseV1::SourceExact);
10030 if let Some(snapshot) = snapshot {
10031 self.acquire_snapshot_source_lease(record)?;
10032 self.revalidate_public_snapshot_source(record)?;
10033 let source = self
10034 .lock_publisher()?
10035 .reconcile_git_snapshot_source(&GitSnapshotSourcePublishRequest {
10036 source_repository: &self.config.source_repository,
10037 source_ref: &snapshot.stage.source_ref,
10038 commit: &snapshot.stage.graph.commit,
10039 authorized: true,
10040 })
10041 .map_err(publisher_failure)?;
10042 if source != GitSnapshotRefReconciliation::Exact {
10043 self.release_record_publication_lease(record)?;
10044 self.release_snapshot_source_lease(record)?;
10045 return Err(provider_failure(
10046 "snapshot_source_ownership_lost",
10047 "Git snapshot source ref changed before dispatch reconciliation",
10048 false,
10049 ));
10050 }
10051 } else if record.git_snapshot.is_some() {
10052 self.release_record_publication_lease(record)?;
10053 return Err(resume_failure("resume_git_snapshot_source_not_exact"));
10054 }
10055 let workflow = generate_workflow(&self.config.workflow);
10056 let manifest = GithubDispatchManifest::new(
10057 &self.config,
10058 &record.temporary_ref,
10059 record.request.clone(),
10060 );
10061 manifest.validate_for(&self.config)?;
10062 let manifest_bytes = manifest.encode()?;
10063 let expected_prepared_commit =
10064 record.prepared_dispatch_commit.as_ref().ok_or_else(|| {
10065 provider_failure(
10066 "publication_reconciliation_unavailable",
10067 "Durable publication does not contain an exact prepared commit",
10068 true,
10069 )
10070 })?;
10071 self.revalidate_record_identity(record)?;
10072 let reconciliation = self
10073 .lock_publisher()?
10074 .reconcile_temporary_ref(&TemporaryRefReconcileRequest {
10075 repository: &self.config.repository,
10076 source_repository: &self.config.source_repository,
10077 trusted_source_ref: self.config.workflow.trusted_source_ref(),
10078 source_revision: &record.source_revision,
10079 snapshot_source_ref: record
10080 .git_snapshot
10081 .as_ref()
10082 .map(|snapshot| &snapshot.stage.source_ref),
10083 temporary_ref: &record.temporary_ref,
10084 workflow_path: workflow.path(),
10085 workflow_bytes: workflow.yaml().as_bytes(),
10086 workflow_fingerprint: &self.config.workflow_fingerprint,
10087 manifest_bytes: &manifest_bytes,
10088 operation_id: &record.operation_id,
10089 created_at_ms: record.created_at_ms,
10090 expected_prepared_commit,
10091 })
10092 .map_err(publisher_failure)?;
10093 cancellation.check()?;
10094 self.revalidate_record_identity(record)?;
10095 match reconciliation {
10096 TemporaryRefReconciliation::Exact(published)
10097 if published.temporary_ref() == &record.temporary_ref
10098 && record
10099 .prepared_dispatch_commit
10100 .as_ref()
10101 .is_none_or(|prepared| prepared == published.commit()) =>
10102 {
10103 record.dispatch_commit = Some(published.commit().clone());
10104 record.publication_uncertain = false;
10105 record.publication_absent = false;
10106 if record.state == JobState::Created
10107 && self.config.workflow.run_trigger() == WorkflowRunTrigger::Push
10108 {
10109 transition_record(record, JobState::Queued)?;
10110 append_event(
10111 record,
10112 self.clock.now_ms(),
10113 "queue",
10114 RemoteBuildEventKind::JobQueued { position: None },
10115 )?;
10116 }
10117 self.checkpoint_record(record)?;
10118 self.release_record_publication_lease(record)?;
10119 self.release_snapshot_source_lease(record)?;
10120 drop(state);
10121 self.sync_job_with_cancel_policy(job_id, cancellation, allow_cancel_dispatch)?;
10122 Ok(GithubJobReconciliation::Recovered)
10123 }
10124 observation => {
10125 let ref_is_missing = matches!(observation, TemporaryRefReconciliation::Missing);
10126 let outcome = if ref_is_missing {
10127 GithubJobReconciliation::Missing
10128 } else {
10129 GithubJobReconciliation::Conflict
10130 };
10131 record.publication_uncertain = true;
10132 record.publication_absent = false;
10133 let prepared_commit = record.prepared_dispatch_commit.clone().ok_or_else(|| {
10134 provider_failure(
10135 "publication_reconciliation_unavailable",
10136 "Durable publication does not contain an exact prepared commit",
10137 true,
10138 )
10139 })?;
10140 if self.config.workflow.run_trigger() == WorkflowRunTrigger::Push {
10141 let run = self.lock_transport()?.find_run(
10142 &self.config.repository,
10143 &self.config.workflow.filename().repository_path(),
10144 &prepared_commit,
10145 record.temporary_ref.branch(),
10146 RunEvent::Push,
10147 );
10148 self.revalidate_record_identity(record)?;
10149 match run {
10150 Ok(handle) => {
10151 let snapshot = self
10152 .lock_transport()?
10153 .run(&self.config.repository, &handle)
10154 .map_err(transport_failure)?;
10155 self.revalidate_record_identity(record)?;
10156 if snapshot.run_attempt() != 1 {
10157 return Err(resume_failure("resume_recovered_run_attempt_invalid"));
10158 }
10159 record.dispatch_commit = Some(prepared_commit);
10160 record.run = Some(handle);
10161 record.run_snapshot = Some(snapshot);
10162 record.publication_uncertain = false;
10163 if record.state == JobState::Created {
10164 transition_record(record, JobState::Queued)?;
10165 append_event(
10166 record,
10167 self.clock.now_ms(),
10168 "queue",
10169 RemoteBuildEventKind::JobQueued { position: None },
10170 )?;
10171 }
10172 append_event(
10173 record,
10174 self.clock.now_ms(),
10175 "queue",
10176 RemoteBuildEventKind::Progress {
10177 message: "Exact GitHub Actions run recovered from publication uncertainty"
10178 .to_owned(),
10179 current: None,
10180 total: None,
10181 },
10182 )?;
10183 self.checkpoint_record(record)?;
10184 self.release_record_publication_lease(record)?;
10185 self.release_snapshot_source_lease(record)?;
10186 drop(state);
10187 self.sync_job_with_cancel_policy(
10188 job_id,
10189 cancellation,
10190 allow_cancel_dispatch,
10191 )?;
10192 return Ok(GithubJobReconciliation::Recovered);
10193 }
10194 Err(TransportError::RunNotFound) => {}
10195 Err(error) => return Err(transport_failure(error)),
10196 }
10197 }
10198
10199 if record.publication_absence_first_observed_at_ms == 0 {
10200 record.publication_absence_first_observed_at_ms = self.clock.now_ms();
10201 record.publication_quiescence_deadline_ms = publication_quiescence_deadline(
10202 record.publication_started_at_ms,
10203 record.publication_absence_first_observed_at_ms,
10204 );
10205 }
10206 record.publication_absence_observations = record
10207 .publication_absence_observations
10208 .saturating_add(1)
10209 .min(2);
10210 let strongly_resolved = record.publication_absence_observations >= 2
10211 && self.clock.now_ms() >= record.publication_quiescence_deadline_ms
10212 && record.publication_process_fenced;
10213 if strongly_resolved {
10214 if ref_is_missing && record.dispatch_commit.is_none() {
10215 record.publication_uncertain = false;
10216 record.publication_absent = true;
10217 record.temporary_ref_deleted = true;
10218 if record.cancellation_requested {
10219 transition_record(record, JobState::Cancelled)?;
10220 append_event(
10221 record,
10222 self.clock.now_ms(),
10223 "finished",
10224 RemoteBuildEventKind::OperationCancelled {
10225 reason: "publication_not_observed".to_owned(),
10226 duration_ms: self
10227 .clock
10228 .now_ms()
10229 .saturating_sub(record.created_at_ms),
10230 },
10231 )?;
10232 } else {
10233 transition_record(record, JobState::Failed)?;
10234 append_failed(
10235 record,
10236 self.clock.now_ms(),
10237 "publication_not_observed",
10238 "Prepared publication remained absent from both the ref and bounded run history",
10239 false,
10240 )?;
10241 }
10242 } else if !record.state.is_build_terminal() && !record.state.is_terminal() {
10243 transition_record(record, JobState::Failed)?;
10244 append_failed(
10245 record,
10246 self.clock.now_ms(),
10247 if ref_is_missing {
10248 "publication_not_observed_after_uncertain_mapping"
10249 } else {
10250 "temporary_ref_reconciliation_conflict"
10251 },
10252 if ref_is_missing {
10253 "Possible publication remained absent from bounded ref and run history"
10254 } else {
10255 "Temporary Git ref differed and no exact prepared-commit run appeared during bounded reconciliation"
10256 },
10257 false,
10258 )?;
10259 }
10260 }
10261 self.checkpoint_record(record)?;
10262 if strongly_resolved {
10263 self.release_record_publication_lease(record)?;
10264 self.release_snapshot_source_lease(record)?;
10265 }
10266 Ok(if strongly_resolved && !ref_is_missing {
10267 GithubJobReconciliation::ConflictResolved
10268 } else {
10269 outcome
10270 })
10271 }
10272 }
10273 }
10274
10275 fn artifact_context(
10276 record: &JobRecord,
10277 config: &GithubProviderConfig,
10278 ) -> Option<GithubArtifactContext> {
10279 Some(GithubArtifactContext {
10280 job_id: record.job_id.clone(),
10281 operation_id: record.operation_id.clone(),
10282 repository: config.repository.clone(),
10283 execution_repository_id: record.execution_repository_id,
10284 run: record.run_snapshot.clone()?,
10285 source_repository: config.source_repository.clone(),
10286 source_revision: record.source_revision.clone(),
10287 dispatch_revision: record.dispatch_commit.clone()?,
10288 request: record.request.clone(),
10289 request_sha256: record.request_sha256.clone(),
10290 })
10291 }
10292
10293 fn has_valid_signed_cleanup_evidence(&self, record: &JobRecord) -> RemoteBuildResult<bool> {
10294 let Some(evidence) = record.signed_cleanup_evidence.as_ref() else {
10295 return Ok(false);
10296 };
10297 let principal = record
10298 .principal
10299 .clone()
10300 .ok_or_else(|| resume_failure("checkpoint_principal_unavailable"))?;
10301 let repository_id = record
10302 .execution_repository_id
10303 .ok_or_else(|| resume_failure("checkpoint_repository_identity_unavailable"))?;
10304 let resume =
10305 GithubJobResumeV1::from_record(&self.config, principal, repository_id, record)?;
10306 evidence.validate_for_resume(&resume)?;
10307 Ok(true)
10308 }
10309
10310 fn sync_job(&self, job_id: &str, cancellation: &CancellationToken) -> RemoteBuildResult<()>
10311 where
10312 R: GhRunner,
10313 {
10314 self.sync_job_with_cancel_policy(job_id, cancellation, true)
10315 }
10316
10317 fn observe_workflow_dispatch_run(
10318 &self,
10319 job_id: &str,
10320 include_terminal: bool,
10321 cancellation: &CancellationToken,
10322 ) -> RemoteBuildResult<Option<RunSnapshot>>
10323 where
10324 R: GhRunner,
10325 {
10326 let (handle, previous) = {
10327 let state = self.lock_state()?;
10328 let record = state
10329 .jobs
10330 .get(job_id)
10331 .ok_or_else(|| job_not_found(job_id))?;
10332 if record.workflow_dispatch.is_none() || record.state.is_terminal() && !include_terminal
10333 {
10334 return Ok(None);
10335 }
10336 let Some(handle) = record.run.clone() else {
10337 return Ok(None);
10338 };
10339 (handle, record.run_snapshot.clone())
10340 };
10341 let snapshot = self
10342 .lock_transport()?
10343 .run(&self.config.repository, &handle)
10344 .map_err(transport_failure)?;
10345 ensure_same_run_attempt(previous.as_ref(), &snapshot)?;
10346 let identity = self.current_resume_identity()?;
10347 cancellation.check()?;
10348
10349 let mut state = self.lock_state()?;
10350 let record = state
10351 .jobs
10352 .get_mut(job_id)
10353 .ok_or_else(|| job_not_found(job_id))?;
10354 if record.workflow_dispatch.is_none() || record.run.as_ref() != Some(&handle) {
10355 return Err(resume_failure("workflow_dispatch_run_identity_changed"));
10356 }
10357 Self::validate_record_identity(record, &identity)?;
10358 if record
10359 .run_snapshot
10360 .as_ref()
10361 .is_some_and(|current| github_run_is_later_attempt(current, &snapshot))
10362 && record.signed_cleanup_evidence.is_some()
10363 {
10364 return Ok(record.run_snapshot.clone());
10365 }
10366 ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot)?;
10367 if (record.state == JobState::Succeeded || !record.manifests.is_empty())
10368 && !run_snapshot_is_successful(&snapshot)
10369 {
10370 return Err(resume_failure("resume_live_success_state_mismatch"));
10371 }
10372 record.run_snapshot = Some(snapshot.clone());
10373 self.checkpoint_record(record)?;
10374 Ok(Some(snapshot))
10375 }
10376
10377 fn apply_workflow_dispatch_snapshot(
10378 &self,
10379 record: &mut JobRecord,
10380 snapshot: &RunSnapshot,
10381 ) -> RemoteBuildResult<()>
10382 where
10383 R: GhRunner,
10384 {
10385 match snapshot.status() {
10386 RunStatus::Requested | RunStatus::Queued | RunStatus::Pending | RunStatus::Waiting => {}
10387 RunStatus::InProgress => {
10388 if record.state == JobState::Queued {
10389 transition_record(record, JobState::Running)?;
10390 append_event(
10391 record,
10392 self.clock.now_ms(),
10393 "remote-build",
10394 RemoteBuildEventKind::PhaseStarted {
10395 message: Some("GitHub macOS worker started".to_owned()),
10396 },
10397 )?;
10398 }
10399 }
10400 RunStatus::Completed => self.finish_from_snapshot(record, snapshot)?,
10401 }
10402 Ok(())
10403 }
10404
10405 #[allow(clippy::too_many_lines)]
10406 fn sync_workflow_dispatch_job_with_cancel_policy(
10407 &self,
10408 job_id: &str,
10409 cancellation: &CancellationToken,
10410 allow_cancel_dispatch: bool,
10411 ) -> RemoteBuildResult<()>
10412 where
10413 R: GhRunner,
10414 {
10415 cancellation.check()?;
10416 {
10417 let mut state = self.lock_state()?;
10418 let record = state
10419 .jobs
10420 .get_mut(job_id)
10421 .ok_or_else(|| job_not_found(job_id))?;
10422 if record.state.is_build_terminal() || record.state.is_terminal() {
10423 self.checkpoint_record(record)?;
10424 return Ok(());
10425 }
10426 if !record.publication_intent {
10427 return Ok(());
10428 }
10429 if record.publication_uncertain {
10430 self.checkpoint_record(record)?;
10431 return Err(provider_failure(
10432 "publication_reconciliation_required",
10433 "Possible temporary-ref publication requires exact ref and run-history reconciliation",
10434 true,
10435 ));
10436 }
10437 normalize_published_record(record, self.clock.now_ms())?;
10438 self.checkpoint_record(record)?;
10439 if record.run.is_none() {
10440 return Err(resume_failure("workflow_dispatch_run_mapping_missing"));
10441 }
10442 }
10443
10444 let Some(mut snapshot) = self.observe_workflow_dispatch_run(job_id, false, cancellation)?
10445 else {
10446 return Ok(());
10447 };
10448 let cancellation_authority = {
10449 let mut state = self.lock_state()?;
10450 let should_reserve = {
10451 let record = state
10452 .jobs
10453 .get_mut(job_id)
10454 .ok_or_else(|| job_not_found(job_id))?;
10455 ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot)?;
10456 record.run_snapshot = Some(snapshot.clone());
10457 record.cancellation_requested
10458 && !record.cancellation_dispatched
10459 && !snapshot.status().is_terminal()
10460 && allow_cancel_dispatch
10461 };
10462 if should_reserve && !self.config.mutation_authorization.cancel_run {
10463 return Err(unsupported(ProviderFeature::Cancellation));
10464 }
10465 should_reserve && state.cancellation_reservations.insert(job_id.to_owned())
10466 };
10467
10468 if cancellation_authority {
10469 let cancellation_result = (|| {
10470 let (handle, previous) = {
10471 let state = self.lock_state()?;
10472 let record = state
10473 .jobs
10474 .get(job_id)
10475 .ok_or_else(|| job_not_found(job_id))?;
10476 let handle = record
10477 .run
10478 .clone()
10479 .ok_or_else(|| resume_failure("workflow_dispatch_run_mapping_missing"))?;
10480 (handle, record.run_snapshot.clone())
10481 };
10482 let checked = self
10483 .lock_transport()?
10484 .run(&self.config.repository, &handle)
10485 .map_err(transport_failure)?;
10486 ensure_same_run_attempt(previous.as_ref(), &checked)?;
10487 let identity = self.current_resume_identity()?;
10488 let should_cancel = {
10489 let mut state = self.lock_state()?;
10490 let record = state
10491 .jobs
10492 .get_mut(job_id)
10493 .ok_or_else(|| job_not_found(job_id))?;
10494 if record.workflow_dispatch.is_none() || record.run.as_ref() != Some(&handle) {
10495 return Err(resume_failure("workflow_dispatch_run_identity_changed"));
10496 }
10497 Self::validate_record_identity(record, &identity)?;
10498 ensure_same_run_attempt(record.run_snapshot.as_ref(), &checked)?;
10499 record.run_snapshot = Some(checked.clone());
10500 snapshot = checked.clone();
10501 self.checkpoint_record(record)?;
10502 record.cancellation_requested
10503 && !record.cancellation_dispatched
10504 && !checked.status().is_terminal()
10505 };
10506 if should_cancel {
10507 self.lock_transport()?
10508 .cancel_run(&self.config.repository, handle.id())
10509 .map_err(transport_failure)?;
10510 }
10511 let mut state = self.lock_state()?;
10512 let record = state
10513 .jobs
10514 .get_mut(job_id)
10515 .ok_or_else(|| job_not_found(job_id))?;
10516 if record.workflow_dispatch.is_none() || record.run.as_ref() != Some(&handle) {
10517 return Err(resume_failure("workflow_dispatch_run_identity_changed"));
10518 }
10519 ensure_same_run_attempt(record.run_snapshot.as_ref(), &checked)?;
10520 record.run_snapshot = Some(checked.clone());
10521 if should_cancel {
10522 record.cancellation_dispatched = true;
10523 }
10524 self.apply_workflow_dispatch_snapshot(record, &checked)?;
10525 self.checkpoint_record(record)
10526 })();
10527 self.lock_state()?.cancellation_reservations.remove(job_id);
10528 cancellation_result?;
10529 } else {
10530 let mut state = self.lock_state()?;
10531 let record = state
10532 .jobs
10533 .get_mut(job_id)
10534 .ok_or_else(|| job_not_found(job_id))?;
10535 ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot)?;
10536 record.run_snapshot = Some(snapshot.clone());
10537 self.apply_workflow_dispatch_snapshot(record, &snapshot)?;
10538 self.checkpoint_record(record)?;
10539 }
10540 cancellation.check()
10541 }
10542
10543 pub fn refresh_restored_job_get_only(
10552 &self,
10553 job_id: &str,
10554 cancellation: &CancellationToken,
10555 ) -> RemoteBuildResult<()>
10556 where
10557 R: GhRunner,
10558 {
10559 self.sync_job_with_cancel_policy(job_id, cancellation, false)
10560 }
10561
10562 fn sync_job_with_cancel_policy(
10563 &self,
10564 job_id: &str,
10565 cancellation: &CancellationToken,
10566 allow_cancel_dispatch: bool,
10567 ) -> RemoteBuildResult<()>
10568 where
10569 R: GhRunner,
10570 {
10571 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
10572 let synchronization =
10573 self.sync_job_with_cancel_policy_inner(job_id, cancellation, allow_cancel_dispatch);
10574 self.checkpoint_workflow_dispatch_cancellation(job_id, cancellation)?;
10575 return synchronization;
10576 }
10577 self.sync_job_with_cancel_policy_inner(job_id, cancellation, allow_cancel_dispatch)
10578 }
10579
10580 #[allow(clippy::too_many_lines)]
10581 fn sync_job_with_cancel_policy_inner(
10582 &self,
10583 job_id: &str,
10584 cancellation: &CancellationToken,
10585 allow_cancel_dispatch: bool,
10586 ) -> RemoteBuildResult<()>
10587 where
10588 R: GhRunner,
10589 {
10590 cancellation.check()?;
10591 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
10592 let dispatch_state = {
10593 let state = self.lock_state()?;
10594 let record = state
10595 .jobs
10596 .get(job_id)
10597 .ok_or_else(|| job_not_found(job_id))?;
10598 (!record.state.is_build_terminal()
10599 && !record.state.is_terminal()
10600 && record.publication_intent
10601 && !record.publication_uncertain
10602 && record.dispatch_commit.is_some()
10603 && record.run.is_none())
10604 .then_some(record.workflow_dispatch.is_some())
10605 };
10606 match dispatch_state {
10607 Some(true) => self.reconcile_workflow_dispatch_with_cancellation_checkpoint(
10608 job_id,
10609 cancellation,
10610 )?,
10611 Some(false) if allow_cancel_dispatch => {
10612 self.start_or_reconcile_workflow_dispatch(job_id, cancellation)?;
10613 }
10614 Some(false) => return Ok(()),
10615 None => {}
10616 }
10617 return self.sync_workflow_dispatch_job_with_cancel_policy(
10618 job_id,
10619 cancellation,
10620 allow_cancel_dispatch,
10621 );
10622 }
10623 let mut state = self.lock_state()?;
10624 let record = state
10625 .jobs
10626 .get_mut(job_id)
10627 .ok_or_else(|| job_not_found(job_id))?;
10628 if record.state.is_build_terminal() || record.state.is_terminal() {
10629 self.checkpoint_record(record)?;
10630 return Ok(());
10631 }
10632 if !record.publication_intent {
10633 return Ok(());
10634 }
10635 if record.publication_uncertain {
10636 self.checkpoint_record(record)?;
10637 return Err(provider_failure(
10638 "publication_reconciliation_required",
10639 "Possible temporary-ref publication requires exact ref and run-history reconciliation",
10640 true,
10641 ));
10642 }
10643 if record.dispatch_commit.is_some() {
10644 normalize_published_record(record, self.clock.now_ms())?;
10645 self.checkpoint_record(record)?;
10646 self.revalidate_record_identity(record)?;
10647 }
10648
10649 let mut mapped_snapshot = None;
10650 if record.run.is_none() {
10651 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
10652 return Err(resume_failure("workflow_dispatch_run_mapping_missing"));
10653 }
10654 let Some(dispatch_commit) = record.dispatch_commit.as_ref() else {
10655 self.checkpoint_record(record)?;
10656 return Ok(());
10657 };
10658 let result = self.lock_transport()?.find_run(
10659 &self.config.repository,
10660 &self.config.workflow.filename().repository_path(),
10661 dispatch_commit,
10662 record.temporary_ref.branch(),
10663 RunEvent::Push,
10664 );
10665 match result {
10666 Ok(handle) => {
10667 let snapshot = self
10668 .lock_transport()?
10669 .run(&self.config.repository, &handle)
10670 .map_err(transport_failure)?;
10671 self.revalidate_record_identity(record)?;
10672 if snapshot.run_attempt() != 1 {
10673 return Err(provider_failure(
10674 "run_initial_attempt_mismatch",
10675 "Initial GitHub run mapping observed an external rerun attempt",
10676 false,
10677 ));
10678 }
10679 record.run = Some(handle);
10680 record.run_snapshot = Some(snapshot.clone());
10681 append_event(
10682 record,
10683 self.clock.now_ms(),
10684 "queue",
10685 RemoteBuildEventKind::Progress {
10686 message: "Exact GitHub Actions run mapped to this job".to_owned(),
10687 current: None,
10688 total: None,
10689 },
10690 )?;
10691 self.checkpoint_record(record)?;
10692 mapped_snapshot = Some(snapshot);
10693 }
10694 Err(TransportError::RunNotFound) => {
10695 record.run_discovery_attempts = record.run_discovery_attempts.saturating_add(1);
10696 if record.run_discovery_attempts >= self.config.poll_policy.discovery_attempts()
10697 || self.clock.now_ms() >= record.run_discovery_deadline_ms
10698 {
10699 ensure_running_or_cancelling(record)?;
10700 transition_record(record, JobState::Failed)?;
10701 append_event(
10702 record,
10703 self.clock.now_ms(),
10704 "cleanup",
10705 RemoteBuildEventKind::Warning {
10706 code: "github.cleanup_required".to_owned(),
10707 message: "Temporary Git ref cleanup is required after run discovery expired"
10708 .to_owned(),
10709 help: Some(
10710 "Run provider cleanup for this exact job before retrying"
10711 .to_owned(),
10712 ),
10713 },
10714 )?;
10715 append_failed(
10716 record,
10717 self.clock.now_ms(),
10718 "run_discovery_exhausted",
10719 "Exact GitHub Actions run was not found within the bounded discovery policy",
10720 true,
10721 )?;
10722 }
10723 self.checkpoint_record(record)?;
10724 return Ok(());
10725 }
10726 Err(error) => return Err(transport_failure(error)),
10727 }
10728 }
10729
10730 let handle = record.run.clone().ok_or_else(|| {
10731 provider_failure(
10732 "run_identity_unavailable",
10733 "GitHub run identity was unavailable after lookup",
10734 true,
10735 )
10736 })?;
10737 let mut snapshot = if let Some(snapshot) = mapped_snapshot {
10738 snapshot
10739 } else {
10740 let snapshot = self
10741 .lock_transport()?
10742 .run(&self.config.repository, &handle)
10743 .map_err(transport_failure)?;
10744 ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot)?;
10745 snapshot
10746 };
10747 record.run_snapshot = Some(snapshot.clone());
10748
10749 if record.cancellation_requested
10750 && !record.cancellation_dispatched
10751 && !snapshot.status().is_terminal()
10752 && allow_cancel_dispatch
10753 {
10754 if !self.config.mutation_authorization.cancel_run {
10755 return Err(unsupported(ProviderFeature::Cancellation));
10756 }
10757 self.checkpoint_record(record)?;
10758 self.revalidate_record_identity(record)?;
10759 let checked = self
10760 .lock_transport()?
10761 .run(&self.config.repository, &handle)
10762 .map_err(transport_failure)?;
10763 ensure_same_run_attempt(record.run_snapshot.as_ref(), &checked)?;
10764 record.run_snapshot = Some(checked.clone());
10765 snapshot = checked;
10766 if !snapshot.status().is_terminal() {
10767 self.revalidate_record_identity(record)?;
10768 self.lock_transport()?
10771 .cancel_run(&self.config.repository, handle.id())
10772 .map_err(transport_failure)?;
10773 record.cancellation_dispatched = true;
10774 self.checkpoint_record(record)?;
10775 }
10776 }
10777
10778 match snapshot.status() {
10779 RunStatus::Requested | RunStatus::Queued | RunStatus::Pending | RunStatus::Waiting => {}
10780 RunStatus::InProgress => {
10781 if record.state == JobState::Queued {
10782 transition_record(record, JobState::Running)?;
10783 append_event(
10784 record,
10785 self.clock.now_ms(),
10786 "remote-build",
10787 RemoteBuildEventKind::PhaseStarted {
10788 message: Some("GitHub macOS worker started".to_owned()),
10789 },
10790 )?;
10791 }
10792 }
10793 RunStatus::Completed => self.finish_from_snapshot(record, &snapshot)?,
10794 }
10795 self.checkpoint_record(record)?;
10796 cancellation.check()
10797 }
10798
10799 fn finish_from_snapshot(
10800 &self,
10801 record: &mut JobRecord,
10802 snapshot: &RunSnapshot,
10803 ) -> RemoteBuildResult<()>
10804 where
10805 R: GhRunner,
10806 {
10807 match snapshot.conclusion() {
10808 Some(RunConclusion::Cancelled) => {
10809 ensure_running_or_cancelling(record)?;
10810 transition_record(record, JobState::Cancelled)?;
10811 append_event(
10812 record,
10813 self.clock.now_ms(),
10814 "finished",
10815 RemoteBuildEventKind::OperationCancelled {
10816 reason: "github_run_cancelled".to_owned(),
10817 duration_ms: self.clock.now_ms().saturating_sub(record.created_at_ms),
10818 },
10819 )
10820 }
10821 Some(RunConclusion::Success) if record.cancellation_requested => {
10822 ensure_running_or_cancelling(record)?;
10823 transition_record(record, JobState::Failed)?;
10824 append_failed(
10825 record,
10826 self.clock.now_ms(),
10827 "cancel_race",
10828 "GitHub run completed after cancellation was requested",
10829 false,
10830 )
10831 }
10832 Some(RunConclusion::Success) => self.finish_success(record),
10833 Some(conclusion) => {
10834 ensure_running_or_cancelling(record)?;
10835 transition_record(record, JobState::Failed)?;
10836 append_failed(
10837 record,
10838 self.clock.now_ms(),
10839 conclusion_code(conclusion),
10840 "GitHub Actions build did not succeed",
10841 conclusion_retryable(conclusion),
10842 )
10843 }
10844 None => Err(provider_failure(
10845 "missing_run_conclusion",
10846 "completed GitHub Actions run has no conclusion",
10847 false,
10848 )),
10849 }
10850 }
10851
10852 #[allow(clippy::too_many_lines)]
10853 fn finish_success(&self, record: &mut JobRecord) -> RemoteBuildResult<()>
10854 where
10855 R: GhRunner,
10856 {
10857 if record.state == JobState::Queued {
10858 transition_record(record, JobState::Running)?;
10859 }
10860 let mut artifacts = self.lock_artifacts()?;
10861 let supports_listing = artifacts.supports_listing();
10862 if !supports_listing {
10863 if !record.verification_pending_event {
10864 append_event(
10865 record,
10866 self.clock.now_ms(),
10867 "artifact-verification",
10868 RemoteBuildEventKind::Warning {
10869 code: "github.artifact_verification_pending".to_owned(),
10870 message: "GitHub run succeeded; independent artifact ingestion is not configured"
10871 .to_owned(),
10872 help: Some(
10873 "Configure a verified artifact store before treating this build as successful"
10874 .to_owned(),
10875 ),
10876 },
10877 )?;
10878 record.verification_pending_event = true;
10879 }
10880 return Ok(());
10881 }
10882 if artifacts.supports_signed_cleanup_evidence()
10883 && (record.principal.is_none() || record.execution_repository_id.is_none())
10884 {
10885 drop(artifacts);
10886 let (principal, repository_id) = self.current_resume_identity()?;
10887 record.principal = Some(principal);
10888 record.execution_repository_id = Some(repository_id);
10889 artifacts = self.lock_artifacts()?;
10890 }
10891 self.revalidate_record_identity(record)?;
10892 let context = Self::artifact_context(record, &self.config).ok_or_else(|| {
10893 provider_failure(
10894 "run_identity_unavailable",
10895 "artifact verification requires a terminal run snapshot",
10896 true,
10897 )
10898 })?;
10899 let manifests = artifacts.list_verified(&context)?;
10900 if manifests.is_empty() {
10901 return Ok(());
10902 }
10903 let verified_evidence = artifacts.verified_run_evidence(&context)?.ok_or_else(|| {
10904 provider_failure(
10905 "verified_run_evidence_missing",
10906 "verified artifacts lack independently retained compile evidence",
10907 false,
10908 )
10909 })?;
10910 drop(artifacts);
10911 validate_manifests(record, &self.config, &manifests)?;
10912 validate_github_compile_phase_evidence(
10913 verified_evidence.compile_evidence(),
10914 &record.request,
10915 &record.job_id,
10916 )?;
10917 let signed_cleanup_evidence = verified_evidence.signed_cleanup_evidence().cloned();
10918 match record.request.signing.mode {
10919 SigningMode::ManualDevelopment if signed_cleanup_evidence.is_none() => {
10920 return Err(provider_failure(
10921 "signed_cleanup_evidence_missing",
10922 "signed artifacts lack attempt-scoped protected-cleanup evidence",
10923 false,
10924 ));
10925 }
10926 SigningMode::UnsignedCompileOnly if signed_cleanup_evidence.is_some() => {
10927 return Err(provider_failure(
10928 "signed_cleanup_evidence_unexpected",
10929 "unsigned artifacts cannot carry signed-cleanup evidence",
10930 false,
10931 ));
10932 }
10933 _ => {}
10934 }
10935 let mut candidate = record.clone();
10936 candidate.manifests.clone_from(&manifests);
10937 candidate.compile_evidence = Some(verified_evidence.compile_evidence().clone());
10938 candidate.signed_cleanup_evidence = signed_cleanup_evidence;
10939 if let Some(evidence) = candidate.signed_cleanup_evidence.as_ref() {
10940 let principal = candidate
10941 .principal
10942 .clone()
10943 .ok_or_else(|| resume_failure("checkpoint_principal_unavailable"))?;
10944 let repository_id = candidate
10945 .execution_repository_id
10946 .ok_or_else(|| resume_failure("checkpoint_repository_identity_unavailable"))?;
10947 let resume =
10948 GithubJobResumeV1::from_record(&self.config, principal, repository_id, &candidate)?;
10949 evidence.validate_for_resume(&resume)?;
10950 }
10951 for manifest in &manifests {
10952 append_event(
10953 record,
10954 self.clock.now_ms(),
10955 "artifact-verification",
10956 RemoteBuildEventKind::ArtifactValidated {
10957 artifact: manifest.clone(),
10958 },
10959 )?;
10960 }
10961 record.manifests = candidate.manifests;
10962 record.compile_evidence = candidate.compile_evidence;
10963 record.signed_cleanup_evidence = candidate.signed_cleanup_evidence;
10964 transition_record(record, JobState::Succeeded)?;
10965 let result = IosDeviceBuildResult {
10966 protocol_version: CURRENT_PROTOCOL_VERSION,
10967 operation_id: record.operation_id.clone(),
10968 job_id: record.job_id.clone(),
10969 state: JobState::Succeeded,
10970 artifacts: manifests,
10971 cleanup: None,
10972 };
10973 result.validate()?;
10974 append_event(
10975 record,
10976 self.clock.now_ms(),
10977 "finished",
10978 RemoteBuildEventKind::OperationFinished {
10979 success: true,
10980 duration_ms: self.clock.now_ms().saturating_sub(record.created_at_ms),
10981 result: Some(result),
10982 error: None,
10983 },
10984 )
10985 }
10986}
10987
10988#[cfg(feature = "secure-job-log-http")]
10989impl<P, A, C> GithubBuildProvider<GhProcessRunner, P, A, C>
10990where
10991 P: TemporaryRefPublisher,
10992 A: VerifiedArtifactStore,
10993 C: ProviderClock,
10994{
10995 pub fn fetch_restored_job_logs(
11005 &self,
11006 job_id: &str,
11007 cancellation: &CancellationToken,
11008 ) -> RemoteBuildResult<Option<GithubJobLogPoll>> {
11009 self.revalidate_restored_job_live(job_id, cancellation)?;
11010 let identity = {
11011 let state = self.lock_state()?;
11012 let record = state
11013 .jobs
11014 .get(job_id)
11015 .ok_or_else(|| job_not_found(job_id))?;
11016 let Some(snapshot) = record.run_snapshot.as_ref() else {
11017 return Ok(None);
11018 };
11019 if !snapshot.status().is_terminal() || snapshot.conclusion().is_none() {
11020 return Ok(None);
11021 }
11022 crate::job_logs::GithubRunAttemptLogIdentity::from_terminal_run(
11023 self.config.repository.clone(),
11024 snapshot,
11025 )
11026 .map_err(|_| job_log_identity_failure())?
11027 };
11028 let runner = { self.lock_transport()?.runner().clone() };
11029 let client = runner
11030 .job_log_http_client()
11031 .map_err(job_log_client_failure)?;
11032 GithubJobLogFetcher::new(client, GithubJobLogLimits::secure_defaults())
11033 .fetch_attempt(&identity, cancellation)
11034 .map(Some)
11035 .map_err(job_log_fetch_failure)
11036 }
11037}
11038
11039impl<R, P, A, C> BuildProvider for GithubBuildProvider<R, P, A, C>
11040where
11041 R: GhRunner + Send,
11042 P: TemporaryRefPublisher + Send,
11043 A: VerifiedArtifactStore + Send,
11044 C: ProviderClock,
11045{
11046 fn id(&self) -> &str {
11047 GITHUB_PROVIDER_ID
11048 }
11049
11050 fn capabilities(&self) -> &ProviderCapabilities {
11051 &self.capabilities
11052 }
11053
11054 fn handshake(
11055 &self,
11056 request: HandshakeRequest,
11057 cancellation: CancellationToken,
11058 ) -> ProviderFuture<'_, HandshakeResponse> {
11059 let result = cancellation.check().and_then(|()| {
11060 HandshakeResponse::negotiate(
11061 &request,
11062 GITHUB_PROVIDER_ID,
11063 "github-hosted-macos",
11064 self.config.worker_version.clone(),
11065 self.capabilities.clone(),
11066 )
11067 });
11068 Box::pin(async move { result })
11069 }
11070
11071 #[allow(clippy::too_many_lines)]
11072 fn doctor(
11073 &self,
11074 request: ProviderDoctorRequest,
11075 cancellation: CancellationToken,
11076 ) -> ProviderFuture<'_, ProviderDoctorReport> {
11077 let result = (|| {
11078 cancellation.check()?;
11079 CURRENT_PROTOCOL_VERSION.negotiate(request.protocol_version)?;
11080 validate_provider_identifier("operation_id", &request.operation_id)?;
11081
11082 let mut checks = Vec::new();
11083 let authenticated = self.lock_transport()?.authenticate(&self.config.repository);
11084 cancellation.check()?;
11085
11086 let execution_info = match self.lock_transport()?.repository(&self.config.repository) {
11087 Ok(repository) => {
11088 checks.push(if repository.is_archived() || repository.is_disabled() {
11089 ProviderCheck {
11090 code: "github.repository".to_owned(),
11091 status: ProviderCheckStatus::Error,
11092 message: "Configured GitHub repository cannot run new builds"
11093 .to_owned(),
11094 help: Some("Use an active, non-archived repository".to_owned()),
11095 }
11096 } else {
11097 ProviderCheck {
11098 code: "github.repository".to_owned(),
11099 status: ProviderCheckStatus::Ready,
11100 message: "Exact configured GitHub execution repository is available"
11101 .to_owned(),
11102 help: None,
11103 }
11104 });
11105 if request.require_signing {
11106 checks.push(if repository.is_private() {
11107 ProviderCheck {
11108 code: "github.signing_repository_visibility".to_owned(),
11109 status: ProviderCheckStatus::Ready,
11110 message: "Exact GitHub signing execution repository is private"
11111 .to_owned(),
11112 help: None,
11113 }
11114 } else {
11115 ProviderCheck {
11116 code: "github.signing_repository_visibility".to_owned(),
11117 status: ProviderCheckStatus::Error,
11118 message:
11119 "Signed artifacts require a private GitHub execution repository"
11120 .to_owned(),
11121 help: Some(
11122 "Configure a private signing repository so embedded provisioning metadata is not published to public readers"
11123 .to_owned(),
11124 ),
11125 }
11126 });
11127 }
11128 Some(repository)
11129 }
11130 Err(error) => {
11131 checks.push(ProviderCheck {
11132 code: "github.repository".to_owned(),
11133 status: ProviderCheckStatus::Error,
11134 message: transport_public_message(error),
11135 help: Some(
11136 "Check execution repository identity and token access".to_owned(),
11137 ),
11138 });
11139 if request.require_signing {
11140 checks.push(ProviderCheck {
11141 code: "github.signing_repository_visibility".to_owned(),
11142 status: ProviderCheckStatus::Error,
11143 message: "Private signing repository visibility could not be proven"
11144 .to_owned(),
11145 help: Some(
11146 "Grant repository metadata access and verify the private execution repository"
11147 .to_owned(),
11148 ),
11149 });
11150 }
11151 None
11152 }
11153 };
11154 let authentication_check = match (&authenticated, &execution_info) {
11155 (Ok(AuthenticatedPrincipal::User(user)), _) => ProviderCheck {
11156 code: "github.authentication".to_owned(),
11157 status: ProviderCheckStatus::Ready,
11158 message: format!(
11159 "Authenticated GitHub account `{}` is available",
11160 user.login()
11161 ),
11162 help: None,
11163 },
11164 (
11165 Ok(AuthenticatedPrincipal::RepositoryCredential { .. }),
11166 _,
11167 ) if self.config.workflow.run_trigger() != WorkflowRunTrigger::WorkflowDispatch => {
11168 ProviderCheck {
11169 code: "github.authentication".to_owned(),
11170 status: ProviderCheckStatus::Error,
11171 message: "Repository-scoped GitHub credentials require explicit workflow dispatch"
11172 .to_owned(),
11173 help: Some(
11174 "Use an authenticated user for Push or configure WorkflowDispatch"
11175 .to_owned(),
11176 ),
11177 }
11178 }
11179 (
11180 Ok(AuthenticatedPrincipal::RepositoryCredential { repository_id }),
11181 Some(repository),
11182 ) if *repository_id == repository.id() => ProviderCheck {
11183 code: "github.authentication".to_owned(),
11184 status: ProviderCheckStatus::Ready,
11185 message: "Repository-scoped GitHub credential is bound to the exact execution repository"
11186 .to_owned(),
11187 help: None,
11188 },
11189 (Ok(AuthenticatedPrincipal::RepositoryCredential { .. }), _) => ProviderCheck {
11190 code: "github.authentication".to_owned(),
11191 status: ProviderCheckStatus::Error,
11192 message: "Repository-scoped GitHub credential identity could not be bound to the exact execution repository"
11193 .to_owned(),
11194 help: Some(
11195 "Grant the credential access to the exact execution repository".to_owned(),
11196 ),
11197 },
11198 (Err(error), _) => ProviderCheck {
11199 code: "github.authentication".to_owned(),
11200 status: ProviderCheckStatus::Error,
11201 message: transport_public_message(*error),
11202 help: Some("Authenticate the configured GitHub transport and retry".to_owned()),
11203 },
11204 };
11205 checks.insert(0, authentication_check);
11206 let execution_repository_url = repository_url(&self.config.repository);
11207 let source_info =
11208 if github_remote_matches(&self.config.source_repository, &execution_repository_url)
11209 {
11210 execution_info.clone()
11211 } else {
11212 let source_repository = repository_from_url(&self.config.source_repository)
11213 .ok_or_else(|| {
11214 provider_failure(
11215 "source_repository_invalid",
11216 "configured source repository identity is invalid",
11217 false,
11218 )
11219 })?;
11220 match self.lock_transport()?.repository(&source_repository) {
11221 Ok(repository) => Some(repository),
11222 Err(error) => {
11223 checks.push(ProviderCheck {
11224 code: "github.source_repository".to_owned(),
11225 status: ProviderCheckStatus::Error,
11226 message: transport_public_message(error),
11227 help: Some(
11228 "Check public source repository identity and token access"
11229 .to_owned(),
11230 ),
11231 });
11232 None
11233 }
11234 }
11235 };
11236 match source_info.as_ref() {
11237 Some(repository) if !repository.is_private() => checks.push(ProviderCheck {
11238 code: "github.source_repository_visibility".to_owned(),
11239 status: ProviderCheckStatus::Ready,
11240 message: "Exact configured source repository is public".to_owned(),
11241 help: None,
11242 }),
11243 Some(_) => checks.push(ProviderCheck {
11244 code: "github.source_repository_visibility".to_owned(),
11245 status: ProviderCheckStatus::Error,
11246 message: "Trusted project source must be in a public GitHub repository"
11247 .to_owned(),
11248 help: Some(
11249 "Keep public source separate from the private signing execution repository"
11250 .to_owned(),
11251 ),
11252 }),
11253 None => checks.push(ProviderCheck {
11254 code: "github.source_repository_visibility".to_owned(),
11255 status: ProviderCheckStatus::Error,
11256 message: "Public source repository visibility could not be proven".to_owned(),
11257 help: Some(
11258 "Grant repository metadata access and verify the public source repository"
11259 .to_owned(),
11260 ),
11261 }),
11262 }
11263 if request.require_signing {
11264 checks.push(match (&source_info, &execution_info) {
11265 (Some(source), Some(execution)) if source.id() != execution.id() => {
11266 ProviderCheck {
11267 code: "github.signing_repository_distinct".to_owned(),
11268 status: ProviderCheckStatus::Ready,
11269 message: "Public source and private signing execution use distinct GitHub repositories"
11270 .to_owned(),
11271 help: None,
11272 }
11273 }
11274 (Some(_), Some(_)) => ProviderCheck {
11275 code: "github.signing_repository_distinct".to_owned(),
11276 status: ProviderCheckStatus::Error,
11277 message: "Signed builds require distinct source and execution repositories"
11278 .to_owned(),
11279 help: Some(
11280 "Keep project source public and configure a separate private signing execution repository"
11281 .to_owned(),
11282 ),
11283 },
11284 _ => ProviderCheck {
11285 code: "github.signing_repository_distinct".to_owned(),
11286 status: ProviderCheckStatus::Error,
11287 message: "Distinct signing repository identity could not be proven"
11288 .to_owned(),
11289 help: Some(
11290 "Grant repository metadata access for both configured repositories"
11291 .to_owned(),
11292 ),
11293 },
11294 });
11295 }
11296
11297 if request.require_signing {
11298 match self
11299 .lock_transport()?
11300 .actions_permissions(&self.config.repository)
11301 {
11302 Ok(permissions) if permissions.enabled() => checks.push(ProviderCheck {
11303 code: "github.actions.enabled".to_owned(),
11304 status: ProviderCheckStatus::Ready,
11305 message: "GitHub Actions is enabled for the execution repository".to_owned(),
11306 help: None,
11307 }),
11308 Ok(_) => checks.push(ProviderCheck {
11309 code: "github.actions.enabled".to_owned(),
11310 status: ProviderCheckStatus::Error,
11311 message: "GitHub Actions is disabled for the execution repository".to_owned(),
11312 help: Some(
11313 "Enable GitHub Actions for the exact execution repository".to_owned(),
11314 ),
11315 }),
11316 Err(error) => checks.push(ProviderCheck {
11317 code: "github.actions.enabled".to_owned(),
11318 status: ProviderCheckStatus::Error,
11319 message: transport_public_message(error),
11320 help: Some(
11321 "Grant repository administration read access and verify GitHub Actions policy"
11322 .to_owned(),
11323 ),
11324 }),
11325 }
11326 }
11327
11328 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
11329 let default_branch_check = execution_info.as_ref().map_or_else(
11330 || ProviderCheck {
11331 code: "github.workflow_dispatch_default_branch".to_owned(),
11332 status: ProviderCheckStatus::Error,
11333 message: "Execution repository default branch could not be proven"
11334 .to_owned(),
11335 help: Some(
11336 "Grant repository metadata access and configure workflow dispatch on the exact default branch"
11337 .to_owned(),
11338 ),
11339 },
11340 |repository| {
11341 let expected = format!("refs/heads/{}", repository.default_branch().as_str());
11342 if self.config.workflow.trusted_source_ref().as_str() == expected {
11343 ProviderCheck {
11344 code: "github.workflow_dispatch_default_branch".to_owned(),
11345 status: ProviderCheckStatus::Ready,
11346 message: "Workflow dispatch is bound to the execution repository default branch"
11347 .to_owned(),
11348 help: None,
11349 }
11350 } else {
11351 ProviderCheck {
11352 code: "github.workflow_dispatch_default_branch".to_owned(),
11353 status: ProviderCheckStatus::Error,
11354 message: "Workflow dispatch trusted ref is not the execution repository default branch"
11355 .to_owned(),
11356 help: Some(
11357 "Configure the exact execution repository default branch for workflow dispatch"
11358 .to_owned(),
11359 ),
11360 }
11361 }
11362 },
11363 );
11364 checks.push(default_branch_check);
11365 let workflow_path = self.config.workflow.filename().repository_path();
11366 match self
11367 .lock_transport()?
11368 .workflow_registration(&self.config.repository, &workflow_path)
11369 {
11370 Ok(_) => checks.push(ProviderCheck {
11371 code: "github.workflow_registration".to_owned(),
11372 status: ProviderCheckStatus::Ready,
11373 message: "Exact workflow_dispatch workflow is actively registered"
11374 .to_owned(),
11375 help: None,
11376 }),
11377 Err(error) => checks.push(ProviderCheck {
11378 code: "github.workflow_registration".to_owned(),
11379 status: ProviderCheckStatus::Error,
11380 message: transport_public_message(error),
11381 help: Some(
11382 "Register and enable the exact configured workflow on the execution repository's default branch"
11383 .to_owned(),
11384 ),
11385 }),
11386 }
11387 checks.push(match self.verify_execution_workflow_default() {
11388 Ok(()) => ProviderCheck {
11389 code: "github.workflow_dispatch_default_workflow".to_owned(),
11390 status: ProviderCheckStatus::Ready,
11391 message: "Exact WorkflowDispatch bytes are present on the execution repository default branch"
11392 .to_owned(),
11393 help: None,
11394 },
11395 Err(_) => ProviderCheck {
11396 code: "github.workflow_dispatch_default_workflow".to_owned(),
11397 status: ProviderCheckStatus::Error,
11398 message: "Exact WorkflowDispatch bytes could not be proven on the execution repository default branch"
11399 .to_owned(),
11400 help: Some(
11401 "Install the generated workflow unchanged on the exact execution default branch"
11402 .to_owned(),
11403 ),
11404 },
11405 });
11406 }
11407 cancellation.check()?;
11408
11409 match self.lock_publisher()?.doctor(&TemporaryRefDoctorRequest {
11410 repository: &self.config.repository,
11411 source_repository: &self.config.source_repository,
11412 trusted_source_ref: self.config.workflow.trusted_source_ref(),
11413 temporary_branch_namespace: self.config.workflow.temporary_branch_namespace(),
11414 workflow_path: &self.config.workflow.filename().repository_path(),
11415 workflow_fingerprint: &self.config.workflow_fingerprint,
11416 }) {
11417 Ok(_) => {
11418 checks.push(ProviderCheck {
11419 code: "github.trusted_source".to_owned(),
11420 status: ProviderCheckStatus::Ready,
11421 message: "Trusted ref contains the exact approved workflow bytes"
11422 .to_owned(),
11423 help: None,
11424 });
11425 checks.push(ProviderCheck {
11426 code: "github.temporary_ref_policy_unproven".to_owned(),
11427 status: ProviderCheckStatus::Warning,
11428 message: "Credentialed receive-pack is reachable; branch mutation policy is proven only by a durable build publication"
11429 .to_owned(),
11430 help: Some(
11431 "Run an authorized build to prove the configured temporary-ref policy"
11432 .to_owned(),
11433 ),
11434 });
11435 }
11436 Err(error) => checks.push(ProviderCheck {
11437 code: "github.trusted_source".to_owned(),
11438 status: ProviderCheckStatus::Error,
11439 message: publisher_public_message(error),
11440 help: Some("Check the configured Git remote and trusted source ref".to_owned()),
11441 }),
11442 }
11443
11444 checks.push(
11445 if self.config.mutation_authorization.publish_temporary_ref {
11446 ProviderCheck {
11447 code: "github.temporary_ref_authorization".to_owned(),
11448 status: ProviderCheckStatus::Ready,
11449 message: "Caller authorized create-only temporary build refs".to_owned(),
11450 help: None,
11451 }
11452 } else {
11453 ProviderCheck {
11454 code: "github.temporary_ref_authorization".to_owned(),
11455 status: ProviderCheckStatus::Error,
11456 message: "Temporary build ref publication is not authorized".to_owned(),
11457 help: Some("Confirm the exact temporary ref before submission".to_owned()),
11458 }
11459 },
11460 );
11461 if request.require_signing {
11462 let mut transport = self.lock_transport()?;
11463 append_signing_environment_checks(&mut checks, &mut transport, &self.config);
11464 drop(transport);
11465 cancellation.check()?;
11466 checks.push(if self.lock_artifacts()?.supports_signed_cleanup_evidence() {
11467 ProviderCheck {
11468 code: "github.signing_cleanup_evidence".to_owned(),
11469 status: ProviderCheckStatus::Ready,
11470 message: "Independent verification retains attempt-scoped protected-cleanup evidence"
11471 .to_owned(),
11472 help: None,
11473 }
11474 } else {
11475 ProviderCheck {
11476 code: "github.signing_cleanup_evidence".to_owned(),
11477 status: ProviderCheckStatus::Error,
11478 message: "Signed cleanup lacks attempt-scoped worker evidence that signing material was removed"
11479 .to_owned(),
11480 help: Some(
11481 "Configure the verified GitHub artifact store before signed builds"
11482 .to_owned(),
11483 ),
11484 }
11485 });
11486 }
11487 if !self
11488 .artifacts
11489 .lock()
11490 .map_err(|_| {
11491 provider_failure(
11492 "artifact_store_unavailable",
11493 "verified artifact store is unavailable",
11494 true,
11495 )
11496 })?
11497 .supports_download()
11498 {
11499 checks.push(ProviderCheck {
11500 code: "github.artifact_ingestion".to_owned(),
11501 status: ProviderCheckStatus::Error,
11502 message: "Independent artifact download and verification are not configured"
11503 .to_owned(),
11504 help: Some("Configure a verified GitHub artifact store".to_owned()),
11505 });
11506 }
11507 let ready = checks
11508 .iter()
11509 .all(|check| check.status != ProviderCheckStatus::Error);
11510 Ok(ProviderDoctorReport {
11511 protocol_version: CURRENT_PROTOCOL_VERSION,
11512 provider: GITHUB_PROVIDER_ID.to_owned(),
11513 ready,
11514 checks,
11515 capabilities: self.capabilities.clone(),
11516 })
11517 })();
11518 Box::pin(async move { result })
11519 }
11520
11521 #[allow(clippy::too_many_lines)]
11522 fn submit(
11523 &self,
11524 request: IosDeviceBuildRequest,
11525 cancellation: CancellationToken,
11526 ) -> ProviderFuture<'_, JobHandle> {
11527 let result = (|| {
11528 cancellation.check()?;
11529 validate_submission(&self.config, &request)?;
11530 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch
11531 && self.checkpoint_sink.is_none()
11532 {
11533 return Err(provider_failure(
11534 "workflow_dispatch_checkpoint_required",
11535 "Workflow dispatch requires a durable provider checkpoint sink",
11536 false,
11537 ));
11538 }
11539 let branch = BranchName::new(format!(
11540 "{}/{}",
11541 self.config.workflow.temporary_branch_namespace().as_str(),
11542 request.operation_id
11543 ))
11544 .map_err(|_| {
11545 provider_failure(
11546 "operation_id_not_ref_safe",
11547 "operation ID cannot form a safe temporary Git ref",
11548 false,
11549 )
11550 })?;
11551 let temporary_ref =
11552 TemporaryGitRef::new(self.config.workflow.temporary_branch_namespace(), branch)
11553 .map_err(|_| {
11554 provider_failure(
11555 "operation_id_not_ref_safe",
11556 "operation ID cannot form a safe temporary Git ref",
11557 false,
11558 )
11559 })?;
11560 let source_revision =
11561 CommitSha::new(request.source_revision.clone().ok_or_else(|| {
11562 provider_failure(
11563 "invalid_source_revision",
11564 "source revision is not an exact commit SHA",
11565 false,
11566 )
11567 })?)
11568 .map_err(|_| {
11569 provider_failure(
11570 "invalid_source_revision",
11571 "source revision is not an exact commit SHA",
11572 false,
11573 )
11574 })?;
11575 let job_id = request.operation_id.clone();
11576 let reservation = JobReservation::acquire(
11577 &self.state,
11578 job_id.clone(),
11579 self.config.max_jobs,
11580 self.checkpoint_sink.is_none(),
11581 )?;
11582 let resume_identity = if self.checkpoint_sink.is_some() {
11583 Some(self.current_resume_identity()?)
11584 } else {
11585 None
11586 };
11587 let mut workflow_dispatch_client =
11588 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
11589 self.preflight_workflow_dispatch_registration()?;
11590 Some(self.prepare_workflow_dispatch_client()?)
11591 } else {
11592 None
11593 };
11594 let created_at_ms = self.clock.now_ms();
11595 let workflow = generate_workflow(&self.config.workflow);
11596 let manifest =
11597 GithubDispatchManifest::new(&self.config, &temporary_ref, request.clone());
11598 manifest.validate_for(&self.config)?;
11599 let manifest_bytes = manifest.encode()?;
11600 let request_sha256 = canonical_request_sha256(&request)?;
11601 let discovery_deadline_ms =
11602 created_at_ms.saturating_add(self.config.poll_policy.discovery_window_ms());
11603 let mut record = JobRecord {
11604 principal: resume_identity
11605 .as_ref()
11606 .map(|(principal, _)| principal.clone()),
11607 execution_repository_id: resume_identity
11608 .as_ref()
11609 .map(|(_, repository_id)| *repository_id),
11610 operation_id: request.operation_id.clone(),
11611 job_id: job_id.clone(),
11612 source_revision: source_revision.clone(),
11613 git_snapshot: None,
11614 request: request.clone(),
11615 request_sha256,
11616 temporary_ref: temporary_ref.clone(),
11617 prepared_dispatch_commit: None,
11618 dispatch_commit: None,
11619 workflow_dispatch: None,
11620 created_at_ms,
11621 publication_started_at_ms: 0,
11622 publication_quiescence_deadline_ms: 0,
11623 state: JobState::Created,
11624 run: None,
11625 run_snapshot: None,
11626 publication_intent: false,
11627 publication_absent: false,
11628 publication_not_attempted: false,
11629 publication_process_fenced: false,
11630 publication_lease_scope_sha256: None,
11631 publication_takeover_lease_owned: false,
11632 snapshot_source_lease_owned: false,
11633 publication_absence_observations: 0,
11634 publication_absence_first_observed_at_ms: 0,
11635 cancellation_requested: false,
11636 cancellation_dispatched: false,
11637 cleanup_requested: false,
11638 remove_artifacts_requested: false,
11639 artifacts_removed: false,
11640 temporary_ref_deleted: false,
11641 verification_pending_event: false,
11642 publication_uncertain: false,
11643 run_discovery_attempts: 0,
11644 run_discovery_deadline_ms: discovery_deadline_ms,
11645 manifests: Vec::new(),
11646 compile_evidence: None,
11647 signed_cleanup_evidence: None,
11648 events: Vec::new(),
11649 };
11650 append_event(
11651 &mut record,
11652 created_at_ms,
11653 "submit",
11654 RemoteBuildEventKind::OperationStarted {
11655 command: "build-iphone".to_owned(),
11656 },
11657 )?;
11658 append_event(
11659 &mut record,
11660 created_at_ms,
11661 "submit",
11662 RemoteBuildEventKind::JobCreated {
11663 state: JobState::Created,
11664 },
11665 )?;
11666 let mut pending = reservation.insert_pending(record)?;
11667 if request.signing.mode == SigningMode::ManualDevelopment {
11668 let source_repository = repository_from_url(&self.config.source_repository)
11669 .ok_or_else(|| {
11670 provider_failure(
11671 "source_repository_invalid",
11672 "configured source repository identity is invalid",
11673 false,
11674 )
11675 })?;
11676 let source = self
11677 .lock_transport()?
11678 .repository(&source_repository)
11679 .map_err(|error| {
11680 provider_failure(
11681 "source_repository_visibility_unavailable",
11682 transport_public_message(error),
11683 true,
11684 )
11685 })?;
11686 if source.is_private() {
11687 return Err(provider_failure(
11688 "private_source_repository",
11689 "signed builds require a public GitHub source repository",
11690 false,
11691 ));
11692 }
11693 let repository = self
11694 .lock_transport()?
11695 .repository(&self.config.repository)
11696 .map_err(|error| {
11697 provider_failure(
11698 "signing_repository_visibility_unavailable",
11699 transport_public_message(error),
11700 true,
11701 )
11702 })?;
11703 if !repository.is_private() {
11704 return Err(provider_failure(
11705 "public_signing_repository",
11706 "signed artifacts require a private GitHub execution repository",
11707 false,
11708 ));
11709 }
11710 if repository.is_archived() || repository.is_disabled() {
11711 return Err(provider_failure(
11712 "signing_repository_read_only",
11713 "the private GitHub signing repository cannot run new builds",
11714 false,
11715 ));
11716 }
11717 let mut signing_checks = Vec::new();
11718 let mut transport = self.lock_transport()?;
11719 append_signing_environment_checks(
11720 &mut signing_checks,
11721 &mut transport,
11722 &self.config,
11723 );
11724 if signing_checks
11725 .iter()
11726 .any(|check| check.status != ProviderCheckStatus::Ready)
11727 {
11728 return Err(provider_failure(
11729 "signing_environment_not_ready",
11730 "protected signing environment policy or secret metadata is not ready",
11731 false,
11732 ));
11733 }
11734 cancellation.check()?;
11735 }
11736 let publish_request = TemporaryRefPublishRequest {
11737 repository: &self.config.repository,
11738 source_repository: &self.config.source_repository,
11739 trusted_source_ref: self.config.workflow.trusted_source_ref(),
11740 source_revision: &source_revision,
11741 snapshot_source_ref: None,
11742 temporary_ref: &temporary_ref,
11743 workflow_path: workflow.path(),
11744 workflow_bytes: workflow.yaml().as_bytes(),
11745 workflow_fingerprint: &self.config.workflow_fingerprint,
11746 manifest_bytes: &manifest_bytes,
11747 operation_id: &request.operation_id,
11748 created_at_ms,
11749 authorized: self.config.mutation_authorization.publish_temporary_ref,
11750 };
11751 let prepared = {
11752 let mut publisher = self.lock_publisher()?;
11753 let prepared = publisher
11754 .prepare(&publish_request)
11755 .map_err(publisher_failure)?;
11756 if prepared
11757 .as_ref()
11758 .is_some_and(|publication| publication.temporary_ref() != &temporary_ref)
11759 {
11760 return Err(publisher_failure(
11761 TemporaryRefPublishError::PublicationVerificationFailed,
11762 ));
11763 }
11764 prepared
11765 };
11766 let (
11767 publication_process_fenced,
11768 publication_lease_scope_sha256,
11769 publication_lease_owned,
11770 ) = {
11771 let mut publisher = self.lock_publisher()?;
11772 let lease_request = TemporaryRefPublicationLeaseRequest {
11773 repository: &self.config.repository,
11774 operation_id: &request.operation_id,
11775 temporary_ref: &temporary_ref,
11776 };
11777 let lease = publisher
11778 .acquire_publication_lease(&lease_request)
11779 .map_err(publisher_failure)?;
11780 match lease {
11781 TemporaryRefPublicationLease::Acquired => {
11782 let scope = publisher
11783 .publication_lease_scope_sha256()
11784 .filter(|scope| is_lower_sha256(scope))
11785 .map(str::to_owned);
11786 let Some(scope) = scope else {
11787 publisher.release_publication_lease(&lease_request);
11788 return Err(publisher_failure(
11789 TemporaryRefPublishError::LocalIsolationFailed,
11790 ));
11791 };
11792 (
11793 publisher.publication_attempt_is_process_fenced(),
11794 Some(scope),
11795 true,
11796 )
11797 }
11798 TemporaryRefPublicationLease::Unsupported => (false, None, false),
11799 TemporaryRefPublicationLease::AlreadyOwned
11800 | TemporaryRefPublicationLease::HeldByOther => {
11801 return Err(provider_failure(
11802 "publication_owner_active",
11803 "Another live provider owns temporary-ref publication",
11804 true,
11805 ));
11806 }
11807 }
11808 };
11809 let mut state = self.lock_state()?;
11810 let record = state.jobs.get_mut(&job_id).ok_or_else(|| {
11811 provider_failure(
11812 "pending_job_lost",
11813 "pending GitHub job disappeared before publication",
11814 false,
11815 )
11816 })?;
11817 record.prepared_dispatch_commit = prepared
11818 .as_ref()
11819 .map(|publication| publication.commit().clone());
11820 record.publication_started_at_ms = 0;
11821 record.publication_quiescence_deadline_ms = u64::MAX;
11822 record.publication_intent = true;
11823 record.publication_process_fenced = publication_process_fenced;
11824 record.publication_lease_scope_sha256 = publication_lease_scope_sha256;
11825 record.publication_takeover_lease_owned = publication_lease_owned;
11826 if self.checkpoint_sink.is_some() {
11827 pending.retain();
11828 }
11829 self.checkpoint_record(record)?;
11830 let prepared_dispatch_commit = record.prepared_dispatch_commit.clone();
11831 drop(state);
11832 let publication_identity = if resume_identity.is_some() {
11833 self.current_resume_identity().map(Some)
11834 } else {
11835 Ok(None)
11836 };
11837 let boundary_cancellation = cancellation.check();
11838 let mut state = self.lock_state()?;
11839 let record = state.jobs.get_mut(&job_id).ok_or_else(|| {
11840 provider_failure(
11841 "pending_job_lost",
11842 "pending GitHub job disappeared before publication",
11843 false,
11844 )
11845 })?;
11846 let publication_boundary = publication_identity
11847 .and_then(|identity| boundary_cancellation.map(|()| identity))
11848 .and_then(|identity| {
11849 identity.as_ref().map_or(Ok(()), |identity| {
11850 Self::validate_record_identity(record, identity)
11851 })
11852 })
11853 .and_then(|()| {
11854 if record.prepared_dispatch_commit != prepared_dispatch_commit
11855 || !record.publication_intent
11856 || record.publication_uncertain
11857 || record.dispatch_commit.is_some()
11858 {
11859 return Err(resume_failure("publication_identity_changed_before_push"));
11860 }
11861 if record.cancellation_requested {
11862 return Err(provider_failure(
11863 "publication_cancelled_before_mutation",
11864 "GitHub publication was cancelled before any remote ref mutation",
11865 false,
11866 ));
11867 }
11868 Ok(())
11869 });
11870 if let Err(error) = publication_boundary {
11871 mark_publication_not_attempted(
11872 record,
11873 self.clock.now_ms(),
11874 "publication_identity_revalidation_failed",
11875 "GitHub identity changed before temporary-ref publication began",
11876 true,
11877 )?;
11878 self.checkpoint_record(record)?;
11879 let release_publication_lease = record.publication_takeover_lease_owned;
11880 record.publication_takeover_lease_owned = false;
11881 drop(state);
11882 if release_publication_lease {
11883 self.lock_publisher()?.release_publication_lease(
11884 &TemporaryRefPublicationLeaseRequest {
11885 repository: &self.config.repository,
11886 operation_id: &request.operation_id,
11887 temporary_ref: &temporary_ref,
11888 },
11889 );
11890 }
11891 return Err(error);
11892 }
11893 record.publication_started_at_ms = self.clock.now_ms();
11894 record.publication_quiescence_deadline_ms =
11895 publication_quiescence_deadline(record.publication_started_at_ms, 0);
11896 self.checkpoint_record(record)?;
11897 drop(state);
11898 let publish_result = self
11899 .lock_publisher()?
11900 .publish_prepared(&publish_request, prepared.as_ref());
11901 let (published, publication_uncertain) = match publish_result {
11902 Ok(published) => (published, false),
11903 Err(failure) => {
11904 if let Some(publication) = failure.possible_publication().cloned() {
11905 (publication, true)
11906 } else {
11907 let error = publisher_failure(failure.error());
11908 let mut state = self.lock_state()?;
11909 let record = state.jobs.get_mut(&job_id).ok_or_else(|| {
11910 provider_failure(
11911 "pending_job_lost",
11912 "pending GitHub job disappeared after publication failure",
11913 false,
11914 )
11915 })?;
11916 mark_publication_not_attempted(
11917 record,
11918 self.clock.now_ms(),
11919 "temporary_ref_publication_not_attempted",
11920 "Temporary-ref publication failed before any remote mutation",
11921 false,
11922 )?;
11923 self.checkpoint_record(record)?;
11924 self.release_record_publication_lease(record)?;
11925 return Err(error);
11926 }
11927 }
11928 };
11929 pending.retain();
11930 let publisher_binding_mismatch = published.temporary_ref() != &temporary_ref
11931 || prepared
11932 .as_ref()
11933 .is_some_and(|expected| expected.commit() != published.commit());
11934 let mut state = self.lock_state()?;
11935 let record = state.jobs.get_mut(&job_id).ok_or_else(|| {
11936 provider_failure(
11937 "pending_job_lost",
11938 "pending GitHub job disappeared during publication",
11939 false,
11940 )
11941 })?;
11942 record.dispatch_commit = (!publisher_binding_mismatch && !publication_uncertain)
11943 .then(|| published.commit().clone());
11944 record.publication_uncertain = publication_uncertain || publisher_binding_mismatch;
11945 self.checkpoint_record(record)?;
11946 if publisher_binding_mismatch {
11947 transition_record(record, JobState::Failed)?;
11948 append_event(
11949 record,
11950 self.clock.now_ms(),
11951 "cleanup",
11952 RemoteBuildEventKind::Warning {
11953 code: "github.cleanup_required".to_owned(),
11954 message: "Temporary-ref publisher violated its prepared ref/commit binding; automatic adoption was refused"
11955 .to_owned(),
11956 help: Some("Inspect the alternate publisher and remote ref manually".to_owned()),
11957 },
11958 )?;
11959 append_failed(
11960 record,
11961 self.clock.now_ms(),
11962 "publisher_binding_mismatch",
11963 "Temporary-ref publisher returned a different ref or commit after possible mutation",
11964 false,
11965 )?;
11966 self.checkpoint_record(record)?;
11967 self.release_record_publication_lease(record)?;
11968 return Ok(JobHandle {
11969 job_id,
11970 state: JobState::Failed,
11971 created_at_ms,
11972 });
11973 }
11974 if publication_uncertain {
11975 append_event(
11976 record,
11977 self.clock.now_ms(),
11978 "submit",
11979 RemoteBuildEventKind::Warning {
11980 code: "github.temporary_ref_publication_uncertain".to_owned(),
11981 message: "Temporary ref push may have succeeded; cleanup ownership was retained"
11982 .to_owned(),
11983 help: Some(
11984 "Continue polling this job or run exact-ref cleanup after it becomes terminal"
11985 .to_owned(),
11986 ),
11987 },
11988 )?;
11989 }
11990 if !record.publication_uncertain
11991 && self.config.workflow.run_trigger() == WorkflowRunTrigger::Push
11992 {
11993 if record.state == JobState::Created {
11994 transition_record(record, JobState::Queued)?;
11995 }
11996 append_event(
11997 record,
11998 created_at_ms,
11999 "queue",
12000 RemoteBuildEventKind::JobQueued { position: None },
12001 )?;
12002 }
12003 record.cancellation_requested |= cancellation.is_cancelled();
12004 if record.cancellation_requested && record.state != JobState::Cancelling {
12005 transition_record(record, JobState::Cancelling)?;
12006 }
12007 self.checkpoint_record(record)?;
12008 if !record.publication_uncertain {
12009 self.release_record_publication_lease(record)?;
12010 }
12011 let start_dispatch = !record.publication_uncertain
12012 && self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch;
12013 drop(state);
12014 if start_dispatch {
12015 let client = workflow_dispatch_client.as_deref_mut().ok_or_else(|| {
12016 provider_failure(
12017 "workflow_dispatch_client_unavailable",
12018 "Preflighted workflow dispatch client is unavailable",
12019 true,
12020 )
12021 })?;
12022 self.start_workflow_dispatch_with_client(&job_id, client, &cancellation)?;
12023 }
12024 let returned_state = self
12025 .lock_state()?
12026 .jobs
12027 .get(&job_id)
12028 .ok_or_else(|| job_not_found(&job_id))?
12029 .state;
12030 Ok(JobHandle {
12031 job_id,
12032 state: returned_state,
12033 created_at_ms,
12034 })
12035 })();
12036 Box::pin(async move { result })
12037 }
12038
12039 fn events(
12040 &self,
12041 request: EventRequest,
12042 cancellation: CancellationToken,
12043 ) -> ProviderFuture<'_, EventPage> {
12044 let result = (|| {
12045 request.validate()?;
12046 cancellation.check()?;
12047 self.sync_job(&request.job_id, &cancellation)?;
12048 let state = self.lock_state()?;
12049 let record = state
12050 .jobs
12051 .get(&request.job_id)
12052 .ok_or_else(|| job_not_found(&request.job_id))?;
12053 let after = request.after_sequence.unwrap_or(0);
12054 let mut matching = record
12055 .events
12056 .iter()
12057 .filter(|event| event.sequence > after)
12058 .cloned();
12059 let events = matching
12060 .by_ref()
12061 .take(request.limit as usize)
12062 .collect::<Vec<_>>();
12063 let truncated = matching.next().is_some();
12064 let next_sequence = events.last().map(|event| event.sequence);
12065 Ok(EventPage {
12066 job_id: record.job_id.clone(),
12067 state: record.state,
12068 events,
12069 next_sequence,
12070 complete: record.state.is_terminal() && !truncated,
12071 })
12072 })();
12073 Box::pin(async move { result })
12074 }
12075
12076 #[allow(clippy::too_many_lines)]
12077 fn cancel(
12078 &self,
12079 request: CancellationRequest,
12080 cancellation: CancellationToken,
12081 ) -> ProviderFuture<'_, CancellationAck> {
12082 let result = (|| {
12083 cancellation.check()?;
12084 validate_provider_identifier("job_id", &request.job_id)?;
12085 validate_provider_identifier("cancellation_reason", &request.reason)?;
12086 if !self.config.mutation_authorization.cancel_run {
12087 return Err(unsupported(ProviderFeature::Cancellation));
12088 }
12089 if self.config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch {
12090 let accepted = {
12091 let mut state = self.lock_state()?;
12092 let record = state
12093 .jobs
12094 .get_mut(&request.job_id)
12095 .ok_or_else(|| job_not_found(&request.job_id))?;
12096 if !record.publication_intent {
12097 return Err(provider_failure(
12098 "job_submission_pending",
12099 "GitHub job has not reached its durable publication boundary",
12100 true,
12101 ));
12102 }
12103 if record.state.is_build_terminal() || record.state.is_terminal() {
12104 self.checkpoint_record(record)?;
12105 return Ok(CancellationAck {
12106 job_id: record.job_id.clone(),
12107 accepted: false,
12108 state: record.state,
12109 });
12110 }
12111 let accepted = !record.cancellation_requested;
12112 record.cancellation_requested = true;
12113 if record.state != JobState::Cancelling {
12114 transition_record(record, JobState::Cancelling)?;
12115 }
12116 self.checkpoint_record(record)?;
12117 accepted
12118 };
12119 self.sync_job_with_cancel_policy(&request.job_id, &CancellationToken::new(), true)?;
12120 let state = self.lock_state()?;
12121 let record = state
12122 .jobs
12123 .get(&request.job_id)
12124 .ok_or_else(|| job_not_found(&request.job_id))?;
12125 return Ok(CancellationAck {
12126 job_id: record.job_id.clone(),
12127 accepted,
12128 state: record.state,
12129 });
12130 }
12131 let mut state = self.lock_state()?;
12132 let record = state
12133 .jobs
12134 .get_mut(&request.job_id)
12135 .ok_or_else(|| job_not_found(&request.job_id))?;
12136 if !record.publication_intent {
12137 return Err(provider_failure(
12138 "job_submission_pending",
12139 "GitHub job has not reached its durable publication boundary",
12140 true,
12141 ));
12142 }
12143 if record.state.is_build_terminal() || record.state.is_terminal() {
12144 self.checkpoint_record(record)?;
12145 return Ok(CancellationAck {
12146 job_id: record.job_id.clone(),
12147 accepted: false,
12148 state: record.state,
12149 });
12150 }
12151 let accepted = !record.cancellation_requested;
12152 record.cancellation_requested = true;
12153 if record.state != JobState::Cancelling {
12154 transition_record(record, JobState::Cancelling)?;
12155 }
12156 self.checkpoint_record(record)?;
12157 if let Some(handle) = record.run.clone()
12158 && !record.cancellation_dispatched
12159 {
12160 self.revalidate_record_identity(record)?;
12161 let snapshot = self
12162 .lock_transport()?
12163 .run(&self.config.repository, &handle)
12164 .map_err(transport_failure)?;
12165 ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot)?;
12166 record.run_snapshot = Some(snapshot.clone());
12167 if snapshot.status().is_terminal() {
12168 self.finish_from_snapshot(record, &snapshot)?;
12169 } else {
12170 self.checkpoint_record(record)?;
12171 let checked = self
12172 .lock_transport()?
12173 .run(&self.config.repository, &handle)
12174 .map_err(transport_failure)?;
12175 ensure_same_run_attempt(record.run_snapshot.as_ref(), &checked)?;
12176 record.run_snapshot = Some(checked.clone());
12177 if checked.status().is_terminal() {
12178 self.finish_from_snapshot(record, &checked)?;
12179 } else {
12180 self.revalidate_record_identity(record)?;
12181 self.lock_transport()?
12184 .cancel_run(&self.config.repository, handle.id())
12185 .map_err(transport_failure)?;
12186 record.cancellation_dispatched = true;
12187 self.checkpoint_record(record)?;
12188 }
12189 }
12190 }
12191 self.checkpoint_record(record)?;
12192 Ok(CancellationAck {
12193 job_id: record.job_id.clone(),
12194 accepted,
12195 state: record.state,
12196 })
12197 })();
12198 Box::pin(async move { result })
12199 }
12200
12201 fn list_artifacts(
12202 &self,
12203 request: ArtifactListRequest,
12204 cancellation: CancellationToken,
12205 ) -> ProviderFuture<'_, Vec<ArtifactManifest>> {
12206 let result = (|| {
12207 cancellation.check()?;
12208 validate_provider_identifier("job_id", &request.job_id)?;
12209 if !self.capabilities.artifact_listing {
12210 return Err(unsupported(ProviderFeature::ArtifactListing));
12211 }
12212 self.sync_job(&request.job_id, &cancellation)?;
12213 let state = self.lock_state()?;
12214 let record = state
12215 .jobs
12216 .get(&request.job_id)
12217 .ok_or_else(|| job_not_found(&request.job_id))?;
12218 if !record.manifests.is_empty() {
12219 return Ok(record.manifests.clone());
12220 }
12221 let context = Self::artifact_context(record, &self.config).ok_or_else(|| {
12222 provider_failure(
12223 "artifacts_pending",
12224 "GitHub run has not completed artifact production",
12225 true,
12226 )
12227 })?;
12228 drop(state);
12229 let manifests = self.lock_artifacts()?.list_verified(&context)?;
12230 validate_manifests_for_context(&context, &manifests)?;
12231 Ok(manifests)
12232 })();
12233 Box::pin(async move { result })
12234 }
12235
12236 fn download_artifact(
12237 &self,
12238 request: ArtifactDownloadRequest,
12239 cancellation: CancellationToken,
12240 ) -> ProviderFuture<'_, ArtifactDownloadResult> {
12241 let result = (|| {
12242 request.validate()?;
12243 cancellation.check()?;
12244 if !self.capabilities.artifact_download {
12245 return Err(unsupported(ProviderFeature::ArtifactDownload));
12246 }
12247 self.sync_job(&request.job_id, &cancellation)?;
12248 let state = self.lock_state()?;
12249 let record = state
12250 .jobs
12251 .get(&request.job_id)
12252 .ok_or_else(|| job_not_found(&request.job_id))?;
12253 let context = Self::artifact_context(record, &self.config).ok_or_else(|| {
12254 provider_failure(
12255 "artifact_pending",
12256 "GitHub run has not completed artifact production",
12257 true,
12258 )
12259 })?;
12260 drop(state);
12261 let result = self
12262 .lock_artifacts()?
12263 .download_verified(&context, &request)?;
12264 if result.manifest.operation_id != context.operation_id
12265 || result.manifest.job_id != context.job_id
12266 || result.local_path != request.destination
12267 {
12268 return Err(provider_failure(
12269 "artifact_identity_mismatch",
12270 "verified artifact result does not match the exact request",
12271 false,
12272 ));
12273 }
12274 Ok(result)
12275 })();
12276 Box::pin(async move { result })
12277 }
12278
12279 #[allow(clippy::too_many_lines)]
12280 fn cleanup(
12281 &self,
12282 request: CleanupRequest,
12283 cancellation: CancellationToken,
12284 ) -> ProviderFuture<'_, CleanupConfirmation> {
12285 let result = (|| {
12286 cancellation.check()?;
12287 validate_provider_identifier("job_id", &request.job_id)?;
12288 if !self.config.mutation_authorization.delete_temporary_ref {
12289 return Err(unsupported(ProviderFeature::Cleanup));
12290 }
12291 let mut state = self.lock_state()?;
12292 let record = state
12293 .jobs
12294 .get_mut(&request.job_id)
12295 .ok_or_else(|| job_not_found(&request.job_id))?;
12296 let terminal_run = record
12297 .run_snapshot
12298 .as_ref()
12299 .is_some_and(|snapshot| snapshot.status().is_terminal());
12300 if !record.state.is_build_terminal() && !record.state.is_terminal() && terminal_run {
12301 ensure_running_or_cancelling(record)?;
12302 transition_record(record, JobState::Failed)?;
12303 append_failed(
12304 record,
12305 self.clock.now_ms(),
12306 "artifact_verification_failed",
12307 "GitHub Actions completed, but independent artifact verification did not complete",
12308 false,
12309 )?;
12310 }
12311 if !record.state.is_build_terminal()
12312 && !matches!(record.state, JobState::Cleaned | JobState::CleanupFailed)
12313 {
12314 return Err(provider_failure(
12315 "cleanup_before_terminal",
12316 "GitHub job must reach a terminal run state before cleanup",
12317 true,
12318 ));
12319 }
12320 let signed_cleanup_proven = self.has_valid_signed_cleanup_evidence(record)?;
12321 if record.state == JobState::Cleaned {
12322 self.checkpoint_record(record)?;
12323 return Err(provider_failure(
12324 "cleanup_already_attempted",
12325 "GitHub job cleanup was already attempted",
12326 false,
12327 ));
12328 }
12329 if record.cleanup_requested
12330 && record.remove_artifacts_requested != request.remove_artifacts
12331 {
12332 return Err(provider_failure(
12333 "cleanup_resume_request_mismatch",
12334 "cleanup retry differs from its durable artifact-removal intent",
12335 false,
12336 ));
12337 }
12338 if request.remove_artifacts && !record.artifacts_removed {
12339 Self::artifact_context(record, &self.config).ok_or_else(|| {
12340 provider_failure(
12341 "artifact_identity_unavailable",
12342 "exact run identity is unavailable for artifact removal",
12343 false,
12344 )
12345 })?;
12346 if !self.lock_artifacts()?.supports_removal() {
12347 return Err(provider_failure(
12348 "artifact_removal_unsupported",
12349 "verified artifact removal is not configured",
12350 false,
12351 ));
12352 }
12353 }
12354 if let Some(handle) = record.run.clone() {
12355 self.revalidate_record_identity(record)?;
12356 let snapshot = self
12357 .lock_transport()?
12358 .run(&self.config.repository, &handle)
12359 .map_err(transport_failure)?;
12360 match ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot) {
12361 Ok(()) => {
12362 self.revalidate_record_identity(record)?;
12363 if !snapshot.status().is_terminal() {
12364 return Err(resume_failure("cleanup_live_run_not_terminal"));
12365 }
12366 record.run_snapshot = Some(snapshot);
12367 }
12368 Err(_)
12369 if signed_cleanup_proven
12370 && record.run_snapshot.as_ref().is_some_and(|previous| {
12371 github_run_is_later_attempt(previous, &snapshot)
12372 }) =>
12373 {
12374 self.revalidate_record_identity(record)?;
12375 }
12376 Err(error) => return Err(error),
12377 }
12378 }
12379 let context = Self::artifact_context(record, &self.config);
12380 record.cleanup_requested = true;
12381 record.remove_artifacts_requested = request.remove_artifacts;
12382 self.checkpoint_record(record)?;
12383 if let Some(handle) = record.run.clone() {
12384 let snapshot = self
12385 .lock_transport()?
12386 .run(&self.config.repository, &handle)
12387 .map_err(transport_failure)?;
12388 match ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot) {
12389 Ok(()) => {
12390 self.revalidate_record_identity(record)?;
12391 if !snapshot.status().is_terminal() {
12392 return Err(resume_failure("cleanup_live_run_not_terminal"));
12393 }
12394 record.run_snapshot = Some(snapshot);
12395 }
12396 Err(_)
12397 if signed_cleanup_proven
12398 && record.run_snapshot.as_ref().is_some_and(|previous| {
12399 github_run_is_later_attempt(previous, &snapshot)
12400 }) =>
12401 {
12402 self.revalidate_record_identity(record)?;
12403 }
12404 Err(error) => return Err(error),
12405 }
12406 }
12407 if record.remove_artifacts_requested && !record.artifacts_removed {
12408 let context = context.as_ref().ok_or_else(|| {
12409 provider_failure(
12410 "artifact_identity_unavailable",
12411 "exact run identity is unavailable for artifact removal",
12412 false,
12413 )
12414 })?;
12415 self.revalidate_record_identity(record)?;
12416 self.lock_artifacts()?.remove_artifacts(context)?;
12417 record.artifacts_removed = true;
12418 self.checkpoint_record(record)?;
12419 }
12420 self.cleanup_git_snapshot_source(record)?;
12421 if record.publication_intent
12422 && !record.temporary_ref_deleted
12423 && !record.publication_not_attempted
12424 {
12425 let expected_commit = record.dispatch_commit.as_ref().ok_or_else(|| {
12426 provider_failure(
12427 "dispatch_commit_unavailable",
12428 "exact dispatch commit is unavailable for safe ref cleanup",
12429 false,
12430 )
12431 })?;
12432 self.revalidate_record_identity(record)?;
12433 self.lock_publisher()?
12434 .delete_temporary_ref(&TemporaryRefDeleteRequest {
12435 repository: &self.config.repository,
12436 source_repository: &self.config.source_repository,
12437 temporary_ref: &record.temporary_ref,
12438 expected_commit,
12439 authorized: self.config.mutation_authorization.delete_temporary_ref,
12440 })
12441 .map_err(publisher_failure)?;
12442 record.temporary_ref_deleted = true;
12443 self.checkpoint_record(record)?;
12444 }
12445 if let Some(handle) = record.run.clone() {
12446 let snapshot = self
12447 .lock_transport()?
12448 .run(&self.config.repository, &handle)
12449 .map_err(transport_failure)?;
12450 match ensure_same_run_attempt(record.run_snapshot.as_ref(), &snapshot) {
12451 Ok(()) => {
12452 self.revalidate_record_identity(record)?;
12453 if !snapshot.status().is_terminal() {
12454 return Err(provider_failure(
12455 "cleanup_run_reactivated_after_mutation",
12456 "GitHub run became active during cleanup",
12457 true,
12458 ));
12459 }
12460 record.run_snapshot = Some(snapshot);
12461 }
12462 Err(_)
12463 if signed_cleanup_proven
12464 && record.run_snapshot.as_ref().is_some_and(|previous| {
12465 github_run_is_later_attempt(previous, &snapshot)
12466 }) =>
12467 {
12468 self.revalidate_record_identity(record)?;
12469 }
12470 Err(_) => {
12471 return Err(provider_failure(
12472 "cleanup_run_changed_after_mutation",
12473 "GitHub run attempt changed during cleanup",
12474 true,
12475 ));
12476 }
12477 }
12478 }
12479 transition_record(record, JobState::Cleaning)?;
12480 append_event(
12481 record,
12482 self.clock.now_ms(),
12483 "cleanup",
12484 RemoteBuildEventKind::CleanupStarted,
12485 )?;
12486
12487 let run_completed = record
12488 .run_snapshot
12489 .as_ref()
12490 .is_some_and(|snapshot| snapshot.status().is_terminal());
12491 let signing_material_removed = record.request.signing.mode
12492 == SigningMode::UnsignedCompileOnly
12493 || signed_cleanup_proven;
12494 let confirmation = CleanupConfirmation {
12495 job_id: record.job_id.clone(),
12496 completed_at_ms: self.clock.now_ms(),
12497 workspace_removed: run_completed,
12498 signing_material_removed,
12499 artifacts_retained: !request.remove_artifacts,
12500 };
12501 let cleanup_state = if run_completed && signing_material_removed {
12502 JobState::Cleaned
12503 } else {
12504 JobState::CleanupFailed
12505 };
12506 transition_record(record, cleanup_state)?;
12507 append_event(
12508 record,
12509 self.clock.now_ms(),
12510 "cleanup",
12511 RemoteBuildEventKind::CleanupFinished {
12512 confirmation: confirmation.clone(),
12513 },
12514 )?;
12515 self.checkpoint_record(record)?;
12516 Ok(confirmation)
12517 })();
12518 Box::pin(async move { result })
12519 }
12520}
12521
12522fn provider_capabilities(
12523 config: &GithubProviderConfig,
12524 artifacts: &impl VerifiedArtifactStore,
12525 snapshot_publisher_supported: bool,
12526) -> ProviderCapabilities {
12527 let mut source_modes = BTreeSet::from([SourceMode::Git]);
12528 let authorization = config.mutation_authorization;
12529 if snapshot_publisher_supported
12530 && authorization.publish_temporary_ref
12531 && authorization.delete_temporary_ref
12532 && authorization.publish_git_snapshot_source
12533 && authorization.delete_git_snapshot_source
12534 && authorization.manage_git_snapshot_keepalive
12535 {
12536 source_modes.insert(SourceMode::GitSnapshot);
12537 }
12538 ProviderCapabilities {
12539 source_modes,
12540 ios_device_build: true,
12541 ios_simulator_build: false,
12542 signing_modes: BTreeSet::from([
12543 SigningMode::ManualDevelopment,
12544 SigningMode::UnsignedCompileOnly,
12545 ]),
12546 personal_team: false,
12547 live_events: true,
12548 live_logs: false,
12549 cancellation: config.mutation_authorization.cancel_run,
12550 artifact_types: BTreeSet::from([
12551 IosArtifactType::Ipa,
12552 IosArtifactType::AppBundle,
12553 IosArtifactType::Dsym,
12554 IosArtifactType::SigningReport,
12555 IosArtifactType::Xcarchive,
12556 ]),
12557 cache: true,
12558 max_source_bytes: None,
12559 retention_seconds: Some(
12560 u64::from(config.workflow.limits().retention_days()) * 24 * 60 * 60,
12561 ),
12562 artifact_listing: artifacts.supports_listing(),
12563 artifact_download: artifacts.supports_download(),
12564 cleanup: config.mutation_authorization.delete_temporary_ref,
12565 physical_device_access: false,
12566 }
12567}
12568
12569fn lock_provider_state(
12570 state: &Mutex<ProviderState>,
12571) -> RemoteBuildResult<std::sync::MutexGuard<'_, ProviderState>> {
12572 state.lock().map_err(|_| {
12573 provider_failure(
12574 "state_unavailable",
12575 "GitHub provider state is unavailable",
12576 true,
12577 )
12578 })
12579}
12580
12581fn append_signing_environment_checks<R: GhRunner>(
12582 checks: &mut Vec<ProviderCheck>,
12583 transport: &mut GithubTransport<R>,
12584 config: &GithubProviderConfig,
12585) {
12586 append_signing_environment_policy_checks(checks, transport, config);
12587 append_signing_branch_policy_check(checks, transport, config);
12588 append_signing_secret_check(checks, transport, config);
12589}
12590
12591fn append_signing_environment_policy_checks<R: GhRunner>(
12592 checks: &mut Vec<ProviderCheck>,
12593 transport: &mut GithubTransport<R>,
12594 config: &GithubProviderConfig,
12595) {
12596 match transport.environment(
12597 config.repository(),
12598 config.workflow().protected_environment(),
12599 ) {
12600 Ok(info) => {
12601 checks.push(if !info.protected_branches() && info.custom_branch_policies() {
12602 ProviderCheck {
12603 code: "github.signing_environment".to_owned(),
12604 status: ProviderCheckStatus::Ready,
12605 message: "Signing environment requires custom deployment branch policies"
12606 .to_owned(),
12607 help: None,
12608 }
12609 } else {
12610 ProviderCheck {
12611 code: "github.signing_environment".to_owned(),
12612 status: ProviderCheckStatus::Error,
12613 message: "Signing environment does not exclusively use custom deployment branch policies"
12614 .to_owned(),
12615 help: Some(
12616 "Disable protected-branch selection and enable custom branch policies"
12617 .to_owned(),
12618 ),
12619 }
12620 });
12621 checks.push(if info.has_required_reviewers() {
12622 ProviderCheck {
12623 code: "github.signing_environment.reviewers".to_owned(),
12624 status: ProviderCheckStatus::Ready,
12625 message: "Signing environment requires a deployment reviewer".to_owned(),
12626 help: None,
12627 }
12628 } else {
12629 ProviderCheck {
12630 code: "github.signing_environment.reviewers".to_owned(),
12631 status: ProviderCheckStatus::Error,
12632 message: "Signing environment has no required deployment reviewer".to_owned(),
12633 help: Some(
12634 "Require server-side deployment approval until signing uses a trusted SHA-pinned reusable workflow"
12635 .to_owned(),
12636 ),
12637 }
12638 });
12639 }
12640 Err(error) => checks.push(ProviderCheck {
12641 code: "github.signing_environment".to_owned(),
12642 status: ProviderCheckStatus::Error,
12643 message: transport_public_message(error),
12644 help: Some("Create the exact configured GitHub signing environment".to_owned()),
12645 }),
12646 }
12647}
12648
12649fn append_signing_branch_policy_check<R: GhRunner>(
12650 checks: &mut Vec<ProviderCheck>,
12651 transport: &mut GithubTransport<R>,
12652 config: &GithubProviderConfig,
12653) {
12654 let workflow = config.workflow();
12655 let expected_policy = format!("{}/*", workflow.temporary_branch_namespace().as_str());
12656 match transport.list_deployment_branch_policies(
12657 config.repository(),
12658 workflow.protected_environment(),
12659 ) {
12660 Ok(policies) if policies.len() == 1 && policies[0].name() == expected_policy.as_str() => {
12661 checks.push(ProviderCheck {
12662 code: "github.signing_branch_policy".to_owned(),
12663 status: ProviderCheckStatus::Ready,
12664 message: "Signing environment allows only the temporary build namespace"
12665 .to_owned(),
12666 help: None,
12667 });
12668 }
12669 Ok(_) => checks.push(ProviderCheck {
12670 code: "github.signing_branch_policy".to_owned(),
12671 status: ProviderCheckStatus::Error,
12672 message: "Signing environment deployment branch policies do not exactly match the temporary build namespace"
12673 .to_owned(),
12674 help: Some(format!("Configure the single deployment branch policy `{expected_policy}`")),
12675 }),
12676 Err(error) => checks.push(ProviderCheck {
12677 code: "github.signing_branch_policy".to_owned(),
12678 status: ProviderCheckStatus::Error,
12679 message: transport_public_message(error),
12680 help: Some(format!("Configure the single deployment branch policy `{expected_policy}`")),
12681 }),
12682 }
12683}
12684
12685fn append_signing_secret_check<R: GhRunner>(
12686 checks: &mut Vec<ProviderCheck>,
12687 transport: &mut GithubTransport<R>,
12688 config: &GithubProviderConfig,
12689) {
12690 let workflow = config.workflow();
12691 let secret_names = workflow.secret_names();
12692 let expected_secrets = secret_names.all_names().cloned().collect::<BTreeSet<_>>();
12693 match transport.list_environment_secrets(config.repository(), workflow.protected_environment())
12694 {
12695 Ok(secrets)
12696 if secrets
12697 .iter()
12698 .map(|secret| secret.name().clone())
12699 .collect::<BTreeSet<_>>()
12700 == expected_secrets =>
12701 {
12702 checks.push(ProviderCheck {
12703 code: "github.signing_secrets".to_owned(),
12704 status: ProviderCheckStatus::Ready,
12705 message: "Signing environment contains exactly the configured signing secret names"
12706 .to_owned(),
12707 help: None,
12708 });
12709 }
12710 Ok(_) => checks.push(ProviderCheck {
12711 code: "github.signing_secrets".to_owned(),
12712 status: ProviderCheckStatus::Error,
12713 message:
12714 "Signing environment secret metadata does not match the configured signing roles"
12715 .to_owned(),
12716 help: Some(
12717 "Configure the certificate, password, and provisioning-profile environment secrets"
12718 .to_owned(),
12719 ),
12720 }),
12721 Err(error) => checks.push(ProviderCheck {
12722 code: "github.signing_secrets".to_owned(),
12723 status: ProviderCheckStatus::Error,
12724 message: transport_public_message(error),
12725 help: Some(
12726 "Grant environment-secret metadata access and configure all signing roles"
12727 .to_owned(),
12728 ),
12729 }),
12730 }
12731}
12732
12733fn validate_submission(
12734 config: &GithubProviderConfig,
12735 request: &IosDeviceBuildRequest,
12736) -> RemoteBuildResult<()> {
12737 validate_provider_request(config, request)?;
12738 if request.source_mode != SourceMode::Git {
12739 return Err(provider_failure(
12740 "snapshot_submission_requires_consent",
12741 "Git snapshots require the explicit consent-bound submission path",
12742 false,
12743 ));
12744 }
12745 Ok(())
12746}
12747
12748fn validate_git_snapshot_submission(
12749 config: &GithubProviderConfig,
12750 request: &IosDeviceBuildRequest,
12751) -> RemoteBuildResult<()> {
12752 validate_provider_request(config, request)?;
12753 if request.source_mode != SourceMode::GitSnapshot
12754 || request.signing.mode != SigningMode::UnsignedCompileOnly
12755 {
12756 return Err(provider_failure(
12757 "unsupported_snapshot_request",
12758 "GitHub snapshots require an unsigned physical-iPhone XCArchive request",
12759 false,
12760 ));
12761 }
12762 let authorization = config.mutation_authorization;
12763 if !authorization.publish_temporary_ref
12764 || !authorization.delete_temporary_ref
12765 || !authorization.publish_git_snapshot_source
12766 || !authorization.delete_git_snapshot_source
12767 || !authorization.manage_git_snapshot_keepalive
12768 {
12769 return Err(provider_failure(
12770 "snapshot_mutation_unauthorized",
12771 "Git snapshot publication and cleanup authority are not configured",
12772 false,
12773 ));
12774 }
12775 Ok(())
12776}
12777
12778fn validate_provider_request(
12779 config: &GithubProviderConfig,
12780 request: &IosDeviceBuildRequest,
12781) -> RemoteBuildResult<()> {
12782 if request.signing.mode == SigningMode::ManualDevelopment
12783 && !(1..=MAX_SIGNING_PROFILES).contains(&request.signing.provisioning.len())
12784 {
12785 return Err(provider_failure(
12786 "profile_mapping_unsupported",
12787 "GitHub provider accepts one application profile and at most two extension profiles",
12788 false,
12789 ));
12790 }
12791 request.validate()?;
12792 if !matches!(
12793 request.source_mode,
12794 SourceMode::Git | SourceMode::GitSnapshot
12795 ) || request.source_repository.as_deref() != Some(config.source_repository())
12796 || request.source_revision.is_none()
12797 {
12798 return Err(provider_failure(
12799 "unsupported_source_request",
12800 "GitHub provider requires its exact configured Git source repository and commit",
12801 false,
12802 ));
12803 }
12804 match (request.source_mode, request.signing.mode) {
12805 (SourceMode::Git | SourceMode::GitSnapshot, SigningMode::UnsignedCompileOnly) => {
12806 validate_exact_artifact_request(
12807 request,
12808 &BTreeSet::from([IosArtifactType::Xcarchive]),
12809 )?;
12810 }
12811 (SourceMode::GitSnapshot, _) => {
12812 return Err(provider_failure(
12813 "snapshot_signing_unsupported",
12814 "GitHub snapshots support unsigned compilation only",
12815 false,
12816 ));
12817 }
12818 (SourceMode::Git, SigningMode::ManualDevelopment) => {
12819 if github_remote_matches(
12820 config.source_repository(),
12821 &repository_url(config.repository()),
12822 ) {
12823 return Err(provider_failure(
12824 "separate_signing_repository_required",
12825 "signed builds require a separate private execution repository",
12826 false,
12827 ));
12828 }
12829 validate_signing_secret_references(config, request)?;
12830 validate_signed_artifact_request(request)?;
12831 }
12832 (_, mode) => return Err(unsupported(ProviderFeature::SigningMode(mode))),
12833 }
12834 validate_ref_segment(&request.operation_id).map_err(|()| {
12835 provider_failure(
12836 "operation_id_not_ref_safe",
12837 "operation ID cannot form a safe temporary Git ref",
12838 false,
12839 )
12840 })
12841}
12842
12843fn validate_signing_secret_references(
12844 config: &GithubProviderConfig,
12845 request: &IosDeviceBuildRequest,
12846) -> RemoteBuildResult<()> {
12847 let names = config.workflow.secret_names();
12848 let identity_references_match = request.signing.signing.as_ref().is_some_and(|signing| {
12849 exact_github_secret(
12850 &signing.identity.private_key.reference,
12851 names.certificate_p12().as_str(),
12852 ) && signing.password.as_ref().is_some_and(|password| {
12853 exact_github_secret(password, names.certificate_password().as_str())
12854 })
12855 });
12856 let profiles_match = if names.uses_legacy_profile_binding() {
12857 request.signing.provisioning.len() == 1
12858 && request.signing.provisioning.first().is_some_and(|profile| {
12859 exact_github_secret(&profile.profile, names.provisioning_profile().as_str())
12860 })
12861 } else {
12862 names.matches_target_graph(&request.signing.targets)
12863 && request.signing.provisioning.len() == names.profile_names().count()
12864 && request.signing.provisioning.iter().all(|profile| {
12865 names
12866 .profile_for_target(&profile.target)
12867 .is_some_and(|expected| {
12868 exact_github_secret(&profile.profile, expected.as_str())
12869 })
12870 })
12871 };
12872 if !identity_references_match || !profiles_match {
12873 return Err(provider_failure(
12874 "signing_secret_reference_mismatch",
12875 "signed GitHub builds require the exact configured GitHub Actions secret roles",
12876 false,
12877 ));
12878 }
12879 Ok(())
12880}
12881
12882fn exact_github_secret(reference: &SecretReference, expected_name: &str) -> bool {
12883 reference.kind() == SecretReferenceKind::GithubActions && reference.name() == expected_name
12884}
12885
12886fn validate_exact_artifact_request(
12887 request: &IosDeviceBuildRequest,
12888 expected: &BTreeSet<IosArtifactType>,
12889) -> RemoteBuildResult<()> {
12890 if request.requested_artifacts == *expected {
12891 return Ok(());
12892 }
12893 if let Some(extra) = request.requested_artifacts.difference(expected).next() {
12894 return Err(unsupported(ProviderFeature::ArtifactType(*extra)));
12895 }
12896 Err(provider_failure(
12897 "artifact_request_mismatch",
12898 "requested artifacts do not exactly match the selected signing mode",
12899 false,
12900 ))
12901}
12902
12903fn validate_signed_artifact_request(request: &IosDeviceBuildRequest) -> RemoteBuildResult<()> {
12904 let required = BTreeSet::from([IosArtifactType::Ipa, IosArtifactType::SigningReport]);
12905 let supported = BTreeSet::from([
12906 IosArtifactType::Ipa,
12907 IosArtifactType::AppBundle,
12908 IosArtifactType::Xcarchive,
12909 IosArtifactType::Dsym,
12910 IosArtifactType::SigningReport,
12911 ]);
12912 if let Some(extra) = request.requested_artifacts.difference(&supported).next() {
12913 return Err(unsupported(ProviderFeature::ArtifactType(*extra)));
12914 }
12915 if required.is_subset(&request.requested_artifacts) {
12916 return Ok(());
12917 }
12918 Err(provider_failure(
12919 "artifact_request_mismatch",
12920 "signed builds require the IPA and signing report artifact set",
12921 false,
12922 ))
12923}
12924
12925fn validate_manifests(
12926 record: &JobRecord,
12927 config: &GithubProviderConfig,
12928 manifests: &[ArtifactManifest],
12929) -> RemoteBuildResult<()> {
12930 let snapshot = record.run_snapshot.clone().ok_or_else(|| {
12931 provider_failure(
12932 "run_identity_unavailable",
12933 "artifact manifests require a terminal run snapshot",
12934 true,
12935 )
12936 })?;
12937 let dispatch_revision = record.dispatch_commit.clone().ok_or_else(|| {
12938 provider_failure(
12939 "dispatch_commit_unavailable",
12940 "artifact manifests require the exact dispatched commit",
12941 true,
12942 )
12943 })?;
12944 let context = GithubArtifactContext {
12945 job_id: record.job_id.clone(),
12946 operation_id: record.operation_id.clone(),
12947 repository: config.repository.clone(),
12948 execution_repository_id: record.execution_repository_id,
12949 run: snapshot,
12950 source_repository: config.source_repository.clone(),
12951 source_revision: record.source_revision.clone(),
12952 dispatch_revision,
12953 request: record.request.clone(),
12954 request_sha256: record.request_sha256.clone(),
12955 };
12956 validate_manifests_for_context(&context, manifests)
12957}
12958
12959fn validate_manifests_for_context(
12960 context: &GithubArtifactContext,
12961 manifests: &[ArtifactManifest],
12962) -> RemoteBuildResult<()> {
12963 if manifests.is_empty() {
12964 return Err(provider_failure(
12965 "artifacts_pending",
12966 "independent artifact verification has not produced a manifest",
12967 true,
12968 ));
12969 }
12970 for manifest in manifests {
12971 if manifest.operation_id != context.operation_id
12972 || manifest.job_id != context.job_id
12973 || manifest.provider != GITHUB_PROVIDER_ID
12974 || manifest.source_repository.as_deref() != Some(context.source_repository.as_str())
12975 || manifest.source_revision.as_deref() != Some(context.source_revision.as_str())
12976 {
12977 return Err(provider_failure(
12978 "artifact_manifest_identity_mismatch",
12979 "verified artifact manifest does not match the exact GitHub job",
12980 false,
12981 ));
12982 }
12983 }
12984 Ok(())
12985}
12986
12987fn ensure_running_or_cancelling(record: &mut JobRecord) -> RemoteBuildResult<()> {
12988 if record.state == JobState::Queued {
12989 transition_record(record, JobState::Running)?;
12990 }
12991 Ok(())
12992}
12993
12994fn transition_record(record: &mut JobRecord, next: JobState) -> RemoteBuildResult<()> {
12995 if record.state == next {
12996 return Ok(());
12997 }
12998 if record.state == JobState::CleanupFailed && next == JobState::Cleaning {
13001 record.state = JobState::Cleaning;
13002 return Ok(());
13003 }
13004 record.state = record.state.transition_to(next)?;
13005 Ok(())
13006}
13007
13008fn normalize_published_record(record: &mut JobRecord, timestamp_ms: u64) -> RemoteBuildResult<()> {
13009 if record.state != JobState::Created || record.dispatch_commit.is_none() {
13010 return Ok(());
13011 }
13012 transition_record(record, JobState::Queued)?;
13013 if !record
13014 .events
13015 .iter()
13016 .any(|event| matches!(&event.kind, RemoteBuildEventKind::JobQueued { .. }))
13017 {
13018 append_event(
13019 record,
13020 timestamp_ms,
13021 "queue",
13022 RemoteBuildEventKind::JobQueued { position: None },
13023 )?;
13024 }
13025 Ok(())
13026}
13027
13028fn mark_publication_not_attempted(
13029 record: &mut JobRecord,
13030 timestamp_ms: u64,
13031 code: &'static str,
13032 message: &'static str,
13033 retryable: bool,
13034) -> RemoteBuildResult<()> {
13035 record.publication_uncertain = false;
13036 record.publication_absent = false;
13037 record.publication_not_attempted = true;
13038 record.publication_absence_observations = 0;
13039 record.publication_absence_first_observed_at_ms = 0;
13040 record.publication_quiescence_deadline_ms = if record.publication_started_at_ms == 0 {
13041 u64::MAX
13042 } else {
13043 publication_quiescence_deadline(record.publication_started_at_ms, 0)
13044 };
13045 record.temporary_ref_deleted = false;
13046 if !record.state.is_build_terminal() && !record.state.is_terminal() {
13047 transition_record(record, JobState::Failed)?;
13048 append_failed(record, timestamp_ms, code, message, retryable)?;
13049 }
13050 Ok(())
13051}
13052
13053fn append_event(
13054 record: &mut JobRecord,
13055 timestamp_ms: u64,
13056 phase: &'static str,
13057 kind: RemoteBuildEventKind,
13058) -> RemoteBuildResult<()> {
13059 if record.events.len() >= MAX_JOB_EVENTS {
13060 return Err(provider_failure(
13061 "event_capacity_reached",
13062 "GitHub job reached its bounded event capacity",
13063 false,
13064 ));
13065 }
13066 let sequence = u64::try_from(record.events.len())
13067 .unwrap_or(u64::MAX)
13068 .saturating_add(1);
13069 let event = RemoteBuildEvent::new(
13070 record.operation_id.clone(),
13071 record.job_id.clone(),
13072 timestamp_ms,
13073 GITHUB_PROVIDER_ID,
13074 phase,
13075 sequence,
13076 kind,
13077 )?;
13078 record.events.push(event);
13079 Ok(())
13080}
13081
13082fn append_failed(
13083 record: &mut JobRecord,
13084 timestamp_ms: u64,
13085 code: &'static str,
13086 message: &'static str,
13087 retryable: bool,
13088) -> RemoteBuildResult<()> {
13089 let result = IosDeviceBuildResult {
13090 protocol_version: CURRENT_PROTOCOL_VERSION,
13091 operation_id: record.operation_id.clone(),
13092 job_id: record.job_id.clone(),
13093 state: JobState::Failed,
13094 artifacts: Vec::new(),
13095 cleanup: None,
13096 };
13097 result.validate()?;
13098 append_event(
13099 record,
13100 timestamp_ms,
13101 "finished",
13102 RemoteBuildEventKind::OperationFinished {
13103 success: false,
13104 duration_ms: timestamp_ms.saturating_sub(record.created_at_ms),
13105 result: Some(result),
13106 error: Some(RemoteErrorInfo {
13107 code: format!("github.{code}"),
13108 message: message.to_owned(),
13109 help: Some(
13110 "Inspect sanitized GitHub Actions diagnostics and retry if appropriate"
13111 .to_owned(),
13112 ),
13113 retryable,
13114 }),
13115 },
13116 )
13117}
13118
13119fn conclusion_code(conclusion: RunConclusion) -> &'static str {
13120 match conclusion {
13121 RunConclusion::Success => "success",
13122 RunConclusion::Failure => "run_failed",
13123 RunConclusion::Cancelled => "run_cancelled",
13124 RunConclusion::TimedOut => "run_timed_out",
13125 RunConclusion::Neutral => "run_neutral",
13126 RunConclusion::Skipped => "run_skipped",
13127 RunConclusion::Stale => "run_stale",
13128 RunConclusion::ActionRequired => "run_action_required",
13129 RunConclusion::StartupFailure => "run_startup_failure",
13130 }
13131}
13132
13133fn conclusion_retryable(conclusion: RunConclusion) -> bool {
13134 matches!(
13135 conclusion,
13136 RunConclusion::TimedOut | RunConclusion::Stale | RunConclusion::StartupFailure
13137 )
13138}
13139
13140fn publisher_failure(error: TemporaryRefPublishError) -> RemoteBuildError {
13141 let (code, retryable) = match error {
13142 TemporaryRefPublishError::Unauthorized => ("temporary_ref_unauthorized", false),
13143 TemporaryRefPublishError::RemoteMismatch => ("git_remote_mismatch", false),
13144 TemporaryRefPublishError::TrustedRefUnavailable => ("trusted_ref_unavailable", true),
13145 TemporaryRefPublishError::SourceRevisionNotTrusted => {
13146 ("source_revision_not_trusted", false)
13147 }
13148 TemporaryRefPublishError::TemporaryRefExists => ("temporary_ref_exists", false),
13149 TemporaryRefPublishError::WorkflowFingerprintMismatch => {
13150 ("workflow_fingerprint_mismatch", false)
13151 }
13152 TemporaryRefPublishError::LocalIsolationFailed => ("git_isolation_failed", true),
13153 TemporaryRefPublishError::Git(GitExecutionError::TimedOut) => {
13154 ("git_operation_timed_out", true)
13155 }
13156 TemporaryRefPublishError::Git(_) => ("git_operation_failed", true),
13157 TemporaryRefPublishError::MalformedGitOutput => ("malformed_git_output", false),
13158 TemporaryRefPublishError::PublicationVerificationFailed => {
13159 ("temporary_ref_verification_failed", false)
13160 }
13161 TemporaryRefPublishError::TemporaryRefLeaseRejected => ("temporary_ref_tip_changed", false),
13162 TemporaryRefPublishError::DeletionVerificationFailed => {
13163 ("temporary_ref_deletion_unverified", true)
13164 }
13165 TemporaryRefPublishError::GitSnapshotUnsupported => ("git_snapshot_unsupported", false),
13166 TemporaryRefPublishError::ExecutionWorkflowVerificationUnsupported => {
13167 ("execution_workflow_verification_unsupported", false)
13168 }
13169 TemporaryRefPublishError::GitSnapshotInvalid => ("git_snapshot_invalid", false),
13170 TemporaryRefPublishError::GitSnapshotDiscoveryChanged => {
13171 ("git_snapshot_discovery_changed", true)
13172 }
13173 TemporaryRefPublishError::GitSnapshotRefConflict => ("git_snapshot_ref_conflict", false),
13174 };
13175 provider_failure(code, "temporary Git ref mutation failed", retryable)
13176}
13177
13178fn transport_failure(error: TransportError) -> RemoteBuildError {
13179 let retryable = matches!(
13180 error,
13181 TransportError::Execution(GhExecutionError::TimedOut)
13182 | TransportError::RunNotFound
13183 | TransportError::PollLimitReached
13184 );
13185 provider_failure(
13186 "github_transport_failed",
13187 "GitHub API operation failed",
13188 retryable,
13189 )
13190}
13191
13192fn workflow_dispatch_client_failure(error: GhExecutionError) -> RemoteBuildError {
13193 let retryable = matches!(
13194 error,
13195 GhExecutionError::TimedOut | GhExecutionError::ProcessIo
13196 );
13197 provider_failure(
13198 "github_workflow_dispatch_client_unavailable",
13199 "GitHub workflow-dispatch authentication is unavailable",
13200 retryable,
13201 )
13202}
13203
13204fn workflow_dispatch_reconciliation_failure(error: TransportError) -> RemoteBuildError {
13205 match error {
13206 TransportError::RunNotFound => provider_failure(
13207 "github_workflow_dispatch_run_not_found",
13208 "Workflow dispatch remains uncertain because no exact run was found",
13209 true,
13210 ),
13211 TransportError::AmbiguousRun => provider_failure(
13212 "github_workflow_dispatch_run_ambiguous",
13213 "Workflow dispatch remains uncertain because multiple exact runs were found",
13214 false,
13215 ),
13216 error => transport_failure(error),
13217 }
13218}
13219
13220fn job_log_identity_failure() -> RemoteBuildError {
13221 provider_failure(
13222 "github_job_log_identity_invalid",
13223 "the restored GitHub run could not bind an exact job-log identity",
13224 false,
13225 )
13226}
13227
13228#[cfg(feature = "secure-job-log-http")]
13229fn job_log_client_failure(error: GhExecutionError) -> RemoteBuildError {
13230 let (code, retryable) = match error {
13231 GhExecutionError::AuthenticationUnavailable => {
13232 ("github_job_log_authentication_unavailable", false)
13233 }
13234 GhExecutionError::TimedOut | GhExecutionError::ProcessIo => {
13235 ("github_job_log_client_unavailable", true)
13236 }
13237 GhExecutionError::SecretTooLarge
13238 | GhExecutionError::SpawnFailed
13239 | GhExecutionError::OutputLimitExceeded
13240 | GhExecutionError::CommandFailed { .. } => ("github_job_log_client_unavailable", false),
13241 };
13242 provider_failure(code, "GitHub job-log authentication failed", retryable)
13243}
13244
13245#[cfg(feature = "secure-job-log-http")]
13246fn job_log_fetch_failure(error: GithubJobLogError) -> RemoteBuildError {
13247 if error == GithubJobLogError::Cancelled {
13248 return RemoteBuildError::Cancelled;
13249 }
13250 let (code, retryable) = match error {
13251 GithubJobLogError::Cancelled => unreachable!("handled above"),
13252 GithubJobLogError::TimedOut
13253 | GithubJobLogError::Transport(_)
13254 | GithubJobLogError::UnexpectedStatus
13255 | GithubJobLogError::IncompleteJobPagination
13256 | GithubJobLogError::JobSetNotTerminal => ("github_job_logs_unavailable", true),
13257 GithubJobLogError::TransportContractViolation
13258 | GithubJobLogError::MalformedJobMetadata
13259 | GithubJobLogError::JobIdentityMismatch
13260 | GithubJobLogError::ConflictingJobIdentity
13261 | GithubJobLogError::JobPaginationLimitReached
13262 | GithubJobLogError::JobLimitReached
13263 | GithubJobLogError::InvalidRedirect
13264 | GithubJobLogError::RedirectHostNotAllowed
13265 | GithubJobLogError::RedirectLimitReached
13266 | GithubJobLogError::UnexpectedContentType
13267 | GithubJobLogError::JobLogTooLarge
13268 | GithubJobLogError::PollTooLarge
13269 | GithubJobLogError::EventLimitReached
13270 | GithubJobLogError::ProjectedByteLimitReached
13271 | GithubJobLogError::SourceSequenceOverflow
13272 | GithubJobLogError::InvalidCompletionMarker => ("github_job_logs_rejected", false),
13273 };
13274 provider_failure(code, "GitHub job logs could not be accepted", retryable)
13275}
13276
13277fn transport_public_message(error: TransportError) -> String {
13278 match error {
13279 TransportError::Execution(GhExecutionError::AuthenticationUnavailable) => {
13280 "Configured GitHub authentication is unavailable".to_owned()
13281 }
13282 TransportError::RepositoryIdentityMismatch => {
13283 "GitHub returned a different repository identity".to_owned()
13284 }
13285 _ => "GitHub readiness check failed".to_owned(),
13286 }
13287}
13288
13289fn publisher_public_message(error: TemporaryRefPublishError) -> String {
13290 match error {
13291 TemporaryRefPublishError::RemoteMismatch => {
13292 "Local Git remote differs from the configured repository".to_owned()
13293 }
13294 TemporaryRefPublishError::TrustedRefUnavailable => {
13295 "Configured trusted source ref is unavailable".to_owned()
13296 }
13297 _ => "Local Git publisher readiness check failed".to_owned(),
13298 }
13299}
13300
13301fn unsupported(feature: ProviderFeature) -> RemoteBuildError {
13302 RemoteBuildError::UnsupportedCapability {
13303 provider: GITHUB_PROVIDER_ID.to_owned(),
13304 feature,
13305 }
13306}
13307
13308fn provider_failure(
13309 code: impl Into<String>,
13310 message: impl Into<String>,
13311 retryable: bool,
13312) -> RemoteBuildError {
13313 RemoteBuildError::ProviderFailure {
13314 provider: GITHUB_PROVIDER_ID.to_owned(),
13315 code: code.into(),
13316 message: message.into(),
13317 retryable,
13318 }
13319}
13320
13321fn job_not_found(_job_id: &str) -> RemoteBuildError {
13322 provider_failure("job_not_found", "GitHub provider job was not found", false)
13323}
13324
13325fn job_not_mapped(_job_id: &str) -> RemoteBuildError {
13326 provider_failure(
13327 "run_not_mapped",
13328 "GitHub Actions run has not been mapped to this job",
13329 true,
13330 )
13331}
13332
13333fn record_is_recyclable(record: &JobRecord) -> bool {
13334 record.state.is_terminal()
13335 || record.publication_not_attempted
13336 || (record.publication_absent && record.temporary_ref_deleted)
13337}
13338
13339fn option_can_bind_once<T: PartialEq + ?Sized>(previous: Option<&T>, next: Option<&T>) -> bool {
13340 match (previous, next) {
13341 (None, _) => true,
13342 (Some(_), None) => false,
13343 (Some(previous), Some(next)) => previous == next,
13344 }
13345}
13346
13347fn publication_boundary_is_armed(resume: &GithubJobResumeV1) -> bool {
13348 resume.publication_started_at_ms >= resume.created_at_ms
13349 && resume.publication_started_at_ms != 0
13350 && (resume.publication_absence_first_observed_at_ms == 0
13351 || resume.publication_absence_first_observed_at_ms >= resume.publication_started_at_ms)
13352 && resume.publication_quiescence_deadline_ms
13353 == publication_quiescence_deadline(
13354 resume.publication_started_at_ms,
13355 resume.publication_absence_first_observed_at_ms,
13356 )
13357}
13358
13359fn publication_boundary_is_unarmed(resume: &GithubJobResumeV1) -> bool {
13360 resume.publication_started_at_ms == 0
13361 && resume.publication_quiescence_deadline_ms == u64::MAX
13362 && resume.publication_absence_first_observed_at_ms == 0
13363}
13364
13365fn publication_record_boundary_is_unarmed(record: &JobRecord) -> bool {
13366 record.publication_started_at_ms == 0
13367 && record.publication_quiescence_deadline_ms == u64::MAX
13368 && record.publication_absence_first_observed_at_ms == 0
13369}
13370
13371fn publication_boundary_can_advance(
13372 previous: &GithubJobResumeV1,
13373 next: &GithubJobResumeV1,
13374) -> bool {
13375 let previous_valid =
13376 publication_boundary_is_unarmed(previous) || publication_boundary_is_armed(previous);
13377 let next_valid = publication_boundary_is_unarmed(next) || publication_boundary_is_armed(next);
13378 previous_valid
13379 && next_valid
13380 && ((previous.publication_started_at_ms == next.publication_started_at_ms
13381 && (previous.publication_quiescence_deadline_ms
13382 == next.publication_quiescence_deadline_ms
13383 || previous.publication_absence_first_observed_at_ms == 0
13384 && next.publication_absence_first_observed_at_ms != 0))
13385 || publication_boundary_is_unarmed(previous) && publication_boundary_is_armed(next))
13386}
13387
13388fn publication_quiescence_deadline(
13389 publication_started_at_ms: u64,
13390 publication_absence_first_observed_at_ms: u64,
13391) -> u64 {
13392 publication_started_at_ms
13393 .max(publication_absence_first_observed_at_ms)
13394 .saturating_add(GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS)
13395}
13396
13397const fn bool_is_monotonic(previous: bool, next: bool) -> bool {
13398 !previous || next
13399}
13400
13401const fn run_status_phase(status: GithubRunStatusV1) -> u8 {
13402 match status {
13403 GithubRunStatusV1::Requested => 0,
13404 GithubRunStatusV1::Queued | GithubRunStatusV1::Pending | GithubRunStatusV1::Waiting => 1,
13405 GithubRunStatusV1::InProgress => 2,
13406 GithubRunStatusV1::Completed => 3,
13407 }
13408}
13409
13410fn job_state_can_reach(previous: JobState, next: JobState) -> bool {
13411 previous == next
13412 || match previous {
13413 JobState::Created => true,
13414 JobState::Queued => !matches!(next, JobState::Created),
13415 JobState::Running => !matches!(next, JobState::Created | JobState::Queued),
13416 JobState::Cancelling => matches!(
13417 next,
13418 JobState::Failed
13419 | JobState::Cancelled
13420 | JobState::Cleaning
13421 | JobState::Cleaned
13422 | JobState::CleanupFailed
13423 ),
13424 JobState::Succeeded | JobState::Failed | JobState::Cancelled => matches!(
13425 next,
13426 JobState::Cleaning | JobState::Cleaned | JobState::CleanupFailed
13427 ),
13428 JobState::Cleaning => matches!(next, JobState::Cleaned | JobState::CleanupFailed),
13429 JobState::Cleaned => false,
13430 JobState::CleanupFailed => {
13431 matches!(
13432 next,
13433 JobState::Cleaning | JobState::Cleaned | JobState::CleanupFailed
13434 )
13435 }
13436 }
13437}
13438
13439fn repository_url(repository: &Repository) -> String {
13440 format!(
13441 "https://github.com/{}/{}",
13442 repository.owner(),
13443 repository.name()
13444 )
13445}
13446
13447fn repository_from_url(value: &str) -> Option<Repository> {
13448 let slug = value.strip_prefix("https://github.com/")?;
13449 let (owner, name) = slug.split_once('/')?;
13450 if name.contains('/') {
13451 return None;
13452 }
13453 Repository::new(owner, name).ok()
13454}
13455
13456#[cfg(test)]
13457fn is_safe_remote_name(value: &str) -> bool {
13458 !value.is_empty()
13459 && value.len() <= 64
13460 && !value.starts_with('-')
13461 && value
13462 .bytes()
13463 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
13464}
13465
13466fn github_remote_matches(actual: &str, expected_https: &str) -> bool {
13467 let actual = actual.trim_end_matches('/').trim_end_matches(".git");
13468 if actual.eq_ignore_ascii_case(expected_https) {
13469 return true;
13470 }
13471 let Some(slug) = expected_https.strip_prefix("https://github.com/") else {
13472 return false;
13473 };
13474 actual.eq_ignore_ascii_case(&format!("git@github.com:{slug}"))
13475 || actual.eq_ignore_ascii_case(&format!("ssh://git@github.com/{slug}"))
13476}
13477
13478fn validate_provider_identifier(field: &'static str, value: &str) -> RemoteBuildResult<()> {
13479 if value.is_empty()
13480 || value.len() > 160
13481 || !value
13482 .bytes()
13483 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
13484 {
13485 return Err(RemoteBuildError::InvalidIdentifier {
13486 field,
13487 reason: "value is not a safe provider identifier",
13488 });
13489 }
13490 Ok(())
13491}
13492
13493fn validate_ref_segment(value: &str) -> Result<(), ()> {
13494 if value.is_empty()
13495 || value.len() > 160
13496 || !value.as_bytes()[0].is_ascii_alphanumeric()
13497 || value.ends_with('.')
13498 || value
13499 .rsplit_once('.')
13500 .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("lock"))
13501 || value.contains("..")
13502 || !value
13503 .bytes()
13504 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
13505 {
13506 Err(())
13507 } else {
13508 Ok(())
13509 }
13510}
13511
13512fn validate_repository_path(value: &str) -> Result<(), ()> {
13513 if value.is_empty()
13514 || value.len() > 512
13515 || value.starts_with('/')
13516 || value.contains('\\')
13517 || value
13518 .split('/')
13519 .any(|part| part.is_empty() || part == "." || part == "..")
13520 || value
13521 .bytes()
13522 .any(|byte| byte.is_ascii_control() || byte == b':')
13523 {
13524 Err(())
13525 } else {
13526 Ok(())
13527 }
13528}
13529
13530fn parse_single_line(output: &[u8]) -> Result<&str, TemporaryRefPublishError> {
13531 let text =
13532 std::str::from_utf8(output).map_err(|_| TemporaryRefPublishError::MalformedGitOutput)?;
13533 let text = text.strip_suffix('\n').unwrap_or(text);
13534 if text.is_empty() || text.contains(['\n', '\r']) {
13535 return Err(TemporaryRefPublishError::MalformedGitOutput);
13536 }
13537 Ok(text)
13538}
13539
13540fn parse_object_id(output: &[u8]) -> Result<String, TemporaryRefPublishError> {
13541 let value = parse_single_line(output)?;
13542 CommitSha::new(value.to_owned()).map_err(|_| TemporaryRefPublishError::MalformedGitOutput)?;
13543 Ok(value.to_owned())
13544}
13545
13546fn parse_ls_remote(
13547 output: &[u8],
13548 expected_ref: &str,
13549) -> Result<Option<CommitSha>, TemporaryRefPublishError> {
13550 if output.is_empty() {
13551 return Ok(None);
13552 }
13553 let text =
13554 std::str::from_utf8(output).map_err(|_| TemporaryRefPublishError::MalformedGitOutput)?;
13555 let text = text.strip_suffix('\n').unwrap_or(text);
13556 if text.is_empty() || text.contains(['\n', '\r']) {
13557 return Err(TemporaryRefPublishError::MalformedGitOutput);
13558 }
13559 let (sha, reference) = text
13560 .split_once('\t')
13561 .ok_or(TemporaryRefPublishError::MalformedGitOutput)?;
13562 if reference != expected_ref {
13563 return Err(TemporaryRefPublishError::MalformedGitOutput);
13564 }
13565 let sha =
13566 CommitSha::new(sha.to_owned()).map_err(|_| TemporaryRefPublishError::MalformedGitOutput)?;
13567 Ok(Some(sha))
13568}
13569
13570fn remove_exact_file_if_present(path: &Path) -> Result<(), TemporaryRefPublishError> {
13571 match fs::symlink_metadata(path) {
13572 Ok(metadata) if metadata.file_type().is_file() => {
13573 fs::remove_file(path).map_err(|_| TemporaryRefPublishError::LocalIsolationFailed)
13574 }
13575 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
13576 Ok(_) | Err(_) => Err(TemporaryRefPublishError::LocalIsolationFailed),
13577 }
13578}
13579
13580#[cfg(test)]
13581fn executable_basename_matches(path: &Path, stem: &str) -> bool {
13582 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
13583 return false;
13584 };
13585 #[cfg(windows)]
13586 {
13587 name.eq_ignore_ascii_case(&format!("{stem}.exe"))
13588 }
13589 #[cfg(not(windows))]
13590 {
13591 name == stem
13592 }
13593}
13594
13595fn canonical_directory(path: &Path) -> Result<PathBuf, GitPublisherConfigError> {
13596 if !is_absolute_normal(path) {
13597 return Err(GitPublisherConfigError::InvalidDirectory);
13598 }
13599 let metadata =
13600 fs::symlink_metadata(path).map_err(|_| GitPublisherConfigError::InvalidDirectory)?;
13601 if metadata.file_type().is_symlink() || !metadata.is_dir() {
13602 return Err(GitPublisherConfigError::InvalidDirectory);
13603 }
13604 fs::canonicalize(path).map_err(|_| GitPublisherConfigError::InvalidDirectory)
13605}
13606
13607fn is_absolute_normal(path: &Path) -> bool {
13608 path.is_absolute()
13609 && path
13610 .components()
13611 .all(|component| !matches!(component, Component::CurDir | Component::ParentDir))
13612}
13613
13614fn ensure_git_process_parent_fence() -> Result<(), GitExecutionError> {
13615 let fenced = rustferry_core::process_control::ensure_descendants_terminate_on_process_exit()
13616 .map_err(|_| GitExecutionError::ProcessIo)?;
13617 if cfg!(windows) && !fenced {
13618 return Err(GitExecutionError::ProcessIo);
13619 }
13620 Ok(())
13621}
13622
13623fn run_git_process(
13624 command: Command,
13625 stdin_bytes: &[u8],
13626 timeout: Duration,
13627 stdout_limit: usize,
13628) -> Result<Vec<u8>, GitExecutionError> {
13629 let output = run_git_process_allow_status(command, stdin_bytes, timeout, stdout_limit)?;
13630 if !output.status.success() {
13631 return Err(git_command_failed(output.status));
13632 }
13633 Ok(output.stdout)
13634}
13635
13636fn run_git_process_file(
13637 command: Command,
13638 input: fs::File,
13639 expected_len: u64,
13640 timeout: Duration,
13641 stdout_limit: usize,
13642) -> Result<Vec<u8>, GitExecutionError> {
13643 let output = run_git_process_allow_status_with_input(
13644 command,
13645 GitProcessInput::File {
13646 input,
13647 expected_len,
13648 },
13649 timeout,
13650 stdout_limit,
13651 )?;
13652 if !output.status.success() {
13653 return Err(git_command_failed(output.status));
13654 }
13655 Ok(output.stdout)
13656}
13657
13658struct GitProcessOutput {
13659 status: ExitStatus,
13660 stdout: Vec<u8>,
13661}
13662
13663fn run_git_process_allow_status(
13664 command: Command,
13665 stdin_bytes: &[u8],
13666 timeout: Duration,
13667 stdout_limit: usize,
13668) -> Result<GitProcessOutput, GitExecutionError> {
13669 run_git_process_allow_status_with_input(
13670 command,
13671 GitProcessInput::Bytes(stdin_bytes.to_vec()),
13672 timeout,
13673 stdout_limit,
13674 )
13675}
13676
13677enum GitProcessInput {
13678 Bytes(Vec<u8>),
13679 File { input: fs::File, expected_len: u64 },
13680}
13681
13682fn run_git_process_allow_status_with_input(
13683 mut command: Command,
13684 input: GitProcessInput,
13685 timeout: Duration,
13686 stdout_limit: usize,
13687) -> Result<GitProcessOutput, GitExecutionError> {
13688 ensure_git_process_parent_fence()?;
13689 let (mut child, platform_guard) =
13690 rustferry_core::process_control::spawn_tracked_child(&mut command)
13691 .map_err(|_| GitExecutionError::ProcessIo)?;
13692 let mut process_tree = GitProcessTreeGuard::from_spawn(&child, platform_guard);
13693 let Some(stdin) = child.stdin.take() else {
13694 let deadline = git_process_cleanup_deadline();
13695 terminate_git_process_tree(&mut child, &mut process_tree, deadline);
13696 return Err(GitExecutionError::ProcessIo);
13697 };
13698 let Some(stdout) = child.stdout.take() else {
13699 let deadline = git_process_cleanup_deadline();
13700 terminate_git_process_tree(&mut child, &mut process_tree, deadline);
13701 return Err(GitExecutionError::ProcessIo);
13702 };
13703 let Some(stderr) = child.stderr.take() else {
13704 let deadline = git_process_cleanup_deadline();
13705 terminate_git_process_tree(&mut child, &mut process_tree, deadline);
13706 return Err(GitExecutionError::ProcessIo);
13707 };
13708 let stdin_writer = spawn_git_process_worker(move || match input {
13709 GitProcessInput::Bytes(input) => {
13710 let mut stdin = stdin;
13711 stdin
13712 .write_all(&input)
13713 .and_then(|()| stdin.flush())
13714 .map_err(|_| GitExecutionError::ProcessIo)
13715 }
13716 GitProcessInput::File {
13717 mut input,
13718 expected_len,
13719 } => copy_exact_git_input(&mut input, stdin, expected_len),
13720 });
13721 let stdout_reader = spawn_git_process_worker(move || read_capped(stdout, stdout_limit, true));
13722 let stderr_reader =
13723 spawn_git_process_worker(move || read_capped(stderr, MAX_GIT_STDERR_BYTES, false));
13724 let Some(deadline) = Instant::now().checked_add(timeout) else {
13725 let cleanup_deadline = git_process_cleanup_deadline();
13726 terminate_git_process_tree(&mut child, &mut process_tree, cleanup_deadline);
13727 let _ = collect_git_process_workers(
13728 &stdin_writer,
13729 &stdout_reader,
13730 &stderr_reader,
13731 cleanup_deadline,
13732 );
13733 return Err(GitExecutionError::TimedOut);
13734 };
13735 let status = loop {
13736 match child.try_wait() {
13737 Ok(Some(status)) => break status,
13738 Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(20)),
13739 Ok(None) => {
13740 let cleanup_deadline = git_process_cleanup_deadline();
13741 terminate_git_process_tree(&mut child, &mut process_tree, cleanup_deadline);
13742 let _ = collect_git_process_workers(
13743 &stdin_writer,
13744 &stdout_reader,
13745 &stderr_reader,
13746 cleanup_deadline,
13747 );
13748 return Err(GitExecutionError::TimedOut);
13749 }
13750 Err(_) => {
13751 let cleanup_deadline = git_process_cleanup_deadline();
13752 terminate_git_process_tree(&mut child, &mut process_tree, cleanup_deadline);
13753 let _ = collect_git_process_workers(
13754 &stdin_writer,
13755 &stdout_reader,
13756 &stderr_reader,
13757 cleanup_deadline,
13758 );
13759 return Err(GitExecutionError::ProcessIo);
13760 }
13761 }
13762 };
13763 process_tree.terminate_descendants();
13766 let (stdout, stderr) = collect_git_process_workers(
13767 &stdin_writer,
13768 &stdout_reader,
13769 &stderr_reader,
13770 git_process_cleanup_deadline(),
13771 )?;
13772 if stdout.truncated || stderr.truncated {
13773 return Err(GitExecutionError::OutputLimitExceeded);
13774 }
13775 Ok(GitProcessOutput {
13776 status,
13777 stdout: stdout.bytes,
13778 })
13779}
13780
13781fn copy_exact_git_input(
13782 input: &mut fs::File,
13783 mut stdin: impl Write,
13784 expected_len: u64,
13785) -> Result<(), GitExecutionError> {
13786 let mut remaining = expected_len;
13787 let mut buffer = [0_u8; 8 * 1024];
13788 while remaining != 0 {
13789 let requested = usize::try_from(remaining.min(buffer.len() as u64))
13790 .map_err(|_| GitExecutionError::ProcessIo)?;
13791 let read = input
13792 .read(&mut buffer[..requested])
13793 .map_err(|_| GitExecutionError::ProcessIo)?;
13794 if read == 0 {
13795 return Err(GitExecutionError::ProcessIo);
13796 }
13797 stdin
13798 .write_all(&buffer[..read])
13799 .map_err(|_| GitExecutionError::ProcessIo)?;
13800 remaining = remaining.saturating_sub(read as u64);
13801 }
13802 let mut extra = [0_u8; 1];
13803 if input
13804 .read(&mut extra)
13805 .map_err(|_| GitExecutionError::ProcessIo)?
13806 != 0
13807 {
13808 return Err(GitExecutionError::ProcessIo);
13809 }
13810 stdin.flush().map_err(|_| GitExecutionError::ProcessIo)
13811}
13812
13813fn spawn_git_process_worker<T>(
13814 worker: impl FnOnce() -> Result<T, GitExecutionError> + Send + 'static,
13815) -> Receiver<Result<T, GitExecutionError>>
13816where
13817 T: Send + 'static,
13818{
13819 let (sender, receiver) = mpsc::sync_channel(1);
13820 drop(thread::spawn(move || {
13821 let _ = sender.send(worker());
13822 }));
13823 receiver
13824}
13825
13826fn collect_git_process_workers(
13827 stdin_writer: &Receiver<Result<(), GitExecutionError>>,
13828 stdout_reader: &Receiver<Result<CapturedGitOutput, GitExecutionError>>,
13829 stderr_reader: &Receiver<Result<CapturedGitOutput, GitExecutionError>>,
13830 deadline: Instant,
13831) -> Result<(CapturedGitOutput, CapturedGitOutput), GitExecutionError> {
13832 receive_git_process_worker(stdin_writer, deadline)?;
13833 let stdout = receive_git_process_worker(stdout_reader, deadline)?;
13834 let stderr = receive_git_process_worker(stderr_reader, deadline)?;
13835 Ok((stdout, stderr))
13836}
13837
13838fn receive_git_process_worker<T>(
13839 receiver: &Receiver<Result<T, GitExecutionError>>,
13840 deadline: Instant,
13841) -> Result<T, GitExecutionError> {
13842 let remaining = deadline.saturating_duration_since(Instant::now());
13843 match receiver.recv_timeout(remaining) {
13844 Ok(result) => result,
13845 Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => {
13846 Err(GitExecutionError::ProcessIo)
13847 }
13848 }
13849}
13850
13851fn git_process_cleanup_deadline() -> Instant {
13852 Instant::now()
13853 .checked_add(GIT_PROCESS_CLEANUP_GRACE)
13854 .unwrap_or_else(Instant::now)
13855}
13856
13857struct GitProcessTreeGuard {
13858 #[cfg(unix)]
13859 process_group: Option<u32>,
13860 platform_guard: Option<rustferry_core::process_control::ProcessGroupGuard>,
13861}
13862
13863impl GitProcessTreeGuard {
13864 fn from_spawn(
13865 child: &Child,
13866 platform_guard: rustferry_core::process_control::ProcessGroupGuard,
13867 ) -> Self {
13868 #[cfg(not(unix))]
13869 let _ = child;
13870 Self {
13871 #[cfg(unix)]
13872 process_group: Some(child.id()),
13873 platform_guard: Some(platform_guard),
13874 }
13875 }
13876
13877 fn terminate_descendants(&mut self) {
13878 #[cfg(unix)]
13879 if let Some(process_group) = self.process_group.take() {
13880 let _ = Command::new("/bin/kill")
13881 .args(["-KILL", "--", &format!("-{process_group}")])
13882 .env_clear()
13883 .stdin(Stdio::null())
13884 .stdout(Stdio::null())
13885 .stderr(Stdio::null())
13886 .status();
13887 }
13888 drop(self.platform_guard.take());
13889 }
13890}
13891
13892impl Drop for GitProcessTreeGuard {
13893 fn drop(&mut self) {
13894 self.terminate_descendants();
13895 }
13896}
13897
13898fn terminate_git_process_tree(
13899 child: &mut Child,
13900 process_tree: &mut GitProcessTreeGuard,
13901 deadline: Instant,
13902) {
13903 process_tree.terminate_descendants();
13904 terminate_git_child(child, deadline);
13905}
13906
13907fn terminate_git_child(child: &mut Child, deadline: Instant) {
13908 let _ = child.kill();
13909 loop {
13910 match child.try_wait() {
13911 Ok(Some(_)) | Err(_) => return,
13912 Ok(None) => {
13913 let remaining = deadline.saturating_duration_since(Instant::now());
13914 if remaining.is_zero() {
13915 return;
13916 }
13917 thread::sleep(remaining.min(Duration::from_millis(20)));
13918 }
13919 }
13920 }
13921}
13922
13923struct CapturedGitOutput {
13924 bytes: Vec<u8>,
13925 truncated: bool,
13926}
13927
13928fn read_capped(
13929 mut reader: impl Read,
13930 limit: usize,
13931 retain: bool,
13932) -> Result<CapturedGitOutput, GitExecutionError> {
13933 let mut bytes = if retain {
13934 Vec::with_capacity(limit.min(16 * 1024))
13935 } else {
13936 Vec::new()
13937 };
13938 let mut total = 0_usize;
13939 let mut buffer = [0_u8; 16 * 1024];
13940 loop {
13941 let read = reader
13942 .read(&mut buffer)
13943 .map_err(|_| GitExecutionError::ProcessIo)?;
13944 if read == 0 {
13945 break;
13946 }
13947 let retained = limit.saturating_sub(total).min(read);
13948 if retain && retained > 0 {
13949 bytes.extend_from_slice(&buffer[..retained]);
13950 }
13951 total = total.saturating_add(read);
13952 }
13953 Ok(CapturedGitOutput {
13954 bytes,
13955 truncated: total > limit,
13956 })
13957}
13958
13959fn git_command_failed(status: ExitStatus) -> GitExecutionError {
13960 GitExecutionError::CommandFailed {
13961 exit_code: status.code(),
13962 }
13963}
13964
13965fn is_lower_sha256(value: &str) -> bool {
13966 value.len() == 64
13967 && value
13968 .bytes()
13969 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
13970}
13971
13972fn provider_config_sha256(config: &GithubProviderConfig) -> RemoteBuildResult<String> {
13973 #[derive(Serialize)]
13974 #[allow(
13975 clippy::struct_excessive_bools,
13976 reason = "canonical provider identity must bind each independent mutation authority bit"
13977 )]
13978 struct ProviderIdentity<'a> {
13979 schema_version: u32,
13980 provider: &'static str,
13981 execution_repository: String,
13982 source_repository: &'a str,
13983 trusted_source_ref: &'a str,
13984 temporary_ref_namespace: &'a str,
13985 workflow_path: String,
13986 workflow_sha256: &'a str,
13987 #[serde(skip_serializing_if = "Option::is_none")]
13988 run_trigger: Option<&'static str>,
13989 worker_version: String,
13990 maximum_jobs: usize,
13991 discovery_attempts: u16,
13992 discovery_window_ms: u64,
13993 publish_temporary_ref: bool,
13994 cancel_run: bool,
13995 delete_temporary_ref: bool,
13996 publish_git_snapshot_source: bool,
13997 delete_git_snapshot_source: bool,
13998 manage_git_snapshot_keepalive: bool,
13999 #[serde(skip_serializing_if = "Option::is_none")]
14000 source_fetch_endpoint: Option<&'a str>,
14001 #[serde(skip_serializing_if = "Option::is_none")]
14002 source_push_endpoint: Option<&'a str>,
14003 #[serde(skip_serializing_if = "Option::is_none")]
14004 execution_fetch_endpoint: Option<&'a str>,
14005 #[serde(skip_serializing_if = "Option::is_none")]
14006 execution_push_endpoint: Option<&'a str>,
14007 }
14008
14009 let identity = ProviderIdentity {
14010 schema_version: GITHUB_JOB_RESUME_SCHEMA_VERSION,
14011 provider: GITHUB_PROVIDER_ID,
14012 execution_repository: repository_url(&config.repository),
14013 source_repository: &config.source_repository,
14014 trusted_source_ref: config.workflow.trusted_source_ref().as_str(),
14015 temporary_ref_namespace: config.workflow.temporary_branch_namespace().as_str(),
14016 workflow_path: config.workflow.filename().repository_path(),
14017 workflow_sha256: config.workflow_fingerprint.as_str(),
14018 run_trigger: (config.workflow.run_trigger() == WorkflowRunTrigger::WorkflowDispatch)
14019 .then_some("workflow_dispatch"),
14020 worker_version: config.worker_version.to_string(),
14021 maximum_jobs: config.max_jobs,
14022 discovery_attempts: config.poll_policy.discovery_attempts(),
14023 discovery_window_ms: config.poll_policy.discovery_window_ms(),
14024 publish_temporary_ref: config.mutation_authorization.publish_temporary_ref,
14025 cancel_run: config.mutation_authorization.cancel_run,
14026 delete_temporary_ref: config.mutation_authorization.delete_temporary_ref,
14027 publish_git_snapshot_source: config.mutation_authorization.publish_git_snapshot_source,
14028 delete_git_snapshot_source: config.mutation_authorization.delete_git_snapshot_source,
14029 manage_git_snapshot_keepalive: config.mutation_authorization.manage_git_snapshot_keepalive,
14030 source_fetch_endpoint: config
14031 .git_endpoints
14032 .as_ref()
14033 .map(|endpoints| endpoints.source.fetch().canonical_url()),
14034 source_push_endpoint: config
14035 .git_endpoints
14036 .as_ref()
14037 .map(|endpoints| endpoints.source.push().canonical_url()),
14038 execution_fetch_endpoint: config
14039 .git_endpoints
14040 .as_ref()
14041 .map(|endpoints| endpoints.execution.fetch().canonical_url()),
14042 execution_push_endpoint: config
14043 .git_endpoints
14044 .as_ref()
14045 .map(|endpoints| endpoints.execution.push().canonical_url()),
14046 };
14047 serde_json::to_vec(&identity)
14048 .map(|bytes| hex_sha256(&bytes))
14049 .map_err(|error| RemoteBuildError::Serialization {
14050 message: bounded_message(&error.to_string()),
14051 })
14052}
14053
14054fn resume_failure(code: &'static str) -> RemoteBuildError {
14055 provider_failure(
14056 code,
14057 "durable GitHub job state failed strict identity validation",
14058 false,
14059 )
14060}
14061
14062fn ensure_same_run_attempt(
14063 previous: Option<&RunSnapshot>,
14064 current: &RunSnapshot,
14065) -> RemoteBuildResult<()> {
14066 if previous.is_some_and(|previous| {
14067 previous.handle() != current.handle()
14068 || previous.run_number() != current.run_number()
14069 || previous.run_attempt() != current.run_attempt()
14070 || previous.status() == RunStatus::Completed
14071 && (current.status() != RunStatus::Completed
14072 || previous.conclusion() != current.conclusion())
14073 }) {
14074 return Err(resume_failure("github_run_attempt_changed"));
14075 }
14076 Ok(())
14077}
14078
14079fn github_run_is_later_attempt(previous: &RunSnapshot, current: &RunSnapshot) -> bool {
14080 previous.handle() == current.handle()
14081 && previous.run_number() == current.run_number()
14082 && current.run_attempt() > previous.run_attempt()
14083}
14084
14085fn run_snapshot_is_successful(snapshot: &RunSnapshot) -> bool {
14086 snapshot.status() == RunStatus::Completed
14087 && snapshot.conclusion() == Some(RunConclusion::Success)
14088}
14089
14090fn hex_sha256(bytes: &[u8]) -> String {
14091 const HEX: &[u8; 16] = b"0123456789abcdef";
14092 let digest = Sha256::digest(bytes);
14093 let mut encoded = String::with_capacity(64);
14094 for byte in digest {
14095 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
14096 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
14097 }
14098 encoded
14099}
14100
14101fn publication_lease_scope_sha256(
14102 directory: &Path,
14103 identity: &rustferry_core::DirectoryFilesystemIdentity,
14104) -> String {
14105 let mut digest = Sha256::new();
14106 digest.update(b"rustferry-github-publication-lease-scope-v1\0");
14107 digest.update(directory.as_os_str().to_string_lossy().as_bytes());
14108 digest.update([0]);
14109 digest.update(identity.as_str());
14110 hex::encode(digest.finalize())
14111}
14112
14113fn publication_lease_key(request: &TemporaryRefPublicationLeaseRequest<'_>) -> String {
14114 let mut digest = Sha256::new();
14115 digest.update(b"rustferry-github-publication-lease-v1\0");
14116 digest.update(repository_url(request.repository));
14117 digest.update([0]);
14118 digest.update(b"refs/heads/");
14119 digest.update(request.temporary_ref.branch().as_str());
14120 digest.update([0]);
14121 digest.update(request.operation_id);
14122 hex::encode(digest.finalize())
14123}
14124
14125fn git_snapshot_source_lease_key(request: &GitSnapshotSourcePublicationLeaseRequest<'_>) -> String {
14126 let mut digest = Sha256::new();
14127 digest.update(b"rustferry-github-snapshot-source-lease-v1\0");
14128 digest.update(request.source_repository);
14129 digest.update([0]);
14130 digest.update(request.source_ref.as_str());
14131 hex::encode(digest.finalize())
14132}
14133
14134fn bounded_message(message: &str) -> String {
14135 message.chars().take(256).collect()
14136}
14137
14138#[cfg(test)]
14139mod tests {
14140 use std::{
14141 collections::{BTreeMap, VecDeque},
14142 sync::{
14143 Arc, Condvar, Mutex,
14144 atomic::{AtomicU64, Ordering},
14145 },
14146 task::{Context, Poll, Waker},
14147 thread,
14148 time::Duration as StdDuration,
14149 };
14150
14151 use crate::{
14152 transport::{
14153 ApiMethod, GhRequest, GithubWorkflowDispatchHttpClient,
14154 GithubWorkflowDispatchHttpClientFactory, GithubWorkflowDispatchHttpError,
14155 GithubWorkflowDispatchHttpRequest, GithubWorkflowDispatchHttpResponse, TransportLimits,
14156 },
14157 workflow::{
14158 ProtectedEnvironment, PublicSourceRepository, SigningSecretNames,
14159 TemporaryBranchNamespace, WorkerDistribution, WorkflowFileName,
14160 },
14161 };
14162 use rustferry_remote::{
14163 ArtifactRecord, ArtifactSigningEvidence, BuildProfile, BundleIdentifier,
14164 COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION, CompileToolchainEvidence, DevelopmentTeam,
14165 DevelopmentTeamPlan, DevicePlan, EntitlementPlan, EntitlementSet,
14166 IosDeviceProductExpectation, ProvisioningPlan, ProvisioningProfileType,
14167 SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION, SealedUnsignedArchive, SigningCertificate,
14168 SigningIdentity, SigningPlan, SigningPrivateKeyReference, SigningReference, SigningTarget,
14169 SigningTargetKind, SourceArchive, SourceManifest, SourceManifestEntry,
14170 UnsignedAppInspection, UnsignedNestedBundleExpectation, UnsignedNestedBundleKind,
14171 UnsignedXcarchiveExpectation, UnsignedXcarchiveInspection,
14172 };
14173
14174 use super::*;
14175
14176 const SOURCE_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
14177 const AUTHENTICATED_USER_ID: u64 = 42;
14178 const TRUSTED_TIP: &str = "123456789abcdef0123456789abcdef012345678";
14179 const DISPATCH_SHA: &str = "abcdef0123456789abcdef0123456789abcdef01";
14180 const TREE_SHA: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
14181 const WORKFLOW_BLOB: &str = "cccccccccccccccccccccccccccccccccccccccc";
14182 const MANIFEST_BLOB: &str = "dddddddddddddddddddddddddddddddddddddddd";
14183 const FAKE_LEASE_SCOPE: &str =
14184 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
14185 const BRANCH_BASE64: &str = "cnVzdGZlcnJ5L2dvYWwzL2J1aWxkcy9vcGVyYXRpb24tMQ==";
14186 const WORKFLOW_PATH_BASE64: &str =
14187 "LmdpdGh1Yi93b3JrZmxvd3MvcnVzdGZlcnJ5LWdvYWwzLWlwaG9uZS55bWw=";
14188 const SIGNING_ENVIRONMENT_BASE64: &str = "cnVzdGZlcnJ5LWdvYWwzLXNpZ25pbmc=";
14189 const SIGNING_BRANCH_POLICY_BASE64: &str = "cnVzdGZlcnJ5L2dvYWwzL2J1aWxkcy8q";
14190 const CERTIFICATE_P12_BASE64: &str = "UlVTVEZFUlJZX0dPQUwzX0lPU19DRVJUSUZJQ0FURV9QMTI=";
14191 const CERTIFICATE_PASSWORD_BASE64: &str =
14192 "UlVTVEZFUlJZX0dPQUwzX0lPU19DRVJUSUZJQ0FURV9QQVNTV09SRA==";
14193 const PROVISIONING_PROFILE_BASE64: &str =
14194 "UlVTVEZFUlJZX0dPQUwzX0lPU19QUk9WSVNJT05JTkdfUFJPRklMRQ==";
14195 const WIDGET_PROFILE_BASE64: &str =
14196 "UlVTVEZFUlJZX0dPQUwzX0lPU19QUk9GSUxFXzI2NEI4RkUyNjhCMDAwOEIzNkYwOUJCNUNEMDA4RUE0";
14197 const SNAPSHOT_ARCHIVE_BYTES: &[u8] = b"snapshot archive fixture\n";
14198
14199 fn base64_fixture(value: &str) -> String {
14200 const ALPHABET: &[u8; 64] =
14201 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
14202 let mut encoded = String::with_capacity(value.len().div_ceil(3) * 4);
14203 for chunk in value.as_bytes().chunks(3) {
14204 let first = chunk[0];
14205 let second = chunk.get(1).copied().unwrap_or(0);
14206 let third = chunk.get(2).copied().unwrap_or(0);
14207 encoded.push(char::from(ALPHABET[usize::from(first >> 2)]));
14208 encoded.push(char::from(
14209 ALPHABET[usize::from((first & 0x03) << 4 | second >> 4)],
14210 ));
14211 encoded.push(if chunk.len() > 1 {
14212 char::from(ALPHABET[usize::from((second & 0x0f) << 2 | third >> 6)])
14213 } else {
14214 '='
14215 });
14216 encoded.push(if chunk.len() > 2 {
14217 char::from(ALPHABET[usize::from(third & 0x3f)])
14218 } else {
14219 '='
14220 });
14221 }
14222 encoded
14223 }
14224
14225 struct TestPrivateDirectory {
14226 _root: tempfile::TempDir,
14227 path: PathBuf,
14228 }
14229
14230 impl TestPrivateDirectory {
14231 fn path(&self) -> &Path {
14232 &self.path
14233 }
14234 }
14235
14236 fn tempdir() -> io::Result<TestPrivateDirectory> {
14237 let root = tempfile::tempdir()?;
14238 let path = root.path().join("private");
14239 create_test_private_directory(&path)?;
14240 Ok(TestPrivateDirectory { _root: root, path })
14241 }
14242
14243 fn create_test_private_directory(path: &Path) -> io::Result<()> {
14244 #[cfg(windows)]
14245 {
14246 rustferry_core::windows_private_directory::create_private_directory(path)
14247 .map(drop)
14248 .map_err(io::Error::other)
14249 }
14250 #[cfg(unix)]
14251 {
14252 use std::os::unix::fs::DirBuilderExt as _;
14253
14254 fs::DirBuilder::new().mode(0o700).create(path)
14255 }
14256 #[cfg(not(any(unix, windows)))]
14257 {
14258 fs::create_dir(path)
14259 }
14260 }
14261
14262 #[cfg(windows)]
14263 fn initialize_caller_git_fixture() -> Option<(tempfile::TempDir, PathBuf, PathBuf)> {
14264 let git = crate::git_process::trusted_git_executable().ok()?;
14265 let temporary = tempfile::tempdir().expect("caller Git fixture");
14266 let checkout = temporary.path().join("checkout");
14267 fs::create_dir(&checkout).expect("checkout");
14268 assert!(
14269 Command::new(&git)
14270 .args(["init", "--quiet"])
14271 .current_dir(&checkout)
14272 .status()
14273 .expect("git init")
14274 .success()
14275 );
14276 fs::write(checkout.join("actual.txt"), b"actual\n").expect("source file");
14277 assert!(
14278 Command::new(&git)
14279 .args(["add", "--", "actual.txt"])
14280 .current_dir(&checkout)
14281 .status()
14282 .expect("git add")
14283 .success()
14284 );
14285 assert!(
14286 Command::new(&git)
14287 .args([
14288 "-c",
14289 "user.name=RustFerry Test",
14290 "-c",
14291 "user.email=rustferry@example.invalid",
14292 "commit",
14293 "--quiet",
14294 "-m",
14295 "actual",
14296 ])
14297 .current_dir(&checkout)
14298 .status()
14299 .expect("git commit")
14300 .success()
14301 );
14302 Some((temporary, checkout, git))
14303 }
14304
14305 #[cfg(windows)]
14306 fn fixture_git_output(git: &Path, checkout: &Path, arguments: &[&str]) -> Vec<u8> {
14307 let output = Command::new(git)
14308 .args(arguments)
14309 .current_dir(checkout)
14310 .output()
14311 .expect("fixture Git command");
14312 assert!(
14313 output.status.success(),
14314 "{}",
14315 String::from_utf8_lossy(&output.stderr)
14316 );
14317 output.stdout
14318 }
14319
14320 #[test]
14321 fn caller_git_config_accepts_legal_subsection_names() {
14322 assert_eq!(
14323 validate_caller_git_config_names(
14324 b"branch.goal3/windows-live-acceptance.remote\0branch.goal3/windows-live-acceptance.merge\0branch.release_candidate.remote\0",
14325 ),
14326 Ok(())
14327 );
14328 }
14329
14330 #[test]
14331 fn caller_git_config_rejects_dangerous_families() {
14332 for key in [
14333 b"include.path\0".as_slice(),
14334 b"includeIf.gitdir:/tmp/**.path\0",
14335 b"extensions.worktreeConfig\0",
14336 b"extensions.partialClone\0",
14337 b"core.worktree\0",
14338 b"filter.lfs.process\0",
14339 b"protocol.ext.allow\0",
14340 b"remote.origin.promisor\0",
14341 b"remote.origin.partialCloneFilter\0",
14342 b"diff.external\0",
14343 b"diff.custom.command\0",
14344 b"diff.custom.textconv\0",
14345 ] {
14346 assert_eq!(
14347 validate_caller_git_config_names(key),
14348 Err(GitPublisherConfigError::UnsafeCallerRepositoryConfig)
14349 );
14350 }
14351 }
14352
14353 #[test]
14354 fn caller_git_config_rejects_malformed_names_and_framing() {
14355 for output in [
14356 b"\0".as_slice(),
14357 b"core.bare",
14358 b"core.bare\0\0",
14359 b"branch.topic\x01.remote\0",
14360 b"branch.topic\r.remote\0",
14361 b"branch.topic\n.remote\0",
14362 b"branch.topic.\xff\0",
14363 ] {
14364 assert_eq!(
14365 validate_caller_git_config_names(output),
14366 Err(GitPublisherConfigError::UnsafeCallerRepositoryConfig)
14367 );
14368 }
14369
14370 let mut oversized = vec![b'a'; 513];
14371 oversized.push(0);
14372 assert_eq!(
14373 validate_caller_git_config_names(&oversized),
14374 Err(GitPublisherConfigError::UnsafeCallerRepositoryConfig)
14375 );
14376 }
14377
14378 #[cfg(windows)]
14379 #[test]
14380 fn caller_git_config_allows_branch_name_with_slash_in_repository() {
14381 let Some((_temporary, checkout, git)) = initialize_caller_git_fixture() else {
14382 return;
14383 };
14384 fixture_git_output(
14385 &git,
14386 &checkout,
14387 &["branch", "-M", "goal3/windows-live-acceptance"],
14388 );
14389 fixture_git_output(
14390 &git,
14391 &checkout,
14392 &[
14393 "config",
14394 "branch.goal3/windows-live-acceptance.remote",
14395 "origin",
14396 ],
14397 );
14398 fixture_git_output(
14399 &git,
14400 &checkout,
14401 &[
14402 "config",
14403 "branch.goal3/windows-live-acceptance.merge",
14404 "refs/heads/goal3/windows-live-acceptance",
14405 ],
14406 );
14407
14408 CallerGitRepository::open(&checkout).expect("legal branch config");
14409 }
14410
14411 #[cfg(windows)]
14412 #[test]
14413 #[allow(
14414 clippy::too_many_lines,
14415 reason = "one adversarial checkout fixture proves every caller-repository invariant together"
14416 )]
14417 fn caller_git_reader_ignores_replace_refs_and_preserves_git_metadata() {
14418 let Some((_temporary, checkout, git)) = initialize_caller_git_fixture() else {
14419 return;
14420 };
14421 let actual_revision =
14422 String::from_utf8(fixture_git_output(&git, &checkout, &["rev-parse", "HEAD"]))
14423 .expect("revision")
14424 .trim()
14425 .to_owned();
14426 let actual_blob = String::from_utf8(fixture_git_output(
14427 &git,
14428 &checkout,
14429 &["rev-parse", "HEAD:actual.txt"],
14430 ))
14431 .expect("blob")
14432 .trim()
14433 .to_owned();
14434 fs::write(checkout.join("actual.txt"), b"replacement\n").expect("replacement source");
14435 fixture_git_output(&git, &checkout, &["add", "--", "actual.txt"]);
14436 fixture_git_output(
14437 &git,
14438 &checkout,
14439 &[
14440 "-c",
14441 "user.name=RustFerry Test",
14442 "-c",
14443 "user.email=rustferry@example.invalid",
14444 "commit",
14445 "--quiet",
14446 "-m",
14447 "replacement",
14448 ],
14449 );
14450 let replacement_revision =
14451 String::from_utf8(fixture_git_output(&git, &checkout, &["rev-parse", "HEAD"]))
14452 .expect("replacement revision")
14453 .trim()
14454 .to_owned();
14455 fixture_git_output(
14456 &git,
14457 &checkout,
14458 &["checkout", "--quiet", "--detach", &actual_revision],
14459 );
14460 fixture_git_output(
14461 &git,
14462 &checkout,
14463 &["replace", &actual_revision, &replacement_revision],
14464 );
14465 fixture_git_output(
14466 &git,
14467 &checkout,
14468 &[
14469 "config",
14470 "remote.safe.url",
14471 "https://github.com/Owner/Repo.git",
14472 ],
14473 );
14474 fixture_git_output(
14475 &git,
14476 &checkout,
14477 &[
14478 "config",
14479 "remote.safe.pushurl",
14480 "git@github.com:owner/repo.git",
14481 ],
14482 );
14483 let canary = checkout.join("escaped-canary");
14484 let canary_path = canary.to_string_lossy().replace('\\', "/");
14485 let config_path = checkout.join(".git/config");
14486 let mut config = fs::read_to_string(&config_path).expect("config");
14487 std::fmt::Write::write_fmt(
14488 &mut config,
14489 format_args!(
14490 "\n[credential]\n\thelper = !cmd.exe /c echo escaped > {canary_path}\n[http]\n\tproxy = http://127.0.0.1:9\n[core]\n\tsshCommand = cmd.exe /c echo escaped > {canary_path}\n\tfsmonitor = cmd.exe /c echo escaped > {canary_path}\n"
14491 ),
14492 )
14493 .expect("append adversarial config");
14494 fs::write(&config_path, config).expect("malicious inert config");
14495 let nested = checkout.join("nested/project");
14496 fs::create_dir_all(&nested).expect("nested start");
14497 let git_before = test_tree_snapshot(&checkout.join(".git"));
14498 let index_before = fs::metadata(checkout.join(".git/index"))
14499 .expect("index metadata")
14500 .modified()
14501 .expect("index timestamp");
14502
14503 let reader = CallerGitRepository::open(&nested).expect("sealed caller reader");
14504 assert_eq!(
14505 reader.root(),
14506 fs::canonicalize(&checkout).expect("canonical checkout")
14507 );
14508 assert_eq!(
14509 std::str::from_utf8(reader.head_revision().expect("HEAD").stdout())
14510 .expect("HEAD UTF-8")
14511 .trim(),
14512 actual_revision
14513 );
14514 assert!(reader.working_tree_status().expect("status").success());
14515 let remote = reader.discover_remote("safe").expect("remote snapshot");
14516 assert_eq!(
14517 remote.fetch().canonical_url(),
14518 "https://github.com/owner/repo"
14519 );
14520 assert_eq!(remote.push().canonical_url(), "git@github.com:owner/repo");
14521 let tree = reader
14522 .tree_entries(&actual_revision, &["actual.txt".to_owned()])
14523 .expect("tree");
14524 assert!(
14525 tree.stdout()
14526 .windows(actual_blob.len())
14527 .any(|part| part == actual_blob.as_bytes())
14528 );
14529 let batch = reader
14530 .blob_batch(format!("{actual_blob}\n").as_bytes(), 1_024)
14531 .expect("blob batch");
14532 assert!(batch.stdout().windows(7).any(|part| part == b"actual\n"));
14533 assert!(!canary.exists());
14534 assert_eq!(test_tree_snapshot(&checkout.join(".git")), git_before);
14535 assert_eq!(
14536 fs::metadata(checkout.join(".git/index"))
14537 .expect("index metadata after reads")
14538 .modified()
14539 .expect("index timestamp after reads"),
14540 index_before
14541 );
14542 }
14543
14544 #[cfg(windows)]
14545 #[test]
14546 fn caller_git_reader_rejects_external_filters_and_includes_before_execution() {
14547 let Some((_temporary, checkout, _git)) = initialize_caller_git_fixture() else {
14548 return;
14549 };
14550 let canary = checkout.join("filter-canary");
14551 let canary_path = canary.to_string_lossy().replace('\\', "/");
14552 let config_path = checkout.join(".git/config");
14553 let mut config = fs::read_to_string(&config_path).expect("config");
14554 std::fmt::Write::write_fmt(
14555 &mut config,
14556 format_args!(
14557 "\n[filter \"evil\"]\n\tclean = cmd.exe /c echo escaped > {canary_path}\n[include]\n\tpath = missing.config\n"
14558 ),
14559 )
14560 .expect("append unsafe config");
14561 fs::write(config_path, config).expect("unsafe local config");
14562 fs::write(checkout.join(".gitattributes"), b"actual.txt filter=evil\n")
14563 .expect("filter attributes");
14564 assert_eq!(
14565 CallerGitRepository::open(&checkout).expect_err("unsafe config must fail closed"),
14566 GitPublisherConfigError::UnsafeCallerRepositoryConfig
14567 );
14568 assert!(!canary.exists());
14569 for key in [
14570 b"include.path\0".as_slice(),
14571 b"filter.evil.process\0",
14572 b"protocol.ext.allow\0",
14573 b"extensions.partialClone\0",
14574 b"remote.origin.promisor\0",
14575 b"remote.origin.partialCloneFilter\0",
14576 ] {
14577 assert_eq!(
14578 validate_caller_git_config_names(key),
14579 Err(GitPublisherConfigError::UnsafeCallerRepositoryConfig)
14580 );
14581 }
14582 }
14583
14584 #[cfg(windows)]
14585 #[test]
14586 #[allow(
14587 clippy::permissions_set_readonly_false,
14588 reason = "Windows-only fixture clears Git's DOS read-only object bit before removing one loose blob"
14589 )]
14590 fn caller_partial_clone_cannot_lazy_fetch_a_missing_blob_through_ext() {
14591 let Some((_temporary, checkout, git)) = initialize_caller_git_fixture() else {
14592 return;
14593 };
14594 let blob = String::from_utf8(fixture_git_output(
14595 &git,
14596 &checkout,
14597 &["rev-parse", "HEAD:actual.txt"],
14598 ))
14599 .expect("blob ID")
14600 .trim()
14601 .to_owned();
14602 let canary = checkout.join("partial-clone-canary");
14603 let canary_path = canary.to_string_lossy().replace('\\', "/");
14604 for arguments in [
14605 vec!["config", "core.repositoryformatversion", "1"],
14606 vec!["config", "extensions.partialClone", "origin"],
14607 vec!["config", "remote.origin.promisor", "true"],
14608 vec!["config", "remote.origin.partialCloneFilter", "blob:none"],
14609 vec!["config", "protocol.ext.allow", "always"],
14610 ] {
14611 fixture_git_output(&git, &checkout, &arguments);
14612 }
14613 fixture_git_output(
14614 &git,
14615 &checkout,
14616 &[
14617 "config",
14618 "remote.origin.url",
14619 &format!("ext::cmd.exe /c echo escaped > {canary_path}"),
14620 ],
14621 );
14622 let object = checkout
14623 .join(".git/objects")
14624 .join(&blob[..2])
14625 .join(&blob[2..]);
14626 let mut permissions = fs::metadata(&object).expect("loose blob").permissions();
14627 permissions.set_readonly(false);
14628 fs::set_permissions(&object, permissions).expect("writable fixture blob");
14629 fs::remove_file(&object).expect("missing promised blob fixture");
14630 assert_eq!(
14631 CallerGitRepository::open(&checkout)
14632 .expect_err("partial clone config must fail closed"),
14633 GitPublisherConfigError::UnsafeCallerRepositoryConfig
14634 );
14635
14636 let toolchain = crate::git_process::WindowsGitToolchain::new(&git)
14637 .expect("sealed Git-for-Windows toolchain");
14638 let process_state = tempfile::tempdir().expect("process state");
14639 for name in ["home", "xdg", "tmp"] {
14640 fs::create_dir(process_state.path().join(name)).expect("process directory");
14641 }
14642 let context = crate::git_process::GitProcessContext::new(
14643 &checkout,
14644 None,
14645 process_state.path().join("home"),
14646 process_state.path().join("xdg"),
14647 process_state.path().join("tmp"),
14648 )
14649 .expect("offline context");
14650 let spec = toolchain
14651 .process_spec(
14652 &context,
14653 GitNetworkPolicy::Offline,
14654 [
14655 OsString::from("--git-dir"),
14656 caller_git_argument_path(&checkout.join(".git")),
14657 OsString::from("--work-tree"),
14658 caller_git_argument_path(&checkout),
14659 OsString::from("cat-file"),
14660 OsString::from("--batch"),
14661 ],
14662 )
14663 .expect("offline spec");
14664 let mut command = spec.command().expect("sealed command");
14665 command
14666 .stdin(Stdio::piped())
14667 .stdout(Stdio::piped())
14668 .stderr(Stdio::piped());
14669 let output = run_git_process_allow_status(
14670 command,
14671 format!("{blob}\n").as_bytes(),
14672 MAX_GIT_TIMEOUT,
14673 MAX_GIT_OUTPUT_BYTES,
14674 )
14675 .expect("bounded missing-object read");
14676 assert!(output.stdout.windows(7).any(|part| part == b"missing"));
14677 assert!(!canary.exists());
14678 }
14679
14680 #[cfg(windows)]
14681 #[test]
14682 fn caller_git_reader_revalidates_paths_at_the_pre_spawn_boundary() {
14683 let Some((_temporary, checkout, _git)) = initialize_caller_git_fixture() else {
14684 return;
14685 };
14686 let reader = CallerGitRepository::open(&checkout).expect("sealed caller reader");
14687 assert!(fs::rename(&checkout, checkout.with_extension("moved")).is_err());
14688 assert!(
14689 fs::rename(&reader.home, reader.home.with_extension("moved")).is_err(),
14690 "private HOME guard must deny same-path replacement"
14691 );
14692 let config_path = checkout.join(".git/config");
14693 let config_replaced = std::cell::Cell::new(false);
14694 let result = reader.execute_at_with_pre_spawn_hook(
14695 reader.root(),
14696 ["rev-parse", "--verify", "HEAD"],
14697 &[],
14698 MAX_GIT_OUTPUT_BYTES,
14699 || {
14700 config_replaced
14701 .set(fs::rename(&config_path, config_path.with_extension("replaced")).is_ok());
14702 },
14703 );
14704 assert!(!config_replaced.get());
14705 assert!(result.is_ok());
14706 }
14707
14708 #[cfg(unix)]
14709 #[test]
14710 fn git_timeout_terminates_a_descendant_that_holds_inherited_pipes() {
14711 let temporary = tempfile::tempdir().expect("fixture");
14712 let parent_script = temporary.path().join("parent.sh");
14713 let child_script = temporary.path().join("child.sh");
14714 let ready = temporary.path().join("ready");
14715 let delayed_marker = temporary.path().join("delayed-marker");
14716 fs::write(
14717 &parent_script,
14718 b"#!/bin/sh\n/bin/sh \"$1\" \"$3\" &\n: > \"$2\"\nsleep 30\n",
14719 )
14720 .expect("parent helper");
14721 fs::write(
14722 &child_script,
14723 b"#!/bin/sh\nsleep 2\nprintf escaped > \"$1\"\nsleep 30\n",
14724 )
14725 .expect("child helper");
14726 let mut command = Command::new("/bin/sh");
14727 command
14728 .arg(&parent_script)
14729 .arg(&child_script)
14730 .arg(&ready)
14731 .arg(&delayed_marker)
14732 .stdin(Stdio::piped())
14733 .stdout(Stdio::piped())
14734 .stderr(Stdio::piped());
14735 let started = Instant::now();
14736 assert_eq!(
14737 run_git_process(command, &[], Duration::from_millis(500), 1_024),
14738 Err(GitExecutionError::TimedOut)
14739 );
14740 assert!(started.elapsed() < Duration::from_secs(3));
14741 assert!(ready.exists(), "parent did not spawn its descendant");
14742 thread::sleep(Duration::from_millis(2_250));
14743 assert!(!delayed_marker.exists());
14744 }
14745
14746 #[cfg(windows)]
14747 #[test]
14748 fn git_timeout_terminates_a_descendant_that_holds_inherited_pipes() {
14749 let system_root =
14750 PathBuf::from(std::env::var_os("SystemRoot").expect("Windows SystemRoot"));
14751 let powershell = system_root
14752 .join("System32")
14753 .join("WindowsPowerShell")
14754 .join("v1.0")
14755 .join("powershell.exe");
14756 let temporary = tempfile::tempdir().expect("fixture");
14757 let parent_script = temporary.path().join("parent.ps1");
14758 let child_script = temporary.path().join("child.ps1");
14759 let ready = temporary.path().join("ready");
14760 let delayed_marker = temporary.path().join("delayed-marker");
14761 fs::write(
14762 &parent_script,
14763 concat!(
14764 "param([string]$ChildScript,[string]$Ready,[string]$Marker)\n",
14765 "$child = Start-Process -FilePath \"$PSHOME\\powershell.exe\" ",
14766 "-ArgumentList @('-NoProfile','-NonInteractive','-File',$ChildScript,$Marker) ",
14767 "-NoNewWindow -PassThru\n",
14768 "[IO.File]::WriteAllText($Ready,'ready')\n",
14769 "Start-Sleep -Seconds 30\n",
14770 ),
14771 )
14772 .expect("parent helper");
14773 fs::write(
14774 &child_script,
14775 concat!(
14776 "param([string]$Marker)\n",
14777 "Start-Sleep -Seconds 4\n",
14778 "[IO.File]::WriteAllText($Marker,'escaped')\n",
14779 "Start-Sleep -Seconds 30\n",
14780 ),
14781 )
14782 .expect("child helper");
14783 let mut command = Command::new(powershell);
14784 command
14785 .args(["-NoProfile", "-NonInteractive", "-File"])
14786 .arg(&parent_script)
14787 .arg(&child_script)
14788 .arg(&ready)
14789 .arg(&delayed_marker)
14790 .stdin(Stdio::piped())
14791 .stdout(Stdio::piped())
14792 .stderr(Stdio::piped());
14793 let started = Instant::now();
14794 assert_eq!(
14795 run_git_process(command, &[], Duration::from_secs(2), 1_024),
14796 Err(GitExecutionError::TimedOut)
14797 );
14798 assert!(started.elapsed() < Duration::from_secs(5));
14799 assert!(ready.exists(), "parent did not spawn its descendant");
14800 thread::sleep(Duration::from_millis(4_250));
14801 assert!(!delayed_marker.exists());
14802 }
14803
14804 fn test_tree_snapshot(root: &Path) -> BTreeMap<PathBuf, Option<Vec<u8>>> {
14805 fn visit(root: &Path, path: &Path, snapshot: &mut BTreeMap<PathBuf, Option<Vec<u8>>>) {
14806 let mut entries = fs::read_dir(path)
14807 .expect("read snapshot directory")
14808 .map(|entry| entry.expect("read snapshot entry").path())
14809 .collect::<Vec<_>>();
14810 entries.sort();
14811 for entry in entries {
14812 let relative = entry
14813 .strip_prefix(root)
14814 .expect("snapshot path below root")
14815 .to_owned();
14816 let metadata = fs::symlink_metadata(&entry).expect("snapshot metadata");
14817 if metadata.is_dir() {
14818 snapshot.insert(relative, None);
14819 visit(root, &entry, snapshot);
14820 } else {
14821 snapshot.insert(relative, Some(fs::read(&entry).expect("snapshot file")));
14822 }
14823 }
14824 }
14825
14826 let mut snapshot = BTreeMap::new();
14827 visit(root, root, &mut snapshot);
14828 snapshot
14829 }
14830
14831 #[derive(Clone, Debug)]
14832 struct FixedClock(u64);
14833
14834 impl ProviderClock for FixedClock {
14835 fn now_ms(&self) -> u64 {
14836 self.0
14837 }
14838 }
14839
14840 #[derive(Clone, Debug)]
14841 struct MutableClock(Arc<AtomicU64>);
14842
14843 impl MutableClock {
14844 fn new(now_ms: u64) -> Self {
14845 Self(Arc::new(AtomicU64::new(now_ms)))
14846 }
14847
14848 fn set(&self, now_ms: u64) {
14849 self.0.store(now_ms, Ordering::SeqCst);
14850 }
14851 }
14852
14853 impl ProviderClock for MutableClock {
14854 fn now_ms(&self) -> u64 {
14855 self.0.load(Ordering::SeqCst)
14856 }
14857 }
14858
14859 #[derive(Clone, Debug, Default)]
14860 struct CapturingCheckpointSink {
14861 checkpoints: Arc<Mutex<Vec<GithubJobResumeV1>>>,
14862 }
14863
14864 impl GithubJobCheckpointSink for CapturingCheckpointSink {
14865 fn checkpoint(&mut self, resume: &GithubJobResumeV1) -> RemoteBuildResult<()> {
14866 self.checkpoints
14867 .lock()
14868 .expect("checkpoint capture")
14869 .push(resume.clone());
14870 Ok(())
14871 }
14872 }
14873
14874 #[derive(Clone, Copy, Debug, Default)]
14875 struct FailingCheckpointSink;
14876
14877 impl GithubJobCheckpointSink for FailingCheckpointSink {
14878 fn checkpoint(&mut self, _resume: &GithubJobResumeV1) -> RemoteBuildResult<()> {
14879 Err(provider_failure(
14880 "checkpoint_fixture_failed",
14881 "checkpoint fixture failed",
14882 true,
14883 ))
14884 }
14885 }
14886
14887 #[derive(Clone, Debug, Default)]
14888 struct WriteThenFailCheckpointSink {
14889 checkpoints: Arc<Mutex<Vec<GithubJobResumeV1>>>,
14890 failed_once: bool,
14891 }
14892
14893 impl GithubJobCheckpointSink for WriteThenFailCheckpointSink {
14894 fn checkpoint(&mut self, resume: &GithubJobResumeV1) -> RemoteBuildResult<()> {
14895 self.checkpoints
14896 .lock()
14897 .expect("checkpoint capture")
14898 .push(resume.clone());
14899 if !self.failed_once {
14900 self.failed_once = true;
14901 return Err(provider_failure(
14902 "checkpoint_durability_uncertain",
14903 "checkpoint may have been published before durability failed",
14904 true,
14905 ));
14906 }
14907 Ok(())
14908 }
14909 }
14910
14911 #[derive(Clone, Debug, Default)]
14912 struct FailOnWorkflowDispatchReceiptSink {
14913 checkpoints: Arc<Mutex<Vec<GithubJobResumeV1>>>,
14914 }
14915
14916 impl GithubJobCheckpointSink for FailOnWorkflowDispatchReceiptSink {
14917 fn checkpoint(&mut self, resume: &GithubJobResumeV1) -> RemoteBuildResult<()> {
14918 if resume
14919 .workflow_dispatch
14920 .as_ref()
14921 .is_some_and(|dispatch| dispatch.receipt.is_some())
14922 && resume.run.is_none()
14923 {
14924 return Err(provider_failure(
14925 "workflow_dispatch_receipt_checkpoint_failed",
14926 "workflow dispatch receipt checkpoint fixture failed",
14927 true,
14928 ));
14929 }
14930 self.checkpoints
14931 .lock()
14932 .expect("workflow dispatch checkpoints")
14933 .push(resume.clone());
14934 Ok(())
14935 }
14936 }
14937
14938 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
14939 enum SnapshotCleanupAuditEvent {
14940 Checkpoint(GithubGitSnapshotPhaseV1),
14941 Acquire,
14942 Delete,
14943 Release,
14944 }
14945
14946 #[derive(Clone, Debug)]
14947 struct SnapshotCleanupCheckpointSink {
14948 events: Arc<Mutex<Vec<SnapshotCleanupAuditEvent>>>,
14949 fail_deleted_once: bool,
14950 }
14951
14952 impl GithubJobCheckpointSink for SnapshotCleanupCheckpointSink {
14953 fn checkpoint(&mut self, resume: &GithubJobResumeV1) -> RemoteBuildResult<()> {
14954 let phase = resume
14955 .git_snapshot
14956 .as_ref()
14957 .expect("snapshot checkpoint")
14958 .phase;
14959 self.events
14960 .lock()
14961 .expect("snapshot cleanup events")
14962 .push(SnapshotCleanupAuditEvent::Checkpoint(phase));
14963 if phase == GithubGitSnapshotPhaseV1::SourceDeleted && self.fail_deleted_once {
14964 self.fail_deleted_once = false;
14965 return Err(provider_failure(
14966 "checkpoint_fixture_failed",
14967 "snapshot source deletion checkpoint failed",
14968 true,
14969 ));
14970 }
14971 Ok(())
14972 }
14973 }
14974
14975 #[derive(Clone, Debug)]
14976 struct FailOnSnapshotPhaseSink {
14977 checkpoints: Arc<Mutex<Vec<GithubJobResumeV1>>>,
14978 phase: GithubGitSnapshotPhaseV1,
14979 failed_once: bool,
14980 }
14981
14982 impl GithubJobCheckpointSink for FailOnSnapshotPhaseSink {
14983 fn checkpoint(&mut self, resume: &GithubJobResumeV1) -> RemoteBuildResult<()> {
14984 self.checkpoints
14985 .lock()
14986 .expect("snapshot checkpoints")
14987 .push(resume.clone());
14988 if resume.git_snapshot.as_ref().map(|snapshot| snapshot.phase) == Some(self.phase)
14989 && self.failed_once
14990 {
14991 self.failed_once = false;
14992 return Err(provider_failure(
14993 "checkpoint_fixture_failed",
14994 "snapshot phase checkpoint failed",
14995 true,
14996 ));
14997 }
14998 Ok(())
14999 }
15000 }
15001
15002 #[derive(Debug)]
15003 struct SnapshotCleanupAuditPublisher {
15004 events: Arc<Mutex<Vec<SnapshotCleanupAuditEvent>>>,
15005 source_lease_owned: bool,
15006 deletion_count: usize,
15007 keepalive_release_count: usize,
15008 }
15009
15010 impl TemporaryRefPublisher for SnapshotCleanupAuditPublisher {
15011 fn supports_git_snapshot(&self) -> bool {
15012 true
15013 }
15014
15015 fn doctor(
15016 &mut self,
15017 _request: &TemporaryRefDoctorRequest<'_>,
15018 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError> {
15019 Ok(TemporaryRefPublisherReadiness {
15020 trusted_ref_tip: CommitSha::new(TRUSTED_TIP).expect("trusted tip"),
15021 })
15022 }
15023
15024 fn publication_attempt_is_process_fenced(&self) -> bool {
15025 true
15026 }
15027
15028 fn publication_lease_scope_sha256(&self) -> Option<&str> {
15029 Some(FAKE_LEASE_SCOPE)
15030 }
15031
15032 fn acquire_git_snapshot_source_lease(
15033 &mut self,
15034 _request: &GitSnapshotSourcePublicationLeaseRequest<'_>,
15035 ) -> Result<TemporaryRefPublicationLease, TemporaryRefPublishError> {
15036 self.events
15037 .lock()
15038 .expect("snapshot cleanup events")
15039 .push(SnapshotCleanupAuditEvent::Acquire);
15040 if self.source_lease_owned {
15041 Ok(TemporaryRefPublicationLease::AlreadyOwned)
15042 } else {
15043 self.source_lease_owned = true;
15044 Ok(TemporaryRefPublicationLease::Acquired)
15045 }
15046 }
15047
15048 fn release_git_snapshot_source_lease(
15049 &mut self,
15050 _request: &GitSnapshotSourcePublicationLeaseRequest<'_>,
15051 ) {
15052 assert!(
15053 self.source_lease_owned,
15054 "cleanup source lease must be owned"
15055 );
15056 self.events
15057 .lock()
15058 .expect("snapshot cleanup events")
15059 .push(SnapshotCleanupAuditEvent::Release);
15060 self.source_lease_owned = false;
15061 }
15062
15063 fn delete_git_snapshot_source(
15064 &mut self,
15065 request: &GitSnapshotSourceDeleteRequest<'_>,
15066 ) -> Result<(), TemporaryRefPublishError> {
15067 assert!(self.source_lease_owned, "delete must hold the source lease");
15068 assert_eq!(request.expected_commit.as_str(), SOURCE_SHA);
15069 self.events
15070 .lock()
15071 .expect("snapshot cleanup events")
15072 .push(SnapshotCleanupAuditEvent::Delete);
15073 self.deletion_count += 1;
15074 Ok(())
15075 }
15076
15077 fn release_git_snapshot_keepalive(
15078 &mut self,
15079 _request: &GitSnapshotKeepaliveRequest<'_>,
15080 ) -> Result<(), TemporaryRefPublishError> {
15081 self.keepalive_release_count += 1;
15082 Ok(())
15083 }
15084
15085 fn publish(
15086 &mut self,
15087 _request: &TemporaryRefPublishRequest<'_>,
15088 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
15089 Err(TemporaryRefPublishError::GitSnapshotUnsupported.into())
15090 }
15091
15092 fn delete_temporary_ref(
15093 &mut self,
15094 _request: &TemporaryRefDeleteRequest<'_>,
15095 ) -> Result<(), TemporaryRefPublishError> {
15096 Err(TemporaryRefPublishError::GitSnapshotUnsupported)
15097 }
15098 }
15099
15100 #[derive(Clone, Debug, Eq, PartialEq)]
15101 enum SnapshotFlowEvent {
15102 AdoptStage,
15103 ReconcileKeepalive,
15104 Import,
15105 CreateKeepalive,
15106 DeleteStage,
15107 AcquireSourceLease,
15108 PublishSource,
15109 ReconcileSource,
15110 ReleaseSourceLease,
15111 ReleaseKeepalive,
15112 PrepareDispatch {
15113 execution_repository: String,
15114 source_repository: String,
15115 source_ref: String,
15116 },
15117 AcquireDispatchLease,
15118 PublishDispatch,
15119 ReleaseDispatchLease,
15120 }
15121
15122 #[derive(Debug)]
15123 struct SnapshotFlowPublisher {
15124 events: Vec<SnapshotFlowEvent>,
15125 keepalive_exact: bool,
15126 keepalive_reconciliations: VecDeque<GitSnapshotRefReconciliation>,
15127 source_publication: GitSnapshotSourcePublication,
15128 source_reconciliation: GitSnapshotRefReconciliation,
15129 source_reconciliations: VecDeque<GitSnapshotRefReconciliation>,
15130 source_lease_owned: bool,
15131 dispatch_lease_owned: bool,
15132 }
15133
15134 impl SnapshotFlowPublisher {
15135 fn exact() -> Self {
15136 Self {
15137 events: Vec::new(),
15138 keepalive_exact: false,
15139 keepalive_reconciliations: VecDeque::new(),
15140 source_publication: GitSnapshotSourcePublication::Exact,
15141 source_reconciliation: GitSnapshotRefReconciliation::Exact,
15142 source_reconciliations: VecDeque::new(),
15143 source_lease_owned: false,
15144 dispatch_lease_owned: false,
15145 }
15146 }
15147 }
15148
15149 impl TemporaryRefPublisher for SnapshotFlowPublisher {
15150 fn supports_git_snapshot(&self) -> bool {
15151 true
15152 }
15153
15154 fn doctor(
15155 &mut self,
15156 _request: &TemporaryRefDoctorRequest<'_>,
15157 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError> {
15158 Ok(TemporaryRefPublisherReadiness {
15159 trusted_ref_tip: CommitSha::new(TRUSTED_TIP).expect("trusted tip"),
15160 })
15161 }
15162
15163 fn adopt_git_snapshot_stage(
15164 &mut self,
15165 _request: &IosDeviceBuildRequest,
15166 ) -> Result<(GitSnapshotStageLocatorV1, GitSnapshotStageV1), TemporaryRefPublishError>
15167 {
15168 self.events.push(SnapshotFlowEvent::AdoptStage);
15169 Err(TemporaryRefPublishError::PublicationVerificationFailed)
15170 }
15171
15172 fn import_git_snapshot(
15173 &mut self,
15174 request: &GitSnapshotImportRequest<'_>,
15175 ) -> Result<GitSnapshotObjectGraphV1, TemporaryRefPublishError> {
15176 self.events.push(SnapshotFlowEvent::Import);
15177 Ok(request.expected_stage.graph.clone())
15178 }
15179
15180 fn delete_git_snapshot_stage(
15181 &mut self,
15182 _request: &GitSnapshotImportRequest<'_>,
15183 ) -> Result<(), TemporaryRefPublishError> {
15184 self.events.push(SnapshotFlowEvent::DeleteStage);
15185 Ok(())
15186 }
15187
15188 fn reconcile_git_snapshot_keepalive(
15189 &mut self,
15190 _request: &GitSnapshotKeepaliveRequest<'_>,
15191 ) -> Result<GitSnapshotRefReconciliation, TemporaryRefPublishError> {
15192 self.events.push(SnapshotFlowEvent::ReconcileKeepalive);
15193 Ok(self
15194 .keepalive_reconciliations
15195 .pop_front()
15196 .unwrap_or(if self.keepalive_exact {
15197 GitSnapshotRefReconciliation::Exact
15198 } else {
15199 GitSnapshotRefReconciliation::Absent
15200 }))
15201 }
15202
15203 fn create_git_snapshot_keepalive(
15204 &mut self,
15205 _request: &GitSnapshotKeepaliveRequest<'_>,
15206 ) -> Result<(), TemporaryRefPublishError> {
15207 self.events.push(SnapshotFlowEvent::CreateKeepalive);
15208 self.keepalive_exact = true;
15209 Ok(())
15210 }
15211
15212 fn release_git_snapshot_keepalive(
15213 &mut self,
15214 _request: &GitSnapshotKeepaliveRequest<'_>,
15215 ) -> Result<(), TemporaryRefPublishError> {
15216 self.events.push(SnapshotFlowEvent::ReleaseKeepalive);
15217 self.keepalive_exact = false;
15218 Ok(())
15219 }
15220
15221 fn publish_git_snapshot_source(
15222 &mut self,
15223 request: &GitSnapshotSourcePublishRequest<'_>,
15224 ) -> Result<GitSnapshotSourcePublication, TemporaryRefPublishError> {
15225 assert!(request.authorized);
15226 self.events.push(SnapshotFlowEvent::PublishSource);
15227 self.source_reconciliation = match self.source_publication {
15228 GitSnapshotSourcePublication::Exact => GitSnapshotRefReconciliation::Exact,
15229 GitSnapshotSourcePublication::Conflict => GitSnapshotRefReconciliation::Conflict,
15230 GitSnapshotSourcePublication::Uncertain => GitSnapshotRefReconciliation::Absent,
15231 };
15232 Ok(self.source_publication)
15233 }
15234
15235 fn reconcile_git_snapshot_source(
15236 &mut self,
15237 _request: &GitSnapshotSourcePublishRequest<'_>,
15238 ) -> Result<GitSnapshotRefReconciliation, TemporaryRefPublishError> {
15239 self.events.push(SnapshotFlowEvent::ReconcileSource);
15240 Ok(self
15241 .source_reconciliations
15242 .pop_front()
15243 .unwrap_or(self.source_reconciliation))
15244 }
15245
15246 fn publication_attempt_is_process_fenced(&self) -> bool {
15247 true
15248 }
15249
15250 fn publication_lease_scope_sha256(&self) -> Option<&str> {
15251 Some(FAKE_LEASE_SCOPE)
15252 }
15253
15254 fn acquire_git_snapshot_source_lease(
15255 &mut self,
15256 _request: &GitSnapshotSourcePublicationLeaseRequest<'_>,
15257 ) -> Result<TemporaryRefPublicationLease, TemporaryRefPublishError> {
15258 self.events.push(SnapshotFlowEvent::AcquireSourceLease);
15259 if self.source_lease_owned {
15260 Ok(TemporaryRefPublicationLease::AlreadyOwned)
15261 } else {
15262 self.source_lease_owned = true;
15263 Ok(TemporaryRefPublicationLease::Acquired)
15264 }
15265 }
15266
15267 fn release_git_snapshot_source_lease(
15268 &mut self,
15269 _request: &GitSnapshotSourcePublicationLeaseRequest<'_>,
15270 ) {
15271 assert!(self.source_lease_owned);
15272 self.events.push(SnapshotFlowEvent::ReleaseSourceLease);
15273 self.source_lease_owned = false;
15274 }
15275
15276 fn prepare(
15277 &mut self,
15278 request: &TemporaryRefPublishRequest<'_>,
15279 ) -> Result<Option<PublishedTemporaryRef>, TemporaryRefPublishError> {
15280 let source_ref = request
15281 .snapshot_source_ref
15282 .expect("snapshot dispatch source ref");
15283 self.events.push(SnapshotFlowEvent::PrepareDispatch {
15284 execution_repository: repository_url(request.repository),
15285 source_repository: request.source_repository.to_owned(),
15286 source_ref: source_ref.as_str().to_owned(),
15287 });
15288 Ok(Some(PublishedTemporaryRef::new(
15289 request.temporary_ref.clone(),
15290 CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
15291 )))
15292 }
15293
15294 fn acquire_publication_lease(
15295 &mut self,
15296 _request: &TemporaryRefPublicationLeaseRequest<'_>,
15297 ) -> Result<TemporaryRefPublicationLease, TemporaryRefPublishError> {
15298 self.events.push(SnapshotFlowEvent::AcquireDispatchLease);
15299 if self.dispatch_lease_owned {
15300 Ok(TemporaryRefPublicationLease::AlreadyOwned)
15301 } else {
15302 self.dispatch_lease_owned = true;
15303 Ok(TemporaryRefPublicationLease::Acquired)
15304 }
15305 }
15306
15307 fn release_publication_lease(
15308 &mut self,
15309 _request: &TemporaryRefPublicationLeaseRequest<'_>,
15310 ) {
15311 assert!(self.dispatch_lease_owned);
15312 self.events.push(SnapshotFlowEvent::ReleaseDispatchLease);
15313 self.dispatch_lease_owned = false;
15314 }
15315
15316 fn publish(
15317 &mut self,
15318 request: &TemporaryRefPublishRequest<'_>,
15319 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
15320 assert!(request.snapshot_source_ref.is_some());
15321 self.events.push(SnapshotFlowEvent::PublishDispatch);
15322 Ok(PublishedTemporaryRef::new(
15323 request.temporary_ref.clone(),
15324 CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
15325 ))
15326 }
15327
15328 fn publish_prepared(
15329 &mut self,
15330 request: &TemporaryRefPublishRequest<'_>,
15331 prepared: Option<&PublishedTemporaryRef>,
15332 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
15333 let expected = PublishedTemporaryRef::new(
15334 request.temporary_ref.clone(),
15335 CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
15336 );
15337 if prepared != Some(&expected) {
15338 return Err(TemporaryRefPublishError::PublicationVerificationFailed.into());
15339 }
15340 self.publish(request)
15341 }
15342
15343 fn delete_temporary_ref(
15344 &mut self,
15345 _request: &TemporaryRefDeleteRequest<'_>,
15346 ) -> Result<(), TemporaryRefPublishError> {
15347 Ok(())
15348 }
15349 }
15350
15351 impl TemporaryRefReconciler for SnapshotFlowPublisher {
15352 fn reconcile_temporary_ref(
15353 &mut self,
15354 _request: &TemporaryRefReconcileRequest<'_>,
15355 ) -> Result<TemporaryRefReconciliation, TemporaryRefPublishError> {
15356 Ok(TemporaryRefReconciliation::Missing)
15357 }
15358 }
15359
15360 #[derive(Clone, Copy, Debug, Default)]
15361 struct DoctorArtifactStore;
15362
15363 impl VerifiedArtifactStore for DoctorArtifactStore {
15364 fn supports_listing(&self) -> bool {
15365 true
15366 }
15367
15368 fn supports_download(&self) -> bool {
15369 true
15370 }
15371
15372 fn supports_removal(&self) -> bool {
15373 false
15374 }
15375
15376 fn supports_signed_cleanup_evidence(&self) -> bool {
15377 true
15378 }
15379
15380 fn list_verified(
15381 &mut self,
15382 _context: &GithubArtifactContext,
15383 ) -> RemoteBuildResult<Vec<ArtifactManifest>> {
15384 Ok(Vec::new())
15385 }
15386
15387 fn download_verified(
15388 &mut self,
15389 _context: &GithubArtifactContext,
15390 _request: &ArtifactDownloadRequest,
15391 ) -> RemoteBuildResult<ArtifactDownloadResult> {
15392 Err(unsupported(ProviderFeature::ArtifactDownload))
15393 }
15394
15395 fn remove_artifacts(&mut self, _context: &GithubArtifactContext) -> RemoteBuildResult<()> {
15396 Err(unsupported(ProviderFeature::Cleanup))
15397 }
15398 }
15399
15400 #[derive(Clone, Copy, Debug, Default)]
15401 struct FailingArtifactStore;
15402
15403 impl VerifiedArtifactStore for FailingArtifactStore {
15404 fn supports_listing(&self) -> bool {
15405 true
15406 }
15407
15408 fn supports_download(&self) -> bool {
15409 true
15410 }
15411
15412 fn supports_removal(&self) -> bool {
15413 false
15414 }
15415
15416 fn list_verified(
15417 &mut self,
15418 _context: &GithubArtifactContext,
15419 ) -> RemoteBuildResult<Vec<ArtifactManifest>> {
15420 Err(provider_failure(
15421 "artifact_fixture_rejected",
15422 "artifact fixture rejected",
15423 false,
15424 ))
15425 }
15426
15427 fn download_verified(
15428 &mut self,
15429 _context: &GithubArtifactContext,
15430 _request: &ArtifactDownloadRequest,
15431 ) -> RemoteBuildResult<ArtifactDownloadResult> {
15432 Err(unsupported(ProviderFeature::ArtifactDownload))
15433 }
15434
15435 fn remove_artifacts(&mut self, _context: &GithubArtifactContext) -> RemoteBuildResult<()> {
15436 Err(unsupported(ProviderFeature::Cleanup))
15437 }
15438 }
15439
15440 #[derive(Clone, Debug, Default)]
15441 struct CapturingArtifactStore {
15442 contexts: Arc<Mutex<Vec<GithubArtifactContext>>>,
15443 }
15444
15445 impl VerifiedArtifactStore for CapturingArtifactStore {
15446 fn supports_listing(&self) -> bool {
15447 true
15448 }
15449
15450 fn supports_download(&self) -> bool {
15451 false
15452 }
15453
15454 fn supports_removal(&self) -> bool {
15455 false
15456 }
15457
15458 fn list_verified(
15459 &mut self,
15460 context: &GithubArtifactContext,
15461 ) -> RemoteBuildResult<Vec<ArtifactManifest>> {
15462 self.contexts
15463 .lock()
15464 .expect("contexts")
15465 .push(context.clone());
15466 Ok(Vec::new())
15467 }
15468
15469 fn download_verified(
15470 &mut self,
15471 _context: &GithubArtifactContext,
15472 _request: &ArtifactDownloadRequest,
15473 ) -> RemoteBuildResult<ArtifactDownloadResult> {
15474 Err(unsupported(ProviderFeature::ArtifactDownload))
15475 }
15476
15477 fn remove_artifacts(&mut self, _context: &GithubArtifactContext) -> RemoteBuildResult<()> {
15478 Err(unsupported(ProviderFeature::Cleanup))
15479 }
15480 }
15481
15482 #[derive(Debug, Default)]
15483 struct FakePublisher {
15484 published: Vec<CapturedPublication>,
15485 deletions: Vec<CapturedDeletion>,
15486 doctor_failure: Option<TemporaryRefPublishError>,
15487 execution_workflow_failure: Option<TemporaryRefPublishError>,
15488 execution_workflow_checks: u64,
15489 prepare_failure: Option<TemporaryRefPublishError>,
15490 failure: Option<TemporaryRefPublishFailure>,
15491 delete_failure: Option<TemporaryRefPublishError>,
15492 cancel_on_publish: Option<CancellationToken>,
15493 returned_temporary_ref: Option<TemporaryGitRef>,
15494 prepared_publication: Option<PublishedTemporaryRef>,
15495 reconciliation: Option<TemporaryRefReconciliation>,
15496 publication_leases: BTreeSet<String>,
15497 publication_lease_blocked: bool,
15498 publication_lease_unsupported: bool,
15499 }
15500
15501 #[derive(Debug)]
15502 struct CapturedPublication {
15503 source_revision: String,
15504 branch: String,
15505 workflow_path: String,
15506 workflow_sha256: String,
15507 manifest: GithubDispatchManifest,
15508 authorized: bool,
15509 }
15510
15511 #[derive(Debug)]
15512 struct CapturedDeletion {
15513 branch: String,
15514 expected_commit: String,
15515 authorized: bool,
15516 }
15517
15518 impl TemporaryRefPublisher for FakePublisher {
15519 fn publication_attempt_is_process_fenced(&self) -> bool {
15520 true
15521 }
15522
15523 fn publication_lease_scope_sha256(&self) -> Option<&str> {
15524 (!self.publication_lease_unsupported).then_some(FAKE_LEASE_SCOPE)
15525 }
15526
15527 fn acquire_publication_lease(
15528 &mut self,
15529 request: &TemporaryRefPublicationLeaseRequest<'_>,
15530 ) -> Result<TemporaryRefPublicationLease, TemporaryRefPublishError> {
15531 if self.publication_lease_unsupported {
15532 return Ok(TemporaryRefPublicationLease::Unsupported);
15533 }
15534 if self.publication_lease_blocked {
15535 return Ok(TemporaryRefPublicationLease::HeldByOther);
15536 }
15537 if self
15538 .publication_leases
15539 .insert(publication_lease_key(request))
15540 {
15541 Ok(TemporaryRefPublicationLease::Acquired)
15542 } else {
15543 Ok(TemporaryRefPublicationLease::AlreadyOwned)
15544 }
15545 }
15546
15547 fn release_publication_lease(&mut self, request: &TemporaryRefPublicationLeaseRequest<'_>) {
15548 self.publication_leases
15549 .remove(&publication_lease_key(request));
15550 }
15551
15552 fn doctor(
15553 &mut self,
15554 _request: &TemporaryRefDoctorRequest<'_>,
15555 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError> {
15556 if let Some(error) = self.doctor_failure.take() {
15557 return Err(error);
15558 }
15559 Ok(TemporaryRefPublisherReadiness {
15560 trusted_ref_tip: CommitSha::new(TRUSTED_TIP).expect("trusted tip"),
15561 })
15562 }
15563
15564 fn verify_execution_workflow(
15565 &mut self,
15566 _request: &ExecutionWorkflowDoctorRequest<'_>,
15567 ) -> Result<TemporaryRefPublisherReadiness, TemporaryRefPublishError> {
15568 self.execution_workflow_checks += 1;
15569 if let Some(error) = self.execution_workflow_failure.take() {
15570 return Err(error);
15571 }
15572 Ok(TemporaryRefPublisherReadiness {
15573 trusted_ref_tip: CommitSha::new(TRUSTED_TIP).expect("trusted tip"),
15574 })
15575 }
15576
15577 fn prepare(
15578 &mut self,
15579 _request: &TemporaryRefPublishRequest<'_>,
15580 ) -> Result<Option<PublishedTemporaryRef>, TemporaryRefPublishError> {
15581 if let Some(error) = self.prepare_failure.take() {
15582 return Err(error);
15583 }
15584 Ok(self.prepared_publication.clone())
15585 }
15586
15587 fn publish(
15588 &mut self,
15589 request: &TemporaryRefPublishRequest<'_>,
15590 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
15591 if let Some(token) = self.cancel_on_publish.take() {
15592 token.cancel();
15593 }
15594 if let Some(failure) = self.failure.take() {
15595 return Err(failure);
15596 }
15597 self.published.push(CapturedPublication {
15598 source_revision: request.source_revision.as_str().to_owned(),
15599 branch: request.temporary_ref.branch().as_str().to_owned(),
15600 workflow_path: request.workflow_path.to_owned(),
15601 workflow_sha256: hex_sha256(request.workflow_bytes),
15602 manifest: serde_json::from_slice(request.manifest_bytes)
15603 .expect("strict manifest JSON"),
15604 authorized: request.authorized,
15605 });
15606 Ok(PublishedTemporaryRef::new(
15607 self.returned_temporary_ref
15608 .clone()
15609 .unwrap_or_else(|| request.temporary_ref.clone()),
15610 CommitSha::new(DISPATCH_SHA).expect("dispatch SHA"),
15611 ))
15612 }
15613
15614 fn publish_prepared(
15615 &mut self,
15616 request: &TemporaryRefPublishRequest<'_>,
15617 prepared: Option<&PublishedTemporaryRef>,
15618 ) -> Result<PublishedTemporaryRef, TemporaryRefPublishFailure> {
15619 let candidate = PublishedTemporaryRef::new(
15620 self.returned_temporary_ref
15621 .clone()
15622 .unwrap_or_else(|| request.temporary_ref.clone()),
15623 CommitSha::new(DISPATCH_SHA).expect("dispatch SHA"),
15624 );
15625 if prepared.is_some_and(|expected| expected != &candidate) {
15626 return Err(TemporaryRefPublishError::PublicationVerificationFailed.into());
15627 }
15628 self.publish(request)
15629 }
15630
15631 fn delete_temporary_ref(
15632 &mut self,
15633 request: &TemporaryRefDeleteRequest<'_>,
15634 ) -> Result<(), TemporaryRefPublishError> {
15635 self.deletions.push(CapturedDeletion {
15636 branch: request.temporary_ref.branch().as_str().to_owned(),
15637 expected_commit: request.expected_commit.as_str().to_owned(),
15638 authorized: request.authorized,
15639 });
15640 if let Some(error) = self.delete_failure.take() {
15641 return Err(error);
15642 }
15643 Ok(())
15644 }
15645 }
15646
15647 impl TemporaryRefReconciler for FakePublisher {
15648 fn reconcile_temporary_ref(
15649 &mut self,
15650 _request: &TemporaryRefReconcileRequest<'_>,
15651 ) -> Result<TemporaryRefReconciliation, TemporaryRefPublishError> {
15652 Ok(self
15653 .reconciliation
15654 .take()
15655 .unwrap_or(TemporaryRefReconciliation::Missing))
15656 }
15657 }
15658
15659 #[derive(Debug, Default)]
15660 struct FakeWorkflowDispatchState {
15661 responses: Mutex<
15662 VecDeque<Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError>>,
15663 >,
15664 requests: Mutex<Vec<GithubWorkflowDispatchHttpRequest>>,
15665 clients_created: AtomicU64,
15666 post_gate: Option<Arc<FakeWorkflowDispatchPostGate>>,
15667 }
15668
15669 #[derive(Debug, Default)]
15670 struct FakeWorkflowDispatchPostGate {
15671 entered: Mutex<bool>,
15672 entered_changed: Condvar,
15673 released: Mutex<bool>,
15674 released_changed: Condvar,
15675 }
15676
15677 impl FakeWorkflowDispatchPostGate {
15678 fn block_post(&self) {
15679 *self.entered.lock().expect("dispatch gate entered") = true;
15680 self.entered_changed.notify_all();
15681 let mut released = self.released.lock().expect("dispatch gate released");
15682 while !*released {
15683 released = self
15684 .released_changed
15685 .wait(released)
15686 .expect("dispatch gate wait");
15687 }
15688 }
15689
15690 fn wait_until_entered(&self) {
15691 let entered = self.entered.lock().expect("dispatch gate entered");
15692 let (entered, timeout) = self
15693 .entered_changed
15694 .wait_timeout_while(entered, StdDuration::from_secs(5), |entered| !*entered)
15695 .expect("dispatch gate wait");
15696 assert!(
15697 *entered && !timeout.timed_out(),
15698 "dispatch POST did not start"
15699 );
15700 }
15701
15702 fn release(&self) {
15703 *self.released.lock().expect("dispatch gate released") = true;
15704 self.released_changed.notify_all();
15705 }
15706 }
15707
15708 #[derive(Clone, Debug, Default)]
15709 struct FakeWorkflowDispatchPlan(Arc<FakeWorkflowDispatchState>);
15710
15711 impl FakeWorkflowDispatchPlan {
15712 fn with_response(response: GithubWorkflowDispatchHttpResponse) -> Self {
15713 let plan = Self::default();
15714 plan.0
15715 .responses
15716 .lock()
15717 .expect("dispatch responses")
15718 .push_back(Ok(response));
15719 plan
15720 }
15721
15722 fn with_blocking_response(
15723 response: GithubWorkflowDispatchHttpResponse,
15724 ) -> (Self, Arc<FakeWorkflowDispatchPostGate>) {
15725 Self::with_blocking_result(Ok(response))
15726 }
15727
15728 fn with_blocking_result(
15729 response: Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError>,
15730 ) -> (Self, Arc<FakeWorkflowDispatchPostGate>) {
15731 let gate = Arc::new(FakeWorkflowDispatchPostGate::default());
15732 let state = FakeWorkflowDispatchState {
15733 responses: Mutex::new(VecDeque::from([response])),
15734 requests: Mutex::new(Vec::new()),
15735 clients_created: AtomicU64::new(0),
15736 post_gate: Some(Arc::clone(&gate)),
15737 };
15738 (Self(Arc::new(state)), gate)
15739 }
15740 }
15741
15742 #[derive(Debug)]
15743 struct FakeWorkflowDispatchFactory(FakeWorkflowDispatchPlan);
15744
15745 impl GithubWorkflowDispatchHttpClientFactory for FakeWorkflowDispatchFactory {
15746 fn create(
15747 self: Box<Self>,
15748 ) -> Result<Box<dyn GithubWorkflowDispatchHttpClient + Send>, GhExecutionError> {
15749 self.0.0.clients_created.fetch_add(1, Ordering::SeqCst);
15750 Ok(Box::new(FakeWorkflowDispatchClient(self.0)))
15751 }
15752 }
15753
15754 #[derive(Debug)]
15755 struct FakeWorkflowDispatchClient(FakeWorkflowDispatchPlan);
15756
15757 impl GithubWorkflowDispatchHttpClient for FakeWorkflowDispatchClient {
15758 fn post(
15759 &mut self,
15760 request: &GithubWorkflowDispatchHttpRequest,
15761 ) -> Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError> {
15762 self.0
15763 .0
15764 .requests
15765 .lock()
15766 .expect("dispatch requests")
15767 .push(request.clone());
15768 if let Some(gate) = &self.0.0.post_gate {
15769 gate.block_post();
15770 }
15771 self.0
15772 .0
15773 .responses
15774 .lock()
15775 .expect("dispatch responses")
15776 .pop_front()
15777 .expect("unexpected workflow dispatch POST")
15778 }
15779 }
15780
15781 #[derive(Debug, Default)]
15782 struct FakeGhRunner {
15783 responses: VecDeque<Result<Vec<u8>, GhExecutionError>>,
15784 requests: Arc<Mutex<Vec<GhRequest>>>,
15785 workflow_dispatch: Option<FakeWorkflowDispatchPlan>,
15786 cancel_before_invocation: Option<(u64, CancellationToken)>,
15787 invocations: u64,
15788 }
15789
15790 impl FakeGhRunner {
15791 fn with(responses: impl IntoIterator<Item = Result<Vec<u8>, GhExecutionError>>) -> Self {
15792 Self {
15793 responses: responses.into_iter().collect(),
15794 requests: Arc::new(Mutex::new(Vec::new())),
15795 workflow_dispatch: None,
15796 cancel_before_invocation: None,
15797 invocations: 0,
15798 }
15799 }
15800
15801 fn with_workflow_dispatch(mut self, plan: FakeWorkflowDispatchPlan) -> Self {
15802 self.workflow_dispatch = Some(plan);
15803 self
15804 }
15805
15806 fn cancelling_before(mut self, invocation: u64, cancellation: CancellationToken) -> Self {
15807 self.cancel_before_invocation = Some((invocation, cancellation));
15808 self
15809 }
15810 }
15811
15812 impl GhRunner for FakeGhRunner {
15813 fn execute(&mut self, request: &GhRequest) -> Result<Vec<u8>, GhExecutionError> {
15814 self.invocations = self.invocations.saturating_add(1);
15815 if self
15816 .cancel_before_invocation
15817 .as_ref()
15818 .is_some_and(|(invocation, _)| *invocation == self.invocations)
15819 {
15820 self.cancel_before_invocation
15821 .as_ref()
15822 .expect("checked cancellation")
15823 .1
15824 .cancel();
15825 }
15826 self.requests
15827 .lock()
15828 .expect("request capture")
15829 .push(request.clone());
15830 self.responses.pop_front().expect("unexpected gh request")
15831 }
15832
15833 fn workflow_dispatch_client_factory(
15834 &self,
15835 ) -> Result<Box<dyn GithubWorkflowDispatchHttpClientFactory>, GhExecutionError> {
15836 self.workflow_dispatch.clone().map_or_else(
15837 || Err(GhExecutionError::AuthenticationUnavailable),
15838 |plan| {
15839 Ok(Box::new(FakeWorkflowDispatchFactory(plan))
15840 as Box<dyn GithubWorkflowDispatchHttpClientFactory>)
15841 },
15842 )
15843 }
15844 }
15845
15846 #[derive(Clone, Debug)]
15847 struct CapturedGitInvocation {
15848 arguments: Vec<OsString>,
15849 stdin_len: usize,
15850 environment: Vec<(GitEnvironmentVariable, OsString)>,
15851 network: GitNetworkPolicy,
15852 stdout_limit: usize,
15853 }
15854
15855 #[derive(Debug, Default)]
15856 struct FakeGitRunner {
15857 responses: VecDeque<Result<Vec<u8>, GitExecutionError>>,
15858 invocations: Vec<CapturedGitInvocation>,
15859 cancel_after_invocations: Option<(usize, CancellationToken)>,
15860 }
15861
15862 impl FakeGitRunner {
15863 fn with(responses: impl IntoIterator<Item = Result<Vec<u8>, GitExecutionError>>) -> Self {
15864 Self {
15865 responses: responses.into_iter().collect(),
15866 invocations: Vec::new(),
15867 cancel_after_invocations: None,
15868 }
15869 }
15870
15871 fn cancelling_after(
15872 responses: impl IntoIterator<Item = Result<Vec<u8>, GitExecutionError>>,
15873 invocation_count: usize,
15874 cancellation: CancellationToken,
15875 ) -> Self {
15876 Self {
15877 responses: responses.into_iter().collect(),
15878 invocations: Vec::new(),
15879 cancel_after_invocations: Some((invocation_count, cancellation)),
15880 }
15881 }
15882
15883 fn record_and_respond(
15884 &mut self,
15885 invocation: &GitInvocation,
15886 stdin_len: usize,
15887 ) -> Result<Vec<u8>, GitExecutionError> {
15888 self.invocations.push(CapturedGitInvocation {
15889 arguments: invocation.arguments().to_vec(),
15890 stdin_len,
15891 environment: invocation.environment().to_vec(),
15892 network: invocation.network,
15893 stdout_limit: invocation.stdout_limit(),
15894 });
15895 if let Some((count, cancellation)) = &self.cancel_after_invocations
15896 && self.invocations.len() == *count
15897 {
15898 cancellation.cancel();
15899 }
15900 self.responses
15901 .pop_front()
15902 .expect("unexpected git invocation")
15903 }
15904 }
15905
15906 impl GitRunner for FakeGitRunner {
15907 fn execute(&mut self, invocation: &GitInvocation) -> Result<Vec<u8>, GitExecutionError> {
15908 self.record_and_respond(invocation, invocation.stdin_len())
15909 }
15910
15911 fn execute_file(
15912 &mut self,
15913 invocation: &GitInvocation,
15914 mut input: fs::File,
15915 expected_len: u64,
15916 ) -> Result<Vec<u8>, GitExecutionError> {
15917 let mut bytes = Vec::new();
15918 input
15919 .read_to_end(&mut bytes)
15920 .map_err(|_| GitExecutionError::ProcessIo)?;
15921 if u64::try_from(bytes.len()).map_err(|_| GitExecutionError::ProcessIo)? != expected_len
15922 {
15923 return Err(GitExecutionError::ProcessIo);
15924 }
15925 self.record_and_respond(invocation, bytes.len())
15926 }
15927
15928 fn process_tree_is_parent_fenced(&self) -> bool {
15929 true
15930 }
15931 }
15932
15933 fn workflow_config_with_secret_names_and_trigger(
15934 secret_names: SigningSecretNames,
15935 run_trigger: WorkflowRunTrigger,
15936 ) -> WorkflowConfig {
15937 let trusted_source_ref = match run_trigger {
15938 WorkflowRunTrigger::Push => "refs/heads/goal3/macless-iphone-builds",
15939 WorkflowRunTrigger::WorkflowDispatch => "refs/heads/main",
15940 };
15941 workflow_config_with_secret_names_trigger_and_trusted_ref(
15942 secret_names,
15943 run_trigger,
15944 trusted_source_ref,
15945 )
15946 }
15947
15948 fn workflow_config_with_secret_names_trigger_and_trusted_ref(
15949 secret_names: SigningSecretNames,
15950 run_trigger: WorkflowRunTrigger,
15951 trusted_source_ref: &str,
15952 ) -> WorkflowConfig {
15953 WorkflowConfig::new_with_run_trigger(
15954 WorkflowFileName::new("rustferry-goal3-iphone.yml").expect("workflow name"),
15955 ProtectedEnvironment::new("rustferry-goal3-signing").expect("environment"),
15956 secret_names,
15957 WorkerDistribution::new(
15958 "https://github.com/ShiroKSH/rust-and-iphone/releases/download/v0.1.0/ferry-worker-macos",
15959 "0".repeat(64),
15960 "0.1.0",
15961 )
15962 .expect("worker"),
15963 PublicSourceRepository::new("shiroksh/rust-and-iphone")
15964 .expect("source repository"),
15965 TrustedSourceRef::new(trusted_source_ref).expect("trusted ref"),
15966 TemporaryBranchNamespace::new("rustferry/goal3/builds").expect("namespace"),
15967 run_trigger,
15968 )
15969 .expect("workflow config")
15970 }
15971
15972 fn workflow_config_with_secret_names(secret_names: SigningSecretNames) -> WorkflowConfig {
15973 workflow_config_with_secret_names_and_trigger(secret_names, WorkflowRunTrigger::Push)
15974 }
15975
15976 fn workflow_config() -> WorkflowConfig {
15977 workflow_config_with_secret_names(SigningSecretNames::goal3_defaults())
15978 }
15979
15980 fn same_repository_provider_config(
15981 authorization: GithubMutationAuthorization,
15982 ) -> GithubProviderConfig {
15983 let workflow = workflow_config();
15984 let fingerprint = WorkflowFingerprint::for_workflow(&generate_workflow(&workflow));
15985 GithubProviderConfig::new(
15986 Repository::new("ShiroKSH", "rust-and-iphone").expect("repository"),
15987 "https://github.com/shiroksh/rust-and-iphone",
15988 workflow,
15989 fingerprint,
15990 GithubPollingPolicy::new(3, Duration::from_millis(250)).expect("poll policy"),
15991 authorization,
15992 &Version::new(0, 1, 0),
15993 8,
15994 )
15995 .expect("provider config")
15996 }
15997
15998 fn provider_config(authorization: GithubMutationAuthorization) -> GithubProviderConfig {
15999 let workflow = workflow_config();
16000 let fingerprint = WorkflowFingerprint::for_workflow(&generate_workflow(&workflow));
16001 GithubProviderConfig::new(
16002 Repository::new("ShiroKSH", "rustferry-signing").expect("execution repository"),
16003 "https://github.com/shiroksh/rust-and-iphone",
16004 workflow,
16005 fingerprint,
16006 GithubPollingPolicy::new(3, Duration::from_millis(250)).expect("poll policy"),
16007 authorization,
16008 &Version::new(0, 1, 0),
16009 8,
16010 )
16011 .expect("split provider config")
16012 }
16013
16014 fn workflow_dispatch_provider_config(
16015 authorization: GithubMutationAuthorization,
16016 ) -> GithubProviderConfig {
16017 let workflow = workflow_config_with_secret_names_and_trigger(
16018 SigningSecretNames::goal3_defaults(),
16019 WorkflowRunTrigger::WorkflowDispatch,
16020 );
16021 let fingerprint = WorkflowFingerprint::for_workflow(&generate_workflow(&workflow));
16022 GithubProviderConfig::new(
16023 Repository::new("ShiroKSH", "rustferry-signing").expect("execution repository"),
16024 "https://github.com/shiroksh/rust-and-iphone",
16025 workflow,
16026 fingerprint,
16027 GithubPollingPolicy::new(3, Duration::from_millis(250)).expect("poll policy"),
16028 authorization,
16029 &Version::new(0, 1, 0),
16030 8,
16031 )
16032 .expect("workflow dispatch provider config")
16033 }
16034
16035 fn workflow_dispatch_provider_config_with_trusted_ref(
16036 authorization: GithubMutationAuthorization,
16037 trusted_source_ref: &str,
16038 ) -> GithubProviderConfig {
16039 let workflow = workflow_config_with_secret_names_trigger_and_trusted_ref(
16040 SigningSecretNames::goal3_defaults(),
16041 WorkflowRunTrigger::WorkflowDispatch,
16042 trusted_source_ref,
16043 );
16044 let fingerprint = WorkflowFingerprint::for_workflow(&generate_workflow(&workflow));
16045 GithubProviderConfig::new(
16046 Repository::new("ShiroKSH", "rustferry-signing").expect("execution repository"),
16047 "https://github.com/shiroksh/rust-and-iphone",
16048 workflow,
16049 fingerprint,
16050 GithubPollingPolicy::new(3, Duration::from_millis(250)).expect("poll policy"),
16051 authorization,
16052 &Version::new(0, 1, 0),
16053 8,
16054 )
16055 .expect("workflow dispatch provider config")
16056 }
16057
16058 fn provider_config_with_secret_names(
16059 authorization: GithubMutationAuthorization,
16060 secret_names: SigningSecretNames,
16061 ) -> GithubProviderConfig {
16062 let workflow = workflow_config_with_secret_names(secret_names);
16063 let fingerprint = WorkflowFingerprint::for_workflow(&generate_workflow(&workflow));
16064 GithubProviderConfig::new(
16065 Repository::new("ShiroKSH", "rustferry-signing").expect("execution repository"),
16066 "https://github.com/shiroksh/rust-and-iphone",
16067 workflow,
16068 fingerprint,
16069 GithubPollingPolicy::new(3, Duration::from_millis(250)).expect("poll policy"),
16070 authorization,
16071 &Version::new(0, 1, 0),
16072 8,
16073 )
16074 .expect("split provider config")
16075 }
16076
16077 fn transport(runner: FakeGhRunner) -> GithubTransport<FakeGhRunner> {
16078 GithubTransport::new(runner, TransportLimits::secure_defaults())
16079 }
16080
16081 fn expected_publication() -> PublishedTemporaryRef {
16082 PublishedTemporaryRef::new(
16083 TemporaryGitRef::new(
16084 provider_config(all_authorized())
16085 .workflow
16086 .temporary_branch_namespace(),
16087 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
16088 )
16089 .expect("temporary ref"),
16090 CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
16091 )
16092 }
16093
16094 fn private_repository_row() -> Vec<u8> {
16095 b"991\tShiroKSH/rustferry-signing\ttrue\tfalse\tfalse\tmain\n".to_vec()
16096 }
16097
16098 fn authenticated_user_row() -> Vec<u8> {
16099 b"42\tU2hpcm9LU0g=\n".to_vec()
16100 }
16101
16102 fn public_source_repository_row() -> Vec<u8> {
16103 b"992\tShiroKSH/rust-and-iphone\tfalse\tfalse\tfalse\tmain\n".to_vec()
16104 }
16105
16106 fn private_source_repository_row() -> Vec<u8> {
16107 b"992\tShiroKSH/rust-and-iphone\ttrue\tfalse\tfalse\tmain\n".to_vec()
16108 }
16109
16110 fn signed_transport(mut runner: FakeGhRunner) -> GithubTransport<FakeGhRunner> {
16111 runner.responses.push_front(Ok(exact_signing_secret_rows()));
16112 runner.responses.push_front(Ok(exact_signing_policy_row()));
16113 runner
16114 .responses
16115 .push_front(Ok(signing_environment_row(false, true, true)));
16116 runner.responses.push_front(Ok(private_repository_row()));
16117 runner
16118 .responses
16119 .push_front(Ok(public_source_repository_row()));
16120 transport(runner)
16121 }
16122
16123 fn valid_request() -> IosDeviceBuildRequest {
16124 let team = DevelopmentTeam::new("ABCDE12345", None).expect("team");
16125 let secret =
16126 |name| SecretReference::new(SecretReferenceKind::GithubActions, name).expect("secret");
16127 let signing = SigningPlan {
16128 mode: SigningMode::ManualDevelopment,
16129 signing: Some(SigningReference {
16130 identity: SigningIdentity {
16131 certificate: SigningCertificate {
16132 common_name: "Apple Development".to_owned(),
16133 sha256_fingerprint: "A".repeat(64),
16134 team: team.clone(),
16135 expires_at_unix_seconds: u64::MAX,
16136 },
16137 private_key: SigningPrivateKeyReference {
16138 reference: secret("RUSTFERRY_GOAL3_IOS_CERTIFICATE_P12"),
16139 },
16140 },
16141 password: Some(secret("RUSTFERRY_GOAL3_IOS_CERTIFICATE_PASSWORD")),
16142 }),
16143 team: Some(DevelopmentTeamPlan {
16144 expected: team.clone(),
16145 }),
16146 device: Some(DevicePlan::from_sha256("0".repeat(64), None).expect("device")),
16147 targets: vec![SigningTarget {
16148 name: "App".to_owned(),
16149 bundle_identifier: BundleIdentifier::new("com.example.app").expect("bundle"),
16150 kind: SigningTargetKind::Application,
16151 }],
16152 provisioning: vec![ProvisioningPlan {
16153 target: "App".to_owned(),
16154 profile: secret("RUSTFERRY_GOAL3_IOS_PROVISIONING_PROFILE"),
16155 profile_type: ProvisioningProfileType::Development,
16156 }],
16157 entitlements: vec![EntitlementPlan {
16158 target: "App".to_owned(),
16159 required: EntitlementSet::new(BTreeMap::new()).expect("entitlements"),
16160 }],
16161 allow_provisioning_updates: false,
16162 };
16163 let request = IosDeviceBuildRequest {
16164 protocol_version: CURRENT_PROTOCOL_VERSION,
16165 operation_id: "operation-1".to_owned(),
16166 product_name: "App".to_owned(),
16167 bundle_identifier: "com.example.app".to_owned(),
16168 minimum_ios_version: "16.0".to_owned(),
16169 product: IosDeviceProductExpectation {
16170 app_directory_name: "App.app".to_owned(),
16171 executable: "App".to_owned(),
16172 app_version: "1.0.0".to_owned(),
16173 build_number: "1".to_owned(),
16174 nested_bundles: Vec::new(),
16175 },
16176 profile: BuildProfile::Release,
16177 source_mode: SourceMode::Git,
16178 source_repository: Some("https://github.com/shiroksh/rust-and-iphone".to_owned()),
16179 source_revision: Some(SOURCE_SHA.to_owned()),
16180 source: compile_source_manifest(),
16181 signing,
16182 requested_artifacts: BTreeSet::from([
16183 IosArtifactType::Ipa,
16184 IosArtifactType::SigningReport,
16185 ]),
16186 };
16187 request.validate().expect("valid Git request");
16188 request
16189 }
16190
16191 fn multi_profile_request() -> (IosDeviceBuildRequest, SigningSecretNames) {
16192 let mut request = valid_request();
16193 let targets = vec![
16194 request.signing.targets[0].clone(),
16195 SigningTarget {
16196 name: "Widget".to_owned(),
16197 bundle_identifier: BundleIdentifier::new("com.example.app.widget")
16198 .expect("widget bundle"),
16199 kind: SigningTargetKind::Extension,
16200 },
16201 ];
16202 let names = SigningSecretNames::for_targets(&targets).expect("profile names");
16203 let secret = |name: &str| {
16204 SecretReference::new(SecretReferenceKind::GithubActions, name).expect("secret")
16205 };
16206 request.signing.targets = targets;
16207 request.product.nested_bundles = vec![UnsignedNestedBundleExpectation {
16208 relative_path: "PlugIns/Widget.appex".to_owned(),
16209 bundle_identifier: "com.example.app.widget".to_owned(),
16210 executable: "Widget".to_owned(),
16211 kind: UnsignedNestedBundleKind::AppExtension,
16212 }];
16213 request.signing.provisioning = vec![
16214 ProvisioningPlan {
16215 target: "App".to_owned(),
16216 profile: secret(
16217 names
16218 .profile_for_target("App")
16219 .expect("application profile")
16220 .as_str(),
16221 ),
16222 profile_type: ProvisioningProfileType::Development,
16223 },
16224 ProvisioningPlan {
16225 target: "Widget".to_owned(),
16226 profile: secret(
16227 names
16228 .profile_for_target("Widget")
16229 .expect("widget profile")
16230 .as_str(),
16231 ),
16232 profile_type: ProvisioningProfileType::Development,
16233 },
16234 ];
16235 request.signing.entitlements.push(EntitlementPlan {
16236 target: "Widget".to_owned(),
16237 required: EntitlementSet::new(BTreeMap::new()).expect("widget entitlements"),
16238 });
16239 request.validate().expect("valid multi-profile request");
16240 (request, names)
16241 }
16242
16243 fn multi_profile_request_with_framework() -> (IosDeviceBuildRequest, SigningSecretNames) {
16244 let (mut request, _) = multi_profile_request();
16245 request.signing.targets.push(SigningTarget {
16246 name: "RuntimeBridge".to_owned(),
16247 bundle_identifier: BundleIdentifier::new("com.example.app.runtime-bridge")
16248 .expect("framework bundle"),
16249 kind: SigningTargetKind::Framework,
16250 });
16251 request
16252 .product
16253 .nested_bundles
16254 .push(UnsignedNestedBundleExpectation {
16255 relative_path: "Frameworks/RuntimeBridge.framework".to_owned(),
16256 bundle_identifier: "com.example.app.runtime-bridge".to_owned(),
16257 executable: "RuntimeBridge".to_owned(),
16258 kind: UnsignedNestedBundleKind::Framework,
16259 });
16260 request
16261 .product
16262 .nested_bundles
16263 .sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
16264 request
16265 .validate()
16266 .expect("valid multi-profile request with framework");
16267 let names = SigningSecretNames::for_targets(&request.signing.targets)
16268 .expect("framework-bound profile names");
16269 (request, names)
16270 }
16271
16272 fn assert_signing_reference_mismatch(
16273 config: &GithubProviderConfig,
16274 request: &IosDeviceBuildRequest,
16275 ) {
16276 let error = validate_submission(config, request)
16277 .expect_err("configured signing target graph must remain exact");
16278 assert!(matches!(
16279 error,
16280 RemoteBuildError::ProviderFailure {
16281 code,
16282 retryable: false,
16283 ..
16284 } if code == "signing_secret_reference_mismatch"
16285 ));
16286 }
16287
16288 fn unsigned_request() -> IosDeviceBuildRequest {
16289 let mut request = valid_request();
16290 request.signing.mode = SigningMode::UnsignedCompileOnly;
16291 request.signing.signing = None;
16292 request.signing.team = None;
16293 request.signing.device = None;
16294 request.signing.provisioning.clear();
16295 request.requested_artifacts = BTreeSet::from([IosArtifactType::Xcarchive]);
16296 request.validate().expect("valid unsigned Git request");
16297 request
16298 }
16299
16300 struct SnapshotResumeFixture {
16301 isolation: TestPrivateDirectory,
16302 resume: GithubJobResumeV1,
16303 }
16304
16305 fn snapshot_object(byte: char) -> GitSha1ObjectId {
16306 GitSha1ObjectId::new(byte.to_string().repeat(40)).expect("snapshot object ID")
16307 }
16308
16309 #[allow(
16310 clippy::too_many_lines,
16311 reason = "the fixture constructs one complete descriptor, stage, locator, and durable resume binding"
16312 )]
16313 fn snapshot_resume_fixture(phase: GithubGitSnapshotPhaseV1) -> SnapshotResumeFixture {
16314 let config = provider_config(all_authorized());
16315 let mut request = unsigned_request();
16316 request.source_mode = SourceMode::GitSnapshot;
16317 request.source_revision = None;
16318 let isolation = tempdir().expect("snapshot isolation");
16319 let stage_directory = crate::snapshot::GitSnapshotStageDirectory::create(
16320 isolation.path(),
16321 &request.operation_id,
16322 )
16323 .expect("snapshot stage directory");
16324 let archive_bytes = SNAPSHOT_ARCHIVE_BYTES;
16325 let archive = SourceArchive {
16326 size: u64::try_from(archive_bytes.len()).expect("archive size"),
16327 sha256: hex_sha256(archive_bytes),
16328 };
16329 let mut archive_file = fs::OpenOptions::new()
16330 .write(true)
16331 .create_new(true)
16332 .open(stage_directory.archive_path())
16333 .expect("create snapshot archive");
16334 archive_file
16335 .write_all(archive_bytes)
16336 .expect("write snapshot archive");
16337 archive_file.sync_all().expect("sync snapshot archive");
16338 drop(archive_file);
16339 let archive_identity = stage_directory
16340 .seal_archive(&archive)
16341 .expect("seal snapshot archive");
16342 let descriptor = GitSnapshotDescriptor::from_request(
16343 &request,
16344 SourceBundleDescriptor::new(archive.clone(), request.source.clone()),
16345 )
16346 .expect("snapshot descriptor");
16347 let descriptor_bytes =
16348 rustferry_remote::canonical_git_snapshot_descriptor_bytes(&descriptor)
16349 .expect("canonical snapshot descriptor");
16350 let descriptor_identity = stage_directory
16351 .write_descriptor_create_new(&descriptor)
16352 .expect("write snapshot descriptor");
16353 let graph = GitSnapshotObjectGraphV1 {
16354 schema_version: crate::snapshot::GIT_SNAPSHOT_GRAPH_SCHEMA_VERSION,
16355 archive_blob: snapshot_object('1'),
16356 descriptor_blob: snapshot_object('2'),
16357 goal3_tree: snapshot_object('3'),
16358 rustferry_tree: snapshot_object('4'),
16359 root_tree: snapshot_object('5'),
16360 commit: GitSha1ObjectId::new(SOURCE_SHA).expect("snapshot commit"),
16361 };
16362 let source_created_at_ms = 1_700_000_000_000;
16363 request.source_revision = Some(SOURCE_SHA.to_owned());
16364 request.validate().expect("valid snapshot request");
16365 let stage = GitSnapshotStageV1 {
16366 schema_version: crate::snapshot::GIT_SNAPSHOT_STAGE_SCHEMA_VERSION,
16367 operation_id: request.operation_id.clone(),
16368 isolation_root_identity: stage_directory.isolation_root_identity().to_owned(),
16369 snapshots_store_identity: stage_directory.snapshots_store_identity().to_owned(),
16370 stage_directory_identity: stage_directory.stage_directory_identity().to_owned(),
16371 source_repository: config.source_repository.clone(),
16372 source_ref: GitSnapshotSourceRef::for_operation(&request.operation_id)
16373 .expect("snapshot source ref"),
16374 keepalive_ref: GitSnapshotKeepaliveRef::for_operation(&request.operation_id)
16375 .expect("snapshot keepalive ref"),
16376 source_created_at_ms,
16377 consent_sha256: "9".repeat(64),
16378 request_template_sha256: descriptor.request_template_sha256.clone(),
16379 manifest_sha256: request.source.sha256.clone(),
16380 archive,
16381 descriptor_sha256: hex_sha256(&descriptor_bytes),
16382 final_request: request.clone(),
16383 archive_file_identity: archive_identity.to_string(),
16384 descriptor_file_identity: descriptor_identity.to_string(),
16385 graph,
16386 };
16387 let locator = stage_directory
16388 .publish_metadata_create_new(&stage, &descriptor, &request)
16389 .expect("publish snapshot metadata");
16390 let source_phase = !matches!(
16391 phase,
16392 GithubGitSnapshotPhaseV1::Prepared
16393 | GithubGitSnapshotPhaseV1::KeepaliveIntent
16394 | GithubGitSnapshotPhaseV1::KeepaliveExact
16395 | GithubGitSnapshotPhaseV1::StageDeleteIntent
16396 | GithubGitSnapshotPhaseV1::StageDeleted
16397 );
16398 let source_armed = matches!(
16399 phase,
16400 GithubGitSnapshotPhaseV1::SourceExact
16401 | GithubGitSnapshotPhaseV1::SourceAbsent
16402 | GithubGitSnapshotPhaseV1::SourceConflict
16403 | GithubGitSnapshotPhaseV1::SourceCleanupIntent
16404 | GithubGitSnapshotPhaseV1::SourceDeleted
16405 | GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent
16406 | GithubGitSnapshotPhaseV1::KeepaliveReleased
16407 );
16408 let absence_proven = phase == GithubGitSnapshotPhaseV1::SourceAbsent;
16409 let source_started_at_ms = if source_armed {
16410 source_created_at_ms
16411 } else {
16412 0
16413 };
16414 let source_deadline_ms = if !source_phase {
16415 0
16416 } else if source_armed {
16417 publication_quiescence_deadline(source_started_at_ms, 0)
16418 } else {
16419 u64::MAX
16420 };
16421 let mut resume = durable_prepublication_resume();
16422 resume.request = request;
16423 resume.request_sha256 = canonical_request_sha256(&resume.request).expect("request digest");
16424 resume.source_revision = SOURCE_SHA.to_owned();
16425 resume.created_at_ms = source_created_at_ms;
16426 resume.git_snapshot = Some(GithubGitSnapshotResumeV1 {
16427 schema_version: GITHUB_GIT_SNAPSHOT_RESUME_SCHEMA_VERSION,
16428 stage_locator: locator,
16429 stage,
16430 phase,
16431 source_publication_attempts: u8::from(source_phase),
16432 source_publication_started_at_ms: source_started_at_ms,
16433 source_publication_quiescence_deadline_ms: source_deadline_ms,
16434 source_publication_process_fenced: source_phase,
16435 source_publication_lease_scope_sha256: source_phase
16436 .then(|| FAKE_LEASE_SCOPE.to_owned()),
16437 source_publication_absence_observations: if absence_proven { 2 } else { 0 },
16438 source_publication_absence_first_observed_at_ms: if absence_proven {
16439 source_deadline_ms
16440 } else {
16441 0
16442 },
16443 source_publication_absence_last_observed_at_ms: if absence_proven {
16444 source_deadline_ms
16445 } else {
16446 0
16447 },
16448 keepalive_release_authorization_sha256: None,
16449 });
16450 resume.prepared_dispatch_commit = None;
16451 resume.publication_started_at_ms = 0;
16452 resume.publication_quiescence_deadline_ms = u64::MAX;
16453 resume.publication_intent = false;
16454 resume.publication_process_fenced = false;
16455 resume.publication_lease_scope_sha256 = None;
16456 resume.publication_absence_observations = 0;
16457 resume.publication_absence_first_observed_at_ms = 0;
16458 resume.run_discovery_deadline_ms =
16459 source_created_at_ms.saturating_add(config.poll_policy.discovery_window_ms());
16460 resume
16461 .git_snapshot
16462 .as_ref()
16463 .expect("snapshot resume")
16464 .validate_for_request(&resume.request)
16465 .expect("valid snapshot resume");
16466 SnapshotResumeFixture { isolation, resume }
16467 }
16468
16469 fn completed_snapshot_resume(
16470 phase: GithubGitSnapshotPhaseV1,
16471 state: JobState,
16472 ) -> SnapshotResumeFixture {
16473 let mut fixture = snapshot_resume_fixture(phase);
16474 let snapshot_request = fixture.resume.request.clone();
16475 let snapshot = fixture.resume.git_snapshot.take().expect("snapshot resume");
16476 let mut resume = durable_success_resume();
16477 resume.request = snapshot_request;
16478 resume.request_sha256 =
16479 canonical_request_sha256(&resume.request).expect("snapshot request digest");
16480 resume.source_revision = SOURCE_SHA.to_owned();
16481 resume.git_snapshot = Some(snapshot);
16482 resume.compile_evidence = Some(compile_evidence_for(&resume.request));
16483 resume.state = state;
16484 resume.cleanup_requested = matches!(
16485 state,
16486 JobState::Cleaning | JobState::Cleaned | JobState::CleanupFailed
16487 );
16488 resume.temporary_ref_deleted = resume.cleanup_requested;
16489 fixture.resume = resume;
16490 fixture
16491 }
16492
16493 fn durable_identity_for_resume(resume: &GithubJobResumeV1) -> GithubDurableIdentityV1 {
16494 GithubDurableIdentityV1 {
16495 provider: resume.provider.clone(),
16496 provider_config_sha256: resume.provider_config_sha256.clone(),
16497 principal: resume.principal.clone(),
16498 execution_repository: resume.execution_repository.clone(),
16499 execution_repository_id: resume.execution_repository_id,
16500 }
16501 }
16502
16503 struct InitialSnapshotPrecomputeFixture {
16504 isolation: TestPrivateDirectory,
16505 stage_directory: GitSnapshotStageDirectory,
16506 archive_identity: rustferry_core::RegularFileFilesystemIdentity,
16507 descriptor_identity: rustferry_core::RegularFileFilesystemIdentity,
16508 archive: SourceArchive,
16509 descriptor: GitSnapshotDescriptor,
16510 }
16511
16512 impl InitialSnapshotPrecomputeFixture {
16513 fn inputs(&self) -> GitSnapshotPrecomputeInputs {
16514 self.stage_directory
16515 .precompute_inputs(
16516 &self.archive_identity,
16517 &self.descriptor_identity,
16518 &self.archive,
16519 &self.descriptor,
16520 )
16521 .expect("retained initial snapshot inputs")
16522 }
16523 }
16524
16525 fn initial_snapshot_precompute_fixture() -> InitialSnapshotPrecomputeFixture {
16526 let isolation = tempdir().expect("snapshot precompute isolation");
16527 let mut request = unsigned_request();
16528 request.source_mode = SourceMode::GitSnapshot;
16529 request.source_revision = None;
16530 let stage_directory =
16531 GitSnapshotStageDirectory::create(isolation.path(), request.operation_id.as_str())
16532 .expect("snapshot precompute stage");
16533 let archive = SourceArchive {
16534 size: u64::try_from(SNAPSHOT_ARCHIVE_BYTES.len()).expect("snapshot archive size"),
16535 sha256: hex_sha256(SNAPSHOT_ARCHIVE_BYTES),
16536 };
16537 let archive_identity = stage_directory
16538 .write_archive_bytes_create_new(SNAPSHOT_ARCHIVE_BYTES, &archive)
16539 .expect("snapshot precompute archive");
16540 let descriptor = GitSnapshotDescriptor::from_request(
16541 &request,
16542 SourceBundleDescriptor::new(archive.clone(), request.source.clone()),
16543 )
16544 .expect("snapshot precompute descriptor");
16545 let descriptor_identity = stage_directory
16546 .write_descriptor_create_new(&descriptor)
16547 .expect("snapshot precompute descriptor file");
16548 InitialSnapshotPrecomputeFixture {
16549 isolation,
16550 stage_directory,
16551 archive_identity,
16552 descriptor_identity,
16553 archive,
16554 descriptor,
16555 }
16556 }
16557
16558 fn initial_snapshot_graph_responses() -> Vec<Result<Vec<u8>, GitExecutionError>> {
16559 vec![
16560 Ok(b"sha1\n".to_vec()),
16561 Ok(format!("{}\n", "1".repeat(40)).into_bytes()),
16562 Ok(format!("{}\n", "2".repeat(40)).into_bytes()),
16563 Ok(format!("{}\n", "3".repeat(40)).into_bytes()),
16564 Ok(format!("{}\n", "4".repeat(40)).into_bytes()),
16565 Ok(format!("{}\n", "5".repeat(40)).into_bytes()),
16566 Ok(format!("{}\n", "6".repeat(40)).into_bytes()),
16567 ]
16568 }
16569
16570 fn exact_retry_git_cycle(
16571 parent: &GithubJobResumeV1,
16572 archive_bytes: &[u8],
16573 archive_blob: char,
16574 ) -> Vec<Result<Vec<u8>, GitExecutionError>> {
16575 let snapshot = parent.git_snapshot.as_ref().expect("snapshot resume");
16576 vec![
16577 Ok(format!(
16578 "{}\0{}\n",
16579 snapshot.stage.keepalive_ref.as_str(),
16580 snapshot.stage.graph.commit.as_str()
16581 )
16582 .into_bytes()),
16583 Ok(b"sha1\n".to_vec()),
16584 Ok(archive_bytes.to_vec()),
16585 Ok(format!("{}\n", archive_blob.to_string().repeat(40)).into_bytes()),
16586 Ok(format!("{}\n", "a".repeat(40)).into_bytes()),
16587 Ok(format!("{}\n", "b".repeat(40)).into_bytes()),
16588 Ok(format!("{}\n", "c".repeat(40)).into_bytes()),
16589 Ok(format!("{}\n", "d".repeat(40)).into_bytes()),
16590 Ok(format!("{}\n", "e".repeat(40)).into_bytes()),
16591 Ok(format!(
16592 "{}\0{}\n",
16593 snapshot.stage.keepalive_ref.as_str(),
16594 snapshot.stage.graph.commit.as_str()
16595 )
16596 .into_bytes()),
16597 ]
16598 }
16599
16600 fn synthetic_exact_retry_plan(parent: &GithubJobResumeV1) -> GithubGitSnapshotExactRetryPlanV1 {
16601 let parent_stage = &parent.git_snapshot.as_ref().expect("snapshot").stage;
16602 let mut request = parent.request.clone();
16603 request.operation_id = "operation-2".to_owned();
16604 let mut graph = parent_stage.graph.clone();
16605 graph.commit = snapshot_object('e');
16606 request.source_revision = Some(graph.commit.as_str().to_owned());
16607 request.validate().expect("synthetic retry request");
16608 let descriptor = GitSnapshotDescriptor::from_request(
16609 &request,
16610 SourceBundleDescriptor::new(parent_stage.archive.clone(), request.source.clone()),
16611 )
16612 .expect("synthetic retry descriptor");
16613 let descriptor_bytes = canonical_git_snapshot_descriptor_bytes(&descriptor)
16614 .expect("synthetic descriptor bytes");
16615 GithubGitSnapshotExactRetryPlanV1 {
16616 request,
16617 source_created_at_ms: 1_700_000_001_000,
16618 request_template_sha256: descriptor.request_template_sha256,
16619 manifest_sha256: parent_stage.manifest_sha256.clone(),
16620 archive: parent_stage.archive.clone(),
16621 descriptor_sha256: hex_sha256(&descriptor_bytes),
16622 graph,
16623 }
16624 }
16625
16626 fn same_invocation_snapshot_submission(
16627 fixture: &SnapshotResumeFixture,
16628 ) -> GithubGitSnapshotSubmissionV1 {
16629 let resume = &fixture.resume;
16630 let snapshot = resume.git_snapshot.as_ref().expect("snapshot resume");
16631 GithubGitSnapshotSubmissionV1::same_invocation(
16632 GithubDurableIdentityV1 {
16633 provider: resume.provider.clone(),
16634 provider_config_sha256: resume.provider_config_sha256.clone(),
16635 principal: resume.principal.clone(),
16636 execution_repository: resume.execution_repository.clone(),
16637 execution_repository_id: resume.execution_repository_id,
16638 },
16639 resume.request.clone(),
16640 snapshot.stage.consent_sha256.clone(),
16641 snapshot.stage_locator.clone(),
16642 snapshot.stage.clone(),
16643 )
16644 .expect("same-invocation snapshot submission")
16645 }
16646
16647 fn public_snapshot_identity_rows(
16648 boundary_count: usize,
16649 ) -> Vec<Result<Vec<u8>, GhExecutionError>> {
16650 let mut rows = Vec::with_capacity(boundary_count.saturating_mul(3));
16651 for _ in 0..boundary_count {
16652 rows.push(Ok(authenticated_user_row()));
16653 rows.push(Ok(private_repository_row()));
16654 rows.push(Ok(public_source_repository_row()));
16655 }
16656 rows
16657 }
16658
16659 fn compile_source_manifest() -> SourceManifest {
16660 let entries = vec![
16661 SourceManifestEntry {
16662 path: "Cargo.lock".to_owned(),
16663 size: 0,
16664 sha256: "b".repeat(64),
16665 executable: false,
16666 },
16667 SourceManifestEntry {
16668 path: "ferry.toml".to_owned(),
16669 size: 0,
16670 sha256: "c".repeat(64),
16671 executable: false,
16672 },
16673 ];
16674 let mut digest = Sha256::new();
16675 digest.update(b"rustferry-source-manifest-v1\0");
16676 digest.update(1_u64.to_be_bytes());
16677 digest.update(b".");
16678 digest.update((entries.len() as u64).to_be_bytes());
16679 for entry in &entries {
16680 digest.update((entry.path.len() as u64).to_be_bytes());
16681 digest.update(entry.path.as_bytes());
16682 digest.update(entry.size.to_be_bytes());
16683 digest.update((entry.sha256.len() as u64).to_be_bytes());
16684 digest.update(entry.sha256.as_bytes());
16685 digest.update([u8::from(entry.executable)]);
16686 }
16687 digest.update(0_u64.to_be_bytes());
16688 SourceManifest {
16689 schema_version: 1,
16690 project_path: ".".to_owned(),
16691 entries,
16692 total_size: 0,
16693 sha256: hex::encode(digest.finalize()),
16694 }
16695 }
16696
16697 fn compile_evidence_for(request: &IosDeviceBuildRequest) -> CompilePhaseEvidence {
16698 let expectation = UnsignedXcarchiveExpectation {
16699 app_directory_name: request.product.app_directory_name.clone(),
16700 bundle_identifier: request.bundle_identifier.clone(),
16701 executable: request.product.executable.clone(),
16702 app_version: request.product.app_version.clone(),
16703 build_number: request.product.build_number.clone(),
16704 minimum_os: request.minimum_ios_version.clone(),
16705 sdk_version: "26.0".to_owned(),
16706 sdk_build_version: "23A".to_owned(),
16707 nested_bundles: request.product.nested_bundles.clone(),
16708 required_resources: BTreeMap::new(),
16709 };
16710 CompilePhaseEvidence {
16711 schema_version: COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION,
16712 job_id: request.operation_id.clone(),
16713 provider: GITHUB_PROVIDER_ID.to_owned(),
16714 request_sha256: canonical_request_sha256(request).expect("request digest"),
16715 source_sha256: request.source.sha256.clone(),
16716 cargo_lock_sha256: "b".repeat(64),
16717 config_sha256: "c".repeat(64),
16718 rustferry_version: "0.1.0".to_owned(),
16719 worker_version: "0.1.0".to_owned(),
16720 toolchain: CompileToolchainEvidence {
16721 worker_os: "macOS 26.0".to_owned(),
16722 worker_architecture: "arm64".to_owned(),
16723 xcode_version: "26.0".to_owned(),
16724 iphoneos_sdk_version: "26.0".to_owned(),
16725 iphoneos_sdk_build_version: "23A".to_owned(),
16726 developer_directory_sha256: "d".repeat(64),
16727 rust_version: "rustc 1.92.0".to_owned(),
16728 rust_target: rustferry_remote::IOS_DEVICE_RUST_TARGET.to_owned(),
16729 },
16730 sealed_archive: SealedUnsignedArchive {
16731 schema_version: SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION,
16732 transport: SourceArchive {
16733 size: 1,
16734 sha256: "e".repeat(64),
16735 },
16736 contents: request.source.clone(),
16737 expectation,
16738 },
16739 archive_inspection: UnsignedXcarchiveInspection {
16740 application_path: "Applications/App.app".to_owned(),
16741 architectures: vec!["arm64".to_owned()],
16742 app: UnsignedAppInspection {
16743 app_directory_name: request.product.app_directory_name.clone(),
16744 bundle_identifier: request.bundle_identifier.clone(),
16745 executable: request.product.executable.clone(),
16746 main_executable: Vec::new(),
16747 nested_executables: BTreeMap::new(),
16748 extensions: Vec::new(),
16749 resources: BTreeMap::new(),
16750 entries: Vec::new(),
16751 },
16752 entries: Vec::new(),
16753 },
16754 started_at_unix_seconds: 1_700_000_000,
16755 finished_at_unix_seconds: 1_700_000_060,
16756 }
16757 }
16758
16759 fn large_source_manifest() -> SourceManifest {
16760 let entries = (0..20_000)
16761 .map(|index| SourceManifestEntry {
16762 path: format!("file-{index:05}"),
16763 size: 0,
16764 sha256: "0".repeat(64),
16765 executable: false,
16766 })
16767 .collect::<Vec<_>>();
16768 let mut digest = Sha256::new();
16769 digest.update(b"rustferry-source-manifest-v1\0");
16770 digest.update(1_u64.to_be_bytes());
16771 digest.update(b".");
16772 digest.update((entries.len() as u64).to_be_bytes());
16773 for entry in &entries {
16774 digest.update((entry.path.len() as u64).to_be_bytes());
16775 digest.update(entry.path.as_bytes());
16776 digest.update(entry.size.to_be_bytes());
16777 digest.update((entry.sha256.len() as u64).to_be_bytes());
16778 digest.update(entry.sha256.as_bytes());
16779 digest.update([u8::from(entry.executable)]);
16780 }
16781 digest.update(0_u64.to_be_bytes());
16782 SourceManifest {
16783 schema_version: 1,
16784 project_path: ".".to_owned(),
16785 entries,
16786 total_size: 0,
16787 sha256: hex::encode(digest.finalize()),
16788 }
16789 }
16790
16791 fn poll_ready<T>(mut future: ProviderFuture<'_, T>) -> RemoteBuildResult<T> {
16792 let waker = Waker::noop();
16793 let mut context = Context::from_waker(waker);
16794 match future.as_mut().poll(&mut context) {
16795 Poll::Ready(result) => result,
16796 Poll::Pending => panic!("provider future unexpectedly pending"),
16797 }
16798 }
16799
16800 fn all_authorized() -> GithubMutationAuthorization {
16801 GithubMutationAuthorization {
16802 publish_temporary_ref: true,
16803 cancel_run: true,
16804 delete_temporary_ref: true,
16805 publish_git_snapshot_source: true,
16806 delete_git_snapshot_source: true,
16807 manage_git_snapshot_keepalive: true,
16808 }
16809 }
16810
16811 fn completed_run_row(conclusion: &str) -> Vec<u8> {
16812 format!(
16813 "41\t17\t{WORKFLOW_PATH_BASE64}\t9\t1\t{DISPATCH_SHA}\t{BRANCH_BASE64}\tpush\tcompleted\t{conclusion}\n"
16814 )
16815 .into_bytes()
16816 }
16817
16818 fn queued_run_row() -> Vec<u8> {
16819 queued_run_attempt_row(1)
16820 }
16821
16822 fn queued_run_attempt_row(attempt: u64) -> Vec<u8> {
16823 format!(
16824 "41\t17\t{WORKFLOW_PATH_BASE64}\t9\t{attempt}\t{DISPATCH_SHA}\t{BRANCH_BASE64}\tpush\tqueued\t\n"
16825 )
16826 .into_bytes()
16827 }
16828
16829 fn workflow_dispatch_registration_row() -> Vec<u8> {
16830 format!("17\t{WORKFLOW_PATH_BASE64}\tactive\n").into_bytes()
16831 }
16832
16833 fn workflow_dispatch_run_row(run_id: u64) -> Vec<u8> {
16834 let request_sha256 = canonical_request_sha256(&unsigned_request()).expect("request digest");
16835 let run_name = crate::transport::canonical_workflow_dispatch_run_name(
16836 "operation-1",
16837 &request_sha256,
16838 &CommitSha::new(SOURCE_SHA).expect("source revision"),
16839 &CommitSha::new(DISPATCH_SHA).expect("dispatch revision"),
16840 )
16841 .expect("workflow dispatch run name");
16842 let run_name = base64_fixture(&run_name);
16843 workflow_dispatch_run_row_with_title(run_id, &run_name)
16844 }
16845
16846 fn workflow_dispatch_run_row_with_title(run_id: u64, encoded_run_name: &str) -> Vec<u8> {
16847 format!(
16848 "{run_id}\t17\t{WORKFLOW_PATH_BASE64}\t9\t1\t{DISPATCH_SHA}\t{BRANCH_BASE64}\tworkflow_dispatch\tqueued\t\t{encoded_run_name}\n"
16849 )
16850 .into_bytes()
16851 }
16852
16853 fn ordinary_workflow_dispatch_run_row(run_id: u64) -> Vec<u8> {
16854 format!(
16855 "{run_id}\t17\t{WORKFLOW_PATH_BASE64}\t9\t1\t{DISPATCH_SHA}\t{BRANCH_BASE64}\tworkflow_dispatch\tqueued\t\n"
16856 )
16857 .into_bytes()
16858 }
16859
16860 fn workflow_dispatch_receipt_response(run_id: u64) -> GithubWorkflowDispatchHttpResponse {
16861 GithubWorkflowDispatchHttpResponse::new(
16862 200,
16863 Some("application/json".to_owned()),
16864 format!(
16865 "{{\"workflow_run_id\":{run_id},\"run_url\":\"https://api.github.com/repos/ShiroKSH/rustferry-signing/actions/runs/{run_id}\",\"html_url\":\"https://github.com/ShiroKSH/rustferry-signing/actions/runs/{run_id}\"}}"
16866 )
16867 .into_bytes(),
16868 )
16869 }
16870
16871 fn workflow_dispatch_submit_responses(run_id: u64) -> Vec<Result<Vec<u8>, GhExecutionError>> {
16872 vec![
16873 Ok(authenticated_user_row()),
16874 Ok(private_repository_row()),
16875 Ok(private_repository_row()),
16876 Ok(workflow_dispatch_registration_row()),
16877 Ok(authenticated_user_row()),
16878 Ok(private_repository_row()),
16879 Ok(authenticated_user_row()),
16880 Ok(private_repository_row()),
16881 Ok(private_repository_row()),
16882 Ok(workflow_dispatch_registration_row()),
16883 Ok(workflow_dispatch_run_row(run_id)),
16884 Ok(authenticated_user_row()),
16885 Ok(private_repository_row()),
16886 ]
16887 }
16888
16889 fn durable_workflow_dispatch_intent_resume() -> GithubJobResumeV1 {
16890 let push_config = provider_config(all_authorized());
16891 let config = workflow_dispatch_provider_config(all_authorized());
16892 let principal = GithubPrincipalIdentityV1::User {
16893 id: AUTHENTICATED_USER_ID,
16894 login: "ShiroKSH".to_owned(),
16895 };
16896 let mut record = durable_prepublication_resume()
16897 .into_record(&push_config, &principal, 991)
16898 .expect("push fixture record");
16899 let dispatch_commit = CommitSha::new(DISPATCH_SHA).expect("dispatch commit");
16900 record.prepared_dispatch_commit = Some(dispatch_commit.clone());
16901 record.dispatch_commit = Some(dispatch_commit.clone());
16902 record.publication_uncertain = false;
16903 let registration = WorkflowRegistration::restore(
16904 config.repository.clone(),
16905 crate::transport::WorkflowId::new(17).expect("workflow ID"),
16906 config.workflow.filename().repository_path(),
16907 )
16908 .expect("workflow registration");
16909 let request = WorkflowDispatchRequest::new(
16910 config.repository.clone(),
16911 registration,
16912 record.temporary_ref.clone(),
16913 record.operation_id.clone(),
16914 record.request_sha256.clone(),
16915 record.source_revision.clone(),
16916 dispatch_commit,
16917 )
16918 .expect("workflow dispatch request");
16919 record.workflow_dispatch = Some(GithubWorkflowDispatchResumeV1::from_request(&request));
16920 GithubJobResumeV1::from_record(&config, principal, 991, &record)
16921 .expect("workflow dispatch intent resume")
16922 }
16923
16924 fn durable_workflow_dispatch_pre_intent_resume() -> GithubJobResumeV1 {
16925 let push_config = provider_config(all_authorized());
16926 let config = workflow_dispatch_provider_config(all_authorized());
16927 let principal = GithubPrincipalIdentityV1::User {
16928 id: AUTHENTICATED_USER_ID,
16929 login: "ShiroKSH".to_owned(),
16930 };
16931 let record = durable_prepared_prepublication_resume()
16932 .into_record(&push_config, &principal, 991)
16933 .expect("push fixture record");
16934 assert!(record.publication_intent);
16935 assert!(record.dispatch_commit.is_none());
16936 assert!(record.workflow_dispatch.is_none());
16937 GithubJobResumeV1::from_record(&config, principal, 991, &record)
16938 .expect("workflow dispatch pre-intent resume")
16939 }
16940
16941 fn durable_workflow_dispatch_mapped_resume() -> GithubJobResumeV1 {
16942 let mut resume = durable_workflow_dispatch_intent_resume();
16943 let dispatch = resume
16944 .workflow_dispatch
16945 .as_mut()
16946 .expect("workflow dispatch intent");
16947 dispatch.receipt = Some(GithubWorkflowDispatchReceiptV1 {
16948 run_id: 73,
16949 workflow_id: dispatch.workflow_id,
16950 workflow_path: dispatch.workflow_path.clone(),
16951 branch: dispatch.branch.clone(),
16952 dispatch_revision: dispatch.dispatch_revision.clone(),
16953 run_name: dispatch.run_name.clone(),
16954 });
16955 dispatch.uncertain = false;
16956 resume.run = Some(GithubRunIdentityV1 {
16957 run_id: 73,
16958 workflow_id: dispatch.workflow_id,
16959 workflow_path: dispatch.workflow_path.clone(),
16960 head_sha: dispatch.dispatch_revision.clone(),
16961 branch: dispatch.branch.clone(),
16962 event: GithubRunEventV1::WorkflowDispatch,
16963 run_number: 9,
16964 run_attempt: 1,
16965 status: GithubRunStatusV1::Queued,
16966 conclusion: None,
16967 });
16968 resume.state = JobState::Queued;
16969 resume
16970 .validate_trigger_binding()
16971 .expect("mapped workflow dispatch binding");
16972 resume
16973 }
16974
16975 fn durable_resume_with_run() -> GithubJobResumeV1 {
16976 let config = provider_config(all_authorized());
16977 let request = unsigned_request();
16978 let branch = BranchName::new("rustferry/goal3/builds/operation-1").expect("branch");
16979 let temporary_ref =
16980 TemporaryGitRef::new(config.workflow.temporary_branch_namespace(), branch.clone())
16981 .expect("temporary ref");
16982 let dispatch_commit = CommitSha::new(DISPATCH_SHA).expect("dispatch commit");
16983 let run = RunHandle::restore(
16984 41,
16985 17,
16986 config.workflow.filename().repository_path(),
16987 DISPATCH_SHA.to_owned(),
16988 branch.as_str().to_owned(),
16989 RunEvent::Push,
16990 )
16991 .expect("run handle");
16992 let run_snapshot =
16993 RunSnapshot::restore(run.clone(), 9, 1, RunStatus::Queued, None).expect("run snapshot");
16994 let record = JobRecord {
16995 principal: Some(GithubPrincipalIdentityV1::User {
16996 id: AUTHENTICATED_USER_ID,
16997 login: "ShiroKSH".to_owned(),
16998 }),
16999 execution_repository_id: Some(991),
17000 operation_id: request.operation_id.clone(),
17001 job_id: request.operation_id.clone(),
17002 source_revision: CommitSha::new(SOURCE_SHA).expect("source revision"),
17003 git_snapshot: None,
17004 request_sha256: canonical_request_sha256(&request).expect("request digest"),
17005 request,
17006 temporary_ref,
17007 prepared_dispatch_commit: None,
17008 dispatch_commit: Some(dispatch_commit),
17009 workflow_dispatch: None,
17010 created_at_ms: 1_700_000_000_000,
17011 publication_started_at_ms: 1_700_000_000_000,
17012 publication_quiescence_deadline_ms: 1_700_000_000_000
17013 + GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS,
17014 state: JobState::Queued,
17015 run: Some(run),
17016 run_snapshot: Some(run_snapshot),
17017 publication_intent: true,
17018 publication_absent: false,
17019 publication_not_attempted: false,
17020 publication_process_fenced: true,
17021 publication_lease_scope_sha256: Some("a".repeat(64)),
17022 publication_takeover_lease_owned: false,
17023 snapshot_source_lease_owned: false,
17024 publication_absence_observations: 0,
17025 publication_absence_first_observed_at_ms: 0,
17026 cancellation_requested: false,
17027 cancellation_dispatched: false,
17028 cleanup_requested: false,
17029 remove_artifacts_requested: false,
17030 artifacts_removed: false,
17031 temporary_ref_deleted: false,
17032 verification_pending_event: false,
17033 publication_uncertain: false,
17034 run_discovery_attempts: 0,
17035 run_discovery_deadline_ms: 1_700_000_000_750,
17036 manifests: Vec::new(),
17037 compile_evidence: None,
17038 signed_cleanup_evidence: None,
17039 events: Vec::new(),
17040 };
17041 GithubJobResumeV1::from_record(
17042 &config,
17043 GithubPrincipalIdentityV1::User {
17044 id: AUTHENTICATED_USER_ID,
17045 login: "ShiroKSH".to_owned(),
17046 },
17047 991,
17048 &record,
17049 )
17050 .expect("durable resume")
17051 }
17052
17053 fn durable_prepublication_resume() -> GithubJobResumeV1 {
17054 let mut resume = durable_resume_with_run();
17055 resume.dispatch_commit = None;
17056 resume.run = None;
17057 resume.state = JobState::Created;
17058 resume.events.clear();
17059 resume
17060 }
17061
17062 fn durable_prepared_prepublication_resume() -> GithubJobResumeV1 {
17063 let mut resume = durable_prepublication_resume();
17064 resume.prepared_dispatch_commit = Some(DISPATCH_SHA.to_owned());
17065 resume
17066 }
17067
17068 fn durable_possible_push_resume() -> GithubJobResumeV1 {
17069 let mut resume = durable_prepared_prepublication_resume();
17070 resume.dispatch_commit = Some(DISPATCH_SHA.to_owned());
17071 resume.publication_uncertain = true;
17072 resume.state = JobState::Queued;
17073 resume
17074 }
17075
17076 fn durable_success_resume() -> GithubJobResumeV1 {
17077 let mut resume = durable_resume_with_run();
17078 let run = resume.run.as_mut().expect("run identity");
17079 run.status = GithubRunStatusV1::Completed;
17080 run.conclusion = Some(GithubRunConclusionV1::Success);
17081 resume.state = JobState::Succeeded;
17082 let mut manifest = ArtifactManifest::new("operation-1", "operation-1");
17083 manifest.provider = GITHUB_PROVIDER_ID.to_owned();
17084 manifest.source_repository = Some(resume.source_repository.clone());
17085 manifest.source_revision = Some(resume.source_revision.clone());
17086 resume.manifests = vec![manifest];
17087 resume.compile_evidence = Some(compile_evidence_for(&resume.request));
17088 resume
17089 }
17090
17091 fn durable_signed_success_resume() -> GithubJobResumeV1 {
17092 let mut resume = durable_resume_with_run();
17093 resume.request = valid_request();
17094 resume.request_sha256 = canonical_request_sha256(&resume.request).expect("request digest");
17095 let run = resume.run.as_mut().expect("run identity");
17096 run.status = GithubRunStatusV1::Completed;
17097 run.conclusion = Some(GithubRunConclusionV1::Success);
17098 resume.state = JobState::Succeeded;
17099 let compile = compile_evidence_for(&resume.request);
17100 let manifest_sha256 = "1".repeat(64);
17101 let signing_report_sha256 = "2".repeat(64);
17102 let validation_report_sha256 = "3".repeat(64);
17103 let mut manifest = ArtifactManifest::new("operation-1", "operation-1");
17104 manifest.provider = GITHUB_PROVIDER_ID.to_owned();
17105 manifest.source_repository = Some(resume.source_repository.clone());
17106 manifest.source_revision = Some(resume.source_revision.clone());
17107 manifest.source_sha256 = compile.source_sha256.clone();
17108 manifest.cargo_lock_sha256 = compile.cargo_lock_sha256.clone();
17109 manifest.config_sha256 = compile.config_sha256.clone();
17110 manifest.cleanup_status = CleanupStatus::Confirmed;
17111 manifest.signing = ArtifactSigningEvidence {
17112 mode: SigningMode::ManualDevelopment,
17113 status: SigningStatus::ArtifactValidated,
17114 team_id: None,
17115 certificate_fingerprint: None,
17116 profile_uuid: None,
17117 profile_expiration: None,
17118 entitlements_sha256: None,
17119 };
17120 manifest.artifacts = vec![
17121 ArtifactRecord {
17122 artifact_id: "manifest".to_owned(),
17123 kind: ArtifactKind::Manifest,
17124 file_name: "artifact-manifest.json".to_owned(),
17125 size: 1,
17126 sha256: manifest_sha256.clone(),
17127 media_type: Some("application/json".to_owned()),
17128 },
17129 ArtifactRecord {
17130 artifact_id: "signing-report".to_owned(),
17131 kind: ArtifactKind::SigningReport,
17132 file_name: "signing-report.json".to_owned(),
17133 size: 1,
17134 sha256: signing_report_sha256.clone(),
17135 media_type: Some("application/json".to_owned()),
17136 },
17137 ArtifactRecord {
17138 artifact_id: "validation-report".to_owned(),
17139 kind: ArtifactKind::ValidationReport,
17140 file_name: "validation-report.json".to_owned(),
17141 size: 1,
17142 sha256: validation_report_sha256.clone(),
17143 media_type: Some("application/json".to_owned()),
17144 },
17145 ];
17146 resume.manifests = vec![manifest];
17147 resume.compile_evidence = Some(compile.clone());
17148 resume.signed_cleanup_evidence = Some(GithubSignedCleanupEvidenceV1 {
17149 schema_version: GITHUB_SIGNED_CLEANUP_EVIDENCE_SCHEMA_VERSION,
17150 provider: GITHUB_PROVIDER_ID.to_owned(),
17151 operation_id: resume.operation_id.clone(),
17152 job_id: resume.job_id.clone(),
17153 execution_repository: resume.execution_repository.clone(),
17154 execution_repository_id: resume.execution_repository_id,
17155 request_sha256: resume.request_sha256.clone(),
17156 source_repository: resume.source_repository.clone(),
17157 source_revision: resume.source_revision.clone(),
17158 source_sha256: resume.request.source.sha256.clone(),
17159 dispatch_revision: resume.dispatch_commit.clone().expect("dispatch"),
17160 run: resume.run.clone().expect("run"),
17161 compile_evidence_sha256: compile_evidence_sha256(&compile).expect("compile digest"),
17162 manifest_sha256,
17163 signing_report_sha256,
17164 validation_report_sha256,
17165 github_artifact_id: 73,
17166 github_artifact_api_sha256: "4".repeat(64),
17167 });
17168 resume
17169 .signed_cleanup_evidence
17170 .as_ref()
17171 .expect("cleanup evidence")
17172 .validate_for_resume(&resume)
17173 .expect("valid signed cleanup evidence");
17174 resume
17175 }
17176
17177 fn signing_environment_row(
17178 protected_branches: bool,
17179 custom_branch_policies: bool,
17180 required_reviewers: bool,
17181 ) -> Vec<u8> {
17182 format!(
17183 "{SIGNING_ENVIRONMENT_BASE64}\t{protected_branches}\t{custom_branch_policies}\t{required_reviewers}\n"
17184 )
17185 .into_bytes()
17186 }
17187
17188 fn exact_signing_policy_row() -> Vec<u8> {
17189 format!("361471\t{SIGNING_BRANCH_POLICY_BASE64}\n").into_bytes()
17190 }
17191
17192 fn exact_signing_secret_rows() -> Vec<u8> {
17193 format!(
17194 "{CERTIFICATE_P12_BASE64}\n{CERTIFICATE_PASSWORD_BASE64}\n{PROVISIONING_PROFILE_BASE64}\n"
17195 )
17196 .into_bytes()
17197 }
17198
17199 fn exact_reconciliation_git_responses(
17200 _workflow_path: &str,
17201 workflow: &[u8],
17202 _manifest: &[u8],
17203 final_tip: &str,
17204 ) -> Vec<Result<Vec<u8>, GitExecutionError>> {
17205 let full_ref = "refs/heads/rustferry/goal3/builds/operation-1";
17206 let trusted_ref = "refs/heads/goal3/macless-iphone-builds";
17207 vec![
17208 Ok(b"https://github.com/ShiroKSH/rust-and-iphone.git\n".to_vec()),
17209 Ok(b"https://github.com/ShiroKSH/rust-and-iphone.git\n".to_vec()),
17210 Ok(b"https://github.com/ShiroKSH/rustferry-signing.git\n".to_vec()),
17211 Ok(b"https://github.com/ShiroKSH/rustferry-signing.git\n".to_vec()),
17212 Ok(format!("{TRUSTED_TIP}\t{trusted_ref}\n").into_bytes()),
17213 Ok(Vec::new()),
17214 Ok(workflow.to_vec()),
17215 Ok(Vec::new()),
17216 Ok(Vec::new()),
17217 Ok(Vec::new()),
17218 Ok(format!("{WORKFLOW_BLOB}\n").into_bytes()),
17219 Ok(format!("{MANIFEST_BLOB}\n").into_bytes()),
17220 Ok(Vec::new()),
17221 Ok(Vec::new()),
17222 Ok(format!("{TREE_SHA}\n").into_bytes()),
17223 Ok(format!("{DISPATCH_SHA}\n").into_bytes()),
17224 Ok(format!("{final_tip}\t{full_ref}\n").into_bytes()),
17225 ]
17226 }
17227
17228 fn successful_prepare_git_responses(
17229 workflow: &[u8],
17230 ) -> Vec<Result<Vec<u8>, GitExecutionError>> {
17231 let full_trusted_ref = "refs/heads/goal3/macless-iphone-builds";
17232 vec![
17233 Ok(b"git@github.com:ShiroKSH/rust-and-iphone.git\n".to_vec()),
17234 Ok(b"ssh://git@github.com/ShiroKSH/rust-and-iphone\n".to_vec()),
17235 Ok(b"git@github.com:ShiroKSH/rustferry-signing.git\n".to_vec()),
17236 Ok(b"ssh://git@github.com/ShiroKSH/rustferry-signing\n".to_vec()),
17237 Ok(format!("{TRUSTED_TIP}\t{full_trusted_ref}\n").into_bytes()),
17238 Ok(Vec::new()),
17239 Ok(workflow.to_vec()),
17240 Ok(Vec::new()),
17241 Ok(Vec::new()),
17242 Ok(Vec::new()),
17243 Ok(Vec::new()),
17244 Ok(format!("{WORKFLOW_BLOB}\n").into_bytes()),
17245 Ok(format!("{MANIFEST_BLOB}\n").into_bytes()),
17246 Ok(Vec::new()),
17247 Ok(Vec::new()),
17248 Ok(format!("{TREE_SHA}\n").into_bytes()),
17249 Ok(format!("{DISPATCH_SHA}\n").into_bytes()),
17250 ]
17251 }
17252
17253 fn successful_snapshot_prepare_git_responses(
17254 workflow: &[u8],
17255 final_snapshot_tip: &str,
17256 ) -> Vec<Result<Vec<u8>, GitExecutionError>> {
17257 let trusted_ref = "refs/heads/goal3/macless-iphone-builds";
17258 let snapshot_ref = "refs/rustferry/goal3/snapshots/operation-1";
17259 vec![
17260 Ok(b"git@github.com:ShiroKSH/rust-and-iphone.git\n".to_vec()),
17261 Ok(b"ssh://git@github.com/ShiroKSH/rust-and-iphone\n".to_vec()),
17262 Ok(b"git@github.com:ShiroKSH/rustferry-signing.git\n".to_vec()),
17263 Ok(b"ssh://git@github.com/ShiroKSH/rustferry-signing\n".to_vec()),
17264 Ok(format!("{TRUSTED_TIP}\t{trusted_ref}\n").into_bytes()),
17265 Ok(Vec::new()),
17266 Ok(workflow.to_vec()),
17267 Ok(format!("{SOURCE_SHA}\t{snapshot_ref}\n").into_bytes()),
17268 Ok(Vec::new()),
17269 Ok(Vec::new()),
17270 Ok(format!("{final_snapshot_tip}\t{snapshot_ref}\n").into_bytes()),
17271 Ok(Vec::new()),
17272 Ok(Vec::new()),
17273 Ok(format!("{WORKFLOW_BLOB}\n").into_bytes()),
17274 Ok(format!("{MANIFEST_BLOB}\n").into_bytes()),
17275 Ok(Vec::new()),
17276 Ok(Vec::new()),
17277 Ok(format!("{TREE_SHA}\n").into_bytes()),
17278 Ok(format!("{DISPATCH_SHA}\n").into_bytes()),
17279 ]
17280 }
17281
17282 fn snapshot_source_mutation_git_responses(
17283 final_tip: Option<&str>,
17284 ) -> Vec<Result<Vec<u8>, GitExecutionError>> {
17285 let snapshot_ref = "refs/rustferry/goal3/snapshots/operation-1";
17286 vec![
17287 Ok(b"git@github.com:ShiroKSH/rust-and-iphone.git\n".to_vec()),
17288 Ok(b"ssh://git@github.com/ShiroKSH/rust-and-iphone\n".to_vec()),
17289 Ok(Vec::new()),
17290 Ok(b"git@github.com:ShiroKSH/rust-and-iphone.git\n".to_vec()),
17291 Ok(b"ssh://git@github.com/ShiroKSH/rust-and-iphone\n".to_vec()),
17292 Ok(final_tip
17293 .map(|tip| format!("{tip}\t{snapshot_ref}\n").into_bytes())
17294 .unwrap_or_default()),
17295 ]
17296 }
17297
17298 fn signed_doctor_report(
17299 environment: Result<Vec<u8>, GhExecutionError>,
17300 policies: Result<Vec<u8>, GhExecutionError>,
17301 secrets: Result<Vec<u8>, GhExecutionError>,
17302 ) -> ProviderDoctorReport {
17303 signed_doctor_report_with_visibility(true, environment, policies, secrets)
17304 }
17305
17306 fn signed_doctor_report_with_visibility(
17307 private: bool,
17308 environment: Result<Vec<u8>, GhExecutionError>,
17309 policies: Result<Vec<u8>, GhExecutionError>,
17310 secrets: Result<Vec<u8>, GhExecutionError>,
17311 ) -> ProviderDoctorReport {
17312 signed_doctor_report_with_repository_policy(
17313 private,
17314 Ok(b"true\n".to_vec()),
17315 environment,
17316 policies,
17317 secrets,
17318 )
17319 }
17320
17321 fn signed_doctor_report_with_repository_policy(
17322 private: bool,
17323 actions: Result<Vec<u8>, GhExecutionError>,
17324 environment: Result<Vec<u8>, GhExecutionError>,
17325 policies: Result<Vec<u8>, GhExecutionError>,
17326 secrets: Result<Vec<u8>, GhExecutionError>,
17327 ) -> ProviderDoctorReport {
17328 let provider = GithubBuildProvider::with_artifact_store_and_clock(
17329 provider_config(all_authorized()),
17330 transport(FakeGhRunner::with([
17331 Ok(authenticated_user_row()),
17332 Ok(
17333 format!("991\tShiroKSH/rustferry-signing\t{private}\tfalse\tfalse\tmain\n")
17334 .into_bytes(),
17335 ),
17336 Ok(b"992\tShiroKSH/rust-and-iphone\tfalse\tfalse\tfalse\tmain\n".to_vec()),
17337 actions,
17338 environment,
17339 policies,
17340 secrets,
17341 ])),
17342 FakePublisher::default(),
17343 DoctorArtifactStore,
17344 FixedClock(1_700_000_000_000),
17345 );
17346 poll_ready(provider.doctor(
17347 ProviderDoctorRequest {
17348 protocol_version: CURRENT_PROTOCOL_VERSION,
17349 operation_id: "signed-doctor".to_owned(),
17350 require_signing: true,
17351 },
17352 CancellationToken::new(),
17353 ))
17354 .expect("doctor report")
17355 }
17356
17357 fn workflow_dispatch_doctor_report(
17358 registration: Result<Vec<u8>, GhExecutionError>,
17359 ) -> ProviderDoctorReport {
17360 let provider = GithubBuildProvider::with_artifact_store_and_clock(
17361 workflow_dispatch_provider_config(all_authorized()),
17362 transport(FakeGhRunner::with([
17363 Ok(authenticated_user_row()),
17364 Ok(private_repository_row()),
17365 Ok(public_source_repository_row()),
17366 Ok(b"true\n".to_vec()),
17367 registration,
17368 Ok(signing_environment_row(false, true, true)),
17369 Ok(exact_signing_policy_row()),
17370 Ok(exact_signing_secret_rows()),
17371 ])),
17372 FakePublisher::default(),
17373 DoctorArtifactStore,
17374 FixedClock(1_700_000_000_000),
17375 );
17376 poll_ready(provider.doctor(
17377 ProviderDoctorRequest {
17378 protocol_version: CURRENT_PROTOCOL_VERSION,
17379 operation_id: "workflow-dispatch-doctor".to_owned(),
17380 require_signing: true,
17381 },
17382 CancellationToken::new(),
17383 ))
17384 .expect("workflow dispatch doctor report")
17385 }
17386
17387 #[test]
17388 fn workflow_fingerprint_is_exact_lowercase_sha256() {
17389 let workflow = generate_workflow(&workflow_config());
17390 let fingerprint = WorkflowFingerprint::for_workflow(&workflow);
17391 assert!(is_lower_sha256(fingerprint.as_str()));
17392 assert_eq!(fingerprint, WorkflowFingerprint::for_workflow(&workflow));
17393 assert!(WorkflowFingerprint::new("A".repeat(64)).is_err());
17394 }
17395
17396 #[test]
17397 fn workflow_dispatch_resume_state_is_heap_indirected() {
17398 assert_eq!(
17399 std::mem::size_of::<Option<Box<GithubWorkflowDispatchResumeV1>>>(),
17400 std::mem::size_of::<usize>()
17401 );
17402 assert!(std::mem::size_of::<GithubJobResumeV1>() <= 4_352);
17403 }
17404
17405 #[test]
17406 fn workflow_dispatch_submit_checkpoints_intent_receipt_then_exact_run_and_posts_once() {
17407 let plan = FakeWorkflowDispatchPlan::with_response(workflow_dispatch_receipt_response(73));
17408 let sink = CapturingCheckpointSink::default();
17409 let checkpoints = Arc::clone(&sink.checkpoints);
17410 let provider = GithubBuildProvider::with_artifact_store_and_clock(
17411 workflow_dispatch_provider_config(all_authorized()),
17412 transport(
17413 FakeGhRunner::with(workflow_dispatch_submit_responses(73))
17414 .with_workflow_dispatch(plan.clone()),
17415 ),
17416 FakePublisher::default(),
17417 NoVerifiedArtifactStore,
17418 FixedClock(1_700_000_000_000),
17419 )
17420 .with_checkpoint_sink(sink);
17421
17422 let handle = poll_ready(provider.submit(unsigned_request(), CancellationToken::new()))
17423 .unwrap_or_else(|error| {
17424 let requests = provider
17425 .transport
17426 .lock()
17427 .expect("transport")
17428 .runner()
17429 .requests
17430 .lock()
17431 .expect("requests")
17432 .iter()
17433 .map(|request| request.endpoint().to_owned())
17434 .collect::<Vec<_>>();
17435 panic!("workflow dispatch submit failed: {error:?}; requests: {requests:?}")
17436 });
17437 assert_eq!(handle.state, JobState::Queued);
17438 assert_eq!(plan.0.clients_created.load(Ordering::SeqCst), 1);
17439 assert_eq!(plan.0.requests.lock().expect("dispatch requests").len(), 1);
17440 assert_eq!(
17441 provider
17442 .publisher
17443 .lock()
17444 .expect("publisher")
17445 .published
17446 .len(),
17447 1
17448 );
17449
17450 let checkpoints = checkpoints.lock().expect("dispatch checkpoints");
17451 let intent = checkpoints
17452 .iter()
17453 .position(|resume| {
17454 resume
17455 .workflow_dispatch
17456 .as_ref()
17457 .is_some_and(|dispatch| dispatch.receipt.is_none())
17458 && resume.run.is_none()
17459 })
17460 .expect("durable dispatch intent");
17461 let receipt = checkpoints
17462 .iter()
17463 .position(|resume| {
17464 resume
17465 .workflow_dispatch
17466 .as_ref()
17467 .is_some_and(|dispatch| dispatch.receipt.is_some())
17468 && resume.run.is_none()
17469 })
17470 .expect("durable dispatch receipt");
17471 let run = checkpoints
17472 .iter()
17473 .position(|resume| {
17474 resume.run.as_ref().is_some_and(|run| {
17475 run.run_id == 73 && run.event == GithubRunEventV1::WorkflowDispatch
17476 })
17477 })
17478 .expect("durable workflow dispatch run");
17479 assert!(intent < receipt && receipt < run);
17480 assert!(
17481 checkpoints[..intent]
17482 .iter()
17483 .any(|resume| resume.dispatch_commit.as_deref() == Some(DISPATCH_SHA))
17484 );
17485 }
17486
17487 #[test]
17488 fn workflow_dispatch_post_releases_state_and_transport_and_late_cancel_maps_exact_run() {
17489 let (plan, gate) = FakeWorkflowDispatchPlan::with_blocking_response(
17490 workflow_dispatch_receipt_response(73),
17491 );
17492 let mut responses = workflow_dispatch_submit_responses(73);
17493 responses.extend([
17494 Ok(ordinary_workflow_dispatch_run_row(73)),
17495 Ok(authenticated_user_row()),
17496 Ok(private_repository_row()),
17497 Ok(ordinary_workflow_dispatch_run_row(73)),
17498 Ok(authenticated_user_row()),
17499 Ok(private_repository_row()),
17500 Ok(Vec::new()),
17501 ]);
17502 let provider = GithubBuildProvider::with_artifact_store_and_clock(
17503 workflow_dispatch_provider_config(all_authorized()),
17504 transport(FakeGhRunner::with(responses).with_workflow_dispatch(plan.clone())),
17505 FakePublisher::default(),
17506 NoVerifiedArtifactStore,
17507 FixedClock(1_700_000_000_000),
17508 )
17509 .with_checkpoint_sink(CapturingCheckpointSink::default());
17510 let cancellation = CancellationToken::new();
17511
17512 thread::scope(|scope| {
17513 let submitted = scope
17514 .spawn(|| poll_ready(provider.submit(unsigned_request(), cancellation.clone())));
17515 gate.wait_until_entered();
17516 assert!(provider.state.try_lock().is_ok());
17517 assert!(provider.transport.try_lock().is_ok());
17518 cancellation.cancel();
17519 gate.release();
17520 let handle = submitted
17521 .join()
17522 .expect("dispatch submit thread")
17523 .expect("submit");
17524 assert_eq!(handle.state, JobState::Cancelling);
17525 });
17526
17527 assert_eq!(plan.0.requests.lock().expect("dispatch requests").len(), 1);
17528 let state = provider.lock_state().expect("dispatch state");
17529 let record = state.jobs.get("operation-1").expect("dispatch record");
17530 assert!(record.cancellation_requested);
17531 assert!(record.cancellation_dispatched);
17532 assert_eq!(
17533 record.run.as_ref().map(RunHandle::id),
17534 Some(RunId::new(73).expect("run"))
17535 );
17536 }
17537
17538 #[test]
17539 fn late_cancellation_checkpoint_preserves_every_terminal_state() {
17540 let mapped = durable_workflow_dispatch_mapped_resume();
17541 let sink = CapturingCheckpointSink::default();
17542 let checkpoints = Arc::clone(&sink.checkpoints);
17543 let provider = GithubBuildProvider::with_artifact_store_and_clock(
17544 workflow_dispatch_provider_config(all_authorized()),
17545 transport(FakeGhRunner::with([])),
17546 FakePublisher::default(),
17547 NoVerifiedArtifactStore,
17548 FixedClock(1_700_000_000_000),
17549 )
17550 .with_checkpoint_sink(sink);
17551 provider
17552 .restore_job_resumes_offline(
17553 vec![mapped.clone()],
17554 &durable_identity_for_resume(&mapped),
17555 )
17556 .expect("restore mapped dispatch run");
17557 let cancellation = CancellationToken::new();
17558 cancellation.cancel();
17559
17560 for terminal in [
17561 JobState::Succeeded,
17562 JobState::Failed,
17563 JobState::Cancelled,
17564 JobState::Cleaned,
17565 ] {
17566 {
17567 let mut state = provider.lock_state().expect("dispatch state");
17568 let record = state.jobs.get_mut("operation-1").expect("dispatch record");
17569 record.state = terminal;
17570 record.cancellation_requested = false;
17571 }
17572 assert!(
17573 !provider
17574 .checkpoint_workflow_dispatch_cancellation("operation-1", &cancellation)
17575 .expect("terminal cancellation check")
17576 );
17577 let state = provider.lock_state().expect("dispatch state");
17578 let record = state.jobs.get("operation-1").expect("dispatch record");
17579 assert_eq!(record.state, terminal);
17580 assert!(!record.cancellation_requested);
17581 }
17582 assert!(checkpoints.lock().expect("checkpoints").is_empty());
17583 }
17584
17585 #[test]
17586 fn cancelled_ambiguous_dispatch_persists_intent_then_restart_adopts_and_cancels() {
17587 let (plan, gate) = FakeWorkflowDispatchPlan::with_blocking_result(Err(
17588 GithubWorkflowDispatchHttpError::TimedOut,
17589 ));
17590 let sink = CapturingCheckpointSink::default();
17591 let durable = Arc::clone(&sink.checkpoints);
17592 let provider = GithubBuildProvider::with_artifact_store_and_clock(
17593 workflow_dispatch_provider_config(all_authorized()),
17594 transport(
17595 FakeGhRunner::with(workflow_dispatch_submit_responses(73))
17596 .with_workflow_dispatch(plan.clone()),
17597 ),
17598 FakePublisher::default(),
17599 NoVerifiedArtifactStore,
17600 FixedClock(1_700_000_000_000),
17601 )
17602 .with_checkpoint_sink(sink);
17603 let cancellation = CancellationToken::new();
17604 thread::scope(|scope| {
17605 let submitted = scope
17606 .spawn(|| poll_ready(provider.submit(unsigned_request(), cancellation.clone())));
17607 gate.wait_until_entered();
17608 cancellation.cancel();
17609 gate.release();
17610 submitted
17611 .join()
17612 .expect("dispatch submit thread")
17613 .expect_err("ambiguous dispatch response");
17614 });
17615 let resume = durable
17616 .lock()
17617 .expect("dispatch checkpoints")
17618 .last()
17619 .cloned()
17620 .expect("cancelled dispatch intent");
17621 assert!(resume.cancellation_requested);
17622 assert!(resume.run.is_none());
17623 assert!(
17624 resume
17625 .workflow_dispatch
17626 .as_ref()
17627 .is_some_and(|dispatch| { dispatch.uncertain && dispatch.receipt.is_none() })
17628 );
17629
17630 let restarted = GithubBuildProvider::with_artifact_store_and_clock(
17631 workflow_dispatch_provider_config(all_authorized()),
17632 transport(FakeGhRunner::with([
17633 Ok(workflow_dispatch_run_row(73)),
17634 Ok(workflow_dispatch_run_row(73)),
17635 Ok(authenticated_user_row()),
17636 Ok(private_repository_row()),
17637 Ok(ordinary_workflow_dispatch_run_row(73)),
17638 Ok(authenticated_user_row()),
17639 Ok(private_repository_row()),
17640 Ok(ordinary_workflow_dispatch_run_row(73)),
17641 Ok(authenticated_user_row()),
17642 Ok(private_repository_row()),
17643 Ok(Vec::new()),
17644 ])),
17645 FakePublisher::default(),
17646 NoVerifiedArtifactStore,
17647 FixedClock(1_700_000_000_000),
17648 )
17649 .with_checkpoint_sink(CapturingCheckpointSink::default());
17650 restarted
17651 .restore_job_resumes_offline(
17652 vec![resume.clone()],
17653 &durable_identity_for_resume(&resume),
17654 )
17655 .expect("restore cancelled dispatch intent");
17656 restarted
17657 .reconcile_restored_job("operation-1", &CancellationToken::new())
17658 .expect("adopt and cancel exact dispatch run");
17659 let state = restarted.lock_state().expect("restarted dispatch state");
17660 let record = state.jobs.get("operation-1").expect("dispatch record");
17661 assert!(record.cancellation_requested);
17662 assert!(record.cancellation_dispatched);
17663 }
17664
17665 #[test]
17666 fn cancellation_during_dispatch_mapping_error_is_checkpointed_before_return() {
17667 let plan = FakeWorkflowDispatchPlan::with_response(workflow_dispatch_receipt_response(73));
17668 let cancellation = CancellationToken::new();
17669 let mut responses = workflow_dispatch_submit_responses(73);
17670 responses[10] = Err(GhExecutionError::CommandFailed { exit_code: Some(1) });
17671 let sink = CapturingCheckpointSink::default();
17672 let durable = Arc::clone(&sink.checkpoints);
17673 let provider = GithubBuildProvider::with_artifact_store_and_clock(
17674 workflow_dispatch_provider_config(all_authorized()),
17675 transport(
17676 FakeGhRunner::with(responses)
17677 .cancelling_before(11, cancellation.clone())
17678 .with_workflow_dispatch(plan),
17679 ),
17680 FakePublisher::default(),
17681 NoVerifiedArtifactStore,
17682 FixedClock(1_700_000_000_000),
17683 )
17684 .with_checkpoint_sink(sink);
17685
17686 poll_ready(provider.submit(unsigned_request(), cancellation))
17687 .expect_err("run mapping failure");
17688 let resume = durable
17689 .lock()
17690 .expect("dispatch checkpoints")
17691 .last()
17692 .cloned()
17693 .expect("cancelled receipt checkpoint");
17694 assert!(resume.cancellation_requested);
17695 assert!(resume.run.is_none());
17696 assert!(resume.workflow_dispatch.as_ref().is_some_and(|dispatch| {
17697 !dispatch.uncertain
17698 && dispatch
17699 .receipt
17700 .as_ref()
17701 .is_some_and(|receipt| receipt.run_id == 73)
17702 }));
17703 }
17704
17705 #[test]
17706 fn crash_after_dispatch_post_reconciles_exact_run_without_a_second_post() {
17707 let plan = FakeWorkflowDispatchPlan::with_response(workflow_dispatch_receipt_response(73));
17708 let crash_sink = FailOnWorkflowDispatchReceiptSink::default();
17709 let durable = Arc::clone(&crash_sink.checkpoints);
17710 let first = GithubBuildProvider::with_artifact_store_and_clock(
17711 workflow_dispatch_provider_config(all_authorized()),
17712 transport(
17713 FakeGhRunner::with(workflow_dispatch_submit_responses(73))
17714 .with_workflow_dispatch(plan.clone()),
17715 ),
17716 FakePublisher::default(),
17717 NoVerifiedArtifactStore,
17718 FixedClock(1_700_000_000_000),
17719 )
17720 .with_checkpoint_sink(crash_sink);
17721
17722 let error = poll_ready(first.submit(unsigned_request(), CancellationToken::new()))
17723 .expect_err("receipt checkpoint crash");
17724 assert!(matches!(
17725 error,
17726 RemoteBuildError::ProviderFailure { code, .. }
17727 if code == "workflow_dispatch_receipt_checkpoint_failed"
17728 ));
17729 assert_eq!(plan.0.requests.lock().expect("dispatch requests").len(), 1);
17730 let intent = durable
17731 .lock()
17732 .expect("durable checkpoints")
17733 .last()
17734 .cloned()
17735 .expect("durable dispatch intent");
17736 assert!(
17737 intent
17738 .workflow_dispatch
17739 .as_ref()
17740 .is_some_and(|dispatch| dispatch.receipt.is_none() && dispatch.uncertain)
17741 );
17742 assert!(intent.run.is_none());
17743
17744 let recovery_sink = CapturingCheckpointSink::default();
17745 let recovered = Arc::clone(&recovery_sink.checkpoints);
17746 let restarted = GithubBuildProvider::with_artifact_store_and_clock(
17747 workflow_dispatch_provider_config(all_authorized()),
17748 transport(FakeGhRunner::with([
17749 Ok(workflow_dispatch_run_row(73)),
17750 Ok(workflow_dispatch_run_row(73)),
17751 Ok(authenticated_user_row()),
17752 Ok(private_repository_row()),
17753 Ok(ordinary_workflow_dispatch_run_row(73)),
17754 Ok(authenticated_user_row()),
17755 Ok(private_repository_row()),
17756 ])),
17757 FakePublisher::default(),
17758 NoVerifiedArtifactStore,
17759 FixedClock(1_700_000_000_000),
17760 )
17761 .with_checkpoint_sink(recovery_sink);
17762 let identity = durable_identity_for_resume(&intent);
17763 restarted
17764 .restore_job_resumes_offline(vec![intent], &identity)
17765 .expect("restore dispatch intent");
17766 assert_eq!(
17767 restarted
17768 .reconcile_restored_job("operation-1", &CancellationToken::new())
17769 .expect("GET-only dispatch reconciliation"),
17770 GithubJobReconciliation::AlreadyMapped
17771 );
17772 let latest = recovered
17773 .lock()
17774 .expect("recovery checkpoints")
17775 .last()
17776 .cloned()
17777 .expect("recovered run checkpoint");
17778 assert_eq!(latest.run.as_ref().map(|run| run.run_id), Some(73));
17779 assert!(latest.workflow_dispatch.as_ref().is_some_and(|dispatch| {
17780 dispatch
17781 .receipt
17782 .as_ref()
17783 .is_some_and(|receipt| receipt.run_id == 73)
17784 }));
17785 }
17786
17787 #[test]
17788 fn existing_dispatch_intent_missing_or_ambiguous_run_never_reposts() {
17789 let intent = durable_workflow_dispatch_intent_resume();
17790 for (rows, expected_code) in [
17791 (Vec::new(), "github_workflow_dispatch_run_not_found"),
17792 (
17793 workflow_dispatch_run_row_with_title(
17794 73,
17795 &base64_fixture("rustferry-v1|wrong-public-input-correlation"),
17796 ),
17797 "github_workflow_dispatch_run_not_found",
17798 ),
17799 (
17800 [workflow_dispatch_run_row(73), workflow_dispatch_run_row(74)].concat(),
17801 "github_workflow_dispatch_run_ambiguous",
17802 ),
17803 ] {
17804 let provider = GithubBuildProvider::with_artifact_store_and_clock(
17805 workflow_dispatch_provider_config(all_authorized()),
17806 transport(FakeGhRunner::with([Ok(rows)])),
17807 FakePublisher::default(),
17808 NoVerifiedArtifactStore,
17809 FixedClock(1_700_000_000_000),
17810 )
17811 .with_checkpoint_sink(CapturingCheckpointSink::default());
17812 let identity = durable_identity_for_resume(&intent);
17813 provider
17814 .restore_job_resumes_offline(vec![intent.clone()], &identity)
17815 .expect("restore dispatch intent");
17816 let error = provider
17817 .refresh_restored_job_get_only("operation-1", &CancellationToken::new())
17818 .expect_err("unresolved dispatch run");
17819 assert!(matches!(
17820 error,
17821 RemoteBuildError::ProviderFailure { code, .. } if code == expected_code
17822 ));
17823 let state = provider.lock_state().expect("dispatch state");
17824 assert!(
17825 state
17826 .jobs
17827 .get("operation-1")
17828 .expect("dispatch job")
17829 .workflow_dispatch
17830 .as_ref()
17831 .is_some_and(|dispatch| dispatch.receipt.is_none() && dispatch.uncertain)
17832 );
17833 }
17834 }
17835
17836 #[test]
17837 fn cancelled_failing_intent_reconciliation_is_durable_before_restart_adoption() {
17838 let intent = durable_workflow_dispatch_intent_resume();
17839 let cancellation = CancellationToken::new();
17840 let sink = CapturingCheckpointSink::default();
17841 let durable = Arc::clone(&sink.checkpoints);
17842 let failing = GithubBuildProvider::with_artifact_store_and_clock(
17843 workflow_dispatch_provider_config(all_authorized()),
17844 transport(
17845 FakeGhRunner::with([Err(GhExecutionError::CommandFailed { exit_code: Some(1) })])
17846 .cancelling_before(1, cancellation.clone()),
17847 ),
17848 FakePublisher::default(),
17849 NoVerifiedArtifactStore,
17850 FixedClock(1_700_000_000_000),
17851 )
17852 .with_checkpoint_sink(sink);
17853 let identity = durable_identity_for_resume(&intent);
17854 failing
17855 .restore_job_resumes_offline(vec![intent], &identity)
17856 .expect("restore dispatch intent");
17857
17858 failing
17859 .refresh_restored_job_get_only("operation-1", &cancellation)
17860 .expect_err("failing run lookup");
17861 let cancelled = durable
17862 .lock()
17863 .expect("dispatch checkpoints")
17864 .last()
17865 .cloned()
17866 .expect("durable cancellation after failing lookup");
17867 assert!(cancelled.cancellation_requested);
17868 assert!(cancelled.run.is_none());
17869
17870 let restarted = GithubBuildProvider::with_artifact_store_and_clock(
17871 workflow_dispatch_provider_config(all_authorized()),
17872 transport(FakeGhRunner::with([
17873 Ok(workflow_dispatch_run_row(73)),
17874 Ok(workflow_dispatch_run_row(73)),
17875 Ok(authenticated_user_row()),
17876 Ok(private_repository_row()),
17877 Ok(ordinary_workflow_dispatch_run_row(73)),
17878 Ok(authenticated_user_row()),
17879 Ok(private_repository_row()),
17880 Ok(ordinary_workflow_dispatch_run_row(73)),
17881 Ok(authenticated_user_row()),
17882 Ok(private_repository_row()),
17883 Ok(Vec::new()),
17884 ])),
17885 FakePublisher::default(),
17886 NoVerifiedArtifactStore,
17887 FixedClock(1_700_000_000_000),
17888 )
17889 .with_checkpoint_sink(CapturingCheckpointSink::default());
17890 restarted
17891 .restore_job_resumes_offline(
17892 vec![cancelled.clone()],
17893 &durable_identity_for_resume(&cancelled),
17894 )
17895 .expect("restore cancelled dispatch intent");
17896 restarted
17897 .reconcile_restored_job("operation-1", &CancellationToken::new())
17898 .expect("adopt and cancel exact dispatch run");
17899 let state = restarted.lock_state().expect("restarted dispatch state");
17900 let record = state.jobs.get("operation-1").expect("dispatch record");
17901 assert!(record.cancellation_requested);
17902 assert!(record.cancellation_dispatched);
17903 }
17904
17905 #[test]
17906 fn cancelled_failing_live_observation_is_durable_before_restart_cancel() {
17907 let mapped = durable_workflow_dispatch_mapped_resume();
17908 let cancellation = CancellationToken::new();
17909 let sink = CapturingCheckpointSink::default();
17910 let durable = Arc::clone(&sink.checkpoints);
17911 let failing = GithubBuildProvider::with_artifact_store_and_clock(
17912 workflow_dispatch_provider_config(all_authorized()),
17913 transport(
17914 FakeGhRunner::with([Err(GhExecutionError::CommandFailed { exit_code: Some(1) })])
17915 .cancelling_before(1, cancellation.clone()),
17916 ),
17917 FakePublisher::default(),
17918 NoVerifiedArtifactStore,
17919 FixedClock(1_700_000_000_000),
17920 )
17921 .with_checkpoint_sink(sink);
17922 failing
17923 .restore_job_resumes_offline(
17924 vec![mapped.clone()],
17925 &durable_identity_for_resume(&mapped),
17926 )
17927 .expect("restore mapped dispatch run");
17928
17929 failing
17930 .revalidate_restored_job_live("operation-1", &cancellation)
17931 .expect_err("failing live run observation");
17932 let cancelled = durable
17933 .lock()
17934 .expect("dispatch checkpoints")
17935 .last()
17936 .cloned()
17937 .expect("durable cancellation after failing observation");
17938 assert!(cancelled.cancellation_requested);
17939 assert_eq!(cancelled.run.as_ref().map(|run| run.run_id), Some(73));
17940
17941 let restarted = GithubBuildProvider::with_artifact_store_and_clock(
17942 workflow_dispatch_provider_config(all_authorized()),
17943 transport(FakeGhRunner::with([
17944 Ok(ordinary_workflow_dispatch_run_row(73)),
17945 Ok(authenticated_user_row()),
17946 Ok(private_repository_row()),
17947 Ok(ordinary_workflow_dispatch_run_row(73)),
17948 Ok(authenticated_user_row()),
17949 Ok(private_repository_row()),
17950 Ok(Vec::new()),
17951 ])),
17952 FakePublisher::default(),
17953 NoVerifiedArtifactStore,
17954 FixedClock(1_700_000_000_000),
17955 )
17956 .with_checkpoint_sink(CapturingCheckpointSink::default());
17957 restarted
17958 .restore_job_resumes_offline(
17959 vec![cancelled.clone()],
17960 &durable_identity_for_resume(&cancelled),
17961 )
17962 .expect("restore cancelled mapped run");
17963 restarted
17964 .reconcile_restored_job("operation-1", &CancellationToken::new())
17965 .expect("cancel exact mapped run");
17966 let state = restarted.lock_state().expect("restarted dispatch state");
17967 let record = state.jobs.get("operation-1").expect("dispatch record");
17968 assert!(record.cancellation_requested);
17969 assert!(record.cancellation_dispatched);
17970 }
17971
17972 #[test]
17973 fn workflow_dispatch_preflight_failures_leave_no_ref_or_intent() {
17974 let cases = [
17975 (
17976 workflow_dispatch_provider_config_with_trusted_ref(
17977 all_authorized(),
17978 "refs/heads/not-default",
17979 ),
17980 vec![
17981 Ok(authenticated_user_row()),
17982 Ok(private_repository_row()),
17983 Ok(private_repository_row()),
17984 ],
17985 Some(FakeWorkflowDispatchPlan::with_response(
17986 workflow_dispatch_receipt_response(73),
17987 )),
17988 ),
17989 (
17990 workflow_dispatch_provider_config(all_authorized()),
17991 vec![
17992 Ok(authenticated_user_row()),
17993 Ok(private_repository_row()),
17994 Ok(private_repository_row()),
17995 Ok(format!("17\t{WORKFLOW_PATH_BASE64}\tdisabled_manually\n").into_bytes()),
17996 ],
17997 Some(FakeWorkflowDispatchPlan::with_response(
17998 workflow_dispatch_receipt_response(73),
17999 )),
18000 ),
18001 (
18002 workflow_dispatch_provider_config(all_authorized()),
18003 vec![
18004 Ok(authenticated_user_row()),
18005 Ok(private_repository_row()),
18006 Ok(private_repository_row()),
18007 Ok(workflow_dispatch_registration_row()),
18008 ],
18009 None,
18010 ),
18011 ];
18012 for (config, responses, plan) in cases {
18013 let sink = CapturingCheckpointSink::default();
18014 let checkpoints = Arc::clone(&sink.checkpoints);
18015 let mut runner = FakeGhRunner::with(responses);
18016 if let Some(plan) = plan {
18017 runner = runner.with_workflow_dispatch(plan);
18018 }
18019 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18020 config,
18021 transport(runner),
18022 FakePublisher::default(),
18023 NoVerifiedArtifactStore,
18024 FixedClock(1_700_000_000_000),
18025 )
18026 .with_checkpoint_sink(sink);
18027 poll_ready(provider.submit(unsigned_request(), CancellationToken::new()))
18028 .expect_err("workflow dispatch preflight must fail");
18029 assert!(checkpoints.lock().expect("checkpoints").is_empty());
18030 assert!(
18031 provider
18032 .publisher
18033 .lock()
18034 .expect("publisher")
18035 .published
18036 .is_empty()
18037 );
18038 }
18039 }
18040
18041 #[test]
18042 fn active_push_only_default_workflow_is_rejected_before_ref_or_dispatch_intent() {
18043 let plan = FakeWorkflowDispatchPlan::with_response(workflow_dispatch_receipt_response(73));
18044 let sink = CapturingCheckpointSink::default();
18045 let checkpoints = Arc::clone(&sink.checkpoints);
18046 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18047 workflow_dispatch_provider_config(all_authorized()),
18048 transport(
18049 FakeGhRunner::with([
18050 Ok(authenticated_user_row()),
18051 Ok(private_repository_row()),
18052 Ok(private_repository_row()),
18053 Ok(workflow_dispatch_registration_row()),
18054 ])
18055 .with_workflow_dispatch(plan.clone()),
18056 ),
18057 FakePublisher {
18058 execution_workflow_failure: Some(
18059 TemporaryRefPublishError::WorkflowFingerprintMismatch,
18060 ),
18061 ..FakePublisher::default()
18062 },
18063 NoVerifiedArtifactStore,
18064 FixedClock(1_700_000_000_000),
18065 )
18066 .with_checkpoint_sink(sink);
18067
18068 poll_ready(provider.submit(unsigned_request(), CancellationToken::new()))
18069 .expect_err("Push-only default workflow bytes");
18070 assert_eq!(plan.0.clients_created.load(Ordering::SeqCst), 0);
18071 assert!(
18072 plan.0
18073 .requests
18074 .lock()
18075 .expect("dispatch requests")
18076 .is_empty()
18077 );
18078 assert!(checkpoints.lock().expect("checkpoints").is_empty());
18079 assert_eq!(
18080 provider
18081 .publisher
18082 .lock()
18083 .expect("publisher")
18084 .execution_workflow_checks,
18085 1
18086 );
18087 assert!(
18088 provider
18089 .publisher
18090 .lock()
18091 .expect("publisher")
18092 .published
18093 .is_empty()
18094 );
18095 }
18096
18097 #[test]
18098 fn durable_identity_is_live_secret_free_and_stably_bound() {
18099 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18100 provider_config(all_authorized()),
18101 transport(FakeGhRunner::with([
18102 Ok(authenticated_user_row()),
18103 Ok(private_repository_row()),
18104 ])),
18105 FakePublisher::default(),
18106 NoVerifiedArtifactStore,
18107 FixedClock(1_700_000_000_000),
18108 );
18109 let identity = provider.durable_identity().expect("durable identity");
18110 assert_eq!(identity.provider, GITHUB_PROVIDER_ID);
18111 assert_eq!(identity.execution_repository_id, 991);
18112 assert_eq!(
18113 identity.execution_repository,
18114 "https://github.com/ShiroKSH/rustferry-signing"
18115 );
18116 assert_eq!(
18117 identity.principal,
18118 GithubPrincipalIdentityV1::User {
18119 id: AUTHENTICATED_USER_ID,
18120 login: "ShiroKSH".to_owned(),
18121 }
18122 );
18123 assert!(is_lower_sha256(&identity.provider_config_sha256));
18124 }
18125
18126 #[test]
18127 fn repository_credential_list_id_must_match_execution_metadata_id() {
18128 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18129 workflow_dispatch_provider_config(all_authorized()),
18130 transport(FakeGhRunner::with([
18131 Err(GhExecutionError::CommandFailed { exit_code: Some(1) }),
18132 Ok(b"992\tU2hpcm9LU0gvcnVzdGZlcnJ5LXNpZ25pbmc=\n".to_vec()),
18133 Ok(private_repository_row()),
18134 ])),
18135 FakePublisher::default(),
18136 NoVerifiedArtifactStore,
18137 FixedClock(1_700_000_000_000),
18138 );
18139
18140 assert!(matches!(
18141 provider.durable_identity(),
18142 Err(RemoteBuildError::ProviderFailure { code, .. })
18143 if code == "resume_repository_credential_repository_mismatch"
18144 ));
18145 }
18146
18147 #[test]
18148 fn uncheckpointed_visible_submission_cannot_be_exported_or_cancelled() {
18149 let config = provider_config(all_authorized());
18150 let principal = GithubPrincipalIdentityV1::User {
18151 id: AUTHENTICATED_USER_ID,
18152 login: "ShiroKSH".to_owned(),
18153 };
18154 let mut record = durable_prepublication_resume()
18155 .into_record(&config, &principal, 991)
18156 .expect("record fixture");
18157 record.publication_intent = false;
18158 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18159 config,
18160 transport(FakeGhRunner::default()),
18161 FakePublisher::default(),
18162 NoVerifiedArtifactStore,
18163 FixedClock(1_700_000_000_000),
18164 );
18165 provider
18166 .lock_state()
18167 .expect("state")
18168 .jobs
18169 .insert(record.job_id.clone(), record);
18170
18171 assert!(provider.export_job_resumes().is_err());
18172 assert!(
18173 poll_ready(provider.cancel(
18174 CancellationRequest {
18175 job_id: "operation-1".to_owned(),
18176 reason: "user-requested".to_owned(),
18177 },
18178 CancellationToken::new(),
18179 ))
18180 .is_err()
18181 );
18182 let state = provider.lock_state().expect("state");
18183 let record = state.jobs.get("operation-1").expect("pending job");
18184 assert_eq!(record.state, JobState::Created);
18185 assert!(!record.cancellation_requested);
18186 }
18187
18188 #[test]
18189 fn export_rejects_jobs_without_their_creation_identity() {
18190 let config = provider_config(all_authorized());
18191 let principal = GithubPrincipalIdentityV1::User {
18192 id: AUTHENTICATED_USER_ID,
18193 login: "ShiroKSH".to_owned(),
18194 };
18195 let mut record = durable_prepublication_resume()
18196 .into_record(&config, &principal, 991)
18197 .expect("record fixture");
18198 record.principal = None;
18199 record.execution_repository_id = None;
18200 let runner = FakeGhRunner::default();
18201 let requests = Arc::clone(&runner.requests);
18202 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18203 config,
18204 transport(runner),
18205 FakePublisher::default(),
18206 NoVerifiedArtifactStore,
18207 FixedClock(1_700_000_000_000),
18208 );
18209 provider
18210 .lock_state()
18211 .expect("state")
18212 .jobs
18213 .insert(record.job_id.clone(), record);
18214
18215 assert!(provider.export_job_resumes().is_err());
18216 assert!(requests.lock().expect("requests").is_empty());
18217 }
18218
18219 #[test]
18220 fn checkpoint_sink_captures_pre_push_intent_and_export_restores_strict_state() {
18221 let sink = CapturingCheckpointSink::default();
18222 let checkpoints = Arc::clone(&sink.checkpoints);
18223 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18224 provider_config(all_authorized()),
18225 transport(FakeGhRunner::with([
18226 Ok(authenticated_user_row()),
18227 Ok(private_repository_row()),
18228 Ok(authenticated_user_row()),
18229 Ok(private_repository_row()),
18230 Ok(authenticated_user_row()),
18231 Ok(private_repository_row()),
18232 ])),
18233 FakePublisher {
18234 prepared_publication: Some(expected_publication()),
18235 ..FakePublisher::default()
18236 },
18237 NoVerifiedArtifactStore,
18238 FixedClock(1_700_000_000_000),
18239 )
18240 .with_checkpoint_sink(sink);
18241
18242 poll_ready(provider.submit(unsigned_request(), CancellationToken::new()))
18243 .expect("checkpointed submission");
18244 let captured = checkpoints.lock().expect("captured checkpoints");
18245 assert_eq!(captured.len(), 4);
18246 assert!(captured[0].publication_intent);
18247 assert_eq!(
18248 captured[0].prepared_dispatch_commit.as_deref(),
18249 Some(DISPATCH_SHA)
18250 );
18251 assert_eq!(captured[0].dispatch_commit, None);
18252 assert_eq!(captured[0].publication_started_at_ms, 0);
18253 assert_eq!(captured[0].publication_quiescence_deadline_ms, u64::MAX);
18254 assert_eq!(captured[1].publication_started_at_ms, 1_700_000_000_000);
18255 assert_eq!(
18256 captured[1].publication_quiescence_deadline_ms,
18257 1_700_000_000_000 + GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS
18258 );
18259 assert_eq!(captured[1].dispatch_commit, None);
18260 assert_eq!(captured[2].dispatch_commit.as_deref(), Some(DISPATCH_SHA));
18261 assert_eq!(captured[3].state, JobState::Queued);
18262 assert!(
18263 captured
18264 .iter()
18265 .all(|resume| resume.publication_process_fenced)
18266 );
18267 assert!(captured.iter().all(|resume| {
18268 resume.principal
18269 == GithubPrincipalIdentityV1::User {
18270 id: AUTHENTICATED_USER_ID,
18271 login: "ShiroKSH".to_owned(),
18272 }
18273 && resume.execution_repository_id == 991
18274 }));
18275 drop(captured);
18276
18277 let exported = provider.export_job_resumes().expect("durable export");
18278 assert_eq!(exported.len(), 1);
18279 assert_eq!(exported[0].state, JobState::Queued);
18280 let encoded_resume = exported[0].encode().expect("encoded resume");
18281 assert_eq!(
18282 GithubJobResumeV1::decode(&encoded_resume).expect("strict resume JSON"),
18283 exported[0]
18284 );
18285 let mut duplicate = encoded_resume;
18286 duplicate.pop();
18287 duplicate.splice(1..1, b"\"schema_version\":1,".iter().copied());
18288 assert!(GithubJobResumeV1::decode(&duplicate).is_err());
18289 let mut encoded = serde_json::to_value(&exported[0]).expect("resume JSON");
18290 encoded
18291 .as_object_mut()
18292 .expect("resume object")
18293 .insert("unexpected".to_owned(), serde_json::json!(true));
18294 let mut unknown = serde_json::to_vec(&encoded).expect("unknown field JSON");
18295 unknown.push(b'\n');
18296 assert!(GithubJobResumeV1::decode(&unknown).is_err());
18297 let mut encoded = serde_json::to_value(&exported[0]).expect("resume JSON");
18298 encoded
18299 .get_mut("request")
18300 .and_then(serde_json::Value::as_object_mut)
18301 .expect("nested request object")
18302 .insert("future_safety_field".to_owned(), serde_json::json!(true));
18303 let mut nested_unknown = serde_json::to_vec(&encoded).expect("nested unknown field JSON");
18304 nested_unknown.push(b'\n');
18305 assert!(GithubJobResumeV1::decode(&nested_unknown).is_err());
18306
18307 let restored = GithubBuildProvider::with_artifact_store_and_clock(
18308 provider_config(all_authorized()),
18309 transport(FakeGhRunner::with([
18310 Ok(authenticated_user_row()),
18311 Ok(private_repository_row()),
18312 ])),
18313 FakePublisher::default(),
18314 NoVerifiedArtifactStore,
18315 FixedClock(1_700_000_000_000),
18316 );
18317 restored
18318 .restore_job_resumes(exported)
18319 .expect("strict restore");
18320 assert_eq!(restored.lock_state().expect("state").jobs.len(), 1);
18321 }
18322
18323 #[test]
18324 fn resume_successor_rejects_stale_replacements_but_allows_skipped_internal_states() {
18325 let prepublication = durable_prepublication_resume();
18326 let queued = durable_resume_with_run();
18327 prepublication
18328 .validate_successor(&queued)
18329 .expect("publication and run identity bind once");
18330
18331 let succeeded = durable_success_resume();
18332 queued
18333 .validate_successor(&succeeded)
18334 .expect("queued checkpoint may complete before the next durable write");
18335 let mut cleaned = succeeded.clone();
18336 cleaned.state = JobState::Cleaned;
18337 cleaned.cleanup_requested = true;
18338 cleaned.temporary_ref_deleted = true;
18339 succeeded
18340 .validate_successor(&cleaned)
18341 .expect("cleanup may skip its in-memory intermediate state");
18342
18343 let mut signed_succeeded = succeeded.clone();
18344 signed_succeeded.request = valid_request();
18345 signed_succeeded.request_sha256 =
18346 canonical_request_sha256(&signed_succeeded.request).expect("signed request digest");
18347 let mut signed_cleaned = signed_succeeded.clone();
18348 signed_cleaned.state = JobState::Cleaned;
18349 signed_cleaned.cleanup_requested = true;
18350 signed_cleaned.temporary_ref_deleted = true;
18351 assert!(
18352 signed_succeeded
18353 .validate_successor(&signed_cleaned)
18354 .is_err(),
18355 "signed cleanup needs attempt-bound worker evidence"
18356 );
18357
18358 assert!(queued.validate_successor(&prepublication).is_err());
18359 let mut changed_attempt = queued.clone();
18360 changed_attempt.run.as_mut().expect("run").run_attempt = 2;
18361 assert!(queued.validate_successor(&changed_attempt).is_err());
18362 let mut changed_fence = queued.clone();
18363 changed_fence.publication_process_fenced = !changed_fence.publication_process_fenced;
18364 assert!(queued.validate_successor(&changed_fence).is_err());
18365 let mut changed_lease_scope = queued.clone();
18366 changed_lease_scope.publication_lease_scope_sha256 = Some("b".repeat(64));
18367 assert!(queued.validate_successor(&changed_lease_scope).is_err());
18368 let mut removed_run = queued.clone();
18369 removed_run.run = None;
18370 assert!(queued.validate_successor(&removed_run).is_err());
18371 let mut mismatched_dispatch = prepublication.clone();
18372 mismatched_dispatch.prepared_dispatch_commit = Some(SOURCE_SHA.to_owned());
18373 let mut mismatched_successor = mismatched_dispatch.clone();
18374 mismatched_successor.dispatch_commit = Some(DISPATCH_SHA.to_owned());
18375 assert!(
18376 mismatched_dispatch
18377 .validate_successor(&mismatched_successor)
18378 .is_err()
18379 );
18380 let mut changed_manifests = cleaned.clone();
18381 changed_manifests.manifests[0].project_id = "different".to_owned();
18382 assert!(cleaned.validate_successor(&changed_manifests).is_err());
18383 }
18384
18385 #[test]
18386 fn legacy_resume_bytes_omit_the_optional_git_snapshot_checkpoint() {
18387 let resume = durable_resume_with_run();
18388 let bytes = resume.encode().expect("legacy resume bytes");
18389 assert!(
18390 !bytes
18391 .windows(b"git_snapshot".len())
18392 .any(|part| part == b"git_snapshot")
18393 );
18394 let decoded = GithubJobResumeV1::decode(&bytes).expect("legacy resume decode");
18395 assert_eq!(decoded.git_snapshot, None);
18396 assert_eq!(decoded.encode().expect("legacy resume reencode"), bytes);
18397 }
18398
18399 #[test]
18400 fn snapshot_successor_validates_the_complete_next_phase_shape() {
18401 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
18402 let mut malformed = fixture.resume.clone();
18403 let snapshot = malformed.git_snapshot.as_mut().expect("snapshot resume");
18404 snapshot.source_publication_started_at_ms = malformed.created_at_ms;
18405 snapshot.source_publication_quiescence_deadline_ms =
18406 publication_quiescence_deadline(snapshot.source_publication_started_at_ms, 0);
18407 let error = fixture
18408 .resume
18409 .validate_successor(&malformed)
18410 .expect_err("same-phase malformed successor must fail");
18411 assert!(matches!(
18412 error,
18413 RemoteBuildError::ProviderFailure { code, .. }
18414 if code == "resume_git_snapshot_source_started_early"
18415 ));
18416 }
18417
18418 #[test]
18419 fn snapshot_exact_source_requires_an_armed_publication_boundary() {
18420 let mut fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::SourceExact);
18421 let snapshot = fixture
18422 .resume
18423 .git_snapshot
18424 .as_mut()
18425 .expect("snapshot resume");
18426 snapshot.source_publication_started_at_ms = 0;
18427 snapshot.source_publication_quiescence_deadline_ms = u64::MAX;
18428 let error = snapshot
18429 .validate_for_request(&fixture.resume.request)
18430 .expect_err("unarmed exact source must not be adopted");
18431 assert!(matches!(
18432 error,
18433 RemoteBuildError::ProviderFailure { code, .. }
18434 if code == "resume_git_snapshot_source_ownership_unarmed"
18435 ));
18436 }
18437
18438 #[test]
18439 fn standalone_cleanup_restore_requires_terminal_snapshot_source_proof() {
18440 let config = provider_config(all_authorized());
18441 let principal = GithubPrincipalIdentityV1::User {
18442 id: AUTHENTICATED_USER_ID,
18443 login: "ShiroKSH".to_owned(),
18444 };
18445 for state in [JobState::Cleaned, JobState::CleanupFailed] {
18446 let exact = completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceExact, state);
18447 let error = exact
18448 .resume
18449 .clone()
18450 .into_record(&config, &principal, 991)
18451 .expect_err("cleanup state cannot retain an owned source ref");
18452 assert!(matches!(
18453 error,
18454 RemoteBuildError::ProviderFailure { code, .. }
18455 if code == "resume_state_inconsistent"
18456 ));
18457
18458 let deleted = completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceDeleted, state);
18459 deleted
18460 .resume
18461 .clone()
18462 .into_record(&config, &principal, 991)
18463 .expect("terminal source deletion proof permits cleanup restore");
18464 }
18465 }
18466
18467 #[test]
18468 #[allow(
18469 clippy::too_many_lines,
18470 reason = "one adversarial regression proves the no-write boundary, capability and operation gates, and both cancellation checks"
18471 )]
18472 fn initial_snapshot_graph_precompute_is_exact_no_write_and_cancellation_bounded() {
18473 let fixture = initial_snapshot_precompute_fixture();
18474 let mut inputs = fixture.inputs();
18475 let git_runner = FakeGitRunner::with(initial_snapshot_graph_responses());
18476 let gh_runner = FakeGhRunner::with([]);
18477 let gh_requests = Arc::clone(&gh_runner.requests);
18478 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18479 provider_config(all_authorized()),
18480 transport(gh_runner),
18481 GitTemporaryRefPublisher::new(
18482 git_runner,
18483 fixture.isolation.path(),
18484 "source",
18485 "execution",
18486 Duration::from_secs(30),
18487 )
18488 .expect("snapshot precompute publisher"),
18489 NoVerifiedArtifactStore,
18490 FixedClock(1_700_000_000_000),
18491 );
18492 let tree_before = test_tree_snapshot(fixture.isolation.path());
18493
18494 let graph = provider
18495 .precompute_git_snapshot_graph(
18496 &mut inputs,
18497 "operation-1",
18498 1_700_000_000_000,
18499 &CancellationToken::new(),
18500 )
18501 .expect("initial snapshot graph");
18502 assert_eq!(
18503 graph,
18504 GitSnapshotObjectGraphV1 {
18505 schema_version: crate::snapshot::GIT_SNAPSHOT_GRAPH_SCHEMA_VERSION,
18506 archive_blob: snapshot_object('1'),
18507 descriptor_blob: snapshot_object('2'),
18508 goal3_tree: snapshot_object('3'),
18509 rustferry_tree: snapshot_object('4'),
18510 root_tree: snapshot_object('5'),
18511 commit: snapshot_object('6'),
18512 }
18513 );
18514 inputs
18515 .verify_contents()
18516 .expect("retained inputs after precompute");
18517 assert_eq!(test_tree_snapshot(fixture.isolation.path()), tree_before);
18518 assert!(gh_requests.lock().expect("GitHub requests").is_empty());
18519 {
18520 let publisher = provider.publisher.lock().expect("publisher");
18521 assert!(publisher.snapshot_imports.is_empty());
18522 assert!(publisher.publication_leases.is_empty());
18523 assert!(publisher.frozen_remotes.is_none());
18524 assert!(publisher.runner.responses.is_empty());
18525 assert_eq!(publisher.runner.invocations.len(), 7);
18526 for invocation in &publisher.runner.invocations {
18527 assert_eq!(invocation.network, GitNetworkPolicy::Offline);
18528 let arguments = invocation
18529 .arguments
18530 .iter()
18531 .map(|argument| argument.to_string_lossy())
18532 .collect::<Vec<_>>();
18533 assert!(!arguments.iter().any(|argument| argument == "-w"));
18534 assert!(!arguments.iter().any(|argument| matches!(
18535 argument.as_ref(),
18536 "push" | "fetch" | "update-ref" | "checkout"
18537 )));
18538 }
18539 }
18540
18541 let invocation_count = provider
18542 .publisher
18543 .lock()
18544 .expect("publisher")
18545 .runner
18546 .invocations
18547 .len();
18548 let invalid_operation = provider
18549 .precompute_git_snapshot_graph(
18550 &mut fixture.inputs(),
18551 "../unsafe",
18552 1_700_000_000_000,
18553 &CancellationToken::new(),
18554 )
18555 .expect_err("unsafe operation must fail before Git");
18556 assert!(matches!(
18557 invalid_operation,
18558 RemoteBuildError::ProviderFailure { code, .. } if code == "git_snapshot_invalid"
18559 ));
18560 let cancelled_before = CancellationToken::new();
18561 assert!(cancelled_before.cancel());
18562 assert_eq!(
18563 provider.precompute_git_snapshot_graph(
18564 &mut fixture.inputs(),
18565 "operation-1",
18566 1_700_000_000_000,
18567 &cancelled_before,
18568 ),
18569 Err(RemoteBuildError::Cancelled)
18570 );
18571 assert_eq!(
18572 provider
18573 .publisher
18574 .lock()
18575 .expect("publisher")
18576 .runner
18577 .invocations
18578 .len(),
18579 invocation_count
18580 );
18581 assert_eq!(test_tree_snapshot(fixture.isolation.path()), tree_before);
18582
18583 let unsupported = GithubBuildProvider::with_artifact_store_and_clock(
18584 provider_config(all_authorized()),
18585 transport(FakeGhRunner::with([])),
18586 FakePublisher::default(),
18587 NoVerifiedArtifactStore,
18588 FixedClock(1_700_000_000_000),
18589 );
18590 assert!(matches!(
18591 unsupported.precompute_git_snapshot_graph(
18592 &mut fixture.inputs(),
18593 "operation-1",
18594 1_700_000_000_000,
18595 &CancellationToken::new(),
18596 ),
18597 Err(RemoteBuildError::UnsupportedCapability {
18598 feature: ProviderFeature::SourceMode(SourceMode::GitSnapshot),
18599 ..
18600 })
18601 ));
18602 assert_eq!(test_tree_snapshot(fixture.isolation.path()), tree_before);
18603 drop(provider);
18604
18605 let cancelled_after = CancellationToken::new();
18606 let post_cancel_gh = FakeGhRunner::with([]);
18607 let post_cancel_requests = Arc::clone(&post_cancel_gh.requests);
18608 let post_cancel_provider = GithubBuildProvider::with_artifact_store_and_clock(
18609 provider_config(all_authorized()),
18610 transport(post_cancel_gh),
18611 GitTemporaryRefPublisher::new(
18612 FakeGitRunner::cancelling_after(
18613 initial_snapshot_graph_responses(),
18614 7,
18615 cancelled_after.clone(),
18616 ),
18617 fixture.isolation.path(),
18618 "source",
18619 "execution",
18620 Duration::from_secs(30),
18621 )
18622 .expect("post-compute cancellation publisher"),
18623 NoVerifiedArtifactStore,
18624 FixedClock(1_700_000_000_000),
18625 );
18626 assert_eq!(
18627 post_cancel_provider.precompute_git_snapshot_graph(
18628 &mut fixture.inputs(),
18629 "operation-1",
18630 1_700_000_000_000,
18631 &cancelled_after,
18632 ),
18633 Err(RemoteBuildError::Cancelled)
18634 );
18635 assert!(cancelled_after.is_cancelled());
18636 assert!(
18637 post_cancel_requests
18638 .lock()
18639 .expect("post-cancel GitHub requests")
18640 .is_empty()
18641 );
18642 let publisher = post_cancel_provider.publisher.lock().expect("publisher");
18643 assert_eq!(publisher.runner.invocations.len(), 7);
18644 assert!(publisher.publication_leases.is_empty());
18645 assert!(publisher.snapshot_imports.is_empty());
18646 assert!(publisher.frozen_remotes.is_none());
18647 assert_eq!(test_tree_snapshot(fixture.isolation.path()), tree_before);
18648 }
18649
18650 #[test]
18651 fn snapshot_recovery_discovery_is_read_only_and_filters_provider_owned_operations() {
18652 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
18653 let isolation = fixture.isolation.path().to_path_buf();
18654 let resume = fixture.resume.clone();
18655 let snapshot = resume.git_snapshot.as_ref().expect("snapshot resume");
18656 let archive_path =
18657 crate::snapshot::git_snapshot_stage_directory(&isolation, &resume.operation_id)
18658 .expect("stage path")
18659 .join(crate::snapshot::GIT_SNAPSHOT_STAGE_ARCHIVE_FILE);
18660 let descriptor_path =
18661 crate::snapshot::git_snapshot_stage_directory(&isolation, &resume.operation_id)
18662 .expect("stage path")
18663 .join(crate::snapshot::GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE);
18664 let metadata_path =
18665 crate::snapshot::git_snapshot_stage_directory(&isolation, &resume.operation_id)
18666 .expect("stage path")
18667 .join(crate::snapshot::GIT_SNAPSHOT_STAGE_METADATA_FILE);
18668 let before = [
18669 fs::read(&archive_path).expect("archive bytes"),
18670 fs::read(&descriptor_path).expect("descriptor bytes"),
18671 fs::read(&metadata_path).expect("metadata bytes"),
18672 ];
18673 let publisher = GitTemporaryRefPublisher::new(
18674 FakeGitRunner::default(),
18675 &isolation,
18676 "source",
18677 "execution",
18678 Duration::from_secs(1),
18679 )
18680 .expect("snapshot publisher");
18681 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18682 provider_config(all_authorized()),
18683 transport(FakeGhRunner::with([])),
18684 publisher,
18685 NoVerifiedArtifactStore,
18686 FixedClock(1_700_000_000_000),
18687 );
18688
18689 let candidates = provider
18690 .discover_uncheckpointed_git_snapshot_stages()
18691 .expect("read-only discovery");
18692 assert_eq!(candidates.len(), 1);
18693 assert_eq!(candidates[0].stage_locator, snapshot.stage_locator);
18694 assert_eq!(candidates[0].stage, snapshot.stage);
18695 assert_eq!(candidates[0].final_request, resume.request);
18696 assert!(
18697 provider
18698 .publisher
18699 .lock()
18700 .expect("publisher")
18701 .runner
18702 .invocations
18703 .is_empty()
18704 );
18705 assert_eq!(
18706 [
18707 fs::read(&archive_path).expect("archive bytes"),
18708 fs::read(&descriptor_path).expect("descriptor bytes"),
18709 fs::read(&metadata_path).expect("metadata bytes"),
18710 ],
18711 before
18712 );
18713
18714 provider
18715 .restore_job_resumes_offline(
18716 vec![resume.clone()],
18717 &durable_identity_for_resume(&resume),
18718 )
18719 .expect("restore provider owner");
18720 assert!(
18721 provider
18722 .discover_uncheckpointed_git_snapshot_stages()
18723 .expect("owned-stage discovery")
18724 .is_empty()
18725 );
18726 assert!(
18727 provider
18728 .publisher
18729 .lock()
18730 .expect("publisher")
18731 .runner
18732 .invocations
18733 .is_empty()
18734 );
18735 }
18736
18737 #[test]
18738 fn fresh_reconsent_reserves_the_operation_before_any_stage_adoption() {
18739 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
18740 let resume = fixture.resume.clone();
18741 let snapshot = resume.git_snapshot.as_ref().expect("snapshot resume");
18742 let submission = GithubGitSnapshotSubmissionV1::after_fresh_reconsent(
18743 durable_identity_for_resume(&resume),
18744 resume.request.clone(),
18745 snapshot.stage.consent_sha256.clone(),
18746 )
18747 .expect("fresh reconsent submission");
18748 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18749 provider_config(all_authorized()),
18750 transport(FakeGhRunner::with([])),
18751 SnapshotFlowPublisher::exact(),
18752 NoVerifiedArtifactStore,
18753 FixedClock(1_700_000_000_000),
18754 )
18755 .with_checkpoint_sink(CapturingCheckpointSink::default());
18756 let reservation = JobReservation::acquire(
18757 &provider.state,
18758 resume.operation_id.clone(),
18759 provider.config.max_jobs,
18760 false,
18761 )
18762 .expect("concurrent operation reservation");
18763
18764 let error = provider
18765 .submit_git_snapshot(submission, &CancellationToken::new())
18766 .expect_err("active operation reservation wins before adoption");
18767 assert!(matches!(
18768 error,
18769 RemoteBuildError::ProviderFailure { code, .. } if code == "duplicate_job_id"
18770 ));
18771 assert!(
18772 provider
18773 .publisher
18774 .lock()
18775 .expect("publisher")
18776 .events
18777 .is_empty()
18778 );
18779 drop(reservation);
18780 }
18781
18782 #[test]
18783 fn snapshot_source_cleanup_holds_lease_until_deleted_checkpoint_is_durable() {
18784 let config = provider_config(all_authorized());
18785 let principal = GithubPrincipalIdentityV1::User {
18786 id: AUTHENTICATED_USER_ID,
18787 login: "ShiroKSH".to_owned(),
18788 };
18789 let fixture =
18790 completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceExact, JobState::Succeeded);
18791 let mut record = fixture
18792 .resume
18793 .clone()
18794 .into_record(&config, &principal, 991)
18795 .expect("snapshot record");
18796 let events = Arc::new(Mutex::new(Vec::new()));
18797 let publisher = SnapshotCleanupAuditPublisher {
18798 events: Arc::clone(&events),
18799 source_lease_owned: false,
18800 deletion_count: 0,
18801 keepalive_release_count: 0,
18802 };
18803 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18804 config,
18805 transport(FakeGhRunner::with([
18806 Ok(authenticated_user_row()),
18807 Ok(private_repository_row()),
18808 ])),
18809 publisher,
18810 NoVerifiedArtifactStore,
18811 FixedClock(1_700_000_000_000),
18812 )
18813 .with_checkpoint_sink(SnapshotCleanupCheckpointSink {
18814 events: Arc::clone(&events),
18815 fail_deleted_once: true,
18816 });
18817
18818 let error = provider
18819 .cleanup_git_snapshot_source(&mut record)
18820 .expect_err("first SourceDeleted checkpoint must fail");
18821 assert!(matches!(
18822 error,
18823 RemoteBuildError::ProviderFailure { code, .. }
18824 if code == "checkpoint_fixture_failed"
18825 ));
18826 assert_eq!(
18827 record.git_snapshot.as_ref().expect("snapshot").phase,
18828 GithubGitSnapshotPhaseV1::SourceDeleted
18829 );
18830 {
18831 let publisher = provider.publisher.lock().expect("publisher");
18832 assert!(publisher.source_lease_owned);
18833 assert_eq!(publisher.deletion_count, 1);
18834 assert_eq!(publisher.keepalive_release_count, 0);
18835 }
18836 assert_eq!(
18837 *events.lock().expect("snapshot cleanup events"),
18838 vec![
18839 SnapshotCleanupAuditEvent::Checkpoint(
18840 GithubGitSnapshotPhaseV1::SourceCleanupIntent,
18841 ),
18842 SnapshotCleanupAuditEvent::Acquire,
18843 SnapshotCleanupAuditEvent::Delete,
18844 SnapshotCleanupAuditEvent::Checkpoint(GithubGitSnapshotPhaseV1::SourceDeleted,),
18845 ]
18846 );
18847
18848 provider
18849 .cleanup_git_snapshot_source(&mut record)
18850 .expect("replay deletion checkpoint and release lease");
18851 let publisher = provider.publisher.lock().expect("publisher");
18852 assert!(!publisher.source_lease_owned);
18853 assert_eq!(publisher.deletion_count, 1);
18854 assert_eq!(publisher.keepalive_release_count, 0);
18855 drop(publisher);
18856 assert_eq!(
18857 *events.lock().expect("snapshot cleanup events"),
18858 vec![
18859 SnapshotCleanupAuditEvent::Checkpoint(
18860 GithubGitSnapshotPhaseV1::SourceCleanupIntent,
18861 ),
18862 SnapshotCleanupAuditEvent::Acquire,
18863 SnapshotCleanupAuditEvent::Delete,
18864 SnapshotCleanupAuditEvent::Checkpoint(GithubGitSnapshotPhaseV1::SourceDeleted,),
18865 SnapshotCleanupAuditEvent::Checkpoint(GithubGitSnapshotPhaseV1::SourceDeleted,),
18866 SnapshotCleanupAuditEvent::Release,
18867 ]
18868 );
18869 }
18870
18871 #[test]
18872 fn snapshot_keepalive_prune_checkpoints_intent_before_release_and_replays() {
18873 let fixture =
18874 completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceDeleted, JobState::Cleaned);
18875 let resume = fixture.resume.clone();
18876 let expected = GithubDurableIdentityV1 {
18877 provider: resume.provider.clone(),
18878 provider_config_sha256: resume.provider_config_sha256.clone(),
18879 principal: resume.principal.clone(),
18880 execution_repository: resume.execution_repository.clone(),
18881 execution_repository_id: resume.execution_repository_id,
18882 };
18883 let checkpoints = Arc::new(Mutex::new(Vec::new()));
18884 let mut publisher = SnapshotFlowPublisher::exact();
18885 publisher.keepalive_exact = true;
18886 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18887 provider_config(all_authorized()),
18888 transport(FakeGhRunner::with([])),
18889 publisher,
18890 NoVerifiedArtifactStore,
18891 FixedClock(1_700_000_000_000),
18892 )
18893 .with_checkpoint_sink(FailOnSnapshotPhaseSink {
18894 checkpoints: Arc::clone(&checkpoints),
18895 phase: GithubGitSnapshotPhaseV1::KeepaliveReleased,
18896 failed_once: true,
18897 });
18898 provider
18899 .restore_job_resumes_offline(vec![resume], &expected)
18900 .expect("offline snapshot restore");
18901 let authorization =
18902 GithubGitSnapshotKeepaliveReleaseAuthorizationV1::new("operation-1", "7".repeat(64))
18903 .expect("prune authorization");
18904
18905 let error = provider
18906 .release_git_snapshot_keepalive_for_prune(&authorization, &CancellationToken::new())
18907 .expect_err("Released checkpoint acknowledgement fails once");
18908 assert!(matches!(
18909 error,
18910 RemoteBuildError::ProviderFailure { code, .. }
18911 if code == "checkpoint_fixture_failed"
18912 ));
18913 let publisher = provider.publisher.lock().expect("publisher");
18914 assert_eq!(
18915 publisher.events,
18916 [
18917 SnapshotFlowEvent::ReconcileKeepalive,
18918 SnapshotFlowEvent::ReleaseKeepalive,
18919 SnapshotFlowEvent::ReconcileKeepalive,
18920 ]
18921 );
18922 assert!(!publisher.keepalive_exact);
18923 drop(publisher);
18924 assert_eq!(
18925 checkpoints
18926 .lock()
18927 .expect("snapshot checkpoints")
18928 .iter()
18929 .map(|resume| resume.git_snapshot.as_ref().expect("snapshot").phase)
18930 .collect::<Vec<_>>(),
18931 [
18932 GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent,
18933 GithubGitSnapshotPhaseV1::KeepaliveReleased,
18934 ]
18935 );
18936
18937 provider
18938 .release_git_snapshot_keepalive_for_prune(&authorization, &CancellationToken::new())
18939 .expect("replay Released checkpoint without another mutation");
18940 let publisher = provider.publisher.lock().expect("publisher");
18941 assert_eq!(
18942 publisher.events,
18943 [
18944 SnapshotFlowEvent::ReconcileKeepalive,
18945 SnapshotFlowEvent::ReleaseKeepalive,
18946 SnapshotFlowEvent::ReconcileKeepalive,
18947 ]
18948 );
18949 drop(publisher);
18950 let state = provider.lock_state().expect("state");
18951 let snapshot = state.jobs["operation-1"]
18952 .git_snapshot
18953 .as_ref()
18954 .expect("snapshot");
18955 assert_eq!(snapshot.phase, GithubGitSnapshotPhaseV1::KeepaliveReleased);
18956 assert_eq!(
18957 snapshot.keepalive_release_authorization_sha256.as_deref(),
18958 Some("7777777777777777777777777777777777777777777777777777777777777777")
18959 );
18960 }
18961
18962 #[test]
18963 fn snapshot_keepalive_prune_requires_terminal_cleanup_and_stable_transaction() {
18964 let fixture =
18965 completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceDeleted, JobState::Succeeded);
18966 let resume = fixture.resume.clone();
18967 let expected = GithubDurableIdentityV1 {
18968 provider: resume.provider.clone(),
18969 provider_config_sha256: resume.provider_config_sha256.clone(),
18970 principal: resume.principal.clone(),
18971 execution_repository: resume.execution_repository.clone(),
18972 execution_repository_id: resume.execution_repository_id,
18973 };
18974 let provider = GithubBuildProvider::with_artifact_store_and_clock(
18975 provider_config(all_authorized()),
18976 transport(FakeGhRunner::with([])),
18977 SnapshotFlowPublisher::exact(),
18978 NoVerifiedArtifactStore,
18979 FixedClock(1_700_000_000_000),
18980 )
18981 .with_checkpoint_sink(CapturingCheckpointSink::default());
18982 provider
18983 .restore_job_resumes_offline(vec![resume], &expected)
18984 .expect("offline snapshot restore");
18985 let authorization =
18986 GithubGitSnapshotKeepaliveReleaseAuthorizationV1::new("operation-1", "7".repeat(64))
18987 .expect("prune authorization");
18988 let error = provider
18989 .release_git_snapshot_keepalive_for_prune(&authorization, &CancellationToken::new())
18990 .expect_err("build-terminal is not cleanup-complete");
18991 assert!(matches!(
18992 error,
18993 RemoteBuildError::ProviderFailure { code, .. }
18994 if code == "snapshot_keepalive_prune_not_terminal"
18995 ));
18996 assert!(
18997 provider
18998 .publisher
18999 .lock()
19000 .expect("publisher")
19001 .events
19002 .is_empty()
19003 );
19004 }
19005
19006 #[test]
19007 #[allow(
19008 clippy::too_many_lines,
19009 reason = "one regression proves no-write planning, durable-plan staging, complete-stage replay, and fixed-Git policy"
19010 )]
19011 fn exact_retry_precompute_is_no_write_and_complete_stage_replay_is_exact() {
19012 let fixture =
19013 completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceDeleted, JobState::Cleaned);
19014 let resume = fixture.resume.clone();
19015 let expected = durable_identity_for_resume(&resume);
19016 let mut responses = Vec::new();
19017 for _ in 0..3 {
19018 responses.extend(exact_retry_git_cycle(&resume, SNAPSHOT_ARCHIVE_BYTES, '1'));
19019 }
19020 let publisher = GitTemporaryRefPublisher::new(
19021 FakeGitRunner::with(responses),
19022 fixture.isolation.path(),
19023 "origin",
19024 "signing",
19025 Duration::from_secs(30),
19026 )
19027 .expect("exact retry publisher");
19028 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19029 provider_config(all_authorized()),
19030 transport(FakeGhRunner::with([])),
19031 publisher,
19032 NoVerifiedArtifactStore,
19033 FixedClock(1_700_000_000_000),
19034 )
19035 .with_checkpoint_sink(CapturingCheckpointSink::default());
19036 provider
19037 .restore_job_resumes_offline(vec![resume.clone()], &expected)
19038 .expect("offline parent restore");
19039 let child_stage_path =
19040 crate::snapshot::git_snapshot_stage_directory(fixture.isolation.path(), "operation-2")
19041 .expect("child stage path");
19042
19043 let plan = provider
19044 .precompute_exact_git_snapshot_retry(
19045 "operation-1",
19046 "operation-2",
19047 1_700_000_001_000,
19048 &CancellationToken::new(),
19049 )
19050 .expect("exact retry plan");
19051 assert!(
19052 !child_stage_path.exists(),
19053 "planning must not create a stage"
19054 );
19055 assert_eq!(plan.request.operation_id, "operation-2");
19056 let expected_child_commit = "e".repeat(40);
19057 assert_eq!(
19058 plan.request.source_revision.as_deref(),
19059 Some(expected_child_commit.as_str())
19060 );
19061 assert_eq!(plan.graph.archive_blob, snapshot_object('1'));
19062 assert_eq!(plan.graph.descriptor_blob, snapshot_object('a'));
19063 assert_eq!(plan.graph.goal3_tree, snapshot_object('b'));
19064 assert_eq!(plan.graph.rustferry_tree, snapshot_object('c'));
19065 assert_eq!(plan.graph.root_tree, snapshot_object('d'));
19066 assert_eq!(plan.graph.commit, snapshot_object('e'));
19067
19068 let authorization = GithubGitSnapshotExactRetryAuthorizationV1::new(
19069 "operation-1",
19070 "operation-2",
19071 1_700_000_001_000,
19072 "6".repeat(64),
19073 )
19074 .expect("retry lineage authorization");
19075 let staged = provider
19076 .stage_exact_git_snapshot_retry(&authorization, &plan, &CancellationToken::new())
19077 .expect("materialize exact retry stage");
19078 assert_eq!(staged.request, plan.request);
19079 assert_eq!(staged.stage.graph, plan.graph);
19080 assert_eq!(staged.stage.final_request, plan.request);
19081 assert_eq!(staged.stage.consent_sha256, "6".repeat(64));
19082 let replay = provider
19083 .stage_exact_git_snapshot_retry(&authorization, &plan, &CancellationToken::new())
19084 .expect("adopt exact complete retry stage");
19085 assert_eq!(replay, staged);
19086 let inputs = GitSnapshotImportInputs::load(
19087 fixture.isolation.path(),
19088 &replay.stage_locator,
19089 &replay.request,
19090 )
19091 .expect("reopen replayed child stage");
19092 assert_eq!(inputs.stage(), &replay.stage);
19093 drop(inputs);
19094 GithubGitSnapshotSubmissionV1::exact_retry_lineage(expected, &authorization, replay)
19095 .expect("distinct exact retry submission authority");
19096
19097 let publisher = provider.publisher.lock().expect("publisher");
19098 assert!(publisher.runner.responses.is_empty());
19099 let cat_file = publisher
19100 .runner
19101 .invocations
19102 .iter()
19103 .filter(|invocation| {
19104 invocation
19105 .arguments
19106 .first()
19107 .and_then(|value| value.to_str())
19108 == Some("cat-file")
19109 })
19110 .collect::<Vec<_>>();
19111 assert_eq!(cat_file.len(), 3);
19112 assert!(
19113 cat_file
19114 .iter()
19115 .all(|invocation| { invocation.stdout_limit == SNAPSHOT_ARCHIVE_BYTES.len() + 1 })
19116 );
19117 for invocation in &publisher.runner.invocations {
19118 assert_eq!(invocation.network, GitNetworkPolicy::Offline);
19119 let arguments = invocation
19120 .arguments
19121 .iter()
19122 .map(|value| value.to_string_lossy())
19123 .collect::<Vec<_>>();
19124 assert!(!arguments.iter().any(|argument| argument == "-w"));
19125 assert!(!arguments.iter().any(|argument| matches!(
19126 argument.as_ref(),
19127 "push" | "fetch" | "update-ref" | "checkout"
19128 )));
19129 }
19130 }
19131
19132 #[test]
19133 fn exact_retry_and_prune_reject_cleanup_failed_parent_without_publisher_calls() {
19134 let fixture = completed_snapshot_resume(
19135 GithubGitSnapshotPhaseV1::SourceDeleted,
19136 JobState::CleanupFailed,
19137 );
19138 let resume = fixture.resume.clone();
19139 let expected = durable_identity_for_resume(&resume);
19140 let plan = synthetic_exact_retry_plan(&resume);
19141 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19142 provider_config(all_authorized()),
19143 transport(FakeGhRunner::with([])),
19144 SnapshotFlowPublisher::exact(),
19145 NoVerifiedArtifactStore,
19146 FixedClock(1_700_000_000_000),
19147 )
19148 .with_checkpoint_sink(CapturingCheckpointSink::default());
19149 provider
19150 .restore_job_resumes_offline(vec![resume], &expected)
19151 .expect("offline cleanup-failed parent restore");
19152 let retry = GithubGitSnapshotExactRetryAuthorizationV1::new(
19153 "operation-1",
19154 "operation-2",
19155 1_700_000_001_000,
19156 "6".repeat(64),
19157 )
19158 .expect("retry authorization");
19159 let error = provider
19160 .stage_exact_git_snapshot_retry(&retry, &plan, &CancellationToken::new())
19161 .expect_err("CleanupFailed parent cannot stage exact retry");
19162 assert!(matches!(
19163 error,
19164 RemoteBuildError::ProviderFailure { code, .. }
19165 if code == "snapshot_exact_retry_parent_not_terminal"
19166 ));
19167 let prune =
19168 GithubGitSnapshotKeepaliveReleaseAuthorizationV1::new("operation-1", "7".repeat(64))
19169 .expect("prune authorization");
19170 let error = provider
19171 .release_git_snapshot_keepalive_for_prune(&prune, &CancellationToken::new())
19172 .expect_err("CleanupFailed parent cannot release keepalive");
19173 assert!(matches!(
19174 error,
19175 RemoteBuildError::ProviderFailure { code, .. }
19176 if code == "snapshot_keepalive_prune_not_terminal"
19177 ));
19178 assert!(
19179 provider
19180 .publisher
19181 .lock()
19182 .expect("publisher")
19183 .events
19184 .is_empty()
19185 );
19186 }
19187
19188 #[test]
19189 fn exact_retry_rejects_releasing_missing_and_conflicting_parent_keepalives() {
19190 let mut releasing =
19191 completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceDeleted, JobState::Cleaned);
19192 let releasing_plan = synthetic_exact_retry_plan(&releasing.resume);
19193 {
19194 let snapshot = releasing.resume.git_snapshot.as_mut().expect("snapshot");
19195 snapshot.phase = GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent;
19196 snapshot.keepalive_release_authorization_sha256 = Some("7".repeat(64));
19197 }
19198 let expected = durable_identity_for_resume(&releasing.resume);
19199 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19200 provider_config(all_authorized()),
19201 transport(FakeGhRunner::with([])),
19202 SnapshotFlowPublisher::exact(),
19203 NoVerifiedArtifactStore,
19204 FixedClock(1_700_000_000_000),
19205 )
19206 .with_checkpoint_sink(CapturingCheckpointSink::default());
19207 provider
19208 .restore_job_resumes_offline(vec![releasing.resume.clone()], &expected)
19209 .expect("offline releasing parent restore");
19210 let authorization = GithubGitSnapshotExactRetryAuthorizationV1::new(
19211 "operation-1",
19212 "operation-2",
19213 1_700_000_001_000,
19214 "6".repeat(64),
19215 )
19216 .expect("retry authorization");
19217 let error = provider
19218 .stage_exact_git_snapshot_retry(
19219 &authorization,
19220 &releasing_plan,
19221 &CancellationToken::new(),
19222 )
19223 .expect_err("releasing parent is unavailable");
19224 assert!(matches!(
19225 error,
19226 RemoteBuildError::ProviderFailure { code, .. }
19227 if code == "snapshot_exact_retry_parent_releasing"
19228 ));
19229 assert!(
19230 provider
19231 .publisher
19232 .lock()
19233 .expect("publisher")
19234 .events
19235 .is_empty()
19236 );
19237
19238 for (reconciliation, expected_code) in [
19239 (
19240 GitSnapshotRefReconciliation::Absent,
19241 "snapshot_exact_retry_parent_keepalive_missing",
19242 ),
19243 (
19244 GitSnapshotRefReconciliation::Conflict,
19245 "snapshot_exact_retry_parent_keepalive_conflict",
19246 ),
19247 ] {
19248 let fixture = completed_snapshot_resume(
19249 GithubGitSnapshotPhaseV1::SourceDeleted,
19250 JobState::Cleaned,
19251 );
19252 let resume = fixture.resume.clone();
19253 let expected = durable_identity_for_resume(&resume);
19254 let plan = synthetic_exact_retry_plan(&resume);
19255 let mut publisher = SnapshotFlowPublisher::exact();
19256 publisher
19257 .keepalive_reconciliations
19258 .push_back(reconciliation);
19259 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19260 provider_config(all_authorized()),
19261 transport(FakeGhRunner::with([])),
19262 publisher,
19263 NoVerifiedArtifactStore,
19264 FixedClock(1_700_000_000_000),
19265 )
19266 .with_checkpoint_sink(CapturingCheckpointSink::default());
19267 provider
19268 .restore_job_resumes_offline(vec![resume], &expected)
19269 .expect("offline parent restore");
19270 let error = provider
19271 .stage_exact_git_snapshot_retry(&authorization, &plan, &CancellationToken::new())
19272 .expect_err("parent keepalive proof fails");
19273 assert!(matches!(
19274 error,
19275 RemoteBuildError::ProviderFailure { code, .. } if code == expected_code
19276 ));
19277 let publisher = provider.publisher.lock().expect("publisher");
19278 assert_eq!(
19279 publisher.events,
19280 [
19281 SnapshotFlowEvent::AcquireSourceLease,
19282 SnapshotFlowEvent::ReconcileKeepalive,
19283 SnapshotFlowEvent::ReleaseSourceLease,
19284 ]
19285 );
19286 }
19287 }
19288
19289 #[test]
19290 fn exact_retry_precompute_rejects_parent_archive_byte_and_object_mismatch() {
19291 for (archive_bytes, archive_object) in [
19292 (b"snapshot archive fixturf\n".as_slice(), '1'),
19293 (SNAPSHOT_ARCHIVE_BYTES, '2'),
19294 ] {
19295 let fixture = completed_snapshot_resume(
19296 GithubGitSnapshotPhaseV1::SourceDeleted,
19297 JobState::Cleaned,
19298 );
19299 let resume = fixture.resume.clone();
19300 let expected = durable_identity_for_resume(&resume);
19301 let snapshot = resume.git_snapshot.as_ref().expect("snapshot");
19302 let responses = vec![
19303 Ok(format!(
19304 "{}\0{}\n",
19305 snapshot.stage.keepalive_ref.as_str(),
19306 snapshot.stage.graph.commit.as_str()
19307 )
19308 .into_bytes()),
19309 Ok(b"sha1\n".to_vec()),
19310 Ok(archive_bytes.to_vec()),
19311 Ok(format!("{}\n", archive_object.to_string().repeat(40)).into_bytes()),
19312 ];
19313 let publisher = GitTemporaryRefPublisher::new(
19314 FakeGitRunner::with(responses),
19315 fixture.isolation.path(),
19316 "origin",
19317 "signing",
19318 Duration::from_secs(30),
19319 )
19320 .expect("exact retry publisher");
19321 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19322 provider_config(all_authorized()),
19323 transport(FakeGhRunner::with([])),
19324 publisher,
19325 NoVerifiedArtifactStore,
19326 FixedClock(1_700_000_000_000),
19327 )
19328 .with_checkpoint_sink(CapturingCheckpointSink::default());
19329 provider
19330 .restore_job_resumes_offline(vec![resume], &expected)
19331 .expect("offline parent restore");
19332 let error = provider
19333 .precompute_exact_git_snapshot_retry(
19334 "operation-1",
19335 "operation-2",
19336 1_700_000_001_000,
19337 &CancellationToken::new(),
19338 )
19339 .expect_err("parent archive proof must remain exact");
19340 assert!(matches!(
19341 error,
19342 RemoteBuildError::ProviderFailure { code, .. } if code == "git_snapshot_invalid"
19343 ));
19344 assert!(
19345 !crate::snapshot::git_snapshot_stage_directory(
19346 fixture.isolation.path(),
19347 "operation-2"
19348 )
19349 .expect("child stage path")
19350 .exists()
19351 );
19352 }
19353 }
19354
19355 #[test]
19356 fn snapshot_prepared_checkpoint_precedes_all_git_and_network_activity() {
19357 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
19358 let submission = same_invocation_snapshot_submission(&fixture);
19359 let runner = FakeGhRunner::with([]);
19360 let requests = Arc::clone(&runner.requests);
19361 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19362 provider_config(all_authorized()),
19363 transport(runner),
19364 SnapshotFlowPublisher::exact(),
19365 NoVerifiedArtifactStore,
19366 FixedClock(1_700_000_000_000),
19367 )
19368 .with_checkpoint_sink(FailingCheckpointSink);
19369
19370 let error = provider
19371 .submit_git_snapshot(submission, &CancellationToken::new())
19372 .expect_err("Prepared checkpoint must be durable");
19373 assert!(matches!(
19374 error,
19375 RemoteBuildError::ProviderFailure { code, .. }
19376 if code == "checkpoint_fixture_failed"
19377 ));
19378 assert!(requests.lock().expect("transport requests").is_empty());
19379 assert!(
19380 provider
19381 .publisher
19382 .lock()
19383 .expect("publisher")
19384 .events
19385 .is_empty()
19386 );
19387 }
19388
19389 #[test]
19390 fn snapshot_submission_publishes_exact_source_before_split_repo_dispatch() {
19391 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
19392 let submission = same_invocation_snapshot_submission(&fixture);
19393 let sink = CapturingCheckpointSink::default();
19394 let checkpoints = Arc::clone(&sink.checkpoints);
19395 let runner = FakeGhRunner::with(public_snapshot_identity_rows(5));
19396 let requests = Arc::clone(&runner.requests);
19397 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19398 provider_config(all_authorized()),
19399 transport(runner),
19400 SnapshotFlowPublisher::exact(),
19401 NoVerifiedArtifactStore,
19402 FixedClock(1_700_000_000_000),
19403 )
19404 .with_checkpoint_sink(sink);
19405
19406 let handle = provider
19407 .submit_git_snapshot(submission, &CancellationToken::new())
19408 .expect("snapshot submission");
19409 assert_eq!(handle.state, JobState::Queued);
19410 assert_eq!(requests.lock().expect("transport requests").len(), 15);
19411 let checkpoints = checkpoints.lock().expect("snapshot checkpoints");
19412 let phases = checkpoints
19413 .iter()
19414 .map(|resume| resume.git_snapshot.as_ref().expect("snapshot").phase)
19415 .collect::<Vec<_>>();
19416 assert_eq!(
19417 &phases[..8],
19418 &[
19419 GithubGitSnapshotPhaseV1::Prepared,
19420 GithubGitSnapshotPhaseV1::KeepaliveIntent,
19421 GithubGitSnapshotPhaseV1::KeepaliveExact,
19422 GithubGitSnapshotPhaseV1::StageDeleteIntent,
19423 GithubGitSnapshotPhaseV1::StageDeleted,
19424 GithubGitSnapshotPhaseV1::SourcePublishIntent,
19425 GithubGitSnapshotPhaseV1::SourcePublishIntent,
19426 GithubGitSnapshotPhaseV1::SourceExact,
19427 ]
19428 );
19429 let first_dispatch_intent = checkpoints
19430 .iter()
19431 .position(|resume| resume.publication_intent)
19432 .expect("dispatch intent checkpoint");
19433 assert!(checkpoints[..first_dispatch_intent].iter().any(|resume| {
19434 resume.git_snapshot.as_ref().map(|snapshot| snapshot.phase)
19435 == Some(GithubGitSnapshotPhaseV1::SourceExact)
19436 }));
19437 drop(checkpoints);
19438
19439 let publisher = provider.publisher.lock().expect("publisher");
19440 let publish_source = publisher
19441 .events
19442 .iter()
19443 .position(|event| event == &SnapshotFlowEvent::PublishSource)
19444 .expect("source publication");
19445 let prepare_dispatch = publisher
19446 .events
19447 .iter()
19448 .position(|event| matches!(event, SnapshotFlowEvent::PrepareDispatch { .. }))
19449 .expect("dispatch preparation");
19450 let publish_dispatch = publisher
19451 .events
19452 .iter()
19453 .position(|event| event == &SnapshotFlowEvent::PublishDispatch)
19454 .expect("dispatch publication");
19455 assert!(publish_source < prepare_dispatch && prepare_dispatch < publish_dispatch);
19456 assert!(publisher.events.iter().any(|event| {
19457 matches!(
19458 event,
19459 SnapshotFlowEvent::PrepareDispatch {
19460 execution_repository,
19461 source_repository,
19462 source_ref,
19463 } if execution_repository == "https://github.com/ShiroKSH/rustferry-signing"
19464 && source_repository == "https://github.com/shiroksh/rust-and-iphone"
19465 && source_ref == "refs/rustferry/goal3/snapshots/operation-1"
19466 )
19467 }));
19468 assert!(!publisher.source_lease_owned);
19469 assert!(!publisher.dispatch_lease_owned);
19470 }
19471
19472 #[test]
19473 fn snapshot_source_conflict_fails_without_dispatch_publication() {
19474 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
19475 let submission = same_invocation_snapshot_submission(&fixture);
19476 let mut publisher = SnapshotFlowPublisher::exact();
19477 publisher.source_publication = GitSnapshotSourcePublication::Conflict;
19478 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19479 provider_config(all_authorized()),
19480 transport(FakeGhRunner::with(public_snapshot_identity_rows(2))),
19481 publisher,
19482 NoVerifiedArtifactStore,
19483 FixedClock(1_700_000_000_000),
19484 )
19485 .with_checkpoint_sink(CapturingCheckpointSink::default());
19486
19487 let handle = provider
19488 .submit_git_snapshot(submission, &CancellationToken::new())
19489 .expect("conflict is durably terminal");
19490 assert_eq!(handle.state, JobState::Failed);
19491 let publisher = provider.publisher.lock().expect("publisher");
19492 assert!(publisher.events.contains(&SnapshotFlowEvent::PublishSource));
19493 assert!(!publisher.events.iter().any(|event| matches!(
19494 event,
19495 SnapshotFlowEvent::PrepareDispatch { .. } | SnapshotFlowEvent::PublishDispatch
19496 )));
19497 assert!(!publisher.source_lease_owned);
19498 }
19499
19500 #[test]
19501 fn snapshot_source_ref_drift_after_exact_checkpoint_blocks_dispatch() {
19502 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
19503 let submission = same_invocation_snapshot_submission(&fixture);
19504 let mut publisher = SnapshotFlowPublisher::exact();
19505 publisher
19506 .source_reconciliations
19507 .push_back(GitSnapshotRefReconciliation::Conflict);
19508 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19509 provider_config(all_authorized()),
19510 transport(FakeGhRunner::with(public_snapshot_identity_rows(3))),
19511 publisher,
19512 NoVerifiedArtifactStore,
19513 FixedClock(1_700_000_000_000),
19514 )
19515 .with_checkpoint_sink(CapturingCheckpointSink::default());
19516
19517 let handle = provider
19518 .submit_git_snapshot(submission, &CancellationToken::new())
19519 .expect("source drift is durably terminal");
19520 assert_eq!(handle.state, JobState::Failed);
19521 let publisher = provider.publisher.lock().expect("publisher");
19522 assert!(
19523 publisher
19524 .events
19525 .contains(&SnapshotFlowEvent::ReconcileSource)
19526 );
19527 assert!(!publisher.events.iter().any(|event| matches!(
19528 event,
19529 SnapshotFlowEvent::PrepareDispatch { .. } | SnapshotFlowEvent::PublishDispatch
19530 )));
19531 }
19532
19533 #[test]
19534 fn snapshot_source_exact_checkpoint_crash_replays_before_dispatch() {
19535 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
19536 let submission = same_invocation_snapshot_submission(&fixture);
19537 let checkpoints = Arc::new(Mutex::new(Vec::new()));
19538 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19539 provider_config(all_authorized()),
19540 transport(FakeGhRunner::with(public_snapshot_identity_rows(5))),
19541 SnapshotFlowPublisher::exact(),
19542 NoVerifiedArtifactStore,
19543 FixedClock(1_700_000_000_000),
19544 )
19545 .with_checkpoint_sink(FailOnSnapshotPhaseSink {
19546 checkpoints: Arc::clone(&checkpoints),
19547 phase: GithubGitSnapshotPhaseV1::SourceExact,
19548 failed_once: true,
19549 });
19550
19551 let error = provider
19552 .submit_git_snapshot(submission, &CancellationToken::new())
19553 .expect_err("SourceExact checkpoint acknowledgement fails");
19554 assert!(matches!(
19555 error,
19556 RemoteBuildError::ProviderFailure { code, .. }
19557 if code == "checkpoint_fixture_failed"
19558 ));
19559 {
19560 let publisher = provider.publisher.lock().expect("publisher");
19561 assert!(publisher.source_lease_owned);
19562 assert!(!publisher.events.iter().any(|event| matches!(
19563 event,
19564 SnapshotFlowEvent::PrepareDispatch { .. } | SnapshotFlowEvent::PublishDispatch
19565 )));
19566 }
19567
19568 provider
19569 .continue_git_snapshot_submission("operation-1", &CancellationToken::new())
19570 .expect("replay exact source then dispatch");
19571 let state = provider.lock_state().expect("state");
19572 assert_eq!(state.jobs["operation-1"].state, JobState::Queued);
19573 drop(state);
19574 let publisher = provider.publisher.lock().expect("publisher");
19575 assert!(!publisher.source_lease_owned);
19576 assert!(
19577 publisher
19578 .events
19579 .contains(&SnapshotFlowEvent::PublishDispatch)
19580 );
19581 drop(publisher);
19582 for pair in checkpoints.lock().expect("snapshot checkpoints").windows(2) {
19583 pair[0]
19584 .validate_successor(&pair[1])
19585 .expect("replayed snapshot checkpoint successor");
19586 }
19587 }
19588
19589 #[test]
19590 #[allow(
19591 clippy::too_many_lines,
19592 reason = "one table proves every submission mutation phase is re-acknowledged after an ambiguous checkpoint"
19593 )]
19594 fn snapshot_submission_phase_entry_failure_blocks_mutation_until_same_phase_reack() {
19595 for (phase, armed_source_intent, expected_event) in [
19596 (
19597 GithubGitSnapshotPhaseV1::KeepaliveIntent,
19598 false,
19599 SnapshotFlowEvent::ReconcileKeepalive,
19600 ),
19601 (
19602 GithubGitSnapshotPhaseV1::KeepaliveExact,
19603 false,
19604 SnapshotFlowEvent::ReconcileKeepalive,
19605 ),
19606 (
19607 GithubGitSnapshotPhaseV1::StageDeleteIntent,
19608 false,
19609 SnapshotFlowEvent::DeleteStage,
19610 ),
19611 (
19612 GithubGitSnapshotPhaseV1::StageDeleted,
19613 false,
19614 SnapshotFlowEvent::AcquireSourceLease,
19615 ),
19616 (
19617 GithubGitSnapshotPhaseV1::SourcePublishIntent,
19618 false,
19619 SnapshotFlowEvent::PublishSource,
19620 ),
19621 (
19622 GithubGitSnapshotPhaseV1::SourcePublishIntent,
19623 true,
19624 SnapshotFlowEvent::ReconcileSource,
19625 ),
19626 (
19627 GithubGitSnapshotPhaseV1::SourceExact,
19628 false,
19629 SnapshotFlowEvent::ReconcileSource,
19630 ),
19631 (
19632 GithubGitSnapshotPhaseV1::SourceAbsent,
19633 false,
19634 SnapshotFlowEvent::AcquireSourceLease,
19635 ),
19636 (
19637 GithubGitSnapshotPhaseV1::SourceConflict,
19638 false,
19639 SnapshotFlowEvent::ReleaseSourceLease,
19640 ),
19641 ] {
19642 let mut fixture = snapshot_resume_fixture(phase);
19643 if armed_source_intent {
19644 let snapshot = fixture
19645 .resume
19646 .git_snapshot
19647 .as_mut()
19648 .expect("snapshot resume");
19649 snapshot.source_publication_started_at_ms = fixture.resume.created_at_ms;
19650 snapshot.source_publication_quiescence_deadline_ms =
19651 publication_quiescence_deadline(fixture.resume.created_at_ms, 0);
19652 snapshot
19653 .validate_for_request(&fixture.resume.request)
19654 .expect("armed source intent");
19655 }
19656 let resume = fixture.resume.clone();
19657 let checkpoints = Arc::new(Mutex::new(Vec::new()));
19658 let mut publisher = SnapshotFlowPublisher::exact();
19659 publisher.keepalive_exact = phase != GithubGitSnapshotPhaseV1::KeepaliveIntent;
19660 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19661 provider_config(all_authorized()),
19662 transport(FakeGhRunner::with(public_snapshot_identity_rows(6))),
19663 publisher,
19664 NoVerifiedArtifactStore,
19665 FixedClock(1_700_000_000_000),
19666 )
19667 .with_checkpoint_sink(FailOnSnapshotPhaseSink {
19668 checkpoints: Arc::clone(&checkpoints),
19669 phase,
19670 failed_once: true,
19671 });
19672 provider
19673 .restore_job_resumes_offline(
19674 vec![resume.clone()],
19675 &durable_identity_for_resume(&resume),
19676 )
19677 .expect("restore snapshot phase");
19678 if phase == GithubGitSnapshotPhaseV1::SourceConflict {
19679 provider
19680 .lock_state()
19681 .expect("provider state")
19682 .jobs
19683 .get_mut(&resume.operation_id)
19684 .expect("snapshot job")
19685 .snapshot_source_lease_owned = true;
19686 provider
19687 .publisher
19688 .lock()
19689 .expect("publisher")
19690 .source_lease_owned = true;
19691 }
19692
19693 let error = provider
19694 .continue_git_snapshot_submission(&resume.operation_id, &CancellationToken::new())
19695 .expect_err("current phase acknowledgement fails once");
19696 assert!(matches!(
19697 error,
19698 RemoteBuildError::ProviderFailure { code, .. }
19699 if code == "checkpoint_fixture_failed"
19700 ));
19701 assert!(
19702 provider
19703 .publisher
19704 .lock()
19705 .expect("publisher")
19706 .events
19707 .is_empty(),
19708 "{phase:?} mutated before its checkpoint"
19709 );
19710
19711 provider
19712 .continue_git_snapshot_submission(&resume.operation_id, &CancellationToken::new())
19713 .expect("same phase re-acknowledges before progressing");
19714 assert!(
19715 provider
19716 .publisher
19717 .lock()
19718 .expect("publisher")
19719 .events
19720 .contains(&expected_event),
19721 "{phase:?} did not reach its expected mutation after re-acknowledgement"
19722 );
19723 assert!(
19724 checkpoints
19725 .lock()
19726 .expect("snapshot checkpoints")
19727 .iter()
19728 .filter(|resume| {
19729 resume.git_snapshot.as_ref().map(|snapshot| snapshot.phase) == Some(phase)
19730 })
19731 .count()
19732 >= 2,
19733 "{phase:?} was not re-acknowledged"
19734 );
19735 }
19736 }
19737
19738 #[test]
19739 fn snapshot_cleanup_intent_failure_reacks_before_exact_source_deletion() {
19740 let config = provider_config(all_authorized());
19741 let principal = GithubPrincipalIdentityV1::User {
19742 id: AUTHENTICATED_USER_ID,
19743 login: "ShiroKSH".to_owned(),
19744 };
19745 let fixture =
19746 completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceExact, JobState::Succeeded);
19747 let mut record = fixture
19748 .resume
19749 .clone()
19750 .into_record(&config, &principal, 991)
19751 .expect("snapshot record");
19752 let events = Arc::new(Mutex::new(Vec::new()));
19753 let checkpoints = Arc::new(Mutex::new(Vec::new()));
19754 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19755 config,
19756 transport(FakeGhRunner::with([
19757 Ok(authenticated_user_row()),
19758 Ok(private_repository_row()),
19759 ])),
19760 SnapshotCleanupAuditPublisher {
19761 events: Arc::clone(&events),
19762 source_lease_owned: false,
19763 deletion_count: 0,
19764 keepalive_release_count: 0,
19765 },
19766 NoVerifiedArtifactStore,
19767 FixedClock(1_700_000_000_000),
19768 )
19769 .with_checkpoint_sink(FailOnSnapshotPhaseSink {
19770 checkpoints: Arc::clone(&checkpoints),
19771 phase: GithubGitSnapshotPhaseV1::SourceCleanupIntent,
19772 failed_once: true,
19773 });
19774
19775 let error = provider
19776 .cleanup_git_snapshot_source(&mut record)
19777 .expect_err("cleanup intent checkpoint fails once");
19778 assert!(matches!(
19779 error,
19780 RemoteBuildError::ProviderFailure { code, .. } if code == "checkpoint_fixture_failed"
19781 ));
19782 assert!(events.lock().expect("cleanup events").is_empty());
19783 provider
19784 .cleanup_git_snapshot_source(&mut record)
19785 .expect("cleanup intent re-acknowledges before delete");
19786 assert_eq!(
19787 checkpoints
19788 .lock()
19789 .expect("snapshot checkpoints")
19790 .iter()
19791 .map(|resume| resume.git_snapshot.as_ref().expect("snapshot").phase)
19792 .collect::<Vec<_>>(),
19793 [
19794 GithubGitSnapshotPhaseV1::SourceCleanupIntent,
19795 GithubGitSnapshotPhaseV1::SourceCleanupIntent,
19796 GithubGitSnapshotPhaseV1::SourceDeleted,
19797 ]
19798 );
19799 assert!(
19800 events
19801 .lock()
19802 .expect("cleanup events")
19803 .contains(&SnapshotCleanupAuditEvent::Delete)
19804 );
19805 }
19806
19807 #[test]
19808 fn snapshot_keepalive_release_intent_failure_reacks_before_local_ref_deletion() {
19809 let fixture =
19810 completed_snapshot_resume(GithubGitSnapshotPhaseV1::SourceDeleted, JobState::Cleaned);
19811 let resume = fixture.resume.clone();
19812 let checkpoints = Arc::new(Mutex::new(Vec::new()));
19813 let mut publisher = SnapshotFlowPublisher::exact();
19814 publisher.keepalive_exact = true;
19815 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19816 provider_config(all_authorized()),
19817 transport(FakeGhRunner::with([])),
19818 publisher,
19819 NoVerifiedArtifactStore,
19820 FixedClock(1_700_000_000_000),
19821 )
19822 .with_checkpoint_sink(FailOnSnapshotPhaseSink {
19823 checkpoints: Arc::clone(&checkpoints),
19824 phase: GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent,
19825 failed_once: true,
19826 });
19827 provider
19828 .restore_job_resumes_offline(
19829 vec![resume.clone()],
19830 &durable_identity_for_resume(&resume),
19831 )
19832 .expect("restore snapshot parent");
19833 let authorization =
19834 GithubGitSnapshotKeepaliveReleaseAuthorizationV1::new("operation-1", "7".repeat(64))
19835 .expect("prune authorization");
19836
19837 let error = provider
19838 .release_git_snapshot_keepalive_for_prune(&authorization, &CancellationToken::new())
19839 .expect_err("release intent checkpoint fails once");
19840 assert!(matches!(
19841 error,
19842 RemoteBuildError::ProviderFailure { code, .. } if code == "checkpoint_fixture_failed"
19843 ));
19844 assert!(
19845 provider
19846 .publisher
19847 .lock()
19848 .expect("publisher")
19849 .events
19850 .is_empty()
19851 );
19852 provider
19853 .release_git_snapshot_keepalive_for_prune(&authorization, &CancellationToken::new())
19854 .expect("release intent re-acknowledges before delete");
19855 assert_eq!(
19856 checkpoints
19857 .lock()
19858 .expect("snapshot checkpoints")
19859 .iter()
19860 .map(|resume| resume.git_snapshot.as_ref().expect("snapshot").phase)
19861 .collect::<Vec<_>>(),
19862 [
19863 GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent,
19864 GithubGitSnapshotPhaseV1::KeepaliveReleaseIntent,
19865 GithubGitSnapshotPhaseV1::KeepaliveReleased,
19866 ]
19867 );
19868 assert!(
19869 provider
19870 .publisher
19871 .lock()
19872 .expect("publisher")
19873 .events
19874 .contains(&SnapshotFlowEvent::ReleaseKeepalive)
19875 );
19876 }
19877
19878 #[test]
19879 fn snapshot_consent_mismatch_stops_before_checkpoint_or_publisher() {
19880 let fixture = snapshot_resume_fixture(GithubGitSnapshotPhaseV1::Prepared);
19881 let resume = &fixture.resume;
19882 let snapshot = resume.git_snapshot.as_ref().expect("snapshot resume");
19883 let submission = GithubGitSnapshotSubmissionV1::same_invocation(
19884 GithubDurableIdentityV1 {
19885 provider: resume.provider.clone(),
19886 provider_config_sha256: resume.provider_config_sha256.clone(),
19887 principal: resume.principal.clone(),
19888 execution_repository: resume.execution_repository.clone(),
19889 execution_repository_id: resume.execution_repository_id,
19890 },
19891 resume.request.clone(),
19892 "8".repeat(64),
19893 snapshot.stage_locator.clone(),
19894 snapshot.stage.clone(),
19895 )
19896 .expect("mismatched consent submission envelope");
19897 let runner = FakeGhRunner::with([]);
19898 let requests = Arc::clone(&runner.requests);
19899 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19900 provider_config(all_authorized()),
19901 transport(runner),
19902 SnapshotFlowPublisher::exact(),
19903 NoVerifiedArtifactStore,
19904 FixedClock(1_700_000_000_000),
19905 )
19906 .with_checkpoint_sink(CapturingCheckpointSink::default());
19907
19908 let error = provider
19909 .submit_git_snapshot(submission, &CancellationToken::new())
19910 .expect_err("consent mismatch");
19911 assert!(matches!(
19912 error,
19913 RemoteBuildError::ProviderFailure { code, .. }
19914 if code == "snapshot_consent_changed"
19915 ));
19916 assert!(requests.lock().expect("transport requests").is_empty());
19917 assert!(
19918 provider
19919 .publisher
19920 .lock()
19921 .expect("publisher")
19922 .events
19923 .is_empty()
19924 );
19925 }
19926
19927 #[test]
19928 fn failed_pre_push_checkpoint_blocks_remote_mutation_and_retains_intent() {
19929 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19930 provider_config(all_authorized()),
19931 transport(FakeGhRunner::with([
19932 Ok(authenticated_user_row()),
19933 Ok(private_repository_row()),
19934 ])),
19935 FakePublisher::default(),
19936 NoVerifiedArtifactStore,
19937 FixedClock(1_700_000_000_000),
19938 )
19939 .with_checkpoint_sink(FailingCheckpointSink);
19940 assert!(poll_ready(provider.submit(unsigned_request(), CancellationToken::new())).is_err());
19941 assert!(
19942 provider
19943 .publisher
19944 .lock()
19945 .expect("publisher")
19946 .published
19947 .is_empty()
19948 );
19949 assert_eq!(
19950 provider
19951 .publisher
19952 .lock()
19953 .expect("publisher")
19954 .publication_leases
19955 .len(),
19956 1
19957 );
19958 let state = provider.lock_state().expect("state");
19959 let record = state.jobs.get("operation-1").expect("retained job");
19960 assert!(record.publication_intent);
19961 assert_eq!(record.state, JobState::Created);
19962 assert!(state.reservations.is_empty());
19963 }
19964
19965 #[test]
19966 fn checkpoint_write_with_uncertain_ack_keeps_live_successor_chain() {
19967 let sink = WriteThenFailCheckpointSink::default();
19968 let checkpoints = Arc::clone(&sink.checkpoints);
19969 let provider = GithubBuildProvider::with_artifact_store_and_clock(
19970 provider_config(all_authorized()),
19971 transport(FakeGhRunner::with([
19972 Ok(authenticated_user_row()),
19973 Ok(private_repository_row()),
19974 Ok(authenticated_user_row()),
19975 Ok(private_repository_row()),
19976 ])),
19977 FakePublisher::default(),
19978 NoVerifiedArtifactStore,
19979 FixedClock(1_700_000_000_000),
19980 )
19981 .with_checkpoint_sink(sink);
19982
19983 assert!(poll_ready(provider.submit(unsigned_request(), CancellationToken::new())).is_err());
19984 assert!(
19985 provider
19986 .publisher
19987 .lock()
19988 .expect("publisher")
19989 .published
19990 .is_empty()
19991 );
19992 assert_eq!(
19993 provider
19994 .publisher
19995 .lock()
19996 .expect("publisher")
19997 .publication_leases
19998 .len(),
19999 1
20000 );
20001 let written = checkpoints
20002 .lock()
20003 .expect("checkpoints")
20004 .first()
20005 .cloned()
20006 .expect("possibly durable checkpoint");
20007 let exported = provider
20008 .export_job_resumes()
20009 .expect("retained in-memory intent can be exported");
20010 assert_eq!(exported.len(), 1);
20011 written
20012 .validate_successor(&exported[0])
20013 .expect("retained record preserves the durable successor chain");
20014 }
20015
20016 #[test]
20017 fn live_identity_failure_records_proven_no_publication_before_returning() {
20018 let sink = CapturingCheckpointSink::default();
20019 let checkpoints = Arc::clone(&sink.checkpoints);
20020 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20021 provider_config(all_authorized()),
20022 transport(FakeGhRunner::with([
20023 Ok(authenticated_user_row()),
20024 Ok(private_repository_row()),
20025 Ok(b"43\tYXR0YWNrZXI=\n".to_vec()),
20026 Ok(private_repository_row()),
20027 ])),
20028 FakePublisher::default(),
20029 NoVerifiedArtifactStore,
20030 FixedClock(1_700_000_000_000),
20031 )
20032 .with_checkpoint_sink(sink);
20033 assert!(poll_ready(provider.submit(unsigned_request(), CancellationToken::new())).is_err());
20034 let checkpoints = checkpoints.lock().expect("checkpoints");
20035 assert_eq!(checkpoints.len(), 2);
20036 checkpoints[0]
20037 .validate_successor(&checkpoints[1])
20038 .expect("terminal no-publication evidence is monotonic");
20039 assert_eq!(checkpoints[1].state, JobState::Failed);
20040 assert!(checkpoints[1].publication_not_attempted);
20041 assert!(!checkpoints[1].publication_absent);
20042 assert!(!checkpoints[1].temporary_ref_deleted);
20043 assert!(!checkpoints[1].publication_uncertain);
20044 drop(checkpoints);
20045 let state = provider.lock_state().expect("state");
20046 let record = state.jobs.get("operation-1").expect("durably owned job");
20047 assert!(record.publication_intent);
20048 assert_eq!(record.dispatch_commit, None);
20049 assert_eq!(record.state, JobState::Failed);
20050 assert!(record.publication_not_attempted);
20051 assert!(!record.publication_absent);
20052 assert!(
20053 provider
20054 .publisher
20055 .lock()
20056 .expect("publisher")
20057 .published
20058 .is_empty()
20059 );
20060 }
20061
20062 #[test]
20063 fn existing_ref_pre_mutation_failure_does_not_claim_ref_absence_or_deletion() {
20064 let sink = CapturingCheckpointSink::default();
20065 let checkpoints = Arc::clone(&sink.checkpoints);
20066 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20067 provider_config(all_authorized()),
20068 transport(FakeGhRunner::with([
20069 Ok(authenticated_user_row()),
20070 Ok(private_repository_row()),
20071 Ok(authenticated_user_row()),
20072 Ok(private_repository_row()),
20073 ])),
20074 FakePublisher {
20075 prepared_publication: Some(expected_publication()),
20076 failure: Some(TemporaryRefPublishError::TemporaryRefExists.into()),
20077 ..FakePublisher::default()
20078 },
20079 NoVerifiedArtifactStore,
20080 FixedClock(1_700_000_000_000),
20081 )
20082 .with_checkpoint_sink(sink);
20083
20084 assert!(poll_ready(provider.submit(unsigned_request(), CancellationToken::new())).is_err());
20085 let checkpoints = checkpoints.lock().expect("checkpoints");
20086 assert_eq!(checkpoints.len(), 3);
20087 checkpoints[0]
20088 .validate_successor(&checkpoints[1])
20089 .expect("publication boundary checkpoint");
20090 checkpoints[1]
20091 .validate_successor(&checkpoints[2])
20092 .expect("definite failure checkpoint");
20093 assert_eq!(checkpoints[2].state, JobState::Failed);
20094 assert_eq!(
20095 checkpoints[2].prepared_dispatch_commit.as_deref(),
20096 Some(DISPATCH_SHA)
20097 );
20098 assert!(checkpoints[2].publication_not_attempted);
20099 assert!(!checkpoints[2].publication_absent);
20100 assert!(!checkpoints[2].temporary_ref_deleted);
20101 assert!(!checkpoints[2].publication_uncertain);
20102 drop(checkpoints);
20103 assert_eq!(
20104 provider
20105 .reconcile_restored_job("operation-1", &CancellationToken::new())
20106 .expect("definite pre-mutation result"),
20107 GithubJobReconciliation::NotStarted
20108 );
20109 }
20110
20111 #[test]
20112 #[allow(clippy::too_many_lines)]
20113 fn resume_validation_rejects_every_security_identity_dimension() {
20114 let config = provider_config(all_authorized());
20115 let principal = GithubPrincipalIdentityV1::User {
20116 id: AUTHENTICATED_USER_ID,
20117 login: "ShiroKSH".to_owned(),
20118 };
20119 let base = durable_resume_with_run();
20120 base.clone()
20121 .into_record(&config, &principal, 991)
20122 .expect("valid resume fixture");
20123 let assert_rejected = |candidate: GithubJobResumeV1| {
20124 assert!(candidate.into_record(&config, &principal, 991).is_err());
20125 };
20126
20127 let mut candidate = base.clone();
20128 candidate.execution_repository_id = 992;
20129 assert_rejected(candidate);
20130 let mut candidate = base.clone();
20131 candidate.principal = GithubPrincipalIdentityV1::User {
20132 id: AUTHENTICATED_USER_ID + 1,
20133 login: "attacker".to_owned(),
20134 };
20135 assert_rejected(candidate);
20136 let mut candidate = base.clone();
20137 candidate.provider_config_sha256 = "f".repeat(64);
20138 assert_rejected(candidate);
20139 let mut candidate = base.clone();
20140 candidate.workflow_sha256 = "f".repeat(64);
20141 assert_rejected(candidate);
20142 let mut candidate = base.clone();
20143 candidate.request.product_name = "Different".to_owned();
20144 assert_rejected(candidate);
20145 let mut candidate = base.clone();
20146 candidate.temporary_ref = "refs/heads/rustferry/goal3/builds/operation-2".to_owned();
20147 assert_rejected(candidate);
20148 let mut candidate = base.clone();
20149 candidate.publication_absent = true;
20150 candidate.temporary_ref_deleted = true;
20151 assert_rejected(candidate);
20152 let mut candidate = base.clone();
20153 candidate.publication_lease_scope_sha256 = None;
20154 assert_rejected(candidate);
20155 let mut candidate = base.clone();
20156 candidate.publication_lease_scope_sha256 = Some("A".repeat(64));
20157 assert_rejected(candidate);
20158 let mut candidate = durable_prepublication_resume();
20159 candidate.state = JobState::Failed;
20160 candidate.publication_absent = true;
20161 candidate.publication_uncertain = false;
20162 candidate.publication_absence_observations = 2;
20163 candidate.temporary_ref_deleted = true;
20164 assert_rejected(candidate);
20165 let mut candidate = durable_prepared_prepublication_resume();
20166 candidate.state = JobState::Failed;
20167 candidate.publication_absent = true;
20168 candidate.publication_uncertain = false;
20169 candidate.publication_absence_observations = 2;
20170 candidate.publication_absence_first_observed_at_ms = candidate.publication_started_at_ms;
20171 candidate.temporary_ref_deleted = true;
20172 candidate.publication_process_fenced = false;
20173 assert_rejected(candidate);
20174 let mut candidate = base.clone();
20175 candidate.publication_intent = false;
20176 assert_rejected(candidate);
20177 let mut candidate = base.clone();
20178 candidate.run_discovery_deadline_ms = u64::MAX;
20179 assert_rejected(candidate);
20180 let mut candidate = base.clone();
20181 candidate.request.source = large_source_manifest();
20182 candidate.request_sha256 =
20183 canonical_request_sha256(&candidate.request).expect("large request digest");
20184 assert!(
20185 candidate.encode().expect("bounded resume record").len() < GITHUB_JOB_RESUME_MAX_BYTES
20186 );
20187 assert_rejected(candidate);
20188 let mut candidate = base.clone();
20189 candidate.state = JobState::Cancelling;
20190 assert_rejected(candidate);
20191 let mut candidate = base.clone();
20192 candidate.state = JobState::Cancelling;
20193 candidate.cancellation_requested = true;
20194 candidate.cancellation_dispatched = true;
20195 candidate.run = None;
20196 assert_rejected(candidate);
20197 let mut candidate = durable_success_resume();
20198 candidate.run.as_mut().expect("run").status = GithubRunStatusV1::Queued;
20199 candidate.run.as_mut().expect("run").conclusion = None;
20200 assert_rejected(candidate);
20201 let mut candidate = base.clone();
20202 candidate.state = JobState::Cancelled;
20203 candidate.cancellation_requested = true;
20204 assert_rejected(candidate);
20205 let mut candidate = durable_success_resume();
20206 candidate.state = JobState::Cleaned;
20207 candidate.cleanup_requested = true;
20208 assert_rejected(candidate);
20209 let mut candidate = base.clone();
20210 candidate.run.as_mut().expect("run").head_sha = "0".repeat(40);
20211 assert_rejected(candidate);
20212 let mut candidate = base.clone();
20213 candidate.run.as_mut().expect("run").branch =
20214 "rustferry/goal3/builds/operation-2".to_owned();
20215 assert_rejected(candidate);
20216 let mut candidate = base.clone();
20217 candidate.run.as_mut().expect("run").event = GithubRunEventV1::WorkflowDispatch;
20218 assert_rejected(candidate);
20219 let mut candidate = base.clone();
20220 candidate.events.push(
20221 RemoteBuildEvent::new(
20222 "different-operation",
20223 "operation-1",
20224 1_700_000_000_000,
20225 GITHUB_PROVIDER_ID,
20226 "queue",
20227 1,
20228 RemoteBuildEventKind::JobQueued { position: None },
20229 )
20230 .expect("valid but foreign event"),
20231 );
20232 assert_rejected(candidate);
20233 let mut candidate = base;
20234 candidate.run.as_mut().expect("run").run_attempt = 0;
20235 assert_rejected(candidate);
20236 }
20237
20238 #[test]
20239 fn restored_cancel_revalidates_exact_run_and_attempt_before_post() {
20240 let runner = FakeGhRunner::with([
20241 Ok(authenticated_user_row()),
20242 Ok(private_repository_row()),
20243 Ok(queued_run_row()),
20244 Ok(authenticated_user_row()),
20245 Ok(private_repository_row()),
20246 Ok(queued_run_row()),
20247 Ok(queued_run_row()),
20248 Ok(authenticated_user_row()),
20249 Ok(private_repository_row()),
20250 Ok(Vec::new()),
20251 ]);
20252 let requests = Arc::clone(&runner.requests);
20253 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20254 provider_config(all_authorized()),
20255 transport(runner),
20256 FakePublisher::default(),
20257 NoVerifiedArtifactStore,
20258 FixedClock(1_700_000_000_000),
20259 );
20260 provider
20261 .restore_job_resumes(vec![durable_resume_with_run()])
20262 .expect("live run restore");
20263 let acknowledgement = poll_ready(provider.cancel(
20264 CancellationRequest {
20265 job_id: "operation-1".to_owned(),
20266 reason: "user-requested".to_owned(),
20267 },
20268 CancellationToken::new(),
20269 ))
20270 .expect("checked cancellation");
20271 assert!(acknowledgement.accepted);
20272
20273 let requests = requests.lock().expect("captured requests");
20274 let final_get = &requests[requests.len() - 4];
20275 assert_eq!(final_get.method(), ApiMethod::Get);
20276 assert_eq!(
20277 final_get.endpoint(),
20278 "/repos/ShiroKSH/rustferry-signing/actions/runs/41"
20279 );
20280 assert_eq!(requests[requests.len() - 3].endpoint(), "/user");
20281 assert_eq!(
20282 requests[requests.len() - 2].endpoint(),
20283 "/repos/ShiroKSH/rustferry-signing"
20284 );
20285 let cancel = &requests[requests.len() - 1];
20286 assert_eq!(cancel.method(), ApiMethod::Post);
20287 assert_eq!(
20288 cancel.endpoint(),
20289 "/repos/ShiroKSH/rustferry-signing/actions/runs/41/cancel"
20290 );
20291 }
20292
20293 #[test]
20294 fn offline_restore_performs_no_transport_or_publisher_operation() {
20295 let runner = FakeGhRunner::with([]);
20296 let requests = Arc::clone(&runner.requests);
20297 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20298 provider_config(all_authorized()),
20299 transport(runner),
20300 FakePublisher::default(),
20301 NoVerifiedArtifactStore,
20302 FixedClock(1_700_000_000_000),
20303 );
20304 let resume = durable_resume_with_run();
20305 let expected = GithubDurableIdentityV1 {
20306 provider: resume.provider.clone(),
20307 provider_config_sha256: resume.provider_config_sha256.clone(),
20308 principal: resume.principal.clone(),
20309 execution_repository: resume.execution_repository.clone(),
20310 execution_repository_id: resume.execution_repository_id,
20311 };
20312
20313 provider
20314 .restore_job_resumes_offline(vec![resume], &expected)
20315 .expect("offline restore");
20316
20317 assert!(requests.lock().expect("requests").is_empty());
20318 assert!(
20319 provider
20320 .publisher
20321 .lock()
20322 .expect("publisher")
20323 .published
20324 .is_empty()
20325 );
20326 assert!(
20327 provider
20328 .lock_state()
20329 .expect("state")
20330 .jobs
20331 .contains_key("operation-1")
20332 );
20333 assert_eq!(
20334 provider
20335 .restored_terminal_job_log_identity("operation-1")
20336 .expect("offline log identity"),
20337 None
20338 );
20339 }
20340
20341 #[test]
20342 fn offline_terminal_restore_exposes_only_a_durable_log_marker_identity() {
20343 let runner = FakeGhRunner::with([]);
20344 let requests = Arc::clone(&runner.requests);
20345 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20346 provider_config(all_authorized()),
20347 transport(runner),
20348 FakePublisher::default(),
20349 NoVerifiedArtifactStore,
20350 FixedClock(1_700_000_000_000),
20351 );
20352 let resume = durable_success_resume();
20353 let expected = GithubDurableIdentityV1 {
20354 provider: resume.provider.clone(),
20355 provider_config_sha256: resume.provider_config_sha256.clone(),
20356 principal: resume.principal.clone(),
20357 execution_repository: resume.execution_repository.clone(),
20358 execution_repository_id: resume.execution_repository_id,
20359 };
20360 provider
20361 .restore_job_resumes_offline(vec![resume], &expected)
20362 .expect("offline terminal restore");
20363
20364 let identity = provider
20365 .restored_terminal_job_log_identity("operation-1")
20366 .expect("durable log identity")
20367 .expect("terminal attempt");
20368 assert_eq!(
20369 identity.expected_completion_source_sequence(),
20370 (1_u64 << 32) | u64::from(u32::MAX)
20371 );
20372 assert!(requests.lock().expect("requests").is_empty());
20373 assert!(
20374 provider
20375 .publisher
20376 .lock()
20377 .expect("publisher")
20378 .published
20379 .is_empty()
20380 );
20381 }
20382
20383 #[test]
20384 fn get_only_cancel_reconciliation_never_posts() {
20385 let runner = FakeGhRunner::with([
20386 Ok(authenticated_user_row()),
20387 Ok(private_repository_row()),
20388 Ok(queued_run_row()),
20389 ]);
20390 let requests = Arc::clone(&runner.requests);
20391 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20392 provider_config(all_authorized()),
20393 transport(runner),
20394 FakePublisher::default(),
20395 NoVerifiedArtifactStore,
20396 FixedClock(1_700_000_000_000),
20397 );
20398 let mut resume = durable_resume_with_run();
20399 resume.state = JobState::Cancelling;
20400 resume.cancellation_requested = true;
20401 let expected = GithubDurableIdentityV1 {
20402 provider: resume.provider.clone(),
20403 provider_config_sha256: resume.provider_config_sha256.clone(),
20404 principal: resume.principal.clone(),
20405 execution_repository: resume.execution_repository.clone(),
20406 execution_repository_id: resume.execution_repository_id,
20407 };
20408 provider
20409 .restore_job_resumes_offline(vec![resume], &expected)
20410 .expect("offline cancelled restore");
20411
20412 provider
20413 .refresh_restored_job_get_only("operation-1", &CancellationToken::new())
20414 .expect("GET-only refresh");
20415
20416 let requests = requests.lock().expect("requests");
20417 assert_eq!(requests.len(), 3);
20418 assert!(
20419 requests
20420 .iter()
20421 .all(|request| request.method() == ApiMethod::Get)
20422 );
20423 let state = provider.lock_state().expect("state");
20424 let record = state.jobs.get("operation-1").expect("restored job");
20425 assert!(record.cancellation_requested);
20426 assert!(!record.cancellation_dispatched);
20427 }
20428
20429 #[test]
20430 fn get_only_exact_ref_recovery_never_creates_dispatch_intent_or_posts() {
20431 let resume = durable_workflow_dispatch_pre_intent_resume();
20432 let runner =
20433 FakeGhRunner::with([Ok(authenticated_user_row()), Ok(private_repository_row())]);
20434 let requests = Arc::clone(&runner.requests);
20435 let plan = FakeWorkflowDispatchPlan::with_response(workflow_dispatch_receipt_response(73));
20436 let sink = CapturingCheckpointSink::default();
20437 let checkpoints = Arc::clone(&sink.checkpoints);
20438 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20439 workflow_dispatch_provider_config(all_authorized()),
20440 transport(runner.with_workflow_dispatch(plan.clone())),
20441 FakePublisher {
20442 reconciliation: Some(TemporaryRefReconciliation::Exact(
20443 PublishedTemporaryRef::new(
20444 TemporaryGitRef::new(
20445 workflow_dispatch_provider_config(all_authorized())
20446 .workflow
20447 .temporary_branch_namespace(),
20448 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
20449 )
20450 .expect("temporary ref"),
20451 CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
20452 ),
20453 )),
20454 ..FakePublisher::default()
20455 },
20456 NoVerifiedArtifactStore,
20457 FixedClock(1_700_000_000_000),
20458 )
20459 .with_checkpoint_sink(sink);
20460 provider
20461 .restore_job_resumes_offline(
20462 vec![resume.clone()],
20463 &durable_identity_for_resume(&resume),
20464 )
20465 .expect("restore pre-intent publication");
20466
20467 let error = provider
20468 .reconcile_restored_job_get_only("operation-1", &CancellationToken::new())
20469 .expect_err("GET-only recovery cannot start a workflow");
20470 assert!(matches!(
20471 error,
20472 RemoteBuildError::ProviderFailure { code, .. }
20473 if code == "workflow_dispatch_mutation_required"
20474 ));
20475 assert_eq!(plan.0.clients_created.load(Ordering::SeqCst), 0);
20476 assert!(
20477 plan.0
20478 .requests
20479 .lock()
20480 .expect("dispatch requests")
20481 .is_empty()
20482 );
20483 assert!(
20484 requests
20485 .lock()
20486 .expect("requests")
20487 .iter()
20488 .all(|request| request.method() == ApiMethod::Get)
20489 );
20490 assert!(
20491 provider
20492 .publisher
20493 .lock()
20494 .expect("publisher")
20495 .published
20496 .is_empty()
20497 );
20498 let latest = checkpoints
20499 .lock()
20500 .expect("checkpoints")
20501 .last()
20502 .cloned()
20503 .expect("recovered exact ref checkpoint");
20504 assert_eq!(latest.dispatch_commit.as_deref(), Some(DISPATCH_SHA));
20505 assert!(latest.workflow_dispatch.is_none());
20506 assert!(latest.run.is_none());
20507 }
20508
20509 #[test]
20510 fn unarmed_dispatch_publication_intent_is_retired_without_ref_or_workflow_mutation() {
20511 let mut resume = durable_workflow_dispatch_pre_intent_resume();
20512 resume.publication_started_at_ms = 0;
20513 resume.publication_quiescence_deadline_ms = u64::MAX;
20514 resume.publication_absence_first_observed_at_ms = 0;
20515 let plan = FakeWorkflowDispatchPlan::with_response(workflow_dispatch_receipt_response(73));
20516 let sink = CapturingCheckpointSink::default();
20517 let checkpoints = Arc::clone(&sink.checkpoints);
20518 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20519 workflow_dispatch_provider_config(all_authorized()),
20520 transport(FakeGhRunner::with([]).with_workflow_dispatch(plan.clone())),
20521 FakePublisher {
20522 reconciliation: Some(TemporaryRefReconciliation::Exact(
20523 PublishedTemporaryRef::new(
20524 TemporaryGitRef::new(
20525 workflow_dispatch_provider_config(all_authorized())
20526 .workflow
20527 .temporary_branch_namespace(),
20528 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
20529 )
20530 .expect("temporary ref"),
20531 CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
20532 ),
20533 )),
20534 ..FakePublisher::default()
20535 },
20536 NoVerifiedArtifactStore,
20537 FixedClock(1_700_000_000_000),
20538 )
20539 .with_checkpoint_sink(sink);
20540 provider
20541 .restore_job_resumes_offline(
20542 vec![resume.clone()],
20543 &durable_identity_for_resume(&resume),
20544 )
20545 .expect("restore unarmed publication intent");
20546
20547 assert_eq!(
20548 provider
20549 .reconcile_restored_job("operation-1", &CancellationToken::new())
20550 .expect("retire unarmed publication intent"),
20551 GithubJobReconciliation::NotStarted
20552 );
20553 assert_eq!(plan.0.clients_created.load(Ordering::SeqCst), 0);
20554 assert!(
20555 plan.0
20556 .requests
20557 .lock()
20558 .expect("dispatch requests")
20559 .is_empty()
20560 );
20561 let publisher = provider.publisher.lock().expect("publisher");
20562 assert!(publisher.published.is_empty());
20563 assert!(publisher.reconciliation.is_some());
20564 drop(publisher);
20565 let latest = checkpoints
20566 .lock()
20567 .expect("checkpoints")
20568 .last()
20569 .cloned()
20570 .expect("unarmed retirement checkpoint");
20571 assert!(latest.publication_not_attempted);
20572 assert_eq!(latest.state, JobState::Failed);
20573 assert!(latest.dispatch_commit.is_none());
20574 assert!(latest.workflow_dispatch.is_none());
20575 assert!(latest.run.is_none());
20576 }
20577
20578 #[test]
20579 fn restored_cancel_rejects_changed_live_principal_before_post() {
20580 let runner = FakeGhRunner::with([
20581 Ok(authenticated_user_row()),
20582 Ok(private_repository_row()),
20583 Ok(queued_run_row()),
20584 Ok(b"43\tYXR0YWNrZXI=\n".to_vec()),
20585 Ok(private_repository_row()),
20586 ]);
20587 let requests = Arc::clone(&runner.requests);
20588 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20589 provider_config(all_authorized()),
20590 transport(runner),
20591 FakePublisher::default(),
20592 NoVerifiedArtifactStore,
20593 FixedClock(1_700_000_000_000),
20594 );
20595 provider
20596 .restore_job_resumes(vec![durable_resume_with_run()])
20597 .expect("live run restore");
20598 assert!(
20599 poll_ready(provider.cancel(
20600 CancellationRequest {
20601 job_id: "operation-1".to_owned(),
20602 reason: "user-requested".to_owned(),
20603 },
20604 CancellationToken::new(),
20605 ))
20606 .is_err()
20607 );
20608 assert!(
20609 requests
20610 .lock()
20611 .expect("requests")
20612 .iter()
20613 .all(|request| request.method() == ApiMethod::Get)
20614 );
20615 }
20616
20617 #[test]
20618 fn restore_rejects_a_changed_run_attempt_before_any_mutation() {
20619 let runner = FakeGhRunner::with([
20620 Ok(authenticated_user_row()),
20621 Ok(private_repository_row()),
20622 Ok(queued_run_attempt_row(2)),
20623 Ok(queued_run_attempt_row(2)),
20624 ]);
20625 let requests = Arc::clone(&runner.requests);
20626 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20627 provider_config(all_authorized()),
20628 transport(runner),
20629 FakePublisher::default(),
20630 NoVerifiedArtifactStore,
20631 FixedClock(1_700_000_000_000),
20632 );
20633 assert!(
20634 provider
20635 .restore_job_resumes(vec![durable_resume_with_run()])
20636 .is_err()
20637 );
20638 assert!(
20639 requests
20640 .lock()
20641 .expect("requests")
20642 .iter()
20643 .all(|request| request.method() == ApiMethod::Get)
20644 );
20645 assert!(provider.lock_state().expect("state").jobs.is_empty());
20646 }
20647
20648 #[test]
20649 fn restore_rejects_live_run_regression_from_persisted_success() {
20650 let runner = FakeGhRunner::with([
20651 Ok(authenticated_user_row()),
20652 Ok(private_repository_row()),
20653 Ok(queued_run_row()),
20654 ]);
20655 let requests = Arc::clone(&runner.requests);
20656 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20657 provider_config(all_authorized()),
20658 transport(runner),
20659 FakePublisher::default(),
20660 NoVerifiedArtifactStore,
20661 FixedClock(1_700_000_000_000),
20662 );
20663 assert!(
20664 provider
20665 .restore_job_resumes(vec![durable_success_resume()])
20666 .is_err()
20667 );
20668 assert!(
20669 requests
20670 .lock()
20671 .expect("requests")
20672 .iter()
20673 .all(|request| request.method() == ApiMethod::Get)
20674 );
20675 assert!(provider.lock_state().expect("state").jobs.is_empty());
20676 }
20677
20678 #[test]
20679 fn pre_push_resume_reconciles_exact_ref_without_duplicate_publication() {
20680 let resume = durable_prepared_prepublication_resume();
20681 let publisher = FakePublisher {
20682 reconciliation: Some(TemporaryRefReconciliation::Exact(
20683 PublishedTemporaryRef::new(
20684 TemporaryGitRef::new(
20685 provider_config(all_authorized())
20686 .workflow
20687 .temporary_branch_namespace(),
20688 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
20689 )
20690 .expect("temporary ref"),
20691 CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
20692 ),
20693 )),
20694 ..FakePublisher::default()
20695 };
20696 let sink = CapturingCheckpointSink::default();
20697 let checkpoints = Arc::clone(&sink.checkpoints);
20698 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20699 provider_config(all_authorized()),
20700 transport(FakeGhRunner::with([
20701 Ok(authenticated_user_row()),
20702 Ok(private_repository_row()),
20703 Ok(authenticated_user_row()),
20704 Ok(private_repository_row()),
20705 Ok(authenticated_user_row()),
20706 Ok(private_repository_row()),
20707 Ok(authenticated_user_row()),
20708 Ok(private_repository_row()),
20709 Ok(Vec::new()),
20710 ])),
20711 publisher,
20712 NoVerifiedArtifactStore,
20713 FixedClock(1_700_000_000_000),
20714 )
20715 .with_checkpoint_sink(sink);
20716 provider
20717 .restore_job_resumes(vec![resume])
20718 .expect("restore publication intent");
20719 assert_eq!(
20720 provider
20721 .reconcile_restored_job("operation-1", &CancellationToken::new())
20722 .expect("reconciliation"),
20723 GithubJobReconciliation::Recovered
20724 );
20725 assert!(
20726 provider
20727 .publisher
20728 .lock()
20729 .expect("publisher")
20730 .published
20731 .is_empty()
20732 );
20733 assert!(
20734 provider
20735 .publisher
20736 .lock()
20737 .expect("publisher")
20738 .publication_leases
20739 .is_empty()
20740 );
20741 assert_eq!(
20742 checkpoints
20743 .lock()
20744 .expect("checkpoints")
20745 .last()
20746 .and_then(|resume| resume.dispatch_commit.as_deref()),
20747 Some(DISPATCH_SHA)
20748 );
20749 }
20750
20751 #[test]
20752 #[allow(clippy::too_many_lines)]
20753 fn possible_push_checkpoint_requires_exact_missing_or_conflict_reconciliation() {
20754 let exact_resume = durable_possible_push_resume();
20755 let exact_sink = CapturingCheckpointSink::default();
20756 let exact_checkpoints = Arc::clone(&exact_sink.checkpoints);
20757 let exact_provider = GithubBuildProvider::with_artifact_store_and_clock(
20758 provider_config(all_authorized()),
20759 transport(FakeGhRunner::with([
20760 Ok(authenticated_user_row()),
20761 Ok(private_repository_row()),
20762 Ok(authenticated_user_row()),
20763 Ok(private_repository_row()),
20764 Ok(authenticated_user_row()),
20765 Ok(private_repository_row()),
20766 Ok(authenticated_user_row()),
20767 Ok(private_repository_row()),
20768 Ok(Vec::new()),
20769 ])),
20770 FakePublisher {
20771 reconciliation: Some(TemporaryRefReconciliation::Exact(expected_publication())),
20772 ..FakePublisher::default()
20773 },
20774 NoVerifiedArtifactStore,
20775 FixedClock(1_700_000_000_000),
20776 )
20777 .with_checkpoint_sink(exact_sink);
20778 exact_provider
20779 .restore_job_resumes(vec![exact_resume.clone()])
20780 .expect("restore possible push");
20781 assert_eq!(
20782 exact_provider
20783 .reconcile_restored_job("operation-1", &CancellationToken::new())
20784 .expect("exact reconciliation"),
20785 GithubJobReconciliation::Recovered
20786 );
20787 let exact = exact_checkpoints
20788 .lock()
20789 .expect("exact checkpoints")
20790 .last()
20791 .cloned()
20792 .expect("exact checkpoint");
20793 exact_resume
20794 .validate_successor(&exact)
20795 .expect("exact evidence is monotonic");
20796 assert!(!exact.publication_uncertain);
20797 assert!(
20798 exact_provider
20799 .publisher
20800 .lock()
20801 .expect("publisher")
20802 .publication_leases
20803 .is_empty()
20804 );
20805
20806 let missing_resume = durable_possible_push_resume();
20807 let missing_sink = CapturingCheckpointSink::default();
20808 let missing_checkpoints = Arc::clone(&missing_sink.checkpoints);
20809 let missing_provider = GithubBuildProvider::with_artifact_store_and_clock(
20810 provider_config(all_authorized()),
20811 transport(FakeGhRunner::with([
20812 Ok(authenticated_user_row()),
20813 Ok(private_repository_row()),
20814 Ok(authenticated_user_row()),
20815 Ok(private_repository_row()),
20816 Ok(authenticated_user_row()),
20817 Ok(private_repository_row()),
20818 Ok(Vec::new()),
20819 Ok(authenticated_user_row()),
20820 Ok(private_repository_row()),
20821 ])),
20822 FakePublisher::default(),
20823 NoVerifiedArtifactStore,
20824 FixedClock(1_700_000_000_000),
20825 )
20826 .with_checkpoint_sink(missing_sink);
20827 missing_provider
20828 .restore_job_resumes(vec![missing_resume.clone()])
20829 .expect("restore possible push");
20830 assert_eq!(
20831 missing_provider
20832 .reconcile_restored_job("operation-1", &CancellationToken::new())
20833 .expect("missing reconciliation"),
20834 GithubJobReconciliation::Missing
20835 );
20836 let missing = missing_checkpoints
20837 .lock()
20838 .expect("missing checkpoints")
20839 .last()
20840 .cloned()
20841 .expect("missing checkpoint");
20842 missing_resume
20843 .validate_successor(&missing)
20844 .expect("missing observation is monotonic");
20845 assert_eq!(missing.state, JobState::Queued);
20846 assert!(missing.publication_uncertain);
20847 assert_eq!(missing.publication_absence_observations, 1);
20848 assert_eq!(
20849 missing_provider
20850 .publisher
20851 .lock()
20852 .expect("publisher")
20853 .publication_leases
20854 .len(),
20855 1
20856 );
20857
20858 let conflict_resume = durable_possible_push_resume();
20859 let conflict_sink = CapturingCheckpointSink::default();
20860 let conflict_checkpoints = Arc::clone(&conflict_sink.checkpoints);
20861 let conflict_clock = MutableClock::new(1_700_000_000_000);
20862 let conflict_provider = GithubBuildProvider::with_artifact_store_and_clock(
20863 provider_config(all_authorized()),
20864 transport(FakeGhRunner::with([
20865 Ok(authenticated_user_row()),
20866 Ok(private_repository_row()),
20867 Ok(authenticated_user_row()),
20868 Ok(private_repository_row()),
20869 Ok(authenticated_user_row()),
20870 Ok(private_repository_row()),
20871 Ok(Vec::new()),
20872 Ok(authenticated_user_row()),
20873 Ok(private_repository_row()),
20874 Ok(authenticated_user_row()),
20875 Ok(private_repository_row()),
20876 Ok(authenticated_user_row()),
20877 Ok(private_repository_row()),
20878 Ok(Vec::new()),
20879 Ok(authenticated_user_row()),
20880 Ok(private_repository_row()),
20881 ])),
20882 FakePublisher {
20883 reconciliation: Some(TemporaryRefReconciliation::Conflict),
20884 ..FakePublisher::default()
20885 },
20886 NoVerifiedArtifactStore,
20887 conflict_clock.clone(),
20888 )
20889 .with_checkpoint_sink(conflict_sink);
20890 conflict_provider
20891 .restore_job_resumes(vec![conflict_resume.clone()])
20892 .expect("restore possible push");
20893 assert_eq!(
20894 conflict_provider
20895 .reconcile_restored_job("operation-1", &CancellationToken::new())
20896 .expect("conflict reconciliation"),
20897 GithubJobReconciliation::Conflict
20898 );
20899 let conflict = conflict_checkpoints
20900 .lock()
20901 .expect("conflict checkpoints")
20902 .last()
20903 .cloned()
20904 .expect("conflict checkpoint");
20905 conflict_resume
20906 .validate_successor(&conflict)
20907 .expect("conflict observation is monotonic");
20908 assert_eq!(conflict.state, JobState::Queued);
20909 assert!(conflict.publication_uncertain);
20910 assert_eq!(conflict.publication_absence_observations, 1);
20911 assert_eq!(
20912 conflict_provider
20913 .publisher
20914 .lock()
20915 .expect("publisher")
20916 .publication_leases
20917 .len(),
20918 1
20919 );
20920
20921 conflict_provider
20922 .publisher
20923 .lock()
20924 .expect("publisher")
20925 .reconciliation = Some(TemporaryRefReconciliation::Conflict);
20926 conflict_clock.set(1_700_000_000_000 + GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS);
20927 assert_eq!(
20928 conflict_provider
20929 .reconcile_restored_job("operation-1", &CancellationToken::new())
20930 .expect("bounded conflict reconciliation"),
20931 GithubJobReconciliation::ConflictResolved
20932 );
20933 let resolved_conflict = conflict_checkpoints
20934 .lock()
20935 .expect("conflict checkpoints")
20936 .last()
20937 .cloned()
20938 .expect("resolved conflict checkpoint");
20939 conflict
20940 .validate_successor(&resolved_conflict)
20941 .expect("bounded conflict resolution is monotonic");
20942 assert_eq!(resolved_conflict.state, JobState::Failed);
20943 assert!(resolved_conflict.publication_uncertain);
20944 assert_eq!(resolved_conflict.publication_absence_observations, 2);
20945 assert!(
20946 conflict_provider
20947 .publisher
20948 .lock()
20949 .expect("publisher")
20950 .publication_leases
20951 .is_empty()
20952 );
20953 }
20954
20955 #[test]
20956 fn restored_unarmed_publication_waits_for_the_original_process_lease() {
20957 let mut resume = durable_prepared_prepublication_resume();
20958 resume.publication_started_at_ms = 0;
20959 resume.publication_quiescence_deadline_ms = u64::MAX;
20960 let sink = CapturingCheckpointSink::default();
20961 let checkpoints = Arc::clone(&sink.checkpoints);
20962 let provider = GithubBuildProvider::with_artifact_store_and_clock(
20963 provider_config(all_authorized()),
20964 transport(FakeGhRunner::with([
20965 Ok(authenticated_user_row()),
20966 Ok(private_repository_row()),
20967 ])),
20968 FakePublisher {
20969 publication_lease_blocked: true,
20970 ..FakePublisher::default()
20971 },
20972 NoVerifiedArtifactStore,
20973 FixedClock(1_700_000_000_000),
20974 )
20975 .with_checkpoint_sink(sink);
20976 provider
20977 .restore_job_resumes(vec![resume])
20978 .expect("restore unarmed intent");
20979
20980 let error = provider
20981 .reconcile_restored_job("operation-1", &CancellationToken::new())
20982 .expect_err("live original owner must block takeover");
20983 assert!(matches!(
20984 error,
20985 RemoteBuildError::ProviderFailure {
20986 code,
20987 retryable: true,
20988 ..
20989 } if code == "publication_owner_active"
20990 ));
20991 assert!(checkpoints.lock().expect("checkpoints").is_empty());
20992 let state = provider.lock_state().expect("state");
20993 let record = state.jobs.get("operation-1").expect("restored job");
20994 assert_eq!(record.state, JobState::Created);
20995 assert_eq!(record.publication_absence_observations, 0);
20996 assert!(!record.publication_not_attempted);
20997 }
20998
20999 #[test]
21000 fn unsupported_publication_lease_never_retires_unarmed_intent() {
21001 let mut resume = durable_prepared_prepublication_resume();
21002 resume.publication_started_at_ms = 0;
21003 resume.publication_quiescence_deadline_ms = u64::MAX;
21004 resume.publication_process_fenced = false;
21005 resume.publication_lease_scope_sha256 = None;
21006 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21007 provider_config(all_authorized()),
21008 transport(FakeGhRunner::with([
21009 Ok(authenticated_user_row()),
21010 Ok(private_repository_row()),
21011 ])),
21012 FakePublisher {
21013 publication_lease_unsupported: true,
21014 ..FakePublisher::default()
21015 },
21016 NoVerifiedArtifactStore,
21017 FixedClock(1_700_000_000_000),
21018 );
21019 provider
21020 .restore_job_resumes(vec![resume])
21021 .expect("restore unsupported intent");
21022 assert!(matches!(
21023 provider.reconcile_restored_job("operation-1", &CancellationToken::new()),
21024 Err(RemoteBuildError::ProviderFailure {
21025 code,
21026 retryable: true,
21027 ..
21028 }) if code == "publication_lease_unavailable"
21029 ));
21030 let state = provider.lock_state().expect("state");
21031 let record = state.jobs.get("operation-1").expect("restored job");
21032 assert_eq!(record.state, JobState::Created);
21033 assert_eq!(record.publication_absence_observations, 0);
21034 }
21035
21036 #[test]
21037 fn absent_pre_push_ref_keeps_cancellation_fail_closed_and_non_recyclable() {
21038 let sink = CapturingCheckpointSink::default();
21039 let checkpoints = Arc::clone(&sink.checkpoints);
21040 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21041 provider_config(all_authorized()),
21042 transport(FakeGhRunner::with([
21043 Ok(authenticated_user_row()),
21044 Ok(private_repository_row()),
21045 Ok(authenticated_user_row()),
21046 Ok(private_repository_row()),
21047 Ok(authenticated_user_row()),
21048 Ok(private_repository_row()),
21049 Ok(Vec::new()),
21050 Ok(authenticated_user_row()),
21051 Ok(private_repository_row()),
21052 ])),
21053 FakePublisher::default(),
21054 NoVerifiedArtifactStore,
21055 FixedClock(1_700_000_000_000),
21056 )
21057 .with_checkpoint_sink(sink);
21058 provider
21059 .restore_job_resumes(vec![durable_prepared_prepublication_resume()])
21060 .expect("restore publication intent");
21061 poll_ready(provider.cancel(
21062 CancellationRequest {
21063 job_id: "operation-1".to_owned(),
21064 reason: "user-requested".to_owned(),
21065 },
21066 CancellationToken::new(),
21067 ))
21068 .expect("durable cancellation intent");
21069 assert_eq!(
21070 provider
21071 .reconcile_restored_job("operation-1", &CancellationToken::new())
21072 .expect("missing ref reconciliation"),
21073 GithubJobReconciliation::Missing
21074 );
21075 let latest = checkpoints
21076 .lock()
21077 .expect("checkpoints")
21078 .last()
21079 .cloned()
21080 .expect("latest checkpoint");
21081 assert!(latest.publication_uncertain);
21082 assert!(!latest.publication_absent);
21083 assert!(!latest.temporary_ref_deleted);
21084 assert_eq!(latest.state, JobState::Cancelling);
21085
21086 assert!(poll_ready(provider.submit(unsigned_request(), CancellationToken::new())).is_err());
21087 }
21088
21089 #[test]
21090 fn absent_pre_push_ref_does_not_claim_publication_absence() {
21091 let sink = CapturingCheckpointSink::default();
21092 let checkpoints = Arc::clone(&sink.checkpoints);
21093 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21094 provider_config(all_authorized()),
21095 transport(FakeGhRunner::with([
21096 Ok(authenticated_user_row()),
21097 Ok(private_repository_row()),
21098 Ok(authenticated_user_row()),
21099 Ok(private_repository_row()),
21100 Ok(authenticated_user_row()),
21101 Ok(private_repository_row()),
21102 Ok(Vec::new()),
21103 Ok(authenticated_user_row()),
21104 Ok(private_repository_row()),
21105 ])),
21106 FakePublisher::default(),
21107 NoVerifiedArtifactStore,
21108 FixedClock(1_700_000_000_000),
21109 )
21110 .with_checkpoint_sink(sink);
21111 provider
21112 .restore_job_resumes(vec![durable_prepared_prepublication_resume()])
21113 .expect("restore publication intent");
21114 assert_eq!(
21115 provider
21116 .reconcile_restored_job("operation-1", &CancellationToken::new())
21117 .expect("missing ref reconciliation"),
21118 GithubJobReconciliation::Missing
21119 );
21120 let latest = checkpoints
21121 .lock()
21122 .expect("checkpoints")
21123 .last()
21124 .cloned()
21125 .expect("latest checkpoint");
21126 assert_eq!(latest.state, JobState::Created);
21127 assert!(latest.publication_uncertain);
21128 assert!(!latest.publication_absent);
21129 assert!(!latest.temporary_ref_deleted);
21130 }
21131
21132 #[test]
21133 fn missing_pre_push_ref_can_be_reconciled_when_exact_ref_appears_later() {
21134 let sink = CapturingCheckpointSink::default();
21135 let checkpoints = Arc::clone(&sink.checkpoints);
21136 let responses = [
21137 Ok(authenticated_user_row()),
21138 Ok(private_repository_row()),
21139 Ok(authenticated_user_row()),
21140 Ok(private_repository_row()),
21141 Ok(authenticated_user_row()),
21142 Ok(private_repository_row()),
21143 Ok(Vec::new()),
21144 Ok(authenticated_user_row()),
21145 Ok(private_repository_row()),
21146 Ok(authenticated_user_row()),
21147 Ok(private_repository_row()),
21148 Ok(authenticated_user_row()),
21149 Ok(private_repository_row()),
21150 Ok(authenticated_user_row()),
21151 Ok(private_repository_row()),
21152 Ok(Vec::new()),
21153 ];
21154 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21155 provider_config(all_authorized()),
21156 transport(FakeGhRunner::with(responses)),
21157 FakePublisher::default(),
21158 NoVerifiedArtifactStore,
21159 FixedClock(1_700_000_000_000),
21160 )
21161 .with_checkpoint_sink(sink);
21162 provider
21163 .restore_job_resumes(vec![durable_prepared_prepublication_resume()])
21164 .expect("restore publication intent");
21165
21166 assert_eq!(
21167 provider
21168 .reconcile_restored_job("operation-1", &CancellationToken::new())
21169 .expect("initial missing reconciliation"),
21170 GithubJobReconciliation::Missing
21171 );
21172 let missing = checkpoints
21173 .lock()
21174 .expect("checkpoints")
21175 .last()
21176 .cloned()
21177 .expect("missing checkpoint");
21178 assert_eq!(missing.state, JobState::Created);
21179 assert!(missing.publication_uncertain);
21180
21181 provider.publisher.lock().expect("publisher").reconciliation = Some(
21182 TemporaryRefReconciliation::Exact(PublishedTemporaryRef::new(
21183 TemporaryGitRef::new(
21184 provider_config(all_authorized())
21185 .workflow
21186 .temporary_branch_namespace(),
21187 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
21188 )
21189 .expect("temporary ref"),
21190 CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
21191 )),
21192 );
21193 assert_eq!(
21194 provider
21195 .reconcile_restored_job("operation-1", &CancellationToken::new())
21196 .expect("later exact reconciliation"),
21197 GithubJobReconciliation::Recovered
21198 );
21199
21200 let latest = checkpoints
21201 .lock()
21202 .expect("checkpoints")
21203 .last()
21204 .cloned()
21205 .expect("exact checkpoint");
21206 missing
21207 .validate_successor(&latest)
21208 .expect("exact evidence safely resolves publication uncertainty");
21209 assert_eq!(latest.state, JobState::Queued);
21210 assert_eq!(latest.dispatch_commit.as_deref(), Some(DISPATCH_SHA));
21211 assert!(!latest.publication_uncertain);
21212 assert!(
21213 provider
21214 .publisher
21215 .lock()
21216 .expect("publisher")
21217 .published
21218 .is_empty()
21219 );
21220 }
21221
21222 #[test]
21223 fn missing_prepared_ref_recovers_exact_run_history_without_duplicate_push() {
21224 let mut responses = Vec::new();
21225 for _ in 0..6 {
21226 responses.push(Ok(authenticated_user_row()));
21227 responses.push(Ok(private_repository_row()));
21228 }
21229 responses.insert(6, Ok(queued_run_row()));
21230 responses.insert(9, Ok(queued_run_row()));
21231 responses.push(Ok(queued_run_row()));
21232 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21233 provider_config(all_authorized()),
21234 transport(FakeGhRunner::with(responses)),
21235 FakePublisher::default(),
21236 NoVerifiedArtifactStore,
21237 FixedClock(1_700_000_000_000),
21238 );
21239 provider
21240 .restore_job_resumes(vec![durable_prepared_prepublication_resume()])
21241 .expect("restore prepared publication");
21242
21243 assert_eq!(
21244 provider
21245 .reconcile_restored_job("operation-1", &CancellationToken::new())
21246 .expect("run-history recovery"),
21247 GithubJobReconciliation::Recovered
21248 );
21249 let state = provider.lock_state().expect("state");
21250 let record = state.jobs.get("operation-1").expect("job");
21251 assert_eq!(
21252 record.dispatch_commit.as_ref().map(CommitSha::as_str),
21253 Some(DISPATCH_SHA)
21254 );
21255 assert!(record.run.is_some());
21256 assert_eq!(record.state, JobState::Queued);
21257 assert!(!record.publication_uncertain);
21258 assert!(
21259 provider
21260 .publisher
21261 .lock()
21262 .expect("publisher")
21263 .published
21264 .is_empty()
21265 );
21266 }
21267
21268 #[test]
21269 fn missing_prepared_ref_rejects_an_external_rerun_before_mapping() {
21270 let mut responses = Vec::new();
21271 for _ in 0..6 {
21272 responses.push(Ok(authenticated_user_row()));
21273 responses.push(Ok(private_repository_row()));
21274 }
21275 responses.insert(6, Ok(queued_run_attempt_row(2)));
21276 responses.insert(9, Ok(queued_run_attempt_row(2)));
21277 responses.push(Ok(queued_run_attempt_row(2)));
21278 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21279 provider_config(all_authorized()),
21280 transport(FakeGhRunner::with(responses)),
21281 FakePublisher::default(),
21282 NoVerifiedArtifactStore,
21283 FixedClock(1_700_000_000_000),
21284 );
21285 provider
21286 .restore_job_resumes(vec![durable_prepared_prepublication_resume()])
21287 .expect("restore prepared publication");
21288
21289 assert!(
21290 provider
21291 .reconcile_restored_job("operation-1", &CancellationToken::new())
21292 .is_err()
21293 );
21294 let state = provider.lock_state().expect("state");
21295 let record = state.jobs.get("operation-1").expect("job");
21296 assert!(record.dispatch_commit.is_none());
21297 assert!(record.run.is_none());
21298 assert!(record.publication_uncertain);
21299 assert!(
21300 provider
21301 .publisher
21302 .lock()
21303 .expect("publisher")
21304 .published
21305 .is_empty()
21306 );
21307 }
21308
21309 #[test]
21310 fn restored_unarmed_publication_is_retired_without_remote_reads_and_releases_capacity() {
21311 let base_config = provider_config(all_authorized());
21312 let principal = GithubPrincipalIdentityV1::User {
21313 id: AUTHENTICATED_USER_ID,
21314 login: "ShiroKSH".to_owned(),
21315 };
21316 let mut unarmed = durable_prepared_prepublication_resume();
21317 unarmed.publication_started_at_ms = 0;
21318 unarmed.publication_quiescence_deadline_ms = u64::MAX;
21319 let record = unarmed
21320 .into_record(&base_config, &principal, 991)
21321 .expect("unarmed prepared record");
21322 let mut config = base_config;
21323 config.max_jobs = 1;
21324 let resume = GithubJobResumeV1::from_record(&config, principal, 991, &record)
21325 .expect("capacity-bound unarmed resume");
21326 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21327 config,
21328 transport(FakeGhRunner::with([
21329 Ok(authenticated_user_row()),
21330 Ok(private_repository_row()),
21331 ])),
21332 FakePublisher {
21333 reconciliation: Some(TemporaryRefReconciliation::Conflict),
21334 ..FakePublisher::default()
21335 },
21336 NoVerifiedArtifactStore,
21337 FixedClock(1_700_000_000_000),
21338 );
21339 provider
21340 .restore_job_resumes(vec![resume])
21341 .expect("restore unarmed publication");
21342
21343 assert_eq!(
21344 provider
21345 .reconcile_restored_job("operation-1", &CancellationToken::new())
21346 .expect("retire unarmed intent"),
21347 GithubJobReconciliation::NotStarted
21348 );
21349 {
21350 let state = provider.lock_state().expect("state");
21351 let retired = state.jobs.get("operation-1").expect("retired job");
21352 assert_eq!(retired.state, JobState::Failed);
21353 assert!(retired.publication_not_attempted);
21354 assert!(record_is_recyclable(retired));
21355 }
21356
21357 let mut replacement = unsigned_request();
21358 replacement.operation_id = "operation-2".to_owned();
21359 let handle = poll_ready(provider.submit(replacement, CancellationToken::new()))
21360 .expect("unarmed intent releases bounded capacity");
21361 assert_eq!(handle.job_id, "operation-2");
21362 assert!(matches!(
21363 provider.publisher.lock().expect("publisher").reconciliation,
21364 Some(TemporaryRefReconciliation::Conflict)
21365 ));
21366 }
21367
21368 #[test]
21369 fn bounded_missing_prepared_publication_becomes_recyclable_without_guessing_once() {
21370 let base_config = provider_config(all_authorized());
21371 let principal = GithubPrincipalIdentityV1::User {
21372 id: AUTHENTICATED_USER_ID,
21373 login: "ShiroKSH".to_owned(),
21374 };
21375 let record = durable_prepared_prepublication_resume()
21376 .into_record(&base_config, &principal, 991)
21377 .expect("prepared record");
21378 let mut config = base_config;
21379 config.max_jobs = 1;
21380 let resume = GithubJobResumeV1::from_record(&config, principal.clone(), 991, &record)
21381 .expect("capacity-bound resume");
21382 let mut previous = resume.clone();
21383 let mut responses = vec![Ok(authenticated_user_row()), Ok(private_repository_row())];
21384 for _ in 0..3 {
21385 responses.push(Ok(authenticated_user_row()));
21386 responses.push(Ok(private_repository_row()));
21387 responses.push(Ok(authenticated_user_row()));
21388 responses.push(Ok(private_repository_row()));
21389 responses.push(Ok(Vec::new()));
21390 responses.push(Ok(authenticated_user_row()));
21391 responses.push(Ok(private_repository_row()));
21392 }
21393 let clock = MutableClock::new(1_700_000_000_000);
21394 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21395 config,
21396 transport(FakeGhRunner::with(responses)),
21397 FakePublisher::default(),
21398 NoVerifiedArtifactStore,
21399 clock.clone(),
21400 );
21401 provider
21402 .restore_job_resumes(vec![resume])
21403 .expect("restore prepared publication");
21404
21405 for attempt in 1..=3 {
21406 if attempt == 3 {
21407 clock.set(1_700_000_000_000 + GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS);
21408 }
21409 assert_eq!(
21410 provider
21411 .reconcile_restored_job("operation-1", &CancellationToken::new())
21412 .expect("bounded missing reconciliation"),
21413 GithubJobReconciliation::Missing
21414 );
21415 let state = provider.lock_state().expect("state");
21416 let record = state.jobs.get("operation-1").expect("job");
21417 if attempt < 3 {
21418 assert_eq!(record.state, JobState::Created);
21419 assert!(!record.publication_absent);
21420 assert_eq!(
21421 record.publication_absence_observations,
21422 u8::try_from(attempt).unwrap_or(2)
21423 );
21424 } else {
21425 assert_eq!(record.state, JobState::Failed);
21426 assert!(record.publication_absent);
21427 assert!(record.temporary_ref_deleted);
21428 }
21429 let next =
21430 GithubJobResumeV1::from_record(&provider.config, principal.clone(), 991, record)
21431 .expect("reconciliation checkpoint");
21432 previous
21433 .validate_successor(&next)
21434 .expect("bounded reconciliation advances monotonically");
21435 previous = next;
21436 }
21437
21438 let mut replacement = unsigned_request();
21439 replacement.operation_id = "operation-2".to_owned();
21440 let handle = poll_ready(provider.submit(replacement, CancellationToken::new()))
21441 .expect("strongly absent terminal record is recyclable");
21442 assert_eq!(handle.job_id, "operation-2");
21443 }
21444
21445 #[test]
21446 fn first_missing_observation_restarts_quiescence_after_a_delayed_push_checkpoint() {
21447 let mut responses = vec![Ok(authenticated_user_row()), Ok(private_repository_row())];
21448 for _ in 0..3 {
21449 responses.push(Ok(authenticated_user_row()));
21450 responses.push(Ok(private_repository_row()));
21451 responses.push(Ok(authenticated_user_row()));
21452 responses.push(Ok(private_repository_row()));
21453 responses.push(Ok(Vec::new()));
21454 responses.push(Ok(authenticated_user_row()));
21455 responses.push(Ok(private_repository_row()));
21456 }
21457 let delayed_now = 1_700_000_000_000 + 10 * GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS;
21458 let clock = MutableClock::new(delayed_now);
21459 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21460 provider_config(all_authorized()),
21461 transport(FakeGhRunner::with(responses)),
21462 FakePublisher::default(),
21463 NoVerifiedArtifactStore,
21464 clock.clone(),
21465 );
21466 provider
21467 .restore_job_resumes(vec![durable_prepared_prepublication_resume()])
21468 .expect("restore aged publication boundary");
21469
21470 for _ in 0..2 {
21471 assert_eq!(
21472 provider
21473 .reconcile_restored_job("operation-1", &CancellationToken::new())
21474 .expect("immediate missing observation"),
21475 GithubJobReconciliation::Missing
21476 );
21477 }
21478 {
21479 let state = provider.lock_state().expect("state");
21480 let record = state.jobs.get("operation-1").expect("job");
21481 assert_eq!(record.state, JobState::Created);
21482 assert_eq!(record.publication_absence_first_observed_at_ms, delayed_now);
21483 assert_eq!(
21484 record.publication_quiescence_deadline_ms,
21485 delayed_now + GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS
21486 );
21487 assert_eq!(record.publication_absence_observations, 2);
21488 assert!(!record.publication_absent);
21489 }
21490
21491 clock.set(delayed_now + GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS);
21492 assert_eq!(
21493 provider
21494 .reconcile_restored_job("operation-1", &CancellationToken::new())
21495 .expect("quiesced missing observation"),
21496 GithubJobReconciliation::Missing
21497 );
21498 let state = provider.lock_state().expect("state");
21499 let record = state.jobs.get("operation-1").expect("job");
21500 assert_eq!(record.state, JobState::Failed);
21501 assert!(record.publication_absent);
21502 }
21503
21504 #[test]
21505 fn unfenced_publication_never_uses_time_as_strong_absence_evidence() {
21506 let mut responses = vec![Ok(authenticated_user_row()), Ok(private_repository_row())];
21507 for _ in 0..2 {
21508 responses.push(Ok(authenticated_user_row()));
21509 responses.push(Ok(private_repository_row()));
21510 responses.push(Ok(authenticated_user_row()));
21511 responses.push(Ok(private_repository_row()));
21512 responses.push(Ok(Vec::new()));
21513 responses.push(Ok(authenticated_user_row()));
21514 responses.push(Ok(private_repository_row()));
21515 }
21516 let delayed_now = 1_700_000_000_000 + 10 * GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS;
21517 let clock = MutableClock::new(delayed_now);
21518 let provider = GithubBuildProvider::with_artifact_store_and_clock(
21519 provider_config(all_authorized()),
21520 transport(FakeGhRunner::with(responses)),
21521 FakePublisher::default(),
21522 NoVerifiedArtifactStore,
21523 clock.clone(),
21524 );
21525 let mut resume = durable_prepared_prepublication_resume();
21526 resume.publication_process_fenced = false;
21527 provider
21528 .restore_job_resumes(vec![resume])
21529 .expect("restore unfenced publication");
21530
21531 assert_eq!(
21532 provider
21533 .reconcile_restored_job("operation-1", &CancellationToken::new())
21534 .expect("first missing observation"),
21535 GithubJobReconciliation::Missing
21536 );
21537 clock.set(delayed_now + GITHUB_PUBLICATION_QUIESCENCE_WINDOW_MS);
21538 assert_eq!(
21539 provider
21540 .reconcile_restored_job("operation-1", &CancellationToken::new())
21541 .expect("post-quiescence missing observation"),
21542 GithubJobReconciliation::Missing
21543 );
21544 let state = provider.lock_state().expect("state");
21545 let record = state.jobs.get("operation-1").expect("job");
21546 assert_eq!(record.publication_absence_observations, 2);
21547 assert_eq!(record.state, JobState::Created);
21548 assert!(!record.publication_absent);
21549 assert!(!record_is_recyclable(record));
21550 }
21551
21552 #[test]
21553 #[allow(
21554 clippy::too_many_lines,
21555 reason = "one regression binds takeover lease, crash residue, recomputation, and no-fetch proof"
21556 )]
21557 fn git_reconciliation_recomputes_the_prepared_commit_without_fetching_remote_objects() {
21558 let directory = tempdir().expect("directory");
21559 let stale_lock = directory.path().join("operation-1.lock");
21560 let stale_index = directory.path().join("operation-1.index");
21561 let stale_attempt = directory.path().join(".rustferry-index-crash-residue");
21562 fs::write(&stale_lock, b"crash-owner").expect("stale legacy reservation lock");
21563 fs::write(&stale_index, b"partial-index").expect("stale legacy index");
21564 fs::create_dir(&stale_attempt).expect("stale attempt directory");
21565 fs::write(stale_attempt.join("index"), b"partial-index").expect("stale attempt index");
21566 fs::write(stale_attempt.join("index.lock"), b"crash-owner").expect("stale attempt lock");
21567 let workflow_path = ".github/workflows/rustferry-goal3-iphone.yml";
21568 let workflow = b"name: exact\n";
21569 let manifest = vec![b'm'; MAX_GIT_OUTPUT_BYTES + 1];
21570 let runner = FakeGitRunner::with(exact_reconciliation_git_responses(
21571 workflow_path,
21572 workflow,
21573 &manifest,
21574 DISPATCH_SHA,
21575 ));
21576 let mut publisher = GitTemporaryRefPublisher::new(
21577 runner,
21578 directory.path(),
21579 "origin",
21580 "signing",
21581 Duration::from_secs(30),
21582 )
21583 .expect("publisher");
21584 let namespace =
21585 TemporaryBranchNamespace::new("rustferry/goal3/builds").expect("temporary namespace");
21586 let temporary_ref = TemporaryGitRef::new(
21587 &namespace,
21588 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
21589 )
21590 .expect("temporary ref");
21591 let config = provider_config(all_authorized());
21592 let source_revision = CommitSha::new(SOURCE_SHA).expect("source revision");
21593 let expected_prepared_commit = CommitSha::new(DISPATCH_SHA).expect("prepared commit");
21594 let workflow_fingerprint = WorkflowFingerprint::for_workflow_bytes(workflow);
21595 assert_eq!(
21596 publisher.acquire_publication_lease(&TemporaryRefPublicationLeaseRequest {
21597 repository: &config.repository,
21598 operation_id: "operation-1",
21599 temporary_ref: &temporary_ref,
21600 }),
21601 Ok(TemporaryRefPublicationLease::Acquired)
21602 );
21603 let result = publisher
21604 .reconcile_temporary_ref(&TemporaryRefReconcileRequest {
21605 repository: &config.repository,
21606 source_repository: &config.source_repository,
21607 trusted_source_ref: config.workflow.trusted_source_ref(),
21608 source_revision: &source_revision,
21609 snapshot_source_ref: None,
21610 temporary_ref: &temporary_ref,
21611 workflow_path,
21612 workflow_bytes: workflow,
21613 workflow_fingerprint: &workflow_fingerprint,
21614 manifest_bytes: &manifest,
21615 operation_id: "operation-1",
21616 created_at_ms: 1_700_000_000_000,
21617 expected_prepared_commit: &expected_prepared_commit,
21618 })
21619 .expect("exact reconciliation");
21620 assert!(matches!(
21621 result,
21622 TemporaryRefReconciliation::Exact(publication)
21623 if publication.commit().as_str() == DISPATCH_SHA
21624 && publication.temporary_ref() == &temporary_ref
21625 ));
21626 assert_eq!(
21627 fs::read(&stale_lock).expect("legacy lock retained"),
21628 b"crash-owner"
21629 );
21630 assert_eq!(
21631 fs::read(&stale_index).expect("legacy index retained"),
21632 b"partial-index"
21633 );
21634 assert_eq!(
21635 fs::read(stale_attempt.join("index")).expect("stale attempt retained"),
21636 b"partial-index"
21637 );
21638 let runner = publisher.into_runner();
21639 assert!(!runner.invocations.iter().any(|invocation| {
21640 invocation.arguments.first() == Some(&OsString::from("fetch"))
21641 && invocation
21642 .arguments
21643 .iter()
21644 .any(|argument| argument == "refs/heads/rustferry/goal3/builds/operation-1")
21645 }));
21646 assert!(
21647 runner
21648 .invocations
21649 .iter()
21650 .filter(|invocation| matches!(
21651 invocation
21652 .arguments
21653 .first()
21654 .and_then(|argument| argument.to_str()),
21655 Some("show" | "cat-file" | "merge-base")
21656 ))
21657 .all(|invocation| invocation.environment.is_empty())
21658 );
21659 }
21660
21661 #[test]
21662 fn git_reconciliation_rejects_exact_ref_when_source_is_not_trusted() {
21663 let directory = tempdir().expect("directory");
21664 let workflow_path = ".github/workflows/rustferry-goal3-iphone.yml";
21665 let workflow = b"name: exact\n";
21666 let manifest = b"{}\n";
21667 let mut responses =
21668 exact_reconciliation_git_responses(workflow_path, workflow, manifest, DISPATCH_SHA);
21669 responses[9] = Err(GitExecutionError::CommandFailed { exit_code: Some(1) });
21670 let runner = FakeGitRunner::with(responses);
21671 let mut publisher = GitTemporaryRefPublisher::new(
21672 runner,
21673 directory.path(),
21674 "origin",
21675 "signing",
21676 Duration::from_secs(30),
21677 )
21678 .expect("publisher");
21679 let config = provider_config(all_authorized());
21680 let temporary_ref = TemporaryGitRef::new(
21681 config.workflow.temporary_branch_namespace(),
21682 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
21683 )
21684 .expect("temporary ref");
21685 let source_revision = CommitSha::new(SOURCE_SHA).expect("source revision");
21686 let expected_prepared_commit = CommitSha::new(DISPATCH_SHA).expect("prepared commit");
21687 let workflow_fingerprint = WorkflowFingerprint::for_workflow_bytes(workflow);
21688
21689 assert_eq!(
21690 publisher.reconcile_temporary_ref(&TemporaryRefReconcileRequest {
21691 repository: &config.repository,
21692 source_repository: &config.source_repository,
21693 trusted_source_ref: config.workflow.trusted_source_ref(),
21694 source_revision: &source_revision,
21695 snapshot_source_ref: None,
21696 temporary_ref: &temporary_ref,
21697 workflow_path,
21698 workflow_bytes: workflow,
21699 workflow_fingerprint: &workflow_fingerprint,
21700 manifest_bytes: manifest,
21701 operation_id: "operation-1",
21702 created_at_ms: 1_700_000_000_000,
21703 expected_prepared_commit: &expected_prepared_commit,
21704 }),
21705 Err(TemporaryRefPublishError::SourceRevisionNotTrusted)
21706 );
21707 let runner = publisher.into_runner();
21708 assert!(!runner.invocations.iter().any(|invocation| {
21709 invocation.arguments.first() == Some(&OsString::from("ls-remote"))
21710 && invocation
21711 .arguments
21712 .iter()
21713 .any(|argument| argument == "refs/heads/rustferry/goal3/builds/operation-1")
21714 }));
21715 }
21716
21717 #[test]
21718 fn git_reconciliation_rejects_a_ref_that_changes_during_content_verification() {
21719 let directory = tempdir().expect("directory");
21720 let workflow_path = ".github/workflows/rustferry-goal3-iphone.yml";
21721 let workflow = b"name: exact\n";
21722 let manifest = b"{}\n";
21723 let runner = FakeGitRunner::with(exact_reconciliation_git_responses(
21724 workflow_path,
21725 workflow,
21726 manifest,
21727 SOURCE_SHA,
21728 ));
21729 let mut publisher = GitTemporaryRefPublisher::new(
21730 runner,
21731 directory.path(),
21732 "origin",
21733 "signing",
21734 Duration::from_secs(30),
21735 )
21736 .expect("publisher");
21737 let namespace =
21738 TemporaryBranchNamespace::new("rustferry/goal3/builds").expect("temporary namespace");
21739 let temporary_ref = TemporaryGitRef::new(
21740 &namespace,
21741 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
21742 )
21743 .expect("temporary ref");
21744 let config = provider_config(all_authorized());
21745 let source_revision = CommitSha::new(SOURCE_SHA).expect("source revision");
21746 let expected_prepared_commit = CommitSha::new(DISPATCH_SHA).expect("prepared commit");
21747 let workflow_fingerprint = WorkflowFingerprint::for_workflow_bytes(workflow);
21748 assert_eq!(
21749 publisher
21750 .reconcile_temporary_ref(&TemporaryRefReconcileRequest {
21751 repository: &config.repository,
21752 source_repository: &config.source_repository,
21753 trusted_source_ref: config.workflow.trusted_source_ref(),
21754 source_revision: &source_revision,
21755 snapshot_source_ref: None,
21756 temporary_ref: &temporary_ref,
21757 workflow_path,
21758 workflow_bytes: workflow,
21759 workflow_fingerprint: &workflow_fingerprint,
21760 manifest_bytes: manifest,
21761 operation_id: "operation-1",
21762 created_at_ms: 1_700_000_000_000,
21763 expected_prepared_commit: &expected_prepared_commit,
21764 })
21765 .expect("bounded reconciliation"),
21766 TemporaryRefReconciliation::Conflict
21767 );
21768 }
21769
21770 #[test]
21771 fn git_publication_lease_is_cross_instance_and_key_scoped() {
21772 let root = tempdir().expect("root");
21773 let isolation = root.path().join("isolation");
21774 create_test_private_directory(&isolation).expect("isolation directory");
21775 let mut first = GitTemporaryRefPublisher::new(
21776 FakeGitRunner::default(),
21777 &isolation,
21778 "origin",
21779 "signing",
21780 Duration::from_secs(30),
21781 )
21782 .expect("first publisher");
21783 let mut second = GitTemporaryRefPublisher::new(
21784 FakeGitRunner::default(),
21785 &isolation,
21786 "origin",
21787 "signing",
21788 Duration::from_secs(30),
21789 )
21790 .expect("second publisher");
21791 assert_eq!(
21792 first.publication_lease_scope_sha256(),
21793 second.publication_lease_scope_sha256()
21794 );
21795 let config = provider_config(all_authorized());
21796 let temporary_ref = TemporaryGitRef::new(
21797 config.workflow.temporary_branch_namespace(),
21798 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
21799 )
21800 .expect("temporary ref");
21801 let request = TemporaryRefPublicationLeaseRequest {
21802 repository: &config.repository,
21803 operation_id: "operation-1",
21804 temporary_ref: &temporary_ref,
21805 };
21806 assert_eq!(
21807 first.acquire_publication_lease(&request),
21808 Ok(TemporaryRefPublicationLease::Acquired)
21809 );
21810 assert_eq!(
21811 second.acquire_publication_lease(&request),
21812 Ok(TemporaryRefPublicationLease::HeldByOther)
21813 );
21814
21815 let independent_ref = TemporaryGitRef::new(
21816 config.workflow.temporary_branch_namespace(),
21817 BranchName::new("rustferry/goal3/builds/operation-2").expect("branch"),
21818 )
21819 .expect("temporary ref");
21820 assert_eq!(
21821 second.acquire_publication_lease(&TemporaryRefPublicationLeaseRequest {
21822 repository: &config.repository,
21823 operation_id: "operation-2",
21824 temporary_ref: &independent_ref,
21825 }),
21826 Ok(TemporaryRefPublicationLease::Acquired)
21827 );
21828 drop(first);
21829 assert_eq!(
21830 second.acquire_publication_lease(&request),
21831 Ok(TemporaryRefPublicationLease::Acquired)
21832 );
21833 }
21834
21835 #[test]
21836 fn git_snapshot_source_lease_is_cross_instance_and_exact_ref_scoped() {
21837 let root = tempdir().expect("root");
21838 let isolation = root.path().join("isolation");
21839 create_test_private_directory(&isolation).expect("isolation directory");
21840 let mut first = GitTemporaryRefPublisher::new(
21841 FakeGitRunner::default(),
21842 &isolation,
21843 "origin",
21844 "signing",
21845 Duration::from_secs(30),
21846 )
21847 .expect("first publisher");
21848 let mut second = GitTemporaryRefPublisher::new(
21849 FakeGitRunner::default(),
21850 &isolation,
21851 "origin",
21852 "signing",
21853 Duration::from_secs(30),
21854 )
21855 .expect("second publisher");
21856 let config = provider_config(all_authorized());
21857 let source_ref =
21858 GitSnapshotSourceRef::for_operation("operation-1").expect("snapshot source ref");
21859 let request = GitSnapshotSourcePublicationLeaseRequest {
21860 source_repository: &config.source_repository,
21861 source_ref: &source_ref,
21862 };
21863 assert_eq!(
21864 first.acquire_git_snapshot_source_lease(&request),
21865 Ok(TemporaryRefPublicationLease::Acquired)
21866 );
21867 assert_eq!(
21868 first.acquire_git_snapshot_source_lease(&request),
21869 Ok(TemporaryRefPublicationLease::AlreadyOwned)
21870 );
21871 assert_eq!(
21872 second.acquire_git_snapshot_source_lease(&request),
21873 Ok(TemporaryRefPublicationLease::HeldByOther)
21874 );
21875
21876 let other_source_ref = GitSnapshotSourceRef::for_operation("operation-2")
21877 .expect("independent snapshot source ref");
21878 assert_eq!(
21879 second.acquire_git_snapshot_source_lease(&GitSnapshotSourcePublicationLeaseRequest {
21880 source_repository: &config.source_repository,
21881 source_ref: &other_source_ref,
21882 },),
21883 Ok(TemporaryRefPublicationLease::Acquired)
21884 );
21885 assert_eq!(
21886 second.acquire_git_snapshot_source_lease(&GitSnapshotSourcePublicationLeaseRequest {
21887 source_repository: "https://github.com/shiroksh/other-public-source",
21888 source_ref: &source_ref,
21889 },),
21890 Ok(TemporaryRefPublicationLease::Acquired)
21891 );
21892 first.release_git_snapshot_source_lease(&request);
21893 assert_eq!(
21894 second.acquire_git_snapshot_source_lease(&request),
21895 Ok(TemporaryRefPublicationLease::Acquired)
21896 );
21897 }
21898
21899 #[test]
21900 fn git_publication_lease_scope_rejects_same_path_directory_replacement() {
21901 let root = tempdir().expect("root");
21902 let isolation = root.path().join("isolation");
21903 let moved = root.path().join("isolation-old");
21904 create_test_private_directory(&isolation).expect("isolation directory");
21905 let mut original = GitTemporaryRefPublisher::new(
21906 FakeGitRunner::default(),
21907 &isolation,
21908 "origin",
21909 "signing",
21910 Duration::from_secs(30),
21911 )
21912 .expect("original publisher");
21913 let original_scope = original
21914 .publication_lease_scope_sha256()
21915 .expect("scope")
21916 .to_owned();
21917 let config = provider_config(all_authorized());
21918 let temporary_ref = TemporaryGitRef::new(
21919 config.workflow.temporary_branch_namespace(),
21920 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
21921 )
21922 .expect("temporary ref");
21923
21924 #[cfg(windows)]
21925 {
21926 assert!(
21927 fs::rename(&isolation, &moved).is_err(),
21928 "retained directory handle must deny replacement before lease acquisition"
21929 );
21930 assert_eq!(
21931 original.publication_lease_scope_sha256(),
21932 Some(original_scope.as_str())
21933 );
21934 assert_eq!(
21935 original.acquire_publication_lease(&TemporaryRefPublicationLeaseRequest {
21936 repository: &config.repository,
21937 operation_id: "operation-1",
21938 temporary_ref: &temporary_ref,
21939 }),
21940 Ok(TemporaryRefPublicationLease::Acquired)
21941 );
21942 }
21943
21944 #[cfg(not(windows))]
21945 {
21946 fs::rename(&isolation, &moved).expect("move original isolation directory");
21947 create_test_private_directory(&isolation).expect("replacement isolation directory");
21948 let replacement = GitTemporaryRefPublisher::new(
21949 FakeGitRunner::default(),
21950 &isolation,
21951 "origin",
21952 "signing",
21953 Duration::from_secs(30),
21954 )
21955 .expect("replacement publisher");
21956 assert_ne!(
21957 replacement.publication_lease_scope_sha256(),
21958 Some(original_scope.as_str())
21959 );
21960 assert_eq!(
21961 original.acquire_publication_lease(&TemporaryRefPublicationLeaseRequest {
21962 repository: &config.repository,
21963 operation_id: "operation-1",
21964 temporary_ref: &temporary_ref,
21965 }),
21966 Err(TemporaryRefPublishError::LocalIsolationFailed)
21967 );
21968 }
21969 }
21970
21971 #[cfg(windows)]
21972 #[test]
21973 fn production_runner_writes_only_its_private_bare_object_database() {
21974 let git = PathBuf::from(r"C:\Program Files\Git\cmd\git.exe");
21975 if !git.is_file() {
21976 return;
21977 }
21978 let temporary = tempfile::tempdir().expect("fixture");
21979 let checkout = temporary.path().join("checkout");
21980 fs::create_dir(&checkout).expect("checkout directory");
21981 assert!(
21982 Command::new(&git)
21983 .args(["init", "--quiet"])
21984 .current_dir(&checkout)
21985 .status()
21986 .expect("git init")
21987 .success()
21988 );
21989 let caller_config = fs::read(checkout.join(".git/config")).expect("caller config");
21990 let caller_git = test_tree_snapshot(&checkout.join(".git"));
21991 let isolation = temporary.path().join("isolation");
21992 create_test_private_directory(&isolation).expect("private isolation");
21993 let mut runner = GitProcessRunner::new(&git, &isolation).expect("isolated runner");
21994
21995 let object = runner
21996 .execute(
21997 &GitInvocation::new(["hash-object", "-w", "--stdin"], Duration::from_secs(5))
21998 .with_stdin(b"private object\n"),
21999 )
22000 .expect("private object write");
22001
22002 let object = parse_object_id(&object).expect("object ID");
22003 assert!(
22004 runner
22005 .repository
22006 .paths()
22007 .bare()
22008 .join("objects")
22009 .join(&object[..2])
22010 .join(&object[2..])
22011 .is_file()
22012 );
22013 assert_eq!(
22014 fs::read(checkout.join(".git/config")).expect("caller config after write"),
22015 caller_config
22016 );
22017 assert_eq!(test_tree_snapshot(&checkout.join(".git")), caller_git);
22018 }
22019
22020 #[test]
22021 fn git_executable_basename_is_platform_exact() {
22022 #[cfg(windows)]
22023 assert!(executable_basename_matches(
22024 Path::new(r"C:\Git\git.exe"),
22025 "git"
22026 ));
22027 #[cfg(not(windows))]
22028 assert!(executable_basename_matches(
22029 Path::new("/usr/bin/git"),
22030 "git"
22031 ));
22032 assert!(!executable_basename_matches(
22033 Path::new("/usr/bin/not-git"),
22034 "git"
22035 ));
22036 }
22037
22038 #[test]
22039 fn github_remote_identity_accepts_equivalent_https_and_ssh_transports() {
22040 let expected = "https://github.com/ShiroKSH/rust-and-iphone";
22041 for actual in [
22042 expected,
22043 "https://github.com/ShiroKSH/rust-and-iphone.git",
22044 "git@github.com:ShiroKSH/rust-and-iphone.git",
22045 "ssh://git@github.com/ShiroKSH/rust-and-iphone",
22046 ] {
22047 assert!(github_remote_matches(actual, expected), "{actual}");
22048 }
22049 for actual in [
22050 "https://github.com/attacker/rust-and-iphone",
22051 "git@github.com:attacker/rust-and-iphone.git",
22052 "ssh://git@github.example/ShiroKSH/rust-and-iphone",
22053 "https://github.com/ShiroKSH/rust-and-iphone/extra",
22054 ] {
22055 assert!(!github_remote_matches(actual, expected), "{actual}");
22056 }
22057 }
22058
22059 #[test]
22060 fn config_accepts_distinct_source_and_rejects_invalid_source_digest_and_worker_version() {
22061 let workflow = workflow_config();
22062 let fingerprint = WorkflowFingerprint::for_workflow(&generate_workflow(&workflow));
22063 let repository = Repository::new("ShiroKSH", "rustferry-signing").expect("repository");
22064 let split = GithubProviderConfig::new(
22065 repository.clone(),
22066 "https://github.com/shiroksh/rust-and-iphone",
22067 workflow.clone(),
22068 fingerprint.clone(),
22069 GithubPollingPolicy::secure_defaults(),
22070 all_authorized(),
22071 &Version::new(0, 1, 0),
22072 8,
22073 )
22074 .expect("distinct source and execution repositories");
22075 assert_eq!(split.repository(), &repository);
22076 assert_eq!(
22077 split.source_repository(),
22078 "https://github.com/shiroksh/rust-and-iphone"
22079 );
22080 assert_eq!(
22081 GithubProviderConfig::new(
22082 repository.clone(),
22083 "https://github.com/ShiroKSH/rust-and-iphone",
22084 workflow.clone(),
22085 fingerprint.clone(),
22086 GithubPollingPolicy::secure_defaults(),
22087 all_authorized(),
22088 &Version::new(0, 1, 0),
22089 8,
22090 ),
22091 Err(GithubProviderConfigError::InvalidSourceRepository)
22092 );
22093 assert_eq!(
22094 GithubProviderConfig::new(
22095 repository.clone(),
22096 "https://github.com/shiroksh/rust-and-iphone",
22097 workflow.clone(),
22098 WorkflowFingerprint::new("f".repeat(64)).expect("fingerprint"),
22099 GithubPollingPolicy::secure_defaults(),
22100 all_authorized(),
22101 &Version::new(0, 1, 0),
22102 8,
22103 ),
22104 Err(GithubProviderConfigError::WorkflowFingerprintMismatch)
22105 );
22106 assert_eq!(
22107 GithubProviderConfig::new(
22108 repository,
22109 "https://github.com/shiroksh/rust-and-iphone",
22110 workflow.clone(),
22111 WorkflowFingerprint::for_workflow(&generate_workflow(&workflow)),
22112 GithubPollingPolicy::secure_defaults(),
22113 all_authorized(),
22114 &Version::new(0, 2, 0),
22115 8,
22116 ),
22117 Err(GithubProviderConfigError::WorkerVersionMismatch)
22118 );
22119 }
22120
22121 #[test]
22122 fn provider_identity_binds_persisted_git_transports() {
22123 let base = same_repository_provider_config(all_authorized());
22124 let base_sha = provider_config_sha256(&base).expect("base identity");
22125 let https = GithubRemoteSnapshot::new(
22126 GithubGitEndpoint::parse("https://github.com/shiroksh/rust-and-iphone")
22127 .expect("HTTPS fetch"),
22128 GithubGitEndpoint::parse("https://github.com/shiroksh/rust-and-iphone")
22129 .expect("HTTPS push"),
22130 )
22131 .expect("HTTPS snapshot");
22132 let mixed = GithubRemoteSnapshot::new(
22133 GithubGitEndpoint::parse("https://github.com/shiroksh/rust-and-iphone")
22134 .expect("HTTPS fetch"),
22135 GithubGitEndpoint::parse("git@github.com:shiroksh/rust-and-iphone").expect("SSH push"),
22136 )
22137 .expect("mixed snapshot");
22138 #[cfg(unix)]
22139 let ssh = GithubRemoteSnapshot::new(
22140 GithubGitEndpoint::parse("git@github.com:shiroksh/rust-and-iphone").expect("SSH fetch"),
22141 GithubGitEndpoint::parse("git@github.com:shiroksh/rust-and-iphone").expect("SSH push"),
22142 )
22143 .expect("SSH snapshot");
22144 #[cfg(windows)]
22145 let https_sha = provider_config_sha256(
22146 &base
22147 .clone()
22148 .bind_git_endpoints(https.clone(), https.clone())
22149 .expect("HTTPS binding"),
22150 )
22151 .expect("HTTPS identity");
22152 #[cfg(unix)]
22153 assert_eq!(
22154 base.clone()
22155 .bind_git_endpoints(https.clone(), https.clone()),
22156 Err(GithubProviderConfigError::UnsupportedExecutionTransport)
22157 );
22158 #[cfg(unix)]
22159 assert_eq!(
22160 base.clone()
22161 .bind_git_endpoints(mixed.clone(), mixed.clone()),
22162 Err(GithubProviderConfigError::UnsupportedExecutionTransport)
22163 );
22164 #[cfg(windows)]
22165 let mixed_sha = provider_config_sha256(
22166 &base
22167 .bind_git_endpoints(mixed.clone(), mixed)
22168 .expect("mixed binding"),
22169 )
22170 .expect("mixed identity");
22171 #[cfg(unix)]
22172 let ssh_sha = provider_config_sha256(
22173 &base
22174 .bind_git_endpoints(ssh.clone(), ssh)
22175 .expect("SSH binding"),
22176 )
22177 .expect("SSH identity");
22178 #[cfg(windows)]
22179 assert_ne!(base_sha, https_sha);
22180 #[cfg(windows)]
22181 assert_ne!(https_sha, mixed_sha);
22182 #[cfg(unix)]
22183 assert_ne!(base_sha, ssh_sha);
22184 }
22185
22186 #[test]
22187 fn split_repository_artifact_context_keeps_execution_and_source_identities_separate() {
22188 let workflow = workflow_config();
22189 let fingerprint = WorkflowFingerprint::for_workflow(&generate_workflow(&workflow));
22190 let config = GithubProviderConfig::new(
22191 Repository::new("ShiroKSH", "rustferry-signing").expect("execution repository"),
22192 "https://github.com/shiroksh/rust-and-iphone",
22193 workflow,
22194 fingerprint,
22195 GithubPollingPolicy::new(3, Duration::from_millis(250)).expect("poll policy"),
22196 all_authorized(),
22197 &Version::new(0, 1, 0),
22198 8,
22199 )
22200 .expect("split repository config");
22201 let runner = FakeGhRunner::with([
22202 Ok(completed_run_row("success")),
22203 Ok(completed_run_row("success")),
22204 ]);
22205 let requests = Arc::clone(&runner.requests);
22206 let artifacts = CapturingArtifactStore::default();
22207 let contexts = Arc::clone(&artifacts.contexts);
22208 let provider = GithubBuildProvider::with_artifact_store_and_clock(
22209 config,
22210 transport(runner),
22211 FakePublisher::default(),
22212 artifacts,
22213 FixedClock(1_700_000_000_000),
22214 );
22215
22216 poll_ready(provider.submit(unsigned_request(), CancellationToken::new())).expect("submit");
22217 poll_ready(provider.events(
22218 EventRequest {
22219 job_id: "operation-1".to_owned(),
22220 after_sequence: None,
22221 limit: 100,
22222 },
22223 CancellationToken::new(),
22224 ))
22225 .expect("events");
22226
22227 let captured = contexts.lock().expect("contexts");
22228 let context = captured.first().expect("artifact context");
22229 assert_eq!(context.repository.owner(), "ShiroKSH");
22230 assert_eq!(context.repository.name(), "rustferry-signing");
22231 assert_eq!(context.execution_repository_id, None);
22232 assert_eq!(
22233 context.source_repository,
22234 "https://github.com/shiroksh/rust-and-iphone"
22235 );
22236 assert_eq!(
22237 context.request.source_repository.as_deref(),
22238 Some(context.source_repository.as_str())
22239 );
22240 assert_eq!(context.run.handle().head_sha(), &context.dispatch_revision);
22241 let requests = requests.lock().expect("requests");
22242 assert_eq!(requests.len(), 2);
22243 assert!(requests.iter().all(|request| {
22244 request
22245 .endpoint()
22246 .starts_with("/repos/ShiroKSH/rustferry-signing/actions/")
22247 }));
22248 }
22249
22250 #[test]
22251 fn production_doctor_rejects_trusted_tip_workflow_byte_mismatch() {
22252 let config = same_repository_provider_config(all_authorized());
22253 let workflow_path = config.workflow.filename().repository_path();
22254 let responses = [
22255 Ok(b"https://github.com/ShiroKSH/rust-and-iphone\n".to_vec()),
22256 Ok(b"git@github.com:ShiroKSH/rust-and-iphone.git\n".to_vec()),
22257 Ok(format!(
22258 "{TRUSTED_TIP}\t{}\n",
22259 config.workflow.trusted_source_ref().as_str()
22260 )
22261 .into_bytes()),
22262 Ok(Vec::new()),
22263 Ok(b"name: unapproved\n".to_vec()),
22264 ];
22265 let directory = tempdir().expect("isolation directory");
22266 let mut publisher = GitTemporaryRefPublisher::new(
22267 FakeGitRunner::with(responses),
22268 directory.path(),
22269 "origin",
22270 "origin",
22271 Duration::from_secs(5),
22272 )
22273 .expect("publisher");
22274 let error = publisher
22275 .doctor(&TemporaryRefDoctorRequest {
22276 repository: &config.repository,
22277 source_repository: &config.source_repository,
22278 trusted_source_ref: config.workflow.trusted_source_ref(),
22279 temporary_branch_namespace: config.workflow.temporary_branch_namespace(),
22280 workflow_path: &workflow_path,
22281 workflow_fingerprint: &config.workflow_fingerprint,
22282 })
22283 .expect_err("unapproved trusted-tip workflow bytes");
22284 assert_eq!(error, TemporaryRefPublishError::WorkflowFingerprintMismatch);
22285 }
22286
22287 #[test]
22288 fn execution_workflow_verifier_reads_only_the_stable_execution_default_ref() {
22289 let config = workflow_dispatch_provider_config(all_authorized());
22290 let workflow = generate_workflow(&config.workflow);
22291 let source = GithubRemoteSnapshot::new(
22292 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rust-and-iphone.git")
22293 .expect("source fetch"),
22294 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rust-and-iphone.git")
22295 .expect("source push"),
22296 )
22297 .expect("source endpoints");
22298 let execution = GithubRemoteSnapshot::new(
22299 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rustferry-signing.git")
22300 .expect("execution fetch"),
22301 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rustferry-signing.git")
22302 .expect("execution push"),
22303 )
22304 .expect("execution endpoints");
22305 let default_ref = config.workflow.trusted_source_ref();
22306 let responses = [
22307 Ok(format!("{TRUSTED_TIP}\t{}\n", default_ref.as_str()).into_bytes()),
22308 Ok(Vec::new()),
22309 Ok(format!("{TRUSTED_TIP}\t{}\n", default_ref.as_str()).into_bytes()),
22310 Ok(workflow.yaml().as_bytes().to_vec()),
22311 ];
22312 let directory = tempdir().expect("isolation directory");
22313 let mut publisher = GitTemporaryRefPublisher::new_with_endpoints(
22314 FakeGitRunner::with(responses),
22315 directory.path(),
22316 &source,
22317 &execution,
22318 Duration::from_secs(5),
22319 )
22320 .expect("publisher");
22321
22322 publisher
22323 .verify_execution_workflow(&ExecutionWorkflowDoctorRequest {
22324 repository: &config.repository,
22325 default_branch_ref: default_ref,
22326 workflow_path: &config.workflow.filename().repository_path(),
22327 workflow_fingerprint: &config.workflow_fingerprint,
22328 })
22329 .expect("execution workflow proof");
22330
22331 let runner = publisher.into_runner();
22332 assert_eq!(runner.invocations.len(), 4);
22333 assert_eq!(
22334 runner
22335 .invocations
22336 .iter()
22337 .map(|invocation| invocation.network)
22338 .collect::<Vec<_>>(),
22339 [
22340 GitNetworkPolicy::HttpsWithCredentialManager,
22341 GitNetworkPolicy::HttpsWithCredentialManager,
22342 GitNetworkPolicy::HttpsWithCredentialManager,
22343 GitNetworkPolicy::Offline,
22344 ]
22345 );
22346 assert!(runner.invocations[..3].iter().all(|invocation| {
22347 invocation.arguments.iter().any(|argument| {
22348 argument == OsStr::new("https://github.com/shiroksh/rustferry-signing")
22349 }) && invocation.arguments.iter().all(|argument| {
22350 argument != OsStr::new("https://github.com/shiroksh/rust-and-iphone")
22351 })
22352 }));
22353 assert_eq!(
22354 runner.invocations[3].arguments.first(),
22355 Some(&OsString::from("show"))
22356 );
22357 }
22358
22359 #[test]
22360 fn execution_workflow_verifier_rejects_push_only_bytes() {
22361 let config = workflow_dispatch_provider_config(all_authorized());
22362 let source = GithubRemoteSnapshot::new(
22363 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rust-and-iphone.git")
22364 .expect("source fetch"),
22365 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rust-and-iphone.git")
22366 .expect("source push"),
22367 )
22368 .expect("source endpoints");
22369 let execution = GithubRemoteSnapshot::new(
22370 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rustferry-signing.git")
22371 .expect("execution fetch"),
22372 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rustferry-signing.git")
22373 .expect("execution push"),
22374 )
22375 .expect("execution endpoints");
22376 let default_ref = config.workflow.trusted_source_ref();
22377 let push_workflow = generate_workflow(&workflow_config());
22378 let responses = [
22379 Ok(format!("{TRUSTED_TIP}\t{}\n", default_ref.as_str()).into_bytes()),
22380 Ok(Vec::new()),
22381 Ok(format!("{TRUSTED_TIP}\t{}\n", default_ref.as_str()).into_bytes()),
22382 Ok(push_workflow.yaml().as_bytes().to_vec()),
22383 ];
22384 let directory = tempdir().expect("isolation directory");
22385 let mut publisher = GitTemporaryRefPublisher::new_with_endpoints(
22386 FakeGitRunner::with(responses),
22387 directory.path(),
22388 &source,
22389 &execution,
22390 Duration::from_secs(5),
22391 )
22392 .expect("publisher");
22393
22394 assert_eq!(
22395 publisher.verify_execution_workflow(&ExecutionWorkflowDoctorRequest {
22396 repository: &config.repository,
22397 default_branch_ref: default_ref,
22398 workflow_path: &config.workflow.filename().repository_path(),
22399 workflow_fingerprint: &config.workflow_fingerprint,
22400 }),
22401 Err(TemporaryRefPublishError::WorkflowFingerprintMismatch)
22402 );
22403 }
22404
22405 #[test]
22406 fn frozen_endpoints_skip_remote_config_and_select_exact_network_policies() {
22407 let config = same_repository_provider_config(all_authorized());
22408 let workflow_path = config.workflow.filename().repository_path();
22409 let workflow = generate_workflow(&config.workflow);
22410 let responses = [
22411 Ok(format!(
22412 "{TRUSTED_TIP}\t{}\n",
22413 config.workflow.trusted_source_ref().as_str()
22414 )
22415 .into_bytes()),
22416 Ok(Vec::new()),
22417 Ok(workflow.yaml().as_bytes().to_vec()),
22418 Ok(Vec::new()),
22419 Ok(Vec::new()),
22420 Ok(Vec::new()),
22421 Ok(Vec::new()),
22422 ];
22423 let endpoints = GithubRemoteSnapshot::new(
22424 GithubGitEndpoint::parse("https://github.com/ShiroKSH/rust-and-iphone.git")
22425 .expect("fetch endpoint"),
22426 GithubGitEndpoint::parse("git@github.com:ShiroKSH/rust-and-iphone.git")
22427 .expect("push endpoint"),
22428 )
22429 .expect("endpoint snapshot");
22430 let directory = tempdir().expect("isolation directory");
22431 let mut publisher = GitTemporaryRefPublisher::new_with_endpoints(
22432 FakeGitRunner::with(responses),
22433 directory.path(),
22434 &endpoints,
22435 &endpoints,
22436 Duration::from_secs(5),
22437 )
22438 .expect("publisher");
22439
22440 publisher
22441 .doctor(&TemporaryRefDoctorRequest {
22442 repository: &config.repository,
22443 source_repository: &config.source_repository,
22444 trusted_source_ref: config.workflow.trusted_source_ref(),
22445 temporary_branch_namespace: config.workflow.temporary_branch_namespace(),
22446 workflow_path: &workflow_path,
22447 workflow_fingerprint: &config.workflow_fingerprint,
22448 })
22449 .expect("frozen endpoint readiness");
22450
22451 let runner = publisher.into_runner();
22452 assert_eq!(runner.invocations.len(), 7);
22453 assert!(
22454 runner.invocations.iter().all(|invocation| {
22455 invocation.arguments.first() != Some(&OsString::from("remote"))
22456 })
22457 );
22458 assert_eq!(
22459 runner
22460 .invocations
22461 .iter()
22462 .map(|invocation| invocation.network)
22463 .collect::<Vec<_>>(),
22464 [
22465 GitNetworkPolicy::HttpsWithCredentialManager,
22466 GitNetworkPolicy::HttpsWithCredentialManager,
22467 GitNetworkPolicy::Offline,
22468 GitNetworkPolicy::HttpsWithCredentialManager,
22469 GitNetworkPolicy::GithubSsh,
22470 GitNetworkPolicy::GithubSsh,
22471 GitNetworkPolicy::GithubSsh,
22472 ]
22473 );
22474 assert!(runner.invocations[0].arguments.contains(&OsString::from(
22475 "https://github.com/shiroksh/rust-and-iphone"
22476 )));
22477 assert_eq!(
22478 runner.invocations[5].arguments.first(),
22479 Some(&OsString::from("push"))
22480 );
22481 assert!(
22482 runner.invocations[5]
22483 .arguments
22484 .contains(&OsString::from("--dry-run"))
22485 );
22486 }
22487
22488 #[test]
22489 fn git_publisher_rejects_a_mismatched_push_url_before_network_access() {
22490 let config = same_repository_provider_config(all_authorized());
22491 let workflow_path = config.workflow.filename().repository_path();
22492 let runner = FakeGitRunner::with([
22493 Ok(b"git@github.com:ShiroKSH/rust-and-iphone.git\n".to_vec()),
22494 Ok(b"git@github.com:attacker/rust-and-iphone.git\n".to_vec()),
22495 ]);
22496 let directory = tempdir().expect("isolation directory");
22497 let mut publisher = GitTemporaryRefPublisher::new(
22498 runner,
22499 directory.path(),
22500 "origin",
22501 "origin",
22502 Duration::from_secs(5),
22503 )
22504 .expect("publisher");
22505
22506 let error = publisher
22507 .doctor(&TemporaryRefDoctorRequest {
22508 repository: &config.repository,
22509 source_repository: &config.source_repository,
22510 trusted_source_ref: config.workflow.trusted_source_ref(),
22511 temporary_branch_namespace: config.workflow.temporary_branch_namespace(),
22512 workflow_path: &workflow_path,
22513 workflow_fingerprint: &config.workflow_fingerprint,
22514 })
22515 .expect_err("mismatched push URL");
22516 assert_eq!(error, TemporaryRefPublishError::RemoteMismatch);
22517 let runner = publisher.into_runner();
22518 assert_eq!(runner.invocations.len(), 2);
22519 assert!(runner.responses.is_empty());
22520 }
22521
22522 #[test]
22523 fn signed_push_doctor_accepts_approved_branch_only_workflow_without_registration() {
22524 let report = signed_doctor_report(
22525 Ok(signing_environment_row(false, true, true)),
22526 Ok(exact_signing_policy_row()),
22527 Ok(exact_signing_secret_rows()),
22528 );
22529 assert!(report.ready);
22530 assert!(report.checks.iter().any(|check| {
22531 check.code == "github.trusted_source" && check.status == ProviderCheckStatus::Ready
22532 }));
22533 assert!(
22534 report
22535 .checks
22536 .iter()
22537 .all(|check| check.code != "github.workflow_registration")
22538 );
22539 }
22540
22541 #[test]
22542 fn push_doctor_rejects_repository_scoped_credentials() {
22543 let provider = GithubBuildProvider::with_artifact_store_and_clock(
22544 provider_config(all_authorized()),
22545 transport(FakeGhRunner::with([
22546 Err(GhExecutionError::CommandFailed { exit_code: Some(1) }),
22547 Ok(b"991\tU2hpcm9LU0gvcnVzdGZlcnJ5LXNpZ25pbmc=\n".to_vec()),
22548 Ok(private_repository_row()),
22549 Ok(public_source_repository_row()),
22550 ])),
22551 FakePublisher::default(),
22552 DoctorArtifactStore,
22553 FixedClock(1_700_000_000_000),
22554 );
22555
22556 let report = poll_ready(provider.doctor(
22557 ProviderDoctorRequest {
22558 protocol_version: CURRENT_PROTOCOL_VERSION,
22559 operation_id: "push-repository-credential-doctor".to_owned(),
22560 require_signing: false,
22561 },
22562 CancellationToken::new(),
22563 ))
22564 .expect("doctor report");
22565 assert!(!report.ready);
22566 assert!(report.checks.iter().any(|check| {
22567 check.code == "github.authentication" && check.status == ProviderCheckStatus::Error
22568 }));
22569 }
22570
22571 #[test]
22572 fn workflow_dispatch_doctor_accepts_exact_repository_scoped_credentials() {
22573 let provider = GithubBuildProvider::with_artifact_store_and_clock(
22574 workflow_dispatch_provider_config(all_authorized()),
22575 transport(FakeGhRunner::with([
22576 Err(GhExecutionError::CommandFailed { exit_code: Some(1) }),
22577 Ok(b"991\tU2hpcm9LU0gvcnVzdGZlcnJ5LXNpZ25pbmc=\n".to_vec()),
22578 Ok(private_repository_row()),
22579 Ok(public_source_repository_row()),
22580 Ok(workflow_dispatch_registration_row()),
22581 ])),
22582 FakePublisher::default(),
22583 DoctorArtifactStore,
22584 FixedClock(1_700_000_000_000),
22585 );
22586
22587 let report = poll_ready(provider.doctor(
22588 ProviderDoctorRequest {
22589 protocol_version: CURRENT_PROTOCOL_VERSION,
22590 operation_id: "dispatch-repository-credential-doctor".to_owned(),
22591 require_signing: false,
22592 },
22593 CancellationToken::new(),
22594 ))
22595 .expect("doctor report");
22596 assert!(report.checks.iter().any(|check| {
22597 check.code == "github.authentication" && check.status == ProviderCheckStatus::Ready
22598 }));
22599 assert!(report.checks.iter().any(|check| {
22600 check.code == "github.workflow_dispatch_default_workflow"
22601 && check.status == ProviderCheckStatus::Ready
22602 }));
22603 }
22604
22605 #[test]
22606 fn provider_doctor_rejects_a_private_source_repository() {
22607 let provider = GithubBuildProvider::with_artifact_store_and_clock(
22608 provider_config(all_authorized()),
22609 transport(FakeGhRunner::with([
22610 Ok(authenticated_user_row()),
22611 Ok(b"991\tShiroKSH/rustferry-signing\ttrue\tfalse\tfalse\tmain\n".to_vec()),
22612 Ok(b"992\tShiroKSH/rust-and-iphone\ttrue\tfalse\tfalse\tmain\n".to_vec()),
22613 ])),
22614 FakePublisher::default(),
22615 DoctorArtifactStore,
22616 FixedClock(1_700_000_000_000),
22617 );
22618 let report = poll_ready(provider.doctor(
22619 ProviderDoctorRequest {
22620 protocol_version: CURRENT_PROTOCOL_VERSION,
22621 operation_id: "private-source-doctor".to_owned(),
22622 require_signing: false,
22623 },
22624 CancellationToken::new(),
22625 ))
22626 .expect("doctor report");
22627 assert!(!report.ready);
22628 assert!(report.checks.iter().any(|check| {
22629 check.code == "github.source_repository_visibility"
22630 && check.status == ProviderCheckStatus::Error
22631 }));
22632 }
22633
22634 #[test]
22635 fn signed_doctor_requires_distinct_stable_repository_ids() {
22636 let provider = GithubBuildProvider::with_artifact_store_and_clock(
22637 provider_config(all_authorized()),
22638 transport(FakeGhRunner::with([
22639 Ok(authenticated_user_row()),
22640 Ok(private_repository_row()),
22641 Ok(b"991\tShiroKSH/rust-and-iphone\tfalse\tfalse\tfalse\tmain\n".to_vec()),
22642 Ok(b"true\n".to_vec()),
22643 Ok(signing_environment_row(false, true, true)),
22644 Ok(exact_signing_policy_row()),
22645 Ok(exact_signing_secret_rows()),
22646 ])),
22647 FakePublisher::default(),
22648 DoctorArtifactStore,
22649 FixedClock(1_700_000_000_000),
22650 );
22651 let report = poll_ready(provider.doctor(
22652 ProviderDoctorRequest {
22653 protocol_version: CURRENT_PROTOCOL_VERSION,
22654 operation_id: "same-repository-id-doctor".to_owned(),
22655 require_signing: true,
22656 },
22657 CancellationToken::new(),
22658 ))
22659 .expect("doctor report");
22660
22661 assert!(!report.ready);
22662 assert!(report.checks.iter().any(|check| {
22663 check.code == "github.signing_repository_distinct"
22664 && check.status == ProviderCheckStatus::Error
22665 }));
22666 }
22667
22668 #[test]
22669 fn signed_doctor_requires_github_actions_to_be_enabled() {
22670 let report = signed_doctor_report_with_repository_policy(
22671 true,
22672 Ok(b"false\n".to_vec()),
22673 Ok(signing_environment_row(false, true, true)),
22674 Ok(exact_signing_policy_row()),
22675 Ok(exact_signing_secret_rows()),
22676 );
22677
22678 assert!(!report.ready);
22679 assert!(report.checks.iter().any(|check| {
22680 check.code == "github.actions.enabled" && check.status == ProviderCheckStatus::Error
22681 }));
22682 }
22683
22684 #[test]
22685 fn workflow_dispatch_signing_doctor_requires_an_active_exact_registration() {
22686 let active = workflow_dispatch_doctor_report(Ok(format!(
22687 "17\t{WORKFLOW_PATH_BASE64}\tactive\n"
22688 )
22689 .into_bytes()));
22690 assert!(active.ready);
22691 assert!(active.checks.iter().any(|check| {
22692 check.code == "github.workflow_registration"
22693 && check.status == ProviderCheckStatus::Ready
22694 }));
22695
22696 let inactive = workflow_dispatch_doctor_report(Ok(format!(
22697 "17\t{WORKFLOW_PATH_BASE64}\tdisabled_manually\n"
22698 )
22699 .into_bytes()));
22700 assert!(!inactive.ready);
22701 assert!(inactive.checks.iter().any(|check| {
22702 check.code == "github.workflow_registration"
22703 && check.status == ProviderCheckStatus::Error
22704 }));
22705 }
22706
22707 #[test]
22708 fn signed_doctor_accepts_attempt_scoped_cleanup_evidence_after_other_checks_are_ready() {
22709 let report = signed_doctor_report(
22710 Ok(signing_environment_row(false, true, true)),
22711 Ok(exact_signing_policy_row()),
22712 Ok(exact_signing_secret_rows()),
22713 );
22714
22715 assert!(report.ready);
22716 for code in [
22717 "github.signing_repository_visibility",
22718 "github.signing_repository_distinct",
22719 "github.actions.enabled",
22720 "github.signing_environment",
22721 "github.signing_branch_policy",
22722 "github.signing_secrets",
22723 "github.signing_environment.reviewers",
22724 ] {
22725 assert!(
22726 report.checks.iter().any(|check| {
22727 check.code == code && check.status == ProviderCheckStatus::Ready
22728 })
22729 );
22730 }
22731 assert!(report.checks.iter().any(|check| {
22732 check.code == "github.signing_cleanup_evidence"
22733 && check.status == ProviderCheckStatus::Ready
22734 }));
22735 }
22736
22737 #[test]
22738 fn multi_profile_secret_metadata_requires_the_exact_configured_set() {
22739 let (_request, names) = multi_profile_request();
22740 let config = provider_config_with_secret_names(all_authorized(), names);
22741 let exact_rows = format!(
22742 "{CERTIFICATE_P12_BASE64}\n{CERTIFICATE_PASSWORD_BASE64}\n{PROVISIONING_PROFILE_BASE64}\n{WIDGET_PROFILE_BASE64}\n"
22743 )
22744 .into_bytes();
22745 let mut checks = Vec::new();
22746 append_signing_secret_check(
22747 &mut checks,
22748 &mut transport(FakeGhRunner::with([Ok(exact_rows)])),
22749 &config,
22750 );
22751 assert!(checks.iter().any(|check| {
22752 check.code == "github.signing_secrets" && check.status == ProviderCheckStatus::Ready
22753 }));
22754
22755 let mut checks = Vec::new();
22756 append_signing_secret_check(
22757 &mut checks,
22758 &mut transport(FakeGhRunner::with([Ok(exact_signing_secret_rows())])),
22759 &config,
22760 );
22761 assert!(checks.iter().any(|check| {
22762 check.code == "github.signing_secrets" && check.status == ProviderCheckStatus::Error
22763 }));
22764 }
22765
22766 #[test]
22767 fn signed_doctor_rejects_a_public_execution_repository() {
22768 let report = signed_doctor_report_with_visibility(
22769 false,
22770 Ok(signing_environment_row(false, true, true)),
22771 Ok(exact_signing_policy_row()),
22772 Ok(exact_signing_secret_rows()),
22773 );
22774
22775 assert!(!report.ready);
22776 assert!(report.checks.iter().any(|check| {
22777 check.code == "github.signing_repository_visibility"
22778 && check.status == ProviderCheckStatus::Error
22779 }));
22780 }
22781
22782 #[test]
22783 fn signed_doctor_requires_server_side_reviewer_approval() {
22784 let report = signed_doctor_report(
22785 Ok(signing_environment_row(false, true, false)),
22786 Ok(exact_signing_policy_row()),
22787 Ok(exact_signing_secret_rows()),
22788 );
22789
22790 assert!(!report.ready);
22791 assert!(report.checks.iter().any(|check| {
22792 check.code == "github.signing_environment.reviewers"
22793 && check.status == ProviderCheckStatus::Error
22794 }));
22795 }
22796
22797 #[test]
22798 fn signed_doctor_fails_closed_on_missing_or_mismatched_production_evidence() {
22799 let cases = [
22800 (
22801 "environment API unavailable",
22802 Err(GhExecutionError::CommandFailed {
22803 exit_code: Some(1),
22804 }),
22805 Ok(exact_signing_policy_row()),
22806 Ok(exact_signing_secret_rows()),
22807 "github.signing_environment",
22808 ),
22809 (
22810 "environment policy flags",
22811 Ok(signing_environment_row(true, true, true)),
22812 Ok(exact_signing_policy_row()),
22813 Ok(exact_signing_secret_rows()),
22814 "github.signing_environment",
22815 ),
22816 (
22817 "deployment branch policy",
22818 Ok(signing_environment_row(false, true, true)),
22819 Ok(b"361471\tbWFpbg==\n".to_vec()),
22820 Ok(exact_signing_secret_rows()),
22821 "github.signing_branch_policy",
22822 ),
22823 (
22824 "unexpected deployment branch policy",
22825 Ok(signing_environment_row(false, true, true)),
22826 Ok(format!("361471\t{SIGNING_BRANCH_POLICY_BASE64}\n361472\tbWFpbg==\n")
22827 .into_bytes()),
22828 Ok(exact_signing_secret_rows()),
22829 "github.signing_branch_policy",
22830 ),
22831 (
22832 "missing signing secret",
22833 Ok(signing_environment_row(false, true, true)),
22834 Ok(exact_signing_policy_row()),
22835 Ok(format!("{CERTIFICATE_P12_BASE64}\n{CERTIFICATE_PASSWORD_BASE64}\n")
22836 .into_bytes()),
22837 "github.signing_secrets",
22838 ),
22839 (
22840 "unexpected signing secret",
22841 Ok(signing_environment_row(false, true, true)),
22842 Ok(exact_signing_policy_row()),
22843 Ok(format!(
22844 "{CERTIFICATE_P12_BASE64}\n{CERTIFICATE_PASSWORD_BASE64}\n{PROVISIONING_PROFILE_BASE64}\nRVhUUkE=\n"
22845 )
22846 .into_bytes()),
22847 "github.signing_secrets",
22848 ),
22849 ];
22850
22851 for (name, environment, policies, secrets, expected_error) in cases {
22852 let report = signed_doctor_report(environment, policies, secrets);
22853 assert!(!report.ready, "accepted {name}");
22854 assert!(
22855 report.checks.iter().any(|check| {
22856 check.code == expected_error && check.status == ProviderCheckStatus::Error
22857 }),
22858 "missing error for {name}"
22859 );
22860 }
22861 }
22862
22863 #[test]
22864 fn strict_dispatch_manifest_rejects_unknown_fields() {
22865 let config = provider_config(all_authorized());
22866 let temporary_ref = TemporaryGitRef::new(
22867 config.workflow.temporary_branch_namespace(),
22868 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
22869 )
22870 .expect("temporary ref");
22871 let manifest = GithubDispatchManifest::new(&config, &temporary_ref, valid_request());
22872 manifest.validate_for(&config).expect("valid manifest");
22873 assert!(github_remote_matches(
22874 &manifest.execution_repository,
22875 &repository_url(&config.repository)
22876 ));
22877 let mut wrong_execution = manifest.clone();
22878 wrong_execution.execution_repository =
22879 "https://github.com/shiroksh/other-signing".to_owned();
22880 assert!(wrong_execution.validate_for(&config).is_err());
22881 let mut value = serde_json::to_value(manifest).expect("manifest value");
22882 value
22883 .as_object_mut()
22884 .expect("object")
22885 .insert("future_shell".to_owned(), serde_json::json!("cargo build"));
22886 assert!(serde_json::from_value::<GithubDispatchManifest>(value).is_err());
22887 }
22888
22889 #[test]
22890 fn operation_ids_must_be_single_safe_ref_segments() {
22891 for invalid in [
22892 "",
22893 "-job",
22894 "job/child",
22895 "job:child",
22896 "job..child",
22897 "job.lock",
22898 ] {
22899 assert!(
22900 validate_ref_segment(invalid).is_err(),
22901 "accepted {invalid:?}"
22902 );
22903 }
22904 assert!(validate_ref_segment("operation-1.test_case").is_ok());
22905 }
22906
22907 #[test]
22908 fn unauthorized_git_publication_executes_nothing() {
22909 let directory = tempdir().expect("isolation directory");
22910 let runner = FakeGitRunner::default();
22911 let mut publisher = GitTemporaryRefPublisher::new(
22912 runner,
22913 directory.path(),
22914 "origin",
22915 "origin",
22916 Duration::from_secs(5),
22917 )
22918 .expect("publisher");
22919 let config = provider_config(all_authorized());
22920 let workflow = generate_workflow(&config.workflow);
22921 let temporary_ref = TemporaryGitRef::new(
22922 config.workflow.temporary_branch_namespace(),
22923 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
22924 )
22925 .expect("temporary ref");
22926 let error = publisher
22927 .publish(&TemporaryRefPublishRequest {
22928 repository: &config.repository,
22929 source_repository: &config.source_repository,
22930 trusted_source_ref: config.workflow.trusted_source_ref(),
22931 source_revision: &CommitSha::new(SOURCE_SHA).expect("source"),
22932 snapshot_source_ref: None,
22933 temporary_ref: &temporary_ref,
22934 workflow_path: workflow.path(),
22935 workflow_bytes: workflow.yaml().as_bytes(),
22936 workflow_fingerprint: &config.workflow_fingerprint,
22937 manifest_bytes: b"{}\n",
22938 operation_id: "operation-1",
22939 created_at_ms: 1_700_000_000_000,
22940 authorized: false,
22941 })
22942 .expect_err("authorization required");
22943 assert_eq!(error.error(), TemporaryRefPublishError::Unauthorized);
22944 assert!(publisher.into_runner().invocations.is_empty());
22945 }
22946
22947 #[test]
22948 fn git_snapshot_source_publication_is_create_only_on_the_full_custom_ref() {
22949 let directory = tempdir().expect("isolation directory");
22950 let mut publisher = GitTemporaryRefPublisher::new(
22951 FakeGitRunner::with(snapshot_source_mutation_git_responses(Some(SOURCE_SHA))),
22952 directory.path(),
22953 "source",
22954 "execution",
22955 Duration::from_secs(5),
22956 )
22957 .expect("publisher");
22958 let config = provider_config(all_authorized());
22959 let source_ref =
22960 GitSnapshotSourceRef::for_operation("operation-1").expect("snapshot source ref");
22961 let commit = GitSha1ObjectId::new(SOURCE_SHA).expect("snapshot commit");
22962 assert_eq!(
22963 publisher
22964 .publish_git_snapshot_source(&GitSnapshotSourcePublishRequest {
22965 source_repository: &config.source_repository,
22966 source_ref: &source_ref,
22967 commit: &commit,
22968 authorized: true,
22969 })
22970 .expect("snapshot source publication"),
22971 GitSnapshotSourcePublication::Exact
22972 );
22973 let runner = publisher.into_runner();
22974 assert!(runner.responses.is_empty());
22975 let push = runner
22976 .invocations
22977 .iter()
22978 .find(|invocation| invocation.arguments.first() == Some(&OsString::from("push")))
22979 .expect("source push");
22980 assert!(push.arguments.contains(&OsString::from("--no-verify")));
22981 assert!(push.arguments.contains(&OsString::from("--atomic")));
22982 assert!(push.arguments.contains(&OsString::from(format!(
22983 "--force-with-lease={}:",
22984 source_ref.as_str()
22985 ))));
22986 assert!(push.arguments.contains(&OsString::from(format!(
22987 "{}:{}",
22988 commit.as_str(),
22989 source_ref.as_str()
22990 ))));
22991 assert!(
22992 push.arguments
22993 .contains(&OsString::from("git@github.com:shiroksh/rust-and-iphone"))
22994 );
22995 assert!(
22996 !push
22997 .arguments
22998 .iter()
22999 .any(|argument| { argument == "git@github.com:shiroksh/rustferry-signing" })
23000 );
23001 }
23002
23003 #[test]
23004 fn git_snapshot_source_deletion_uses_the_exact_expected_commit_lease() {
23005 let directory = tempdir().expect("isolation directory");
23006 let mut publisher = GitTemporaryRefPublisher::new(
23007 FakeGitRunner::with(snapshot_source_mutation_git_responses(None)),
23008 directory.path(),
23009 "source",
23010 "execution",
23011 Duration::from_secs(5),
23012 )
23013 .expect("publisher");
23014 let config = provider_config(all_authorized());
23015 let source_ref =
23016 GitSnapshotSourceRef::for_operation("operation-1").expect("snapshot source ref");
23017 let commit = GitSha1ObjectId::new(SOURCE_SHA).expect("snapshot commit");
23018 publisher
23019 .delete_git_snapshot_source(&GitSnapshotSourceDeleteRequest {
23020 source_repository: &config.source_repository,
23021 source_ref: &source_ref,
23022 expected_commit: &commit,
23023 authorized: true,
23024 })
23025 .expect("exact source deletion");
23026 let runner = publisher.into_runner();
23027 assert!(runner.responses.is_empty());
23028 let push = runner
23029 .invocations
23030 .iter()
23031 .find(|invocation| invocation.arguments.first() == Some(&OsString::from("push")))
23032 .expect("source deletion push");
23033 assert!(push.arguments.contains(&OsString::from(format!(
23034 "--force-with-lease={}:{}",
23035 source_ref.as_str(),
23036 commit.as_str()
23037 ))));
23038 assert!(
23039 push.arguments
23040 .contains(&OsString::from(format!(":{}", source_ref.as_str())))
23041 );
23042 assert!(push.arguments.contains(&OsString::from("--no-verify")));
23043 }
23044
23045 #[test]
23046 fn git_snapshot_source_deletion_rejects_a_foreign_tip_without_claiming_absence() {
23047 let directory = tempdir().expect("isolation directory");
23048 let mut publisher = GitTemporaryRefPublisher::new(
23049 FakeGitRunner::with(snapshot_source_mutation_git_responses(Some(DISPATCH_SHA))),
23050 directory.path(),
23051 "source",
23052 "execution",
23053 Duration::from_secs(5),
23054 )
23055 .expect("publisher");
23056 let config = provider_config(all_authorized());
23057 let source_ref =
23058 GitSnapshotSourceRef::for_operation("operation-1").expect("snapshot source ref");
23059 let commit = GitSha1ObjectId::new(SOURCE_SHA).expect("snapshot commit");
23060 assert_eq!(
23061 publisher.delete_git_snapshot_source(&GitSnapshotSourceDeleteRequest {
23062 source_repository: &config.source_repository,
23063 source_ref: &source_ref,
23064 expected_commit: &commit,
23065 authorized: true,
23066 }),
23067 Err(TemporaryRefPublishError::GitSnapshotRefConflict)
23068 );
23069 let runner = publisher.into_runner();
23070 let push = runner
23071 .invocations
23072 .iter()
23073 .find(|invocation| invocation.arguments.first() == Some(&OsString::from("push")))
23074 .expect("lease-protected deletion attempt");
23075 assert!(push.arguments.contains(&OsString::from(format!(
23076 "--force-with-lease={}:{}",
23077 source_ref.as_str(),
23078 commit.as_str()
23079 ))));
23080 }
23081
23082 #[test]
23083 fn git_prepare_binds_deterministic_commit_without_remote_mutation() {
23084 let full_trusted_ref = "refs/heads/goal3/macless-iphone-builds";
23085 let config = provider_config(all_authorized());
23086 let workflow = generate_workflow(&config.workflow);
23087 let responses = [
23088 Ok(b"git@github.com:ShiroKSH/rust-and-iphone.git\n".to_vec()),
23089 Ok(b"ssh://git@github.com/ShiroKSH/rust-and-iphone\n".to_vec()),
23090 Ok(b"git@github.com:ShiroKSH/rustferry-signing.git\n".to_vec()),
23091 Ok(b"ssh://git@github.com/ShiroKSH/rustferry-signing\n".to_vec()),
23092 Ok(format!("{TRUSTED_TIP}\t{full_trusted_ref}\n").into_bytes()),
23093 Ok(Vec::new()),
23094 Ok(workflow.yaml().as_bytes().to_vec()),
23095 Ok(Vec::new()),
23096 Ok(Vec::new()),
23097 Ok(Vec::new()),
23098 Ok(Vec::new()),
23099 Ok(format!("{WORKFLOW_BLOB}\n").into_bytes()),
23100 Ok(format!("{MANIFEST_BLOB}\n").into_bytes()),
23101 Ok(Vec::new()),
23102 Ok(Vec::new()),
23103 Ok(format!("{TREE_SHA}\n").into_bytes()),
23104 Ok(format!("{DISPATCH_SHA}\n").into_bytes()),
23105 ];
23106 let directory = tempdir().expect("isolation directory");
23107 let mut publisher = GitTemporaryRefPublisher::new(
23108 FakeGitRunner::with(responses),
23109 directory.path(),
23110 "source",
23111 "execution",
23112 Duration::from_secs(5),
23113 )
23114 .expect("publisher");
23115 let temporary_ref = TemporaryGitRef::new(
23116 config.workflow.temporary_branch_namespace(),
23117 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
23118 )
23119 .expect("temporary ref");
23120 let prepared = publisher
23121 .prepare(&TemporaryRefPublishRequest {
23122 repository: &config.repository,
23123 source_repository: &config.source_repository,
23124 trusted_source_ref: config.workflow.trusted_source_ref(),
23125 source_revision: &CommitSha::new(SOURCE_SHA).expect("source"),
23126 snapshot_source_ref: None,
23127 temporary_ref: &temporary_ref,
23128 workflow_path: workflow.path(),
23129 workflow_bytes: workflow.yaml().as_bytes(),
23130 workflow_fingerprint: &config.workflow_fingerprint,
23131 manifest_bytes: b"{\"schema_version\":1}\n",
23132 operation_id: "operation-1",
23133 created_at_ms: 1_700_000_000_000,
23134 authorized: true,
23135 })
23136 .expect("read-only preparation")
23137 .expect("prepared commit");
23138 assert_eq!(prepared.commit().as_str(), DISPATCH_SHA);
23139 let runner = publisher.into_runner();
23140 assert!(runner.responses.is_empty());
23141 assert!(!runner.invocations.iter().any(|invocation| {
23142 invocation
23143 .arguments
23144 .first()
23145 .and_then(|argument| argument.to_str())
23146 == Some("push")
23147 }));
23148 }
23149
23150 #[test]
23151 #[allow(
23152 clippy::too_many_lines,
23153 reason = "the regression proves split-repository custom-ref trust and the absence of ordinary ancestry fallback"
23154 )]
23155 fn git_snapshot_prepare_fetches_the_exact_custom_ref_from_the_source_repository() {
23156 let config = provider_config(all_authorized());
23157 let workflow = generate_workflow(&config.workflow);
23158 let directory = tempdir().expect("isolation directory");
23159 let mut publisher = GitTemporaryRefPublisher::new(
23160 FakeGitRunner::with(successful_snapshot_prepare_git_responses(
23161 workflow.yaml().as_bytes(),
23162 SOURCE_SHA,
23163 )),
23164 directory.path(),
23165 "source",
23166 "execution",
23167 Duration::from_secs(5),
23168 )
23169 .expect("publisher");
23170 let temporary_ref = TemporaryGitRef::new(
23171 config.workflow.temporary_branch_namespace(),
23172 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
23173 )
23174 .expect("temporary ref");
23175 let source_revision = CommitSha::new(SOURCE_SHA).expect("source revision");
23176 let snapshot_ref =
23177 GitSnapshotSourceRef::for_operation("operation-1").expect("snapshot ref");
23178 let prepared = publisher
23179 .prepare(&TemporaryRefPublishRequest {
23180 repository: &config.repository,
23181 source_repository: &config.source_repository,
23182 trusted_source_ref: config.workflow.trusted_source_ref(),
23183 source_revision: &source_revision,
23184 snapshot_source_ref: Some(&snapshot_ref),
23185 temporary_ref: &temporary_ref,
23186 workflow_path: workflow.path(),
23187 workflow_bytes: workflow.yaml().as_bytes(),
23188 workflow_fingerprint: &config.workflow_fingerprint,
23189 manifest_bytes: b"{\"schema_version\":1}\n",
23190 operation_id: "operation-1",
23191 created_at_ms: 1_700_000_000_000,
23192 authorized: true,
23193 })
23194 .expect("snapshot dispatch preparation")
23195 .expect("prepared dispatch");
23196 assert_eq!(prepared.commit().as_str(), DISPATCH_SHA);
23197 let runner = publisher.into_runner();
23198 assert!(runner.responses.is_empty());
23199 assert!(!runner.invocations.iter().any(|invocation| {
23200 invocation.arguments.first() == Some(&OsString::from("merge-base"))
23201 }));
23202 assert_eq!(
23203 runner
23204 .invocations
23205 .iter()
23206 .filter(|invocation| {
23207 invocation.arguments.first() == Some(&OsString::from("ls-remote"))
23208 && invocation.arguments.last()
23209 == Some(&OsString::from(snapshot_ref.as_str()))
23210 })
23211 .count(),
23212 2
23213 );
23214 let snapshot_fetch = runner
23215 .invocations
23216 .iter()
23217 .find(|invocation| {
23218 invocation.arguments.first() == Some(&OsString::from("fetch"))
23219 && invocation.arguments.last() == Some(&OsString::from(snapshot_ref.as_str()))
23220 })
23221 .expect("snapshot source fetch");
23222 assert!(
23223 snapshot_fetch
23224 .arguments
23225 .contains(&OsString::from("git@github.com:shiroksh/rust-and-iphone"))
23226 );
23227 let temporary_ref_lookup = runner
23228 .invocations
23229 .iter()
23230 .find(|invocation| {
23231 invocation.arguments.first() == Some(&OsString::from("ls-remote"))
23232 && invocation.arguments.last()
23233 == Some(&OsString::from(
23234 "refs/heads/rustferry/goal3/builds/operation-1",
23235 ))
23236 })
23237 .expect("execution temporary-ref lookup");
23238 assert!(
23239 temporary_ref_lookup
23240 .arguments
23241 .contains(&OsString::from("git@github.com:shiroksh/rustferry-signing"))
23242 );
23243 }
23244
23245 #[test]
23246 fn git_snapshot_prepare_rejects_custom_ref_drift_before_object_writes() {
23247 let config = provider_config(all_authorized());
23248 let workflow = generate_workflow(&config.workflow);
23249 let directory = tempdir().expect("isolation directory");
23250 let mut publisher = GitTemporaryRefPublisher::new(
23251 FakeGitRunner::with(successful_snapshot_prepare_git_responses(
23252 workflow.yaml().as_bytes(),
23253 DISPATCH_SHA,
23254 )),
23255 directory.path(),
23256 "source",
23257 "execution",
23258 Duration::from_secs(5),
23259 )
23260 .expect("publisher");
23261 let temporary_ref = TemporaryGitRef::new(
23262 config.workflow.temporary_branch_namespace(),
23263 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
23264 )
23265 .expect("temporary ref");
23266 let source_revision = CommitSha::new(SOURCE_SHA).expect("source revision");
23267 let snapshot_ref =
23268 GitSnapshotSourceRef::for_operation("operation-1").expect("snapshot ref");
23269 let error = publisher
23270 .prepare(&TemporaryRefPublishRequest {
23271 repository: &config.repository,
23272 source_repository: &config.source_repository,
23273 trusted_source_ref: config.workflow.trusted_source_ref(),
23274 source_revision: &source_revision,
23275 snapshot_source_ref: Some(&snapshot_ref),
23276 temporary_ref: &temporary_ref,
23277 workflow_path: workflow.path(),
23278 workflow_bytes: workflow.yaml().as_bytes(),
23279 workflow_fingerprint: &config.workflow_fingerprint,
23280 manifest_bytes: b"{\"schema_version\":1}\n",
23281 operation_id: "operation-1",
23282 created_at_ms: 1_700_000_000_000,
23283 authorized: true,
23284 })
23285 .expect_err("changed source ref must fail closed");
23286 assert_eq!(error, TemporaryRefPublishError::GitSnapshotRefConflict);
23287 let runner = publisher.into_runner();
23288 assert!(!runner.invocations.iter().any(|invocation| {
23289 matches!(
23290 invocation
23291 .arguments
23292 .first()
23293 .and_then(|argument| argument.to_str()),
23294 Some("hash-object" | "update-index" | "write-tree" | "commit-tree" | "push")
23295 )
23296 }));
23297 }
23298
23299 #[test]
23300 fn snapshot_publish_prepared_rechecks_the_custom_ref_before_push() {
23301 let config = provider_config(all_authorized());
23302 let workflow = generate_workflow(&config.workflow);
23303 let mut responses =
23304 successful_snapshot_prepare_git_responses(workflow.yaml().as_bytes(), SOURCE_SHA);
23305 responses.extend(successful_snapshot_prepare_git_responses(
23306 workflow.yaml().as_bytes(),
23307 DISPATCH_SHA,
23308 ));
23309 let directory = tempdir().expect("isolation directory");
23310 let mut publisher = GitTemporaryRefPublisher::new(
23311 FakeGitRunner::with(responses),
23312 directory.path(),
23313 "source",
23314 "execution",
23315 Duration::from_secs(5),
23316 )
23317 .expect("publisher");
23318 let temporary_ref = TemporaryGitRef::new(
23319 config.workflow.temporary_branch_namespace(),
23320 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
23321 )
23322 .expect("temporary ref");
23323 let source_revision = CommitSha::new(SOURCE_SHA).expect("source revision");
23324 let snapshot_ref =
23325 GitSnapshotSourceRef::for_operation("operation-1").expect("snapshot ref");
23326 let request = TemporaryRefPublishRequest {
23327 repository: &config.repository,
23328 source_repository: &config.source_repository,
23329 trusted_source_ref: config.workflow.trusted_source_ref(),
23330 source_revision: &source_revision,
23331 snapshot_source_ref: Some(&snapshot_ref),
23332 temporary_ref: &temporary_ref,
23333 workflow_path: workflow.path(),
23334 workflow_bytes: workflow.yaml().as_bytes(),
23335 workflow_fingerprint: &config.workflow_fingerprint,
23336 manifest_bytes: b"{\"schema_version\":1}\n",
23337 operation_id: "operation-1",
23338 created_at_ms: 1_700_000_000_000,
23339 authorized: true,
23340 };
23341 let prepared = publisher
23342 .prepare(&request)
23343 .expect("initial exact source check")
23344 .expect("prepared dispatch");
23345 let failure = publisher
23346 .publish_prepared(&request, Some(&prepared))
23347 .expect_err("source ref drift must block dispatch push");
23348 assert_eq!(
23349 failure.error(),
23350 TemporaryRefPublishError::GitSnapshotRefConflict
23351 );
23352 assert!(failure.possible_publication().is_none());
23353 let runner = publisher.into_runner();
23354 assert!(
23355 !runner.invocations.iter().any(|invocation| {
23356 invocation.arguments.first() == Some(&OsString::from("push"))
23357 })
23358 );
23359 }
23360
23361 #[test]
23362 #[allow(clippy::too_many_lines)]
23363 fn prepared_publications_do_not_share_mutable_cross_job_state() {
23364 let config = provider_config(all_authorized());
23365 let workflow = generate_workflow(&config.workflow);
23366 let temporary_ref_a = TemporaryGitRef::new(
23367 config.workflow.temporary_branch_namespace(),
23368 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch A"),
23369 )
23370 .expect("temporary ref A");
23371 let temporary_ref_b = TemporaryGitRef::new(
23372 config.workflow.temporary_branch_namespace(),
23373 BranchName::new("rustferry/goal3/builds/operation-2").expect("branch B"),
23374 )
23375 .expect("temporary ref B");
23376 let source_revision = CommitSha::new(SOURCE_SHA).expect("source");
23377 let mut responses = successful_prepare_git_responses(workflow.yaml().as_bytes());
23378 responses.extend(successful_prepare_git_responses(workflow.yaml().as_bytes()));
23379 responses.extend(successful_prepare_git_responses(workflow.yaml().as_bytes()));
23380 responses.push(Ok(Vec::new()));
23381 responses.push(Ok(format!(
23382 "{DISPATCH_SHA}\trefs/heads/{}\n",
23383 temporary_ref_a.branch().as_str()
23384 )
23385 .into_bytes()));
23386 let directory = tempdir().expect("isolation directory");
23387 let mut publisher = GitTemporaryRefPublisher::new(
23388 FakeGitRunner::with(responses),
23389 directory.path(),
23390 "source",
23391 "execution",
23392 Duration::from_secs(5),
23393 )
23394 .expect("publisher");
23395
23396 let prepared_a = publisher
23397 .prepare(&TemporaryRefPublishRequest {
23398 repository: &config.repository,
23399 source_repository: &config.source_repository,
23400 trusted_source_ref: config.workflow.trusted_source_ref(),
23401 source_revision: &source_revision,
23402 snapshot_source_ref: None,
23403 temporary_ref: &temporary_ref_a,
23404 workflow_path: workflow.path(),
23405 workflow_bytes: workflow.yaml().as_bytes(),
23406 workflow_fingerprint: &config.workflow_fingerprint,
23407 manifest_bytes: b"{\"schema_version\":1}\n",
23408 operation_id: "operation-1",
23409 created_at_ms: 1_700_000_000_000,
23410 authorized: true,
23411 })
23412 .expect("prepare A")
23413 .expect("prepared A");
23414 publisher
23415 .prepare(&TemporaryRefPublishRequest {
23416 repository: &config.repository,
23417 source_repository: &config.source_repository,
23418 trusted_source_ref: config.workflow.trusted_source_ref(),
23419 source_revision: &source_revision,
23420 snapshot_source_ref: None,
23421 temporary_ref: &temporary_ref_b,
23422 workflow_path: workflow.path(),
23423 workflow_bytes: workflow.yaml().as_bytes(),
23424 workflow_fingerprint: &config.workflow_fingerprint,
23425 manifest_bytes: b"{\"schema_version\":1}\n",
23426 operation_id: "operation-2",
23427 created_at_ms: 1_700_000_000_000,
23428 authorized: true,
23429 })
23430 .expect("interleaved prepare B")
23431 .expect("prepared B");
23432 let published_a = publisher
23433 .publish_prepared(
23434 &TemporaryRefPublishRequest {
23435 repository: &config.repository,
23436 source_repository: &config.source_repository,
23437 trusted_source_ref: config.workflow.trusted_source_ref(),
23438 source_revision: &source_revision,
23439 snapshot_source_ref: None,
23440 temporary_ref: &temporary_ref_a,
23441 workflow_path: workflow.path(),
23442 workflow_bytes: workflow.yaml().as_bytes(),
23443 workflow_fingerprint: &config.workflow_fingerprint,
23444 manifest_bytes: b"{\"schema_version\":1}\n",
23445 operation_id: "operation-1",
23446 created_at_ms: 1_700_000_000_000,
23447 authorized: true,
23448 },
23449 Some(&prepared_a),
23450 )
23451 .expect("publish A after interleaved prepare B");
23452 assert_eq!(published_a, prepared_a);
23453 let runner = publisher.into_runner();
23454 assert!(runner.responses.is_empty());
23455 assert_eq!(
23456 runner
23457 .invocations
23458 .iter()
23459 .filter(|invocation| {
23460 invocation
23461 .arguments
23462 .first()
23463 .and_then(|argument| argument.to_str())
23464 == Some("push")
23465 })
23466 .count(),
23467 1
23468 );
23469 }
23470
23471 #[test]
23472 fn publish_prepared_revalidates_source_trust_immediately_before_push() {
23473 let config = provider_config(all_authorized());
23474 let workflow = generate_workflow(&config.workflow);
23475 let temporary_ref = TemporaryGitRef::new(
23476 config.workflow.temporary_branch_namespace(),
23477 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
23478 )
23479 .expect("temporary ref");
23480 let source_revision = CommitSha::new(SOURCE_SHA).expect("source");
23481 let mut responses = successful_prepare_git_responses(workflow.yaml().as_bytes());
23482 let mut revoked = successful_prepare_git_responses(workflow.yaml().as_bytes());
23483 revoked.truncate(10);
23484 revoked[9] = Err(GitExecutionError::CommandFailed { exit_code: Some(1) });
23485 responses.extend(revoked);
23486 let directory = tempdir().expect("isolation directory");
23487 let mut publisher = GitTemporaryRefPublisher::new(
23488 FakeGitRunner::with(responses),
23489 directory.path(),
23490 "source",
23491 "execution",
23492 Duration::from_secs(5),
23493 )
23494 .expect("publisher");
23495 let request = TemporaryRefPublishRequest {
23496 repository: &config.repository,
23497 source_repository: &config.source_repository,
23498 trusted_source_ref: config.workflow.trusted_source_ref(),
23499 source_revision: &source_revision,
23500 snapshot_source_ref: None,
23501 temporary_ref: &temporary_ref,
23502 workflow_path: workflow.path(),
23503 workflow_bytes: workflow.yaml().as_bytes(),
23504 workflow_fingerprint: &config.workflow_fingerprint,
23505 manifest_bytes: b"{\"schema_version\":1}\n",
23506 operation_id: "operation-1",
23507 created_at_ms: 1_700_000_000_000,
23508 authorized: true,
23509 };
23510 let prepared = publisher
23511 .prepare(&request)
23512 .expect("initial trust preflight")
23513 .expect("prepared publication");
23514 let failure = publisher
23515 .publish_prepared(&request, Some(&prepared))
23516 .expect_err("revoked source trust must block publication");
23517 assert_eq!(
23518 failure.error(),
23519 TemporaryRefPublishError::SourceRevisionNotTrusted
23520 );
23521 assert!(failure.possible_publication().is_none());
23522 let runner = publisher.into_runner();
23523 assert!(runner.responses.is_empty());
23524 assert!(!runner.invocations.iter().any(|invocation| {
23525 invocation
23526 .arguments
23527 .first()
23528 .and_then(|argument| argument.to_str())
23529 == Some("push")
23530 }));
23531 }
23532
23533 #[test]
23534 #[allow(clippy::too_many_lines)]
23535 fn git_publisher_creates_an_orphan_two_file_dispatch_commit() {
23536 let full_trusted_ref = "refs/heads/goal3/macless-iphone-builds";
23537 let full_temporary_ref = "refs/heads/rustferry/goal3/builds/operation-1";
23538 let config = provider_config(all_authorized());
23539 let workflow = generate_workflow(&config.workflow);
23540 let responses = [
23541 Ok(b"git@github.com:ShiroKSH/rust-and-iphone.git\n".to_vec()),
23542 Ok(b"ssh://git@github.com/ShiroKSH/rust-and-iphone\n".to_vec()),
23543 Ok(b"git@github.com:ShiroKSH/rustferry-signing.git\n".to_vec()),
23544 Ok(b"ssh://git@github.com/ShiroKSH/rustferry-signing\n".to_vec()),
23545 Ok(format!("{TRUSTED_TIP}\t{full_trusted_ref}\n").into_bytes()),
23546 Ok(Vec::new()),
23547 Ok(workflow.yaml().as_bytes().to_vec()),
23548 Ok(Vec::new()),
23549 Ok(Vec::new()),
23550 Ok(Vec::new()),
23551 Ok(Vec::new()),
23552 Ok(format!("{WORKFLOW_BLOB}\n").into_bytes()),
23553 Ok(format!("{MANIFEST_BLOB}\n").into_bytes()),
23554 Ok(Vec::new()),
23555 Ok(Vec::new()),
23556 Ok(format!("{TREE_SHA}\n").into_bytes()),
23557 Ok(format!("{DISPATCH_SHA}\n").into_bytes()),
23558 Ok(Vec::new()),
23559 Ok(format!("{DISPATCH_SHA}\t{full_temporary_ref}\n").into_bytes()),
23560 ];
23561 let directory = tempdir().expect("isolation directory");
23562 let runner = FakeGitRunner::with(responses);
23563 let mut publisher = GitTemporaryRefPublisher::new(
23564 runner,
23565 directory.path(),
23566 "source",
23567 "execution",
23568 Duration::from_secs(5),
23569 )
23570 .expect("publisher");
23571 let temporary_ref = TemporaryGitRef::new(
23572 config.workflow.temporary_branch_namespace(),
23573 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
23574 )
23575 .expect("temporary ref");
23576 let publication = publisher
23577 .publish(&TemporaryRefPublishRequest {
23578 repository: &config.repository,
23579 source_repository: &config.source_repository,
23580 trusted_source_ref: config.workflow.trusted_source_ref(),
23581 source_revision: &CommitSha::new(SOURCE_SHA).expect("source"),
23582 snapshot_source_ref: None,
23583 temporary_ref: &temporary_ref,
23584 workflow_path: workflow.path(),
23585 workflow_bytes: workflow.yaml().as_bytes(),
23586 workflow_fingerprint: &config.workflow_fingerprint,
23587 manifest_bytes: b"{\"schema_version\":1}\n",
23588 operation_id: "operation-1",
23589 created_at_ms: 1_700_000_000_000,
23590 authorized: true,
23591 })
23592 .expect("publication");
23593 assert_eq!(publication.commit().as_str(), DISPATCH_SHA);
23594 let runner = publisher.into_runner();
23595 assert!(runner.responses.is_empty());
23596 assert!(runner.invocations.iter().all(|invocation| {
23597 !invocation.arguments.iter().any(|argument| {
23598 matches!(
23599 argument.to_str(),
23600 Some("checkout" | "switch" | "branch" | "commit")
23601 )
23602 })
23603 }));
23604 assert!(runner.invocations.iter().all(|invocation| {
23605 !matches!(
23606 invocation
23607 .arguments
23608 .first()
23609 .and_then(|argument| argument.to_str()),
23610 Some("read-tree" | "ls-files")
23611 ) && !invocation
23612 .arguments
23613 .contains(&OsString::from("--force-remove"))
23614 }));
23615 let indexed_paths = runner
23616 .invocations
23617 .iter()
23618 .filter(|invocation| {
23619 invocation.arguments.first() == Some(&OsString::from("update-index"))
23620 })
23621 .filter_map(|invocation| invocation.arguments.last()?.to_str())
23622 .collect::<Vec<_>>();
23623 assert_eq!(
23624 indexed_paths,
23625 [
23626 ".github/workflows/rustferry-goal3-iphone.yml",
23627 DISPATCH_MANIFEST_PATH,
23628 ]
23629 );
23630 let push = runner
23631 .invocations
23632 .iter()
23633 .find(|invocation| invocation.arguments.first() == Some(&OsString::from("push")))
23634 .expect("push invocation");
23635 assert!(push.arguments.contains(&OsString::from("--no-verify")));
23636 assert!(push.arguments.contains(&OsString::from(format!(
23637 "--force-with-lease={full_temporary_ref}:"
23638 ))));
23639 assert!(push.arguments.contains(&OsString::from(format!(
23640 "{DISPATCH_SHA}:{full_temporary_ref}"
23641 ))));
23642 assert!(
23643 push.arguments
23644 .contains(&OsString::from("git@github.com:shiroksh/rustferry-signing"))
23645 );
23646 let fetch = runner
23647 .invocations
23648 .iter()
23649 .find(|invocation| invocation.arguments.first() == Some(&OsString::from("fetch")))
23650 .expect("trusted-ref fetch");
23651 assert!(
23652 fetch
23653 .arguments
23654 .contains(&OsString::from("git@github.com:shiroksh/rust-and-iphone"))
23655 );
23656 assert_eq!(
23657 runner
23658 .invocations
23659 .iter()
23660 .filter(|invocation| invocation.arguments.first()
23661 == Some(&OsString::from("hash-object")))
23662 .count(),
23663 2
23664 );
23665 let commit = runner
23666 .invocations
23667 .iter()
23668 .find(|invocation| invocation.arguments.first() == Some(&OsString::from("commit-tree")))
23669 .expect("commit-tree invocation");
23670 assert_eq!(
23671 commit.arguments,
23672 [OsString::from("commit-tree"), OsString::from(TREE_SHA)]
23673 );
23674 assert!(commit.stdin_len > 0);
23675 for expected in [
23676 (GitEnvironmentVariable::AuthorName, "ShiroKSH"),
23677 (
23678 GitEnvironmentVariable::AuthorEmail,
23679 "kushidashiro@gmail.com",
23680 ),
23681 (GitEnvironmentVariable::CommitterName, "ShiroKSH"),
23682 (
23683 GitEnvironmentVariable::CommitterEmail,
23684 "kushidashiro@gmail.com",
23685 ),
23686 ] {
23687 assert!(
23688 commit
23689 .environment
23690 .contains(&(expected.0, OsString::from(expected.1),))
23691 );
23692 }
23693 assert!(
23694 commit
23695 .environment
23696 .iter()
23697 .any(|(name, _)| *name == GitEnvironmentVariable::AuthorDate)
23698 );
23699 }
23700
23701 #[test]
23702 fn git_publisher_deletes_only_with_exact_expected_tip_lease() {
23703 let full_ref = "refs/heads/rustferry/goal3/builds/operation-1";
23704 let config = provider_config(all_authorized());
23705 let temporary_ref = TemporaryGitRef::new(
23706 config.workflow.temporary_branch_namespace(),
23707 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
23708 )
23709 .expect("temporary ref");
23710 let responses = [
23711 Ok(b"git@github.com:ShiroKSH/rustferry-signing.git\n".to_vec()),
23712 Ok(b"ssh://git@github.com/ShiroKSH/rustferry-signing\n".to_vec()),
23713 Ok(Vec::new()),
23714 Ok(Vec::new()),
23715 ];
23716 let directory = tempdir().expect("isolation directory");
23717 let mut publisher = GitTemporaryRefPublisher::new(
23718 FakeGitRunner::with(responses),
23719 directory.path(),
23720 "origin",
23721 "origin",
23722 Duration::from_secs(5),
23723 )
23724 .expect("publisher");
23725 publisher
23726 .delete_temporary_ref(&TemporaryRefDeleteRequest {
23727 repository: &config.repository,
23728 source_repository: &config.source_repository,
23729 temporary_ref: &temporary_ref,
23730 expected_commit: &CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
23731 authorized: true,
23732 })
23733 .expect("lease-protected deletion");
23734 let runner = publisher.into_runner();
23735 let push = runner
23736 .invocations
23737 .iter()
23738 .find(|invocation| invocation.arguments.first() == Some(&OsString::from("push")))
23739 .expect("delete push");
23740 assert!(push.arguments.contains(&OsString::from(format!(
23741 "--force-with-lease={full_ref}:{DISPATCH_SHA}"
23742 ))));
23743 assert!(
23744 push.arguments
23745 .contains(&OsString::from(format!(":{full_ref}")))
23746 );
23747 assert!(push.arguments.contains(&OsString::from("--atomic")));
23748 }
23749
23750 #[test]
23751 fn git_publisher_reports_changed_tip_after_delete_lease_rejection() {
23752 let full_ref = "refs/heads/rustferry/goal3/builds/operation-1";
23753 let config = provider_config(all_authorized());
23754 let temporary_ref = TemporaryGitRef::new(
23755 config.workflow.temporary_branch_namespace(),
23756 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
23757 )
23758 .expect("temporary ref");
23759 let responses = [
23760 Ok(b"git@github.com:ShiroKSH/rustferry-signing.git\n".to_vec()),
23761 Ok(b"ssh://git@github.com/ShiroKSH/rustferry-signing\n".to_vec()),
23762 Err(GitExecutionError::CommandFailed { exit_code: Some(1) }),
23763 Ok(format!("{TRUSTED_TIP}\t{full_ref}\n").into_bytes()),
23764 ];
23765 let directory = tempdir().expect("isolation directory");
23766 let mut publisher = GitTemporaryRefPublisher::new(
23767 FakeGitRunner::with(responses),
23768 directory.path(),
23769 "origin",
23770 "origin",
23771 Duration::from_secs(5),
23772 )
23773 .expect("publisher");
23774 assert_eq!(
23775 publisher.delete_temporary_ref(&TemporaryRefDeleteRequest {
23776 repository: &config.repository,
23777 source_repository: &config.source_repository,
23778 temporary_ref: &temporary_ref,
23779 expected_commit: &CommitSha::new(DISPATCH_SHA).expect("dispatch commit"),
23780 authorized: true,
23781 }),
23782 Err(TemporaryRefPublishError::TemporaryRefLeaseRejected)
23783 );
23784 }
23785
23786 #[test]
23787 fn provider_submit_publishes_exact_request_and_queues_without_fake_success() {
23788 let config = provider_config(all_authorized());
23789 let provider = GithubBuildProvider::with_artifact_store_and_clock(
23790 config.clone(),
23791 signed_transport(FakeGhRunner::default()),
23792 FakePublisher::default(),
23793 NoVerifiedArtifactStore,
23794 FixedClock(1_700_000_000_000),
23795 );
23796 let handle =
23797 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
23798 assert_eq!(handle.job_id, "operation-1");
23799 assert_eq!(handle.state, JobState::Queued);
23800 let publisher = provider.publisher.lock().expect("publisher");
23801 let captured = publisher.published.first().expect("publication");
23802 assert_eq!(captured.source_revision, SOURCE_SHA);
23803 assert_eq!(captured.branch, "rustferry/goal3/builds/operation-1");
23804 assert_eq!(
23805 captured.workflow_path,
23806 ".github/workflows/rustferry-goal3-iphone.yml"
23807 );
23808 assert_eq!(
23809 captured.workflow_sha256,
23810 config.workflow_fingerprint.as_str()
23811 );
23812 assert_eq!(captured.manifest.request, valid_request());
23813 assert!(captured.authorized);
23814 assert!(publisher.publication_leases.is_empty());
23815 assert!(!provider.capabilities().artifact_download);
23816 let state = provider.state.lock().expect("state");
23817 let record = state.jobs.get("operation-1").expect("job record");
23818 assert_eq!(record.request, valid_request());
23819 assert_eq!(
23820 record.request_sha256,
23821 canonical_request_sha256(&record.request).expect("request hash")
23822 );
23823 }
23824
23825 #[test]
23826 fn signed_submission_rechecks_private_visibility_before_publication() {
23827 let runner = FakeGhRunner::with([
23828 Ok(public_source_repository_row()),
23829 Ok(b"991\tShiroKSH/rustferry-signing\tfalse\tfalse\tfalse\tmain\n".to_vec()),
23830 ]);
23831 let captured_requests = Arc::clone(&runner.requests);
23832 let provider = GithubBuildProvider::with_artifact_store_and_clock(
23833 provider_config(all_authorized()),
23834 transport(runner),
23835 FakePublisher::default(),
23836 NoVerifiedArtifactStore,
23837 FixedClock(1_700_000_000_000),
23838 );
23839
23840 let error = poll_ready(provider.submit(valid_request(), CancellationToken::new()))
23841 .expect_err("public signing repository must fail closed");
23842 assert!(matches!(
23843 error,
23844 RemoteBuildError::ProviderFailure { code, retryable: false, .. }
23845 if code == "public_signing_repository"
23846 ));
23847 assert_eq!(captured_requests.lock().expect("requests").len(), 2);
23848 assert!(
23849 provider
23850 .publisher
23851 .lock()
23852 .expect("publisher")
23853 .published
23854 .is_empty()
23855 );
23856 assert!(provider.state.lock().expect("state").jobs.is_empty());
23857 }
23858
23859 #[test]
23860 fn submission_requires_worker_supported_artifacts_and_bounded_profiles() {
23861 let config = provider_config(all_authorized());
23862 let provider = GithubBuildProvider::with_artifact_store_and_clock(
23863 config.clone(),
23864 signed_transport(FakeGhRunner::default()),
23865 FakePublisher::default(),
23866 NoVerifiedArtifactStore,
23867 FixedClock(1_700_000_000_000),
23868 );
23869 assert_eq!(
23870 provider.capabilities().artifact_types,
23871 BTreeSet::from([
23872 IosArtifactType::Ipa,
23873 IosArtifactType::AppBundle,
23874 IosArtifactType::Dsym,
23875 IosArtifactType::SigningReport,
23876 IosArtifactType::Xcarchive,
23877 ])
23878 );
23879 assert_eq!(
23880 provider.capabilities().signing_modes,
23881 BTreeSet::from([
23882 SigningMode::ManualDevelopment,
23883 SigningMode::UnsignedCompileOnly,
23884 ])
23885 );
23886
23887 let mut optional_artifacts = valid_request();
23888 optional_artifacts.requested_artifacts.extend([
23889 IosArtifactType::AppBundle,
23890 IosArtifactType::Dsym,
23891 IosArtifactType::Xcarchive,
23892 ]);
23893 validate_submission(&config, &optional_artifacts)
23894 .expect("provider accepts supported optional signed artifacts");
23895
23896 let mut missing_ipa = valid_request();
23897 missing_ipa
23898 .requested_artifacts
23899 .remove(&IosArtifactType::Ipa);
23900 assert_eq!(
23901 validate_submission(&config, &missing_ipa).expect_err("signed IPA is mandatory"),
23902 RemoteBuildError::InvalidEventPayload {
23903 event: "ios_device_build_request",
23904 reason: "signed builds must request the installable IPA",
23905 }
23906 );
23907
23908 let mut extra_artifact = valid_request();
23909 extra_artifact
23910 .requested_artifacts
23911 .insert(IosArtifactType::ProvisioningReport);
23912 assert_eq!(
23913 poll_ready(provider.submit(extra_artifact, CancellationToken::new()))
23914 .expect_err("worker does not produce provisioning reports"),
23915 unsupported(ProviderFeature::ArtifactType(
23916 IosArtifactType::ProvisioningReport
23917 ))
23918 );
23919
23920 let mut too_many_profiles = valid_request();
23921 for _ in 0..MAX_SIGNING_PROFILES {
23922 too_many_profiles
23923 .signing
23924 .provisioning
23925 .push(too_many_profiles.signing.provisioning[0].clone());
23926 }
23927 let error = poll_ready(provider.submit(too_many_profiles, CancellationToken::new()))
23928 .expect_err("more than three profiles must fail closed");
23929 assert!(matches!(
23930 error,
23931 RemoteBuildError::ProviderFailure { code, .. }
23932 if code == "profile_mapping_unsupported"
23933 ));
23934 }
23935
23936 #[test]
23937 fn submission_accepts_exact_static_multi_profile_map_and_rejects_role_swaps() {
23938 let (request, names) = multi_profile_request();
23939 let config = provider_config_with_secret_names(all_authorized(), names);
23940 validate_submission(&config, &request).expect("exact multi-profile map");
23941
23942 let mut swapped = request.clone();
23943 let first = swapped.signing.provisioning[0].profile.clone();
23944 swapped.signing.provisioning[0].profile = swapped.signing.provisioning[1].profile.clone();
23945 swapped.signing.provisioning[1].profile = first;
23946 let error = validate_submission(&config, &swapped).expect_err("profile roles are bound");
23947 assert!(matches!(
23948 error,
23949 RemoteBuildError::ProviderFailure { code, retryable: false, .. }
23950 if code == "signing_secret_reference_mismatch"
23951 ));
23952
23953 let legacy = provider_config(all_authorized());
23954 let error = validate_submission(&legacy, &request)
23955 .expect_err("legacy profile binding must remain single-profile only");
23956 assert!(matches!(
23957 error,
23958 RemoteBuildError::ProviderFailure { code, retryable: false, .. }
23959 if code == "signing_secret_reference_mismatch"
23960 ));
23961 }
23962
23963 #[test]
23964 fn submission_rejects_application_and_extension_identity_drift() {
23965 let (request, names) = multi_profile_request();
23966 let config = provider_config_with_secret_names(all_authorized(), names);
23967
23968 let mut app_bundle_drift = request.clone();
23969 app_bundle_drift.bundle_identifier = "com.example.renamed-app".to_owned();
23970 app_bundle_drift
23971 .signing
23972 .targets
23973 .iter_mut()
23974 .find(|target| target.kind == SigningTargetKind::Application)
23975 .expect("application target")
23976 .bundle_identifier =
23977 BundleIdentifier::new("com.example.renamed-app").expect("drifted app bundle");
23978 app_bundle_drift
23979 .validate()
23980 .expect("internally valid app bundle drift");
23981 assert_signing_reference_mismatch(&config, &app_bundle_drift);
23982
23983 let mut extension_bundle_drift = request.clone();
23984 extension_bundle_drift
23985 .signing
23986 .targets
23987 .iter_mut()
23988 .find(|target| target.kind == SigningTargetKind::Extension)
23989 .expect("extension target")
23990 .bundle_identifier = BundleIdentifier::new("com.example.app.renamed-widget")
23991 .expect("drifted extension bundle");
23992 extension_bundle_drift
23993 .product
23994 .nested_bundles
23995 .iter_mut()
23996 .find(|bundle| bundle.kind == UnsignedNestedBundleKind::AppExtension)
23997 .expect("extension bundle")
23998 .bundle_identifier = "com.example.app.renamed-widget".to_owned();
23999 extension_bundle_drift
24000 .validate()
24001 .expect("internally valid extension bundle drift");
24002 assert_signing_reference_mismatch(&config, &extension_bundle_drift);
24003
24004 let mut extension_name_drift = request.clone();
24005 extension_name_drift
24006 .signing
24007 .targets
24008 .iter_mut()
24009 .find(|target| target.kind == SigningTargetKind::Extension)
24010 .expect("extension target")
24011 .name = "RenamedWidget".to_owned();
24012 extension_name_drift
24013 .signing
24014 .provisioning
24015 .iter_mut()
24016 .find(|profile| profile.target == "Widget")
24017 .expect("extension profile")
24018 .target = "RenamedWidget".to_owned();
24019 extension_name_drift
24020 .signing
24021 .entitlements
24022 .iter_mut()
24023 .find(|entitlements| entitlements.target == "Widget")
24024 .expect("extension entitlements")
24025 .target = "RenamedWidget".to_owned();
24026 extension_name_drift
24027 .validate()
24028 .expect("internally valid extension name drift");
24029 assert_signing_reference_mismatch(&config, &extension_name_drift);
24030 }
24031
24032 #[test]
24033 fn submission_target_graph_match_is_order_insensitive_and_exact() {
24034 let (request, names) = multi_profile_request_with_framework();
24035 let config = provider_config_with_secret_names(all_authorized(), names);
24036 validate_submission(&config, &request).expect("exact full target graph");
24037
24038 let mut reordered = request.clone();
24039 reordered.signing.targets.reverse();
24040 reordered.validate().expect("valid reordered target graph");
24041 validate_submission(&config, &reordered).expect("target order is not identity");
24042
24043 let mut omitted = request.clone();
24044 omitted
24045 .signing
24046 .targets
24047 .retain(|target| target.name != "RuntimeBridge");
24048 omitted
24049 .product
24050 .nested_bundles
24051 .retain(|bundle| bundle.executable != "RuntimeBridge");
24052 omitted.validate().expect("valid omitted framework graph");
24053 assert_signing_reference_mismatch(&config, &omitted);
24054
24055 let mut extra = request.clone();
24056 extra.signing.targets.push(SigningTarget {
24057 name: "SupportKit".to_owned(),
24058 bundle_identifier: BundleIdentifier::new("com.example.app.support-kit")
24059 .expect("extra framework bundle"),
24060 kind: SigningTargetKind::Framework,
24061 });
24062 extra
24063 .product
24064 .nested_bundles
24065 .push(UnsignedNestedBundleExpectation {
24066 relative_path: "Frameworks/SupportKit.framework".to_owned(),
24067 bundle_identifier: "com.example.app.support-kit".to_owned(),
24068 executable: "SupportKit".to_owned(),
24069 kind: UnsignedNestedBundleKind::Framework,
24070 });
24071 extra
24072 .product
24073 .nested_bundles
24074 .sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
24075 extra.validate().expect("valid extra framework graph");
24076 assert_signing_reference_mismatch(&config, &extra);
24077 }
24078
24079 #[test]
24080 fn signed_secrets_are_exact_by_kind_and_role_while_unsigned_xcarchive_is_accepted() {
24081 let runner = FakeGhRunner::default();
24082 let captured_requests = Arc::clone(&runner.requests);
24083 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24084 provider_config(all_authorized()),
24085 transport(runner),
24086 FakePublisher::default(),
24087 NoVerifiedArtifactStore,
24088 FixedClock(1_700_000_000_000),
24089 );
24090
24091 let mut wrong_kind = valid_request();
24092 wrong_kind
24093 .signing
24094 .signing
24095 .as_mut()
24096 .expect("signing")
24097 .identity
24098 .private_key
24099 .reference = SecretReference::new(
24100 SecretReferenceKind::Environment,
24101 "RUSTFERRY_GOAL3_IOS_CERTIFICATE_P12",
24102 )
24103 .expect("environment reference");
24104 let error = poll_ready(provider.submit(wrong_kind, CancellationToken::new()))
24105 .expect_err("wrong secret namespace");
24106 assert!(matches!(
24107 error,
24108 RemoteBuildError::ProviderFailure { code, .. }
24109 if code == "signing_secret_reference_mismatch"
24110 ));
24111
24112 let mut swapped_roles = valid_request();
24113 let signing = swapped_roles.signing.signing.as_mut().expect("signing");
24114 signing.identity.private_key.reference = SecretReference::new(
24115 SecretReferenceKind::GithubActions,
24116 "RUSTFERRY_GOAL3_IOS_CERTIFICATE_PASSWORD",
24117 )
24118 .expect("password role");
24119 signing.password = Some(
24120 SecretReference::new(
24121 SecretReferenceKind::GithubActions,
24122 "RUSTFERRY_GOAL3_IOS_CERTIFICATE_P12",
24123 )
24124 .expect("certificate role"),
24125 );
24126 let error = poll_ready(provider.submit(swapped_roles, CancellationToken::new()))
24127 .expect_err("secret roles are not interchangeable");
24128 assert!(matches!(
24129 error,
24130 RemoteBuildError::ProviderFailure { code, .. }
24131 if code == "signing_secret_reference_mismatch"
24132 ));
24133
24134 let unsigned = unsigned_request();
24135 let handle = poll_ready(provider.submit(unsigned.clone(), CancellationToken::new()))
24136 .expect("unsigned xcarchive request");
24137 assert_eq!(handle.state, JobState::Queued);
24138 assert_eq!(
24139 provider
24140 .publisher
24141 .lock()
24142 .expect("publisher")
24143 .published
24144 .first()
24145 .expect("publication")
24146 .manifest
24147 .request,
24148 unsigned
24149 );
24150 assert!(captured_requests.lock().expect("requests").is_empty());
24151 }
24152
24153 #[test]
24154 fn failed_pre_push_submission_releases_job_reservation() {
24155 let publisher = FakePublisher {
24156 failure: Some(TemporaryRefPublishError::RemoteMismatch.into()),
24157 ..FakePublisher::default()
24158 };
24159 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24160 provider_config(all_authorized()),
24161 signed_transport(FakeGhRunner::with([
24162 Ok(public_source_repository_row()),
24163 Ok(private_repository_row()),
24164 Ok(signing_environment_row(false, true, true)),
24165 Ok(exact_signing_policy_row()),
24166 Ok(exact_signing_secret_rows()),
24167 ])),
24168 publisher,
24169 NoVerifiedArtifactStore,
24170 FixedClock(1_700_000_000_000),
24171 );
24172 poll_ready(provider.submit(valid_request(), CancellationToken::new()))
24173 .expect_err("first publication fails before push");
24174 let handle = poll_ready(provider.submit(valid_request(), CancellationToken::new()))
24175 .expect("reservation was released");
24176 assert_eq!(handle.job_id, "operation-1");
24177 }
24178
24179 #[test]
24180 fn cancellation_during_successful_push_returns_visible_handle() {
24181 let cancellation = CancellationToken::new();
24182 let publisher = FakePublisher {
24183 cancel_on_publish: Some(cancellation.clone()),
24184 ..FakePublisher::default()
24185 };
24186 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24187 provider_config(all_authorized()),
24188 signed_transport(FakeGhRunner::default()),
24189 publisher,
24190 NoVerifiedArtifactStore,
24191 FixedClock(1_700_000_000_000),
24192 );
24193 let handle = poll_ready(provider.submit(valid_request(), cancellation))
24194 .expect("published job remains visible");
24195 assert_eq!(handle.job_id, "operation-1");
24196 assert_eq!(handle.state, JobState::Cancelling);
24197 assert!(
24198 provider
24199 .state
24200 .lock()
24201 .expect("state")
24202 .jobs
24203 .contains_key("operation-1")
24204 );
24205 }
24206
24207 #[test]
24208 fn signed_submission_rejects_same_source_and_execution_repository() {
24209 let runner = FakeGhRunner::default();
24210 let captured_requests = Arc::clone(&runner.requests);
24211 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24212 same_repository_provider_config(all_authorized()),
24213 transport(runner),
24214 FakePublisher::default(),
24215 NoVerifiedArtifactStore,
24216 FixedClock(1_700_000_000_000),
24217 );
24218
24219 let error = poll_ready(provider.submit(valid_request(), CancellationToken::new()))
24220 .expect_err("same-repository signing must fail closed");
24221 assert!(matches!(
24222 error,
24223 RemoteBuildError::ProviderFailure { code, retryable: false, .. }
24224 if code == "separate_signing_repository_required"
24225 ));
24226 assert!(captured_requests.lock().expect("requests").is_empty());
24227 assert!(
24228 provider
24229 .publisher
24230 .lock()
24231 .expect("publisher")
24232 .published
24233 .is_empty()
24234 );
24235 }
24236
24237 #[test]
24238 fn signed_submission_rechecks_public_source_visibility_before_publication() {
24239 let runner = FakeGhRunner::with([Ok(private_source_repository_row())]);
24240 let captured_requests = Arc::clone(&runner.requests);
24241 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24242 provider_config(all_authorized()),
24243 transport(runner),
24244 FakePublisher::default(),
24245 NoVerifiedArtifactStore,
24246 FixedClock(1_700_000_000_000),
24247 );
24248
24249 let error = poll_ready(provider.submit(valid_request(), CancellationToken::new()))
24250 .expect_err("private source repository must fail closed");
24251 assert!(matches!(
24252 error,
24253 RemoteBuildError::ProviderFailure { code, retryable: false, .. }
24254 if code == "private_source_repository"
24255 ));
24256 assert_eq!(captured_requests.lock().expect("requests").len(), 1);
24257 assert!(
24258 provider
24259 .publisher
24260 .lock()
24261 .expect("publisher")
24262 .published
24263 .is_empty()
24264 );
24265 }
24266
24267 #[test]
24268 fn signed_submission_rechecks_required_environment_reviewers_before_publication() {
24269 let runner = FakeGhRunner::with([
24270 Ok(public_source_repository_row()),
24271 Ok(private_repository_row()),
24272 Ok(signing_environment_row(false, true, false)),
24273 Ok(exact_signing_policy_row()),
24274 Ok(exact_signing_secret_rows()),
24275 ]);
24276 let captured_requests = Arc::clone(&runner.requests);
24277 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24278 provider_config(all_authorized()),
24279 transport(runner),
24280 FakePublisher::default(),
24281 NoVerifiedArtifactStore,
24282 FixedClock(1_700_000_000_000),
24283 );
24284
24285 let error = poll_ready(provider.submit(valid_request(), CancellationToken::new()))
24286 .expect_err("missing deployment reviewer must fail closed");
24287 assert!(matches!(
24288 error,
24289 RemoteBuildError::ProviderFailure { code, retryable: false, .. }
24290 if code == "signing_environment_not_ready"
24291 ));
24292 assert_eq!(captured_requests.lock().expect("requests").len(), 5);
24293 assert!(
24294 provider
24295 .publisher
24296 .lock()
24297 .expect("publisher")
24298 .published
24299 .is_empty()
24300 );
24301 }
24302
24303 #[test]
24304 fn uncertain_push_requires_exact_reconciliation_before_normal_polling() {
24305 let publication = PublishedTemporaryRef::new(
24306 TemporaryGitRef::new(
24307 provider_config(all_authorized())
24308 .workflow
24309 .temporary_branch_namespace(),
24310 BranchName::new("rustferry/goal3/builds/operation-1").expect("branch"),
24311 )
24312 .expect("temporary ref"),
24313 CommitSha::new(DISPATCH_SHA).expect("dispatch SHA"),
24314 );
24315 let publisher = FakePublisher {
24316 prepared_publication: Some(publication.clone()),
24317 failure: Some(TemporaryRefPublishFailure::uncertain(
24318 TemporaryRefPublishError::Git(GitExecutionError::TimedOut),
24319 publication.clone(),
24320 )),
24321 reconciliation: Some(TemporaryRefReconciliation::Exact(publication)),
24322 ..FakePublisher::default()
24323 };
24324 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24325 provider_config(all_authorized()),
24326 transport(FakeGhRunner::with([Ok(Vec::new())])),
24327 publisher,
24328 NoVerifiedArtifactStore,
24329 FixedClock(1_700_000_000_000),
24330 );
24331 let handle = poll_ready(provider.submit(unsigned_request(), CancellationToken::new()))
24332 .expect("uncertain publication returns owned handle");
24333 assert_eq!(handle.state, JobState::Created);
24334 let state = provider.state.lock().expect("state");
24335 let record = state.jobs.get("operation-1").expect("durable record");
24336 assert!(record.publication_uncertain);
24337 assert!(record.dispatch_commit.is_none());
24338 assert!(record.publication_takeover_lease_owned);
24339 drop(state);
24340
24341 let error = poll_ready(provider.events(
24342 EventRequest {
24343 job_id: "operation-1".to_owned(),
24344 after_sequence: None,
24345 limit: 100,
24346 },
24347 CancellationToken::new(),
24348 ))
24349 .expect_err("ordinary polling must not adopt a possible push");
24350 assert!(matches!(
24351 error,
24352 RemoteBuildError::ProviderFailure {
24353 code,
24354 retryable: true,
24355 ..
24356 } if code == "publication_reconciliation_required"
24357 ));
24358
24359 assert_eq!(
24360 provider
24361 .reconcile_restored_job("operation-1", &CancellationToken::new())
24362 .expect("exact possible-push reconciliation"),
24363 GithubJobReconciliation::Recovered
24364 );
24365 let state = provider.state.lock().expect("state");
24366 let record = state.jobs.get("operation-1").expect("reconciled record");
24367 assert_eq!(record.state, JobState::Queued);
24368 assert!(!record.publication_uncertain);
24369 assert_eq!(
24370 record.dispatch_commit.as_ref().map(CommitSha::as_str),
24371 Some(DISPATCH_SHA)
24372 );
24373 assert!(!record.publication_takeover_lease_owned);
24374 }
24375
24376 #[test]
24377 fn publisher_binding_mismatch_fails_without_adopting_cleanup_ownership() {
24378 let config = provider_config(all_authorized());
24379 let returned_ref = TemporaryGitRef::new(
24380 config.workflow.temporary_branch_namespace(),
24381 BranchName::new("rustferry/goal3/builds/unexpected").expect("branch"),
24382 )
24383 .expect("temporary ref");
24384 let publisher = FakePublisher {
24385 returned_temporary_ref: Some(returned_ref.clone()),
24386 ..FakePublisher::default()
24387 };
24388 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24389 config,
24390 signed_transport(FakeGhRunner::default()),
24391 publisher,
24392 NoVerifiedArtifactStore,
24393 FixedClock(1_700_000_000_000),
24394 );
24395 let handle = poll_ready(provider.submit(valid_request(), CancellationToken::new()))
24396 .expect("possibly mutated ref retains a visible handle");
24397 assert_eq!(handle.state, JobState::Failed);
24398 let state = provider.state.lock().expect("state");
24399 let record = state.jobs.get("operation-1").expect("owned record");
24400 assert_ne!(record.temporary_ref, returned_ref);
24401 assert_eq!(
24402 record.temporary_ref.branch().as_str(),
24403 "rustferry/goal3/builds/operation-1"
24404 );
24405 assert_eq!(record.dispatch_commit, None);
24406 assert!(record.publication_uncertain);
24407 }
24408
24409 #[test]
24410 fn run_discovery_exhaustion_becomes_typed_cleanup_needed_failure() {
24411 let mut config = provider_config(all_authorized());
24412 config.poll_policy =
24413 GithubPollingPolicy::new(2, Duration::from_millis(250)).expect("policy");
24414 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24415 config,
24416 signed_transport(FakeGhRunner::with([Ok(Vec::new()), Ok(Vec::new())])),
24417 FakePublisher::default(),
24418 NoVerifiedArtifactStore,
24419 FixedClock(1_700_000_000_000),
24420 );
24421 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
24422 let events = || EventRequest {
24423 job_id: "operation-1".to_owned(),
24424 after_sequence: None,
24425 limit: 100,
24426 };
24427 let first = poll_ready(provider.events(events(), CancellationToken::new()))
24428 .expect("first discovery attempt");
24429 assert_eq!(first.state, JobState::Queued);
24430 let second = poll_ready(provider.events(events(), CancellationToken::new()))
24431 .expect("bounded discovery failure");
24432 assert_eq!(second.state, JobState::Failed);
24433 assert!(!second.complete);
24434 assert!(second.events.iter().any(|event| matches!(
24435 &event.kind,
24436 RemoteBuildEventKind::Warning { code, .. } if code == "github.cleanup_required"
24437 )));
24438 assert!(second.events.iter().any(|event| matches!(
24439 &event.kind,
24440 RemoteBuildEventKind::OperationFinished {
24441 success: false,
24442 error: Some(error),
24443 ..
24444 } if error.code == "github.run_discovery_exhausted"
24445 )));
24446 }
24447
24448 #[test]
24449 fn cleanup_uses_one_exact_lease_and_refuses_a_changed_tip() {
24450 let mut config = provider_config(all_authorized());
24451 config.poll_policy =
24452 GithubPollingPolicy::new(1, Duration::from_millis(250)).expect("policy");
24453 let runner = FakeGhRunner::with([Ok(Vec::new())]);
24454 let captured_requests = Arc::clone(&runner.requests);
24455 let publisher = FakePublisher {
24456 delete_failure: Some(TemporaryRefPublishError::TemporaryRefLeaseRejected),
24457 ..FakePublisher::default()
24458 };
24459 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24460 config,
24461 signed_transport(runner),
24462 publisher,
24463 NoVerifiedArtifactStore,
24464 FixedClock(1_700_000_000_000),
24465 );
24466 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
24467 let page = poll_ready(provider.events(
24468 EventRequest {
24469 job_id: "operation-1".to_owned(),
24470 after_sequence: None,
24471 limit: 100,
24472 },
24473 CancellationToken::new(),
24474 ))
24475 .expect("bounded discovery failure");
24476 assert_eq!(page.state, JobState::Failed);
24477 let request_count_before_cleanup = captured_requests.lock().expect("requests").len();
24478 let error = poll_ready(provider.cleanup(
24479 CleanupRequest {
24480 job_id: "operation-1".to_owned(),
24481 remove_artifacts: false,
24482 },
24483 CancellationToken::new(),
24484 ))
24485 .expect_err("changed remote ref must not be deleted");
24486 assert!(matches!(
24487 error,
24488 RemoteBuildError::ProviderFailure { code, .. }
24489 if code == "temporary_ref_tip_changed"
24490 ));
24491 assert_eq!(
24492 captured_requests.lock().expect("captured requests").len(),
24493 request_count_before_cleanup,
24494 "cleanup must not issue a GitHub DELETE after a tip mismatch"
24495 );
24496 let publisher = provider.publisher.lock().expect("publisher");
24497 let deletion = publisher.deletions.first().expect("delete lease");
24498 assert_eq!(deletion.branch, "rustferry/goal3/builds/operation-1");
24499 assert_eq!(deletion.expected_commit, DISPATCH_SHA);
24500 assert!(deletion.authorized);
24501 }
24502
24503 #[test]
24504 fn unsupported_artifact_removal_does_not_bind_cleanup_intent() {
24505 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24506 provider_config(all_authorized()),
24507 transport(FakeGhRunner::with([
24508 Ok(authenticated_user_row()),
24509 Ok(private_repository_row()),
24510 Ok(completed_run_row("success")),
24511 Ok(authenticated_user_row()),
24512 Ok(private_repository_row()),
24513 Ok(completed_run_row("success")),
24514 Ok(authenticated_user_row()),
24515 Ok(private_repository_row()),
24516 Ok(completed_run_row("success")),
24517 Ok(authenticated_user_row()),
24518 Ok(private_repository_row()),
24519 Ok(authenticated_user_row()),
24520 Ok(private_repository_row()),
24521 Ok(completed_run_row("success")),
24522 Ok(authenticated_user_row()),
24523 Ok(private_repository_row()),
24524 ])),
24525 FakePublisher::default(),
24526 NoVerifiedArtifactStore,
24527 FixedClock(1_700_000_000_000),
24528 );
24529 provider
24530 .restore_job_resumes(vec![durable_success_resume()])
24531 .expect("restore successful job");
24532
24533 let error = poll_ready(provider.cleanup(
24534 CleanupRequest {
24535 job_id: "operation-1".to_owned(),
24536 remove_artifacts: true,
24537 },
24538 CancellationToken::new(),
24539 ))
24540 .expect_err("unsupported removal must fail before durable intent");
24541 assert!(matches!(
24542 error,
24543 RemoteBuildError::ProviderFailure { code, .. }
24544 if code == "artifact_removal_unsupported"
24545 ));
24546 {
24547 let state = provider.state.lock().expect("state");
24548 let record = state.jobs.get("operation-1").expect("job");
24549 assert!(!record.cleanup_requested);
24550 assert!(!record.remove_artifacts_requested);
24551 assert!(!record.artifacts_removed);
24552 assert!(!record.temporary_ref_deleted);
24553 }
24554
24555 poll_ready(provider.cleanup(
24556 CleanupRequest {
24557 job_id: "operation-1".to_owned(),
24558 remove_artifacts: false,
24559 },
24560 CancellationToken::new(),
24561 ))
24562 .expect("ref-only retry remains available");
24563 assert_eq!(
24564 provider
24565 .publisher
24566 .lock()
24567 .expect("publisher")
24568 .deletions
24569 .len(),
24570 1
24571 );
24572 }
24573
24574 #[test]
24575 fn cleanup_revalidates_exact_run_attempt_before_binding_or_mutating() {
24576 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24577 provider_config(all_authorized()),
24578 transport(FakeGhRunner::with([
24579 Ok(authenticated_user_row()),
24580 Ok(private_repository_row()),
24581 Ok(completed_run_row("success")),
24582 Ok(authenticated_user_row()),
24583 Ok(private_repository_row()),
24584 Ok(queued_run_attempt_row(2)),
24585 ])),
24586 FakePublisher::default(),
24587 NoVerifiedArtifactStore,
24588 FixedClock(1_700_000_000_000),
24589 );
24590 provider
24591 .restore_job_resumes(vec![durable_success_resume()])
24592 .expect("restore successful attempt");
24593
24594 assert!(
24595 poll_ready(provider.cleanup(
24596 CleanupRequest {
24597 job_id: "operation-1".to_owned(),
24598 remove_artifacts: false,
24599 },
24600 CancellationToken::new(),
24601 ))
24602 .is_err()
24603 );
24604 let state = provider.lock_state().expect("state");
24605 let record = state.jobs.get("operation-1").expect("job");
24606 assert!(!record.cleanup_requested);
24607 assert!(!record.temporary_ref_deleted);
24608 assert!(
24609 provider
24610 .publisher
24611 .lock()
24612 .expect("publisher")
24613 .deletions
24614 .is_empty()
24615 );
24616 }
24617
24618 #[test]
24619 fn cleanup_never_confirms_after_a_rerun_starts_during_ref_deletion() {
24620 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24621 provider_config(all_authorized()),
24622 transport(FakeGhRunner::with([
24623 Ok(authenticated_user_row()),
24624 Ok(private_repository_row()),
24625 Ok(completed_run_row("success")),
24626 Ok(authenticated_user_row()),
24627 Ok(private_repository_row()),
24628 Ok(completed_run_row("success")),
24629 Ok(authenticated_user_row()),
24630 Ok(private_repository_row()),
24631 Ok(completed_run_row("success")),
24632 Ok(authenticated_user_row()),
24633 Ok(private_repository_row()),
24634 Ok(authenticated_user_row()),
24635 Ok(private_repository_row()),
24636 Ok(queued_run_attempt_row(2)),
24637 ])),
24638 FakePublisher::default(),
24639 NoVerifiedArtifactStore,
24640 FixedClock(1_700_000_000_000),
24641 );
24642 provider
24643 .restore_job_resumes(vec![durable_success_resume()])
24644 .expect("restore successful attempt");
24645
24646 let error = poll_ready(provider.cleanup(
24647 CleanupRequest {
24648 job_id: "operation-1".to_owned(),
24649 remove_artifacts: false,
24650 },
24651 CancellationToken::new(),
24652 ))
24653 .expect_err("rerun drift must prevent positive cleanup confirmation");
24654 assert!(
24655 matches!(
24656 &error,
24657 RemoteBuildError::ProviderFailure {
24658 code,
24659 retryable: true,
24660 ..
24661 } if code == "cleanup_run_changed_after_mutation"
24662 ),
24663 "{error:?}"
24664 );
24665 let state = provider.lock_state().expect("state");
24666 let record = state.jobs.get("operation-1").expect("job");
24667 assert!(record.temporary_ref_deleted);
24668 assert_ne!(record.state, JobState::Cleaned);
24669 assert!(
24670 !record
24671 .events
24672 .iter()
24673 .any(|event| matches!(event.kind, RemoteBuildEventKind::CleanupFinished { .. }))
24674 );
24675 drop(state);
24676 assert_eq!(
24677 provider
24678 .publisher
24679 .lock()
24680 .expect("publisher")
24681 .deletions
24682 .len(),
24683 1
24684 );
24685 }
24686
24687 #[test]
24688 fn signed_cleanup_evidence_survives_a_distinct_later_attempt() {
24689 let mut responses = vec![
24690 Ok(authenticated_user_row()),
24691 Ok(private_repository_row()),
24692 Ok(queued_run_attempt_row(2)),
24693 ];
24694 for response in [
24695 authenticated_user_row(),
24696 private_repository_row(),
24697 queued_run_attempt_row(2),
24698 authenticated_user_row(),
24699 private_repository_row(),
24700 queued_run_attempt_row(2),
24701 authenticated_user_row(),
24702 private_repository_row(),
24703 authenticated_user_row(),
24704 private_repository_row(),
24705 queued_run_attempt_row(2),
24706 authenticated_user_row(),
24707 private_repository_row(),
24708 ] {
24709 responses.push(Ok(response));
24710 }
24711 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24712 provider_config(all_authorized()),
24713 transport(FakeGhRunner::with(responses)),
24714 FakePublisher::default(),
24715 NoVerifiedArtifactStore,
24716 FixedClock(1_700_000_000_000),
24717 );
24718 provider
24719 .restore_job_resumes(vec![durable_signed_success_resume()])
24720 .expect("restore attempt-one evidence while attempt two exists");
24721
24722 let confirmation = poll_ready(provider.cleanup(
24723 CleanupRequest {
24724 job_id: "operation-1".to_owned(),
24725 remove_artifacts: false,
24726 },
24727 CancellationToken::new(),
24728 ))
24729 .expect("attempt-one cleanup evidence remains valid");
24730
24731 assert!(confirmation.workspace_removed);
24732 assert!(confirmation.signing_material_removed);
24733 let state = provider.lock_state().expect("state");
24734 let record = state.jobs.get("operation-1").expect("job");
24735 assert_eq!(record.state, JobState::Cleaned);
24736 assert_eq!(
24737 record
24738 .run_snapshot
24739 .as_ref()
24740 .expect("bound attempt")
24741 .run_attempt(),
24742 1
24743 );
24744 }
24745
24746 #[test]
24747 fn signed_cleanup_evidence_is_bind_once_and_rejects_attempt_replay() {
24748 let resume = durable_signed_success_resume();
24749 let mut replay = resume.clone();
24750 replay
24751 .signed_cleanup_evidence
24752 .as_mut()
24753 .expect("evidence")
24754 .run
24755 .run_attempt = 2;
24756 assert!(
24757 replay
24758 .signed_cleanup_evidence
24759 .as_ref()
24760 .expect("evidence")
24761 .validate_for_resume(&replay)
24762 .is_err()
24763 );
24764 assert!(resume.validate_successor(&replay).is_err());
24765
24766 let mut removed = resume.clone();
24767 removed.signed_cleanup_evidence = None;
24768 assert!(resume.validate_successor(&removed).is_err());
24769 }
24770
24771 #[test]
24772 fn signed_cleanup_evidence_rejects_manifest_and_envelope_forgery() {
24773 let resume = durable_signed_success_resume();
24774 let mut candidates = Vec::new();
24775
24776 let mut candidate = resume.clone();
24777 candidate.manifests[0].operation_id = "other-operation".to_owned();
24778 candidates.push(candidate);
24779 let mut candidate = resume.clone();
24780 candidate.manifests[0].job_id = "other-job".to_owned();
24781 candidates.push(candidate);
24782 let mut candidate = resume.clone();
24783 candidate.manifests[0].source_repository =
24784 Some("https://github.com/other/source".to_owned());
24785 candidates.push(candidate);
24786 let mut candidate = resume.clone();
24787 candidate.manifests[0].source_sha256 = "9".repeat(64);
24788 candidates.push(candidate);
24789 let mut candidate = resume.clone();
24790 candidate.manifests[0]
24791 .artifacts
24792 .iter_mut()
24793 .find(|artifact| artifact.kind == ArtifactKind::SigningReport)
24794 .expect("signing report")
24795 .sha256 = "8".repeat(64);
24796 candidates.push(candidate);
24797 let mut candidate = resume.clone();
24798 candidate
24799 .signed_cleanup_evidence
24800 .as_mut()
24801 .expect("evidence")
24802 .execution_repository_id += 1;
24803 candidates.push(candidate);
24804 let mut candidate = resume.clone();
24805 candidate
24806 .signed_cleanup_evidence
24807 .as_mut()
24808 .expect("evidence")
24809 .dispatch_revision = SOURCE_SHA.to_owned();
24810 candidates.push(candidate);
24811 let mut candidate = resume.clone();
24812 candidate
24813 .signed_cleanup_evidence
24814 .as_mut()
24815 .expect("evidence")
24816 .compile_evidence_sha256 = "7".repeat(64);
24817 candidates.push(candidate);
24818
24819 for candidate in candidates {
24820 assert!(
24821 candidate
24822 .signed_cleanup_evidence
24823 .as_ref()
24824 .expect("evidence")
24825 .validate_for_resume(&candidate)
24826 .is_err()
24827 );
24828 }
24829 }
24830
24831 #[test]
24832 fn failed_signed_attempt_restores_cleanup_failure_without_positive_evidence() {
24833 let config = provider_config(all_authorized());
24834 let principal = GithubPrincipalIdentityV1::User {
24835 id: AUTHENTICATED_USER_ID,
24836 login: "ShiroKSH".to_owned(),
24837 };
24838 let mut resume = durable_resume_with_run();
24839 resume.request = valid_request();
24840 resume.request_sha256 = canonical_request_sha256(&resume.request).expect("request digest");
24841 let run = resume.run.as_mut().expect("run");
24842 run.status = GithubRunStatusV1::Completed;
24843 run.conclusion = Some(GithubRunConclusionV1::Failure);
24844 resume.state = JobState::CleanupFailed;
24845 resume.cleanup_requested = true;
24846 resume.temporary_ref_deleted = true;
24847
24848 let record = resume
24849 .into_record(&config, &principal, 991)
24850 .expect("negative cleanup evidence remains restorable");
24851 assert_eq!(record.state, JobState::CleanupFailed);
24852 assert!(record.signed_cleanup_evidence.is_none());
24853 }
24854
24855 #[test]
24856 fn event_page_completes_only_after_closed_lifecycle_and_untruncated_delivery() {
24857 let mut config = provider_config(all_authorized());
24858 config.poll_policy =
24859 GithubPollingPolicy::new(1, Duration::from_millis(250)).expect("policy");
24860 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24861 config,
24862 signed_transport(FakeGhRunner::with([Ok(Vec::new())])),
24863 FakePublisher::default(),
24864 NoVerifiedArtifactStore,
24865 FixedClock(1_700_000_000_000),
24866 );
24867 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
24868 let failed = poll_ready(provider.events(
24869 EventRequest {
24870 job_id: "operation-1".to_owned(),
24871 after_sequence: None,
24872 limit: 100,
24873 },
24874 CancellationToken::new(),
24875 ))
24876 .expect("discovery failure");
24877 assert_eq!(failed.state, JobState::Failed);
24878 assert!(!failed.complete);
24879 poll_ready(provider.cleanup(
24880 CleanupRequest {
24881 job_id: "operation-1".to_owned(),
24882 remove_artifacts: false,
24883 },
24884 CancellationToken::new(),
24885 ))
24886 .expect("cleanup closes lifecycle");
24887 let truncated = poll_ready(provider.events(
24888 EventRequest {
24889 job_id: "operation-1".to_owned(),
24890 after_sequence: None,
24891 limit: 1,
24892 },
24893 CancellationToken::new(),
24894 ))
24895 .expect("truncated events");
24896 assert!(truncated.state.is_terminal());
24897 assert!(!truncated.complete);
24898 let complete = poll_ready(provider.events(
24899 EventRequest {
24900 job_id: "operation-1".to_owned(),
24901 after_sequence: None,
24902 limit: 100,
24903 },
24904 CancellationToken::new(),
24905 ))
24906 .expect("complete events");
24907 assert!(complete.state.is_terminal());
24908 assert!(complete.complete);
24909 }
24910
24911 #[test]
24912 fn closed_job_records_are_recycled_at_capacity() {
24913 let mut config = provider_config(all_authorized());
24914 config.max_jobs = 1;
24915 config.poll_policy =
24916 GithubPollingPolicy::new(1, Duration::from_millis(250)).expect("policy");
24917 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24918 config,
24919 signed_transport(FakeGhRunner::with([
24920 Ok(Vec::new()),
24921 Ok(public_source_repository_row()),
24922 Ok(private_repository_row()),
24923 Ok(signing_environment_row(false, true, true)),
24924 Ok(exact_signing_policy_row()),
24925 Ok(exact_signing_secret_rows()),
24926 ])),
24927 FakePublisher::default(),
24928 NoVerifiedArtifactStore,
24929 FixedClock(1_700_000_000_000),
24930 );
24931 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
24932 poll_ready(provider.events(
24933 EventRequest {
24934 job_id: "operation-1".to_owned(),
24935 after_sequence: None,
24936 limit: 100,
24937 },
24938 CancellationToken::new(),
24939 ))
24940 .expect("discovery failure");
24941 poll_ready(provider.cleanup(
24942 CleanupRequest {
24943 job_id: "operation-1".to_owned(),
24944 remove_artifacts: false,
24945 },
24946 CancellationToken::new(),
24947 ))
24948 .expect("closed old job");
24949
24950 let mut replacement = valid_request();
24951 replacement.operation_id = "operation-2".to_owned();
24952 let handle = poll_ready(provider.submit(replacement, CancellationToken::new()))
24953 .expect("closed record recycled");
24954 assert_eq!(handle.job_id, "operation-2");
24955 let state = provider.state.lock().expect("state");
24956 assert_eq!(state.jobs.len(), 1);
24957 assert!(state.jobs.contains_key("operation-2"));
24958 }
24959
24960 #[test]
24961 fn github_success_stays_pending_without_independent_artifact_verifier() {
24962 let responses = [
24963 Ok(completed_run_row("success")),
24964 Ok(completed_run_row("success")),
24965 ];
24966 let provider = GithubBuildProvider::with_artifact_store_and_clock(
24967 provider_config(all_authorized()),
24968 signed_transport(FakeGhRunner::with(responses)),
24969 FakePublisher::default(),
24970 NoVerifiedArtifactStore,
24971 FixedClock(1_700_000_000_000),
24972 );
24973 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
24974 let page = poll_ready(provider.events(
24975 EventRequest {
24976 job_id: "operation-1".to_owned(),
24977 after_sequence: None,
24978 limit: 100,
24979 },
24980 CancellationToken::new(),
24981 ))
24982 .expect("events");
24983 assert_eq!(page.state, JobState::Running);
24984 assert!(!page.complete);
24985 assert!(page.events.iter().any(|event| matches!(
24986 &event.kind,
24987 RemoteBuildEventKind::Warning { code, .. }
24988 if code == "github.artifact_verification_pending"
24989 )));
24990 assert!(!page.events.iter().any(|event| matches!(
24991 event.kind,
24992 RemoteBuildEventKind::OperationFinished { success: true, .. }
24993 )));
24994 }
24995
24996 #[test]
24997 fn signed_terminal_run_cannot_claim_signing_material_cleanup_without_worker_evidence() {
24998 let responses = [
24999 Ok(completed_run_row("success")),
25000 Ok(completed_run_row("success")),
25001 Ok(completed_run_row("success")),
25002 Ok(completed_run_row("success")),
25003 Ok(completed_run_row("success")),
25004 ];
25005 let provider = GithubBuildProvider::with_artifact_store_and_clock(
25006 provider_config(all_authorized()),
25007 signed_transport(FakeGhRunner::with(responses)),
25008 FakePublisher::default(),
25009 FailingArtifactStore,
25010 FixedClock(1_700_000_000_000),
25011 );
25012 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
25013 let error = poll_ready(provider.events(
25014 EventRequest {
25015 job_id: "operation-1".to_owned(),
25016 after_sequence: None,
25017 limit: 100,
25018 },
25019 CancellationToken::new(),
25020 ))
25021 .expect_err("artifact verification fails");
25022 assert!(matches!(
25023 error,
25024 RemoteBuildError::ProviderFailure { code, .. }
25025 if code == "artifact_fixture_rejected"
25026 ));
25027
25028 let confirmation = poll_ready(provider.cleanup(
25029 CleanupRequest {
25030 job_id: "operation-1".to_owned(),
25031 remove_artifacts: false,
25032 },
25033 CancellationToken::new(),
25034 ))
25035 .expect("terminal run cleanup");
25036 assert!(confirmation.workspace_removed);
25037 assert!(!confirmation.signing_material_removed);
25038 let state = provider.state.lock().expect("state");
25039 assert_eq!(
25040 state.jobs.get("operation-1").expect("job").state,
25041 JobState::CleanupFailed
25042 );
25043 drop(state);
25044 assert_eq!(
25045 provider
25046 .publisher
25047 .lock()
25048 .expect("publisher")
25049 .deletions
25050 .len(),
25051 1
25052 );
25053 }
25054
25055 #[test]
25056 fn unsigned_cleanup_failure_can_retry_to_positive_confirmation() {
25057 let mut resume = durable_resume_with_run();
25058 let run = resume.run.as_mut().expect("run");
25059 run.status = GithubRunStatusV1::Completed;
25060 run.conclusion = Some(GithubRunConclusionV1::Cancelled);
25061 resume.state = JobState::CleanupFailed;
25062 resume.cleanup_requested = true;
25063 resume.temporary_ref_deleted = true;
25064 let previous = resume.clone();
25065 let sink = CapturingCheckpointSink::default();
25066 let checkpoints = Arc::clone(&sink.checkpoints);
25067 let provider = GithubBuildProvider::with_artifact_store_and_clock(
25068 provider_config(all_authorized()),
25069 transport(FakeGhRunner::with([
25070 Ok(authenticated_user_row()),
25071 Ok(private_repository_row()),
25072 Ok(authenticated_user_row()),
25073 Ok(private_repository_row()),
25074 Ok(completed_run_row("cancelled")),
25075 Ok(authenticated_user_row()),
25076 Ok(private_repository_row()),
25077 Ok(completed_run_row("cancelled")),
25078 Ok(authenticated_user_row()),
25079 Ok(private_repository_row()),
25080 Ok(completed_run_row("cancelled")),
25081 Ok(authenticated_user_row()),
25082 Ok(private_repository_row()),
25083 ])),
25084 FakePublisher::default(),
25085 NoVerifiedArtifactStore,
25086 FixedClock(1_700_000_000_000),
25087 )
25088 .with_checkpoint_sink(sink);
25089 provider
25090 .restore_job_resumes(vec![resume])
25091 .expect("restore cleanup failure");
25092
25093 let confirmation = poll_ready(provider.cleanup(
25094 CleanupRequest {
25095 job_id: "operation-1".to_owned(),
25096 remove_artifacts: false,
25097 },
25098 CancellationToken::new(),
25099 ))
25100 .expect("retry unsigned cleanup");
25101 assert!(confirmation.workspace_removed);
25102 assert!(confirmation.signing_material_removed);
25103 let latest = checkpoints
25104 .lock()
25105 .expect("checkpoints")
25106 .last()
25107 .cloned()
25108 .expect("positive cleanup checkpoint");
25109 previous
25110 .validate_successor(&latest)
25111 .expect("cleanup retry remains a monotonic successor");
25112 assert_eq!(latest.state, JobState::Cleaned);
25113 assert!(matches!(
25114 latest.events[latest.events.len() - 2].kind,
25115 RemoteBuildEventKind::CleanupStarted
25116 ));
25117 assert!(matches!(
25118 latest.events.last().expect("finish event").kind,
25119 RemoteBuildEventKind::CleanupFinished { .. }
25120 ));
25121 }
25122
25123 #[test]
25124 fn failed_run_produces_typed_terminal_failure() {
25125 let responses = [
25126 Ok(completed_run_row("failure")),
25127 Ok(completed_run_row("failure")),
25128 ];
25129 let provider = GithubBuildProvider::with_artifact_store_and_clock(
25130 provider_config(all_authorized()),
25131 signed_transport(FakeGhRunner::with(responses)),
25132 FakePublisher::default(),
25133 NoVerifiedArtifactStore,
25134 FixedClock(1_700_000_000_000),
25135 );
25136 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
25137 let page = poll_ready(provider.events(
25138 EventRequest {
25139 job_id: "operation-1".to_owned(),
25140 after_sequence: None,
25141 limit: 100,
25142 },
25143 CancellationToken::new(),
25144 ))
25145 .expect("events");
25146 assert_eq!(page.state, JobState::Failed);
25147 assert!(page.events.iter().any(|event| matches!(
25148 &event.kind,
25149 RemoteBuildEventKind::OperationFinished {
25150 success: false,
25151 error: Some(error),
25152 ..
25153 } if error.code == "github.run_failed"
25154 )));
25155 }
25156
25157 #[test]
25158 fn cancellation_is_idempotent_before_run_mapping() {
25159 let provider = GithubBuildProvider::with_artifact_store_and_clock(
25160 provider_config(all_authorized()),
25161 signed_transport(FakeGhRunner::default()),
25162 FakePublisher::default(),
25163 NoVerifiedArtifactStore,
25164 FixedClock(1_700_000_000_000),
25165 );
25166 poll_ready(provider.submit(valid_request(), CancellationToken::new())).expect("submit");
25167 let request = CancellationRequest {
25168 job_id: "operation-1".to_owned(),
25169 reason: "user_requested".to_owned(),
25170 };
25171 let first = poll_ready(provider.cancel(request.clone(), CancellationToken::new()))
25172 .expect("first cancellation");
25173 let second = poll_ready(provider.cancel(request, CancellationToken::new()))
25174 .expect("second cancellation");
25175 assert!(first.accepted);
25176 assert!(!second.accepted);
25177 assert_eq!(second.state, JobState::Cancelling);
25178 }
25179
25180 #[test]
25181 fn unconfigured_artifact_download_is_typed_unsupported() {
25182 let provider = GithubBuildProvider::with_artifact_store_and_clock(
25183 provider_config(all_authorized()),
25184 transport(FakeGhRunner::default()),
25185 FakePublisher::default(),
25186 NoVerifiedArtifactStore,
25187 FixedClock(1_700_000_000_000),
25188 );
25189 let destination = rustferry_remote::ProtocolPath::new(
25190 rustferry_remote::ProtocolPathSemantics::ClientAbsolute,
25191 "/tmp/application.ipa",
25192 )
25193 .expect("destination");
25194 let error = poll_ready(provider.download_artifact(
25195 ArtifactDownloadRequest {
25196 job_id: "operation-1".to_owned(),
25197 artifact_id: "ipa".to_owned(),
25198 destination,
25199 },
25200 CancellationToken::new(),
25201 ))
25202 .expect_err("download unsupported");
25203 assert_eq!(error, unsupported(ProviderFeature::ArtifactDownload));
25204 }
25205}