Skip to content

D5 delta (#438): Inno Setup installer, gated signing + notarisation, protected signing environment - #512

Merged
eaitbrahim merged 4 commits into
mainfrom
ci-438-signing
Aug 23, 2026
Merged

D5 delta (#438): Inno Setup installer, gated signing + notarisation, protected signing environment#512
eaitbrahim merged 4 commits into
mainfrom
ci-438-signing

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

What already existed (the reconciliation)

The v0.11 desktop job already freezes --onedir via packaging/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-OS SHA256SUMS + 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 the desktop job behind a protected environment. Reversing this is adding steps, not undoing work."

The delta, in four commits

  1. fix(release) — the wheel-floor comment tells the truth. The verify step claimed Requires-Python: >=3.14.4 while every pyproject.toml says >=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.

  2. 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 route docs/desktop-install.md documents); both are attached, checksumed, and attested (one space-separated multi-subject subject-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 because db.py has no down-migrations) is documented in the .iss header as deliberately not here yet — the issue itself deferred it ("Specified here rather than built").

  3. 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:

    • macOS: Developer ID Application .p12 imported into an ephemeral keychain (created / set-key-partition-list / deleted via trap), codesign --deep --force --options runtime --timestamp (hardened runtime — required for notarisation), notarytool store-credentials from an App Store Connect API key, notarytool submit --wait + stapler on 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), spctl check. Gate = all five Apple secrets or none (signed-but-un-notarised is the worst state on macOS).
    • Windows: signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com on the setup.exe + signtool verify /pa. RFC 3161 timestamping — an untimestamped signature dies with the certificate.
    • Signing sits between packaging and Checksums, so the sums cover the signed bytes; the fix(release): the desktop legs publish FILES, and each leg's checksums survive #483/fix(release): repair release.yml on main — glued comment made the workflow unparseable #484 invariants (files-only upload, per-OS sums, per-OS subjects) are untouched.
    • Secrets live on the signing environment (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.
    • Each missing credential produces a ::notice naming every secret to add, the product, and the price — never a red release, never a silent gap.
  4. docs(desktop) — the operator checklist. docs/desktop-install.md gains 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

platform product price secrets on the signing environment
macOS Apple Developer Program (Developer ID Application cert + App Store Connect API key) $99/yr MACOS_CERT_P12_BASE64, MACOS_CERT_PASSWORD, APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, APP_STORE_CONNECT_KEY_CONTENT
Windows OV code-signing certificate or Azure Trusted Signing ~$70–500/yr or $9.99/mo WINDOWS_CERT_PFX_BASE64, WINDOWS_CERT_PASSWORD

Setup: 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.yml compiles packaging/keel.iss against a placeholder bundle on a Windows runner — on every PR that touches the .iss or the smoke itself (a paths filter keeps the runner spend bounded; workflow_dispatch too, 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.py grows from 25 to 34 tests: the comment-fix pin (floor derived from pyproject.toml), the .iss per-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.py gains 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 -n on every bash run block.

Gates: uv run pytest -q4469 passed, 3 skipped; uv run ruff check keel tests packages clean.

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.

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.
@eaitbrahim eaitbrahim self-assigned this Aug 22, 2026
@eaitbrahim
eaitbrahim merged commit 9c60779 into main Aug 23, 2026
6 checks passed
@eaitbrahim
eaitbrahim deleted the ci-438-signing branch August 23, 2026 05:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

D5 — Package and sign macOS/Windows artifacts in the release workflow

1 participant