blaise/.github/workflows/bootstrap.yml
Graeme Geldenhuys 0ee32e51f5 ci(freebsd): run cross-compiled FreeBSD binaries under emulation (Step 8)
Host-side static ELF checks (EI_OSABI/ET_EXEC/no-PT_INTERP, already asserted by
TInternalLinkerE2ETests.TestCompile_FreeBSDTarget_EmitsStaticFreeBSDExe) cannot
catch a wrong FreeBSD struct-stat offset or syscall number — only running the
binary on a real FreeBSD kernel does.

Add a freebsd-crosscheck CI job (needs: bootstrap) that:
  * downloads the -pre binary the bootstrap job built,
  * cross-compiles the fixtures in compiler/src/it/freebsd (hello, fileio) with
    --target freebsd-x86_64 on the Linux runner via
    scripts/freebsd-crosscheck-build.sh (which also sanity-checks each emitted
    ELF is a FreeBSD ET_EXEC), and
  * boots a FreeBSD VM (vmactions/freebsd-vm, release 14.2 to match the
    struct-stat offsets in rtl.platform.layout.freebsd) and runs the binaries
    there, asserting hello prints 'Hello' and fileio round-trips a file
    (write/read/stat/delete) with 'file-io-ok'/'exists-ok'/'done'.

The FreeBSD output is a static, freestanding raw-syscall ET_EXEC, so the VM
needs no extra packages to run it.  fileio is the meaningful check: it exercises
open/write/read/stat/unlink through the FreeBSD leaf and layout, which the
static checks are blind to.
2026-07-01 17:28:07 +01:00

353 lines
16 KiB
YAML

name: Rolling bootstrap & tests
# Triggers:
# - push to master : keep the -pre binary fresh on every commit
# - push to ci/** , feature/**, bootstrap/**
# : validate-only pre-flight for risky changes — does
# the rolling bootstrap still succeed? These runs do
# NOT write the carry-forward cache or publish the
# -pre artifact (see the master-only guards on those
# steps), so a broken branch cannot affect master
# consumers or other developers. Use a throwaway
# branch under one of these prefixes to prove a
# change before merging it to master.
# - workflow_dispatch : manual run from the Actions UI, against any ref
on:
push:
branches:
- master
- 'ci/**'
- 'feature/**'
- 'bootstrap/**'
workflow_dispatch:
inputs:
force_full:
description: "Ignore cached -pre binary and bootstrap from the release tag"
type: boolean
default: false
# Cancel an in-flight run on master when a newer commit lands — no point
# bootstrapping a commit that is already superseded.
concurrency:
group: bootstrap-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# Cold-start anchor: the newest *real* release whose binary is published as a
# GitHub Release asset. Used only when no cached -pre binary is available.
# v0.12.0 is the clean rolling-bootstrap anchor: the broken v0.11.0->v0.12.0
# commit range is BEFORE it, so the replay range (v0.12.0..HEAD) is all
# native-default + internal-toolchain and needs neither QBE nor gcc.
STAGE1_TAG: v0.12.0
jobs:
bootstrap:
runs-on: ubuntu-latest
timeout-minutes: 120 # full ~190-commit replay is slow; incremental is fast
steps:
# --------------------------------------------------------------------
# 1. Check out the full history. rolling-bootstrap.sh replays commits,
# so a shallow clone is useless — fetch-depth: 0 gets everything.
# --------------------------------------------------------------------
- name: Checkout (full history)
uses: actions/checkout@v6
with:
fetch-depth: 0
# --------------------------------------------------------------------
# 2. Toolchain. From the v0.12.0 anchor onward the bootstrap chain is
# native-default with the internal assembler + linker, so neither QBE
# nor gcc is needed to replay it — only make (runtime Makefile) and the
# distro CRT objects (build-essential) the internal linker reads.
# --------------------------------------------------------------------
- name: Install build tools
run: |
sudo apt-get update -qq
sudo apt-get install -y build-essential unzip
# --------------------------------------------------------------------
# 3b. Build vendored QBE. The bootstrap chain is native-default and does
# NOT need QBE, but the E2E test suite still shells out to it (the
# QBE backend arm of AssertRunsOnAll, plus the toolchain-gated
# round-trip tests). Without it ~218 tests fail with
# "<toolchain-missing>" / "[qbe] compile+run". It is vendored, so a
# plain `make` builds the binary the tests' BLAISE_QBE fallback finds.
# --------------------------------------------------------------------
- name: Build vendored QBE
run: make -C vendor/qbe
# --------------------------------------------------------------------
# 4. Restore the carry-forward cache: the previous successful master run
# stashes its -pre binary + a marker file recording the commit SHA it
# was built at. We restore them into releases/<sha>/ so the script can
# resume with --from <sha> instead of replaying from the release tag.
#
# Keyed by run id so every run writes a fresh entry; restore-keys does
# a prefix match to pull the most recent prior cache. (A fixed key
# would never update — caches are immutable once written.)
# --------------------------------------------------------------------
- name: Restore bootstrap cache
id: cache-restore
if: ${{ !inputs.force_full }}
uses: actions/cache/restore@v5
with:
path: .bootstrap-cache
key: blaise-bootstrap-${{ github.run_id }}
restore-keys: |
blaise-bootstrap-
# --------------------------------------------------------------------
# 5. Decide the start point.
# - If a cached binary exists AND its marker SHA is an ancestor of
# HEAD, stage it into releases/<sha>/ and set FROM=<sha>.
# - Otherwise fall back to the published release tag (cold start),
# downloading its binary asset from the GitHub Release.
# --------------------------------------------------------------------
- name: Resolve start binary
id: resolve
run: |
set -euo pipefail
FROM=""
CACHE_BIN=".bootstrap-cache/blaise"
CACHE_RTL=".bootstrap-cache/blaise_rtl.a"
CACHE_MARK=".bootstrap-cache/commit.sha"
if [ -x "$CACHE_BIN" ] && [ -f "$CACHE_MARK" ]; then
SHA="$(cat "$CACHE_MARK")"
if git cat-file -e "${SHA}^{commit}" 2>/dev/null \
&& git merge-base --is-ancestor "$SHA" HEAD 2>/dev/null; then
echo "Cached -pre binary found at commit $SHA — resuming from there."
mkdir -p "releases/$SHA"
cp "$CACHE_BIN" "releases/$SHA/blaise"
cp "$CACHE_RTL" "releases/$SHA/blaise_rtl.a"
chmod +x "releases/$SHA/blaise"
FROM="$SHA"
else
echo "Cached binary's commit ($SHA) is not an ancestor of HEAD — ignoring it."
fi
else
echo "No cached binary present."
fi
if [ -z "$FROM" ]; then
echo "Cold start from release tag $STAGE1_TAG."
DEST="releases/$STAGE1_TAG"
mkdir -p "$DEST"
# The stage-1 release ships as a versioned tarball, not bare assets.
curl -fsSL -o /tmp/stage1.tar.gz \
"https://github.com/graemeg/blaise/releases/download/${STAGE1_TAG}/blaise-${STAGE1_TAG}-linux-x86_64.tar.gz"
mkdir -p /tmp/stage1
tar -xzf /tmp/stage1.tar.gz -C /tmp/stage1
# Normalise to the names rolling-bootstrap.sh expects, wherever the
# files landed inside the archive.
BIN="$(find /tmp/stage1 -type f -name blaise | head -1)"
RTL="$(find /tmp/stage1 -type f -name blaise_rtl.a | head -1)"
if [ -z "$BIN" ] || [ -z "$RTL" ]; then
echo "::error::blaise / blaise_rtl.a not found inside the stage-1 tarball."
find /tmp/stage1 -maxdepth 2 -type f | sed 's/^/ /'
exit 1
fi
cp "$BIN" "$DEST/blaise"
cp "$RTL" "$DEST/blaise_rtl.a"
chmod +x "$DEST/blaise"
FROM="$STAGE1_TAG"
fi
echo "from=$FROM" >> "$GITHUB_OUTPUT"
# --------------------------------------------------------------------
# 6. Run the rolling bootstrap. Installs the final -pre binary under
# releases/v<version>-pre/ (the script computes the dir from
# Blaise.pas's Version constant).
# --------------------------------------------------------------------
- name: Rolling bootstrap
run: |
set -euo pipefail
./scripts/rolling-bootstrap.sh --from "${{ steps.resolve.outputs.from }}" --to HEAD
# --------------------------------------------------------------------
# 7. Locate the freshly-installed -pre binary for the steps that follow.
# --------------------------------------------------------------------
- name: Locate -pre binary
id: pre
run: |
set -euo pipefail
PRE_DIR="$(ls -d releases/v*-pre 2>/dev/null | sort -V | tail -1)"
if [ -z "$PRE_DIR" ] || [ ! -x "$PRE_DIR/blaise" ]; then
echo "No -pre binary produced — bootstrap must have failed." >&2
exit 1
fi
echo "Using $PRE_DIR/blaise"
echo "dir=$PRE_DIR" >> "$GITHUB_OUTPUT"
echo "bin=$PWD/$PRE_DIR/blaise" >> "$GITHUB_OUTPUT"
# --------------------------------------------------------------------
# 8. Install RTL + build the test runner with the -pre binary, then run
# the full test suite. PasBuild is NOT on the runner, so we mirror the
# documented manual build path: make install for the RTL, then
# pasbuild only if available — otherwise we fall back to the binary's
# own test-compile. (Adjust here if PasBuild gets installed below.)
# --------------------------------------------------------------------
- name: Build RTL with -pre binary
run: |
set -euo pipefail
BLAISE="${{ steps.pre.outputs.bin }}"
# Native default backend builds the RTL with no external tools.
( cd runtime && make BLAISE="$BLAISE" && make BLAISE="$BLAISE" install )
- name: Install PasBuild
run: |
set -euo pipefail
curl -fsSL -o /tmp/pasbuild.zip \
https://github.com/graemeg/PasBuild/releases/download/v1.9.0/pasbuild-1.9.0-x86_64-linux.zip
unzip -o -q /tmp/pasbuild.zip -d /tmp/pasbuild
# The binary may sit at the zip root or inside a subdir — find it.
PB="$(find /tmp/pasbuild -type f -name pasbuild | head -1)"
if [ -z "$PB" ]; then
echo "::error::pasbuild binary not found inside the release zip."
find /tmp/pasbuild -maxdepth 2 -type f | sed 's/^/ /'
exit 1
fi
chmod +x "$PB"
sudo install -m 0755 "$PB" /usr/local/bin/pasbuild
pasbuild --version || true
- name: Build test runner & run tests
run: |
set -euo pipefail
BLAISE="${{ steps.pre.outputs.bin }}"
pasbuild test-compile -m blaise-compiler --compiler "$BLAISE"
# Point the CLI-contract and internal-assembler suites at the
# bootstrapped -pre binary. Without this they fall back to the
# /tmp/fp_blaise* fixpoint binaries (which only exist after a local
# fixpoint.sh run) and silently Ignore ~24 tests in CI.
# BLAISE_QBE points the E2E suite at the vendored QBE built above
# (its fallback is vendor/qbe/qbe, but set it explicitly).
BLAISE_QBE="$PWD/vendor/qbe/qbe" \
BLAISE_QBE_COMPILER="$BLAISE" compiler/target/TestRunner
# --------------------------------------------------------------------
# 9. Save the carry-forward cache for the next run: the -pre binary, its
# rtl, and a marker recording the HEAD SHA it was built at.
# MASTER ONLY: the carry-forward cache is the resume anchor for master
# runs. A branch run must never overwrite it — otherwise a feature
# branch's -pre binary would leak into the next master run and a broken
# branch could poison master's bootstrap. Branch runs are validate-only.
# --------------------------------------------------------------------
- name: Stage carry-forward cache
if: ${{ success() && github.ref == 'refs/heads/master' }}
run: |
set -euo pipefail
rm -rf .bootstrap-cache
mkdir -p .bootstrap-cache
cp "${{ steps.pre.outputs.dir }}/blaise" .bootstrap-cache/blaise
cp "${{ steps.pre.outputs.dir }}/blaise_rtl.a" .bootstrap-cache/blaise_rtl.a
git rev-parse HEAD > .bootstrap-cache/commit.sha
echo "Marked cache at $(cat .bootstrap-cache/commit.sha)"
- name: Save bootstrap cache
if: ${{ success() && github.ref == 'refs/heads/master' }}
uses: actions/cache/save@v5
with:
path: .bootstrap-cache
key: blaise-bootstrap-${{ github.run_id }}
# --------------------------------------------------------------------
# 10. Publish the -pre binary + rtl as downloadable workflow artifacts.
# Runs on branches too (the name is keyed by commit SHA, so a branch
# artifact never collides with master's and is handy for inspecting a
# pre-flight build). Unlike the cache, an artifact is pull-only — it
# is never consumed by a later run, so it cannot affect master.
# --------------------------------------------------------------------
- name: Upload -pre binary artifact
if: success()
uses: actions/upload-artifact@v7
with:
name: blaise-pre-${{ github.sha }}
path: |
${{ steps.pre.outputs.dir }}/blaise
${{ steps.pre.outputs.dir }}/blaise_rtl.a
retention-days: 30
if-no-files-found: error
# ======================================================================
# FreeBSD cross-compile verification under emulation
# (docs/freebsd-x86_64-backend-design.adoc Step 8).
#
# The bootstrap job's TestRunner already runs the HOST-side static ELF
# checks (EI_OSABI / ET_EXEC / no PT_INTERP via
# TInternalLinkerE2ETests.TestCompile_FreeBSDTarget_EmitsStaticFreeBSDExe).
# Those cannot catch a wrong FreeBSD struct-stat offset or syscall number —
# only running the binary on a real FreeBSD kernel does. This job
# cross-compiles the fixtures on the Linux runner, then boots a FreeBSD VM
# (vmactions/freebsd-vm) and executes them there.
#
# The FreeBSD output is a static, freestanding raw-syscall ET_EXEC (no libc,
# no rtld), so the VM needs no extra packages to run it.
# ======================================================================
freebsd-crosscheck:
needs: bootstrap
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
# The RTL is compiled from source by the -pre binary, so the compiler
# needs no prebuilt blaise_rtl.a beside it — only the checked-out RTL
# source (present after checkout). Pull the -pre binary the bootstrap
# job just built and published.
- name: Download -pre binary
uses: actions/download-artifact@v7
with:
name: blaise-pre-${{ github.sha }}
path: .pre
- name: Cross-compile FreeBSD fixtures (Linux host)
run: |
set -euo pipefail
chmod +x .pre/blaise
BLAISE="$PWD/.pre/blaise" scripts/freebsd-crosscheck-build.sh
echo "Cross-built FreeBSD binaries:"
ls -l target/freebsd-crosscheck
# Boot a FreeBSD VM and run the cross-compiled binaries there. The
# workspace is rsync-mounted into the VM, so target/freebsd-crosscheck/*
# is available inside. release pinned to a 14.x line: the struct-stat
# offsets in rtl.platform.layout.freebsd are verified against FreeBSD 14
# (stable since FreeBSD 12). Bump when 15.x is validated.
- name: Run FreeBSD binaries under emulation
uses: vmactions/freebsd-vm@v1
with:
release: "14.2"
usesh: true
copyback: false
run: |
set -e
cd target/freebsd-crosscheck
echo "=== uname ==="
uname -a
echo "=== hello ==="
OUT="$(./hello)"
echo "$OUT"
[ "$OUT" = "Hello" ] || { echo "FAIL: hello output '$OUT' != 'Hello'"; exit 1; }
echo "=== fileio ==="
OUT="$(./fileio)"
echo "$OUT"
# Flatten to a single line (newlines -> '|') so the compare stays a
# one-line shell string inside this YAML block scalar.
GOT="$(printf '%s' "$OUT" | tr '\n' '|')"
[ "$GOT" = "file-io-ok|exists-ok|done" ] || {
echo "FAIL: fileio output '$GOT' != 'file-io-ok|exists-ok|done'"
exit 1
}
echo "FREEBSD_EMULATION_OK"