@@ -660,6 +660,10 @@ pub fn verify_published_destination(destination: &Path, signtool_path: Option<&P
660660/// Returns an error for unsupported hosts, invalid package identity/version,
661661/// missing tools, failed signatures, unsafe destinations, or conflicting immutable files.
662662pub fn publish ( options : & PublishOptions ) -> Result < PublishReport > {
663+ publish_after_copy ( options, |_| Ok ( ( ) ) )
664+ }
665+
666+ fn publish_after_copy ( options : & PublishOptions , after_copy : impl FnOnce ( & Path ) -> Result < ( ) > ) -> Result < PublishReport > {
663667 if !cfg ! ( windows) {
664668 return Err ( PublisherError :: Tool (
665669 "MSIX packaging and signing are currently supported only on Windows" . to_owned ( ) ,
@@ -771,8 +775,8 @@ pub fn publish(options: &PublishOptions) -> Result<PublishReport> {
771775 & destination,
772776 options,
773777 & build_id,
774- unsigned_development,
775778 prior,
779+ after_copy,
776780 ) ;
777781 if result. is_err ( ) {
778782 let _ = fs:: remove_dir_all ( & temporary) ;
@@ -2110,9 +2114,10 @@ fn package_and_publish(
21102114 destination : & Path ,
21112115 options : & PublishOptions ,
21122116 build_id : & str ,
2113- unsigned_development : bool ,
21142117 prior : Option < OwnerMarker > ,
2118+ after_copy : impl FnOnce ( & Path ) -> Result < ( ) > ,
21152119) -> Result < PublishReport > {
2120+ let unsigned_development = matches ! ( options. signing, Signing :: UnsignedDevelopment ) ;
21162121 let metadata: SnapshotMetadata = serde_json:: from_slice ( & fs:: read ( snapshot. join ( "snapshot.json" ) ) ?) ?;
21172122 let package_root = temporary. join ( "package" ) ;
21182123 fs:: create_dir_all ( package_root. join ( "Public" ) ) ?;
@@ -2219,6 +2224,7 @@ fn package_and_publish(
22192224 }
22202225 }
22212226 }
2227+ after_copy ( destination) ?;
22222228 let target_msix = destination. join ( "source2.msix" ) ;
22232229 let staged_package = stage_package ( & msix, destination, build_id) ?;
22242230 rollback. files . push ( staged_package. clone ( ) ) ;
@@ -3830,6 +3836,110 @@ mod tests {
38303836 fs:: remove_dir_all ( root) . expect ( "cleanup" ) ;
38313837 }
38323838
3839+ #[ cfg( windows) ]
3840+ #[ test]
3841+ fn initial_publish_post_copy_failure_cleans_destination_and_allows_retry ( ) {
3842+ let makeappx = doctor ( ) . makeappx_path . expect ( "Windows SDK MakeAppx is required" ) ;
3843+ let root = unit_directory ( "initial-publish" ) ;
3844+ let input = root. join ( "input" ) ;
3845+ let state = root. join ( "state" ) ;
3846+ let destination = root. join ( "destination" ) ;
3847+ fs:: create_dir ( & input) . expect ( "manifest input" ) ;
3848+ fs:: write (
3849+ input. join ( "package.yaml" ) ,
3850+ concat ! (
3851+ "PackageIdentifier: Pinget.Test.InitialRollback\n " ,
3852+ "PackageVersion: 1.0.0\n PackageLocale: en-US\n " ,
3853+ "Publisher: Pinget Test\n PackageName: Initial Rollback\n " ,
3854+ "License: MIT\n ShortDescription: Initial publication rollback regression\n " ,
3855+ "Installers:\n - Architecture: x64\n InstallerType: exe\n " ,
3856+ " InstallerUrl: https://example.invalid/unused.exe\n " ,
3857+ " InstallerSha256: 1111111111111111111111111111111111111111111111111111111111111111\n " ,
3858+ "ManifestType: singleton\n ManifestVersion: 1.9.0\n " ,
3859+ ) ,
3860+ )
3861+ . expect ( "manifest" ) ;
3862+ let build_report = build ( & BuildOptions {
3863+ input,
3864+ state : state. clone ( ) ,
3865+ } )
3866+ . expect ( "build snapshot" ) ;
3867+ let metadata: SnapshotMetadata = serde_json:: from_slice (
3868+ & fs:: read ( build_report. snapshot_directory . join ( "snapshot.json" ) ) . expect ( "snapshot metadata" ) ,
3869+ )
3870+ . expect ( "snapshot" ) ;
3871+ assert ! (
3872+ metadata. artifacts. len( ) >= 2 ,
3873+ "manifest and version sidecar must be copied"
3874+ ) ;
3875+ let options = PublishOptions {
3876+ state : state. clone ( ) ,
3877+ destination : destination. clone ( ) ,
3878+ build_id : Some ( build_report. build_id . clone ( ) ) ,
3879+ name : "Pinget.Test.InitialRollback" . to_owned ( ) ,
3880+ publisher : "CN=Pinget Initial Rollback Test" . to_owned ( ) ,
3881+ version : "1.0.0.0" . to_owned ( ) ,
3882+ signing : Signing :: UnsignedDevelopment ,
3883+ makeappx : Some ( makeappx) ,
3884+ signtool : None ,
3885+ timestamp_url : None ,
3886+ } ;
3887+ let mut reached_after_copy = false ;
3888+ let error = publish_after_copy ( & options, |feed| {
3889+ reached_after_copy = true ;
3890+ for ( relative, expected) in & metadata. artifacts {
3891+ let path = safe_join ( feed, relative) . expect ( "copied artifact path" ) ;
3892+ assert ! (
3893+ path. is_file( ) ,
3894+ "artifact must exist before injected failure: {relative}"
3895+ ) ;
3896+ assert_eq ! ( sha256_hex( & fs:: read( path) . expect( "copied artifact" ) ) , * expected) ;
3897+ }
3898+ assert ! (
3899+ !feed. join( ".pinget-publisher.json" ) . exists( ) ,
3900+ "no ownership marker before promotion"
3901+ ) ;
3902+ assert ! ( !feed. join( "source2.msix" ) . exists( ) , "no root package before promotion" ) ;
3903+ Err ( PublisherError :: Invalid ( "injected initial post-copy failure" . to_owned ( ) ) )
3904+ } )
3905+ . expect_err ( "injected post-copy failure" ) ;
3906+ assert ! ( reached_after_copy, "failure must occur after actual verified copies" ) ;
3907+ assert ! ( error. to_string( ) . contains( "injected initial post-copy failure" ) ) ;
3908+ assert_eq ! (
3909+ fs:: read_dir( & destination) . expect( "destination" ) . count( ) ,
3910+ 0 ,
3911+ "initial failure must leave no files, directories, marker, package or staging"
3912+ ) ;
3913+ let failed_status = status ( & state) . expect ( "state after failure" ) ;
3914+ assert_eq ! ( failed_status. last_published_build_id, None ) ;
3915+ assert ! ( !failed_status. last_build_published) ;
3916+ verify ( & state, Some ( & build_report. build_id ) , None ) . expect ( "snapshot preserved after failure" ) ;
3917+
3918+ let published = publish ( & options) . expect ( "normal initial retry" ) ;
3919+ assert ! ( published. valid) ;
3920+ assert ! (
3921+ !published. reused,
3922+ "retry must perform publication rather than reuse a false commit"
3923+ ) ;
3924+ assert_eq ! ( published. build_id, build_report. build_id) ;
3925+ let verified = verify_published_destination ( & destination, None ) . expect ( "destination verification" ) ;
3926+ assert ! ( verified. valid) ;
3927+ assert_eq ! ( verified. build_id. as_deref( ) , Some ( build_report. build_id. as_str( ) ) ) ;
3928+ assert_eq ! (
3929+ verified. artifact_count,
3930+ metadata. artifacts. len( ) + 1 ,
3931+ "feed artifacts plus root package"
3932+ ) ;
3933+ let published_status = status ( & state) . expect ( "published state" ) ;
3934+ assert_eq ! (
3935+ published_status. last_published_build_id. as_deref( ) ,
3936+ Some ( build_report. build_id. as_str( ) )
3937+ ) ;
3938+ assert ! ( published_status. last_build_published) ;
3939+ verify ( & state, Some ( & build_report. build_id ) , None ) . expect ( "snapshot preserved after retry" ) ;
3940+ fs:: remove_dir_all ( root) . expect ( "cleanup" ) ;
3941+ }
3942+
38333943 #[ test]
38343944 fn destination_lock_serializes_independent_publisher_states ( ) {
38353945 let root = unit_directory ( "lock" ) ;
0 commit comments