Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,148 @@ jobs:
timeout-minutes: 20
run: make test-macos-leaks

# ============================================================
# FreeBSD cross-compile gate (#402)
#
# Why this exists: the FreeBSD spelling of every `#ifdef` in the
# tree used to be compiled in exactly one place, the release
# workflow's build-freebsd leg, which runs only AFTER merge and was
# best-effort. A FreeBSD-only break therefore passed PR CI, passed
# the release, and surfaced only when somebody built on FreeBSD.
# That is how MNT_NODEV (removed in FreeBSD 10, still present on
# macOS and OpenBSD) reached main.
#
# `make ci` cannot catch this on any single host: it compiles only
# the branch of each platform conditional that matches the runner.
# Compiling FOR FreeBSD is the only thing that reads the FreeBSD
# branches, so it has to happen before merge, and it has to be
# required. No continue-on-error here on purpose.
# ============================================================
ci-freebsd-cross:
name: FreeBSD / cross-compile (x86_64)
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v4

- name: Install toolchain
run: sudo apt-get update && sudo apt-get install -y gcc make bc curl xz-utils

# The pinned zig and the FreeBSD base sysroot (headers + libc) live in the
# sibling aether-crossbuild repo, same as the release leg uses. zig cc does
# not bundle a FreeBSD libc, so the base is what makes FreeBSD headers
# visible to the compile.
- name: Checkout aether-crossbuild
uses: actions/checkout@v4
with:
repository: aether-lang-dev/aether-crossbuild
path: crossbuild
fetch-depth: 1

# Cache the downloaded tarballs, not the unpacked trees: get-zig.sh and
# fetch-freebsd-base.sh both skip the download when the tarball is already
# present and then re-extract, and fetch-freebsd-base.sh deletes and
# rebuilds its sysroot dir every run. Keyed on deps.lock, which pins both
# URLs and their checksums, so a hit is always the pinned bytes.
- name: Restore cross-toolchain download cache
id: cross_cache
uses: actions/cache/restore@v4
with:
path: |
crossbuild/toolchain
crossbuild/work/downloads
key: freebsd-cross-${{ hashFiles('crossbuild/deps.lock') }}

- name: Provision zig + FreeBSD base sysroot
id: prov
run: |
cd crossbuild
./scripts/get-zig.sh
./scripts/fetch-freebsd-base.sh x86_64 15
ZIG="$(find "$PWD/toolchain" -maxdepth 2 -name zig -type f | head -1)"
SR="$PWD/bases/x86_64-freebsd15"
test -x "$ZIG" || { echo "zig not provisioned"; exit 1; }
test -f "$SR/lib/libc.so.7" || { echo "FreeBSD base sysroot missing libc.so.7"; exit 1; }
echo "zig=$ZIG" >> "$GITHUB_OUTPUT"
echo "sysroot=$SR" >> "$GITHUB_OUTPUT"

- name: Save cross-toolchain download cache (merge to main, on miss only)
if: >-
github.event_name == 'push' && github.ref == 'refs/heads/main'
&& steps.cross_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
crossbuild/toolchain
crossbuild/work/downloads
key: freebsd-cross-${{ hashFiles('crossbuild/deps.lock') }}

- name: Cross-build for FreeBSD (FREEBSD=1)
run: |
make compiler ae stdlib \
FREEBSD=1 \
ZIG="${{ steps.prov.outputs.zig }}" \
AETHER_SYSROOT="${{ steps.prov.outputs.sysroot }}"
# A cross-build that silently produced Linux binaries would pass the
# compile and prove nothing, so confirm the target really is FreeBSD.
file build/aetherc build/ae
file build/ae | grep -q 'FreeBSD' || { echo "ae is not a FreeBSD binary"; exit 1; }

# ============================================================
# FreeBSD native build + test (#402) — POST-MERGE
#
# The cross gate above proves the FreeBSD branches COMPILE. It
# cannot run anything: a FreeBSD ELF needs a FreeBSD kernel. This
# job boots a real FreeBSD VM and runs the C unit suite, so the
# runtime, scheduler (kqueue poller) and stdlib are exercised on
# the platform rather than merely type-checked for it.
#
# Why this one runs on push to main and not on pull requests:
# booting a VM, installing packages and building the tree from
# scratch costs tens of minutes, and what it adds over the cross
# gate is RUNTIME divergence, which is far rarer than a compile
# break and rarely PR-specific. The compile-break class, which is
# what actually broke FreeBSD, is fully covered before merge by
# the cross gate. Every merge to main runs this, so coverage is
# continuous rather than scheduled.
#
# What makes this safe as a post-merge check is that it HARD
# FAILS. The MNT_NODEV break did not hide because its check ran
# after merge; it hid because that check was continue-on-error
# and excluded from the release gate, so failing looked exactly
# like passing. Nothing here is allowed to fail quietly.
#
# Scope is `make test` (the 229 C unit tests), not the full
# `make ci`: the .ae suites and examples shell out through the
# freshly built toolchain and would put a multi-hour build inside
# an emulated VM. The C suite is where the platform-specific
# runtime and stdlib code actually lives.
# ============================================================
ci-freebsd-native:
name: FreeBSD / native build + unit tests
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- uses: actions/checkout@v4

- name: Build and test inside a FreeBSD VM
uses: vmactions/freebsd-vm@v1
with:
release: '14.2'
usesh: true
# FreeBSD base ships clang, bc and BSD make; this tree needs GNU make,
# and pkgconf for the Makefile's optional-library probes. bc is NOT a
# package here (it lives in base), so asking pkg for it fails the run.
prepare: |
pkg install -y gmake pkgconf
run: |
set -e
gmake compiler ae stdlib
gmake test

# ============================================================
# contrib/host bridge check — syntax check of every host bridge
# in stub mode (always-on), plus end-to-end build+link+run of
Expand Down
24 changes: 13 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -442,17 +442,18 @@ jobs:
# cc (Makefile FREEBSD=1 mode), against a FreeBSD base sysroot #
# provisioned from the aether-crossbuild repo. #
# #
# continue-on-error: this leg is BEST-EFFORT — a provisioning hiccup #
# (mirror down, upstream base moved) must never block the release of #
# the four first-class platforms. The artifact is also UNTESTED (no #
# FreeBSD to run `make test` on); it is validated only as a correct #
# FreeBSD ELF. If the leg fails or is skipped, publish simply omits #
# the FreeBSD asset. #
# This leg is REQUIRED, not best-effort. It used to be #
# continue-on-error with publish deliberately excluding it from the #
# gate, so a FreeBSD compile failure produced a green release that #
# silently shipped without the FreeBSD asset. That is precisely how #
# the MNT_NODEV break stayed invisible after merge (#402). A broken #
# FreeBSD build is now a broken release. PR CI cross-compiles for #
# FreeBSD on every pull request, so a code break is caught before it #
# can ever reach this point. #
# ================================================================== #
build-freebsd:
name: Build -- freebsd-x86_64 (cross)
needs: tag
continue-on-error: true
if: |
always() && (
(needs.tag.result == 'success' && needs.tag.outputs.tag != '') ||
Expand Down Expand Up @@ -548,13 +549,14 @@ jobs:
# ================================================================== #
publish:
name: Publish release
# build-freebsd is in `needs` only so publish waits for its artifact to be
# available — it is NOT in the `if` gate, so a failed/skipped FreeBSD leg
# (continue-on-error) never blocks the release; publish just omits the asset.
# build-freebsd is in the `if` gate, not just in `needs`: a release that
# quietly drops a platform because its build broke is worse than no
# release, and dropping it silently is what hid the last FreeBSD break.
needs: [tag, build, build-freebsd]
runs-on: ubuntu-latest
if: |
always() && needs.build.result == 'success' && (
always() && needs.build.result == 'success'
&& needs.build-freebsd.result == 'success' && (
(needs.tag.result == 'success' && needs.tag.outputs.tag != '') ||
startsWith(github.ref, 'refs/tags/')
)
Expand Down
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`main`, the release pipeline automatically replaces `[current]` with the
next version number before tagging the release.

## [current]

### Added

- **FreeBSD is a real CI target** (#402), split by what each check can catch.
Every pull request cross-compiles the toolchain for FreeBSD (`FREEBSD=1`,
zig cc against a pinned FreeBSD base sysroot): a compile break can only be
caught by compiling, and this is fast and deterministic. Every merge to main
additionally builds the tree and runs the C unit suite inside a native
FreeBSD VM, which is what covers runtime divergence (the kqueue poller) and
costs too much to put in front of every pull request. Both hard-fail. Until
now the only FreeBSD compile in the tree lived in the release workflow, so
nothing read the FreeBSD branch of any `#ifdef` before code landed. `make ci`
cannot cover this on its own: it compiles only the branch of each platform
conditional that matches the host it runs on.

### Changed

- **A failed FreeBSD build now fails the release** instead of silently shipping
without the FreeBSD asset. The leg was `continue-on-error` and deliberately
left out of the publish gate, so a broken FreeBSD build produced a green
release with a platform quietly missing. That is what kept the `MNT_NODEV`
break invisible after it merged.
- **Mount options are built from a table of the flags the platform actually
defines**, rather than a fixed format string with an empty-string substitute
for whatever is missing. A flag absent from the OS is absent from the table,
so FreeBSD (which removed `MNT_NODEV` in 10, where nodev became a no-op)
reports no nodev state instead of reporting it as off.

### Removed

- **The optional-macro portability probe** (`make ci-optional-macros`), added
one release ago to simulate a missing `MNT_NODEV` by preprocessing the source
and recompiling it. Compiling for FreeBSD in CI supersedes it: the probe
approximated one platform through a hand-maintained list of file/macro/anchor
triples that every future guard had to be added to by hand, and its awk
line-insertion and its hardcoded `-std=` both diverged from the real build
before it caught anything. `make ci` is back to 9 steps.

### Fixed

- `fs_is_socket` and `os_user_id_raw` (#1368) were defined without declarations
in `std/fs/aether_fs.h` and `std/os/aether_os.h`, the only functions in
either module missing a prototype. Documented the new stat kinds,
`fs.fs_is_socket` and `os.user_id()` in `docs/stdlib-reference.md`, where the
kind encoding still described only kinds 1 through 4.

## [0.473.0]

### Added
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Keep checked-in Aether source canonically formatted; CI enforces this
Run the full CI suite locally, this is the same suite that GitHub Actions runs:

```bash
make ci # Full 10-step suite with -Werror (compiler, tests, examples, smoke tests)
make ci # Full 9-step suite with -Werror (compiler, tests, examples, smoke tests)
HARDEN=1 make ci # Hardened-build sweep (-fstack-protector-all + _FORTIFY_SOURCE=2);
# required for any PR that touches C in compiler/, runtime/, or std/.
# Catches unchecked memcpy / printf-format-injection bugs early.
Expand Down
65 changes: 11 additions & 54 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2118,41 +2118,38 @@ ci: clean
@echo " Parallel: $(NPROC) jobs (build) / $(NPROC) (.ae tests) / $${SH_NPROC:-1} (shell tests)"
@echo "==================================="
@echo ""
@echo "[0/10] Restoring miniaudio object cache (if valid)..."
@echo "[0/9] Restoring miniaudio object cache (if valid)..."
@$(MAKE) audio-cache-restore
@echo ""
@echo "[1/10] Building compiler (-Werror)..."
@echo "[1/9] Building compiler (-Werror)..."
@$(MAKE) -j$(NPROC) compiler EXTRA_CFLAGS=-Werror
@$(MAKE) audio-cache-save
@echo ""
@echo "[2/10] Building ae CLI..."
@echo "[2/9] Building ae CLI..."
@$(MAKE) -j$(NPROC) ae
@echo ""
@echo "[3/10] Building stdlib..."
@echo "[3/9] Building stdlib..."
@$(MAKE) -j$(NPROC) stdlib
@echo ""
@echo "[4/10] Running C unit tests..."
@echo "[4/9] Running C unit tests..."
@$(MAKE) -j$(NPROC) test
@echo ""
@echo "[5/10] Running .ae integration tests..."
@echo "[5/9] Running .ae integration tests..."
@$(MAKE) test-ae
@echo ""
@echo "[6/10] Building examples..."
@echo "[6/9] Building examples..."
@$(MAKE) examples
@echo ""
@echo "[7/10] Install smoke test..."
@echo "[7/9] Install smoke test..."
@$(MAKE) test-install
@echo ""
@echo "[8/10] ae test smoke check..."
@echo "[8/9] ae test smoke check..."
@AETHER_HOME="" ./build/ae test examples/basics/hello.ae 2>&1 | tail -1
@echo " [PASS] ae test runs correctly"
@echo ""
@echo "[9/10] Release archive smoke test..."
@echo "[9/9] Release archive smoke test..."
@$(MAKE) test-release-archive
@echo ""
@echo "[10/10] Optional-macro portability probe..."
@$(MAKE) ci-optional-macros
@echo ""
@echo "==================================="
@echo " CI PASSED — all checks green"
@echo "==================================="
Expand Down Expand Up @@ -2261,7 +2258,7 @@ asan-check: clean
fi
@echo "✓ ASan clean — no memory errors detected"

.PHONY: ci-optional-macros all compiler lsp apkg ae profiler docgen docs-server docs docs-serve test test-build test-valgrind test-asan test-macos-leaks test-memory test-manual-runtime test-cross test-install test-release-archive benchmark benchmark-ui examples run compile repl clean help self-test install stats stdlib stdlib-asan stdlib-memory stdlib-dbg ci ci-windows docker-ci docker-ci-windows docker-build-ci valgrind-check asan-check ci-coop ci-wasm ci-embedded ci-portability docker-ci-wasm docker-ci-embedded contrib-host-check contrib install-contrib stdlib-cov ci-coverage ci-coverage-clean ci-coverage-html
.PHONY: all compiler lsp apkg ae profiler docgen docs-server docs docs-serve test test-build test-valgrind test-asan test-macos-leaks test-memory test-manual-runtime test-cross test-install test-release-archive benchmark benchmark-ui examples run compile repl clean help self-test install stats stdlib stdlib-asan stdlib-memory stdlib-dbg ci ci-windows docker-ci docker-ci-windows docker-build-ci valgrind-check asan-check ci-coop ci-wasm ci-embedded ci-portability docker-ci-wasm docker-ci-embedded contrib-host-check contrib install-contrib stdlib-cov ci-coverage ci-coverage-clean ci-coverage-html

# Cross-language benchmark UI (alias for benchmark)
benchmark-ui: benchmark
Expand Down Expand Up @@ -2561,46 +2558,6 @@ docker-ci-embedded:
docker run --rm -v $(PWD):/aether -w /aether aether-embedded make ci-embedded

# Run ALL portability checks (native coop + Docker WASM + Docker embedded)
# Optional-macro portability probe. Some platform macros this tree keys on
# exist on one BSD and not another: FreeBSD 10 removed MNT_NODEV while macOS
# and OpenBSD still define it, and that difference broke the FreeBSD build
# after passing macOS CI, because the fallback path had never been compiled
# anywhere. This target compiles the affected sources with each such macro
# forced absent, so both sides of every #ifdef are built on every run.
#
# The undef goes before the LAST match of the anchor, not the first: these
# files carry a no-op stub of the same function for platforms without the
# feature, and that stub sits ABOVE the system include that defines the
# macro, so undefining there would be silently reversed by the include.
#
# Add a line here whenever you guard a new optional platform macro.
ci-optional-macros:
@echo "=== Optional-macro portability probe ==="
@mkdir -p build/portability
@fail=0; \
for probe in "std/fs/aether_fs.c:MNT_NODEV:fs_try_mounts(void) {"; do \
src=$${probe%%:*}; rest=$${probe#*:}; \
macro=$${rest%%:*}; anchor=$${rest#*:}; \
out="build/portability/$$(basename $$src)"; \
awk -v a="$$anchor" -v m="$$macro" \
'NR==FNR { if (index($$0, a)) last=FNR; next } \
FNR==last { print "#undef " m } { print }' \
"$$src" "$$src" > "$$out"; \
printf ' %-28s without %s ... ' "$$(basename $$src)" "$$macro"; \
if $(CC) -std=gnu11 -Werror -fsyntax-only \
-I"$$(dirname $$src)" $(CFLAGS) "$$out" \
2>build/portability/err.log; then \
echo "OK"; \
else \
echo "FAILED"; sed 's/^/ /' build/portability/err.log; fail=1; \
fi; \
done; \
if [ $$fail -ne 0 ]; then \
echo " A guarded platform macro's fallback path does not compile."; \
exit 1; \
fi
@echo " All optional-macro fallback paths compile."

ci-portability: ci-coop docker-ci-wasm docker-ci-embedded
@echo ""
@echo "==================================="
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ Same file is config, validation, conditional logic, and the entry point. No seco
### Running Tests

```bash
# Full CI suite (10 steps, -Werror), runs on your current platform
# Full CI suite (9 steps, -Werror), runs on your current platform
make ci

# Unit tests only (runtime C test suite)
Expand Down
Loading
Loading