Skip to content

Releases and Signing

Randy Northrup edited this page Nov 11, 2026 · 9 revisions

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.
Check the release notes can be written Stops unless CHANGELOG.md has a section for this version with a paragraph saying what is new.
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 what the app is, from .github/release-notes.md, what is new, from the paragraph under the version's heading in CHANGELOG.md, and which file to download. The build job checks first that they can be written, so a version with no such paragraph stops before anything is 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:

  • 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 release environment. 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 release can be used only from v* 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.

Checking a Release

gh attestation verify .\WinAppMigrator-v0.6.1.exe --repo RandyNorthrup/WinAppMigrator
(Get-AuthenticodeSignature .\WinAppMigrator-v0.6.1.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):

  • It imports only DLLs Windows always loads from System32: kernel32, advapi32, ws2_32, and ntdll. That matters on Windows 7, which ignores /DEPENDENTLOADFLAG. Its randomness for pairing comes through RtlGenRandom in advapi32, not bcrypt.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, RtlGenRandom loads cryptbase.dll that 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, and cmd, 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.

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:

  • load the DLLs they import only from System32 (/DEPENDENTLOADFLAG:0x800), and DLLs loaded later likewise (SetDefaultDllDirectories, first thing in main);
  • link the C runtime statically, so VCRUNTIME140.dll is 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, so WGPU_DX12_AGILITY_SDK_PATH cannot make Direct3D load a D3D12Core.dll from elsewhere;
  • start every tool with a fixed environment, and a runtime's setup, DISM, or winget with a TEMP only administrators can write to;
  • ask whoami whether 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.

Clone this wiki locally