Skip to content

MSI: Add support for signing of installers #1224

Merged
lrandersson merged 2 commits into
conda:briefcase-integrationfrom
lrandersson:dev-ra-822
May 1, 2026
Merged

MSI: Add support for signing of installers #1224
lrandersson merged 2 commits into
conda:briefcase-integrationfrom
lrandersson:dev-ra-822

Conversation

@lrandersson
Copy link
Copy Markdown
Contributor

Description

Adds code signing support for MSI installers built via Briefcase, reusing the existing EXE signing infrastructure.

  • Add sign() method to WindowsSignTool and AzureSignTool for direct file signing (post-build)
  • Extract signing tool initialization into create_signing_tool() factory to reduce duplication
  • Sign MSI after Briefcase builds it, using the same windows_signing_tool/signing_certificate config as EXE
  • Add signature verification to test_example_signing

Checklist - did you ...

  • Add a file to the news directory (using the template) for the next release's release notes?
  • Add / update necessary tests?
  • Add / update outdated documentation?

@lrandersson lrandersson self-assigned this Apr 24, 2026
@github-project-automation github-project-automation Bot moved this to 🆕 New in 🔎 Review Apr 24, 2026
@conda-bot conda-bot added the cla-signed [bot] added once the contributor has signed the CLA label Apr 24, 2026
Comment thread constructor/signing.py
certificate_file=certificate_file,
)

def _get_signing_params(self):
Copy link
Copy Markdown
Contributor Author

@lrandersson lrandersson Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About the changes in this file to simplify review: The new code is based on existing patterns, _get_signing_params() extracts the same environment variables already used in get_signing_command(), and sign() builds the same command structure but as a list for direct execution. The create_signing_tool() function is essentially the same logic that was already in winexe.py, just moved to reduce duplicated code, but reuses existing logic to enable MSI signing.

Comment thread tests/test_examples.py
CONSTRUCTOR_SIGNING_CERTIFICATE=str(cert_path),
CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD=cert_pwd,
):
_verify_windows_signature(installer)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this because I thought it was odd that this test was even passing before signing of installers was supported for MSI Installers. This example was already running for EXE and MSI installers before this PR, and passed for MSI due to lack of actual verification during the testing.

@lrandersson lrandersson force-pushed the briefcase-integration branch from 6173fcf to c9cb1bd Compare April 24, 2026 16:18
@lrandersson lrandersson marked this pull request as ready for review April 24, 2026 19:13
@lrandersson lrandersson requested a review from a team as a code owner April 24, 2026 19:13
Comment thread constructor/briefcase.py Outdated
outpath.unlink(missing_ok=True)
shutil.move(msi_paths[0], outpath)

if signing_tool:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we sign before moving the installer out of the staging directory?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point I like that idea, 72d0414

Comment thread constructor/signing.py
command += ' /p "%CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD%"'
return command

def sign(self, file_path: str | Path):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is still some duplication here since get_signing_command is just the " ".join() on the list you're building inside sign()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is with some code duplication but its kept intentionally because I tried to keep it simple, here get_signing_command and sign have very different requirements.
To unify them, we'd need some kind of helper function that returns the command as a list of tuples like (value, needs_escaping, is_env_reference). Then sign() would extract just the values, while get_signing_command() would iterate through, apply win_str_esc() where needs_escaping=True, and replace actual values with %VAR% syntax where is_env_reference=True. For context:

  • paths: sign() uses actual value, get_signing_command() needs win_str_esc(value)
  • password: sign() uses actual value, get_signing_command() emits "%ENV_VAR%" literal for NSIS to expand during runtime.
    Most elements just need optional escaping, but password is fundamentally different.

I'm happy to do this refactoring if we are OK with the added complexity.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, I think keeping them separate is good.

Comment thread constructor/signing.py
"tenant_id": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID"),
}

def sign(self, file_path: str | Path):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: get_signing_command and what you need for sign are just two different representations of the same data.

Comment thread constructor/signing.py Outdated


def create_signing_tool(info: dict) -> SigningTool | None:
"""Create a signing tool based on construct.yaml configuration.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Create a signing tool based on construct.yaml configuration.
"""Create a signing tool based on construct.yaml.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread constructor/signing.py
signing_tool = WindowsSignTool(certificate_file=info.get("signing_certificate"))
elif signing_tool_name == "azuresigntool":
signing_tool = AzureSignTool()
else:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function seems to only cover Windows, but this file also contains macOS signing tools.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed it to make it more clear 72d0414

@lrandersson lrandersson requested a review from marcoesters April 28, 2026 16:15
@github-project-automation github-project-automation Bot moved this from 🆕 New to ✅ Approved in 🔎 Review Apr 28, 2026
@lrandersson
Copy link
Copy Markdown
Contributor Author

@marcoesters I just added two more commits

  1. 1b2d01c - which wasn't enough
  2. e7771ba
    The reason for this is that the macos-latest, Python 3.12 was failing with a bizarre error and it seems that the latest runner system Python has been updated to Python 3.14, because the command pip install -e was failing. I don't know why (is it intentional) that we use system pip? The pip command was finding /Library/Frameworks/Python.framework/Versions/3.14/bin/pip first in PATH, even though the conda environment is activated.
    With the above mentioned reason I explicitly now invoke pip via python (that hopefully is the python from the conda environment), then the second commit I guess we need ever since the update to conda-libmamba-solver with sharded repodata enabled as default (since we saw that pip was no longer included as a dependency).
    The jobs are still running with these changes so there might be additional changes needed to make it work.

@marcoesters
Copy link
Copy Markdown
Contributor

2. I don't know why (is it intentional) that we use system pip?

This is strange. The environment should have been activated (and it is, or conda install wouldn't have worked). Maybe it's the way setup-miniconda is used? There has been a major release recently.

@lrandersson
Copy link
Copy Markdown
Contributor Author

This is strange. The environment should have been activated (and it is, or conda install wouldn't have worked). Maybe it's the way setup-miniconda is used? There has been a major release recently.

@marcoesters Im not sure, this branch is still pinned to the previous version conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3.3.0. It also only happened on the macos latest runner which is odd so I wonder if its just due to updates in the environment upstream.

@lrandersson lrandersson force-pushed the briefcase-integration branch from 0bc0fc6 to f0ec7b7 Compare May 1, 2026 09:52
@lrandersson
Copy link
Copy Markdown
Contributor Author

@marcoesters this has also been rebased and I removed the python/pip specific commits after the rebase so it also needs a new approval.

@lrandersson lrandersson merged commit 7f2977e into conda:briefcase-integration May 1, 2026
20 checks passed
@github-project-automation github-project-automation Bot moved this from ✅ Approved to 🏁 Done in 🔎 Review May 1, 2026
lrandersson added a commit to lrandersson/constructor that referenced this pull request May 28, 2026
* add signing of installers - work in progress

* Review fixes: sign before move, docstring update, rename func
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed [bot] added once the contributor has signed the CLA

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

3 participants