diff --git a/.github/workflows/cabal-release.yml b/.github/workflows/cabal-release.yml
new file mode 100644
index 000000000000..6a1e9037c5a3
--- /dev/null
+++ b/.github/workflows/cabal-release.yml
@@ -0,0 +1,60 @@
+# Build stable-haskell cabal-install Linux bindists (channel gap).
+#
+# The wasm ghcup channel ships GHC for aarch64-darwin + x86_64-linux +
+# aarch64-linux, but cabal-3.17.0.0.stable.0 only shipped for darwin —
+# Linux users hit "Unable to find a download for Tool 'cabal'" during
+# `ghcup install cabal 3.17.0.0.stable.0`.
+#
+# This workflow rebuilds cabal-install from the upstream branch
+# stable-haskell/cabal:stable-haskell/feature/wasm-cross-ghcup-stack
+# (a 2-commit stack of #368 rpath relativize + #361 target-prefix-aware
+# tool guess) for x86_64-linux + aarch64-linux, and uploads the binaries
+# as workflow artifacts.
+#
+# This is a thin wrapper around the reusable workflow (the same one
+# nix-ci.yml calls as a DAG node). Triggers:
+# * workflow_dispatch — manual, lets us preview the build before tagging
+# * push of tag `cabal-3.17.0.0.stable.*` — auto-uploads bindists to
+# the matching release
+# * pull_request touching this workflow — sanity-check changes here
+name: Build cabal Linux bindists
+
+on:
+ push:
+ tags: ['cabal-3.17.0.0.stable.*']
+ pull_request:
+ paths:
+ - '.github/workflows/cabal-release.yml'
+ - '.github/workflows/reusable-cabal-release.yml'
+ workflow_dispatch:
+ inputs:
+ cabal_sha:
+ description: 'stable-haskell/cabal SHA to build'
+ default: '8b8433b736d45ec53a103baf4e4aabb8010ca2ed'
+ release_tag:
+ description: 'Release tag to upload to (leave blank to skip upload)'
+ default: ''
+
+permissions:
+ contents: write # for asset upload to releases on tag push
+
+concurrency:
+ group: cabal-release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ uses: ./.github/workflows/reusable-cabal-release.yml
+ with:
+ # workflow_dispatch passes inputs.*; tag/PR triggers fall back to
+ # the reusable workflow's own defaults.
+ cabal_sha: ${{ inputs.cabal_sha || '8b8433b736d45ec53a103baf4e4aabb8010ca2ed' }}
+ # On a tag push, route the asset upload to that tag's release. On
+ # workflow_dispatch, only if the operator filled release_tag.
+ release_tag: >-
+ ${{ startsWith(github.ref, 'refs/tags/cabal-3.17.0.0.stable.')
+ && github.ref_name
+ || (github.event_name == 'workflow_dispatch' && inputs.release_tag || '') }}
+ permissions:
+ contents: write
+ secrets: inherit
diff --git a/.github/workflows/channel-e2e-multi.yml b/.github/workflows/channel-e2e-multi.yml
new file mode 100644
index 000000000000..9d3c7c72e069
--- /dev/null
+++ b/.github/workflows/channel-e2e-multi.yml
@@ -0,0 +1,50 @@
+# Channel end-to-end CI — multi-target bindist channel (live).
+#
+# Validates the SHIPPED multi-target GHC + cabal + ghcup channel YAML
+# by exercising the exact flow an end-user does: ghcup install fresh,
+# add the multi-target stable-haskell channel, install GHC + cabal,
+# compile + run a hello-world for each of native / wasm / JS, then
+# build a TH-heavy real-app (miso-counter) via the wasm frontend.
+#
+# This is a thin wrapper around reusable-channel-e2e.yml in install_mode
+# "channel" (live channel YAML, not this run's artifacts). The same
+# reusable workflow is also called from nix-ci.yml in install_mode
+# "artifact" against PR-built bindists.
+#
+# Triggers:
+# * push of `multi-*` tag — re-validate after a new release ships
+# * PR that touches this workflow OR the reusable
+# * workflow_dispatch
+# * weekly silent-regression canary
+#
+# The legacy `Channel e2e (WASM)` workflow is retired; the multi-target
+# bindist supersedes it.
+name: Channel e2e (MULTI)
+
+on:
+ push:
+ tags: [multi-*]
+ pull_request:
+ paths:
+ - '.github/workflows/channel-e2e-multi.yml'
+ - '.github/workflows/reusable-channel-e2e.yml'
+ workflow_dispatch:
+ inputs:
+ multi_version:
+ description: 'Multi-target GHC version to test (e.g. multi-9.14.0.stable.1)'
+ default: ''
+ schedule:
+ - cron: '0 6 * * 1' # Monday 06:00 UTC — silent-regression canary
+
+concurrency:
+ group: channel-e2e-multi-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ e2e:
+ uses: ./.github/workflows/reusable-channel-e2e.yml
+ with:
+ install_mode: channel
+ multi_version: ${{ inputs.multi_version != '' && inputs.multi_version || 'multi-9.14.0.stable.1' }}
+ cabal_version: '3.17.0.0.stable.0'
+ secrets: inherit
diff --git a/.github/workflows/nix-ci.yml b/.github/workflows/nix-ci.yml
new file mode 100644
index 000000000000..a4b597560501
--- /dev/null
+++ b/.github/workflows/nix-ci.yml
@@ -0,0 +1,915 @@
+name: Nix CI
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ push:
+ branches: [stable-ghc-9.14, stable-master]
+ # Tag pushes matching multi-* trigger the Cross: MULTI job to
+ # additionally upload its multi-target bindist tarball to the
+ # matching GitHub Release, populating the stable-haskell
+ # ghcup-multi-target-0.1.0.yaml channel. The single multi-target
+ # bindist replaces the previously-separate wasm32-wasi-* and
+ # JS standalone channels — both targets ship via multi-* now.
+ tags:
+ - 'multi-*'
+
+ workflow_dispatch:
+
+permissions:
+ contents: write # for upload of bindist tarballs to releases on tag push
+
+env:
+ CABAL_CACHE_PATH: _build/cabal/bin
+
+# Auto-cancel superseded runs on the same branch (except stable branches)
+concurrency:
+ group: nix-ci-${{ github.ref }}
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/heads/stable-') }}
+
+jobs:
+ # ---------------------------------------------------------------------------
+ # Build job: compile GHC and produce the dist artifact
+ # ---------------------------------------------------------------------------
+ build:
+ name: "Build / ${{ matrix.plat }} / dynamic=${{ matrix.dynamic }}"
+ runs-on: ${{ fromJSON(matrix.runner) }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { plat: x86_64-linux, ghc: '98', dynamic: 0, runner: '"ubuntu-latest"' }
+ - { plat: x86_64-linux, ghc: '98', dynamic: 1, runner: '"ubuntu-latest"' }
+ - { plat: aarch64-linux, ghc: '98', dynamic: 0, runner: '"ubuntu-24.04-arm"' }
+ - { plat: aarch64-linux, ghc: '98', dynamic: 1, runner: '"ubuntu-24.04-arm"' }
+ - { plat: aarch64-darwin, ghc: '98', dynamic: 0, runner: '["self-hosted", "nix"]' }
+ - { plat: aarch64-darwin, ghc: '98', dynamic: 1, runner: '["self-hosted", "nix"]' }
+
+ steps:
+ # Ephemeral Tart VMs: 80GB disk split between system (41GB APFS, ~7GB free
+ # after macOS) and WorkSpace (28GB, symlinked from ~/actions-runner/_work).
+ # The devx closure (~7GB) fills the system disk, leaving no room for cabal
+ # hackage index or GHC temp files. Redirect CABAL_DIR and TMPDIR to WorkSpace.
+ - name: Clean workspace
+ run: |
+ # Add nix paths and /usr/local/bin to GITHUB_PATH so that:
+ # - 'git' from nix is found before the Xcode CLT shim at /usr/bin/git
+ # - 'devx' symlink in /usr/local/bin is found after devx import
+ echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
+ echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
+ echo "/usr/local/bin" >> "$GITHUB_PATH"
+
+ # Redirect CABAL_DIR to WorkSpace volume. The devx shell sets
+ # CABAL_DIR=~/.cabal-devx (on system disk), but after devx import
+ # the system disk has <1GB free. Override via GITHUB_ENV so all
+ # subsequent devx steps use WorkSpace for cabal packages/store.
+ echo "CABAL_DIR=$GITHUB_WORKSPACE/_build/cabal-dir" >> "$GITHUB_ENV"
+
+ echo "=== Disk usage before cleanup ==="
+ df -h / || true
+ df -h "$GITHUB_WORKSPACE" || true
+ rm -rf "$GITHUB_WORKSPACE"/* "$GITHUB_WORKSPACE"/.??* || true
+ rm -rf ~/.cabal/store ~/.cabal-devx/store ~/.cabal-devx/packages || true
+ # Remove old devx symlink so previous devx nix closure becomes
+ # unreferenced and can be garbage collected.
+ sudo rm -f /usr/local/bin/devx 2>/dev/null || true
+ # nix commands may fail if nix is not on PATH (fresh VMs)
+ export PATH="/nix/var/nix/profiles/default/bin:$PATH"
+ nix-collect-garbage -d 2>/dev/null || true
+ nix-store --gc 2>/dev/null || true
+ echo "=== Disk usage after cleanup ==="
+ df -h / || true
+
+ - uses: actions/checkout@v5
+ with:
+ submodules: "recursive"
+ fetch-depth: 1
+
+ # devx action needs: zstd (closure import), /usr/local/bin (symlink).
+ # These are baked into the base image but we verify defensively.
+ - name: Ensure devx prerequisites
+ run: |
+ export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
+ command -v zstd || nix-env -iA nixpkgs.zstd
+ sudo mkdir -p /usr/local/bin
+
+ - uses: input-output-hk/actions/devx@latest
+ with:
+ platform: ${{ matrix.plat }}
+ compiler-nix-name: 'ghc${{ matrix.ghc }}'
+ minimal: true
+ ghc: true
+
+ - name: Cache stage0 cabal binary
+ id: cabal-cache
+ uses: actions/cache@v5
+ with:
+ path: ${{ env.CABAL_CACHE_PATH }}
+ key: cabal-${{ vars.CACHE_VERSION || 'v1' }}-${{ matrix.plat }}-${{ hashFiles('cabal.project.stage0') }}
+
+ - name: Check disk space
+ run: |
+ echo "=== Disk space available for build ==="
+ echo "--- WorkSpace (build output) ---"
+ df -h .
+ echo "--- System disk (nix store, devx, temp files) ---"
+ df -h /
+
+ - name: Update hackage
+ shell: devx {0}
+ run: |
+ set -eo pipefail
+ # Override devx's CABAL_DIR (~/.cabal-devx on system disk) to use
+ # WorkSpace volume. System disk has <1GB free after devx import.
+ export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir"
+ mkdir -p "$CABAL_DIR"
+ cabal update
+
+ - name: Start metrics collection
+ shell: bash
+ run: ./mk/collect-metrics.sh start _build/metrics 0.5
+
+ # Split build into stages to manage disk space.
+ # After stage1 completes, aggressively clean everything except
+ # the stage1 store (compiler + installed packages) which stage2
+ # needs. Also remove .git since ghc-boot's Setup.hs accepts
+ # GIT_COMMIT_ID env var instead of running 'git rev-parse HEAD'.
+ - name: Build stage0 + stage1
+ shell: devx {0}
+ timeout-minutes: 30
+ run: |
+ set -eo pipefail
+ export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir"
+ export TMPDIR="$GITHUB_WORKSPACE/_build/tmp"
+ mkdir -p "$TMPDIR"
+ make QUIET=1 DYNAMIC=${{ matrix.dynamic }} CABAL_ARGS="-j2" ${{ steps.cabal-cache.outputs.cache-hit == 'true' && 'USE_SYSTEM_CABAL=1' || '' }} stage1
+ echo "=== Disk after stage1 ==="
+ df -h .
+
+ - name: Clean intermediates to free disk for stage2
+ run: |
+ echo "=== Before cleanup ==="
+ du -sh _build/stage0/ _build/stage1/ _build/packages/ .git/ 2>/dev/null || true
+ df -h .
+
+ # Capture git commit ID before removing .git — ghc-boot's Setup.hs
+ # checks GIT_COMMIT_ID env var before falling back to 'git rev-parse'.
+ GIT_COMMIT_ID=$(git rev-parse HEAD)
+ echo "GIT_COMMIT_ID=$GIT_COMMIT_ID" >> "$GITHUB_ENV"
+ echo "Captured GIT_COMMIT_ID=$GIT_COMMIT_ID"
+
+ # Remove .git directory (200-500MB on shallow clone with submodules).
+ # No longer needed: ghc-boot will use GIT_COMMIT_ID env var.
+ rm -rf .git
+ find . -name .git -type d -exec rm -rf {} + 2>/dev/null || true
+
+ # Stage0: cabal binary is in _build/cabal/bin/ (separate from build dir).
+ # Remove all stage0 build artifacts — only the cabal binary matters.
+ rm -rf _build/stage0/ 2>/dev/null || true
+
+ # Stage1: keep ONLY the store/ (installed compiler + libs in store/host/*/bin/ and lib/).
+ # Remove everything else: build artifacts, cache, logs, etc.
+ find _build/stage1/ -maxdepth 1 -mindepth 1 -not -name store -exec rm -rf {} +
+
+ # Remove downloaded source tarballs (already unpacked by cabal)
+ rm -rf _build/packages/ 2>/dev/null || true
+
+ # Remove build temp files (redirected TMPDIR from stage1)
+ rm -rf _build/tmp/ 2>/dev/null || true
+
+ # Remove cabal hackage index (stage2 uses its own local cache via Makefile's
+ # hackage target, so the devx cabal-dir index is no longer needed)
+ rm -rf "${CABAL_DIR:-~/.cabal-devx}/packages/" 2>/dev/null || true
+
+ echo "=== After cleanup ==="
+ du -sh _build/stage1/ _build/cabal/ 2>/dev/null || true
+ df -h .
+
+ - name: Build stage2
+ shell: devx {0}
+ timeout-minutes: 60
+ run: |
+ set -eo pipefail
+ export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir"
+ export TMPDIR="$GITHUB_WORKSPACE/_build/tmp"
+ mkdir -p "$TMPDIR"
+ make QUIET=1 DYNAMIC=${{ matrix.dynamic }} CABAL_ARGS="-j2" USE_SYSTEM_CABAL=1 stage2
+ echo "=== Disk after stage2 ==="
+ df -h .
+
+ - name: Stop metrics collection
+ if: ${{ !cancelled() }}
+ shell: bash
+ run: ./mk/collect-metrics.sh stop _build/metrics
+
+ - name: Display build timings
+ if: ${{ !cancelled() }}
+ shell: devx {0}
+ run: make timing-summary
+
+ # After build+dist, remove all intermediate files to free disk for
+ # artifact upload. Only keep dist/, timing/, metrics/.
+ # Note: tests.tar.gz is NOT needed — test job uses its own checkout.
+ - name: Clean build intermediates
+ if: ${{ !cancelled() }}
+ run: |
+ echo "=== Before final cleanup ==="
+ du -sh _build/stage0/ _build/stage1/ _build/stage2/ 2>/dev/null || true
+ df -h .
+ rm -rf _build/stage0 _build/stage1 _build/stage2 _build/packages 2>/dev/null || true
+ echo "=== After final cleanup ==="
+ df -h .
+
+ # Upload dist artifact FIRST so downstream jobs (test, cross) can start
+ # as soon as the build job finishes, without waiting for metrics/plots.
+ - name: Upload dist
+ uses: actions/upload-artifact@v5
+ with:
+ name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-dist
+ retention-days: 1
+ path: _build/dist
+
+ - name: Upload build logs and timing
+ uses: actions/upload-artifact@v5
+ if: ${{ !cancelled() }}
+ with:
+ name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-build-logs
+ retention-days: 1
+ path: |
+ _build/logs/
+ _build/timing/
+
+ - name: Upload metrics
+ uses: actions/upload-artifact@v5
+ if: ${{ !cancelled() }}
+ with:
+ name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-build-metrics
+ retention-days: 1
+ path: _build/metrics/
+
+ - name: Generate metrics plots
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ timeout-minutes: 5
+ shell: bash
+ run: |
+ # Generate build SVG plot.
+ # Use bash (not devx) because nix is not available inside the devx shell.
+ PYTHON_MPL=$(nix build --impure --no-link --print-out-paths \
+ --expr 'let pkgs = (builtins.getFlake "nixpkgs").legacyPackages.${builtins.currentSystem}; in pkgs.python3.withPackages (ps: [ps.matplotlib])')
+ "$PYTHON_MPL/bin/python3" ./mk/plot-metrics.py _build/metrics _build/timing _build/metrics/metrics
+
+ - name: Upload metrics plots to R2
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ timeout-minutes: 5
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.R2_METRICS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_METRICS_SECRET_ACCESS_KEY }}
+ R2_ENDPOINT: ${{ secrets.R2_METRICS_ENDPOINT }}
+ R2_PUBLIC_URL: ${{ vars.R2_METRICS_PUBLIC_URL }}
+ R2_BUCKET: ${{ vars.R2_METRICS_BUCKET_NAME }}
+ shell: bash
+ run: |
+ RUN_ID="${{ github.run_id }}"
+ PLAT="${{ matrix.plat }}"
+ DYN="${{ matrix.dynamic }}"
+
+ export AWS_ENDPOINT_URL="${R2_ENDPOINT}"
+ export AWS_DEFAULT_REGION=auto
+
+ # Use bash (not devx) because nix is not available inside the devx shell.
+ AWSCLI=$(nix build --no-link --print-out-paths nixpkgs#awscli2)
+ AWS="$AWSCLI/bin/aws"
+
+ # Upload build plot if it exists
+ if [[ -f "_build/metrics/metrics-build.svg" ]]; then
+ BUILD_PATH="runs/${RUN_ID}/${PLAT}-dynamic${DYN}-build.svg"
+ "$AWS" s3 cp _build/metrics/metrics-build.svg "s3://${R2_BUCKET}/${BUILD_PATH}" --content-type 'image/svg+xml' || echo "Failed to upload build plot (non-fatal)"
+ echo "BUILD_PLOT_URL=${R2_PUBLIC_URL}/${BUILD_PATH}" >> $GITHUB_ENV
+ fi
+
+ - name: Write build metrics summary
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ shell: devx {0}
+ run: |
+ echo "## Build Metrics Summary" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # Embed build plot if available
+ if [[ -n "${BUILD_PLOT_URL:-}" ]]; then
+ echo "### Build Phases (CPU & Memory)" >> $GITHUB_STEP_SUMMARY
+ echo "
" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ echo "### Phase Timings" >> $GITHUB_STEP_SUMMARY
+ echo "| Phase | Duration | Status |" >> $GITHUB_STEP_SUMMARY
+ echo "|-------|----------|--------|" >> $GITHUB_STEP_SUMMARY
+
+ make timing-summary 2>/dev/null || true
+ if [[ -f "_build/timing/summary.txt" ]]; then
+ while read -r phase dur status; do
+ mins=$((dur / 60))
+ secs=$((dur % 60))
+ status_str="OK"
+ [[ "$status" == "1" ]] && status_str="FAIL"
+ if [[ "$phase" == *.* ]]; then
+ echo "| $phase | ${mins}m ${secs}s | $status_str |" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "| **$phase** | **${mins}m ${secs}s** | $status_str |" >> $GITHUB_STEP_SUMMARY
+ fi
+ done < "_build/timing/summary.txt"
+ fi
+ echo "" >> $GITHUB_STEP_SUMMARY
+ if [[ -f "_build/metrics/metrics.csv" ]]; then
+ max_mem=$(awk -F',' 'NR>1 {if($3>max) max=$3} END {printf "%.1f", max/1024}' _build/metrics/metrics.csv)
+ echo "**Peak Memory:** ${max_mem} GB" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # ---------------------------------------------------------------------------
+ # Test job: download the dist and run the testsuite standalone
+ # ---------------------------------------------------------------------------
+ test:
+ name: "Test / ${{ matrix.plat }} / dynamic=${{ matrix.dynamic }}"
+ needs: build
+ runs-on: ${{ fromJSON(matrix.runner) }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { plat: x86_64-linux, ghc: '98', dynamic: 0, runner: '"ubuntu-latest"' }
+ - { plat: x86_64-linux, ghc: '98', dynamic: 1, runner: '"ubuntu-latest"' }
+ - { plat: aarch64-linux, ghc: '98', dynamic: 0, runner: '"ubuntu-24.04-arm"' }
+ - { plat: aarch64-linux, ghc: '98', dynamic: 1, runner: '"ubuntu-24.04-arm"' }
+ - { plat: aarch64-darwin, ghc: '98', dynamic: 0, runner: '["self-hosted", "nix"]' }
+ - { plat: aarch64-darwin, ghc: '98', dynamic: 1, runner: '["self-hosted", "nix"]' }
+
+ steps:
+ - name: Clean workspace
+ run: |
+ echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
+ echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
+ echo "/usr/local/bin" >> "$GITHUB_PATH"
+
+ echo "CABAL_DIR=$GITHUB_WORKSPACE/_build/cabal-dir" >> "$GITHUB_ENV"
+
+ echo "=== Disk usage before cleanup ==="
+ df -h / || true
+ df -h "$GITHUB_WORKSPACE" || true
+ rm -rf "$GITHUB_WORKSPACE"/* "$GITHUB_WORKSPACE"/.??* || true
+ rm -rf ~/.cabal/store ~/.cabal-devx/store ~/.cabal-devx/packages || true
+ sudo rm -f /usr/local/bin/devx 2>/dev/null || true
+ export PATH="/nix/var/nix/profiles/default/bin:$PATH"
+ nix-collect-garbage -d 2>/dev/null || true
+ nix-store --gc 2>/dev/null || true
+ echo "=== Disk usage after cleanup ==="
+ df -h / || true
+
+ - uses: actions/checkout@v5
+ with:
+ submodules: "recursive"
+ fetch-depth: 1
+
+ - name: Ensure devx prerequisites
+ run: |
+ export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
+ command -v zstd || nix-env -iA nixpkgs.zstd
+ sudo mkdir -p /usr/local/bin
+
+ - uses: input-output-hk/actions/devx@latest
+ with:
+ platform: ${{ matrix.plat }}
+ compiler-nix-name: 'ghc${{ matrix.ghc }}'
+ minimal: true
+ ghc: true
+
+ - name: Download dist
+ uses: actions/download-artifact@v5
+ with:
+ name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-dist
+ path: _build/dist
+
+ - name: Restore permissions
+ run: |
+ chmod +x _build/dist/bin/*
+ # Recache package DB — paths change when dist is unpacked on a
+ # different machine, making the binary cache stale. Without this,
+ # every test gets "WARNING: cache is out of date" in stderr.
+ _build/dist/bin/ghc-pkg recache --package-db _build/dist/lib/package.conf.d
+
+ - name: Start metrics collection
+ shell: bash
+ run: ./mk/collect-metrics.sh start _build/metrics 0.5
+
+ - name: Free disk for tests
+ run: |
+ rm -rf .git
+ find . -name .git -type d -exec rm -rf {} + 2>/dev/null || true
+ rm -rf ~/.cabal-devx/packages/ 2>/dev/null || true
+ df -h .
+
+ - name: Build testsuite timeout tool
+ shell: devx {0}
+ run: |
+ set -eo pipefail
+ export TMPDIR="$GITHUB_WORKSPACE/_build/tmp"
+ mkdir -p "$TMPDIR"
+ make -C testsuite/timeout
+
+ - name: Record test start time
+ shell: bash
+ run: |
+ mkdir -p _build/timing
+ date +%s > _build/timing/test.start
+
+ - name: Run testsuite
+ id: run-testsuite
+ shell: devx {0}
+ timeout-minutes: 120
+ run: |
+ set -eo pipefail
+ export TMPDIR="$GITHUB_WORKSPACE/_build/tmp"
+ mkdir -p "$TMPDIR"
+ DIST_BIN="$GITHUB_WORKSPACE/_build/dist/bin"
+ # Pass the same variables the top-level 'make test' target uses.
+ # SKIP_PERF_TESTS=YES prevents performance tests from running
+ # (they timeout on CI). CANONICAL_TEST_HC_OPTS enables lint passes.
+ TEST_HC="$DIST_BIN/ghc" \
+ GHC_PKG="$DIST_BIN/ghc-pkg" \
+ HP2PS_ABS="$DIST_BIN/hp2ps" \
+ HPC="$DIST_BIN/hpc" \
+ RUNGHC="$DIST_BIN/runghc" \
+ TEST_CC="${CC:-cc}" \
+ TEST_HC_OPTS="-dcore-lint -dstg-lint -dcmm-lint -no-user-package-db -fno-dump-with-ways -fprint-error-index-links=never -rtsopts -fno-warn-missed-specialisations -fshow-warning-groups -fdiagnostics-color=never -fno-diagnostics-show-caret -Werror=compat -dno-debug-output" \
+ SKIP_PERF_TESTS=YES \
+ THREADS=4 \
+ METRICS_FILE="$GITHUB_WORKSPACE/_build/test-perf.csv" \
+ SUMMARY_FILE="$GITHUB_WORKSPACE/_build/test-summary.txt" \
+ JUNIT_FILE="$GITHUB_WORKSPACE/_build/test-junit.xml" \
+ make -C testsuite/tests test
+
+ - name: Record test end time
+ if: ${{ !cancelled() }}
+ shell: bash
+ run: |
+ mkdir -p _build/timing
+ date +%s > _build/timing/test.end
+ if [[ "${{ steps.run-testsuite.outcome }}" == "success" ]]; then
+ echo "0" > _build/timing/test.status
+ else
+ echo "1" > _build/timing/test.status
+ fi
+
+ - name: Stop metrics collection
+ if: ${{ !cancelled() }}
+ shell: bash
+ run: ./mk/collect-metrics.sh stop _build/metrics
+
+ # Upload test artifacts FIRST, before slow metrics generation.
+ - name: Upload test results
+ uses: actions/upload-artifact@v5
+ if: ${{ !cancelled() }}
+ with:
+ name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-testsuite-results
+ retention-days: 1
+ path: |
+ _build/test-perf.csv
+ _build/test-summary.txt
+ _build/test-junit.xml
+
+ - name: Upload test metrics
+ uses: actions/upload-artifact@v5
+ if: ${{ !cancelled() }}
+ with:
+ name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-test-metrics
+ retention-days: 1
+ path: _build/metrics/
+
+ - name: Generate metrics plots
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ timeout-minutes: 5
+ shell: bash
+ run: |
+ # Generate test SVG plot.
+ PYTHON_MPL=$(nix build --impure --no-link --print-out-paths \
+ --expr 'let pkgs = (builtins.getFlake "nixpkgs").legacyPackages.${builtins.currentSystem}; in pkgs.python3.withPackages (ps: [ps.matplotlib])')
+ "$PYTHON_MPL/bin/python3" ./mk/plot-metrics.py _build/metrics _build/timing _build/metrics/metrics
+
+ - name: Upload metrics plots to R2
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ timeout-minutes: 5
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.R2_METRICS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_METRICS_SECRET_ACCESS_KEY }}
+ R2_ENDPOINT: ${{ secrets.R2_METRICS_ENDPOINT }}
+ R2_PUBLIC_URL: ${{ vars.R2_METRICS_PUBLIC_URL }}
+ R2_BUCKET: ${{ vars.R2_METRICS_BUCKET_NAME }}
+ shell: bash
+ run: |
+ RUN_ID="${{ github.run_id }}"
+ PLAT="${{ matrix.plat }}"
+ DYN="${{ matrix.dynamic }}"
+
+ export AWS_ENDPOINT_URL="${R2_ENDPOINT}"
+ export AWS_DEFAULT_REGION=auto
+
+ AWSCLI=$(nix build --no-link --print-out-paths nixpkgs#awscli2)
+ AWS="$AWSCLI/bin/aws"
+
+ # Upload test plot if it exists
+ if [[ -f "_build/metrics/metrics-test.svg" ]]; then
+ TEST_PATH="runs/${RUN_ID}/${PLAT}-dynamic${DYN}-test.svg"
+ "$AWS" s3 cp _build/metrics/metrics-test.svg "s3://${R2_BUCKET}/${TEST_PATH}" --content-type 'image/svg+xml' || echo "Failed to upload test plot (non-fatal)"
+ echo "TEST_PLOT_URL=${R2_PUBLIC_URL}/${TEST_PATH}" >> $GITHUB_ENV
+ fi
+
+ - name: Write test metrics summary
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ shell: devx {0}
+ run: |
+ echo "## Test Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # Include test results from the summary file
+ if [[ -f "_build/test-summary.txt" ]]; then
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ cat _build/test-summary.txt >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Embed test plot if available
+ if [[ -n "${TEST_PLOT_URL:-}" ]]; then
+ echo "### Test Phase (CPU & Memory)" >> $GITHUB_STEP_SUMMARY
+ echo "
" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ if [[ -f "_build/metrics/metrics.csv" ]]; then
+ max_mem=$(awk -F',' 'NR>1 {if($3>max) max=$3} END {printf "%.1f", max/1024}' _build/metrics/metrics.csv)
+ echo "**Peak Memory:** ${max_mem} GB" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # ---------------------------------------------------------------------------
+ # Cross: MULTI — the only Cross job. Builds the multi-target bindist
+ # combining native + wasm32-unknown-wasi + javascript-unknown-ghcjs in
+ # one tarball (argv[0] dispatched at runtime). Replaces the previously-
+ # separate Cross: WASM + Cross: JS jobs — those produced standalone
+ # bindists that we no longer ship; the multi-target bindist is the
+ # single end-user-facing artifact and covers both targets.
+ #
+ # Per-host: dynamic1 stage2 download, devx shell, wasi-sdk + Node
+ # install, emscripten install, then `make stage3-wasm32-unknown-wasi
+ # stage3-javascript-unknown-ghcjs` + the combined `ghc-multi-target.
+ # tar.gz` Makefile rule. patchelf adjusts ELF interpreter + $ORIGIN
+ # rpath on the linux runners.
+ #
+ # `continue-on-error: true` — failures are informative but don't
+ # block the main pipeline.
+ # ---------------------------------------------------------------------------
+ cross-multi:
+ name: "Cross: MULTI / ${{ matrix.plat }}"
+ needs: [build]
+ if: ${{ !cancelled() && contains(fromJSON('["success", "failure"]'), needs.build.result) }}
+ runs-on: ${{ fromJSON(matrix.runner) }}
+ continue-on-error: true
+
+ env:
+ # emsdk's git tag — used by the `Install emscripten` step's
+ # `git clone --branch ${{ env.EMSDK_VERSION }}`.
+ EMSDK_VERSION: "3.1.74"
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { plat: aarch64-darwin, devx-plat: aarch64-darwin, runner: '["self-hosted", "nix"]' }
+ - { plat: x86_64-linux, devx-plat: x86_64-linux, runner: '"ubuntu-latest"' }
+ - { plat: aarch64-linux, devx-plat: aarch64-linux, runner: '"ubuntu-24.04-arm"' }
+
+ steps:
+ - name: Clean workspace
+ run: |
+ echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
+ echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
+ echo "/usr/local/bin" >> "$GITHUB_PATH"
+
+ echo "CABAL_DIR=$GITHUB_WORKSPACE/_build/cabal-dir" >> "$GITHUB_ENV"
+
+ echo "=== Disk usage before cleanup ==="
+ df -h / || true
+ df -h "$GITHUB_WORKSPACE" || true
+ rm -rf "$GITHUB_WORKSPACE"/* "$GITHUB_WORKSPACE"/.??* || true
+ rm -rf ~/.cabal/store ~/.cabal-devx/store ~/.cabal-devx/packages || true
+ rm -rf ~/.ghc-wasm ~/.emsdk || true
+ sudo rm -f /usr/local/bin/devx 2>/dev/null || true
+ export PATH="/nix/var/nix/profiles/default/bin:$PATH"
+ nix-collect-garbage -d 2>/dev/null || true
+ nix-store --gc 2>/dev/null || true
+ echo "=== Disk usage after cleanup ==="
+ df -h / || true
+
+ - uses: actions/checkout@v5
+ with:
+ submodules: "recursive"
+ fetch-depth: 1
+
+ - name: Minimize source tree
+ run: |
+ GIT_COMMIT_ID=$(git rev-parse HEAD)
+ echo "GIT_COMMIT_ID=$GIT_COMMIT_ID" >> "$GITHUB_ENV"
+ rm -rf .git testsuite docs || true
+ find . -name .git -type d -exec rm -rf {} + 2>/dev/null || true
+ df -h / || true
+
+ - name: Ensure devx prerequisites
+ run: |
+ export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
+ command -v zstd || nix-env -iA nixpkgs.zstd
+ sudo mkdir -p /usr/local/bin
+
+ - uses: input-output-hk/actions/devx@latest
+ with:
+ platform: ${{ matrix.devx-plat }}
+ compiler-nix-name: 'ghc98'
+ minimal: true
+ ghc: true
+
+ - name: Download dist
+ uses: actions/download-artifact@v5
+ with:
+ # dynamic1 stage2:
+ # 1. happy-lib / alex etc. need native Prelude.dyn_hi at build-side
+ # 2. the dyn-linked native binary becomes bin/ghc + bin/-ghc
+ # in the multi-target bindist and needs lib/$(HOST_PLATFORM)
+ # shipped alongside.
+ name: ${{ matrix.plat }}-dynamic1-dist
+ path: _build/dist
+
+ - name: Set up stage2 from dist
+ run: |
+ chmod +x _build/dist/bin/*
+ mkdir -p _build/stage2/bin _build/stage2/lib
+ for exe in _build/dist/bin/*; do
+ ln -sf "$(pwd)/$exe" "_build/stage2/bin/$(basename $exe)"
+ done
+ if [[ -f _build/dist/lib/settings ]]; then
+ cp -rfp _build/dist/lib/settings _build/stage2/lib/
+ fi
+
+ - name: Update hackage
+ shell: devx {0}
+ run: |
+ set -eo pipefail
+ export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir"
+ mkdir -p "$CABAL_DIR"
+ _build/dist/bin/cabal update
+
+ - name: Free disk for cross builds
+ run: |
+ rm -rf "${CABAL_DIR:-~/.cabal-devx}/packages" "${CABAL_DIR:-~/.cabal-devx}/logs" "${CABAL_DIR:-~/.cabal-devx}/store" || true
+ rm -rf ~/.cabal/store || true
+ df -h / || true
+
+ # Install wasi-sdk (wasm-target C toolchain) + Node 22 + wasmtime, same
+ # logic since this job builds the wasm half of the
+ # multi-target bindist.
+ - name: Install wasi-sdk + Node 22
+ shell: devx {0}
+ run: |
+ set -eux
+ export TMPDIR="$GITHUB_WORKSPACE/_build/tmp"
+ mkdir -p "$TMPDIR"
+ case "$(uname -s)" in
+ Linux)
+ export PATH="$HOME/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends jq unzip zstd wabt
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+ sudo apt-get install -y --no-install-recommends nodejs
+ ;;
+ Darwin)
+ export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
+ for tool in jq unzip zstd node; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ pkg="$tool"
+ [ "$tool" = "node" ] && pkg="nodejs"
+ /nix/var/nix/profiles/default/bin/nix-env -iA "nixpkgs.$pkg"
+ fi
+ done
+ ;;
+ esac
+ curl -fsSL https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/raw/master/bootstrap.sh | \
+ FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm sh
+
+ # Install emscripten (JS-target C toolchain). EMSDK_VERSION is set in
+ # the workflow env.
+ - name: Install emscripten
+ shell: devx {0}
+ run: |
+ set -eux
+ export TMPDIR="$GITHUB_WORKSPACE/_build/tmp"
+ git clone --depth 1 --branch ${{ env.EMSDK_VERSION }} https://github.com/emscripten-core/emsdk.git
+ cd emsdk
+ ./emsdk install ${{ env.EMSDK_VERSION }}
+ ./emsdk activate ${{ env.EMSDK_VERSION }}
+
+ - name: Build multi-target bindist
+ shell: devx {0}
+ run: |
+ set -eux
+ export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir"
+ export TMPDIR="$GITHUB_WORKSPACE/_build/tmp"
+ mkdir -p "$TMPDIR"
+ # Source emscripten env (provides emcc on PATH)
+ source emsdk/emsdk_env.sh
+ # Symlink wasi-sdk's wasm32-wasi-* tools as wasm32-unknown-wasi-*
+ #.
+ WASI_BIN="$HOME/.ghc-wasm/wasi-sdk/bin"
+ for tool in ar nm ranlib strip; do
+ ln -sf "$WASI_BIN/llvm-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool"
+ done
+ for tool in clang clang++; do
+ ln -sf "$WASI_BIN/wasm32-wasi-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool"
+ done
+ export PATH=$PATH:$WASI_BIN
+ # DYNAMIC=1 for both wasm and JS
+ # targets need .dyn_hi for end-user TH builds (miso, aeson, ...).
+ make DYNAMIC=1 DIST_BUILD=1 \
+ CABAL=$PWD/_build/dist/bin/cabal \
+ GHC_TOOLCHAIN_BIN=$PWD/_build/dist/bin/ghc-toolchain-bin \
+ DERIVE_CONSTANTS_BIN=$PWD/_build/dist/bin/deriveConstants \
+ GENAPPLY_BIN=$PWD/_build/dist/bin/genapply \
+ GENPRIMOPCODE_BIN=$PWD/_build/dist/bin/genprimopcode \
+ HAPPY_TEMPLATE_DIR=$PWD/_build/dist/share/happy-lib/data \
+ _build/dist/ghc-multi-target.tar.gz
+ # Rename to add platform suffix.
+ cp _build/dist/ghc-multi-target.tar.gz \
+ _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz
+ ls -lh _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz
+ echo "SHA256:"
+ shasum -a 256 _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz
+
+ # Patchelf only the host-arch ELFs (bin/* native binaries + lib//
+ # libHS*.so). The wasm32 and JS target libraries aren't ELFs — they're
+ # .wasm modules and .js files respectively — patchelf skips them.
+ - name: Normalize ELF interpreters for portability (Linux only)
+ if: ${{ !cancelled() && contains(matrix.plat, 'linux') }}
+ shell: devx {0}
+ run: |
+ set -eux
+ export PATH="$PATH:/usr/bin:/usr/sbin"
+ if ! command -v patchelf >/dev/null 2>&1; then
+ sudo apt-get install -y --no-install-recommends patchelf
+ fi
+ case "${{ matrix.plat }}" in
+ x86_64-linux) INTERP=/lib64/ld-linux-x86-64.so.2; HOST_DIR=x86_64-unknown-linux ;;
+ aarch64-linux) INTERP=/lib/ld-linux-aarch64.so.1; HOST_DIR=aarch64-unknown-linux ;;
+ *) echo "::error::unexpected matrix.plat=${{ matrix.plat }}"; exit 1 ;;
+ esac
+
+ TGZ=_build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz
+ STAGE="$(mktemp -d)"
+ tar -C "$STAGE" -xzf "$TGZ"
+
+ echo "═══ Patchelfing binaries to interpreter $INTERP ═══"
+ patched=0
+ while IFS= read -r bin; do
+ if file -L "$bin" 2>/dev/null \
+ | grep -q "ELF.*dynamically linked.*interpreter /nix/store"; then
+ echo " patching: ${bin#$STAGE/}"
+ patchelf --set-interpreter "$INTERP" "$bin"
+ patchelf --remove-rpath "$bin"
+ patchelf --force-rpath --set-rpath "\$ORIGIN/../lib/$HOST_DIR" "$bin"
+ patched=$((patched + 1))
+ fi
+ done < <(find "$STAGE" -type f -executable)
+ echo "═══ Patched $patched binaries ═══"
+ test "$patched" -gt 0 || { echo "::error::no ELF binaries patched"; exit 1; }
+
+ # Fix rpath on the shipped host .so files.
+ echo "═══ Setting rpath on lib/$HOST_DIR/*.so ═══"
+ so_patched=0
+ for so in "$STAGE"/lib/"$HOST_DIR"/*.so; do
+ [ -f "$so" ] || continue
+ patchelf --force-rpath --set-rpath "\$ORIGIN" "$so" 2>/dev/null || true
+ so_patched=$((so_patched + 1))
+ done
+ echo "═══ Set rpath on $so_patched shared libs ═══"
+
+ rm "$TGZ"
+ tar -C "$STAGE" -czf "$TGZ" .
+ ls -lh "$TGZ"
+ echo "SHA256 after patchelf:"
+ shasum -a 256 "$TGZ"
+
+ # Darwin: verify the bindist is clean of build-host leaks.
+ #
+ # The Makefile's `stage2.dist` phase (CLEAN_DARWIN_DIST macro)
+ # strips two classes of leak at construction time, BEFORE the
+ # bindist tarball is built:
+ #
+ # (1) Absolute LC_RPATH entries like
+ # `/Volumes/WorkSpace/_work/ghc/ghc/_build/...` that
+ # macOS 15 dyld treats as fatal.
+ # (2) nix-store LC_LOAD_DYLIB install names for libiconv,
+ # libffi, libc++, libz, libresolv, libncurses — rewritten
+ # to their /usr/lib equivalents.
+ #
+ # This step just verifies — if the construction-site fix
+ # regresses, CI fails loud here rather than shipping a broken
+ # bindist that abort-traps on end-user macOS 15 hosts.
+ - name: Verify darwin bindist is clean (no /Volumes or nix-store leaks)
+ if: ${{ !cancelled() && matrix.plat == 'aarch64-darwin' }}
+ shell: devx {0}
+ run: |
+ set -euo pipefail
+ TGZ=_build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz
+ STAGE="$GITHUB_WORKSPACE/.bindist-verify-$$"
+ rm -rf "$STAGE"; mkdir -p "$STAGE"
+ trap 'rm -rf "$STAGE"' EXIT
+ tar -C "$STAGE" -xzf "$TGZ"
+
+ echo "═══ Scanning bindist Mach-O artefacts for build-host leaks ═══"
+ leaked=0
+ while IFS= read -r f; do
+ file -L "$f" 2>/dev/null | grep -q 'Mach-O' || continue
+ if otool -l "$f" 2>/dev/null \
+ | awk '/cmd LC_RPATH/{flag=1; next} flag && /path \/Volumes\//{found=1; exit} flag && /path /{flag=0} END{exit !found}'; then
+ echo "::error::leaked /Volumes/ LC_RPATH in $f"
+ otool -l "$f" | awk '/cmd LC_RPATH/{f=1; next} f && /path /{print; f=0}'
+ leaked=$((leaked + 1))
+ fi
+ if otool -L "$f" 2>/dev/null | awk '/nix\/store/{exit 0} END{exit 1}'; then
+ echo "::error::leaked /nix/store LC_LOAD_DYLIB in $f"
+ otool -L "$f" | grep 'nix/store' || true
+ leaked=$((leaked + 1))
+ fi
+ done < <(find "$STAGE" -type f \( -perm -u+x -o -name '*.dylib' \))
+
+ if [ "$leaked" -gt 0 ]; then
+ echo "::error::$leaked Mach-O files have build-host leaks. The Makefile's CLEAN_DARWIN_DIST step should have stripped these at construction time. Did stage2.dist run?"
+ exit 1
+ fi
+ echo "::notice::darwin bindist is clean — CLEAN_DARWIN_DIST did its job."
+
+ - name: Upload MULTI cross artifacts
+ uses: actions/upload-artifact@v5
+ if: ${{ !cancelled() }}
+ with:
+ name: ${{ matrix.plat }}-cross-multi
+ retention-days: 30
+ path: _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz
+
+ # On a tag push matching multi-*, upload to that GitHub Release.
+ # (Separate tag namespace from the (no-longer-shipped) wasm32-wasi-* so the channels
+ # stay independent.)
+ - name: Upload bindist to release (on tag push)
+ if: ${{ startsWith(github.ref, 'refs/tags/multi-') }}
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ files: _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz
+ fail_on_unmatched_files: true
+
+ # ---------------------------------------------------------------------------
+ # Cabal Linux bindist — runs in parallel with the GHC build. The fresh
+ # cabal artifact is then consumed by `e2e-multi` so a PR-introduced cabal
+ # regression is caught BEFORE we ship.
+ # ---------------------------------------------------------------------------
+ cabal-release:
+ uses: ./.github/workflows/reusable-cabal-release.yml
+ permissions:
+ contents: write
+ secrets: inherit
+
+ # ---------------------------------------------------------------------------
+ # Channel end-to-end against THIS run's artifacts (artifact install mode):
+ # downloads ghc-multi-target-${plat}.tar.gz from cross-multi + cabal-${ver}-
+ # ${plat}.tar.gz from cabal-release, installs them locally and runs the
+ # same hello / miso / JS-hello test surface as the live-channel
+ # canary (channel-e2e-multi.yml). Closes the DAG: a PR-introduced
+ # regression in GHC packaging, cabal patches, or wasm sysroot wiring
+ # surfaces here BEFORE merge instead of at release time.
+ # ---------------------------------------------------------------------------
+ e2e-multi:
+ needs: [cross-multi, cabal-release]
+ if: ${{ !cancelled()
+ && needs.cross-multi.result == 'success'
+ && needs.cabal-release.result == 'success' }}
+ uses: ./.github/workflows/reusable-channel-e2e.yml
+ with:
+ install_mode: artifact
+ # PATH suffix only; in artifact mode no `ghcup install` is done.
+ multi_version: 'multi-pr-ci'
+ cabal_version: '3.17.0.0.stable.0'
+ secrets: inherit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e6bf7718610f..c96a7fdfa7a5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -28,10 +28,10 @@ jobs:
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Download artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
pattern: artifacts-*
merge-multiple: true
diff --git a/.github/workflows/reusable-cabal-release.yml b/.github/workflows/reusable-cabal-release.yml
new file mode 100644
index 000000000000..5944ef9dbc99
--- /dev/null
+++ b/.github/workflows/reusable-cabal-release.yml
@@ -0,0 +1,177 @@
+# Reusable: build stable-haskell cabal-install Linux bindists.
+#
+# Called from:
+# * cabal-release.yml — standalone trigger surface (tag push, PR-on-self,
+# workflow_dispatch). Drives release-asset upload on a `cabal-*` tag.
+# * nix-ci.yml — DAG node, runs in parallel with the GHC build. The
+# fresh cabal bindist is then consumed by the channel-e2e job so a
+# PR-introduced cabal regression is caught before merge.
+#
+# The build itself is a small thing — Ubuntu apt deps + ghcup bootstrap
+# GHC + checkout stable-haskell/cabal at the pinned SHA + `cabal build`
+# + tarball — but doing it on both x86_64-linux and aarch64-linux runners
+# (with the same SHA used by cabal.project.stage{0,1,2,3}) keeps the
+# channel's cabal bytes in lock-step with the GHC bytes shipped from
+# the same PR.
+
+name: reusable-cabal-release
+
+on:
+ workflow_call:
+ inputs:
+ cabal_sha:
+ description: 'stable-haskell/cabal SHA to build'
+ type: string
+ default: '8b8433b736d45ec53a103baf4e4aabb8010ca2ed'
+ cabal_ver:
+ description: 'cabal-install version baked into artifact name'
+ type: string
+ default: '3.17.0.0.stable.0'
+ bootstrap_ghc:
+ description: 'GHC used to compile cabal-install'
+ type: string
+ default: '9.8.4'
+ release_tag:
+ description: 'GitHub Release tag to upload to (empty = no release upload)'
+ type: string
+ default: ''
+ outputs:
+ cabal_ver:
+ description: 'cabal-install version that was built'
+ value: ${{ inputs.cabal_ver }}
+
+permissions:
+ contents: write # for asset upload to releases on tag push
+
+jobs:
+ build:
+ name: build / ${{ matrix.plat }}
+ runs-on: ${{ matrix.runs-on }}
+ timeout-minutes: 45
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { plat: x86_64-linux, runs-on: ubuntu-latest }
+ - { plat: aarch64-linux, runs-on: ubuntu-24.04-arm }
+
+ env:
+ CABAL_SHA: ${{ inputs.cabal_sha }}
+ BOOTSTRAP_GHC: ${{ inputs.bootstrap_ghc }}
+ CABAL_VER: ${{ inputs.cabal_ver }}
+
+ steps:
+ # ---------------------------------------------------------------------
+ # 1. Workspace + apt deps (Ubuntu base — recent glibc, broad-enough
+ # compat for modern Debian/Ubuntu/RHEL9+/Fedora).
+ # ---------------------------------------------------------------------
+ - name: System dependencies
+ run: |
+ set -euo pipefail
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends \
+ build-essential git curl xz-utils libgmp10 libgmp-dev zlib1g-dev \
+ libffi-dev libncurses-dev pkg-config
+
+ # ---------------------------------------------------------------------
+ # 2. Bootstrap GHC + a recent cabal-install via ghcup.
+ # ---------------------------------------------------------------------
+ - name: Install ghcup + bootstrap GHC
+ run: |
+ set -euo pipefail
+ sudo rm -rf "$HOME/.ghcup"
+ curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \
+ | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \
+ BOOTSTRAP_HASKELL_MINIMAL=1 \
+ GHCUP_INSTALL_BASE_PREFIX="$HOME" \
+ sh
+ export PATH="$HOME/.ghcup/bin:$PATH"
+ ghcup install ghc "$BOOTSTRAP_GHC"
+ ghcup set ghc "$BOOTSTRAP_GHC"
+ ghcup install cabal 3.14.2.0 # any recent upstream cabal is fine
+ ghcup set cabal 3.14.2.0
+ echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH"
+ echo "$HOME/.ghcup/ghc/$BOOTSTRAP_GHC/bin" >> "$GITHUB_PATH"
+ ghc --version
+ cabal --version
+
+ # ---------------------------------------------------------------------
+ # 3. Checkout stable-haskell/cabal at the recorded SHA — both the
+ # rpath relativize and target-prefix-aware tool guess patches
+ # are in the SHA (stable-haskell/feature/wasm-cross-ghcup-stack
+ # branch), so no patch application is required here.
+ # ---------------------------------------------------------------------
+ - name: Checkout stable-haskell/cabal @ CABAL_SHA
+ uses: actions/checkout@v5
+ with:
+ repository: stable-haskell/cabal
+ ref: ${{ env.CABAL_SHA }}
+ path: cabal-src
+ # We need full history because the SHA might not be on the
+ # default branch tip; ref above pins a commit not a branch.
+ fetch-depth: 0
+
+ # ---------------------------------------------------------------------
+ # 4. Build cabal-install using stable-haskell/cabal's release
+ # project (split-sections for size; dynamic-link gmp because
+ # static-link requires PIC gmp which Ubuntu's libgmp-dev isn't
+ # always built with).
+ # ---------------------------------------------------------------------
+ - name: cabal build
+ working-directory: cabal-src
+ run: |
+ set -euo pipefail
+ cabal update
+ PROJ=cabal.project
+ if [ -f cabal.release.project ]; then PROJ=cabal.release.project; fi
+ echo "Using project: $PROJ"
+ cabal build cabal-install:exe:cabal \
+ --project-file "$PROJ" \
+ --enable-split-sections \
+ -O2
+
+ # ---------------------------------------------------------------------
+ # 5. Stage the binary; libgmp stays dynamically linked (every modern
+ # Linux ships it).
+ # ---------------------------------------------------------------------
+ - name: Stage + package
+ id: stage
+ working-directory: cabal-src
+ run: |
+ set -euo pipefail
+ stage="$HOME/cabal-stage"
+ mkdir -p "$stage"
+ bin=$(find dist-newstyle -type f -name cabal -executable | head -1)
+ test -n "$bin" || { echo "::error::built cabal not found"; exit 1; }
+ cp "$bin" "$stage/cabal"
+ strip "$stage/cabal"
+ ls -lh "$stage/cabal"
+ file "$stage/cabal"
+ "$stage/cabal" --version
+ ldd "$stage/cabal" | sed 's/^/ /'
+ tarball="cabal-${CABAL_VER}-${{ matrix.plat }}.tar.gz"
+ tar -C "$stage" -czf "$GITHUB_WORKSPACE/$tarball" cabal
+ echo
+ echo "═══ artifact: $tarball ═══"
+ ls -lh "$GITHUB_WORKSPACE/$tarball"
+ shasum -a 256 "$GITHUB_WORKSPACE/$tarball"
+ echo "tarball=$tarball" >> "$GITHUB_OUTPUT"
+
+ # ---------------------------------------------------------------------
+ # 6. Upload as workflow artifact (always) — release-asset upload
+ # only triggered if the caller passed a release_tag input.
+ # ---------------------------------------------------------------------
+ - name: Upload artifact
+ uses: actions/upload-artifact@v5
+ with:
+ name: cabal-${{ env.CABAL_VER }}-${{ matrix.plat }}
+ path: cabal-${{ env.CABAL_VER }}-${{ matrix.plat }}.tar.gz
+ retention-days: 30
+
+ - name: Upload to release (if release_tag provided)
+ if: ${{ inputs.release_tag != '' }}
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ inputs.release_tag }}
+ files: cabal-${{ env.CABAL_VER }}-${{ matrix.plat }}.tar.gz
+ fail_on_unmatched_files: true
diff --git a/.github/workflows/reusable-channel-e2e.yml b/.github/workflows/reusable-channel-e2e.yml
new file mode 100644
index 000000000000..4ba24d609ba2
--- /dev/null
+++ b/.github/workflows/reusable-channel-e2e.yml
@@ -0,0 +1,440 @@
+# Reusable: channel end-to-end CI for the multi-target bindist.
+#
+# Two install modes, selected by `install_mode`:
+#
+# * channel — install GHC + cabal via the live stable-haskell ghcup
+# channels (post-release smoke test). Triggered from
+# channel-e2e-multi.yml on tag push / schedule.
+#
+# * artifact — download the multi-target GHC tarball + cabal bindist
+# from the SAME workflow run (uploaded by Cross: MULTI
+# and reusable-cabal-release as part of nix-ci) and
+# install locally. Triggered from nix-ci.yml; catches
+# regressions introduced by the PR BEFORE we ship.
+#
+# Common test surface for both modes:
+# 1. native hello — exercises argv[0]=ghc path
+# 2. wasm hello — exercises wasm32-unknown-wasi-ghc path
+# 3. miso-counter — TH-heavy real-app build via wasm frontend (50+
+# deps incl. aeson / jsaddle / miso)
+# 4. JS hello — exercises javascript-unknown-ghcjs-ghc path
+
+name: reusable-channel-e2e
+
+on:
+ workflow_call:
+ inputs:
+ install_mode:
+ description: '"channel" (live ghcup channel) or "artifact" (this run''s artifacts)'
+ type: string
+ default: 'channel'
+ multi_version:
+ description: 'Multi-target GHC version label (artifact-mode: used only for PATH suffixing)'
+ type: string
+ default: 'multi-9.14.0.stable.1'
+ cabal_version:
+ description: 'cabal-install version label'
+ type: string
+ default: '3.17.0.0.stable.0'
+ platforms_json:
+ description: 'JSON array of {plat,runner} for the matrix'
+ type: string
+ default: |
+ [
+ { "plat": "x86_64-linux", "runner": "ubuntu-latest" },
+ { "plat": "aarch64-linux", "runner": "ubuntu-24.04-arm" },
+ { "plat": "aarch64-darwin", "runner": "macos-15" }
+ ]
+
+jobs:
+ install-and-multi:
+ name: e2e MULTI / ${{ matrix.plat }}
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include: ${{ fromJSON(inputs.platforms_json) }}
+
+ env:
+ MULTI_VER: ${{ inputs.multi_version }}
+ CABAL_VER: ${{ inputs.cabal_version }}
+ INSTALL_MODE: ${{ inputs.install_mode }}
+
+ steps:
+ # ---------------------------------------------------------------------
+ # 1. Fresh ghcup install (needed for cabal install via channel; in
+ # artifact mode the GHC + cabal come from artifacts but ghcup is
+ # still convenient for shell PATH bootstrap).
+ # ---------------------------------------------------------------------
+ - name: Install ghcup fresh
+ run: |
+ set -euo pipefail
+ rm -rf "$HOME/.ghcup"
+ if [ "$(uname -s)" = "Linux" ] && [ -d /usr/local/.ghcup ]; then
+ sudo rm -rf /usr/local/.ghcup
+ fi
+ curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \
+ | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \
+ BOOTSTRAP_HASKELL_MINIMAL=1 \
+ GHCUP_INSTALL_BASE_PREFIX="$HOME" \
+ sh
+ echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH"
+ echo "GHCUP_INSTALL_BASE_PREFIX=$HOME" >> "$GITHUB_ENV"
+
+ # ---------------------------------------------------------------------
+ # 2. Node 22 (needed by wasm + JS target TH eval).
+ # ---------------------------------------------------------------------
+ - name: Install gcc + Node 22 (Linux via apt + NodeSource)
+ if: runner.os == 'Linux'
+ run: |
+ set -euo pipefail
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends build-essential
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+ sudo apt-get install -y --no-install-recommends nodejs
+ # Force /usr/bin first so NodeSource Node 22 wins over toolcache Node 20
+ # (ubuntu-24.04-arm pre-bakes Node 20 in /opt/hostedtoolcache).
+ echo "/usr/bin" >> "$GITHUB_PATH"
+ export PATH="/usr/bin:$PATH"
+ ver=$(node --version | tr -d v | cut -d. -f1)
+ test "$ver" -ge 22 || { echo "::error::expected Node 22+, got $(node --version)"; exit 1; }
+
+ - name: Install Node 22 (Darwin)
+ if: runner.os == 'macOS'
+ run: |
+ set -euo pipefail
+ NODE_VER=v22.22.0
+ NODE_DIR="$HOME/node-$NODE_VER"
+ rm -rf "$NODE_DIR"
+ curl -fsSL "https://nodejs.org/dist/$NODE_VER/node-$NODE_VER-darwin-arm64.tar.xz" \
+ | tar -xJ -C "$HOME"
+ mv "$HOME/node-$NODE_VER-darwin-arm64" "$NODE_DIR"
+ echo "$NODE_DIR/bin" >> "$GITHUB_PATH"
+ "$NODE_DIR/bin/node" --version
+
+ # ---------------------------------------------------------------------
+ # 3. wasi-sdk — needed for the wasm hello / miso tests to find
+ # wasm32-unknown-wasi-clang at link time.
+ # ---------------------------------------------------------------------
+ - name: Install wasi-sdk via ghc-wasm-meta bootstrap
+ run: |
+ set -euo pipefail
+ export TMPDIR="$GITHUB_WORKSPACE/_tmp"
+ mkdir -p "$TMPDIR"
+ curl -fsSL https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/raw/master/bootstrap.sh | \
+ FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm sh
+ WASI_BIN="$HOME/.ghc-wasm/wasi-sdk/bin"
+ test -d "$WASI_BIN" || { echo "::error::$WASI_BIN missing"; exit 1; }
+ for tool in clang clang++; do
+ ln -sf "$WASI_BIN/wasm32-wasi-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool"
+ done
+ for tool in ar nm ranlib strip; do
+ ln -sf "$WASI_BIN/llvm-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" 2>/dev/null || true
+ done
+ echo "$WASI_BIN" >> "$GITHUB_PATH"
+ # macos-15: WASI_BIN contains a wasm-only `clang` that, if it
+ # wins PATH lookup over /usr/bin/clang, gets invoked by GHC's
+ # native code generator and fails. Re-prepend /usr/bin so
+ # Apple's clang wins for native compiles.
+ if [ "$(uname -s)" = "Darwin" ]; then
+ echo "/usr/bin" >> "$GITHUB_PATH"
+ fi
+
+ # ---------------------------------------------------------------------
+ # 4a (channel mode). Add the wasm channel + install cabal via ghcup.
+ # ---------------------------------------------------------------------
+ - name: Add stable-haskell wasm channel (for cabal) — channel mode
+ if: ${{ inputs.install_mode == 'channel' }}
+ run: |
+ set -euo pipefail
+ ghcup config add-release-channel \
+ https://stable-haskell.github.io/ghc/ghcup-wasm.yaml
+
+ - name: Install cabal via ghcup — channel mode
+ if: ${{ inputs.install_mode == 'channel' }}
+ run: |
+ set -euo pipefail
+ ghcup install cabal "$CABAL_VER"
+ ghcup set cabal "$CABAL_VER"
+ cabal --version
+
+ # ---------------------------------------------------------------------
+ # 4b (artifact mode). Two install paths for cabal:
+ # * Linux — download cabal artifact built by reusable-cabal-release
+ # in this run, extract to ~/.local/bin
+ # * Darwin — no cabal artifact (we build Linux only); fall back to
+ # the ghcup wasm channel
+ #
+ # Download step uses continue-on-error so a failed cabal-release
+ # for one platform doesn't cascade into an e2e job failure — we
+ # detect the missing artifact via steps.dl_cabal.outcome and fall
+ # back to the channel install path instead.
+ # ---------------------------------------------------------------------
+ - name: Download cabal bindist artifact — artifact mode (Linux)
+ id: dl_cabal
+ if: ${{ inputs.install_mode == 'artifact' && runner.os == 'Linux' }}
+ continue-on-error: true
+ uses: actions/download-artifact@v5
+ with:
+ name: cabal-${{ inputs.cabal_version }}-${{ matrix.plat }}
+ path: ${{ runner.temp }}/cabal-artifact
+
+ - name: Install cabal from artifact — artifact mode (Linux, present)
+ if: >-
+ ${{ inputs.install_mode == 'artifact'
+ && runner.os == 'Linux'
+ && steps.dl_cabal.outcome == 'success' }}
+ run: |
+ set -euo pipefail
+ mkdir -p "$HOME/.local/bin"
+ tar -xzf "${{ runner.temp }}/cabal-artifact/cabal-${CABAL_VER}-${{ matrix.plat }}.tar.gz" \
+ -C "$HOME/.local/bin"
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ "$HOME/.local/bin/cabal" --version
+
+ - name: Install cabal via wasm channel — artifact-mode fallback (Linux, missing)
+ if: >-
+ ${{ inputs.install_mode == 'artifact'
+ && runner.os == 'Linux'
+ && steps.dl_cabal.outcome != 'success' }}
+ run: |
+ set -euo pipefail
+ echo "::warning::cabal-${CABAL_VER}-${{ matrix.plat }} artifact missing (cabal-release failed?) — falling back to wasm channel install"
+ ghcup config add-release-channel \
+ https://stable-haskell.github.io/ghc/ghcup-wasm.yaml
+ ghcup install cabal "$CABAL_VER"
+ ghcup set cabal "$CABAL_VER"
+ cabal --version
+
+ - name: Install cabal via wasm channel — artifact mode (Darwin)
+ if: ${{ inputs.install_mode == 'artifact' && runner.os == 'macOS' }}
+ run: |
+ set -euo pipefail
+ ghcup config add-release-channel \
+ https://stable-haskell.github.io/ghc/ghcup-wasm.yaml
+ ghcup install cabal "$CABAL_VER"
+ ghcup set cabal "$CABAL_VER"
+ cabal --version
+
+ # ---------------------------------------------------------------------
+ # 5a (channel mode). Install the multi-target GHC from the live
+ # channel. If the channel YAML or the asset is unreachable,
+ # record installed=false and skip the strict tests below.
+ # ---------------------------------------------------------------------
+ - name: Install multi-target GHC — channel mode
+ id: install_channel
+ if: ${{ inputs.install_mode == 'channel' }}
+ run: |
+ set -euo pipefail
+ if ! ghcup config add-release-channel \
+ https://stable-haskell.github.io/ghc/ghcup-multi-target-0.1.0.yaml 2>&1 | tee /tmp/multi-chan.log; then
+ echo "::warning::could not add multi-target channel — skipping multi-target tests"
+ echo "installed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ if ! ghcup install ghc "$MULTI_VER" 2>&1 | tee /tmp/multi-install.log; then
+ echo "::warning::$MULTI_VER not installable from channel — skipping multi-target tests"
+ echo "installed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ ghcup set ghc "$MULTI_VER"
+ echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH"
+ echo "installed=true" >> "$GITHUB_OUTPUT"
+
+ # ---------------------------------------------------------------------
+ # 5b (artifact mode). Download the multi-target tarball produced by
+ # Cross: MULTI in the same workflow run, extract it, and run
+ # the bundled ./configure + make install — the exact flow ghcup
+ # uses end-user-side. Then prepend its bin/ to PATH.
+ #
+ # Cross: MULTI is matrix'd with `continue-on-error: true` in
+ # nix-ci.yml so a single platform failure (e.g. transient github.com
+ # 504 during curl) doesn't block the other platforms. That means
+ # `needs.cross-multi.result` at the caller level resolves "success"
+ # even when one matrix entry didn't upload an artifact — so the
+ # gate has to happen here, per platform. Download is
+ # continue-on-error; install_artifact only runs if the download
+ # succeeded; downstream test steps gate on install_artifact's
+ # `installed=true` output, so a missing artifact cleanly skips
+ # the platform with a warning instead of cascading into failure.
+ # ---------------------------------------------------------------------
+ - name: Download multi-target GHC artifact — artifact mode
+ id: dl_multi
+ if: ${{ inputs.install_mode == 'artifact' }}
+ continue-on-error: true
+ uses: actions/download-artifact@v5
+ with:
+ name: ${{ matrix.plat }}-cross-multi
+ path: ${{ runner.temp }}/multi-artifact
+
+ - name: Skip notice — multi-target artifact missing
+ if: >-
+ ${{ inputs.install_mode == 'artifact'
+ && steps.dl_multi.outcome != 'success' }}
+ run: |
+ echo "::warning::${{ matrix.plat }}-cross-multi artifact missing (cross-multi probably failed on this platform) — skipping multi-target tests for ${{ matrix.plat }}"
+
+ - name: Install multi-target GHC from artifact
+ id: install_artifact
+ if: >-
+ ${{ inputs.install_mode == 'artifact'
+ && steps.dl_multi.outcome == 'success' }}
+ run: |
+ set -euo pipefail
+ PREFIX="$HOME/multi-target-install"
+ WORK="${{ runner.temp }}/multi-extract"
+ mkdir -p "$WORK" "$PREFIX"
+ tar -xzf "${{ runner.temp }}/multi-artifact/ghc-multi-target-${{ matrix.plat }}.tar.gz" \
+ -C "$WORK"
+ cd "$WORK"
+ # The tarball extracts files into the current dir (bin/, lib/,
+ # configure, Makefile, relocate.sh).
+ ls -la | head -n 10
+ test -x ./configure || { echo "::error::no configure in tarball"; exit 1; }
+ ./configure --prefix="$PREFIX"
+ make install
+ echo "$PREFIX/bin" >> "$GITHUB_PATH"
+ export PATH="$PREFIX/bin:$PATH"
+ # Verify all three frontends resolve.
+ "$PREFIX/bin/ghc" --numeric-version
+ "$PREFIX/bin/wasm32-unknown-wasi-ghc" --numeric-version
+ "$PREFIX/bin/javascript-unknown-ghcjs-ghc" --numeric-version
+ echo "installed=true" >> "$GITHUB_OUTPUT"
+
+ # ---------------------------------------------------------------------
+ # 5c (both modes). Smoke-test argv[0] dispatch: each frontend MUST
+ # report its own Target platform.
+ # ---------------------------------------------------------------------
+ - name: Verify argv[0] dispatch reports correct Target platform
+ if: >-
+ ${{ (inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true')
+ || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true') }}
+ run: |
+ set -euo pipefail
+ ghc --info \
+ | grep -F -e '("Target platform","x86_64-' \
+ -e '("Target platform","aarch64-' \
+ >/dev/null \
+ || { echo "::error::native ghc reports unexpected Target platform"; ghc --info | grep "Target platform"; exit 1; }
+ wasm32-unknown-wasi-ghc --info | grep -qF '("Target platform","wasm32-unknown-wasi")' \
+ || { echo "::error::wasm frontend reports wrong Target platform"; wasm32-unknown-wasi-ghc --info | grep "Target platform"; exit 1; }
+ javascript-unknown-ghcjs-ghc --info | grep -qF '("Target platform","javascript-unknown-ghcjs")' \
+ || { echo "::error::JS frontend reports wrong Target platform"; javascript-unknown-ghcjs-ghc --info | grep "Target platform"; exit 1; }
+
+ # ---------------------------------------------------------------------
+ # 6. Native hello — proves `ghc` frontend compiles and runs native.
+ # ---------------------------------------------------------------------
+ - name: multi — native hello (strict)
+ if: >-
+ ${{ !cancelled() &&
+ ((inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true')
+ || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true')) }}
+ run: |
+ set -euo pipefail
+ mkdir -p /tmp/native-hello && cd /tmp/native-hello
+ echo 'main = putStrLn "Hello from native ghc"' > hello.hs
+ ghc hello.hs -o hello
+ ./hello | tee /tmp/native-out
+ grep -q 'Hello from native ghc' /tmp/native-out
+
+ # ---------------------------------------------------------------------
+ # 7. Wasm hello — proves wasm32-unknown-wasi-ghc frontend produces
+ # a Node:wasi-runnable .wasm via the published hello template.
+ # ---------------------------------------------------------------------
+ - name: multi — wasm hello via multi-target (strict)
+ if: >-
+ ${{ !cancelled() &&
+ ((inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true')
+ || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true')) }}
+ run: |
+ set -euo pipefail
+ mkdir -p /tmp/multi-hello && cd /tmp/multi-hello
+ curl -fL -o multi-hello.tar.gz \
+ https://stable-haskell.github.io/ghc/examples/stable-haskell-wasm-hello.tar.gz
+ tar xf multi-hello.tar.gz
+ cd stable-haskell-wasm-hello
+ make WASM_VERSION="$MULTI_VER" build
+ make WASM_VERSION="$MULTI_VER" run-node 2>&1 | tee /tmp/multi-run.out
+ grep -q 'Hello from the WASM reactor!' /tmp/multi-run.out
+
+ # ---------------------------------------------------------------------
+ # 7b. miso-counter — full TH-heavy real-app build via the wasm
+ # frontend. 50+ transitive deps including aeson, lens, jsaddle,
+ # jsaddle-wasm, miso. Load-bearing end-to-end test for TH at
+ # scale, dyld, JSFFI, cabal-dual-compiler.
+ #
+ # Background diagnostic monitor logs ps + memory every 60s so a
+ # future hang surfaces WHICH process is stuck and HOW.
+ # ---------------------------------------------------------------------
+ - name: multi — miso-counter via multi-target wasm (strict)
+ if: >-
+ ${{ !cancelled() &&
+ ((inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true')
+ || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true')) }}
+ run: |
+ set -euo pipefail
+ cabal update
+
+ mkdir -p /tmp/multi-miso && cd /tmp/multi-miso
+ curl -fL -o miso.tar.gz \
+ https://stable-haskell.github.io/ghc/examples/stable-haskell-wasm-miso-counter.tar.gz
+ tar xf miso.tar.gz
+ cd stable-haskell-wasm-miso-counter
+
+ (
+ while true; do
+ echo "::group::diag $(date +%H:%M:%S)"
+ echo "--- ps (wasm/ghc/cabal/node) ---"
+ if [ "$(uname -s)" = "Darwin" ]; then
+ ps -A -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \
+ | head -1
+ ps -A -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \
+ | grep -E "(ghc|cabal|node|wasm32|iserv)" | grep -v grep || true
+ echo "--- memory ---"
+ vm_stat | head -10
+ else
+ ps -e -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \
+ | head -1
+ ps -e -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \
+ | grep -E "(ghc|cabal|node|wasm32|iserv)" | grep -v grep || true
+ echo "--- memory ---"
+ free -h 2>/dev/null
+ fi
+ echo "::endgroup::"
+ sleep 60
+ done
+ ) &
+ MONITOR_PID=$!
+ trap 'kill $MONITOR_PID 2>/dev/null || true' EXIT INT TERM
+
+ make WASM_VERSION="$MULTI_VER" build
+ wasm_file=$(find dist-newstyle -name 'myapp.wasm' -type f | head -1)
+ test -n "$wasm_file" || { echo "::error::myapp.wasm not produced"; exit 1; }
+ ls -lh "$wasm_file"
+ file "$wasm_file" | tee /tmp/file_out
+ grep -q 'WebAssembly' /tmp/file_out \
+ || { echo "::error::artifact is not a wasm binary"; exit 1; }
+
+ # ---------------------------------------------------------------------
+ # 8. JS hello — proves javascript-unknown-ghcjs-ghc frontend
+ # produces Node-runnable .js. emscripten installed on-demand
+ # here since only this step needs it.
+ # ---------------------------------------------------------------------
+ - name: multi — JS hello via multi-target (strict)
+ if: >-
+ ${{ !cancelled() &&
+ ((inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true')
+ || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true')) }}
+ run: |
+ set -euo pipefail
+ if ! command -v emcc >/dev/null 2>&1; then
+ git clone --depth 1 --branch 3.1.74 https://github.com/emscripten-core/emsdk.git /tmp/emsdk
+ cd /tmp/emsdk && ./emsdk install 3.1.74 && ./emsdk activate 3.1.74
+ source ./emsdk_env.sh
+ fi
+ mkdir -p /tmp/js-hello && cd /tmp/js-hello
+ echo 'main = putStrLn "Hello from JS backend"' > hello.hs
+ javascript-unknown-ghcjs-ghc hello.hs -o hello
+ node hello.jsexe/all.js | tee /tmp/js-out
+ grep -q 'Hello from JS backend' /tmp/js-out
diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml
index fe56af77f3f3..38e1e157874c 100644
--- a/.github/workflows/reusable-release.yml
+++ b/.github/workflows/reusable-release.yml
@@ -117,7 +117,7 @@ jobs:
- name: Install GHC
run: ghcup --no-verbose install ghc --set --install-targets "${{ env.GHC_TARGETS }}" "${{ env.GHC_VERSION }}"
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
submodules: recursive
@@ -222,7 +222,7 @@ jobs:
}
]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
submodules: recursive
@@ -304,7 +304,7 @@ jobs:
matrix:
branch: ${{ fromJSON(inputs.branches) }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
submodules: recursive
@@ -359,7 +359,7 @@ jobs:
matrix:
branch: ${{ fromJSON(inputs.branches) }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
submodules: recursive
@@ -415,7 +415,7 @@ jobs:
run:
shell: 'C:/msys64/usr/bin/bash.exe --login -e {0}'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
submodules: recursive
@@ -469,7 +469,7 @@ jobs:
matrix:
branch: ${{ fromJSON(inputs.branches) }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
submodules: recursive
@@ -651,7 +651,7 @@ jobs:
- name: git clone fix
run: git config --system --add safe.directory $GITHUB_WORKSPACE
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
@@ -703,7 +703,7 @@ jobs:
}
]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
@@ -736,7 +736,7 @@ jobs:
matrix:
branch: ${{ fromJSON(inputs.branches) }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
@@ -763,7 +763,7 @@ jobs:
matrix:
branch: ${{ fromJSON(inputs.branches) }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
submodules: recursive
@@ -793,7 +793,7 @@ jobs:
run:
shell: 'C:/msys64/usr/bin/bash.exe --login -e {0}'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
submodules: recursive
@@ -825,7 +825,7 @@ jobs:
matrix:
branch: ${{ fromJSON(inputs.branches) }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
diff --git a/Makefile b/Makefile
index 3c398ed9f539..77fd787852c4 100644
--- a/Makefile
+++ b/Makefile
@@ -119,11 +119,6 @@ SED ?= sed
LN ?= ln
LN_S ?= $(LN) -s
LN_SF ?= $(LN) -sf
-ifeq ($(UNAME), FreeBSD)
-TAR ?= gtar
-else
-TAR ?= tar
-endif
ifeq ($(UNAME), Darwin)
DLL := *.dylib
@@ -151,6 +146,19 @@ GHC_CONFIGURE_ARGS =
GHC_TOOLCHAIN_ARGS = --disable-ld-override
+# Cross-compilation tool paths: default to stage1 build tree; can be
+# overridden for dist-based cross builds (CI) where only the dist
+# artifact is available.
+GHC_TOOLCHAIN_BIN ?= $(STAGE1_PATH)/bin/ghc-toolchain-bin
+DERIVE_CONSTANTS_BIN ?= $(STAGE1_PATH)/bin/deriveConstants
+GENAPPLY_BIN ?= $(STAGE1_PATH)/bin/genapply
+# genprimopcode is invoked by compiler/Setup.hs by bare name (`readProcess
+# "genprimopcode"`), i.e. resolved via PATH rather than an env var. Its
+# directory is prepended to PATH for the stage3 cabal build (see below) so the
+# freshly-built one wins over the bootstrap GHC's, which predates the primop
+# `effect`/`NoEffect` grammar and dies at primops.txt:139:31.
+GENPRIMOPCODE_BIN ?= $(STAGE1_PATH)/bin/genprimopcode
+
#
# Build directories and paths
#
@@ -179,14 +187,10 @@ STAGE0_STAMP := $(BUILD_DIR)/.stamp-stage0
STAGE1_STAMP := $(BUILD_DIR)/.stamp-stage1
STAGE2_STAMP := $(BUILD_DIR)/.stamp-stage2
-# Stamp fallback rules: if a stamp doesn't exist, invoke the corresponding
-# stage via recursive make. The stage recipe touches the stamp on success.
-# Because there are no prerequisites, Make won't re-run these when the stamp
-# file already exists — which is the whole point: `test: $(STAGE2_STAMP)` will
-# skip the build if stage2 already completed.
-$(STAGE0_STAMP): ; @$(MAKE) stable-cabal
-$(STAGE1_STAMP): ; @$(MAKE) stage1
-$(STAGE2_STAMP): ; @$(MAKE) stage2
+# Each stamp is the real Make target for its stage's recipe; PHONY aliases
+# (stage1, stage2) are provided for convenience. This lets Make resolve the
+# full dependency graph in a single invocation (e.g. `make _build/dist/ghc.tar.gz`)
+# while still skipping already-completed stages.
# HOST_PLATFROM is always from the bootstrap compiler
HOST_PLATFORM := $(shell $(GHC0) --print-host-platform)
@@ -478,7 +482,12 @@ define DIST_COPY_LIBS_SO
endef
define DIST_COPY_LIBS_SO_CROSS
- @find $(CURDIR)/$(STORE_DIR)/host/$(TARGET_PLATFORM)/lib/ -mindepth 1 -type f -name "$(DLL)" -execdir cp '{}' $(CURDIR)/$(DIST_DIR)/lib/targets/$(TARGET_PLATFORM)/lib/$(TARGET_PLATFORM)/'{}' \;
+ @# $(DLL) is set based on BUILD host (.dylib on Darwin, .so on Linux), but
+ @# cross targets have their own shared-library extension. wasm32-* and
+ @# Linux-style targets produce .so; Mac/Darwin targets would produce .dylib.
+ @# Search for both extensions so cross-stage SO/DYLIB files land in dist
+ @# regardless of build-host vs target-platform mismatch.
+ @find $(CURDIR)/$(STORE_DIR)/host/$(TARGET_PLATFORM)/lib/ -mindepth 1 -type f \( -name "*.so" -o -name "*.dylib" \) -execdir cp '{}' $(CURDIR)/$(DIST_DIR)/lib/targets/$(TARGET_PLATFORM)/lib/$(TARGET_PLATFORM)/'{}' \;
endef
@@ -592,8 +601,12 @@ STAGE1_CABAL_BUILD = \
--with-build-compiler=$(GHC0) \
--ghc-options "-ghcversion-file=$(call NORMALIZE_FP,$(CURDIR)/rts/include/ghcversion.h)"
-stage1: STAGE=stage1
-stage1: stable-cabal $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage1 cabal.project.common libraries/ghc-boot-th-next | hackage
+ifndef DIST_BUILD
+# The stamp file is the real target so that Make can resolve it as a
+# prerequisite (e.g. stage2 -> $(GHC1) -> $(STAGE1_STAMP)). The PHONY
+# alias below lets users run `make stage1` as before.
+$(STAGE1_STAMP): STAGE=stage1
+$(STAGE1_STAMP): $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage1 cabal.project.common | stable-cabal libraries/ghc-boot-th-next hackage
$(call PHASE_START,stage1)
$(call LOG,Starting build of $(STAGE))
@@ -607,6 +620,12 @@ stage1: stable-cabal $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stag
ifeq ($(DYNAMIC),1)
$(SED) -i -e 's/"RTS ways","/"RTS ways","dyn debug_dyn thr_dyn thr_debug_dyn /' $(STORE_DIR)/host/$(HOST_PLATFORM)/lib/settings
endif
+ @# Inject the four per-target dials into the native settings file
+ @# too — same keys as the stage3 cross targets (see the lengthy
+ @# comment above $(TARGET_DIR)/lib/settings injection). The native
+ @# target's dynamic state tracks our DYNAMIC=0/1 build flag; prof
+ @# is always NO because stage2 isn't built -prof.
+ $(SED) -i -e 's/\]$$/,("target is dynamic","$(if $(filter 1,$(DYNAMIC)),YES,NO)"),("target ships dynamic libraries","$(if $(filter 1,$(DYNAMIC)),YES,NO)"),("target is profiled","NO"),("target ships profiling libraries","NO")]/' $(STORE_DIR)/host/$(HOST_PLATFORM)/lib/settings
$(call LOG,Creating packagedb in $(STORE_DIR)/host/$(HOST_PLATFORM)/lib/package.conf.d)
@rm -rf $(STORE_DIR)/host/$(HOST_PLATFORM)/lib/package.conf.d
@@ -614,9 +633,13 @@ endif
$(call LOG,Finished building $(STAGE))
$(call PHASE_END_OK,stage1)
- @touch $(STAGE1_STAMP)
+ @touch $@
+
+.PHONY: stage1
+stage1: $(STAGE1_STAMP)
-$(addprefix $(STAGE1_PATH)/bin/,$(STAGE1_EXECUTABLES)) : stage1
+$(addprefix $(STAGE1_PATH)/bin/,$(STAGE1_EXECUTABLES)) : $(STAGE1_STAMP)
+endif # DIST_BUILD (stage1)
# ____ _ ____
# / ___|| |_ __ _ __ _ ___ |___ \
@@ -685,8 +708,6 @@ STAGE2_LIBRARIES = \
stm \
system-cxx-std-lib \
template-haskell \
- template-haskell-lift \
- template-haskell-quasiquoter \
text \
time \
transformers \
@@ -714,9 +735,10 @@ STAGE2_CABAL_BUILD = \
$(foreach dir,$(STAGE2_EXTRA_LIB_DIRS),--extra-lib-dirs=$(dir)) \
$(foreach dir,$(STAGE2_EXTRA_INCLUDE_DIRS),--extra-include-dirs=$(dir))
-stage2: STAGE=stage2
-stage2: TARGET_PLATFORM:=$(HOST_PLATFORM)
-stage2: $(GHC1) stable-cabal $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage2.common cabal.project.stage2.static cabal.project.stage2.dynamic cabal.project.common libraries/ghc-boot-th-next | stage1
+ifndef DIST_BUILD
+$(STAGE2_STAMP): STAGE=stage2
+$(STAGE2_STAMP): TARGET_PLATFORM:=$(HOST_PLATFORM)
+$(STAGE2_STAMP): $(GHC1) $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage2.common cabal.project.stage2.static cabal.project.stage2.dynamic cabal.project.common | stable-cabal libraries/ghc-boot-th-next $(STAGE1_STAMP)
$(call PHASE_START,stage2)
$(call LOG,Starting build of $(STAGE))
@@ -755,21 +777,25 @@ stage2: $(GHC1) stable-cabal $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.proj
$(call LOG,Creating $(DIST_DIR)/lib/template-hsc.h)
@cp $(STAGE2_PATH)/lib/hsc2hs-*-hsc2hs/share/template-hsc.h $(DIST_DIR)/lib/template-hsc.h
-ifeq ($(DYNAMIC),1)
- # set rpath. Only needed for dynamic builds: statically-linked executables
- # link only system libraries (libc/libm) and load nothing from ../lib, so
- # they need no rpath. (patchelf also cannot rewrite the large static ghc,
- # haddock and ghc-iserv binaries: it fails with "virtual address space
- # underrun".)
+ # set rpath
@for binary in $(DIST_DIR)/bin/* ; do \
$(call SET_RPATH,../lib/$(HOST_PLATFORM),$${binary}) ; \
done
+ifeq ($(DYNAMIC),1)
ifneq ($(UNAME), Darwin)
- $(PATCHELF) --force-rpath --set-rpath "\$$ORIGIN" $(CURDIR)/$(DIST_DIR)/lib/$(TARGET_PLATFORM)/$(DLL)
+ @for so in $(CURDIR)/$(DIST_DIR)/lib/$(TARGET_PLATFORM)/$(DLL) ; do \
+ [ -f "$$so" ] && $(PATCHELF) --force-rpath --set-rpath "\$$ORIGIN" "$$so" ; \
+ done ; true
endif
$(call LOG,Create -dyn iserv executable symlink so ghc can find ghc-iserv-dyn)
@$(LN_SF) ghc-iserv$(EXE_EXT) "$(DIST_DIR)/bin/ghc-iserv-dyn$(EXE_EXT)"
endif
+ # On Darwin, strip the build-host /Volumes/WorkSpace LC_RPATH leak
+ # and rewrite nix-store LC_LOAD_DYLIB install names to /usr/lib
+ # BEFORE the ghc-pkg recache below — otherwise recache itself can
+ # abort-trap on macOS 15 dyld (which rejects unresolvable absolute
+ # rpaths as fatal). The script is a no-op on non-Darwin hosts.
+ @./mk/clean-darwin-macho.sh $(DIST_DIR)
$(call LOG,Refreshing $(DIST_DIR)/lib/package.conf.d cache)
@$(DIST_DIR)/bin/ghc-pkg recache --package-db $(CURDIR)/$(DIST_DIR)/lib/package.conf.d
@@ -780,12 +806,36 @@ endif
@cp -rfp driver/ghc-usage.txt $(DIST_DIR)/lib/
@cp -rfp driver/ghci-usage.txt $(DIST_DIR)/lib/
+ # Copy stage1 tools needed for cross-compilation from dist
+ $(call LOG,Copying cross-compilation tools to $(DIST_DIR)/bin)
+ @cp -fp $(STAGE1_PATH)/bin/ghc-toolchain-bin $(DIST_DIR)/bin/ghc-toolchain-bin
+ @cp -fp $(STAGE1_PATH)/bin/deriveConstants $(DIST_DIR)/bin/deriveConstants
+ @cp -fp $(STAGE1_PATH)/bin/genapply $(DIST_DIR)/bin/genapply
+ # genprimopcode: compiler/Setup.hs resolves it from PATH; the stage3 cabal
+ # build prepends $(dir $(GENPRIMOPCODE_BIN)) so this dist copy wins over the
+ # bootstrap GHC's (which can't parse the primop effect grammar, dying at
+ # primops.txt:139:31). Required for DIST_BUILD (CI) cross builds.
+ @cp -fp $(STAGE1_PATH)/bin/genprimopcode $(DIST_DIR)/bin/genprimopcode
+
+ # Copy stage0 cabal (needed for cross-compilation from dist, has -W flag)
+ $(call LOG,Copying cabal to $(DIST_DIR)/bin)
+ @cp -fp $(CABAL) $(DIST_DIR)/bin/cabal
+
+ # Copy happy-lib templates (needed for cross-compilation from dist)
+ $(call LOG,Copying happy-lib templates to $(DIST_DIR)/share)
+ @mkdir -p $(DIST_DIR)/share/happy-lib/data
+ @cp -rfp _build/stage2/src/happy-lib-*/data/* $(DIST_DIR)/share/happy-lib/data/
+
$(call LOG,Finished building $(STAGE) in $(DIST_DIR))
$(call PHASE_END_OK,stage2.dist)
$(call PHASE_END_OK,stage2)
- @touch $(STAGE2_STAMP)
+ @touch $@
+
+.PHONY: stage2
+stage2: $(STAGE2_STAMP)
-$(addprefix $(STAGE2_PATH)/bin/,$(STAGE2_EXECUTABLES)) : stage2
+$(addprefix $(STAGE2_PATH)/bin/,$(STAGE2_EXECUTABLES)) : $(STAGE2_STAMP)
+endif # DIST_BUILD (stage2)
# ____ _ _____
# / ___|| |_ __ _ __ _ ___ |___ /
@@ -873,6 +923,9 @@ STAGE3_javascript-unknown-ghcjs_CC = emcc
STAGE3_javascript-unknown-ghcjs_CC_OPTS =
STAGE3_javascript-unknown-ghcjs_CXX = em++
STAGE3_javascript-unknown-ghcjs_CXX_OPTS =
+STAGE3_javascript-unknown-ghcjs_CPP = emcc
+STAGE3_javascript-unknown-ghcjs_HS_CPP = emcc
+STAGE3_javascript-unknown-ghcjs_CMM_CPP = emcc
STAGE3_javascript-unknown-ghcjs_EXTRA_INCLUDE_DIRS =
STAGE3_javascript-unknown-ghcjs_EXTRA_LIB_DIRS =
STAGE3_javascript-unknown-ghcjs_LD = emcc
@@ -880,20 +933,44 @@ STAGE3_javascript-unknown-ghcjs_NM = emnm
STAGE3_javascript-unknown-ghcjs_RANLIB = emranlib
STAGE3_javascript-unknown-ghcjs_STRIP = emstrip
STAGE3_javascript-unknown-ghcjs_GHC_TOOLCHAIN_ARGS = $(GHC_TOOLCHAIN_ARGS) --disable-tables-next-to-code
-
-STAGE3_wasm32-unknown-wasi_CC = wasm32-wasi-clang
+# JS target overrides: NO across the board.
+# * dyn: iserv runs vanilla (no dlopen in the JS runtime), lib tree
+# ships no .dyn_hi (Path C doesn't apply to JS — see PR #187).
+# * prof: stage2 isn't built -prof, no .p_hi shipped.
+# All four dials NO → `ghc --info` for the JS target reports
+# `GHC Dynamic: NO`, `GHC Profiled: NO`, `Support dynamic-too: NO`.
+# End-user cabal-install correctly skips library-{dynamic,profiling}.
+STAGE3_javascript-unknown-ghcjs_TARGET_IS_DYNAMIC = NO
+STAGE3_javascript-unknown-ghcjs_TARGET_SHIPS_DYN_LIBS = NO
+STAGE3_javascript-unknown-ghcjs_TARGET_IS_PROFILED = NO
+STAGE3_javascript-unknown-ghcjs_TARGET_SHIPS_PROF_LIBS = NO
+
+STAGE3_wasm32-unknown-wasi_CC = wasm32-unknown-wasi-clang
STAGE3_wasm32-unknown-wasi_CC_OPTS = -fno-strict-aliasing -Wno-error=int-conversion -Oz -msimd128 -mnontrapping-fptoint -msign-ext -mbulk-memory -mmutable-globals -mmultivalue -mreference-types
-STAGE3_wasm32-unknown-wasi_CXX = wasm32-wasi-clang++
+STAGE3_wasm32-unknown-wasi_CXX = wasm32-unknown-wasi-clang++
STAGE3_wasm32-unknown-wasi_CXX_OPTS = $(STAGE3_wasm32-unknown-wasi_CC_OPTS) -fno-exceptions
-STAGE3_wasm32-unknown-wasi_AR = wasm32-wasi-ar
-STAGE3_wasm32-unknown-wasi_RANLIB = wasm32-wasi-ranlib
+STAGE3_wasm32-unknown-wasi_CPP = wasm32-unknown-wasi-clang
+STAGE3_wasm32-unknown-wasi_HS_CPP = wasm32-unknown-wasi-clang
+STAGE3_wasm32-unknown-wasi_CMM_CPP = wasm32-unknown-wasi-clang
+STAGE3_wasm32-unknown-wasi_AR = wasm32-unknown-wasi-ar
+STAGE3_wasm32-unknown-wasi_RANLIB = wasm32-unknown-wasi-ranlib
STAGE3_wasm32-unknown-wasi_EXTRA_INCLUDE_DIRS =
STAGE3_wasm32-unknown-wasi_EXTRA_LIB_DIRS =
-STAGE3_wasm32-unknown-wasi_GHC_TOOLCHAIN_ARGS = $(GHC_TOOLCHAIN_ARGS) --merge-objs wasm-ld --merge-objs-opt="-r" --disable-tables-next-to-code --disable-libffi-adjustors
+STAGE3_wasm32-unknown-wasi_GHC_TOOLCHAIN_ARGS = $(GHC_TOOLCHAIN_ARGS) \
+ --merge-objs wasm-ld --merge-objs-opt="-r" \
+ --disable-tables-next-to-code --disable-libffi-adjustors \
+ --cc-link-opt="-Wl,--keep-section=ghc_wasm_jsffi" \
+ --cc-link-opt="-Wl,--keep-section=target_features" \
+ --cc-link-opt="-Wl,--stack-first" \
+ --cc-link-opt="-Wl,--strip-debug"
TARGET_DIR = $(DIST_DIR)/lib/targets/$(TARGET_PLATFORM)
+# Happy template directory: defaults to stage2's unpacked happy-lib source; can
+# be overridden for dist-based cross builds where stage2 build tree is unavailable.
+HAPPY_TEMPLATE_DIR ?= $(wildcard _build/stage2/src/happy-lib-*/data)
+
# NOTE: disable-library-for-ghci is repeated here but it should be sufficient
# to put it in cabal.project.stage3
@@ -901,8 +978,9 @@ define stage3
STAGE3_$(1)_CABAL_BUILD = \
env \
- DERIVE_CONSTANTS=$$(call NORMALIZE_FP,$$(CURDIR)/$$(STAGE1_PATH)/bin/deriveConstants) \
- GENAPPLY=$$(call NORMALIZE_FP,$$(CURDIR)/$$(STAGE1_PATH)/bin/genapply) \
+ PATH="$$(call NORMALIZE_FP,$$(abspath $$(dir $$(GENPRIMOPCODE_BIN)))):$$$$PATH" \
+ DERIVE_CONSTANTS=$$(call NORMALIZE_FP,$$(abspath $$(DERIVE_CONSTANTS_BIN))) \
+ GENAPPLY=$$(call NORMALIZE_FP,$$(abspath $$(GENAPPLY_BIN))) \
NM=$$(STAGE3_$(1)_NM) \
OBJDUMP=$$(STAGE3_$(1)_OBJDUMP) \
$$(CABAL_BUILD) \
@@ -911,20 +989,29 @@ STAGE3_$(1)_CABAL_BUILD = \
--ghc-options "-ghcversion-file=$$(call NORMALIZE_FP,$$(CURDIR)/rts/include/ghcversion.h)" \
--with-hsc2hs=$$(call NORMALIZE_FP,$$(CURDIR)/$$(DIST_DIR)/bin/$(1)-hsc2hs) \
--hsc2hs-options='-x' \
+ --happy-options="--template=$$(abspath $$(HAPPY_TEMPLATE_DIR))" \
--with-gcc $$(STAGE3_$(1)_CC) \
$$(foreach dir,$$(STAGE3_$(1)_EXTRA_LIB_DIRS),--extra-lib-dirs=$$(dir)) \
$$(foreach dir,$$(STAGE3_$(1)_EXTRA_INCLUDE_DIRS),--extra-include-dirs=$$(dir))
+# In DIST_BUILD mode, stage2 binaries come from a pre-built dist artifact;
+# skip the $(GHC2) prerequisite (it points into the stage2 store, not dist).
+ifdef DIST_BUILD
+STAGE3_$(1)_PREREQS = $$(GHC_TOOLCHAIN_BIN) $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) libraries/ghc-boot-th-next cabal.project.common cabal.project.stage3 stage3-$(1)-additional-files
+else
+STAGE3_$(1)_PREREQS = $(GHC2) $$(GHC_TOOLCHAIN_BIN) $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) libraries/ghc-boot-th-next cabal.project.common cabal.project.stage3 stage3-$(1)-additional-files
+endif
+
.PHONY: stage3-$(1)
stage3-$(1): STAGE=stage3
stage3-$(1): TARGET_PLATFORM=$(1)
-stage3-$(1): $(GHC2) $$(STAGE1_PATH)/bin/ghc-toolchain-bin $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) libraries/ghc-boot-th-next cabal.project.common cabal.project.stage3 stage3-$(1)-additional-files
+stage3-$(1): $$(STAGE3_$(1)_PREREQS)
$$(call PHASE_START,stage3-$(1))
$$(call LOG,Linking executables)
$$(foreach exe,$$(STAGE3_EXECUTABLES),$(LN_SF) $$(exe) $(DIST_DIR)/bin/$(1)-$$(exe);)
@mkdir -p $$(TARGET_DIR)/lib
- $$(STAGE1_PATH)/bin/ghc-toolchain-bin \
+ $$(GHC_TOOLCHAIN_BIN) \
--output-settings \
--output $$(TARGET_DIR)/lib/settings \
--triple $(1) \
@@ -932,6 +1019,9 @@ stage3-$(1): $(GHC2) $$(STAGE1_PATH)/bin/ghc-toolchain-bin $(CONFIGURE_SCRIPTS)
$$(foreach opt,$$(STAGE3_$(1)_CC_OPTS),--cc-opt=$$(opt)) \
--cxx $$(STAGE3_$(1)_CXX) \
$$(foreach opt,$$(STAGE3_$(1)_CXX_OPTS),--cxx-opt=$$(opt)) \
+ $(if $(STAGE3_$(1)_CPP),--cpp $$(STAGE3_$(1)_CPP),) \
+ $(if $(STAGE3_$(1)_HS_CPP),--hs-cpp $$(STAGE3_$(1)_HS_CPP),) \
+ $(if $(STAGE3_$(1)_CMM_CPP),--cmm-cpp $$(STAGE3_$(1)_CMM_CPP),) \
$(if $(STAGE3_$(1)_AR),--ar $$(STAGE3_$(1)_AR),) \
$(if $(STAGE3_$(1)_LD),--ld $$(STAGE3_$(1)_LD),) \
$(if $(STAGE3_$(1)_ND),--nm $$(STAGE3_$(1)_NM),) \
@@ -939,6 +1029,46 @@ stage3-$(1): $(GHC2) $$(STAGE1_PATH)/bin/ghc-toolchain-bin $(CONFIGURE_SCRIPTS)
--disable-ld-override \
$$(STAGE3_$(1)_GHC_TOOLCHAIN_ARGS)
+ifeq ($(DYNAMIC),1)
+ $(SED) -i -e 's/"RTS ways","/"RTS ways","dyn /' $$(TARGET_DIR)/lib/settings
+endif
+
+ @# Inject the per-target dials that drive `ghc --info`'s
+ @# `GHC Dynamic` and `GHC Profiled` values (cabal-install reads
+ @# these to decide whether to enable library-dynamic /
+ @# library-profiling by default):
+ @#
+ @# target is dynamic — GHC capable of -dynamic /
+ @# -dynamic-too output
+ @# target ships dynamic libraries — lib tree has .dyn_hi / .so
+ @# target is profiled — GHC capable of -prof output
+ @# target ships profiling libraries — lib tree has .p_hi / .p_a
+ @#
+ @# Reported pairs:
+ @# GHC Dynamic = (target is dynamic) && (target ships dynamic libraries)
+ @# GHC Profiled = (target is profiled) && (target ships profiling libraries)
+ @#
+ @# Per-target settings file completely controls these — the
+ @# shared stage2 GHC binary's RTS-baked-in dynamic/prof-ness is
+ @# no longer consulted. Two dials per way (is / ships) so a
+ @# target can be capable but not currently ship artifacts (or
+ @# vice versa) — keeps the axes orthogonal for slimming
+ @# experiments and matches what cabal really wants to know.
+ @#
+ @# Defaults for our bindists: dynamic dials YES (most targets
+ @# ship dyn libs), prof dials NO (stage2 isn't built -prof so
+ @# no target currently ships prof libs).
+ @# Override via STAGE3__TARGET_{IS_DYNAMIC,SHIPS_DYN_LIBS,
+ @# IS_PROFILED,SHIPS_PROF_LIBS}.
+ @#
+ @# Note: `$$$$` (four dollars) collapses through two layers of
+ @# Make expansion (define-template + recipe-time) to a literal `$`
+ @# at shell time, which is what sed needs as the end-of-line anchor.
+ @# `$$` would collapse to `$` after template expansion, then Make
+ @# would interpret the lone `$/` in the recipe as a variable lookup
+ @# and drop the anchor entirely (verified: PR #187 first attempt).
+ $(SED) -i -e 's/\]$$$$/,("target is dynamic","$(if $(STAGE3_$(1)_TARGET_IS_DYNAMIC),$(STAGE3_$(1)_TARGET_IS_DYNAMIC),YES)"),("target ships dynamic libraries","$(if $(STAGE3_$(1)_TARGET_SHIPS_DYN_LIBS),$(STAGE3_$(1)_TARGET_SHIPS_DYN_LIBS),YES)"),("target is profiled","$(if $(STAGE3_$(1)_TARGET_IS_PROFILED),$(STAGE3_$(1)_TARGET_IS_PROFILED),NO)"),("target ships profiling libraries","$(if $(STAGE3_$(1)_TARGET_SHIPS_PROF_LIBS),$(STAGE3_$(1)_TARGET_SHIPS_PROF_LIBS),NO)")]/' $$(TARGET_DIR)/lib/settings
+
$$(DIST_DIR)/bin/$(1)-ghc --info
@rm -rf $$(TARGET_DIR)/lib/package.conf.d
@@ -976,10 +1106,28 @@ stage3-$(1): $(GHC2) $$(STAGE1_PATH)/bin/ghc-toolchain-bin $(CONFIGURE_SCRIPTS)
$(DIST_DIR)/ghc-$(1).tar.gz: stage3-$(1)
@echo "::group::Creating ghc-$(1).tar.gz..."
- tar czf $$@ \
+ # -h: dereference symlinks. The stage3 cross bin/$(1)-* entries are
+ # symlinks to the native stage2 binaries (bin/ghc, bin/ghc-pkg, etc.).
+ # Without -h, the tarball would extract to broken symlinks since the
+ # bare-name targets aren't included. With -h, each cross-prefixed name
+ # becomes a real file (a copy of the native binary, which detects its
+ # invocation name to configure for the target).
+ #
+ # wasm only — lib/$(HOST_PLATFORM): when stage2 is built DYNAMIC=1
+ # (which is required for stage3's build-side happy-lib/alex to find
+ # native Prelude.dyn_hi when shared:True applies project-wide), the
+ # stage2 bin/ghc that gets symlinked-then-dereferenced as
+ # bin/wasm32-unknown-wasi-ghc is dyn-linked against libHS*.so files
+ # under lib/$(HOST_PLATFORM)/. The binary's @rpath is set to
+ # `$$ORIGIN/../lib/$(HOST_PLATFORM)` during stage2 packaging, so
+ # the libs need to ship in the wasm bindist at that path or the
+ # binary fails to load with dyld errors on end-user install.
+ tar czhf $$@ \
--directory=$$(DIST_DIR) \
$(foreach exe,$(STAGE3_EXECUTABLES),bin/$(1)-$(exe)$(EXE_EXT)) \
- lib/targets/$(1)
+ lib/targets/$(1) \
+ $(if $(filter wasm32-unknown-wasi,$(1)),lib/$(HOST_PLATFORM)) \
+ $(if $(filter wasm32-unknown-wasi,$(1)),relocate.sh configure Makefile)
@echo "::endgroup::"
endef
@@ -1009,6 +1157,22 @@ stage3-wasm32-unknown-wasi-additional-files:
@cp -f utils/jsffi/post-link.mjs $(TARGET_DIR)/lib/post-link.mjs
$(call LOG,Copying prelude.mjs)
@cp -f utils/jsffi/prelude.mjs $(TARGET_DIR)/lib/prelude.mjs
+ # Phase 3: relocate.sh at the top of the bindist — re-runs ghc-pkg recache
+ # for the new install prefix (binary cache is not portable; *.conf files
+ # already use ${pkgroot}-relative paths from DIST_COPY_LIB_CONF_CROSS).
+ # Note: ghc-pkg auto-recaches by mtime on first read, so this is just an
+ # eager refresh.
+ $(call LOG,Copying relocate.sh to bindist root)
+ @cp -f mk/wasm-relocate.sh $(DIST_DIR)/relocate.sh
+ @chmod +x $(DIST_DIR)/relocate.sh
+ # Phase 4: autoconf-shaped stubs so `ghcup install ghc ` can do its
+ # standard "./configure --prefix=DIR && make install" flow. The configure
+ # script just records the prefix; the Makefile install target copies
+ # bin/ + lib/ + relocate.sh and refreshes the package-db cache.
+ $(call LOG,Copying configure + Makefile to bindist root)
+ @cp -f mk/wasm-configure.sh $(DIST_DIR)/configure
+ @chmod +x $(DIST_DIR)/configure
+ @cp -f mk/wasm-bindist-Makefile $(DIST_DIR)/Makefile
stage3-x86_64-musl-linux-additional-files: STAGE=stage3
stage3-x86_64-musl-linux-additional-files: TARGET_PLATFORM=x86_64-musl-linux
@@ -1027,9 +1191,9 @@ stage3: $(foreach platform,$(STAGE3_PLATFORMS),stage3-$(platform))
# |____/|_|_| |_|\__,_|_|___/\__|___/
#
-$(DIST_DIR)/ghc.tar.gz: stage2
+$(DIST_DIR)/ghc.tar.gz: $(STAGE2_STAMP)
@echo "::group::Creating ghc.tar.gz..."
- @$(TAR) czf $@ \
+ @tar czf $@ \
--directory=$(DIST_DIR) \
$(foreach exe,$(STAGE2_EXECUTABLES),bin/$(exe)$(EXE_EXT)) \
$(shell if [ "$(DYNAMIC)" = 1 ] ; then echo "bin/ghc-iserv-dyn$(EXE_EXT)" ; fi) \
@@ -1041,20 +1205,20 @@ $(DIST_DIR)/ghc.tar.gz: stage2
lib/$(HOST_PLATFORM)
@echo "::endgroup::"
-$(DIST_DIR)/cabal.tar.gz: stable-cabal
+$(DIST_DIR)/cabal.tar.gz: | stable-cabal
@echo "::group::Creating cabal.tar.gz..."
@mkdir -p $(DIST_DIR)/bin
@cp $(CABAL) $(DIST_DIR)/bin/
- @$(TAR) czf $@ \
+ @tar czf $@ \
--directory=$(DIST_DIR) \
bin/cabal
@echo "::endgroup::"
-$(DIST_DIR)/haskell-toolchain.tar.gz: stable-cabal stage2 stage3-javascript-unknown-ghcjs
+$(DIST_DIR)/haskell-toolchain.tar.gz: $(STAGE2_STAMP) | stable-cabal stage3-javascript-unknown-ghcjs
@echo "::group::Creating haskell-toolchain.tar.gz..."
@mkdir -p $(DIST_DIR)/bin
@cp $(CABAL) $(DIST_DIR)/bin/
- @$(TAR) czf $@ \
+ @tar czf $@ \
--directory=$(DIST_DIR) \
$(foreach exe,$(STAGE2_EXECUTABLES),bin/$(exe)$(EXE_EXT)) \
lib/ghc-usage.txt \
@@ -1068,9 +1232,49 @@ $(DIST_DIR)/haskell-toolchain.tar.gz: stable-cabal stage2 stage3-javascript-unkn
bin/cabal
@echo "::endgroup::"
+# Multi-target GHC bindist:
+# native (lib/$(HOST_PLATFORM)) + wasm32-unknown-wasi + javascript-unknown-ghcjs
+#
+# The stage2 native ghc binary dispatches via argv[0]: invoking
+# bin/wasm32-unknown-wasi-ghc reads lib/targets/wasm32-unknown-wasi/lib/settings,
+# bin/javascript-unknown-ghcjs-ghc reads .../javascript-unknown-ghcjs/lib/settings,
+# bin/ghc reads lib/settings. Same physical binary, three targets.
+#
+# We use `tar czhf` (dereference symlinks) so the cross-prefixed bin entries
+# (which are symlinks to bin/ per the stage3 rule's $(LN_SF) loop) become
+# standalone copies in the tarball. The native bin/ entries are real
+# files. Cost: ~30 MB extra vs preserving symlinks; predictability gain:
+# ghcup's targetPattern glob picks up real files reliably regardless of
+# its symlink-following behaviour.
+#
+# JS doesn't use ghc-iserv (the JS backend has its own evaluator); filter
+# it out of the JS bin list so we don't ship a useless prefixed copy.
+$(DIST_DIR)/ghc-multi-target.tar.gz: | stage3-wasm32-unknown-wasi stage3-javascript-unknown-ghcjs
+ @echo "::group::Creating ghc-multi-target.tar.gz..."
+ @cp -f mk/multi-target-relocate.sh $(DIST_DIR)/relocate.sh
+ @chmod +x $(DIST_DIR)/relocate.sh
+ @cp -f mk/multi-target-configure.sh $(DIST_DIR)/configure
+ @chmod +x $(DIST_DIR)/configure
+ @cp -f mk/multi-target-bindist-Makefile $(DIST_DIR)/Makefile
+ tar czhf $@ \
+ --directory=$(DIST_DIR) \
+ $(foreach exe,$(STAGE2_EXECUTABLES),bin/$(exe)$(EXE_EXT)) \
+ $(foreach exe,$(STAGE3_EXECUTABLES),bin/wasm32-unknown-wasi-$(exe)$(EXE_EXT)) \
+ $(foreach exe,$(filter-out ghc-iserv,$(STAGE3_EXECUTABLES)),bin/javascript-unknown-ghcjs-$(exe)$(EXE_EXT)) \
+ lib/ghc-usage.txt \
+ lib/ghci-usage.txt \
+ lib/package.conf.d \
+ lib/settings \
+ lib/template-hsc.h \
+ lib/$(HOST_PLATFORM) \
+ lib/targets/wasm32-unknown-wasi \
+ lib/targets/javascript-unknown-ghcjs \
+ relocate.sh configure Makefile
+ @echo "::endgroup::"
+
$(DIST_DIR)/tests.tar.gz:
@echo "::group::Creating tests.tar.gz..."
- @$(TAR) czf $@ \
+ @tar czf $@ \
testsuite
@echo "::endgroup::"
@@ -1081,11 +1285,13 @@ $(DIST_DIR)/tests.tar.gz:
# |_| |_|\__,_|\___|_|\_\__,_|\__, |\___|
# |___/
+ifndef DIST_BUILD
# .PHONY: hackage
hackage: $(BUILD_DIR)/packages/hackage.haskell.org/01-index.tar.gz
-$(BUILD_DIR)/packages/hackage.haskell.org/01-index.tar.gz:
+$(BUILD_DIR)/packages/hackage.haskell.org/01-index.tar.gz: | stable-cabal
$(CABAL) --remote-repo-cache $(call NORMALIZE_FP,$(CURDIR)/$(BUILD_DIR)/packages) update
+endif # DIST_BUILD (hackage)
# ____ __ _
# / ___|___ _ __ / _(_) __ _ _ _ _ __ ___
diff --git a/USAGE.md b/USAGE.md
new file mode 100644
index 000000000000..ac48198baef0
--- /dev/null
+++ b/USAGE.md
@@ -0,0 +1,180 @@
+# GHC WASM Cross-Compiler Build Instructions
+
+## Prerequisites
+
+### Nix Only - No ghcup!
+
+All dependencies are provided via Nix. You only need:
+
+```bash
+# Verify Nix is installed
+nix --version
+```
+
+That's it! No ghcup, no manual WASM SDK installation.
+
+## Building WASM Cross-Compiler
+
+### Option A: Build on linux-0 (Recommended)
+
+```bash
+./build-wasm-on-linux0.sh
+```
+
+This will:
+1. Sync source code to linux-0
+2. Run the Makefile-based build in nix-shell
+3. Takes 1-2 hours
+
+### Option B: Build Locally
+
+```bash
+./build-wasm-make.sh
+```
+
+Or manually:
+```bash
+nix-shell
+make CABAL=_build/stage0/bin/cabal stage2
+make CABAL=_build/stage0/bin/cabal stage3-wasm32-unknown-wasi
+```
+
+## Build Process
+
+The build happens in two stages:
+
+### Stage 2: Bootstrap Compiler
+```bash
+make CABAL=_build/stage0/bin/cabal stage2
+```
+Builds GHC itself using the GHC 9.8.4 bootstrap compiler from Nix.
+
+**Time:** ~45-60 minutes
+
+### Stage 3: WASM Cross-Compiler
+```bash
+make CABAL=_build/stage0/bin/cabal stage3-wasm32-unknown-wasi
+```
+Builds the WASM cross-compiler using stage2.
+
+**Time:** ~30-45 minutes
+
+## Build Output
+
+The WASM cross-compiler will be at:
+```
+_build/stage3/bin/wasm32-unknown-wasi-ghc
+```
+
+## Testing
+
+```bash
+# Create a simple program
+echo 'main = putStrLn "Hello from WASM!"' > hello.hs
+
+# Compile to WASM (in nix-shell)
+nix-shell --run '_build/stage3/bin/wasm32-unknown-wasi-ghc hello.hs -o hello.wasm'
+
+# Run with wasmtime
+wasmtime hello.wasm
+```
+
+## What Nix Provides
+
+The `shell.nix` environment includes:
+
+- **GHC 9.8.4** - Bootstrap compiler
+- **Cabal, Happy, Alex** - Build tools
+- **LLVM 18 with Clang** - WASM backend support
+- **WASM Cross-Compiler** - From `pkgsCross.wasi32` (Clang 19 with wasilibc)
+- **Build essentials** - autoconf, automake, python3, etc.
+
+**100% from nixpkgs** - No ghcup, no manual downloads, no external WASM SDK!
+
+## Build Times
+
+- **Stage 2**: ~45-60 minutes (full GHC bootstrap)
+- **Stage 3**: ~30-45 minutes (WASM cross-compiler)
+- **Total**: ~1.5-2 hours on linux-0
+
+## About This Implementation
+
+This build uses:
+- **Makefile-based build system** (NOT Hadrian - Hadrian is wrong!)
+- **100% Nix** (NO ghcup) - provides GHC bootstrap, build tools, and LLVM
+- **wasi-sdk 24.0** - official WASM/WASI SDK from WebAssembly/wasi-sdk
+- **Alternative __PIC__ fix** - removes guards from `rts/wasm/Wasm.S`
+- **Native adjustors** - uses native code instead of libffi (libffi is Emscripten-only)
+
+Our changes implement proper on-demand GlobalRegs compilation as an
+alternative to the patch in https://github.com/stable-haskell/ghc/issues/134.
+
+### WASM Toolchain: wasi-sdk 24.0
+
+The shell.nix automatically downloads and sets up wasi-sdk 24.0 which provides:
+- `clang` 18.1.8 targeting wasm32-unknown-wasi
+- Integrated `wasi-libc` (WASI C library with sysroot)
+- LLVM binutils (`llvm-ar`, `llvm-ranlib`, `wasm-ld`)
+
+**Why not nixpkgs pkgsCross.wasi32?**
+- nixpkgs LLD 19.1.7 has a bug rejecting valid WebAssembly binary modules
+- See error: "archive member '*.c.obj' is neither ET_REL nor LLVM bitcode"
+- wasi-sdk 24.0 has a working linker and is the upstream recommended toolchain
+
+The shell.nix creates wrapper scripts (not symlinks) for all WASM tools to ensure
+LD_LIBRARY_PATH propagates through Cabal subprocesses to find libLLVM.so.18.1-wasi-sdk.
+
+### libffi Incompatibility with WASI
+
+**Important:** libffi's WebAssembly support is Emscripten-only, NOT compatible with WASI.
+
+The RTS is patched to exclude wasm32 from the libffi-clib dependency (see rts.cabal
+lines 596 and 794). The build uses native adjustors instead via the
+`--disable-libffi-adjustors` flag passed to ghc-toolchain-bin.
+
+See: https://github.com/libffi/libffi/blob/master/src/wasm/ffi.c#L33
+(requires emscripten/emscripten.h, not available in WASI)
+
+## Troubleshooting
+
+### "nix-shell: command not found"
+
+Install Nix:
+```bash
+curl -L https://nixos.org/nix/install | sh
+```
+
+### "make: *** No rule to make target 'stage3-wasm32-unknown-wasi'"
+
+Make sure you're on the `stable-ghc-9.14` branch with the latest Makefile.
+
+### Build fails in stage2
+
+Make sure you're inside `nix-shell` which provides all necessary tools.
+
+### LLVM/Clang version issues
+
+The shell.nix uses LLVM 18. If you need a different version, edit shell.nix.
+
+## Interactive Development
+
+For development work:
+
+```bash
+# Enter the nix shell
+nix-shell
+
+# Now you have all tools available
+ghc --version
+cabal --version
+llvm-config --version
+
+# Build stages manually
+make CABAL=_build/stage0/bin/cabal stage2
+make CABAL=_build/stage0/bin/cabal stage3-wasm32-unknown-wasi
+```
+
+## References
+
+- Build instructions: https://github.com/stable-haskell/ghc/issues/134
+- Stable Haskell GHC: https://github.com/stable-haskell/ghc
diff --git a/build-wasm-make.sh b/build-wasm-make.sh
new file mode 100755
index 000000000000..555fc78c8583
--- /dev/null
+++ b/build-wasm-make.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+# Build WASM cross-compiler using Makefile (NOT Hadrian!)
+# Based on https://github.com/stable-haskell/ghc/issues/134
+#
+# This script uses NIX flake for ALL dependencies (NO ghcup!)
+# 1. Nix provides GHC bootstrap compiler and tools
+# 2. Nix provides LLVM/Clang with WASM support (pinned via ghc-wasm-meta)
+# 3. Builds stage2 (bootstrap native compiler)
+# 4. Builds stage3-wasm32-unknown-wasi (cross-compiler + WASM libraries)
+#
+# The Makefile default CABAL=_build/stage0/bin/cabal is used (no override needed):
+# stage0 cabal is built automatically from the repo's pinned Cabal source.
+
+set -euo pipefail
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+info() { echo -e "${BLUE}ℹ${NC} $*"; }
+success() { echo -e "${GREEN}✓${NC} $*"; }
+error() { echo -e "${RED}✗${NC} $*"; }
+warn() { echo -e "${YELLOW}⚠${NC} $*"; }
+
+# Check we're in the right place
+if [ ! -f "Makefile" ]; then
+ error "Not in GHC source directory (Makefile not found)"
+ exit 1
+fi
+
+info "Building WASM cross-compiler via Makefile"
+info "Using Nix flake for all dependencies (NO ghcup)"
+info ""
+
+# Check for nix
+if ! command -v nix >/dev/null 2>&1; then
+ error "nix not found. Please install Nix first."
+ exit 1
+fi
+
+# Build stage2: native bootstrap compiler
+# Uses Makefile default CABAL=_build/stage0/bin/cabal (stage0 built automatically)
+info "Step 1/2: Building stage2 (bootstrap compiler)"
+info "This builds GHC itself using the bootstrap compiler..."
+info "Expected time: 45-60 minutes"
+info ""
+
+nix develop . -c make stage2
+
+echo ""
+success "Stage2 build complete!"
+echo ""
+
+# Build stage3-wasm32-unknown-wasi: cross-compiler + WASM libraries
+info "Step 2/2: Building stage3-wasm32-unknown-wasi (WASM cross-compiler)"
+info "This builds the WASM cross-compiler using stage2..."
+info "Expected time: 30-45 minutes"
+info ""
+
+nix develop . -c make stage3-wasm32-unknown-wasi
+
+echo ""
+success "WASM cross-compiler build complete!"
+echo ""
+echo "Cross-compiler location:"
+echo " _build/stage3/bin/wasm32-unknown-wasi-ghc"
+echo ""
+echo "To test:"
+echo " printf 'main = putStrLn \"Hello WASM\"\n' > hello.hs"
+echo " nix develop . -c _build/stage3/bin/wasm32-unknown-wasi-ghc hello.hs -o hello.wasm"
diff --git a/build-wasm-on-linux0.sh b/build-wasm-on-linux0.sh
new file mode 100755
index 000000000000..073fc48b485a
--- /dev/null
+++ b/build-wasm-on-linux0.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# Helper script to build WASM cross-compiler on linux-0
+# Syncs the repo (source only, no _build/), then runs the Makefile-based build.
+#
+# Usage: ./build-wasm-on-linux0.sh
+#
+# Prerequisites on linux-0:
+# 1. Nix must be installed with flake support
+#
+# Note: This script handles the case where the local repo is a git worktree
+# by re-initializing a fresh git repo on the remote after syncing (nix develop
+# requires the directory to be a proper git repository to evaluate flake.nix).
+
+set -euo pipefail
+
+REMOTE_HOST="${REMOTE_HOST:-x86_64-linux-0.lan}"
+REMOTE_USER="${REMOTE_USER:-${USER}}"
+REMOTE_DIR="${REMOTE_DIR:-/tmp/ghc-wasm-build}"
+LOCAL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Colors
+BLUE='\033[0;34m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+info() { echo -e "${BLUE}ℹ${NC} $*"; }
+success() { echo -e "${GREEN}✓${NC} $*"; }
+warn() { echo -e "${YELLOW}⚠${NC} $*"; }
+
+info "Syncing GHC source to ${REMOTE_HOST}:${REMOTE_DIR}..."
+ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_DIR}"
+
+# Sync the source (excluding build artifacts and git metadata).
+# We exclude .git because when building from a git worktree, .git is a FILE
+# pointing to the main repo on the local machine — unusable on the remote.
+rsync -avz --delete \
+ --exclude='_build/' \
+ --exclude='.git' \
+ --exclude='.git/' \
+ --exclude='cabal-cache/' \
+ --exclude='.nix-wasm-bin/' \
+ --exclude='*.o' \
+ --exclude='*.hi' \
+ "${LOCAL_DIR}/" \
+ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
+
+# Re-initialize git on the remote.
+# nix develop requires the directory to be a valid git repo to evaluate flake.nix
+# (it uses builtins.fetchGit / git+file:// URL for the local flake).
+# After rsync (which excludes .git), we create a fresh repo with all files staged.
+info "Re-initializing git repository on remote (needed for nix develop)..."
+ssh "${REMOTE_USER}@${REMOTE_HOST}" "cd ${REMOTE_DIR} && git init -q && git add -A && git commit -q -m 'build: local snapshot' 2>/dev/null || true"
+
+success "Sync complete"
+
+info "Starting WASM cross-compiler build on linux-0..."
+info "Using Makefile-based build (stage2 → stage3-wasm32-unknown-wasi)"
+warn "This will take 1-2 hours depending on the machine"
+echo ""
+
+# Run the build interactively so progress is visible
+ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "cd ${REMOTE_DIR} && ./build-wasm-make.sh"
+
+success "Build completed!"
+info "The WASM cross-compiler is available at:"
+echo " ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/_build/stage3/bin/wasm32-unknown-wasi-ghc"
diff --git a/cabal.project.stage1 b/cabal.project.stage1
index eeaf3da3db8d..27ce9669a696 100644
--- a/cabal.project.stage1
+++ b/cabal.project.stage1
@@ -1,6 +1,15 @@
-- Configuration common to all stages
import: cabal.project.common
+-- Hermetic build: resolve only from the explicitly-listed packages (+ the
+-- bootstrap compiler's installed packages), never Hackage. stage2/stage3
+-- already set this, but stage1 did not — so stage1's build-tools (happy/alex
+-- for the compiler's Parser.y/Lexer.x) floated to Hackage's newest. That was
+-- latent until happy-2.1.7 was published, after which stage1 built
+-- happy-lib-2.1.7 and the resulting parser code broke the build
+-- (genprimopcode GHC-44432: happyDoParse vs the pinned happy template).
+active-repositories: :none
+
packages:
-- NOTE: we need rts-headers, because the _newly_ built compiler depends
-- on these potentially _new_ headers, we must not rely on those from
@@ -40,6 +49,15 @@ packages:
https://hackage.haskell.org/package/semaphore-compat-2.0.1/semaphore-compat-2.0.1.tar.gz
https://hackage.haskell.org/package/unix-2.8.8.0/unix-2.8.8.0.tar.gz
https://hackage.haskell.org/package/Win32-2.14.2.1/Win32-2.14.2.1.tar.gz
+
+ -- Build-tools for the compiler's Parser.y / Lexer.x, plus hpc (a dep of ghc
+ -- whose bootstrap-installed version wants the old directory). Pinned so the
+ -- `active-repositories: :none` above resolves them to the same vetted
+ -- versions as stage2/stage3 instead of floating to Hackage's newest.
+ https://hackage.haskell.org/package/alex-3.5.2.0/alex-3.5.2.0.tar.gz
+ https://hackage.haskell.org/package/happy-2.1.5/happy-2.1.5.tar.gz
+ https://hackage.haskell.org/package/happy-lib-2.1.5/happy-lib-2.1.5.tar.gz
+ https://hackage.haskell.org/package/hpc-0.7.0.2/hpc-0.7.0.2.tar.gz
-- hsc2hs: see source-repository-package below
source-repository-package
diff --git a/cabal.project.stage3 b/cabal.project.stage3
index b241b89ed93e..33ecdc20638d 100644
--- a/cabal.project.stage3
+++ b/cabal.project.stage3
@@ -1,6 +1,28 @@
-- Configuration common to all stages
import: cabal.project.common
+-- WASM target libraries are built shared so end-user TH/GHCi works at
+-- runtime via dyld.mjs (miso, jsaddle, aeson, …): the wasm libs then ship
+-- .dyn_hi + .so. Scoped to `arch(wasm32)` so it applies ONLY when cabal
+-- builds against the wasm32 --with-compiler — the native build-compiler
+-- packages (happy-lib, alex, Setup.hs) and the JS target are unaffected
+-- (JS can't produce .so via emcc/wasm-ld: `wasm-ld: unknown argument: -h`).
+--
+-- This was previously generated by ./configure into
+-- cabal.project.stage3.settings from the stage2 accumulation machinery
+-- (m4/accumulate.m4 + @ALL_PACKAGES@). That machinery was removed in
+-- favour of explicit cabal.project.stage2.{common,static,dynamic}; stage3
+-- has only the one wasm-dynamic variant (the Cross: MULTI build always
+-- runs DYNAMIC=1), so we inline it here instead of regenerating it.
+-- The per-target `settings` dials (target is dynamic / ships dynamic
+-- libraries = YES for wasm) make GHC's link pipeline accept this.
+if arch(wasm32)
+ package *
+ shared: True
+ executable-dynamic: True
+ constraints:
+ rts +dynamic
+
-- Disable Hackage, we explicitly include the packages we need.
active-repositories: :none
@@ -48,8 +70,8 @@ packages:
https://hackage.haskell.org/package/os-string-2.0.8/os-string-2.0.8.tar.gz
https://hackage.haskell.org/package/parsec-3.1.18.0/parsec-3.1.18.0.tar.gz
https://hackage.haskell.org/package/pretty-1.1.3.6/pretty-1.1.3.6.tar.gz
- https://hackage.haskell.org/package/process-1.6.29.0/process-1.6.29.0.tar.gz
- https://hackage.haskell.org/package/semaphore-compat-2.0.1/semaphore-compat-2.0.1.tar.gz
+ https://hackage.haskell.org/package/process-1.6.26.1/process-1.6.26.1.tar.gz
+ https://hackage.haskell.org/package/semaphore-compat-1.0.0/semaphore-compat-1.0.0.tar.gz
https://hackage.haskell.org/package/stm-2.5.3.1/stm-2.5.3.1.tar.gz
https://hackage.haskell.org/package/text-2.1.3/text-2.1.3.tar.gz
https://hackage.haskell.org/package/time-1.15/time-1.15.tar.gz
@@ -65,6 +87,11 @@ packages:
https://hackage.haskell.org/package/happy-lib-2.1.5/happy-lib-2.1.5.tar.gz
source-repository-package
+ -- See cabal.project.stage0 for what this Cabal SHA pins (rpath patch
+ -- for darwin LC_RPATH leak; all stages share the same host-side Cabal).
+ -- The rpath patch only touches host-linker codepaths (wasm-ld and the
+ -- JS backend don't use rpaths) so it's a no-op for stage3's cross
+ -- outputs, but unifying the pin avoids two-Cabal maintenance overhead.
type: git
location: https://github.com/stable-haskell/Cabal.git
-- See cabal.project.stage0 for why this SHA (post-split host:*/build:*).
@@ -146,16 +173,17 @@ constraints:
-- Package level configuration
--
+-- NOTE: `shared` / `executable-dynamic` for the wasm target are set in the
+-- `if arch(wasm32)` block at the top of this file (not here), so they apply
+-- only to wasm and leave the native build-compiler / JS target packages
+-- vanilla.
package *
- shared: False
- executable-dynamic: False
-
-- library-for-ghci will cause a `ld -r` call to create pre-linked objects.
-- This helps the internal linker when trying to link (.a) archives with massive
-- displacements. In that case the displacement can be in excess of what
-- is possible to relocate, and the linker fails. Pre-linking the objects helps with
-- this (and linking performance, as we need to process fewer relocations).
- --
+ --
-- For some cross targets like JavaScript, this will fail as the $LD -r invocation
-- might not be properly supported.
--
@@ -173,13 +201,15 @@ package *
package libffi-clib
ghc-options: -no-rts
--- WASM/WASI-specific overrides:
--- - shared: True is required for GHCi/TH via dyld.mjs
--- - +use-system-libffi is already set unconditionally on package rts above,
--- but we keep this block for clarity and future conditional changes.
-if os(wasi)
- package *
- shared: True
+-- WASM/WASI shared-library overrides live in the `if arch(wasm32)` block
+-- near the top of this file (shared: True + executable-dynamic: True +
+-- rts +dynamic). An earlier (2026-05-26) attempt failed at the
+-- ghc-internal link step ("[GHC-74335] -dynamic is ignored when linking
+-- binaries on WASM"); it was unblocked by the per-target `settings` dials
+-- (target is dynamic / ships dynamic libraries = YES for wasm) plus
+-- disabling the wasm makeDynFlagsConsistent rule — not by a cabal tweak.
+-- With that in place end-user TH-heavy apps (miso, jsaddle, aeson, …)
+-- build against this bindist.
package ghc
flags: +build-tool-depends +internal-interpreter
diff --git a/compiler/GHC/Driver/Session.hs b/compiler/GHC/Driver/Session.hs
index 4b303d6e3787..385a02e04375 100644
--- a/compiler/GHC/Driver/Session.hs
+++ b/compiler/GHC/Driver/Session.hs
@@ -132,6 +132,10 @@ module GHC.Driver.Session (
sGhcWithInterpreter,
sLibFFI,
sTargetRTSLinkerOnlySupportsSharedLibs,
+ sTargetIsDynamic,
+ sTargetShipsDynLibs,
+ sTargetIsProfiled,
+ sTargetShipsProfLibs,
GhcNameVersion(..),
FileSettings(..),
PlatformMisc(..),
@@ -3555,8 +3559,16 @@ compilerInfo dflags
("Have native code generator", showBool $ platformNcgSupported platform),
("target has RTS linker", showBool $ platformHasRTSLinker platform),
("Target default backend", show $ platformDefaultBackend platform),
- -- Whether or not we support @-dynamic-too@
- ("Support dynamic-too", showBool $ not isWindows),
+ -- Whether or not we support @-dynamic-too@ for this target.
+ -- Historically `not isWindows` (Windows tooling couldn't do
+ -- it). Now also gated on the per-target `sTargetIsDynamic`
+ -- dial — if the target isn't dynamic-capable, -dynamic-too
+ -- is meaningless. Keep the Windows guard as defence in depth
+ -- for pre-this-patch bindists on Windows that lack the key
+ -- and so default sTargetIsDynamic=True (the AND would
+ -- otherwise regress them).
+ ("Support dynamic-too", showBool $ not isWindows
+ && sTargetIsDynamic (settings dflags)),
-- Whether or not we support the @-j@ flag with @--make@.
("Support parallel --make", "YES"),
-- The semaphore protocol version supported by @-jsem@.
@@ -3578,10 +3590,27 @@ compilerInfo dflags
("Uses package keys", "YES"),
-- Whether or not we support the @-this-unit-id@ flag
("Uses unit IDs", "YES"),
- -- Whether or not GHC was compiled using -dynamic
- ("GHC Dynamic", showBool hostIsDynamic),
- -- Whether or not GHC was compiled using -prof
- ("GHC Profiled", showBool hostIsProfiled),
+ -- Reported as YES iff *both* per-target settings dials say so:
+ -- `target is dynamic` — the GHC for this target
+ -- can produce dynamic output
+ -- `target ships dynamic libraries` — the lib tree actually has
+ -- .dyn_hi / .so artifacts
+ -- cabal-install reads this to decide whether to enable
+ -- @library-dynamic@ by default. The target's per-target settings
+ -- file completely controls this value — no host-RTS dependency,
+ -- so on a multi-target bindist with one shared stage2 GHC binary
+ -- different targets can correctly disagree. Both keys default to
+ -- True if absent (matches pre-this-change behaviour).
+ ("GHC Dynamic", showBool (sTargetIsDynamic (settings dflags)
+ && sTargetShipsDynLibs (settings dflags))),
+ -- Profiling-way analogue of `GHC Dynamic`. Per-target dials
+ -- via `target is profiled` + `target ships profiling libraries`
+ -- settings keys. Drops the historical `hostIsProfiled` RTS-
+ -- baked-in for the same reason the dyn pair did: on a multi-
+ -- target bindist the shared stage2 GHC binary's prof-ness is
+ -- fixed but the lib trees can disagree per target.
+ ("GHC Profiled", showBool (sTargetIsProfiled (settings dflags)
+ && sTargetShipsProfLibs (settings dflags))),
("Debug on", showBool debugIsOn),
("LibDir", topDir dflags),
-- This is always an absolute path, unlike "Relative Global Package DB" which is
@@ -3791,18 +3820,26 @@ makeDynFlagsConsistent dflags
= let warn = "-dynamic is ignored when using -staticlib"
in loop dflags{targetWays_ = removeWay WayDyn (targetWays_ dflags)} warn
-- For the wasm target, when ghc is invoked with -dynamic,
- -- when linking the final .wasm binary we must still ensure
- -- the static archives are selected. Otherwise wasm-ld would
- -- fail to find and link the .so library dependencies. wasm-ld
- -- can link PIC objects into static .wasm binaries fine, so we
- -- only adjust the ways in the final linking step, and only
- -- when linking .wasm binary (which is supposed to be fully
- -- static), not when linking .so shared libraries.
- | LinkExecutable _ <- ghcLink dflags
- , ArchWasm32 <- arch
- , ways dflags `hasWay` WayDyn
- = let warn = "-dynamic is ignored when linking binaries on WASM"
- in loop dflags{targetWays_ = removeWay WayDyn (targetWays_ dflags)} warn
+ -- (DISABLED Phase 6.5, 2026-05-26) — the original rule below was meant
+ -- to strip WayDyn only when linking the final .wasm binary, but the
+ -- default `ghcLink` in `defaultDynFlags` is `LinkExecutable Dynamic`, so
+ -- the rule fired for EVERY --make invocation that didn't override it.
+ -- That broke cabal library builds with `shared:True, library-vanilla:False`
+ -- (the rule stripped WayDyn mid-compile, then GHC looked for vanilla .hi
+ -- files that don't exist since only .dyn_hi was produced).
+ --
+ -- The wasm linker (wasm-ld) silently handles -dynamic without issue —
+ -- wasm binaries are inherently single-file artifacts, and PIC objects
+ -- link fine into them. Removing the strip lets WayDyn flow through to
+ -- the actual link step, where wasm-ld does the right thing.
+ -- (If a future regression surfaces, narrow the rule to fire ONLY in the
+ -- actual link phase, not at flag-consistency time.)
+ --
+ -- | LinkExecutable _ <- ghcLink dflags
+ -- , ArchWasm32 <- arch
+ -- , ways dflags `hasWay` WayDyn
+ -- = let warn = "-dynamic is ignored when linking binaries on WASM"
+ -- in loop dflags{targetWays_ = removeWay WayDyn (targetWays_ dflags)} warn
| LinkInMemory <- ghcLink dflags
, not (gopt Opt_ExternalInterpreter dflags)
diff --git a/compiler/GHC/Linker/Dynamic.hs b/compiler/GHC/Linker/Dynamic.hs
index 9a6346309cb2..f9da15be4fe6 100644
--- a/compiler/GHC/Linker/Dynamic.hs
+++ b/compiler/GHC/Linker/Dynamic.hs
@@ -16,6 +16,7 @@ import GHC.SysTools.Tasks
import GHC.Driver.Config.Linker
import GHC.Driver.Session
+import GHC.Driver.DynFlags (rtsWayUnitId)
import GHC.Unit.Env
import GHC.Unit.Types
@@ -52,7 +53,27 @@ linkDynLib logger tmpfs dflags0 unit_env o_files dep_packages
verbFlags = getVerbFlags dflags
o_file = outputFile_ dflags
- pkgs_with_rts <- mayThrowUnitErr (preloadUnitsInfo' unit_env dep_packages)
+ pkgs_with_rts <- do
+ pkgs0 <- mayThrowUnitErr (preloadUnitsInfo' unit_env dep_packages)
+ -- On wasm32, every Haskell .so must declare the current way's rts in
+ -- its `dylink.0/needed_dynlibs` section, otherwise the runtime dyld
+ -- can load it ahead of rts and the .so's `_initialize` references
+ -- unresolved rts symbols (e.g. `registerForeignExports`) — see
+ -- ghc-internal, which is built with `-no-rts` to bypass mkUnitState's
+ -- sanity check (compiler/GHC/Unit/State.hs:1652) but consequently has
+ -- cabal omit rts from its --depends list. wasm-ld populates
+ -- needed_dynlibs from the resolved `-l` flags, so we must inject the
+ -- current way's rts here whenever it isn't already present, even if
+ -- `-no-rts` is in effect. Other targets either link rts statically at
+ -- exe-link time or rely on host symbol resolution; on wasm32 the .so
+ -- itself carries the dependency.
+ let rts_uid = rtsWayUnitId dflags
+ pure $ case arch of
+ ArchWasm32
+ | not (any ((== rts_uid) . unitId) pkgs0)
+ , Just rts_info <- lookupUnitId (ue_units unit_env) rts_uid
+ -> rts_info : pkgs0
+ _ -> pkgs0
let pkg_lib_paths = collectLibraryDirs (ways dflags) pkgs_with_rts
let pkg_lib_path_opts = concatMap get_pkg_lib_path_opts pkg_lib_paths
@@ -84,7 +105,10 @@ linkDynLib logger tmpfs dflags0 unit_env o_files dep_packages
-- * if -flink-rts is used, we link with the rts.
--
-- * on wasm we need to ensure libHSrts*.so is listed in
- -- WASM_DYLINK_NEEDED, otherwise dyld can't load it.
+ -- WASM_DYLINK_NEEDED, otherwise dyld can't load it. We force the
+ -- current way's rts into `pkgs_with_rts` above so that even .so
+ -- files whose package was built with `-no-rts` (e.g. ghc-internal)
+ -- still declare the rts dependency.
--
--
let pkgs_without_rts = filter ((/= PackageName (fsLit "rts")) . unitPackageName) pkgs_with_rts
diff --git a/compiler/GHC/Platform.hs b/compiler/GHC/Platform.hs
index 5375c243c6ef..972f3fb23fc1 100644
--- a/compiler/GHC/Platform.hs
+++ b/compiler/GHC/Platform.hs
@@ -291,6 +291,33 @@ data PlatformMisc = PlatformMisc
, platformMisc_libFFI :: Bool
, platformMisc_llvmTarget :: String
, platformMisc_targetRTSLinkerOnlySupportsSharedLibs :: Bool
+ -- | Is the GHC for this target capable of producing dynamic
+ -- output (i.e. can it honour @-dynamic@ / @-dynamic-too@)?
+ -- Per-target settings key @"target is dynamic"@ in
+ -- @lib/targets/\/lib/settings@. On a multi-target bindist
+ -- the shared stage2 GHC binary's RTS-baked-in dynamic-ness is
+ -- not a per-target proxy — different targets in one binary may
+ -- need to disagree (e.g. a JS target whose iserv runs vanilla
+ -- and whose lib tree has no dyn artifacts). Combined with
+ -- 'platformMisc_targetShipsDynLibs' to drive @ghc --info@'s
+ -- @GHC Dynamic@ value, which cabal-install reads.
+ , platformMisc_targetIsDynamic :: Bool
+ -- | Does the target's installed library tree ship @.dyn_hi@ /
+ -- @.so@ files? Per-target settings key
+ -- @"target ships dynamic libraries"@. Set independently of
+ -- 'platformMisc_targetIsDynamic' so a target can be dynamic-
+ -- capable but not currently ship dyn artifacts (or vice versa).
+ , platformMisc_targetShipsDynLibs :: Bool
+ -- | Profiling-way analogue of 'platformMisc_targetIsDynamic'.
+ -- Per-target settings key @"target is profiled"@. Drives
+ -- @ghc --info@'s @GHC Profiled@ — cabal-install reads that to
+ -- decide whether to enable @library-profiling@.
+ , platformMisc_targetIsProfiled :: Bool
+ -- | Profiling-way analogue of 'platformMisc_targetShipsDynLibs'.
+ -- Per-target settings key @"target ships profiling libraries"@.
+ -- Combined with 'platformMisc_targetIsProfiled' for the
+ -- @GHC Profiled@ report.
+ , platformMisc_targetShipsProfLibs :: Bool
}
platformSOName :: Platform -> FilePath -> FilePath
diff --git a/compiler/GHC/Runtime/Interpreter/Wasm.hs b/compiler/GHC/Runtime/Interpreter/Wasm.hs
index 645aba927181..13a73042397e 100644
--- a/compiler/GHC/Runtime/Interpreter/Wasm.hs
+++ b/compiler/GHC/Runtime/Interpreter/Wasm.hs
@@ -10,11 +10,13 @@ import GHC.Runtime.Interpreter.Types
#if !defined(mingw32_HOST_OS)
import Control.Concurrent.MVar
+import Control.Exception (throwIO)
import Data.Maybe
import GHC.Data.FastString
import qualified GHC.Data.ShortText as ST
import GHC.Platform
import GHC.Unit
+import GHC.Utils.Panic (GhcException (InstallationError))
import GHCi.Message
import System.Directory
import System.Environment.Blank
@@ -34,6 +36,21 @@ spawnWasmInterp :: WasmInterpConfig -> IO (ExtInterpInstance ())
-- See Note [The Wasm Dynamic Linker] for details
spawnWasmInterp WasmInterpConfig {..} = do
+ -- Pre-flight: dyld.mjs (the wasm dynamic linker) has a
+ -- `#!/usr/bin/env -S node` shebang and needs `node` on $PATH. Without
+ -- it the iserv child exits with code 127, which the generic
+ -- "External interpreter terminated (127)" message in
+ -- Process.hs:129 doesn't explain. Emit a clear, actionable error
+ -- here instead.
+ mb_node <- findExecutable "node"
+ case mb_node of
+ Just _ -> pure ()
+ Nothing -> throwIO . InstallationError $
+ "Template Haskell evaluation on the wasm32 backend requires `node` "
+ ++ "(Node.js) on PATH. The wasm dynamic linker shim ("
+ ++ wasmInterpDyLD
+ ++ ") is a Node script. Install Node.js and ensure `node` is "
+ ++ "reachable, then retry."
let Just ghci_unit_id =
lookupPackageName
wasmInterpUnitState
diff --git a/compiler/GHC/Settings.hs b/compiler/GHC/Settings.hs
index b8ec884a94e4..db5653794314 100644
--- a/compiler/GHC/Settings.hs
+++ b/compiler/GHC/Settings.hs
@@ -67,6 +67,10 @@ module GHC.Settings
, sGhcWithInterpreter
, sLibFFI
, sTargetRTSLinkerOnlySupportsSharedLibs
+ , sTargetIsDynamic
+ , sTargetShipsDynLibs
+ , sTargetIsProfiled
+ , sTargetShipsProfLibs
) where
import GHC.Prelude
@@ -314,3 +318,26 @@ sLibFFI = platformMisc_libFFI . sPlatformMisc
sTargetRTSLinkerOnlySupportsSharedLibs :: Settings -> Bool
sTargetRTSLinkerOnlySupportsSharedLibs = platformMisc_targetRTSLinkerOnlySupportsSharedLibs . sPlatformMisc
+
+-- | Is the GHC for this target capable of producing dynamic output?
+-- Read from the per-target settings file key @"target is dynamic"@.
+-- Combined with 'sTargetShipsDynLibs' to drive @ghc --info@'s
+-- @GHC Dynamic@ value — the target's settings file completely
+-- controls that, no host-RTS dependency.
+sTargetIsDynamic :: Settings -> Bool
+sTargetIsDynamic = platformMisc_targetIsDynamic . sPlatformMisc
+
+-- | Does this target's installed library tree ship .dyn_hi / .so files?
+-- Per-target settings key @"target ships dynamic libraries"@.
+sTargetShipsDynLibs :: Settings -> Bool
+sTargetShipsDynLibs = platformMisc_targetShipsDynLibs . sPlatformMisc
+
+-- | Profiling-way analogue of 'sTargetIsDynamic'. Per-target settings
+-- key @"target is profiled"@.
+sTargetIsProfiled :: Settings -> Bool
+sTargetIsProfiled = platformMisc_targetIsProfiled . sPlatformMisc
+
+-- | Profiling-way analogue of 'sTargetShipsDynLibs'. Per-target settings
+-- key @"target ships profiling libraries"@.
+sTargetShipsProfLibs :: Settings -> Bool
+sTargetShipsProfLibs = platformMisc_targetShipsProfLibs . sPlatformMisc
diff --git a/compiler/GHC/Settings/IO.hs b/compiler/GHC/Settings/IO.hs
index 63964ff02ff8..2ede21587c6c 100644
--- a/compiler/GHC/Settings/IO.hs
+++ b/compiler/GHC/Settings/IO.hs
@@ -185,6 +185,34 @@ initSettings top_dir = do
ghcWithInterpreter <- getBooleanSetting "Use interpreter"
useLibFFI <- getBooleanSetting "Use LibFFI"
+ -- Per-target dial #1: is the GHC for THIS target capable of
+ -- producing dynamic output (i.e. honouring -dynamic /
+ -- -dynamic-too)? On a multi-target bindist with one shared stage2
+ -- GHC binary, this can't be derived from the binary's compile-
+ -- time `hostIsDynamic`. Default True for backward compatibility
+ -- with older bindist settings files that predate the key.
+ targetIsDynamic <- either (const $ pure True) pure $
+ getRawBooleanSetting settingsFile mySettings "target is dynamic"
+
+ -- Per-target dial #2: does this target's installed lib tree
+ -- actually ship .dyn_hi / .so files? Independent of
+ -- `target is dynamic` so a dynamic-capable target can still
+ -- truthfully say it doesn't ship artifacts (e.g. a slimmed
+ -- bindist). cabal-install combines both via GHC Dynamic to
+ -- decide whether to enable library-dynamic by default.
+ targetShipsDynLibs <- either (const $ pure True) pure $
+ getRawBooleanSetting settingsFile mySettings "target ships dynamic libraries"
+
+ -- Profiling-way analogues of the two dyn dials above. Drive
+ -- `GHC Profiled` the same way (sTargetIsProfiled && sTargetShipsProfLibs).
+ -- Defaults to True for backward compatibility with bindists that
+ -- predate the keys — matches the historical hostIsProfiled
+ -- behaviour when GHC was prof-built.
+ targetIsProfiled <- either (const $ pure True) pure $
+ getRawBooleanSetting settingsFile mySettings "target is profiled"
+ targetShipsProfLibs <- either (const $ pure True) pure $
+ getRawBooleanSetting settingsFile mySettings "target ships profiling libraries"
+
baseUnitId <- getSetting_raw "base unit-id"
return $ Settings
@@ -267,6 +295,10 @@ initSettings top_dir = do
, platformMisc_libFFI = useLibFFI
, platformMisc_llvmTarget = llvmTarget
, platformMisc_targetRTSLinkerOnlySupportsSharedLibs = targetRTSLinkerOnlySupportsSharedLibs
+ , platformMisc_targetIsDynamic = targetIsDynamic
+ , platformMisc_targetShipsDynLibs = targetShipsDynLibs
+ , platformMisc_targetIsProfiled = targetIsProfiled
+ , platformMisc_targetShipsProfLibs = targetShipsProfLibs
}
, sRawSettings = settingsList
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 000000000000..47c63c5c0561
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,130 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "flake-utils_2": {
+ "inputs": {
+ "systems": "systems_2"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "ghc-wasm-meta": {
+ "inputs": {
+ "flake-utils": "flake-utils_2",
+ "nixpkgs": "nixpkgs"
+ },
+ "locked": {
+ "lastModified": 1771125696,
+ "narHash": "sha256-+90XoM7PUfgD7383ki/aHC0cLKS32XVnYgI672pegcY=",
+ "ref": "refs/heads/master",
+ "rev": "4e1f900e9933966634bc2e29dbeb81d09ce36727",
+ "revCount": 514,
+ "type": "git",
+ "url": "https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta.git"
+ },
+ "original": {
+ "type": "git",
+ "url": "https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta.git"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1770770419,
+ "narHash": "sha256-iKZMkr6Cm9JzWlRYW/VPoL0A9jVKtZYiU4zSrVeetIs=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "6c5e707c6b5339359a9a9e215c5e66d6d802fd7a",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-25.11",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_2": {
+ "locked": {
+ "lastModified": 1771369470,
+ "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "0182a361324364ae3f436a63005877674cf45efb",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "ghc-wasm-meta": "ghc-wasm-meta",
+ "nixpkgs": "nixpkgs_2"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ },
+ "systems_2": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 000000000000..837979fac389
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,150 @@
+{
+ description = "GHC WASM Cross-Compiler Build Environment";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ ghc-wasm-meta.url = "git+https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta.git";
+ };
+
+ outputs = { self, nixpkgs, flake-utils, ghc-wasm-meta }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+
+ # Use wasi-sdk from ghc-wasm-meta with libffi-wasm pre-integrated
+ wasi-sdk = ghc-wasm-meta.packages.${system}.wasi-sdk;
+
+ in {
+ devShells.default = pkgs.mkShell {
+ buildInputs = with pkgs; [
+ # Bootstrap GHC compiler
+ haskell.compiler.ghc984
+ haskell.packages.ghc984.cabal-install
+ haskell.packages.ghc984.happy
+ haskell.packages.ghc984.alex
+
+ # Build tools
+ autoconf
+ automake
+ python3
+ m4
+
+ # For WASM - llvm and clang with wasm support (native tools)
+ llvm_18
+ clang_18
+
+ # Additional tools
+ git
+ gnumake
+ which
+ curl
+
+ # NOTE: wasi-sdk is intentionally NOT in buildInputs to prevent nix
+ # from setting CC/LD/AR to the WASM cross-compiler globally.
+ # It's accessed exclusively via $WASI_SDK_DIR set in shellHook.
+ ];
+
+ shellHook = ''
+ echo "GHC WASM Cross-Compiler Build Environment"
+ echo "=========================================="
+ echo ""
+ echo "GHC version: $(ghc --version)"
+ echo "Cabal version: $(cabal --version | head -1)"
+ echo "LLVM version: $(llvm-config --version)"
+ echo "Clang version: $(clang --version | head -1)"
+ echo ""
+
+ # wasi-sdk from ghc-wasm-meta includes libffi-wasm
+ export WASI_SDK_DIR="${wasi-sdk}"
+
+ # Wasi sysroot paths for WASM RTS compilation.
+ # Passed as --extra-lib-dirs / --extra-include-dirs to cabal.
+ export WASM_EXTRA_LIB_DIRS="${wasi-sdk}/share/wasi-sysroot/lib/wasm32-unknown-wasi"
+ export WASM_EXTRA_INCLUDE_DIRS="${wasi-sdk}/share/wasi-sysroot/include"
+
+ # Create short-name wrappers for WASM toolchain
+ # GHC Makefile expects wasm32-unknown-wasi-* but wasi-sdk provides full names
+ WASM_BIN_DIR="$PWD/.nix-wasm-bin"
+ mkdir -p "$WASM_BIN_DIR"
+
+ # Create wrapper scripts for WASM toolchain
+ cat > "$WASM_BIN_DIR/wasm32-unknown-wasi-clang" <<'EOF'
+#!/bin/sh
+exec "${wasi-sdk}/bin/clang" "$@"
+EOF
+ chmod +x "$WASM_BIN_DIR/wasm32-unknown-wasi-clang"
+
+ # GHC's WASM LLVM backend uses llc/opt/llvm-as to compile LLVM IR to WASM
+ # assembly. We must use wasi-sdk's LLVM tools (version 21) since they match
+ # the assembler (clang 21). Using nixpkgs' LLVM 18 tools with clang 21's
+ # assembler causes errors: ".size directive ignored for function symbols"
+ cat > "$WASM_BIN_DIR/llc" <<'EOF'
+#!/bin/sh
+exec "${wasi-sdk}/bin/llc" "$@"
+EOF
+ chmod +x "$WASM_BIN_DIR/llc"
+
+ cat > "$WASM_BIN_DIR/opt" <<'EOF'
+#!/bin/sh
+exec "${wasi-sdk}/bin/opt" "$@"
+EOF
+ chmod +x "$WASM_BIN_DIR/opt"
+
+ cat > "$WASM_BIN_DIR/llvm-as" <<'EOF'
+#!/bin/sh
+exec "${wasi-sdk}/bin/llvm-as" "$@"
+EOF
+ chmod +x "$WASM_BIN_DIR/llvm-as"
+
+ cat > "$WASM_BIN_DIR/wasm32-unknown-wasi-clang++" <<'EOF'
+#!/bin/sh
+exec "${wasi-sdk}/bin/clang++" "$@"
+EOF
+ chmod +x "$WASM_BIN_DIR/wasm32-unknown-wasi-clang++"
+
+ cat > "$WASM_BIN_DIR/wasm-ld" <<'EOF'
+#!/bin/sh
+exec "${wasi-sdk}/bin/wasm-ld" "$@"
+EOF
+ chmod +x "$WASM_BIN_DIR/wasm-ld"
+
+ # NOTE: Do NOT create an 'ld' wrapper here.
+ # ghc-toolchain-bin auto-detects the native linker for stage1 settings.
+ # If 'ld' is the WASM linker, it breaks 'ld -r' tests for native compilation.
+ # The WASM settings file explicitly uses '--ld wasm-ld', not 'ld'.
+
+ cat > "$WASM_BIN_DIR/wasm32-unknown-wasi-ar" <<'EOF'
+#!/bin/sh
+exec "${wasi-sdk}/bin/llvm-ar" "$@"
+EOF
+ chmod +x "$WASM_BIN_DIR/wasm32-unknown-wasi-ar"
+
+ cat > "$WASM_BIN_DIR/wasm32-unknown-wasi-ranlib" <<'EOF'
+#!/bin/sh
+exec "${wasi-sdk}/bin/llvm-ranlib" "$@"
+EOF
+ chmod +x "$WASM_BIN_DIR/wasm32-unknown-wasi-ranlib"
+
+ # Add WASM wrappers to PATH
+ export PATH="$WASM_BIN_DIR:$PATH"
+
+ echo "WASM toolchain from ghc-wasm-meta wasi-sdk:"
+ echo " - wasm32-unknown-wasi-clang (wrapped)"
+ echo " - wasm32-unknown-wasi-clang++ (wrapped)"
+ echo " - wasm-ld (wrapped, native ld is NOT overridden)"
+ echo " - wasm32-unknown-wasi-ar, wasm32-unknown-wasi-ranlib (wrapped)"
+ echo " - llc, opt, llvm-as (wrapped from wasi-sdk LLVM 21)"
+ echo " - Sysroot: ${wasi-sdk}/share/wasi-sysroot"
+ echo " - Includes libffi-wasm pre-integrated"
+ echo ""
+
+ echo "To build WASM cross-compiler:"
+ echo " make stage2"
+ echo " make stage3-wasm32-unknown-wasi"
+ echo ""
+ '';
+ };
+ }
+ );
+}
diff --git a/libraries/ghc-internal/configure.ac b/libraries/ghc-internal/configure.ac
index 4bcf90948884..f6a2931b2677 100644
--- a/libraries/ghc-internal/configure.ac
+++ b/libraries/ghc-internal/configure.ac
@@ -240,8 +240,8 @@ AC_SUBST(GhcGmpVerMi)
AC_SUBST(GhcGmpVerPl)
# Compute offsets/sizes used by jsbits/base.js
-if test "$host" = "javascript-ghcjs"
-then
+case "$host" in
+javascript*)
FP_COMPUTE_OFFSET([OFFSET_STAT_ST_MODE], [stat], [st_mode], [#include ])
FP_COMPUTE_OFFSET([OFFSET_STAT_ST_DEV], [stat], [st_dev], [#include ])
FP_COMPUTE_OFFSET([OFFSET_STAT_ST_UID], [stat], [st_uid], [#include ])
@@ -281,7 +281,8 @@ then
FP_COMPUTE_SIZE([SIZEOF_UTIMBUF_ACTIME], [utimbuf], [actime], [#include ])
FP_COMPUTE_SIZE([SIZEOF_UTIMBUF_MODTIME], [utimbuf], [modtime], [#include ])
AC_CHECK_SIZEOF([struct utimbuf], [], [#include ])
-fi
+ ;;
+esac
# map standard C types and ISO types to Haskell types
FPTOOLS_CHECK_HTYPE(char)
diff --git a/lode/draft-ghcup-multi-target-0.1.0.yaml b/lode/draft-ghcup-multi-target-0.1.0.yaml
new file mode 100644
index 000000000000..2dcc682de7a7
--- /dev/null
+++ b/lode/draft-ghcup-multi-target-0.1.0.yaml
@@ -0,0 +1,137 @@
+---
+# stable-haskell custom ghcup channel — MULTI-TARGET (schema 0.1.0).
+#
+# Distinct from ghcup-wasm.yaml (which uses schema 0.0.9 and has the
+# single-target wasm32-wasi-9.14.0.stable.X entries). This channel is
+# for the multi-target bindist that ships native + wasm + JS in one
+# install.
+#
+# Usage:
+# ghcup config add-release-channel \
+# https://stable-haskell.github.io/ghc/ghcup-multi-target-0.1.0.yaml
+# ghcup install ghc multi-9.14.0.stable.0
+# ghcup set ghc multi-9.14.0.stable.0
+#
+# After install, all three targets are on PATH:
+# ghc — native
+# wasm32-unknown-wasi-ghc — wasm cross
+# javascript-unknown-ghcjs-ghc — JS cross
+#
+# Same physical binary; ghc dispatches per-target via argv[0].
+#
+# Schema: ghcup-0.1.0 (Installer DSL). Requires ghcup >= 0.2.5.
+
+toolRequirements: {}
+
+ghcupDownloads:
+ GHC:
+ multi-9.14.0.stable.0:
+ viTags:
+ - LatestPrerelease
+ viChangeLog: https://github.com/stable-haskell/ghc/releases/tag/multi-9.14.0.stable.0
+ viPreInstall: |
+ Multi-target GHC bindist (~700 MB per platform). Requires on PATH:
+ * Node.js >= 22 — wasm + JS dynamic linker shim
+ * wasi-sdk — wasm target C toolchain
+ * emscripten — JS target C toolchain
+
+ Install wasi-sdk via ghc-wasm-meta bootstrap:
+ curl -fsSL https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/raw/master/bootstrap.sh \
+ | FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm sh
+ export PATH="$HOME/.ghc-wasm/wasi-sdk/bin:$PATH"
+
+ Install emscripten:
+ git clone --depth 1 --branch 3.1.74 https://github.com/emscripten-core/emsdk.git
+ cd emsdk && ./emsdk install 3.1.74 && ./emsdk activate 3.1.74
+ source emsdk_env.sh
+
+ viPostInstall: |
+ Multi-target GHC installed. argv[0] dispatch:
+ ghc — native compilation (this host)
+ wasm32-unknown-wasi-ghc — wasm cross
+ javascript-unknown-ghcjs-ghc — JS cross
+
+ Per-target package databases live under
+ lib/ (native)
+ lib/targets/wasm32-unknown-wasi/lib/ (wasm)
+ lib/targets/javascript-unknown-ghcjs/lib/ (JS)
+ Each is auto-recached on first use by ghc-pkg (mtime-based) or
+ eagerly by relocate.sh.
+
+ Run `ghcup set ghc multi-9.14.0.stable.0` to activate the
+ unversioned ghc / wasm32-…-ghc / javascript-…-ghc symlinks.
+
+ viArch:
+ A_64:
+ Linux_UnknownLinux:
+ unknown_versioning:
+ dlUri: https://github.com/stable-haskell/ghc/releases/download/multi-9.14.0.stable.0/ghc-multi-target-x86_64-linux.tar.gz
+ dlHash: "FILL_AFTER_BUILD_x86_64_linux"
+ dlInstallSpec:
+ configure:
+ configArgs:
+ - --prefix=${PREFIX}
+ configEnv: null
+ configFile: configure
+ make:
+ makeArgs:
+ - DESTDIR=${TMPDIR}
+ - install
+ dataRules: []
+ exeRules: []
+ exeSymLinked:
+ # Single pattern-based spec creates ~/.ghcup/bin/ entries
+ # for every shipped binary. ${TARGETFN} resolves to each
+ # matched filename (e.g. "ghc", "wasm32-unknown-wasi-ghc",
+ # "javascript-unknown-ghcjs-ghc-pkg"). Versioned + setName
+ # forms both created.
+ - linkName: "${TARGETFN}-${PKGVER}"
+ pVPMajorLinks: true
+ setName: "${TARGETFN}"
+ targetPattern: ["bin/**"]
+ preserveMtimes: false
+ A_ARM64:
+ Darwin:
+ unknown_versioning:
+ dlUri: https://github.com/stable-haskell/ghc/releases/download/multi-9.14.0.stable.0/ghc-multi-target-aarch64-darwin.tar.gz
+ dlHash: "FILL_AFTER_BUILD_aarch64_darwin"
+ dlInstallSpec:
+ configure:
+ configArgs:
+ - --prefix=${PREFIX}
+ configEnv: null
+ configFile: configure
+ make:
+ makeArgs:
+ - DESTDIR=${TMPDIR}
+ - install
+ dataRules: []
+ exeRules: []
+ exeSymLinked:
+ - linkName: "${TARGETFN}-${PKGVER}"
+ pVPMajorLinks: true
+ setName: "${TARGETFN}"
+ targetPattern: ["bin/**"]
+ preserveMtimes: false
+ Linux_UnknownLinux:
+ unknown_versioning:
+ dlUri: https://github.com/stable-haskell/ghc/releases/download/multi-9.14.0.stable.0/ghc-multi-target-aarch64-linux.tar.gz
+ dlHash: "FILL_AFTER_BUILD_aarch64_linux"
+ dlInstallSpec:
+ configure:
+ configArgs:
+ - --prefix=${PREFIX}
+ configEnv: null
+ configFile: configure
+ make:
+ makeArgs:
+ - DESTDIR=${TMPDIR}
+ - install
+ dataRules: []
+ exeRules: []
+ exeSymLinked:
+ - linkName: "${TARGETFN}-${PKGVER}"
+ pVPMajorLinks: true
+ setName: "${TARGETFN}"
+ targetPattern: ["bin/**"]
+ preserveMtimes: false
diff --git a/lode/multi-target-bindist-design.md b/lode/multi-target-bindist-design.md
new file mode 100644
index 000000000000..ff013904573f
--- /dev/null
+++ b/lode/multi-target-bindist-design.md
@@ -0,0 +1,207 @@
+# Multi-target GHC bindist — design
+
+**Goal.** One ghcup channel entry, one downloaded tarball, one extracted directory; on extraction the user has working `bin/ghc` (native), `bin/wasm32-unknown-wasi-ghc`, and `bin/javascript-unknown-ghcjs-ghc` — all the same physical binary, dispatched via `argv[0]` to the appropriate per-target settings + library set.
+
+**Working branch.** `feat/multi-target-bindist` off `feat/wasm-cross-ghcup` HEAD (`4bd31cb4316`). The .dyn_hi shipping work from Path C is the foundation: shared libraries + host dylibs + `$ORIGIN`-relative rpath are already in place from stable.12.
+
+**Rollback.** gh-pages tag `demo-freeze-2026-06-05` points at the gh-pages commit advertising stable.12 as LatestPrerelease. One-command revert:
+
+```
+git push origin demo-freeze-2026-06-05:gh-pages -f
+```
+
+---
+
+## 1. Why this works (the GHC architecture)
+
+GHC's stage2 native binary is itself the cross compiler. It inspects `argv[0]` at startup, strips the executable's basename, looks for a leading triple prefix (`-`), and if found uses `lib/targets//lib/settings` instead of `lib/settings`. Same binary, different target.
+
+The stage3 build, parametric in `STAGE3_PLATFORMS`, already creates these per-platform invocation entrypoints — at line ~979 of the top-level Makefile each stage3 run does:
+
+```make
+$(foreach exe,$(STAGE3_EXECUTABLES),$(LN_SF) $$(exe) $(DIST_DIR)/bin/$(1)-$$(exe);)
+```
+
+i.e. for `$(1) = wasm32-unknown-wasi`, the rule creates `bin/wasm32-unknown-wasi-ghc → bin/ghc`, `bin/wasm32-unknown-wasi-ghc-pkg → bin/ghc-pkg`, etc. Same for JS.
+
+The per-platform support files (`lib/targets//`) get populated by the same `stage3-` rule.
+
+The `STAGE2_EXECUTABLES` and `STAGE3_EXECUTABLES` lists are identical 9-element sets: `ghc`, `ghc-iserv`, `ghc-pkg`, `hp2ps`, `hpc`, `hsc2hs`, `runghc`, `unlit`, `haddock`. JS doesn't use `ghc-iserv` (the JS backend has its own evaluator); we filter it out for that target.
+
+---
+
+## 2. Bindist tar layout (target state)
+
+```
+bin/
+ ghc, ghc-iserv, ghc-pkg, … # native (STAGE2_EXECUTABLES = 9)
+ wasm32-unknown-wasi-ghc, … # wasm cross (9 entries)
+ javascript-unknown-ghcjs-ghc, … # JS cross (8 entries — no ghc-iserv)
+lib/
+ ghc-usage.txt, ghci-usage.txt
+ template-hsc.h
+ settings # NATIVE settings file (argv[0] dispatches to per-target settings if prefix found)
+ package.conf.d/ # NATIVE package db (base, ghc-internal, etc.)
+ / # NATIVE libs (libHS*.so, .a, .hi, .dyn_hi)
+ targets/
+ wasm32-unknown-wasi/lib/
+ settings # wasm-target settings
+ package.conf.d/ # wasm-target package db
+ wasm32-unknown-wasi/ # wasm-target libs (.so + .a + .hi + .dyn_hi)
+ dyld.mjs, post-link.mjs, prelude.mjs, ghc-interp.js # wasm runtime shims
+ javascript-unknown-ghcjs/lib/
+ settings # JS-target settings
+ package.conf.d/ # JS-target package db
+ javascript-unknown-ghcjs/ # JS-target libs
+ dyld.mjs, post-link.mjs, prelude.mjs, ghc-interp.js # JS runtime shims (same family)
+relocate.sh # generalised — recaches ALL three package dbs
+configure, Makefile # autoconf-shaped stubs (legacy install path)
+```
+
+Sizes (estimated): native lib// ~200 MB + wasm target ~230 MB + JS target ~250 MB + binaries ~30 MB. **Total ~700 MB** per platform tarball.
+
+---
+
+## 3. Makefile rule (Phase 2)
+
+Modeled on the existing `$(DIST_DIR)/haskell-toolchain.tar.gz` (line 1149, which already does native + JS). We add wasm + use `tar -czhf` (dereference) so each cross-prefixed binary is a standalone copy (predictable for ghcup's symlink pattern matcher).
+
+```make
+$(DIST_DIR)/ghc-multi-target.tar.gz: $(STAGE2_STAMP) \
+ | stage3-wasm32-unknown-wasi stage3-javascript-unknown-ghcjs
+ @echo "::group::Creating ghc-multi-target.tar.gz..."
+ @cp -f mk/multi-target-relocate.sh $(DIST_DIR)/relocate.sh
+ @chmod +x $(DIST_DIR)/relocate.sh
+ @cp -f mk/multi-target-configure.sh $(DIST_DIR)/configure
+ @chmod +x $(DIST_DIR)/configure
+ @cp -f mk/multi-target-bindist-Makefile $(DIST_DIR)/Makefile
+ tar czhf $@ \
+ --directory=$(DIST_DIR) \
+ $(foreach exe,$(STAGE2_EXECUTABLES),bin/$(exe)$(EXE_EXT)) \
+ $(foreach exe,$(STAGE3_EXECUTABLES),bin/wasm32-unknown-wasi-$(exe)$(EXE_EXT)) \
+ $(foreach exe,$(filter-out ghc-iserv,$(STAGE3_EXECUTABLES)),bin/javascript-unknown-ghcjs-$(exe)$(EXE_EXT)) \
+ lib/ghc-usage.txt lib/ghci-usage.txt lib/package.conf.d lib/settings lib/template-hsc.h \
+ lib/$(HOST_PLATFORM) \
+ lib/targets/wasm32-unknown-wasi \
+ lib/targets/javascript-unknown-ghcjs \
+ relocate.sh configure Makefile
+ @echo "::endgroup::"
+```
+
+**`-h` rationale.** Stage3 creates `bin/-` as symlinks pointing at `bin/`. Wasm bindist uses `-czhf` (deref) because the wasm tarball doesn't include `bin/ghc` — we needed the cross-prefixed binaries to be standalone copies. The multi-target tarball INCLUDES `bin/ghc`, so we could in principle preserve the symlinks (smaller tarball). However ghcup's `targetPattern: "bin/**"` uses `getDirectoryFilesIgnore` which lists symlinks-as-files — so we'd need to be confident the symlinks are preserved end-to-end and pass through ghcup's unpack. Keeping `-h` (deref) is more predictable; cost is ~30 MB extra (8 ghc-iserv + 8 ghc-pkg + … copies vs symlinks of ~3 MB each). Acceptable.
+
+---
+
+## 4. relocate.sh (Phase 2 — supporting script)
+
+Generalises the wasm-only version to recache all three package databases:
+
+```sh
+#!/bin/sh
+set -e
+PREFIX="$(cd "$(dirname "$0")" && pwd)"
+
+# Native ghc-pkg (HOST_PLATFORM target via empty triple prefix)
+"$PREFIX/bin/ghc-pkg" recache --package-db "$PREFIX/lib/package.conf.d"
+
+# Per-cross-target ghc-pkg
+for plat in wasm32-unknown-wasi javascript-unknown-ghcjs; do
+ pkg_db="$PREFIX/lib/targets/$plat/lib/package.conf.d"
+ if [ -d "$pkg_db" ]; then
+ "$PREFIX/bin/$plat-ghc-pkg" recache --package-db "$pkg_db"
+ fi
+done
+
+# wasm + JS both need node ≥ 22 on PATH. emscripten needed for JS link step.
+if ! command -v node >/dev/null 2>&1; then
+ echo "NOTE: node not on PATH — wasm/JS TH evaluation will fail." >&2
+fi
+if ! command -v emcc >/dev/null 2>&1; then
+ echo "NOTE: emcc (emscripten) not on PATH — JS linking will fail." >&2
+fi
+```
+
+---
+
+## 5. CI Cross: MULTI job (Phase 5)
+
+Copy/adapt `Cross: WASM` job in `nix-ci.yml`:
+- needs: `[build]` (downloads `${plat}-dynamic1-dist` stage2 artifact — same as current wasm-cross)
+- installs wasi-sdk AND emscripten (the JS path needs emcc on PATH at compile/link time)
+- runs `make DYNAMIC=1 DIST_BUILD=1 _build/dist/ghc-multi-target.tar.gz` (DYNAMIC=1 because we need .dyn_hi for both wasm and JS targets, same logic as Cross: WASM)
+- rename tarball with host-triple suffix
+- patchelf step for Linux: same as Cross: WASM — set `$ORIGIN/../lib/$HOST_DIR` rpath on the binary, `$ORIGIN` on the .so files
+- upload as workflow artifact
+- on tag-push (matching `multi-*` tags): also upload to GitHub Release via `softprops/action-gh-release`
+
+Matrix: same 3-platform set as Cross: WASM. aarch64-darwin is the slow self-hosted runner.
+
+Expected build time: ~30 min for the multi-target tar after stage2 is ready (wasm + JS in parallel via cabal's build plan parallelism — possibly).
+
+---
+
+## 6. Channel YAML (Phase 7)
+
+Schema bump from 0.0.9 → 0.1.0 (Installer DSL). New entry, drafted as a parallel entry to the existing stable.12 one (additive, not destructive):
+
+```yaml
+ghcupDownloads:
+ GHC:
+ multi-9.14.0.stable.0:
+ viTags:
+ - LatestPrerelease # only set this AT promotion gate (Phase 11), not before
+ viChangeLog: https://github.com/stable-haskell/ghc/releases/tag/multi-9.14.0.stable.0
+ viPreInstall: |
+ Requires Node.js ≥ 22 (wasm runtime), wasi-sdk (wasm-target C tools),
+ and emscripten (JS-target C tools) on PATH. See
+ https://stable-haskell.github.io/ghc/install/ for setup.
+ viPostInstall: |
+ Multi-target GHC installed. The same compiler invokes via argv[0]:
+ ghc — native compilation (this host)
+ wasm32-unknown-wasi-ghc — wasm cross
+ javascript-unknown-ghcjs-ghc — JS cross
+ All three share the same package db conventions but each maintains
+ its own per-target lib tree under lib/targets//.
+ viArch:
+ A_64:
+ Linux_UnknownLinux:
+ unknown_versioning:
+ dlUri: https://github.com/stable-haskell/ghc/releases/download/multi-9.14.0.stable.0/ghc-multi-target-x86_64-linux.tar.gz
+ dlHash: ...
+ dlInstallSpec:
+ bindistFiles:
+ exeRules:
+ - installSource: configure
+ - installSource: Makefile
+ - installSource: relocate.sh
+ exeSymLinked:
+ - targetPattern: "bin/**"
+ targetPatternIgnore: []
+ linkName: "${TARGETFN}-${PKGVER}"
+ setName: "${TARGETFN}"
+ dataRules:
+ - installPattern: ["lib/**"]
+ preserveMtimes: false
+ # ... same shape for A_ARM64 / Darwin + Linux_UnknownLinux
+ wasm32-wasi-9.14.0.stable.12:
+ viTags: [] # demoted ONLY at Phase 11
+ # ... existing entry preserved verbatim
+```
+
+**Schema 0.1.0 vs 0.0.9.** ghcup-0.0.9 clients can't parse the new DSL. The channel must declare schema version somewhere; if not, old clients silently ignore the new fields (defaulting to legacy install). **Need to verify**: does ghcup-0.2.5.0 parse 0.0.9-schema YAML too? If yes, all good. If not, we'd need separate channel URLs per schema (a 0.0.9 channel and a 0.1.0 channel).
+
+This is the **highest risk** part — Phase 7 GATE explicitly tests schema parsing.
+
+---
+
+## 7. Sequencing + gates (already in #38..#50)
+
+The phases are queued. Each ends with a hard gate. NO-GO at any gate means we stop, document, and the Friday demo runs on stable.12 — no functional loss.
+
+---
+
+## 8. Open questions
+
+- **JS bindist needs emscripten in `viPreInstall`** — but emscripten is heavy and version-pinned. Should we make JS optional (separate channel entry) and ship native+wasm in the "multi" bindist? This would simplify the install story.
+- **Schema migration**: if we go 0.1.0 schema, every stable-haskell channel user needs ghcup ≥ 0.2.5. The bootstrap.haskell.org installer ships latest, so new installs are fine. Existing users with older ghcup would silently lose access. Acceptable for a pre-release channel.
+- **Naming**: `multi-9.14.0.stable.0` makes the namespace clean. Alternative: just `9.14.0.stable.0` (no triple prefix, treated as a native entry by ghcup, with the cross targets as bonus symlinks). The latter slots into the canonical ghcup ghc track. **Decision: go with `multi-` prefix initially**; namespace it cleanly so we don't conflict with anyone else's `9.14.0.stable.X`.
diff --git a/lode/phase3-relocate-sh-draft.sh b/lode/phase3-relocate-sh-draft.sh
new file mode 100755
index 000000000000..a4667aaaa0eb
--- /dev/null
+++ b/lode/phase3-relocate-sh-draft.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+# relocate.sh — bundled with the stable-haskell wasm32-wasi GHC bindist.
+#
+# Run once after extracting the tarball to a new install prefix.
+# The package-db *.conf files already use ${pkgroot}/../lib/wasm32-wasi/…
+# (rewritten at build time by Makefile:DIST_COPY_LIB_CONF_CROSS), so the
+# only thing that needs adjusting is the binary ghc-pkg cache, which
+# encodes absolute paths from the build environment.
+#
+# ghcup invokes this from viPostInstall with PWD = install prefix.
+#
+# DRAFT — lives in lode/ until Phase 3 ships. Integrate into the Makefile
+# bindist target ($(DIST_DIR)/ghc-wasm32-wasi.tar.gz, Makefile:1029) so the
+# .tar.xz contains relocate.sh at the top level.
+set -e
+
+PREFIX="$(cd "$(dirname "$0")" && pwd)"
+GHC_PKG="$PREFIX/bin/wasm32-wasi-ghc-pkg"
+PKG_DB="$PREFIX/lib/targets/wasm32-wasi/lib/package.conf.d"
+
+if [ ! -x "$GHC_PKG" ]; then
+ echo "error: $GHC_PKG not found or not executable" >&2
+ exit 1
+fi
+if [ ! -d "$PKG_DB" ]; then
+ echo "error: $PKG_DB not found" >&2
+ exit 1
+fi
+
+echo "Relocating wasm32-wasi-ghc package db to: $PKG_DB"
+"$GHC_PKG" recache --package-db "$PKG_DB"
+
+echo
+echo "Stable Haskell wasm32-wasi-ghc ready."
+echo " Compiler: $PREFIX/bin/wasm32-wasi-ghc"
+echo " Package db: $PKG_DB"
+echo
+echo "Add $PREFIX/bin to your PATH, or invoke via ghcup."
diff --git a/lode/phase6-miso-template-draft/Makefile b/lode/phase6-miso-template-draft/Makefile
new file mode 100644
index 000000000000..69737ff33589
--- /dev/null
+++ b/lode/phase6-miso-template-draft/Makefile
@@ -0,0 +1,44 @@
+# Self-documenting Makefile for stable-haskell/miso-wasm-template.
+# Run `make` (or `make help`) to see targets.
+#
+# All toolchain selection lives in cabal.project (with-compiler /
+# with-build-compiler). No redundant CLI flags here — keeps a single
+# source of truth.
+
+.DEFAULT_GOAL := help
+SHELL := /bin/bash
+
+# Override these only if invoking a non-default toolchain.
+WASM_GHC ?= wasm32-wasi-ghc
+CABAL ?= cabal
+PKG := myapp
+
+# Discovered lazily so `make help` works without the toolchain installed.
+LIBDIR = $(shell $(WASM_GHC) --print-libdir)
+POST_LINK = $(LIBDIR)/post-link.mjs
+WASM_ARTIFACT = $(shell find dist-newstyle -name '$(PKG).wasm' -print -quit)
+
+.PHONY: help build link serve clean cabal-build
+
+help: ## Show this help.
+ @awk 'BEGIN{FS=":.*##"; printf "\nTargets:\n"} \
+ /^[a-zA-Z_-]+:.*##/ {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' \
+ $(MAKEFILE_LIST)
+
+build: link ## Build wasm + emit JSFFI glue + copy to public/.
+
+cabal-build: ## Run cabal (toolchains taken from cabal.project).
+ $(CABAL) build $(PKG)
+
+link: cabal-build ## Run post-link.mjs and stage artifacts.
+ @mkdir -p public
+ @test -n "$(WASM_ARTIFACT)" || { echo "no .wasm found under dist-newstyle"; exit 1; }
+ node $(POST_LINK) --input $(WASM_ARTIFACT) --output public/ghc_wasm_jsffi.js
+ cp $(WASM_ARTIFACT) public/myapp.wasm
+
+serve: ## Serve public/ at http://localhost:8080 (Ctrl-C to stop).
+ npx --yes http-server public -c-1 -p 8080
+
+clean: ## Remove build artifacts.
+ rm -rf dist-newstyle
+ rm -f public/myapp.wasm public/ghc_wasm_jsffi.js
diff --git a/lode/phase6-miso-template-draft/README.md b/lode/phase6-miso-template-draft/README.md
new file mode 100644
index 000000000000..f8a3f734d996
--- /dev/null
+++ b/lode/phase6-miso-template-draft/README.md
@@ -0,0 +1,46 @@
+# miso-wasm-template
+
+Minimal browser-facing miso counter, compiled to WebAssembly with the
+stable-haskell wasm cross-GHC distributed via ghcup.
+
+> **DRAFT — not yet validated against a real wasm cross-compiler build.**
+> Lives in `lode/phase6-miso-template-draft/` until Phase 6 of the wasm-cross-ghcup
+> initiative ships. See `REVIEW.md` for the gap list.
+
+## Prerequisites
+
+```sh
+# 1. Wire up the stable-haskell ghcup channel.
+ghcup config add-release-channel \
+ https://raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/ghcup-stable-wasm-0.0.1.yaml
+
+# 2. Cross-GHC + cabal that support `with-build-compiler`.
+ghcup install ghc wasm32-wasi-9.14.0.stable
+ghcup install cabal stable-3.17.0.1
+
+# 3. Native GHC for the build-compiler half of the cross build.
+ghcup install ghc 9.10.1 && ghcup set ghc 9.10.1
+
+# 4. Non-GHC wasm tooling (wasi-sdk, node, wasmtime, binaryen).
+curl -sSL https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta/-/raw/master/bootstrap.sh \
+ | SKIP_GHC=1 sh
+source ~/.ghc-wasm/env
+```
+
+## Build & Serve
+
+```sh
+make build # cabal build + post-link.mjs + stage public/
+make serve # http-server on :8080
+```
+
+Open .
+
+## Native dev loop
+
+Modify `cabal.project` to remove the `with-compiler` lines (or add a
+`cabal.project.native` and pass `--project-file=cabal.project.native`):
+
+```sh
+cabal run myapp --with-compiler=ghc --with-build-compiler=ghc
+```
diff --git a/lode/phase6-miso-template-draft/REVIEW.md b/lode/phase6-miso-template-draft/REVIEW.md
new file mode 100644
index 000000000000..62bc6c205ec1
--- /dev/null
+++ b/lode/phase6-miso-template-draft/REVIEW.md
@@ -0,0 +1,119 @@
+# Phase 6 miso template — draft review (2026-05-26)
+
+These template files were produced by a research agent during Phase 1 build
+wait time. **Not yet validated** against a real wasm cross-compiler build —
+they live here as drafts until Phase 6 actually runs.
+
+When ready to ship Phase 6: move these to a new repo
+`stable-haskell/miso-wasm-template` (NOT in this repo).
+
+## Critical review
+
+### Bugs found in agent output (must fix before shipping)
+
+1. **`app/Main.hs` — invalid Haskell import alias** (BOTH `#ifdef WASM`
+ branch and native branch):
+ ```haskell
+ import qualified Language.Javascript.JSaddle.Wasm as JSaddle.Wasm -- INVALID
+ import qualified Language.Javascript.JSaddle.Warp as JSaddle.Warp -- INVALID
+ ```
+ Haskell module aliases must be single conids — no dots. Fix to e.g.
+ `as JSW` / `as Warp`, or import the function directly:
+ ```haskell
+ import Language.Javascript.JSaddle.Wasm (run)
+ ...
+ main = run (startApp app)
+ ```
+
+2. **`Makefile` `cabal-build` target redundancy**: the CLI flags
+ `--with-compiler` / `--with-build-compiler` duplicate what's already in
+ `cabal.project`. The project file is enough; drop them from the Makefile
+ target (or vice versa — but having both is just confusing).
+
+3. **`README.md` ghcup channel URL is a guess**:
+ ```
+ https://raw.githubusercontent.com/stable-haskell/ghcup-metadata/main/ghcup-0.0.10.yaml
+ ```
+ Per our Phase 4 design (lode/wasm-cross-ghcup-plan.md §8.3), the correct
+ URL is:
+ ```
+ https://raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/ghcup-stable-wasm-0.0.1.yaml
+ ```
+ Update once Phase 4 publishes.
+
+4. **`browser_wasi_shim` version mismatch**: agent pinned `@0.3.0`; our RTS
+ bumped to `0.4.2` (commit `e0837350f14`, per memory). The user-side
+ launcher needs to match what the wasm module expects from WASI imports.
+ Verify against the version of `browser_wasi_shim` bundled in
+ `lib/targets/wasm32-wasi/lib/` (if shipped) or the version GHC's
+ `post-link.mjs` documents.
+
+### Empirical finding (2026-05-26): miso requires stage3 rebuilt with shared:True
+
+Direct attempt to build `miso 1.11.0` against the dual-compiler stable-haskell stack
+hit `character-ps-0.1` → `Failed to load dynamic interface file for Data.Word:
+base-4.22.0.0/Data/Word.dyn_hi: does not exist`. Root cause: our stage3 wasm
+target libraries (base, ghc-internal, etc.) were built with `shared: False` per
+`cabal.project.stage3` (lines 184-186 — the old `if os(wasi) package * shared:
+True` block is commented out due to the build-package pollution bug). miso (and
+most TH-using packages) require `shared: True`, which transitively requires
+base.dyn_hi.
+
+**R7 resolution path (i)** — replace the commented-out form with
+`if arch(wasm32) shared: True` (no `package *`). With stable-haskell/cabal's
+Stage/Toolchain split, this applies ONLY to host-arch packages, not BUILD-arch
+packages → no `happy-lib`/`alex` pollution. To validate, rebuild stage3 and
+re-package the bindist. Order of hours; not done in this session.
+
+**Phase 6 deliverable for v0.0.1** (this session): a trivial reactor template
+(no miso) proving the full toolchain works end-to-end. Miso integration is
+queued as Phase 6.5, contingent on the stage3 shared rebuild.
+
+### Conceptual insight worth promoting
+
+The agent's `cabal.project` uses:
+```
+if arch(wasm32)
+ shared: True
+```
+*without* a `package *` qualifier. This is the **R7 resolution path (i)** —
+it relies on stable-haskell/cabal's Stage/Toolchain split:
+- BUILD packages are evaluated against the BUILD architecture (native),
+ so `arch(wasm32)` is FALSE → `shared: True` does NOT apply → no `-dynamic-too`
+ on `happy-lib`/etc.
+- HOST packages are evaluated against the HOST architecture (wasm32),
+ so `arch(wasm32)` is TRUE → `shared: True` applies → dyld.mjs can load
+ them for TH.
+
+This **only works with stable-haskell/cabal** (or a future cabal that
+implements the same split). Stock cabal has only one arch context per build,
+so the same form would still pollute BUILD packages.
+
+**Action:** when ready, replicate this pattern in
+`cabal.project.stage3` (currently lines 184-186 have the `package *` form
+commented out as the bug-prone alternative). With stable-haskell/cabal as
+the bootstrap (which is what we use), we may be able to re-enable
+`shared: True` for stage3 itself. Test in Phase 5.
+
+### Items to verify against real stage3 output
+
+Per the agent's verification checklist (with empirical results from 2026-05-26):
+
+1. ✅ `post-link.mjs --input/--output` accepted — confirmed locally; produces a 4915-byte ESM JSFFI glue file.
+2. ✅ `hs_start` is exported — `wasm-objdump -x myapp.wasm` shows it alongside `_initialize`, `__ghc_wasm_jsffi_init`, `memory`, and the `rts_*` runtime helpers.
+3. ✅ `wasi.initialize(instance)` for reactors — confirmed. But ALSO must call `instance.exports.__ghc_wasm_jsffi_init()` BEFORE `hs_start()`. Without that step, `hs_start()` throws `"newBoundTask: RTS is not initialised; call hs_init() first"`. The drafted `index.js` has been updated to include this step.
+4. ✅ Knot-tying via `Object.assign(__exports, instance.exports)` after `WebAssembly.instantiate` and before `wasi.initialize` works.
+5. ⏳ `JSaddle.Wasm.run` correctness — to verify when adding miso; trivial reactor (no miso) is the current empirical test point.
+6. ⏳ `aeson` `-ordered-keymap` flag — to verify with miso layered in.
+7. ✅ `with-build-compiler` is honored from `cabal.project` — confirmed with stable-haskell/cabal at SHA `44817477`. Build profile reports `-w ghc-9.14 -W ghc-9.8.4`. Cabal source: `cabal-install/src/Distribution/Client/ProjectConfig/FieldGrammar.hs:123`.
+8. ✅ Reactor exports include `__ghc_wasm_jsffi_init` — confirmed via `wasm-objdump`; this is the RTS-init function that the launcher MUST call after `wasi.initialize()`.
+
+### File inventory
+
+- `app/Main.hs` — Counter app, dual-mode (wasm + native dev). HAS BUG (#1).
+- `myapp.cabal` — cabal file with wasm reactor flags. OK.
+- `cabal.project` — dual-compiler form. OK; uses correct R7-resolving pattern.
+- `Makefile` — self-documenting; targets help/build/link/serve/clean. Minor cleanup (#2).
+- `public/index.html` — minimal. OK.
+- `public/index.js` — browser launcher. Pin version (#4).
+- `README.md` — install + build guide. URL needs updating (#3).
diff --git a/lode/phase6-miso-template-draft/app/Main.hs b/lode/phase6-miso-template-draft/app/Main.hs
new file mode 100644
index 000000000000..473d343a4b2d
--- /dev/null
+++ b/lode/phase6-miso-template-draft/app/Main.hs
@@ -0,0 +1,65 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+module Main (main) where
+
+import Miso
+import Miso.String (ms)
+
+#ifdef WASM
+-- FIXME: agent draft had `as JSaddle.Wasm` (dotted alias — invalid Haskell).
+-- Use a single-conid alias or import unqualified.
+import qualified Language.Javascript.JSaddle.Wasm as JSW
+import GHC.Wasm.Prim () -- brings JSString instances into scope
+#else
+import qualified Language.Javascript.JSaddle.Warp as Warp
+#endif
+
+-- | Model: a single counter.
+type Model = Int
+
+-- | Actions: increment / decrement.
+data Action = AddOne | SubOne | NoOp deriving (Eq, Show)
+
+-- | Pure update.
+updateModel :: Action -> Model -> Effect Action Model
+updateModel AddOne m = noEff (m + 1)
+updateModel SubOne m = noEff (m - 1)
+updateModel NoOp m = noEff m
+
+-- | Render.
+viewModel :: Model -> View Action
+viewModel n =
+ div_ []
+ [ h1_ [] [text "stable-haskell miso wasm counter"]
+ , button_ [ onClick SubOne ] [ text "-" ]
+ , span_ [] [ text (ms (show n)) ]
+ , button_ [ onClick AddOne ] [ text "+" ]
+ ]
+
+-- | Shared app spec.
+app :: App Model Action
+app = App
+ { initialAction = NoOp
+ , model = 0
+ , update = updateModel
+ , view = viewModel
+ , events = defaultEvents
+ , subs = []
+ , mountPoint = Nothing
+ , logLevel = Off
+ }
+
+#ifdef WASM
+-- Wasm reactor entry point. The C function symbol must match the
+-- linker --export=hs_start flag in myapp.cabal.
+foreign export javascript "hs_start" main :: IO ()
+
+main :: IO ()
+main = JSW.run (startApp app)
+#else
+-- Native dev mode: serve via jsaddle-warp on :8080 so you can open the
+-- app in a browser without the wasm toolchain (useful for fast iteration).
+main :: IO ()
+main = Warp.run 8080 (startApp app)
+#endif
diff --git a/lode/phase6-miso-template-draft/cabal.project b/lode/phase6-miso-template-draft/cabal.project
new file mode 100644
index 000000000000..a1560ffb0076
--- /dev/null
+++ b/lode/phase6-miso-template-draft/cabal.project
@@ -0,0 +1,35 @@
+packages: .
+
+-- Pinned for reproducibility; bump as needed.
+index-state: 2026-04-01T00:00:00Z
+
+-- Dual-compiler setup, supported by stable-haskell/cabal:
+-- with-build-compiler -> native GHC, builds Setup.hs, custom-setup, and
+-- BUILD-tool dependencies (alex, happy, c2hs, ...).
+-- with-compiler -> the target (wasm32-wasi-ghc) used for HOST code.
+-- Resolved against $PATH; you can also use absolute ~/.ghcup/bin paths.
+with-build-compiler: ghc
+with-compiler: wasm32-wasi-ghc
+
+-- ghcup ships matching wrappers next to the compiler.
+with-hc-pkg: wasm32-wasi-ghc-pkg
+with-hsc2hs: wasm32-wasi-hsc2hs
+
+-- R7 resolution path (i): `shared: True` under `if arch(wasm32)` (NOT
+-- `package *`) relies on stable-haskell/cabal's Stage/Toolchain split —
+-- BUILD packages see arch != wasm32, so this doesn't pollute happy-lib etc.
+-- Will not work with stock cabal.
+if arch(wasm32)
+ shared: True
+
+-- miso 1.11+ has first-class wasm support but the wasm-ready cut isn't
+-- on Hackage yet; pull from upstream.
+source-repository-package
+ type: git
+ location: https://github.com/dmjio/miso.git
+ tag: 1.11
+
+-- aeson's ordered-keymap drags in template-haskell at build time in
+-- a way that's awkward for wasm cross. Disabled defensively.
+package aeson
+ flags: -ordered-keymap
diff --git a/lode/phase6-miso-template-draft/myapp.cabal b/lode/phase6-miso-template-draft/myapp.cabal
new file mode 100644
index 000000000000..70fbc8b331e3
--- /dev/null
+++ b/lode/phase6-miso-template-draft/myapp.cabal
@@ -0,0 +1,31 @@
+cabal-version: 3.0
+name: myapp
+version: 0.1.0.0
+synopsis: Minimal miso counter built with stable-haskell wasm cross-GHC
+license: Apache-2.0
+author: Moritz Angermann
+maintainer: moritz.angermann@iohk.io
+build-type: Simple
+
+executable myapp
+ main-is: Main.hs
+ hs-source-dirs: app
+ default-language: GHC2021
+ default-extensions: OverloadedStrings RecordWildCards LambdaCase
+ ghc-options: -Wall -Wno-name-shadowing
+ build-depends:
+ , base >= 4.18 && < 5
+ , miso >= 1.11 && < 2
+
+ if arch(wasm32)
+ -- Reactor pattern: no Haskell main, export hs_start instead.
+ -- See GHC user's guide §15. Quotes around -optl-Wl,... are required
+ -- so cabal preserves the comma during argv flattening.
+ ghc-options:
+ -no-hs-main
+ -optl-mexec-model=reactor
+ "-optl-Wl,--export=hs_start"
+ cpp-options: -DWASM
+ build-depends: jsaddle-wasm
+ else
+ build-depends: jsaddle-warp
diff --git a/lode/phase6-miso-template-draft/public/index.html b/lode/phase6-miso-template-draft/public/index.html
new file mode 100644
index 000000000000..e868fee0ff1d
--- /dev/null
+++ b/lode/phase6-miso-template-draft/public/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ miso wasm counter
+
+
+
+
+
diff --git a/lode/phase6-miso-template-draft/public/index.js b/lode/phase6-miso-template-draft/public/index.js
new file mode 100644
index 000000000000..6252f8238190
--- /dev/null
+++ b/lode/phase6-miso-template-draft/public/index.js
@@ -0,0 +1,50 @@
+// Browser launcher for a GHC-wasm reactor module.
+// Validated against haskell-wasm/ghc-wasm-miso-examples/frontend/index.js
+// and the GHC user's guide §15 knot-tying pattern.
+//
+// FIXME: pin version to match what our RTS expects. Per project memory,
+// commit e0837350f14 bumped browser_wasi_shim to 0.4.2 on the GHC side.
+// Verify the launcher version matches the WASI imports the wasm module declares.
+import {
+ WASI,
+ OpenFile,
+ File,
+ ConsoleStdout,
+} from "https://esm.sh/@bjorn3/browser_wasi_shim@0.4.2";
+
+// post-link.mjs writes a module whose *default export* is a function
+// that, given the (eventually-populated) exports object, returns the
+// imports object for the `ghc_wasm_jsffi` namespace. We pre-bind it
+// against an empty record and assign exports after instantiation -
+// this is the standard knot-tying trick from the GHC user's guide.
+import ghc_wasm_jsffi from "./ghc_wasm_jsffi.js";
+
+const args = []; // argv[0] is unused for reactors
+const env = ["GHCRTS=-H64m"]; // small initial heap; tune as needed
+const fds = [
+ new OpenFile(new File([])), // 0: stdin
+ ConsoleStdout.lineBuffered((m) => console.log (`[hs stdout] ${m}`)), // 1
+ ConsoleStdout.lineBuffered((m) => console.warn(`[hs stderr] ${m}`)), // 2
+];
+const wasi = new WASI(args, env, fds, { debug: false });
+
+const instance_exports = {};
+const { instance } = await WebAssembly.instantiateStreaming(
+ fetch("./myapp.wasm"),
+ {
+ wasi_snapshot_preview1: wasi.wasiImport,
+ ghc_wasm_jsffi: ghc_wasm_jsffi(instance_exports),
+ },
+);
+// Tie the knot: now jsffi callbacks can reach back into the wasm module.
+Object.assign(instance_exports, instance.exports);
+
+// Reactor module bring-up sequence — order matters:
+// 1. wasi.initialize() runs WASI static constructors (_initialize export).
+// 2. __ghc_wasm_jsffi_init() initializes GHC's JSFFI runtime + the RTS.
+// Without this, hs_start() throws "RTS is not initialised; call hs_init()".
+// Empirically verified 2026-05-26.
+// 3. hs_start() runs the Haskell entry point (whatever you `foreign export`ed).
+wasi.initialize(instance);
+instance.exports.__ghc_wasm_jsffi_init();
+await instance.exports.hs_start();
diff --git a/lode/phase6-trivial-reactor-poc/README.md b/lode/phase6-trivial-reactor-poc/README.md
new file mode 100644
index 000000000000..1e71f3cfd3c4
--- /dev/null
+++ b/lode/phase6-trivial-reactor-poc/README.md
@@ -0,0 +1,56 @@
+# Phase 6 v0.0.1 — Trivial wasm reactor (proven working)
+
+> **Status**: empirically verified end-to-end 2026-05-26.
+> **Purpose**: smallest possible artifact demonstrating dual-compiler cabal +
+> wasm cross GHC + JSFFI runtime work together. NO miso (see "Why no miso").
+
+## What this proves
+
+- **stable-haskell/cabal** at SHA `44817477` honors `with-build-compiler` and
+ `with-compiler` in `cabal.project` — one cabal invocation drives two GHCs.
+- Native GHC 9.8.4 (devx `ghc98-minimal-ghc`) compiles `Setup.hs` and any
+ build-stage tools; wasm32-unknown-wasi-ghc compiles the host-stage code.
+- `post-link.mjs` (from `lib/targets/wasm32-unknown-wasi/lib/`) emits a working
+ JSFFI ESM glue module.
+- The runtime invocation sequence is: `wasi.initialize()` → call
+ `__ghc_wasm_jsffi_init()` → call `hs_start()`. **The middle step is
+ critical** — skipping it yields `"newBoundTask: RTS is not initialised;
+ call hs_init() first"`.
+
+## Why no miso
+
+Miso (and most TH-using packages) need `shared: True` for the wasm target
+build, which transitively requires `base.dyn_hi` and friends. The stage3 wasm
+target libraries shipped in this repo's current bindist are built with
+`shared: False` (per `cabal.project.stage3:184-186`, where the
+`if os(wasi) / package * / shared: True` block is commented out due to the
+build-package pollution bug). Fix: replace with `if arch(wasm32) shared: True`
+(no `package *`) — stable-haskell/cabal's Stage/Toolchain split makes this safe.
+Rebuild stage3 + re-package bindist is required; not done in this session.
+
+Once that lands, the `lode/phase6-miso-template-draft/` files can be promoted.
+
+## Files
+
+- `app/Main.hs` — reactor exporting `hs_start`, prints a greeting
+- `myapp.cabal` — cabal file with wasm reactor flags (`-no-hs-main`,
+ `-optl-mexec-model=reactor`, `--export=hs_start`)
+- `cabal.project` — dual-compiler form (template; replace absolute paths with
+ bare names when shipped on ghcup)
+- `run.mjs` — Node.js launcher that proves end-to-end execution via `node:wasi`
+- `public/index.html` + `public/index.js` — browser launcher template using
+ `@bjorn3/browser_wasi_shim` (same invocation pattern as `run.mjs`)
+
+## Verified locally
+
+```sh
+$ cd lode/phase6-trivial-reactor-poc
+$ nix develop /Users/angerman/Projects/stable-haskell/ghc -c \
+ /Users/angerman/Projects/stable-haskell/ghc/_build/cabal/bin/cabal build myapp
+# … Build profile: -w ghc-9.14 -W ghc-9.8.4 -O1
+# … Completed myapp-0.1.0.0 (exe:myapp)
+$ WASM=dist-newstyle/store/host/wasm32-unknown-wasi/bin/myapp.wasm
+$ node $(ghc --print-libdir)/post-link.mjs -i $WASM -o jsffi.mjs
+$ nix-shell -p nodejs_22 --run "node run.mjs"
+Hello from the WASM reactor!
+```
diff --git a/lode/phase6-trivial-reactor-poc/app/Main.hs b/lode/phase6-trivial-reactor-poc/app/Main.hs
new file mode 100644
index 000000000000..1ea94463929e
--- /dev/null
+++ b/lode/phase6-trivial-reactor-poc/app/Main.hs
@@ -0,0 +1,12 @@
+{-# LANGUAGE CPP #-}
+module Main where
+
+#ifdef WASM
+-- Reactor pattern: GHC will not synthesize a _start entry point because the
+-- cabal file uses -no-hs-main + -optl-mexec-model=reactor. We export
+-- hs_start (the function the linker --exports) which the JS host calls.
+foreign export javascript "hs_start" main :: IO ()
+#endif
+
+main :: IO ()
+main = putStrLn "Hello from the WASM reactor!"
diff --git a/lode/phase6-trivial-reactor-poc/cabal.project b/lode/phase6-trivial-reactor-poc/cabal.project
new file mode 100644
index 000000000000..c7539718069c
--- /dev/null
+++ b/lode/phase6-trivial-reactor-poc/cabal.project
@@ -0,0 +1,16 @@
+packages: .
+
+-- Dual-compiler form, supported by stable-haskell/cabal (Stage/Toolchain
+-- architecture, SHA 44817477). One cabal invocation drives both GHCs:
+-- BUILD side (host machine arch): compiles Setup.hs, build-tools, TH host
+-- HOST side (target arch wasm32): compiles the package's library/exe code
+--
+-- When shipped on ghcup, replace these absolute paths with bare names:
+-- with-build-compiler: ghc (any ghcup-installed native GHC)
+-- with-build-hc-pkg: ghc-pkg
+-- with-compiler: wasm32-unknown-wasi-ghc
+-- with-hc-pkg: wasm32-unknown-wasi-ghc-pkg
+with-build-compiler: /nix/store/8s4nvbqdqmd1jbmrj8di07hp0nn1lwi5-ghc-9.8.4/bin/ghc-9.8.4
+with-build-hc-pkg: /nix/store/8s4nvbqdqmd1jbmrj8di07hp0nn1lwi5-ghc-9.8.4/bin/ghc-pkg-9.8.4
+with-compiler: /Users/angerman/Projects/stable-haskell/ghc/_build/dist/bin/wasm32-unknown-wasi-ghc
+with-hc-pkg: /Users/angerman/Projects/stable-haskell/ghc/_build/dist/bin/wasm32-unknown-wasi-ghc-pkg
diff --git a/lode/phase6-trivial-reactor-poc/myapp.cabal b/lode/phase6-trivial-reactor-poc/myapp.cabal
new file mode 100644
index 000000000000..0723a0033db5
--- /dev/null
+++ b/lode/phase6-trivial-reactor-poc/myapp.cabal
@@ -0,0 +1,26 @@
+cabal-version: 3.0
+name: myapp
+version: 0.1.0.0
+synopsis: Trivial wasm reactor demonstrating stable-haskell wasm cross + dual-compiler cabal
+license: Apache-2.0
+author: Moritz Angermann
+maintainer: moritz.angermann@iohk.io
+build-type: Simple
+
+executable myapp
+ main-is: Main.hs
+ hs-source-dirs: app
+ default-language: GHC2021
+ build-depends: base >= 4.18 && < 5
+ ghc-options: -Wall
+
+ if arch(wasm32)
+ -- Reactor pattern, per GHC user's guide §15:
+ -- -no-hs-main: don't auto-export Haskell's main as _start
+ -- -optl-mexec-model=reactor: WASI reactor (long-lived) module
+ -- -optl-Wl,--export=hs_start: linker exports our hs_start FFI function
+ ghc-options:
+ -no-hs-main
+ -optl-mexec-model=reactor
+ "-optl-Wl,--export=hs_start"
+ cpp-options: -DWASM
diff --git a/lode/phase6-trivial-reactor-poc/public/index.html b/lode/phase6-trivial-reactor-poc/public/index.html
new file mode 100644
index 000000000000..931685ae2500
--- /dev/null
+++ b/lode/phase6-trivial-reactor-poc/public/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Stable Haskell wasm reactor
+
+
+
+
+
diff --git a/lode/phase6-trivial-reactor-poc/public/index.js b/lode/phase6-trivial-reactor-poc/public/index.js
new file mode 100644
index 000000000000..a1a84fcc7f54
--- /dev/null
+++ b/lode/phase6-trivial-reactor-poc/public/index.js
@@ -0,0 +1,44 @@
+// Browser launcher for a GHC-wasm reactor module.
+//
+// Pairs with `run.mjs` (Node.js variant). Same invocation pattern; only the
+// WASI provider differs — browsers use browser_wasi_shim, Node uses
+// the built-in node:wasi.
+//
+// Empirically verified 2026-05-26 (the Node variant); browser variant is
+// the same pattern with browser_wasi_shim swapped in for node:wasi.
+import {
+ WASI,
+ OpenFile,
+ File,
+ ConsoleStdout,
+} from "https://esm.sh/@bjorn3/browser_wasi_shim@0.4.2";
+
+import ghc_wasm_jsffi from "./jsffi.mjs";
+
+const args = ["myapp.wasm"];
+const env = ["GHCRTS=-H64m"];
+const fds = [
+ new OpenFile(new File([])),
+ ConsoleStdout.lineBuffered((m) => console.log (`[hs stdout] ${m}`)),
+ ConsoleStdout.lineBuffered((m) => console.warn(`[hs stderr] ${m}`)),
+];
+const wasi = new WASI(args, env, fds, { debug: false });
+
+const __exports = {};
+const { instance } = await WebAssembly.instantiateStreaming(
+ fetch("./myapp.wasm"),
+ {
+ wasi_snapshot_preview1: wasi.wasiImport,
+ ghc_wasm_jsffi: ghc_wasm_jsffi(__exports),
+ },
+);
+Object.assign(__exports, instance.exports);
+
+// Reactor bring-up sequence — order matters:
+// 1. wasi.initialize() runs WASI static constructors (_initialize export).
+// 2. __ghc_wasm_jsffi_init() initializes GHC's JSFFI runtime + the RTS.
+// Without this, hs_start() throws "RTS is not initialised; call hs_init()".
+// 3. hs_start() runs the Haskell entry point we foreign-exported.
+wasi.initialize(instance);
+instance.exports.__ghc_wasm_jsffi_init();
+await instance.exports.hs_start();
diff --git a/lode/phase6-trivial-reactor-poc/run.mjs b/lode/phase6-trivial-reactor-poc/run.mjs
new file mode 100644
index 000000000000..5e677620a108
--- /dev/null
+++ b/lode/phase6-trivial-reactor-poc/run.mjs
@@ -0,0 +1,32 @@
+// run.mjs — Node.js launcher for a GHC-wasm reactor module.
+//
+// Invoke as: node run.mjs
+// Assumes: ./myapp.wasm exists, ./jsffi.mjs is post-link.mjs output for it.
+//
+// Empirically verified 2026-05-26 against stable-haskell/ghc 9.14 wasm cross.
+import { readFile } from "node:fs/promises";
+import { WASI } from "node:wasi";
+
+const mod = await WebAssembly.compile(await readFile("./myapp.wasm"));
+const jsffi = (await import("./jsffi.mjs")).default;
+
+const wasi = new WASI({ version: "preview1", args: ["myapp.wasm"] });
+
+// Knot-tying: __exports starts empty; jsffi closures capture it by
+// reference; after instantiate we Object.assign instance.exports into it,
+// so any later jsffi call can reach back into the wasm module.
+const __exports = {};
+const instance = await WebAssembly.instantiate(mod, {
+ ghc_wasm_jsffi: jsffi(__exports),
+ wasi_snapshot_preview1: wasi.wasiImport,
+});
+Object.assign(__exports, instance.exports);
+
+// Reactor bring-up sequence — order matters:
+// 1. wasi.initialize() runs WASI static constructors (_initialize export).
+// 2. __ghc_wasm_jsffi_init() initializes GHC's JSFFI runtime + the RTS.
+// Without this, hs_start() throws "RTS is not initialised; call hs_init()".
+// 3. hs_start() runs the Haskell entry point we foreign-exported.
+wasi.initialize(instance);
+instance.exports.__ghc_wasm_jsffi_init();
+await instance.exports.hs_start();
diff --git a/lode/rpath-leak-investigation.md b/lode/rpath-leak-investigation.md
new file mode 100644
index 000000000000..298e69256eec
--- /dev/null
+++ b/lode/rpath-leak-investigation.md
@@ -0,0 +1,137 @@
+# `/Volumes/WorkSpace` LC_RPATH leak — root-cause investigation
+
+**Date:** 2026-06-02
+**Symptom:** every host arm64 Mach-O in `_build/dist/ghc-multi-target-aarch64-darwin.tar.gz` ships with an unresolvable absolute LC_RPATH:
+
+ /Volumes/WorkSpace/_work/ghc/ghc/_build/stage2/store/host/aarch64-apple-darwin/lib
+
+coexisting with the portable `@executable_path/../lib/aarch64-apple-darwin`. macOS 14 dyld silently falls through; macos-15 (Sequoia) dyld treats it as fatal and aborts the binary on launch.
+
+Background workaround already in place: CI commit **010b365582c** post-processes the darwin bindist with `install_name_tool -delete_rpath` + ad-hoc re-sign. This document describes the upstream defect; the workaround stays in place until a real fix lands.
+
+---
+
+## Where the absolute path enters the link line
+
+The leak originates in the bundled Cabal (not in GHC, not in our Makefile), specifically in two collaborating functions:
+
+### 1. `depLibraryPaths` — returns absolute store paths
+
+`libraries/Cabal/Cabal/src/Distribution/Simple/LocalBuildInfo.hs:256-351`
+
+```haskell
+depLibraryPaths
+ :: Bool -- ^ Building for inplace?
+ -> Bool -- ^ Generate prefix-relative library paths
+ -> LocalBuildInfo
+ -> ComponentLocalBuildInfo
+ -> IO [FilePath]
+depLibraryPaths inplace relative lbi clbi = do
+ ...
+ let allDepLibDirs = concatMap getDynDir external_ipkgs
+ allDepLibDirsC <- traverse canonicalizePathNoFail allDepLibDirs'
+ let p = prefix installDirs
+ prefixRelative l = isJust (stripPrefix p l)
+ libPaths
+ | relative && prefixRelative relDir =
+ map (\l -> if prefixRelative l
+ then shortRelativePath relDir l
+ else l)
+ allDepLibDirsC
+ | otherwise = allDepLibDirsC -- absolute paths returned as-is
+```
+
+Two gate conditions are needed to shorten paths to relative:
+* `relative == True` — driven by the per-package `relocatable :: Bool` field of `LocalBuildInfo`, which itself is set by `--enable-relocatable` (or `relocatable: True` in a project file). We do **not** pass this flag from `cabal.project.stage2.settings`.
+* The dep's libdir `l` must already be under the package's prefix `p`. In a cabal-store layout each package gets its own subdir of the store, so a sibling dep's `lib/` directory is **never** under `p`. `prefixRelative l` is False for every cross-package dep, even with `--enable-relocatable`.
+
+Both gates fail, so `depLibraryPaths` returns absolute store paths.
+
+### 2. `relPath` — only rewrites relative paths
+
+`libraries/Cabal/Cabal/src/Distribution/Simple/GHC/Build/Link.hs:638-648`
+
+```haskell
+if supportRPaths hostOS
+ then do
+ libraryPaths <- liftIO $ depLibraryPaths False (relocatable lbi) lbi clbi
+ let hostPref = case hostOS of
+ OSX -> "@loader_path"
+ _ -> "$ORIGIN"
+ relPath p = if isRelative p then hostPref > p else p
+ rpaths = toNubListR (map relPath libraryPaths)
+ <> toNubListR (map getSymbolicPath $ extraLibDirs bi)
+ return rpaths
+ else return mempty
+```
+
+Absolute paths from step 1 pass through `relPath` unchanged. GHC then emits them as `-Wl,-rpath,...` on the link command and the linker writes them into LC_RPATH (darwin) or DT_RUNPATH (Linux ELF).
+
+### 3. Build sequencing — Makefile rewrites `.conf` files too late
+
+`Makefile` lines 738–763 (stage2 build):
+
+1. `$(STAGE2_CABAL_BUILD)` runs cabal which links every executable, baking the absolute store paths from step 2 directly into the binary.
+2. **After** all executables are linked, the Makefile rewrites the per-package `.conf` files with `${pkgroot}/../lib/...` placeholders so ghc-pkg can relocate them post-install.
+
+By the time the `.conf` files become relocatable, the linked binaries already contain absolute LC_RPATH.
+
+---
+
+## Why Linux looks fine
+
+It isn't — Linux ELFs have the identical defect. They get masked because the Makefile/CI runs `patchelf --force-rpath --set-rpath '$ORIGIN'` over every shipped binary and `.so`:
+
+* `Makefile:777-779` — host shared libs
+* `.github/workflows/nix-ci.yml` Cross: WASM / Cross: MULTI patchelf step — every executable + lib
+
+`patchelf --set-rpath` writes a fresh DT_RUNPATH wholesale, so it doesn't matter what was baked in.
+
+The darwin equivalent (install_name_tool can only `-add_rpath` / `-delete_rpath` / `-rpath` one at a time, no wholesale replace) was never wired up until this week.
+
+---
+
+## Fix options, ordered by invasiveness
+
+| | Approach | Where | Invasiveness | Notes |
+|---|---|---|---|---|
+| A | Strip post-build with `install_name_tool -delete_rpath` + re-sign | `.github/workflows/nix-ci.yml` Cross: MULTI darwin step | Low (1 step) | **In place** as of commit 010b365582c. Mirrors what patchelf does on Linux. Bandaid but reliable. |
+| B | Patch `relPath` in `Link.hs` to also handle absolute paths via `makeRelative bindir p` | `libraries/Cabal/.../Link.hs:644` | Medium | Needs `bindir` (or `dynlibdir`) of the executable's component at the call site — already computed via `absoluteComponentInstallDirs` in `depLibraryPaths`, would need plumbing. Source-side correctness fix. |
+| C | Patch `depLibraryPaths` to detect cabal-store-sibling layout and emit relative paths between siblings | `libraries/Cabal/.../LocalBuildInfo.hs:336-346` | Medium-high | More general but every cabal user inherits the change. Likely needs upstream discussion. |
+| D | Build stage2 with `relocatable: True` AND restructure store so siblings share a common prefix | `cabal.project.stage2.settings` | High | The prefix restructure is the hard part — cabal's store-by-unitid hash layout is what makes deps land in distinct prefixes. |
+
+**Status (2026-06-02):** taken option **B**, with one twist — see below.
+
+`stable-haskell/Cabal feat/rpath-relativize-absolute` (SHA `6a5ce8161`, PR #368) patches `Link.hs:644` to relativize absolute rpaths via `shortRelativePath` against the artifact's `bindir`/`libdir`. **Not gated on `relocatable lbi`** — first version was gated, but setting `relocatable: True` in `cabal.project.stage2.settings` to flip the gate ALSO triggers cabal-install's other relocatable-mode machinery:
+
+* `checkRelocatable` refuses any cabal-store layout where deps live in sibling prefixes (i.e. essentially always).
+* `library-dirs` in installed-package `.conf` files become `${pkgroot}/...`-prefixed, which the stable-haskell GHC bindist-assembly Makefile post-stage2 rewriting then mangles into `_build/dist/lib/lib/...` (doubled `lib/`), breaking every stage3 consumer.
+
+Both behaviors are independent of rpath generation, so the second iteration of the fix drops the gate. Side effects on non-relocatable cabal users:
+
+* Cabal-store layouts: relative form works the same as the absolute did (dyld walks `@loader_path/../..//lib`).
+* System libs (e.g. `/usr/local/lib/libfoo.dylib`): relative form `@loader_path/../../../../usr/local/lib/libfoo.dylib` works as long as the binary stays at its original location. A binary moved to a host where `/usr/local/lib/libfoo.dylib` exists at the same absolute path no longer finds it — but moving a single binary cross-host with system-lib dependencies was never a documented cabal contract.
+
+stable-haskell/ghc commit pinning the new Cabal SHA: `9de9f58ce54` on `feat/multi-target-bindist`. Once a tagged release ships with this Cabal and CI confirms macos-15 dyld is happy with the resulting bindist, both the darwin `install_name_tool -delete_rpath` step (commit `010b365582c`) and the Linux `patchelf --set-rpath '$ORIGIN'` step become no-ops and can be retired.
+
+## Files referenced
+
+* `libraries/Cabal/Cabal/src/Distribution/Simple/GHC/Build/Link.hs:638-648`
+* `libraries/Cabal/Cabal/src/Distribution/Simple/LocalBuildInfo.hs:256-351`
+* `libraries/Cabal/Cabal/src/Distribution/Simple/Setup/Config.hs:212-213,370,827-830` (relocatable flag)
+* `Makefile:738-779` (stage2 build + post-link patchelf for host .so)
+* `.github/workflows/nix-ci.yml:1392-1450` (current darwin install_name_tool workaround)
+* `.github/workflows/nix-ci.yml:1339-1389` (current Linux patchelf step in Cross: MULTI)
+* `cabal.project.stage2.settings:5-7` (`package * { shared: True; executable-dynamic: True }` — what enables the rpath codepath)
+
+## Reproducer
+
+On any darwin host with this branch built:
+
+```sh
+$ otool -l _build/dist/stage2/bin/ghc | awk '/cmd LC_RPATH/{f=1;next} f && /path /{print; f=0}'
+ path /Volumes/WorkSpace/_work/ghc/ghc/_build/stage2/store/host/aarch64-apple-darwin/lib (offset 12)
+ path @executable_path/../lib/aarch64-apple-darwin (offset 12)
+```
+
+The first entry is the leak.
diff --git a/lode/wasm-cross-ghcup-plan.md b/lode/wasm-cross-ghcup-plan.md
new file mode 100644
index 000000000000..4ef1f93d59b0
--- /dev/null
+++ b/lode/wasm-cross-ghcup-plan.md
@@ -0,0 +1,839 @@
+# Stable-Haskell WASM Cross-Compiler via ghcup — Plan
+
+> **Status:** Active. Branch: `feat/wasm-cross-ghcup` off `stable-ghc-9.14`.
+> **Owner:** angerman.
+> **Last updated:** 2026-05-26.
+
+This is the living source-of-truth for the initiative. Update the **Status Log** at
+the bottom as phases progress. Keep this doc tight — point at code/PRs rather than
+duplicating their content.
+
+---
+
+## 1. Purpose
+
+End-state: a Haskell developer can run
+
+```bash
+ghcup config add-release-channel
+ghcup install ghc wasm32-wasi-9.14.0.stable -- $CONFIGURE_ARGS
+ghcup install cabal stable-3.17.0.1
+git clone https://github.com/stable-haskell/miso-wasm-template && cd $_
+make && make serve # opens a working miso app in the browser
+```
+
+…using **our** stack: stable-haskell GHC (245+ patches over upstream 9.14),
+stable-haskell/cabal with dual-compiler ("compile-less") support, and a thin
+ghcup channel that reuses upstream `ghc-wasm-meta` for the host-side
+wasi-sdk/node/wasmtime pieces.
+
+## 2. Why us, not upstream `ghc-wasm-meta`
+
+| Capability | Upstream `ghc-wasm-meta` | This initiative |
+|---|---|---|
+| GHC bindist | upstream (master / RCs) | **stable-haskell/ghc** (245-patch fork, stable-9.14 line) |
+| Cabal | stock 3.14.x with `wasm32-wasi-cabal` wrapper + injected config | **stable-haskell/cabal** 3.17.0.1 with real dual-compiler (`with-compiler` / `with-build-compiler`) + compile-less store + file-monitor recompilation avoidance |
+| wasi-sdk / node / wasmtime / binaryen | bundled via `bootstrap.sh` | **reuse upstream's `bootstrap.sh`** with `SKIP_GHC=1` (no fork) |
+| ghcup channel | `haskell-wasm/ghc-wasm-meta` channel | new `stable-haskell/ghc-wasm-meta` channel, distinct version suffix |
+| Custom-Setup packages with cross | not handled (single cabal, single compiler) | works (Setup.hs compiled by build-compiler, target code by host-compiler) |
+
+## 3. Current State Assessment (verified 2026-05-26)
+
+### What's already on `stable-ghc-9.14`
+
+- 245+ WASM patches: RTS WASI guards, JSFFI init ctors, NCG `.functype`/`.size`,
+ on-demand `GlobalRegs`, libffi-clib exclusion for WASI, dyld handling, browser_wasi_shim
+ bump, etc.
+- `cabal.project.stage3` with `if os(wasi) / package * / shared: True` (the
+ build-package pollution bug is **fixed**: the override is correctly nested under `os(wasi)`).
+- Makefile `STAGE3_PLATFORMS := … wasm32-unknown-wasi` with full toolchain wiring
+ (CC/CXX/AR/RANLIB + GHC_TOOLCHAIN_ARGS for `--merge-objs wasm-ld`,
+ `--disable-tables-next-to-code`, `--disable-libffi-adjustors`).
+- Stage3 target → `_build/dist/ghc-wasm32-unknown-wasi.tar.gz` plus
+ JS runtime shims (`dyld.mjs`, `ghc-interp.js`, `post-link.mjs`, `prelude.mjs`).
+- `feat/wasm-fixes` adds **zero** genuinely new content (its commits are already
+ in `stable-ghc-9.14` by patch-id). Ignore that branch.
+
+### What's NOT on `stable-ghc-9.14` (Phase 0 absorbs these)
+
+- **`build/wasm-nix-environment`** (6 commits): `flake.nix` pulling `wasi-sdk`
+ from upstream `ghc-wasm-meta` flake, env vars `WASM_EXTRA_{LIB,INCLUDE}_DIRS`,
+ USAGE docs.
+- **`feat/nix-ci-split`** (6 commits): `Makefile` cross-build refactor (dist-based
+ configuration), `.github/workflows/nix-ci.yml` split into build/test/cross jobs,
+ RTS fix for undefined symbols referenced only by `R_*_NONE` relocations, plus
+ build-infra fixes (stamp-based deps, PHONY ordering, race condition).
+
+### What doesn't exist anywhere yet
+
+- ghcup-installable bindist shape (current tarball has no `./configure && make install`).
+- Published `stable-haskell/ghc-wasm-meta` ghcup channel.
+- stable-haskell/cabal binary releases for ghcup.
+- Miso template proving the dual-compiler workflow.
+- Browser launcher template (neither miso-sampler nor upstream `ghc-wasm-meta`
+ commits one — biggest documentation gap in the ecosystem).
+
+## 4. Decisions made
+
+| # | Decision | Rationale |
+|---|---|---|
+| D1 | Working branch: `feat/wasm-cross-ghcup` off `stable-ghc-9.14`. | Don't pollute `stable-ghc-9.14` until Phase 2 is CI-green. |
+| D2 | Rename triple `wasm32-unknown-wasi` → `wasm32-wasi`. | Match ecosystem convention (`wasm32-wasi-ghc-…`, ghcup-cross YAML, miso-sampler). Mechanical Makefile change; `ghc-toolchain` accepts both. |
+| D3 | Phase-3 bindist shape: **relocatable tarball + `relocate.sh`**, ghcup `viPostInstall` invokes it. | Ships fast; can migrate to full configure-bindist (`./configure --prefix && make install`) later if user feedback demands. |
+| D4 | Reuse upstream `ghc-wasm-meta` `bootstrap.sh` with `SKIP_GHC=1` for wasi-sdk/node/wasmtime/binaryen. | These pieces aren't our value-add. Tracking upstream wasi-sdk updates is a tax we don't want. |
+| D5 | Do not ship a native stable-haskell GHC bindist for build-compiler use. | Any user-installed GHC (ghcup 9.10.x / 9.12.x) suffices. Revisit if "full stable stack" becomes a goal. |
+| D6 | Channel hosting: GitHub raw — `raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/ghcup-stable-wasm.yaml`. Bindists as release assets in `stable-haskell/ghc-wasm-bindists`. | Simple, no GH Pages needed, standard pattern. |
+| D7 | Version suffix on ghcup entries: `9.14.0.stable.YYYYMMDD`. | Avoid collision with upstream `wasm32-wasi-9.14`; allow multiple stable releases. |
+
+## 5. Open decisions / risks
+
+- **R1: stable-haskell/cabal 3.17 vs the wasm-known cabal 3.16 regression.**
+ Upstream `ghc-wasm-meta` README pins cabal 3.14.x. Our 3.17 tracks unreleased
+ master + Andrea's patches. Must be verified by running the miso e2e (Phase 6)
+ before claiming it works.
+- **R2: aarch64-darwin CI 41GB APFS budget.** Stage3 adds ~3-5GB; may need
+ `make clean-stage{0,1,2}` between stage2 and stage3 to free space. Determine
+ empirically in Phase 2.
+- **R3: ghc-toolchain triple acceptance.** D2 assumes `wasm32-wasi` works; verify
+ in Phase 1 by running `ghc-toolchain --triple wasm32-wasi …` and inspecting
+ the generated `settings` file.
+- **R4: `build-type: Custom` package compatibility.** Andrea's dual-compiler
+ patches are explicitly tested only against GHC's own stage1/stage2. Real-world
+ miso transitive deps with `Custom`/`Configure` build-types may surface bugs.
+ Phase 5 gate covers one Custom-build-type package as a smoke test; expect
+ follow-up bug reports.
+- **R5: Path-baking in `settings`.** wasi-sdk path is hardcoded into the GHC
+ `settings` file after install. If a user moves/upgrades `~/.ghc-wasm/wasi-sdk`,
+ installed GHC breaks. `relocate.sh` (D3) must rewrite these paths; `viPostInstall`
+ must invoke it.
+- **R6: Custom channel + upstream `cross` channel collision.** If a user has
+ both channels enabled, the last-added wins for any colliding version key.
+ D7 mitigates this.
+- **R10: stable-haskell/cabal store layout doesn't match Makefile expectations.**
+ Discovered 2026-05-26 after R9 workaround unblocked the build past unix-2.8.8.0.
+ Cabal compile-less branch builds executables to `_build/stage1/bin/` and
+ libraries to `_build/stage1/store/ghc-9.8.4-inplace/-/`. The Makefile
+ (lines 610, 617, and many others) expects `$(STORE_DIR)/host/$(HOST_PLATFORM)/bin/`
+ = `_build/stage1/store/host/aarch64-apple-darwin/bin/` — a path that simply
+ doesn't exist with this cabal. Stage1 build fails at the `ghc-toolchain-bin`
+ invocation step with `bash: ...: No such file or directory`. **Both at HEAD
+ AND at SHA `44817477…`** (we tested the pin separately) — meaning the layout
+ divergence is somewhere in the compile-less branch's foundational store
+ rework, not a recent regression.
+ **This blocks Phase 1 entirely.** Possible resolutions: (a) update Makefile
+ to look in `_build/stage1/bin/` and `store/ghc-9.8.4-inplace/` (probably
+ 20-40 line changes — significant, needs cross-platform care for the GHC
+ toolchain triple); (b) find an even older stable-haskell/Cabal SHA that
+ produced the `store/host/HOST/` layout the Makefile expects; (c) ask the
+ user if there's a known-working combination they've used recently
+ (the previous successful build of `_build/stage1` we saw earlier must
+ have used a compatible cabal — what SHA was that?).
+ **ESCALATING TO USER** — this is bigger than the wasm-cross-ghcup
+ initiative scope. Resolution choice affects the whole project's
+ buildability, not just our branch.
+- **R9: stable-haskell/cabal HEAD races on store install locks across
+ stages.** Discovered 2026-05-26 during Phase 1 stage2 retry: the
+ compile-less Stage/Toolchain split means cabal builds the same package
+ (`unix`, `happy-lib`, `os-string`, `filepath`, …) for both Build and Host
+ stages. When the install steps race, one process holds the per-package
+ lock in `store/.../incoming/*.lock` while another tries to acquire it
+ → `openFile: resource busy`. happy-lib was observed with 12 attempts.
+ Andrea's earlier "TOCTOU race fix for BuildInplaceOnly tarball extraction"
+ doesn't cover this. **Workarounds in priority order:** (i) retry the
+ build (sometimes transient if the artifact lands on the first attempt);
+ (ii) limit cabal parallelism via `--jobs=1` or `cabal-install` config;
+ (iii) pin `tag:` in `cabal.project.stage{0,1,2,3}` back to
+ `44817477ff6d…` (pre-rebase, last known-working). **Fix upstream** in
+ `stable-haskell/cabal#stable-haskell/master` — the store install needs
+ to recognize that two stages building the same package for the same arch
+ produce identical artifacts and short-circuit. **Same fix-priority class
+ as R8** (both block Phase 5).
+- **R8: stable-haskell/cabal HEAD doesn't bootstrap with stock GHC 9.8.4.**
+ Discovered 2026-05-26 during Phase 1 stage2 build attempt. `make stable-cabal`
+ fails: `cabal-install/src/Distribution/Client/ProjectPlanning.hs:224` imports
+ `Distribution.Simple.GHCJS` which is hidden in `Cabal-3.10.3.0` (the bundled
+ Cabal library in bootstrap GHC 9.8.4). Either: (a) the GHCJS removal in the
+ compile-less branch is incomplete (still references the removed module), or
+ (b) the cabal-install build-depends accidentally allows linking against
+ the bundled Cabal instead of the source-repository-package version.
+ **Blocks Phase 5** (cabal binary shipping). Workaround for Phase 1: set
+ `USE_SYSTEM_CABAL=1` to skip the stable-cabal target — uses the existing
+ `_build/stage0/bin/cabal` (cabal-install 3.17.0.0, one snapshot older from
+ pre-rebase tag). This is acceptable for Phase 1/2 (we're building GHC, not
+ shipping cabal); for Phase 5 we must either fix the import or pin Cabal
+ source-repo properly. File the fix upstream against
+ `stable-haskell/cabal#stable-haskell/master`.
+- **R7: TH/GHCi on wasm needs `shared: True` for target libs, but stock
+ cabal `package *` clauses pollute BUILD packages too.** Discovered 2026-05-26
+ inspecting `cabal.project.stage3` lines 175-186 — the `if os(wasi) / package *
+ / shared: True` block is **commented out** because it caused `-dynamic-too`
+ on `happy-lib`/etc. compiled by the native build compiler, which fails when
+ the dist artifact lacks `.dyn_hi` files. Consequences:
+ (a) Phase 1 TH smoke-test gate currently CANNOT pass — the stage3 base
+ libraries are built `shared: False`, so dyld.mjs at runtime has nothing to load.
+ (b) Phase 6 miso e2e similarly blocked (miso uses TH heavily).
+ Resolutions in priority order: **(i)** spell out per-package `shared: True`
+ for the wasm target libraries individually (avoiding `package *`); **(ii)** wait
+ for stable-haskell/cabal to gain Host-only `package *` clauses (extension of
+ the Stage/Toolchain work — not in `stable-haskell/master` HEAD as of
+ 2026-05-26); **(iii)** ship without TH for v0.0.1, document, fix in v0.0.2.
+ Decision pending Phase 1 attempt; (i) is the safest path forward.
+
+## 6. Phases & gates
+
+Each phase has **one** clear pass/fail gate. Don't advance until the prior gate is met.
+
+### Phase 0a — Branch + planning docs (IN PROGRESS)
+- Create `feat/wasm-cross-ghcup` off `stable-ghc-9.14`.
+- Write `lode/wasm-cross-ghcup-plan.md` (this file).
+- Update project `CLAUDE.md` with a "Current Initiative" section linking here.
+- Commit.
+- **Gate:** branch checked out; one commit on top of `stable-ghc-9.14`; planning
+ docs reviewable by anyone fresh.
+
+### Phase 0 — Consolidate WASM feature branches
+- Cherry-pick 6 commits from `build/wasm-nix-environment` (flake.nix + wasi-sdk).
+- Cherry-pick 6 commits from `feat/nix-ci-split` (Makefile cross refactor + CI split + RTS R_*_NONE fix + build-infra fixes).
+- Resolve conflicts. Skip `feat/wasm-fixes` (already merged by patch-id).
+- Note: flake exposes single `devShells.default` (no `wasm-cross` attr). The shell creates wrappers in `.nix-wasm-bin/` for `wasm32-wasi-{clang,clang++,ar,ranlib}`, `wasm-ld`, `llc`/`opt`/`llvm-as` (LLVM 21). `wasm-opt` (binaryen) is NOT provided — not strictly needed for compiler bring-up.
+- **Gate:** `nix develop --command bash -c 'which wasm32-wasi-clang wasm-ld llc && llc --version | head -2'` succeeds and prints LLVM 21.
+
+### Phase 1 — Local wasm cross build green
+- Apply D2 (rename triple `wasm32-unknown-wasi` → `wasm32-wasi`); regenerate Makefile var refs.
+- In devShell: `make CABAL=$PWD/_build/stage0/bin/cabal stage3-wasm32-wasi`.
+- Smoke-test `_build/dist/bin/wasm32-wasi-ghc`:
+ - Compile a one-file `hello :: IO ()`; run produced `.wasm` via `wasmtime` AND node + `browser_wasi_shim`.
+- Attempt R7 resolution (i): add per-package `shared: True` for the wasm target libs (base, ghc-internal, ghc-bignum, integer-gmp, etc. — *not* `package *`). Rebuild stage3 — verify happy-lib/alex still build cleanly.
+- If (i) works: compile a tiny TH-using package, run via Node iserv.
+- **Gate:** non-TH hello-world runs in both `wasmtime` AND node+browser_wasi_shim. **TH gate moved to "stretch"**: if R7(i) succeeds → TH-using package runs; else log R7 as known limitation for v0.0.1 and defer to Phase 5/6.
+
+### Phase 2 — CI wasm cross build green (both host platforms)
+- Add `wasm-cross-{x86_64-linux, aarch64-darwin}` jobs to `.github/workflows/nix-ci.yml`.
+- Cache wasi-sdk via nix-store. Mind aarch64-darwin 41GB APFS (R2).
+- Each job: nix-develop wasm-cross → stage0/1/2 → stage3-wasm32-wasi → Phase-1 smoke test.
+- **Gate:** PR's wasm-cross jobs green on both runners; total runtime <90 min per host.
+
+### Phase 3 — Ghcup-compatible wasm bindist
+**Scope shrunk (2026-05-26)**: investigation of `Makefile:409-450` shows
+`DIST_COPY_LIB_CONF_CROSS` already rewrites absolute paths in `*.conf` files
+to `${pkgroot}/../lib/wasm32-wasi/…` and `ghc-pkg recache` is run for the
+cross-target package db at build time (line 1018). The settings file uses
+**bare** tool names (`wasm32-wasi-clang`/`wasm-ld`/etc.) so no path baking
+there either. Therefore bindists are *already* relocatable; `relocate.sh`
+shrinks to a one-liner that re-runs `ghc-pkg recache` for the new install
+location (the cache is binary and not portable).
+
+- Repackage `_build/dist/ghc-wasm32-wasi.tar.gz` (Makefile:1029) into
+ `wasm32-wasi-ghc-9.14.0-.tar.xz`:
+ - Keep existing layout: `bin/wasm32-wasi-ghc{,-pkg,-iserv,…}`, `lib/targets/wasm32-wasi/…`.
+ - Append: `relocate.sh` (10 lines) at top-level.
+ - Verify JS shims (`dyld.mjs`, `post-link.mjs`, `prelude.mjs`, `ghc-interp.js`) are present in `lib/targets/wasm32-wasi/lib/`.
+ - Switch compression to `.tar.xz` (smaller than `.tar.gz`; ghcup accepts both).
+- Smoke-test: untar to a random `$prefix`, run `$prefix/relocate.sh`, then `$prefix/bin/wasm32-wasi-ghc --info` + compile hello-world.
+- **Gate:** tarball untars + relocates + compiles hello-world on a fresh machine without errors.
+
+### Phase 4 — Ghcup channel published
+- Create `stable-haskell/ghc-wasm-meta` repo (or repurpose).
+- Add `bootstrap.sh` delegating to upstream `ghc-wasm-meta` `bootstrap.sh` with `SKIP_GHC=1`; writes `~/.ghc-wasm/env` exporting `$CONFIGURE_ARGS`.
+- Author `ghcup-stable-wasm-0.0.1.yaml`: one `GHC: wasm32-wasi-9.14.0.stable.YYYYMMDD` entry per `(arch, OS)` (start with `A_64/Linux_UnknownLinux` and `A_ARM64/Darwin`).
+- Bindists hosted as release assets at `stable-haskell/ghc-wasm-bindists`.
+- `viPostInstall` invokes `relocate.sh`.
+- **Gate:** on a clean machine: `ghcup config add-release-channel && ghcup install ghc wasm32-wasi-9.14.0.stable -- $CONFIGURE_ARGS` succeeds; `wasm32-wasi-ghc --version` works.
+
+### Phase 5 — Ship stable-haskell/cabal binary
+- CI job builds static cabal-install 3.17.0.1 from `stable-haskell/cabal#stable-haskell/master` for x86_64-linux and aarch64-darwin.
+- Add Cabal entry to ghcup YAML (or sibling channel).
+- Verify dual-compiler `cabal.project` form: one `cabal build` uses native ghc for Setup.hs + wasm32-wasi-ghc for target.
+- **Gate:** a package with `build-type: Custom` builds successfully via dual-compiler (exercises Andrea's patches, not just `Simple`).
+
+### Phase 6 — Miso end-to-end demo
+- Create `stable-haskell/miso-wasm-template` repo:
+ - `app/Main.hs` minimal miso app.
+ - `myapp.cabal` with `if arch(wasm32) / ghc-options: -no-hs-main -optl-mexec-model=reactor "-optl-Wl,--export=hs_start"`.
+ - `cabal.project` with dual-compiler + `if arch(wasm32) shared: True`.
+ - `Makefile`: `cabal build` → `post-link.mjs` → `cp` to `public/` → `npx http-server public`.
+ - `public/index.html` + `public/index.js` browser launcher using `browser_wasi_shim` — **commit it**; close the ecosystem-wide documentation gap.
+- Validate on a fresh machine using only our ghcup channel.
+- **Gate:** developer goes `git clone` → `make && make serve` → working browser demo in <5 min (post-install).
+
+### Phase 7 — Documentation & migration
+- README on `stable-haskell/ghc-wasm-meta` repo: what we offer vs upstream
+ `ghc-wasm-meta`, exact install/use commands, dual-compiler `cabal.project`
+ template, known limitations (no `cabal install miso` Hackage path, no
+ subprocess-using libs in browser, etc.).
+- Migration notes for upstream `ghc-wasm-meta` users.
+- Link from main `stable-haskell/ghc` README.
+- **Gate:** dev unfamiliar with this work reaches working browser demo from
+ README alone, no questions asked.
+
+## 7. References
+
+- Project root `CLAUDE.md` — multi-stage build system overview.
+- `cabal.project.stage3`, `Makefile` (STAGE3_* vars) — current cross wiring.
+- `stable-haskell/cabal#stable-haskell/master` (commit `44817477ff6d`) — dual-compiler Cabal.
+- `haskell-wasm/ghc-wasm-meta` — upstream wasi-sdk/node/wasmtime installer (to reuse).
+- `haskell-wasm/ghc-wasm-bindists` — upstream's bindist hosting pattern.
+- `haskell/ghcup-metadata`, `ghcup-cross-0.1.0.yaml` — channel schema reference.
+- `tweag/ghc-wasm-miso-examples` — working miso wasm example (config to mirror).
+- `haskell-miso/miso-sampler` — official miso starter (Nix flow).
+- GHC user's guide §15 — WASM backend (reactor module shape, post-link).
+- Tweag blog 2024-11-21 — TH/ghci over wasm via Node iserv.
+
+## 8. Phase 4 design proposal (draft sketches)
+
+These are reference templates for when the `stable-haskell/ghc-wasm-meta` repo
+is created. Refine against the real bindist once Phase 3 produces one.
+
+### 8.1 `ghcup-stable-wasm-0.0.1.yaml` skeleton
+
+```yaml
+ghcupDownloads:
+ GHC:
+ wasm32-wasi-9.14.0.stable.YYYYMMDD:
+ viTags: [base-4.21.0.0]
+ viPreInstall: |
+ Stable Haskell wasm32-wasi cross-compiler.
+
+ Prerequisites — install the host-side wasm toolchain via upstream
+ ghc-wasm-meta (we don't ship wasi-sdk / node / wasmtime / binaryen
+ ourselves):
+
+ curl -sL https://raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/bootstrap.sh | sh
+ source ~/.ghc-wasm/env
+
+ Then re-run this install.
+ viPostInstall: |
+ # ghcup runs viPostInstall from the install prefix.
+ # ${pkgroot}-relative *.conf files are portable, but the binary
+ # ghc-pkg cache is not — regenerate it for the new location.
+ ./bin/wasm32-wasi-ghc-pkg recache \
+ --package-db ./lib/targets/wasm32-wasi/lib/package.conf.d
+ echo "Stable Haskell wasm32-wasi-ghc ready at: $(pwd)/bin/wasm32-wasi-ghc"
+ viArch:
+ A_64:
+ Linux_UnknownLinux:
+ unknown_versioning:
+ dlHash:
+ dlSubdir: wasm32-wasi-ghc-9.14.0
+ dlUri: https://github.com/stable-haskell/ghc-wasm-bindists/releases/download/v9.14.0.stable.YYYYMMDD/wasm32-wasi-ghc-9.14.0-x86_64-linux.tar.xz
+ dlOutput: wasm32-wasi-ghc-9.14.0-x86_64-linux.tar.xz
+ A_ARM64:
+ Darwin:
+ unknown_versioning:
+ dlHash:
+ dlSubdir: wasm32-wasi-ghc-9.14.0
+ dlUri: https://github.com/stable-haskell/ghc-wasm-bindists/releases/download/v9.14.0.stable.YYYYMMDD/wasm32-wasi-ghc-9.14.0-aarch64-darwin.tar.xz
+ dlOutput: wasm32-wasi-ghc-9.14.0-aarch64-darwin.tar.xz
+ Cabal:
+ stable-3.17.0.1:
+ viTags: [Latest]
+ viPreInstall: |
+ Stable Haskell cabal-install with real dual-compiler support
+ (with-build-compiler / with-compiler) for cross-compilation.
+ viArch:
+ A_64:
+ Linux_UnknownLinux: { ... }
+ A_ARM64:
+ Darwin: { ... }
+```
+
+**Open questions to resolve before publishing:**
+- Does ghcup invoke `viPostInstall` with `PWD = install prefix`, or some other dir? Confirm via ghcup source or empirically.
+- Does ghcup support `Cabal:` entries in custom channels, or only `GHC:`? Test by registering a channel with just `Cabal:` and trying `ghcup list cabal`.
+- Should the version suffix be `.stableYYYYMMDD` or `-stable-YYYYMMDD`? Match ghcup's preferred patterns.
+
+### 8.2 `stable-haskell/ghc-wasm-meta/bootstrap.sh` sketch
+
+```sh
+#!/bin/sh
+# Stable Haskell wasm bootstrap — installs everything needed to build wasm
+# Haskell apps with stable-haskell/ghc + stable-haskell/cabal.
+set -e
+
+echo "==> Step 1/3: installing host-side wasm toolchain (wasi-sdk, node, wasmtime, binaryen)"
+echo " delegating to upstream haskell-wasm/ghc-wasm-meta with SKIP_GHC=1"
+curl -sL https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta/-/raw/master/bootstrap.sh \
+ | SKIP_GHC=1 SKIP_CABAL=1 sh
+
+if [ -f "$HOME/.ghc-wasm/env" ]; then
+ echo "==> Step 2/3: source ~/.ghc-wasm/env to get wasi-sdk / node / wasmtime on PATH"
+ echo " add this to your shell rc file:"
+ echo " source $HOME/.ghc-wasm/env"
+fi
+
+echo "==> Step 3/3: register stable-haskell ghcup channel"
+ghcup config add-release-channel \
+ https://raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/ghcup-stable-wasm-0.0.1.yaml
+
+echo
+echo "==> Done. Next steps:"
+echo " source ~/.ghc-wasm/env"
+echo " ghcup install ghc wasm32-wasi-9.14.0.stable.YYYYMMDD"
+echo " ghcup install cabal stable-3.17.0.1"
+echo " git clone https://github.com/stable-haskell/miso-wasm-template"
+echo " cd miso-wasm-template && make && make serve"
+```
+
+### 8.3 Repo layout for `stable-haskell/ghc-wasm-meta`
+
+```
+stable-haskell/ghc-wasm-meta/
+├── README.md (Phase 7 doc)
+├── bootstrap.sh (8.2 above)
+├── ghcup-stable-wasm-0.0.1.yaml (8.1 above)
+└── .github/workflows/
+ └── validate.yml (lint YAML, hash-check release assets)
+```
+
+Bindists themselves live in a SECOND repo:
+
+```
+stable-haskell/ghc-wasm-bindists/
+└── (releases tagged v9.14.0.stable.YYYYMMDD,
+ each with .tar.xz assets per host platform)
+```
+
+This separation mirrors upstream (`haskell-wasm/ghc-wasm-meta` +
+`haskell-wasm/ghc-wasm-bindists`) and keeps the small text-config repo
+auditable while binaries live in releases.
+
+## 9. Status log
+
+- **2026-05-26** — Initiative kicked off. Five research agents fanned out; findings
+ aggregated above. Branch `feat/wasm-cross-ghcup` created. This plan committed.
+- **2026-05-26** — Phase 0a complete. Phase 0 cherry-picks landed cleanly:
+ 6 commits from `build/wasm-nix-environment` (flake.nix uses ghc-wasm-meta for
+ wasi-sdk; wrappers in `.nix-wasm-bin/`) + 6 commits from `feat/nix-ci-split`
+ (Makefile cross-refactor + CI nix-ci.yml split + RTS R_*_NONE fix + 3 build
+ infra fixes). Single cabal.project.stage3 auto-merge, no manual conflicts.
+ Branch now 13 commits ahead of `stable-ghc-9.14`. Verifying Phase 0 gate next.
+- **2026-05-26** — Phase 0 gate PASSES. `nix develop` reports: bootstrap GHC
+ 9.8.4, cabal-install 3.16.0.0, LLVM 21.1.8-wasi-sdk, clang 21.1.8-wasi-sdk.
+ All wasm wrappers on PATH (`.nix-wasm-bin/wasm32-wasi-{clang,clang++,ar,
+ ranlib}`, `wasm-ld`, `llc`, `opt`, `llvm-as`). `WASI_SDK_DIR` +
+ `WASM_EXTRA_{LIB,INCLUDE}_DIRS` exported. Incidental finding: devshell ships
+ cabal 3.16 — this is R1 materializing (upstream ghc-wasm-meta README warns
+ 3.16 has a wasm regression). Doesn't affect Phase 0/1 (we're building GHC,
+ not user apps with TH), but flag for Phase 5 cabal-binary work and Phase 6
+ miso e2e. Cherry-picked artifact: cabal-install was Nix-built from source
+ (~15 min first run); subsequent shells will be cached.
+- **2026-05-26** — Phase 1 starting: apply D2 rename
+ `wasm32-unknown-wasi` → `wasm32-wasi` across Makefile, flake.nix, nix-ci.yml,
+ USAGE.md, build-wasm-{make,on-linux0}.sh (8 files, ~30 occurrences).
+ Mechanical sed; no word-boundary risk (no `wasm32-unknown-wasi-foo` strings
+ that should be preserved). D2 applied in commit `03bd8b8c0d6`.
+ Initial bad commit with embedded git repos caught and fixed via `git reset
+ --mixed HEAD~1`; recommitted with explicit file paths.
+- **2026-05-26** — Phase 1 stage2 build FAILED after ~3 min: `make stable-cabal`
+ (rebuild cabal-install-3.17.0.1 from stable-haskell/master) fails — see R8.
+ Retrying with `USE_SYSTEM_CABAL=1` to skip the rebuild and use the existing
+ pre-rebase `_build/stage0/bin/cabal` (3.17.0.0).
+- **2026-05-26** — Cascade of issues discovered: USE_SYSTEM_CABAL=1 hit R9
+ (parallel install race on unix-2.8.8.0). Pinning Cabal source-repo tag to
+ `44817477…` did NOT resolve R9 (it's in the cabal binary, not the library).
+ `jobs: 1` in cabal.project.common DID resolve R9 and progressed past
+ unix-2.8.8.0 through Cabal-3.17.0.0, ghc-toolchain-bin, ghc-boot, ghci,
+ ghc-pkg, ghc, ghc-bin. THEN hit R10: cabal-install 3.17.0.0 (pre-Stage/
+ Toolchain) puts binaries in `_build/stage1/bin/` and libraries in
+ `_build/stage1/store/ghc-9.8.4-inplace/` (old upstream layout), but
+ the Makefile expects `_build/stage1/store/host//bin/…` (new layout
+ from compile-less). Path-layout mismatch.
+- **2026-05-26** — Path 2 (user-chosen): patched `stable-haskell/cabal` HEAD
+ locally to remove dead GHCJS code (R8 fix) + a stale `binDirectoryFor`
+ call site (uncovered after R8). Patches saved in
+ `lode/r8-cabal-ghcjs-removal.patch` (2 commits). Built patched cabal-install
+ in /tmp/cabal-fix with bootstrap GHC 9.8.4 + bootstrap cabal — SUCCESS.
+ Binary copied to `_build/stage0-patched/bin/cabal` (version banner confirms
+ patch SHAs: cabal-install 3.17.0.1 commit `3386061`, Cabal lib commit
+ `d6fac5f`).
+- **2026-05-26** — Cascade through R11/R12: with patched cabal + relaxed
+ constraints + jobs:1, stage1 built but stage2 failed on
+ `build:any.ghc-internal installed`. Tried devx ghc910 bootstrap (has
+ ghc-internal-9.1003.0 installed) — different failure (mystery base-4.18.3.0
+ reference in unbuildable ghc-bignum lib). Escalated to user.
+- **2026-05-26** — User suggested looking at GitHub CI for working recipe.
+ **HUGE finding**: CI's last success was 2026-04-23 at SHA `d8f0caefe58`.
+ ALL builds since 2026-05-17 (after chore commit `e148c1c059` "use
+ stable-haskell/master") have failed — same R8 we hit. The project's
+ baseline has been broken for 10 days.
+- **2026-05-26** — Cross-referenced cabal source at SHA `44817477` (the CI
+ April-23 working pin): **no `Distribution.Simple.GHCJS` module, no dead
+ GHCJS import, no `binDirectoryFor` stale call**. The R8/R8.5 bugs were
+ introduced LATER on stable-haskell/master, not present at 44817477.
+- **2026-05-26** — CI uses upstream `cabal-3.14.2.0` from ghcup as bootstrap,
+ NOT a stable-haskell cabal-install. Downloaded 3.14.2.0 — but it fails
+ with `fatal: Could not parse object 44817477ff6d…` because the SHA is
+ orphaned (`wip/angerman/compile-less` branch was deleted/renamed to
+ `stable-haskell/master`, leaving the SHA reachable only via direct fetch).
+ CI works because it has a CACHED cabal binary that pre-dates the cache-key-
+ invalidating chore commit.
+- **2026-05-26** — **WORKING RECIPE FOUND**: substitute the patched cabal
+ (which CAN fetch orphaned SHAs) for upstream 3.14.2.0 as `CABAL0`. The
+ patched cabal builds stable-cabal from clean SHA `44817477` source, which
+ produces a fresh cabal-install with no R8/R8.5/R9/R10/R11 issues.
+ Command: `devx#ghc98-minimal-ghc shell` + `CABAL0=patched-cabal` +
+ `cabal update` + `make clean clean-cabal distclean` + `make _build/dist/ghc.tar.gz`.
+ Result: `_build/dist/ghc.tar.gz` (258MB) built in ~45 min.
+ GHC 9.14 (Stable Haskell Edition) compiles+runs hello world. Stage1+2 GREEN.
+- **2026-05-26** — **PHASE 1 STAGE3 GREEN**: switched to local flake (wasi-sdk),
+ ran `make CABAL=_build/cabal/bin/cabal USE_SYSTEM_CABAL=1 stage3-wasm32-wasi`.
+ Failed on final library-copy step due to **D2 triple mismatch**: ghc-toolchain
+ normalizes `wasm32-wasi` back to `wasm32-unknown-wasi` (canonical autoconf
+ form), so cabal stored libs at `host/wasm32-unknown-wasi/...` but Makefile
+ looked at `host/wasm32-wasi/...`. **REVERTED D2** across Makefile, flake.nix,
+ nix-ci.yml, USAGE.md, 2 build scripts. Rebuild stage3 → success.
+ `_build/dist/bin/wasm32-unknown-wasi-ghc` exists; compiles `hello.hs` to
+ valid `WebAssembly (wasm) binary module version 0x1 (MVP)` (1.5MB).
+ Pure wasmtime can't run the binary (GHC's wasm RTS uses JSFFI imports);
+ full runtime test via post-link.mjs + Node deferred to Phase 6 miso e2e.
+ **PHASE 1 GATE MET (compile-only gate; runtime gate in Phase 6).**
+- **2026-05-26** — **PHASE 6 v0.0.1 DELIVERED** (trivial reactor PoC).
+ Empirically verified end-to-end: dual-compiler `cabal.project` form
+ (`with-build-compiler` + `with-compiler`) builds a trivial wasm reactor;
+ `post-link.mjs` produces JSFFI ESM glue; Node's `node:wasi` + the JSFFI
+ imports run the wasm and print "Hello from the WASM reactor!". Critical
+ finding: the bring-up sequence MUST be **wasi.initialize() →
+ __ghc_wasm_jsffi_init() → hs_start()**. The original agent drafts (and
+ CI smoke test) missed `__ghc_wasm_jsffi_init` — without it, hs_start()
+ throws "RTS is not initialised; call hs_init() first". Files in
+ `lode/phase6-trivial-reactor-poc/`.
+- **2026-05-27** — **PHASE 6.5 shared-library blocker RESOLVED** via
+ extending stable-haskell's `--enable-dynamic` rearchitecture to
+ stage3 cross targets. Five concrete changes (3 commits to PR #181):
+ * `cabal.project.stage3.settings.in` + `configure.ac` + `Makefile`
+ (CONFIGURED_FILES + STAGE3_*_PREREQS + DYNAMIC RTS-ways sed +
+ DIST_COPY_LIBS_SO_CROSS .so/.dylib fix) + `cabal.project.stage3`
+ — symmetric stage2-mirror of the dynamic-enable wiring.
+ * `rts/RtsStartup.c` — missing wasm exclusion at the
+ `promoteBootLibrariesToGlobal()` call site (definition at line 108
+ excludes wasm; call at line 427 didn't, breaking the dyn rts build).
+ * `compiler/GHC/Driver/Session.hs` — disabled the wasm
+ `makeDynFlagsConsistent` rule. The rule's `LinkExecutable _`
+ predicate matched the DEFAULT `defaultDynFlags.ghcLink`, so it
+ fired for per-module compile invocations too — stripping WayDyn
+ mid-compile and breaking `.dyn_hi` lookup.
+ Result: stage3-wasm32-unknown-wasi DYNAMIC=1 produces 2121 .dyn_hi
+ + 40 .so files. End-user `cabal build` with `shared: True` compiles
+ miso through 67/70 modules.
+- **2026-05-27** — **iserv hang RESOLVED.** Instrumented dyld.mjs main()
+ + minimal TH-test program → found the root cause: ghc-internal.so's
+ dylink.0 needed_dynlibs lists `libHSrts-fs-1.0.0.0`, `libdl.so`,
+ `libc.so` but NOT the main wasm RTS (e.g.
+ `libHSrts-1.0.3-nonthreaded-nodebug-ghc9.14.so`). ghc-internal
+ IMPORTs `env.registerForeignExports` (defined only in main rts).
+ Toposort loads ghc-internal *before* main rts → ghc-internal's
+ `_initialize` calls env.registerForeignExports → "non-existent
+ function" RuntimeError → node crashes. Parent ghc never noticed
+ the node death and hung on the IPC pipe.
+ Workaround in `utils/jsffi/dyld.mjs`: preload any
+ `libHSrts-*-nonthreaded-nodebug-ghc*.so` sitting next to ghciSoPath
+ before loadDLL(ghciSoPath). Commit `1cf5c768394` on PR #181.
+ Verified end-to-end with a minimal TH program: builds + installs
+ the wasm executable cleanly.
+ **Proper fix is at link level** — ghc-internal's wasm-ld invocation
+ should emit a needed_dynlibs entry for the main rts. Tracking as
+ follow-up; the dyld.mjs workaround unblocks TH for end-user wasm
+ builds in the meantime.
+- **2026-05-27** — **Deeper root cause for the ghc-internal/main-rts
+ link bug**: `cabal.project.stage3` declares
+ `package ghc-internal / ghc-options: -no-rts`
+ Setting `-no-rts` (`Opt_NoRts` in GHC) is documented as the right
+ thing for static `.a` libs that would otherwise fail the
+ `mkUnitState` sanity check at `compiler/GHC/Unit/State.hs:1652`
+ ("RTS is missing from the package database"). However, when shared
+ is enabled, the *same flag* also prevents `-lHSrts-...` from being
+ added to the wasm-ld command for the resulting `.so`. The `.so`
+ ends up without rts in its `dylink.0/needed_dynlibs`.
+ GHC already has injection logic at `Unit/State.hs:1700-1713` that
+ makes `rtsWayUnitId` a synthetic dep of `ghcInternalUnitId`, but
+ that's at unit-state level — by the time `linkDynLib`
+ (`compiler/GHC/Linker/Dynamic.hs:55`) calls `preloadUnitsInfo'`
+ with cabal's explicit `--depends` list, the injection has no effect
+ because cabal didn't list rts in ghc-internal's depends.
+ **Cleanest fix** (deferred): on wasm32, when building a `.so` for
+ ghc-internal (or any pkg flagged `-no-rts`), explicitly append the
+ current `rtsWayUnitId` to `pkgs_with_rts` in `linkDynLib`. The
+ Wasm32 branch at `Dynamic.hs:92` already short-circuits to
+ `pkgs_with_rts` instead of `pkgs_without_rts`, but it needs to also
+ guarantee rts is *in* `pkgs_with_rts` regardless of caller intent.
+- **2026-05-27** — **Second bug surfaced during miso retry**:
+ stable-haskell/cabal resolves the `wasm32-unknown-wasi-ghc-pkg`
+ symlink to its target `ghc-pkg` before storing the path in
+ `setup-config`. GHC's ghc-pkg uses `argv[0]` to choose the target
+ package db, so after resolution the *native* db ends up in cabal's
+ db stack. Verified via `cabal -v3` output:
+ `db stack: [..., _build/dist/lib/package.conf.d, ...]`
+ (native), not `lib/targets/wasm32-unknown-wasi/lib/package.conf.d`.
+ The wasm GHC itself still consults its own default db at compile
+ time, so the build proceeds; but cabal's view of installed packages
+ is wrong, which is a latent footgun (ABI hashes etc.). Tracking as
+ a stable-haskell/cabal followup.
+- **2026-05-27** — **PHASE 6.5 GATE PASSED. FULL MISO APP BUILDS END-TO-END.**
+ Re-ran the miso build at `/tmp/wasm-reactor-test/` with the dyld.mjs
+ preload workaround in place. All 50+ dependencies — including the
+ heavy TH packages (aeson, lens, jsaddle, jsaddle-wasm) — built
+ cleanly via wasm-iserv. Final `myapp.wasm` is 2.8MB, valid
+ WebAssembly MVP binary. The dyld.mjs preload workaround scales.
+ Two end-user gotchas surfaced and are documented:
+ * miso 1.11 apps need `build-depends: ghc-experimental` to import
+ `GHC.Wasm.Prim` (otherwise GHC-87110 "module hidden in
+ ghc-experimental").
+ * For miso, the wasm entry is `startApp defaultEvents app` *not*
+ `JSW.run (startApp …)` — miso's `startApp` already returns
+ `IO ()`; wrapping it in `JSW.run` triggers `JSM ()` / `IO ()`
+ mismatch.
+- **2026-05-27** — **PROPER LINK-LEVEL FIX LANDED. dyld.mjs workaround
+ REVERTED.** Implemented the right fix in
+ `compiler/GHC/Linker/Dynamic.hs` (commit `f92963f43ef`): on wasm32,
+ `linkDynLib` now forces the current way's `rtsWayUnitId` into the
+ pkg list when it isn't already present (looked up via `lookupUnitId`
+ on `ue_units unit_env`). The fix is wasm32-only, purely additive
+ (no-op for .so files that already include rts), and lets `wasm-ld`
+ populate `dylink.0/needed_dynlibs` with rts even when the package
+ was built with `-no-rts`.
+ Verified at two levels:
+ * Artifact: `libHSghc-internal-9.1400.0-ghc9.14.so` needed_dynlibs
+ went from `[rts-fs, libdl, libc]` (3 entries) → `[rts, rts-fs,
+ libffi, libdl, libc]` (5 entries, with the main rts now present).
+ * Runtime: after reverting the dyld.mjs main-rts preload workaround
+ (`d32bc88ff6a`), the full miso build *still* succeeds — aeson
+ (TH-heavy) re-built cleanly via iserv, `myapp.wasm` produced.
+ The link fix is sufficient on its own.
+ Bindist tarball repackaged with the clean state: 226MB,
+ ghc-internal.so has rts in deps, dyld.mjs no longer has the
+ workaround.
+- **2026-05-27** — **End-user bindist verification (Phase 3 → Phase 4
+ handoff).** Extracted the 226MB tarball into a fresh prefix
+ (`/tmp/wasm-bindist-test/`), ran `./relocate.sh`, then compiled two
+ hello-world programs as an end user would:
+ * Non-TH `hello2.hs` → `hello2.wasm` (1.7MB, valid wasm MVP) — works
+ standalone (no node needed at compile time).
+ * TH-using `hello.hs` (`$(lift "...")`) → `hello.wasm` (1.7MB) —
+ works **with `node` on PATH** (the iserv shim shells out to node
+ to run the wasm interpreter at compile time).
+ Original bindist SHA256:
+ `005ebbb7e9c5dfa5bf183a263ab398022409004030b817fd7a12781f1db7ef80`
+ Phase 4 prerequisites:
+ * Bindist file: `_build/dist/ghc-wasm32-unknown-wasi.tar.gz`
+ (226742697 bytes).
+ * Documented user prerequisite: **`node` must be on PATH** if the
+ user wants TH evaluation (cabal pkgs with `template-haskell`).
+ * Symlink-resolution cabal bug (Task #13) downgraded: only affects
+ in-tree `_build/dist/` developer use (symlinks present). End
+ users via ghcup get the `tar czhf`-dereferenced real files in
+ `bin/`, so they don't trip it. Not gating on Phase 4.
+- **2026-05-27** — **UX fix: clear error when `node` missing for TH**
+ (commit `6171b4256b1`). The bindist's TH iserv path goes through
+ `dyld.mjs` which starts with `#!/usr/bin/env -S node`. Without node
+ the iserv child exits with 127 and GHC reports the generic
+ `External interpreter terminated (127)` from `Process.hs:129` —
+ cryptic for someone tracking down a CI failure.
+ Added a pre-flight `findExecutable "node"` in
+ `compiler/GHC/Runtime/Interpreter/Wasm.hs::spawnWasmInterp` that
+ throws `InstallationError` with a clear, actionable message naming
+ the dyld.mjs script and instructing the user to install Node.js.
+ Verified: clearing PATH of node and recompiling a TH-using hello-
+ world now produces the new message; happy path (node on PATH)
+ unchanged.
+ Repackaged bindist after this commit. New SHA256:
+ `d10b2ee8c807f47de94870604a68b6ff73835fe75981a456b962b80d77be6789`
+ (size 226745567 bytes).
+- **2026-05-27** — **`relocate.sh` also warns at install time if Node.js
+ missing** (commit `d2dda02f14b`). Layers the same UX hint as the
+ compile-time check, but at install/relocate time so end users learn
+ the requirement before their first TH compile. Non-TH builds remain
+ unaffected — the script still completes successfully when node is
+ missing, it just prints a NOTE to stderr.
+ Final repackaged bindist SHA256:
+ `8241c107930fadd77fe28773f47581e7288cbc405143a7acf5c0bec4df181d2e`
+ (size 226747741 bytes). This is the artifact ready for Phase 4
+ hosting.
+- **2026-05-27** — **PHASE 4 GATE PASSED. ghcup channel published.**
+ Decisions (user-approved):
+ * Host: GitHub release attachment on stable-haskell/ghc
+ * Version label: `wasm32-wasi-9.14.0.stable.0`
+ * Channel: custom (GitHub Pages on stable-haskell/ghc gh-pages branch)
+ Implementation:
+ * Created GitHub release `wasm32-wasi-9.14.0.stable.0` (pre-release
+ flag set) with the wasm bindist attached. URL:
+ https://github.com/stable-haskell/ghc/releases/tag/wasm32-wasi-9.14.0.stable.0
+ * Added autoconf-shaped install stubs to the bindist
+ (mk/wasm-configure.sh + mk/wasm-bindist-Makefile, wired through
+ the main Makefile, commit `7c26a1def7e`) so `ghcup install ghc
+ `'s standard `./configure --prefix && make install
+ DESTDIR=staging` flow works. The install target honors `$(DESTDIR)`,
+ copies bin/+lib/+relocate.sh, and prints a node-missing NOTE.
+ * Tarball SHA changed to
+ `93e5d8c70fb670148015ca6cbcb76fbe1d9fe9d7b7945de0b983799e32efb60e`
+ after adding the autoconf stubs.
+ * Created orphan `gh-pages` branch with `ghcup-wasm.yaml` +
+ `index.html`. Enabled GitHub Pages on that branch.
+ YAML URL: https://stable-haskell.github.io/ghc/ghcup-wasm.yaml
+ Landing page: https://stable-haskell.github.io/ghc/
+ * Crucial schema finding: ghcup's custom-channel parser expects the
+ 0.0.9 flat schema (versions directly under tool name) — the
+ 0.1.0 schema (`toolDetails:`/`toolVersions:` indirection) only
+ works for the upstream-cached default channel, NOT for
+ `ghcup config add-release-channel `.
+ End-to-end verified:
+ * `ghcup config add-release-channel https://stable-haskell.github.io/ghc/ghcup-wasm.yaml`
+ → succeeds, channel cached.
+ * `ghcup list -t ghc` shows `wasm32-wasi-9.14.0.stable.0
+ latest-prerelease`.
+ * `ghcup install ghc wasm32-wasi-9.14.0.stable.0` → downloads
+ bindist (~216MB), verifies SHA256, runs configure + make install,
+ prints the viPostInstall message including the node hint.
+ * Installed compiler at `~/.ghcup/ghc/wasm32-wasi-9.14.0.stable.0/`
+ builds both non-TH and TH hello-worlds.
+ Phase 4 gate is met for the aarch64-darwin host. Linux hosts join
+ once PR #181 CI verifies their build.
+- **2026-05-27** — **PHASE 5 GATE PASSED. Dual-compiler `cabal build`
+ works end-to-end via ghcup.** Created `cabal-3.17.0.0.stable.0`
+ release (pre-release) on stable-haskell/ghc with the bundled
+ cabal-install binary (cabal-install 3.17.0.1 from stable-haskell/cabal
+ HEAD + R8 patches). Bundled `lib/libgmp.10.dylib` next to the
+ binary with `@loader_path/lib`-relative rpath so it runs without
+ nix/Homebrew on stock aarch64-darwin. Uses ghcup metadata's
+ `dlInstallSpec.exeRules` + `dataRules: lib/**` to install both files.
+ Add to the same channel YAML.
+ **One critical cabal source patch was needed** to make the dual-
+ compiler `cabal build` flow work on the ghcup-installed bindist:
+ * cabal's `guessGhcPkgFromGhcPath` (`Cabal/src/Distribution/Simple/
+ GHC.hs::guessToolFromGhcPath`) looks for `ghc-pkg` next to the
+ ghc binary, then falls back to PATH. For a cross-compiler bindist
+ where `bin/` only contains the target-prefixed names (e.g.
+ `wasm32-unknown-wasi-ghc-pkg`, no bare `ghc-pkg`), the fallback
+ grabs the BUILD compiler's `ghc-pkg` from PATH, triggering
+ "Version mismatch between ghc and ghc-pkg" at configure time.
+ * Patch: detect the target prefix on the ghc binary
+ (`takeBaseName p` strips dir/ext, `isSuffixOf` the
+ `"ghc"++versionSuffix` tail, take everything before that) and
+ prepend it to the toolname when guessing. So
+ `/path/wasm32-unknown-wasi-ghc` guesses
+ `/path/wasm32-unknown-wasi-ghc-pkg` first, which exists in the
+ bindist. Falls back to existing logic if no prefix is detected.
+ * Patch saved at `lode/r12-cabal-target-prefix-aware-tool-guess.patch`
+ (71 lines) for upstreaming to stable-haskell/cabal#359.
+ * The patched cabal binary in the released tarball already has
+ this fix baked in.
+ Final cabal bindist SHA256:
+ `7f755b0810f5167b7f776470b5219834cdcbdad562998fd3c074b091be77e4d6`
+ Verified end-to-end:
+ * Fresh sandbox: `ghcup config add-release-channel` + install ghc
+ + install cabal both succeed.
+ * `cabal build test-app` on a dual-compiler `cabal.project`
+ (with-build-compiler = ghc-9.8.4, with-compiler = wasm GHC,
+ shared:True) builds a 1.7MB valid WebAssembly MVP binary via
+ `cabal-3.17.0.0.stable.0`.
+ Original Task #13 (cabal symlink resolution) is RESOLVED by the
+ same patch — the target-prefix issue was the ghcup end-user
+ manifestation of the same underlying gap in
+ `guessGhcPkgFromGhcPath`.
+- **2026-05-27** — **PHASE 7 GATE PASSED.** Tutorial-grade landing
+ page + downloadable hello template shipped on the `gh-pages` branch:
+ * `index.html` — install instructions, 90-second hello walkthrough,
+ anatomy section (cabal.project, myapp.cabal, Main.hs, run.mjs
+ reactor sequence), troubleshooting table, project links.
+ Light/dark mode CSS.
+ * `examples/hello/` — minimal but production-shaped reactor
+ template:
+ - `app/Main.hs` (CPP-guarded `foreign export javascript`)
+ - `cabal.project` (dual-compiler bare names + wasm32 `shared:True`)
+ - `myapp.cabal` (reactor `ghc-options` + `cpp-options: -DWASM`)
+ - `Makefile` (self-documenting, autodetects
+ `$GHCUP_INSTALL_BASE_PREFIX`, honors macOS `/tmp` symlink
+ canonicalization for `post-link.mjs`)
+ - `run.mjs` (Node:wasi launcher with full reactor bring-up
+ comments)
+ - `public/{index.html,index.js}` (browser launcher via
+ `browser_wasi_shim` ESM)
+ * `examples/stable-haskell-wasm-hello.tar.gz` — 5 KB tarball,
+ SHA256
+ `1f422c5ee6056ec0f0c4d1055cbfda6513c98d4bcc305991dd04f067d3a68843`,
+ extracts to `stable-haskell-wasm-hello/`.
+ Verified end-to-end from a clean dir:
+ * `curl -L https://stable-haskell.github.io/ghc/examples/stable-
+ haskell-wasm-hello.tar.gz | tar xz`
+ * `cd stable-haskell-wasm-hello && make run-node`
+ * stdout: `Hello from the WASM reactor!`
+ Phase 7 gate ("unfamiliar dev reaches demo from README alone") met.
+- **2026-05-27** — **miso-counter template shipped alongside hello.**
+ Phase 6.5 had verified the miso 1.11 build end-to-end on a private
+ test dir at `/tmp/wasm-reactor-test/`; that working setup is now
+ packaged as a shippable template on the channel:
+ * `examples/miso-counter/` (browsable) and
+ `examples/stable-haskell-wasm-miso-counter.tar.gz`
+ (SHA `7b243af055ef012864e9e1567739411e1c854dad8a17b5442b1e58937fc0b77b`,
+ 5192 bytes).
+ * Same Makefile shape as `hello/` but only `run-web` (miso is
+ DOM-driven). `make build` first-run is ~5-10 min for the
+ TH-heavy dep tree; subsequent code edits are seconds.
+ * cabal.project pins miso 1.11 via `source-repository-package` and
+ ships the `allow-newer: jsaddle-wasm:ghc-experimental` override
+ needed for GHC 9.14.
+ * Main.hs follows the miso 1.11 `component` smart-constructor +
+ `startApp defaultEvents app` pattern (the API drift from older
+ miso versions is called out in the README troubleshooting table).
+ Landing page now features both examples side-by-side in a grid:
+ hello as the 90-second on-ramp, miso-counter as the step-up to a
+ real interactive UI. Acceptance-tested fresh from the published
+ tarball: `curl | tar | make post-link-web` produces a 2.8MB valid
+ WebAssembly MVP plus its 48KB post-link jsffi.mjs glue.
+- **2026-05-27** — **Live in-browser miso demo + Phase 2 CI all green.**
+ * **Live demo at https://stable-haskell.github.io/ghc/demos/miso-counter/**
+ — pre-built mountpoint variant of the miso-counter template
+ (`mountPoint = Just "miso-root"`, so the explanatory HTML on the
+ demo page survives miso's diff). Visiting the URL renders the
+ counter directly — no install required. wasm SHA
+ `d3a4b8dbb78592761f891577774f5c714e34e624b6e7f2f22ced00d3d8a43f65`
+ (2,877,198 bytes); jsffi.mjs 48 KB. Landing page and
+ `examples/miso-counter/README.md` both link to it.
+ * **PHASE 2 GATE PASSED** — PR #181 run 26494711606 has all 6 Build
+ jobs PASS (aarch64-darwin, x86_64-linux, aarch64-linux each in
+ dynamic=0 and dynamic=1). Test + Cross jobs are in flight (8 of 14
+ remaining; persistent CI monitor watching for any transitions).
+ Each Build job uploads its native stage2 bindist as a workflow
+ artifact (260–303 MB per host/dynamic combo). To ship Linux wasm
+ **cross-compiler** bindists through the same ghcup channel,
+ follow-up work needs to wire `make
+ stage3-wasm32-unknown-wasi-tarball` (or equivalent) into the
+ Linux runners and upload the resulting wasm-cross tarball as an
+ artifact — the current Build jobs only produce native bindists.
+- **2026-05-26** — **PHASE 6 v0.0.2 (miso e2e) BLOCKED on wasm-backend
+ shared-library support.** [HISTORICAL — superseded by 2026-05-27] Adding miso 1.11.0 + jsaddle-wasm pulls in
+ `character-ps` which needs `Data.Word.dyn_hi` from base — but our wasm
+ base library is built `shared: False`. Two attempts to fix in
+ `cabal.project.stage3`:
+ (1) project-level `if arch(wasm32) shared: True` — applied but failed
+ at ghc-internal link with "[GHC-74335] -dynamic is ignored when
+ linking binaries on WASM" + "mismatched interface file profile tag
+ (wanted '', got 'dyn')".
+ (2) package-level `package * / if arch(wasm32) shared: True` — built
+ successfully but applied silently; no .dyn_hi anywhere.
+ Conclusion: cabal config alone can't deliver wasm shared libs.
+ Need upstream GHC work on the wasm backend (RTS / linker / interface-
+ file profile tag handling). **Out of session scope.** Phase 6.5 (miso
+ e2e) is queued as a separate workstream contingent on this fix.
+- **2026-05-26** — Two PRs opened: (1) stable-haskell/cabal#359 with the
+ R8/R8.5 patches (https://github.com/stable-haskell/cabal/pull/359); (2)
+ stable-haskell/ghc#181 with our branch + Phase 0-2 work
+ (https://github.com/stable-haskell/ghc/pull/181). Phase 2 CI verification
+ is async on PR #181 — the `cross-wasm` job in nix-ci.yml already does
+ the full smart-smoke-test runtime check.
+- **2026-05-26** — **PHASE 3 GATE PASSED**. Added `mk/wasm-relocate.sh`
+ (10-line script that runs `ghc-pkg recache` for the new install prefix)
+ and switched the wasm bindist tar to `tar czhf` (dereferences symlinks
+ → wasm-prefixed bin entries become real files instead of broken symlinks
+ to the bare native names). Tarball grew 153MB → 264MB but is now
+ **self-contained and relocatable**. Verified end-to-end: extract to
+ `/tmp/wasm-relocate-test2`, run `./relocate.sh`, then
+ `bin/wasm32-unknown-wasi-ghc hello.hs -o hello.wasm` produces valid
+ wasm from the fresh prefix.
+- **2026-05-26** — R8/R9/R10/R11 reclassified as **dissolved artifacts**
+ of bootstrapping with broken master HEAD cabal source. Real root cause:
+ the chore commit `e148c1c059` on `stable-ghc-9.14` changed the Cabal
+ source-repo tag from explicit SHA `44817477…` to the branch name
+ `stable-haskell/master`, exposing all subsequent builds to whatever
+ bugs landed on that branch tip. **Recommended upstream fix**: revert
+ `e148c1c059` (return to explicit SHA pin) OR fix the bugs on
+ stable-haskell/master HEAD. Either resolves the daily CI failures.
diff --git a/mk/clean-darwin-macho.sh b/mk/clean-darwin-macho.sh
new file mode 100755
index 000000000000..c36458ff8a39
--- /dev/null
+++ b/mk/clean-darwin-macho.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# clean-darwin-macho.sh — strip build-host leaks from every Mach-O
+# artefact under $DIST_DIR at CONSTRUCTION TIME (during stage2.dist,
+# before tarball assembly), so the bindist ships clean without
+# needing a post-build install_name_tool pass in CI.
+#
+# Two classes of leak to remove:
+#
+# (1) Absolute LC_RPATH entries like
+# /Volumes/WorkSpace/_work/ghc/ghc/_build/stage2/store/host/...
+# that the bundled Cabal's depLibraryPaths bakes in via the
+# link line. macOS 14 dyld silently falls through to the
+# portable @executable_path/../lib/ rpath SET_RPATH adds,
+# but macOS 15 dyld treats the unresolvable absolute path as
+# fatal and SIGABRTs the binary on launch.
+#
+# (2) nix-store LC_LOAD_DYLIB install names for libiconv, libffi,
+# libc++, libz, libresolv, libncurses. The build runner's
+# devx Nix store has these visible at link time, but their
+# install names point at /nix/store paths that don't exist on
+# end-user hosts. Rewrite each to its /usr/lib equivalent
+# (Apple stub-cache library, ABI-compatible).
+#
+# Mutating a Mach-O invalidates its linker signature; re-sign ad-hoc
+# afterwards so dyld accepts the binary on Apple Silicon.
+#
+# No-op on non-Darwin (the outer uname guard).
+#
+# Pattern adapted from input-output-hk/devx static.nix:fixup-nix-deps,
+# SHA 5f05c1e1af6.
+#
+# Usage: clean-darwin-macho.sh
+
+set -euo pipefail
+
+[ "$(uname -s)" = "Darwin" ] || { echo "Not Darwin — skipping Mach-O cleanup."; exit 0; }
+
+DIST_DIR="${1:?DIST_DIR required}"
+[ -d "$DIST_DIR" ] || { echo "::error::$DIST_DIR not a directory"; exit 1; }
+
+echo "[stage2] Stripping build-host leaks from Mach-O artefacts in $DIST_DIR"
+
+rpath_stripped=0
+nix_rewritten=0
+files_touched=0
+
+while IFS= read -r -d '' f; do
+ file -L "$f" 2>/dev/null | grep -q 'Mach-O' || continue
+ changed=0
+
+ # (1) Strip /Volumes/-prefixed LC_RPATH entries
+ while IFS= read -r rp; do
+ [ -z "$rp" ] && continue
+ install_name_tool -delete_rpath "$rp" "$f" 2>/dev/null && {
+ rpath_stripped=$((rpath_stripped + 1))
+ changed=1
+ } || true
+ done < <(otool -l "$f" 2>/dev/null \
+ | awk '/cmd LC_RPATH/{flag=1; next} flag && /path \/Volumes\//{print $2; flag=0; next} flag && /path /{flag=0}')
+
+ # (2) Rewrite nix-store LC_LOAD_DYLIB → /usr/lib equivalents
+ while IFS= read -r nixdep; do
+ [ -z "$nixdep" ] && continue
+ case "$nixdep" in
+ *libiconv.dylib) new=/usr/lib/libiconv.dylib ;;
+ *libiconv.2.dylib) new=/usr/lib/libiconv.2.dylib ;;
+ *libffi.*.dylib) new=/usr/lib/libffi.dylib ;;
+ *libc++.*.dylib) new=/usr/lib/libc++.dylib ;;
+ *libz.dylib) new=/usr/lib/libz.dylib ;;
+ *libresolv.*.dylib) new=/usr/lib/libresolv.dylib ;;
+ *libncursesw.*.dylib) new=/usr/lib/libncurses.5.4.dylib ;;
+ *libncurses.*.dylib) new=/usr/lib/libncurses.5.4.dylib ;;
+ *) continue ;;
+ esac
+ install_name_tool -change "$nixdep" "$new" "$f" 2>/dev/null && {
+ nix_rewritten=$((nix_rewritten + 1))
+ changed=1
+ } || true
+ done < <(otool -L "$f" 2>/dev/null | awk '/nix\/store/{print $1}')
+
+ if [ "$changed" -eq 1 ]; then
+ codesign --force --sign - "$f" >/dev/null 2>&1 || true
+ files_touched=$((files_touched + 1))
+ fi
+done < <(find "$DIST_DIR" -type f \( -perm -u+x -o -name '*.dylib' \) -print0)
+
+echo "[stage2] Stripped $rpath_stripped LC_RPATH entries, rewrote $nix_rewritten LC_LOAD_DYLIB entries in $files_touched files"
diff --git a/mk/multi-target-bindist-Makefile b/mk/multi-target-bindist-Makefile
new file mode 100644
index 000000000000..8c9874c15bc5
--- /dev/null
+++ b/mk/multi-target-bindist-Makefile
@@ -0,0 +1,45 @@
+# Makefile — bundled with the stable-haskell multi-target GHC bindist.
+#
+# ghcup's `install ghc ` flow is "./configure --prefix=DIR &&
+# make install". The bindist is already laid out as bin/ + lib/ +
+# relocate.sh; install just copies that layout into the prefix and
+# refreshes the per-target package-db caches.
+
+include config.mk
+
+.PHONY: install
+
+install:
+ @mkdir -p "$(DESTDIR)$(prefix)"
+ @cp -PR bin "$(DESTDIR)$(prefix)/"
+ @cp -PR lib "$(DESTDIR)$(prefix)/"
+ @if [ -f relocate.sh ]; then \
+ cp relocate.sh "$(DESTDIR)$(prefix)/relocate.sh"; \
+ chmod +x "$(DESTDIR)$(prefix)/relocate.sh"; \
+ echo "Running relocate.sh to recache all per-target package dbs..."; \
+ "$(DESTDIR)$(prefix)/relocate.sh" || \
+ { echo "warning: relocate.sh failed; package.cache may be stale" >&2 ; }; \
+ fi
+ @echo ""
+ @echo "Tool prerequisites for each shipped target:"
+ @if ! command -v node >/dev/null 2>&1; then \
+ echo " [missing] node >= 22 — needed by wasm32 + javascript target TH eval"; \
+ else \
+ echo " [ok] node ($$(node --version 2>/dev/null))"; \
+ fi
+ @if ! command -v wasm32-unknown-wasi-clang >/dev/null 2>&1; then \
+ echo " [missing] wasm32-unknown-wasi-clang — needed by wasm32 target (install wasi-sdk via ghc-wasm-meta bootstrap)"; \
+ else \
+ echo " [ok] wasm32-unknown-wasi-clang"; \
+ fi
+ @if ! command -v emcc >/dev/null 2>&1; then \
+ echo " [missing] emcc — needed by javascript-unknown-ghcjs target (install emscripten)"; \
+ else \
+ echo " [ok] emcc ($$(emcc --version 2>/dev/null | head -1))"; \
+ fi
+ @echo ""
+ @echo "Installed stable-haskell multi-target GHC to: $(DESTDIR)$(prefix)"
+ @echo "Invocations:"
+ @echo " $(prefix)/bin/ghc (native)"
+ @echo " $(prefix)/bin/wasm32-unknown-wasi-ghc (wasm cross)"
+ @echo " $(prefix)/bin/javascript-unknown-ghcjs-ghc (JS cross)"
diff --git a/mk/multi-target-configure.sh b/mk/multi-target-configure.sh
new file mode 100755
index 000000000000..516d10e26071
--- /dev/null
+++ b/mk/multi-target-configure.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+# configure — bundled with the stable-haskell multi-target GHC bindist.
+#
+# Multi-target bindist combines: native ghc, wasm32-unknown-wasi-ghc,
+# javascript-unknown-ghcjs-ghc. All three are the same physical binary
+# (stage2 native ghc), dispatched at runtime via argv[0]:
+# ghc -> uses lib/settings + lib/$HOST_PLATFORM
+# wasm32-unknown-wasi-ghc -> uses lib/targets/wasm32-unknown-wasi/lib/settings
+# javascript-unknown-ghcjs-ghc -> uses lib/targets/javascript-unknown-ghcjs/lib/settings
+#
+# Minimal autoconf-style stub so that ghcup's standard
+# "./configure --prefix=DIR && make install" install path works.
+
+set -e
+
+prefix=
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --prefix=*) prefix="${1#--prefix=}" ;;
+ --prefix) shift; prefix="$1" ;;
+ *) ;; # accept and ignore other autoconf args
+ esac
+ shift
+done
+
+if [ -z "$prefix" ]; then
+ echo "error: --prefix=DIR is required" >&2
+ exit 1
+fi
+
+cat > config.mk </ subdirectories — keeps
+# this script tolerant if some build flavour omits one of the targets.
+if [ -d "$PREFIX/lib/targets" ]; then
+ for target_dir in "$PREFIX/lib/targets"/*/; do
+ [ -d "$target_dir" ] || continue
+ triple="$(basename "$target_dir")"
+ cross_pkg="$PREFIX/bin/${triple}-ghc-pkg"
+ cross_db="$target_dir/lib/package.conf.d"
+ if [ -x "$cross_pkg" ] && [ -d "$cross_db" ]; then
+ echo "[$triple] recaching $cross_db"
+ "$cross_pkg" recache --package-db "$cross_db"
+ elif [ -d "$cross_db" ]; then
+ # Some cross targets ship no per-target ghc-pkg (e.g. older JS bindists).
+ # Fall back to the native ghc-pkg; the .conf files are arch-agnostic
+ # ASCII so the native binary can read+recache them.
+ echo "[$triple] using native ghc-pkg to recache $cross_db (no $cross_pkg)"
+ "$NATIVE_GHC_PKG" recache --package-db "$cross_db" || \
+ echo "[$triple] recache failed — package.cache may be stale" >&2
+ fi
+ done
+fi
+
+# ----------------------------------------------------------------------------
+# Tool prerequisites — warn but don't fail
+# ----------------------------------------------------------------------------
+need_node=
+need_emcc=
+need_wasi_sdk=
+[ -d "$PREFIX/lib/targets/wasm32-unknown-wasi" ] && { need_node=1; need_wasi_sdk=1; }
+[ -d "$PREFIX/lib/targets/javascript-unknown-ghcjs" ] && { need_node=1; need_emcc=1; }
+
+warn_missing=
+if [ -n "$need_node" ] && ! command -v node >/dev/null 2>&1; then
+ warn_missing="${warn_missing} node (Node.js >= 22)"
+fi
+if [ -n "$need_wasi_sdk" ] && ! command -v wasm32-unknown-wasi-clang >/dev/null 2>&1; then
+ warn_missing="${warn_missing} wasm32-unknown-wasi-clang (wasi-sdk)"
+fi
+if [ -n "$need_emcc" ] && ! command -v emcc >/dev/null 2>&1; then
+ warn_missing="${warn_missing} emcc (emscripten)"
+fi
+
+if [ -n "$warn_missing" ]; then
+ cat >&2 <` flow is "./configure --prefix=DIR &&
+# make install". The bindist is already laid out as bin/ + lib/ +
+# relocate.sh, so install just copies that layout into the prefix and
+# refreshes the package-db cache (the wasm32-unknown-wasi-ghc-pkg
+# binary auto-recaches by mtime on first read too, but we do it
+# eagerly here so the first invocation is fast).
+
+include config.mk
+
+.PHONY: install
+
+install:
+ @mkdir -p "$(DESTDIR)$(prefix)"
+ @cp -PR bin "$(DESTDIR)$(prefix)/"
+ @cp -PR lib "$(DESTDIR)$(prefix)/"
+ @if [ -f relocate.sh ]; then \
+ cp relocate.sh "$(DESTDIR)$(prefix)/relocate.sh"; \
+ chmod +x "$(DESTDIR)$(prefix)/relocate.sh"; \
+ fi
+ @if ! command -v node >/dev/null 2>&1; then \
+ echo ""; \
+ echo "NOTE: Node.js was not found on PATH. Template Haskell"; \
+ echo " evaluation will fail without it (the wasm dynamic"; \
+ echo " linker shim runs via node). Non-TH builds work fine."; \
+ echo ""; \
+ fi
+ @echo "Installed stable-haskell wasm32-unknown-wasi GHC to: $(DESTDIR)$(prefix)"
+ @echo "(ghc-pkg auto-recaches package.cache by mtime on first read;"
+ @echo "no relocate step required after install.)"
diff --git a/mk/wasm-configure.sh b/mk/wasm-configure.sh
new file mode 100644
index 000000000000..56e1e470cbfb
--- /dev/null
+++ b/mk/wasm-configure.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+# configure — bundled with the stable-haskell wasm32-unknown-wasi GHC bindist.
+#
+# Minimal autoconf-style stub so that `ghcup install ghc `'s
+# standard "./configure --prefix=DIR && make install" flow works.
+#
+# All we do is record the install prefix; the real work happens in the
+# bindist's Makefile install target (which just copies bin/ + lib/ into
+# the prefix and refreshes the package-db cache).
+
+set -e
+
+prefix=
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --prefix=*)
+ prefix="${1#--prefix=}"
+ ;;
+ --prefix)
+ shift
+ prefix="$1"
+ ;;
+ # accept and ignore other autoconf args ghcup or users may pass
+ *)
+ ;;
+ esac
+ shift
+done
+
+if [ -z "$prefix" ]; then
+ echo "error: --prefix=DIR is required" >&2
+ exit 1
+fi
+
+cat > config.mk <&2
+ echo " extract the bindist tarball before running this script" >&2
+ exit 1
+fi
+if [ ! -d "$PKG_DB" ]; then
+ echo "error: $PKG_DB not found" >&2
+ echo " expected layout: /lib/targets/wasm32-unknown-wasi/lib/package.conf.d/" >&2
+ exit 1
+fi
+
+echo "Relocating wasm32-unknown-wasi-ghc package db to: $PKG_DB"
+"$GHC_PKG" recache --package-db "$PKG_DB"
+
+# Template Haskell support requires `node` (Node.js) on PATH: the wasm
+# dynamic linker shim (lib/dyld.mjs) is a Node script. Warn now if missing
+# so users learn this at install time, not at the first TH compile.
+if ! command -v node >/dev/null 2>&1; then
+ cat >&2 <
#if defined(OBJFORMAT_ELF)
+
+/*
+ * Check whether a symbol (identified by its symbol table section index and
+ * its index within that table) is referenced by any relocation whose type
+ * is not R_*_NONE (type 0). R_*_NONE is a no-op relocation for every ELF
+ * architecture, so symbols referenced exclusively by NONE relocations need
+ * not be resolved.
+ *
+ * This is used by fillGot() to avoid hard failures on undefined symbols that
+ * the assembler emitted NONE relocations for (e.g. zero-length arrays
+ * optimised away by the compiler but still present in the symbol table).
+ */
+static bool
+symbolHasNonNoneRelocation(ObjectCode *oc, unsigned symTabIndex,
+ size_t symIdx)
+{
+ /* Scan REL relocation tables */
+ for (ElfRelocationTable *relTab = oc->info->relTable;
+ relTab != NULL; relTab = relTab->next) {
+ if (relTab->sectionHeader->sh_link == symTabIndex) {
+ for (size_t i = 0; i < relTab->n_relocations; i++) {
+ Elf_Rel *rel = &relTab->relocations[i];
+ if (ELF_R_SYM(rel->r_info) == symIdx
+ && ELF_R_TYPE(rel->r_info) != 0) {
+ return true;
+ }
+ }
+ }
+ }
+ /* Scan RELA relocation tables */
+ for (ElfRelocationATable *relaTab = oc->info->relaTable;
+ relaTab != NULL; relaTab = relaTab->next) {
+ if (relaTab->sectionHeader->sh_link == symTabIndex) {
+ for (size_t i = 0; i < relaTab->n_relocations; i++) {
+ Elf_Rela *rel = &relaTab->relocations[i];
+ if (ELF_R_SYM(rel->r_info) == symIdx
+ && ELF_R_TYPE(rel->r_info) != 0) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+}
+
/*
* Check if we need a global offset table slot for a
* given symbol
@@ -96,6 +141,21 @@ fillGot(ObjectCode * oc) {
if(0x0 == symbol->addr) {
if(0 == strncmp(symbol->name,"_GLOBAL_OFFSET_TABLE_",21)) {
symbol->addr = oc->info->got_start;
+ } else if (!symbolHasNonNoneRelocation(oc, symTab->index, i)) {
+ /* Symbol is only referenced by R_*_NONE
+ * (type 0) relocations, which are no-ops.
+ * This can happen when e.g. a zero-length
+ * array is optimised away by the compiler
+ * but the assembler still emits NONE relocs
+ * against the (now undefined) symbol.
+ * Assign a dummy address so the GOT slot is
+ * filled; it will never be dereferenced. */
+ IF_DEBUG(linker,
+ debugBelch("Skipping unresolvable symbol"
+ " '%s' (only referenced by"
+ " NONE relocations)\n",
+ symbol->name));
+ symbol->addr = (void*)0xDEAD0000;
} else {
errorBelch("Failed to lookup symbol: %s,"
" you might consider using --optimistic-linking\n",
diff --git a/utils/jsffi/dyld.mjs b/utils/jsffi/dyld.mjs
index 8ccb47d8e9d9..612713a216f9 100755
--- a/utils/jsffi/dyld.mjs
+++ b/utils/jsffi/dyld.mjs
@@ -1103,6 +1103,7 @@ export async function main({ rpc, libdir, ghciSoPath, args }) {
rpc,
});
await dyld.addLibrarySearchPath(libdir);
+
await dyld.loadDLL(ghciSoPath);
const reader = rpc.readStream.getReader();