Skip to content

Commit 8efac12

Browse files
Cover Rust initial publication rollback and restore release runner availability
Inject a deterministic private post-copy failure to prove complete cleanup and successful retry, without changing verification ordering or public API. Gate the Windows-only lock test and migrate retiring macOS 14 release runners to macOS 15 with unchanged targets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 6b67555 commit 8efac12

4 files changed

Lines changed: 120 additions & 6 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,12 @@ jobs:
190190
archive_name: pinget-windows-arm64.zip
191191
binary_name: pinget.exe
192192
- name: macos-x64
193-
os: macos-14
193+
os: macos-15
194194
target: x86_64-apple-darwin
195195
archive_name: pinget-macos-x64.zip
196196
binary_name: pinget
197197
- name: macos-arm64
198-
os: macos-14
198+
os: macos-15
199199
target: aarch64-apple-darwin
200200
archive_name: pinget-macos-arm64.zip
201201
binary_name: pinget

‎docs/repository-publishing.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,11 @@ Failure tests cover packaging/signing errors, update promotion rollback and
196196
destination locking. The .NET publisher also has an end-to-end regression
197197
for initial publication failure after metadata copying: a controlled MSIX
198198
identity mismatch leaves the destination empty and no publication recorded,
199-
then a valid retry succeeds. The equivalent Rust post-copy failure path
200-
remains unverified; Rust verifies the staged MSIX before copying metadata.
199+
then a valid retry succeeds. The Rust regression uses a private deterministic
200+
post-copy callback to verify the copied artifact hashes, inject a failure,
201+
assert complete cleanup and unchanged publication state, then verify a normal
202+
retry. Rust still verifies the staged MSIX before copying metadata; the
203+
public API adds no failure-injection option.
201204

202205
Stock-client acceptance and signature trust checks belong in a fresh Windows
203206
Sandbox or an ephemeral Windows CI runner. They must not register test sources

‎rust/crates/pinget-publisher/src/lib.rs‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
662662
pub 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\nPackageLocale: en-US\n",
3853+
"Publisher: Pinget Test\nPackageName: Initial Rollback\n",
3854+
"License: MIT\nShortDescription: 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\nManifestVersion: 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");

‎rust/crates/pinget-publisher/tests/conformance.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,7 @@ fn publish_semantic_metadata_promotion_failure_preserves_old_feed_then_retry_suc
11321132
}
11331133

11341134
#[test]
1135+
#[cfg(windows)]
11351136
fn publish_destination_lock_serializes_different_state_roots_and_allows_retry() {
11361137
use std::os::windows::fs::OpenOptionsExt;
11371138

0 commit comments

Comments
 (0)