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 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.
- In
Cargo.toml, set the workspaceversionand each path dependency under[workspace.dependencies]. LetCargo.lockfollow with acargo build. - In
CHANGELOG.md, turn## Unreleasedinto## X.Y.Z — YYYY-MM-DD. - 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. - Run the checks, push
main, and wait for CI to pass on that commit. - Tag that commit and push the tag:
git tag vX.Y.Z, thengit 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.
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.
| 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. |
Signing uses Azure Artifact Signing, with no stored password or certificate:
- An Azure app registration with no secrets and no certificates of its own trusts GitHub's sign-in token (OIDC) only for this repository's
releaseenvironment. It is tied to the repository's and owner's fixed IDs, so a renamed or re-created repository does not inherit it. - That identity may do one thing: sign with one certificate profile.
- The GitHub environment
releasecan be used only fromv*tags. It holds two values that identify the Azure app and tenant, which do not authenticate anything by themselves.
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.
gh attestation verify .\WinAppMigrator-v0.6.0.exe --repo RandyNorthrup/WinAppMigrator
(Get-AuthenticodeSignature .\WinAppMigrator-v0.6.0.exe).SignerCertificate.SubjectThe 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 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:
- 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.
-
Building refuses a signed stub (
refuse_signed_stubinarchive.rs). - 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.
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):
-
It imports only DLLs Windows always loads from System32:
kernel32,advapi32,ws2_32, andntdll. That matters on Windows 7, which ignores/DEPENDENTLOADFLAG. Its randomness for pairing comes throughRtlGenRandominadvapi32, notbcrypt.dll, which is not one of them. -
It runs from a folder only an administrator can change. Windows loads some DLLs only when they are first needed, by name, and looks beside the program first, whatever the program asks. On 32-bit Windows 7,
RtlGenRandomloadscryptbase.dllthat way, and a copy planted beside the client was loaded into it. So the client first copies itself into%ProgramFiles%\WinAppMigrator Client, before it has done anything that loads a DLL, runs the copy in the same window, and removes it when it stops. Its caretaker removes it after a closed window or a kill, and the next run removes any left. -
Every Windows tool it starts, PowerShell,
netsh, andcmd, gets an environment of Windows' own, not the person's.
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.
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.
A signed program that runs elevated is a target for loading someone else's code under its name. Between them, the two executables:
- load the DLLs they import only from System32 (
/DEPENDENTLOADFLAG:0x800), and DLLs loaded later likewise (SetDefaultDllDirectories, first thing inmain); - link the C runtime statically, so
VCRUNTIME140.dllis never searched for; - run Windows' tools by full path, with Windows' folder read from the registry, not from a variable the user controls;
- find winget by asking Windows where its package is, and check that file's signature;
- take nothing about how a window is drawn from the environment, except
WGPU_BACKEND, which can only turn Direct3D 12 off, soWGPU_DX12_AGILITY_SDK_PATHcannot make Direct3D load aD3D12Core.dllfrom elsewhere; - start every tool with a fixed environment, and a runtime's setup, DISM, or winget with a
TEMPonly administrators can write to; - ask
whoamiwhether they are elevated, and treat not knowing as a failure; - check a runtime installer is signed by Microsoft when it is downloaded and again just before it runs;
- write the embedded stub straight into an installer, never through
%TEMP%.
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.