Skip to content

Commit 65df284

Browse files
corecompiledclaude
andauthored
NativeAOT, Scoop packaging, signing hooks (#2)
* ci: trial NativeAOT on a runner that has the C++ toolchain AOT compiled cleanly here — zero IL warnings from the trim/AOT analyzers, which retires the last open question about Markdig — but the native link step needs the MSVC linker from the Desktop C++ workload, and this machine does not have it registered (VsDevCmd sets no VCToolsInstallDir, and `where link.exe` finds Git's unrelated link.exe). Installing a multi-gigabyte workload to answer a sizing question is the wrong trade when the GitHub Windows runners already have it. CI is also where releases are actually built, so proving it there is worth more than proving it locally. Reports AOT size against the current single-file build and smoke-tests that the native binary starts and renders, since AOT failures typically surface as an immediate abort rather than a build error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ * ci: judge the AOT smoke test on output, not exit code Aborting first-run with no key is a correct non-zero exit, which failed the step even though the binary had rendered fine. Now asserts on what was drawn, and covers the first-run copy as well as startup so a Spectre rendering break under AOT cannot pass silently. Artifact uploads unconditionally — it is most wanted when a step failed and someone needs to run the binary by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ * ci: prove AOT for arm64 too before adopting it x64 AOT is 10.8 MB against 43 MB and starts in 0.16s, verified against a real key. Before making that the shipping configuration, arm64 has to be proven as well: cross-compiling needs the ARM64 C++ build tools, a separate component from the x64 ones, and a release tag is the wrong place to discover it is missing. fail-fast off so one architecture failing still reports the other. The smoke test stays x64-only, since an arm64 binary cannot execute on an x64 runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ * build: ship NativeAOT, add Scoop packaging and signing hooks AOT was recorded as "watching" with the note that its blocker had expired and only measurement remained. Measured, via a trial workflow that stays in the repo so this can be re-checked rather than re-argued: size 43 MB -> 10.8 MB startup ~1-2 s -> ~0.16 s extracts to temp on first run: yes -> no loose DLLs beside the exe: 0 Every one of those lands on the same thing: software copied onto a USB stick and run on a machine with nothing installed. Proven on both architectures in CI — arm64 needs a separate C++ component, which the trial installs rather than leaving a release tag to discover — and the x64 binary was run locally against a live model. Streaming, markdown, the tokenizer, DPAPI and config persistence all work compiled. Markdig, the one library whose AOT behaviour was unverified, is fine. The cost is real and is documented rather than buried: `dotnet publish` now needs the MSVC linker from the Desktop C++ workload. `build`, `test` and `run` do not, so day-to-day work is unchanged, but producing a release build requires a one-time install. Distribution: - Scoop manifest with checkver and autoupdate, hashes pinned to v0.2.0. Installing through Scoop sidesteps SmartScreen entirely, which is much of the point. - Release workflow emits a per-release checksums asset, which is what Scoop's autoupdate reads. - Azure Trusted Signing wired into the release workflow behind a SIGNING_ENABLED flag, skipped until the secrets exist. Signing runs before checksums, since it changes the file. - packaging/README.md documents the free Microsoft reputation submission and the signing route, since both need a human at a web form. Also removed the CI sketch that had been copied into docs/06 — a workflow reproduced in prose is a second source of truth that drifts from the real one, which is exactly the mistake the publish flags already made once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ --------- Co-authored-by: corecompiled <285886213+corecompiled@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 57c1d24 commit 65df284

11 files changed

Lines changed: 389 additions & 95 deletions

File tree

‎.github/workflows/aot-trial.yml‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: AOT trial
2+
3+
# Experiment, not a gate. NativeAOT needs the MSVC linker from the Desktop C++ workload, which the
4+
# GitHub Windows runners have and a typical dev machine may not — so this is where the question
5+
# "does OpenKey build and run AOT-compiled" actually gets answered.
6+
#
7+
# Reports size and startup against the current single-file build so the trade is a measurement
8+
# rather than an argument. See docs/06 and docs/architecture/08-decisions.md.
9+
10+
on:
11+
workflow_dispatch:
12+
push:
13+
branches: [feat/aot-and-distribution]
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
aot:
20+
runs-on: windows-latest
21+
22+
strategy:
23+
fail-fast: false # arm64 failing must not hide an x64 result, or vice versa
24+
matrix:
25+
rid: [win-x64, win-arm64]
26+
27+
steps:
28+
- uses: actions/checkout@v4
29+
- uses: actions/setup-dotnet@v4
30+
31+
# Cross-compiling to arm64 needs the ARM64 C++ build tools, which are a separate component
32+
# from the x64 ones. Installing them here rather than finding out during a release.
33+
- name: Ensure ARM64 C++ tools
34+
if: matrix.rid == 'win-arm64'
35+
shell: pwsh
36+
run: |
37+
$vs = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe"
38+
$path = & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
39+
Write-Host "Visual Studio at: $path"
40+
& $vs modify --installPath "$path" --quiet --norestart --nocache `
41+
--add Microsoft.VisualStudio.Component.VC.Tools.ARM64 | Out-Null
42+
Write-Host "exit: $LASTEXITCODE"
43+
44+
- name: Publish AOT
45+
run: >
46+
dotnet publish src/OpenKey/OpenKey.csproj -c Release -r ${{ matrix.rid }}
47+
-p:PublishAot=true
48+
-p:PublishSingleFile=false
49+
-p:PublishReadyToRun=false
50+
-p:EnableCompressionInSingleFile=false
51+
-o out-aot
52+
53+
- name: Publish the current single-file build for comparison
54+
run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r ${{ matrix.rid }} -o out-singlefile
55+
56+
- name: Compare
57+
shell: pwsh
58+
run: |
59+
$aot = Get-Item out-aot/OpenKey.exe
60+
$single = Get-Item out-singlefile/OpenKey.exe
61+
$aotMb = [math]::Round($aot.Length / 1MB, 1)
62+
$singleMb = [math]::Round($single.Length / 1MB, 1)
63+
Write-Host "single-file : $singleMb MB"
64+
Write-Host "AOT : $aotMb MB"
65+
Write-Host "delta : $([math]::Round($singleMb - $aotMb, 1)) MB smaller"
66+
67+
# A native build must not drag loose assemblies alongside it.
68+
$dlls = (Get-ChildItem out-aot -Filter *.dll).Count
69+
Write-Host "loose DLLs beside the AOT exe: $dlls"
70+
71+
- name: Smoke test the AOT binary
72+
if: matrix.rid == 'win-x64' # an arm64 binary cannot execute on an x64 runner
73+
shell: pwsh
74+
run: |
75+
# No key exists on a runner, so first-run setup runs and then aborts on EOF — for which
76+
# the app correctly returns a non-zero exit code. That is expected here, so judge the
77+
# rendered output rather than the exit status.
78+
$out = "/quit`n" | ./out-aot/OpenKey.exe 2>&1 | Out-String
79+
$global:LASTEXITCODE = 0
80+
Write-Host $out
81+
82+
# Each assertion covers a distinct thing AOT could plausibly have broken.
83+
if ($out -notmatch "OpenKey") { throw "no recognisable output — binary likely aborted on startup" }
84+
if ($out -notmatch "Welcome to OpenKey") { throw "first-run copy missing — Spectre rendering is broken" }
85+
if ($out -notmatch "OpenRouter") { throw "setup flow did not render" }
86+
Write-Host "AOT binary starts, renders, and reaches first-run setup."
87+
88+
# always(): the artifact is the point of this workflow, and it is most wanted when a later
89+
# step failed and someone needs to run the binary by hand.
90+
- if: always()
91+
uses: actions/upload-artifact@v4
92+
with:
93+
name: OpenKey-aot-${{ matrix.rid }}
94+
path: out-aot/OpenKey.exe

‎.github/workflows/release.yml‎

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77

88
permissions:
99
contents: write
10+
id-token: write # required by Azure Trusted Signing, when enabled
1011

1112
jobs:
1213
publish:
@@ -33,16 +34,60 @@ jobs:
3334
New-Item -ItemType Directory -Force dist | Out-Null
3435
Copy-Item "out/${{ matrix.rid }}/OpenKey.exe" "dist/OpenKey-${{ matrix.rid }}.exe"
3536
37+
# Code signing removes the SmartScreen "unrecognized app" warning, which is the biggest
38+
# friction point for a product handed over on a USB stick. Disabled until the secrets exist;
39+
# enable by adding the four AZURE_* secrets and setting vars.SIGNING_ENABLED to 'true'.
40+
# See docs/06-build-and-distribute.md.
41+
- name: Sign
42+
if: vars.SIGNING_ENABLED == 'true'
43+
uses: azure/trusted-signing-action@v0
44+
with:
45+
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
46+
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
47+
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
48+
endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
49+
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
50+
certificate-profile-name: ${{ secrets.AZURE_CERT_PROFILE }}
51+
files-folder: dist
52+
files-folder-filter: exe
53+
file-digest: SHA256
54+
timestamp-rfc3161: http://timestamp.acs.microsoft.com
55+
timestamp-digest: SHA256
56+
57+
# Hashes are computed after signing, since signing changes the file.
58+
- name: Checksum
59+
shell: pwsh
60+
run: |
61+
$h = (Get-FileHash "dist/OpenKey-${{ matrix.rid }}.exe" -Algorithm SHA256).Hash.ToLower()
62+
"$h OpenKey-${{ matrix.rid }}.exe" | Out-File -Encoding ascii "dist/${{ matrix.rid }}.sha256"
63+
Write-Host "$h OpenKey-${{ matrix.rid }}.exe"
64+
3665
- uses: actions/upload-artifact@v4
3766
with:
3867
name: OpenKey-${{ matrix.rid }}
39-
path: dist/OpenKey-${{ matrix.rid }}.exe
68+
path: dist/*
69+
70+
release:
71+
needs: publish
72+
runs-on: ubuntu-latest
73+
if: startsWith(github.ref, 'refs/tags/v')
74+
75+
steps:
76+
- uses: actions/download-artifact@v4
77+
with:
78+
path: artifacts
79+
80+
# One checksums file per release, which is what the Scoop manifest's autoupdate reads.
81+
- name: Collect checksums
82+
run: |
83+
mkdir -p dist
84+
find artifacts -name '*.exe' -exec cp {} dist/ \;
85+
cat artifacts/*/*.sha256 > "dist/OpenKey-${GITHUB_REF_NAME#v}-checksums.txt"
86+
cat "dist/OpenKey-${GITHUB_REF_NAME#v}-checksums.txt"
4087
4188
# Release titles carry the version only — never a phase number. See docs/06.
42-
- name: Attach to the release
43-
if: startsWith(github.ref, 'refs/tags/v')
44-
uses: softprops/action-gh-release@v2
89+
- uses: softprops/action-gh-release@v2
4590
with:
4691
name: ${{ github.ref_name }}
47-
files: dist/OpenKey-${{ matrix.rid }}.exe
92+
files: dist/*
4893
generate_release_notes: true

‎BACKLOG.md‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,8 @@ and a step change in scope rather than more polish.
9696

9797
Not scheduled; revisit when the trigger fires.
9898

99-
- **NativeAOT** — would cut the binary from ~42 MB to roughly 15–20 MB, remove the extract-to-temp
100-
step on first run, and start faster. All three matter for the USB story. The old blocker
101-
(Spectre reflection) is gone as of 0.55, and JSON source generation has landed, so the remaining
102-
cost is measuring what the analyzers still report. `IsAotCompatible` is already on and the tree
103-
is warning-clean.
99+
- ~~**NativeAOT**~~ — **done.** 43 MB → 10.8 MB, ~0.16 s startup, nothing extracted to temp. See
100+
[`docs/architecture/08-decisions.md`](docs/architecture/08-decisions.md).
104101
- **`System.Net.ServerSentEvents`** — would replace the hand-rolled SSE reader. Preview-only today
105102
(`11.0.0-preview.6`); adopt when it ships stable.
106103
- **Bracketed paste** — a more robust multi-line paste than the current timing heuristic. Needs a

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
Notable changes to OpenKey. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
44
versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6+
## [Unreleased]
7+
8+
### Changed
9+
10+
- OpenKey is now a native binary: **11 MB instead of 43 MB**, starting in about a sixth of a
11+
second, with nothing unpacked to a temporary folder the first time you run it. All three matter
12+
most on a USB stick or someone else's machine.
13+
- Installable with [Scoop](https://scoop.sh), which also avoids the "unrecognized app" prompt.
14+
615
## [0.2.0] — 2026-08-03
716

817
### Added

‎CONTRIBUTING.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,17 @@ dotnet publish src\OpenKey\OpenKey.csproj -c Release -r win-x64 -o publish\
2626

2727
Every publish flag lives in `OpenKey.csproj`. Don't pass them on the command line, and don't
2828
document a different command anywhere — the point is that CI and a developer machine produce the
29-
same artifact. See [`docs/06-build-and-distribute.md`](docs/06-build-and-distribute.md).
29+
same artifact.
30+
31+
OpenKey publishes as a NativeAOT binary, so that one command needs the MSVC linker:
32+
33+
```cmd
34+
winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
35+
```
36+
37+
Without it you get *"Platform linker not found"*. `build`, `test` and `run` are unaffected, so you
38+
only need this to cut a release build. See
39+
[`docs/06-build-and-distribute.md`](docs/06-build-and-distribute.md).
3040

3141
## Before you open a PR
3242

‎README.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ Patron ❯
3030
## Get it
3131

3232
Download `OpenKey.exe` from [Releases](https://github.com/corecompiled/OpenKey/releases) and
33-
double-click it. It runs from a USB stick.
33+
double-click it. One 11 MB file, nothing installed, runs from a USB stick.
34+
35+
Or via [Scoop](https://scoop.sh), which also avoids the SmartScreen prompt:
36+
37+
```
38+
scoop install https://raw.githubusercontent.com/corecompiled/OpenKey/main/packaging/scoop/openkey.json
39+
```
3440

3541
You'll need a free [OpenRouter](https://openrouter.ai) key. OpenKey can fetch one through your
3642
browser on first run, or you can paste one you already have. Either way it's encrypted for your
@@ -70,7 +76,7 @@ Requires the .NET SDK pinned in `global.json`. Windows only.
7076

7177
```cmd
7278
dotnet run --project src\OpenKey\OpenKey.csproj # run
73-
dotnet test # 65 tests
79+
dotnet test # 87 tests
7480
dotnet publish src\OpenKey\OpenKey.csproj -c Release -r win-x64 -o publish\
7581
```
7682

‎docs/06-build-and-distribute.md‎

Lines changed: 42 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ publish that didn't paste that exact line — including CI — silently produced
1616
than the one that had been smoke-tested. Changing how the binary is built is a project-file edit,
1717
reviewed like any other.
1818

19+
### Prerequisite: the C++ workload
20+
21+
OpenKey publishes as a NativeAOT binary, so `dotnet publish` needs the MSVC linker. Install once:
22+
23+
```cmd
24+
winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
25+
```
26+
27+
For `win-arm64`, also add `Microsoft.VisualStudio.Component.VC.Tools.ARM64`.
28+
29+
Without it you get *"Platform linker not found"*. **`dotnet build`, `dotnet test` and `dotnet run`
30+
are unaffected** — only publishing needs this.
31+
1932
For Windows on ARM, swap the RID:
2033

2134
```cmd
@@ -40,64 +53,41 @@ All set in `OpenKey.csproj`, not on the command line.
4053

4154
| Setting | Why |
4255
|------|-----|
43-
| `SelfContained` | Bundles the .NET runtime. The user doesn't need .NET installed — this is what makes it click-and-play. |
44-
| `PublishSingleFile` | One file instead of a folder of DLLs. |
45-
| `IncludeNativeLibrariesForSelfExtract` | Native libraries bundled and extracted to a temp dir at runtime. Required for a genuine single file. |
46-
| `EnableCompressionInSingleFile` | Roughly 30% smaller, at the cost of a one-time decompression on first launch. |
47-
| `PublishReadyToRun` | Pre-jits IL so startup feels instant. Larger file. |
48-
| `IsAotCompatible` | Turns the trim/AOT analyzers on. Doesn't change the output; keeps the option open by failing the build on new reflection. |
56+
| `PublishAot` | Compiles to a native binary. No JIT, no runtime to bundle, nothing extracted at startup. |
57+
| `SelfContained` | Implied by AOT; stated for clarity. The user needs nothing installed. |
4958
| `RuntimeIdentifiers` | `win-x64;win-arm64`. |
5059
| `InvariantGlobalization=false` | LLM replies are full of non-ASCII text. Costs ICU in the bundle; a deliberate trade. |
5160

52-
## Why NOT trimming
61+
`IsAotCompatible` is gone — it existed to surface trim/AOT warnings without committing to AOT, and
62+
`PublishAot` implies the same analyzers.
5363

54-
```
55-
# DO NOT add:
56-
-p:PublishTrimmed=true
57-
```
58-
59-
Trimming is not enabled yet. The trim analyzers *are* on (`IsAotCompatible` is set on all three
60-
projects) and the tree builds warning-clean, so the historical objection — that Spectre.Console's
61-
internal reflection would silently break the UI — no longer applies unexamined. Enabling
62-
`PublishTrimmed` now needs measurement rather than argument.
64+
## AOT, and why the old objection expired
6365

64-
## Why NOT AOT (yet)
66+
This document used to say AOT was blocked by Spectre.Console's internal reflection. Measured from
67+
the shipped assemblies, `IsTrimmable` metadata is **absent** in Spectre.Console 0.49.1 and
68+
**present** in 0.55.2 — the library did the work. The other stated blocker, reflection-based
69+
`System.Text.Json`, went away when everything persisted moved to source-generated contexts.
6570

66-
```
67-
# Not enabled today:
68-
-p:PublishAot=true
69-
```
71+
So the question became a measurement, and `.github/workflows/aot-trial.yml` answered it:
7072

71-
**The reason originally given here has expired.** This section used to say Spectre.Console's
72-
reflection blocked AOT. Measured from the shipped assemblies: `IsTrimmable` metadata is **absent**
73-
in Spectre.Console 0.49.1 and **present** in 0.55.2. The library did the work.
73+
| | Single-file (previous) | NativeAOT (now) |
74+
|---|---|---|
75+
| Size | 43 MB | **10.8 MB** |
76+
| Startup | ~1–2 s cold (decompress + extract) | **~0.16 s** |
77+
| Extracts to temp on first run | yes | **no** |
78+
| Loose DLLs beside the exe | n/a | 0 |
7479

75-
The other stated blocker, reflection-based `System.Text.Json`, is also gone — everything persisted
76-
and every request body now goes through source-generated contexts.
80+
All three differences land on the same thing: this is software people copy onto a USB stick and
81+
run on someone else's machine.
7782

78-
Current status:
83+
Verified on both architectures in CI, and the x64 binary was run locally against a live model —
84+
streaming, markdown rendering, the tokenizer, DPAPI key load and config persistence all work
85+
compiled. The trial workflow stays in the repo so the comparison can be re-run rather than
86+
re-argued.
7987

80-
| Concern | State |
81-
|---|---|
82-
| Spectre.Console | Annotated trim/AOT-compatible since 0.55 |
83-
| `System.Text.Json` | Source-generated contexts in place |
84-
| Markdig | No analyzer warnings at our call sites |
85-
| DPAPI via `ProtectedData` | AOT-safe |
86-
| `Microsoft.Extensions.DependencyInjection` | Fine — composition is explicit, no assembly scanning |
88+
The cost is the C++ workload prerequisite above. `build`, `test` and `run` are unaffected.
8789

88-
So what remains is measurement, not a known obstacle. The prize is real for a USB-distributed app:
89-
roughly 42 MB → 15–20 MB, no extract-to-temp on first run, and faster startup. Tracked in
90-
[`../BACKLOG.md`](../BACKLOG.md); rationale in
91-
[`architecture/08-decisions.md`](architecture/08-decisions.md).
92-
93-
## Expected output
94-
95-
| Metric | Value |
96-
|--------|-------|
97-
| File size | ~30–50 MB (compressed) |
98-
| First launch cold-start | ~1–2 s (decompress + R2R) |
99-
| Subsequent launches | <500 ms |
100-
| Working set RAM | ~80–120 MB |
90+
Trimming is not separately enabled: AOT already implies it.
10191

10292
## Versioning
10393

@@ -206,21 +196,9 @@ Implemented — see `.github/workflows/ci.yml` (build, test, and a publish check
206196
and `.github/workflows/release.yml` (both architectures attached to a `v*` tag). The sketch that
207197
used to live here has been replaced by the real thing.
208198

209-
For reference, the shape is:
210-
211-
```yaml
212-
name: build
213-
on: [push]
214-
jobs:
215-
publish:
216-
runs-on: windows-latest
217-
steps:
218-
- uses: actions/checkout@v4
219-
- uses: actions/setup-dotnet@v4
220-
with: { dotnet-version: '10.0.x' }
221-
- run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:PublishReadyToRun=true -o publish
222-
- uses: actions/upload-artifact@v4
223-
with: { name: OpenKey-exe, path: publish/OpenKey.exe }
224-
```
199+
No sketch is reproduced here: a workflow copied into prose is a second source of truth that drifts
200+
from the real one, which is the mistake the publish flags already made once. Read the files.
225201

226-
Tag-driven releases come in Phase 1.2 with the update checker.
202+
A third workflow, `aot-trial.yml`, exists to re-measure AOT against the current single-file settings
203+
on demand. It is an experiment rather than a gate, and it is where the numbers in this document
204+
came from.

0 commit comments

Comments
 (0)