WinAppMigrator

Releases and Signing

For developers. How a version is numbered, how a release is cut, what the release workflow checks, and what is signed. The authorities are CONTRIBUTING.md, .github/workflows/release.yml, and docs/signing.md.

Versions

Versions and tags have three parts, as in v0.5.0: vX.Y.Z. The two four-part tags from before that, v0.4.2.0 and v0.4.2.1, stay as they are.

The version is written in Cargo.toml as the workspace version, and again in each path dependency under [workspace.dependencies]: two in 0.5.0, and four from 0.6.0, which adds winappmigrator-link and winappmigrator-client. Every crate takes its version from the first.

Cutting a Release

  1. In Cargo.toml, set the workspace version and each path dependency under [workspace.dependencies]. Let Cargo.lock follow with a cargo build.
  2. In CHANGELOG.md, turn ## Unreleased into ## X.Y.Z — YYYY-MM-DD.
  3. From the repository's root, run cargo about generate about.hbs -o THIRD-PARTY-LICENSES.md. A release makes its own for the zip, and this keeps the one in the repository the same.
  4. Run the checks, push main, and wait for CI to pass on that commit.
  5. Tag that commit and push the tag: git tag vX.Y.Z, then git push origin vX.Y.Z.

Pushing a v* tag starts the release workflow.

To retry a run that failed before publishing, start the workflow by hand from that tag. If the release already exists, delete it first.

What the Release Workflow Does

release.yml has five jobs, each on windows-latest, and each waits for the one before. Only one release run per tag goes at a time. Building and signing are kept apart: every step of a job that may sign in to Azure could sign anything, so the jobs that build, test, or install anything from crates.io get no secrets and a token that can only read, and the jobs that sign run only pinned actions.

Job Name in GitHub What it does
client Build the client for Windows 7 and later Runs scripts/build-client.ps1, which builds WinAppMigrator Client for Windows 7 and later, and hands the client on, unsigned, for a day. See Building and Testing.
sign-client Sign the client Signs the client with Azure Artifact Signing, and hands it on, signed. It uses the release environment, and runs only pinned actions: nothing from the repository or crates.io runs once it can sign, and the client itself is not run. See why the client may be signed.
build Build and check Windows x64 release Checks, builds, and tests, below, with no secrets and a token that can only read, and hands on the app, unsigned, and the third-party licenses, for a day.
sign Sign the app Signs winappmigrator.exe with Azure Artifact Signing, checks that its signature is Valid, the signer is CN=Randy Northrup, and it has a timestamp, and hands it on. Like sign-client, it uses the release environment and runs only pinned actions. It is the only job that signs the app.
publish Publish Windows x64 release Checks the app's signature again, then stages, checksums, records where the downloads were built, and publishes, below. It is not in the release environment, so its sign-in token is not one Azure accepts.

Neither signing job restores its signing tools from GitHub's cache, which a job that builds could write to.

The build job, in order:

Step What it checks or does
Check the tag matches the version Stops unless the tag is v followed by the version in Cargo.toml.
Lint cargo clippy --locked --workspace --all-targets --all-features -- -D warnings
Test cargo test --locked --workspace --all-features
Generate third-party license attribution Installs cargo-about, at a pinned version, and regenerates THIRD-PARTY-LICENSES.md from the dependencies really compiled in.
Build installer stub (release) cargo build --locked --release -p winappmigrator-installer
Check the stub is unsigned Stops if Get-AuthenticodeSignature says the stub is anything but NotSigned.
Take the signed client, and check its signature Takes the client the sign-client job signed, and stops unless its signature is Valid, the signer is CN=Randy Northrup, and it has a timestamp. It is checked here too, as what this job takes from another.
Build app with embedded stub and client (release) Builds the app with WINAPPMIGRATOR_INSTALLER_STUB pointing at that stub, and WINAPPMIGRATOR_CLIENT at the signed client. A release build fails without either.
Check the app embeds the stub, and the signed client Stops unless the app holds that stub, and that signed client, byte for byte.

The publish job, after checking the signature:

Step What it checks or does
Stage release artifacts Makes the zip, with the signed app, README, changelog, license, and third-party licenses, and a copy of the app named for the version. Checksums are made last, so they describe the signed files.
Record where the downloads were built Makes a build-provenance attestation for the zip and the exe.
Publish GitHub Release Publishes the zip, the exe, and SHA256SUMS.txt. Its notes say the app, and WinAppMigrator Client inside it, are signed.

The release has three files: WinAppMigrator-vX.Y.Z.exe, WinAppMigrator-vX.Y.Z-windows-x64.zip, and SHA256SUMS.txt. Neither the stub nor the client is published on its own.

Signing

What Is Signed

File Signed? Why
winappmigrator.exe, the app Yes, by the release workflow It is what people download. The UAC prompt shows the publisher.
winappmigrator-installer.exe, the stub Never See below. It is inside the app and never published alone.
WinAppMigrator Client.exe, the client Yes, by the release workflow, before the app is built around it It runs elevated on the old PC, where the UAC prompt then shows the publisher. It is inside the app and never published alone. See below.
An installer someone builds Only if they sign it They are its publisher, not this project.
The loader of a two-file installer Only if its builder signs it It is the .exe of that installer. Its footer holds the data file's hash, so its signature covers the data file too.
The data file of a two-file installer No It is not a program.
The uninstaller an install leaves Never It is a bare copy of the stub.

How Release Signing Works

Signing uses Azure Artifact Signing, with no stored password or certificate:

So only a v* tag can reach either signing step. A fork does not have this set up. The exact setup is in docs/signing.md.

Releases before v0.5.0 were not signed. v0.5.0 is the first release this path ran for, and v0.6.0 the first whose client it signed.

Checking a Release

gh attestation verify .\WinAppMigrator-v0.6.0.exe --repo RandyNorthrup/WinAppMigrator
(Get-AuthenticodeSignature .\WinAppMigrator-v0.6.0.exe).SignerCertificate.Subject

The same gh attestation verify works for the zip. See Getting started. The client the app saves, WinAppMigrator Client.exe, can be checked with Get-AuthenticodeSignature the same way. It has no attestation of its own, as it is published only inside the app.

The Stub Is Never Signed

The stub installs whatever package comes after it, with administrator rights, and nothing checks who made that package. Windows leaves a file's own certificate table out of what its signature covers. So anyone holding a signed stub could hide a package inside that table, and the signature would still read as valid. This is CVE-2013-3900, and Microsoft's fix for it is opt-in and off by default. It was confirmed on Windows 11: 4 KB added this way still reported Valid. The result would be malware with this project's name as its verified publisher at the UAC prompt.

Three independent layers stop that:

  1. The release workflow fails if the stub carries a signature, signs only the app and the client, and fails unless the app holds that unsigned stub byte for byte.
  2. Building refuses a signed stub (refuse_signed_stub in archive.rs).
  3. The stub only trusts what a signature would cover. It reads its package, or the footer that vouches for a data file, from directly before the certificate table, never from inside it, and refuses anything after it. So even a stub signed by mistake could not be abused this way.

The uninstaller an install leaves is cut from the installer, with the package or footer and any certificate table removed, and the certificate table's location and the checksum field in the program's headers cleared. It comes out byte for byte the stub the installer was built from, and Windows reports it as NotSigned. The elevated tests check that, for an installer and for a loader given a valid signature from a throwaway certificate. So the uninstaller's UAC prompt says "Unknown publisher". That is the price of the rule. What makes it safe is where the uninstaller is: a folder only administrators can write to.

Why the Client May Be Signed

What makes a signed stub dangerous is that it runs what its own file carries. WinAppMigrator Client reads nothing from its own file. Whatever someone hid in its certificate table would never be looked at, so it would still only do what the client does. Its signature says the program is this project's, as the app's does.

What a signature does change is that someone could hand out the signed client beside a DLL of their own, for Windows to load into it under this project's name. So, besides what the app and the stub do (below):

This was tried on Windows 7, 32-bit and 64-bit, with Windows' own DLLs planted beside the client: none were loaded, before the copy or after, through pairing and a whole build. The signed client was run on Windows 7 SP1 virtual machines, 32-bit and 64-bit, with none of its later updates. It worked, and Windows read its signature as untrusted, because they lacked Microsoft's 2020 root certificate. With that root added, the 64-bit one read it as valid. On a Windows 7 without that root, the UAC prompt says the publisher is unknown.

The release workflow signs the client in a job of its own, which runs nothing from the repository or crates.io. The job after it checks the client's signature, and that the app carries it byte for byte. See docs/signing.md.

Signing an Installer You Built

az login
.\scripts\sign.ps1 'D:\Out\Example_installer.exe'

The script's defaults are this project's own account, and only work for someone with the signer role on it. Anyone else passes their own -Endpoint, -Account, and -CertificateProfile. It needs the .NET 8 runtime, and installs the ArtifactSigning PowerShell module for the current user if it is missing.

Sign only installers you built from software you are entitled to pass on, because your name goes on them. Do not offer signing as a service to others. For a two-file installer, sign the .exe only.

Other Protections in Both Executables

A signed program that runs elevated is a target for loading someone else's code under its name. Between them, the two executables:

See docs/signing.md.

WinAppMigrator Client also runs elevated, is signed, and is started from a USB drive or a shared folder. It too loads the DLLs it imports only from System32 (/DEPENDENTLOADFLAG:0x800, which Windows 7 ignores), links the C runtime statically, and runs Windows' tools by full path, with Windows' folder read from the registry, and with an environment of Windows' own. What else it does is above.

Last changed 2026-11-11. Describes WinAppMigrator as of its latest release, 0.6.0.