Skip to content

OSX

OSX #381

Workflow file for this run

name: OSX
on:
workflow_dispatch:
inputs:
run-tests:
description: 'Run the test suite (turn off to validate packaging alone)'
type: boolean
default: true
push:
branches: [main]
tags: ['[0-9]+.[0-9]+.[0-9]+']
jobs:
build:
# Pinned label (macos-latest rolls to new macOS releases); the image
# still receives weekly tool updates under the label, recorded in the
# artifact's BUILD_INFO.
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Pin Xcode toolchain
# macos-15 keeps several Xcodes around and can switch the default
# between image rolls; a default switch would silently recompile
# both binary slices with a different clang. Select an explicit
# version (the long-staying image default); if a future image
# drops it this fails loudly instead of drifting. Must run before
# setup-nim: on macOS the action builds Nim from source, and the
# bootstrap compile uses this clang too.
run: |
sudo xcode-select -s /Applications/Xcode_16.4.app
xcodebuild -version
xcodebuild -version | grep -qx 'Xcode 16.4' || { echo "Xcode 16.4 not selected" >&2; exit 1; }
cc --version | head -1
- name: Restore Nim toolchain cache
id: cache-nim
uses: actions/cache@v4
with:
path: /Users/runner/nim-toolchain
# Key includes the pinned nim-version: a hit must never resurrect
# a toolchain older/newer than the nim-version input above.
key: ${{ runner.os }}-${{ runner.arch }}-nim-toolchain-2.2.12-${{ hashFiles('*.nimble') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-nim-toolchain-
- uses: jiro4989/setup-nim-action@v2
if: steps.cache-nim.outputs.cache-hit != 'true'
with:
# Exact version, not `stable`: the same commit must compile with
# the same compiler forever. Bump deliberately, together with
# regenerating nimble.lock (its `nim` entry records the version).
nim-version: 2.2.12
repo-token: ${{ secrets.GITHUB_TOKEN }}
parent-nim-install-directory: $HOME
nim-install-directory: nim-toolchain
- name: Add Nim to PATH on cache hit
if: steps.cache-nim.outputs.cache-hit == 'true'
run: echo "$HOME/nim-toolchain/bin" >> $GITHUB_PATH
- name: Install dependencies
# nimble.lock pins every dependency to an exact revision + checksum;
# install honors it, so rebuilds of the same commit get identical
# dependency trees (bump requires -> `nimble lock` to update).
run: |
nimble install -y --depsOnly
- name: Compute build flags
shell: bash
run: |
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
# autoupdate on releases only
echo "BUILD_FLAGS=-d:autoUpdate" >> "$GITHUB_ENV"
fi
- name: Build
run: nimble build -y -d:release ${{ env.BUILD_FLAGS }}
- name: Verify sandbox matrix (seatbelt + wall proxy)
# End-to-end enforcement check of the shipped binary, mirroring
# the manual stefani VM verification: the wall proxy fences
# egress behind an allowlist, the box subprocess applies the
# Seatbelt profile, and the policy's path denies punch through.
run: |
set -e
BIN="$PWD/3code"
SB="$(mktemp -d)/sb"
mkdir -p "$SB/subdir"
cd "$SB"
fail() { echo "SANDBOX MATRIX FAIL: $1" >&2; exit 1; }
# 1. fenced network: allowlist proxy lets google.com through
printf 'allow\ndeny ./subdir\nallow *\ndeny 1.1.1.1\n' > .sandbox
"$BIN" wall proxy --policy .sandbox --port 0 > /tmp/wallport.txt 2>&1 &
sleep 2
PORT=$(awk '{print $2}' /tmp/wallport.txt)
[ -n "$PORT" ] || fail "wall proxy did not start"
export WALL_PROXY_PORT="$PORT"
export http_proxy="http://127.0.0.1:$PORT" https_proxy="http://127.0.0.1:$PORT"
code=$("$BIN" sandbox --policy .sandbox restrict -- curl -sS -o /dev/null -w '%{http_code}' --max-time 20 https://www.google.com) \
|| fail "curl google.com through the wall proxy"
[ "$code" = 200 ] || fail "google.com expected 200, got $code"
# 2. denied host: the proxy must refuse 1.1.1.1. For a plain
# http:// URL the 403 arrives as a regular response, so curl
# exits 0; only the status code tells the deny.
out=$("$BIN" sandbox --policy .sandbox restrict -- curl -sS -o /dev/null -w '%{http_code}' --max-time 20 http://1.1.1.1) \
|| fail "curl 1.1.1.1 through the wall proxy errored"
[ "$out" = 403 ] || fail "1.1.1.1 expected 403, got $out"
# 3. writable root: bash write into the project dir works
"$BIN" sandbox --policy .sandbox restrict -- sh -c 'echo hi > ok.txt' \
|| fail "write to the project root was denied"
[ "$(cat ok.txt)" = hi ] || fail "project-root write produced wrong content"
# 4. denied subdir: bash write under ./subdir must fail
if "$BIN" sandbox --policy .sandbox restrict -- sh -c 'echo bad > subdir/bad.txt' 2>/dev/null; then
fail "write into ./subdir was allowed"
fi
[ ! -e subdir/bad.txt ] || fail "./subdir/bad.txt exists despite the deny"
kill %1 2>/dev/null || true
unset http_proxy https_proxy
# 5. open network: a policy without host rules must not block
# sockets (the (deny default) baseline covers network-* too;
# regression check for the bare (allow network-*) fix)
printf 'allow\ndeny ./subdir\n' > .sandbox
code=$("$BIN" sandbox --policy .sandbox restrict -- curl -sS -o /dev/null -w '%{http_code}' --max-time 20 https://www.google.com) \
|| fail "open-network curl google.com"
[ "$code" = 200 ] || fail "open-network google.com expected 200, got $code"
echo "sandbox matrix: all checks passed"
- name: Generate nimble.paths for direct nim invocations
# Placed after the arm64 slice's `nimble build`: extra --path
# switches change module symbol naming, so the release slice keeps
# the exact path set nimble passes. See the comment in windows.yml
# for what this file is for.
run: |
{ echo '--noNimblePath'
for d in "$HOME"/.nimble/pkgs2/*/; do echo "--path:\"$d\""; done
} > nimble.paths
cat nimble.paths
- name: Build x86_64 and create universal binary
env:
SLICE_DIR: ${{ runner.temp }}
run: |
mv 3code "$SLICE_DIR/3code-arm64"
rm -rf ~/.cache/nim/threecode_r
nim c -d:release ${{ env.BUILD_FLAGS }} --passC:"-arch x86_64" --passL:"-arch x86_64" --cpu:amd64 -o:"$SLICE_DIR/3code-x86_64" src/threecode.nim
lipo -create "$SLICE_DIR/3code-arm64" "$SLICE_DIR/3code-x86_64" -output 3code
file 3code
- name: Run tests
# tests/tools/ci_tests.sh wraps testament: log file instead of a pipe (a
# single hung test used to stall the pipe and hide everything after
# it), a watchdog that bounds the run and attributes any hang to the
# exact test binary, and a propagated exit code. Categories run in
# parallel via `testament all`; the tty PTY tests get the runner to
# themselves because each category is still sequential inside
# testament. If load-induced tty flakes reappear on this 3-core
# runner, pass explicit categories to serialize them.
# Dispatch-only bypass: tty timing tests are marginal on the 3-core
# macos-15 runner; run-tests=false validates build+packaging alone.
if: github.event_name != 'workflow_dispatch' || inputs.run-tests
run: sh tests/tools/ci_tests.sh 1500
timeout-minutes: 30
- name: Package
# Deterministic archive: entry order from a sorted list, mtimes
# anchored to the commit (SOURCE_DATE_EPOCH), owner normalized,
# gzip without name/timestamp. Python tarfile, not bsdtar: the
# runner's bsdtar rejects --mtime on create, and its failure inside
# the find|sort|bsdtar|gzip pipeline is masked by gzip's success
# (the step shell is bash -e, no pipefail), which shipped an empty
# tarball that still passed the h1==h2 double-pack check. Python
# writes every field explicitly and the member-count guard below
# catches any empty archive. The openssl dylibs come from the
# pinned bottle below.
shell: bash
run: |
export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"
mkdir -p 3code-macos-universal
cp 3code 3code-macos-universal/
# Capture the version from the built binary so it can be checked
# without running the binary on its target OS.
./3code -v > 3code-macos-universal/VERSION
cp README.md LICENSE 3code-macos-universal/
# OpenSSL dylibs from a pinned Homebrew bottle instead of the
# runner's floating brew tree: the ghcr blob is digest-addressed
# and immutable, and the sha256 makes a pin bump a conscious act
# (same policy as cacert.pem). The bottle's pre-pour install
# names (@@HOMEBREW_CELLAR@@/...) are rewritten to @loader_path
# so the pair loads standalone.
OPENSSL_VERSION=3.6.4
OPENSSL_BOTTLE_SHA256=b7139450ed389be82f807daaadffa778d9c4753a3b2d8f784da1cf7383994ee2
TOKEN="$(curl -fsSL "https://ghcr.io/token?scope=repository:homebrew/core/openssl/3:pull&service=ghcr.io" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')"
curl -fsSL -H "Authorization: Bearer ${TOKEN}" \
"https://ghcr.io/v2/homebrew/core/openssl/3/blobs/sha256:${OPENSSL_BOTTLE_SHA256}" \
-o openssl-bottle.tar.gz
echo "${OPENSSL_BOTTLE_SHA256} openssl-bottle.tar.gz" | shasum -a 256 -c -
OSSL="$(mktemp -d)"
tar -xzf openssl-bottle.tar.gz -C "$OSSL" \
"openssl@3/${OPENSSL_VERSION}/lib/libssl.3.dylib" \
"openssl@3/${OPENSSL_VERSION}/lib/libcrypto.3.dylib"
cp "$OSSL/openssl@3/${OPENSSL_VERSION}/lib/libssl.3.dylib" 3code-macos-universal/
cp "$OSSL/openssl@3/${OPENSSL_VERSION}/lib/libcrypto.3.dylib" 3code-macos-universal/
chmod +w 3code-macos-universal/libssl.3.dylib 3code-macos-universal/libcrypto.3.dylib
install_name_tool -id @loader_path/libcrypto.3.dylib 3code-macos-universal/libcrypto.3.dylib
install_name_tool -id @loader_path/libssl.3.dylib 3code-macos-universal/libssl.3.dylib
# The pre-pour reference to libcrypto varies by bottle; rewrite
# whatever otool reports instead of hardcoding it.
CRYPTO_REF="$(otool -L 3code-macos-universal/libssl.3.dylib | awk '/libcrypto/ {print $1; exit}')"
install_name_tool -change "$CRYPTO_REF" @loader_path/libcrypto.3.dylib \
3code-macos-universal/libssl.3.dylib
ln -sf libssl.3.dylib 3code-macos-universal/libssl.dylib
ln -sf libcrypto.3.dylib 3code-macos-universal/libcrypto.dylib
# Pinned by checksum: cacert.pem is a mutable URL, so an upstream
# refresh must be a conscious pin bump, not silent drift.
CACERT_SHA256=a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505
curl -fsSL https://curl.se/ca/cacert.pem -o 3code-macos-universal/cacert.pem
echo "${CACERT_SHA256} 3code-macos-universal/cacert.pem" | shasum -a 256 -c -
{
nim --version | head -1
cc --version | head -1
echo "openssl@3 ${OPENSSL_VERSION} bottle:${OPENSSL_BOTTLE_SHA256}"
shasum -a 256 3code | cut -d' ' -f1
echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH"
} > 3code-macos-universal/BUILD_INFO
cat > /tmp/mktar.py <<'PY'
import gzip, os, sys, tarfile
src, out, epoch = sys.argv[1], sys.argv[2], int(sys.argv[3])
names = sorted(
os.path.join(r, n).replace(os.sep, "/")
for r, ds, fs in os.walk(src)
for n in ds + fs)
# GzipFile with filename='' and mtime=0: no name, no timestamp
with open(out, "wb") as raw, \
gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz, \
tarfile.open(fileobj=gz, mode="w") as t:
for n in names:
ti = t.gettarinfo(n)
ti.mtime = epoch
ti.uid = ti.gid = 0
ti.uname = ti.gname = ""
if ti.isfile():
t.addfile(ti, open(n, "rb"))
else:
t.addfile(ti)
PY
pack() { python3 /tmp/mktar.py 3code-macos-universal 3code-macos-universal.tar.gz "$SOURCE_DATE_EPOCH"; }
pack
h1=$(shasum -a 256 3code-macos-universal.tar.gz | cut -d' ' -f1)
# files-only layout, like the windows zip: 10 members
members=$(tar -tzf 3code-macos-universal.tar.gz | wc -l)
[ "$members" -ge 10 ] || { echo "archive has only $members members" >&2; exit 1; }
find 3code-macos-universal ! -type l -exec touch {} + # perturb mtimes (not symlinks: touch would create dangling targets)
pack
h2=$(shasum -a 256 3code-macos-universal.tar.gz | cut -d' ' -f1)
[ "$h1" = "$h2" ] || { echo "archive not reproducible: $h1 vs $h2" >&2; exit 1; }
- name: Upload artifact (GitHub Actions)
uses: actions/upload-artifact@v4
with:
name: 3code-macos-universal
path: 3code-macos-universal.tar.gz
- name: Upload artifact to 3code.capocasa.dev
if: github.ref == 'refs/heads/main'
run: |
# --retry: 5xx is transient on the upload endpoint
curl -f --retry 5 --retry-delay 5 -H "Authorization: Bearer ${{ secrets.RELEASE_SECRET }}" -F "file=@3code-macos-universal.tar.gz" \
https://3code.capocasa.dev/main/builds/upload.nim