D5 delta (#438): Inno Setup installer, gated signing + notarisation, protected signing environment - #512
Merged
Merged
Conversation
The verify step's comment claimed the wheel carries `Requires-Python: >=3.14.4` while every pyproject.toml declares `>=3.11` (pinned by tests/test_python_floor.py). Harmless to the run -- the pinned interpreter is used either way -- but it was the only place a reader could learn what the wheel demands, and the desktop packaging work #438 builds on it would have been built on a floor that does not exist. The comment now states the real floor and the real reason for the pin (verify on the interpreter the release was built and typed against, not pip refusing), and the test derives the floor from pyproject.toml so the claim cannot drift again.
packaging/keel.iss wraps the frozen --onedir tree into one setup.exe:
PrivilegesRequired=lowest, DefaultDirName={localappdata}\Programs\keel --
no admin prompt on a tool that then asks for exchange API keys. The
installer owns the PROGRAM directory only; the deployment (config.yaml,
keel*.db, .env, logs/) is never written and survives uninstall (no
[UninstallDelete]).
The zip stays beside the setup.exe -- it is the no-install route
desktop-install.md documents. Both are attached, checksumed (the files-only
globs cover out/ by construction), and attested (one space-separated
multi-subject subject-path).
The first ISCC compile of the script is NOT the release dispatch:
.github/workflows/installer-smoke.yml compiles the same .iss against a
placeholder bundle on a Windows runner, dispatch-only, because the release
is manual and its desktop job runs after the tag is pushed -- a script
typo there would fail a published release.
The install-over-existing-keel UX #438 specifies (version from on-disk
metadata, confirm same-version, warn on downgrade because db.py has no
down-migrations) is documented in the .iss header as deliberately not
here yet; this commit is the packaging vehicle.
…est skips (#438) The desktop job gains the signing work #438 specified, each leg gated on its own credentials -- the #402 pattern: implemented, never red for a missing prerequisite, skipping with a notice that names every secret to add, what it costs, and where the checklist lives. - macOS: Developer ID Application .p12 imported into an EPHEMERAL keychain (created, used, deleted via trap), codesign --options runtime (the hardened runtime notarisation requires), notarytool store-credentials from an App Store Connect API key + submit --wait + stapler on the app AND the re-cut dmg, spctl check. The gate is all five Apple secrets or none: a signed-but-un-notarised app still trips Gatekeeper and now looks like it tried not to. - Windows: signtool sign /fd SHA256 with RFC 3161 timestamping (/tr /td) on the setup.exe, then signtool verify /pa. - The secrets live on the `signing` ENVIRONMENT (referenced on the desktop job), not the repo: a repository secret reaches every same-repo PR build, an environment secret stops at the environment's reviewers. Until the environment exists the reference is inert and the gates skip. - Signing sits between packaging and Checksums, so the sums cover the signed bytes; attestations and upload follow unchanged (files-only globs cover the new setup.exe by construction). The ship-unsigned DECISION is unchanged -- this is the issue's own "If this is revisited: signing steps drop into the desktop job behind a protected environment", pre-built. Activation is an operator checklist in docs/desktop-install.md (next commit), not a code change.
Signing activation is a purchase followed by ten minutes of GitHub settings, and the workflow's skip notices point at this page -- so the page now holds the complete shopping list: the products and prices from #438's signing table (Apple $99/yr Developer Program with the ASC API key for notarytool; Windows OV ~$70-500/yr or Azure Trusted Signing $9.99/mo, with the explicit warning that EV buys nothing extra since 2024), every secret name the gates check, the `signing` environment by name and why the secrets must live THERE (a repository secret reaches every same-repo PR build), and the one manual step left -- flipping the release-notes wording -- whose failure mode is stated and is the safe direction, because the release job cannot see the environment's secrets by design. The user-facing promise is unchanged: when signing is on, this page becomes the one sentence it always said it would.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What already existed (the reconciliation)
The v0.11
desktopjob already freezes--onedirviapackaging/keel.spec, wraps macOS into a.dmg(packaging/macos_app.sh, browser-opening launcher, READ ME FIRST inside the image), zips Windows, and attaches per-OSSHA256SUMS+ build attestations — all unsigned, by the decision recorded in #438's comments. This PR is the delta to full #438, exactly the shape the issue's own "If this is revisited" comment called for: "signing steps drop into thedesktopjob behind a protected environment. Reversing this is adding steps, not undoing work."The delta, in four commits
fix(release)— the wheel-floor comment tells the truth. The verify step claimedRequires-Python: >=3.14.4while everypyproject.tomlsays>=3.11. Now derived from the manifest in the test, so it cannot drift. 3.14.4 is the build interpreter pin (.python-version), not a floor the wheel enforces — the comment says so now.ci(release)— the Windows Inno Setup installer.packaging/keel.iss:PrivilegesRequired=lowest,DefaultDirName={localappdata}\Programs\keel— per-user, no admin prompt. It owns the program directory only; the deployment (config.yaml,keel*.db,.env,logs/) is never written and survives uninstall (no[UninstallDelete]). The zip stays beside the setup.exe (the no-install routedocs/desktop-install.mddocuments); both are attached, checksumed, and attested (one space-separated multi-subjectsubject-path). The version-decision UX D5 — Package and sign macOS/Windows artifacts in the release workflow #438 specifies (version from on-disk metadata, confirm same-version, downgrade warning becausedb.pyhas no down-migrations) is documented in the.issheader as deliberately not here yet — the issue itself deferred it ("Specified here rather than built").ci(release)— gated signing + notarisation, protected environment, honest skips. The ci: Code Quality push-runs fail on every main commit (Sonar org property + Snyk env missing outside PR context) #402 pattern applied to certificates:.p12imported into an ephemeral keychain (created /set-key-partition-list/ deleted viatrap),codesign --deep --force --options runtime --timestamp(hardened runtime — required for notarisation),notarytool store-credentialsfrom an App Store Connect API key,notarytool submit --wait+stapleron the app and the re-cut dmg (the unsigned READ ME is replaced by a signed-build note — a signed dmg must not ship instructions for a warning it no longer triggers),spctlcheck. Gate = all five Apple secrets or none (signed-but-un-notarised is the worst state on macOS).signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.comon the setup.exe +signtool verify /pa. RFC 3161 timestamping — an untimestamped signature dies with the certificate.signingenvironment (referenced by the desktop job), not the repo — a repository secret reaches every same-repo PR build; an environment secret stops at the environment's reviewers. Until the environment exists the reference is inert and the gates skip. The workflow stays dispatch-only — never fork PRs.::noticenaming every secret to add, the product, and the price — never a red release, never a silent gap.docs(desktop)— the operator checklist.docs/desktop-install.mdgains the purchase-and-activation checklist (below, mirrored in the doc), including why the secrets must be environment secrets and the one manual step left (flipping the release-notes wording), whose failure mode is stated and errs safe — the release job cannot see the environment's secrets by design, so it cannot auto-detect signing.What activates when the certificates arrive — no code changes
signingenvironmentMACOS_CERT_P12_BASE64,MACOS_CERT_PASSWORD,APP_STORE_CONNECT_KEY_ID,APP_STORE_CONNECT_ISSUER_ID,APP_STORE_CONNECT_KEY_CONTENTWINDOWS_CERT_PFX_BASE64,WINDOWS_CERT_PASSWORDSetup: Settings → Environments →
signing→ add required reviewers → add the secrets → dispatch. (EV is not worth extra: since 2024 it no longer buys an instant SmartScreen pass. Azure Trusted Signing may be limited to US/Canada identities — verify before budgeting; the PFX+signtool shape is what's implemented, Azure is the documented alternative.)Until then, nothing changes for users: artifacts ship exactly as today — unsigned, with attestations and checksums — and the workflow log says so with the notices above. The actual signing cannot be exercised in this PR for the honest reason that it needs the paid certificates; the gating logic, the Inno build, and the comment fix are what is testable here, and they are tested.
The Inno Setup smoke story
ISCC cannot run on macOS, so
.github/workflows/installer-smoke.ymlcompilespackaging/keel.issagainst a placeholder bundle on a Windows runner — on every PR that touches the.issor the smoke itself (apathsfilter keeps the runner spend bounded;workflow_dispatchtoo, e.g. after editing on a branch). It is running on this PR: that green check is the first real ISCC compile of the script, so the next release dispatch is not.Tests
tests/test_desktop_packaging.pygrows from 25 to 34 tests: the comment-fix pin (floor derived frompyproject.toml), the.issper-user/never-the-deployment pins, the setup.exe build + attestation subject, the smoke workflow's trigger discipline, the environment reference, the per-secret gate conditions and their exact-complement skip notices (naming secrets + price), signing order vs checksums, hardened runtime/--wait/stapler/ephemeral keychain, RFC 3161 + verify, and the docs checklist completeness.tests/test_security_scans.pygains a deliberate, bounded carve-out for the gated signing secrets (the always-on rule stands for everything else). Strict-loader discipline on every workflow touched;bash -non every bash run block.Gates:
uv run pytest -q→ 4469 passed, 3 skipped;uv run ruff check keel tests packagesclean.Closes #438 — the code-workable scope is complete; the paid certificate is the operator action named in the docs checklist (#402 precedent), and the version-decision installer UX stays deferred exactly as the issue deferred it.