Skip to content

Commit ca58a1e

Browse files
committed
CM-53930: fix onedir signing issues on mac
1 parent 457022c commit ca58a1e

File tree

2 files changed

+102
-8
lines changed

2 files changed

+102
-8
lines changed

.github/workflows/build_executable.yml

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,34 @@ jobs:
132132
env:
133133
APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }}
134134
run: |
135-
# Sign all Mach-O binaries in the onedir output (excluding the main executable)
136-
# Main executable must be signed last after all its dependencies
137-
find dist/cycode-cli -type f ! -name "cycode-cli" | while read -r file; do
135+
# 1. Sign framework bundles (preserves Info.plist binding for --strict verification)
136+
while IFS= read -r framework; do
137+
echo "Signing framework: $framework"
138+
codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime "$framework"
139+
done < <(find dist/cycode-cli -name "*.framework" -type d -maxdepth 3)
140+
141+
# 2. Replace standalone Python binary with symlink to the framework version.
142+
# The standalone copy fails codesign --verify --strict because it lacks the
143+
# framework's Info.plist context. The framework version passes.
144+
if [ -f dist/cycode-cli/_internal/Python ] && [ -d dist/cycode-cli/_internal/Python.framework ]; then
145+
FRAMEWORK_PYTHON=$(find dist/cycode-cli/_internal/Python.framework/Versions -name "Python" -type f | head -1)
146+
if [ -n "$FRAMEWORK_PYTHON" ]; then
147+
RELATIVE_PATH=${FRAMEWORK_PYTHON#dist/cycode-cli/_internal/}
148+
echo "Replacing _internal/Python with symlink to $RELATIVE_PATH"
149+
rm dist/cycode-cli/_internal/Python
150+
ln -s "$RELATIVE_PATH" dist/cycode-cli/_internal/Python
151+
fi
152+
fi
153+
154+
# 3. Sign remaining Mach-O files (dylibs, .so files) outside of framework bundles
155+
find dist/cycode-cli -type f ! -name "cycode-cli" ! -path "*.framework/*" | while read -r file; do
138156
if file -b "$file" | grep -q "Mach-O"; then
157+
echo "Signing: $file"
139158
codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime "$file"
140159
fi
141160
done
142161
143-
# Re-sign the main executable with entitlements (must be last)
162+
# 4. Re-sign the main executable with entitlements (must be last)
144163
codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime --entitlements entitlements.plist dist/cycode-cli/cycode-cli
145164
146165
- name: Notarize macOS executable
@@ -176,15 +195,37 @@ jobs:
176195
177196
# we can't staple the app because it's executable
178197
198+
- name: Verify macOS code signatures
199+
if: runner.os == 'macOS'
200+
run: |
201+
# verify all Mach-O binaries are properly signed
202+
# use -L to follow symlinks (e.g. _internal/Python -> Python.framework/...)
203+
FAILED=false
204+
while IFS= read -r file; do
205+
if file -b "$file" | grep -q "Mach-O"; then
206+
if ! codesign --verify --strict "$file" 2>&1; then
207+
echo "INVALID: $file"
208+
codesign -dv "$file" 2>&1 || true
209+
FAILED=true
210+
else
211+
echo "OK: $file"
212+
fi
213+
fi
214+
done < <(find -L dist/cycode-cli -type f)
215+
216+
if [ "$FAILED" = true ]; then
217+
echo "Found binaries with invalid signatures!"
218+
exit 1
219+
fi
220+
221+
codesign -dv --verbose=4 $PATH_TO_CYCODE_CLI_EXECUTABLE
222+
179223
- name: Test macOS signed executable
180224
if: runner.os == 'macOS'
181225
run: |
182226
file -b $PATH_TO_CYCODE_CLI_EXECUTABLE
183227
time $PATH_TO_CYCODE_CLI_EXECUTABLE version
184228
185-
# verify signature
186-
codesign -dv --verbose=4 $PATH_TO_CYCODE_CLI_EXECUTABLE
187-
188229
- name: Import cert for Windows and setup envs
189230
if: runner.os == 'Windows'
190231
env:
@@ -236,6 +277,46 @@ jobs:
236277
name: ${{ env.ARTIFACT_NAME }}
237278
path: dist
238279

280+
- name: Verify macOS artifact end-to-end
281+
if: runner.os == 'macOS' && matrix.mode == 'onedir'
282+
uses: actions/download-artifact@v4
283+
with:
284+
name: ${{ env.ARTIFACT_NAME }}
285+
path: /tmp/artifact-verify
286+
287+
- name: Verify macOS artifact signatures and run with quarantine
288+
if: runner.os == 'macOS' && matrix.mode == 'onedir'
289+
run: |
290+
# extract the onedir zip exactly as an end user would
291+
ARCHIVE=$(find /tmp/artifact-verify -name "*.zip" | head -1)
292+
echo "Verifying archive: $ARCHIVE"
293+
unzip "$ARCHIVE" -d /tmp/artifact-extracted
294+
295+
# verify all Mach-O code signatures (strict mode)
296+
FAILED=false
297+
while IFS= read -r file; do
298+
if file -b "$file" | grep -q "Mach-O"; then
299+
if ! codesign --verify --strict "$file" 2>&1; then
300+
echo "INVALID: $file"
301+
codesign -dv "$file" 2>&1 || true
302+
FAILED=true
303+
else
304+
echo "OK: $file"
305+
fi
306+
fi
307+
done < <(find -L /tmp/artifact-extracted -type f)
308+
309+
if [ "$FAILED" = true ]; then
310+
echo "Artifact contains binaries with invalid signatures!"
311+
exit 1
312+
fi
313+
314+
# simulate download quarantine and test execution
315+
find -L /tmp/artifact-extracted -type f -exec xattr -w com.apple.quarantine "0081;$(printf '%x' $(date +%s));CI;$(uuidgen)" {} \;
316+
EXECUTABLE=$(find -L /tmp/artifact-extracted -name "cycode-cli" -type f | head -1)
317+
echo "Testing quarantined executable: $EXECUTABLE"
318+
time "$EXECUTABLE" version
319+
239320
- name: Upload files to release
240321
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }}
241322
uses: svenstaro/upload-release-action@v2

process_executable_file.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import os
1414
import platform
1515
import shutil
16+
import subprocess
1617
from pathlib import Path
1718
from string import Template
1819
from typing import Union
@@ -140,6 +141,18 @@ def get_cli_archive_path(output_path: Path, is_onedir: bool) -> str:
140141
return os.path.join(output_path, get_cli_archive_filename(is_onedir))
141142

142143

144+
def archive_directory(input_path: Path, output_path: str) -> None:
145+
if get_os_name() == 'darwin':
146+
# use zip -y to preserve symlinks (required for macOS code signature validity)
147+
subprocess.run(
148+
['zip', '-y', '-r', os.path.abspath(output_path), '.'],
149+
cwd=str(input_path),
150+
check=True,
151+
)
152+
else:
153+
shutil.make_archive(output_path.removesuffix(f'.{_ARCHIVE_FORMAT}'), _ARCHIVE_FORMAT, input_path)
154+
155+
143156
def process_executable_file(input_path: Path, is_onedir: bool) -> str:
144157
output_path = input_path.parent
145158
hash_file_path = get_cli_hash_path(output_path, is_onedir)
@@ -150,7 +163,7 @@ def process_executable_file(input_path: Path, is_onedir: bool) -> str:
150163
write_hashes_db_to_file(normalized_hashes, hash_file_path)
151164

152165
archived_file_path = get_cli_archive_path(output_path, is_onedir)
153-
shutil.make_archive(archived_file_path, _ARCHIVE_FORMAT, input_path)
166+
archive_directory(input_path, f'{archived_file_path}.{_ARCHIVE_FORMAT}')
154167
shutil.rmtree(input_path)
155168
else:
156169
file_hash = get_hash_of_file(input_path)

0 commit comments

Comments
 (0)