Skip to content

Commit 8a4a91d

Browse files
authored
ci: reuse cached signed/notarized native binaries on release (#775)
* ci: reuse cached signed/notarized native binaries on release The release workflow rebuilt and re-notarized the macOS/Rust native encryption binaries on every publish, even when nothing under the native source changed. The Apple notarization round-trip in particular is slow and externally dependent, and re-running it for a byte-identical binary is wasted work. Gate the native build/notarize/Rust jobs on a content-addressed cache probe. The cache key covers the native source, the build/bundle scripts, and the workflows that drive them, plus a manual 'v1' salt for toolchain bumps the file hash can't see. On a cache hit the build and notarize jobs are skipped and the previously notarized bundle is reused. To never publish the wrong thing: - keys are content-addressed, so a restored binary always matches the source that produced it - caches restored on a release are main-scoped only (feature-branch caches are not visible), so a release can only reuse a binary a prior main run produced - a reused macOS bundle is re-verified on a macOS runner (codesign + notarization staple + functional smoke) before the publish step; if it can't be verified the release is blocked - the publish job fails loudly if any native binary is missing First release after this lands rebuilds once to seed the cache, then subsequent releases with unchanged native source reuse it. * ci: hardcode native bundle version constant to enforce byte-stability The macOS .app reuse relied on the bundle being byte-identical across releases, but that was only true by accident: release.yaml happened not to pass a --version, so the bundle fell back to a constant default. Anyone wiring a real version into that call would have silently defeated the notarized-bundle cache with no error. Make it stable by construction. CFBundleVersion is vestigial — nothing reads it (Swift binary, Rust binary, varlock CLI, notarization) — so hardcode it to a fixed constant in build-swift.ts and remove the --version plumbing from the macOS build workflow and its callers. The bundle is now byte-stable in every path, not just the release one. First release after this rebuilds + re-notarizes once (the build script hash changed), then reuse kicks in. * ci: drop orphaned version plumbing from binary-release debug job The bundle version is now a fixed constant (build-swift.ts), so the binary-release path no longer threads a version into the macOS build. That left the debug job's version output unused and a vestigial 'needs: debug' on build-native-macos. Remove both; the context-printing debug step stays. The re-release flow (workflow_dispatch version input -> RELEASE_VERSION -> release upload / homebrew / docker) is unchanged.
1 parent 849b259 commit 8a4a91d

5 files changed

Lines changed: 202 additions & 49 deletions

File tree

.github/workflows/binary-release.yaml

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,32 +20,19 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
2020
jobs:
2121
debug:
2222
runs-on: ubuntu-latest
23-
outputs:
24-
version: ${{ steps.version.outputs.version }}
2523
steps:
2624
- name: print github context
2725
env:
2826
GITHUB_CONTEXT: ${{ toJson(github) }}
2927
run: |
3028
echo "$GITHUB_CONTEXT"
31-
- name: Extract version
32-
id: version
33-
run: |
34-
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
35-
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
36-
else
37-
# github.ref_name is e.g. "varlock@1.2.3" — strip the package prefix
38-
echo "version=${GITHUB_REF_NAME#varlock@}" >> $GITHUB_OUTPUT
39-
fi
4029
4130
# Build and sign the macOS native binary (cache hit if already built in CI)
4231
build-native-macos:
43-
needs: debug
4432
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'varlock@')
4533
uses: ./.github/workflows/build-native-macos.yaml
4634
with:
4735
mode: release
48-
version: ${{ needs.debug.outputs.version }}
4936
artifact-name: native-bin-macos-signed
5037
secrets:
5138
OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN }}

.github/workflows/build-native-macos.yaml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ name: Build macOS native binary
55
#
66
# The Swift .build directory is cached by source hash, so the compile
77
# step (~minutes) is near-instant on cache hit. The .app bundle wrapping
8-
# (plist, icon, signing) always runs since it varies by mode/version.
8+
# (plist, icon, signing) always runs since it varies by mode.
99
#
1010
# Notarization is intentionally NOT included here — it's a separate
1111
# workflow for production releases.
@@ -20,10 +20,6 @@ on:
2020
description: 'Build mode: dev, preview, or release (affects bundle metadata)'
2121
type: string
2222
default: 'preview'
23-
version:
24-
description: 'Bundle version string (e.g. 1.2.3)'
25-
type: string
26-
default: '0.0.0-preview'
2723
artifact-name:
2824
description: 'Name for the uploaded artifact'
2925
type: string
@@ -103,7 +99,7 @@ jobs:
10399
- name: Build, bundle, and sign
104100
run: |
105101
bun run --filter @varlock/encryption-binary-swift build:universal \
106-
-- --mode ${{ inputs.mode }} --version ${{ inputs.version }} --sign "$APPLE_SIGNING_IDENTITY"
102+
-- --mode ${{ inputs.mode }} --sign "$APPLE_SIGNING_IDENTITY"
107103
108104
- name: Verify binary
109105
run: |

.github/workflows/notarize-native-macos.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ on:
1818
description: 'Name for the notarized artifact'
1919
type: string
2020
default: 'native-bin-macos-notarized'
21+
cache-key:
22+
description: 'If set, the notarized .app is saved to the Actions cache under this key for cross-run reuse'
23+
type: string
24+
default: ''
2125
secrets:
2226
OP_CI_TOKEN:
2327
required: true
@@ -89,3 +93,23 @@ jobs:
8993
name: ${{ inputs.artifact-name }}
9094
path: VarlockEnclave.app
9195
retention-days: 7
96+
97+
# Stage the notarized bundle at the canonical path and cache it so future
98+
# releases with unchanged source can reuse it (skipping build + notarize).
99+
# ditto preserves the bundle's signature and stapled notarization ticket.
100+
# The cache key is content-addressed (source + scripts + workflows), so a
101+
# restored bundle always corresponds to the source that produced it; the
102+
# release re-verifies it on macOS before publishing.
103+
- name: Stage notarized .app for caching
104+
if: inputs.cache-key != ''
105+
run: |
106+
mkdir -p packages/varlock/native-bins/darwin
107+
rm -rf packages/varlock/native-bins/darwin/VarlockEnclave.app
108+
ditto VarlockEnclave.app packages/varlock/native-bins/darwin/VarlockEnclave.app
109+
- name: Cache notarized .app for cross-run reuse
110+
if: inputs.cache-key != ''
111+
uses: actions/cache/save@v5
112+
continue-on-error: true # immutable key; harmless if a concurrent run saved it first
113+
with:
114+
path: packages/varlock/native-bins/darwin/VarlockEnclave.app
115+
key: ${{ inputs.cache-key }}

.github/workflows/release.yaml

Lines changed: 169 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,20 @@ jobs:
3838
echo "::error::Timed out waiting for CI test suite"
3939
exit 1
4040
41-
# Determine what bumpy will do — only run expensive build/notarize when publishing
41+
# Determine what bumpy will do — only run expensive build/notarize when publishing.
42+
# Also probe the native-binary caches: if this exact source (+ build scripts +
43+
# workflows) already produced a signed/notarized binary on a previous run, we
44+
# reuse it instead of rebuilding and — for macOS — re-notarizing.
4245
plan:
4346
needs: wait-for-tests
4447
runs-on: ubuntu-latest
4548
outputs:
4649
mode: ${{ steps.plan.outputs.mode }}
4750
includes-varlock: ${{ contains(fromJSON(steps.plan.outputs.json).packageNames, 'varlock') }}
51+
macos-cache-key: ${{ steps.keys.outputs.macos-cache-key }}
52+
rust-source-hash: ${{ steps.keys.outputs.rust-source-hash }}
53+
macos-cache-hit: ${{ steps.macos-cache.outputs.cache-hit }}
54+
rust-cache-hit: ${{ steps.rust-cache.outputs.cache-hit }}
4855
steps:
4956
- uses: actions/checkout@v6
5057
- name: Setup Bun
@@ -54,39 +61,139 @@ jobs:
5461
env:
5562
GH_TOKEN: ${{ github.token }}
5663

57-
# Build and sign the macOS native binary (cache hit if already built in CI)
64+
# Comprehensive content hashes for the native binaries. These cover the
65+
# native source, the build/bundle scripts, AND the workflows that drive
66+
# them — anything that can change the produced binary. The leading `v1`
67+
# salt forces a full rebuild when something hashFiles can't see changes
68+
# (runner image / Xcode / Rust / UPX toolchain) — bump it to invalidate.
69+
#
70+
# Note: the macOS .app bundle embeds a fixed constant version (hardcoded in
71+
# build-swift.ts, not the npm version), so the notarized bundle is
72+
# byte-stable across releases — which is what makes cross-release reuse
73+
# valid. Do not reintroduce a per-release version there.
74+
- id: keys
75+
run: |
76+
MACOS_HASH=${{ hashFiles('packages/encryption-binary-swift/swift/**', 'packages/encryption-binary-swift/scripts/**', 'packages/encryption-binary-swift/package.json', '.github/workflows/build-native-macos.yaml', '.github/workflows/notarize-native-macos.yaml') }}
77+
RUST_HASH=${{ hashFiles('packages/encryption-binary-rust/Cargo.lock', 'packages/encryption-binary-rust/Cargo.toml', 'packages/encryption-binary-rust/src/**', 'packages/encryption-binary-rust/scripts/**', '.github/workflows/build-native-rust.yaml') }}
78+
echo "macos-cache-key=native-bin-macos-notarized-v1-$MACOS_HASH" >> "$GITHUB_OUTPUT"
79+
echo "rust-source-hash=v1-$RUST_HASH" >> "$GITHUB_OUTPUT"
80+
81+
# Probe caches (lookup-only, no download). cache-hit is 'true' only on an
82+
# exact key match within this branch's scope (main + its base) — feature
83+
# branch caches are not visible here, so a release can only reuse a binary
84+
# produced by a prior run on main.
85+
- name: Probe macOS notarized binary cache
86+
id: macos-cache
87+
if: steps.plan.outputs.mode == 'publish'
88+
uses: actions/cache/restore@v5
89+
with:
90+
path: packages/varlock/native-bins/darwin/VarlockEnclave.app
91+
key: ${{ steps.keys.outputs.macos-cache-key }}
92+
lookup-only: true
93+
- name: Probe Rust cache - linux-x64
94+
id: rust-cache-linux-x64
95+
if: steps.plan.outputs.mode == 'publish'
96+
uses: actions/cache/restore@v5
97+
with:
98+
path: packages/varlock/native-bins/linux-x64/
99+
key: native-bin-rust-linux-x64-${{ steps.keys.outputs.rust-source-hash }}
100+
lookup-only: true
101+
- name: Probe Rust cache - linux-arm64
102+
id: rust-cache-linux-arm64
103+
if: steps.plan.outputs.mode == 'publish'
104+
uses: actions/cache/restore@v5
105+
with:
106+
path: packages/varlock/native-bins/linux-arm64/
107+
key: native-bin-rust-linux-arm64-${{ steps.keys.outputs.rust-source-hash }}
108+
lookup-only: true
109+
- name: Probe Rust cache - win32-x64
110+
id: rust-cache-win32-x64
111+
if: steps.plan.outputs.mode == 'publish'
112+
uses: actions/cache/restore@v5
113+
with:
114+
path: packages/varlock/native-bins/win32-x64/
115+
key: native-bin-rust-win32-x64-${{ steps.keys.outputs.rust-source-hash }}
116+
lookup-only: true
117+
- name: Aggregate Rust cache status
118+
id: rust-cache
119+
if: steps.plan.outputs.mode == 'publish'
120+
run: |
121+
if [[ "${{ steps.rust-cache-linux-x64.outputs.cache-hit }}" == "true" \
122+
&& "${{ steps.rust-cache-linux-arm64.outputs.cache-hit }}" == "true" \
123+
&& "${{ steps.rust-cache-win32-x64.outputs.cache-hit }}" == "true" ]]; then
124+
echo "cache-hit=true" >> "$GITHUB_OUTPUT"
125+
else
126+
echo "cache-hit=false" >> "$GITHUB_OUTPUT"
127+
fi
128+
129+
# Build + sign the macOS native binary. Skipped on a cache hit — the previously
130+
# notarized bundle is reused and re-verified (see verify-native-macos) instead.
58131
build-native-macos:
59132
needs: plan
60-
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
133+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.macos-cache-hit != 'true'
61134
uses: ./.github/workflows/build-native-macos.yaml
62135
with:
63136
mode: release
64137
artifact-name: native-bin-macos-signed
65138
secrets:
66139
OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN }}
67140

68-
# Notarize for production npm distribution
141+
# Notarize for production npm distribution and cache the notarized bundle so
142+
# future releases with unchanged source can reuse it.
69143
notarize-native-macos:
70144
needs: [plan, build-native-macos]
71-
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
145+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.macos-cache-hit != 'true'
72146
uses: ./.github/workflows/notarize-native-macos.yaml
73147
with:
74148
source-artifact-name: native-bin-macos-signed
75149
artifact-name: native-bin-macos-npm
150+
cache-key: ${{ needs.plan.outputs.macos-cache-key }}
76151
secrets:
77152
OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN }}
78153

79-
# Build Rust native binaries for Linux and Windows
154+
# When the notarized bundle is reused from cache (build + notarize skipped),
155+
# re-verify it on a macOS runner before we trust it in the publish step. This
156+
# is the safety net for "never publish the wrong thing": a corrupted or
157+
# un-notarized cached bundle fails here and blocks the release.
158+
verify-native-macos:
159+
needs: plan
160+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.macos-cache-hit == 'true'
161+
runs-on: macos-latest
162+
steps:
163+
- name: Restore notarized macOS binary from cache
164+
uses: actions/cache/restore@v5
165+
with:
166+
path: packages/varlock/native-bins/darwin/VarlockEnclave.app
167+
key: ${{ needs.plan.outputs.macos-cache-key }}
168+
fail-on-cache-miss: true
169+
- name: Verify signature, notarization, and function
170+
run: |
171+
APP="packages/varlock/native-bins/darwin/VarlockEnclave.app"
172+
BIN="$APP/Contents/MacOS/varlock-local-encrypt"
173+
echo "=== codesign verify ==="
174+
codesign --verify --deep --strict --verbose=2 "$APP"
175+
codesign -dvv "$APP" 2>&1 || true
176+
echo "=== notarization staple ==="
177+
xcrun stapler validate "$APP"
178+
echo "=== gatekeeper assessment (non-fatal) ==="
179+
spctl -a -t exec -vv "$APP" || true
180+
echo "=== functional smoke ==="
181+
chmod +x "$BIN"
182+
"$BIN" status | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['ok'], 'status not ok'"
183+
echo "Cached notarized binary verified"
184+
185+
# Build Rust native binaries for Linux and Windows. Skipped on a cache hit.
80186
build-native-rust:
81187
needs: plan
82-
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
188+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.rust-cache-hit != 'true'
83189
uses: ./.github/workflows/build-native-rust.yaml
84190
with:
85191
artifact-name: native-bin-rust
192+
source-hash: ${{ needs.plan.outputs.rust-source-hash }}
86193

87194
release:
88195
name: Release
89-
needs: [plan, notarize-native-macos, build-native-rust]
196+
needs: [plan, notarize-native-macos, verify-native-macos, build-native-rust]
90197
if: always() && !failure() && !cancelled()
91198
runs-on: ubuntu-latest
92199
permissions:
@@ -117,42 +224,86 @@ jobs:
117224
- name: Update npm
118225
run: npm install -g npm@latest
119226

120-
# Download signed macOS native binary so it's included in the npm package
121-
- name: Download macOS native binary
122-
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
227+
# macOS native binary: download the freshly-notarized artifact (cache miss),
228+
# or restore the previously-notarized bundle from cache (cache hit, already
229+
# re-verified by verify-native-macos).
230+
- name: Download macOS native binary (built this run)
231+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.macos-cache-hit != 'true'
123232
uses: actions/download-artifact@v8
124233
with:
125234
name: native-bin-macos-npm
126235
path: packages/varlock/native-bins/darwin/VarlockEnclave.app
236+
- name: Restore macOS native binary (from cache)
237+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.macos-cache-hit == 'true'
238+
uses: actions/cache/restore@v5
239+
with:
240+
path: packages/varlock/native-bins/darwin/VarlockEnclave.app
241+
key: ${{ needs.plan.outputs.macos-cache-key }}
242+
fail-on-cache-miss: true
127243
- name: Restore native binary execute permission
128244
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
129245
run: chmod +x packages/varlock/native-bins/darwin/VarlockEnclave.app/Contents/MacOS/varlock-local-encrypt
130246

131-
# Download Rust native binaries for Linux and Windows
132-
- name: Download Linux x64 native binary
133-
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
247+
# Rust native binaries: download artifacts (cache miss) or restore from cache (hit)
248+
- name: Download Rust native binaries (built this run)
249+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.rust-cache-hit != 'true'
134250
uses: actions/download-artifact@v8
135251
with:
136252
name: native-bin-rust-linux-x64
137253
path: packages/varlock/native-bins/linux-x64
138-
- name: Download Linux arm64 native binary
139-
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
254+
- name: Download Rust native binaries - linux-arm64 (built this run)
255+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.rust-cache-hit != 'true'
140256
uses: actions/download-artifact@v8
141257
with:
142258
name: native-bin-rust-linux-arm64
143259
path: packages/varlock/native-bins/linux-arm64
144-
- name: Download Windows x64 native binary
145-
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
260+
- name: Download Rust native binaries - win32-x64 (built this run)
261+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.rust-cache-hit != 'true'
146262
uses: actions/download-artifact@v8
147263
with:
148264
name: native-bin-rust-win32-x64
149265
path: packages/varlock/native-bins/win32-x64
266+
- name: Restore Rust native binaries (from cache)
267+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.rust-cache-hit == 'true'
268+
uses: actions/cache/restore@v5
269+
with:
270+
path: packages/varlock/native-bins/linux-x64/
271+
key: native-bin-rust-linux-x64-${{ needs.plan.outputs.rust-source-hash }}
272+
fail-on-cache-miss: true
273+
- name: Restore Rust native binaries - linux-arm64 (from cache)
274+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.rust-cache-hit == 'true'
275+
uses: actions/cache/restore@v5
276+
with:
277+
path: packages/varlock/native-bins/linux-arm64/
278+
key: native-bin-rust-linux-arm64-${{ needs.plan.outputs.rust-source-hash }}
279+
fail-on-cache-miss: true
280+
- name: Restore Rust native binaries - win32-x64 (from cache)
281+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' && needs.plan.outputs.rust-cache-hit == 'true'
282+
uses: actions/cache/restore@v5
283+
with:
284+
path: packages/varlock/native-bins/win32-x64/
285+
key: native-bin-rust-win32-x64-${{ needs.plan.outputs.rust-source-hash }}
286+
fail-on-cache-miss: true
150287
- name: Restore Rust binary execute permissions
151288
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
152289
run: |
153290
chmod +x packages/varlock/native-bins/linux-x64/varlock-local-encrypt
154291
chmod +x packages/varlock/native-bins/linux-arm64/varlock-local-encrypt
155292
293+
# Fail loudly if any native binary is missing before we build the npm package,
294+
# rather than silently publishing an incomplete package.
295+
- name: Verify native binaries present
296+
if: needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true'
297+
run: |
298+
missing=0
299+
check() { if [ ! -f "$1" ]; then echo "::error::missing native binary: $1"; missing=1; fi; }
300+
check packages/varlock/native-bins/darwin/VarlockEnclave.app/Contents/MacOS/varlock-local-encrypt
301+
check packages/varlock/native-bins/linux-x64/varlock-local-encrypt
302+
check packages/varlock/native-bins/linux-arm64/varlock-local-encrypt
303+
check packages/varlock/native-bins/win32-x64/varlock-local-encrypt.exe
304+
if [ "$missing" = "1" ]; then exit 1; fi
305+
echo "All native binaries present"
306+
156307
# ------------------------------------------------------------
157308
- name: Build libraries
158309
run: bun run build:libs

0 commit comments

Comments
 (0)