OSX #381
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |